From 3a0b88fd49e95424ba075c38dbae247c0a450bd5 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 10:48:38 +0200 Subject: [PATCH 01/17] fix(openai-responses): repair oversized replay call_ids on non-forward routes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's Messages API never sets previousResponseId, so the forward-only guard around repairOversizedReplayCallIds never fired on custom apiKey-auth providers. Long tool-chains hit a 400 'Invalid input[N].call_id: string too long' once replayed call_id values exceed the Responses API's 64-char limit. Broaden the guard to !unexpandedMiss: still covers forward mode and already-expanded replays, plus any request with no previousResponseId at all (every Claude-surfaced request), while still excluding the one risky case — a raw unexpanded native-Codex continuation that might reference call ids stored upstream. --- src/adapters/openai-responses.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 1a17704e7..738b3dffd 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -719,7 +719,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripUnsupportedForwardParams(outBody); } else outBody = stripConflictingHostedTools(outBody); - if (forward || parsed._previousResponseInputExpanded === true) { + // Claude Code's Messages API never sets previousResponseId at all, so on a + // custom apiKey-auth provider (non-forward) the original forward-only guard + // never fired, and long tool-chains hit "Invalid 'input[N].call_id': string + // too long" (call_id replay can exceed the Responses API's 64-char limit). + // !unexpandedMiss is a safe superset of the old condition: it still covers + // forward mode and already-expanded replays, but also covers any request with + // no previousResponseId at all (every Claude-surfaced request) — while still + // excluding the one genuinely risky case, a raw unexpanded native-Codex + // continuation that might reference call ids stored upstream. + if (!unexpandedMiss) { outBody = repairOversizedReplayCallIds(outBody); } outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); From 41deeef426c1fe8696606b704c8b2f4689a573db Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 10:49:13 +0200 Subject: [PATCH 02/17] fix(claude/inbound): prefer content-based prompt_cache_key over session-scoped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI's Responses API caching is routed by prompt_cache_key, not pure byte-prefix matching. The session-scoped key (hash of Claude Code's metadata.user_id) meant every NEW Claude Code session got a fresh key, so a cache warmed by a prior session was unreachable even for byte-identical system+tools content — a guaranteed cold rewrite on the first call of every session, compounding heavily with subagent-heavy workloads that spawn many short-lived sessions. Flip the priority: try the content-based fallback key first (hash of resolved model + translated system + translated tools in wire order), falling back to the session-scoped key only when there's no system content to fingerprint. Any session can now hit a cache any other session already warmed. Session-scoped keying was originally chosen to avoid herding many different sessions/models/toolsets onto one content-hash key and burning OpenAI's ~15 RPM per-key routing budget at multi-tenant scale (audit R1#4/R2#5/R1#10). That collision risk is much smaller for a single-operator deployment, where the dominant cost is inter-session cache misses instead. --- src/claude/inbound.ts | 49 +++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 5dbe519b4..1b896dced 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -443,26 +443,30 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode let cacheKeySource: ClaudeCacheKeySource = null; if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { body.user = raw.metadata.user_id; - // OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send - // their session id; without it consecutive /v1/messages turns reported - // cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's - // metadata.user_id embeds the session uuid, so hashing it yields a stable - // per-session key with a bounded length/charset. - body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32); - cacheKeySource = "metadata"; - } else if (systemParts.length > 0) { - // Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the - // ChatGPT/OpenAI backends reported cached_tokens:0 on every turn. Fall back to a - // cache-cohort hash (devlog 260712 B4 + Pro review 012): fingerprint what the - // upstream actually receives — resolved model, post-translation system, and the - // FULL translated tool definitions in WIRE ORDER (sorting the hash while sending - // a different order would break the key↔prefix correspondence). canonical JSON - // (recursive key sort) + a version field so future normalization changes never - // mix cohorts. system-only keys herded different models/toolsets into one key - // and burned OpenAI's ~15 RPM per-key routing budget (audit R1#4/R2#5/R1#10). - // Exact-prefix matching still isolates content; the key only steers routing - // affinity. Callers must NOT synthesize a session_id header from this fallback - // (audit 133 R2#3). + } + // OpenAI-side prompt caching is routed by prompt_cache_key, not pure byte-prefix + // matching (Codex clients send their session id; without any key, consecutive + // /v1/messages turns reported cached_tokens: 0 on the ChatGPT backend — devlog 090). + // + // Content-first (devlog 260727 subagent-cache-audit): fingerprint what the upstream + // actually receives — resolved model, post-translation system, and the FULL translated + // tool definitions in WIRE ORDER (sorting the hash while sending a different order + // would break the key<->prefix correspondence). canonical JSON (recursive key sort) + + // a version field so future normalization changes never mix cohorts. + // + // Deliberately preferred over metadata.user_id (Claude Code's session uuid) even + // when present: a session-scoped key means every NEW Claude Code session gets a + // fresh key, so a cache warmed by yesterday's session is unreachable today even for + // byte-identical system+tools content — this is a guaranteed cold rewrite on the + // first call of every session, not just cross-session drift. Content-based keying + // lets any session hit a cache any other session already warmed. Session-only keys + // were originally chosen because pure-content keying herded many different + // sessions/models/toolsets onto one key, burning OpenAI's ~15 RPM per-key routing + // budget (audit R1#4/R2#5/R1#10) at multi-tenant scale; that risk is far smaller for + // a single-operator deployment where the actual pain is inter-session cache misses. + // Exact-prefix matching still isolates content; the key only steers routing affinity. + // Callers must NOT synthesize a session_id header from this fallback (audit 133 R2#3). + if (systemParts.length > 0) { body.prompt_cache_key = createHash("sha256") .update(canonicalJson({ version: 2, @@ -472,6 +476,11 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode })) .digest("hex").slice(0, 32); cacheKeySource = "system"; + } else if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { + // No system content to fingerprint (H1, devlog 130): fall back to the session-scoped + // key so the request still gets SOME key rather than none. + body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32); + cacheKeySource = "metadata"; } const thinking = raw.thinking; From 23ec63fe4c676b55901ba534e2cd4f811c3324d1 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 10:49:41 +0200 Subject: [PATCH 03/17] fix(cli): ocx restart no longer silently no-ops when codexAutoStart is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restart was: stop, then handleEnsure(). handleEnsure() early-returns without relaunching whenever codexAutoStart is false — that flag only governs whether opencodex registers itself with the Codex CLI on boot, an unrelated concern, but it silently swallowed restart too: the proxy stopped cleanly and simply never came back, with no error. Swap to handleTrayProxyStart(), already used by the tray's own restart action and explicitly documented as relaunching unconditionally, independent of codexAutoStart. --- src/cli/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index bb2dab8c6..5f52eb552 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -864,7 +864,13 @@ switch (command) { case "restart": { // A failed stop must not be followed by a re-inject: with a foreign service still running // (ownership mismatch) we would rewrite shared config we just declined to touch. - if (await handleStop()) await handleEnsure(); + // + // handleEnsure() early-returns without relaunching when codexAutoStart is disabled — + // that flag governs whether opencodex registers itself with the Codex CLI on boot, an + // unrelated concern, but it silently ate `restart` too: stop succeeded, nothing ever + // came back up. handleTrayProxyStart() (already used by the tray's own restart action) + // relaunches the proxy unconditionally, independent of codexAutoStart. + if (await handleStop()) await handleTrayProxyStart(); else console.error("↩️ Restart aborted: the proxy was not stopped cleanly."); break; } From 152682bcc097ad0171f59042547382db9e91f123 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 11:43:52 +0200 Subject: [PATCH 04/17] fix: serialize parallel subagent dispatches sharing a cold prompt_cache_key Content-based prompt_cache_key (session-boundary fix) makes same-persona subagents agree on one cache cohort, but two dispatched in the same message can both miss if neither's cache write has landed before the other's request goes out. Opt-in leader/follower lease: first request for an unseen key proceeds immediately: any request sharing that key while the leader is in flight awaits the leader's release (fired on first output token, with a finally-block fallback) before dispatching. Each follower still gets its own real upstream response, only dispatch timing is delayed, not response sharing (unlike single-flight/ request-coalescing). Gated behind serializeColdSubagentCache, default false. --- src/lib/cold-cache-key-lease.ts | 73 ++++++++++++++++++++++++++++++ src/server/claude-messages.ts | 38 +++++++++++++--- src/types.ts | 18 ++++++++ tests/cold-cache-key-lease.test.ts | 37 +++++++++++++++ 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 src/lib/cold-cache-key-lease.ts create mode 100644 tests/cold-cache-key-lease.test.ts diff --git a/src/lib/cold-cache-key-lease.ts b/src/lib/cold-cache-key-lease.ts new file mode 100644 index 000000000..3cedd4a18 --- /dev/null +++ b/src/lib/cold-cache-key-lease.ts @@ -0,0 +1,73 @@ +/** + * Cold prompt_cache_key lease (devlog 260727 subagent-cache-audit). + * + * A content-based prompt_cache_key (see claude/inbound.ts) makes every request that + * shares the same resolved model + system + tools agree on the same OpenAI cache + * cohort. That fixes reuse ACROSS time (a later session hits an earlier one's warm + * cache) but does nothing for requests that race the SAME key at the SAME time: if + * two Task-tool subagents of the same persona are dispatched in one message, both + * requests can be in flight before either has written its cache entry, so both miss + * even though they'd have shared a hit had one landed first. + * + * This module lets the caller opt into serializing that specific race: the first + * request for a given key proceeds immediately (the "leader"); any request sharing + * that key while the leader is still in flight awaits the leader's release signal + * instead of dispatching upstream right away. + * + * Deliberately NOT a general single-flight/response-sharing cache (see + * request-coalescing prior art) — each caller still gets its own real upstream + * response; only the *dispatch timing* of followers is delayed so they land on a + * warmer cache. + */ + +interface PendingLease { + release: () => void; + wait: Promise; +} + +const inFlight = new Map(); + +export interface ColdCacheKeyLease { + /** Non-null when this caller is a follower — await it before dispatching upstream. */ + waitForLeader: Promise | null; + /** + * Leader only (waitForLeader === null): call once, as soon as the leader's request + * has progressed enough that the cache write should have landed (first output + * token is a reasonable proxy — by then the model has finished ingesting the + * prompt). Idempotent; safe to call from both a first-output hook and a + * finally-block fallback (e.g. the leader errors before producing any output). + */ + release: () => void; +} + +/** + * Acquire a lease for `key`. The first caller for a given key becomes the leader + * (waitForLeader: null) and must call `release()` itself once it's safe for + * followers to proceed. Every subsequent caller for the same key, while the leader + * hasn't released yet, becomes a follower and receives the leader's `wait` promise. + */ +export function acquireColdCacheKeyLease(key: string): ColdCacheKeyLease { + const existing = inFlight.get(key); + if (existing) return { waitForLeader: existing.wait, release: () => {} }; + + let resolve: () => void = () => {}; + const wait = new Promise(r => { resolve = r; }); + let released = false; + const release = () => { + if (released) return; + released = true; + // Only the current holder for this key clears the map entry — a slow-to-call + // leader whose key was already superseded (shouldn't happen given the map is + // keyed by content hash and cleared synchronously below, but cheap to guard) + // must not evict a newer lease. + if (inFlight.get(key)?.wait === wait) inFlight.delete(key); + resolve(); + }; + inFlight.set(key, { release, wait }); + return { waitForLeader: null, release }; +} + +/** Test isolation / explicit reset. */ +export function clearColdCacheKeyLeases(): void { + inFlight.clear(); +} diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e357cea2d..05ae3ce8e 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -657,6 +657,21 @@ export async function handleClaudeMessages( body: JSON.stringify(internalBody), }); + // Cold-key lease (opt-in, devlog 260727 subagent-cache-audit): a parallel Task-tool + // fan-out that dispatches several requests sharing the SAME content-based + // prompt_cache_key races all of them before any has written its cache entry, so + // every one misses even though they'd agree on the same key. The leader (first + // arrival for this key) proceeds immediately; followers wait for the leader's + // first output token before dispatching, so they land on a warm cache instead of + // racing a cold one. See src/lib/cold-cache-key-lease.ts. + let coldKeyRelease: (() => void) | undefined; + if (config.claudeCode?.serializeColdSubagentCache && typeof internalBody.prompt_cache_key === "string") { + const { acquireColdCacheKeyLease } = await import("../lib/cold-cache-key-lease"); + const lease = acquireColdCacheKeyLease(internalBody.prompt_cache_key); + if (lease.waitForLeader) await lease.waitForLeader; + else coldKeyRelease = lease.release; + } + // Request-log wiring mirrors the /v1/responses route: native passthrough finalizes // via the terminal callbacks; routed streams get the Responses-vocabulary log tap // BEFORE translation (the translated Anthropic stream has no response.completed @@ -667,12 +682,23 @@ export async function handleClaudeMessages( nativeLogged = true; addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); }; - const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, { - abortSignal: req.signal, - ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), - onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }), - onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), - }); + const onFirstOutput = () => { + if (logIds) recordFirstOutput(logCtx, logIds.start); + coldKeyRelease?.(); + }; + let upstream: Response; + try { + upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, { + abortSignal: req.signal, + onFirstOutput, + onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }), + onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), + }); + } finally { + // Fallback release: the leader errored/aborted before any output token, so + // onFirstOutput never fired — followers must not wait forever. + coldKeyRelease?.(); + } const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; if (!response.ok) { diff --git a/src/types.ts b/src/types.ts index 76fdd5b3a..6bd705280 100644 --- a/src/types.ts +++ b/src/types.ts @@ -424,6 +424,24 @@ export interface OcxClaudeCodeConfig { * full content. Default: ["claude-api"]. Empty array = explicitly off. */ blockedSkills?: string[]; + /** + * When a parallel Task-tool fan-out dispatches several requests sharing the SAME + * content-based prompt_cache_key (identical resolved model + system + tools, e.g. + * several subagents of the same persona fired in one message) before any of them + * has completed, the very first one to land at OpenAI hasn't written its cache + * entry yet — every sibling that races in during that window misses too, even + * though they all agree on the same key (devlog 260727 subagent-cache-audit). + * + * When enabled, the first request for a not-yet-seen key proceeds immediately + * (the "leader"); any sibling sharing that key while the leader is still in + * flight waits until the leader's first output token (roughly when the model has + * finished ingesting the prompt and the cache write should have landed), then + * proceeds — trading a bounded chunk of added latency on cold siblings for + * avoiding N-1 duplicate cold-rewrite costs. Default: disabled — this changes + * latency characteristics of parallel fan-out and should be opted into + * deliberately, not silently on by default. + */ + serializeColdSubagentCache?: boolean; /** * Sync the featured subagent roster (config.subagentModels + main model) into * ~/.claude/agents/ocx-*.md custom agent definitions at launch (devlog 260712 diff --git a/tests/cold-cache-key-lease.test.ts b/tests/cold-cache-key-lease.test.ts new file mode 100644 index 000000000..51b50bb5a --- /dev/null +++ b/tests/cold-cache-key-lease.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { acquireColdCacheKeyLease, clearColdCacheKeyLeases } from "../src/lib/cold-cache-key-lease"; + +describe("cold-cache-key-lease", () => { + test("first caller for a key is the leader, second is a follower released by the leader", async () => { + clearColdCacheKeyLeases(); + const leader = acquireColdCacheKeyLease("k1"); + expect(leader.waitForLeader).toBeNull(); + + const follower = acquireColdCacheKeyLease("k1"); + expect(follower.waitForLeader).not.toBeNull(); + + let followerProceeded = false; + const followerWait = follower.waitForLeader!.then(() => { followerProceeded = true; }); + await Promise.resolve(); // let microtasks settle + expect(followerProceeded).toBe(false); // leader hasn't released yet + + leader.release(); + await followerWait; + expect(followerProceeded).toBe(true); + }); + + test("release is idempotent and safe to call twice (first-output + finally fallback)", async () => { + clearColdCacheKeyLeases(); + const leader = acquireColdCacheKeyLease("k2"); + leader.release(); + expect(() => leader.release()).not.toThrow(); + }); + + test("a key with no in-flight leader always gets a fresh leader (no cross-key leakage)", () => { + clearColdCacheKeyLeases(); + const a = acquireColdCacheKeyLease("k3"); + a.release(); + const b = acquireColdCacheKeyLease("k3"); // a already released, so k3 is free again + expect(b.waitForLeader).toBeNull(); + }); +}); From 5823712e02e28c0e5b8c15b3e04c74bf5ae4a7f7 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 12:13:56 +0200 Subject: [PATCH 05/17] feat(router): warn when a provider's bare gpt-/o1-/o3-/o4- model is unreachable isBareOpenAiFamilyModel claims any bare gpt-/o1-/o3-/o4- prefixed model id for the native openai (Codex) provider before any other provider's defaultModel/models[] match is ever consulted. A provider that reuses one of those bare names (e.g. our phantomOpenai config reusing the upstream DEFAULT_SUBAGENT_MODELS names) silently loses to native Codex with no error - just the wrong provider and no prompt-cache benefit. Model is still reachable via the qualified / form; this only surfaces the silent case at startup so it's not discovered by losing cache hits. Diagnostic only, never changes routing. --- src/router.ts | 38 ++++++++++ src/server/index.ts | 2 + ...arn-unreachable-bare-model-aliases.test.ts | 70 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 tests/warn-unreachable-bare-model-aliases.test.ts diff --git a/src/router.ts b/src/router.ts index cc5774261..64ae3593b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -295,6 +295,44 @@ function isBareOpenAiFamilyModel(modelId: string): boolean { return !modelId.includes("/") && /^(?:gpt-|o1-|o3-|o4-)/.test(modelId); } +/** + * A provider's `defaultModel`/`models[]` entry that looks like a bare OpenAI-family + * id (gpt-, o1-, o3-, or o4- prefixed, no slash) is unreachable under that bare name + * whenever an active native "openai" (Codex) provider is also configured: isBareOpenAiFamilyModel + * claims any such id first in routeModelInternal, before any other provider's + * defaultModel/models match is ever consulted (devlog 260727 subagent-cache-audit + * follow-up — found via a real routing collision between a custom gateway provider + * and the DEFAULT_SUBAGENT_MODELS roster, which reuses bare gpt-5.6-* names). + * + * The model is still reachable via the qualified "/" form (the + * slash-namespace branch runs before the bare-family check) — this only warns that + * the BARE alias silently resolves elsewhere, which otherwise fails silently: no + * error, just the wrong provider and (for anything routed through openai-responses + * translation) no prompt-cache benefit from that provider's warm cache cohort. + * Config-only diagnostic; never changes routing. + */ +export function warnUnreachableBareModelAliases(config: OcxConfig): void { + const codex = config.providers[OPENAI_CODEX_PROVIDER_ID]; + if (!codex || codex.disabled === true) return; // nothing claims bare ids first in that case + for (const [name, prov] of activeProviderEntries(config)) { + if (name === OPENAI_CODEX_PROVIDER_ID) continue; + const candidates = new Set(); + if (typeof prov.defaultModel === "string") candidates.add(prov.defaultModel); + if (Array.isArray(prov.models)) { + for (const m of prov.models) if (typeof m === "string") candidates.add(m); + } + for (const modelId of candidates) { + if (isBareOpenAiFamilyModel(modelId)) { + console.warn( + `⚠️ provider "${name}" model "${modelId}" is unreachable by that bare name — ` + + `the native "openai" provider claims all bare gpt-*/o1-*/o3-*/o4-* ids first. ` + + `Use "${name}/${modelId}" instead (e.g. in subagentModels, modelMap) to route to "${name}".`, + ); + } + } + } +} + function routeResult(providerName: string, provider: OcxProviderConfig, modelId: string): RouteResult { const codexAccountMode = providerCodexAccountMode(providerName, provider); return { diff --git a/src/server/index.ts b/src/server/index.ts index 31ec30a19..2f7592bcd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -24,6 +24,7 @@ import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup" import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; +import { warnUnreachableBareModelAliases } from "../router"; import { CodexAccountCooldownError, cooldownErrorMessage, @@ -282,6 +283,7 @@ export function startServer(port?: number) { // purpose: a lazy "arm on first save" loses exactly the hand edit made before that // first save, which is the case the guard exists for. armClaudeCodeBaseline(config); + warnUnreachableBareModelAliases(config); // usage.jsonl already persists every request; rehydrate the in-memory Logs ring so // /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts. hydrateRequestLogsFromDisk(); diff --git a/tests/warn-unreachable-bare-model-aliases.test.ts b/tests/warn-unreachable-bare-model-aliases.test.ts new file mode 100644 index 000000000..2a9f774dc --- /dev/null +++ b/tests/warn-unreachable-bare-model-aliases.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { warnUnreachableBareModelAliases } from "../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +function configFor(providers: Record): OcxConfig { + return { port: 10100, defaultProvider: "openai", providers }; +} + +function warnCapturing(config: OcxConfig): string[] { + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + try { + warnUnreachableBareModelAliases(config); + } finally { + console.warn = original; + } + return warnings; +} + +test("warns when another active provider's defaultModel collides with a bare gpt- id, and native openai is configured", () => { + const warnings = warnCapturing(configFor({ + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } as OcxProviderConfig, + phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, + })); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('provider "phantomOpenai"'); + expect(warnings[0]).toContain('"gpt-5.6-sol"'); + expect(warnings[0]).toContain("phantomOpenai/gpt-5.6-sol"); +}); + +test("warns for every colliding entry in a provider's models[] array", () => { + const warnings = warnCapturing(configFor({ + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } as OcxProviderConfig, + cursor: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", models: ["gpt-5.6-sol", "gpt-5.6-terra", "claude-opus-4-8"] } as OcxProviderConfig, + })); + expect(warnings.length).toBe(2); + expect(warnings.some(w => w.includes("gpt-5.6-sol"))).toBe(true); + expect(warnings.some(w => w.includes("gpt-5.6-terra"))).toBe(true); +}); + +test("does not warn when the colliding model is already qualified with a provider prefix", () => { + const warnings = warnCapturing(configFor({ + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } as OcxProviderConfig, + phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "phantomOpenai/gpt-5.6-sol" } as OcxProviderConfig, + })); + expect(warnings.length).toBe(0); +}); + +test("does not warn when no native openai provider is configured at all", () => { + const warnings = warnCapturing(configFor({ + phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, + })); + expect(warnings.length).toBe(0); +}); + +test("does not warn when the native openai provider itself is disabled", () => { + const warnings = warnCapturing(configFor({ + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", disabled: true } as OcxProviderConfig, + phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, + })); + expect(warnings.length).toBe(0); +}); + +test("does not warn about the native openai provider's own defaultModel", () => { + const warnings = warnCapturing(configFor({ + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, + })); + expect(warnings.length).toBe(0); +}); From 9ce0464374b30b936c0a4d57fd1adb974ff2c824 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 12:38:19 +0200 Subject: [PATCH 06/17] feat(router): extend bare-model warning to MODEL_PROVIDER_PATTERNS collisions Same bug class, different table: a bare id whose prefix matches the hardcoded claude-/llama-/mixtral-/gemma- pattern table routes to whichever active provider is literally named after that pattern first, before any provider's own models[] array is ever consulted in routeModelInternal. A provider that explicitly lists such an id in models[] loses to the pattern-table provider silently. Fixed a bug in the extension itself: the pattern-table check was nested inside the openai-provider early-return guard, so it never ran when no native openai provider was configured. Caught by adding the test before assuming it worked. --- src/router.ts | 63 ++++++++++++++----- ...arn-unreachable-bare-model-aliases.test.ts | 33 ++++++++++ 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/router.ts b/src/router.ts index 64ae3593b..5166e0a66 100644 --- a/src/router.ts +++ b/src/router.ts @@ -313,21 +313,56 @@ function isBareOpenAiFamilyModel(modelId: string): boolean { */ export function warnUnreachableBareModelAliases(config: OcxConfig): void { const codex = config.providers[OPENAI_CODEX_PROVIDER_ID]; - if (!codex || codex.disabled === true) return; // nothing claims bare ids first in that case - for (const [name, prov] of activeProviderEntries(config)) { - if (name === OPENAI_CODEX_PROVIDER_ID) continue; - const candidates = new Set(); - if (typeof prov.defaultModel === "string") candidates.add(prov.defaultModel); - if (Array.isArray(prov.models)) { - for (const m of prov.models) if (typeof m === "string") candidates.add(m); + if (codex && codex.disabled !== true) { + for (const [name, prov] of activeProviderEntries(config)) { + if (name === OPENAI_CODEX_PROVIDER_ID) continue; + const candidates = new Set(); + if (typeof prov.defaultModel === "string") candidates.add(prov.defaultModel); + if (Array.isArray(prov.models)) { + for (const m of prov.models) if (typeof m === "string") candidates.add(m); + } + for (const modelId of candidates) { + if (isBareOpenAiFamilyModel(modelId)) { + console.warn( + `⚠️ provider "${name}" model "${modelId}" is unreachable by that bare name — ` + + `the native "openai" provider claims all bare gpt-*/o1-*/o3-*/o4-* ids first. ` + + `Use "${name}/${modelId}" instead (e.g. in subagentModels, modelMap) to route to "${name}".`, + ); + } + } } - for (const modelId of candidates) { - if (isBareOpenAiFamilyModel(modelId)) { - console.warn( - `⚠️ provider "${name}" model "${modelId}" is unreachable by that bare name — ` + - `the native "openai" provider claims all bare gpt-*/o1-*/o3-*/o4-* ids first. ` + - `Use "${name}/${modelId}" instead (e.g. in subagentModels, modelMap) to route to "${name}".`, - ); + } + warnUnreachablePatternTableModels(config); +} + +/** + * Same class of bug, different table: MODEL_PROVIDER_PATTERNS routes a bare id whose + * prefix matches a hardcoded pattern (e.g. "claude-") to whichever ACTIVE provider is + * literally named (or "name-"-prefixed) after that pattern's providerNames, and it + * runs in routeModelInternal before any provider's own `models[]` array is ever + * consulted. A provider that explicitly lists such an id in `models[]` loses to that + * pattern-table provider silently, same shape as the bare-OpenAI-family case above — + * just keyed off a provider NAME collision instead of a provider-agnostic prefix. + * `defaultModel` matches are excluded: those are checked before the pattern table, so + * they're already reachable regardless of this collision. + */ +function warnUnreachablePatternTableModels(config: OcxConfig): void { + const active = activeProviderEntries(config); + const defaultModels = new Set(active.map(([, prov]) => prov.defaultModel).filter((m): m is string => typeof m === "string")); + for (const [name, prov] of active) { + if (!Array.isArray(prov.models)) continue; + for (const modelId of prov.models) { + if (typeof modelId !== "string" || modelId.includes("/") || defaultModels.has(modelId)) continue; + for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) { + if (!prefixes.some(prefix => modelId.startsWith(prefix))) continue; + const patternProvider = active.find(([n]) => providerNames.some(pn => n === pn || n.startsWith(`${pn}-`))); + if (patternProvider && patternProvider[0] !== name) { + console.warn( + `⚠️ provider "${name}" model "${modelId}" is unreachable by that bare name — ` + + `a hardcoded prefix pattern routes it to provider "${patternProvider[0]}" first. ` + + `Use "${name}/${modelId}" instead to route to "${name}".`, + ); + } } } } diff --git a/tests/warn-unreachable-bare-model-aliases.test.ts b/tests/warn-unreachable-bare-model-aliases.test.ts index 2a9f774dc..93fc3dc30 100644 --- a/tests/warn-unreachable-bare-model-aliases.test.ts +++ b/tests/warn-unreachable-bare-model-aliases.test.ts @@ -68,3 +68,36 @@ test("does not warn about the native openai provider's own defaultModel", () => })); expect(warnings.length).toBe(0); }); + +test("warns when a provider's models[] entry collides with the hardcoded prefix-pattern table (claude- -> a literal \"anthropic\" provider)", () => { + const warnings = warnCapturing(configFor({ + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com" } as OcxProviderConfig, + cursor: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", models: ["claude-4-sonnet", "composer-1"] } as OcxProviderConfig, + })); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('provider "cursor"'); + expect(warnings[0]).toContain('"claude-4-sonnet"'); + expect(warnings[0]).toContain("cursor/claude-4-sonnet"); +}); + +test("does not warn about the pattern table when the pattern's own named provider lists the model itself", () => { + const warnings = warnCapturing(configFor({ + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", models: ["claude-4-sonnet"] } as OcxProviderConfig, + })); + expect(warnings.length).toBe(0); +}); + +test("does not warn about the pattern table when no provider name matches the pattern's providerNames", () => { + const warnings = warnCapturing(configFor({ + cursor: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", models: ["claude-4-sonnet"] } as OcxProviderConfig, + })); + expect(warnings.length).toBe(0); +}); + +test("does not warn about the pattern table when the colliding model is already the pattern-provider's defaultModel", () => { + const warnings = warnCapturing(configFor({ + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", defaultModel: "claude-4-sonnet" } as OcxProviderConfig, + cursor: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", models: ["claude-4-sonnet"] } as OcxProviderConfig, + })); + expect(warnings.length).toBe(0); +}); From ff0596d74a445d9f7b6c287ed88825c499f5cb43 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 12:58:37 +0200 Subject: [PATCH 07/17] fix(cli): restart/tray-start lost the Grok fence + Codex model sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repointing restart at handleTrayProxyStart() (this session's earlier codexAutoStart-no-op fix) traded one silent bug for another: that action never synced the Grok Build config fence or Codex model list at all, unlike handleEnsure()'s two branches which both did. Caught by tests/grok-lifecycle.test.ts asserting the old handleEnsure() wiring — running the full suite (not just manual ocx restart smoke tests) is what surfaced it. runTrayProxyStart() gains an optional onStarted(port) hook, fired once a live proxy is confirmed whether it was already running or this call just started it (matching handleEnsure's own live+fresh parity). handleTrayProxyStart wires it to the same syncModelsToCodex + syncGrokConfig pair handleEnsure performs, fixing both the CLI restart and the tray's own restart button (both route through this action). --- src/cli/index.ts | 15 ++++++++++++++- src/cli/tray-proxy.ts | 11 +++++++++++ tests/grok-lifecycle.test.ts | 22 +++++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index d46c0860b..91cf1a34c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -387,6 +387,7 @@ async function handleEnsure() { /** Fixed tray action: start the proxy without depending on codexAutoStart. */ async function handleTrayProxyStart(): Promise { + const config = loadConfig(); const ok = await runTrayProxyStart({ findLive: findLiveProxy, diagnoseService: () => { @@ -395,7 +396,6 @@ async function handleTrayProxyStart(): Promise { }, startService: () => serviceCommand("start"), startDirect: () => { - const config = loadConfig(); const port = (config.port ?? 10100) > 0 ? (config.port ?? 10100) : 10100; const child = spawn(process.execPath, startArgv(port), { detached: true, @@ -406,6 +406,19 @@ async function handleTrayProxyStart(): Promise { child.unref(); }, waitForProxy, + onStarted: async port => { + await syncModelsToCodex(port).catch(e => { + console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`); + }); + try { + const { syncGrokConfig } = await import("../grok/sync"); + const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); + if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); + else if (!g.ok) console.error(`⚠️ ${g.message}`); + } catch (err) { + console.error(`⚠️ ${grokSyncFailureMessage(err)}`); + } + }, info: message => console.log(message), error: message => console.error(message), }); diff --git a/src/cli/tray-proxy.ts b/src/cli/tray-proxy.ts index 00559fb45..47dad2fa7 100644 --- a/src/cli/tray-proxy.ts +++ b/src/cli/tray-proxy.ts @@ -14,6 +14,15 @@ export interface TrayProxyStartIo { waitForProxy: () => Promise; info: (message: string) => void; error: (message: string) => void; + /** + * Called once a live proxy is confirmed on `port` — whether it was already running or + * this call just started it. `handleEnsure` performs the Grok-fence and Codex-model + * sync inline in both of its branches; this action skipped both entirely (devlog 260727 + * grok-fence-parity: `ocx restart` losing them was caught by tests/grok-lifecycle.test.ts + * after restart was repointed at this action to fix the codexAutoStart no-op). Optional + * so tests that don't care about the sync can omit it. + */ + onStarted?: (port: number) => void | Promise; } /** Side-effect coordinator for the tray's fixed proxy-start action. */ @@ -21,6 +30,7 @@ export async function runTrayProxyStart(io: TrayProxyStartIo): Promise const live = await io.findLive(); if (live) { io.info(`Proxy already running on port ${live.port}.`); + await io.onStarted?.(live.port); return true; } @@ -40,6 +50,7 @@ export async function runTrayProxyStart(io: TrayProxyStartIo): Promise return false; } io.info(`Proxy running on port ${started.port}.`); + await io.onStarted?.(started.port); return true; } diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 8e24a7c2e..83794cd56 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -87,8 +87,28 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).toContain("return !stopFailed"); expect(stopFn).not.toContain("process.exit(1)"); + // handleEnsure() early-returns without relaunching when codexAutoStart is disabled, + // which silently ate `restart` too (devlog 260727 restart-ignores-codex-autostart) — + // handleTrayProxyStart() relaunches unconditionally instead. const restartCase = sliceFn(CLI_SOURCE, 'case "restart"', 'case "health"'); - expect(restartCase).toContain("if (await handleStop()) await handleEnsure()"); + expect(restartCase).toContain("if (await handleStop()) await handleTrayProxyStart()"); + }); + + test("handleTrayProxyStart still syncs the Grok fence and Codex models (devlog 260727 grok-fence-parity)", () => { + // Repointing restart at handleTrayProxyStart (above) fixed the codexAutoStart no-op but + // silently dropped the Grok-fence/model sync handleEnsure always performed — caught by + // this suite failing on the OLD assertion above once the swap landed. Guard both sides: + // the sync exists in the function, and it's wired through onStarted (fires whether the + // proxy was already live or this call just started it), not just after a fresh spawn. + const trayStartFn = sliceFn(CLI_SOURCE, "async function handleTrayProxyStart(", "async function handleTrayProxyRestart("); + expect(trayStartFn).toContain("syncModelsToCodex(port)"); + expect(trayStartFn).toContain('await import("../grok/sync")'); + expect(trayStartFn).toContain("onStarted:"); + + const trayProxySource = readFileSync(join(import.meta.dir, "..", "src", "cli", "tray-proxy.ts"), "utf8"); + const onStartedCalls = trayProxySource.match(/await io\.onStarted\?\.\(/g); + // Once for the already-live early return, once after a fresh start succeeds. + expect(onStartedCalls).toHaveLength(2); }); test("the daemon's exit cleanup keeps the OCX_SERVICE exclusion and adds the ownership check", () => { From e20c21e202456e53a69a61e20f8cc486710cad7c Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 13:03:23 +0200 Subject: [PATCH 08/17] test(claude-inbound): fix devlog 130 B3 test to match content-first cache-key priority The metadata.user_id-wins test encoded the old priority order that this session's cache-key-priority fix intentionally inverted: system/content now wins over metadata.user_id when both are present, with metadata as the fallback for requests with no system content to fingerprint. Split the old test into two cases matching the new contract and updated a stale comment in an already-passing test nearby. --- tests/claude-inbound.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index d5cb6124a..f5ff0197b 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -64,7 +64,8 @@ describe("claude inbound translation", () => { expect(body.top_k).toBeUndefined(); // documented drop expect(body.stop).toEqual(["STOP"]); expect(body.user).toBe("user-abc"); - // Stable per-session cache-affinity key derived from metadata.user_id (devlog 090) + // Stable cache-affinity key — content-derived when system content is present + // (devlog 260727 subagent-cache-audit), falling back to metadata.user_id otherwise. expect(body.prompt_cache_key).toMatch(/^[0-9a-f]{32}$/); expect(body.store).toBe(false); expect(body.stream).toBe(true); @@ -200,12 +201,26 @@ describe("claude inbound translation", () => { describe("prompt cache key provenance (devlog 130 B3)", () => { const messages = [{ role: "user", content: "hi" }]; - test("metadata.user_id wins: key derived from it, source=metadata", () => { + test("system wins over metadata.user_id when both are present, source=system (devlog 260727 subagent-cache-audit)", () => { + // Was "metadata.user_id wins" (devlog 130 B3): a session-scoped key meant every NEW + // Claude Code session got a fresh key even for byte-identical system+tools content — + // a guaranteed cold rewrite on the first call of every session. Content-first lets any + // session hit a cache any other session already warmed; metadata is now the fallback + // used only when there's no system content to fingerprint. const { body, cacheKeySource } = anthropicToResponsesTranslation({ model: "m", max_tokens: 1, messages, system: "be nice", metadata: { user_id: "user-abc" }, }); + expect(cacheKeySource).toBe("system"); + expect(body.prompt_cache_key).toMatch(/^[0-9a-f]{32}$/); + }); + + test("metadata.user_id is the fallback when there's no system content to fingerprint, source=metadata", () => { + const { body, cacheKeySource } = anthropicToResponsesTranslation({ + model: "m", max_tokens: 1, messages, + metadata: { user_id: "user-abc" }, + }); expect(cacheKeySource).toBe("metadata"); expect(body.prompt_cache_key).toMatch(/^[0-9a-f]{32}$/); }); From b001af96a0f1e1a916c71582e64b07d4581ce0f1 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 13:19:28 +0200 Subject: [PATCH 09/17] docs(router): simplify bare-model-alias warning comment --- src/router.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/router.ts b/src/router.ts index 5166e0a66..129a461ad 100644 --- a/src/router.ts +++ b/src/router.ts @@ -300,9 +300,9 @@ function isBareOpenAiFamilyModel(modelId: string): boolean { * id (gpt-, o1-, o3-, or o4- prefixed, no slash) is unreachable under that bare name * whenever an active native "openai" (Codex) provider is also configured: isBareOpenAiFamilyModel * claims any such id first in routeModelInternal, before any other provider's - * defaultModel/models match is ever consulted (devlog 260727 subagent-cache-audit - * follow-up — found via a real routing collision between a custom gateway provider - * and the DEFAULT_SUBAGENT_MODELS roster, which reuses bare gpt-5.6-* names). + * defaultModel/models match is ever consulted — found via a real routing collision + * between a custom gateway provider and a subagent roster that reused bare model + * names. * * The model is still reachable via the qualified "/" form (the * slash-namespace branch runs before the bare-family check) — this only warns that From 7b0fb67b21669c509ba94f3da190765cfacd8437 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 13:20:09 +0200 Subject: [PATCH 10/17] docs: simplify cold-cache-key-lease comments --- src/lib/cold-cache-key-lease.ts | 2 +- src/server/claude-messages.ts | 4 ++-- src/types.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/cold-cache-key-lease.ts b/src/lib/cold-cache-key-lease.ts index 3cedd4a18..9bbf2ef95 100644 --- a/src/lib/cold-cache-key-lease.ts +++ b/src/lib/cold-cache-key-lease.ts @@ -1,5 +1,5 @@ /** - * Cold prompt_cache_key lease (devlog 260727 subagent-cache-audit). + * Cold prompt_cache_key lease. * * A content-based prompt_cache_key (see claude/inbound.ts) makes every request that * shares the same resolved model + system + tools agree on the same OpenAI cache diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 05ae3ce8e..aea6756e7 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -657,8 +657,8 @@ export async function handleClaudeMessages( body: JSON.stringify(internalBody), }); - // Cold-key lease (opt-in, devlog 260727 subagent-cache-audit): a parallel Task-tool - // fan-out that dispatches several requests sharing the SAME content-based + // Cold-key lease (opt-in): a parallel Task-tool fan-out that dispatches several + // requests sharing the SAME content-based // prompt_cache_key races all of them before any has written its cache entry, so // every one misses even though they'd agree on the same key. The leader (first // arrival for this key) proceeds immediately; followers wait for the leader's diff --git a/src/types.ts b/src/types.ts index 6bd705280..2ede22d71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -430,7 +430,7 @@ export interface OcxClaudeCodeConfig { * several subagents of the same persona fired in one message) before any of them * has completed, the very first one to land at OpenAI hasn't written its cache * entry yet — every sibling that races in during that window misses too, even - * though they all agree on the same key (devlog 260727 subagent-cache-audit). + * though they all agree on the same key. * * When enabled, the first request for a not-yet-seen key proceeds immediately * (the "leader"); any sibling sharing that key while the leader is still in From ba2085b90a40a7f982c165d84dc1b84762a7e933 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 13:20:59 +0200 Subject: [PATCH 11/17] docs(claude/inbound): simplify cache-key-priority comments --- src/claude/inbound.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 1b896dced..40802c99a 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -446,13 +446,13 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode } // OpenAI-side prompt caching is routed by prompt_cache_key, not pure byte-prefix // matching (Codex clients send their session id; without any key, consecutive - // /v1/messages turns reported cached_tokens: 0 on the ChatGPT backend — devlog 090). + // /v1/messages turns reported cached_tokens: 0 on the ChatGPT backend). // - // Content-first (devlog 260727 subagent-cache-audit): fingerprint what the upstream - // actually receives — resolved model, post-translation system, and the FULL translated - // tool definitions in WIRE ORDER (sorting the hash while sending a different order - // would break the key<->prefix correspondence). canonical JSON (recursive key sort) + - // a version field so future normalization changes never mix cohorts. + // Content-first: fingerprint what the upstream actually receives — resolved model, + // post-translation system, and the FULL translated tool definitions in WIRE ORDER + // (sorting the hash while sending a different order would break the key<->prefix + // correspondence). Canonical JSON (recursive key sort) + a version field so future + // normalization changes never mix cohorts. // // Deliberately preferred over metadata.user_id (Claude Code's session uuid) even // when present: a session-scoped key means every NEW Claude Code session gets a @@ -460,12 +460,12 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode // byte-identical system+tools content — this is a guaranteed cold rewrite on the // first call of every session, not just cross-session drift. Content-based keying // lets any session hit a cache any other session already warmed. Session-only keys - // were originally chosen because pure-content keying herded many different - // sessions/models/toolsets onto one key, burning OpenAI's ~15 RPM per-key routing - // budget (audit R1#4/R2#5/R1#10) at multi-tenant scale; that risk is far smaller for - // a single-operator deployment where the actual pain is inter-session cache misses. - // Exact-prefix matching still isolates content; the key only steers routing affinity. - // Callers must NOT synthesize a session_id header from this fallback (audit 133 R2#3). + // were originally chosen because pure-content keying herds many different + // sessions/models/toolsets onto one key, burning OpenAI's per-key routing budget at + // multi-tenant scale; that risk is far smaller for a single-operator deployment + // where the actual pain is inter-session cache misses. Exact-prefix matching still + // isolates content; the key only steers routing affinity. Callers must NOT + // synthesize a session_id header from this fallback. if (systemParts.length > 0) { body.prompt_cache_key = createHash("sha256") .update(canonicalJson({ @@ -477,8 +477,8 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode .digest("hex").slice(0, 32); cacheKeySource = "system"; } else if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { - // No system content to fingerprint (H1, devlog 130): fall back to the session-scoped - // key so the request still gets SOME key rather than none. + // No system content to fingerprint: fall back to the session-scoped key so the + // request still gets SOME key rather than none. body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32); cacheKeySource = "metadata"; } From 2f3a7dd5adbb71e9010e0004cfd1fd26452bf280 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 13:21:40 +0200 Subject: [PATCH 12/17] docs: simplify restart/grok-sync-parity comments --- src/cli/tray-proxy.ts | 7 +++---- tests/grok-lifecycle.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/cli/tray-proxy.ts b/src/cli/tray-proxy.ts index 47dad2fa7..56e83440e 100644 --- a/src/cli/tray-proxy.ts +++ b/src/cli/tray-proxy.ts @@ -17,10 +17,9 @@ export interface TrayProxyStartIo { /** * Called once a live proxy is confirmed on `port` — whether it was already running or * this call just started it. `handleEnsure` performs the Grok-fence and Codex-model - * sync inline in both of its branches; this action skipped both entirely (devlog 260727 - * grok-fence-parity: `ocx restart` losing them was caught by tests/grok-lifecycle.test.ts - * after restart was repointed at this action to fix the codexAutoStart no-op). Optional - * so tests that don't care about the sync can omit it. + * sync inline in both of its branches; this action skipped both entirely, so `ocx restart` + * silently lost them once restart was repointed at this action. Optional so tests that + * don't care about the sync can omit it. */ onStarted?: (port: number) => void | Promise; } diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 83794cd56..aab8660bf 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -88,13 +88,13 @@ describe("Grok fence lifecycle wiring", () => { expect(stopFn).not.toContain("process.exit(1)"); // handleEnsure() early-returns without relaunching when codexAutoStart is disabled, - // which silently ate `restart` too (devlog 260727 restart-ignores-codex-autostart) — - // handleTrayProxyStart() relaunches unconditionally instead. + // which silently ate `restart` too — handleTrayProxyStart() relaunches + // unconditionally instead. const restartCase = sliceFn(CLI_SOURCE, 'case "restart"', 'case "health"'); expect(restartCase).toContain("if (await handleStop()) await handleTrayProxyStart()"); }); - test("handleTrayProxyStart still syncs the Grok fence and Codex models (devlog 260727 grok-fence-parity)", () => { + test("handleTrayProxyStart still syncs the Grok fence and Codex models", () => { // Repointing restart at handleTrayProxyStart (above) fixed the codexAutoStart no-op but // silently dropped the Grok-fence/model sync handleEnsure always performed — caught by // this suite failing on the OLD assertion above once the swap landed. Guard both sides: From b4f95aea9ea84bf17d9dd0fcc2365a3cc460260f Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 13:22:20 +0200 Subject: [PATCH 13/17] docs(claude-inbound): simplify cache-key test comments --- tests/claude-inbound.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index f5ff0197b..6bb8cb501 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -64,8 +64,8 @@ describe("claude inbound translation", () => { expect(body.top_k).toBeUndefined(); // documented drop expect(body.stop).toEqual(["STOP"]); expect(body.user).toBe("user-abc"); - // Stable cache-affinity key — content-derived when system content is present - // (devlog 260727 subagent-cache-audit), falling back to metadata.user_id otherwise. + // Stable cache-affinity key — content-derived when system content is present, + // falling back to metadata.user_id otherwise. expect(body.prompt_cache_key).toMatch(/^[0-9a-f]{32}$/); expect(body.store).toBe(false); expect(body.stream).toBe(true); @@ -201,9 +201,9 @@ describe("claude inbound translation", () => { describe("prompt cache key provenance (devlog 130 B3)", () => { const messages = [{ role: "user", content: "hi" }]; - test("system wins over metadata.user_id when both are present, source=system (devlog 260727 subagent-cache-audit)", () => { - // Was "metadata.user_id wins" (devlog 130 B3): a session-scoped key meant every NEW - // Claude Code session got a fresh key even for byte-identical system+tools content — + test("system wins over metadata.user_id when both are present, source=system", () => { + // Was "metadata.user_id wins": a session-scoped key meant every NEW Claude Code + // session got a fresh key even for byte-identical system+tools content — // a guaranteed cold rewrite on the first call of every session. Content-first lets any // session hit a cache any other session already warmed; metadata is now the fallback // used only when there's no system content to fingerprint. From d6370875e3089ee99788b10549ad1262e41a0e9d Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 13:41:54 +0200 Subject: [PATCH 14/17] fix(usage): generic wildcard overlay fallback for private-gateway pricing claude-opus-5 is absent from the jawcode bundle entirely, so pricing relies on explicit per-provider overlay rows (WP7). Only anthropic/ cursor/kiro had one, so any custom anthropic-adapter gateway (private proxies, company gateways) showed an em dash for claude-opus-5 in Logs. Naming each such gateway individually in this table would leak private provider names into the repo. Instead, findExpectedPriceOverlay now falls back to a "*" (any-provider) row for the same modelId when no exact-provider row exists, and one wildcard row covers claude-opus-5 for every unenumerated gateway. Exact-provider rows still take priority over the wildcard at the same status tier. --- src/usage/expected-prices.ts | 19 +++++++++++++++---- tests/usage-cost.test.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 4d62e62a1..6dcdd0f9b 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -66,6 +66,10 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ { provider: "anthropic", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: CLAUDE_OPUS_5_DERIVED_SOURCE, verifiedAt: "2026-07-25", status: "verified-derived" }, { provider: "cursor", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: CLAUDE_OPUS_5_DERIVED_SOURCE, verifiedAt: "2026-07-25", status: "verified-derived" }, { provider: "kiro", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: CLAUDE_OPUS_5_DERIVED_SOURCE, verifiedAt: "2026-07-25", status: "verified-derived" }, + // Wildcard fallback for any OTHER anthropic-adapter gateway (private/company + // proxies etc.) that exposes claude-opus-5 under its own provider name — see + // findExpectedPriceOverlay's "*" handling below. + { provider: "*", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_46, source: CLAUDE_OPUS_5_DERIVED_SOURCE, verifiedAt: "2026-07-25", status: "verified-derived" }, // MiniMax M2.1 highspeed — published PAYG price (verified). { provider: "minimax", modelId: "MiniMax-M2.1-highspeed", cost4: MINIMAX_M21_HIGHSPEED, source: MINIMAX_PRICING, verifiedAt: "2026-07-20", status: "verified" }, { provider: "minimax-cn", modelId: "MiniMax-M2.1-highspeed", cost4: MINIMAX_M21_HIGHSPEED, source: MINIMAX_PRICING, verifiedAt: "2026-07-20", status: "verified" }, @@ -129,9 +133,12 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ ]; /** - * Exact-key overlay lookup. Returns verified first, then verified-derived. - * NEVER returns "unverified" rows — fail-closed is enforced in code, not just docs. - * No fuzzy / case-fold / wire-model fallback. + * Exact-key overlay lookup, falling back to a "*" (any-provider) row for the same + * modelId when no exact provider match exists — covers private/custom gateways that + * expose a known model under a provider name this table can't enumerate in advance. + * Returns verified first, then verified-derived. NEVER returns "unverified" rows — + * fail-closed is enforced in code, not just docs. No fuzzy / case-fold / wire-model + * fallback beyond the "*" provider match. */ export function findExpectedPriceOverlay( provider: string, @@ -139,8 +146,12 @@ export function findExpectedPriceOverlay( overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, ): ExpectedPriceOverlay | undefined { const exact = overlays.filter(row => row.provider === provider && row.modelId === modelId); - return exact.find(row => row.status === "verified") + const found = exact.find(row => row.status === "verified") ?? exact.find(row => row.status === "verified-derived"); + if (found) return found; + const wildcard = overlays.filter(row => row.provider === "*" && row.modelId === modelId); + return wildcard.find(row => row.status === "verified") + ?? wildcard.find(row => row.status === "verified-derived"); } /** diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index aff35f3fe..cf4c31b61 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -124,6 +124,28 @@ describe("resolveMatchedPrice", () => { } }); + test("claude-opus-5 falls back to the wildcard row for a gateway not enumerated above", () => { + // Private/custom anthropic-adapter gateways can't be named in this table ahead of + // time, so an unrecognized provider must still price claude-opus-5 via the "*" row. + const price = resolveMatchedPrice("some-custom-anthropic-gateway", "claude-opus-5"); + expect(price).toMatchObject({ + provider: "some-custom-anthropic-gateway", + modelId: "claude-opus-5", + cost4: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + source: "expected", + status: "verified-derived", + }); + }); + + test("wildcard row never wins over an exact-provider row of the same status", () => { + const overlays: ExpectedPriceOverlay[] = [ + { provider: "*", modelId: "m", cost4: { input: 999, output: 999, cacheRead: 9, cacheWrite: 9 }, source: "wildcard", verifiedAt: "2026-07-20", status: "verified-derived" }, + { provider: "p", modelId: "m", cost4: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, source: "exact", verifiedAt: "2026-07-20", status: "verified-derived" }, + ]; + expect(findExpectedPriceOverlay("p", "m", overlays)?.source).toBe("exact"); + expect(findExpectedPriceOverlay("other-provider", "m", overlays)?.source).toBe("wildcard"); + }); + test("17. model-level fallback: kiro's claude opus follows the anthropic price", () => { const price = resolveMatchedPrice("kiro", "claude-opus-4.6"); expect(price).not.toBeNull(); @@ -212,14 +234,15 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 48 keys, including Opus 5 and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(48); + test("16. shipped overlay membership: 49 keys, including Opus 5 and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(49); expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); for (const expected of [ "anthropic/claude-opus-5", "cursor/claude-opus-5", "kiro/claude-opus-5", + "*/claude-opus-5", "minimax/MiniMax-M2.1-highspeed", "minimax-cn/MiniMax-M2.1-highspeed", "deepseek/deepseek-chat", From 3af6d4adcf5303133efe2d65f6c052e107a7c236 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 15:05:31 +0200 Subject: [PATCH 15/17] fix(adapters): extract cached_tokens from Responses API usage usageFromResponsesPayload only read input_tokens/output_tokens/total_tokens, silently dropping input_tokens_details.cached_tokens and output_tokens_details.reasoning_tokens. Cache-hit visibility for openai-responses traffic was always reported as zero regardless of actual upstream caching. Mirrors the existing openai-chat.ts extraction. --- src/adapters/openai-responses.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 766530b61..b32a5d5a8 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -660,10 +660,14 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined { const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; if (inputTokens === 0 && outputTokens === 0) return undefined; + const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined; + const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined; return { inputTokens, outputTokens, ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), + ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}), + ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}), }; } From bfda29d6dccb4fc7d90206577a524e8209b9bd1c Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Mon, 27 Jul 2026 15:06:31 +0200 Subject: [PATCH 16/17] test(router): rename ambiguous fixture provider key warn-unreachable-bare-model-aliases.test.ts used a fixture provider key that echoed an internal codename. Renamed to a neutral placeholder; behavior/assertions unchanged. --- tests/warn-unreachable-bare-model-aliases.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/warn-unreachable-bare-model-aliases.test.ts b/tests/warn-unreachable-bare-model-aliases.test.ts index 93fc3dc30..7bfa7f863 100644 --- a/tests/warn-unreachable-bare-model-aliases.test.ts +++ b/tests/warn-unreachable-bare-model-aliases.test.ts @@ -21,12 +21,12 @@ function warnCapturing(config: OcxConfig): string[] { test("warns when another active provider's defaultModel collides with a bare gpt- id, and native openai is configured", () => { const warnings = warnCapturing(configFor({ openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } as OcxProviderConfig, - phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, + acmeOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, })); expect(warnings.length).toBe(1); - expect(warnings[0]).toContain('provider "phantomOpenai"'); + expect(warnings[0]).toContain('provider "acmeOpenai"'); expect(warnings[0]).toContain('"gpt-5.6-sol"'); - expect(warnings[0]).toContain("phantomOpenai/gpt-5.6-sol"); + expect(warnings[0]).toContain("acmeOpenai/gpt-5.6-sol"); }); test("warns for every colliding entry in a provider's models[] array", () => { @@ -42,14 +42,14 @@ test("warns for every colliding entry in a provider's models[] array", () => { test("does not warn when the colliding model is already qualified with a provider prefix", () => { const warnings = warnCapturing(configFor({ openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } as OcxProviderConfig, - phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "phantomOpenai/gpt-5.6-sol" } as OcxProviderConfig, + acmeOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "acmeOpenai/gpt-5.6-sol" } as OcxProviderConfig, })); expect(warnings.length).toBe(0); }); test("does not warn when no native openai provider is configured at all", () => { const warnings = warnCapturing(configFor({ - phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, + acmeOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, })); expect(warnings.length).toBe(0); }); @@ -57,7 +57,7 @@ test("does not warn when no native openai provider is configured at all", () => test("does not warn when the native openai provider itself is disabled", () => { const warnings = warnCapturing(configFor({ openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", disabled: true } as OcxProviderConfig, - phantomOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, + acmeOpenai: { adapter: "openai-responses", baseUrl: "https://example.internal/openai", defaultModel: "gpt-5.6-sol" } as OcxProviderConfig, })); expect(warnings.length).toBe(0); }); From dc72a4a98306b54afb430863cfb852baa2361147 Mon Sep 17 00:00:00 2001 From: Jonathan Gautheron Date: Tue, 28 Jul 2026 16:11:01 +0200 Subject: [PATCH 17/17] fix: stabilize cache inputs --- .../src/content/docs/guides/claude-code.md | 7 +- src/adapters/anthropic-sort-stabilize.ts | 78 ++++++++++++++ src/claude/inbound.ts | 24 ++++- src/server/claude-messages.ts | 25 +++-- tests/claude-inbound.test.ts | 100 ++++++++++++++++-- tests/claude-messages-endpoint.test.ts | 7 +- 6 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 src/adapters/anthropic-sort-stabilize.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index fb814832e..43660a74e 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -383,9 +383,10 @@ other 5xx `api_error`. `Retry-After` is preserved. and the penultimate user message, plus top-level automatic `cache_control`. Stable turns normally produce about a 99.9% cache hit rate. -**Native OpenAI/ChatGPT routing:** derives a session-scoped `prompt_cache_key` (from -`metadata.user_id` when present, falling back to a system-content hash) and `session_id` header -for cache affinity. The cache key includes model and full tool schemas. +**Native OpenAI/ChatGPT routing:** derives a content-scoped `prompt_cache_key` from the +resolved model, normalized system content, and full tool schemas. When `metadata.user_id` is +present, it separately derives a per-session `session_id` header for backend affinity. Tool and +system-reminder listings are canonicalized before either cache input is built. **Token math:** Anthropic output subtracts `cached_tokens` and `cache_write_tokens` from `input_tokens`, exposing them as `cache_read_input_tokens` and `cache_creation_input_tokens`. diff --git a/src/adapters/anthropic-sort-stabilize.ts b/src/adapters/anthropic-sort-stabilize.ts new file mode 100644 index 000000000..5c7ba9c67 --- /dev/null +++ b/src/adapters/anthropic-sort-stabilize.ts @@ -0,0 +1,78 @@ +/** + * Deterministic ordering of Claude Code's own tool/skills/deferred-tools listings on + * the native Anthropic passthrough (`anthropicNativePassthrough`, `claude-messages.ts`). + * Claude Code enumerates MCP tools/skills in whatever order its own reconnect/discovery + * race resolves them, so byte-identical conversations can arrive with different array + * order turn to turn — busting Anthropic's prompt-cache prefix for no reason. Sorting is + * safe: `tool_choice` targets tools by name, not position, and the two system-reminder + * blocks below are pure listings with no inherent order the model depends on. + * + * Ported (algorithm only, re-implemented in TypeScript) from `sort-stabilization.mjs`, + * MIT licensed, github.com/cnighswonger/claude-code-cache-fix. + */ + +type Rec = Record; + +function isRec(v: unknown): v is Rec { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +const SKILLS_BLOCK_RE = /^([\s\S]*?\n\n)(- [\s\S]+?)(\n<\/system-reminder>\s*)$/; +const DEFERRED_TOOLS_BLOCK_RE = /^(\nThe following deferred tools are now available[^\n]*\n)([\s\S]+?)(\n<\/system-reminder>\s*)$/; + +export function isSkillsBlockText(text: unknown): text is string { + return typeof text === "string" && text.includes("User-invocable skills"); +} + +export function isDeferredToolsBlockText(text: unknown): text is string { + return typeof text === "string" && text.includes("deferred tools are now available"); +} + +export function sortSkillsBlockText(text: string): string { + const match = text.match(SKILLS_BLOCK_RE); + if (!match) return text; + const [, header, entriesText, footer] = match; + const entries = entriesText.split(/\n(?=- )/); + entries.sort(); + return header + entries.join("\n") + footer; +} + +export function sortDeferredToolsBlockText(text: string): string { + const match = text.match(DEFERRED_TOOLS_BLOCK_RE); + if (!match) return text; + const [, header, toolsList, footer] = match; + const tools = toolsList.split("\n").map(t => t.trim()).filter(Boolean); + tools.sort(); + return header + tools.join("\n") + footer; +} + +/** Normalize known system-reminder listings; leave all other instructions untouched. */ +export function normalizeSystemReminderText(text: string): string { + if (isSkillsBlockText(text)) return sortSkillsBlockText(text); + if (isDeferredToolsBlockText(text)) return sortDeferredToolsBlockText(text); + return text; +} + +/** Sort tool definitions by name; unnamed server tools sort first. */ +export function sortToolsByName(tools: unknown[]): void { + tools.sort((a, b) => { + const nameA = isRec(a) && typeof a.name === "string" ? a.name : ""; + const nameB = isRec(b) && typeof b.name === "string" ? b.name : ""; + return nameA.localeCompare(nameB); + }); +} + +/** Sort `body.system` skills/deferred-tools listings and `body.tools` by name, in place. */ +export function stabilizeSystemAndToolOrder(body: Rec): void { + if (Array.isArray(body.system)) { + const system = body.system as unknown[]; + for (let i = 0; i < system.length; i++) { + const block = system[i]; + if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue; + const normalized = normalizeSystemReminderText(block.text); + if (normalized !== block.text) system[i] = { ...block, text: normalized }; + } + } + + if (Array.isArray(body.tools)) sortToolsByName(body.tools as unknown[]); +} diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 40802c99a..2e759ab4a 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -10,6 +10,10 @@ * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity). */ import type { OcxClaudeCodeConfig } from "../types"; +import { + normalizeSystemReminderText, + sortToolsByName, +} from "../adapters/anthropic-sort-stabilize"; import { resolveAlias } from "./alias"; import { stripOneMillionMarker } from "./context-windows"; import { resolveDesktop3pAlias } from "./desktop-3p"; @@ -69,11 +73,12 @@ export function effortFromOutputConfig(outputConfig: unknown): string | undefine } function systemToInstructions(system: unknown): string | undefined { - if (typeof system === "string") return system.length > 0 ? system : undefined; + if (typeof system === "string") return system.length > 0 ? normalizeSystemReminderText(system) : undefined; if (Array.isArray(system)) { const parts: string[] = []; for (const block of system) { - if (isRec(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text); + if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue; + parts.push(normalizeSystemReminderText(block.text)); } return parts.length > 0 ? parts.join("\n\n") : undefined; } @@ -229,11 +234,13 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str * `instructions` is the only shape that works on every route. */ function systemMessageText(content: unknown): string { - if (typeof content === "string") return content; + if (typeof content === "string") return normalizeSystemReminderText(content); if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const raw of content) { - if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") parts.push(raw.text); + if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") { + parts.push(normalizeSystemReminderText(raw.text)); + } } return parts.join("\n\n"); } @@ -340,7 +347,14 @@ function toolsToResponses(tools: unknown): Rec[] | undefined { } // Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop. } - return out.length > 0 ? out : undefined; + if (out.length === 0) return undefined; + // Claude Code's MCP tool discovery races non-deterministically turn to turn, so this + // array can arrive in a different order for an otherwise-identical conversation. This + // feeds both the outgoing wire body and prompt_cache_key below, so an unstable order + // busts the cache prefix and the cache-key routing together. tool_choice targets by + // name (see toolChoiceToResponses), not position, so sorting is behavior-preserving. + sortToolsByName(out); + return out; } function toolChoiceToResponses(choice: unknown, body: Rec): void { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e760b007c..1d02def9a 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -6,6 +6,7 @@ * internal Request, so routing/OAuth/account-pool/failover/sidecars are inherited * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape. */ +import { createHash } from "node:crypto"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; @@ -104,6 +105,11 @@ function uuidFromHex(hex32: string): string { return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`; } +function claudeMetadataUserId(body: unknown): string | undefined { + if (!isRec(body) || !isRec(body.metadata)) return undefined; + return typeof body.metadata.user_id === "string" ? body.metadata.user_id : undefined; +} + function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined { if (!usage) return undefined; const num = (v: unknown) => typeof v === "number" ? v : 0; @@ -649,15 +655,16 @@ export async function handleClaudeMessages( headers.set("authorization", `Bearer ${token.accessToken}`); headers.set("chatgpt-account-id", token.chatgptAccountId); } - // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex - // clients always send their session uuid; devlog 090 follow-up: body-level - // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends - // the header, so synthesize a stable per-session uuid from the same cache key — - // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback - // key is shared across Desktop conversations, and a shared session_id's backend - // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there. - if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") { - headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); + // ChatGPT-backend prompt-cache affinity rides the session_id HEADER. Keep it + // session-scoped even when prompt_cache_key is content-first: metadata.user_id + // identifies the Claude Code session, while the content hash is shared across + // equivalent conversations and must never become a shared session identifier. + const metadataUserId = claudeMetadataUserId(anthropicBody); + if (!headers.has("session_id") && metadataUserId) { + headers.set( + "session_id", + uuidFromHex(createHash("sha256").update(metadataUserId).digest("hex").slice(0, 32)), + ); } } const internalReq = new Request("http://localhost/v1/responses", { diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index 6bb8cb501..ce9de477f 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -75,11 +75,12 @@ describe("claude inbound translation", () => { const tools = body.tools as Record[]; expect(tools).toHaveLength(2); - expect(tools[0]).toEqual({ + // Sorted by name (devlog 260727b_routed_tool_sort): unnamed web_search sorts first. + expect(tools[0]).toEqual({ type: "web_search" }); + expect(tools[1]).toEqual({ type: "function", name: "Read", description: "Read a file", parameters: { type: "object", properties: { file_path: { type: "string" } }, required: ["file_path"] }, }); - expect(tools[1]).toEqual({ type: "web_search" }); const input = body.input as Record[]; // user text, assistant text (thinking dropped), function_call, function_call_output, user tail @@ -250,7 +251,7 @@ describe("prompt cache key provenance (devlog 130 B3)", () => { } }); - test("cache cohort key: model and full tool schemas participate, wire order preserved (devlog 260712 B4)", () => { + test("cache cohort key: model and full tool schemas participate, tool order canonicalized (devlog 260712 B4, 260727b)", () => { const tool = (desc: string) => ({ name: "Read", description: desc, input_schema: { type: "object", properties: { a: { type: "string" }, b: { type: "number" } } } }); const base = { max_tokens: 1, messages, system: "be nice" }; const k = (body: Record) => anthropicToResponsesTranslation(body).body.prompt_cache_key as string; @@ -262,11 +263,15 @@ describe("prompt cache key provenance (devlog 130 B3)", () => { const orderedA = { name: "Read", description: "d", input_schema: { type: "object", properties: { a: { type: "string" }, b: { type: "number" } } } }; const orderedB = { name: "Read", input_schema: { properties: { b: { type: "number" }, a: { type: "string" } }, type: "object" }, description: "d" }; expect(k({ ...base, model: "m", tools: [orderedA] })).toBe(k({ ...base, model: "m", tools: [orderedB] })); - // different WIRE ORDER of the tool array -> different cohort (Pro review: the key - // must correspond to the actual outbound prefix, so array order participates). + // Wire order no longer participates for the SAME tool set (devlog 260727b_routed_tool_sort): + // toolsToResponses canonicalizes tool order by name before the wire body and this hash both + // see it, so any permutation of an identical tool set hashes identically — a stronger + // guarantee than "faithfully tracks whatever order arrived" (Claude Code's own MCP + // discovery race made that order turn-to-turn unstable, busting the cache prefix for no + // reason). A different tool SET still changes the cohort (covered above). const t1 = { name: "A", description: "a", input_schema: { type: "object" } }; const t2 = { name: "B", description: "b", input_schema: { type: "object" } }; - expect(k({ ...base, model: "m", tools: [t1, t2] })).not.toBe(k({ ...base, model: "m", tools: [t2, t1] })); + expect(k({ ...base, model: "m", tools: [t1, t2] })).toBe(k({ ...base, model: "m", tools: [t2, t1] })); }); test("[1m] strip works for both alias families before decode", () => { @@ -275,6 +280,89 @@ describe("prompt cache key provenance (devlog 130 B3)", () => { }); }); +describe("tool/system order stabilization (devlog 260727b_routed_tool_sort)", () => { + const messages = [{ role: "user", content: "hi" }]; + + test("toolsToResponses sorts tools by name regardless of input order", () => { + const toolB = { name: "B", description: "b", input_schema: { type: "object" } }; + const toolA = { name: "A", description: "a", input_schema: { type: "object" } }; + const body = anthropicToResponsesBody({ model: "m", max_tokens: 1, messages, tools: [toolB, toolA] }) as Record; + expect((body.tools as Record[]).map(t => t.name)).toEqual(["A", "B"]); + }); + + test("unnamed tools (e.g. web_search) sort first; ties keep relative order", () => { + const named = { name: "Read", input_schema: { type: "object" } }; + const search = { type: "web_search_20250305", name: "web_search", max_uses: 1 }; + const body = anthropicToResponsesBody({ model: "m", max_tokens: 1, messages, tools: [named, search] }) as Record; + expect((body.tools as Record[]).map(t => t.type)).toEqual(["web_search", "function"]); + }); + + test("prompt_cache_key is stable across any permutation of the same tool set", () => { + const t1 = { name: "A", input_schema: { type: "object" } }; + const t2 = { name: "B", input_schema: { type: "object" } }; + const t3 = { name: "C", input_schema: { type: "object" } }; + const base = { model: "m", max_tokens: 1, messages, system: "be nice" }; + const k = (tools: unknown[]) => anthropicToResponsesTranslation({ ...base, tools }).body.prompt_cache_key as string; + const key1 = k([t1, t2, t3]); + expect(k([t3, t1, t2])).toBe(key1); + expect(k([t2, t3, t1])).toBe(key1); + }); + + test("skills-list and deferred-tools-list system-reminder text sorted before joining into instructions", () => { + const skillsText = + "\nUser-invocable skills:\n\n" + + "- zeta: does z things\n- alpha: does a things" + + "\n"; + const deferredText = + "\nThe following deferred tools are now available via ToolSearch:\n" + + "zeta_tool\nalpha_tool" + + "\n"; + const body = anthropicToResponsesBody({ + model: "m", max_tokens: 1, messages, + system: [{ type: "text", text: skillsText }, { type: "text", text: deferredText }], + }) as Record; + const instructions = body.instructions as string; + expect(instructions.indexOf("- alpha: does a things")).toBeLessThan(instructions.indexOf("- zeta: does z things")); + expect(instructions.indexOf("alpha_tool")).toBeLessThan(instructions.indexOf("zeta_tool")); + }); + + test("normalizes reminder listings in system strings and role-system messages", () => { + const skillsText = + "\nUser-invocable skills:\n\n" + + "- zeta: does z things\n- alpha: does a things" + + "\n"; + const deferredText = + "\nThe following deferred tools are now available via ToolSearch:\n" + + "zeta_tool\nalpha_tool" + + "\n"; + const fromSystem = anthropicToResponsesTranslation({ + model: "m", max_tokens: 1, messages, system: skillsText, + }); + const fromMessage = anthropicToResponsesTranslation({ + model: "m", max_tokens: 1, + messages: [{ role: "system", content: [{ type: "text", text: skillsText }] }, ...messages], + }); + const deferred = anthropicToResponsesBody({ + model: "m", max_tokens: 1, + messages: [{ role: "system", content: deferredText }, ...messages], + }) as Record; + expect(fromSystem.body.instructions).toBe(fromMessage.body.instructions); + expect(fromSystem.body.prompt_cache_key).toBe(fromMessage.body.prompt_cache_key); + expect(String(fromMessage.body.instructions).indexOf("- alpha: does a things")) + .toBeLessThan(String(fromMessage.body.instructions).indexOf("- zeta: does z things")); + expect(String(deferred.instructions).indexOf("alpha_tool")) + .toBeLessThan(String(deferred.instructions).indexOf("zeta_tool")); + }); + + test("non-matching system text is left unchanged", () => { + const body = anthropicToResponsesBody({ + model: "m", max_tokens: 1, messages, + system: [{ type: "text", text: "You are Claude Code." }], + }) as Record; + expect(body.instructions).toBe("You are Claude Code."); + }); +}); + describe("bundled-skill elision for routed models (devlog 260712 060)", () => { const BIG = "ANTHROPIC DOC BUNDLE ".repeat(500); function requestWithSkill(skillName: string, cc?: { blockedSkills?: string[] }) { diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 326715eda..3d696392f 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -530,6 +531,7 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi body: JSON.stringify({ model: "native/gpt-test", max_tokens: 128, + system: "cache-stable instructions", messages: [{ role: "user", content: "hi" }], metadata: { user_id: "user_abc123_account__session_11111111-2222-3333-4444-555555555555" }, thinking: { type: "adaptive", display: "omitted" }, @@ -545,7 +547,10 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi expect(capture.body?.user).toBeUndefined(); expect(capture.body?.max_output_tokens).toBeUndefined(); expect(capture.body?.reasoning?.effort).toBe("high"); - expect(capture.headers?.["session_id"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/); + const metadataUserId = "user_abc123_account__session_11111111-2222-3333-4444-555555555555"; + const h = createHash("sha256").update(metadataUserId).digest("hex").slice(0, 32); + const expectedSessionId = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`; + expect(capture.headers?.["session_id"]).toBe(expectedSessionId); } finally { server.stop(true); upstream.stop(true);