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 example providers.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/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
Client or integration
Direct HTTP/API client
Area
Proxy and routing
Summary
A single
routingProfilescandidate naming adisabledprovider invalidates the entireconfig.json. The loader falls back to the built-in default, so the proxy keeps serving — but with only theopenaiprovider, none of the operator's API keys, and nomodelCosts. Every other provider, profile, and price row silently disappears.The failure is hard to notice because nothing turns red.
/healthzreturns 200, the management API returns 200, andocx statusreports a healthy proxy. The only visible symptom is unrelated-looking: a routing-profile dry-run returns404 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()feedsctx.addIssue({ code: "custom", ... })inside the ZodsuperRefineinsrc/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.
config.json, mark any configured provider"disabled": true— for exampleproviders.TR.ocx restart && ocx ensureObserved:
Could not load opencodex config ... Using default config.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
Redacted configuration
{ "providers": { "TR": { "adapter": "openai-chat", "apiKey": "REDACTED", "disabled": true } }, "routingProfiles": { "example": { "candidates": [ { "provider": "TR", "model": "moonshotai/kimi-k3" } ] } } }Checks