Skip to content

[Bug]: a routing profile candidate naming a disabled provider invalidates the entire config #1785

Closed

Description

@brunoflma

Client or integration

Direct HTTP/API client

Area

Proxy and routing

Summary

A single routingProfiles candidate naming a disabled provider invalidates the entire config.json. The loader falls back to the built-in default, so the proxy keeps serving — but with only the openai provider, none of the operator's API keys, and no modelCosts. Every other provider, profile, and price row silently disappears.

The failure is hard to notice because nothing turns red. /healthz returns 200, the management API returns 200, and ocx status reports a healthy proxy. The only visible symptom is unrelated-looking: a routing-profile dry-run returns 404 unknown_profile, which reads like a broken profile rather than a discarded config.

The validation itself is correct and worth keeping — a candidate pointing at a disabled provider is a real mistake. What seems disproportionate is the blast radius: one bad candidate in one profile discards the whole document. Dropping the offending candidate (or the offending profile) with a startup warning would preserve everything else and still surface the mistake.

Mechanically, routingProfileIssues() feeds ctx.addIssue({ code: "custom", ... }) inside the Zod superRefine in src/config.ts (~line 1633), so a candidate-level problem is raised as a document-level parse failure. Combos are validated the same way immediately above it, so the same shape likely applies there.

Reproduction

Verified on 2.20.0. A provider being disabled is the trigger, so any disabled provider works.

  1. In config.json, mark any configured provider "disabled": true — for example providers.TR.
  2. Add a routing profile whose candidate list names that provider:
"routingProfiles": {
  "example": {
    "candidates": [
      { "provider": "TR", "model": "moonshotai/kimi-k3" }
    ]
  }
}
  1. Restart the proxy: ocx restart && ocx ensure
  2. Observe that the proxy starts and reports healthy.

Observed:

  • The service log records Could not load opencodex config ... Using default config.
  • A config.json.invalid-* file is written on each load attempt (10 accumulated here before it was noticed).
  • GET /api/providers returns 1 provider (openai) while config.json on disk defines 11.
  • POST /api/routing-profiles/dry-run returns 404 unknown_profile.
  • /healthz and the management API return 200 throughout.

Expected: the invalid candidate (or the profile containing it) is rejected with a warning naming the profile and provider, and the rest of the config still loads.

A cheap check that distinguishes "config loaded" from "silently fell back to default" is comparing the provider count on disk against the served one:

node -e "const fs=require('fs'),h=require('http');const T=fs.readFileSync(process.env.USERPROFILE+'/.opencodex/admin-api-token','utf8').trim();const n=Object.keys(JSON.parse(fs.readFileSync(process.env.USERPROFILE+'/.opencodex/config.json','utf8')).providers).length;h.get({host:'127.0.0.1',port:10100,path:'/api/providers',headers:{authorization:'Bearer '+T}},r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{const a=JSON.parse(d);const m=(Array.isArray(a)?a:a.data||a.providers||[]).length;console.log('disk',n,'served',m,n===m?'OK':'CONFIG REJECTED')})})"

Version

2.20.0

Operating system

Windows 11 Home Single Language 26200

Provider and model

Not provider-specific — reproduced with a disabled provider of any kind.

Logs or error output

Could not load opencodex config (see config.json.invalid-*). Using default config

# config.json on disk defines 11 providers; the served config has 1
$ curl -s -H "authorization: Bearer $TOKEN" http://127.0.0.1:10100/api/providers | jq 'length'
1

$ curl -s -X POST -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
    -d '{"profile":"example","evidence":{}}' \
    http://127.0.0.1:10100/api/routing-profiles/dry-run
{"error":"unknown_profile"}

$ curl -s http://127.0.0.1:10100/healthz
{"ok":true}

Redacted configuration

{
  "providers": {
    "TR": { "adapter": "openai-chat", "apiKey": "REDACTED", "disabled": true }
  },
  "routingProfiles": {
    "example": {
      "candidates": [
        { "provider": "TR", "model": "moonshotai/kimi-k3" }
      ]
    }
  }
}

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. coderabbitai commented on Aug 15, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #652 - feat(providers): add bounded model discovery contract [merged]
    #746 - fix(providers): route GitHub Copilot Responses-only models off chat completions [open]
    #1011 - feat(routing): add validated routing policy profiles [merged]
    #1012 - feat(routing): execute capability-aware policy profiles [merged]
    #1018 - feat(gui): add routing profiles and why-this-route views [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. added
    bugSomething isn't working
    proxyHTTP proxy, routing, reverse-proxy / management auth
    on Aug 15, 2026
  3. lidge-jun commented on Aug 16, 2026

    @lidge-jun
    Owner

    Fixed on dev by #1819.

    A routing profile whose candidate names a disabled provider no longer discards the config. The invalid entry is dropped and everything else — providers, API keys, modelCosts, the valid profiles — is preserved, with a warning naming what went and why.

    Four things had to be true before this was safe to close:

    Salvage repeats. The sections are not independent of each other: routing profiles validate against the combo map, so dropping an invalid combo can expose a profile that referenced it. A single pass saw that second failure and discarded the whole config anyway. Salvage now loops until the document parses, bounded by the number of salvageable entries, and stops as soon as a pass removes nothing.

    Diagnostics no longer hand back factory defaults. readConfigDiagnostics() returned getDefaultConfig(), so a config command writing that result back would have persisted built-in defaults over the operator's providers and keys. It now carries the salvaged document instead. The failure is still reported — source stays "fallback" and error keeps the schema message, because provider reload, catalog sync, cost reconcile and Codex admission all gate on exactly those two fields. Only the payload changed.

    Entry ids are redacted before logging. Combo and profile ids are operator-chosen and can be pasted secrets; both the dropped list and the serialized issue paths went to console.error raw. Dynamic components now go through redactSecretString while the static section name stays readable, so the warning still tells you where to look.

    Salvage cannot weaken a security boundary. Found while testing: a Codex account namespace collision is a relationship between a combo and an account selector but is reported on the combo, so dropping that combo made the document parse and quietly admitted the selector the schema had just refused. Those findings are now unsalvageable and fail the whole document.

    There is also a management-auth regression proving the salvaged path does not weaken credential separation: /api/config still returns 401 anonymously, 401 for the data-plane token, 401 for a wrong admin token, and 200 for the valid one, with the salvaged providers intact.

    Verified at the exact merge head: full Cross-platform CI green.

    Closing as completed. The fix is on dev and ships with the next release; it is not in a published version yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions