Repository navigation
[Bug]: a routing profile candidate naming a disabled provider invalidates the entire config #1785
Description
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.
- In
config.json, mark any configured provider"disabled": true— for exampleproviders.TR. - Add a routing profile whose candidate list names that provider:
"routingProfiles": {
"example": {
"candidates": [
{ "provider": "TR", "model": "moonshotai/kimi-k3" }
]
}
}- Restart the proxy:
ocx restart && ocx ensure - 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/providersreturns 1 provider (openai) whileconfig.jsonon disk defines 11.POST /api/routing-profiles/dry-runreturns404 unknown_profile./healthzand 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
coderabbitai commentedon Aug 15, 2026
🔗 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 plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!
fix(config): keep the operator's config in diagnostics without hiding…
lidge-jun commentedon Aug 16, 2026
Fixed on
devby #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()returnedgetDefaultConfig(), 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 —sourcestays"fallback"anderrorkeeps 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.errorraw. Dynamic components now go throughredactSecretStringwhile 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/configstill 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
devand ships with the next release; it is not in a published version yet.fix(config): keep the operator's config in diagnostics without hiding…