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/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d7e9ef5be..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 } : {}), }; } @@ -741,7 +745,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); diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 5dbe519b4..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 { @@ -443,26 +457,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). + // + // 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 + // 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 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({ version: 2, @@ -472,6 +490,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: 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; diff --git a/src/cli/index.ts b/src/cli/index.ts index ea622b330..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), }); @@ -887,7 +900,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; } diff --git a/src/cli/tray-proxy.ts b/src/cli/tray-proxy.ts index 00559fb45..56e83440e 100644 --- a/src/cli/tray-proxy.ts +++ b/src/cli/tray-proxy.ts @@ -14,6 +14,14 @@ 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, 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; } /** Side-effect coordinator for the tray's fixed proxy-start action. */ @@ -21,6 +29,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 +49,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/src/lib/cold-cache-key-lease.ts b/src/lib/cold-cache-key-lease.ts new file mode 100644 index 000000000..9bbf2ef95 --- /dev/null +++ b/src/lib/cold-cache-key-lease.ts @@ -0,0 +1,73 @@ +/** + * 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 + * 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/router.ts b/src/router.ts index cc5774261..129a461ad 100644 --- a/src/router.ts +++ b/src/router.ts @@ -295,6 +295,79 @@ 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 — 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 + * 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) { + 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}".`, + ); + } + } + } + } + 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}".`, + ); + } + } + } + } +} + function routeResult(providerName: string, provider: OcxProviderConfig, modelId: string): RouteResult { const codexAccountMode = providerCodexAccountMode(providerName, provider); return { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index e1ea90927..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", { @@ -666,6 +673,21 @@ export async function handleClaudeMessages( body: JSON.stringify(internalBody), }); + // 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 + // 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 @@ -676,12 +698,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/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/src/types.ts b/src/types.ts index 2c5d5be09..975b98aab 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. + * + * 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/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/claude-inbound.test.ts b/tests/claude-inbound.test.ts index d5cb6124a..ce9de477f 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, + // 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); @@ -74,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 @@ -200,12 +202,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", () => { + // 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. 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}$/); }); @@ -235,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; @@ -247,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", () => { @@ -260,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); 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(); + }); +}); diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 8e24a7c2e..aab8660bf 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 — 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", () => { + // 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", () => { 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", 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..7bfa7f863 --- /dev/null +++ b/tests/warn-unreachable-bare-model-aliases.test.ts @@ -0,0 +1,103 @@ +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, + 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 "acmeOpenai"'); + expect(warnings[0]).toContain('"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", () => { + 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, + 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({ + acmeOpenai: { 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, + acmeOpenai: { 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); +}); + +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); +});