Repository navigation
ocx claude: 3s budget exceeded by uncached codex --version probe on /api/claude-code and /v1/models #606
Description
Client or integration
Claude Code
Area
Proxy and routing
Summary
On Windows, ocx claude prints both of these on almost every launch:
⚠ 모델 컨텍스트 정보를 불러오지 못했습니다 — 1M 자동 표시는 이번 실행에서 생략됩니다.
⚠ Gateway model cache could not be refreshed; the model picker may be stale.
Neither is an auth or connectivity failure. Both are timeouts. cmdClaude gives
fetchClaudeContextWindows (/api/claude-code) and refreshGatewayModelCacheFromProxy
(/v1/models) a 3s budget each, and the local proxy takes 5-8s to answer. Both catch
blocks swallow the AbortError and emit the generic warning above.
The user-visible consequences are quiet but real: [1m] context marking is skipped for
the run, and ~/.claude/cache/gateway-models.json is never rewritten. Because Claude Code
only refreshes that cache itself when it holds a credential — and the subscription-preserving
launch deliberately injects none — the picker keeps serving whatever is on disk. Mine was
6 days stale (80 entries) before I traced this.
Expected: both endpoints answer within the 3s budget, so the cache is rewritten and [1m]
marking applies. Actual: both abort, every launch, and the warnings suggest an auth problem
rather than a latency one.
Root cause is a codex --version subprocess probe sitting on the request path, which is
effectively uncached for two independent reasons (details below).
Reproduction
-
Install
@bitkyc08/opencodex2.7.42 on Windows with acodexCLI present, plus several
additionalcodexlaunchers reachable viaPATH(mine: npm global shim, a Codex
App-managed binary, and a shim backup — 102 probe candidates in total). -
Start the proxy:
ocx start --port 10100. -
Run
ocx claude --version. -
Observe both warnings on stderr.
-
Time the two endpoints
cmdClaudecalls directly:Measure-Command { Invoke-WebRequest 'http://127.0.0.1:10100/api/claude-code' -UseBasicParsing } Measure-Command { Invoke-WebRequest 'http://127.0.0.1:10100/v1/models?limit=1000&ids=cli' -Headers @{'anthropic-version'='2023-06-01'} -UseBasicParsing }
Both exceed 3000 ms on a cold proxy. Note that repeating step 5 after a >60s idle gap
reproduces it again, becauseBUNDLED_CATALOG_CACHE_MSis 60s. -
Confirm the effect:
~/.claude/cache/gateway-models.jsonkeeps its oldfetchedAt.
Timed in-process against the same config, to isolate where the time goes:
fetchAllModels 457 ms (all 7 providers, network included)
listCatalogNativeSlugs 1328 ms <-- no network at all
visibleNativeSlugs 1645 ms
nativeOpenAiSlugs 2072 ms
buildClaudeContextWindows 2018 ms
All of the native-slug cost is readCurrentCatalogOrCache() → loadBundledCodexCatalog().
The subprocess it wraps is not the problem:
codex debug models --bundled 87 ms
codex --version 721 ms
loadBundledCodexCatalog() three times in a row, nothing changing on disk, 60s cache:
#0 1214 ms
#1 1136 ms <-- expected ~0 on a warm cache
#2 1186 ms
Cause 1 — the bundled-catalog cache can never be hit
In src/codex/catalog/bundled.ts, candidates is computed in an IIFE that runs before
the bundledCatalogCache lookup, because cacheKey is assigned inside it. So
resolveAndPersistCodexRuntime() runs on every call, warm hits included. That would still
be cheap if the resolve were memoized, but line 154 forwards the real execFileSync:
const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
// ...
const resolved = resolveAndPersistCodexRuntime({
execFileSync: execFile, // always truthyand resolveCacheKey() in src/codex/runtime.ts deliberately refuses to memoize
injected-dependency resolves:
if (deps.execFileSync || deps.existsSync || deps.readFileSync || deps.configDir || deps.now) {
return null;
}The guard reads correct in intent (injected deps imply a test double), but this caller
defeats it for the real-runtime path by passing its own already-defaulted value.
There is a related self-invalidation: resolveAndPersistCodexRuntime() (runtime.ts:505)
calls persistCodexRuntime unconditionally for any non-fallback selection, and
persistCodexRuntime() ends with resolveCache = null (runtime.ts:228). Since
persistedRuntimeCacheStamp() also folds the on-disk updatedAt into the cache key, each
write both clears the memo and changes the key it would have been stored under — even when
the resolved runtime is byte-identical to what is already persisted.
Cause 2 — cold reads probe every codex on PATH
bundled.ts:161 passes discoverAlternatives: deps.discoverAlternatives, i.e. undefined
for normal callers, which resolveCodexRuntimeUncached() treats as "discover". It then
probes every candidate to populate newerAvailable, each probe spawning codex --version
with a fresh mkdtempSync sandbox:
resolveCodexRuntime({}) 1224 ms failures: 102
resolveCodexRuntime({ discoverAlternatives: false }) 60 ms
Both select the identical runtime. Catalog loading only consumes resolved.runtime.command;
it never reads newerAvailable or failures. So on a cold read that ~1.2s scan is pure
overhead, and on its own eats more than a third of the 3s budget before any real work.
Effect
/api/claude-code measured over HTTP:
| before | after local patch | |
|---|---|---|
| cold (fresh proxy) | 5000-8300 ms | 165 ms |
| warm | 6300 ms | ~25 ms |
| after 65s idle (cache expired) | 6325 ms | 1440 ms |
With the patches ocx claude runs clean, and gateway-models.json refreshed from 80 stale
entries to 98 current ones, 25 carrying [1m].
Patches I verified locally
Not opening a PR since you may prefer different tradeoffs, but these are what I tested.
src/codex/catalog/bundled.ts — forward an injected execFileSync only, so real-runtime
resolves stay memoizable:
...(deps.execFileSync ? { execFileSync: deps.execFileSync } : {}),src/codex/catalog/bundled.ts — default the catalog path to non-discovering:
discoverAlternatives: deps.discoverAlternatives ?? false,src/codex/runtime.ts — only persist when the selection actually changed, so the write
stops clearing its own memo:
const persistedRuntime = loadPersistedCodexRuntime(deps);
const selectionUnchanged = persistedRuntime !== null
&& persistedRuntime.command === result.runtime.command
&& persistedRuntime.source === result.runtime.source
&& (persistedRuntime.selectedVersion ?? null) === result.runtime.version;
if (result.runtime.command && result.runtime.source !== "fallback" && !selectionUnchanged) {Tradeoff worth flagging on the second one: catalog-path callers lose newerAvailable
diagnostics. If something depends on those, moving discovery to a slow background interval
instead of every catalog read would preserve them while keeping the request path fast.
Suggested improvements beyond the fix
- Check
bundledCatalogCachebefore resolving the runtime. Deriving the cache key from
the very thing the cache exists to avoid means a warm hit can only ever save the
codex debug modelscall (87 ms), never the probe (1.2 s) that dominates. A cheaper key
would let warm hits skip both. - Attribute the timeout in the warning text. Both messages read like auth or network
faults. Something liketimed out after 3000mswould point straight at the cause; I went
looking at OAuth state and provider connectivity first. - Consider raising or making the 3s budget configurable. Even fully patched, a cold read
is ~1.4s here, so a slower machine or a longerPATHcould still trip it silently. - A regression test that a repeated
loadBundledCodexCatalog()spawns no subprocess
would have caught both causes.
Version
2.7.42
Operating system
Windows 11
Provider and model
Not provider-specific. Reproduced with openai (forward), kiro, kiro-native,
opencode-go, xai, and google-antigravity configured; the slow path is local catalog
loading, not any upstream call.
Logs or error output
$ ocx claude --version
⚠ 모델 컨텍스트 정보를 불러오지 못했습니다 — 1M 자동 표시는 이번 실행에서 생략됩니다.
⚠ Gateway model cache could not be refreshed; the model picker may be stale.
2.1.220 (Claude Code)
# proxy endpoint timings, cold proxy (3s budget in cmdClaude)
claude-code #1 : 4771 ms
claude-code #2 : 23 ms
claude-code #3 : 18 ms
# and again after a 65s idle gap (BUNDLED_CATALOG_CACHE_MS = 60_000)
claude-code after 65s idle: 6325 ms
immediately after: 69 ms
# runtime resolution, same machine
resolveCodexRuntime({}) 1224 ms failures: 102
resolveCodexRuntime({ discoverAlternatives: false }) 60 ms
selected(true) : ...\Codex\bin\<hash>\codex.exe environment 0.146.0-alpha.3.1
selected(false): ...\Codex\bin\<hash>\codex.exe environment 0.146.0-alpha.3.1
newerAvailable : ...\npm\codex.cmdRedacted configuration
{
"port": 10100,
"defaultProvider": "openai",
"codexAutoStart": true,
"providers": {
"openai": { "adapter": "openai-responses", "authMode": "forward", "codexAccountMode": "pool" },
"kiro": { "adapter": "kiro", "authMode": "oauth", "liveModels": false },
"kiro-native": { "adapter": "anthropic", "baseUrl": "http://127.0.0.1:3456", "allowPrivateNetwork": true, "liveModels": false },
"opencode-go": { "adapter": "openai-chat", "authMode": "key", "apiKey": "<redacted>" },
"xai": { "adapter": "openai-chat", "authMode": "oauth", "liveModels": true },
"google-antigravity": { "adapter": "google", "googleMode": "cloud-code-assist", "authMode": "oauth" }
}
}Relevant environment: CODEX_CLI_PATH is set, so the runtime resolves with
source: "environment"; codex-cli is 0.146.0-alpha.14; the proxy runs under the bundled
Bun 1.3.14. No combos configured, disabledModels empty.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Unrelated aside, happy to split out if useful: model discovery for the adapter: "google" /
googleMode: "cloud-code-assist" provider fails with HTTP 404 and degrades to the static
catalog (Provider model discovery for "google-antigravity" failed with HTTP 404 [urlClass=provider-models, fallback=configured]).
Translated Message
Original language: Korean
Client or integration
Claude Code
Area
Proxy and routing
Summary
On Windows, ocx claude prints both of these on almost every launch:
⚠ Unable to load model context information — 1M auto marking will be skipped for this run.
⚠ Gateway model cache could not be refreshed; the model picker may be stale.
Neither is an auth or connectivity failure. Both are timeouts. cmdClaude gives
fetchClaudeContextWindows (/api/claude-code) and refreshGatewayModelCacheFromProxy
(/v1/models) a 3s budget each, and the local proxy takes 5-8s to answer. Both catch
blocks swallow the AbortError and emit the generic warning above.
The user-visible consequences are quiet but real: [1m] context marking is skipped for
the run, and ~/.claude/cache/gateway-models.json is never rewritten. Because Claude Code
only refreshes that cache itself when it holds a credential — and the subscription-preserving
launch deliberately injects none — the picker keeps serving whatever is on disk. Mine was
6 days stale (80 entries) before I traced this.
Expected: both endpoints answer within the 3s budget, so the cache is rewritten and [1m]
marking applies. Actual: both abort, every launch, and the warnings suggest an auth problem
rather than a latency one.
Root cause is a codex --version subprocess probe sitting on the request path, which is
effectively uncached for two independent reasons (details below).
Reproduction
-
Install
@bitkyc08/opencodex2.7.42 on Windows with acodexCLI present, plus several
additionalcodexlaunchers reachable viaPATH(mine: npm global shim, a Codex
App-managed binary, and a shim backup — 102 probe candidates in total). -
Start the proxy:
ocx start --port 10100. -
Run
ocx claude --version. -
Observe both warnings on stderr.
-
Time the two endpoints
cmdClaudecalls directly:Measure-Command { Invoke-WebRequest 'http://127.0.0.1:10100/api/claude-code' -UseBasicParsing } Measure-Command { Invoke-WebRequest 'http://127.0.0.1:10100/v1/models?limit=1000&ids=cli' -Headers @{'anthropic-version'='2023-06-01'} -UseBasicParsing }
Both exceed 3000 ms on a cold proxy. Note that repeating step 5 after a >60s idle gap
reproduces it again, becauseBUNDLED_CATALOG_CACHE_MSis 60s. -
Confirm the effect:
~/.claude/cache/gateway-models.jsonkeeps its oldfetchedAt.
Timed in-process against the same config, to isolate where the time goes:
fetchAllModels 457 ms (all 7 providers, network included)
listCatalogNativeSlugs 1328 ms <-- no network at all
visibleNativeSlugs 1645 ms
nativeOpenAiSlugs 2072 ms
buildClaudeContextWindows 2018 ms
All of the native-slug cost is readCurrentCatalogOrCache() → loadBundledCodexCatalog().
The subprocess it wraps is not the problem:
codex debug models --bundled 87 ms
codex --version 721 ms
loadBundledCodexCatalog() three times in a row, nothing changing on disk, 60s cache:
#0 1214 ms
#1 1136 ms <-- expected ~0 on a warm cache
#2 1186 ms
Cause 1 — the bundled-catalog cache can never be hit
In src/codex/catalog/bundled.ts, candidates is computed in an IIFE that runs before
the bundledCatalogCache lookup, because cacheKey is assigned inside it. So
resolveAndPersistCodexRuntime() runs on every call, warm hits included. That would still
be cheap if the resolve were memoized, but line 154 forwards the real execFileSync:
const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
// ...
const resolved = resolveAndPersistCodexRuntime({
execFileSync: execFile, // always truthyand resolveCacheKey() in src/codex/runtime.ts deliberately refuses to memoize
injected-dependency resolves:
if (deps.execFileSync || deps.existsSync || deps.readFileSync || deps.configDir || deps.now) {
return null;
}The guard reads correct in intent (injected deps imply a test double), but this caller
defeats it for the real-runtime path by passing its own already-defaulted value.
There is a related self-invalidation: resolveAndPersistCodexRuntime() (runtime.ts:505)
calls persistCodexRuntime unconditionally for any non-fallback selection, and
persistCodexRuntime() ends with resolveCache = null (runtime.ts:228). Since
persistedRuntimeCacheStamp() also folds the on-disk updatedAt into the cache key, each
write both clears the memo and changes the key it would have been stored under — even when
the resolved runtime is byte-identical to what is already persisted.
Cause 2 — cold reads probe every codex on PATH
bundled.ts:161 passes discoverAlternatives: deps.discoverAlternatives, i.e. undefined
for normal callers, which resolveCodexRuntimeUncached() treats as "discover". It then
probes every candidate to populate newerAvailable, each probe spawning codex --version
with a fresh mkdtempSync sandbox:
resolveCodexRuntime({}) 1224 ms failures: 102
resolveCodexRuntime({ discoverAlternatives: false }) 60 ms
Both select the identical runtime. Catalog loading only consumes resolved.runtime.command;
it never reads newerAvailable or failures. So on a cold read that ~1.2s scan is pure
overhead, and on its own eats more than a third of the 3s budget before any real work.
Effect
/api/claude-code measured over HTTP:
| before | after local patch | |
|---|---|---|
| cold (fresh proxy) | 5000-8300 ms | 165 ms |
| warm | 6300 ms | ~25 ms |
| after 65s idle (cache expired) | 6325 ms | 1440 ms |
With the patches ocx claude runs clean, and gateway-models.json refreshed from 80 stale
entries to 98 current ones, 25 carrying [1m].
Patches I verified locally
Not opening a PR since you may prefer different tradeoffs, but these are what I tested.
src/codex/catalog/bundled.ts — forward an injected execFileSync only, so real-runtime
resolves stay memoizable:
...(deps.execFileSync ? { execFileSync: deps.execFileSync } : {}),src/codex/catalog/bundled.ts — default the catalog path to non-discovering:
discoverAlternatives: deps.discoverAlternatives ?? false,src/codex/runtime.ts — only persist when the selection actually changed, so the write
stops clearing its own memo:
const persistedRuntime = loadPersistedCodexRuntime(deps);
const selectionUnchanged = persistedRuntime !== null
&& persistedRuntime.command === result.runtime.command
&& persistedRuntime.source === result.runtime.source
&& (persistedRuntime.selectedVersion ?? null) === result.runtime.version;
if (result.runtime.command && result.runtime.source !== "fallback" && !selectionUnchanged) {Tradeoff worth flagging on the second one: catalog-path callers lose newerAvailable
diagnostics. If something depends on those, moving discovery to a slow background interval
instead of every catalog read would preserve them while keeping the request path fast.
Suggested improvements beyond the fix
- Check
bundledCatalogCachebefore resolving the runtime. Deriving the cache key from
the very thing the cache exists to avoid means a warm hit can only ever save the
codex debug modelscall (87 ms), never the probe (1.2 s) that dominates. A cheaper key
would let warm hits skip both. - Attribute the timeout in the warning text. Both messages read like auth or network
faults. Something liketimed out after 3000mswould point straight at the cause; I went
looking at OAuth state and provider connectivity first. - Consider raising or making the 3s budget configurable. Even fully patched, a cold read
is ~1.4s here, so a slower machine or a longerPATHcould still trip it silently. - A regression test that a repeated
loadBundledCodexCatalog()spawns no subprocess
would have caught both causes.
Version
2.7.42
Operating system
Windows 11
Provider and model
Not provider-specific. Reproduced with openai (forward), kiro, kiro-native,
opencode-go, xai, and google-antigravity configured; the slow path is local catalog
loading, not any upstream call.
Logs or error output
$ ocx claude --version
⚠ Unable to load model context information — 1M auto marking will be skipped for this run.
⚠ Gateway model cache could not be refreshed; the model picker may be stale.
2.1.220 (Claude Code)
# proxy endpoint timings, cold proxy (3s budget in cmdClaude)
claude-code #1 : 4771 ms
claude-code #2 : 23 ms
claude-code #3 : 18 ms
# and again after a 65s idle gap (BUNDLED_CATALOG_CACHE_MS = 60_000)
claude-code after 65s idle: 6325 ms
immediately after: 69 ms
# runtime resolution, same machine
resolveCodexRuntime({}) 1224 ms failures: 102
resolveCodexRuntime({ discoverAlternatives: false }) 60 ms
selected(true) : ...\Codex\bin\<hash>\codex.exe environment 0.146.0-alpha.3.1
selected(false): ...\Codex\bin\<hash>\codex.exe environment 0.146.0-alpha.3.1
newerAvailable : ...\npm\codex.cmdRedacted configuration
{
"port": 10100,
"defaultProvider": "openai",
"codexAutoStart": true,
"providers": {
"openai": { "adapter": "openai-responses", "authMode": "forward", "codexAccountMode": "pool" },
"kiro": { "adapter": "kiro", "authMode": "oauth", "liveModels": false },
"kiro-native": { "adapter": "anthropic", "baseUrl": "http://127.0.0.1:3456", "allowPrivateNetwork": true, "liveModels": false },
"opencode-go": { "adapter": "openai-chat", "authMode": "key", "apiKey": "<redacted>" },
"xai": { "adapter": "openai-chat", "authMode": "oauth", "liveModels": true },
"google-antigravity": { "adapter": "google", "googleMode": "cloud-code-assist", "authMode": "oauth" }
}
}Relevant environment: CODEX_CLI_PATH is set, so the runtime resolves with
source: "environment"; codex-cli is 0.146.0-alpha.14; the proxy runs under the bundled
Bun 1.3.14. No combos configured, disabledModels empty.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Unrelated aside, happy to split out if useful: model discovery for the adapter: "google" /
googleMode: "cloud-code-assist" provider fails with HTTP 404 and degrades to the static
catalog (Provider model discovery for "google-antigravity" failed with HTTP 404 [urlClass=provider-models, fallback=configured]).
Activity
github-actions commentedon Jul 28, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
github-actions commentedon Jul 28, 2026
Automated translation bookkeeping — detected language: unknown.
lidge-jun commentedon Jul 28, 2026
Status: PR #610 addresses the probe cost; the cache-key ordering stays open
Confirmed the mechanism you traced. On
dev(7710185c0),loadBundledCodexCatalog()atsrc/codex/catalog/bundled.ts:146-175builds itscacheKeyinside the candidate IIFE, soresolveAndPersistCodexRuntime()— and thecodex --versionprobe atsrc/codex/runtime.ts:252, which allows up to 8s and creates amkdtempSyncsandbox per call — runs before the warm-cache branch is ever consulted. A 60sBUNDLED_CATALOG_CACHE_MSwindow cannot help when the key is derived from the very work it exists to skip.PR #610 is open against this. To be precise about what it does and does not change, since it matters for whether this issue closes with it:
resolveCacheKey()'s memoization by forwarding an already-defaultedexecFileSync.resolveAndPersistCodexRuntime()from rewriting the persisted runtime when the selection is unchanged, which had been clearing its own memo and shifting the cache key.discoverAlternativestofalseon the catalog path, so a cold read no longer probes everycodexonPATH(measured 1224ms → 60ms for the same selection).Reported effect on the endpoints in your report:
/api/claude-codewarm 6300ms → ~25ms, cold 5000-8300ms → 165ms.What it deliberately leaves alone is the ordering itself — the author notes it as a follow-up suggestion rather than part of the change ("I left the ordering alone to keep this change minimal"). So after #610 a warm hit is fast because the probe is memoized, not because it is skipped, and a cold or 60s-expired read still pays ~1.4s. Your other two observations — that both warnings read like auth or network faults rather than timeouts, and that a 3s budget may still be tight on a slower machine or a longer
PATH— are unaddressed and worth keeping in view here.Keeping this open until #610 lands and the residual is re-measured.
lidge-jun commentedon Jul 30, 2026
Triage note so this does not get closed on PR #610's back.
#610 is a real fix and should land, but it addresses a different part of this than the issue asks for. It stops the repeated
codex --versionprobing on warm reads: forwarding only an injectedexecFileSyncrestores resolver memoization (src/codex/catalog/bundled.ts:153-158), defaultingdiscoverAlternativestofalseremoves the PATH-wide sweep that costs ~1.2s on a machine with many candidate launchers, and no longer rewriting an unchanged persisted runtime selection stops the write that was clearing the resolver memo.What it does not change is the ordering this issue names.
resolveAndPersistCodexRuntime()still runs beforebundledCatalogCacheis consulted, and necessarily so, because the cache key is derived from the resolved runtime. A cold read, or one where the memo or catalog entry has expired, still pays for runtime resolution before anything cheap is checked.So the pathological case that made this visible is gone, and the structural risk to the 3s budget on
/api/claude-codeis not.Staying open pending a re-measurement on Windows: cold read, warm read, and the >60s case, timed against the 3s budget. If those come back comfortably inside it, this can close with the numbers attached. If they do not, the fix is the cache-ordering change originally proposed here.
fix(catalog): stop respawning the codex --version probe on every cata…
lidge-jun commentedon Jul 31, 2026
Fixed on
devby716f39cb6 fix(codex): key catalog cache by runtime and address CodeRabbit follow-ups.Two changes remove the repeated
codex --versionspawn from the catalog path (src/codex/catalog/bundled.ts:153-168): only an injectedexecFileSyncis forwarded, soresolveCacheKey()no longer refuses to memoize and re-probes on every warm read; anddiscoverAlternativesdefaults tofalse, so full PATH discovery (100+ candidate launchers, ~1.2s) no longer runs on the hot path thatocx claudebudgets at 3s.PR #610 proposed the same fix and is closed as superseded.
feat(api): serve the generated Codex catalog at GET /api/catalog (#709)
fix(catalog): stop respawning the codex --version probe on every cata…
feat(api): serve the generated Codex catalog at GET /api/catalog (lid…