Skip to content

ocx claude: 3s budget exceeded by uncached codex --version probe on /api/claude-code and /v1/models #606

Description

@mihneaptu

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

  1. Install @bitkyc08/opencodex 2.7.42 on Windows with a codex CLI present, plus several
    additional codex launchers reachable via PATH (mine: npm global shim, a Codex
    App-managed binary, and a shim backup — 102 probe candidates in total).

  2. Start the proxy: ocx start --port 10100.

  3. Run ocx claude --version.

  4. Observe both warnings on stderr.

  5. Time the two endpoints cmdClaude calls 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, because BUNDLED_CATALOG_CACHE_MS is 60s.

  6. Confirm the effect: ~/.claude/cache/gateway-models.json keeps its old fetchedAt.

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 truthy

and 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

  1. Check bundledCatalogCache before 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 models call (87 ms), never the probe (1.2 s) that dominates. A cheaper key
    would let warm hits skip both.
  2. Attribute the timeout in the warning text. Both messages read like auth or network
    faults. Something like timed out after 3000ms would point straight at the cause; I went
    looking at OAuth state and provider connectivity first.
  3. Consider raising or making the 3s budget configurable. Even fully patched, a cold read
    is ~1.4s here, so a slower machine or a longer PATH could still trip it silently.
  4. 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.cmd

Redacted 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

  1. Install @bitkyc08/opencodex 2.7.42 on Windows with a codex CLI present, plus several
    additional codex launchers reachable via PATH (mine: npm global shim, a Codex
    App-managed binary, and a shim backup — 102 probe candidates in total).

  2. Start the proxy: ocx start --port 10100.

  3. Run ocx claude --version.

  4. Observe both warnings on stderr.

  5. Time the two endpoints cmdClaude calls 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, because BUNDLED_CATALOG_CACHE_MS is 60s.

  6. Confirm the effect: ~/.claude/cache/gateway-models.json keeps its old fetchedAt.

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 truthy

and 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

  1. Check bundledCatalogCache before 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 models call (87 ms), never the probe (1.2 s) that dominates. A cheaper key
    would let warm hits skip both.
  2. Attribute the timeout in the warning text. Both messages read like auth or network
    faults. Something like timed out after 3000ms would point straight at the cause; I went
    looking at OAuth state and provider connectivity first.
  3. Consider raising or making the 3s budget configurable. Even fully patched, a cold read
    is ~1.4s here, so a slower machine or a longer PATH could still trip it silently.
  4. 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.cmd

Redacted 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]).

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions