Skip to content

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

Closed

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

Activity

  1. github-actions commented on Jul 28, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  2. github-actions commented on Jul 28, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: unknown.

  3. lidge-jun commented on Jul 28, 2026

    @lidge-jun
    Owner

    Status: PR #610 addresses the probe cost; the cache-key ordering stays open

    Confirmed the mechanism you traced. On dev (7710185c0), loadBundledCodexCatalog() at src/codex/catalog/bundled.ts:146-175 builds its cacheKey inside the candidate IIFE, so resolveAndPersistCodexRuntime() — and the codex --version probe at src/codex/runtime.ts:252, which allows up to 8s and creates a mkdtempSync sandbox per call — runs before the warm-cache branch is ever consulted. A 60s BUNDLED_CATALOG_CACHE_MS window 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:

    • It stops the catalog path from defeating resolveCacheKey()'s memoization by forwarding an already-defaulted execFileSync.
    • It stops resolveAndPersistCodexRuntime() from rewriting the persisted runtime when the selection is unchanged, which had been clearing its own memo and shifting the cache key.
    • It defaults discoverAlternatives to false on the catalog path, so a cold read no longer probes every codex on PATH (measured 1224ms → 60ms for the same selection).

    Reported effect on the endpoints in your report: /api/claude-code warm 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.

  4. lidge-jun commented on Jul 30, 2026

    @lidge-jun
    Owner

    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 --version probing on warm reads: forwarding only an injected execFileSync restores resolver memoization (src/codex/catalog/bundled.ts:153-158), defaulting discoverAlternatives to false removes 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 before bundledCatalogCache is 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-code is 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.

  5. lidge-jun commented on Jul 31, 2026

    @lidge-jun
    Owner

    Fixed on dev by 716f39cb6 fix(codex): key catalog cache by runtime and address CodeRabbit follow-ups.

    Two changes remove the repeated codex --version spawn from the catalog path (src/codex/catalog/bundled.ts:153-168): only an injected execFileSync is forwarded, so resolveCacheKey() no longer refuses to memoize and re-probes on every warm read; and discoverAlternatives defaults to false, so full PATH discovery (100+ candidate launchers, ~1.2s) no longer runs on the hot path that ocx claude budgets at 3s.

    PR #610 proposed the same fix and is closed as superseded.

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

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