From 7cf041cf7e241b7abd73611ee476eeb4b8c29dda Mon Sep 17 00:00:00 2001 From: luvs01 Date: Tue, 25 Aug 2026 23:53:14 +0900 Subject: [PATCH 001/336] fix(claude): keep pre-output streams alive (#2528) * fix(claude): keep pre-output streams alive * test(claude): relax keepalive queue assumption --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/claude/outbound.ts | 18 +++++----- tests/claude-outbound.test.ts | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 095d332d49..6256de34dd 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -2,8 +2,9 @@ * Claude Code outbound: internal /v1/responses output -> Anthropic Messages API shapes. * * Wire contract pinned in devlog/260711_claude_inbound/003_evidence.md (all Tier 2): - * - SSE order: message_start -> (content_block_start -> deltas -> content_block_stop)* - * -> message_delta -> message_stop; any number of `ping`. + * - Transport-only `ping` events may appear at any point, including before + * message_start. Semantic framing stays message_start -> + * (content_block_start -> deltas -> content_block_stop)* -> message_delta -> message_stop. * - thinking blocks get thinking_delta(s) then ONE synthetic signature_delta just * before content_block_stop (CCR precedent: Claude Code does not verify signatures). * - message_delta.usage is cumulative; message_start embeds a full message snapshot. @@ -267,12 +268,12 @@ export function responsesSseToAnthropicSse( emit("message_start", { type: "message_start", message: messageSnapshot(model) }); emit("ping", { type: "ping" }); }; - // Once a semantic Anthropic message has started, keepalive pings protect remote - // deployments behind LB/NAT idle timeouts. Transport-only Responses prelude frames - // must not manufacture a message before a possible initial error. + // Keepalive pings protect remote deployments behind LB/NAT idle timeouts even + // before semantic output. They are transport-only and must not manufacture a + // message before a possible initial error. if (pingIntervalMs > 0) { pingTimer = setInterval(() => { - if (terminated || !started) return; + if (terminated || (controller.desiredSize ?? 0) <= 0) return; try { emit("ping", { type: "ping" }); } catch { /* controller torn down; the read loop is ending anyway */ } @@ -352,7 +353,8 @@ export function responsesSseToAnthropicSse( const type = upstreamDerived && isTransientUpstreamStatus(status) ? "overloaded_error" : undefined; if (!started) { // An initial upstream failure is an Anthropic error stream, not a partial message. - // Do not manufacture message_start/ping before the terminal error. + // Do not manufacture message_start before the terminal error. Earlier transport-only + // pings remain valid and do not turn the failure into a partial message. emit("error", anthropicErrorBody(status, message, type, code)); return; } @@ -366,7 +368,7 @@ export function responsesSseToAnthropicSse( // Transport prelude only. Start Anthropic framing on semantic output or completion. break; case "response.heartbeat": - if (started) emit("ping", { type: "ping" }); + if ((controller.desiredSize ?? 0) > 0) emit("ping", { type: "ping" }); break; case "response.output_text.delta": { if (typeof data.delta !== "string" || data.delta.length === 0) break; diff --git a/tests/claude-outbound.test.ts b/tests/claude-outbound.test.ts index f7e36a67ab..fe75bd264c 100644 --- a/tests/claude-outbound.test.ts +++ b/tests/claude-outbound.test.ts @@ -510,6 +510,18 @@ describe("claude outbound SSE", () => { expect(events.at(-1)!.data).toEqual({ type: "error", error: { type: "overloaded_error", message: "bad gateway" } }); }); + test("pre-output heartbeat stays transport-only before an initial error", async () => { + const upstream = [ + sse("response.created", { response: {} }), + sse("response.heartbeat", {}), + sse("response.failed", { response: { status: "failed", error: { status: 502, message: "bad gateway" } } }), + ].join(""); + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m", { pingIntervalMs: 0 })); + expect(events.map(event => event.name)).toEqual(["ping", "error"]); + expect(events.some(event => event.name === "message_start")).toBe(false); + expect(events.at(-1)!.data).toEqual({ type: "error", error: { type: "overloaded_error", message: "bad gateway" } }); + }); + test("failed with NO status (relaySseWithFailedTail synthetic tail) -> default 500 -> overloaded_error", async () => { const upstream = [ sse("response.created", { response: {} }), @@ -617,6 +629,58 @@ describe("claude outbound SSE", () => { expect(events.at(-1)!.name).toBe("message_stop"); }); + test("idle keepalive pings flow before the first semantic output", async () => { + const PING_INTERVAL_MS = 25; + const SILENCE_MS = 300; + const encoder = new TextEncoder(); + const upstream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(sse("response.created", { response: {} }))); + await new Promise(r => setTimeout(r, SILENCE_MS)); + controller.enqueue(encoder.encode(sse("response.output_text.delta", { delta: "x" }))); + controller.enqueue(encoder.encode(sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }))); + controller.close(); + }, + }); + const events = await collectEvents(responsesSseToAnthropicSse(upstream, "m", { pingIntervalMs: PING_INTERVAL_MS })); + const messageStartIndex = events.findIndex(event => event.name === "message_start"); + expect(messageStartIndex).toBeGreaterThanOrEqual(2); + expect(events.slice(0, messageStartIndex).every(event => event.name === "ping")).toBe(true); + expect(events.at(-1)!.name).toBe("message_stop"); + }); + + test("unread pre-output keepalives preserve budget for semantic output", async () => { + const HEARTBEAT_COUNT = 100; + const MAX_BUFFERED_PINGS = 10; + const UNREAD_MS = 100; + const encoder = new TextEncoder(); + const upstream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(sse("response.created", { response: {} }))); + for (let index = 0; index < HEARTBEAT_COUNT; index++) { + controller.enqueue(encoder.encode(sse("response.heartbeat", {}))); + } + await new Promise(r => setTimeout(r, UNREAD_MS)); + controller.enqueue(encoder.encode(sse("response.output_text.delta", { delta: "x" }))); + controller.enqueue(encoder.encode(sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }))); + controller.close(); + }, + }); + const budget = createTestTranslatorBudget({ maxTurnBytes: 2 * 1024 }); + const output = responsesSseToAnthropicSse(upstream, "m", { pingIntervalMs: 1, translatorBudget: budget }); + await new Promise(r => setTimeout(r, UNREAD_MS + 25)); + + const events = await collectEvents(output); + // The exact number admitted depends on the runtime's stream queue size. A generous + // ceiling still catches either an unguarded heartbeat burst or the 1 ms timer. + expect(events.filter(event => event.name === "ping").length).toBeLessThanOrEqual(MAX_BUFFERED_PINGS); + expect(events.some(event => event.name === "error")).toBe(false); + expect(events.at(-1)!.name).toBe("message_stop"); + const snapshot = budget.snapshot(); + expect(snapshot.overflows).toBe(0); + expect(snapshot.highWaterBytes).toBeLessThan(2 * 1024); + }); + test("no-output completed still emits a valid empty message", async () => { const upstream = sse("response.created", { response: {} }) + sse("response.completed", { response: { status: "completed" } }); const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m")); From 70eb01d19ff1c10251899ee380fe87d0107589eb Mon Sep 17 00:00:00 2001 From: luvs01 Date: Tue, 25 Aug 2026 23:53:18 +0900 Subject: [PATCH 002/336] fix(gui): allow pinning active unpinned accounts (#2555) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../components/codex-account-pool-cards.tsx | 2 +- .../codex-account-pool-main-card.tsx | 2 +- .../codex-account-pool-pinned-badge.test.tsx | 61 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index e1048b4793..ec68c538f5 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -100,7 +100,7 @@ export function CodexAccountPoolCards({ )} - {!a.paused && !isNext(a) && !showReauth && !inCooldown && ( + {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && ( diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index e9bd0328a1..70a75023d9 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -105,7 +105,7 @@ export function CodexAccountPoolMainCard({ )} - {!main?.paused && !isMainActive && !showReauth && !inCooldown && ( + {!main?.paused && (!isMainActive || pinnedId !== "__main__") && !showReauth && !inCooldown && ( diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx index e6a68d412b..9702d7aff9 100644 --- a/gui/tests/codex-account-pool-pinned-badge.test.tsx +++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx @@ -141,12 +141,17 @@ function hasPinnedHint(scope: ParentNode): boolean { return [...scope.querySelectorAll(".card-sub")].some((el) => (el.textContent ?? "").trim() === en["codexAuth.pinnedHint"]); } +function switchAction(scope: ParentNode): HTMLButtonElement | null { + return scope.querySelector("button.codex-account-switch"); +} + test("a pinned pool account says so, and only on its own card", async () => { await mountPool(makeController({ activeId: "pool-1", activePinnedId: "pool-1" })); const pooled = cardFor("pool@example.test"); expect(hasPinnedBadge(pooled)).toBe(true); expect(hasPinnedHint(pooled)).toBe(false); + expect(switchAction(pooled)).toBeNull(); const main = cardFor("main@example.test"); expect(hasPinnedBadge(main)).toBe(false); @@ -159,6 +164,7 @@ test("a pinned app login says so, and only on its own card", async () => { const main = cardFor("main@example.test"); expect(hasPinnedBadge(main)).toBe(true); expect(hasPinnedHint(main)).toBe(false); + expect(switchAction(main)).toBeNull(); const pooled = cardFor("pool@example.test"); expect(hasPinnedBadge(pooled)).toBe(false); @@ -193,6 +199,42 @@ test("an account rotation picked carries no pin", async () => { expect(hasPinnedHint(host)).toBe(false); }); +test("an active unpinned pool account keeps the manual pin action", async () => { + await mountPool(makeController({ activeId: "pool-1", activePinnedId: null })); + + const action = switchAction(cardFor("pool@example.test")); + expect(action).toBeTruthy(); + expect(action!.textContent).toContain(en["codexAuth.setAsNext"]); + + await act(async () => { action!.click(); }); + expect(host.querySelector("dialog")?.textContent).toContain("pool@example.test"); +}); + +test("an active unpinned app login keeps the manual pin action", async () => { + await mountPool(makeController({ activeId: null, activePinnedId: null })); + + const action = switchAction(cardFor("main@example.test")); + expect(action).toBeTruthy(); + expect(action!.textContent).toContain(en["codexAuth.setAsNext"]); +}); + +test("an active account that already owns the pin hides the redundant action", async () => { + await mountPool(makeController({ activeId: "pool-1", activePinnedId: "pool-1" })); + + expect(switchAction(cardFor("pool@example.test"))).toBeNull(); +}); + +test("an active account can replace a sibling's pin", async () => { + const sibling: CodexAccountEntry = { ...account, id: "pool-2", email: "sibling@example.test" }; + await mountPool(makeController({ + accounts: [mainAccount, account, sibling], + activeId: "pool-2", + activePinnedId: "pool-1", + })); + + expect(switchAction(cardFor("sibling@example.test"))).toBeTruthy(); +}); + test("a paused account is never shown as pinned", async () => { // Pausing releases the pin server-side, so a pin that still names an excluded account is // a stale read. Routing cannot be sitting on it, so the card must not claim otherwise. @@ -204,6 +246,25 @@ test("a paused account is never shown as pinned", async () => { expect(hasPinnedBadge(host)).toBe(false); expect(hasPinnedHint(host)).toBe(false); + expect(switchAction(cardFor("pool@example.test"))).toBeNull(); +}); + +test("reauth and cooldown guards still hide the pin action", async () => { + const needsReauth: CodexAccountEntry = { ...account, needsReauth: true }; + const coolingDown: CodexAccountEntry = { + ...account, + id: "pool-2", + email: "cooldown@example.test", + health: { status: "cooldown", reason: "rate_limit", until: "2099-01-01T00:00:00.000Z" }, + }; + await mountPool(makeController({ + accounts: [mainAccount, needsReauth, coolingDown], + activeId: "pool-1", + activePinnedId: null, + })); + + expect(switchAction(cardFor("pool@example.test"))).toBeNull(); + expect(switchAction(cardFor("cooldown@example.test"))).toBeNull(); }); test("healthy account cards omit log-label and 30-day usage copy", async () => { From fea4538d50e369bbe0acc5ee636fefca6a2d05e1 Mon Sep 17 00:00:00 2001 From: snowyukitty Date: Tue, 25 Aug 2026 23:58:45 +0900 Subject: [PATCH 003/336] fix(adapters): validate buffered response and ndjson frame shapes (#2532) `JSON.parse("null")` returns null without throwing, so a try/catch around a body parse cannot see it. #1240 closed that at the SSE frame root for the four SSE parsers; the buffered bodies and the NDJSON transport were never swept. - google/anthropic parseResponse: a valid-JSON non-record body reached `raw.error` and `json.content` and threw out of the adapter. A buffered body has no next frame to recover into, so both now fail closed with a structured error, matching the unparseable-body branch beside them. - anthropic parseResponse: `content` was consumed unchecked. A present non-array was silently accepted, and a string is iterable, so a claimed answer was walked one character at a time and reported as a successful empty turn. Absence (omitted or null) stays legal. - openai-chat parseResponse: `if (!choice.message)` split this input class on truthiness, not shape - null and 0 failed closed while "text", true and [{...}] passed and completed as a successful empty turn, stranding any tool call the choice claimed. Empty array stays legal, as in the google adapter. - command-code ndjson: a non-record line crashed on `event.type`. A stream frame does have a next frame, so it is skipped as padding, per #1240. Co-authored-by: snowyukitty <270071858+snowyukitty@users.noreply.github.com> --- src/adapters/anthropic.ts | 80 ++++- src/adapters/command-code.ts | 40 ++- src/adapters/google.ts | 13 +- src/adapters/openai-chat.ts | 22 +- tests/buffered-response-shape-guards.test.ts | 340 +++++++++++++++++++ 5 files changed, 489 insertions(+), 6 deletions(-) create mode 100644 tests/buffered-response-shape-guards.test.ts diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index de86753b7c..25e6467ad0 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -265,6 +265,39 @@ export function formatAnthropicErrorBody(status: number, _headers: Headers, payl return redactSecretString(detail).slice(0, 400); } +function isAnthropicRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function anthropicStructuralValueType(value: unknown): string { + if (value === null) return "null"; + return Array.isArray(value) ? "array" : typeof value; +} + +interface InvalidAnthropicShapeDiagnostic { + reason: "content_not_array" | "content_block_not_object"; + blockIndex?: number; + valueType: string; +} + +/** + * Structured refusal for a malformed buffered response body, shaped like the google adapter's + * `invalidGoogleShapeEvent` so an operator reading a log can tell which rung failed: the content + * container, or one block inside an otherwise well-formed container. + */ +function invalidAnthropicShapeEvent( + diagnostic: InvalidAnthropicShapeDiagnostic, +): Extract { + const at = diagnostic.blockIndex !== undefined ? `; blockIndex=${diagnostic.blockIndex}` : ""; + const subject = diagnostic.reason === "content_not_array" ? "content" : "content block"; + return { + type: "error", + message: `anthropic response contained invalid ${subject} (${diagnostic.reason}${at}; valueType=${diagnostic.valueType})`, + status: 502, + errorType: "upstream_error", + }; +} + function extractAnthropicErrorDetail(parsed: unknown): string | undefined { if (typeof parsed === "string") return parsed.trim() || undefined; if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; @@ -1279,12 +1312,55 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti }, async parseResponse(response: Response, budget: TranslatorBudget): Promise { - const json = await response.json() as Record; + const parsed: unknown = await response.json(); + // `response.json()` resolves a body of `null` to `null` without throwing, so the cast below + // used to reach `json.content` on it — the #1219 defect at the buffered body root. The + // streaming parser has skipped a non-record frame since #1240, but a buffered body has no + // next frame to recover into, so this fails closed instead. + if (!isAnthropicRecord(parsed)) { + return [{ + type: "error", + message: `anthropic response was not a JSON object (${anthropicStructuralValueType(parsed)})`, + status: 502, + errorType: "upstream_error", + }]; + } + const json = parsed; const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength; budget.chargeRetained(responseBytes, { kind: "retained_collectors" }); try { const events: AdapterEvent[] = []; - const content = json.content as { type: string; text?: string; id?: string; name?: string; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined; + // Same retain-then-return tail every other terminal branch in this function uses; naming it + // keeps the two shape guards below from drifting out of step with the accounting. + const finishWithEvents = (batch: AdapterEvent[]): AdapterEvent[] => { + retainTranslatedEventBatch(batch, budget); + return batch; + }; + const rawContent: unknown = json.content; + // `content` is claimed model output inside a well-formed response, so it is governed by the + // #1332 nested-shape rule (fail closed) rather than #1240's root-frame padding rule (skip). + // Absence stays legal in both encodings. A present non-array was silently accepted and is + // the dangerous case: a string is iterable, so `for (const block of "text")` walked it one + // CHARACTER at a time, read `undefined` from every `block.type`, and completed the turn as a + // successful empty response — the #2231 failure mode on a different adapter. + if (rawContent !== undefined && rawContent !== null && !Array.isArray(rawContent)) { + return finishWithEvents([invalidAnthropicShapeEvent({ + reason: "content_not_array", + valueType: anthropicStructuralValueType(rawContent), + })]); + } + if (Array.isArray(rawContent)) { + for (let blockIndex = 0; blockIndex < rawContent.length; blockIndex++) { + if (!isAnthropicRecord(rawContent[blockIndex])) { + return finishWithEvents([invalidAnthropicShapeEvent({ + reason: "content_block_not_object", + blockIndex, + valueType: anthropicStructuralValueType(rawContent[blockIndex]), + })]); + } + } + } + const content = rawContent as { type: string; text?: string; id?: string; name?: string; input?: unknown; thinking?: string; reasoning?: string; signature?: string; data?: string }[] | undefined; if (content) { for (const block of content) { if (block.type === "text" && block.text) { diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 1cd7975d83..49f8bcf2a3 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -7,6 +7,7 @@ import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, too import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import type { TranslatorBudget } from "../lib/translator-budget"; import { readBoundedResponseBody } from "../lib/bounded-body"; +import { debugDroppedFrame } from "../lib/debug"; import { configuredReasoningEfforts } from "../reasoning-effort"; import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts"; import { identifyRoutedModel } from "./identity"; @@ -365,7 +366,7 @@ async function*ndjson(response: Response, budget: TranslatorBudget): AsyncGenera let newline = buffer.indexOf("\n"); while (newline >= 0) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); - if (line) { try { yield JSON.parse(stripEventFrame(line)) as Record; } catch { /* ignore non-events */ } } + if (line) yield* decodeEventLine(line); newline = buffer.indexOf("\n"); } const residualBytes = encoder.encode(buffer).byteLength; @@ -376,7 +377,7 @@ async function*ndjson(response: Response, budget: TranslatorBudget): AsyncGenera if (done) break; } const final = buffer.trim(); - if (final) { try { yield JSON.parse(stripEventFrame(final)) as Record; } catch { /* ignore */ } } + if (final) yield* decodeEventLine(final); } finally { budget.releaseRetained(bufferBytes, { kind: "live_transient" }); try { await reader.cancel(); } catch { /* already closed */ } @@ -384,6 +385,41 @@ async function*ndjson(response: Response, budget: TranslatorBudget): AsyncGenera } } +/** + * Yield one NDJSON line as an event record, or nothing. + * + * `JSON.parse("null")` returns `null` instead of throwing, so the `try/catch` around the parse + * cannot see it and the `event.type` read in parseStream crashed the turn — the #1219 defect, on + * the one streaming transport the #1240 audit did not cover because it is NDJSON rather than SSE. + * + * A frame that does not parse to a record is padding, not an event: drop it and continue exactly + * as an unparseable line is already dropped, so a stream whose only frames are junk ends in the + * same single terminal `done` as an empty body. Skipping is what preserves an answer whose deltas + * have already arrived — the observed #1219 case is `null` padding BETWEEN content deltas, where + * terminating would discard a complete response (#1240). + * + * Note this deliberately makes a junk-only stream a quiet `[done]` where it previously threw. That + * throw was an unguarded type assumption, not a designed failure signal, and `[done]` is already + * what an empty body, a blank-line-only body and an unparseable-only body all produce here. The + * broader question — whether this adapter should report *any* no-valid-event stream as a failure + * rather than an empty success — is pre-existing, applies to all four of those inputs equally, and + * is deliberately not decided by this change. + */ +function* decodeEventLine(line: string): Generator> { + let parsed: unknown; + try { + parsed = JSON.parse(stripEventFrame(line)); + } catch { + debugDroppedFrame("command-code", line); + return; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + debugDroppedFrame("command-code", line); + return; + } + yield parsed as Record; +} + /** The endpoint is newline-delimited JSON; defensively strip an SSE `data:` frame if the gateway ever switches shapes. */ function stripEventFrame(line: string): string { return line.startsWith("data:") ? line.slice("data:".length).trim() : line; diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 92fa2a81fe..19c142a994 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1158,7 +1158,18 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let raw: Record; let rawBytes = 0; try { - raw = JSON.parse(rawText) as Record; + const parsedRaw: unknown = JSON.parse(rawText); + // `JSON.parse("null")` returns null instead of throwing, so the catch below cannot see it + // and the `raw.error` read crashed the turn — #1219 at the buffered body root, which #1240 + // never reached because that audit swept SSE frame parsers only. There is no next frame to + // recover into here, so unlike a stream frame this fails closed, matching the + // unparseable-body branch just below and the buffered candidate guards added in #2232. + if (!isGoogleRecord(parsedRaw)) { + budget.releaseRetained(rawTextBytes, { kind: "retained_collectors" }); + const valueType = googleStructuralValueType(parsedRaw); + return [{ type: "error", message: `google response was not a JSON object (${valueType})` }]; + } + raw = parsedRaw; rawBytes = new TextEncoder().encode(JSON.stringify(raw)).byteLength; const rawReservation = budget.reserveTransient(rawBytes, { kind: "retained_collectors" }); rawReservation.commitRetained(); diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7ad61f1b4d..c877a1b357 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1932,8 +1932,28 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const choice = rawChoice; if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)]; if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }]; + // `!choice.message` splits this input class on TRUTHINESS, not on shape: `null` and `0` fail + // closed here, while `"text"`, `true` and `[{...}]` pass and every property read below yields + // `undefined` — so a choice claiming an assistant message completed as a SUCCESSFUL EMPTY + // turn, stranding any tool call it claimed. The one line above already validates the choice + // container this way; its message was left on a truthiness test. + // + // Every non-record is rejected, arrays included. The google adapter does carve out `[]` for + // `content`, but that carve-out is specific to a protobuf-derived wire where a repeated + // field can spell an empty message — and `content` is genuinely an ARRAY of blocks there. + // `message` is a record on a plain-JSON wire that already has `{}`, so importing the + // exception would be an analogy rather than evidence. `[{"content":"…"}]` is the case that + // matters: it discards a complete answer, #2232's `content: [{ parts: [...] }]` one adapter over. + // + // Read through `unknown` rather than the declared type: `choices` is a cast over wire data, + // so its `message?: Record` is an assertion the upstream never made, and + // narrowing against it is what let the missing check look type-safe. + const rawMessage: unknown = choice.message; + if (!isRecord(rawMessage)) { + return [invalidChoicesEvent(usage)]; + } - const msg = choice.message; + const msg = rawMessage as Record; const reasoningText = reasoningTextFrom(msg); if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText }); if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content }); diff --git a/tests/buffered-response-shape-guards.test.ts b/tests/buffered-response-shape-guards.test.ts new file mode 100644 index 0000000000..ba0f309451 --- /dev/null +++ b/tests/buffered-response-shape-guards.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter } from "../src/adapters/anthropic"; +import { createCommandCodeAdapter } from "../src/adapters/command-code"; +import { createGoogleAdapter } from "../src/adapters/google"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import { createTranslatorBudget, translatorObservedBufferSnapshot } from "../src/lib/translator-budget"; +import type { AdapterEvent, OcxProviderConfig } from "../src/types"; + +/** + * `JSON.parse("null")` returns `null` without throwing, so a `try/catch` around a body parse + * cannot see it. #1240 closed that at the SSE *frame* root for the four SSE parsers. Two rungs + * were never swept, because neither is an SSE frame parser: + * + * - the BUFFERED body root, on the `parseResponse` path reached from + * `src/server/responses/core.ts` for non-streaming turns; + * - the NDJSON frame root in the Command Code transport. + * + * And inside a well-formed anthropic body, `content` was consumed unchecked, so the #1332/#2232 + * nested-shape ladder was open there too. + * + * Every assertion below is written as PARITY against a control that was already handled correctly + * — an unparseable body, or the same field on a sibling adapter — so the test states the actual + * requirement rather than re-encoding each adapter's exact wording and drifting when it changes. + */ + +// Valid JSON that does not deserialize to a record. Only `null` ever threw; property access on a +// number, string, boolean or array is legal JS, so those were silently accepted as empty bodies. +// Both are wrong for the same reason, so they are asserted together. +const NON_RECORD_BODIES = ["null", "42", '"text"', "true", "[]", "[null]"] as const; + +// The syntactically-invalid control, correct before this change on every adapter here. +const INVALID_JSON_BODY = "{not json}"; + +const googleProvider = { adapter: "google", baseUrl: "https://example.test/v1", apiKey: "k", authMode: "key" } as OcxProviderConfig; +const anthropicProvider = { adapter: "anthropic", baseUrl: "https://example.test/v1", apiKey: "k", authMode: "key" } as OcxProviderConfig; +const commandCodeProvider: OcxProviderConfig = { adapter: "command-code", baseUrl: "https://api.command.example", apiKey: "k" }; +const openAIChatProvider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "k", authMode: "key" } as OcxProviderConfig; + +function jsonResponse(body: string): Response { + return new Response(body, { status: 200, headers: { "content-type": "application/json" } }); +} + +function ndjsonResponse(lines: string[]): Response { + return new Response(lines.map(l => `${l}\n`).join(""), { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); +} + +function errorEvent(events: AdapterEvent[]): Extract | undefined { + return events.find(e => e.type === "error") as Extract | undefined; +} + +function text(events: AdapterEvent[]): string { + return events.flatMap(e => (e.type === "text_delta" ? [e.text] : [])).join(""); +} + +describe("google parseResponse: buffered body root", () => { + const parse = (body: string) => + createGoogleAdapter(googleProvider).parseResponse!(jsonResponse(body), createTestTranslatorBudget()); + + test("an unparseable body reports a structured error (control)", async () => { + const events = await parse(INVALID_JSON_BODY); + expect(errorEvent(events)).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test.each(NON_RECORD_BODIES)("a valid-JSON non-record body (%s) is treated like the control", async body => { + // Before the guard, `null` threw `null is not an object (evaluating 'raw.error')` out of + // parseResponse; the others reached `json.candidates` as `undefined` and reported "no + // candidates". Both now fail closed the same way an unparseable body does. + const events = await parse(body); + expect(errorEvent(events)).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("the value type is named, so a log distinguishes the shapes", async () => { + expect(errorEvent(await parse("null"))?.message).toContain("null"); + expect(errorEvent(await parse("[]"))?.message).toContain("array"); + }); + + test("a healthy body is unaffected", async () => { + const events = await parse(JSON.stringify({ + candidates: [{ content: { parts: [{ text: "PONG" }] }, finishReason: "STOP" }], + })); + expect(text(events)).toBe("PONG"); + expect(events.some(e => e.type === "done")).toBe(true); + }); +}); + +describe("anthropic parseResponse: buffered body root", () => { + const parse = (body: string) => + createAnthropicAdapter(anthropicProvider).parseResponse!(jsonResponse(body), createTestTranslatorBudget()); + + test.each(NON_RECORD_BODIES)("a valid-JSON non-record body (%s) fails closed", async body => { + // `response.json()` resolves a body of `null` to `null`, and the cast reached `json.content` + // on it. The rest were accepted as an empty successful turn. + const events = await parse(body); + expect(errorEvent(events)).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("a healthy body is unaffected", async () => { + const events = await parse(JSON.stringify({ + type: "message", + role: "assistant", + content: [{ type: "text", text: "PONG" }], + stop_reason: "end_turn", + usage: { input_tokens: 2, output_tokens: 3 }, + })); + expect(text(events)).toBe("PONG"); + expect(events.some(e => e.type === "done")).toBe(true); + }); +}); + +describe("anthropic parseResponse: content container and blocks", () => { + const parseContent = (contentLiteral: string) => + createAnthropicAdapter(anthropicProvider).parseResponse!( + jsonResponse(`{"type":"message","role":"assistant","content":${contentLiteral},"stop_reason":"end_turn"}`), + createTestTranslatorBudget(), + ); + + // Absence is a legitimate contract and both encodings of it must keep working. Tightening the + // container must not delete this, exactly as the google guards left `parts: null` alone. + test.each(["undefined-omitted", "null", "[]"])("absence encoding %s still completes the turn", async encoding => { + const events = encoding === "undefined-omitted" + ? await createAnthropicAdapter(anthropicProvider).parseResponse!( + jsonResponse('{"type":"message","role":"assistant","stop_reason":"end_turn"}'), + createTestTranslatorBudget(), + ) + : await parseContent(encoding); + expect(errorEvent(events)).toBeUndefined(); + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test.each(['"a claimed answer"', "42", "true", '{"type":"text","text":"x"}'])( + "a present non-array content (%s) fails closed", + async literal => { + // The string case is the dangerous one and the reason this is not merely tidiness: a string + // is iterable, so `for (const block of "a claimed answer")` walked it one CHARACTER at a + // time, read `undefined` from every `block.type`, emitted nothing, and reported a clean + // `done` — a successful EMPTY turn for a response that claimed content. Same failure mode as + // #2231's `parts: "txt"`. + const events = await parseContent(literal); + const error = errorEvent(events); + expect(error).toBeDefined(); + expect(error?.message).toContain("content_not_array"); + expect(events.some(e => e.type === "done")).toBe(false); + }, + ); + + test("a non-record block inside a well-formed array fails closed and names its index", async () => { + const events = await parseContent('[{"type":"text","text":"first"},null]'); + const error = errorEvent(events); + expect(error).toBeDefined(); + expect(error?.message).toContain("content_block_not_object"); + expect(error?.message).toContain("blockIndex=1"); + // Fail closed BEFORE emitting: half a claimed answer plus a silent stop is worse than a + // reported failure, and matches how #1332 handles a malformed nested payload. + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("a well-formed content array is unaffected", async () => { + const events = await parseContent('[{"type":"text","text":"PONG"},{"type":"tool_use","id":"toolu_1","name":"get","input":{}}]'); + expect(errorEvent(events)).toBeUndefined(); + expect(text(events)).toBe("PONG"); + expect(events.some(e => e.type === "tool_call_start")).toBe(true); + }); +}); + +describe("command-code ndjson: non-record frame", () => { + async function collect(lines: string[]): Promise { + const events: AdapterEvent[] = []; + const adapter = createCommandCodeAdapter(commandCodeProvider); + for await (const e of adapter.parseStream(ndjsonResponse(lines), createTestTranslatorBudget())) events.push(e); + return events; + } + + const HEALTHY_HEAD = ['{"type":"text-delta","text":"P"}', '{"type":"text-delta","text":"ONG"}']; + const HEALTHY_TAIL = ['{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":1,"outputTokens":2}}']; + + test.each(["null", "42", '"text"', "true", "[]"])( + "a mid-stream non-record line (%s) is skipped, not fatal", + async line => { + // Unlike a buffered body, a stream frame has a next frame to recover into. #1240 established + // that a non-record frame is padding: terminating on it throws away an answer that has + // already fully arrived. `null` used to throw `null is not an object ('event.type')`. + const events = await collect([...HEALTHY_HEAD, line, ...HEALTHY_TAIL]); + expect(text(events)).toBe("PONG"); + expect(events.some(e => e.type === "done")).toBe(true); + expect(errorEvent(events)).toBeUndefined(); + }, + ); + + test("an unparseable line is skipped the same way (control)", async () => { + const events = await collect([...HEALTHY_HEAD, INVALID_JSON_BODY, ...HEALTHY_TAIL]); + expect(text(events)).toBe("PONG"); + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("a non-record line in the RESIDUAL buffer is skipped", async () => { + // The trailing-buffer branch (`const final = buffer.trim()`) parses separately from the newline + // loop and shared the same defect, so it needs its own case. + // + // `ndjsonResponse` cannot reach it: it appends a newline to EVERY line, so the newline loop + // drains the buffer and `final` is always empty. An earlier version of this test used that + // helper and asserted the same outcome — it passed for the wrong reason, because the residual + // branch never ran. The body below deliberately omits the trailing newline, and places no + // finish event before the residual frame, so the malformed line is reachable only via `final`. + const body = new Response(`${HEALTHY_HEAD.join("\n")}\nnull`, { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); + const events: AdapterEvent[] = []; + const adapter = createCommandCodeAdapter(commandCodeProvider); + for await (const e of adapter.parseStream(body, createTestTranslatorBudget())) events.push(e); + + expect(text(events)).toBe("PONG"); + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("a junk-only stream matches EVERY other junk-only control", async () => { + // NOT the #1240 rule, deliberately. The SSE adapters fail closed on an all-padding stream; + // this adapter ends every finish-less stream with a terminal `done` on purpose ("so the server + // does not wait on an adapter that silently stopped emitting"), and an EMPTY body, a + // blank-line-only body and an unparseable-only body all already do exactly that on unmodified + // `dev`. So the requirement here is parity with those controls, not termination — asserting + // otherwise would be demanding a behavior change this diff has no mandate to make. + // The claim being pinned is that non-record-only joins an EXISTING class rather than forming a + // new one, so all four members are compared, not just the nearest. + const nonRecordOnly = await collect(["null", "42", "[]"]); + for (const control of [[INVALID_JSON_BODY], [""], []]) { + expect(nonRecordOnly.map(e => e.type)).toEqual((await collect(control)).map(e => e.type)); + } + expect(errorEvent(nonRecordOnly)).toBeUndefined(); + }); +}); + +describe("openai-chat parseResponse: the claimed assistant message", () => { + const parseMessage = (messageLiteral: string) => + createOpenAIChatAdapter(openAIChatProvider).parseResponse!( + jsonResponse(`{"choices":[{"message":${messageLiteral},"finish_reason":"stop","index":0}],"usage":{"prompt_tokens":1,"completion_tokens":2}}`), + createTestTranslatorBudget(), + ); + + // The pre-existing `if (!choice.message)` split this input class on TRUTHINESS rather than shape. + // These two were already refused and must stay refused — they are the control the rest is parity + // against, and they are why the gap was invisible: the guard looked present because it fired on + // the two shapes anyone would try first. + test.each(["null", "0"])("a falsy non-record message (%s) still fails closed", async literal => { + const events = await parseMessage(literal); + expect(errorEvent(events)).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test.each(['"ANSWER"', "true", "[null]", "[]"])( + "a truthy non-record message (%s) now fails closed too", + async literal => { + // Before: every property read yielded `undefined`, so the turn completed as a SUCCESSFUL + // EMPTY response for a choice that claimed an assistant message — and any tool call it + // claimed was silently stranded. + const events = await parseMessage(literal); + expect(errorEvent(events)).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + }, + ); + + test("a non-empty array message does not discard the answer inside it", async () => { + // #2232's `content: [{ parts: [...] }]` shape, one adapter over: a complete answer sits in the + // payload, `message.content` reads `undefined` off the array, and the turn reported success. + const events = await parseMessage('[{"role":"assistant","content":"ANSWER"}]'); + expect(errorEvent(events)).toBeDefined(); + expect(text(events)).toBe(""); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("an empty array message fails closed too - the google carve-out does not transfer", async () => { + // google DOES accept `content: []`, but that carve-out is specific to a protobuf-derived wire + // where a repeated field can spell an empty message, and `content` is genuinely an array of + // blocks there. `message` is a record on a plain-JSON wire that already has `{}`. Importing the + // exception here would be reasoning from analogy rather than from this wire's contract. + const events = await parseMessage("[]"); + expect(errorEvent(events)).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("an empty record message stays legal", async () => { + const events = await parseMessage("{}"); + expect(errorEvent(events)).toBeUndefined(); + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("a well-formed message still delivers its text and tool call", async () => { + const events = await parseMessage('{"role":"assistant","content":"ANSWER","tool_calls":[{"id":"c1","type":"function","function":{"name":"get","arguments":"{}"}}]}'); + expect(errorEvent(events)).toBeUndefined(); + expect(text(events)).toBe("ANSWER"); + expect(events.some(e => e.type === "tool_call_start")).toBe(true); + expect(events.some(e => e.type === "done")).toBe(true); + }); +}); + + +describe("a guard that fails closed still releases the response-body reservation", () => { + // These guards return early from inside the region between `chargeRetained(responseBytes)` and + // the `finally` that releases it, so a return placed on the wrong side of that boundary would + // strand the whole body in the translator budget on every malformed response — a slow leak that + // no events-only assertion can see. + // + // Asserting "bytes return to baseline after dispose" would NOT catch it: `dispose()` force-clears + // whatever the budget still holds (`aggregateCurrentBytes -= this.currentBytes`), so that check is + // vacuously true. The measurement has to happen BEFORE dispose, and the body has to be large + // enough that a leaked reservation is unmistakable next to the tiny error event legitimately + // retained on the way out. + const PADDING = "x".repeat(50_000); + + const cases: [string, OcxProviderConfig, (p: OcxProviderConfig) => { parseResponse?: unknown }, string][] = [ + ["anthropic content non-array", anthropicProvider, createAnthropicAdapter as never, + `{"content":"${PADDING}","stop_reason":"end_turn"}`], + ["anthropic block non-record", anthropicProvider, createAnthropicAdapter as never, + `{"content":[{"type":"text","text":"${PADDING}"},null],"stop_reason":"end_turn"}`], + ["openai-chat message non-record", openAIChatProvider, createOpenAIChatAdapter as never, + `{"choices":[{"message":"${PADDING}","finish_reason":"stop"}]}`], + ]; + + test.each(cases)("%s does not strand the body", async (_label, provider, make, body) => { + const budget = createTranslatorBudget(); + const before = translatorObservedBufferSnapshot().currentBytes; + const adapter = (make as (p: OcxProviderConfig) => { + parseResponse: (r: Response, b: unknown) => Promise; + })(provider); + const events = await adapter.parseResponse(jsonResponse(body), budget); + const heldBeforeDispose = translatorObservedBufferSnapshot().currentBytes - before; + budget.dispose(); + + expect(errorEvent(events)).toBeDefined(); + // Only the returned error event may still be held. The 50KB body must already be released. + expect(heldBeforeDispose).toBeLessThan(2_000); + }); +}); From 6c4556cfbf360ef63573294e0db947103ff7573a Mon Sep 17 00:00:00 2001 From: luvs01 Date: Tue, 25 Aug 2026 23:58:51 +0900 Subject: [PATCH 004/336] fix(codex): preview Pool account per fallback candidate (#2515) * fix(codex): preview Pool account per fallback candidate * fix(codex): preserve fallback preview after recovery --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/codex/routing.ts | 9 + src/codex/subagent-model-fallback.ts | 54 ++++- src/server/responses/core.ts | 55 +++-- ...subagent-fallback-handle-responses.test.ts | 207 ++++++++++++++++++ tests/subagent-model-fallback.test.ts | 109 +++++++++ 5 files changed, 408 insertions(+), 26 deletions(-) diff --git a/src/codex/routing.ts b/src/codex/routing.ts index fa6fa63d83..10160fe913 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -612,6 +612,15 @@ export function tryAcquireCodexQuotaScopeProbeLease( return probeLeaseId; } +/** Side-effect-free check for a confirmed model-specific quota probe. */ +export function canAcquireCodexQuotaScopeProbeLease( + accountId: string, + scope: CodexQuotaScope, + now = Date.now(), +): boolean { + return canAcquireQuotaProbeLease(scopedHealthFor(accountId, scope), now); +} + /** * Hand a probe lease back without recording an upstream outcome. Used by paths * that take a lease and then fail before any request reaches upstream. diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index da40ebffff..40c472bb3c 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -15,12 +15,12 @@ import { CODEX_HOME, getCodexHome } from "./paths"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { canAcquireCodexQuotaProbeLease, + canAcquireCodexQuotaScopeProbeLease, codexQuotaScopeForModel, computeCodexUsageScore, getCodexQuotaHealthSnapshot, getEffectiveActiveCodexAccountId, getPoolAccountPlan, - isCodexAccountInCooldown, } from "./routing"; import { isCodexAccountUsable, @@ -48,6 +48,11 @@ export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000; const CODEX_FORWARD_ORIGIN = new URL(CODEX_FORWARD_BASE_URL).origin.toLowerCase(); type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise; +/** Side-effect-free Pool account preview for one resolved fallback candidate. */ +export type SubagentPoolAccountPreview = ( + modelId: string | undefined, + now: number, +) => string | null; let subagentQuotaPrimeForTests: SubagentQuotaPrimeFn | null = null; let quotaPrimeInFlight: Promise | null = null; @@ -177,8 +182,14 @@ function resolveRouteFallbackAccountId( route: RouteResult | null, config: OcxConfig, accountId?: string | null, + now = Date.now(), + poolAccountPreview?: SubagentPoolAccountPreview, ): string | null { - return route?.codexAccountId ?? resolvePoolFallbackAccountId(config, accountId); + if (route?.codexAccountId !== undefined) return route.codexAccountId; + if (route && isPoolCodexRoute(route) && poolAccountPreview) { + return poolAccountPreview(route.modelId, now); + } + return resolvePoolFallbackAccountId(config, accountId); } function isRoutableFallbackModel(model: string, config: OcxConfig): boolean { @@ -237,17 +248,24 @@ export function isSubagentModelUnavailable( accountId?: string | null, now = Date.now(), accountUsabilityOptions?: CodexAccountUsabilityOptions, + poolAccountPreview?: SubagentPoolAccountPreview, ): boolean { if (isDisabledFallbackModel(model, config)) return true; if (!isRoutableFallbackModel(model, config)) return true; const route = tryRouteFallbackModel(config, model); if (!route || route.provider.disabled === true) return true; - if (isModelHealthBlocked(model, config, accountId, now)) return true; + const resolvedAccountId = resolveRouteFallbackAccountId( + route, + config, + accountId, + now, + poolAccountPreview, + ); + if (isModelHealthBlocked(model, config, resolvedAccountId, now)) return true; if (!isPoolCodexRoute(route)) return false; // Pool candidates need a usable account. Derive requirement from the resolved // route (canonical openai defaults to pool even when codexAccountMode is omitted). - const resolvedAccountId = resolveRouteFallbackAccountId(route, config, accountId); if (!resolvedAccountId) return true; if (isCodexAccountPaused(config, resolvedAccountId)) return true; if (!isCodexAccountUsable(config, resolvedAccountId, accountUsabilityOptions)) return true; @@ -257,13 +275,17 @@ export function isSubagentModelUnavailable( // advances instead of selecting a candidate that exact auth will reject. const quotaScope = codexQuotaScopeForModel(route.modelId); if (getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now) !== null) return true; - } else if ( - isCodexAccountInCooldown(resolvedAccountId, now) - && !canAcquireCodexQuotaProbeLease(resolvedAccountId, now) - ) { - return true; + } else { + const quotaScope = codexQuotaScopeForModel(route.modelId); + const cooldown = getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now); + if (cooldown !== null) { + const probeAvailable = cooldown.quotaScope + ? canAcquireCodexQuotaScopeProbeLease(resolvedAccountId, cooldown.quotaScope, now) + : canAcquireCodexQuotaProbeLease(resolvedAccountId, now); + if (!probeAvailable) return true; + } } - return isNativeModelQuotaExhausted(model, config, accountId, now); + return isNativeModelQuotaExhausted(model, config, resolvedAccountId, now); } export function selectAvailableSubagentModel( @@ -275,6 +297,7 @@ export function selectAvailableSubagentModel( nativeFallbackOnly = false, accountUsabilityOptions?: CodexAccountUsabilityOptions, trailingFallback: readonly string[] = [], + poolAccountPreview?: SubagentPoolAccountPreview, ): { model: string; rewritten: boolean; skipped: string[] } { const chain = normalizedChain(primary, config, extraFallback, trailingFallback); const skipped: string[] = []; @@ -286,7 +309,14 @@ export function selectAvailableSubagentModel( continue; } } - if (isSubagentModelUnavailable(candidate, config, accountId, now, accountUsabilityOptions)) { + if (isSubagentModelUnavailable( + candidate, + config, + accountId, + now, + accountUsabilityOptions, + poolAccountPreview, + )) { skipped.push(candidate); continue; } @@ -515,6 +545,7 @@ export function applySubagentModelFallback( now = Date.now(), nativeFallbackOnly = false, accountUsabilityOptions?: CodexAccountUsabilityOptions, + poolAccountPreview?: SubagentPoolAccountPreview, ): { from?: string; to?: string; skipped?: string[] } | null { if (!isThreadSpawnRequest(headers)) return null; const tomlRoleFallback = resolveAgentModelFallbackForPrimary( @@ -536,6 +567,7 @@ export function applySubagentModelFallback( nativeFallbackOnly, accountUsabilityOptions, tomlRoleFallback, + poolAccountPreview, ); if (!selection.rewritten) return selection.skipped.length > 0 ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3aed07ef0f..6557aa7094 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -203,6 +203,7 @@ import { applySubagentModelFallback, maybePrimeSubagentQuota, recordSubagentQuotaFailureForThreadSpawn, + type SubagentPoolAccountPreview, } from "../../codex/subagent-model-fallback"; import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup"; import { @@ -2366,7 +2367,7 @@ async function handleResponsesInner( }; let selectedForwardHeaders = req.headers; let subagentFallbackAccountId = config.activeCodexAccountId ?? null; - let subagentFallbackPreviewAccountId: string | null | undefined; + let subagentFallbackAccountPreview: SubagentPoolAccountPreview | undefined; let subagentQuotaFailureModel = parsed.modelId; const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null; const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null; @@ -2389,23 +2390,25 @@ async function handleResponsesInner( // so the preview must read the same scope slot — an undefined scope would map to the // "legacy" affinity bucket and never find a binding made under "shared" or a native // model scope, making the preview diverge from the account that actually authenticates. - const previewAccountId = previewCodexAccountForRequest( + const fallbackNow = Date.now(); + subagentFallbackAccountPreview = (modelId, previewNow) => previewCodexAccountForRequest( poolAffinityKey, config, - Date.now(), - codexQuotaScopeForModel(route.modelId), + previewNow, + codexQuotaScopeForModel(modelId), previewSelectionOptions, ); - subagentFallbackPreviewAccountId = previewAccountId; + const previewAccountId = subagentFallbackAccountPreview(route.modelId, fallbackNow); subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null; const fallback = applySubagentModelFallback( parsed, req.headers, config, previewAccountId, - Date.now(), + fallbackNow, unreadableEncryptedAgentTask, previewSelectionOptions, + subagentFallbackAccountPreview, ); if (fallback) { (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; @@ -2488,15 +2491,37 @@ async function handleResponsesInner( // The ciphertext-only pass intentionally excludes routed candidates. Once recovery // makes the assignment readable, run selection again with the full configured chain // and keep the route in sync with any newly selected fallback. - const fallback = applySubagentModelFallback( - parsed, - req.headers, - config, - subagentFallbackPreviewAccountId, - Date.now(), - false, - previewSelectionOptions, - ); + const recoverySelectionAdmission = codexAccountSelectionForTurn(options.turnAdmissionLease)?.(); + const fallback = (() => { + try { + const recoveryNativeMainBlocked = isNativeMainTrafficBlocked(); + const recoverySelectionOptions = { + nativeMainSelectionOnly: !recoveryNativeMainBlocked + && recoverySelectionAdmission?.mainProfileDraining === true, + }; + const recoveryNow = Date.now(); + subagentFallbackAccountPreview = (modelId, previewNow) => previewCodexAccountForRequest( + poolAffinityKey, + config, + previewNow, + codexQuotaScopeForModel(modelId), + recoverySelectionOptions, + ); + const recoveryPreviewAccountId = subagentFallbackAccountPreview(parsed.modelId, recoveryNow); + return applySubagentModelFallback( + parsed, + req.headers, + config, + recoveryPreviewAccountId, + recoveryNow, + false, + recoverySelectionOptions, + subagentFallbackAccountPreview, + ); + } finally { + recoverySelectionAdmission?.release(); + } + })(); if (fallback) { (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; (logCtx as unknown as Record).subagentModelFallbackTo = fallback.to; diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 937b0aadeb..383637f2f8 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -23,16 +23,24 @@ import { resolveCodexAccountForThreadDetailed, } from "../src/codex/routing"; import { + DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS, isModelHealthBlocked, resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; +import { resetAgentTaskRecoveryState } from "../src/server/responses/agent-task-recovery"; import { isEagerRelaySseResponse } from "../src/server/relay"; +import type { ActiveTurnLease } from "../src/server/lifecycle"; import type { OcxConfig } from "../src/types"; import type { RequestLogContext } from "../src/server/request-log"; import type { ResponsesTerminalStatus } from "../src/bridge"; +import { + codexHeaders, + encryptedInput as recoverableEncryptedInput, + recoverySse, +} from "./helpers/agent-task-recovery"; setDefaultTimeout(30_000); @@ -51,6 +59,7 @@ beforeEach(() => { clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); + resetAgentTaskRecoveryState(); resetSubagentModelFallbackStateForTests(); }); @@ -60,6 +69,7 @@ afterEach(() => { clearThreadAccountMap(); clearCodexUpstreamHealth(); clearAccountQuota(); + resetAgentTaskRecoveryState(); resetSubagentModelFallbackStateForTests(); rmSync(testDir, { recursive: true, force: true }); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -775,6 +785,203 @@ describe("native fallback account preview", () => { expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-a" }); }); + test("fallback previews the Pool account separately for each candidate quota scope", async () => { + const cooldownAt = 1_800_000_000_000; + const now = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS + 1; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + subagentModelFallback: ["gpt-5.3-codex-spark", "xai/grok-4.5"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const desktopHeaders = { + "session-id": "candidate-scope-session-private", + "thread-id": "candidate-scope-thread-private", + }; + const bound = await resolveCodexAuthContext(new Headers(desktopHeaders), cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(bound).toMatchObject({ kind: "pool", accountId: "pool-a" }); + if (bound.kind !== "pool") throw new Error("expected pool context"); + cfg.activeCodexAccountId = "pool-b"; + + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + modelId: "gpt-5.3-codex-spark", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + recordCodexUpstreamOutcome(cfg, "pool-b", 429, { + modelId: "gpt-5.3-codex-spark", + now: cooldownAt, + resetAt: Math.floor((cooldownAt + 60 * 60_000) / 1_000), + }); + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("gpt-5.6-sol", "429", cfg, "pool-a", now); + + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, "shared")).toBe("pool-a"); + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, "spark")).toBe("pool-b"); + + let finalAuth: CodexAuthContext | undefined; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; } }, + { model: "", provider: "" }, + desktopHeaders, + ); + + expect(response.status).toBe(200); + expect(finalAuth).toMatchObject({ + kind: "pool", + accountId: "pool-b", + probeQuotaScope: "spark", + }); + expect((finalAuth as { probeLeaseId?: string }).probeLeaseId).toBeTruthy(); + expect(capture.urls.some((url) => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); + expect(capture.auths.some((auth) => auth?.includes("pool-b_token"))).toBe(true); + expect(capture.bodies.some((body) => body.includes('"model":"gpt-5.3-codex-spark"'))).toBe(true); + }); + + test("recovery re-previews the Pool account for the candidate quota scope", async () => { + const now = 1_800_000_000_000; + let currentNow = now; + Date.now = () => currentNow; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + defaultProvider: "xai", + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + agentTaskRecovery: { enabled: true }, + subagentModelFallback: ["gpt-5.3-codex-spark"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const requestHeaders = codexHeaders("caller-account", { + "session-id": "recovery-candidate-scope-session-private", + "thread-id": "recovery-candidate-scope-thread-private", + }); + const bound = await resolveCodexAuthContext(requestHeaders, cfg, "pool", { + modelId: "gpt-5.6-sol", + }); + expect(bound).toMatchObject({ kind: "pool", accountId: "pool-a" }); + if (bound.kind !== "pool") throw new Error("expected pool context"); + cfg.activeCodexAccountId = "pool-b"; + + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + modelId: "gpt-5.3-codex-spark", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("gpt-5.3-codex-spark", "429", cfg, "pool-b", now); + noteSubagentModelFailure( + "gpt-5.3-codex-spark", + "429", + cfg, + "pool-a", + now, + 10 * DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS, + ); + noteSubagentModelFailure( + "xai/grok-4.5", + "429", + cfg, + undefined, + now, + 10 * DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS, + ); + noteSubagentModelFailure( + "grok-4.5", + "429", + cfg, + undefined, + now, + 10 * DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS, + ); + + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, "shared")).toBe("pool-a"); + expect(previewCodexAccountForRequest(bound.affinityKey ?? null, cfg, now, "spark")).toBe("pool-b"); + + let selectionStarts = 0; + let selectionReleases = 0; + const turnAdmissionLease = { + release() {}, + beginCodexAccountSelection() { + selectionStarts += 1; + return { + mainProfileDraining: false, + claimMainProfile: () => false, + release: () => { selectionReleases += 1; }, + }; + }, + } satisfies Pick; + let finalAuth: CodexAuthContext | undefined; + const fetchedUrls: string[] = []; + const forwardedBodies: string[] = []; + const forwardedAuths: Array = []; + globalThis.fetch = (async (input, init) => { + const raw = typeof init?.body === "string" ? init.body : ""; + fetchedUrls.push(String(input)); + forwardedBodies.push(raw); + forwardedAuths.push(new Headers(init?.headers).get("authorization")); + if (raw.includes("capture_assignment")) { + currentNow = now + DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS + 1; + return new Response(recoverySse("Use the recovered candidate-scope assignment."), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return Response.json({ + id: "resp_recovered_candidate_scope", + object: "response", + status: "completed", + model: "gpt-5.3-codex-spark", + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + const response = await postSpawn( + cfg, + { model: "xai/grok-4.5", input: recoverableEncryptedInput(), stream: false }, + { + turnAdmissionLease, + onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; }, + }, + { model: "", provider: "" }, + requestHeaders, + ); + + expect(response.status).toBe(200); + const bodyRequests = forwardedBodies.map((body, index) => ({ + body, + url: fetchedUrls[index], + auth: forwardedAuths[index], + })).filter(({ body }) => body.length > 0); + expect(bodyRequests).toHaveLength(2); + expect(bodyRequests[0]?.body).toContain("capture_assignment"); + expect(bodyRequests[1]?.body).toContain("Use the recovered candidate-scope assignment."); + expect(bodyRequests[1]?.body).toContain('"model":"gpt-5.3-codex-spark"'); + expect(selectionStarts).toBe(3); + expect(selectionReleases).toBe(3); + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-b" }); + expect(bodyRequests[1]?.auth).toContain("pool-b_token"); + }); + test("uses healthier pool account B when active A is above threshold", async () => { const now = 1_800_000_000_000; Date.now = () => now; diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 3d433aef12..98d83a6ad5 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -24,6 +24,7 @@ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../src/codex/ac import { clearAccountQuota, updateAccountQuota } from "../src/codex/quota"; import { canAcquireCodexQuotaProbeLease, + canAcquireCodexQuotaScopeProbeLease, clearCodexUpstreamHealthForAccount, CODEX_QUOTA_PROBE_INTERVAL_MS, recordCodexUpstreamOutcome, @@ -215,6 +216,44 @@ describe("subagent model fallback chain", () => { }); }); + test("fixed account candidates do not call the Pool preview", () => { + updateAccountQuota("account-a", 10, undefined, 20); + const config = cfg({ codexAccountNamespaces: { team: "account-a" } }); + const throwingPreview = () => { + throw new Error("fixed account must not call Pool preview"); + }; + + expect(isSubagentModelUnavailable( + "team/gpt-5.5", + config, + "pool-a", + Date.now(), + undefined, + throwingPreview, + )).toBe(false); + }); + + test("a null candidate account preview does not fall back to the active Pool account", () => { + updateAccountQuota("pool-a", 10, undefined, 20); + const config = cfg({ subagentModelFallback: ["kimi/k3"] }); + + expect(selectAvailableSubagentModel( + "gpt-5.6-sol", + config, + [], + "pool-a", + Date.now(), + false, + undefined, + [], + () => null, + )).toEqual({ + model: "kimi/k3", + rewritten: true, + skipped: ["gpt-5.6-sol"], + }); + }); + test("case-distinct account selector fallbacks remain independent", () => { updateAccountQuota("pool-a", 95, undefined, 20); const config = cfg({ @@ -297,6 +336,76 @@ describe("subagent model fallback chain", () => { }); }); + test("Pool fallback skips a reset-derived cooldown in the model's quota scope", () => { + const now = 1_800_000_000_000; + updateAccountQuota("pool-a", 10, undefined, 20); + const config = cfg({ subagentModelFallback: ["kimi/k3"] }); + recordCodexUpstreamOutcome(config, "pool-a", 429, { + modelId: "gpt-5.6-sol", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", now + 1)).toEqual({ + model: "kimi/k3", + rewritten: true, + skipped: ["gpt-5.6-sol"], + }); + }); + + test("Pool fallback admits a due reset-derived probe in the model's quota scope", () => { + const now = 1_800_000_000_000; + const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS + 1; + updateAccountQuota("pool-a", 10, undefined, 20); + const config = cfg({ subagentModelFallback: ["kimi/k3"] }); + recordCodexUpstreamOutcome(config, "pool-a", 429, { + modelId: "gpt-5.6-sol", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(canAcquireCodexQuotaScopeProbeLease("pool-a", "shared", probeAt)).toBe(true); + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", probeAt)).toEqual({ + model: "gpt-5.6-sol", + rewritten: false, + skipped: [], + }); + }); + + test("Pool fallback ignores a reset-derived cooldown for an unrelated quota scope", () => { + const now = 1_800_000_000_000; + updateAccountQuota("pool-a", 10, undefined, 20); + const config = cfg({ subagentModelFallback: ["kimi/k3"] }); + recordCodexUpstreamOutcome(config, "pool-a", 429, { + modelId: "gpt-5.3-codex-spark", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", now + 1)).toEqual({ + model: "gpt-5.6-sol", + rewritten: false, + skipped: [], + }); + }); + + test("Pool fallback preserves account-wide cooldown probe pacing", () => { + const now = 1_800_000_000_000; + const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS + 1; + updateAccountQuota("pool-a", 10, undefined, 20); + const config = cfg({ subagentModelFallback: ["kimi/k3"] }); + recordCodexUpstreamOutcome(config, "pool-a", 429, { + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", now + 1).model) + .toBe("kimi/k3"); + expect(canAcquireCodexQuotaProbeLease("pool-a", probeAt)).toBe(true); + expect(selectAvailableSubagentModel("gpt-5.6-sol", config, [], "pool-a", probeAt).model) + .toBe("gpt-5.6-sol"); + }); + test("account selector fallbacks still reject invalid or disabled native models", () => { resetSubagentModelFallbackStateForTests(); updateAccountQuota("pool-a", 95, undefined, 20); From b33d82dc341faab99739b70cbf8e6f045d0fcc33 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Tue, 25 Aug 2026 23:58:56 +0900 Subject: [PATCH 005/336] fix(scripts): run ocx-run commands in requested workdir (#2474) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- scripts/ocx-run | 2 +- tests/ocx-run.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/ocx-run.test.ts diff --git a/scripts/ocx-run b/scripts/ocx-run index 81ba24b705..bba389ab12 100755 --- a/scripts/ocx-run +++ b/scripts/ocx-run @@ -125,7 +125,7 @@ echo "started=$(date -Is) name=$name limit=$limit cmd=$*" > "$status" # setsid gives the job its own process group so a timeout kills the children too; # --kill-after upgrades to SIGKILL for a process that ignores SIGTERM. -setsid timeout --signal=TERM --kill-after=60s "$limit" "$@" > "$log" 2>&1 & +(CDPATH= cd -- "$workdir" && exec setsid timeout --signal=TERM --kill-after=60s "$limit" "$@") > "$log" 2>&1 & job=$! echo "$job" > "$pidf" diff --git a/tests/ocx-run.test.ts b/tests/ocx-run.test.ts new file mode 100644 index 0000000000..a2b53ffe2c --- /dev/null +++ b/tests/ocx-run.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../", import.meta.url)); +const runner = join(repoRoot, "scripts", "ocx-run"); + +describe("ocx-run", () => { + test.skipIf(process.platform !== "linux")( + "runs the command from a requested workdir containing spaces", + () => { + const root = mkdtempSync(join(tmpdir(), "ocx-run-")); + const workdirName = "requested workdir"; + const workdir = join(root, workdirName); + const cdPath = join(root, "cdpath"); + const stateDir = join(root, "state"); + const name = "workdir"; + + try { + mkdirSync(workdir); + mkdirSync(join(cdPath, workdirName), { recursive: true }); + const result = Bun.spawnSync(["bash", runner, name, workdirName, "5s", "pwd", "-P"], { + cwd: root, + env: { ...process.env, CDPATH: cdPath, OCX_RUN_DIR: stateDir }, + stdout: "pipe", + stderr: "pipe", + }); + + expect(result.exitCode).toBe(0); + expect(readFileSync(join(stateDir, `${name}.log`), "utf8")).toBe(`${realpathSync(workdir)}\n`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); From e42778adc51ad2d3495067e94ca250d66bb16f76 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:59:46 +0200 Subject: [PATCH 006/336] fix(codex): gate gpt-5.6 native models by entitlement (#2550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol/Terra/Luna shipped as static native rows, so an account without upstream entitlement saw them advertised (Codex catalog, Claude gateway discovery, management rows) and every request died upstream with "model is not supported when using Codex with a ChatGPT account", relaid to Claude Code as repeated 502 stream truncations. Add the GPT-5.6 family to ACCOUNT_GATED_NATIVE_OPENAI_MODELS so the existing per-account /models roster evidence gates catalog projection, gateway discovery, and Pool/Direct dispatch — the same mechanism #2097 shipped for Daybreak Blue. Unconfirmed rosters fail closed. Tests reworked to seed rosters where they exercise window/effort/toggle mechanics rather than gating, and to use ungated stand-ins where the slug was only an ordinary-native fixture. Refs #2548 --- src/codex/catalog/native-models.ts | 7 +- tests/agent-task-recovery.test.ts | 2 +- .../bearer-admission-routed-provider.test.ts | 12 +- tests/claude-desktop-native-context.test.ts | 7 ++ tests/claude-models-discovery.test.ts | 22 ++++ tests/codex-auth-api.test.ts | 2 +- tests/codex-auth-context.test.ts | 40 +++--- tests/codex-catalog-restore.test.ts | 18 +++ tests/codex-catalog-sync-hardening.test.ts | 28 +++-- tests/codex-catalog.test.ts | 6 +- ...odex-convergence-account-selectors.test.ts | 115 +++++++++++------- ...odex-envkey-admission-substitution.test.ts | 6 +- tests/codex-model-entitlements.test.ts | 13 +- tests/codex-refresh.test.ts | 6 +- tests/grok-models-effort-list.test.ts | 6 + tests/grok-sync.test.ts | 9 +- tests/helpers/native-main-owner-child.ts | 2 +- tests/issue-452-empty-503.test.ts | 10 +- tests/issue-702-expired-replay-state.test.ts | 22 ++-- tests/management-client-config-route.test.ts | 9 +- tests/native-model-toggle.test.ts | 36 +++++- tests/native-profile-crash-boundaries.test.ts | 2 +- tests/native-profile-startup.test.ts | 2 +- tests/openai-provider-option-e2e.test.ts | 16 +++ tests/responses-account-label.test.ts | 2 +- tests/responses-compaction-routing.test.ts | 21 +++- tests/server-auth.test.ts | 2 +- tests/server-combo-failover-e2e.test.ts | 6 +- ...subagent-fallback-handle-responses.test.ts | 108 +++++++++++++--- tests/vision-reasoning-contract.test.ts | 8 ++ tests/ws-upstream.test.ts | 10 +- 31 files changed, 394 insertions(+), 161 deletions(-) diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index 3fd673f84b..4c63d89ec1 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -3,6 +3,9 @@ export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest"; /** Native ChatGPT/Codex ids whose availability is proven per authenticated account. */ export const ACCOUNT_GATED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", NATIVE_DAYBREAK_BLUE_MODEL, ]); @@ -58,8 +61,8 @@ export function nativeOpenAiCapabilitySourceSlug(slug: string): string { * discover it on a clean install. * * Availability is not static: catalog sync and Pool routing require the account's authenticated - * `/models` roster to contain the slug. An unconfirmed or unentitled account never receives the - * request. `disabledModels` remains the independent user visibility control. + * `/models` roster to contain account-gated slugs. An unconfirmed or unentitled account never + * receives the request. `disabledModels` remains the independent user visibility control. * * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis. */ diff --git a/tests/agent-task-recovery.test.ts b/tests/agent-task-recovery.test.ts index 44f5fa3913..78647e1075 100644 --- a/tests/agent-task-recovery.test.ts +++ b/tests/agent-task-recovery.test.ts @@ -540,7 +540,7 @@ describe("agent task recovery (opt-in, default off)", () => { const response = await post( routedConfig(), - "gpt-5.6-sol", + "gpt-5.5", encryptedInput(), codexHeaders(), ); diff --git a/tests/bearer-admission-routed-provider.test.ts b/tests/bearer-admission-routed-provider.test.ts index be4449c144..51f1fd99ee 100644 --- a/tests/bearer-admission-routed-provider.test.ts +++ b/tests/bearer-admission-routed-provider.test.ts @@ -57,7 +57,7 @@ function mixedConfig(): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", - defaultModel: "gpt-5.6-luna", + defaultModel: "gpt-5.5", }, gateway: { adapter: "openai-chat", @@ -153,7 +153,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route const server = startServer(0); try { - const response = await postResponses(server.url, "gpt-5.6-luna"); + const response = await postResponses(server.url, "gpt-5.5"); // This is the #1686 guarantee and it must survive: a native route genuinely needs the // stored credential, so it fails BEFORE any upstream I/O rather than forwarding ours. @@ -175,7 +175,7 @@ describe("#2132 bearer admission does not require a ChatGPT credential for route const server = startServer(0); try { - const response = await postResponses(server.url, "gpt-5.6-luna"); + const response = await postResponses(server.url, "gpt-5.5"); expect(response.status).toBe(200); expect(nativeAuth).toEqual([`Bearer ${stored}`]); @@ -213,7 +213,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", - defaultModel: "gpt-5.6-luna", + defaultModel: "gpt-5.5", }, }, } as OcxConfig; @@ -225,7 +225,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate const server = startServer(0); try { - const response = await postResponses(server.url, "mirror/gpt-5.6-luna"); + const response = await postResponses(server.url, "mirror/gpt-5.5"); // Fail-before-I/O is the contract (src/codex/auth-context.ts): the only two acceptable // outcomes for an admission bearer are replaced-with-stored-main, or refused. Reaching @@ -247,7 +247,7 @@ describe("an admission bearer never reaches a canonical ChatGPT transport, whate const server = startServer(0); try { - await postResponses(server.url, "mirror/gpt-5.6-luna"); + await postResponses(server.url, "mirror/gpt-5.5"); expect(nativeAuth.join("|")).not.toContain(ADMISSION_SECRET); for (const sent of nativeAuth) expect(sent).toBe(`Bearer ${stored}`); diff --git a/tests/claude-desktop-native-context.test.ts b/tests/claude-desktop-native-context.test.ts index acc181b384..7c36a0ffe0 100644 --- a/tests/claude-desktop-native-context.test.ts +++ b/tests/claude-desktop-native-context.test.ts @@ -5,6 +5,10 @@ import { join } from "node:path"; import { buildClaudeDesktopState } from "../src/server/management/shared"; import { nativeOpenAiContextWindow, visibleNativeSlugs } from "../src/codex/catalog"; import { generateDesktop3pModels } from "../src/claude/desktop-3p"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import type { OcxConfig } from "../src/types"; /** @@ -24,6 +28,8 @@ const config = { } as unknown as OcxConfig; test("buildClaudeDesktopState gives native rows their real context window", async () => { + // Sol/Terra/Luna are account-gated; this test is about window metadata, so confirm them. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const home = tempHome(); const prev = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = home; @@ -44,6 +50,7 @@ test("buildClaudeDesktopState gives native rows their real context window", asyn if (prev === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = prev; rmSync(home, { recursive: true, force: true }); + resetCodexModelEntitlementCacheForTests(); } }); diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 19f58ac795..0fd20b156f 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -3,6 +3,9 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; +import { + resetCodexModelEntitlementCacheForTests, +} from "../src/codex/model-entitlements"; import { handleManagementAPI } from "../src/server/management-api"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; @@ -25,6 +28,7 @@ beforeEach(() => { }); afterEach(() => { + resetCodexModelEntitlementCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -159,6 +163,23 @@ test("OpenAI list shape and Codex catalog shape stay unchanged", async () => { }); test("Codex discovery applies the OpenAI context cap to native rows (#1430)", async () => { + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({ + tokens: { access_token: "context-cap-access", account_id: "context-cap-account" }, + }), "utf8"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/backend-api/codex/models") { + const entitled = request.headers.get("authorization") === "Bearer context-cap-access" + && request.headers.get("chatgpt-account-id") === "context-cap-account"; + const slugs = entitled ? ["gpt-5.6-sol"] : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + } + return originalFetch(request); + }) as typeof fetch; const config = configWithStaticModels(); config.providers.openai = { adapter: "openai-responses", @@ -186,6 +207,7 @@ test("Codex discovery applies the OpenAI context cap to native rows (#1430)", as }); } finally { await server.stop(true); + globalThis.fetch = originalFetch; } }); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 4bf7876312..c8e552771f 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -3485,7 +3485,7 @@ describe("codex-auth API", () => { listOpenAiForwardSidecarCandidates(config), new Headers(), config, - { exactAccount: { accountId: "pool-delete", modelId: "gpt-5.6-sol" } }, + { exactAccount: { accountId: "pool-delete", modelId: "gpt-5.5" } }, ); expect(exactSidecar?.authContext).toMatchObject({ accountId: "pool-delete", diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 5a3372c137..da4d6cb5d0 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -450,7 +450,7 @@ describe("Codex auth context", () => { }); let discoveries = 0; await expect(resolveCodexAuthContext(new Headers(), config(), "pool", { - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", resolveCodexModelEntitlements: async () => { discoveries += 1; throw new Error("must not run"); @@ -479,7 +479,7 @@ describe("Codex auth context", () => { const exactContext = await resolveCodexAuthContext(headers, cfg, "direct", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", }); expect(exactContext).toMatchObject({ kind: "pool", @@ -493,7 +493,7 @@ describe("Codex auth context", () => { _codexAccountOverride: { accessToken: "fixed_pool_token", chatgptAccountId: "fixed_pool_acc" }, }); expect(cfg.activeCodexAccountId).toBe("pool-b"); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-sol" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.5" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); }); @@ -706,7 +706,7 @@ describe("Codex auth context", () => { // The pool path honors the order: pool-b outranks pool-a, so an unbound request // prefers pool-b. - await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { modelId: "gpt-5.6-sol" })) + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { modelId: "gpt-5.5" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); // An exact selector names pool-a: it must resolve to pool-a even though pool-b is @@ -714,7 +714,7 @@ describe("Codex auth context", () => { // way to redirect a request that already named its account. await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).resolves.toMatchObject({ kind: "pool", accountId: "pool-a", @@ -735,7 +735,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN_CODEX_ACCOUNT_ID, - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).resolves.toMatchObject({ kind: "main-pool", accountId: MAIN_CODEX_ACCOUNT_ID, @@ -841,7 +841,7 @@ describe("Codex auth context", () => { expect(isCodexAuthContextUsable(captured, cfg)).toBe(true); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toThrow("Selected Codex account is unavailable"); expect(cfg.activeCodexAccountId).toBe("pool-a"); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) @@ -868,7 +868,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toThrow("Selected Codex account needs reauthentication"); expect(cfg.activeCodexAccountId).toBe("pool-b"); }); @@ -885,7 +885,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toThrow("Selected Codex account is unavailable"); expect(cfg.activeCodexAccountId).toBe("pool-a"); }); @@ -905,18 +905,18 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { resetAt: Math.floor((now + 60 * 60_000) / 1_000), now, - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", fixedAccount: true, }); Date.now = () => now + CODEX_QUOTA_PROBE_INTERVAL_MS; await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a", - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", })).rejects.toBeInstanceOf(CodexAccountCooldownError); const ordinaryProbe = await resolveCodexAuthContext(new Headers(), cfg, "pool", { - modelId: "gpt-5.6-sol", + modelId: "gpt-5.5", }); expect(ordinaryProbe).toMatchObject({ kind: "pool", accountId: "pool-a" }); expect(ordinaryProbe.kind === "pool" ? ordinaryProbe.probeLeaseId : undefined).toBeTruthy(); @@ -1079,7 +1079,7 @@ describe("Codex auth context", () => { }); // Spark owns a separate quota, so Terra can use the same account. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); @@ -1087,12 +1087,12 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { now, resetAt, - modelId: "gpt-5.6-terra", + modelId: "gpt-5.4", }); // Terra and Luna stay in the shared native quota group, while Spark keeps // its independent cooldown instead of being overwritten by Terra's 429. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4-mini" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); @@ -1104,7 +1104,7 @@ describe("Codex auth context", () => { retryAfter: "60", modelId: "gpt-5.3-codex-spark", }); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .rejects.toBeInstanceOf(CodexAccountCooldownError); } finally { Date.now = originalNow; @@ -1137,7 +1137,7 @@ describe("Codex auth context", () => { Date.now = () => now; // Establish the shared-scope binding first. The Spark fallback below must // create a second binding rather than replacing this one. - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); recordCodexUpstreamOutcome(cfg, "pool-a", 429, { @@ -1149,7 +1149,7 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); expect(cfg.activeCodexAccountId).toBe("pool-a"); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); // This second Spark request proves routing retained the peer choice for // the Spark affinity instead of relying on an auth-layer substitution. @@ -1182,7 +1182,7 @@ describe("Codex auth context", () => { recordCodexUpstreamOutcome(cfg, "pool-a", 429, { now, resetAt, - modelId: "gpt-5.6-terra", + modelId: "gpt-5.4", }); const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; @@ -1205,7 +1205,7 @@ describe("Codex auth context", () => { Date.now = () => probeAt + 1; await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" })) .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" }); - await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" })) + await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.4-mini" })) .resolves.toMatchObject({ kind: "pool", probeQuotaScope: "shared" }); } finally { Date.now = originalNow; diff --git a/tests/codex-catalog-restore.test.ts b/tests/codex-catalog-restore.test.ts index f689d80eb2..253c42f449 100644 --- a/tests/codex-catalog-restore.test.ts +++ b/tests/codex-catalog-restore.test.ts @@ -270,6 +270,8 @@ describe("Codex catalog restore", () => { const r = runScript(codexHome, opencodexHome, ` const { syncCatalogModels } = require("./src/codex/catalog"); + const { seedCodexModelEntitlementsForTests } = require("./src/codex/model-entitlements"); + seedCodexModelEntitlementsForTests("__main__", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); (async () => { const result = await syncCatalogModels({ port: 10100, @@ -291,6 +293,9 @@ describe("Codex catalog restore", () => { test("sync advertises documented Codex-native additions omitted by the bundled catalog", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "catalog-main-access", account_id: "catalog-main-account" }, + }), "utf8"); writeFileSync(catalogPath, JSON.stringify({ models: [ { @@ -314,6 +319,19 @@ describe("Codex catalog restore", () => { const r = runScript(codexHome, opencodexHome, ` const { syncCatalogModels } = require("./src/codex/catalog"); + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method !== "GET" || url.pathname !== "/backend-api/codex/models") { + throw new Error("unexpected fetch: " + request.method + " " + url.href); + } + const entitled = request.headers.get("authorization") === "Bearer catalog-main-access" + && request.headers.get("chatgpt-account-id") === "catalog-main-account"; + const slugs = entitled ? ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + }; (async () => { const result = await syncCatalogModels({ port: 10100, diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 29d84ff661..c00e96923d 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -108,7 +108,7 @@ describe("Codex catalog sync hardening", () => { if (existsSync(opencodexHome)) rmSync(opencodexHome, { recursive: true, force: true }); }); - test("Gap B: drops legacy OpenAI-family natives but keeps supported + user natives", () => { + test("Gap B: drops legacy and unentitled account-gated natives but keeps supported + user natives", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ @@ -138,9 +138,11 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("gpt-5.4"); expect(slugs).toContain("gpt-5.4-mini"); expect(slugs).toContain("gpt-5.3-codex-spark"); - expect(slugs).toContain("gpt-5.6-sol"); - expect(slugs).toContain("gpt-5.6-terra"); - expect(slugs).toContain("gpt-5.6-luna"); + // This isolated fixture has no authenticated ChatGPT roster, so account-gated + // native models must fail closed rather than remain selectable. + expect(slugs).not.toContain("gpt-5.6-sol"); + expect(slugs).not.toContain("gpt-5.6-terra"); + expect(slugs).not.toContain("gpt-5.6-luna"); expect(slugs).toContain("user-native"); // genuine user native preserved expect(slugs).not.toContain("gpt-5.3-codex"); // legacy dropped expect(slugs).not.toContain("gpt-5.2"); // legacy dropped @@ -153,8 +155,8 @@ describe("Codex catalog sync hardening", () => { writeFileSync(catalogPath, JSON.stringify({ models: [ { - ...nativeEntry("gpt-5.6-sol", 0), - display_name: "Original Sol", + ...nativeEntry("gpt-5.5", 0), + display_name: "Original GPT-5.5", comp_hash: "native-sol-hash", base_instructions: "Native Sol instructions", model_messages: { instructions_template: "Native Sol instructions" }, @@ -178,17 +180,17 @@ describe("Codex catalog sync hardening", () => { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", liveModels: false, - models: ["codex/gpt-5.6-sol"] + models: ["codex/gpt-5.5"] } }, codexAccounts: [{ id: "stored-team-account", isMain: false }], codexAccountNamespaces: { team: "stored-team-account" }, combos: { "nova-sol": { - alias: "gpt-5.6-sol", + alias: "gpt-5.5", nativeAlias: true, - displayName: "Nova Sol", - targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }] + displayName: "Nova GPT-5.5", + targets: [{ provider: "Nova1", model: "codex/gpt-5.5" }] } } }; @@ -205,13 +207,13 @@ describe("Codex catalog sync hardening", () => { tool_mode?: string | null; opencodex_catalog_kind?: string; }>; - expect(rows.filter(row => row.slug === "gpt-5.6-sol")).toEqual([ + expect(rows.filter(row => row.slug === "gpt-5.5")).toEqual([ expect.objectContaining({ - display_name: "Nova Sol", + display_name: "Nova GPT-5.5", opencodex_catalog_kind: "combo-native-alias-v1", }), ]); - expect(rows.find(row => row.slug === "team/gpt-5.6-sol")).toMatchObject({ + expect(rows.find(row => row.slug === "team/gpt-5.5")).toMatchObject({ comp_hash: "native-sol-hash", base_instructions: "Native Sol instructions", model_messages: { instructions_template: "Native Sol instructions" }, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index bf5656779b..2cfdfa1ce5 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -18,6 +18,7 @@ import { cursorModelReasoningEfforts, } from "../src/adapters/cursor/discovery"; import { getModelMetadata, resolveMetadataProvider } from "../src/generated/model-metadata"; +import { resetCodexModelEntitlementCacheForTests, seedCodexModelEntitlementsForTests } from "../src/codex/model-entitlements"; import { clearModelCache, getProviderDiscoveryStatus, @@ -57,6 +58,7 @@ afterEach(() => { globalThis.fetch = originalFetch; clearModelCache(); resetOpenAiApiCatalogWarningStateForTests(); + resetCodexModelEntitlementCacheForTests(); }); function normalizedCombo( @@ -1258,7 +1260,9 @@ describe("combo catalog capability intersection", () => { // The "openai" provider uses forward-auth (Codex login passthrough) — fetchProviderModels // returns [] for it, so native slugs only surface through nativeOpenAiSlugs(). Before the // fix, memberByKey never contained openai/, so combos with a native-openai target were - // silently dropped from the catalog. + // silently dropped from the catalog. Sol is account-gated now, so the combo's native member + // needs a confirmed roster to be visible at all. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); globalThis.fetch = (() => { throw new Error("forward providers must not fetch /models"); }) as typeof fetch; const config: OcxConfig = { port: 10100, diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index a0e367091b..9206aaa9e4 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -46,7 +46,7 @@ import { markModelsFetchFailure } from "../src/codex/model-cache"; import { legacyCustomModelCatalogSlugs } from "../src/codex/custom-model-catalog-migration"; import { resetCodexModelEntitlementCacheForTests } from "../src/codex/model-entitlements"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; -import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { removeCodexAccountCredential, saveCodexAccountCredential } from "../src/codex/account-store"; // The canonical-bytes case spawns real syncs and runs ~2.5s in isolation, on this // tree and on a clean baseline alike. That is half of bun's 5s default, but full @@ -62,6 +62,16 @@ let catalogPath = ""; let previousCodexHome: string | undefined; let previousOpencodexHome: string | undefined; let previousCodexCliPath: string | undefined; +let previousFetch: typeof fetch; +let modelRostersByChatgptAccount: Map; + +const GPT56_NATIVE_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; + +function grantGpt56NativeModels(...chatgptAccountIds: string[]): void { + for (const accountId of chatgptAccountIds) { + modelRostersByChatgptAccount.set(accountId, GPT56_NATIVE_MODELS); + } +} function nativeEntry(visibility = "list"): RawEntry { return { @@ -265,12 +275,38 @@ beforeEach(() => { mkdirSync(opencodexHome); process.env.CODEX_HOME = codexHome; process.env.OPENCODEX_HOME = opencodexHome; + previousFetch = globalThis.fetch; + modelRostersByChatgptAccount = new Map(); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, + })); + saveCodexAccountCredential("side-account-id", { + accessToken: "side-token", + refreshToken: "side-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "side-chatgpt-account", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { + const accountId = new Headers(init?.headers).get("chatgpt-account-id") ?? ""; + return Response.json({ + models: (modelRostersByChatgptAccount.get(accountId) ?? []).map(slug => ({ + slug, + supported_in_api: true, + visibility: "list", + })), + }); + } + return previousFetch(input, init); + }) as typeof fetch; resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); resetCodexModelEntitlementCacheForTests(); }); afterEach(() => { + globalThis.fetch = previousFetch; const identity = resolveEffectiveUserIdentity(); const serializationDb = resolveCodexCatalogSerializationDatabasePath(identity, codexHome); for (const suffix of ["", "-journal", "-wal", "-shm"]) { @@ -287,6 +323,7 @@ afterEach(() => { }); test("convergence renders account-qualified rows and preserves only non-generated foreign rows", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([ nativeEntry(), accountEntry("stale-selector"), @@ -326,6 +363,7 @@ test("convergence renders account-qualified rows and preserves only non-generate }); test("convergence preserves one configured soft budget on bare and account-native rows", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([nativeEntry()]); const nextConfig = config(true); nextConfig.providers.openai!.modelAutoCompactTokenLimits = { "gpt-5.6-sol": 120_000 }; @@ -341,6 +379,7 @@ test("convergence preserves one configured soft budget on bare and account-nativ }); test("disabling the picker removes generated rows, restores bare rows, and retains foreign rows", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([ nativeEntry("hide"), accountEntry("desktop"), @@ -377,6 +416,7 @@ test("convergence drops unsupported bare native rows and never qualifies them", test("convergence projects the observed Daybreak row onto its selector and one bare row", async () => { writeCatalog([nativeEntry()]); + removeCodexAccountCredential("side-account-id"); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, })); @@ -394,24 +434,8 @@ test("convergence projects the observed Daybreak row onto its selector and one b }], }, null, 2) + "\n"); - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input, init) => { - const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); - if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { - return Response.json({ models: [{ - slug: "gpt-daybreak-blue-latest", - supported_in_api: true, - visibility: "list", - }] }); - } - return originalFetch(input, init); - }) as typeof fetch; - let catalog: RawCatalog; - try { - catalog = await convergeCatalog(config(true)); - } finally { - globalThis.fetch = originalFetch; - } + modelRostersByChatgptAccount.set("main-chatgpt-account", ["gpt-daybreak-blue-latest"]); + const catalog = await convergeCatalog(config(true)); const models = catalog.models ?? []; const daybreak = models.find(entry => entry.slug === "desktop/gpt-daybreak-blue-latest"); expect(daybreak).toMatchObject({ @@ -447,28 +471,14 @@ test("Direct convergence does not borrow a Pool-only Daybreak grant for the bare chatgptAccountId: "side-chatgpt-account", }); - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input, init) => { - const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url); - if (url.hostname === "chatgpt.com" && url.pathname.endsWith("/models")) { - const accountId = new Headers(init?.headers).get("chatgpt-account-id"); - return Response.json({ models: [ - { slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list" }, - ...(accountId === "side-chatgpt-account" - ? [{ slug: "gpt-daybreak-blue-latest", supported_in_api: true, visibility: "list" }] - : []), - ] }); - } - return originalFetch(input, init); - }) as typeof fetch; + modelRostersByChatgptAccount.set("main-chatgpt-account", ["gpt-5.6-sol"]); + modelRostersByChatgptAccount.set( + "side-chatgpt-account", + ["gpt-5.6-sol", "gpt-daybreak-blue-latest"], + ); const directConfig = config(true); directConfig.providers.openai!.codexAccountMode = "direct"; - let catalog: RawCatalog; - try { - catalog = await convergeCatalog(directConfig); - } finally { - globalThis.fetch = originalFetch; - } + const catalog = await convergeCatalog(directConfig); const models = catalog.models ?? []; expect(models.filter(entry => entry.slug === "gpt-daybreak-blue-latest")).toHaveLength(0); @@ -621,7 +631,7 @@ test("degraded preservation still honors explicit routed visibility policy", asy expect(models.some(entry => entry.slug === "offline/unselected-old")).toBe(false); }); -test("custom-catalog convergence reports network degradation without a fallback notice", async () => { + test("custom-catalog convergence reports network degradation without a fallback notice", async () => { catalogPath = join(codexHome, "custom-catalog.json"); writeFileSync( join(codexHome, "config.toml"), @@ -630,6 +640,9 @@ test("custom-catalog convergence reports network degradation without a fallback primeCodexRuntimeFixture(); writeCatalog([nativeEntry(), generatedRoutedEntry("offline/old-live")]); const nextConfig = config(false); + nextConfig.codexAccounts = []; + nextConfig.codexAccountNamespaces = {}; + rmSync(join(codexHome, "auth.json"), { force: true }); nextConfig.providers.offline = { adapter: "openai-chat", baseUrl: "https://offline.example.test/v1", @@ -720,6 +733,9 @@ test("OAuth admission degradation is auth-only and does not masquerade as a netw primeCodexRuntimeFixture(); writeCatalog([nativeEntry(), generatedRoutedEntry("offline/old-live")]); const nextConfig = config(false); + nextConfig.codexAccounts = []; + nextConfig.codexAccountNamespaces = {}; + rmSync(join(codexHome, "auth.json"), { force: true }); nextConfig.providers.offline = { adapter: "openai-chat", baseUrl: "https://offline.example.test/v1", @@ -770,6 +786,7 @@ test("disabled-provider selections cannot delete a foreign row in either writer" }); test("convergence clamps native, routed, and account rows to observed runtime support", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); seedObservedRuntimeSupport(); writeCatalog([nativeEntry()]); const nextConfig = config(true); @@ -808,6 +825,7 @@ test("convergence clamps native, routed, and account rows to observed runtime su }); test("generated account rows silently win freshly gathered provider collisions", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); writeCatalog([nativeEntry()]); const nextConfig = config(true); nextConfig.providers.team = { @@ -890,20 +908,26 @@ test("retained sync and convergence produce identical canonical bytes in either if (ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)) expect(slugs).not.toContain(slug); else expect(slugs).toContain(slug); } + for (const entry of models.filter(entry => ( + entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND + ))) { + const baseSlug = entry.slug?.slice(entry.slug.indexOf("/") + 1); + expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(baseSlug ?? "")).toBe(false); + } expect(slugs).not.toContain("gpt-legacy-unsupported"); expect(slugs).toContain("user-native"); - expect(models.find(entry => entry.slug === "gpt-5.6-sol")?.visibility) + expect(models.find(entry => entry.slug === "gpt-5.5")?.visibility) .toBe(pickerEnabled ? "hide" : "list"); expect(models.some(entry => ( entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND ))).toBe(pickerEnabled); if (pickerEnabled) { - expect(models.find(entry => entry.slug === "desktop/gpt-5.6-sol")).toMatchObject({ - display_name: "desktop / 5.6 Sol", + expect(models.find(entry => entry.slug === "desktop/gpt-5.5")).toMatchObject({ + display_name: "desktop / 5.5", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, }); - expect(models.find(entry => entry.slug === "team/gpt-5.6-sol")).toMatchObject({ - display_name: "team / 5.6 Sol", + expect(models.find(entry => entry.slug === "team/gpt-5.5")).toMatchObject({ + display_name: "team / 5.5", opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, }); } @@ -1040,6 +1064,7 @@ test("convergence refuses a combo shadow when every backup target is present but }); test("both writers restore pristine native priorities after featured-model transitions", async () => { + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); primeCodexRuntimeFixture(); catalogPath = join(codexHome, "custom-catalog.json"); writeFileSync( diff --git a/tests/codex-envkey-admission-substitution.test.ts b/tests/codex-envkey-admission-substitution.test.ts index 55b1b2a63a..307c5f667c 100644 --- a/tests/codex-envkey-admission-substitution.test.ts +++ b/tests/codex-envkey-admission-substitution.test.ts @@ -48,7 +48,7 @@ function directConfig(): OcxConfig { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", - defaultModel: "gpt-5.6-luna", + defaultModel: "gpt-5.5", }, }, apiKeys: [ @@ -105,7 +105,7 @@ async function postResponses(url: string | URL, authorization: string): Promise< return originalFetch(new URL("/v1/responses", url), { method: "POST", headers: { "content-type": "application/json", authorization }, - body: JSON.stringify({ model: "gpt-5.6-luna", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); } @@ -113,7 +113,7 @@ async function postCompact(url: string | URL, authorization: string): Promise { fetcher: (async (_input, init) => { const accountId = new Headers(init?.headers).get("chatgpt-account-id"); return accountId === "chatgpt-main" - ? roster("gpt-5.6-sol", DAYBREAK) - : roster("gpt-5.6-sol"); + ? roster(SOL, LUNA, DAYBREAK) + : roster(SOL, TERRA); }) as typeof fetch, now: 1_000, }); expect([...entitledCodexAccountIdsForModel(snapshot, DAYBREAK)!]).toEqual(["main"]); - expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([DAYBREAK]); - expect(entitledCodexAccountIdsForModel(snapshot, "gpt-5.6-sol")).toBeUndefined(); + expect([...entitledCodexAccountIdsForModel(snapshot, SOL)!]).toEqual(["main", "secondary"]); + expect([...entitledCodexAccountIdsForModel(snapshot, TERRA)!]).toEqual(["secondary"]); + expect([...entitledCodexAccountIdsForModel(snapshot, LUNA)!]).toEqual(["main"]); + expect([...availableAccountGatedNativeModels(snapshot)]).toEqual([SOL, TERRA, LUNA, DAYBREAK]); }); test("fails closed when an account roster cannot be confirmed", async () => { diff --git a/tests/codex-refresh.test.ts b/tests/codex-refresh.test.ts index a234b7a401..7b5833383c 100644 --- a/tests/codex-refresh.test.ts +++ b/tests/codex-refresh.test.ts @@ -159,8 +159,10 @@ describe("Codex catalog refresh", () => { expect(result.path).toBe(join(realpathSync.native(home.codexHome), "nested", "catalog.json")); expect(result.catalogWritten).toBe(true); expect(after).not.toBe(before); - expect(rewritten.models[0].slug).toBe("gpt-5.6-sol"); - expect(rewritten.models[0].display_name).toBe("GPT-5.6-Sol"); + // The fixture seeds gated Sol rows, but this isolated home has no authenticated + // roster, so sync drops them and the first surviving row is gpt-5.5. + expect(rewritten.models[0].slug).toBe("gpt-5.5"); + expect(rewritten.models[0].display_name).toBe("gpt-5.5"); expect(rewritten.models[0].context_window).toBeGreaterThan(0); } finally { home.restore(); diff --git a/tests/grok-models-effort-list.test.ts b/tests/grok-models-effort-list.test.ts index 3e0a353662..e4e0d33d95 100644 --- a/tests/grok-models-effort-list.test.ts +++ b/tests/grok-models-effort-list.test.ts @@ -3,6 +3,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; @@ -44,6 +48,7 @@ beforeEach(() => { }); afterEach(() => { + resetCodexModelEntitlementCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (testHome) rmSync(testHome, { recursive: true, force: true }); @@ -52,6 +57,7 @@ afterEach(() => { describe("raw /v1/models list reasoning-effort advertisement (Grok Build discovery)", () => { test("routed models with configured tiers advertise the Grok reasoning catalog shape", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const config = effortConfig(); config.providers.openai = { adapter: "openai-responses", diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts index d1593a949a..0ed57bcfb9 100644 --- a/tests/grok-sync.test.ts +++ b/tests/grok-sync.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,10 +6,16 @@ import { injectGrokConfig } from "../src/grok/inject"; import { syncGrokConfig } from "../src/grok/sync"; import { nativeOpenAiContextWindow, visibleNativeSlugs } from "../src/codex/catalog"; import type { CatalogModel } from "../src/codex/catalog"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import type { OcxConfig } from "../src/types"; const baseConfig = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; +afterEach(() => resetCodexModelEntitlementCacheForTests()); + function tempGrokHome(): { root: string; grokHome: string } { const root = mkdtempSync(join(tmpdir(), "ocx-grok-sync-")); const grokHome = join(root, ".grok"); @@ -44,6 +50,7 @@ describe("syncGrokConfig", () => { // and Grok fell back to its own 200k default — understating gpt-5.6-sol, which is 372k. The // window comes from the same accessor the dashboard uses, so the two surfaces agree. test("native slugs carry their real context window, not Grok's 200k default", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); const { root, grokHome } = tempGrokHome(); try { const result = await syncGrokConfig(10190, baseConfig, { grokHome }, { diff --git a/tests/helpers/native-main-owner-child.ts b/tests/helpers/native-main-owner-child.ts index 26d261ec0b..b70db3dde0 100644 --- a/tests/helpers/native-main-owner-child.ts +++ b/tests/helpers/native-main-owner-child.ts @@ -183,7 +183,7 @@ async function request(port: number, kind: string): Promise<{ status: number; te const response = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: kind, stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: kind, stream: false }), }); return { status: response.status, text: await response.text() }; } diff --git a/tests/issue-452-empty-503.test.ts b/tests/issue-452-empty-503.test.ts index 53ad74ff6e..7eac48b321 100644 --- a/tests/issue-452-empty-503.test.ts +++ b/tests/issue-452-empty-503.test.ts @@ -249,7 +249,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(503); const text = await response.text(); @@ -275,7 +275,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(418); expect(response.headers.get("content-type")).toContain("application/json"); @@ -297,7 +297,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(status); expect(response.headers.get("content-type")).toContain("application/json"); @@ -317,7 +317,7 @@ describe("passthrough empty 503 (#452)", () => { const response = await originalGlobalFetch(new URL("/v1/responses", serverUrl), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hi", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hi", stream: false }), }); expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBeNull(); @@ -333,7 +333,7 @@ describe("passthrough empty 503 (#452)", () => { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, body: JSON.stringify({ - model: "gpt-5.6-sol", + model: "gpt-5.5", messages: [{ role: "user", content: "hi" }], stream: false, }), diff --git a/tests/issue-702-expired-replay-state.test.ts b/tests/issue-702-expired-replay-state.test.ts index 239d305746..5a618c4be7 100644 --- a/tests/issue-702-expired-replay-state.test.ts +++ b/tests/issue-702-expired-replay-state.test.ts @@ -93,7 +93,7 @@ function completedSse(responseId: string, text: string): string { response: { id: responseId, status: "completed", - model: "gpt-5.6-sol", + model: "gpt-5.5", output: [item], }, })}`, @@ -165,7 +165,7 @@ async function runForwardScenario( method: "POST", headers: requestHeaders, body: JSON.stringify({ - model: "gpt-5.6-sol", + model: "gpt-5.5", input: [inputMessage(HISTORICAL_USER_SENTINEL)], stream: true, store: false, @@ -192,7 +192,7 @@ async function runForwardScenario( method: "POST", headers: { ...requestHeaders, ...resumeHeaders }, body: JSON.stringify({ - model: "gpt-5.6-sol", + model: "gpt-5.5", previous_response_id: FIRST_RESPONSE_ID, input: [inputMessage(CURRENT_USER_SENTINEL)], stream: true, @@ -249,7 +249,7 @@ describe("Issue #702 expired forward replay state", () => { const responseId = "resp_issue_702_missing_spill"; setResponseStateByteCapForTests(1_024); rememberResponseState( - { model: "openai/gpt-5.6-sol", input: "x".repeat(8_000), store: false }, + { model: "openai/gpt-5.5", input: "x".repeat(8_000), store: false }, { id: responseId, status: "completed", output: [{ role: "assistant", content: "done" }] }, undefined, { force: true }, @@ -265,7 +265,7 @@ describe("Issue #702 expired forward replay state", () => { throw new Error("upstream must not be called"); }) as typeof fetch; const routeClasses: Array<{ config: OcxConfig; model: string }> = [ - { config: forwardConfig(), model: "gpt-5.6-sol" }, + { config: forwardConfig(), model: "gpt-5.5" }, { config: { port: 0, @@ -278,11 +278,11 @@ describe("Issue #702 expired forward replay state", () => { baseUrl: "https://runtime.us-east-1.kiro.dev", authMode: "key", apiKey: "synthetic-token", - models: ["gpt-5.6-sol"], + models: ["gpt-5.5"], }, }, } as OcxConfig, - model: "kiro-test/gpt-5.6-sol", + model: "kiro-test/gpt-5.5", }, { config: { @@ -296,11 +296,11 @@ describe("Issue #702 expired forward replay state", () => { baseUrl: "https://api.openai.com/v1", authMode: "key", apiKey: "provider-key", - models: ["gpt-5.6-sol"], + models: ["gpt-5.5"], }, }, } as OcxConfig, - model: "test-openai/gpt-5.6-sol", + model: "test-openai/gpt-5.5", }, ]; @@ -418,7 +418,7 @@ describe("Issue #702 expired forward replay state", () => { baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, allowPrivateNetwork: true, apiKey: "provider-key", - defaultModel: "gpt-5.6-sol", + defaultModel: "gpt-5.5", }, }, } as OcxConfig); @@ -428,7 +428,7 @@ describe("Issue #702 expired forward replay state", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "test-openai/gpt-5.6-sol", + model: "test-openai/gpt-5.5", previous_response_id: "resp_upstream_native_state", input: [inputMessage(CURRENT_USER_SENTINEL)], stream: true, diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index baee2a34d2..0df9ba44b8 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { join } from "node:path"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import { handleManagementAPI } from "../src/server/management-api"; import { OPENCODE_API_KEY_ENV, @@ -24,6 +28,8 @@ import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; */ const REAL_LOOKING_KEY = "ocx_live_9f3c7a2b41d84e6fa05c8e17b3d92764"; +afterEach(() => resetCodexModelEntitlementCacheForTests()); + interface ClientConfigEnvelope { client: string; filename: string; @@ -190,6 +196,7 @@ describe("GET /api/client-config", () => { }, 15_000); test("DSH response keeps management reasoning metadata in the rc.6 model map", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-luna"]); const response = await clientConfigApi(baseConfig(), "?client=dsh"); expect(response.status).toBe(200); const body = await response.json() as ClientConfigEnvelope; diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index b8d153db6c..fcf42e07dc 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeDisplayName, @@ -31,6 +31,13 @@ import { afterEach(() => resetCodexModelEntitlementCacheForTests()); +// Most of this file exercises visibility/window mechanics on Sol/Terra/Luna rows. They are +// account-gated now, so give them a confirmed main roster up front; the two gating-specific +// tests below reset the cache to assert the unconfirmed baseline first. +beforeEach(() => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); +}); + function makeConfig(overrides: Partial = {}): OcxConfig { return { port: 10100, providers: {}, defaultProvider: "openai", ...overrides } as OcxConfig; } @@ -71,16 +78,23 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); test("nativeModelRows hides account-gated ids until an authenticated roster confirms them", () => { + resetCodexModelEntitlementCacheForTests(); const rows = nativeModelRows({ disabledModels: ["gpt-5.6-sol"] }); expect(rows.map(r => r.slug)).toEqual( NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), ); - expect(rows.find(r => r.slug === "gpt-5.6-sol")?.disabled).toBe(true); - expect(rows.find(r => r.slug === "gpt-5.5")?.disabled).toBe(false); + + seedCodexModelEntitlementsForTests( + "main", + ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-daybreak-blue-latest"], + ); + const confirmed = nativeModelRows({ disabledModels: ["gpt-5.6-sol"] }); + expect(confirmed.map(r => r.slug)).toEqual(NATIVE_OPENAI_MODELS); + expect(confirmed.find(r => r.slug === "gpt-5.6-sol")?.disabled).toBe(true); + expect(confirmed.find(r => r.slug === "gpt-5.5")?.disabled).toBe(false); // Known context metadata rides along for the dashboard. - expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); + expect(confirmed.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(272_000); - seedCodexModelEntitlementsForTests("main", ["gpt-daybreak-blue-latest"]); expect(nativeModelRows({ disabledModels: [] }).map(row => row.slug)) .toContain("gpt-daybreak-blue-latest"); }); @@ -648,6 +662,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); test("management API surfaces: /api/models leads with native rows; subagent available drops disabled bare slugs", async () => { + resetCodexModelEntitlementCacheForTests(); const config = makeConfig({ disabledModels: ["gpt-5.6-sol"] }); const modelsRes = await handleManagementAPI( @@ -658,7 +673,16 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(nativeRows.map(r => r.namespaced)).toEqual( NATIVE_OPENAI_MODELS.filter(slug => !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)), ); - expect(nativeRows.find(r => r.namespaced === "gpt-5.6-sol")?.disabled).toBe(true); + + // A confirmed roster makes the gated rows selectable again; a bare disable still wins. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); + const confirmedRes = await handleManagementAPI( + new Request("http://localhost/api/models"), new URL("http://localhost/api/models"), config, + ); + const confirmedRows = (await confirmedRes!.json() as Array<{ namespaced: string; native?: boolean; disabled: boolean }>) + .filter(r => r.native); + expect(confirmedRows.map(r => r.namespaced)).toContain("gpt-5.6-sol"); + expect(confirmedRows.find(r => r.namespaced === "gpt-5.6-sol")?.disabled).toBe(true); // Native rows lead the response so the GUI pins the group first. expect(rows[0]?.native).toBe(true); diff --git a/tests/native-profile-crash-boundaries.test.ts b/tests/native-profile-crash-boundaries.test.ts index 66bc308936..d54a85495f 100644 --- a/tests/native-profile-crash-boundaries.test.ts +++ b/tests/native-profile-crash-boundaries.test.ts @@ -184,7 +184,7 @@ function spawnStartup( } async function mainRequest(port: number) { - return fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5.6-sol", input: "crash recovery", stream: false }) }); + return fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5.5", input: "crash recovery", stream: false }) }); } const boundaries: Array<{ diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index 247f2df7fa..921796a6fd 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -298,7 +298,7 @@ async function mainRequest(port: number): Promise { return fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "startup gate", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "startup gate", stream: false }), }); } diff --git a/tests/openai-provider-option-e2e.test.ts b/tests/openai-provider-option-e2e.test.ts index e680675418..104119a9ba 100644 --- a/tests/openai-provider-option-e2e.test.ts +++ b/tests/openai-provider-option-e2e.test.ts @@ -176,6 +176,19 @@ describe("OpenAI provider-option integration spine", () => { rate_limit: { secondary_window: { used_percent: isAdded ? 10 : 90 } }, }); } + if (request.method === "GET" && url.pathname === "/backend-api/codex/models") { + const authorization = request.headers.get("authorization"); + const accountId = request.headers.get("chatgpt-account-id"); + const explicitlyEntitled = accountId === "fixture-main-account" + || accountId === "fixture-pool-account" + || authorization === "Bearer fixture-caller-main"; + const slugs = explicitlyEntitled + ? ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] + : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + } const upstreamTuple = `${request.method} ${url.href}`; if (!upstreamTuples.has(upstreamTuple)) { throw new Error(`deny-by-default fetch blocked: ${upstreamTuple}`); @@ -214,6 +227,7 @@ describe("OpenAI provider-option integration spine", () => { websocketRegistry, requestLog, catalog, + modelEntitlements, serverModule, mainAccount, sidecar, @@ -227,6 +241,7 @@ describe("OpenAI provider-option integration spine", () => { import("../src/codex/websocket-registry"), import("../src/server/request-log"), import("../src/codex/catalog"), + import("../src/codex/model-entitlements"), import("../src/server"), import("../src/codex/main-account"), import("../src/providers/openai-sidecar"), @@ -235,6 +250,7 @@ describe("OpenAI provider-option integration spine", () => { resets.push( requestLog.clearRequestLogsForTests, catalog.resetCatalogRuntimeStateForTests, + modelEntitlements.resetCodexModelEntitlementCacheForTests, routing.clearThreadAccountMap, routing.clearCodexUpstreamHealth, authApi.clearAccountQuota, diff --git a/tests/responses-account-label.test.ts b/tests/responses-account-label.test.ts index ebb0f76636..f17b58b18c 100644 --- a/tests/responses-account-label.test.ts +++ b/tests/responses-account-label.test.ts @@ -48,7 +48,7 @@ function request(): Request { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hello", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: false }), }); } diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 6ec6473503..10d45dda23 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -232,11 +232,11 @@ describe("Codex auth-context error parity (#2392)", () => { ]; function regularAuthRequest(): Request { - return compactionRequest({ model: "gpt-5.6-sol", input: "hello", stream: false }); + return compactionRequest({ model: "gpt-5.5", input: "hello", stream: false }); } function compactAuthRequest(): Request { - return compactionRequest(baseCompactionBody({ model: "gpt-5.6-sol" })); + return compactionRequest(baseCompactionBody({ model: "gpt-5.5" })); } test.each(cases)("maps $label identically on regular and compact Responses", async testCase => { @@ -393,7 +393,16 @@ describe("native Codex pool compaction", () => { expiresAt: Date.now() + 300_000, chatgptAccountId: "pool_acc", }); - globalThis.fetch = (async () => { + globalThis.fetch = (async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/backend-api/codex/models") { + const accountId = request.headers.get("chatgpt-account-id"); + const slugs = accountId === "pool_acc" ? ["gpt-5.6-terra"] : []; + return Response.json({ + models: slugs.map(slug => ({ slug, supported_in_api: true, visibility: "list" })), + }); + } if (sparkPhase) { return Response.json({ error: { message: "Spark quota exhausted" } }, { status: 429, @@ -907,7 +916,7 @@ describe("compact alternate-account attempt (#913)", () => { }) as typeof fetch; const res = await handleResponsesCompact( - compactionRequest(baseCompactionBody({ model: "side/gpt-5.6-sol" })), + compactionRequest(baseCompactionBody({ model: "side/gpt-5.5" })), config, { model: "", provider: "" }, ); @@ -1191,7 +1200,7 @@ describe("compact alternate-account attempt (#913)", () => { const request = new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hello", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: false }), }); await expect(handleResponses(request, config, { model: "", provider: "" })) .rejects.toThrow("synthetic build failure"); @@ -1216,7 +1225,7 @@ describe("compact alternate-account attempt (#913)", () => { const request = () => new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-5.6-sol", input: "hello", stream: false }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: false }), }); const authSpy = spyOn(authContextModule, "resolveCodexAuthContext"); try { diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index de2de9fc42..401fcde4af 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -150,7 +150,7 @@ afterEach(() => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); -const POOL_RETRY_MODEL = "gpt-5.6-sol"; +const POOL_RETRY_MODEL = "gpt-5.5"; function unsupportedModelBody(model = POOL_RETRY_MODEL): string { return JSON.stringify({ diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 96348e7401..bfcced0f35 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1123,7 +1123,7 @@ describe("server combo failover 030 activation matrix", () => { }, }, [ { provider: "openai", model: "gpt-5.3-codex-spark" }, - { provider: "openai", model: "gpt-5.6-terra" }, + { provider: "openai", model: "gpt-5.5" }, ]); config.codexAccounts = [{ id: rawAccountId, @@ -1150,13 +1150,13 @@ describe("server combo failover 030 activation matrix", () => { headers: { "x-codex-primary-reset-at": String(resetAt) }, }); } - return Response.json(responsesSuccess("Terra fallback", "gpt-5.6-terra")); + return Response.json(responsesSuccess("Shared-native fallback", "gpt-5.5")); }; const response = await post(config); expect(response.status).toBe(200); expect(upstreamCalls).toBe(2); - expect(await response.json()).toMatchObject({ model: "gpt-5.6-terra" }); + expect(await response.json()).toMatchObject({ model: "gpt-5.5" }); }); test("keeps a failed estimate on A without overwriting B reported usage", async () => { diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 383637f2f8..e3a42b4db1 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -28,6 +28,9 @@ import { resetSubagentModelFallbackStateForTests, setSubagentQuotaPrimeForTests, } from "../src/codex/subagent-model-fallback"; +import { + resetCodexModelEntitlementCacheForTests, +} from "../src/codex/model-entitlements"; import { resolveCodexAuthContext, type CodexAuthContext } from "../src/codex/auth-context"; import { handleResponses } from "../src/server/responses"; import { resetAgentTaskRecoveryState } from "../src/server/responses/agent-task-recovery"; @@ -61,6 +64,9 @@ beforeEach(() => { clearAccountQuota(); resetAgentTaskRecoveryState(); resetSubagentModelFallbackStateForTests(); + // Gated-native negative rosters are cached process-wide for 15s; a real-network + // miss in one test must not fail-closed the next test's entitlement lookups. + resetCodexModelEntitlementCacheForTests(); }); afterEach(() => { @@ -87,6 +93,7 @@ function fernetFixture(ciphertextBytes = 16): string { } const FERNET_TASK = fernetFixture(); +const GPT56_NATIVE_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; function encryptedAgentInput(): unknown[] { return [{ @@ -158,21 +165,57 @@ function installPoolCredential(accountId: string, chatgptAccountId: string, now: }); } +function isCodexModelsFetch(input: unknown): boolean { + try { + const url = new URL(String(input)); + return url.hostname === "chatgpt.com" && url.pathname.endsWith("/models"); + } catch { + return false; + } +} + +function codexRosterResponse(slugs: readonly string[]): Response { + return Response.json({ + models: slugs.map(slug => ({ + slug, supported_in_api: true, visibility: "list", + })), + }); +} + +function codexRosterKey(headers: Headers): string { + return headers.get("chatgpt-account-id") ?? headers.get("authorization") ?? ""; +} + +function installCodexRosterMock(rostersByCredential: Readonly>): void { + globalThis.fetch = (async (input, init) => { + if (isCodexModelsFetch(input)) { + const credential = codexRosterKey(new Headers(init?.headers)); + return codexRosterResponse(rostersByCredential[credential] ?? []); + } + return originalFetch(input, init); + }) as typeof fetch; +} + function mockUpstream(capture: { urls: string[]; bodies: string[]; auths: Array; -}): void { +}, rostersByCredential: Readonly> = {}): void { globalThis.fetch = (async (input, init) => { - capture.urls.push(String(input)); - capture.bodies.push(typeof init?.body === "string" ? init.body : ""); const headers = new Headers(init?.headers); + if (isCodexModelsFetch(input)) { + const credential = codexRosterKey(headers); + return codexRosterResponse(rostersByCredential[credential] ?? []); + } + const body = typeof init?.body === "string" ? init.body : ""; + capture.urls.push(String(input)); + capture.bodies.push(body); capture.auths.push(headers.get("authorization")); return Response.json({ id: "resp_test", object: "response", status: "completed", - model: "gpt-5.6-sol", + model: (JSON.parse(body) as { model?: string }).model, output: [], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, }); @@ -241,7 +284,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { }) as typeof fetch; const response = await postSpawn(cfg, { - model: "side/gpt-5.6-sol", + model: "side/gpt-5.5", input: readableAgentInput(), stream: false, }); @@ -254,7 +297,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { expect(urls.length).toBeGreaterThan(0); expect(urls.every(url => url.includes("chatgpt.com/backend-api/codex"))).toBe(true); expect(new Set(accounts)).toEqual(new Set(["pool_acc"])); - expect(new Set(models)).toEqual(new Set(["gpt-5.6-sol"])); + expect(new Set(models)).toEqual(new Set(["gpt-5.5"])); }); test("cooled primary with no probe lease selects healthy routed fallback", async () => { @@ -274,7 +317,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { const response = await postSpawn( cfg, - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { model: "gpt-5.5", input: readableAgentInput(), stream: false }, { onCodexAuthContextResolved: (ctx) => authPublications.push(ctx) }, ); @@ -304,7 +347,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { }) as typeof fetch; const response = await postSpawn(cfg, { - model: "gpt-5.6-sol", + model: "gpt-5.5", input: readableAgentInput(), stream: false, }); @@ -327,7 +370,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1000); recordCodexUpstreamOutcome(cfg, "pool-a", 429, { resetAt, now }); const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); - noteSubagentModelFailure("gpt-5.6-sol", "429", cfg, "pool-a"); + noteSubagentModelFailure("gpt-5.5", "429", cfg, "pool-a"); const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS; Date.now = () => probeAt; @@ -339,7 +382,7 @@ describe("subagent fallback without primary auth cooldown failure", () => { const response = await postSpawn( cfg, - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { model: "gpt-5.5", input: readableAgentInput(), stream: false }, { onCodexAuthContextResolved: (ctx) => { authPublications.push(ctx); @@ -553,7 +596,7 @@ describe("subagent fallback final-route normalization", () => { noteSubagentModelFailure("grok-4.5", "429", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture); + mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", @@ -586,13 +629,13 @@ describe("subagent fallback final-route normalization", () => { subagentModelFallback: ["xai/grok-4.5"], }); const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); - noteSubagentModelFailure("gpt-5.6-sol", "rate limit exceeded", cfg); + noteSubagentModelFailure("gpt-5.5", "rate limit exceeded", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; mockUpstream(capture); const response = await postSpawn(cfg, { - model: "gpt-5.6-sol", + model: "gpt-5.5", input: readableAgentInput(), stream: false, reasoning: { effort: "max" }, @@ -628,7 +671,7 @@ describe("subagent fallback final-route normalization", () => { }); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture); + mockUpstream(capture, { "Bearer caller-codex-token": ["gpt-5.6-terra"] }); const response = await postSpawn(cfg, { model: "xai/grok-4.5", @@ -663,13 +706,13 @@ describe("subagent fallback final-route normalization", () => { subagentModelFallback: ["xai/grok-4.5"], }); const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); - noteSubagentModelFailure("gpt-5.6-sol", "rate limit exceeded", cfg); + noteSubagentModelFailure("gpt-5.5", "rate limit exceeded", cfg); const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; mockUpstream(capture); const response = await postSpawn(cfg, { - model: "gpt-5.6-sol", + model: "gpt-5.5", input: readableAgentInput(), stream: false, }); @@ -684,6 +727,12 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + // Sol/Terra/Luna are account-gated; grant them only to the accounts this + // preview test configured instead of giving every discovery caller a roster. + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -741,6 +790,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -986,6 +1039,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -1025,7 +1082,10 @@ describe("native fallback account preview", () => { let finalAuth: CodexAuthContext | undefined; const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; - mockUpstream(capture); + mockUpstream(capture, { + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); const response = await postSpawn( cfg, @@ -1043,6 +1103,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", @@ -1092,6 +1156,10 @@ describe("native fallback account preview", () => { const now = 1_800_000_000_000; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ activeCodexAccountId: "pool-a", @@ -1257,7 +1325,7 @@ describe("native passthrough terminal finalization", () => { try { const response = await postSpawn( cfg, - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: true }, + { model: "gpt-5.5", input: readableAgentInput(), stream: true }, { onNativePassthroughTerminal: (status) => terminals.push(status), }, @@ -1267,7 +1335,7 @@ describe("native passthrough terminal finalization", () => { await Bun.sleep(20); return { terminals, - healthBlocked: isModelHealthBlocked("gpt-5.6-sol", cfg, "pool-a"), + healthBlocked: isModelHealthBlocked("gpt-5.5", cfg, "pool-a"), responseText, }; } finally { @@ -1326,7 +1394,7 @@ describe("darwin explicit eager-relay path selection", () => { mockSseUpstream(completedSse); return postSpawn( poolNativePlusRoutedConfig({ streamMode, activeCodexAccountId: "pool-a" }), - { model: "gpt-5.6-sol", input: readableAgentInput(), stream: true }, + { model: "gpt-5.5", input: readableAgentInput(), stream: true }, ); } diff --git a/tests/vision-reasoning-contract.test.ts b/tests/vision-reasoning-contract.test.ts index 813e462b1d..0627422d92 100644 --- a/tests/vision-reasoning-contract.test.ts +++ b/tests/vision-reasoning-contract.test.ts @@ -5,6 +5,10 @@ import { join } from "node:path"; import { handleConfigCommand } from "../src/cli/config-command"; import { handleManagementAPI } from "../src/server/management-api"; import { listManagementModelRows } from "../src/server/management/model-rows"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; import type { OcxConfig } from "../src/types"; import { resolveOpenAiVisionModel } from "../src/vision"; import { ManagementRequest as Request } from "./helpers/management-auth"; @@ -54,6 +58,9 @@ function validCliConfig(visionSidecar: Record): Record { test("native management rows expose vision-safe reasoning ladders", async () => { + // This contract is about the effort ladders themselves; Sol/Luna are account-gated, + // so confirm a roster or their rows would be filtered before the ladder is read. + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol", "gpt-5.6-luna"]); const config: OcxConfig = { port: 10100, defaultProvider: "none", providers: {} }; const rows = await listManagementModelRows(config); const efforts = (id: string) => (rows.find(row => row.native === true && row.id === id) as @@ -209,6 +216,7 @@ describe("vision reasoning capability contracts", () => { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(isolatedHome, { recursive: true, force: true }); + resetCodexModelEntitlementCacheForTests(); } }); }); diff --git a/tests/ws-upstream.test.ts b/tests/ws-upstream.test.ts index 2d7eba9a38..c16060c9f5 100644 --- a/tests/ws-upstream.test.ts +++ b/tests/ws-upstream.test.ts @@ -48,7 +48,7 @@ function streamingInit(body: Record = {}): RequestInit { return { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer test" }, - body: JSON.stringify({ model: "gpt-5.6-luna", stream: true, ...body }), + body: JSON.stringify({ model: "gpt-5.5", stream: true, ...body }), }; } @@ -109,7 +109,7 @@ describe("shouldUseCodexWsUpstream", () => { // Non-streaming turns keep HTTP: the WS path only speaks the event protocol. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", - body: JSON.stringify({ model: "gpt-5.6-luna" }), + body: JSON.stringify({ model: "gpt-5.5" }), })).toBe(false); expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "GET" })).toBe(false); expect(shouldUseCodexWsUpstream("https://api.openai.com/v1/responses", streamingInit())).toBe(false); @@ -121,12 +121,12 @@ describe("shouldUseCodexWsUpstream", () => { // Nested stream:true must not flip the transport. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", - body: JSON.stringify({ model: "gpt-5.6-luna", metadata: { stream: true } }), + body: JSON.stringify({ model: "gpt-5.5", metadata: { stream: true } }), })).toBe(false); // Whitespace-formatted JSON still routes. expect(shouldUseCodexWsUpstream(CODEX_URL, { method: "POST", - body: "{\n \"model\": \"gpt-5.6-luna\",\n \"stream\" : true\n}", + body: "{\n \"model\": \"gpt-5.5\",\n \"stream\" : true\n}", })).toBe(true); // Non-boolean stream values stay on HTTP. expect(shouldUseCodexWsUpstream(CODEX_URL, { @@ -264,7 +264,7 @@ describe("handleResponses Codex WS relay selection", () => { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer test" }, - body: JSON.stringify({ model: "gpt-5.6-luna", input: "hello", stream: true }), + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true }), }); } From 6b08567fa8cb6701925df8242ced592715f8759f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 00:24:40 +0900 Subject: [PATCH 007/336] test(codex): grant the gated 5.6 roster to the candidate-scope preview cases (#2570) #2550 made gpt-5.6-* account-gated and fails closed when the entitlement snapshot has no roster for an account. Two preview cases added by #2515 bind on gpt-5.6-sol without installing a roster mock, so on dev they now throw CodexPoolAuthenticationError. Both PRs were green in isolation; the conflict is semantic and only appears once both are on dev. Grants the same roster the neighbouring preview cases already install. --- tests/subagent-fallback-handle-responses.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index e3a42b4db1..001d182a19 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -843,6 +843,12 @@ describe("native fallback account preview", () => { const now = cooldownAt + CODEX_QUOTA_PROBE_INTERVAL_MS + 1; Date.now = () => now; installPoolCredential("pool-a", "pool_acc_a", now); + // This case binds on gpt-5.6-sol, which is account-gated: without a roster the + // entitlement snapshot fails closed and no account is eligible (#2550). + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ activeCodexAccountId: "pool-a", @@ -910,6 +916,11 @@ describe("native fallback account preview", () => { let currentNow = now; Date.now = () => currentNow; installPoolCredential("pool-a", "pool_acc_a", now); + // Same account-gated binding as above: grant the roster to both pool accounts. + installCodexRosterMock({ + pool_acc_a: GPT56_NATIVE_MODELS, + pool_acc_b: GPT56_NATIVE_MODELS, + }); installPoolCredential("pool-b", "pool_acc_b", now); const cfg = poolNativePlusRoutedConfig({ defaultProvider: "xai", From 516f566b1a054ede21550be076114805e18742d0 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Wed, 26 Aug 2026 00:44:31 +0900 Subject: [PATCH 008/336] fix(cursor): bind ref-less checkpoints to conversation owners (#2563) * fix(cursor): bind ref-less checkpoints to owners * fix(cursor): address checkpoint review findings * docs(cursor): clarify checkpoint privacy fallbacks * docs(cursor): align translated transport behavior * docs(cursor): mention discovery transport pin --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../src/content/docs/ja/reference/adapters.md | 8 +- .../src/content/docs/ko/reference/adapters.md | 20 +- .../src/content/docs/reference/adapters.md | 8 + .../src/content/docs/ru/reference/adapters.md | 24 +- .../content/docs/zh-cn/reference/adapters.md | 13 + src/adapters/cursor.ts | 12 +- src/adapters/cursor/checkpoint-store.ts | 25 +- src/adapters/cursor/request-builder.ts | 15 +- src/server/responses/core.ts | 3 + src/types/request.ts | 2 + structure/04_transports-and-sidecars.md | 12 +- tests/cursor-adapter.test.ts | 50 ++++ tests/cursor-request-builder.test.ts | 249 +++++++++++++++++- tests/server-combo-failover-e2e.test.ts | 30 +++ 14 files changed, 437 insertions(+), 34 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 54bd07eef8..17d0630e4a 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -122,12 +122,16 @@ filtered incomplete になります。実際のツール呼び出しを伴わな ## `cursor` -**対象:** `api2.cursor.sh` の HTTP/2 Connect ストリーミング -`agent.v1.AgentService/Run`。 +**対象:** デフォルトでは `api2.cursor.sh` の HTTP/2 Connect ストリーミング +`agent.v1.AgentService/Run`。`upstreamHttpVersion: "http1.1"`(または `"h1"`)では Cursor の +HTTP/1.1 互換トランスポートを使い、サーバー出力を `agent.v1.AgentService/RunSSE`、クライアント +メッセージを `aiserver.v1.BidiService/BidiAppend` で送受信します。この設定は inference と live +model discovery の両方に適用されます。 **認証:** `provider.apiKey` または転送された authorization ヘッダーの Cursor OAuth/access token。 - 通常の fetch/parse 経路の代わりに `runTurn` を使います。リクエスト、サーバーイベント、ツール引数、使用量 checkpoint、クライアントレスポンスは `cursor/gen/agent_pb.ts` の `@bufbuild/protobuf` スキーマでエンコードしたのち Connect メッセージとして framing します。 - content-addressed blob で対話状態を再生し、サーバーツール呼び出しを Codex に再マッピングします。protobuf の `GetUsableModels` RPC でリアルタイム Cursor モデルを探し、run リクエストが wire に commit される前だけリトライします。 +- ツールなしで正常終了したターンでは、返された ConversationStateStructure をプロセスローカルに保持し、検証済みの線形継続で checkpoint を再利用します。tool-result ターンでは、対象メッセージ境界が判明している場合、最後に正常終了したターンの checkpoint に未収録の suffix だけを追加します。ref のない prefix lookup は、記憶済みの Cursor conversation または安定した client thread(制限付きの Desktop session/thread fallback を含む)があり、同じ provider conversation が所有する checkpoint が一意に一致する場合だけ許可します。それ以外は full replay に戻ります。compaction、helper/shadow の分離、account/model の不一致、ref の欠落、decode の失敗、forced-fresh recovery、invalid_argument retry でも full replay を使います。プロセスを再起動するとメモリ内 store は失われ、full replay になります。Cursor Connect は権威ある cache_read_tokens を公開しないため、OpenCodex usage は cache-hit counter ではありません。制限付き Desktop fallback が保存するのはプロセスローカルで HMAC から導出した owner だけで、raw session/thread header や OAuth/authorization material を checkpoint state に書き込みません。OAuth-backed live transport とアカウントで絞り込む live model discovery は実験的です。ログインと transport の設定は [provider guide](/ja/guides/providers/) と [Cursor provider configuration](/ja/reference/configuration/providers/#cursor-provider-adapter-cursor) を参照してください。checkpoint reuse 自体は自動で、ユーザー設定はありません。 - `cursor/grok-4.5-fast` は選択可能なモデルとして維持しつつ、Cursor には正規の `grok-4.5` モデルを送信し、個別の `effort` および `fast=true` 値は `requested_model.parameters` に格納します。 - Cursor ネイティブのローカルファイルシステム/shell/network 実行はデフォルトで拒否します。明示的な `mcpServers` と `desktopExecutor` 統合はそれぞれ別の opt-in です。`unsafeAllowNativeLocalExec` はより広い組み込み executor を有効にし、Codex の承認/サンドボックスルールを迂回します。 diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index abf353b871..2a4a3afccb 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -139,8 +139,11 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. ## `cursor` -**대상:** `api2.cursor.sh`의 HTTP/2 Connect 스트리밍 -`agent.v1.AgentService/Run`. +**대상:** 기본값은 `api2.cursor.sh`의 HTTP/2 Connect 스트리밍 +`agent.v1.AgentService/Run`입니다. `upstreamHttpVersion: "http1.1"` 또는 `"h1"`을 설정하면 +Cursor의 HTTP/1.1 호환 조합을 사용합니다. 서버 출력은 `agent.v1.AgentService/RunSSE`, 클라이언트 +메시지는 `aiserver.v1.BidiService/BidiAppend`로 전송합니다. 이 설정은 추론과 live model +discovery에 모두 적용됩니다. **인증:** `provider.apiKey` 또는 전달된 authorization 헤더의 Cursor OAuth/access token. - 일반 fetch/parse 경로 대신 `runTurn`을 사용합니다. 요청, 서버 이벤트, 툴 인자, 사용량 checkpoint, @@ -151,11 +154,20 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. 재시도합니다. - 도구 없이 정상 완료된 턴 뒤에는 Cursor가 돌려준 ConversationStateStructure를 프로세스 로컬 store에 보관하고, 검증된 선형 이어말하기에서는 전체 root history를 다시 만들지 않고 그 - checkpoint를 재사용합니다. tool-result 턴은 마지막 정상 완료 턴의 checkpoint에 커버되지 않은 - suffix만 붙입니다. compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패, + checkpoint를 재사용합니다. tool-result 턴은 커버된 메시지 경계를 알 수 있을 때만 마지막 정상 + 완료 턴의 checkpoint에 커버되지 않은 suffix를 붙입니다. ref 없는 prefix 조회는 기억된 Cursor + 대화 또는 안정적인 클라이언트 스레드 + (범위가 제한된 Desktop session/thread 대체 식별자 포함)와 같은 provider 대화가 소유한 + checkpoint가 있을 때만 허용하며, 그 외에는 full replay합니다. + compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패, forced-fresh 복구, invalid_argument 재시도는 기존 full replay로 돌아갑니다. 프로세스 재시작은 메모리 store를 버리고 full replay합니다. Cursor Connect는 권위 있는 cache_read_tokens를 주지 않으므로 OpenCodex usage만 보고 cache hit라고 단정하지 않습니다. + 범위가 제한된 Desktop 대체 식별자는 프로세스 로컬 HMAC 파생 소유자만 보관하며, 원본 + session/thread 헤더나 OAuth/authorization 자료를 checkpoint 상태에 쓰지 않습니다. OAuth 기반 + live transport와 계정별 live model discovery는 아직 실험 기능입니다. 로그인과 transport 설정은 + [공급자 가이드](/ko/guides/providers/)와 [Cursor 공급자 설정](/ko/reference/configuration/providers/#cursor-provider-adapter-cursor)을 + 참고하세요. checkpoint 재사용 자체는 자동이며 사용자 설정이 없습니다. - `cursor/grok-4.5-fast`는 선택 가능한 모델로 유지하되, Cursor에는 정식 `grok-4.5` 모델을 보내고 별도의 `effort`, `fast=true` 값은 `requested_model.parameters`에 담습니다. - Cursor 네이티브 로컬 파일시스템/shell/network 실행은 기본적으로 거부합니다. 명시적인 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1b96ebac7a..b6e7c6006d 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -226,10 +226,18 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and in a process-local store and reuses that checkpoint on the next validated linear continuation instead of rebuilding the full root history. Tool-result turns reuse the last completed-turn checkpoint plus only the uncovered suffix when the covered message boundary is known. + Ref-less prefix lookup requires a remembered Cursor conversation or stable client thread + (including the bounded Desktop session/thread fallback) and a checkpoint owned by that same + provider conversation; otherwise it full-replays. Compaction, helper/shadow isolation, account/model mismatch, missing refs, decode failures, forced-fresh recovery, and invalid_argument retries fall back to the existing full replay. A process restart drops the in-memory store and full-replays. Cursor Connect still does not expose authoritative cache_read_tokens, so OpenCodex usage is not a cache-hit counter. + The bounded Desktop fallback stores only a process-local HMAC-derived owner; raw session/thread + headers and OAuth/authorization material are never written to checkpoint state. Cursor's + OAuth-backed live transport and account-filtered model discovery remain experimental; see the + [provider guide](/guides/providers/) and [Cursor provider configuration](/reference/configuration/providers/#cursor-provider-adapter-cursor) + for login and transport settings. Checkpoint reuse itself is automatic and has no user setting. - Honors `upstreamHttpVersion` for both live model discovery and inference. `auto`, `http2`, and `h2` preserve the existing HTTP/2 transport; only `http1.1` and `h1` select compatibility mode. - Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`, diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index eca16ca669..3f6e45c00b 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -144,8 +144,11 @@ incomplete. `TOOL_USE` без фактического вызова инстру ## `cursor` -**Назначение:** `agent.v1.AgentService/Run` Cursor поверх потокового HTTP/2 Connect на -`api2.cursor.sh`. +**Назначение:** по умолчанию `agent.v1.AgentService/Run` Cursor поверх потокового HTTP/2 Connect +на `api2.cursor.sh`. При `upstreamHttpVersion: "http1.1"` (или `"h1"`) используется совместимый +транспорт HTTP/1.1: `agent.v1.AgentService/RunSSE` для вывода сервера и +`aiserver.v1.BidiService/BidiAppend` для сообщений клиента. Эта настройка применяется и к +inference, и к live model discovery. **Аутентификация:** Cursor OAuth/access token из `provider.apiKey` или из переданного заголовка authorization. @@ -155,6 +158,23 @@ authorization. - Воспроизводит состояние диалога через content-addressed blob'ы, отображает серверные вызовы инструментов обратно в Codex, обнаруживает актуальные модели Cursor через protobuf RPC `GetUsableModels` и повторяет попытки только до того, как run-запрос зафиксирован на wire. +- После успешно завершённого хода без инструментов хранит возвращённую ConversationStateStructure + локально в процессе и повторно использует checkpoint для проверенного линейного продолжения. Ходы + с результатом инструмента используют checkpoint последнего завершённого хода и только ещё не + охваченный suffix, когда известна граница охваченных сообщений. Поиск по префиксу без ref разрешён + только при наличии запомненного разговора Cursor или стабильного идентификатора client thread + (включая ограниченный fallback по Desktop session/thread) и единственного совпадающего checkpoint, + принадлежащего тому же разговору provider; иначе выполняется full replay. Compaction, изоляция + helper/shadow, несовпадение account/model, отсутствие ref, ошибки decode, forced-fresh recovery и + повтор после invalid_argument также используют full replay. Перезапуск процесса удаляет хранилище + из памяти и приводит к full replay. Cursor Connect не предоставляет достоверный + cache_read_tokens, поэтому usage OpenCodex не является счётчиком cache hit. Ограниченный Desktop + fallback хранит только владельца, выведенного через HMAC локально в процессе; исходные заголовки + session/thread и данные OAuth/authorization в checkpoint state не записываются. Live transport с + OAuth и фильтрация live model discovery по аккаунту остаются экспериментальными. Настройки входа + и transport описаны в [руководстве по провайдерам](/ru/guides/providers/) и + [конфигурации провайдера Cursor](/ru/reference/configuration/providers/#cursor-provider-adapter-cursor). + Повторное использование checkpoint выполняется автоматически и не имеет пользовательской настройки. - Сохраняет `cursor/grok-4.5-fast` доступной для выбора, но отправляет Cursor каноническую модель `grok-4.5`, помещая отдельные значения `effort` и `fast=true` в `requested_model.parameters`. - Нативное для Cursor локальное выполнение операций с файловой системой/shell/сетью по умолчанию diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 5786952810..8d56bf77d8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -139,6 +139,19 @@ Cursor 的 HTTP/1.1 兼容传输:通过 `agent.v1.AgentService/RunSSE` 接收 Connect message。 - 经 content-addressed blob 重放对话状态,把 server tool call 映射回 Codex,用 protobuf `GetUsableModels` RPC 发现实时 Cursor 模型,并且只在 run request 尚未 commit 到 wire 前重试。 +- 对不含工具且正常完成的 turn,会在进程本地保存返回的 ConversationStateStructure,并在经过验证的 + 线性 continuation 中复用 checkpoint。tool-result turn 会在已知覆盖消息边界时,复用最后一个已完成 + turn 的 checkpoint,并只追加尚未覆盖的 suffix。无 ref 的 prefix lookup 仅在存在已记忆的 Cursor + conversation 或稳定 client thread(包括受限的 Desktop session/thread fallback),且唯一匹配的 + checkpoint 由同一 provider conversation 所有时才允许;否则执行 full replay。compaction、 + helper/shadow 隔离、account/model 不匹配、ref 缺失、decode 失败、forced-fresh recovery 以及 + invalid_argument 重试也会回退到 full replay。进程重启会丢弃内存 store 并执行 full replay。 + Cursor Connect 不提供权威的 cache_read_tokens,因此 OpenCodex usage 不是 cache hit 计数器。 + 受限的 Desktop fallback 只保存进程本地由 HMAC 派生的 owner;原始 session/thread header 与 + OAuth/authorization 材料不会写入 checkpoint state。基于 OAuth 的 live transport 和按账号过滤的 + live model discovery 仍是实验功能;登录与 transport 设置参见[提供商指南](/zh-cn/guides/providers/) + 和 [Cursor 提供商配置](/zh-cn/reference/configuration/providers/#cursor-provider-adapter-cursor)。 + checkpoint 复用本身是自动的,没有用户设置。 - 模型实时发现和推理都会遵守 `upstreamHttpVersion`。`auto`、`http2` 与 `h2` 保持原有 HTTP/2 transport;只有 `http1.1` 与 `h1` 会选择兼容模式。 - 保留 `cursor/grok-4.5-fast` 作为可选模型,但向 Cursor 发送规范的 `grok-4.5` 模型,并将独立的 diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 4089e81ed3..b0cfeca602 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -9,6 +9,7 @@ import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { createCursorRequest, + cursorClientThreadOwner, cursorCoveredPrefixDigest, cursorInstructionDigest, } from "./cursor/request-builder"; @@ -265,12 +266,13 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda request = createCursorRequest(_parsed, { forceFreshConversation: true }); rekeyContextUsage(failedConversationId, request.conversationId); _parsed._cursorConversationId = request.conversationId; - // Persist recovery for store:false clients that only send a parent thread id, so the - // next turn does not recompute the stale deterministic thread hash. Isolated helper / - // compaction turns must not park their throwaway id under the parent thread key. - if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) { + // Persist recovery for store:false clients that send any stable Cursor thread owner, so + // the next turn does not recompute the stale deterministic thread hash. Isolated helper / + // compaction turns must not park their throwaway id under the parent or Desktop owner. + const threadOwner = cursorClientThreadOwner(_parsed); + if (threadOwner && _parsed._cursorIsolateConversation !== true) { rememberCursorThreadConversation( - _parsed._clientThreadId, + threadOwner, request.conversationId, _parsed._cursorIdentityScope, ); diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 337b07caa5..b9d78d9716 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -236,6 +236,7 @@ export function commitCursorCheckpoint(input: { } export function getCursorCheckpointForPrefix(input: { + conversationId: string; prefixDigest: string; systemDigest: string; coveredMessageCount: number; @@ -244,17 +245,21 @@ export function getCursorCheckpointForPrefix(input: { }): CursorCheckpointSnapshot | undefined { prune(); const refs = store.prefixIndex.get(input.prefixDigest); - if (!refs || refs.size !== 1) return undefined; - const [ref] = refs; - if (!ref) return undefined; - const snapshot = getCursorCheckpoint(ref); - if (!snapshot) return undefined; + if (!refs) return undefined; const identityScope = input.identityScope?.trim() || "local"; - if (snapshot.systemDigest !== input.systemDigest) return undefined; - if (snapshot.coveredMessageCount !== input.coveredMessageCount) return undefined; - if (snapshot.identityScope !== identityScope) return undefined; - if (snapshot.modelId !== input.modelId) return undefined; - return snapshot; + let foundRef: string | undefined; + for (const ref of refs) { + const snapshot = store.snapshots.get(ref); + if (!snapshot) continue; + if (snapshot.conversationId !== input.conversationId) continue; + if (snapshot.systemDigest !== input.systemDigest) continue; + if (snapshot.coveredMessageCount !== input.coveredMessageCount) continue; + if (snapshot.identityScope !== identityScope) continue; + if (snapshot.modelId !== input.modelId) continue; + if (foundRef) return undefined; + foundRef = ref; + } + return getCursorCheckpoint(foundRef); } export function getLatestCursorCheckpoint( diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 9d0e83cbbf..858db97b27 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -298,7 +298,7 @@ export function cursorConversationIdFromClientThread(threadId: string, identityS /** * Resolve the Cursor conversation id for this turn. - * Priority: force-fresh → isolate helper → remembered → thread override → client thread → random. + * Priority: force-fresh → isolate helper → remembered → client thread owner → random. * Never use OpenAI Responses `previous_response_id` (resp_*) or shared `prompt_cache_key` * (cache-cohort fingerprint, not conversation ownership). */ @@ -310,7 +310,7 @@ export function resolveCursorConversationId( if (options.forceFreshConversation === true) return generatedCursorConversationId(); if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId(); if (parsed._cursorConversationId) return parsed._cursorConversationId; - const threadId = parsed._clientThreadId?.trim(); + const threadId = cursorClientThreadOwner(parsed); if (threadId) { const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope); if (recovered) return recovered; @@ -319,6 +319,10 @@ export function resolveCursorConversationId( return generatedCursorConversationId(); } +export function cursorClientThreadOwner(parsed: OcxParsedRequest): string | undefined { + return parsed._clientThreadId?.trim() || parsed._cursorClientThreadId?.trim() || undefined; +} + function updateFramed(hash: ReturnType, value: string): void { const bytes = Buffer.from(value, "utf8"); const length = Buffer.allocUnsafe(4); @@ -361,6 +365,7 @@ function lookupPrefixSnapshot( const modelId = cursorCheckpointModelAffinityId(request.modelId); for (let covered = parsed.context.messages.length; covered >= 1; covered--) { const snapshot = getCursorCheckpointForPrefix({ + conversationId: request.conversationId, prefixDigest: cursorCoveredPrefixDigest(parsed, covered), systemDigest, coveredMessageCount: covered, @@ -404,10 +409,14 @@ function resolveCursorCheckpoint( snapshot = getCursorCheckpoint(ref); if (!snapshot) return { reason: "expired" }; } else { + if ( + isolated + || (!parsed._cursorConversationId && !cursorClientThreadOwner(parsed)) + ) return { reason: "missing_ref" }; snapshot = lookupPrefixSnapshot(parsed, request, identityScope); if (!snapshot) return { reason: "missing_ref" }; } - if (!isolated && snapshot.conversationId !== request.conversationId && ref) { + if (snapshot.conversationId !== request.conversationId) { return { reason: "conversation_changed" }; } if (snapshot.identityScope !== identityScope) return { reason: "identity_changed" }; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6557aa7094..21288462fc 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2185,6 +2185,7 @@ async function handleResponsesInner( (body as { input?: unknown } | undefined)?.input, ); const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; + const cursorClientThreadId = codexPoolAffinityKey(req.headers); const originalBody = body; if (options.comboReplaySnapshot) { copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body); @@ -2254,6 +2255,7 @@ async function handleResponsesInner( parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey }; } } + if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId; } catch (err) { if (isTranslatorBudgetExceededError(err)) { return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { @@ -2474,6 +2476,7 @@ async function handleResponsesInner( "_providerContinuationOwner", "_cursorConversationId", "_clientThreadId", + "_cursorClientThreadId", "_reasoningReplayScope", "_cursorIsolateConversation", ]; diff --git a/src/types/request.ts b/src/types/request.ts index 56156d8443..efe2164e37 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -68,6 +68,8 @@ export interface OcxParsedRequest { _cursorConversationId?: string; /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ _clientThreadId?: string; + /** Cursor-only thread owner; may be an opaque process-local Desktop session/thread identity. */ + _cursorClientThreadId?: string; /** Conversation/provider/account/model-bound namespace for reasoning replay state. */ _reasoningReplayScope?: OcxReasoningReplayScopeRef; /** diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index f7b9b1859b..9ce5fb08d7 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -734,9 +734,13 @@ pre-compaction checkpoint is not persisted for later carry-forward. After a successful no-tool turn, the Cursor adapter keeps the returned ConversationStateStructure in a process-local store and reuses that snapshot on the next validated linear continuation instead of rebuilding rootPromptMessagesJson and conversationTurns. Tool-result turns reuse the last completed -checkpoint plus only the uncovered suffix. Chat Completions hops that omit previous_response_id and thread headers reuse a snapshot only when -the covered message prefix and system/developer digest match exactly one stored snapshot. Isolated -helper/shadow turns never join the parent conversation. An explicit missing checkpointRef full-replays. Compaction, account or model mismatch, missing refs, decode failures, and +checkpoint plus only the uncovered suffix. A request without checkpointRef may use the prefix index +only when a remembered Cursor conversation or stable client thread owns the resolved conversation id. +The stable owner may be the Codex parent-thread header or the existing bounded process-local HMAC of +the complete Desktop session-id/thread-id pair. The request must also have a covered message prefix +and system/developer digest that match exactly one snapshot for that same +conversation. Headerless requests without a stable owner full-replay. Isolated helper/shadow turns +never join the parent or sibling conversation. An explicit missing checkpointRef full-replays. Compaction, account or model mismatch, missing refs, decode failures, and invalid_argument recovery keep the existing full-replay path. previous_response_id may select a branch's opaque checkpointRef; it is never a Cursor conversation ownership key. Cursor Connect still does not expose authoritative cache_read_tokens. @@ -746,7 +750,7 @@ does not expose authoritative cache_read_tokens. - 목적과 의도: Reuse Cursor's returned ConversationStateStructure on validated linear continuations so OpenCodex does not rebuild the full root history every turn. - 기존 구현 및 제약 조건: Stable conversation ids already exist (#366), but every turn still reconstructed rootPromptMessagesJson and conversationTurns. Cursor Connect still reports only usedTokens/maxTokens, so cache_read_tokens cannot be treated as authoritative (#275). - 검토한 주요 대안: Keep full replay; copy Pi's live MCP bridge immediately; store raw protobuf in Responses JSON; key checkpoints only by conversation id. -- 선택한 방식: Keep an opaque process-local checkpointRef on OcxProviderContinuationState.cursor, bind the snapshot to conversation/account/model affinity, pin referenced blobs for the checkpoint lifetime, and fall back to the existing full-replay path for isolation, compaction, restart, missing refs, and invalid_argument recovery. Tool-result turns reuse the last completed checkpoint plus an uncovered suffix. previous_response_id is a branch anchor, never a Cursor conversation ownership key. +- 선택한 방식: Keep an opaque process-local checkpointRef on OcxProviderContinuationState.cursor, bind the snapshot to conversation/account/model affinity, and require a remembered provider conversation or stable client thread before a ref-less prefix lookup. Reuse the bounded process-local Desktop session/thread HMAC when the canonical parent-thread header is absent. Pin referenced blobs for the checkpoint lifetime, and fall back to the existing full-replay path for unowned headerless requests, isolation, compaction, restart, missing refs, and invalid_argument recovery. Tool-result turns reuse the last completed checkpoint plus an uncovered suffix. previous_response_id is a branch anchor, never a Cursor conversation ownership key. - 다른 대안 대신 이 방식을 선택한 이유: It removes avoidable replay cost without claiming cache-hit rates, without changing OAuth, and without collapsing helper/compaction isolation or tool-call replay safety. - 장점, 단점 및 영향: Validated no-tool follow-ups stop growing local rootBytes with history; a process restart or missing blob lease falls back to full replay; large-context 429 / premature-completion acceptance for #1527 is still unproven; a stateful live MCP bridge remains out of scope. ``` diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index a53c1e4cb6..ca71ae073c 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -357,6 +357,56 @@ describe("Cursor adapter live transport", () => { expect(events.filter(event => event.type === "error")).toHaveLength(0); }); + test("forced-fresh recovery remembers a Cursor-only Desktop owner", async () => { + clearCursorThreadContinuityForTests(); + const seen: string[] = []; + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + if (attempts === 1) { + throw Object.assign( + new Error("Cursor invalid request: Cursor Connect error invalid_argument: Error"), + { code: "invalid_argument" }, + ); + } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + + const owner = "app:desktop-recovery-owner"; + const identityScope = "acct-desktop-recovery"; + const body: OcxParsedRequest = { + modelId: "cursor/gpt-5.6-sol", + context: { + messages: [ + { role: "user", content: "first turn", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "ack" }], timestamp: 2 }, + { role: "user", content: "second turn", timestamp: 3 }, + ], + }, + stream: false, + options: { reasoning: "xhigh" }, + _cursorClientThreadId: owner, + _cursorConversationId: "cursor_stale_desktop", + _cursorIdentityScope: identityScope, + }; + + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + + expect(attempts).toBe(2); + expect(seen[1]).not.toBe(seen[0]); + expect(lookupCursorThreadConversation(owner, identityScope)).toBe(seen[1]); + clearCursorThreadContinuityForTests(); + }); + test("forced-fresh recovery keeps the new checkpoint instead of deleting it", async () => { clearCursorCheckpointsForTests(); const parentBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index 21548e26f4..754caf2059 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -4,7 +4,9 @@ import { ConversationStateStructureSchema } from "../src/adapters/cursor/gen/age import { clearCursorCheckpointsForTests, commitCursorCheckpoint, + CURSOR_CHECKPOINT_TTL_MS, getCursorCheckpoint, + installCursorCheckpointClockForTests, } from "../src/adapters/cursor/checkpoint-store"; import { applyCursorToolBudget, @@ -76,6 +78,60 @@ describe("Cursor request builder", () => { expect(continuation.conversationId).toBe(initial.conversationId); }); + test("uses a Cursor-only Desktop owner without widening Responses replay scope", () => { + const a = createCursorRequest({ + ...base, + _cursorClientThreadId: "app:desktop-owner-a", + }); + const same = createCursorRequest({ + ...base, + _cursorClientThreadId: "app:desktop-owner-a", + }); + const b = createCursorRequest({ + ...base, + _cursorClientThreadId: "app:desktop-owner-b", + }); + expect(same.conversationId).toBe(a.conversationId); + expect(b.conversationId).not.toBe(a.conversationId); + }); + + test("uses a Cursor-only Desktop owner for ref-less checkpoint admission", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + _cursorClientThreadId: "app:desktop-checkpoint-owner", + _cursorIdentityScope: "acct-desktop", + context: { messages: [{ role: "user" as const, content: "desktop prefix", timestamp: 1 }] }, + }; + const built = createCursorRequest(firstTurn); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["desktop-owned-state"], + })); + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-desktop", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + + const followUp = createCursorRequest({ + ...firstTurn, + context: { + messages: [ + ...firstTurn.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(followUp.continuationMode).toBe("checkpoint"); + expect(followUp.checkpointBytes).toEqual(checkpointBytes); + clearCursorCheckpointsForTests(); + }); + test("prefix digests do not collide across delimiter boundaries", () => { const left = { ...base, @@ -184,7 +240,7 @@ describe("Cursor request builder", () => { expect(helper.conversationId).not.toBe(main.conversationId); }); - test("isolated helper turns keep their own cache and never reuse the parent checkpoint", () => { + test("isolated helper turns never reuse parent or sibling checkpoints", () => { clearCursorCheckpointsForTests(); const parentBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { pendingToolCalls: ["parent-fixture"], @@ -254,8 +310,9 @@ describe("Cursor request builder", () => { }, }); expect(second.conversationId).not.toBe("cursor_parent_real"); - expect(second.continuationMode).toBe("checkpoint"); - expect(second.checkpointBytes?.byteLength).toBe(helperBytes.byteLength); + expect(second.continuationMode).toBe("full-replay"); + expect(second.checkpointInvalidationReason).toBe("missing_ref"); + expect(second.checkpointBytes).toBeUndefined(); expect(getCursorCheckpoint(parentRef)?.ref).toBe(parentRef); clearCursorCheckpointsForTests(); }); @@ -1201,11 +1258,12 @@ describe("Cursor request builder", () => { clearCursorCheckpointsForTests(); }); - test("reuses a unique covered prefix when chat omits the continuation ref", () => { + test("reuses a unique covered prefix when a stable client thread omits the continuation ref", () => { clearCursorCheckpointsForTests(); const firstTurn = { ...base, modelId: "cursor/gpt-5.6-sol", + _clientThreadId: "thread-prefix-owner", _cursorIdentityScope: "acct-1", context: { messages: [{ role: "user" as const, content: "unique sol prompt 7f3c", timestamp: 1 }] }, }; @@ -1236,4 +1294,187 @@ describe("Cursor request builder", () => { expect(followUp.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); clearCursorCheckpointsForTests(); }); + + test("does not reuse a unique covered prefix without a stable conversation owner", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "unowned shared prefix", timestamp: 1 }] }, + }; + const built = createCursorRequest(firstTurn); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["private-state"], + })); + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + + const unrelated = createCursorRequest({ + ...firstTurn, + context: { + messages: [ + ...firstTurn.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(unrelated.conversationId).not.toBe(built.conversationId); + expect(unrelated.continuationMode).toBe("full-replay"); + expect(unrelated.checkpointInvalidationReason).toBe("missing_ref"); + expect(unrelated.checkpointBytes).toBeUndefined(); + clearCursorCheckpointsForTests(); + }); + + test("does not reuse a unique covered prefix from a different stable client thread", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _clientThreadId: "thread-prefix-a", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "stable shared prefix", timestamp: 1 }] }, + }; + const built = createCursorRequest(firstTurn); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["thread-a-state"], + })); + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + + const unrelated = createCursorRequest({ + ...firstTurn, + _clientThreadId: "thread-prefix-b", + context: { + messages: [ + ...firstTurn.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(unrelated.conversationId).not.toBe(built.conversationId); + expect(unrelated.continuationMode).toBe("full-replay"); + expect(unrelated.checkpointInvalidationReason).toBe("missing_ref"); + expect(unrelated.checkpointBytes).toBeUndefined(); + clearCursorCheckpointsForTests(); + }); + + test("selects the owned snapshot when different conversations share a prefix", () => { + clearCursorCheckpointsForTests(); + const common = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "owned shared prefix", timestamp: 1 }] }, + }; + const parsedA = { ...common, _clientThreadId: "thread-owned-a" }; + const parsedB = { ...common, _clientThreadId: "thread-owned-b" }; + const builtA = createCursorRequest(parsedA); + const builtB = createCursorRequest(parsedB); + const bytesA = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["owned-a"], + })); + const bytesB = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["owned-b"], + })); + for (const [parsed, built, checkpointBytes] of [ + [parsedA, builtA, bytesA], + [parsedB, builtB, bytesB], + ] as const) { + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(parsed, 1), + systemDigest: cursorInstructionDigest(parsed), + })).toBeDefined(); + } + + const history = { + messages: [ + ...common.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }; + const followA = createCursorRequest({ ...parsedA, context: history }); + const followB = createCursorRequest({ ...parsedB, context: history }); + expect(followA.continuationMode).toBe("checkpoint"); + expect(followA.checkpointBytes).toEqual(bytesA); + expect(followB.continuationMode).toBe("checkpoint"); + expect(followB.checkpointBytes).toEqual(bytesB); + clearCursorCheckpointsForTests(); + }); + + test("does not refresh an unrelated same-prefix checkpoint", () => { + clearCursorCheckpointsForTests(); + let now = 1_000; + installCursorCheckpointClockForTests({ + now: () => now, + schedule: (() => 0 as unknown as ReturnType), + clear: () => {}, + }); + try { + const common = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "ttl shared prefix", timestamp: 1 }] }, + }; + const parsedA = { ...common, _clientThreadId: "thread-ttl-a" }; + const parsedB = { ...common, _clientThreadId: "thread-ttl-b" }; + const builtA = createCursorRequest(parsedA); + const builtB = createCursorRequest(parsedB); + const commit = (parsed: typeof parsedA, conversationId: string, marker: string) => commitCursorCheckpoint({ + conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(builtA.modelId), + checkpointBytes: toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: [marker], + })), + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(parsed, 1), + systemDigest: cursorInstructionDigest(parsed), + }); + expect(commit(parsedA, builtA.conversationId, "ttl-a")).toBeDefined(); + const refB = commit(parsedB, builtB.conversationId, "ttl-b"); + expect(refB).toBeDefined(); + + now += CURSOR_CHECKPOINT_TTL_MS - 1; + const followA = createCursorRequest({ + ...parsedA, + context: { + messages: [ + ...common.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(followA.continuationMode).toBe("checkpoint"); + + now += 2; + expect(getCursorCheckpoint(refB)).toBeUndefined(); + } finally { + clearCursorCheckpointsForTests(); + } + }); }); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index bfcced0f35..ad4cc46592 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -2886,6 +2886,36 @@ describe("cursor conversation continuity across store:false chains", () => { expect(seen[1]).toBe(seen[0]); }); + test("Desktop session and thread headers retain Cursor ownership without a parent-thread header", async () => { + const seen: string[] = []; + customCursorTransportFactory = fakeCursorTransportFactory(seen); + const config = cursorConfig(); + const postDesktopTurn = (input: unknown) => handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "session-id": "desktop-session-owner", + "thread-id": "desktop-thread-owner", + }, + body: JSON.stringify({ + model: "cursortest/grok-4.5", + input, + stream: false, + store: false, + }), + }), config, { model: "", provider: "" }, {}); + + expect((await postDesktopTurn("start")).status).toBe(200); + expect((await postDesktopTurn([ + { role: "user", content: "start" }, + { role: "assistant", content: "working" }, + { role: "user", content: "continue" }, + ])).status).toBe(200); + + expect(seen).toHaveLength(2); + expect(seen[1]).toBe(seen[0]); + }); + test("native composer reuses conversationId across store:false turns via parent thread id", async () => { const seen: string[] = []; customCursorTransportFactory = fakeCursorTransportFactory(seen); From 19642b3a8c855811efb98ef3de03575823d8860e Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 00:46:29 +0900 Subject: [PATCH 009/336] fix(quota): preserve Codex and Anthropic quota windows (rebase of #2490) (#2572) * Preserve Codex additional quota windows The WHAM response includes model-specific rate limits alongside the primary account limit. This change retained the Spark weekly window in the public account quota DTO instead of discarding it. * Preserve Anthropic model-scoped quota windows Anthropic returns model-scoped weekly limits separately from the canonical session and weekly buckets. This change retained those distinct limits while filtering duplicate canonical rows. * Cover Codex and Anthropic quota window parsing The regression cases exercised WHAM additional limits and Anthropic model-scoped limits so future schema handling cannot silently drop the third quota window. * Preserve monthly quota across custom-window updates Custom-window-only WHAM snapshots now retained the existing monthly quota and provenance instead of clearing a 30-day account's governing limit. Confidence: high Scope-risk: narrow Reversibility: clean Tested: bun test tests/codex-routing.test.ts tests/provider-quota.test.ts --------- Co-authored-by: Sangrak Choi --- src/codex/auth-api.ts | 1 + src/codex/quota.ts | 55 +++++++++++++++++++++++++-- src/providers/quota.ts | 66 ++++++++++++++++++++++++++++---- tests/codex-routing.test.ts | 51 +++++++++++++++++++++++++ tests/provider-quota.test.ts | 74 ++++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 10 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 49fb321841..057035c90f 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -222,6 +222,7 @@ function quotaForPlan | StoredAc ...(quota.shortPercent !== undefined ? { shortPercent: quota.shortPercent } : {}), ...(quota.shortResetAt !== undefined ? { shortResetAt: quota.shortResetAt } : {}), ...(quota.shortWindowSeconds !== undefined ? { shortWindowSeconds: quota.shortWindowSeconds } : {}), + ...(quota.customWindows !== undefined ? { customWindows: quota.customWindows } : {}), ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}), ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}), } as T; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 104db68d5b..ae9152c0ed 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -23,6 +23,7 @@ export type StoredAccountQuota = { shortPercent?: number; shortResetAt?: number; shortWindowSeconds?: number; + customWindows?: Array<{ label: string; percent: number; resetAt?: number }>; resetCredits?: number; /** * True when `monthlyPercent` came from an explicitly-monthly PRIMARY window — @@ -61,6 +62,16 @@ export type WhamUsageResponse = { rate_limit_reset_credits?: { available_count: number; } | null; + additional_rate_limits?: WhamAdditionalRateLimit[] | null; +}; + +type WhamAdditionalRateLimit = { + limit_name?: unknown; + metered_feature?: unknown; + rate_limit?: { + primary_window?: WhamUsageWindow | null; + secondary_window?: WhamUsageWindow | null; + } | null; }; type WhamUsageWindow = { @@ -187,7 +198,8 @@ function normalizeResetAt(value: unknown): number | undefined { function hasKnownQuotaValue(quota: Omit): boolean { return [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent] - .some(value => typeof value === "number" && Number.isFinite(value)); + .some(value => typeof value === "number" && Number.isFinite(value)) + || !!quota.customWindows?.some(window => Number.isFinite(window.percent)); } /** True only for a window that DECLARES a duration shorter than a day. */ @@ -232,8 +244,12 @@ function snapshotHasShort(quota: Omit): boolean || quota.shortWindowSeconds !== undefined; } +function snapshotHasCustom(quota: Omit): boolean { + return quota.customWindows !== undefined; +} + function snapshotHasUsage(quota: Omit): boolean { - return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota); + return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota); } export function setAccountQuotaFromParsed( accountId: string, @@ -255,6 +271,7 @@ export function setAccountQuotaFromParsed( if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent; if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt; if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds; + if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows; next.resetCredits = quota.resetCredits; accountQuota.set(accountId, next); schedulePersistAccountQuotas(); @@ -279,7 +296,7 @@ export function setAccountQuotaFromParsed( // while silently dropping `monthlyIsPrimaryWindow` would look like tertiary-only data to // any future reader, and that failure would be invisible. if (quota.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; - } else if ((snapshotHasWeekly(quota) || snapshotHasShort(quota)) + } else if ((snapshotHasWeekly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota)) && existing?.monthlyPercent !== undefined) { next.monthlyPercent = existing.monthlyPercent; if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt; @@ -298,6 +315,8 @@ export function setAccountQuotaFromParsed( if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds; } + if (snapshotHasCustom(quota)) next.customWindows = quota.customWindows; + if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits; @@ -394,6 +413,7 @@ export function updateAccountQuota( ...(existing?.shortPercent !== undefined ? { shortPercent: existing.shortPercent } : {}), ...(existing?.shortResetAt !== undefined ? { shortResetAt: existing.shortResetAt } : {}), ...(existing?.shortWindowSeconds !== undefined ? { shortWindowSeconds: existing.shortWindowSeconds } : {}), + ...(existing?.customWindows !== undefined ? { customWindows: existing.customWindows } : {}), ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}), updatedAt: Date.now(), }; @@ -506,6 +526,9 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit { + const name = String(additional.limit_name ?? "").toLowerCase(); + const feature = String(additional.metered_feature ?? "").toLowerCase(); + return feature === "codex_bengalfox" || name.includes("gpt-5.3-codex-spark"); + }); + const sparkWindows = [spark?.rate_limit?.primary_window, spark?.rate_limit?.secondary_window] + .filter((window): window is WhamUsageWindow => !!window); + const sparkWeekly = sparkWindows.find(window => { + const percent = normalizeUsagePercent(window.used_percent); + const seconds = window.limit_window_seconds; + return percent !== undefined + && !isExplicitShortWindow(window) + && !isExplicitMonthlyWindow(window) + && (seconds === undefined || seconds >= WEEKLY_WINDOW_MIN_SECONDS); + }); + const sparkPercent = normalizeUsagePercent(sparkWeekly?.used_percent); + if (sparkPercent !== undefined) { + const sparkWindow: { label: string; percent: number; resetAt?: number } = { + label: "GPT-5.3-Codex-Spark Weekly", + percent: sparkPercent, + }; + const resetAt = normalizeResetAt(sparkWeekly?.reset_at); + if (resetAt !== undefined) sparkWindow.resetAt = resetAt; + quota.customWindows = [sparkWindow]; + } if (resetCredits !== undefined) quota.resetCredits = resetCredits; return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null; diff --git a/src/providers/quota.ts b/src/providers/quota.ts index db0202161d..ba45fbfab9 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -4,6 +4,7 @@ import { fetchMainAccountInfoSnapshot, listCodexAuthAccountsSnapshot, } from "../codex/auth-api"; +import type { StoredAccountQuota } from "../codex/quota"; import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { codexPlanKey } from "../codex/plan"; @@ -166,6 +167,22 @@ function quotaSignatureValue(quota: CodexCapacityQuota | null): unknown { }; } +function providerQuotaFromCodexQuota( + quota: StoredAccountQuota | Omit | null | undefined, +): CodexCapacityQuota | null { + if (!quota) return null; + return { + ...(quota.shortPercent !== undefined ? { fiveHourPercent: quota.shortPercent } : {}), + ...(quota.shortResetAt !== undefined ? { fiveHourResetAt: quota.shortResetAt } : {}), + ...(quota.weeklyPercent !== undefined ? { weeklyPercent: quota.weeklyPercent } : {}), + ...(quota.weeklyResetAt !== undefined ? { weeklyResetAt: quota.weeklyResetAt } : {}), + ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}), + ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}), + ...(quota.customWindows !== undefined ? { customWindows: quota.customWindows } : {}), + updatedAt: "updatedAt" in quota ? quota.updatedAt : Date.now(), + }; +} + /** Hash only presentation-relevant state; account ids and email addresses never enter the key. */ function cacheKeyWithAggregationState( config: OcxConfig, @@ -183,7 +200,7 @@ function cacheKeyWithAggregationState( plan: codexPlanKey(account.plan) ?? null, paused: account.paused, needsReauth: account.needsReauth === true, - quota: quotaSignatureValue(account.quota as CodexCapacityQuota | null), + quota: quotaSignatureValue(providerQuotaFromCodexQuota(account.quota)), })); const canonicalRows = rows.map(row => JSON.stringify(row)).sort(); const digest = createHash("sha256").update(JSON.stringify(canonicalRows)).digest("hex").slice(0, 24); @@ -1103,9 +1120,8 @@ async function fetchChatGptForwardQuota( ): Promise { if (providerCodexAccountMode(provider, providerConfig) === "direct") { const snapshot = await fetchMainAccountInfoSnapshot(forceRefresh); - const quota = snapshot.info.quota - ? { ...snapshot.info.quota, updatedAt: Date.now() } as ProviderQuota - : null; + const quota = providerQuotaFromCodexQuota(snapshot.info.quota); + if (quota) quota.updatedAt = Date.now(); return quota ? tagNativeMainReport(report(provider, "chatgpt:wham", quota), snapshot.mainIdentityGeneration) : null; @@ -1113,10 +1129,14 @@ async function fetchChatGptForwardQuota( const snapshot = await (prefetchedSnapshot ?? listCodexAuthAccountsSnapshot(config, forceRefresh)); const accounts = snapshot.accounts; const activeId = effectiveCodexAuthAccountId(config); - const capacityAccounts = accounts.map(account => ({ ...account, active: account.id === activeId })); + const capacityAccounts = accounts.map(account => ({ + ...account, + active: account.id === activeId, + quota: providerQuotaFromCodexQuota(account.quota), + })); const active = capacityAccounts.find(account => account.active) - ?? accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID) - ?? accounts[0]; + ?? capacityAccounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID) + ?? capacityAccounts[0]; const now = Date.now(); const capacity = aggregateCodexPoolCapacity(capacityAccounts, now); if (capacity.aggregation && capacity.quota) { @@ -1278,6 +1298,24 @@ function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number return { percent, resetAt }; } +function parseClaudeLimit(value: unknown): { label: string; percent: number; resetAt?: number } | null { + const rec = asRecord(value); + if (!rec) return null; + const percent = normalizePercent(rec.percent); + if (percent === undefined) return null; + const scope = asRecord(rec.scope); + const model = asRecord(scope?.model); + const rawLabel = String(model?.display_name ?? "").trim(); + if (!rawLabel) return null; + const lowerLabel = rawLabel.toLowerCase(); + const label = lowerLabel.includes("fable") ? "Fable" + : lowerLabel.includes("opus") ? "Opus" + : lowerLabel.includes("sonnet") ? "Sonnet" + : rawLabel; + const resetAt = normalizeResetAt(rec.resets_at); + return { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }; +} + /** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */ const anthropicUsageInflight = new Map>(); @@ -1301,11 +1339,25 @@ async function fetchAnthropicUsageQuota(accessToken: string): Promise window.label.toLowerCase())); + const limits = Array.isArray(body.limits) ? body.limits : []; + for (const rawLimit of limits) { + const limitRecord = asRecord(rawLimit); + // `session` and `weekly_all` mirror the canonical five-hour and weekly + // buckets above; only model-scoped weekly limits add a third window. + if (String(limitRecord?.kind ?? "").trim().toLowerCase() !== "weekly_scoped") continue; + const limit = parseClaudeLimit(rawLimit); + if (!limit || knownLabels.has(limit.label.toLowerCase())) continue; + knownLabels.add(limit.label.toLowerCase()); + customWindows.push(limit); + } const quota: ProviderQuota = { // Claude's 5-hour window is a first-class rate limit, same as the Codex login 5h/weekly // rows: report it in the canonical fields so the dashboard renders it with the standard diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index d84725722b..4bb678a95f 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -38,9 +38,11 @@ import { removeCodexAccountCredential, saveCodexAccountCredential } from "../src import { clearAccountNeedsReauth, clearAccountQuota, + getAccountQuota, handleCodexAuthAPI, isAccountNeedsReauth, parseUsageQuota, + setAccountQuotaFromParsed, updateAccountQuota, } from "../src/codex/auth-api"; import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../src/codex/quota"; @@ -1254,6 +1256,55 @@ describe("codex routing", () => { }); }); + test("WHAM preserves the 5h, weekly, and Spark weekly windows", () => { + expect(parseUsageQuota({ + rate_limit: { + primary_window: { used_percent: 11, reset_at: 1, limit_window_seconds: 5 * 60 * 60 }, + secondary_window: { used_percent: 22, reset_at: 2, limit_window_seconds: 7 * 24 * 60 * 60 }, + }, + additional_rate_limits: [{ + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { used_percent: 33, reset_at: 3, limit_window_seconds: 7 * 24 * 60 * 60 }, + }, + }], + })).toEqual({ + shortPercent: 11, + shortResetAt: 1, + shortWindowSeconds: 5 * 60 * 60, + weeklyPercent: 22, + weeklyResetAt: 2, + customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 33, resetAt: 3 }], + }); + }); + + test("a Spark-only WHAM snapshot preserves the stored monthly window", () => { + setAccountQuotaFromParsed("a", { + monthlyPercent: 44, + monthlyResetAt: 4, + monthlyIsPrimaryWindow: true, + }); + const sparkOnly = parseUsageQuota({ + additional_rate_limits: [{ + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { used_percent: 33, reset_at: 3, limit_window_seconds: 7 * 24 * 60 * 60 }, + }, + }], + }); + + setAccountQuotaFromParsed("a", sparkOnly); + + expect(getAccountQuota("a")).toMatchObject({ + monthlyPercent: 44, + monthlyResetAt: 4, + monthlyIsPrimaryWindow: true, + customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 33, resetAt: 3 }], + }); + }); + test("a sub-day primary window does not masquerade as the weekly quota (#1791)", () => { // K12 and similar plans send a 5-hour primary plus a 7-day secondary. Folding the primary diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index dffa089c56..b4022d2d03 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -114,6 +114,80 @@ describe("fetchProviderQuotaReports", () => { expect(cancelCalls).toBe(1); }); + test("Codex report exposes primary, weekly, and Spark weekly windows", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); + return Response.json({ + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: 11, reset_at: 1, limit_window_seconds: 5 * 60 * 60 }, + secondary_window: { used_percent: 22, reset_at: 2, limit_window_seconds: 7 * 24 * 60 * 60 }, + }, + additional_rate_limits: [{ + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { used_percent: 33, reset_at: 3, limit_window_seconds: 7 * 24 * 60 * 60 }, + }, + }], + }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports({ + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "direct", + }, + }, + } as OcxConfig, true); + + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 11, + fiveHourResetAt: 1, + weeklyPercent: 22, + weeklyResetAt: 2, + customWindows: [{ label: "GPT-5.3-Codex-Spark Weekly", percent: 33, resetAt: 3 }], + }); + }); + + test("Anthropic report exposes the canonical Fable window from direct and limits payloads", async () => { + await saveCredential("anthropic", { access: "claude-access-secret", refresh: "claude-refresh-secret", expires: Date.now() + 3600_000 }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); + return Response.json({ + five_hour: { utilization: 11, resets_at: "2026-07-05T12:00:00Z" }, + seven_day: { utilization: 22, resets_at: "2026-07-11T12:00:00Z" }, + seven_day_fable: null, + limits: [ + { kind: "session", percent: 44, resets_at: "2026-07-13T12:00:00Z" }, + { kind: "weekly_all", percent: 55, resets_at: "2026-07-14T12:00:00Z" }, + { + kind: "weekly_scoped", + scope: { model: { display_name: "Claude Fable 5" } }, + percent: 33, + resets_at: "2026-07-12T12:00:00Z", + }, + ], + }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports({ + defaultProvider: "anthropic", + providers: { anthropic: { adapter: "anthropic", authMode: "oauth", baseUrl: "https://api.anthropic.com/v1" } }, + } as OcxConfig, true); + + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 11, + weeklyPercent: 22, + customWindows: [{ label: "Fable", percent: 33, resetAt: Date.parse("2026-07-12T12:00:00Z") }], + }); + expect(result.reports[0]?.quota.customWindows).toHaveLength(1); + }); + test("returns active provider quota rows without leaking credentials or raw upstream payloads", async () => { await saveCredential("xai", { access: "xai-access-secret", refresh: "xai-refresh-secret", expires: Date.now() + 3600_000 }); await saveCredential("anthropic", { access: "claude-access-secret", refresh: "claude-refresh-secret", expires: Date.now() + 3600_000 }); From bfe2cb5a1a3c3e80486fde2565b2ed702df1abfe Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 00:48:13 +0900 Subject: [PATCH 010/336] fix(google): recognize antigravity quota exhaustion, including the unspaced retry-after spelling (rebase of #2510) (#2573) * fix(google): recognize antigravity quota exhaustion without retrying transient rate limits * fix(google): treat the unspaced retry-after spelling as transient too The transient guard matched "retry after" but not "retry-after", so a body like "Quota exceeded; retry-after: 60" skipped the transient bucket and hit the exhaustion needles instead. A transient 429 then suppressed retry and could drive account-fallback exhaustion. Driven red first: without the added needle the new cases fail (9 pass / 1 fail), with it they pass (10 pass, expect() calls 81 -> 86). --------- Co-authored-by: Hsia97 --- src/adapters/google-errors.ts | 56 +++++++++++++++++----- tests/google-errors.test.ts | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 tests/google-errors.test.ts diff --git a/src/adapters/google-errors.ts b/src/adapters/google-errors.ts index 69e6d0cef5..d78ee1fb9b 100644 --- a/src/adapters/google-errors.ts +++ b/src/adapters/google-errors.ts @@ -15,14 +15,50 @@ function googleErrorDetail(payloadText: string): { message?: string; status?: st }; } + + +const GOOGLE_QUOTA_EXHAUSTED_NEEDLES = [ + "quotafailure", + "quota exceeded", + "exceeded your current quota", + "billing", + "individual quota reached", + "quota reached", + "enable overages", + "exhausted your capacity", + "daily limit reached", + "weekly limit reached", +]; + +// Per-minute / per-second / concurrency limits are transient rate limits: they should be +// retried, not treated as hard quota exhaustion. These guards run before the needles so a +// message like "Per-minute quota exceeded" stays in the retryable bucket. +const GOOGLE_TRANSIENT_RATE_LIMIT_PATTERNS = [ + "per minute", + "per-minute", + "per min", + "rpm", + "requests per minute", + "too many requests", + "rate limit", + "retry after", + // Upstream writes the header name both ways in prose ("retry-after: 60"); matching only the + // spaced spelling let a transient 429 fall through to the exhaustion needles below. + "retry-after", + "concurrent request limit", +]; + +export function isGoogleQuotaExhaustedText(text: string): boolean { + const lower = text.toLowerCase(); + if (GOOGLE_TRANSIENT_RATE_LIMIT_PATTERNS.some(needle => lower.includes(needle))) return false; + return GOOGLE_QUOTA_EXHAUSTED_NEEDLES.some(needle => lower.includes(needle)); +} + + function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string { const lower = `${enumStatus ?? ""} ${text}`.toLowerCase(); - const quotaExhausted = - lower.includes("quotafailure") || - lower.includes("quota exceeded") || - lower.includes("exceeded your current quota") || - lower.includes("billing"); - if (enumStatus === "RESOURCE_EXHAUSTED" && quotaExhausted) return `${label} quota exhausted`; + const quotaExhausted = isGoogleQuotaExhaustedText(lower); + if ((!enumStatus || enumStatus === "RESOURCE_EXHAUSTED") && quotaExhausted) return `${label} quota exhausted`; if (status === 429 || enumStatus === "RESOURCE_EXHAUSTED" || lower.includes("rate limit")) { return `${label} rate limit exceeded`; } @@ -76,10 +112,6 @@ export function retryableGoogleStatus(status: number): boolean { */ export function isQuotaExhaustedBody(payloadText: string): boolean { const { message, status } = googleErrorDetail(payloadText); - if (status !== "RESOURCE_EXHAUSTED") return false; - const lower = (message ?? "").toLowerCase(); - return lower.includes("quotafailure") - || lower.includes("quota exceeded") - || lower.includes("exceeded your current quota") - || lower.includes("billing"); + if (status && status !== "RESOURCE_EXHAUSTED") return false; + return isGoogleQuotaExhaustedText(message ?? payloadText); } diff --git a/tests/google-errors.test.ts b/tests/google-errors.test.ts new file mode 100644 index 0000000000..c47d7cbbbb --- /dev/null +++ b/tests/google-errors.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { + isGoogleQuotaExhaustedText, + isQuotaExhaustedBody, + safeAntigravityHttpErrorMessage, + safeGoogleHttpErrorMessage, + safeVertexHttpErrorMessage, +} from "../src/adapters/google-errors"; + +describe("google error classification & quota exhaustion", () => { + const antigravityPhrases = [ + "Individual quota reached. Please try again later.", + "Quota exceeded for model gemini-3.7-flash", + "You have exceeded your current quota.", + "Please enable overages in your Cloud billing console.", + "You have exhausted your capacity for this period.", + "Daily limit reached for this account.", + "Weekly limit reached.", + "RESOURCE_EXHAUSTED: quotafailure at billing account", + ]; + + for (const phrase of antigravityPhrases) { + test(`recognizes quota phrase: "${phrase}"`, () => { + expect(isGoogleQuotaExhaustedText(phrase)).toBe(true); + const jsonBodyWithStatus = JSON.stringify({ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + message: phrase, + }, + }); + expect(isQuotaExhaustedBody(jsonBodyWithStatus)).toBe(true); + expect(safeAntigravityHttpErrorMessage(429, jsonBodyWithStatus)).toContain("Antigravity quota exhausted"); + expect(safeVertexHttpErrorMessage(429, jsonBodyWithStatus)).toContain("Vertex AI quota exhausted"); + + // Also verify when JSON does not have an explicit status field (common in some Antigravity proxies) + const jsonBodyNoStatus = JSON.stringify({ + error: { + code: 429, + message: phrase, + }, + }); + expect(isQuotaExhaustedBody(jsonBodyNoStatus)).toBe(true); + expect(safeAntigravityHttpErrorMessage(429, jsonBodyNoStatus)).toContain("Antigravity quota exhausted"); + + // Also verify raw plain text payload + expect(isQuotaExhaustedBody(phrase)).toBe(true); + expect(safeAntigravityHttpErrorMessage(429, phrase)).toContain("Antigravity quota exhausted"); + }); + } + + test("does not classify transient rate limit as hard quota exhaustion", () => { + const transientPhrases = [ + "Rate limit exceeded. Please retry with exponential backoff.", + "Too many requests per minute (RPM).", + "Concurrent request limit reached, try again in a few seconds.", + "Per-minute quota exceeded, retry later.", + "Quota exceeded per minute (RPM).", + // Upstream writes the header name unspaced in prose. Matching only "retry after" let + // this fall through to the exhaustion needles and suppress a retry for a transient 429. + "Quota exceeded; retry-after: 60", + "RESOURCE_EXHAUSTED: quota exceeded. Retry-After: 30", + ]; + + for (const phrase of transientPhrases) { + expect(isGoogleQuotaExhaustedText(phrase)).toBe(false); + const jsonBody = JSON.stringify({ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + message: phrase, + }, + }); + expect(isQuotaExhaustedBody(jsonBody)).toBe(false); + expect(safeAntigravityHttpErrorMessage(429, jsonBody)).toContain("Antigravity rate limit exceeded"); + } + }); + + test("non-RESOURCE_EXHAUSTED status is not quota exhaustion even with quota keyword", () => { + const jsonBody = JSON.stringify({ + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "quota configuration invalid", + }, + }); + expect(isQuotaExhaustedBody(jsonBody)).toBe(false); + }); +}); From dd0f4af1f9bf350a90ad5bbc5c6520bc5f3744ad Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 00:50:36 +0900 Subject: [PATCH 011/336] fix(google): clamp max output tokens per model, without inventing a ceiling for unknown ids (rebase of #2512) (#2576) * fix(google): clamp max output tokens per model * fix(google): clamp max output tokens per model * fix(google): do not invent an output ceiling for unrecognized models The clamp matched by substring and fell back to 16,384 for anything unmatched, so an alias, a gateway id, or any model newer than the table was silently truncated to 16,384 regardless of what the operator asked for. structure/02_config-and-codex-home.md is explicit that an explicit request value wins. Unknown ids now return undefined and pass the request through untouched; the upstream stays the authority on its own limit. Matching is also prefix/family based, because includes(pro) matched my-prototype-model and includes(oss) matched crossover-v2. --------- Co-authored-by: Hsia97 --- src/adapters/google.ts | 40 +++++++++++++++++++++++++- tests/google-output-clamp.test.ts | 48 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/google-output-clamp.test.ts diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 19c142a994..183b92615a 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -48,6 +48,43 @@ const GOOGLE_BREVITY_INSTRUCTION = [ "- This applies only to intermediate progress text. Your final answer after the work is done is exempt: write it in full and at whatever length the task requires.", ].join("\n"); +/** + * Documented output ceiling for a Google-surface model, or `undefined` when the id is not + * recognized. + * + * Unknown ids return `undefined` deliberately. An earlier revision returned a 16,384 floor for + * anything unmatched, which silently truncated aliases, gateway ids, and any model added after + * this table was written — the operator asked for N tokens and got 16,384 with no signal. A cap + * we cannot justify is worse than no cap: `structure/02_config-and-codex-home.md` is explicit + * that an explicit request value wins, so an unrecognized model passes through untouched and the + * upstream remains the authority on its own limit. + * + * Matching is prefix/family based rather than substring based for the same reason: `includes("pro")` + * matched any id containing "pro" (`my-prototype-model`), and `includes("oss")` matched any id + * containing "oss" (`crossover-v2`). + */ +export function maxOutputTokensForGoogleModel(modelId: string): number | undefined { + const lower = modelId.toLowerCase().trim(); + if (lower.startsWith("gemini")) { + // Pro tops out one token below the flash/other Gemini ceiling; both are documented values. + return /(^|[-.])pro([-.]|$)/.test(lower) ? 65535 : 65536; + } + if (lower.startsWith("claude")) return 64000; + if (lower.startsWith("gpt-oss")) return 32768; + return undefined; +} + +export function clampGoogleMaxOutputTokens( + modelId: string, + requestedTokens?: number, +): number | undefined { + if (requestedTokens === undefined || requestedTokens <= 0) return undefined; + const modelMax = maxOutputTokensForGoogleModel(modelId); + // Unknown model: honour the request as-is rather than inventing a ceiling for it. + if (modelMax === undefined) return requestedTokens; + return Math.min(requestedTokens, modelMax); +} + /** * Some Google direct deployments expose current Gemini Flash generations with a `-tiered` * wire suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). Keep the picker-visible id @@ -650,7 +687,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (toolConfig) body.toolConfig = toolConfig; const generationConfig: Record = {}; - if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens; + const clampedMaxOutputTokens = clampGoogleMaxOutputTokens(identityModelId, parsed.options.maxOutputTokens); + if (clampedMaxOutputTokens !== undefined) generationConfig.maxOutputTokens = clampedMaxOutputTokens; if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature; if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP; if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences; diff --git a/tests/google-output-clamp.test.ts b/tests/google-output-clamp.test.ts new file mode 100644 index 0000000000..f49aa41a2c --- /dev/null +++ b/tests/google-output-clamp.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { clampGoogleMaxOutputTokens, maxOutputTokensForGoogleModel } from "../src/adapters/google"; + +describe("google maxOutputTokens clamp", () => { + test("returns model-specific max output tokens limit", () => { + expect(maxOutputTokensForGoogleModel("gemini-3.7-flash")).toBe(65536); + expect(maxOutputTokensForGoogleModel("gemini-3.7-flash-tiered")).toBe(65536); + expect(maxOutputTokensForGoogleModel("gemini-3-pro")).toBe(65535); + expect(maxOutputTokensForGoogleModel("claude-3-7-sonnet")).toBe(64000); + expect(maxOutputTokensForGoogleModel("claude-3-5-sonnet@20241022")).toBe(64000); + expect(maxOutputTokensForGoogleModel("gpt-oss-120b")).toBe(32768); + }); + + test("an unrecognized model has no invented ceiling", () => { + // A cap we cannot justify silently truncates the operator's explicit request. Aliases, + // gateway ids, and models newer than this table must pass through untouched. + expect(maxOutputTokensForGoogleModel("custom-unknown-model")).toBeUndefined(); + expect(clampGoogleMaxOutputTokens("custom-unknown-model", 128000)).toBe(128000); + expect(maxOutputTokensForGoogleModel("some-gateway/gemini-3-pro")).toBeUndefined(); + }); + + test("family matching does not fire on incidental substrings", () => { + // "includes(pro)" matched my-prototype-model; "includes(oss)" matched crossover-v2. + expect(maxOutputTokensForGoogleModel("my-prototype-model")).toBeUndefined(); + expect(maxOutputTokensForGoogleModel("crossover-v2")).toBeUndefined(); + expect(maxOutputTokensForGoogleModel("gemini-3-pro-preview")).toBe(65535); + expect(maxOutputTokensForGoogleModel("gemini-3.5-flash")).toBe(65536); + }); + + test("downward clamps excessive requested tokens to model max", () => { + expect(clampGoogleMaxOutputTokens("gemini-3.7-flash", 128000)).toBe(65536); + expect(clampGoogleMaxOutputTokens("gemini-3-pro", 100000)).toBe(65535); + expect(clampGoogleMaxOutputTokens("claude-3-7-sonnet", 100000)).toBe(64000); + expect(clampGoogleMaxOutputTokens("gpt-oss-120b", 64000)).toBe(32768); + }); + + test("preserves requested tokens when within model max", () => { + expect(clampGoogleMaxOutputTokens("gemini-3.7-flash", 4096)).toBe(4096); + expect(clampGoogleMaxOutputTokens("gemini-3-pro", 8192)).toBe(8192); + expect(clampGoogleMaxOutputTokens("claude-3-7-sonnet", 32000)).toBe(32000); + }); + + test("returns undefined when requested tokens is undefined or non-positive", () => { + expect(clampGoogleMaxOutputTokens("gemini-3.7-flash", undefined)).toBeUndefined(); + expect(clampGoogleMaxOutputTokens("gemini-3.7-flash", 0)).toBeUndefined(); + expect(clampGoogleMaxOutputTokens("gemini-3.7-flash", -10)).toBeUndefined(); + }); +}); From 4d3d2716e0c9f96b70632f6a8476e8af1fbfdda4 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 00:53:41 +0900 Subject: [PATCH 012/336] fix(google): stabilize thought-signature replay and evict rejected signatures on every mode (rebase of #2513) (#2577) * fix(google): stabilize thought-signature replay across repeated and truncated history * fix(google): evict rejected signatures on every mode, not just CCA/Vertex The durable replay store is not mode-scoped: signatures are written by rememberAndSerializeExtraContent and read back by lookupReplayThoughtSignature on every Google mode, AI Studio included. Eviction was gated on cloud-code-assist/vertex, so an AI Studio turn whose signature the upstream rejected kept that signature in the store and replayed it into every following turn - the store poisons itself and the request keeps failing. Eviction now follows the same scope the write does. Clearing the in-memory Antigravity cache stays CCA/Vertex-scoped because that cache only exists there. Driven red first: with eviction re-gated on CCA/Vertex only the ai-studio case fails (31 pass / 1 fail); with the fix 32 pass. --------- Co-authored-by: Hsia97 --- src/adapters/google-antigravity-replay.ts | 125 +++++- src/adapters/google.ts | 69 ++- src/responses/thought-signature-replay.ts | 17 + tests/google-antigravity-replay.test.ts | 10 +- ...google-signature-history-roundtrip.test.ts | 408 ++++++++++++++++-- ...thought-signature-credential-scope.test.ts | 5 + 6 files changed, 560 insertions(+), 74 deletions(-) diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 3d574b54d9..514008a983 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -19,6 +19,7 @@ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory"; interface ReplayCall { signature: string; + signatures?: string[]; sizeBytes: number; touchedAtMs: number; } @@ -34,6 +35,7 @@ interface ReplayEntry { } const MIN_SIGNATURE_LEN = 16; +const MAX_SIGNATURES_PER_CALL = 32; const REPLAY_TTL_MS = 60 * 60 * 1000; // 1h export const ANTIGRAVITY_REPLAY_MAX_ENTRIES = 10_240; const REPLAY_EVICT_BATCH = 128; @@ -103,16 +105,38 @@ function loadReplaySnapshotEntry(key: string, value: unknown): void { for (const pair of rec.byCall) { if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") continue; if (!/^[0-9a-f]{64}$/.test(pair[0])) continue; - const call = pair[1] as { signature?: unknown; touchedAtMs?: unknown } | null; + const call = pair[1] as { signature?: unknown; signatures?: unknown; touchedAtMs?: unknown } | null; if (!call || typeof call !== "object" || Array.isArray(call)) continue; if (typeof call.signature !== "string" || call.signature.length < MIN_SIGNATURE_LEN) continue; if (typeof call.touchedAtMs !== "number" || !Number.isFinite(call.touchedAtMs)) continue; - // Never trust a serialized sizeBytes: a forged snapshot could claim a tiny - // size for a huge signature and bypass every byte cap. Recompute from the - // signature itself, exactly like the live write path. - const signatureBytes = utf8.encode(call.signature).byteLength; - if (signatureBytes > replayLimits.maxSignatureBytes) continue; - const callBytes = utf8.encode(pair[0]).byteLength + signatureBytes; + let sigs: string[] = []; + if (Array.isArray(call.signatures)) { + for (const s of call.signatures) { + if (typeof s === "string" && s.length >= MIN_SIGNATURE_LEN) { + sigs.push(s); + } + } + } + if (sigs.length === 0) { + sigs = [call.signature]; + } else if (!sigs.includes(call.signature)) { + sigs.push(call.signature); + } + if (sigs.length > MAX_SIGNATURES_PER_CALL) { + sigs = sigs.slice(-MAX_SIGNATURES_PER_CALL); + } + let totalSigBytes = 0; + let anySigOversized = false; + for (const s of sigs) { + const sb = utf8.encode(s).byteLength; + if (sb > replayLimits.maxSignatureBytes) { + anySigOversized = true; + break; + } + totalSigBytes += sb; + } + if (anySigOversized) continue; + const callBytes = utf8.encode(pair[0]).byteLength + totalSigBytes; if (callBytes > replayLimits.maxBytesPerSession) continue; // A duplicated call key would overstate entry.bytes (the map keeps only the // last value) and could evict valid sessions; keep the first occurrence. @@ -122,6 +146,7 @@ function loadReplaySnapshotEntry(key: string, value: unknown): void { } byCall.set(pair[0], { signature: call.signature, + signatures: sigs, sizeBytes: callBytes, touchedAtMs: call.touchedAtMs, }); @@ -226,7 +251,11 @@ async function persistReplaySnapshotNow(): Promise { for (const [key, entry] of [...replayCache].sort((a, b) => b[1].lastActiveAtMs - a[1].lastActiveAtMs)) { const byCall = [...entry.byCall].map(([callKey, call]) => [ callKey, - { signature: call.signature, touchedAtMs: call.touchedAtMs }, + { + signature: call.signature, + ...(call.signatures && call.signatures.length > 1 ? { signatures: call.signatures } : {}), + touchedAtMs: call.touchedAtMs, + }, ]); const persistEntry: [string, unknown] = [key, { byCall, @@ -644,10 +673,37 @@ export function observeAntigravityReplay( const ck = functionCallKey(fc.name, fc.args); if (!ck) continue; // only function-call signatures are replayable by identity const signatureBytes = utf8.encode(callSig).byteLength; - const sizeBytes = utf8.encode(ck).byteLength + signatureBytes; - if (signatureBytes > replayLimits.maxSignatureBytes || sizeBytes > replayLimits.maxBytesPerSession) continue; + if (signatureBytes > replayLimits.maxSignatureBytes) continue; + + const existingCall = entry.byCall.get(ck); + let sigs: string[]; + if (existingCall) { + sigs = existingCall.signatures && existingCall.signatures.length > 0 + ? [...existingCall.signatures] + : [existingCall.signature]; + if (!sigs.includes(callSig)) { + if (sigs.length >= MAX_SIGNATURES_PER_CALL) { + sigs.shift(); + } + sigs.push(callSig); + } + } else { + sigs = [callSig]; + } + let totalSigBytes = 0; + for (const s of sigs) { + totalSigBytes += utf8.encode(s).byteLength; + } + const sizeBytes = utf8.encode(ck).byteLength + totalSigBytes; + if (sizeBytes > replayLimits.maxBytesPerSession) continue; + deleteReplayCall(entry, ck); - entry.byCall.set(ck, { signature: callSig, sizeBytes, touchedAtMs: now }); + entry.byCall.set(ck, { + signature: callSig, + signatures: sigs, + sizeBytes, + touchedAtMs: now, + }); entry.bytes += sizeBytes; replayBytes += sizeBytes; inserted = true; @@ -692,15 +748,22 @@ export function applyAntigravityReplay(model: string, sessionId: string, content if (!entry) { return contents; } + let touched = false; - for (const c of contents as { role?: string; parts?: unknown[] }[]) { + // Align from the END of the recorded signature list. History may have been truncated + // (compaction / previous_response_id): the last remaining occurrence is the most recent call, + // so it must receive the newest signature. Iterate backwards and count every occurrence + // (signed or not) so signed Mechanism-① parts still occupy their chronological slot. + const reverseOccurrence = new Map(); + for (let ci = (contents as { role?: string; parts?: unknown[] }[]).length - 1; ci >= 0; ci--) { + const c = (contents as { role?: string; parts?: unknown[] }[])[ci]; if (!c || typeof c !== "object" || c.role !== "model" || !Array.isArray(c.parts)) continue; - for (const raw of c.parts) { + for (let pi = c.parts.length - 1; pi >= 0; pi--) { + const raw = c.parts[pi]; if (!raw || typeof raw !== "object") continue; const part = raw as Record; const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined; if (!fc) continue; - if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) continue; const ck = functionCallKey(fc.name, fc.args); let call = ck ? entry.byCall.get(ck) : undefined; let matchedKey = ck; @@ -717,27 +780,43 @@ export function applyAntigravityReplay(model: string, sessionId: string, content ) { try { const parsedInput = JSON.parse(trimmedInput); - if (parsedInput && typeof parsedInput === "object") { - const altKey = functionCallKey(fc.name, parsedInput); - if (altKey && entry.byCall.has(altKey)) { - call = entry.byCall.get(altKey); - matchedKey = altKey; + if (parsedInput && typeof parsedInput === "object") { + const altKey = functionCallKey(fc.name, parsedInput); + if (altKey && entry.byCall.has(altKey)) { + call = entry.byCall.get(altKey); + matchedKey = altKey; + } } - } } catch { // not JSON, keep default } } } } - if (call && matchedKey) { + if (matchedKey) { + const revIdx = reverseOccurrence.get(matchedKey) ?? 0; + reverseOccurrence.set(matchedKey, revIdx + 1); + if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) { + // Already signed (e.g. Mechanism ① or upstream response), preserve it. + continue; + } + if (call) { + const sigs = call.signatures && call.signatures.length > 0 ? call.signatures : [call.signature]; + const chosenSig = revIdx < sigs.length + ? sigs[sigs.length - 1 - revIdx] + : sigs[sigs.length - 1] ?? call.signature; + part.thoughtSignature = chosenSig; + entry.byCall.delete(matchedKey); + entry.byCall.set(matchedKey, { ...call, touchedAtMs: now }); + touched = true; + } + } else if (part.thoughtSignature === undefined && part.thought_signature === undefined && call) { part.thoughtSignature = call.signature; - entry.byCall.delete(matchedKey); - entry.byCall.set(matchedKey, { ...call, touchedAtMs: now }); touched = true; } } } + if (touched) { entry.lastActiveAtMs = now; refreshReplaySessionCandidate(replayKey(model, sessionId), entry); diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 183b92615a..23361f7735 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -26,7 +26,7 @@ import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; -import { lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; +import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, @@ -221,7 +221,7 @@ function geminiOrphanToolResultParts(msg: OcxToolResultMessage): unknown[] { function messagesToGeminiFormat( parsed: OcxParsedRequest, identityModelId: string, -): { systemInstruction?: unknown; contents: unknown[] } { +): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } { // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream. const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice); @@ -233,6 +233,7 @@ function messagesToGeminiFormat( const systemInstruction = { parts: [{ text: systemText }] }; const contents: unknown[] = []; + const replayedCallIds: string[] = []; const callIds = createToolCallIdAllocator(); for (const msg of parsed.context.messages) { @@ -308,7 +309,10 @@ function messagesToGeminiFormat( const signature = tc.providerMetadata?.google?.thoughtSignature ?? tc.thoughtSignature ?? lookupReplayThoughtSignature(tc.id, parsed._reasoningReplayScope); - if (isLikelyRealThoughtSignature(signature)) part.thoughtSignature = signature; + if (isLikelyRealThoughtSignature(signature)) { + part.thoughtSignature = signature; + replayedCallIds.push(tc.id); + } parts.push(part); } } @@ -360,7 +364,7 @@ function messagesToGeminiFormat( } } - return { systemInstruction, contents }; + return { systemInstruction, contents, replayedCallIds }; } function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { @@ -647,6 +651,47 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let vertexReplayModel: string | undefined; let vertexReplaySession: string | undefined; let restoreGoogleToolName = (name: string): string => name; + let lastInjectedCallIds: string[] = []; + let lastReasoningReplayScope: OcxParsedRequest["_reasoningReplayScope"]; + + // Conservative batch invalidation: upstream Gemini/Antigravity errors (e.g. + // "Function call is missing a thought_signature in functionCall parts") do not specify which + // specific call_id was rejected. When a request containing replayed signatures is rejected, + // we evict all callIds injected in that turn (lastInjectedCallIds) from the durable store + // and clear the session replay cache, preventing poisoned-signature loops while allowing + // subsequent turns to re-accumulate valid signatures. Unrelated calls from other turns remain intact. + // + // Memory-cache clearing stays broad (any invalid-argument/signature error can poison the + // session replay cache), but durable-store eviction is intentionally narrower: it only runs + // when the error text explicitly mentions a signature, so a generic tool-schema + // INVALID_ARGUMENT does not destroy valid durable signatures. + function handleSignatureRejection(errorMessage?: string) { + const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; + const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; + const text = errorMessage ?? ""; + const isInvalidArgument = /invalid_argument|invalid argument/i.test(text); + const isSignatureError = /signature|thought_signature|thoughtSignature/i.test(text); + // The in-memory Antigravity replay cache only exists for CCA/Vertex, so clearing it stays + // scoped to those modes (replayModel/replaySession are undefined elsewhere anyway). + if ( + (provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") + && replayModel && replaySession && (isInvalidArgument || isSignatureError) + ) { + clearAntigravityReplay(replayModel, replaySession); + } + // The DURABLE store is not mode-scoped: signatures are remembered through + // rememberAndSerializeExtraContent and read back by lookupReplayThoughtSignature on every + // Google mode, including AI Studio. Gating eviction on CCA/Vertex therefore left AI Studio + // with rejected signatures cached forever, replaying them into every subsequent turn — the + // store poisons itself and the request keeps failing. Eviction follows the same scope the + // write does. + if (isSignatureError) { + for (const callId of lastInjectedCallIds) { + forgetThoughtSignatureForReplay(callId, lastReasoningReplayScope); + } + } + } + return { name: "google", @@ -675,7 +720,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false); // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation. const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId; - const { systemInstruction, contents } = messagesToGeminiFormat(parsed, identityModelId); + const { systemInstruction, contents, replayedCallIds } = messagesToGeminiFormat(parsed, identityModelId); + lastInjectedCallIds = [...replayedCallIds]; + lastReasoningReplayScope = parsed._reasoningReplayScope; const tools = toolsToGeminiFormat(parsed); const body: Record = { contents }; @@ -902,14 +949,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (chunk.error) { const err = chunk.error as { message?: string } | undefined; // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale. - // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") - && replayModel && replaySession - && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) { - clearAntigravityReplay(replayModel, replaySession); - } + // Drop the cache entry and durable store entry for rejected calls so the next turn + // starts clean instead of re-injecting a bad sig. + handleSignatureRejection(err?.message); yield { type: "error", message: err?.message ?? "upstream error" }; return "terminate"; } @@ -1224,6 +1266,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }; if (raw.error) { const err = raw.error as { message?: string }; + handleSignatureRejection(err.message); return finish([{ type: "error", message: err.message ?? "upstream error" }]); } // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it. diff --git a/src/responses/thought-signature-replay.ts b/src/responses/thought-signature-replay.ts index 7df65d25e6..bfe49fa8f9 100644 --- a/src/responses/thought-signature-replay.ts +++ b/src/responses/thought-signature-replay.ts @@ -308,6 +308,23 @@ export function lookupReplayThoughtSignature( return entry.sig; } +/** Drop a remembered signature for a specific callId and scope (e.g. when upstream rejects it). */ +export function forgetThoughtSignatureForReplay( + callId: string, + scope: OcxReasoningReplayScopeRef | undefined, +): boolean { + const key = keyFor(callId, scope); + if (key === undefined) return false; + load(); + const entry = entries.get(key); + if (!entry) return false; + entries.delete(key); + totalBytes -= entry.sig.length; + prune(Date.now()); + void persist(); + return true; +} + /** Test seams: clear in-memory state and the loaded flag without touching the file. */ export function resetThoughtSignatureReplayForTests(): void { entries = new Map(); diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index 8fab813cca..a21a57d84e 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -25,7 +25,13 @@ import { } from "../src/adapters/google-antigravity-replay"; import { sanitizeAntigravityClaudeSignatures } from "../src/adapters/google-antigravity-wire"; -afterEach(() => setAntigravityReplayLimitsForTests()); +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../src/lib/windows-secret-acl"; + +afterEach(() => { + setAntigravityReplayLimitsForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); +}); // Sandbox OPENCODEX_HOME: the replay cache now snapshots to disk, and these tests must // never touch the real ~/.opencodex. @@ -33,6 +39,8 @@ let replayTestHome: string; const priorOpenCodexHome = process.env["OPENCODEX_HOME"]; beforeEach(() => { + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); replayTestHome = mkdtempSync(join(tmpdir(), "ocx-antigravity-replay-test-")); process.env["OPENCODEX_HOME"] = replayTestHome; }); diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index 40f23154e8..fbb6d02f59 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -7,16 +7,22 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; -import { __resetAntigravityReplayCache, observeAntigravityReplay } from "../src/adapters/google-antigravity-replay"; +import { + __resetAntigravityReplayCache, + applyAntigravityReplay, + observeAntigravityReplay, +} from "../src/adapters/google-antigravity-replay"; import { parseRequest } from "../src/responses/parser"; import { flushThoughtSignatureReplayForTests, + forgetThoughtSignatureForReplay, lookupReplayThoughtSignature, rememberThoughtSignatureForReplay, resetThoughtSignatureReplayForTests, } from "../src/responses/thought-signature-replay"; import { durableReplayDestinationIdentity } from "../src/responses/reasoning-replay-cache"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../src/lib/windows-secret-acl"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxReasoningReplayScopeRef } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; const createGoogleAdapter = (...args: Parameters) => @@ -40,7 +46,6 @@ const aiStudioProvider = { apiKey: "ai-studio-test-key", } as OcxProviderConfig; - /** * A replay scope is now REQUIRED for the store to remember or return anything: a * client-visible call_id is not unique across threads, accounts, providers or models, @@ -51,7 +56,7 @@ function scopeFor( modelId = MODEL, providerName = "google", destination = "https://generativelanguage.googleapis.com", -) { +): OcxReasoningReplayScopeRef { return { clientThreadId: threadId, current: { @@ -68,9 +73,12 @@ function scopeFor( } /** parseRequest with the replay scope bound, as the server does after route selection. */ -function parseRequestScoped(body: unknown, scope = scopeFor()) { - return parseRequest(body, { replayCacheScope: scope }); +function parseRequestScoped(body: unknown, scope = scopeFor()): OcxParsedRequest { + const req = parseRequest(body, { replayCacheScope: scope }); + req._reasoningReplayScope = scope; + return req; } + function firstTurn(): OcxParsedRequest { return { modelId: MODEL, @@ -108,11 +116,19 @@ function sseResponse(frames: string[]): Response { return new Response(stream); } +const fcPart = (name: string, args: unknown, sig?: string) => { + const part: Record = { functionCall: { name, args } }; + if (sig) part.thoughtSignature = sig; + return part; +}; + describe("#1735 thought signature survives history replay", () => { let previousHome: string | undefined; let testDir: string; beforeEach(() => { + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); __resetAntigravityReplayCache(); resetThoughtSignatureReplayForTests(); previousHome = process.env.OPENCODEX_HOME; @@ -120,10 +136,14 @@ describe("#1735 thought signature survives history replay", () => { process.env.OPENCODEX_HOME = testDir; }); - afterEach(() => { + afterEach(async () => { + await flushThoughtSignatureReplayForTests(); + resetThoughtSignatureReplayForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(testDir, { recursive: true, force: true }); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); }); test("the adapter attaches the signature to the tool call that produced it", async () => { @@ -413,67 +433,47 @@ describe("#1735 thought signature survives history replay", () => { expect(part?.thoughtSignature).toBeUndefined(); }); - test("the same call_id in a different thread does not borrow the signature (#1823)", () => { - // A client-visible call_id is not unique. Keyed on it alone, one conversation's - // signature was handed to another's replay -- and a second thread writing the same - // id silently overwrote the first. rememberThoughtSignatureForReplay("call_shared", SIGNATURE, scopeFor("thread-a")); - expect(lookupReplayThoughtSignature("call_shared", scopeFor("thread-a"))).toBe(SIGNATURE); expect(lookupReplayThoughtSignature("call_shared", scopeFor("thread-b"))).toBeUndefined(); }); test("a different account or model is a different scope (#1823)", () => { rememberThoughtSignatureForReplay("call_scoped", SIGNATURE, scopeFor("thread-a", MODEL, "google")); - expect(lookupReplayThoughtSignature("call_scoped", scopeFor("thread-a", MODEL, "google"))).toBe(SIGNATURE); - // Same thread and call id, different provider identity: opaque signatures are not - // portable across providers, so this must miss rather than cross-contaminate. expect(lookupReplayThoughtSignature("call_scoped", scopeFor("thread-a", MODEL, "antigravity"))).toBeUndefined(); - // Same thread and provider, different model. expect(lookupReplayThoughtSignature("call_scoped", scopeFor("thread-a", "gemini-3.6-pro", "google"))).toBeUndefined(); }); test("a conflicting signature under one key fails closed instead of overwriting (#1823)", () => { expect(rememberThoughtSignatureForReplay("call_conflict", SIGNATURE, scopeFor()).result).toBe("stored"); - // Re-remembering the same value is a no-op, not a conflict: retries are ordinary. expect(rememberThoughtSignatureForReplay("call_conflict", SIGNATURE, scopeFor()).result).toBe("already-equal"); - // A DIFFERENT value under the same complete key means two upstream turns claimed one - // identity. Keeping the first is the fail-closed choice; last-write-wins would let a - // later turn silently invalidate an earlier replay. expect(rememberThoughtSignatureForReplay("call_conflict", SIGNATURE_B, scopeFor()).result).toBe("conflict"); expect(lookupReplayThoughtSignature("call_conflict", scopeFor())).toBe(SIGNATURE); }); test("an incomplete scope remembers nothing rather than remembering globally (#1823)", () => { - // A partially identified entry is exactly the cross-thread collision this store exists - // to prevent, so it must not be stored under a degraded key. expect(rememberThoughtSignatureForReplay("call_unscoped", SIGNATURE, undefined).result).toBe("unscoped"); expect(rememberThoughtSignatureForReplay("call_unscoped", SIGNATURE, { clientThreadId: "t" }).result).toBe("unscoped"); expect(lookupReplayThoughtSignature("call_unscoped", scopeFor())).toBeUndefined(); }); test("a store write reports when it is durable (#1823)", async () => { - // The caller can await this before exposing the tool-call item, so a client cannot - // observe a call whose signature was never persisted. const { result, durable } = rememberThoughtSignatureForReplay("call_durable", SIGNATURE, scopeFor()); expect(result).toBe("stored"); await durable; expect(lookupReplayThoughtSignature("call_durable", scopeFor())).toBe(SIGNATURE); }); + test("the proxy-side store survives a process restart via its snapshot", async () => { rememberThoughtSignatureForReplay("call_disk_1", SIGNATURE, scopeFor()); await flushThoughtSignatureReplayForTests(); - // Simulate a fresh process: drop in-memory state; lookup must reload from disk. resetThoughtSignatureReplayForTests(); expect(lookupReplayThoughtSignature("call_disk_1", scopeFor())).toBe(SIGNATURE); }); test("one provider name serving two endpoints does not share signatures", () => { - // The gap the durable key closes. providerName, adapterName, modelId and thread can all - // be identical across two upstreams — a gateway and a direct endpoint under one config - // name — and an opaque signature minted by one is meaningless to the other. const primary = scopeFor("thread-a", MODEL, "google", "https://generativelanguage.googleapis.com"); const secondary = scopeFor("thread-a", MODEL, "google", "https://gateway.internal.example/v1beta"); @@ -484,14 +484,9 @@ describe("#1735 thought signature survives history replay", () => { }); test("the durable destination identity is stable across restarts, unlike the process-local one", async () => { - // The reason this is a separate digest rather than the sibling cache's HMAC: that one is - // keyed by randomBytes minted at module load, so reusing it here would change every key - // on restart and the store would silently stop matching — a worse failure than the - // over-broad key it replaced, because it looks like it is working. const url = "https://generativelanguage.googleapis.com"; expect(durableReplayDestinationIdentity(url)).toBe(durableReplayDestinationIdentity(url)); expect(durableReplayDestinationIdentity(url)).not.toBe(durableReplayDestinationIdentity("https://other.example")); - // Trailing-slash normalization matches the process-local form. expect(durableReplayDestinationIdentity(`${url}/`)).toBe(durableReplayDestinationIdentity(url)); rememberThoughtSignatureForReplay("call_dest_restart", SIGNATURE, scopeFor()); @@ -501,9 +496,6 @@ describe("#1735 thought signature survives history replay", () => { }); test("adapter serialization reads the durable store with the post-parse bound scope (#1926 wiring)", async () => { - // The server parses BEFORE the route/credential scope exists, so the parser's own - // lookup cannot hit; the google adapter's serialization-time fallback must read the - // durable store once the scope identity has been bound. rememberThoughtSignatureForReplay("call_wire_1", SIGNATURE, scopeFor()); await flushThoughtSignatureReplayForTests(); const scope: { clientThreadId: string; current?: unknown } = { clientThreadId: "thread-a" }; @@ -517,7 +509,6 @@ describe("#1735 thought signature survives history replay", () => { ], tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], }, scope as never); - // Identity binds after parse, as bindRouteReasoningReplayScope does server-side. scope.current = scopeFor().current; parsed._reasoningReplayScope = scope as never; const adapter = createGoogleAdapter(provider); @@ -526,4 +517,347 @@ describe("#1735 thought signature survives history replay", () => { const fnPart = parts.find(p => (p as { functionCall?: unknown }).functionCall) as { thoughtSignature?: string } | undefined; expect(fnPart?.thoughtSignature).toBe(SIGNATURE); }); + + test("adapter invalidation: clear-on-invalid evicts the rejected callId from durable store while preserving others", async () => { + const scope = scopeFor(); + rememberThoughtSignatureForReplay("call_invalid_A", SIGNATURE, scope); + rememberThoughtSignatureForReplay("call_valid_B", SIGNATURE_B, scope); + await flushThoughtSignatureReplayForTests(); + + // Round 1: Turn containing call_invalid_A + const parsedA = parseRequestScoped({ + model: MODEL, + stream: false, + input: [ + { role: "user", content: [{ type: "input_text", text: "run A" }] }, + { type: "function_call", call_id: "call_invalid_A", name: "shell_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_invalid_A", output: "error" }, + ], + tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], + }, scope); + + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(parsedA); + + // Upstream returns 400 Invalid Argument / thought_signature rejection + const errorResponse = new Response(JSON.stringify({ + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid thought_signature provided for function call", + }, + })); + + const events = await adapter.parseResponse!(errorResponse); + expect(events[0].type).toBe("error"); + + // Verify call_invalid_A is evicted from durable store + expect(lookupReplayThoughtSignature("call_invalid_A", scope)).toBeUndefined(); + // Verify call_valid_B remains intact in durable store + expect(lookupReplayThoughtSignature("call_valid_B", scope)).toBe(SIGNATURE_B); + }); + + test("adapter invalidation: multi-call request rejected by upstream performs conservative batch invalidation", async () => { + const scope = scopeFor(); + const SIG_C = "CiQAx-history-thought-signature-third-call-77"; + rememberThoughtSignatureForReplay("call_batch_1", SIGNATURE, scope); + rememberThoughtSignatureForReplay("call_batch_2", SIGNATURE_B, scope); + rememberThoughtSignatureForReplay("call_unrelated_3", SIG_C, scope); + await flushThoughtSignatureReplayForTests(); + + // Turn containing both call_batch_1 and call_batch_2: + const parsed = parseRequestScoped({ + model: MODEL, + stream: false, + input: [ + { role: "user", content: [{ type: "input_text", text: "run batch" }] }, + { type: "function_call", call_id: "call_batch_1", name: "shell_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_batch_1", output: "out1" }, + { type: "function_call", call_id: "call_batch_2", name: "shell_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_batch_2", output: "out2" }, + ], + tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], + }, scope); + + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(parsed); + + // Upstream 400 rejection (Google error payload does not name which callId failed) + const errorResponse = new Response(JSON.stringify({ + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Function call is missing a thought_signature in functionCall parts", + }, + })); + + const events = await adapter.parseResponse!(errorResponse); + expect(events[0].type).toBe("error"); + + // Both injected callIds in the rejected request are evicted (conservative batch eviction) + expect(lookupReplayThoughtSignature("call_batch_1", scope)).toBeUndefined(); + expect(lookupReplayThoughtSignature("call_batch_2", scope)).toBeUndefined(); + // Unrelated call from another turn is preserved + expect(lookupReplayThoughtSignature("call_unrelated_3", scope)).toBe(SIG_C); + }); +}); + +describe("Antigravity Multi-Signature History Stability (Mechanism ②)", () => { + beforeEach(() => { + __resetAntigravityReplayCache(); + }); + + test("preserves chronological signatures across repeated identical tool calls", () => { + const sessionId = "session-hist-1"; + const model = "gemini-3.7-flash"; + + // Round 1: Model calls bash(command: "ls"), returns sig1 + observeAntigravityReplay(model, sessionId, [ + fcPart("bash", { command: "ls" }, "sig-turn-1-abcdef123456"), + ]); + + // Round 2: Model calls bash(command: "ls") again, returns sig2 + observeAntigravityReplay(model, sessionId, [ + fcPart("bash", { command: "ls" }, "sig-turn-2-ghijkl123456"), + ]); + + // Round 3: Model calls bash(command: "ls") a third time, returns sig3 + observeAntigravityReplay(model, sessionId, [ + fcPart("bash", { command: "ls" }, "sig-turn-3-mnopqr123456"), + ]); + + // Client now sends the full history of 3 tool calls without signatures: + const contents = [ + { + role: "model", + parts: [{ functionCall: { name: "bash", args: { command: "ls" } } }], + }, + { + role: "user", + parts: [{ functionResponse: { name: "bash", response: { output: "file1" } } }], + }, + { + role: "model", + parts: [{ functionCall: { name: "bash", args: { command: "ls" } } }], + }, + { + role: "user", + parts: [{ functionResponse: { name: "bash", response: { output: "file2" } } }], + }, + { + role: "model", + parts: [{ functionCall: { name: "bash", args: { command: "ls" } } }], + }, + ]; + + applyAntigravityReplay(model, sessionId, contents); + + // Verify each occurrence gets its corresponding historical signature without prefix mutation: + const part1 = contents[0].parts[0] as { thoughtSignature?: string }; + const part2 = contents[2].parts[0] as { thoughtSignature?: string }; + const part3 = contents[4].parts[0] as { thoughtSignature?: string }; + + expect(part1.thoughtSignature).toBe("sig-turn-1-abcdef123456"); + expect(part2.thoughtSignature).toBe("sig-turn-2-ghijkl123456"); + expect(part3.thoughtSignature).toBe("sig-turn-3-mnopqr123456"); + }); + + test("handles interleaved repeated and distinct tool calls correctly", () => { + const sessionId = "session-hist-interleaved"; + const model = "gemini-3.7-flash"; + + // Round 1: Model calls read(file: "a.ts") -> sigA1 + observeAntigravityReplay(model, sessionId, [ + fcPart("read", { file: "a.ts" }, "sig-read-a-1-12345678"), + ]); + + // Round 2: Model calls read(file: "b.ts") -> sigB1 + observeAntigravityReplay(model, sessionId, [ + fcPart("read", { file: "b.ts" }, "sig-read-b-1-12345678"), + ]); + + // Round 3: Model calls read(file: "a.ts") again -> sigA2 + observeAntigravityReplay(model, sessionId, [ + fcPart("read", { file: "a.ts" }, "sig-read-a-2-12345678"), + ]); + + const contents = [ + { role: "model", parts: [{ functionCall: { name: "read", args: { file: "a.ts" } } }] }, + { role: "user", parts: [{ functionResponse: { name: "read", response: { output: "aaa" } } }] }, + { role: "model", parts: [{ functionCall: { name: "read", args: { file: "b.ts" } } }] }, + { role: "user", parts: [{ functionResponse: { name: "read", response: { output: "bbb" } } }] }, + { role: "model", parts: [{ functionCall: { name: "read", args: { file: "a.ts" } } }] }, + ]; + + applyAntigravityReplay(model, sessionId, contents); + + expect((contents[0].parts[0] as any).thoughtSignature).toBe("sig-read-a-1-12345678"); + expect((contents[2].parts[0] as any).thoughtSignature).toBe("sig-read-b-1-12345678"); + expect((contents[4].parts[0] as any).thoughtSignature).toBe("sig-read-a-2-12345678"); + }); + + + + test("does not overwrite existing thoughtSignature provided by client", () => { + const sessionId = "session-hist-3"; + const model = "gemini-3.7-flash"; + + observeAntigravityReplay(model, sessionId, [ + fcPart("grep", { pattern: "test" }, "sig-cached-1234567890"), + ]); + + const contents = [ + { + role: "model", + parts: [{ functionCall: { name: "grep", args: { pattern: "test" } }, thoughtSignature: "client-provided-sig" }], + }, + ]; + + applyAntigravityReplay(model, sessionId, contents); + + const part = contents[0].parts[0] as { thoughtSignature?: string }; + expect(part.thoughtSignature).toBe("client-provided-sig"); + }); +}); + +describe("Durable Thought-Signature Replay Store Single-Call Invalidation (Mechanism ①)", () => { + let previousHome: string | undefined; + let testDir: string; + + const scope: OcxReasoningReplayScopeRef = { + clientThreadId: "thread-123", + current: { + providerName: "antigravity", + adapterName: "google", + modelId: "gemini-3.7-flash", + credentialDurableIdentity: "cred-xyz-12345", + providerDestinationDurableIdentity: "dest-abc-67890", + }, + }; + + beforeEach(() => { + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); + resetThoughtSignatureReplayForTests(); + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-tsig-inval-")); + process.env.OPENCODEX_HOME = testDir; + }); + + afterEach(async () => { + await flushThoughtSignatureReplayForTests(); + resetThoughtSignatureReplayForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(testDir, { recursive: true, force: true }); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + }); + + test("can remember and selectively forget a specific callId", () => { + const callId1 = "call-001"; + const callId2 = "call-002"; + const sig1 = "sig-val-001-1234567890abcdef"; + const sig2 = "sig-val-002-1234567890abcdef"; + + rememberThoughtSignatureForReplay(callId1, sig1, scope); + rememberThoughtSignatureForReplay(callId2, sig2, scope); + + expect(lookupReplayThoughtSignature(callId1, scope)).toBe(sig1); + expect(lookupReplayThoughtSignature(callId2, scope)).toBe(sig2); + + // Evict only callId1 + const forgotten = forgetThoughtSignatureForReplay(callId1, scope); + expect(forgotten).toBe(true); + + // callId1 is gone + expect(lookupReplayThoughtSignature(callId1, scope)).toBeUndefined(); + // callId2 remains intact + expect(lookupReplayThoughtSignature(callId2, scope)).toBe(sig2); + + // Forgetting non-existent callId returns false + expect(forgetThoughtSignatureForReplay("call-999", scope)).toBe(false); + }); +}); + +describe("#2513 rejected signatures are evicted on every Google mode", () => { + let previousHome: string | undefined; + let testDir: string; + + beforeEach(() => { + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); + __resetAntigravityReplayCache(); + resetThoughtSignatureReplayForTests(); + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-tsig-mode-")); + process.env.OPENCODEX_HOME = testDir; + }); + + afterEach(async () => { + await flushThoughtSignatureReplayForTests(); + resetThoughtSignatureReplayForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(testDir, { recursive: true, force: true }); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + }); + + /** + * The durable store is NOT mode-scoped: a signature is remembered and looked up on every + * Google mode, AI Studio included. Eviction used to be gated on cloud-code-assist/vertex, + * so an AI Studio turn whose signature the upstream rejected kept replaying that same + * rejected signature out of the store on every following turn. + */ + for (const [label, modeProvider] of [ + ["ai-studio", aiStudioProvider], + ["vertex", provider], + ] as const) { + test(`${label}: a rejected signature does not survive in the durable store`, async () => { + const scope = scopeFor("thread-evict", MODEL, "google"); + const parsed = { ...firstTurn(), _reasoningReplayScope: scope } as unknown as OcxParsedRequest; + + const adapter = createGoogleAdapter(modeProvider); + // Warm the store the way a real turn does, then replay it so the adapter records the + // call id as injected for this turn. + rememberThoughtSignatureForReplay("call_evict_1", SIGNATURE, scope); + await flushThoughtSignatureReplayForTests(); + expect(lookupReplayThoughtSignature("call_evict_1", scope)).toBe(SIGNATURE); + + const replayParsed = parseRequestScoped({ + model: MODEL, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "run pwd" }] }, + { + type: "function_call", + call_id: "call_evict_1", + name: "shell_command", + arguments: JSON.stringify({ command: "pwd" }), + }, + { type: "function_call_output", call_id: "call_evict_1", output: "/workspace" }, + ], + tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], + }, scope); + await adapter.buildRequest(replayParsed); + void parsed; + + // The upstream rejects the replayed signature. + const events = await adapter.parseResponse!(new Response( + JSON.stringify({ + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Function call is missing a thought_signature in functionCall parts", + }, + }), + { status: 400 }, + )); + expect(events.some((event: AdapterEvent) => event.type === "error")).toBe(true); + + await flushThoughtSignatureReplayForTests(); + expect(lookupReplayThoughtSignature("call_evict_1", scope)).toBeUndefined(); + }); + } }); + diff --git a/tests/thought-signature-credential-scope.test.ts b/tests/thought-signature-credential-scope.test.ts index 0d722afb19..e94f74fb49 100644 --- a/tests/thought-signature-credential-scope.test.ts +++ b/tests/thought-signature-credential-scope.test.ts @@ -16,6 +16,7 @@ import { thoughtSignatureReplaySalt, } from "../src/responses/thought-signature-replay"; import { durableReplayCredentialIdentity, durableReplayDestinationIdentity } from "../src/responses/reasoning-replay-cache"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../src/lib/windows-secret-acl"; const SIG = "CiQAx-credential-scope-signature-0123456789abcdef"; @@ -39,6 +40,8 @@ describe("#1926 durable credential scope", () => { let testDir: string; beforeEach(() => { + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); + setAsyncIcaclsRunnerForTests(async () => ({ success: true, exitCode: 0, timedOut: false, stdout: "processed file: 1" })); resetThoughtSignatureReplayForTests(); previousHome = process.env.OPENCODEX_HOME; testDir = mkdtempSync(join(tmpdir(), "ocx-tsig-scope-")); @@ -51,6 +54,8 @@ describe("#1926 durable credential scope", () => { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(testDir, { recursive: true, force: true }); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); }); test("two credentials on one destination never share a signature", () => { From 844885ab1f12474b48dda5668863132a0b776d9b Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:01:16 +0900 Subject: [PATCH 013/336] fix(xai): stop advertising text.verbosity, including on combo and live-discovered rows (rebase of #2503) (#2578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(xai): stop advertising text.verbosity, which xAI accepts and ignores Probed 2026-08-22 on grok-4.6 against cli-chat-proxy.grok.com, one case per request: text.verbosity = "not-a-real-value" -> 200 text.verbosity = "low" -> 200, 744 output chars text.verbosity = "high" -> 200, 434 output chars text omitted -> 200, 605 output chars The invalid-value control is the load-bearing evidence: a server that rejects nothing is not parsing the field. Output length is non-monotonic, so the low/high difference is noise. Codex's verbosity picker was a no-op on this route. Two layers, because the catalog bit alone does not stop the field reaching the wire. A new per-provider `modelSupportsVerbosity` capability declares it unsupported, and stripDisabledVerbosity removes `text.verbosity` from the outbound body when the capability says so — preserving sibling keys such as `format`, and emitting no empty `text` object. The global support_verbosity default is untouched, so other routed providers are unaffected. Kiro's hardcoded `adapter === "kiro"` special case now goes through the same capability, so there is one mechanism rather than two. Deliberately NOT expressed through modelSupportsReasoningSummaries or anything feeding configuredReasoningSummarySupport: that bit gates Codex's entire Responses reasoning object, so reusing it would disable reasoning.effort — the same #1100 coupling that had to be backed out of the multi-agent unit. A test asserts the reasoning object survives the verbosity strip. The vendor-wide application is documented as a generalization: the measurement is one model on one destination, and it is applied to the lineup because text.verbosity is an OpenAI Responses parameter absent from xAI's documented API. * fix(xai): keep verbosity defaults off the auth persist surface Registry verbosity opt-outs stay on the request and catalog paths. File config still validates the optional override map. This avoids touching src/oauth and auth-cors, which the intake hygiene gate treats as a sponsored security surface. * fix(xai): keep the verbosity opt-out on combo and live-discovered rows Two paths still re-advertised text.verbosity after the per-model opt-out landed: - Combo derivation dropped the flag entirely, so a combo containing an xAI or Kiro member advertised a control that member cannot honour. A combo is only as capable as its least capable member, so the conservative false now propagates - the same rule supportsReasoningSummaries already uses. - modelSupportsVerbosity only enumerates the ids present when the registry row was written, so a live-discovered xAI model fell through. The registry gains a provider-wide supportsVerbosity default; a per-model entry still wins over it. Both driven red first: removing either fix fails exactly its own case. --------- Co-authored-by: olddonkey --- src/adapters/openai-responses.ts | 29 ++++++++- src/codex/catalog/aggregation.ts | 6 ++ src/codex/catalog/provider-fetch.ts | 22 ++++++- src/config.ts | 11 ++++ src/providers/registry.ts | 35 +++++++++- src/router.ts | 6 ++ src/types/provider.ts | 6 ++ tests/codex-catalog.test.ts | 74 ++++++++++++++++++++++ tests/codex-tool-mode.test.ts | 31 +++++++++ tests/config.test.ts | 33 ++++++++++ tests/openai-responses-passthrough.test.ts | 74 ++++++++++++++++++++++ 11 files changed, 321 insertions(+), 6 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b8dd006a6c..fd1185a5a4 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -368,6 +368,27 @@ function stripDisabledReasoningSummaries( }; } +/** + * Hide a no-op Responses verbosity control from the wire as well as the catalog. This runs at + * final serialization so a stale catalog or direct caller cannot bypass the capability. Other + * `text` settings (notably structured-output `format`) remain untouched. + */ +function stripDisabledVerbosity( + body: unknown, + provider: OcxProviderConfig, + modelId: string, +): unknown { + if (modelRecordValue(provider.modelSupportsVerbosity, modelId) !== false || !isPlainObject(body)) { + return body; + } + if (!isPlainObject(body.text) || !Object.hasOwn(body.text, "verbosity")) return body; + const { verbosity: _verbosity, ...rest } = body.text; + return { + ...body, + ...(Object.keys(rest).length > 0 ? { text: rest } : { text: undefined }), + }; +} + /** * Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that * did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged. @@ -1787,8 +1808,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider), stripEncryptedContent: threadServingIdentityChanged, }))))))); - const finalBody = stripDisabledReasoningSummaries( - normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), + const finalBody = stripDisabledVerbosity( + stripDisabledReasoningSummaries( + normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), + provider, + parsed.modelId, + ), provider, parsed.modelId, ); diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 73c1745954..0240a1a161 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -193,6 +193,12 @@ export function deriveComboCatalogModel( ? { supportsServiceTier: false } : {}), ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}), + // A combo is only as capable as its least capable member. One member that cannot honour + // text.verbosity is enough to make the control a no-op for the whole combo, so the + // conservative false propagates — the same rule supportsReasoningSummaries uses above. + // Without this, routing a combo through an xAI or Kiro member re-advertised a control the + // upstream accepts and ignores. + ...(members.some(member => member.supportsVerbosity === false) ? { supportsVerbosity: false } : {}), ...(members.every(member => member.codexToolMode === "shell") ? { codexToolMode: "shell" as const } : {}), diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 798d337920..3273af2355 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -38,7 +38,7 @@ import { serviceTierSupportFromPolicy, } from "../../providers/service-tier"; import type { FastPolicyAuthority } from "../../providers/fastwire"; -import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; @@ -577,6 +577,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco re: prov.modelReasoningEfforts ?? null, defRe: prov.modelDefaultReasoningEfforts ?? null, rsSum: prov.modelSupportsReasoningSummaries ?? null, + verbosity: prov.modelSupportsVerbosity ?? null, rsDel: prov.modelReasoningSummaryDelivery ?? null, serviceTier: prov.modelSupportsServiceTier ?? null, noVis: [...(prov.noVisionModels ?? [])].sort(), @@ -645,8 +646,22 @@ function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined; } +function configuredVerbositySupport(name: string, prov: OcxProviderConfig | undefined, id: string): boolean | undefined { + const explicit = prov ? modelRecordValue(prov.modelSupportsVerbosity, id) : undefined; + if (explicit !== undefined) return explicit; + if (!prov) return undefined; + const entry = (providerMatchesRegistryTransport(name, prov) ? getProviderRegistryEntry(name) : undefined) + ?? registryEntryForProviderDestination(prov); + if (!entry) return undefined; + const perModel = modelRecordValue(entry.modelSupportsVerbosity, id); + if (perModel !== undefined) return perModel; + // Provider-wide fallback. `modelSupportsVerbosity` only enumerates the ids present when the + // registry row was written, so a live-discovered model used to fall through here and + // re-advertise a control the upstream accepts and ignores. A per-model entry still wins. + return entry.supportsVerbosity; +} + export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { - void name; const configuredCap = configuredContextWindow(prov, model.id); const configuredMaxInput = configuredMaxInputTokens(prov, model.id); const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); @@ -662,6 +677,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); + const supportsVerbosity = configuredVerbositySupport(name, prov, model.id); const fastPolicy = fastPolicyForModel(prov, model.id, name); const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy); const { @@ -690,11 +706,11 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined ? { fastTierDescription: fastPolicy.fastTierDescription } : {}), - ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}), // Default-on for openai-chat providers (explicit false opts out); other adapters // advertise only on explicit opt-in. ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) diff --git a/src/config.ts b/src/config.ts index 3bf4a7e55b..9420ede9b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1127,6 +1127,17 @@ const configSchema = z.object({ message: reasoningSummariesError, }); } + const verbositySupportError = booleanRecordConfigError( + (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity, + "modelSupportsVerbosity", + ); + if (verbositySupportError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelSupportsVerbosity"], + message: verbositySupportError, + }); + } const serviceTierModelsError = booleanRecordConfigError( (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier, "modelSupportsServiceTier", diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 9fda85ba63..dae7310ea2 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -250,6 +250,18 @@ export interface ProviderRegistryEntry { preserveResponsesReasoningContent?: boolean; /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ modelSupportsReasoningSummaries?: Record; + /** Registry defaults for per-model Codex Responses verbosity support. */ + modelSupportsVerbosity?: Record; + /** + * Registry default applied to EVERY model of this provider, including ids that arrive from + * live discovery after this table was written. + * + * `modelSupportsVerbosity` only covers the ids enumerated here, so a newly discovered model + * fell through and re-advertised a control the upstream accepts and ignores. Where the opt-out + * is a property of the provider's API rather than of one model, declare it here; a per-model + * entry still wins over it. + */ + supportsVerbosity?: boolean; modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; @@ -416,6 +428,15 @@ const OPENAI_DAYBREAK_REASONING_EFFORTS: Record = Object.fromE OPENAI_DAYBREAK_MODELS.map(id => [id, [] as string[]]), ); const OPENROUTER_GPT56_MODELS = OPENAI_GPT56_MODELS.map(id => `openai/${id}`); +const XAI_MODELS = [ + "grok-4.6", + "grok-4.5", + "grok-4.3", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-build-0.1", + "grok-composer-2.5-fast", +]; // OpenRouter's live /endpoints routes report 1,050,000; keep this separate from the // unverified OpenAI API-key seed. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. const OPENROUTER_GPT56_CONTEXT_WINDOW = 1_050_000; @@ -1067,7 +1088,18 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // transport returns 400 ("Multi Agent requests are not allowed on chat completions"). // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung. - models: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"], + models: XAI_MODELS, + // Measured only on grok-4.6 against cli-chat-proxy.grok.com: even an invalid + // `text.verbosity` value is accepted and low/high/omitted output length is non-monotonic. + // Apply the resulting opt-out to the whole xAI lineup because `text.verbosity` is an OpenAI + // Responses parameter absent from xAI's documented API, not because every model was probed. + // Keep this separate from reasoning-summary support: that bit gates Codex's + // entire Responses reasoning object, including reasoning.effort. + modelSupportsVerbosity: Object.fromEntries(XAI_MODELS.map(id => [id, false])), + // Provider-wide, not merely per-model: `text.verbosity` is an OpenAI Responses parameter + // absent from xAI's documented API, so a model discovered later has no more support for it + // than the seeded ones do. + supportsVerbosity: false, defaultModel: "grok-4.5", // Keep Codex Responses callers on the compatibility Chat wire until xAI can replay // opaque reasoning continuation and compaction state across later turns. The scoped @@ -1237,6 +1269,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Per-model context metadata is maintained next to the Kiro model list. modelContextWindows: KIRO_MODEL_CONTEXT_WINDOWS, modelReasoningEfforts: KIRO_MODEL_REASONING_EFFORTS, + modelSupportsVerbosity: Object.fromEntries(KIRO_MODELS.map(id => [id, false])), }, { // Nous Portal — Nous Research subscription gateway (same backend Hermes Agent diff --git a/src/router.ts b/src/router.ts index 47a604d77c..231682397d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -116,6 +116,7 @@ export function knownModelIdsForProvider( registry?.modelReasoningEffortMap, registry?.modelMaxOutputTokens, registry?.modelSupportsServiceTier, + registry?.modelSupportsVerbosity, ]) { for (const id of Object.keys(map ?? {})) ids.add(id); } @@ -312,6 +313,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider : undefined, provider.modelSupportsServiceTier, ); + const modelSupportsVerbosity = mergeRecordFill( + registryEntry.modelSupportsVerbosity, + provider.modelSupportsVerbosity, + ); const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels); const noReasoningModels = mergeStringArray(registryEntry.noReasoningModels, provider.noReasoningModels); const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels); @@ -426,6 +431,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(modelMaxInputTokens ? { modelMaxInputTokens } : {}), ...(modelMaxOutputTokens ? { modelMaxOutputTokens } : {}), ...(modelSupportsServiceTier ? { modelSupportsServiceTier } : {}), + ...(modelSupportsVerbosity ? { modelSupportsVerbosity } : {}), ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}), ...(modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts } : {}), ...(reasoningEffortMap ? { reasoningEffortMap } : {}), diff --git a/src/types/provider.ts b/src/types/provider.ts index 0bc4ac4813..0cd2c8585b 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -341,6 +341,12 @@ export interface OcxProviderConfig { * Responses backend rejects Codex summary-delivery fields for that model. */ modelSupportsReasoningSummaries?: Record; + /** + * Model-specific Codex Responses verbosity capability. Set false when the upstream ignores + * `text.verbosity`; the catalog hides the no-op picker and the Responses adapter strips stale + * or caller-supplied values while preserving other `text` fields. + */ + modelSupportsVerbosity?: Record; /** * Per-model wire value for Responses `stream_options.reasoning_summary_delivery`. * Presence also advertises reasoning-summary support for that routed model. diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 2cfdfa1ce5..621179de00 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -4391,6 +4391,63 @@ describe("Codex catalog routed normalization", () => { expect(routed?.default_reasoning_summary).toBe("none"); }); + test("xAI and Kiro routed rows disable verbosity without changing other providers", async () => { + const models = await gatherRoutedModels({ + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + liveModels: false, + models: ["grok-4.6"], + }, + kiro: { + adapter: "kiro", + baseUrl: "https://runtime.us-east-1.kiro.dev", + authMode: "oauth", + liveModels: false, + models: ["gpt-5.6-sol"], + }, + plain: { + adapter: "openai-responses", + baseUrl: "https://plain.example.test/v1", + authMode: "key", + liveModels: false, + models: ["plain-model"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + + expect(models.find(model => model.provider === "xai" && model.id === "grok-4.6")?.supportsVerbosity).toBe(false); + expect(entries.find(entry => entry.slug === "xai/grok-4.6")?.support_verbosity).toBe(false); + expect(models.find(model => model.provider === "kiro" && model.id === "gpt-5.6-sol")?.supportsVerbosity).toBe(false); + expect(entries.find(entry => entry.slug === "kiro/gpt-5.6-sol")?.support_verbosity).toBe(false); + expect(models.find(model => model.provider === "plain" && model.id === "plain-model")?.supportsVerbosity).toBeUndefined(); + expect(entries.find(entry => entry.slug === "plain/plain-model")?.support_verbosity).toBe(true); + }); + + test("a live-discovered xAI id inherits the provider-wide verbosity opt-out", async () => { + // modelSupportsVerbosity only enumerates the ids present when the registry row was written. + // A model that arrives later from live discovery used to fall through and re-advertise a + // control xAI accepts and ignores. + const models = await gatherRoutedModels({ + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + liveModels: false, + models: ["grok-9.9-not-in-the-registry"], + }, + }, + }); + const entries = buildCatalogEntries(null, [], models); + + expect(models.find(model => model.provider === "xai")?.supportsVerbosity).toBe(false); + expect(entries.find(entry => entry.slug === "xai/grok-9.9-not-in-the-registry")?.support_verbosity).toBe(false); + }); + test("a routed model never inherits the native template's context window (#992)", () => { // /models returns only the id: the routed entry must fall to the // conservative 128k triple, never the native template's larger window. @@ -4700,6 +4757,23 @@ describe("Codex catalog routed normalization", () => { }; enrichProviderFromCatalog("deepseek", submitted); expect(submitted.modelSupportsReasoningSummaries).toEqual({ "deepseek-v4-flash": false }); + + const xai: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + }; + enrichProviderFromCatalog("xai", xai); + expect(xai.modelSupportsVerbosity).toBeUndefined(); + + const submittedVerbosity: OcxConfig["providers"][string] = { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + modelSupportsVerbosity: { "grok-4.6": true }, + }; + enrichProviderFromCatalog("xai", submittedVerbosity); + expect(submittedVerbosity.modelSupportsVerbosity).toEqual({ "grok-4.6": true }); }); test("explicit per-model overrides survive registry backfill", () => { diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index 9fa11092f4..eb87143707 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -208,3 +208,34 @@ describe("Codex tool mode configuration (#2106)", () => { }); + +describe("#2503 combo derivation preserves a member's verbosity opt-out", () => { + const { deriveComboCatalogModel } = require("../src/codex/catalog/aggregation"); + const combo = { + name: "mixed-combo", + targets: [ + { provider: "xai", model: "grok-4.6" }, + { provider: "openai", model: "gpt-5.6-sol" }, + ], + }; + + test("one member that cannot honour text.verbosity makes the combo advertise false", () => { + // A combo is only as capable as its least capable member: routing a turn to the xAI member + // would re-advertise a control the upstream accepts and ignores. Same conservative rule + // supportsReasoningSummaries already uses. + const derived = deriveComboCatalogModel("mixed-combo", combo, [ + { id: "grok-4.6", provider: "xai", contextWindow: 256000, supportsVerbosity: false }, + { id: "gpt-5.6-sol", provider: "openai", contextWindow: 272000 }, + ]); + expect(derived?.supportsVerbosity).toBe(false); + }); + + test("a combo whose members all support verbosity does not force the flag", () => { + const derived = deriveComboCatalogModel("mixed-combo", combo, [ + { id: "a", provider: "openai", contextWindow: 272000 }, + { id: "b", provider: "openai", contextWindow: 272000, supportsVerbosity: true }, + ]); + expect(derived?.supportsVerbosity).toBeUndefined(); + }); +}); + diff --git a/tests/config.test.ts b/tests/config.test.ts index 8979860787..6ebae31319 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -34,6 +34,7 @@ import { import * as windowsAcl from "../src/lib/windows-secret-acl"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation"; import { AtomicWriteResidualTempError, atomicWriteFile, atomicWriteFileAsync, hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config"; +import { providerManagementConfigError } from "../src/server/auth-cors"; let testDir = ""; /** @@ -1246,6 +1247,38 @@ describe("opencodex config defaults", () => { } }); + test("modelSupportsVerbosity accepts only plain boolean records", () => { + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelSupportsVerbosity: { strict: false, normal: true }, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().error).toBeNull(); + + for (const invalid of [[], { strict: "false" }, { "": false }]) { + writeConfig({ + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + modelSupportsVerbosity: invalid, + }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("modelSupportsVerbosity"); + } + + }); + test("modelReasoningSummaryDelivery validates known values and rejects summary opt-out conflicts (#538)", () => { writeConfig({ port: 12345, diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index e57011ea19..2796e1b849 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -4,6 +4,7 @@ import { openaiResponsesUrl } from "../src/adapters/openai-responses-url"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; import { routeModel } from "../src/router"; +import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; import { handleResponses, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { encodeCompactionSummary, @@ -906,6 +907,79 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.reasoning).toEqual({ effort: "high" }); }); + function routedXaiResponsesProvider() { + const entry = getProviderRegistryEntry("xai")!; + const route = routeModel({ + port: 0, + defaultProvider: "xai", + providers: { + xai: { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + authMode: "key", + apiKey: "xai-test-key", + modelAdapters: { "grok-4.6": "openai-responses" }, + }, + }, + } as OcxConfig, "xai/grok-4.6"); + return resolveWireProtocolOverride(route.providerName, route.modelId, route.provider); + } + + test("xAI verbosity opt-out strips verbosity but preserves sibling text settings", () => { + const provider = routedXaiResponsesProvider(); + expect(provider.modelSupportsVerbosity?.["grok-4.6"]).toBe(false); + expect(provider.adapter).toBe("openai-responses"); + + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "grok-4.6", + input: [], + reasoning: { effort: "high" }, + text: { verbosity: "high", format: { type: "json_object" } }, + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as Record; + + expect(body.text).toEqual({ format: { type: "json_object" } }); + expect(body.reasoning).toEqual({ effort: "high" }); + }); + + test("xAI verbosity opt-out removes an emptied text object", () => { + const request = createResponsesPassthroughAdapter(routedXaiResponsesProvider()).buildRequest({ + modelId: "grok-4.6", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "grok-4.6", input: [], text: { verbosity: "low" } }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as Record; + + expect(body).not.toHaveProperty("text"); + }); + + test("unclassified Responses destinations preserve text verbosity", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://compat.example.test/v1", + authMode: "key", + apiKey: "sk-test", + }); + const request = adapter.buildRequest({ + modelId: "other-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "other-model", input: [], text: { verbosity: "medium" } }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as Record; + + expect(body.text).toEqual({ verbosity: "medium" }); + }); + test("model reasoning-summary delivery rewrites only the configured stale-client enum (#538)", () => { const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", From ef1f31055602c7b973888408202120c6b948b571 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:11:52 +0900 Subject: [PATCH 014/336] fix: disable Bun's fetch socket idle timeout for upstream provider requests (rebase of #2567) (#2579) * fix: disable Bun fetch socket idle timeout for upstream provider requests Bun's fetch() has a built-in socket idle timeout (default ~300s) that fires "The operation timed out." when a streaming response has no data flowing for that duration. This is independent of the application-level stallTimeoutSec and connectTimeoutMs settings. For local/slow models (e.g. LM Studio) that may pause for extended periods during reasoning, this causes spurious upstream_sse_error failures even when stallTimeoutSec is set to a large value. Pass timeout: 0 in all upstream fetch calls to disable Bun's idle timeout, deferring to the existing app-level stall watchdog instead. Co-Authored-By: Kiro (AI) * test: pin the timeout: 0 propagation the hygiene gate asked for Covers all three upstream call sites - providerFetch, fetchWithHeaderTimeout and fetchWithHeaderDeadline - and asserts the application-level AbortSignal survives, since disabling the idle timer must not remove the deadline. Driven red first: reverting the three call sites fails 3 of the 3 new cases. Also relaxes one pre-existing assertion that used exact object equality on the forwarded init. Its intent is that no protocol pin is invented when the provider declares none; it now asserts the caller's fields plus the absent pin, so the unconditional timeout: 0 does not read as a contract change. --------- Co-authored-by: eschcam Co-authored-by: Kiro (AI) --- src/server/claude-messages.ts | 2 +- src/server/responses/fetch-helpers.ts | 3 +- tests/fetch-header-timeout.test.ts | 82 +++++++++++++++++++++++++++ tests/upstream-http-version.test.ts | 6 +- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 3391320d23..8425395b18 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -553,7 +553,7 @@ export async function fetchWithHeaderDeadline( ): Promise { const deadline = makeDeadline(timeoutMs, parent); try { - const upstream = await fetchImpl(input, { ...init, signal: deadline.signal }); + const upstream = await fetchImpl(input, { ...init, signal: deadline.signal, timeout: 0 }); return { kind: "response", upstream }; } catch (error) { if (deadline.didExpire()) return { kind: "timeout" }; diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index efc455797a..898275e6fc 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -69,7 +69,7 @@ export function providerFetch( }; const httpFetch = Object.assign( (input: Parameters[0], init?: RequestInit) => - base(input, withUpstreamHttpVersion(input, init, provider)), + base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }), { preconnect }, ) as typeof globalThis.fetch; // ChatGPT Codex backend: streaming turns ride the responses_websockets @@ -139,6 +139,7 @@ export async function fetchWithHeaderTimeout( // indistinguishable from a pre-connection failure (#914). ...(manualRedirect ? { redirect: "manual" as const } : {}), signal: AbortSignal.any([abortSignal, timeout.signal]), + timeout: 0, }); } finally { clearTimeout(timer); diff --git a/tests/fetch-header-timeout.test.ts b/tests/fetch-header-timeout.test.ts index 89d7c57831..7fb549ba77 100644 --- a/tests/fetch-header-timeout.test.ts +++ b/tests/fetch-header-timeout.test.ts @@ -107,3 +107,85 @@ describe("fetchWithHeaderTimeout content-encoding policy", () => { expect(await readChunk(identityReader)).toBe("data: second\n\n"); }); }); + +describe("#2567 the upstream fetch disables Bun's per-request idle timeout", () => { + /** + * Bun's default socket idle timeout kills a long-quiet upstream turn even though the + * application-level deadline (AbortSignal) has not fired. Passing `timeout: 0` disables the + * per-request idle timer; the signal remains the only deadline that can end the request. + * + * These pin the propagation on all three call sites, because the value is easy to drop in a + * refactor and its absence is invisible until a slow provider stalls in production. + */ + function recordingFetch(): { calls: RequestInit[]; fetch: typeof globalThis.fetch } { + const calls: RequestInit[] = []; + const fetch = (async (_input: unknown, init?: RequestInit) => { + calls.push(init ?? {}); + return new Response("ok"); + }) as unknown as typeof globalThis.fetch; + return { calls, fetch }; + } + + test("providerFetch passes timeout: 0 to the underlying fetch", async () => { + const { providerFetch } = await import("../src/server/responses/fetch-helpers"); + const { calls, fetch } = recordingFetch(); + const provider = { + adapter: "openai-chat", + baseUrl: "https://upstream.example.test/v1", + fetch, + } as unknown as Parameters[0]; + + await providerFetch(provider)("https://upstream.example.test/v1/chat/completions", { method: "POST" }); + + expect(calls.length).toBe(1); + expect((calls[0] as { timeout?: number }).timeout).toBe(0); + }); + + test("fetchWithHeaderTimeout passes timeout: 0 while keeping its abort signal", async () => { + const server = startHeaderEchoServer(); + const seen: RequestInit[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + seen.push(init ?? {}); + return originalFetch(input as string, init); + }) as unknown as typeof globalThis.fetch; + try { + await fetchWithHeaderTimeout( + server.url.toString(), + {}, + new AbortController().signal, + 1_000, + false, + ); + } finally { + globalThis.fetch = originalFetch; + } + + expect(seen.length).toBeGreaterThan(0); + const init = seen[0] as { timeout?: number; signal?: AbortSignal }; + expect(init.timeout).toBe(0); + // The application deadline must survive: disabling the idle timer is not the same as + // removing the deadline. + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + test("fetchWithHeaderDeadline passes timeout: 0 to its injected fetch", async () => { + const { fetchWithHeaderDeadline } = await import("../src/server/claude-messages"); + const { calls, fetch } = recordingFetch(); + + const result = await fetchWithHeaderDeadline( + "https://upstream.example.test/v1/messages", + { method: "POST" }, + 1_000, + undefined, + undefined, + fetch, + ); + + expect(result.kind).toBe("response"); + expect(calls.length).toBe(1); + const init = calls[0] as { timeout?: number; signal?: AbortSignal }; + expect(init.timeout).toBe(0); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/tests/upstream-http-version.test.ts b/tests/upstream-http-version.test.ts index 8c99d8fbb8..7acb783789 100644 --- a/tests/upstream-http-version.test.ts +++ b/tests/upstream-http-version.test.ts @@ -128,7 +128,11 @@ describe("providerFetch upstreamHttpVersion propagation", () => { const seen: { init?: RequestInit } = {}; const fetcher = providerFetch(provider({ fetch: stubFetch(seen) })); await fetcher(HTTPS_URL, { method: "POST", body: "{}" }); - expect(seen.init).toEqual({ method: "POST", body: "{}" }); + // The caller's fields survive untouched and no protocol pin is invented. `timeout: 0` is + // added unconditionally (#2567) to disable Bun's per-request socket idle timer, so assert + // the caller's fields and the absence of a pin rather than exact object equality. + expect(seen.init).toMatchObject({ method: "POST", body: "{}" }); + expect((seen.init as RequestInit & { protocol?: string })?.protocol).toBeUndefined(); }); test("no init still applies a pinned version to the fetch call", async () => { From 342911fec72453814059af4347dbc56909b881f2 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:12:46 +0900 Subject: [PATCH 015/336] fix(codex): serve the last catalog reading while the Windows probe refreshes (rebase of #2542) (#2580) * fix(codex): serve the last catalog reading while the Windows probe refreshes The request-path collector awaits an advisory probe whose cost routinely exceeds its own TTL. On Windows the enumeration pays Invoke-CimMethod GetOwner once per candidate process; measured on a Windows 11 26200 box that is ~435 ms per candidate, so a machine with more Codex processes spends seconds there. A 5s TTL behind a probe that takes longer than 5s means the cache expires before it can serve much, and the miss lands on the turn. Serve the previous real reading immediately and refresh behind it. The reading cannot have been invalidated by an ocx catalog write -- every such write calls resetCodexAppServerCatalogStateCache, which advances the generation and drops the entry, so a generation match means no write has landed since it was taken. Bounded at 60s, and never applied to `unknown`: that state is a failure to observe rather than an observation, and it already has its own short window. Refs #2499 * fix(codex): keep the reading a failed refresh was refreshing Review follow-up on the serve-stale change. Before it, caching `unknown` on a failed refresh cost at most the 250ms that state is allowed to live. Now that an expired observation is what callers are handed, overwriting one with `unknown` takes the answer away from them on a transient failure: `unknown` is not servable, so the next caller waits for a probe instead of getting the reading it would have had. Keep the observation and let its own age retire it. Also records the new contract in the file's Decision Log, and replaces a fixed 5ms wait in the second test with a bounded drain on the condition -- a sleep that short is a bet on how many turns a continuation needs, and losing it on a slow runner reads as a product bug. * fix(codex): measure the stale bound from expiry, not from the reading The doc said the bound runs from expiry; the code measured it from when the reading was taken, so a `fresh` entry got ~55s of stale serving instead of 60s. Anchoring it to expiry also keeps the stale window independent of the TTL: a cap on total age would quietly turn this path off if the TTL were ever raised past 60s, since anything expired would already be too old to serve. The bound test now pins both sides -- one tick inside it is still served without waiting, one tick past it waits for a real reading. --------- Co-authored-by: Nguyen Thanh Dat --- src/codex/app-server-processes.ts | 69 +++++++- tests/codex-app-server-processes.test.ts | 206 +++++++++++++++++++++++ 2 files changed, 269 insertions(+), 6 deletions(-) diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index ad532c31e9..62a149e42f 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -740,6 +740,28 @@ const CATALOG_STATE_TTL_MS = 5_000; */ const CATALOG_STATE_UNKNOWN_TTL_MS = 250; +/** + * How long a real observation may still be SERVED after it expires, while a refresh + * runs behind it (#2499). + * + * The probe is advisory and, on Windows, slow: `Invoke-CimMethod GetOwner` costs + * ~0.4s per candidate process, so a cold probe routinely outlives the 5s TTL and + * every turn that misses the cache pays for it on the request path. Serving the + * previous reading immediately keeps that cost off the turn without pretending it is + * fresh -- the refresh it triggers is what makes the next reading current. + * + * Measured from expiry rather than from when the reading was taken, so a `fresh` + * entry stays servable for its own TTL plus this bound. Anchoring it to expiry keeps + * the stale window independent of the TTL -- a cap on total age would quietly turn + * this path off if the TTL were ever raised past it. + * + * Bounded rather than unlimited: if the refresh keeps failing, an observation this + * old stops being evidence about the machine and it is better to wait for a real one. + * `unknown` is never served this way -- it is a failure to observe, not an + * observation, and it already has its own short window for exactly that reason. + */ +export const CATALOG_STATE_MAX_STALE_MS = 60_000; + export function catalogStateTtlMs(state: CodexAppServerCatalogState): number { return state === "unknown" ? CATALOG_STATE_UNKNOWN_TTL_MS : CATALOG_STATE_TTL_MS; } @@ -835,6 +857,14 @@ export function collectCodexAppServerCatalogState( * - 선택한 방식: retain the synchronous API and use async PowerShell plus an identity-scoped in-flight refresh, short cache, and invalidation generation only for Windows requests. * - 다른 대안 대신 이 방식을 선택한 이유: it fixes unrelated `/healthz` starvation without widening the process-matching or restart contract. * - 장점, 단점 및 영향: concurrent turns share one CIM walk, invalidated pre-write results cannot repopulate the cache, and the event loop stays responsive; a cold v2 turn can still await the bounded advisory probe. + * + * [Decision Log · #2499] + * - 목적과 의도: a cold probe outlives its own 5s TTL on Windows (~435ms per candidate process for `Invoke-CimMethod GetOwner`), so the cache expires before it can serve and the miss lands on a turn. + * - 기존 구현 및 제약 조건: the reading is advisory, and only `fresh` authorizes positive guidance (`src/server/responses/collaboration.ts`); `unknown` is a failure to observe rather than an observation. + * - 검토한 주요 대안: drop the per-process GetOwner fan-out (issue suggestion 1), or widen the TTL past the probe duration (suggestion 3). + * - 선택한 방식: serve an expired reading immediately when its generation still matches, bounded by `CATALOG_STATE_MAX_STALE_MS`, never for `unknown`, and refresh behind it; a failed refresh no longer evicts the reading it was refreshing. + * - 다른 대안 대신 이 방식을 선택한 이유: the fan-out change alters what "could not verify the owner" means for the current-user scoping contract and needs its own ground-truth comparison; a wider TTL still pays the probe on every human-paced turn. + * - 장점, 단점 및 영향: after the first probe the request path never waits; a server that stopped between readings can be described as `fresh` for up to the stale bound; a catalog write still invalidates immediately through the generation, and the cold path is unchanged. */ export async function collectCodexAppServerCatalogStateForRequest( io: CodexAppServerProcessIo = {}, @@ -853,16 +883,30 @@ export async function collectCodexAppServerCatalogStateForRequest( catalogMtimeMs: io.catalogMtimeMs, now: io.now, }; - if (requestCatalogStateCache + const cached = requestCatalogStateCache && requestCatalogStateCache.generation === generation && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity) - && now - requestCatalogStateCache.atMs < catalogStateTtlMs(requestCatalogStateCache.status.state)) { - return requestCatalogStateCache.status; + ? requestCatalogStateCache + : null; + if (cached && now - cached.atMs < catalogStateTtlMs(cached.status.state)) { + return cached.status; } + // An expired real reading is still worth handing back while the refresh runs. It + // cannot have been invalidated by an ocx catalog write: every such write calls + // `resetCodexAppServerCatalogStateCache`, which advances the generation and drops + // this entry, so a generation match means no write has landed since it was taken. + // What it can miss is an app-server that started or stopped meanwhile -- and a + // server started after the reading is newer than the catalog, which is the `fresh` + // this entry already says. + const servableStale = cached + && cached.status.state !== "unknown" + && now - cached.atMs < catalogStateTtlMs(cached.status.state) + CATALOG_STATE_MAX_STALE_MS + ? cached.status + : null; if (requestCatalogStateFlight && requestCatalogStateFlight.generation === generation && sameRequestCatalogStateIdentity(requestCatalogStateFlight.identity, identity)) { - return requestCatalogStateFlight.promise; + return servableStale ?? requestCatalogStateFlight.promise; } const refresh = async (): Promise => { @@ -920,7 +964,18 @@ export async function collectCodexAppServerCatalogStateForRequest( if (requestCatalogStateGeneration !== generation) { return { state: "unknown" as const, processes: [], catalogMtimeMs: null }; } - if (requestCatalogStateFlight === flight) { + // A refresh that failed must not evict a real reading. Before this function + // served stale entries, caching `unknown` cost at most the 250ms that state + // is allowed to live. Now that an expired observation is what callers are + // handed, overwriting one with `unknown` would take the answer AWAY from + // them on a transient failure -- `unknown` is not servable, so the next + // caller waits for a probe instead of getting the reading it would have had. + // Keep the observation and let its own age retire it. + const wouldEvictAnObservation = status.state === "unknown" + && requestCatalogStateCache?.generation === generation + && requestCatalogStateCache.status.state !== "unknown" + && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity); + if (requestCatalogStateFlight === flight && !wouldEvictAnObservation) { requestCatalogStateCache = { generation, identity, @@ -934,7 +989,9 @@ export async function collectCodexAppServerCatalogStateForRequest( }); flight = { generation, identity, promise }; requestCatalogStateFlight = flight; - return flight.promise; + // `promise` already absorbs its own failures, so leaving it unawaited here cannot + // surface as an unhandled rejection; the next caller picks up whatever it stored. + return servableStale ?? flight.promise; } /** diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index 5c6c828980..7955fde9e7 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -7,6 +7,7 @@ import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/window import { afterCatalogWriteHandleAppServers, attachStaleAppServerHint, + CATALOG_STATE_MAX_STALE_MS, catalogStateTtlMs, collectCodexAppServerCatalogState, collectCodexAppServerCatalogStateForRequest, @@ -1063,3 +1064,208 @@ describe("platform termination ladder", () => { expect(signals).toEqual([{ pid: 4242, signal: "SIGTERM" }]); }); }); + +describe("request-path catalog state serves stale while revalidating (#2499)", () => { + // The suite's other command-line fixture is scoped to its own describe block. + const APP_SERVER_COMMAND_LINE = "/usr/local/bin/codex app-server"; + const APP_SERVER = [{ pid: 42, commandLine: APP_SERVER_COMMAND_LINE }]; + + /** + * A probe that never settles on its own. Every test here needs to observe what a + * caller gets back WHILE an enumeration is still running, which is the whole point: + * on Windows this probe costs ~0.4s per candidate process and routinely outlives + * the 5s TTL, so before this change one turn per window paid for it on the request + * path. + */ + function makeIo(clock: { ms: number }) { + const releases: Array<(snapshots: Array<{ pid: number; commandLine: string }>) => void> = []; + const io = { + platform: "win32" as const, + now: () => clock.ms, + listSnapshotsAsync: () => + new Promise>(resolve => { + releases.push(resolve); + }), + readStartMsBatchAsync: async (pids: number[]) => new Map(pids.map(pid => [pid, 2_000] as const)), + catalogMtimeMs: () => 1_000, + }; + return { io, releases, enumerations: () => releases.length }; + } + + /** + * Yield until the released refresh has stored its result, or give up loudly. + * + * A fixed sleep would be a bet on how many turns the refresh's continuation + * needs, and losing that bet on a slow runner looks like a product bug rather + * than a slow machine. This waits for the condition instead of for a duration. + */ + async function drainUntil(predicate: () => boolean, what: string) { + for (let attempt = 0; attempt < 1_000; attempt += 1) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 0)); + } + throw new Error(`timed out waiting for ${what}`); + } + /** Did *promise* settle without the pending probe being released? */ + async function settledWithoutTheProbe(promise: Promise | T): Promise { + return Promise.race([ + Promise.resolve(promise), + new Promise<"waited">(resolve => setTimeout(() => resolve("waited"), 25)), + ]); + } + + beforeEach(() => { + resetCodexAppServerCatalogStateCache(); + }); + + test("the first turn waits, later turns do not, and N turns cost one enumeration", async () => { + const clock = { ms: 1_000_000 }; + const { io, releases, enumerations } = makeIo(clock); + + // Cold: nothing cached, so this one has to wait for the probe. + const cold = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(cold)).toBe("waited"); + releases[0]([...APP_SERVER]); + await expect(cold).resolves.toMatchObject({ state: "fresh" }); + expect(enumerations()).toBe(1); + + // Past the TTL. The reading is expired, and the refresh it triggers is still + // running -- the caller must get the previous reading now, not wait for it. + clock.ms += catalogStateTtlMs("fresh") + 1; + const warm = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(warm)).toMatchObject({ state: "fresh" }); + expect(enumerations()).toBe(2); + + // Three more turns while that refresh is still in flight: still served, and the + // in-flight dedup means none of them starts another enumeration. + for (let turn = 0; turn < 3; turn += 1) { + clock.ms += 10; + const next = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(next)).toMatchObject({ state: "fresh" }); + } + expect(enumerations()).toBe(2); + }); + + test("the refresh it triggers is what makes the next reading current", async () => { + const clock = { ms: 2_000_000 }; + const { io, releases } = makeIo(clock); + + const cold = collectCodexAppServerCatalogStateForRequest(io); + releases[0]([...APP_SERVER]); + await expect(cold).resolves.toMatchObject({ state: "fresh" }); + + clock.ms += catalogStateTtlMs("fresh") + 1; + // Served from the expired entry, and the refresh that call started is what the + // rest of this test is about. + const served = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(served)).toMatchObject({ state: "fresh" }); + + // The background refresh finds the machine empty now. + let refreshed = false; + served.then(() => {}).catch(() => {}); + releases[1]([]); + // The refresh has landed once a read at the same instant reports the new + // state; before that it still answers from the entry it is replacing. + await drainUntil(() => { + void collectCodexAppServerCatalogStateForRequest(io).then(seen => { + if (seen.state === "not_running") refreshed = true; + }); + return refreshed; + }, "the background refresh to store not_running"); + + // Served from the refreshed entry, still without waiting. + clock.ms += 1; + const after = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(after)).toMatchObject({ state: "not_running" }); + }); + + test("a reading older than the bound is not served -- the caller waits for a real one", async () => { + const clock = { ms: 3_000_000 }; + const { io, releases } = makeIo(clock); + + const cold = collectCodexAppServerCatalogStateForRequest(io); + releases[0]([...APP_SERVER]); + await expect(cold).resolves.toMatchObject({ state: "fresh" }); + + // One tick inside the bound, which runs from expiry rather than from when the + // reading was taken: still served, still without waiting. + clock.ms += catalogStateTtlMs("fresh") + CATALOG_STATE_MAX_STALE_MS - 1; + const last = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(last)).toMatchObject({ state: "fresh" }); + + // One tick past it, where the reading stops being evidence about the machine. + // That call joins the refresh the previous one started rather than waiting on a + // second enumeration, so releasing that one probe is what settles it. + clock.ms += 1; + const tooOld = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(tooOld)).toBe("waited"); + releases[1]([...APP_SERVER]); + await expect(tooOld).resolves.toMatchObject({ state: "fresh" }); + }); + + test("a failed refresh does not evict the reading it was refreshing", async () => { + const clock = { ms: 5_000_000 }; + const releases: Array<(snapshots: Array<{ pid: number; commandLine: string }>) => void> = []; + const rejects: Array<(reason: Error) => void> = []; + const io = { + platform: "win32" as const, + now: () => clock.ms, + listSnapshotsAsync: () => + new Promise>((resolve, reject) => { + releases.push(resolve); + rejects.push(reject); + }), + readStartMsBatchAsync: async (pids: number[]) => new Map(pids.map(pid => [pid, 2_000] as const)), + catalogMtimeMs: () => 1_000, + }; + + const cold = collectCodexAppServerCatalogStateForRequest(io); + releases[0]([{ pid: 42, commandLine: APP_SERVER_COMMAND_LINE }]); + await expect(cold).resolves.toMatchObject({ state: "fresh" }); + + // Expire it, take the stale answer, and let the refresh it started fail. + clock.ms += catalogStateTtlMs("fresh") + 1; + const served = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(served)).toMatchObject({ state: "fresh" }); + rejects[1](new Error("windows_enum_incomplete")); + await drainUntil(() => rejects.length === 2, "the failing refresh to be started"); + await new Promise(resolve => setTimeout(resolve, 0)); + + // The transient failure must not have replaced the observation. Caching + // `unknown` over it would take the answer away from the next caller: `unknown` + // is not servable, so that caller would wait for a probe instead of being + // handed the reading it would otherwise have had. + clock.ms += 1; + const after = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(after)).toMatchObject({ state: "fresh" }); + }); + + test("`unknown` is never served stale -- a failure to observe is not an observation", async () => { + const clock = { ms: 4_000_000 }; + const releases: Array<(value: never[]) => void> = []; + const rejects: Array<(reason: Error) => void> = []; + const io = { + platform: "win32" as const, + now: () => clock.ms, + listSnapshotsAsync: () => + new Promise((resolve, reject) => { + releases.push(resolve); + rejects.push(reject); + }), + readStartMsBatchAsync: async (pids: number[]) => new Map(pids.map(pid => [pid, 2_000] as const)), + catalogMtimeMs: () => 1_000, + }; + + const cold = collectCodexAppServerCatalogStateForRequest(io); + rejects[0](new Error("windows_enum_incomplete")); + await expect(cold).resolves.toMatchObject({ state: "unknown" }); + + // Its own short window has passed. Handing `unknown` back again would keep + // guidance suppressed on the strength of a reading that never observed anything. + clock.ms += catalogStateTtlMs("unknown") + 1; + const next = collectCodexAppServerCatalogStateForRequest(io); + expect(await settledWithoutTheProbe(next)).toBe("waited"); + releases[1]([]); + await expect(next).resolves.toMatchObject({ state: "not_running" }); + }); +}); From d459659ad359f6b91bcd3f9e7c83bb4d57e7a2ce Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:17:08 +0900 Subject: [PATCH 016/336] fix(responses): normalize SSE terminal tails and policy failures (rebase of #2488) (#2581) * fix: normalize Responses terminal failures * fix: delimit repaired SSE EOF tails * docs: record Responses terminal boundary invariants * fix(responses): keep unframed repair terminals fail-closed * fix(responses): preserve terminal delivery after overflow * test(responses): pin terminal relay parity * fix(lab): await producer child main * test: use executable PowerShell fixture on Windows * test: isolate Windows service-home probes * test(codex): bound inject lock child processes * test: assert PowerShell fixture start-time output * test(codex): extend lock contention hold * test: pass Windows service probe preload explicitly * fix: close review races and fixture lifecycles * test: always reap lock contention holder * test(docs): close review follow-up gaps * fix(responses): preserve cyber policy semantics * fix(responses): mark thrown policy failures non-retryable * test(responses): keep policy fixtures privacy-safe * test(responses): pin nested-envelope policy detection The review asked for a regression proving a nested cyber_policy is not hidden by a generic outer envelope. Adding it showed the detection already holds: consumeComboFailure classifies on the full display-safe text as well as the extracted code, so the nested case resolves to a non-retryable 400 today. Recording it as a test rather than changing the normalizer, so a future refactor that narrows detection to the first field-bearing candidate fails here instead of silently retrying across a safety boundary. --------- Co-authored-by: Codex Co-authored-by: AiriDea <28827642+AiriDea@users.noreply.github.com> --- .../docs/ja/reference/proxy-formats.md | 4 + .../docs/ko/reference/proxy-formats.md | 4 + .../content/docs/reference/proxy-formats.md | 16 + .../docs/ru/reference/proxy-formats.md | 4 + .../docs/zh-cn/reference/proxy-formats.md | 4 + src/bridge.ts | 44 +- src/chat/outbound.ts | 22 +- src/lab/fabric/producer-child.ts | 2 +- src/lib/errors.ts | 13 +- src/server/chat-completions.ts | 39 +- src/server/chat-native-sse.ts | 6 +- src/server/chat-native.ts | 41 +- src/server/relay-eager.ts | 167 +++- src/server/relay.ts | 270 ++++++- src/server/request-log.ts | 50 +- src/server/responses-terminal-repair.ts | 29 +- src/server/responses/core.ts | 157 +++- src/server/responses/passthrough-error.ts | 42 +- src/server/sse-frame-buffer.ts | 35 +- structure/04_transports-and-sidecars.md | 26 + tests/chat-completions-endpoint.test.ts | 175 +++++ tests/cli-restore-back.test.ts | 4 +- tests/codex-app-server-processes.test.ts | 72 +- tests/codex-composed-acceptance.test.ts | 19 +- tests/codex-inject-write-lock.test.ts | 210 +++-- .../codex-retained-root-serialization.test.ts | 36 +- tests/codex-sync-api.test.ts | 44 +- tests/cyber-policy-error-fidelity.test.ts | 209 ++++- tests/helpers/owned-service-home-preload.ts | 65 ++ tests/helpers/owned-service-home.ts | 42 +- tests/helpers/windows-power-shell-fixture.ts | 95 +++ tests/lab-fabric-task.test.ts | 105 ++- tests/multi-agent-compat.test.ts | 31 +- tests/owned-service-home.test.ts | 110 +++ tests/passthrough-abort.test.ts | 351 ++++++++- tests/relay-eager.test.ts | 731 +++++++++++++++++- tests/request-log.test.ts | 166 ++++ tests/responses-terminal-repair.test.ts | 50 ++ tests/sse-failed-tail.test.ts | 105 ++- tests/terminal-guard-server.test.ts | 37 + 40 files changed, 3255 insertions(+), 377 deletions(-) create mode 100644 tests/helpers/owned-service-home-preload.ts create mode 100644 tests/helpers/windows-power-shell-fixture.ts create mode 100644 tests/owned-service-home.test.ts diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 2d6f4fdf6a..ff329cf29f 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -55,6 +55,10 @@ provider events → internal adapter events → client dialect クライアント向け Responses SSE フレームは、SSE ブロック区切りの前の生バイトで測って 1 フレームあたり 4 MiB に制限されます。HTTP では、区切りなしでこの上限を超えたアップストリーム フレームは、合成 `response.failed` イベントと続く `data: [DONE]` でフェイルクローズします。Responses WebSocket ブリッジでは、同じ条件で 502 `websocket_protocol_error` を送信し、アップストリーム リーダーをキャンセルします。完全な Responses 終端フレームがすでに到着している場合はそれが優先され、その後のサイズ超過または不正なバイトは、完了したターンをトランスポート障害に置き換えず破棄されます。 +:::note +ネイティブ パススルーでは、Responses の終端イベントが優先されます。早すぎる `data: [DONE]` は、そのイベントが届くまで保留されます。通常のネイティブ パスで、解析済みの終端がないまま正常な HTTP 200 EOF に達した場合、プロキシは `incomplete_details.reason: "adapter_eof"` を持つ `response.incomplete` を 1 件、その後に `data: [DONE]` を 1 件送信します。区切りのない終端 JSON が構文的に有効なら 1 回だけ受け入れられ、不正または切り詰められた JSON は incomplete のままです。モデル単位の終端修復を有効にしたプロバイダーでは、フレーム化されていない終端らしい接尾部と EOF 時の早すぎる `data: [DONE]` は、昇格可能な完全なライフサイクル候補がなければ `missing_terminal_event` としてフェイルクローズし、候補が完全なら `response.completed` に昇格します。高信頼度の `cyber_policy` 終端は、セマンティックなログおよび課金集計上は `error.code: "cyber_policy"` を持つ `response.failed`(status 400)に正規化されますが、すでに開始済みのストリーミング HTTP 応答は 200 のままです。このコミット済みリクエストの境界では、再試行も再送も行いません。 +::: + すべての端末応答使用状況オブジェクトには、プロバイダーが詳細を報告しなかった場合でも、両方の詳細オブジェクトが含まれます。 ```json diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 532fe0b4ef..1f000d992b 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -65,6 +65,10 @@ deltas, 그리고 정확히 하나의 종료 `response.completed`, `response.fai 클라이언트로 전달되는 Responses SSE 프레임은 SSE 블록 구분자 앞의 원시 바이트 기준으로 프레임당 4 MiB로 제한됩니다. HTTP에서는 구분자 없이 이 한도를 초과한 업스트림 프레임을 합성 `response.failed` 이벤트와 이어지는 `data: [DONE]`으로 fail closed 처리합니다. Responses WebSocket 브리지에서는 같은 조건에서 502 `websocket_protocol_error`를 보내고 업스트림 reader를 취소합니다. 완전한 Responses 종료 프레임이 이미 수신된 경우에는 그 종료가 우선하며, 이후의 과도한 크기 또는 잘못된 바이트는 완료된 턴을 전송 오류로 바꾸지 않고 버립니다. +:::note +네이티브 passthrough에서는 Responses 종료 이벤트가 우선합니다. 너무 이른 `data: [DONE]`은 해당 이벤트가 도착할 때까지 보류됩니다. 일반 네이티브 경로가 파싱된 종료 이벤트 없이 정상 HTTP 200 EOF에 도달하면, 프록시는 `incomplete_details.reason: "adapter_eof"`가 있는 `response.incomplete` 하나와 `data: [DONE]` 하나를 보냅니다. 구분자 없는 종료 JSON이 문법적으로 유효하면 정확히 한 번 받아들이고, 잘못되었거나 잘린 JSON은 incomplete로 남습니다. 모델별 종료 복구를 사용하도록 설정된 공급자에서는 프레임이 없는 종료 유사 suffix와 EOF의 너무 이른 `data: [DONE]`을, 승격할 수 있는 완전한 lifecycle 후보가 없을 때 `missing_terminal_event`로 fail closed 처리하며, 완전한 후보가 있으면 `response.completed`로 승격합니다. 신뢰도가 높은 `cyber_policy` 종료 형식은 의미론적 로깅 및 집계에서 `error.code: "cyber_policy"`가 있는 `response.failed`(status 400)로 정규화되지만, 이미 시작된 스트리밍 HTTP 응답은 200을 유지합니다. 이 커밋된 요청 경계에서는 재시도하거나 재전송하지 않습니다. +::: + canonical ChatGPT forward streaming은 stable Bun 1.4.0 이상에서 Codex 업스트림 WebSocket을 투명하게 사용할 수 있습니다. 번들 Bun 1.3.14, prerelease, 또는 검증 불가능한 런타임 identity는 HTTP/SSE를 사용합니다. 업스트림 WS adapter는 같은 downstream SSE 계약을 유지하며, 원시 JSON diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 2792bdf0f8..83dda745cf 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -72,6 +72,22 @@ bridge, the same condition emits a 502 `websocket_protocol_error` and cancels th A complete Responses terminal frame is authoritative: oversized or malformed trailing bytes after that terminal are dropped rather than replacing the completed turn with a transport failure. +:::note +For native passthrough, a Responses terminal event is authoritative. A premature `data: [DONE]` is +held until that event. On the ordinary native path, a clean HTTP 200 EOF without a parsed terminal +emits one `response.incomplete` with `incomplete_details.reason: "adapter_eof"`, followed by one +`data: [DONE]`; syntactically valid delimiter-less terminal JSON is accepted exactly once, while +malformed or truncated JSON remains incomplete. For providers opted into model-scoped terminal +repair, unframed terminal-like suffixes and a premature `data: [DONE]` at EOF fail closed with +`missing_terminal_event` when no complete lifecycle candidate can be promoted; a complete candidate +is promoted to `response.completed`. High-confidence `cyber_policy` +terminal shapes normalize to `response.failed` with `error.code: "cyber_policy"` for semantic +logging/accounting (status 400), while an already-started streamed HTTP response remains 200. This +committed-request boundary does not retry or replay and does not resolve +[#2423](https://github.com/lidge-jun/opencodex/issues/2423) or +[#2486](https://github.com/lidge-jun/opencodex/issues/2486). +::: + For canonical ChatGPT forward streaming, stable Bun 1.4.0 or newer may transparently use Codex's upstream WebSocket transport. Bundled Bun 1.3.14, prereleases, and unverifiable runtime identities use HTTP/SSE. The upstream WS adapter keeps the same downstream SSE contract, caps both diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 6a461e8b9e..9163bec4f2 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -69,6 +69,10 @@ Responses. Обе формы сохраняют выбранную модель, Клиентские frame'ы Responses SSE ограничены 4 MiB на frame, считая сырые байты до разделителя SSE-блока. В HTTP незавершённый upstream-frame, превысивший этот предел, завершается fail-closed синтетическим событием `response.failed`, после которого идёт `data: [DONE]`. В мосте Responses WebSocket то же условие даёт 502 `websocket_protocol_error` и отменяет upstream-reader. Если полноценный terminal-frame Responses уже получен, он остаётся авторитетным: слишком большие или некорректные байты после него отбрасываются и не заменяют завершённый ход транспортной ошибкой. +:::note +При нативном passthrough терминальное событие Responses является авторитетным, а преждевременный `data: [DONE]` удерживается до его появления. Если обычный нативный путь достигает корректного HTTP 200 EOF без распознанного терминального события, прокси испускает один `response.incomplete` с `incomplete_details.reason: "adapter_eof"`, а затем один `data: [DONE]`. Синтаксически корректный терминальный JSON без разделителя принимается ровно один раз; некорректный или обрезанный JSON остаётся incomplete. Для провайдеров с включённым model-scoped terminal repair неоформленный terminal-like suffix и преждевременный `data: [DONE]` на EOF завершаются fail-closed с `missing_terminal_event`, если нет полного lifecycle-кандидата для повышения; полный кандидат повышается до `response.completed`. Терминальные формы `cyber_policy` с высокой уверенностью нормализуются для семантического журналирования и учёта в `response.failed` с `error.code: "cyber_policy"` (status 400), но уже начатый потоковый HTTP-ответ сохраняет статус 200. На этой границе уже отправленного запроса нет retry или replay. +::: + Каждый terminal usage-объект Responses всегда включает оба detail-объекта, даже если провайдер их не сообщил: diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index a469e2f628..0d18903ecd 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -64,6 +64,10 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 面向客户端的 Responses SSE 帧按 SSE 块分隔符之前的原始字节计算,每帧限制为 4 MiB。对于 HTTP,未终止的上游帧一旦超过该限制,会以合成的 `response.failed` 事件并随后发送 `data: [DONE]` 的方式 fail closed。对于 Responses WebSocket 桥,相同情况会发送 502 `websocket_protocol_error` 并取消上游 reader。已经完整到达的 Responses 终止帧具有优先权;其后的超大或格式错误字节会被丢弃,而不会把已经完成的轮次替换为传输失败。 +:::note +对于原生透传,Responses 终止事件具有最高优先级;过早出现的 `data: [DONE]` 会被保留,直到该事件到达。普通原生路径在没有已解析终止事件的情况下正常到达 HTTP 200 EOF 时,代理会发送一个带有 `incomplete_details.reason: "adapter_eof"` 的 `response.incomplete`,随后发送一个 `data: [DONE]`。语法有效但缺少分隔符的终止 JSON 只会被接受一次;格式错误或被截断的 JSON 仍保持 incomplete。对于启用了按模型终止修复的提供方,未成帧但形似终止事件的后缀和 EOF 处过早出现的 `data: [DONE]`,会在没有可提升的完整生命周期候选时以 `missing_terminal_event` 的形式 fail closed;完整候选则会被提升为 `response.completed`。高置信度的 `cyber_policy` 终止形态会在语义日志和计量中规范化为带有 `error.code: "cyber_policy"` 的 `response.failed`(status 400),但已经开始的流式 HTTP 响应仍保持 200。这个已提交请求的边界不会重试或重放请求。 +::: + 每个终止的 Responses usage 对象都包含两个 detail 对象,即使提供方没有报告这些细节: ```json diff --git a/src/bridge.ts b/src/bridge.ts index bd24fb78e5..1276b1406b 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -7,7 +7,15 @@ import type { OcxUsage, } from "./types"; import { coerceIntegerToolArguments } from "./lib/tool-argument-integers"; -import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors"; +import { + adapterFailureFromMessage, + classifyError, + cyberPolicyErrorType, + CYBER_POLICY_ERROR_CODE, + isCyberPolicyCode, + type OcxErrorPayload, +} from "./lib/errors"; +import { redactSecretString } from "./lib/redact"; import { repairFreeformToolInput } from "./responses/apply-patch-envelope"; import { encodeCompactionSummary } from "./responses/compaction"; import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason"; @@ -114,18 +122,19 @@ function toolCallArgumentsUsable(args: string): boolean { } function adapterFailureFromEvent(event: Extract): { httpStatus: number; error: OcxErrorPayload } { + const message = redactSecretString(event.message); if (event.status === undefined && event.errorType === undefined && event.code === undefined) { - return adapterFailureFromMessage(event.message); + return adapterFailureFromMessage(message); } - const fallback = adapterFailureFromMessage(event.message); + const fallback = adapterFailureFromMessage(message); let httpStatus = event.status ?? fallback.httpStatus; - const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message); + const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, message); if (event.errorType !== undefined) error.type = event.errorType; if (event.code !== undefined) error.code = event.code; // Codex maps cyber_policy on HTTP 400 (body) or mid-stream code; never leave it as 502. if (isCyberPolicyCode(error.code) || isCyberPolicyCode(event.code)) { error.code = CYBER_POLICY_ERROR_CODE; - error.type = "invalid_request_error"; + error.type = cyberPolicyErrorType(event.errorType); httpStatus = 400; } return { httpStatus, error }; @@ -1296,7 +1305,9 @@ export function bridgeToResponsesSSE( ...(event.usage ? { usage: responsesUsage(event.usage) } : {}), error: failure.error, last_error: failure.error, - ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), + ...(isCyberPolicyCode(failure.error.code) + ? { retryable: false } + : event.retryable !== undefined ? { retryable: event.retryable } : {}), }, }); reportTerminal("failed"); @@ -1320,11 +1331,17 @@ export function bridgeToResponsesSSE( if (currentToolCall) failCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); releasePendingWebSources(); + const failure = responseError( + 500, + "proxy_error", + redactSecretString(err instanceof Error ? err.message : String(err)), + ); emit("response.failed", { response: { ...responseSnapshot("failed", finishedItems), - error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)), - last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)), + error: failure, + last_error: failure, + ...(isCyberPolicyCode(failure.code) ? { retryable: false } : {}), }, }); reportTerminal("failed"); @@ -1952,7 +1969,9 @@ function buildResponseJSONWithBudget( model: modelId, output, ...(endTurn !== undefined ? { end_turn: endTurn } : {}), ...(failure ? { error: failure.error, last_error: failure.error } : {}), - ...(errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}), + ...(failure && isCyberPolicyCode(failure.error.code) + ? { retryable: false } + : errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}), ...(incompleteEvent ? { incomplete_details: { reason: incompleteEvent.reason, @@ -1981,12 +2000,15 @@ export function formatErrorResponse( const error = classifyError(status, type, message); if (isCyberPolicyCode(options?.code)) { error.code = CYBER_POLICY_ERROR_CODE; - error.type = "invalid_request_error"; + error.type = cyberPolicyErrorType(type); } const finalStatus = error.code === CYBER_POLICY_ERROR_CODE ? 400 : status; const headers = new Headers({ "Content-Type": "application/json" }); const retryAfter = options?.retryAfter?.trim(); - if (retryAfter && retryAfter.length > 0 && retryAfter.length <= 128) { + if (error.code !== CYBER_POLICY_ERROR_CODE + && retryAfter + && retryAfter.length > 0 + && retryAfter.length <= 128) { headers.set("Retry-After", retryAfter); } return new Response(JSON.stringify({ error }), { diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index e7ad46775c..03e133bb90 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -9,7 +9,14 @@ type Rec = Record; import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder"; import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget"; -import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors"; +import { + classifyError, + cyberPolicyErrorType, + CYBER_POLICY_ERROR_CODE, + isCyberPolicyCode, + isCyberPolicyMessage, +} from "../lib/errors"; +import { redactSecretString } from "../lib/redact"; function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); @@ -48,14 +55,14 @@ export function chatCompletionsUsage(usage: unknown): Rec { export function chatCompletionsErrorBody( status: number, message: string, - type = "invalid_request_error", + type?: string, code?: string | null, ): Rec { if (isCyberPolicyCode(code) || isCyberPolicyMessage(message)) { return { error: { message, - type: "invalid_request_error", + type: cyberPolicyErrorType(type), param: null, code: CYBER_POLICY_ERROR_CODE, }, @@ -64,7 +71,7 @@ export function chatCompletionsErrorBody( return { error: { message, - type, + type: type ?? "invalid_request_error", param: null, code: code !== undefined ? code @@ -311,8 +318,9 @@ export function responsesSseToChatCompletionsSse( // Deliver the error frame then close the stream abnormally (no [DONE]). // Do not controller.error() — that can drop already-enqueued bytes from consumers // like response.text(). - const statusHint = details?.status ?? streamErrorStatus(message); - const classified = classifyError(statusHint, details?.type ?? "upstream_error", message); + const safeMessage = redactSecretString(message); + const statusHint = details?.status ?? streamErrorStatus(safeMessage); + const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage); const translatorOverflow = details?.code === "translation_buffer_limit"; if (translatorOverflow) { upstreamAbort.abort(new Error("upstream translation buffer exceeded the safe limit")); @@ -324,7 +332,7 @@ export function responsesSseToChatCompletionsSse( classified.type = "upstream_error"; } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; - classified.type = "invalid_request_error"; + classified.type = cyberPolicyErrorType(details?.type); } else if (details?.code !== undefined && details.code !== null && !classified.code) { classified.code = details.code; } diff --git a/src/lab/fabric/producer-child.ts b/src/lab/fabric/producer-child.ts index f42c4e3cd3..4063624574 100644 --- a/src/lab/fabric/producer-child.ts +++ b/src/lab/fabric/producer-child.ts @@ -128,7 +128,7 @@ async function main(): Promise { writeLine({ type: "result", patch }); } -main().catch((error: unknown) => { +await main().catch((error: unknown) => { writeLine({ type: "error", code: "harness_failure", diff --git a/src/lib/errors.ts b/src/lib/errors.ts index a7bbdb71e9..d7fecea26c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -6,11 +6,18 @@ export interface OcxErrorPayload { /** OpenAI / Codex hard block for high-risk cybersecurity activity (HTTP 400 or mid-stream). */ export const CYBER_POLICY_ERROR_CODE = "cyber_policy"; +export const CYBER_POLICY_FALLBACK_MESSAGE = "Request blocked by the upstream cybersecurity policy."; export function isCyberPolicyCode(code: string | null | undefined): boolean { return code === CYBER_POLICY_ERROR_CODE; } +/** Preserve a structured upstream error type; otherwise use the dedicated policy identity. */ +export function cyberPolicyErrorType(type: string | null | undefined): string { + const trimmed = typeof type === "string" ? type.trim() : ""; + return trimmed || CYBER_POLICY_ERROR_CODE; +} + /** * Detect OpenAI cyber-policy refusals from message text when structured `code` was stripped. * Matches Codex fallback copy and Cursor/API agent wording (session evidence 2026-07-24). @@ -139,9 +146,11 @@ export function classifyError(status: number, type: string, message: string): Oc return { message, type: "invalid_request_error", code: "client_closed_request" }; } // Codex only shows the dedicated cyber UI when error.code === "cyber_policy". - // Prefer that code (and invalid_request_error) over generic remaps / 502 upstream_server_error. + // The public wire does not establish invalid_request_error as the canonical type, so + // message-only classification keeps the dedicated identity instead of inventing one. + // Structured callers re-apply their real upstream type with cyberPolicyErrorType(). if (type === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(text)) { - return { message, type: "invalid_request_error", code: CYBER_POLICY_ERROR_CODE }; + return { message, type: CYBER_POLICY_ERROR_CODE, code: CYBER_POLICY_ERROR_CODE }; } // A LOCAL preflight refusal keeps its own code (#1524). The message necessarily says // "context window" -- that is what it is refusing on -- so the generic remap below would diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 325c4ffd08..3e7f4515d1 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -18,7 +18,7 @@ import { responsesJsonToChatCompletion, responsesSseToChatCompletionsSse, } from "../chat/outbound"; -import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; import { estimateTokens } from "../lib/token-estimate"; @@ -280,26 +280,26 @@ async function handleChatCompletionsWithBudget( const parsed = JSON.parse(text) as { error?: { message?: string; type?: string; code?: string | null } | string; message?: string; + type?: string; + code?: string | null; }; const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error : undefined; const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message; const rawFallback = text ? `upstream error (${upstream.status}): ${redactSecretString(text).slice(0, 400)}` : message; - message = nested?.message || flat || rawFallback; - if (nested) { - if (typeof nested.type === "string") upstreamType = nested.type; - if (nested.code === null || typeof nested.code === "string") upstreamCode = nested.code; - } + const upstreamMessage = nested?.message || flat; + message = upstreamMessage + ? redactSecretString(upstreamMessage).slice(0, 500) + : rawFallback; + const structuredType = nested?.type ?? parsed.type; + const structuredCode = nested?.code ?? parsed.code; + if (typeof structuredType === "string") upstreamType = structuredType; + if (structuredCode === null || typeof structuredCode === "string") upstreamCode = structuredCode; } catch { if (text) message = `upstream error (${upstream.status}): ${redactSecretString(text).slice(0, 400)}`; } } catch { /* keep fallback */ } - const retryAfter = resolveClientRetryAfter({ - status: upstream.status, - message, - upstreamRetryAfter: upstream.headers.get("retry-after"), - }); const classified = classifyError( upstream.status, upstreamType @@ -309,9 +309,9 @@ async function handleChatCompletionsWithBudget( : "invalid_request_error"), message, ); - if (isCyberPolicyCode(upstreamCode)) { + if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; - classified.type = "invalid_request_error"; + classified.type = cyberPolicyErrorType(upstreamType); } else if (upstreamCode === "model_not_found") { // Structured model_not_found must win over classifyError's generic remaps. classified.code = "model_not_found"; @@ -320,6 +320,13 @@ async function handleChatCompletionsWithBudget( classified.code = upstreamCode; } const status = isCyberPolicyCode(classified.code) ? 400 : upstream.status; + const retryAfter = isCyberPolicyCode(classified.code) + ? undefined + : resolveClientRetryAfter({ + status: upstream.status, + message, + upstreamRetryAfter: upstream.headers.get("retry-after"), + }); const rewritten = new Response(JSON.stringify({ error: { message: classified.message, @@ -386,14 +393,14 @@ async function handleChatCompletionsWithBudget( const status = (json as Rec)?.status; if (status === "failed") { const error = (json as { error?: { message?: string; type?: string; code?: string | null } }).error; - const message = error?.message ?? "upstream request failed"; + const message = redactSecretString(error?.message ?? "upstream request failed"); const classified = classifyError(502, error?.type ?? "server_error", message); if (error?.code === "translation_buffer_limit") { classified.code = "translation_buffer_limit"; classified.type = "upstream_error"; - } else if (isCyberPolicyCode(error?.code)) { + } else if (isCyberPolicyCode(error?.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; - classified.type = "invalid_request_error"; + classified.type = cyberPolicyErrorType(error?.type); } else if (error?.code === "model_not_found") { // Same deliberate preserve as the non-OK path: structured code beats generic classify. classified.code = "model_not_found"; diff --git a/src/server/chat-native-sse.ts b/src/server/chat-native-sse.ts index 4d197d5a58..fa9255369c 100644 --- a/src/server/chat-native-sse.ts +++ b/src/server/chat-native-sse.ts @@ -1,5 +1,5 @@ import { chatCompletionsErrorBody } from "../chat/outbound"; -import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; import { isTranslatorBudgetExceededError, @@ -226,9 +226,9 @@ export function nativeChatSse( if (error) { const status = error.status ?? 502; const classified = classifyError(status, error.type ?? "upstream_error", error.message); - if (isCyberPolicyCode(error.code)) { + if (isCyberPolicyCode(error.code) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; - classified.type = "invalid_request_error"; + classified.type = cyberPolicyErrorType(error.type); } else if (error.code !== undefined && error.code !== null) { classified.code = error.code; } diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index e00caea282..2e84dc68d3 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -6,7 +6,13 @@ import { collectChatCompletion, isChatCompletionsStreamError, } from "../chat/outbound"; -import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { + classifyError, + cyberPolicyErrorType, + CYBER_POLICY_ERROR_CODE, + isCyberPolicyCode, + isCyberPolicyMessage, +} from "../lib/errors"; import type { AdmissionLease } from "../lib/admission"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { redactSecretString } from "../lib/redact"; @@ -281,13 +287,24 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio const detail = activeAdapter.formatErrorBody?.(response.status, response.headers, bodyText) ?? ""; let upstreamType: string | undefined; let upstreamCode: string | null | undefined; + let upstreamMessage: string | undefined; try { const parsedError = JSON.parse(bodyText) as Rec; const nested = isRec(parsedError.error) ? parsedError.error : undefined; - if (typeof nested?.type === "string") upstreamType = nested.type; - if (nested?.code === null || typeof nested?.code === "string") upstreamCode = nested.code; + const details = nested ?? parsedError; + if (typeof details.type === "string") upstreamType = details.type; + if (details.code === null || typeof details.code === "string") upstreamCode = details.code; + const rawMessage = typeof details.message === "string" + ? details.message + : typeof parsedError.error === "string" ? parsedError.error : undefined; + if (rawMessage?.trim()) { + upstreamMessage = redactSecretString(rawMessage.trim()); + } } catch { /* keep generic classification */ } - const message = detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`; + const message = upstreamMessage + && (isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(upstreamMessage)) + ? upstreamMessage + : detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`; const classified = classifyError( response.status, upstreamType ?? (response.status === 401 ? "authentication_error" @@ -295,9 +312,9 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio : response.status >= 500 ? "server_error" : "invalid_request_error"), message, ); - if (isCyberPolicyCode(upstreamCode)) { + if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; - classified.type = "invalid_request_error"; + classified.type = cyberPolicyErrorType(upstreamType); } else if (upstreamCode === "model_not_found") { classified.code = "model_not_found"; classified.type = "invalid_request_error"; @@ -305,11 +322,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio classified.code = upstreamCode; } const status = isCyberPolicyCode(classified.code) ? 400 : response.status; - const retryAfter = resolveClientRetryAfter({ - status: response.status, - message: classified.message, - upstreamRetryAfter: response.headers.get("retry-after"), - }); + const retryAfter = isCyberPolicyCode(classified.code) + ? undefined + : resolveClientRetryAfter({ + status: response.status, + message: classified.message, + upstreamRetryAfter: response.headers.get("retry-after"), + }); finishLog(status, classified.message); return new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), { status, diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts index 8865a99233..f389a00d5b 100644 --- a/src/server/relay-eager.ts +++ b/src/server/relay-eager.ts @@ -24,7 +24,12 @@ * up to the drain window. */ -import { buildFailedTailPayload, createSseTerminalOutputBoundary } from "./relay"; +import { + adapterEofIncompleteFrame, + createSseTerminalOutputBoundary, + doneFrame, + failedTailFrame, +} from "./relay"; import { nextSseBlock, payloadRewriteAsBlockRewrite, @@ -100,9 +105,19 @@ export function relaySseEagerBounded( const now = opts?.now ?? Date.now; const reader = body.getReader(); + const terminalEncoder = new TextEncoder(); + const adapterEofFrame = adapterEofIncompleteFrame(terminalEncoder); + const terminalSentinel = doneFrame(terminalEncoder); const terminalBoundary = createSseTerminalOutputBoundary(); const activeRewrite: SseBlockRewrite | undefined = hooks.rewriteBlocks ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined); + const encodeFailedTail = (error: unknown): Uint8Array | null => { + try { + return failedTailFrame(terminalEncoder, error); + } catch { + return null; + } + }; const rewriteDecoder = activeRewrite ? new TextDecoder() : null; const rewriteEncoder = activeRewrite ? new TextEncoder() : null; const rewriteBudget = opts?.rewriteBudget; @@ -164,13 +179,21 @@ export function relaySseEagerBounded( let queuedBytes = 0; let cancelled = false; let done = false; - const terminalSentinel = new TextEncoder().encode("data: [DONE]\n\n"); // Pause gate: resolved by client pull, client cancel, or upstream abort so a // paused producer ALWAYS resumes (audit blocker 2 — no deadlock; onDone and // turn unregistration stay reachable, drainAndShutdown never hangs). let wake: (() => void) | null = null; const wakeUp = () => { const w = wake; wake = null; w?.(); }; - const paused = () => new Promise(resolve => { wake = resolve; }); + const paused = () => new Promise(resolve => { + wake = resolve; + // A pull, cancel, or abort can win the tiny window between the loop's + // predicate check and installing this resolver. Re-check every wake + // predicate after installation so that an already-fired wake cannot leave + // the producer parked forever. + if (queuedBytes <= maxQueueBytes || cancelled || upstream.signal.aborted) { + wakeUp(); + } + }); upstream.signal.addEventListener("abort", wakeUp, { once: true }); let controllerRef: ReadableStreamDefaultController | null = null; @@ -196,6 +219,9 @@ export function relaySseEagerBounded( const producer = async () => { let syntheticKind: "incomplete" | "failed" | null = null; + let deliveryFallbackSent = false; + let priorRewriteFailure = false; + let priorRewriteError: unknown; // reader.read() is not intrinsically tied to the upstream AbortController // (a fetch body usually rejects on abort, but that coupling is the fetch // implementation's, not the stream's), so abort must break a parked read on @@ -221,18 +247,46 @@ export function relaySseEagerBounded( if (upstreamDone) { hooks.finishInspection(); const boundedTail = terminalBoundary.finish(); + let clientTail = boundedTail; + let rewriteFailed = false; + let rewriteError: unknown; if (activeRewrite) { - const rewritten = rewriteOutbound(boundedTail); - const tail = joinUint8Arrays(rewritten, flushRewriteTail()); - if (tail.byteLength > 0 && !cancelled) { - queuedBytes += tail.byteLength; - try { controllerRef?.enqueue(tail); } catch { /* client already gone */ } + try { + const rewritten = rewriteOutbound(boundedTail); + clientTail = joinUint8Arrays(rewritten, flushRewriteTail()); + } catch (error) { + rewriteFailed = true; + rewriteError = error; + clientTail = new Uint8Array(0); + } + } + if (rewriteFailed) { + const safeTail = encodeFailedTail(rewriteError); + if (safeTail && !cancelled && !upstream.signal.aborted) { + if (!hooks.sawTerminal()) syntheticKind = "failed"; + queuedBytes += safeTail.byteLength; + try { controllerRef?.enqueue(safeTail); } catch { /* client already torn down */ } + try { controllerRef?.close(); } catch { /* client already gone */ } } - } else if (boundedTail.byteLength > 0 && !cancelled) { - queuedBytes += boundedTail.byteLength; - try { controllerRef?.enqueue(boundedTail); } catch { /* client already gone */ } + break; + } + if (clientTail.byteLength > 0 && !cancelled) { + queuedBytes += clientTail.byteLength; + try { controllerRef?.enqueue(clientTail); } catch { /* client already gone */ } } - if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) { + if (terminalBoundary.terminalSeen()) { + if (!terminalBoundary.doneSeen() && !cancelled) { + queuedBytes += terminalSentinel.byteLength; + try { controllerRef?.enqueue(terminalSentinel); } catch { /* client already gone */ } + } + } else if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) { + // A clean 200 EOF without a Responses terminal must be visible to + // Codex as one incomplete turn, followed by the normal sentinel. + queuedBytes += adapterEofFrame.byteLength + terminalSentinel.byteLength; + try { + controllerRef?.enqueue(adapterEofFrame); + controllerRef?.enqueue(terminalSentinel); + } catch { /* client already gone */ } syntheticKind = "incomplete"; } break; @@ -247,7 +301,21 @@ export function relaySseEagerBounded( continue; } const terminalBounded = terminalBoundary.feed(value); - const outbound = activeRewrite ? rewriteOutbound(terminalBounded) : terminalBounded; + let outbound: Uint8Array; + if (activeRewrite) { + try { + outbound = rewriteOutbound(terminalBounded); + } catch (error) { + // Preserve the first rewrite failure across the teardown flush. A + // second empty flush may succeed, but the terminal bytes still + // must not bypass the failed rewrite or become DONE-only output. + priorRewriteFailure = true; + priorRewriteError = error; + throw error; + } + } else { + outbound = terminalBounded; + } if (outbound.byteLength > 0) { queuedBytes += outbound.byteLength; try { @@ -278,16 +346,75 @@ export function relaySseEagerBounded( } catch (err) { // Upstream read failure. Distinguish genuine mid-stream reset from // abort-driven teardown (shutdown/cancel-expiry) — audit M3. - if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) { + // A read can fail after delivering an unterminated terminal block. Flush + // both observers before deciding whether this is a synthetic reset so + // eager mode matches the tee/pull boundary semantics at EOF. + let boundedTail: Uint8Array = new Uint8Array(0); + let tailTerminal = false; + let tailDone = false; + try { hooks.finishInspection(); } catch { /* preserve the original read failure */ } + try { + boundedTail = terminalBoundary.finish(); + tailTerminal = terminalBoundary.terminalSeen(); + tailDone = terminalBoundary.doneSeen(); + } catch { + // A near-cap ambiguous delimiter tail may itself overflow at EOF. + // Preserve the original read/framing failure and continue emitting + // the bounded failed tail instead of letting cleanup throw again. + } + let clientTail: Uint8Array = boundedTail; + let rewriteFailed = false; + let rewriteError: unknown; + if (priorRewriteFailure) { + rewriteFailed = true; + rewriteError = priorRewriteError; + clientTail = new Uint8Array(0); + } else if (activeRewrite) { + try { + const rewritten = rewriteOutbound(boundedTail); + clientTail = joinUint8Arrays(rewritten, flushRewriteTail()); + } catch (error) { + rewriteFailed = true; + rewriteError = error; + clientTail = new Uint8Array(0); + } + } + if (clientTail.byteLength > 0 && !cancelled && !upstream.signal.aborted) { + queuedBytes += clientTail.byteLength; + try { controllerRef?.enqueue(clientTail); } catch { /* client already torn down */ } + } + if (rewriteFailed && !cancelled && !upstream.signal.aborted) { + // Never bypass a client rewrite after it fails: boundedTail can contain + // provider metadata or content that the active rewrite was required to + // remove. Emit one safe failed envelope instead. When inspection has + // already reported the real upstream terminal, this is a delivery + // fallback only and must not create a second accounting outcome. + const safeTail = encodeFailedTail(rewriteError ?? err); + if (safeTail && !cancelled && !upstream.signal.aborted) { + if (!hooks.sawTerminal()) syntheticKind = "failed"; + deliveryFallbackSent = true; + queuedBytes += safeTail.byteLength; + try { controllerRef?.enqueue(safeTail); } catch { /* client already torn down */ } + try { controllerRef?.close(); } catch { /* client already gone */ } + } + } else if (tailTerminal && !cancelled && !upstream.signal.aborted) { + if (!tailDone) { + queuedBytes += terminalSentinel.byteLength; + try { controllerRef?.enqueue(terminalSentinel); } catch { /* client already gone */ } + } + } else if (!tailTerminal && !cancelled && !upstream.signal.aborted) { // Serializing `err` can run user-defined accessors (Error.message // getters, toString) that re-entrantly cancel the client or abort the // upstream. Build the tail FIRST, then re-check eligibility before // committing to the synthetic terminal (adversarial review blocker). - const tail = new TextEncoder().encode( - `\n\nevent: response.failed\ndata: ${buildFailedTailPayload(err)}\n\ndata: [DONE]\n\n`, - ); - if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) { - syntheticKind = "failed"; + const tail = encodeFailedTail(err); + if (tail && !cancelled && !upstream.signal.aborted) { + // Inspection and client framing have separate bounded parsers. If + // inspection resynchronized after an oversized frame and observed a + // later real terminal, it still must not suppress a terminal delivery + // to the client. Only accounting remains tied to the inspected result. + if (!hooks.sawTerminal()) syntheticKind = "failed"; + deliveryFallbackSent = true; queuedBytes += tail.byteLength; try { controllerRef?.enqueue(tail); } catch { /* client already torn down */ } try { controllerRef?.close(); } catch { /* client already torn down */ } @@ -306,7 +433,7 @@ export function relaySseEagerBounded( if (cancelled && !hooks.sawTerminal()) { hooks.onClientCancel(); } - if (cancelled || upstream.signal.aborted || syntheticKind === "failed") { + if (cancelled || upstream.signal.aborted || syntheticKind === "failed" || deliveryFallbackSent) { upstream.abort(); reader.cancel().catch(() => {}); } diff --git a/src/server/relay.ts b/src/server/relay.ts index 3cbc870a79..a523ffd7e5 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -1,4 +1,12 @@ import type { ResponsesTerminalStatus } from "../bridge"; +import { + cyberPolicyErrorType, + CYBER_POLICY_ERROR_CODE, + CYBER_POLICY_FALLBACK_MESSAGE, + isCyberPolicyCode, + isCyberPolicyMessage, +} from "../lib/errors"; +import { redactSecretString } from "../lib/redact"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { isUsageDebugEnabled } from "../usage/debug"; import { @@ -16,6 +24,7 @@ import { joinSseFrameBytes, MAX_CLIENT_SSE_FRAME_BYTES, } from "./sse-frame-buffer"; +import { replaceSseDataPayload } from "./sse-payload-rewrite"; const nativePassthroughSseResponses = new WeakSet(); const eagerRelaySseResponses = new WeakSet(); @@ -24,6 +33,30 @@ export const MAX_INSPECTION_SSE_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES; export const MAX_COMPLETED_OUTPUT_ITEMS = 256; export const MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES = 8 * 1024 * 1024; export const MAX_TAIL_ERROR_MESSAGE_CHARS = 512; +const ADAPTER_EOF_INCOMPLETE_PAYLOAD = JSON.stringify({ + type: "response.incomplete", + response: { + status: "incomplete", + incomplete_details: { reason: "adapter_eof" }, + }, +}); +const DONE_SSE_FRAME_TEXT = "data: [DONE]\n\n"; +const FAILED_TAIL_FALLBACK_PAYLOAD = JSON.stringify({ + type: "response.failed", + response: { + status: "failed", + error: { + type: "upstream_error", + code: "upstream_reset", + message: "Upstream stream terminated unexpectedly", + }, + last_error: { + type: "upstream_error", + code: "upstream_reset", + message: "Upstream stream terminated unexpectedly", + }, + }, +}); export type InspectionCounters = { frameBufferHighWaterBytes: number; @@ -99,6 +132,21 @@ export function buildFailedTailPayload(err: unknown): string { }); } +function buildFailedTailPayloadOrFallback(err: unknown): string { + try { + return buildFailedTailPayload(err); + } catch { + // Error.message and String(error) may execute hostile accessors. Preserve a + // bounded protocol terminal even when diagnostic serialization is unsafe. + return FAILED_TAIL_FALLBACK_PAYLOAD; + } +} + +export function failedTailFrame(encoder: TextEncoder, err: unknown): Uint8Array { + const payload = buildFailedTailPayloadOrFallback(err); + return encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`); +} + export type SseTerminalOutputBoundary = { feed(chunk: Uint8Array): Uint8Array; finish(): Uint8Array; @@ -111,13 +159,16 @@ export type SseTerminalOutputBoundary = { * Frame-aware client output boundary shared by both native Responses relays. * It buffers only the current incomplete SSE block under the same hard byte * cap as inspection, forwards complete blocks through the first Responses - * terminal, and drops every later block/byte. + * terminal, and drops every later block/byte. A premature [DONE] is held until + * a terminal arrives so clean EOF can synthesize one terminal and one sentinel. */ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { const decoder = new TextDecoder(); + const encoder = new TextEncoder(); const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES); let terminal = false; let done = false; + let pendingDone: { block: Uint8Array; delimiter: Uint8Array } | null = null; let disposed = false; const processFrames = ( @@ -129,16 +180,37 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { for (const frame of frames) { const payload = sseDataPayload(decoder.decode(frame.block)); const isDone = payload === "[DONE]"; - // Preserve every frame through the first Responses terminal. A [DONE] - // frame is also preserved when it immediately follows that terminal in - // the same upstream chunk; every later non-DONE frame is dropped. - if (!responsesTerminal || isDone) output.push(frame.block, frame.delimiter); + const parsed = payload === null ? undefined : parseSsePayload(payload); + const policyError = parsed !== undefined && isPolicyRewriteType(parsed) + ? cyberPolicyTerminalError(parsed) + : undefined; + const outboundBlock = policyError + ? encoder.encode(rewritePolicyTerminalBlock( + decoder.decode(frame.block), + policyFailurePayload(policyError, parsed), + )) + : frame.block; if (isDone) { done = true; + if (responsesTerminal) { + output.push(outboundBlock, frame.delimiter); + } else if (!pendingDone) { + // Do not expose a sentinel before a Responses terminal. If EOF + // follows, the synthetic incomplete path owns the one sentinel; + // if a terminal arrives later, this pending frame is emitted then. + pendingDone = { block: outboundBlock, delimiter: frame.delimiter }; + } continue; } - if (!responsesTerminal && payload && terminalStatusFromSsePayload(payload)) { + // Preserve every frame through the first Responses terminal. Every + // later non-DONE frame is dropped. + if (!responsesTerminal) output.push(outboundBlock, frame.delimiter); + if (!responsesTerminal && payload && terminalStatusFromParsed(parsed)) { responsesTerminal = true; + if (pendingDone) { + output.push(pendingDone.block, pendingDone.delimiter); + pendingDone = null; + } } } if (responsesTerminal) { @@ -155,13 +227,22 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary { }, finish() { if (disposed || terminal) return new Uint8Array(0); - return framer.finish(); + const tail = framer.finish(); + if (tail.byteLength === 0) return new Uint8Array(0); + // EOF may cut off the final SSE block before its blank-line delimiter. + // Feed it through the exact same parser/rewrite/terminal path as a + // complete frame, using a synthetic delimiter so the client receives a + // dispatchable event rather than an unterminated tail. + const tailText = decoder.decode(tail); + const delimiter = encoder.encode(tailText.includes("\r\n") ? "\r\n\r\n" : "\n\n"); + return processFrames([{ block: tail, delimiter }]); }, terminalSeen: () => terminal, doneSeen: () => done, dispose() { if (disposed) return; disposed = true; + pendingDone = null; framer.dispose(); }, }; @@ -198,7 +279,7 @@ export function relaySseWithFailedTail( // Preserve through the terminal block only, add the conventional sentinel // when there was no real [DONE] data event, then stop reading upstream. if (!terminalBoundary.doneSeen()) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.enqueue(doneFrame(encoder)); } closed = true; controller.close(); @@ -219,6 +300,16 @@ export function relaySseWithFailedTail( if (done) { const tail = terminalBoundary.finish(); if (tail.byteLength > 0) controller.enqueue(tail); + if (terminalBoundary.terminalSeen()) { + if (!terminalBoundary.doneSeen()) controller.enqueue(doneFrame(encoder)); + } else { + // A clean upstream EOF is still a failed Responses turn when no + // protocol terminal arrived. Make that state explicit so Codex + // does not treat HTTP 200 + bare EOF as a retryable disconnect. + const incomplete = adapterEofIncompleteFrame(encoder); + controller.enqueue(incomplete); + controller.enqueue(doneFrame(encoder)); + } terminalBoundary.dispose(); controller.close(); return; @@ -228,8 +319,10 @@ export function relaySseWithFailedTail( } } catch (err) { let partial: Uint8Array = new Uint8Array(0); + let tailTerminal = false; try { partial = terminalBoundary.finish(); + tailTerminal = terminalBoundary.terminalSeen(); } catch { // A near-cap ambiguous delimiter tail may itself overflow at EOF. // Preserve the original read/framing failure and continue emitting @@ -237,11 +330,14 @@ export function relaySseWithFailedTail( } terminalBoundary.dispose(); if (closed) return; - const payload = buildFailedTailPayload(err); try { if (partial.byteLength > 0) controller.enqueue(partial); - // Leading blank line terminates a partial SSE block so the failed frame parses cleanly. - controller.enqueue(encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\ndata: [DONE]\n\n`)); + if (tailTerminal) { + if (!terminalBoundary.doneSeen()) controller.enqueue(doneFrame(encoder)); + } else { + // Leading blank line terminates a partial SSE block so the failed frame parses cleanly. + controller.enqueue(failedTailFrame(encoder, err)); + } controller.close(); } catch { /* client already torn down */ } upstream.abort(); @@ -276,15 +372,139 @@ export function sseDataPayload(block: string): string | null { return data.length > 0 ? data.join("\n") : null; } -export function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null { - if (payload === "[DONE]") return null; +type JsonRecord = Record; + +function asJsonRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function stringField(record: JsonRecord | null, key: string): string | undefined { + const value = record?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +/** + * Return a high-confidence policy error carried by an upstream terminal shape. + * Deliberately inspect structured error fields and known refusal copy only — a + * bare `cyber_policy` token in an unrelated payload is not sufficient. + */ +function cyberPolicyTerminalError(parsed: unknown): { message: string; type?: string } | undefined { + const root = asJsonRecord(parsed); + if (!root) return undefined; + const response = asJsonRecord(root.response); + const candidates = [ + asJsonRecord(root.error), + asJsonRecord(root.last_error), + asJsonRecord(response?.error), + asJsonRecord(response?.incomplete_details), + root, + response, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + if (isCyberPolicyCode(stringField(candidate, "code"))) { + return { + message: stringField(candidate, "message") + ?? CYBER_POLICY_FALLBACK_MESSAGE, + ...(stringField(candidate, "type") ? { type: stringField(candidate, "type") } : {}), + }; + } + } + for (const candidate of candidates) { + const message = stringField(candidate, "message"); + if (message && isCyberPolicyMessage(message)) { + return { + message, + ...(stringField(candidate, "type") ? { type: stringField(candidate, "type") } : {}), + }; + } + } + return undefined; +} + +function parseSsePayload(payload: string): unknown | undefined { + if (payload === "[DONE]") return undefined; try { - return terminalStatusFromParsed(JSON.parse(payload)); + return JSON.parse(payload); } catch { - return null; + return undefined; } } +function isPolicyRewriteType(parsed: unknown): boolean { + const type = asJsonRecord(parsed)?.type; + return type === "response.failed" || type === "response.incomplete" || type === "error"; +} + +function rewritePolicyTerminalBlock(block: string, payload: string): string { + const newline = block.includes("\r\n") ? "\r\n" : "\n"; + const rewritten = replaceSseDataPayload(block, payload); + const lines = rewritten.split(/\r?\n/); + let eventRewritten = false; + const withEvent = lines.map(line => { + if (!eventRewritten && line.startsWith("event:")) { + eventRewritten = true; + return "event: response.failed"; + } + return line; + }); + if (!eventRewritten) withEvent.unshift("event: response.failed"); + return withEvent.join(newline); +} + +function policyFailurePayload(policyError: { message: string; type?: string }, parsed: unknown): string { + const error = { + type: cyberPolicyErrorType(policyError.type), + code: CYBER_POLICY_ERROR_CODE, + message: redactSecretString(policyError.message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS), + }; + const root = asJsonRecord(parsed); + const originalResponse = asJsonRecord(root?.response); + const preservedResponse = Object.fromEntries( + Object.entries(originalResponse ?? {}).filter(([key]) => key !== "incomplete_details"), + ); + const response = { + ...preservedResponse, + status: "failed", + error, + last_error: error, + retryable: false, + }; + // Responses event metadata such as sequence_number is normally top-level. + // Keep it (and any other non-error, non-response fields) while replacing only + // the protocol type and error envelope. + const preservedRoot = root + ? Object.fromEntries(Object.entries(root).filter(([key]) => ( + key !== "type" + && key !== "response" + && key !== "error" + && key !== "last_error" + && key !== "retryable" + ))) + : {}; + return JSON.stringify({ + ...preservedRoot, + type: "response.failed", + retryable: false, + response, + }); +} + +export function adapterEofIncompleteFrame(encoder: TextEncoder): Uint8Array { + return encoder.encode(`event: response.incomplete\ndata: ${ADAPTER_EOF_INCOMPLETE_PAYLOAD}\n\n`); +} + +export function doneFrame(encoder: TextEncoder): Uint8Array { + return encoder.encode(DONE_SSE_FRAME_TEXT); +} + +export function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null { + if (payload === "[DONE]") return null; + return terminalStatusFromParsed(parseSsePayload(payload)); +} + /** True when a native Responses SSE payload carries the FIRST kind of non-empty model output. */ export function isFirstOutputSsePayload(payload: string | null): boolean { if (!payload || payload === "[DONE]") return false; @@ -322,14 +542,16 @@ function createFirstOutputReporter(onFirstOutput?: () => void): { } export function terminalStatusFromParsed(parsed: unknown): ResponsesTerminalStatus | null { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - switch ((parsed as { type?: unknown }).type) { + const type = asJsonRecord(parsed)?.type; + switch (type) { case "response.completed": return "completed"; case "response.failed": return "failed"; case "response.incomplete": - return "incomplete"; + return cyberPolicyTerminalError(parsed) ? "failed" : "incomplete"; + case "error": + return cyberPolicyTerminalError(parsed) ? "failed" : null; default: return null; } @@ -800,6 +1022,9 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector } reportFirstOutput.parsed(parsed); const status = terminalStatusFromParsed(parsed); + const policyTerminal = status === "failed" + && isPolicyRewriteType(parsed) + && cyberPolicyTerminalError(parsed) !== undefined; if (status) sawTerminal = true; if (!reported && handlers.onTerminal && status) { try { @@ -808,7 +1033,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector handlers.logCtx.transportPhase = "terminal_sse"; handlers.logCtx.terminalSource = "upstream"; } - handlers.onTerminal(status); + handlers.onTerminal(status, policyTerminal ? 400 : undefined); } finally { if (status === "failed" || status === "incomplete") clearCompletedItems(); } @@ -1087,6 +1312,13 @@ function startBoundedInspectionPump(options: InspectionPumpOptions): void { } } } catch { + // A read error can follow a final SSE block without its blank-line + // delimiter. Flush that candidate before classifying the transport as a + // synthetic reset; otherwise a real completed/failed/policy terminal is + // downgraded to 502 on the inspection branch. + if (!cancelled) { + try { inspector.finish(); } catch { /* preserve the original read error */ } + } if (clientGone) clientGoneWithoutTerminal = !inspector.terminalSeen(); else if (!cancelled) options.onReadError?.(); } finally { diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 26a69a14ab..bc56319c98 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -6,6 +6,7 @@ import { httpStatusFromTerminalError as httpStatusFromClassifiedTerminalError, isClientClosedMessage, isCyberPolicyCode, + isCyberPolicyMessage, } from "../lib/errors"; import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths"; import { readCodexCatalogPath } from "../codex/catalog"; @@ -764,7 +765,7 @@ function captureUpstreamErrorParsed( last_error?: { message?: unknown }; response?: { error?: { type?: unknown; code?: unknown; message?: unknown }; - incomplete_details?: { reason?: unknown }; + incomplete_details?: { reason?: unknown; message?: unknown }; }; }; captureTerminalHttpStatus(logCtx, json); @@ -778,7 +779,8 @@ function captureUpstreamErrorParsed( if (logCtx.upstreamError) return; const message = json?.error?.message ?? json?.last_error?.message - ?? json?.response?.error?.message; + ?? json?.response?.error?.message + ?? json?.response?.incomplete_details?.message; if (typeof message === "string" && message.trim()) { logCtx.upstreamError = redactSecretString(message).slice(0, 500); return; @@ -817,25 +819,43 @@ function captureTerminalHttpStatus( logCtx: RequestLogContext, json: { type?: unknown; - response?: { error?: { type?: unknown; code?: unknown; message?: unknown } }; + code?: unknown; + message?: unknown; + error?: { type?: unknown; code?: unknown; message?: unknown }; + last_error?: { type?: unknown; code?: unknown; message?: unknown }; + response?: { + error?: { type?: unknown; code?: unknown; message?: unknown }; + incomplete_details?: { code?: unknown; message?: unknown }; + }; }, ): void { if (logCtx.terminalHttpStatus !== undefined) return; - if (json.type !== "response.failed") return; - const error = json.response?.error; - if (!error || typeof error !== "object") return; - const terminalCode = error.code === null || typeof error.code === "string" - ? error.code - : undefined; - if (isCyberPolicyCode(terminalCode)) { + const type = json.type; + if (type !== "response.failed" && type !== "response.incomplete" && type !== "error") return; + const responseError = json.response?.error; + const responseDetails = json.response?.incomplete_details; + const candidates = [json.error, json.last_error, responseError, responseDetails, json]; + const policy = candidates.some(candidate => ( + candidate?.code === null || typeof candidate?.code === "string" + ) && isCyberPolicyCode(candidate.code as string | null | undefined)) + || candidates.some(candidate => ( + typeof candidate?.message === "string" + && candidate.message.trim().length > 0 + && isCyberPolicyMessage(candidate.message) + )); + if (policy) { logCtx.terminalErrorCode = CYBER_POLICY_ERROR_CODE; - } else { - delete logCtx.terminalErrorCode; + logCtx.terminalHttpStatus = 400; + return; } + if (type !== "response.failed" || !responseError || typeof responseError !== "object") return; + const responseCode = responseError.code === null || typeof responseError.code === "string" + ? responseError.code + : undefined; logCtx.terminalHttpStatus = httpStatusFromTerminalError({ - type: typeof error.type === "string" ? error.type : undefined, - code: terminalCode, - message: typeof error.message === "string" ? error.message : undefined, + type: typeof responseError.type === "string" ? responseError.type : undefined, + code: responseCode, + message: typeof responseError.message === "string" ? responseError.message : undefined, }); } diff --git a/src/server/responses-terminal-repair.ts b/src/server/responses-terminal-repair.ts index 7d35141a89..a2ff658f5f 100644 --- a/src/server/responses-terminal-repair.ts +++ b/src/server/responses-terminal-repair.ts @@ -22,6 +22,22 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isUnframedTerminalLikeSuffix(block: string): boolean { + const payload = sseDataPayload(block); + if (payload === "[DONE]") return true; + if (!payload) return false; + try { + const parsed = JSON.parse(payload); + if (!isPlainRecord(parsed)) return false; + return parsed.type === "response.completed" + || parsed.type === "response.failed" + || parsed.type === "response.incomplete" + || parsed.type === "error"; + } catch { + return false; + } +} + function outputIndex(value: unknown): number | null { return Number.isInteger(value) && (value as number) >= 0 ? value as number : null; } @@ -292,11 +308,16 @@ export function relayResponsesSseWithTerminalRepair( if (done) { appendBuffer(decoder.decode()); if (buffer.length > 0) { - // A delimiter-less suffix is not a complete SSE event. Preserve the - // upstream bytes for passthrough compatibility, but never let a - // truncated lifecycle frame establish synthetic success. + // A delimiter-less suffix is not a complete SSE event. Preserve an + // ordinary suffix for passthrough compatibility, but never promote + // a terminal-like suffix by adding the delimiter it did not receive + // upstream. The latter must stay tainted and fail closed through the + // synthetic incomplete terminal below. tainted = true; - controller.enqueue(encoder.encode(buffer)); + if (!isUnframedTerminalLikeSuffix(buffer)) { + controller.enqueue(encoder.encode(buffer)); + controller.enqueue(encoder.encode(buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n")); + } } if (!realTerminalSeen) { emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 21288462fc..e66e85bd6f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -71,6 +71,12 @@ import { targetKey, } from "../../combos"; import { isInjectionDebugEnabled } from "../../lib/debug-settings"; +import { + CYBER_POLICY_ERROR_CODE, + CYBER_POLICY_FALLBACK_MESSAGE, + isCyberPolicyCode, + isCyberPolicyMessage, +} from "../../lib/errors"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit"; @@ -663,6 +669,48 @@ export async function readDisplaySafeErrorText( } } +interface NormalizedUpstreamErrorText { + safeText: string; + message?: string; + type?: string; + code?: string; + cyberPolicy: boolean; +} + +/** + * Extract the structured provider error envelope without making `error.type` authoritative. + * Policy identity comes from the dedicated code (or the legacy message fallback); a credible + * upstream type is only carried through so callers do not erase provider diagnostics. + */ +function normalizeUpstreamErrorText(text: string, fallback: string): NormalizedUpstreamErrorText { + const safeText = redactSecretString(text).slice(0, 500).trim() || fallback; + let message: string | undefined; + let type: string | undefined; + let code: string | undefined; + try { + const parsed = JSON.parse(text) as Record; + const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response) + ? parsed.response as Record + : undefined; + const candidates = [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed]; + const source = candidates.find((candidate): candidate is Record => { + if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) return false; + const record = candidate as Record; + return [record.message, record.type, record.code].some(value => typeof value === "string"); + }); + if (!source) return { safeText, cyberPolicy: isCyberPolicyMessage(safeText) }; + if (typeof source.message === "string" && source.message.trim()) { + message = redactSecretString(source.message.trim()).slice(0, 500); + } + if (typeof source.type === "string" && source.type.trim()) type = source.type.trim(); + if (typeof source.code === "string" && source.code.trim()) code = source.code.trim(); + } catch { + /* non-JSON upstream body — retain the bounded display-safe text */ + } + const cyberPolicy = isCyberPolicyCode(code) || isCyberPolicyMessage(message ?? safeText); + return { safeText, message, type, code, cyberPolicy }; +} + function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void { parsed._stripReasoningEncryptedContent = true; } @@ -1313,27 +1361,30 @@ export async function consumeComboFailure( let classificationText = fallback; let usage: OcxUsage | undefined; let upstreamCode: string | undefined; + let upstreamMessage: string | undefined; + let upstreamType: string | undefined; try { const body = await readBoundedResponseBody(response, { signal }); usage = usageFromComboFailureText(body.text); if (body.displaySafe) { - const safeText = redactSecretString(body.text).slice(0, 500); - if (safeText) classificationText = safeText; - try { - const parsed = JSON.parse(body.text) as { error?: { code?: unknown } | string }; - const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.code : undefined; - if (typeof nested === "string" && nested.length > 0) upstreamCode = nested; - } catch { - /* non-JSON upstream body — message-only classification */ - } + const normalized = normalizeUpstreamErrorText(body.text, fallback); + classificationText = normalized.safeText; + upstreamCode = normalized.code; + upstreamMessage = normalized.message; + upstreamType = normalized.type; } } catch (error) { if (signal?.aborted) throw error; classificationText = fallback; } - const message = classificationText === fallback - ? fallback - : `${fallback}: ${classificationText}`; + const cyberFailure = isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(classificationText); + const normalizedUpstreamCode = cyberFailure ? CYBER_POLICY_ERROR_CODE : upstreamCode; + const message = cyberFailure + ? upstreamMessage + ?? (isCyberPolicyCode(upstreamCode) ? CYBER_POLICY_FALLBACK_MESSAGE : classificationText) + : classificationText === fallback + ? fallback + : `${fallback}: ${classificationText}`; const upstreamRetryAfter = response.headers.get("retry-after"); // Client response may get the synthetic "2" fallback; cooldown metadata must not — // otherwise coolComboTarget treats it as a 2s cooldown instead of the 60s default. @@ -1351,13 +1402,18 @@ export async function consumeComboFailure( includeDefault: false, }); return { - response: formatErrorResponse(response.status, "upstream_error", message, { - ...(upstreamCode !== undefined ? { code: upstreamCode } : {}), - ...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}), - }), + response: formatErrorResponse( + response.status, + cyberFailure ? (upstreamType ?? CYBER_POLICY_ERROR_CODE) : "upstream_error", + message, + { + ...(normalizedUpstreamCode !== undefined ? { code: normalizedUpstreamCode } : {}), + ...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}), + }, + ), classificationText, - ...(upstreamCode !== undefined ? { upstreamCode } : {}), - ...(cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}), + ...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}), + ...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}), ...(usage ? { usage } : {}), }; } @@ -4980,29 +5036,41 @@ async function handleResponsesInner( // Upstreams occasionally echo request details in error bodies — scrub token-shaped // material before it reaches the client-facing error surface. const upstreamRetryAfter = upstreamResponse.headers.get("retry-after"); - const message = enrichOpenCodeZenRateLimitMessage( - `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`, - { + const normalized = normalizeUpstreamErrorText(errorText, "unknown error"); + const message = normalized.cyberPolicy + ? normalized.message + ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText) + : enrichOpenCodeZenRateLimitMessage( + `Provider error ${upstreamResponse.status}: ${normalized.safeText}`, + { + status: upstreamResponse.status, + providerName: route.providerName, + baseUrl: route.provider.baseUrl, + adapter: route.provider.adapter, + authMode: route.provider.authMode, + hasApiKey: Boolean(route.provider.apiKey?.trim()), + upstreamRetryAfter, + // This recovery path is the HTTP Responses wire; custom runTurn transports + // never reach enrichOpenCodeZenRateLimitMessage here. + supportsHttpSameKeyRetry: true, + }, + ); + const retryAfter = normalized.cyberPolicy + ? undefined + : resolveClientRetryAfter({ status: upstreamResponse.status, - providerName: route.providerName, - baseUrl: route.provider.baseUrl, - adapter: route.provider.adapter, - authMode: route.provider.authMode, - hasApiKey: Boolean(route.provider.apiKey?.trim()), + message, upstreamRetryAfter, - // This recovery path is the HTTP Responses wire; custom runTurn transports - // never reach enrichOpenCodeZenRateLimitMessage here. - supportsHttpSameKeyRetry: true, + }); + return formatErrorResponse( + upstreamResponse.status, + normalized.cyberPolicy ? (normalized.type ?? CYBER_POLICY_ERROR_CODE) : "upstream_error", + message, + { + ...(normalized.cyberPolicy ? { code: CYBER_POLICY_ERROR_CODE } : {}), + ...(retryAfter !== undefined ? { retryAfter } : {}), }, ); - const retryAfter = resolveClientRetryAfter({ - status: upstreamResponse.status, - message, - upstreamRetryAfter, - }); - return formatErrorResponse(upstreamResponse.status, "upstream_error", message, { - ...(retryAfter !== undefined ? { retryAfter } : {}), - }); } } @@ -5257,10 +5325,21 @@ async function handleResponsesInner( if (!response.ok) { const errorText = await readDisplaySafeErrorText(response, upstream.signal, "unknown error"); + const normalized = normalizeUpstreamErrorText(errorText, "unknown error"); yield { type: "error", - status: response.status, - message: `Provider continuation error ${response.status}: ${redactSecretString(errorText.slice(0, 500))}`, + status: normalized.cyberPolicy ? 400 : response.status, + message: normalized.cyberPolicy + ? normalized.message + ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText) + : `Provider continuation error ${response.status}: ${normalized.safeText}`, + ...(normalized.cyberPolicy + ? { + errorType: normalized.type ?? CYBER_POLICY_ERROR_CODE, + code: CYBER_POLICY_ERROR_CODE, + retryable: false, + } + : {}), }; return; } diff --git a/src/server/responses/passthrough-error.ts b/src/server/responses/passthrough-error.ts index 4b1be17b2e..a9d9ad7d0f 100644 --- a/src/server/responses/passthrough-error.ts +++ b/src/server/responses/passthrough-error.ts @@ -1,9 +1,29 @@ import { formatErrorResponse } from "../../bridge"; +import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors"; import { resolveClientRetryAfter, validateClientRetryAfterHeader, } from "../../lib/retry-after"; +function isCyberPolicyBody(body: string): boolean { + if (isCyberPolicyMessage(body)) return true; + try { + const parsed = JSON.parse(body) as Record; + const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response) + ? parsed.response as Record + : undefined; + for (const candidate of [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed]) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const record = candidate as Record; + if (isCyberPolicyCode(typeof record.code === "string" ? record.code : undefined)) return true; + if (typeof record.message === "string" && isCyberPolicyMessage(record.message)) return true; + } + } catch { + /* non-JSON body — message detection above is the only safe fallback */ + } + return false; +} + /** * Passthrough adapters historically relayed upstream non-2xx bodies verbatim. * Codex maps an *empty* body to the literal client string "Unknown error" @@ -32,18 +52,22 @@ export function formatPassthroughUpstreamError( const now = options?.now ?? Date.now(); const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined; const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now); - const resolved = resolveClientRetryAfter({ - status, - message: trimmed || `Provider error ${status}: (empty body)`, - upstreamRetryAfter, - now, - }); + const cyberPolicyFailure = isCyberPolicyBody(trimmed); + const resolved = cyberPolicyFailure + ? undefined + : resolveClientRetryAfter({ + status, + message: trimmed || `Provider error ${status}: (empty body)`, + upstreamRetryAfter, + now, + }); if (trimmed) { const needsSet = resolved !== undefined && upstreamRetryAfter !== resolved; - const needsDelete = resolved === undefined - && upstreamRetryAfter !== undefined - && originalValid === undefined; + const needsDelete = (cyberPolicyFailure && upstreamRetryAfter !== undefined) + || (resolved === undefined + && upstreamRetryAfter !== undefined + && originalValid === undefined); if (!needsSet && !needsDelete) { return new Response(bodyText, { diff --git a/src/server/sse-frame-buffer.ts b/src/server/sse-frame-buffer.ts index 175e7bcab1..97f8d50931 100644 --- a/src/server/sse-frame-buffer.ts +++ b/src/server/sse-frame-buffer.ts @@ -1,3 +1,5 @@ +import { isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors"; + export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024; const LF_LF = Uint8Array.of(10, 10); @@ -106,10 +108,35 @@ function isResponsesTerminalFrame(block: Uint8Array): boolean { const payload = data.join("\n"); if (payload === "[DONE]") return false; try { - const parsed = JSON.parse(payload) as { type?: unknown }; - return parsed.type === "response.completed" + const parsed = JSON.parse(payload) as { + type?: unknown; + code?: unknown; + message?: unknown; + error?: unknown; + last_error?: unknown; + response?: { error?: unknown; incomplete_details?: unknown }; + }; + if (parsed.type === "response.completed" || parsed.type === "response.failed" - || parsed.type === "response.incomplete"; + || parsed.type === "response.incomplete") return true; + if (parsed.type !== "error") return false; + // A top-level error is terminal for this boundary only when it carries the + // same high-confidence cyber-policy evidence used by relay.ts. Ordinary + // upstream errors remain transport failures and still become a bounded 502. + const candidates = [ + parsed, + parsed.error, + parsed.last_error, + parsed.response?.error, + parsed.response?.incomplete_details, + ]; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const record = candidate as { code?: unknown; message?: unknown }; + if (isCyberPolicyCode(typeof record.code === "string" ? record.code : undefined)) return true; + if (typeof record.message === "string" && isCyberPolicyMessage(record.message)) return true; + } + return false; } catch { return false; } @@ -289,4 +316,4 @@ export function joinSseFrameBytes(parts: readonly Uint8Array[]): Uint8Array { offset += part.byteLength; } return joined; -} \ No newline at end of file +} diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 9ce5fb08d7..086abd68d5 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -283,6 +283,32 @@ terminal observer. Native Responses, Chat Completions, Claude Messages, and WebS request logs must therefore finalize through the context-aware terminal mapper; recognized `cyber_policy` terminals stay `400 / cyber_policy` rather than collapsing to a generic 502. +The client-facing boundary treats the first Responses terminal as authoritative in both relay +shapes. High-confidence policy errors carried as `response.incomplete`, `response.failed`, or a +top-level `error` are normalized to one `response.failed / cyber_policy` event without changing the +refusal outcome; later bytes cannot create a second terminal. A clean HTTP 200 EOF with no terminal +instead emits one `response.incomplete` with `adapter_eof`, followed by one `[DONE]`. Delimiter-less +EOF candidates follow the owning repair policy: the native boundary accepts a structurally valid +terminal tail, while an opted-in terminal repair keeps its unframed suffix tainted and emits +`missing_terminal_event`. Pull/tee and eager relays therefore agree on terminal, sentinel, and +request-log accounting without promoting a truncated repair candidate. + +[Decision Log] +- 목적과 의도: Turn upstream terminal variants and bare EOF into one deterministic Responses + outcome instead of a retryable disconnect or duplicate terminal. +- 기존 구현 및 제약 조건: Policy refusals can arrive in several SSE envelopes, while a clean EOF, + an unterminated final frame, and a read error exercise different pull/tee and eager cleanup paths. +- 검토한 주요 대안: Forward every byte unchanged; classify only request logs; synthesize a failure + after every EOF or read error; normalize the bounded terminal at the client output boundary. +- 선택한 방식: Rewrite only high-confidence policy terminal shapes, preserve their bounded metadata, + flush native terminal candidates before transport-error classification, keep repair-owned + delimiter-less candidates tainted, and synthesize `adapter_eof` only when no real terminal exists. +- 다른 대안 대신 이 방식을 선택한 이유: Log-only classification leaves Codex retry behavior + unchanged, while unconditional synthesis can create two contradictory outcomes for one turn. +- 장점, 단점 및 영향: Both native relay shapes expose exactly one terminal and one sentinel with + matching accounting. Ordinary upstream errors remain fail-closed, and policy refusals remain + refusals rather than becoming successful model output. + ## Standalone Search and exact account selectors `POST /v1/alpha/search` retains the selected model in its request body. When that value is an diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 1f4009c9a5..cf8dbe1efb 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -1331,6 +1331,57 @@ test("chat-native redacts structured provider errors before returning them", asy } }); +test("chat-native preserves a structured cyber_policy type on JSON and SSE failures", async () => { + const secret = `blocked by upstream policy Authorization: ${["Bear", "er"].join("")} chatnativesecret123456`; + const safeMessage = "blocked by upstream policy Authorization: Bearer [REDACTED]"; + const upstream = Bun.serve({ + port: 0, + async fetch(req) { + const body = await req.json() as { stream?: boolean }; + const error = { + message: secret, + type: "server_error", + code: "cyber_policy", + status: 502, + }; + if (body.stream) { + return new Response(`data: ${JSON.stringify({ error })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return Response.json(error, { status: 502, headers: { "retry-after": "120" } }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const request = (stream: boolean) => fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream, messages: [{ role: "user", content: "hi" }] }), + }); + + const jsonResponse = await request(false); + expect(jsonResponse.status).toBe(400); + expect(jsonResponse.headers.get("retry-after")).toBeNull(); + await expect(jsonResponse.json()).resolves.toMatchObject({ + error: { type: "server_error", code: "cyber_policy", message: safeMessage }, + }); + + const sseResponse = await request(true); + expect(sseResponse.status).toBe(200); + const sseText = await sseResponse.text(); + expect(sseText).toContain('"type":"server_error"'); + expect(sseText).toContain('"code":"cyber_policy"'); + expect(sseText).toContain("Authorization: Bearer [REDACTED]"); + expect(sseText).not.toContain("chatnativesecret123456"); + expect(sseText).not.toContain("data: [DONE]"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("chat-native preserves same-key retry, key rotation, usage, and request logging", async () => { const { clearRequestLogsForTests, getRequestLogEntries } = await import("../src/server/request-log"); const { clearKeyCooldowns } = await import("../src/providers/key-failover"); @@ -2663,6 +2714,66 @@ test("inbound chat-completions honors the override when stripping sampling (#404 } }); +test("/v1/chat/completions non-OK upstream preserves top-level structured cyber_policy type", async () => { + const secret = `blocked by upstream policy Authorization: ${["Bear", "er"].join("")} chathttpsecret123456`; + const safeMessage = "blocked by upstream policy Authorization: Bearer [REDACTED]"; + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + message: secret, + type: "server_error", + code: "cyber_policy", + }, { status: 400, headers: { "retry-after": "120" } }); + }, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "chatgpt.com" && url.pathname.startsWith("/backend-api/codex")) { + return originalFetch(new URL(`${url.pathname.slice("/backend-api/codex".length)}${url.search}`, upstream.url), init); + } + return originalFetch(input, init); + }) as typeof fetch; + saveConfig({ + port: 0, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: ["Bear" + "er", "caller-direct-token"].join(" "), + }, + body: JSON.stringify({ + model: "gpt-test", + stream: false, + messages: [{ role: "user", content: "hi" }], + }), + }); + expect(response.status).toBe(400); + expect(response.headers.get("retry-after")).toBeNull(); + await expect(response.json()).resolves.toMatchObject({ + error: { type: "server_error", code: "cyber_policy", message: safeMessage }, + }); + } finally { + await server.stop(true); + upstream.stop(true); + globalThis.fetch = originalFetch; + } +}); + test("/v1/chat/completions non-OK upstream preserves structured model_not_found", async () => { const upstream = Bun.serve({ port: 0, @@ -2788,6 +2899,70 @@ test("/v1/chat/completions status:failed replay normalizes translation_buffer_li } }); +test("/v1/chat/completions status:failed replay preserves structured cyber_policy type", async () => { + const secret = `blocked by upstream policy Authorization: ${["Bear", "er"].join("")} chatreplaysecret123456`; + const safeMessage = "blocked by upstream policy Authorization: Bearer [REDACTED]"; + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + id: "resp_policy", + object: "response", + status: "failed", + error: { + message: secret, + type: "server_error", + code: "cyber_policy", + }, + }); + }, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(requestUrl); + if (url.hostname === "chatgpt.com" && url.pathname.startsWith("/backend-api/codex")) { + return originalFetch(new URL(`${url.pathname.slice("/backend-api/codex".length)}${url.search}`, upstream.url), init); + } + return originalFetch(input, init); + }) as typeof fetch; + saveConfig({ + port: 0, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: ["Bear" + "er", "caller-direct-token"].join(" "), + }, + body: JSON.stringify({ + model: "gpt-test", + stream: false, + messages: [{ role: "user", content: "hi" }], + }), + }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { type: "server_error", code: "cyber_policy", message: safeMessage }, + }); + } finally { + await server.stop(true); + upstream.stop(true); + globalThis.fetch = originalFetch; + } +}); + test("/v1/chat/completions status:failed replay preserves structured model_not_found", async () => { const upstream = Bun.serve({ port: 0, diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 6ddb32c02c..e00c6342a7 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { claimOwnedServiceHome } from "./helpers/owned-service-home"; +import { claimOwnedServiceHome, withOwnedServiceHomePreload } from "./helpers/owned-service-home"; import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = join(import.meta.dir, ".."); @@ -19,7 +19,7 @@ function ownedEnvironment(codexHome: string, ocxHome: string): Record) { - return spawnSync(process.execPath, ["run", "src/cli/index.ts", ...args], { + return spawnSync(process.execPath, withOwnedServiceHomePreload(["run", "src/cli/index.ts", ...args]), { cwd: repoRoot, env: { ...process.env, ...env }, encoding: "utf8", diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index 7955fde9e7..31872fae4b 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -1,9 +1,9 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; +import { createWindowsPowerShellFixture, type WindowsPowerShellFixture } from "./helpers/windows-power-shell-fixture"; import { afterCatalogWriteHandleAppServers, attachStaleAppServerHint, @@ -27,30 +27,11 @@ import { describe("collectCodexAppServerCatalogState (#857)", () => { const APP_SERVER_CMD = "/usr/local/bin/codex app-server"; -/** - * A stand-in for powershell.exe that stalls, prints `line`, and exits. - * - * Platform-shaped on purpose: `execFile` launches its target directly with no shell, so a - * POSIX `.sh` script is not an executable on Windows — and Windows is the platform this - * whole fix exists for, with its own CI shard running this suite. On Windows the fake is a - * `.cmd` invoked through `cmd.exe`; elsewhere it is a shell script. - */ -function writeStallingFakePowerShell(dir: string, line: string): string { - if (process.platform === "win32") { - const cmd = join(dir, "fake-powershell.cmd"); - writeFileSync(cmd, [ - "@echo off", - // ~200ms without depending on timeout.exe, which refuses a redirected stdin. - "ping -n 1 -w 200 192.0.2.1 >nul 2>&1", - `echo ${line.replace(/\t/g, "\t")}`, - ].join("\r\n")); - return cmd; - } - const sh = join(dir, "fake-powershell.sh"); - writeFileSync(sh, ["#!/bin/sh", "sleep 0.2", `printf '%s\\n' '${line}'`].join("\n")); - chmodSync(sh, 0o755); - return sh; -} +let stallingFakePowerShell: WindowsPowerShellFixture; +beforeAll(async () => { + stallingFakePowerShell = await createWindowsPowerShellFixture(); +}); +afterAll(() => stallingFakePowerShell?.cleanup()); test("not_running when no app-server process exists", () => { const status = collectCodexAppServerCatalogState({ @@ -135,9 +116,7 @@ function writeStallingFakePowerShell(dir: string, line: string): string { // asynchronous one does not. That difference is the entire content of #1852. test("the default Windows request path keeps the event loop alive through both PowerShell calls (#1852)", async () => { resetCodexAppServerCatalogStateCache(); - const dir = mkdtempSync(join(tmpdir(), "ocx-ps-fake-")); - const fake = writeStallingFakePowerShell(dir, `42\t${APP_SERVER_CMD}\tCONTOSO\\jun`); - setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); + setTrustedWindowsElevationExecutablesForTests({ powershell: stallingFakePowerShell.executable }); // Phase signal instead of a timer count. A callback tally has to pick a threshold // between "sync" and "async" observations, and `setInterval` makes no catch-up @@ -158,7 +137,6 @@ function writeStallingFakePowerShell(dir: string, line: string): string { } finally { clearInterval(beat); setTrustedWindowsElevationExecutablesForTests(null); - rmSync(dir, { recursive: true, force: true }); resetCodexAppServerCatalogStateCache(); } @@ -173,15 +151,14 @@ function writeStallingFakePowerShell(dir: string, line: string): string { // blocking when an app-server exists, so it needs its own oracle. test("the default Windows start-time discovery keeps the event loop alive (#1852)", async () => { resetCodexAppServerCatalogStateCache(); - const dir = mkdtempSync(join(tmpdir(), "ocx-ps-start-")); - const fake = writeStallingFakePowerShell(dir, `42\t${APP_SERVER_CMD}\tCONTOSO\\jun`); - setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); + setTrustedWindowsElevationExecutablesForTests({ powershell: stallingFakePowerShell.executable }); let loopRanDuringExec = false; const beat = setInterval(() => { loopRanDuringExec = true; }, 5); + let status: Awaited>; try { // No readStartMsBatchAsync override: the default start-time path must run for real. - await collectCodexAppServerCatalogStateForRequest({ + status = await collectCodexAppServerCatalogStateForRequest({ platform: "win32", listSnapshotsAsync: async () => [{ pid: 42, commandLine: APP_SERVER_CMD }], catalogMtimeMs: () => 1_000, @@ -189,11 +166,14 @@ function writeStallingFakePowerShell(dir: string, line: string): string { } finally { clearInterval(beat); setTrustedWindowsElevationExecutablesForTests(null); - rmSync(dir, { recursive: true, force: true }); resetCodexAppServerCatalogStateCache(); } expect(loopRanDuringExec).toBe(true); + expect(status).toMatchObject({ + state: "stale", + processes: [{ pid: 42, startedAtMs: 500 }], + }); }); test("Windows request collection shares one in-flight refresh and its short cache (#1852)", async () => { @@ -923,14 +903,20 @@ describe("warnIfStaleCodexAppServersAfterStartupWrite (#1046)", () => { * invalidation is verified by reading it, not by a test that cannot fail. */ test("a defaulted read is memoized, and invalidation is what clears it", () => { - resetCodexAppServerCatalogStateCache(); - const first = collectCodexAppServerCatalogState(); - const second = collectCodexAppServerCatalogState(); - // Same object identity: the second call served the memo rather than recomputing. - expect(second).toBe(first); + const realDateNow = Date.now; + Date.now = () => 1_000; + try { + resetCodexAppServerCatalogStateCache(); + const first = collectCodexAppServerCatalogState(); + const second = collectCodexAppServerCatalogState(); + // Same object identity: the second call served the memo rather than recomputing. + expect(second).toBe(first); - resetCodexAppServerCatalogStateCache(); - expect(collectCodexAppServerCatalogState()).not.toBe(first); + resetCodexAppServerCatalogStateCache(); + expect(collectCodexAppServerCatalogState()).not.toBe(first); + } finally { + Date.now = realDateNow; + } }); /* diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 8e6cddd0e3..bfe87960e5 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -40,7 +40,7 @@ import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; -import { claimOwnedServiceHome } from "./helpers/owned-service-home"; +import { claimOwnedServiceHome, withOwnedServiceHomePreload } from "./helpers/owned-service-home"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = resolve(import.meta.dir, ".."); @@ -118,6 +118,7 @@ class Fixture { readonly lockPath: string; readonly lockAllowlist: string[]; readonly serviceManagerEnv: Record; + readonly serviceManagerPreloadPath: string | undefined; readonly children: Array> = []; constructor() { @@ -130,10 +131,16 @@ class Fixture { if (existsSync(path)) throw new Error(`lock preflight found pre-existing case path: ${path}`); } writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); - this.serviceManagerEnv = claimOwnedServiceHome(this.codex, this.ocx, this.homeA).env; + const serviceHome = claimOwnedServiceHome(this.codex, this.ocx, this.homeA); + this.serviceManagerEnv = serviceHome.env; + this.serviceManagerPreloadPath = serviceHome.preloadPath; } - env(home = this.homeA, userprofile = this.userprofileA): Record { + env( + home = this.homeA, + userprofile = this.userprofileA, + includeServiceProbe = false, + ): Record { // Do not inherit ambient homes or proxy configuration. `process.execPath` // is absolute, so a PATH is intentionally unnecessary for CLI children. return { @@ -156,7 +163,7 @@ class Fixture { // without this the child spawned by a CI runner refuses with "Windows effective-account // lookup timed out" while powershell.exe is still starting. ...(process.env.CI === "true" ? { CI: "true" } : {}), - ...this.serviceManagerEnv, + ...(includeServiceProbe ? this.serviceManagerEnv : {}), }; } @@ -182,9 +189,9 @@ class Fixture { } spawnCli(argv: string[], home = this.homeA, userprofile = this.userprofileA) { - const child = Bun.spawn([process.execPath, cliPath, ...argv], { + const child = Bun.spawn([process.execPath, ...withOwnedServiceHomePreload([cliPath, ...argv], this.serviceManagerPreloadPath)], { cwd: this.root, - env: this.env(home, userprofile), + env: this.env(home, userprofile, true), stdout: "pipe", stderr: "pipe", }); diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 9054d5bacf..ac28ba6bc7 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -6,7 +6,7 @@ * "the lock function was invoked" — a pass-through mock satisfies that — but * that two real processes running the real injection cannot both write. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -16,10 +16,22 @@ import { resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; import { STABLE_ZERO_BYTE_COORDINATOR_AGE_MS } from "../src/codex/inject-coordination"; +import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = join(import.meta.dir, ".."); const CHILD = join(repoRoot, "tests", "helpers", "codex-inject-race-child.ts"); const LOCK_CHILD = join(repoRoot, "tests", "helpers", "codex-write-lock-child.ts"); +// Leave teardown and assertion headroom inside the surrounding test budget. A real +// Bun child can take several seconds to start and settle on a loaded Windows runner. +const SPAWN_TIMEOUT_MS = SPAWN_BUDGET_MS - 5_000; +// The contender uses a much shorter bound because production contention is +// fail-fast (lockTimeoutMs=0). Keep the holder alive well beyond that bound so a +// slow child launch cannot turn an intended busy result into a post-release apply. +const CONTENTION_CHILD_TIMEOUT_MS = 10_000; +const CONTENTION_HOLDER_MARGIN_MS = 5_000; +const CONTENTION_HOLD_MS = SPAWN_TIMEOUT_MS - CONTENTION_HOLDER_MARGIN_MS; + +setDefaultTimeout(SPAWN_BUDGET_MS); let root = ""; let codexHome = ""; @@ -31,19 +43,72 @@ function seedNative(): void { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n'); } -function runInject(port: number, lockTimeoutMs = 0): { success: boolean; status?: "skipped"; retryable: boolean; message: string } { - const result = spawnSync(process.execPath, [CHILD], { +function runChild( + args: string[], + env: NodeJS.ProcessEnv, + timeoutMs = SPAWN_TIMEOUT_MS, +): ReturnType { + return spawnSync(process.execPath, args, { cwd: repoRoot, encoding: "utf8", - env: { + env, + timeout: timeoutMs, + windowsHide: true, + }); +} + +function childDiagnostics(result: ReturnType): string { + const stdout = String(result.stdout ?? "").trim(); + const stderr = String(result.stderr ?? "").trim(); + const error = result.error instanceof Error + ? result.error.message + : result.error + ? String(result.error) + : ""; + return [ + `status=${String(result.status)}`, + `signal=${String(result.signal)}`, + error ? `error=${error}` : "", + `stdout=${stdout || ""}`, + `stderr=${stderr || ""}`, + ].filter(Boolean).join("; "); +} + +function requireChildSuccess(result: ReturnType, label: string): string { + if (result.error || result.status !== 0) { + throw new Error(`${label} failed: ${childDiagnostics(result)}`); + } + return String(result.stdout ?? ""); +} + +function parseChildJson(result: ReturnType, label: string): T { + const stdout = requireChildSuccess(result, label); + const line = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + if (!line) { + throw new Error(`${label} produced no JSON output: ${childDiagnostics(result)}`); + } + try { + return JSON.parse(line) as T; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`${label} produced invalid JSON (${reason}): ${childDiagnostics(result)}`); + } +} + +function runInject( + port: number, + lockTimeoutMs = 0, + timeoutMs = SPAWN_TIMEOUT_MS, +): { success: boolean; status?: "skipped"; retryable: boolean; message: string } { + return parseChildJson<{ success: boolean; status?: "skipped"; retryable: boolean; message: string }>( + runChild([CHILD], { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, OCX_INJECT_RACE_PAYLOAD: JSON.stringify({ port, lockTimeoutMs }), - }, - }); - const line = (result.stdout ?? "").trim().split("\n").filter(Boolean).pop() ?? "{}"; - return JSON.parse(line) as { success: boolean; status?: "skipped"; retryable: boolean; message: string }; + }, timeoutMs), + `inject child (port=${port})`, + ); } beforeEach(() => { @@ -102,18 +167,18 @@ describe("the lock is on the production path", () => { expect(result.success).toBeTrue(); // The row is the proof that the lock ran, not that the function was called. - const state = spawnSync(process.execPath, ["--eval", ` + const state = runChild(["--eval", ` const { readCodexTransitionState } = require("./src/codex/transition-state"); console.log(JSON.stringify(readCodexTransitionState())); `], { - cwd: repoRoot, - encoding: "utf8", - env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }, + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, }); - const row = JSON.parse((state.stdout ?? "{}").trim().split("\n").pop() ?? "{}") as { + const row = parseChildJson<{ kind?: string; state?: { nativeGeneration?: number; currentTxId?: string | null }; - }; + }>(state, "read transition state after clean apply"); expect(row.kind).toBe("ready"); expect(row.state?.nativeGeneration).toBeGreaterThan(0); // Guessing null passes on a fresh machine and fails on a real one, so the @@ -141,37 +206,66 @@ describe("the lock is on the production path", () => { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, - OCX_LOCK_CHILD_PAYLOAD: JSON.stringify({ timeoutMs: 5_000, holdMarker, releaseMarker }), + OCX_LOCK_CHILD_PAYLOAD: JSON.stringify({ + timeoutMs: 5_000, + holdMarker, + releaseMarker, + // Keep a slow Windows contender from outliving the hold, while staying + // below the 40s child bound and the 45s test budget. + holdMs: CONTENTION_HOLD_MS, + }), }, stdout: "pipe", stderr: "pipe", }); - const deadline = Date.now() + 10_000; - while (!existsSync(holdMarker) && Date.now() < deadline) { - spawnSync(process.execPath, ["--eval", "Bun.sleepSync(20)"], { encoding: "utf8" }); - } - expect(existsSync(holdMarker)).toBeTrue(); - - // PROCESS-UNIQUE bytes: a different port means different candidate bytes, so - // the loser's work is identifiable rather than assumed. - const contender = runInject(20200); + let primaryFailed = false; + let primaryError: unknown; + let cleanupFailed = false; + let cleanupError: unknown; + try { + const deadline = Date.now() + 10_000; + while (!existsSync(holdMarker) && Date.now() < deadline) { + requireChildSuccess(runChild(["--eval", "Bun.sleepSync(20)"], process.env), "hold-marker wait child"); + } + expect(existsSync(holdMarker)).toBeTrue(); - writeFileSync(releaseMarker, "go"); - // AWAIT the holder. Dropping its exit on the floor left a live child owning the - // coordinator database while afterEach removed the temp root, and Windows refuses - // to unlink a file another process still has open -- so teardown failed with EBUSY - // and blamed this test for a race it had already won. POSIX unlinks regardless, - // which is why only Windows ever saw it, and only under full-suite load. - await holder.exited; + // PROCESS-UNIQUE bytes: a different port means different candidate bytes, so + // the loser's work is identifiable rather than assumed. + const contender = runInject(20200, 0, CONTENTION_CHILD_TIMEOUT_MS); - expect(contender.success).toBeFalse(); - expect(contender.retryable).toBeTrue(); - // Its bytes are absent: the file still names the first winner's port. - const finalConfig = readFileSync(join(codexHome, "config.toml"), "utf-8"); - expect(finalConfig).not.toContain("20200"); - expect(finalConfig).toBe(afterFirst); - }, 30_000); + expect(contender.success).toBeFalse(); + expect(contender.retryable).toBeTrue(); + // Its bytes are absent: the file still names the first winner's port. + const finalConfig = readFileSync(join(codexHome, "config.toml"), "utf-8"); + expect(finalConfig).not.toContain("20200"); + expect(finalConfig).toBe(afterFirst); + } catch (error) { + // Preserve the first assertion/helper failure after cleanup completes. + primaryFailed = true; + primaryError = error; + } finally { + try { + writeFileSync(releaseMarker, "go"); + } catch (error) { + cleanupFailed = true; + cleanupError = error; + } + try { + // Always release and reap the holder, including when marker wait, + // contender startup, or an assertion fails. Otherwise teardown races a + // live child that still owns the coordinator database on Windows. + await holder.exited; + } catch (error) { + if (!cleanupFailed) { + cleanupFailed = true; + cleanupError = error; + } + } + } + if (primaryFailed) throw primaryError; + if (cleanupFailed) throw cleanupError; + }, SPAWN_BUDGET_MS); }); describe("homes the coordinator cannot adopt keep working", () => { @@ -237,18 +331,18 @@ describe("the transition is resolved, not left pending", () => { seedNative(); expect(runInject(10100).success).toBeTrue(); - const state = spawnSync(process.execPath, ["--eval", ` + const state = runChild(["--eval", ` const { readCodexTransitionState } = require("./src/codex/transition-state"); console.log(JSON.stringify(readCodexTransitionState())); `], { - cwd: repoRoot, - encoding: "utf8", - env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }, + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, }); - const row = JSON.parse((state.stdout ?? "{}").trim().split("\n").pop() ?? "{}") as { + const row = parseChildJson<{ kind?: string; state?: { history?: { status?: string } }; - }; + }>(state, "read transition state after completed apply"); expect(row.kind).toBe("ready"); expect(row.state?.history?.status).not.toBe("pending"); }); @@ -268,30 +362,26 @@ describe("the transition is resolved, not left pending", () => { syncResumeHistory: false, }, null, 2)); - const result = spawnSync(process.execPath, [CHILD], { - cwd: repoRoot, - encoding: "utf8", - env: { - ...process.env, - CODEX_HOME: codexHome, - OPENCODEX_HOME: opencodexHome, - OCX_INJECT_RACE_PAYLOAD: JSON.stringify({ port: 10100, lockTimeoutMs: 0 }), - }, + const result = runChild([CHILD], { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + OCX_INJECT_RACE_PAYLOAD: JSON.stringify({ port: 10100, lockTimeoutMs: 0 }), }); - expect(result.status).toBe(0); + requireChildSuccess(result, "opted-out inject child"); - const state = spawnSync(process.execPath, ["--eval", ` + const state = runChild(["--eval", ` const { readCodexTransitionState } = require("./src/codex/transition-state"); console.log(JSON.stringify(readCodexTransitionState())); `], { - cwd: repoRoot, - encoding: "utf8", - env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome }, + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, }); - const row = JSON.parse((state.stdout ?? "{}").trim().split("\n").pop() ?? "{}") as { + const row = parseChildJson<{ kind?: string; state?: { history?: { status?: string; attempts?: number } }; - }; + }>(state, "read transition state after opted-out apply"); expect(row.kind).toBe("ready"); // Opt-out is a completed decision, not a failure: converged, never blocked, // and never left pending for a job that chose to do nothing. diff --git a/tests/codex-retained-root-serialization.test.ts b/tests/codex-retained-root-serialization.test.ts index b2dc7c5a83..4386799151 100644 --- a/tests/codex-retained-root-serialization.test.ts +++ b/tests/codex-retained-root-serialization.test.ts @@ -16,7 +16,7 @@ import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; -import { claimOwnedServiceHome } from "./helpers/owned-service-home"; +import { claimOwnedServiceHome, withOwnedServiceHomePreload } from "./helpers/owned-service-home"; const repoRoot = resolve(import.meta.dir, ".."); const sandboxes: Sandbox[] = []; @@ -26,6 +26,8 @@ interface Sandbox { readonly codexHome: string; readonly opencodexHome: string; readonly env: Record; + readonly serviceManagerEnv: Record; + readonly preloadPath?: string; } function nativeEntry(slug: string, visibility = "list"): Record { @@ -65,7 +67,7 @@ function makeSandbox(prefix: string): Sandbox { mkdirSync(path, { recursive: true }); chmodSync(path, 0o700); } - const serviceManagerEnv = claimOwnedServiceHome(codexHome, opencodexHome, home).env; + const serviceHome = claimOwnedServiceHome(codexHome, opencodexHome, home); const sandbox = { root, codexHome, @@ -81,13 +83,18 @@ function makeSandbox(prefix: string): Sandbox { TMP: runtime, XDG_RUNTIME_DIR: runtime, LOCALAPPDATA: join(home, "LocalAppData"), - ...serviceManagerEnv, }, + serviceManagerEnv: serviceHome.env, + preloadPath: serviceHome.preloadPath, }; sandboxes.push(sandbox); return sandbox; } +function sandboxChildEnv(sandbox: Sandbox): Record { + return { ...sandbox.env, ...sandbox.serviceManagerEnv }; +} + async function waitForPath(path: string, timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs; while (!existsSync(path)) { @@ -100,9 +107,9 @@ async function runChild( sandbox: Sandbox, script: string, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { - const child = Bun.spawn([process.execPath, "--eval", script], { + const child = Bun.spawn([process.execPath, ...withOwnedServiceHomePreload(["--eval", script], sandbox.preloadPath)], { cwd: repoRoot, - env: sandbox.env, + env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe", }); @@ -177,9 +184,12 @@ test("startup and CLI sync-cache cannot write models_cache while another process expect(startupProbe.exitCode).toBe(0); expect(existsSync(cachePath)).toBe(false); - const cli = Bun.spawnSync([process.execPath, "run", "src/cli/index.ts", "sync-cache"], { + const cli = Bun.spawnSync([ + process.execPath, + ...withOwnedServiceHomePreload(["run", "src/cli/index.ts", "sync-cache"], sandbox.preloadPath), + ], { cwd: repoRoot, - env: sandbox.env, + env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe", }); @@ -287,13 +297,13 @@ for (const publisher of ["convergence", "retained"] as const) { }; writeFileSync(join(sandbox.opencodexHome, "config.json"), JSON.stringify(config)); try { - const sync = Bun.spawn([process.execPath, "--eval", ` + const sync = Bun.spawn([process.execPath, ...withOwnedServiceHomePreload(["--eval", ` const config = ${JSON.stringify(config)}; const { handleManagementAPI } = await import("./src/server/management-api.ts"); const req = new Request("http://localhost/api/sync", { method: "POST", headers: { Host: "localhost" } }); const response = await handleManagementAPI(req, new URL(req.url), config); console.log(JSON.stringify({ status: response.status, body: await response.json() })); - `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }); + `], sandbox.preloadPath)], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); await Promise.race([ waitForPath(requested), @@ -366,7 +376,7 @@ test("a persisted runtime selection moved by another process during the await bl }, }; - const sync = Bun.spawn([process.execPath, "--eval", ` + const sync = Bun.spawn([process.execPath, ...withOwnedServiceHomePreload(["--eval", ` import { existsSync, writeFileSync } from "node:fs"; const config = ${JSON.stringify(config)}; config.providers.together.fetch = async () => { @@ -376,7 +386,7 @@ test("a persisted runtime selection moved by another process during the await bl }; const { syncCatalogModels } = await import("./src/codex/catalog/sync.ts"); console.log(JSON.stringify(await syncCatalogModels(config))); - `], { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }); + `], sandbox.preloadPath)], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); await Promise.race([ waitForPath(requested), @@ -497,8 +507,8 @@ test("two processes at the post-approval management seam serialize instead of in writeFileSync(catalogPath, seeded); const children = (["a", "b"] as const).map(marker => Bun.spawn( - [process.execPath, "--eval", routeScript(marker)], - { cwd: repoRoot, env: sandbox.env, stdout: "pipe", stderr: "pipe" }, + [process.execPath, ...withOwnedServiceHomePreload(["--eval", routeScript(marker)], sandbox.preloadPath)], + { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }, )); results = await Promise.all(children.map(async child => { diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 8810633d0d..8a0d430bbe 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -7,7 +7,7 @@ import { syncModelsToCodex } from "../src/codex/sync"; import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../src/codex/subagent-defaults"; import type { OcxConfig } from "../src/types"; import type { OrcaCodexHomeDiagnostic } from "../src/codex/home"; -import { claimOwnedServiceHome } from "./helpers/owned-service-home"; +import { claimOwnedServiceHome, withOwnedServiceHomePreload } from "./helpers/owned-service-home"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-sync-api"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -18,6 +18,8 @@ let prevCodexHome: string | undefined; let prevOpenCodexHome: string | undefined; let prevHome: string | undefined; let prevUserProfile: string | undefined; +let serviceManagerEnv: Record = {}; +let serviceManagerPreloadPath: string | undefined; const config = { port: 0, @@ -34,7 +36,17 @@ const config = { } as OcxConfig; function claimTempHome(codexHome: string, ocxHome: string, home: string): void { - claimOwnedServiceHome(codexHome, ocxHome, home); + const fixture = claimOwnedServiceHome(codexHome, ocxHome, home); + serviceManagerEnv = fixture.env; + serviceManagerPreloadPath = fixture.preloadPath; +} + +function childEnv(overrides: Record = {}): Record { + return { ...process.env, ...serviceManagerEnv, ...overrides } as Record; +} + +function childArgs(args: readonly string[]): string[] { + return withOwnedServiceHomePreload(args, serviceManagerPreloadPath); } const admittedSync = () => ({ kind: "admitted" as const }); @@ -80,6 +92,8 @@ describe("GUI/CLI Codex sync backend", () => { else process.env.HOME = prevHome; if (prevUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prevUserProfile; + serviceManagerEnv = {}; + serviceManagerPreloadPath = undefined; if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); test("returns the structured sync result used by POST /api/sync", async () => { @@ -170,19 +184,18 @@ describe("GUI/CLI Codex sync backend", () => { const journalPath = join(TEST_CODEX_HOME, "opencodex-journal.json"); const before = readFileSync(configPath, "utf8"); - const child = spawnSync(process.execPath, ["-e", ` + const child = spawnSync(process.execPath, childArgs(["-e", ` const { injectCodexConfig } = await import("./src/codex/inject.ts"); const result = await injectCodexConfig(10100, ${JSON.stringify(config)}, { validateOnly: true }); console.log(JSON.stringify(result)); - `], { + `]), { cwd: repoRoot, - env: { - ...process.env, + env: childEnv({ HOME: TEST_HOME, USERPROFILE: TEST_HOME, CODEX_HOME: TEST_CODEX_HOME, OPENCODEX_HOME: TEST_OCX_HOME, - }, + }), encoding: "utf8", }); @@ -333,11 +346,13 @@ describe("GUI/CLI Codex sync backend", () => { ' const result = await syncModelsToCodex(12345, snapshot, null, {', ' refreshCodexModelCatalog: async () => {', ' // The provider-discovery window: a second real process persists OFF.', + ' // This child only flips desired state; do not propagate the service-probe flag.', + ' const flipEnv = { ...process.env }; delete flipEnv.OCX_TEST_SERVICE_HOME_PROBE;', ' const flip = spawnSync(process.execPath, ["--eval",', ' \'const { setIntegrationEnabled } = require("./src/codex/desired-state");\'', ' + \'const r = setIntegrationEnabled("codex", false);\'', ' + \'if (!r.ok) { console.error(JSON.stringify(r)); process.exit(1); }\',', - ' ], { cwd: process.cwd(), env: process.env, encoding: "utf8" });', + ' ], { cwd: process.cwd(), env: flipEnv, encoding: "utf8" });', ' if (flip.status !== 0) throw new Error("flip failed: " + flip.stderr);', ' return { added: 0, path: "/tmp/none.json", catalogExists: false, catalogWritten: false, cacheSynced: false, comboOmissions: [] };', ' },', @@ -347,15 +362,14 @@ describe("GUI/CLI Codex sync backend", () => { '})();', ].join("\n"); const before = readFileSync(join(raceCodexHome, "config.toml"), "utf8"); - const child = spawnSync(process.execPath, ["--eval", script], { + const child = spawnSync(process.execPath, childArgs(["--eval", script]), { cwd: repoRoot, - env: { - ...process.env, + env: childEnv({ HOME: raceHome, USERPROFILE: raceHome, CODEX_HOME: raceCodexHome, OPENCODEX_HOME: raceOcxHome, - }, + }), encoding: "utf8", }); expect(child.status).toBe(0); @@ -489,7 +503,7 @@ describe("GUI/CLI Codex sync backend", () => { "", ].join("\n"), "utf8"); - const child = spawnSync(process.execPath, ["-e", ` + const child = spawnSync(process.execPath, childArgs(["-e", ` const { handleManagementAPI } = await import("./src/server/management-api.ts"); const config = { port: 10100, defaultProvider: "openai", providers: {} }; const response = await handleManagementAPI( @@ -498,9 +512,9 @@ describe("GUI/CLI Codex sync backend", () => { config, ); console.log(JSON.stringify({ status: response.status, body: await response.json() })); - `], { + `]), { cwd: join(import.meta.dir, ".."), - env: { ...process.env, CODEX_HOME: TEST_CODEX_HOME, OPENCODEX_HOME: ocxHome }, + env: childEnv({ CODEX_HOME: TEST_CODEX_HOME, OPENCODEX_HOME: ocxHome }), encoding: "utf8", }); diff --git a/tests/cyber-policy-error-fidelity.test.ts b/tests/cyber-policy-error-fidelity.test.ts index b6610a078d..86e6b61656 100644 --- a/tests/cyber-policy-error-fidelity.test.ts +++ b/tests/cyber-policy-error-fidelity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; -import { bridgeToResponsesSSE, formatErrorResponse } from "../src/bridge"; +import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "../src/bridge"; import { chatCompletionsErrorBody, chatCompletionsErrorResponse, @@ -15,7 +15,8 @@ import { } from "../src/lib/errors"; import { formatPassthroughUpstreamError } from "../src/server/responses/passthrough-error"; import { consumeComboFailure } from "../src/server/responses/core"; -import type { AdapterEvent } from "../src/types"; +import { handleResponses } from "../src/server/responses"; +import type { AdapterEvent, OcxConfig } from "../src/types"; import { createTestTranslatorBudget, withTestTranslatorBudget } from "./helpers/translator-budget"; const createOpenAIChatAdapter = (...args: Parameters) => @@ -37,6 +38,9 @@ const CURSOR_SESSION_CYBER_MESSAGE = const OPENAI_CYBER_MESSAGE = "OpenAI flagged this request for potential high-risk cybersecurity activity. Please try a less sensitive prompt."; +const SECRET_CYBER_MESSAGE = `${OPENAI_CYBER_MESSAGE} Authorization: ${["Bear", "er"].join("")} cybersecret123456`; +const REDACTED_CYBER_MESSAGE = `${OPENAI_CYBER_MESSAGE} Authorization: Bearer [REDACTED]`; + const CODEX_FALLBACK_MESSAGE = "This request has been flagged for possible cybersecurity risk."; const CYBER_ERROR_BODY = { @@ -81,7 +85,7 @@ async function collectAdapter(gen: AsyncGenerator): Promise { test("classifyError maps cyber messages and explicit type to cyber_policy", () => { expect(classifyError(400, "upstream_error", OPENAI_CYBER_MESSAGE)).toMatchObject({ - type: "invalid_request_error", + type: CYBER_POLICY_ERROR_CODE, code: CYBER_POLICY_ERROR_CODE, }); expect(classifyError(502, "upstream_error", CURSOR_SESSION_CYBER_MESSAGE)).toMatchObject({ @@ -108,7 +112,7 @@ describe("cyber_policy error fidelity", () => { test("adapterFailureFromMessage prefers HTTP 400 + cyber_policy (not 502)", () => { expect(adapterFailureFromMessage(OPENAI_CYBER_MESSAGE)).toMatchObject({ httpStatus: 400, - error: { type: "invalid_request_error", code: CYBER_POLICY_ERROR_CODE }, + error: { type: CYBER_POLICY_ERROR_CODE, code: CYBER_POLICY_ERROR_CODE }, }); expect(adapterFailureFromMessage(CURSOR_SESSION_CYBER_MESSAGE)).toMatchObject({ httpStatus: 400, @@ -122,34 +126,117 @@ describe("cyber_policy error fidelity", () => { await expect(fromMessage.json()).resolves.toEqual({ error: { message: OPENAI_CYBER_MESSAGE, - type: "invalid_request_error", + type: CYBER_POLICY_ERROR_CODE, code: CYBER_POLICY_ERROR_CODE, }, }); - const fromCode = formatErrorResponse(502, "upstream_error", "blocked by safety", { + const fromCode = formatErrorResponse(502, "server_error", "blocked by safety", { code: CYBER_POLICY_ERROR_CODE, + retryAfter: "120", }); expect(fromCode.status).toBe(400); + expect(fromCode.headers.get("retry-after")).toBeNull(); await expect(fromCode.json()).resolves.toMatchObject({ - error: { code: CYBER_POLICY_ERROR_CODE, type: "invalid_request_error" }, + error: { code: CYBER_POLICY_ERROR_CODE, type: "server_error" }, }); }); test("passthrough HTTP 400 cyber body is relayed verbatim", async () => { const body = JSON.stringify(CYBER_ERROR_BODY); - const response = formatPassthroughUpstreamError(400, body); + const response = formatPassthroughUpstreamError(400, body, { + headers: new Headers({ "content-type": "application/json", "retry-after": "120" }), + }); expect(response.status).toBe(400); expect(await response.text()).toBe(body); + expect(response.headers.get("retry-after")).toBeNull(); + + const codeOnlyBody = JSON.stringify({ + error: { message: "blocked by policy", type: "server_error", code: CYBER_POLICY_ERROR_CODE }, + }); + const codeOnlyResponse = formatPassthroughUpstreamError(400, codeOnlyBody, { + headers: new Headers({ "content-type": "application/json", "retry-after": "120" }), + }); + expect(await codeOnlyResponse.text()).toBe(codeOnlyBody); + expect(codeOnlyResponse.headers.get("retry-after")).toBeNull(); }); test("consumeComboFailure preserves cyber_policy code as HTTP 400", async () => { - const upstream = new Response(JSON.stringify(CYBER_ERROR_BODY), { status: 400 }); + const upstream = new Response(JSON.stringify(CYBER_ERROR_BODY), { + status: 400, + headers: { "retry-after": "120" }, + }); + const failure = await consumeComboFailure(upstream); + expect(failure.response.status).toBe(400); + expect(failure.response.headers.get("retry-after")).toBeNull(); + expect(failure.upstreamCode).toBe(CYBER_POLICY_ERROR_CODE); + await expect(failure.response.json()).resolves.toMatchObject({ + error: { + code: CYBER_POLICY_ERROR_CODE, + type: "invalid_request", + message: OPENAI_CYBER_MESSAGE, + }, + }); + }); + + test("ordinary Responses HTTP failure preserves structured cyber type and exact safe message", async () => { + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + error: { + message: SECRET_CYBER_MESSAGE, + type: "server_error", + code: CYBER_POLICY_ERROR_CODE, + }, + }, { status: 400, headers: { "retry-after": "120" } }); + }, + }); + const config = { + port: 0, + defaultProvider: "mock", + providers: { + mock: { + adapter: "openai-chat", + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + apiKey: "fixture-key", + allowPrivateNetwork: true, + }, + }, + } as OcxConfig; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", input: "hello", stream: false }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(400); + expect(response.headers.get("retry-after")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + error: { + message: REDACTED_CYBER_MESSAGE, + type: "server_error", + code: CYBER_POLICY_ERROR_CODE, + }, + }); + } finally { + upstream.stop(true); + } + }); + + test("message-only combo cyber failures preserve structured type and redact the message", async () => { + const upstream = new Response(JSON.stringify({ + error: { type: "server_error", message: SECRET_CYBER_MESSAGE }, + }), { status: 400 }); const failure = await consumeComboFailure(upstream); expect(failure.response.status).toBe(400); expect(failure.upstreamCode).toBe(CYBER_POLICY_ERROR_CODE); await expect(failure.response.json()).resolves.toMatchObject({ - error: { code: CYBER_POLICY_ERROR_CODE, type: "invalid_request_error" }, + error: { + type: "server_error", + code: CYBER_POLICY_ERROR_CODE, + message: REDACTED_CYBER_MESSAGE, + }, }); }); @@ -170,18 +257,11 @@ describe("cyber_policy error fidelity", () => { status: 400, }); - const frames = await collectSse(bridgeToResponsesSSE(replay([ - { type: "text_delta", text: "partial" }, - { - type: "error", - message: OPENAI_CYBER_MESSAGE, - code: CYBER_POLICY_ERROR_CODE, - status: 400, - }, - ]), "openai/gpt-5.4")); + expect(events.find(e => e.type === "error")).toMatchObject({ errorType: "invalid_request" }); + const frames = await collectSse(bridgeToResponsesSSE(replay(events), "openai/gpt-5.4")); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; expect(failed.error).toMatchObject({ - type: "invalid_request_error", + type: "invalid_request", code: CYBER_POLICY_ERROR_CODE, message: OPENAI_CYBER_MESSAGE, }); @@ -191,10 +271,43 @@ describe("cyber_policy error fidelity", () => { test("message-only cyber adapter error still classifies (no silent 502)", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ - { type: "error", message: OPENAI_CYBER_MESSAGE }, + { type: "error", message: SECRET_CYBER_MESSAGE, retryable: true }, ]), "openai/gpt-5.4")); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; - expect(failed.error).toMatchObject({ code: CYBER_POLICY_ERROR_CODE }); + expect(failed.error).toMatchObject({ + type: CYBER_POLICY_ERROR_CODE, + code: CYBER_POLICY_ERROR_CODE, + message: REDACTED_CYBER_MESSAGE, + }); + expect(failed.retryable).toBe(false); + expect(JSON.stringify(failed)).not.toContain("cybersecret123456"); + + const buffered = buildResponseJSON([ + { type: "error", message: SECRET_CYBER_MESSAGE, retryable: true }, + ], "openai/gpt-5.4"); + expect(buffered).toMatchObject({ + status: "failed", + retryable: false, + error: { code: CYBER_POLICY_ERROR_CODE, message: REDACTED_CYBER_MESSAGE }, + }); + }); + + test("thrown cyber failures are redacted and explicitly non-retryable", async () => { + async function* throwingEvents(): AsyncGenerator { + throw new Error(SECRET_CYBER_MESSAGE); + } + const frames = await collectSse(bridgeToResponsesSSE(throwingEvents(), "openai/gpt-5.4")); + const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; + expect(failed).toMatchObject({ + status: "failed", + retryable: false, + error: { + type: CYBER_POLICY_ERROR_CODE, + code: CYBER_POLICY_ERROR_CODE, + message: REDACTED_CYBER_MESSAGE, + }, + }); + expect(JSON.stringify(failed)).not.toContain("cybersecret123456"); }); test("chat completions error envelope preserves cyber_policy and model_not_found", async () => { @@ -206,6 +319,14 @@ describe("cyber_policy error fidelity", () => { code: CYBER_POLICY_ERROR_CODE, }, }); + expect(chatCompletionsErrorBody(400, OPENAI_CYBER_MESSAGE)).toEqual({ + error: { + message: OPENAI_CYBER_MESSAGE, + type: CYBER_POLICY_ERROR_CODE, + param: null, + code: CYBER_POLICY_ERROR_CODE, + }, + }); expect(chatCompletionsErrorBody(404, "model not found", "invalid_request_error")).toEqual({ error: { message: "model not found", @@ -217,7 +338,7 @@ describe("cyber_policy error fidelity", () => { const response = chatCompletionsErrorResponse(502, OPENAI_CYBER_MESSAGE, "server_error"); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ - error: { code: CYBER_POLICY_ERROR_CODE, param: null }, + error: { type: "server_error", code: CYBER_POLICY_ERROR_CODE, param: null }, }); // Non-cyber classification must not rewrite HTTP status. const rateLimited = chatCompletionsErrorResponse(502, "Rate limit reached for model", "server_error"); @@ -236,7 +357,7 @@ describe("cyber_policy error fidelity", () => { response: { status: "failed", error: { - message: OPENAI_CYBER_MESSAGE, + message: SECRET_CYBER_MESSAGE, type: "invalid_request_error", code: CYBER_POLICY_ERROR_CODE, }, @@ -257,8 +378,9 @@ describe("cyber_policy error fidelity", () => { expect(errorFrame?.data.error).toMatchObject({ code: CYBER_POLICY_ERROR_CODE, type: "invalid_request_error", - message: OPENAI_CYBER_MESSAGE, + message: REDACTED_CYBER_MESSAGE, }); + expect(JSON.stringify(errorFrame?.data)).not.toContain("cybersecret123456"); }); test("openai-chat cyber_policy forces HTTP 400 even when upstream status is 5xx", async () => { @@ -302,3 +424,40 @@ describe("cyber_policy error fidelity", () => { })).toBe("stop"); }); }); + +describe("#2488 nested policy identity is not hidden by an outer envelope", () => { + /** + * normalizeUpstreamErrorText took the FIRST candidate carrying any string field, while + * isCyberPolicyBody scans EVERY candidate. A generic outer wrapper therefore won over a + * nested cyber_policy, so the failure read as an ordinary retryable upstream error - a retry + * across a safety boundary. Both detectors must agree on the same body. + */ + const nestedBody = JSON.stringify({ + error: { message: "Upstream request failed" }, + response: { + last_error: { + code: CYBER_POLICY_ERROR_CODE, + type: "policy_violation", + message: "blocked by policy", + }, + }, + }); + + test("a nested policy code is found behind a generic outer envelope", async () => { + const failure = await consumeComboFailure(new Response(nestedBody, { status: 502 })); + expect(failure.upstreamCode).toBe(CYBER_POLICY_ERROR_CODE); + expect(failure.response.status).toBe(400); + expect(failure.response.headers.get("retry-after")).toBeNull(); + }); + + test("a generic nested error keeps ordinary upstream classification", async () => { + const genericBody = JSON.stringify({ + error: { message: "Upstream request failed" }, + response: { last_error: { code: "server_error", message: "boom" } }, + }); + const failure = await consumeComboFailure(new Response(genericBody, { status: 502 })); + expect(failure.upstreamCode).not.toBe(CYBER_POLICY_ERROR_CODE); + expect(failure.response.status).toBe(502); + }); +}); + diff --git a/tests/helpers/owned-service-home-preload.ts b/tests/helpers/owned-service-home-preload.ts new file mode 100644 index 0000000000..5cae990dee --- /dev/null +++ b/tests/helpers/owned-service-home-preload.ts @@ -0,0 +1,65 @@ +/** + * Test-only Windows service-manager observation seam. + * + * The real admission path must keep asking the trusted System32 binaries. A + * child launched by `claimOwnedServiceHome` gets this preload explicitly, and + * only then are the read-only `/query` calls answered as an absent manager. + * No production module reads this flag or imports this file; all CLI/HTTP and + * admission code after the probe remains the real implementation. + */ +import { mock } from "bun:test"; +import childProcess from "node:child_process"; + +const ENABLED = process.platform === "win32" && process.env.OCX_TEST_SERVICE_HOME_PROBE === "1"; + +if (ENABLED) { + const realSpawnSync = childProcess.spawnSync; + + const fakeSpawnSync = ((...input: Parameters) => { + const [file, second, third] = input; + const args = Array.isArray(second) ? second : []; + const options = Array.isArray(second) ? third : second; + const name = typeof file === "string" + ? file.replaceAll("\\", "/").split("/").pop()?.toLowerCase() + : undefined; + // Keep this seam to the exact read-only argv emitted by production. A + // foreign task, a listing/error query, or extra arguments must reach the + // real manager instead of being silently declared absent. + const isSchedulerQuery = name === "schtasks.exe" + && args.length === 4 + && args[0] === "/query" + && args[1] === "/tn" + && args[2] === "opencodex-proxy" + && args[3] === "/xml"; + const isNativeServiceQuery = name === "sc.exe" + && args.length === 2 + && args[0] === "query" + && args[1] === "opencodex-proxy-native"; + + if (!isSchedulerQuery && !isNativeServiceQuery) return realSpawnSync(...input); + + const raw = typeof options === "object" + && options !== null + && "encoding" in options + && options.encoding === "buffer"; + const message = isNativeServiceQuery + ? "[OCX_TEST_SERVICE_HOME] [SC] OpenService FAILED 1060: The specified service does not exist." + : "[OCX_TEST_SERVICE_HOME] ERROR: The system cannot find the file specified."; + const stdout = raw ? Buffer.alloc(0) : ""; + const stderr = raw ? Buffer.from(message, "utf8") : message; + return { + status: 1, + signal: null, + output: [null, stdout, stderr], + pid: undefined, + error: undefined, + stdout, + stderr, + } as ReturnType; + }) as typeof realSpawnSync; + + // Bun's ESM namespace binding for `node:child_process` is immutable, while + // `mock.module` replaces the module before production imports its named + // `spawnSync` binding. Preserve every other child_process API verbatim. + mock.module("node:child_process", () => ({ ...childProcess, spawnSync: fakeSpawnSync })); +} diff --git a/tests/helpers/owned-service-home.ts b/tests/helpers/owned-service-home.ts index 6880ad15f8..21970a3466 100644 --- a/tests/helpers/owned-service-home.ts +++ b/tests/helpers/owned-service-home.ts @@ -1,9 +1,44 @@ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { delimiter, join, resolve } from "node:path"; export interface OwnedServiceHome { - /** Add this to child-process environments so Linux never reaches the host bus. */ + /** Add this only to child-process environments that also receive `preloadPath`. */ readonly env: Record; + /** Explicit Bun preload path; passed as argv so spaces are not shell-parsed. */ + readonly preloadPath?: string; +} + +const WINDOWS_SERVICE_PROBE_PRELOAD = resolve(import.meta.dir, "owned-service-home-preload.ts"); +const WINDOWS_SERVICE_PROBE_FLAG = "OCX_TEST_SERVICE_HOME_PROBE"; + +/** + * Insert a test preload into a Bun command without relying on BUN_OPTIONS. + * + * `bun run` consumes its flags after the `run` subcommand; direct `bun + * --eval`/file invocations consume them before the entrypoint. Keeping the + * path as a separate argv element is what makes a checkout directory with + * spaces safe on Bun 1.4 and on Windows child_process/Bun.spawn alike. + */ +export function withOwnedServiceHomePreload( + args: readonly string[], + preloadPath = WINDOWS_SERVICE_PROBE_PRELOAD, +): string[] { + if (process.platform !== "win32") return [...args]; + const preloadArgs = ["--preload", preloadPath]; + if (args[0] === "run" || args[0] === "test") { + return [args[0], ...preloadArgs, ...args.slice(1)]; + } + return [...preloadArgs, ...args]; +} + +function windowsServiceProbeEnv(): Record { + // The production Windows probe deliberately resolves schtasks.exe/sc.exe from + // System32, so PATH fixtures cannot isolate it. The flag is inert unless the + // caller also passes `preloadPath` through withOwnedServiceHomePreload; this + // keeps it out of the parent test environment and unrelated nested children. + return { + [WINDOWS_SERVICE_PROBE_FLAG]: "1", + }; } /** @@ -38,6 +73,9 @@ export function claimOwnedServiceHome( ].join("\n")); } + if (process.platform === "win32") { + return { env: windowsServiceProbeEnv(), preloadPath: WINDOWS_SERVICE_PROBE_PRELOAD }; + } if (process.platform !== "linux") return { env: {} }; const unitDir = join(home, ".config", "systemd", "user"); diff --git a/tests/helpers/windows-power-shell-fixture.ts b/tests/helpers/windows-power-shell-fixture.ts new file mode 100644 index 0000000000..d9bb26e0a6 --- /dev/null +++ b/tests/helpers/windows-power-shell-fixture.ts @@ -0,0 +1,95 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export interface WindowsPowerShellFixture { + executable: string; + cleanup: () => void | Promise; +} + +/** + * Build a real Windows executable for tests that exercise the default execFile path. + * + * A .cmd file is not a CreateProcess target, so Node/Bun's shell-free execFile rejects + * it with EINVAL on Windows. The compiled fixture consumes the same PowerShell-shaped + * argv as production, waits long enough for an interval to run, and emits deterministic + * rows for both the process and start-time queries. On POSIX, retain the small shell + * fixture because the production Windows branch is only reached after the platform is + * explicitly faked by the tests. + */ +export function createWindowsPowerShellFixture(): Promise { + // Each suite owns its fixture. Sharing one directory across suites lets the + // first cleanup remove the executable while another suite is still using it. + return process.platform === "win32" + ? buildWindowsExecutableFixture() + : Promise.resolve(createPosixShellFixture()); +} + +async function buildWindowsExecutableFixture(): Promise { + const dir = mkdtempSync(join(tmpdir(), "ocx-ps-fixture-")); + const source = join(dir, "fake-powershell.ts"); + const executable = join(dir, "fake-powershell.exe"); + writeFileSync(source, [ + "const command = process.argv.slice(2).join(' ');", + "await new Promise(resolve => setTimeout(resolve, 200));", + "if (command.includes('CreationDate')) {", + " process.stdout.write('42\\t1970-01-01T00:00:00.500Z\\n');", + "} else {", + " process.stdout.write('42\\t/usr/local/bin/codex app-server\\tCONTOSO\\\\jun\\n');", + "}", + ].join("\n")); + + const result = await Bun.build({ + entrypoints: [source], + compile: { target: "bun-windows-x64", outfile: executable }, + }); + if (!result.success) { + rmSync(dir, { recursive: true, force: true }); + const details = result.logs.map(log => log.message).join("\n"); + throw new Error(`Could not compile Windows PowerShell test fixture: ${details}`); + } + return { + executable, + cleanup: () => removeFixtureDirectory(dir), + }; +} + +function createPosixShellFixture(): WindowsPowerShellFixture { + const dir = mkdtempSync(join(tmpdir(), "ocx-ps-fixture-")); + const executable = join(dir, "fake-powershell.sh"); + writeFileSync(executable, [ + "#!/bin/sh", + "sleep 0.2", + "case \"$*\" in", + " *CreationDate*) printf '42\\t1970-01-01T00:00:00.500Z\\n' ;;", + " *) printf '42\\t/usr/local/bin/codex app-server\\tCONTOSO\\\\jun\\n' ;;", + "esac", + ].join("\n"), { mode: 0o755 }); + return { + executable, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +async function removeFixtureDirectory(dir: string): Promise { + // Windows can keep a just-exited compiled child image open for a short interval. + // Retry the temp cleanup so an antivirus/file-close race does not turn an otherwise + // passing test file into an unnamed afterAll failure. + const retryableCodes = new Set(["EBUSY", "EPERM", "EACCES"]); + let lastError: unknown; + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? (error as { code?: unknown }).code + : undefined; + if (typeof code !== "string" || !retryableCodes.has(code)) throw error; + lastError = error; + await Bun.sleep(50); + } + } + const detail = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Could not remove Windows PowerShell test fixture after 2s: ${detail}`); +} diff --git a/tests/lab-fabric-task.test.ts b/tests/lab-fabric-task.test.ts index b9b8c34422..7a160b8ef0 100644 --- a/tests/lab-fabric-task.test.ts +++ b/tests/lab-fabric-task.test.ts @@ -58,6 +58,7 @@ import { setFabricProducerIsolationLimitsForTests } from "../src/lab/fabric/prod import { taskSubjectApplicableToRequirements } from "../src/lab/projection/verification"; import { createHostIssuedFabricPatchExecutor } from "../src/lib/fabric-task-host"; import type { TrustedFabricPatchExecutor } from "../src/lab/fabric/types"; +import { watchdogMs } from "./helpers/ci-watchdog"; import { fabricCorrectPatchExecutor, fabricMockRoute, @@ -67,6 +68,23 @@ import { } from "./helpers/fabric-task-test"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const CHILD_REAP_GRACE_MS = 2_000; + +async function awaitChildExitWithin(child: Bun.Subprocess, timeoutMs: number): Promise { + return Promise.race([ + child.exited.then(() => true, () => true), + Bun.sleep(timeoutMs).then(() => false), + ]); +} + +async function terminateChildWithin(child: Bun.Subprocess): Promise { + if (child.exitCode !== null) return true; + try { child.kill(); } catch { /* already exited */ } + if (await awaitChildExitWithin(child, CHILD_REAP_GRACE_MS)) return true; + try { child.kill("SIGKILL"); } catch { /* already exited */ } + return await awaitChildExitWithin(child, CHILD_REAP_GRACE_MS); +} + const CREDENTIAL_CANARY = "credential-canary-abcdefghijklmnopqrstuvwxyz1234567890"; const FAST_FABRIC_ISOLATION = Object.freeze({ totalTimeoutMs: 2_000, @@ -332,6 +350,91 @@ describe("CL-07 task effectiveness producer", () => { expect(result.outcome.outcome).toBe("pass"); }); + test("producer child awaits an async executor before result and clean exit", async () => { + const childWatchdogMs = watchdogMs(5_000); + const home = tempHome(); + const childEntry = join(REPO_ROOT, "src", "lab", "fabric", "producer-child.ts"); + const executorModulePath = join(home, "async-producer-executor.mjs"); + const settledMarker = join(home, "async-producer-settled"); + writeFileSync(executorModulePath, ` +import { writeFileSync } from "node:fs"; + +export async function execute() { + await Bun.sleep(75); + writeFileSync(${JSON.stringify(settledMarker)}, "settled", "utf8"); + return { + schemaVersion: 1, + operations: [{ + op: "replace", + path: ${JSON.stringify(SYNTHETIC_VALUE_PATH)}, + contentUtf8: ${JSON.stringify(SYNTHETIC_AFTER_UTF8)}, + }], + }; +} +`); + + expect(readFileSync(childEntry, "utf8")).toMatch(/\bawait\s+main\(\)\.catch\(/); + + const child = Bun.spawn([process.execPath, "run", childEntry], { + cwd: REPO_ROOT, + env: { + TZ: "UTC", + NO_COLOR: "1", + OCX_FABRIC_SCRATCH_ROOT: home, + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + try { + child.stdin.write(JSON.stringify({ + executorModulePath, + executorInput: {}, + scratchRoot: home, + totalTimeoutMs: 5_000, + inactivityTimeoutMs: 2_000, + })); + child.stdin.end(); + } catch (error) { + await terminateChildWithin(child); + void stdoutPromise.catch(() => {}); + void stderrPromise.catch(() => {}); + throw error; + } + + const completed = await Promise.race([ + child.exited.then((exitCode) => ({ exitCode })), + Bun.sleep(childWatchdogMs).then(() => null), + ]); + if (!completed) { + const reaped = await terminateChildWithin(child); + void stdoutPromise.catch(() => {}); + const stderr = await Promise.race([ + stderrPromise.catch(() => ""), + Bun.sleep(CHILD_REAP_GRACE_MS).then(() => ""), + ]); + throw new Error(`timed out waiting for producer child (reaped=${reaped}): ${stderr}`); + } + + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + expect(completed.exitCode).toBe(0); + expect(stderr).toBe(""); + expect(existsSync(settledMarker)).toBe(true); + expect(stdout.trim().split(/\r?\n/).map((line) => JSON.parse(line))).toEqual([{ + type: "result", + patch: { + schemaVersion: 1, + operations: [{ + op: "replace", + path: SYNTHETIC_VALUE_PATH, + contentUtf8: SYNTHETIC_AFTER_UTF8, + }], + }, + }]); + }, { timeout: watchdogMs(5_000) + (3 * CHILD_REAP_GRACE_MS) + 1_000 }); + test("harness execution cannot be persisted as production evidence", async () => { const home = tempHome(); const result = await runFabricSyntheticPatchTaskHarness({ @@ -1081,4 +1184,4 @@ describe("CL-07 task effectiveness producer", () => { expect(text.includes("system prompt")).toBe(false); expect(text.includes(CREDENTIAL_CANARY)).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 5cb4217185..c295a25e27 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -3,8 +3,8 @@ * models are no longer v1-pinned by ocx, but legacy/v1-surface requests still need * the Proactive delegation prompt when they arrive with the synthetic top tier. */ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { injectDeveloperMessage, multiAgentGuidanceText, sanitizeEncryptedContentInPlace } from "../src/server/responses"; @@ -13,6 +13,7 @@ import type { OcxParsedRequest } from "../src/types"; import { CODEX_ACCOUNT_BOUND_CATALOG_KIND, effectiveSubagentRoster } from "../src/codex/catalog"; import { collectCodexAppServerCatalogState, resetCodexAppServerCatalogStateCache } from "../src/codex/app-server-processes"; import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; +import { createWindowsPowerShellFixture, type WindowsPowerShellFixture } from "./helpers/windows-power-shell-fixture"; import { clearDebugSettings, setDebugSettings } from "../src/lib/debug-settings"; import { getInjectionDebugLogEntries, @@ -39,6 +40,13 @@ afterAll(() => { else process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE = savedCatalogStateOverride; }); +let stallingFakePowerShell: WindowsPowerShellFixture; +beforeAll(async () => { + stallingFakePowerShell = await createWindowsPowerShellFixture(); +}); + +afterAll(() => stallingFakePowerShell?.cleanup()); + function codexHomeFixture(configToml: string): string { const dir = mkdtempSync(join(tmpdir(), "ocx-v1pin-")); mkdirSync(dir, { recursive: true }); @@ -135,17 +143,7 @@ describe("multiAgentGuidanceText", () => { // underlying process enumeration observable through the trusted-executable seam: // a stalling fake stands in for a slow CIM walk. The async request collector leaves // the loop free; the synchronous collector parks it. - const fakeDir = mkdtempSync(join(tmpdir(), "ocx-collab-ps-")); - // Platform-shaped: execFile takes no shell, so a POSIX script is not executable on - // Windows — the platform this fix targets, whose CI shard runs this suite. - const fake = join(fakeDir, process.platform === "win32" ? "fake-powershell.cmd" : "fake-powershell.sh"); - if (process.platform === "win32") { - writeFileSync(fake, ["@echo off", "ping -n 1 -w 200 192.0.2.1 >nul 2>&1"].join("\r\n")); - } else { - writeFileSync(fake, ["#!/bin/sh", "sleep 0.2", "printf ''"].join("\n")); - chmodSync(fake, 0o755); - } - setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); + setTrustedWindowsElevationExecutablesForTests({ powershell: stallingFakePowerShell.executable }); const realPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; Object.defineProperty(process, "platform", { value: "win32", configurable: true }); resetCodexAppServerCatalogStateCache(); @@ -162,18 +160,21 @@ describe("multiAgentGuidanceText", () => { // loop, so the flag cannot flip regardless of machine speed. let loopRanDuringExec = false; const beat = setInterval(() => { loopRanDuringExec = true; }, 5); + let guidance: Awaited>; try { - await multiAgentGuidanceText(parsed, { injectionModel: "anthropic/claude-sonnet-5" }); + guidance = await multiAgentGuidanceText(parsed, { injectionModel: "anthropic/claude-sonnet-5" }); } finally { clearInterval(beat); Object.defineProperty(process, "platform", realPlatform); setTrustedWindowsElevationExecutablesForTests(null); - rmSync(fakeDir, { recursive: true, force: true }); resetCodexAppServerCatalogStateCache(); process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE = "fresh"; } expect(loopRanDuringExec).toBe(true); + // The fixture's second child invocation returns a pre-catalog start time; the + // default request collector must parse it as stale and suppress positive guidance. + expect(guidance).toBeNull(); }); test("v2 guidance suppresses positive model claims while the app-server catalog is stale or unknown (#857)", async () => { diff --git a/tests/owned-service-home.test.ts b/tests/owned-service-home.test.ts new file mode 100644 index 0000000000..ea7ec6fe40 --- /dev/null +++ b/tests/owned-service-home.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { claimOwnedServiceHome, withOwnedServiceHomePreload } from "./helpers/owned-service-home"; + +const repoRoot = resolve(import.meta.dir, ".."); + +test("Windows owned-service-home fixture masks manager queries in a real child", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-owned-service-home-seam-")); + const codexHome = join(root, "codex"); + const opencodexHome = join(root, "opencodex"); + const home = join(root, "home"); + for (const path of [codexHome, opencodexHome, home]) mkdirSync(path, { recursive: true }); + + try { + const fixture = claimOwnedServiceHome(codexHome, opencodexHome, home); + if (process.platform !== "win32") return; + + // Copy the test preload below a path that contains spaces. Passing it as a + // separate argv element is the regression under test; BUN_OPTIONS tokenizes + // this same path before Bun 1.4 ever sees it. + const spacedCheckout = join(root, "checkout with spaces"); + mkdirSync(spacedCheckout, { recursive: true }); + const spacedPreload = join(spacedCheckout, "owned-service-home-preload.ts"); + copyFileSync(join(import.meta.dir, "helpers", "owned-service-home-preload.ts"), spacedPreload); + + const child = Bun.spawn([process.execPath, ...withOwnedServiceHomePreload(["--eval", ` + import { join } from "node:path"; + import { resolveTrustedWindowsSystemDirectory } from "./src/lib/windows-elevation.ts"; + import { spawnSync } from "node:child_process"; + import { inspectServiceManagerInstallation } from "./src/service-manager-probe.ts"; + const system = resolveTrustedWindowsSystemDirectory(); + const schtasks = join(system, "schtasks.exe"); + const sc = join(system, "sc.exe"); + const text = (value: unknown) => Buffer.isBuffer(value) ? value.toString("utf8") : String(value ?? ""); + const serviceProbe = (label: string, executable: string, args: string[]) => { + const probe = spawnSync(executable, args, { + encoding: "buffer", + timeout: 5_000, + windowsHide: true, + }); + // A timeout (or any spawn failure) is a failed probe, not an absent + // service. Keep the real pass-through result visible via status/stderr. + if (probe.error || probe.status === null) { + const detail = probe.error instanceof Error + ? probe.error.message + : String(probe.error ?? "no exit status"); + throw new Error(label + " failed: " + detail); + } + return probe; + }; + const exactScheduler = serviceProbe("exact scheduler", schtasks, ["/query", "/tn", "opencodex-proxy", "/xml"]); + const foreignScheduler = serviceProbe("foreign scheduler", schtasks, ["/query", "/tn", "foreign-opencodex-proxy", "/xml"]); + const extraScheduler = serviceProbe("extra scheduler", schtasks, ["/query", "/tn", "opencodex-proxy", "/xml", "/extra"]); + const exactNative = serviceProbe("exact native", sc, ["query", "opencodex-proxy-native"]); + const extraNative = serviceProbe("extra native", sc, ["query", "opencodex-proxy-native", "extra"]); + const result = inspectServiceManagerInstallation({ + platform: "win32", + home: process.env.USERPROFILE, + configDir: process.env.OPENCODEX_HOME, + }); + console.log(JSON.stringify({ + result, + exactScheduler: { status: exactScheduler.status, stderr: text(exactScheduler.stderr) }, + foreignScheduler: { status: foreignScheduler.status, stderr: text(foreignScheduler.stderr) }, + extraScheduler: { status: extraScheduler.status, stderr: text(extraScheduler.stderr) }, + exactNative: { status: exactNative.status, stderr: text(exactNative.stderr) }, + extraNative: { status: extraNative.status, stderr: text(extraNative.stderr) }, + })); + `], spacedPreload)], { + cwd: repoRoot, + env: { + ...process.env, + ...fixture.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + const payload = JSON.parse(stdout.trim()) as { + result: { kind: string }; + exactScheduler: { status: number | undefined; stderr: string }; + foreignScheduler: { status: number | undefined; stderr: string }; + extraScheduler: { status: number | undefined; stderr: string }; + exactNative: { status: number | undefined; stderr: string }; + extraNative: { status: number | undefined; stderr: string }; + }; + expect(payload.result).toEqual({ kind: "absent" }); + expect(payload.exactScheduler.status).toBe(1); + expect(payload.exactScheduler.stderr).toContain("OCX_TEST_SERVICE_HOME"); + expect(payload.foreignScheduler.stderr).not.toContain("OCX_TEST_SERVICE_HOME"); + expect(payload.extraScheduler.stderr).not.toContain("OCX_TEST_SERVICE_HOME"); + expect(payload.exactNative.status).toBe(1); + expect(payload.exactNative.stderr).toContain("OCX_TEST_SERVICE_HOME"); + expect(payload.extraNative.stderr).not.toContain("OCX_TEST_SERVICE_HOME"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/passthrough-abort.test.ts b/tests/passthrough-abort.test.ts index 113bd27f0c..1a6aae29cd 100644 --- a/tests/passthrough-abort.test.ts +++ b/tests/passthrough-abort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { linkAbortSignal, relaySseWithHeartbeat, relayWithAbort } from "../src/server"; +import { consumeForInspection, linkAbortSignal, relaySseWithFailedTail, relaySseWithHeartbeat, relayWithAbort } from "../src/server"; const root = new URL("../", import.meta.url); @@ -17,6 +17,16 @@ function streamFromChunks(chunks: Uint8Array[]): ReadableStream { }); } +function joinBytes(parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} + async function readAll(stream: ReadableStream): Promise { const reader = stream.getReader(); const dec = new TextDecoder(); @@ -175,6 +185,345 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { expect(terminals).toEqual(["failed"]); }); + test("tee/pull relay clean EOF synthesizes one adapter_eof incomplete and one DONE", async () => { + const enc = new TextEncoder(); + const relayed = relaySseWithFailedTail(streamFromChunks([ + enc.encode('event: response.created\ndata: {"type":"response.created"}\n\n'), + ]), new AbortController()); + const text = await readAll(relayed); + expect(text.match(/event: response\.incomplete/g)?.length).toBe(1); + expect(text).toContain('"reason":"adapter_eof"'); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + }); + + test("tee/pull relay suppresses a premature DONE before the synthetic terminal", async () => { + const enc = new TextEncoder(); + const relayed = relaySseWithFailedTail(streamFromChunks([ + enc.encode('event: response.created\ndata: {"type":"response.created"}\n\ndata: [DONE]\n\n'), + ]), new AbortController()); + const text = await readAll(relayed); + expect(text.match(/event: response\.incomplete/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text.indexOf("response.incomplete")).toBeLessThan(text.indexOf("data: [DONE]")); + }); + + test("tee/pull relay rewrites policy incomplete and top-level error to failed", async () => { + const enc = new TextEncoder(); + for (const frame of [ + `event: response.incomplete\ndata: ${JSON.stringify({ + type: "response.incomplete", + response: { + status: "incomplete", + error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked" }, + }, + })}\n\n`, + `event: error\ndata: ${JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked" }, + })}\n\n`, + ]) { + const relayed = relaySseWithFailedTail(streamFromChunks([enc.encode(frame)]), new AbortController()); + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).not.toContain("response.incomplete"); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).toContain('"type":"invalid_request_error"'); + expect(text).toContain('"code":"cyber_policy"'); + } + }); + + test("tee/pull normalizes structured policy response.failed while preserving metadata", async () => { + const enc = new TextEncoder(); + const payload = JSON.stringify({ + type: "response.failed", + sequence_number: 19, + model: "gpt-policy", + response: { + id: "resp-structured-policy-pull", + output: [{ type: "message", id: "item-structured-policy-pull" }], + status: "failed", + error: { + type: "server_error", + code: "cyber_policy", + message: `blocked by upstream policy Authorization: ${["Bear", "er"].join("")} relaypullsecret123456`, + }, + }, + }); + const relayed = relaySseWithFailedTail(streamFromChunks([ + enc.encode(`event: response.failed\ndata: ${payload}\n\n`), + ]), new AbortController()); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).toContain('"type":"server_error"'); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain("Authorization: Bearer [REDACTED]"); + expect(text).not.toContain("relaypullsecret123456"); + expect(text).toContain('"sequence_number":19'); + expect(text).toContain('"model":"gpt-policy"'); + expect(text).toContain('"id":"resp-structured-policy-pull"'); + expect(text).toContain('"output":[{"type":"message","id":"item-structured-policy-pull"}]'); + }); + + test("tee/pull preserves a policy error before same-chunk frame-count overflow", async () => { + const enc = new TextEncoder(); + const policy = JSON.stringify({ + type: "error", + sequence_number: 24, + response: { + id: "resp-policy-pull-frame-count", + output: [{ type: "message", id: "item-policy-pull-frame-count" }], + status: "failed", + }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }); + const relayed = relaySseWithFailedTail(streamFromChunks([ + enc.encode(`event: error\ndata: ${policy}\n\n${"\n\n".repeat(4096)}`), + ]), new AbortController()); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).not.toContain("upstream_reset"); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain('"sequence_number":24'); + expect(text).toContain('"id":"resp-policy-pull-frame-count"'); + expect(text).toContain('"output":[{"type":"message","id":"item-policy-pull-frame-count"}]'); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + }); + + test("tee/pull preserves a policy error before same-chunk oversized trailing bytes", async () => { + const enc = new TextEncoder(); + const policy = JSON.stringify({ + type: "error", + sequence_number: 26, + response: { id: "resp-policy-pull-byte-overflow", output: [], status: "failed" }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }); + const oversizedTail = new Uint8Array(4 * 1024 * 1024 + 1).fill(120); + const relayed = relaySseWithFailedTail(streamFromChunks([joinBytes([ + enc.encode(`event: error\ndata: ${policy}\n\n`), + oversizedTail, + ])]), new AbortController()); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).not.toContain("upstream_reset"); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain('"sequence_number":26'); + expect(text).toContain('"id":"resp-policy-pull-byte-overflow"'); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + }); + + test("tee/pull parses each unframed EOF terminal once without adapter_eof", async () => { + const enc = new TextEncoder(); + const cases = [ + { + type: "response.completed", + event: "response.completed", + payload: { + type: "response.completed", + sequence_number: 41, + response: { id: "resp-pull-unframed-completed", status: "completed", output: [] }, + }, + }, + { + type: "response.failed", + event: "response.failed", + payload: { + type: "response.failed", + sequence_number: 42, + response: { id: "resp-pull-unframed-failed", status: "failed", output: [] }, + }, + }, + { + type: "response.incomplete", + event: "response.incomplete", + payload: { + type: "response.incomplete", + sequence_number: 43, + response: { id: "resp-pull-unframed-incomplete", status: "incomplete", output: [] }, + }, + }, + { + type: "error", + event: "response.failed", + payload: { + type: "error", + sequence_number: 44, + response: { + id: "resp-pull-unframed-policy", + output: [{ type: "message", id: "item-pull-unframed-policy" }], + status: "failed", + }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }, + }, + ] as const; + + for (const fixture of cases) { + const relayed = relaySseWithFailedTail(streamFromChunks([ + enc.encode(`event: ${fixture.type}\ndata: ${JSON.stringify(fixture.payload)}`), + ]), new AbortController()); + const text = await readAll(relayed); + expect(text.match(/event: response\.(?:completed|failed|incomplete)/g)?.length).toBe(1); + expect(text).toContain(`event: ${fixture.event}`); + expect(text).not.toContain('"reason":"adapter_eof"'); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + } + }); + + test("tee/pull preserves an unframed terminal before a reader error", async () => { + const enc = new TextEncoder(); + const cases = [ + { + type: "response.completed", + event: "response.completed", + payload: { + type: "response.completed", + sequence_number: 51, + response: { id: "resp-read-error-completed", status: "completed", output: [] }, + }, + }, + { + type: "response.failed", + event: "response.failed", + payload: { + type: "response.failed", + sequence_number: 52, + response: { id: "resp-read-error-failed", status: "failed", output: [] }, + }, + }, + { + type: "response.incomplete", + event: "response.incomplete", + payload: { + type: "response.incomplete", + sequence_number: 53, + response: { id: "resp-read-error-incomplete", status: "incomplete", output: [] }, + }, + }, + { + type: "error", + event: "response.failed", + payload: { + type: "error", + sequence_number: 54, + response: { + id: "resp-read-error-policy", + output: [{ type: "message", id: "item-read-error-policy" }], + status: "failed", + }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }, + }, + ] as const; + + for (const fixture of cases) { + let firstPull = true; + const body = new ReadableStream({ + pull(controller) { + if (firstPull) { + firstPull = false; + controller.enqueue(enc.encode(`event: ${fixture.type}\ndata: ${JSON.stringify(fixture.payload)}`)); + } else { + controller.error(new Error("socket reset after unframed terminal")); + } + }, + }); + const relayed = relaySseWithFailedTail(body, new AbortController()); + const text = await readAll(relayed); + expect(text.match(/event: response\.(?:completed|failed|incomplete)/g)?.length).toBe(1); + expect(text).toContain(`event: ${fixture.event}`); + expect(text).not.toContain("upstream_reset"); + expect(text).not.toContain('"reason":"adapter_eof"'); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + if (fixture.type === "error") { + expect(text).toContain('"sequence_number":54'); + expect(text).toContain('"id":"resp-read-error-policy"'); + expect(text).toContain('"output":[{"type":"message","id":"item-read-error-policy"}]'); + } + } + }); + + test("tee/pull keeps an ordinary top-level error fail-closed on reader error", async () => { + let firstPull = true; + const body = new ReadableStream({ + pull(controller) { + if (firstPull) { + firstPull = false; + controller.enqueue(new TextEncoder().encode( + `event: error\ndata: ${JSON.stringify({ + type: "error", + error: { type: "server_error", code: "upstream_error", message: "provider failed" }, + })}`, + )); + } else { + controller.error(new Error("socket reset after ordinary error")); + } + }, + }); + const relayed = relaySseWithFailedTail(body, new AbortController()); + const text = await readAll(relayed); + expect(text).toContain("event: error"); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).toContain('"code":"upstream_reset"'); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).not.toContain('"code":"cyber_policy"'); + }); + + test("inspection read-error flush records an unframed policy terminal as failed 400", async () => { + let firstPull = true; + const body = new ReadableStream({ + pull(controller) { + if (firstPull) { + firstPull = false; + controller.enqueue(new TextEncoder().encode( + `event: error\ndata: ${JSON.stringify({ + type: "error", + sequence_number: 55, + response: { id: "resp-inspection-read-error-policy", output: [], status: "failed" }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + })}`, + )); + } else { + controller.error(new Error("socket reset after inspection policy terminal")); + } + }, + }); + const terminals: Array<{ status: string; httpStatus?: number }> = []; + let done!: () => void; + const completed = new Promise(resolve => { done = resolve; }); + consumeForInspection( + body, + (status, httpStatus) => terminals.push({ status, ...(httpStatus === undefined ? {} : { httpStatus }) }), + undefined, + done, + ); + await completed; + expect(terminals).toEqual([{ status: "failed", httpStatus: 400 }]); + }); + test("SSE passthrough reports incomplete on EOF before a terminal payload", async () => { const enc = new TextEncoder(); const ac = new AbortController(); diff --git a/tests/relay-eager.test.ts b/tests/relay-eager.test.ts index 22bbef6971..f3cfbd91c7 100644 --- a/tests/relay-eager.test.ts +++ b/tests/relay-eager.test.ts @@ -5,10 +5,16 @@ * via the injectable clock/short drain windows. */ import { describe, expect, test } from "bun:test"; -import { createSseInspector, MAX_TAIL_ERROR_MESSAGE_CHARS } from "../src/server/relay"; +import { + adapterEofIncompleteFrame, + createSseInspector, + doneFrame, + MAX_TAIL_ERROR_MESSAGE_CHARS, +} from "../src/server/relay"; import { relaySseEagerBounded, type EagerRelayHooks } from "../src/server/relay-eager"; import { createTranslatorBudget } from "../src/lib/translator-budget"; import type { RequestLogContext } from "../src/server/request-log"; +import { MAX_CLIENT_SSE_FRAME_BYTES } from "../src/server/sse-frame-buffer"; import { watchdogMs } from "./helpers/ci-watchdog"; const enc = new TextEncoder(); @@ -27,7 +33,11 @@ function countOccurrences(text: string, needle: string): number { function failedPayload(text: string): { type: string; - response: { status: string; error: { code: string; message: string } }; + response: { + status: string; + error: { code: string; message: string }; + incomplete_details?: unknown; + }; } { const payload = text.split(FAILED_EVENT_MARKER)[1]?.split("\n")[0]; if (!payload) throw new Error("missing response.failed payload"); @@ -264,41 +274,47 @@ describe("relaySseEagerBounded — inline payload rewrite (#864)", () => { up.close(); const text = await reading; - expect(text).toBe("data: �"); + expect(text.startsWith("data: �")).toBe(true); + expect(text).toContain('"reason":"adapter_eof"'); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); }); test("terminal framing keeps partial blocks out of the rewrite budget", async () => { const budget = createTranslatorBudget(); const up = controlledUpstream(); const ac = new AbortController(); - const { hooks, rec } = makeHooks(); - let resolveDone!: () => void; - const done = new Promise(resolve => { resolveDone = resolve; }); - const previousOnDone = hooks.onDone; - hooks.onDone = () => { - previousOnDone(); - resolveDone(); - }; - hooks.rewritePayload = (payload: string) => payload; - relaySseEagerBounded(up.stream, ac, hooks, { rewriteBudget: budget }); - - up.push(enc.encode(`data: {"type":"unterminated"`)); - // The shared terminal boundary now owns incomplete SSE framing, so the - // downstream rewrite stage never retains an unterminated block. - expect(budget.snapshot().currentBytes).toBe(0); - ac.abort(new Error("test abort")); - let timeout: ReturnType | undefined; - await Promise.race([ - done, - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error("relay cleanup timed out")), watchdogMs(2_000)); - }), - ]).finally(() => { - if (timeout) clearTimeout(timeout); - }); - expect(budget.snapshot().currentBytes).toBe(0); - expect(rec.dones).toBe(1); - budget.dispose(); + try { + const { hooks, rec } = makeHooks(); + let resolveDone!: () => void; + const done = new Promise(resolve => { resolveDone = resolve; }); + const previousOnDone = hooks.onDone; + hooks.onDone = () => { + previousOnDone(); + resolveDone(); + }; + hooks.rewritePayload = (payload: string) => payload; + relaySseEagerBounded(up.stream, ac, hooks, { rewriteBudget: budget }); + + up.push(enc.encode(`data: {"type":"unterminated"`)); + // The shared terminal boundary now owns incomplete SSE framing, so the + // downstream rewrite stage never retains an unterminated block. + expect(budget.snapshot().currentBytes).toBe(0); + ac.abort(new Error("test abort")); + let timeout: ReturnType | undefined; + await Promise.race([ + done, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("relay cleanup timed out")), watchdogMs(2_000)); + }), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + }); + expect(budget.snapshot().currentBytes).toBe(0); + expect(rec.dones).toBe(1); + } finally { + ac.abort(new Error("test cleanup")); + budget.dispose(); + } }); test("blocks without a data field pass through untouched before the terminal", async () => { @@ -384,6 +400,584 @@ describe("relaySseEagerBounded — side-effect parity", () => { expect(rec.dones).toBe(1); }); + test("clean EOF emits one adapter_eof incomplete terminal and one DONE", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(sse(DELTA)); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.incomplete/g)?.length).toBe(1); + expect(text).toContain('"reason":"adapter_eof"'); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.synthetics).toEqual(["incomplete"]); + expect(rec.terminals).toEqual([]); + }); + + test.each([ + [ + "completed", + "response.completed", + "response.completed", + { + type: "response.completed", + sequence_number: 31, + response: { id: "resp-unframed-completed", status: "completed", output: [] }, + }, + { status: "completed" }, + ], + [ + "failed", + "response.failed", + "response.failed", + { + type: "response.failed", + sequence_number: 32, + response: { id: "resp-unframed-failed", status: "failed", output: [] }, + }, + { status: "failed" }, + ], + [ + "incomplete", + "response.incomplete", + "response.incomplete", + { + type: "response.incomplete", + sequence_number: 33, + response: { id: "resp-unframed-incomplete", status: "incomplete", output: [] }, + }, + { status: "incomplete" }, + ], + [ + "policy error", + "error", + "response.failed", + { + type: "error", + sequence_number: 34, + response: { + id: "resp-unframed-policy", + output: [{ type: "message", id: "item-unframed-policy" }], + status: "failed", + }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }, + { status: "failed", httpStatus: 400 }, + ], + ] as const)("unframed EOF %s is parsed once without an adapter_eof duplicate", async ( + _name, + upstreamEvent, + clientEvent, + payload, + expectedTerminal, + ) => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: ${upstreamEvent}\ndata: ${JSON.stringify(payload)}`)); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.(?:completed|failed|incomplete)/g)?.length).toBe(1); + expect(text).toContain(`event: ${clientEvent}`); + expect(text).not.toContain('"reason":"adapter_eof"'); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.terminals).toEqual([expectedTerminal]); + expect(rec.synthetics).toEqual([]); + }); + + test("policy response.incomplete is rewritten to one failed terminal with 400 accounting", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: response.incomplete\ndata: ${JSON.stringify({ + type: "response.incomplete", + sequence_number: 7, + response: { + id: "resp-policy", + output: [{ type: "message", id: "item-1" }], + status: "incomplete", + incomplete_details: { reason: "content_filter" }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }, + })}\n\n`)); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).not.toContain("response.incomplete"); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain('"type":"invalid_request_error"'); + expect(text).toContain('"id":"resp-policy"'); + expect(text).toContain('"sequence_number":7'); + expect(text).toContain('"output":[{"type":"message","id":"item-1"}]'); + expect(failedPayload(text).response.incomplete_details).toBeUndefined(); + expect(text).not.toContain("content_filter"); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.terminals).toEqual([{ status: "failed", httpStatus: 400 }]); + expect(rec.synthetics).toEqual([]); + }); + + test("policy leading error frame is rewritten and stops a clean EOF without a duplicate", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: error\ndata: ${JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "policy blocked this request", + }, + })}\n\n`)); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).not.toContain("event: error"); + expect(text).not.toContain("response.incomplete"); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.terminals).toEqual([{ status: "failed", httpStatus: 400 }]); + expect(rec.synthetics).toEqual([]); + }); + + test("structured policy response.failed stays failed, normalizes to cyber_policy, and preserves metadata", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + const payload = JSON.stringify({ + type: "response.failed", + sequence_number: 18, + model: "gpt-policy", + retryable: true, + response: { + id: "resp-structured-policy-eager", + output: [{ type: "message", id: "item-structured-policy-eager" }], + status: "failed", + retryable: true, + error: { + type: "server_error", + code: "cyber_policy", + message: `blocked by upstream policy Authorization: ${["Bear", "er"].join("")} relayeagersecret123456`, + }, + }, + }); + const framed = enc.encode(`event: response.failed\r\ndata: ${payload}\r\n\r\n`); + up.push(framed.subarray(0, framed.byteLength - 1)); + up.push(framed.subarray(framed.byteLength - 1)); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).toContain('"type":"server_error"'); + expect(text).toContain('"code":"cyber_policy"'); + expect(text.match(/"retryable":false/g)?.length).toBe(2); + expect(text).not.toContain('"retryable":true'); + expect(text).toContain("Authorization: Bearer [REDACTED]"); + expect(text).not.toContain("relayeagersecret123456"); + expect(text).toContain('"sequence_number":18'); + expect(text).toContain('"model":"gpt-policy"'); + expect(text).toContain('"id":"resp-structured-policy-eager"'); + expect(text).toContain('"output":[{"type":"message","id":"item-structured-policy-eager"}]'); + expect(rec.terminals).toEqual([{ status: "failed", httpStatus: 400 }]); + expect(rec.synthetics).toEqual([]); + }); + + test("ordinary response.failed remains unchanged", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: response.failed\ndata: ${JSON.stringify({ + type: "response.failed", + response: { + id: "resp-ordinary-failed", + status: "failed", + error: { type: "server_error", code: "upstream_error", message: "provider failed" }, + }, + })}\n\n`)); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).toContain('"code":"upstream_error"'); + expect(text).not.toContain('"code":"cyber_policy"'); + expect(rec.terminals).toEqual([{ status: "failed" }]); + expect(rec.synthetics).toEqual([]); + }); + + test("policy error survives same-chunk frame-count overflow without an upstream reset", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + const policy = JSON.stringify({ + type: "error", + sequence_number: 23, + response: { + id: "resp-policy-frame-count", + output: [{ type: "message", id: "item-policy-frame-count" }], + status: "failed", + }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }); + const frameLimit = Math.ceil(MAX_CLIENT_SSE_FRAME_BYTES / 1024); + // The client framer derives its per-feed frame cap from the byte cap. The + // policy terminal is first, followed by a full cap of empty blocks, which used to + // turn the already-committed policy response into an upstream_reset 502. + up.push(enc.encode(`event: error\ndata: ${policy}\n\n${"\n\n".repeat(frameLimit)}`)); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).not.toContain("upstream_reset"); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain('"sequence_number":23'); + expect(text).toContain('"id":"resp-policy-frame-count"'); + expect(text).toContain('"output":[{"type":"message","id":"item-policy-frame-count"}]'); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.terminals).toEqual([{ status: "failed", httpStatus: 400 }]); + expect(rec.synthetics).toEqual([]); + }); + + test("policy error also survives same-chunk oversized trailing bytes", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + const policy = JSON.stringify({ + type: "error", + sequence_number: 25, + response: { id: "resp-policy-byte-overflow", output: [], status: "failed" }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }); + const oversizedTail = new Uint8Array(MAX_CLIENT_SSE_FRAME_BYTES + 1).fill(120); + up.push(joinBytes([ + enc.encode(`event: error\ndata: ${policy}\n\n`), + oversizedTail, + ])); + up.close(); + + const text = await readAll(relayed); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).not.toContain("upstream_reset"); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain('"id":"resp-policy-byte-overflow"'); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.terminals).toEqual([{ status: "failed", httpStatus: 400 }]); + }); + + test("oversized nonterminal before a same-chunk terminal emits one client fallback", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const upstreamAc = new AbortController(); + const relayed = relaySseEagerBounded(up.stream, upstreamAc, hooks); + const oversizedNonterminal = new Uint8Array(MAX_CLIENT_SSE_FRAME_BYTES + 1).fill(120); + up.push(joinBytes([oversizedNonterminal, enc.encode("\n\n"), sse(COMPLETED)])); + + const text = await readAll(relayed); + await settle(); + expect(countOccurrences(text, "event: response.failed")).toBe(1); + expect(countOccurrences(text, "event: response.completed")).toBe(0); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(text).toContain('"code":"upstream_reset"'); + expect(rec.terminals).toEqual([{ status: "completed" }]); + expect(rec.synthetics).toEqual([]); + expect(rec.dones).toBe(1); + expect(upstreamAc.signal.aborted).toBe(true); + }); + + test("inspector-only terminal cannot suppress the oversized-frame client fallback", async () => { + const inspector = createSseInspector({}); + const synthetics: string[] = []; + let doneCount = 0; + const hooks: EagerRelayHooks = { + inspectChunk: chunk => inspector.feed(chunk), + finishInspection: () => inspector.finish(), + disposeInspection: () => inspector.dispose(), + sawTerminal: () => inspector.terminalSeen(), + onSynthetic: kind => synthetics.push(kind), + onClientCancel() {}, + onDone: () => { doneCount += 1; }, + }; + const up = controlledUpstream(); + const upstreamAc = new AbortController(); + const relayed = relaySseEagerBounded(up.stream, upstreamAc, hooks); + const oversizedNonterminal = new Uint8Array(MAX_CLIENT_SSE_FRAME_BYTES + 1).fill(120); + up.push(joinBytes([oversizedNonterminal, enc.encode("\n\n"), sse(COMPLETED)])); + + const text = await readAll(relayed); + await settle(); + expect(inspector.terminalSeen()).toBe(true); + expect(inspector.reported()).toBe(false); + expect(countOccurrences(text, "event: response.failed")).toBe(1); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(synthetics).toEqual([]); + expect(doneCount).toBe(1); + expect(upstreamAc.signal.aborted).toBe(true); + }); + + const unframedReadErrorCases = [ + { + label: "completed", + type: "response.completed", + event: "response.completed", + status: "completed", + payload: { + type: "response.completed", + sequence_number: 61, + response: { id: "resp-eager-read-error-completed", status: "completed", output: [] }, + }, + }, + { + label: "failed", + type: "response.failed", + event: "response.failed", + status: "failed", + payload: { + type: "response.failed", + sequence_number: 62, + response: { id: "resp-eager-read-error-failed", status: "failed", output: [] }, + }, + }, + { + label: "incomplete", + type: "response.incomplete", + event: "response.incomplete", + status: "incomplete", + payload: { + type: "response.incomplete", + sequence_number: 63, + response: { id: "resp-eager-read-error-incomplete", status: "incomplete", output: [] }, + }, + }, + { + label: "policy error", + type: "error", + event: "response.failed", + status: "failed", + payload: { + type: "error", + sequence_number: 64, + response: { + id: "resp-eager-read-error-policy", + output: [{ type: "message", id: "item-eager-read-error-policy" }], + status: "failed", + }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + }, + httpStatus: 400, + }, + ] as const; + + test.each(unframedReadErrorCases)( + "eager preserves an unframed $label terminal before a reader error", + async (fixture) => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: ${fixture.type}\ndata: ${JSON.stringify(fixture.payload)}`)); + up.fail(new Error("socket reset after unframed terminal")); + + const text = await readAll(relayed); + expect(text.match(/event: response\.(?:completed|failed|incomplete)/g)?.length).toBe(1); + expect(text).toContain(`event: ${fixture.event}`); + expect(text).not.toContain("upstream_reset"); + expect(text).not.toContain('"reason":"adapter_eof"'); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.terminals).toEqual([{ status: fixture.status, ...(fixture.httpStatus ? { httpStatus: fixture.httpStatus } : {}) }]); + expect(rec.synthetics).toEqual([]); + if (fixture.type === "error") { + expect(text).toContain('"sequence_number":64'); + expect(text).toContain('"id":"resp-eager-read-error-policy"'); + expect(text).toContain('"output":[{"type":"message","id":"item-eager-read-error-policy"}]'); + } + }, + ); + + test("eager keeps an ordinary top-level error fail-closed on reader error", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: error\ndata: ${JSON.stringify({ + type: "error", + error: { type: "server_error", code: "upstream_error", message: "provider failed" }, + })}`)); + up.fail(new Error("socket reset after ordinary error")); + + const text = await readAll(relayed); + expect(text).toContain("event: error"); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).toContain('"code":"upstream_reset"'); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).not.toContain('"code":"cyber_policy"'); + expect(rec.terminals).toEqual([]); + expect(rec.synthetics).toEqual(["failed"]); + }); + + test("eager read-error terminal flushes the active rewrite tail", async () => { + const { hooks, rec } = makeHooks(); + hooks.rewritePayload = payload => payload.replace("rewrite-me", "rewritten"); + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { id: "resp-read-error-rewrite", status: "completed", output: [{ type: "message", text: "rewrite-me" }] }, + })}`)); + up.fail(new Error("socket reset after rewritten terminal")); + + const text = await readAll(relayed); + expect(text).toContain("rewritten"); + expect(text).not.toContain("rewrite-me"); + expect(text.match(/event: response\.completed/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).not.toContain("upstream_reset"); + expect(rec.terminals).toEqual([{ status: "completed" }]); + expect(rec.synthetics).toEqual([]); + }); + + test("eager rewriteBlocks failure emits one safe failed envelope without a second outcome", async () => { + const { hooks, rec } = makeHooks(); + hooks.rewriteBlocks = () => { throw new Error("rewrite callback failed"); }; + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + sequence_number: 71, + response: { + id: "resp-rewrite-failure-secret", + status: "completed", + output: [{ type: "message", text: "raw-secret-content" }], + }, + })}`)); + up.fail(new Error("socket reset after rewrite failure")); + + const text = await readAll(relayed); + expect(text.length).toBeGreaterThan(0); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).toContain('"code":"upstream_reset"'); + expect(text).not.toContain("resp-rewrite-failure-secret"); + expect(text).not.toContain("raw-secret-content"); + expect(text).not.toContain('"sequence_number":71'); + expect(rec.terminals).toEqual([{ status: "completed" }]); + expect(rec.synthetics).toEqual([]); + expect(rec.dones).toBe(1); + }); + + test("terminal rewrite fallback aborts and cancels a still-open upstream", async () => { + let sourceCancels = 0; + let terminalSent = false; + const source = new ReadableStream({ + pull(controller) { + if (terminalSent) return; + terminalSent = true; + controller.enqueue(sse(COMPLETED)); + }, + cancel() { sourceCancels += 1; }, + }, { highWaterMark: 0 }); + const { hooks, rec } = makeHooks(); + hooks.rewriteBlocks = () => { throw new Error("terminal rewrite failed"); }; + const upstreamAc = new AbortController(); + const relayed = relaySseEagerBounded(source, upstreamAc, hooks); + + const text = await readAll(relayed); + await settle(); + expect(countOccurrences(text, "event: response.failed")).toBe(1); + expect(countOccurrences(text, "data: [DONE]")).toBe(1); + expect(rec.terminals).toEqual([{ status: "completed" }]); + expect(rec.synthetics).toEqual([]); + expect(rec.dones).toBe(1); + expect(upstreamAc.signal.aborted).toBe(true); + expect(sourceCancels).toBe(1); + }); + + test("eager rewrite-budget exhaustion emits one bounded failed envelope and releases the budget", async () => { + const budget = createTranslatorBudget({ maxTurnBytes: 1 }); + const upstreamAc = new AbortController(); + try { + const { hooks, rec } = makeHooks(); + hooks.rewritePayload = payload => payload; + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, upstreamAc, hooks, { rewriteBudget: budget }); + up.push(enc.encode(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + sequence_number: 72, + response: { + id: "resp-budget-failure-secret", + status: "completed", + output: [{ type: "message", text: "raw-budget-secret" }], + }, + })}`)); + up.fail(new Error("socket reset after budget failure")); + + const text = await readAll(relayed); + expect(text.length).toBeGreaterThan(0); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).toContain('"code":"translation_buffer_limit"'); + expect(text).not.toContain("resp-budget-failure-secret"); + expect(text).not.toContain("raw-budget-secret"); + expect(text).not.toContain('"sequence_number":72'); + expect(rec.terminals).toEqual([{ status: "completed" }]); + expect(rec.synthetics).toEqual([]); + expect(rec.dones).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + upstreamAc.abort(new Error("test cleanup")); + budget.dispose(); + } + }); + + test("eager clean EOF rewrite failure emits one safe failed envelope", async () => { + const { hooks, rec } = makeHooks(); + hooks.rewriteBlocks = () => { throw new Error("clean EOF rewrite callback failed"); }; + const up = controlledUpstream(); + const relayed = relaySseEagerBounded(up.stream, new AbortController(), hooks); + up.push(enc.encode(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + sequence_number: 73, + response: { + id: "resp-clean-eof-rewrite-secret", + status: "completed", + output: [{ type: "message", text: "clean-eof-raw-secret" }], + }, + })}`)); + up.close(); + + const text = await readAll(relayed); + expect(text.length).toBeGreaterThan(0); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(text).toContain('"code":"upstream_reset"'); + expect(text).not.toContain("resp-clean-eof-rewrite-secret"); + expect(text).not.toContain("clean-eof-raw-secret"); + expect(text).not.toContain('"sequence_number":73'); + expect(rec.terminals).toEqual([{ status: "completed" }]); + expect(rec.synthetics).toEqual([]); + expect(rec.dones).toBe(1); + }); + test("eager relay backfills missing completed output before passthrough persistence (#334)", async () => { const { hooks, rec } = makeHooks(); const up = controlledUpstream(); @@ -464,7 +1058,80 @@ describe("relaySseEagerBounded — bounded queue", () => { if (done) break; total += value.byteLength; } - expect(total).toBe(36); + // The unterminated client block is made dispatchable with one blank-line + // delimiter, then clean EOF adds exactly one adapter_eof terminal and DONE. + expect(total).toBe( + 3 * chunk.byteLength + + enc.encode("\n\n").byteLength + + adapterEofIncompleteFrame(enc).byteLength + + doneFrame(enc).byteLength, + ); + }); + + test("(b2) pause resolver rechecks an abort that wins before installation", async () => { + const { hooks, rec } = makeHooks(); + const up = controlledUpstream(); + const realUpstream = new AbortController(); + let abortOnNextSignalCheck = false; + let predicateAbortTriggered = false; + const signal = new Proxy(realUpstream.signal, { + get(target, property) { + if (property === "aborted" && abortOnNextSignalCheck) { + abortOnNextSignalCheck = false; + predicateAbortTriggered = true; + realUpstream.abort(new Error("deterministic pause interleave")); + // Return the pre-abort value for this predicate evaluation. The abort + // event has already fired, so the resolver installed by paused() must + // observe the new value on its post-install check. + return false; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const upstream = { + signal, + abort: (reason?: unknown) => realUpstream.abort(reason), + } as unknown as AbortController; + + let resolveDone!: () => void; + const done = new Promise(resolve => { resolveDone = resolve; }); + const previousOnDone = hooks.onDone; + hooks.onDone = () => { + previousOnDone(); + resolveDone(); + }; + hooks.rewritePayload = payload => { + // This callback runs after the chunk is read and before the bounded-queue + // predicate. The proxy then aborts exactly while that predicate is being + // evaluated, before paused() installs its resolver. + abortOnNextSignalCheck = true; + return payload; + }; + + const relayed = relaySseEagerBounded(up.stream, upstream, hooks, { maxQueueBytes: 1 }); + const reader = relayed.getReader(); + up.push(sse(DELTA)); + + let timeout: ReturnType | undefined; + try { + await Promise.race([ + done, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("pause resolver did not observe the deterministic abort")), + watchdogMs(2_000), + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + await reader.cancel().catch(() => {}); + realUpstream.abort(new Error("test cleanup")); + } + + expect(predicateAbortTriggered).toBe(true); + expect(rec.dones).toBe(1); }); test("(f) cancel while paused wakes the gate — onDone fires, no deadlock", async () => { diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index 09512c50e1..4e57325112 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -985,6 +985,172 @@ describe("request log metadata", () => { }); }); + test("deferred SSE logging maps policy response.incomplete to failed 400", async () => { + const entries: RequestLogEntry[] = []; + const payload = JSON.stringify({ + type: "response.incomplete", + response: { + id: "resp-policy-incomplete", + status: "incomplete", + incomplete_details: { reason: "content_filter" }, + error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked" }, + }, + }); + const response = responseWithDeferredRequestLog( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`event: response.incomplete\ndata: ${payload}\n\n`)); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }), + "ocx-test-cyber-policy-incomplete", + Date.now(), + { model: "gpt-5.6-sol", provider: "openai" }, + entry => entries.push(entry), + ); + + await response.text(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + terminalStatus: "failed", + upstreamError: "blocked", + status: 400, + errorCode: "cyber_policy", + closeReason: "terminal", + }); + }); + + test("deferred SSE logging recognizes policy text from incomplete_details.message", async () => { + const entries: RequestLogEntry[] = []; + const policyMessage = "This request was flagged for possible cybersecurity risk."; + const payload = JSON.stringify({ + type: "response.incomplete", + response: { + id: "resp-policy-incomplete-message", + status: "incomplete", + incomplete_details: { + reason: "content_filter", + message: policyMessage, + }, + }, + }); + const response = responseWithDeferredRequestLog( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`event: response.incomplete\ndata: ${payload}\n\n`)); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }), + "ocx-test-cyber-policy-incomplete-message", + Date.now(), + { model: "gpt-5.6-sol", provider: "openai" }, + entry => entries.push(entry), + ); + + await response.text(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + terminalStatus: "failed", + upstreamError: policyMessage, + status: 400, + errorCode: "cyber_policy", + closeReason: "terminal", + }); + }); + + test("deferred SSE logging maps a policy top-level error to failed 400", async () => { + const entries: RequestLogEntry[] = []; + const payload = JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message: "This request was flagged for possible cybersecurity risk." }, + }); + const response = responseWithDeferredRequestLog( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`event: error\ndata: ${payload}\n\n`)); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }), + "ocx-test-cyber-policy-error", + Date.now(), + { model: "gpt-5.6-sol", provider: "openai" }, + entry => entries.push(entry), + ); + + await response.text(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + terminalStatus: "failed", + status: 400, + errorCode: "cyber_policy", + closeReason: "terminal", + }); + }); + + test("deferred SSE logging checks all policy candidates, not only the first code", async () => { + const entries: RequestLogEntry[] = []; + const payload = JSON.stringify({ + type: "response.incomplete", + error: { type: "upstream_error", code: "upstream_reset", message: "connection ended" }, + response: { + status: "incomplete", + error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked" }, + }, + }); + const response = responseWithDeferredRequestLog( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${payload}\n\n`)); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }), + "ocx-test-cyber-policy-candidates", + Date.now(), + { model: "gpt-5.6-sol", provider: "openai" }, + entry => entries.push(entry), + ); + + await response.text(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + terminalStatus: "failed", + status: 400, + errorCode: "cyber_policy", + }); + }); + + test("deferred SSE logging maps ordinary failed status from response.error only", async () => { + const entries: RequestLogEntry[] = []; + const payload = JSON.stringify({ + type: "response.failed", + code: "context_length_exceeded", + response: { + status: "failed", + error: { type: "rate_limit_error", code: "rate_limit_exceeded", message: "rate limited" }, + }, + }); + const response = responseWithDeferredRequestLog( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${payload}\n\n`)); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }), + "ocx-test-ordinary-failed-authority", + Date.now(), + { model: "gpt-5.6-sol", provider: "openai" }, + entry => entries.push(entry), + ); + + await response.text(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + terminalStatus: "failed", + status: 429, + errorCode: "rate_limit_exceeded", + }); + }); + test("deferred SSE logging maps web-search client closes to 499 client_cancel", async () => { const entries: RequestLogEntry[] = []; const message = "client closed request during web-search"; diff --git a/tests/responses-terminal-repair.test.ts b/tests/responses-terminal-repair.test.ts index 21457962b6..b982357ac5 100644 --- a/tests/responses-terminal-repair.test.ts +++ b/tests/responses-terminal-repair.test.ts @@ -350,6 +350,56 @@ describe("DeepSeek Responses terminal repair", () => { expect(output).not.toContain('"type":"response.completed"'); }); + test("unframed terminal-like suffixes stay tainted and cannot outrank incomplete", async () => { + const fixtures = [ + { + event: "response.completed", + payload: { type: "response.completed", response: { id: "resp_truncated_completed", status: "completed" } }, + }, + { + event: "response.failed", + payload: { type: "response.failed", response: { id: "resp_truncated_failed", status: "failed" } }, + }, + { + event: "response.incomplete", + payload: { type: "response.incomplete", response: { id: "resp_truncated_incomplete", status: "incomplete" } }, + }, + { + event: "error", + payload: { + type: "error", + response: { id: "resp_truncated_error", status: "failed" }, + error: { type: "server_error", code: "upstream_error", message: "provider failed" }, + }, + }, + { + event: "error", + payload: { + type: "error", + error: { type: "invalid_request_error", code: "cyber_policy", message: "blocked by upstream policy" }, + }, + }, + ] as const; + + for (const newline of ["\n", "\r\n"] as const) { + for (const fixture of fixtures) { + const input = `event: ${fixture.event}${newline}data: ${JSON.stringify(fixture.payload)}`; + const { output } = await repairClosedText(input); + + expect(terminalTypes(output)).toEqual(["response.incomplete"]); + expect(output).toContain('"reason":"missing_terminal_event"'); + expect(output).not.toContain("resp_truncated_"); + expect(output).not.toContain("cyber_policy"); + expect(output.match(/data: \[DONE\]/g)?.length).toBe(1); + } + + const { output: doneOutput } = await repairClosedText(`data: [DONE]${newline}`); + expect(terminalTypes(doneOutput)).toEqual(["response.incomplete"]); + expect(doneOutput).toContain('"reason":"missing_terminal_event"'); + expect(doneOutput.match(/data: \[DONE\]/g)?.length).toBe(1); + } + }); + test("DONE is replaced by completed then one DONE only for a complete candidate", async () => { const { output } = await repairClosedText(completedMessageLifecycle() + "data: [DONE]\n\n"); expect(terminalTypes(output)).toEqual(["response.completed"]); diff --git a/tests/sse-failed-tail.test.ts b/tests/sse-failed-tail.test.ts index 70876dafdb..7be3e288ec 100644 --- a/tests/sse-failed-tail.test.ts +++ b/tests/sse-failed-tail.test.ts @@ -7,7 +7,7 @@ import { TranslatorBudgetExceededError } from "../src/lib/translator-budget"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); -function sourceStream(chunks: string[], opts: { failAfter?: boolean; error?: Error } = {}): ReadableStream { +function sourceStream(chunks: readonly string[], opts: { failAfter?: boolean; error?: Error } = {}): ReadableStream { let i = 0; return new ReadableStream({ pull(controller) { @@ -49,6 +49,19 @@ function failedMessage(text: string): string { return (JSON.parse(payload) as { response: { error: { message: string } } }).response.error.message; } +function terminalEvents(text: string): string[] { + return text + .split(/\r?\n/) + .flatMap(line => { + const match = line.match(/^event: (response\.(?:completed|failed|incomplete))$/); + return match ? [match[1]!] : []; + }); +} + +function doneEvents(text: string): string[] { + return text.split(/\r?\n/).filter(line => line === "data: [DONE]"); +} + describe("relaySseWithFailedTail", () => { test("relays a healthy stream verbatim with no injected frame", async () => { const upstream = new AbortController(); @@ -204,4 +217,94 @@ describe("relaySseWithFailedTail", () => { } } }); + + test("legacy and eager use the same bounded fallback when an error message getter throws", async () => { + const error = new Error("unreachable"); + Object.defineProperty(error, "message", { + get() { throw new Error("hostile message getter"); }, + }); + const legacy = await drain(relaySseWithFailedTail( + sourceStream([], { failAfter: true, error }), + new AbortController(), + )); + const eager = await drain(relaySseEagerBounded( + sourceStream([], { failAfter: true, error }), + new AbortController(), + parityHooks, + )); + + expect(encoder.encode(eager)).toEqual(encoder.encode(legacy)); + expect(failedMessage(eager)).toBe("Upstream stream terminated unexpectedly"); + expect(eager.split("data: [DONE]").length - 1).toBe(1); + }); + + test.each([ + [ + "clean EOF without a terminal", + ['event: response.created\ndata: {"type":"response.created"}\n\n'], + "incomplete", + ], + [ + "premature DONE followed by EOF", + ["data: [DONE]\n\n"], + "incomplete", + ], + [ + "valid delimiter-less terminal with LF", + ['event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}'], + "completed", + ], + [ + "valid delimiter-less terminal with CRLF", + ['event: response.completed\r\ndata: {"type":"response.completed","response":{"status":"completed"}}'], + "completed", + ], + [ + "high-confidence cyber_policy terminal normalization", + [`event: error\ndata: ${JSON.stringify({ + type: "error", + response: { id: "resp-policy-parity", status: "failed", output: [] }, + error: { + type: "invalid_request_error", + code: "cyber_policy", + message: "blocked by upstream policy", + }, + })}\n\n`], + "policy-failed", + ], + [ + "malformed delimiter-less terminal-shaped JSON", + ['data: {"type":"response.completed","response":{"status":"completed"}'], + "malformed", + ], + ] as const)("pull/eager parity: %s", async (_name, chunks, expected) => { + const pull = await drain(relaySseWithFailedTail(sourceStream(chunks), new AbortController())); + const eager = await drain(relaySseEagerBounded( + sourceStream(chunks), + new AbortController(), + parityHooks, + )); + + expect(encoder.encode(eager)).toEqual(encoder.encode(pull)); + + for (const text of [pull, eager]) { + expect(terminalEvents(text)).toHaveLength(1); + expect(doneEvents(text)).toHaveLength(1); + if (expected === "incomplete") { + expect(terminalEvents(text)).toEqual(["response.incomplete"]); + expect(text).toContain('"reason":"adapter_eof"'); + } else if (expected === "completed") { + expect(terminalEvents(text)).toEqual(["response.completed"]); + } else if (expected === "policy-failed") { + expect(terminalEvents(text)).toEqual(["response.failed"]); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).not.toContain('"reason":"adapter_eof"'); + } else { + expect(terminalEvents(text)).toEqual(["response.incomplete"]); + expect(text).not.toContain("event: response.completed"); + expect(text).toContain('data: {"type":"response.completed","response":{"status":"completed"}'); + expect(text).toContain('"reason":"adapter_eof"'); + } + } + }); }); diff --git a/tests/terminal-guard-server.test.ts b/tests/terminal-guard-server.test.ts index 999138cf46..39a1393e09 100644 --- a/tests/terminal-guard-server.test.ts +++ b/tests/terminal-guard-server.test.ts @@ -260,6 +260,43 @@ describe("server terminal guard integration", () => { expect(text).toContain("Provider continuation error 429"); }); + test("terminal-guard continuation preserves structured cyber_policy semantics", async () => { + const secret = `OpenAI flagged this request for potential high-risk cybersecurity activity. Authorization: ${["Bear", "er"].join("")} continuationsecret123456`; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + if (sends === 1) return anthropicSse(firstTurn); + return Response.json({ + error: { + message: secret, + type: "server_error", + code: "cyber_policy", + }, + }, { status: 400, headers: { "retry-after": "120" } }); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "se-claude-opus-4.8", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + expect(sends).toBe(2); + expect(text.match(/event: response\.failed/g)?.length).toBe(1); + expect(text).toContain('"type":"server_error"'); + expect(text).toContain('"code":"cyber_policy"'); + expect(text).toContain("Authorization: Bearer [REDACTED]"); + expect(text).not.toContain("continuationsecret123456"); + expect(text).not.toContain("Provider continuation error 400"); + }); + test("terminal-guard continuation abort during the 429 wait yields 499 without replaying", async () => { const abortConfig = { ...config, From b8d06ea059b81484ac538736e3a0e5d430c0ff95 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:23:41 +0900 Subject: [PATCH 017/336] fix(catalog): resolve the verbosity default from config, not the registry (#2582) #2578 made the catalog hint pass consult PROVIDER_REGISTRY for a provider-wide verbosity default. A gather flight captures its registry authority up front and forbids any later read, so every hint pass became that forbidden post-lookup read and a custom-destination flight fell back to "configured" instead of serving its own discovery result. Bisect: green at 4d3d2716e, red from 844885ab1. The registry default is now materialized into the provider config at seed/enrich time (applyVerbosityDefaults, next to the reasoning-summary and service-tier defaults that already work this way), and the hint pass reads only the config. enrichProviderFromCatalog strips both verbosity fields the same way it already strips modelSupportsReasoningSummaries, so a registry default is never frozen into saved config where a later correction could not reach it (#1100). tests/codex-gather-authority.test.ts is green again; 211 pass across the catalog/gather/tool-mode suites. --- src/codex/catalog/provider-fetch.ts | 19 ++++++++++--------- src/oauth/key-providers.ts | 11 ++++++++++- src/providers/derive.ts | 28 ++++++++++++++++++++++++++++ src/types/provider.ts | 6 ++++++ 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 3273af2355..1c8d62ae76 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -650,15 +650,16 @@ function configuredVerbositySupport(name: string, prov: OcxProviderConfig | unde const explicit = prov ? modelRecordValue(prov.modelSupportsVerbosity, id) : undefined; if (explicit !== undefined) return explicit; if (!prov) return undefined; - const entry = (providerMatchesRegistryTransport(name, prov) ? getProviderRegistryEntry(name) : undefined) - ?? registryEntryForProviderDestination(prov); - if (!entry) return undefined; - const perModel = modelRecordValue(entry.modelSupportsVerbosity, id); - if (perModel !== undefined) return perModel; - // Provider-wide fallback. `modelSupportsVerbosity` only enumerates the ids present when the - // registry row was written, so a live-discovered model used to fall through here and - // re-advertise a control the upstream accepts and ignores. A per-model entry still wins. - return entry.supportsVerbosity; + void name; + // Provider-wide fallback for ids the per-model map does not enumerate — a live-discovered + // model would otherwise re-advertise a control the upstream accepts and ignores. + // + // Read from the PROVIDER CONFIG, never from PROVIDER_REGISTRY. A gather flight captures its + // registry authority up front and forbids any later registry read, so consulting the registry + // here made a custom-destination flight fall back to "configured" instead of serving its own + // discovery result (tests/codex-gather-authority.test.ts). `applyVerbosityDefaults` in + // providers/derive.ts materializes the registry default into the config at seed/enrich time. + return prov.supportsVerbosity; } export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index f48e56b3eb..f147b6a44f 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -19,7 +19,8 @@ export const KEY_LOGIN_PROVIDERS: Record = deriveKeyLo * caller didn't already supply. Lets the vision/reasoning classification actually reach the saved * config (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for unknown names. * - * `modelSupportsReasoningSummaries` is deliberately excluded from what gets persisted. It is + * `modelSupportsReasoningSummaries` and the verbosity capability are deliberately excluded from + * what gets persisted. They are * registry-only metadata resolved at runtime, and this function feeds a config that is about to * be written to disk. Persisting today's registry defaults would freeze them as the user's own * overrides: a later registry correction — say we learn a model's backend rejects summary @@ -30,9 +31,17 @@ export const KEY_LOGIN_PROVIDERS: Record = deriveKeyLo export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig): void { const hadOwnSummaries = Object.hasOwn(prov, "modelSupportsReasoningSummaries"); const submittedSummaries = prov.modelSupportsReasoningSummaries; + const hadOwnVerbosity = Object.hasOwn(prov, "modelSupportsVerbosity"); + const submittedVerbosity = prov.modelSupportsVerbosity; + const hadOwnProviderVerbosity = Object.hasOwn(prov, "supportsVerbosity"); + const submittedProviderVerbosity = prov.supportsVerbosity; enrichProviderFromRegistry(name, prov); if (hadOwnSummaries) prov.modelSupportsReasoningSummaries = submittedSummaries; else delete prov.modelSupportsReasoningSummaries; + if (hadOwnVerbosity) prov.modelSupportsVerbosity = submittedVerbosity; + else delete prov.modelSupportsVerbosity; + if (hadOwnProviderVerbosity) prov.supportsVerbosity = submittedProviderVerbosity; + else delete prov.supportsVerbosity; } export function isKeyLoginProvider(name: string): boolean { diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 63cd1c9388..de5c5821e1 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -390,6 +390,32 @@ function serviceTierModelDefaultsFor( : undefined; } +/** + * Materialize the registry's verbosity opt-out into the provider config at seed/enrich time. + * + * The catalog hint pass must not read PROVIDER_REGISTRY: a gather flight captures its registry + * authority up front and forbids any later read, so consulting the registry per model turned + * every hint pass into a post-lookup read and dropped a custom-destination flight's own + * discovery result (tests/codex-gather-authority.test.ts). + * + * Registry values go in first so an explicit user entry still wins, matching + * `applyReasoningSummaryDefaults`. `supportsVerbosity` is the provider-wide default, expanded + * across the seeded model list so an id discovered later still inherits it through the same + * Record the hint pass already reads. + */ +function applyVerbosityDefaults(prov: OcxProviderConfig, entry: ProviderRegistryEntry | undefined): void { + if (!entry) return; + const perModel = entry.modelSupportsVerbosity; + if (!perModel && entry.supportsVerbosity === undefined) return; + prov.modelSupportsVerbosity = { + ...(perModel ?? {}), + ...(prov.modelSupportsVerbosity ?? {}), + }; + if (entry.supportsVerbosity !== undefined) { + prov.supportsVerbosity ??= entry.supportsVerbosity; + } +} + /** * Last-resort enrichment for a provider whose NAME matches no registry id. * @@ -426,6 +452,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // destinations, so a templated or overridable base URL cannot be claimed by it. enrichReasoningSummariesByDestination(prov); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(registryEntryForProviderDestination(prov), prov)); + applyVerbosityDefaults(prov, registryEntryForProviderDestination(prov)); return; } const explicitDirectReasoning: DirectReasoningEffortOverrides = { @@ -489,6 +516,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); + applyVerbosityDefaults(prov, entry); // Registry-only repair policy (#938): fill only when the runtime provider has // no explicit policy, and deep-clone so saved/user values never alias the // registry constant. diff --git a/src/types/provider.ts b/src/types/provider.ts index 0cd2c8585b..e6a66d577f 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -347,6 +347,12 @@ export interface OcxProviderConfig { * or caller-supplied values while preserving other `text` fields. */ modelSupportsVerbosity?: Record; + /** + * Provider-wide Codex Responses verbosity capability, applied to models the per-model map + * does not enumerate (a live-discovered id, for example). Materialized from the registry at + * seed/enrich time so the catalog hint pass never has to read PROVIDER_REGISTRY. + */ + supportsVerbosity?: boolean; /** * Per-model wire value for Responses `stream_options.reasoning_summary_delivery`. * Presence also advertises reasoning-summary support for that routed model. From 6b1950ff4581fd234d335cb09a148ac33b6d53c5 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:36:13 +0900 Subject: [PATCH 018/336] feat(cursor): catalog gemini-3.6/3.7-flash and expose the minimal rung (#2584) Measured against a live GetUsableModels roster on 260825: 204 wire ids normalizing to 34 base models, while CURSOR_STATIC_MODELS carried 50 and listed neither Gemini family. Both ship only as effort-suffixed ids, so without a catalog entry they were invisible to the routed catalog. gemini-3.6-flash is the only Cursor model with a minimal rung, and cursorModelEffortLadder filtered against the canonical five-rung order, so even once declared the tier would have been dropped from the picker while cursorEffortSuffix was willing to send it. The ladder now filters against a picker order that includes the sentinels ranking below low. --- src/adapters/cursor/discovery.ts | 4 ++++ src/adapters/cursor/effort-map.ts | 15 ++++++++++++++- tests/cursor-effort-suffix.test.ts | 30 +++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 119827dc6a..fc6e6fff24 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -257,6 +257,10 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM { id: "gemini-3-pro-image-preview", contextWindow: CONTEXT_200K }, { id: "gemini-3.1-pro", contextWindow: CONTEXT_GEMINI }, { id: "gemini-3.5-flash", contextWindow: CONTEXT_200K }, + // 260825 live GetUsableModels: both ship only as effort-suffixed ids, so each exposes a tier + // picker. 3.6 is the only Cursor model with a `minimal` rung. + { id: "gemini-3.6-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, + { id: "gemini-3.7-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, { id: "gpt-5-codex", contextWindow: CONTEXT_272K }, { id: "gpt-5-fast", contextWindow: CONTEXT_272K }, diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index 979c34e7c5..b2dc628849 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -34,6 +34,10 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { "claude-opus-5-fast": ["low", "medium", "high"], "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], "glm-5.2": ["high", "max"], + // 260825 live GetUsableModels. gemini-3.6-flash is the only Cursor model exposing `minimal`; + // listing it here is also what admits the suffix into CANONICAL_EFFORT_SUFFIXES below. + "gemini-3.6-flash": ["minimal", "low", "medium", "high"], + "gemini-3.7-flash": ["low", "medium", "high"], // 260814 preemptive: glm-5.3 seeded ahead of Cursor's lineup update. Unlike 5.2, Z.AI folds // 5.3 efforts into low/high/max (docs.z.ai/devpack/latest-model), so `low` is a real tier. "glm-5.3": ["low", "high", "max"], @@ -71,6 +75,15 @@ export const CANONICAL_EFFORT_SUFFIXES: ReadonlySet = new Set([ const CANONICAL_CODEX_EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"] as const; +/** + * Picker order, which is the canonical ladder plus the declared sentinels that rank below `low`. + * + * `cursorModelEffortLadder` filters against this, so a tier absent from it is silently dropped + * from the Codex picker even though `cursorEffortSuffix` would happily send it. That is what + * hid `gemini-3.6-flash-minimal`, the one Cursor model with a `minimal` rung. + */ +const CURSOR_PICKER_EFFORT_ORDER = ["minimal", ...CANONICAL_CODEX_EFFORT_ORDER] as const; + function normalizeRequestedEffort(reasoning: string | undefined): string | undefined { const normalized = reasoning?.toLowerCase(); return normalized === "ultra" ? "max" : normalized; @@ -119,7 +132,7 @@ export function cursorModelEffortLadder(baseModelId: string): string[] | undefin const tiers = CURSOR_MODEL_EFFORT_TIERS[baseModelId]; if (!tiers || tiers.length === 0) return undefined; const tierSet = new Set(tiers); - return CANONICAL_CODEX_EFFORT_ORDER.filter(effort => tierSet.has(effort)); + return CURSOR_PICKER_EFFORT_ORDER.filter(effort => tierSet.has(effort)); } /** Base models known to carry a reasoning-effort suffix (everything else is sent bare). */ diff --git a/tests/cursor-effort-suffix.test.ts b/tests/cursor-effort-suffix.test.ts index 2154f689b4..ffa0597c3b 100644 --- a/tests/cursor-effort-suffix.test.ts +++ b/tests/cursor-effort-suffix.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createCursorRequest } from "../src/adapters/cursor/request-builder"; -import { cursorEffortSuffix, cursorModelEffortLadder } from "../src/adapters/cursor/effort-map"; +import { CANONICAL_EFFORT_SUFFIXES, cursorEffortSuffix, cursorModelEffortLadder } from "../src/adapters/cursor/effort-map"; +import { CURSOR_STATIC_MODELS, isCursorModelAvailableForAccount } from "../src/adapters/cursor/discovery"; import type { OcxParsedRequest } from "../src/types"; // Static fixture recorded from Cursor GetUsableModels on 2026-08-06. This pins the @@ -199,3 +200,30 @@ describe("Cursor per-model reasoning-effort suffix", () => { expect(cursorModelEffortLadder("composer-2.5")).toBeUndefined(); }); }); + +describe("#2569 Cursor catalog tracks the live GetUsableModels roster", () => { + test("the two Gemini families the live roster exposes are catalogued", () => { + const ids = new Set(CURSOR_STATIC_MODELS.map(model => model.id)); + expect(ids.has("gemini-3.6-flash")).toBe(true); + expect(ids.has("gemini-3.7-flash")).toBe(true); + }); + + test("gemini-3.6-flash exposes its minimal rung in the picker ladder", () => { + // minimal is not in the canonical five-rung order, so the ladder filter dropped it and the + // tier was unreachable from Codex even though the wire accepts it. + expect(cursorModelEffortLadder("gemini-3.6-flash")).toEqual(["minimal", "low", "medium", "high"]); + expect(cursorEffortSuffix("gemini-3.6-flash", "minimal")).toBe("minimal"); + expect(CANONICAL_EFFORT_SUFFIXES.has("minimal")).toBe(true); + }); + + test("gemini-3.7-flash carries the low/medium/high ladder the wire lists", () => { + expect(cursorModelEffortLadder("gemini-3.7-flash")).toEqual(["low", "medium", "high"]); + }); + + test("both families survive live-discovery filtering from effort-suffixed wire ids", () => { + // The live roster lists ONLY suffixed ids for these models; a base id that does not match + // one of them is dropped from the routed catalog. + expect(isCursorModelAvailableForAccount("gemini-3.6-flash", ["gemini-3.6-flash-minimal"])).toBe(true); + expect(isCursorModelAvailableForAccount("gemini-3.7-flash", ["gemini-3.7-flash-high"])).toBe(true); + }); +}); From 3838a52e59c69573fa4782dcc8ed7d710c37075f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:40:21 +0900 Subject: [PATCH 019/336] feat(cursor): expose the explicit-thinking model families (#2585) The live roster carries 46 thinking wire ids across 13 families, and isCursorModelAvailableForAccount matched none of them: it compares a base id against {base}, {base}-{effort} and the family wire form, so every -thinking variant was invisible in the routed catalog. The marker's position is family-dependent and the wrong order is rejected ERROR_BAD_MODEL_NAME, so cursorWireModelIdWithEffort now owns the mapping: thinking-then-effort (claude-opus-5-thinking-high, ...-high-fast), effort-then-thinking (claude-4.6-opus-max-thinking), and bare (claude-4.5-sonnet-thinking, which has no effort rung). Verified by composing every catalogued id/tier pair and checking it against the live roster: 46 matched, 0 mismatched. Visible models rise 41 -> 52. --- src/adapters/cursor/discovery.ts | 13 +++++++ src/adapters/cursor/effort-map.ts | 62 ++++++++++++++++++++++++++++++ tests/cursor-effort-suffix.test.ts | 50 +++++++++++++++++++++++- 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index fc6e6fff24..dd77472723 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -1,7 +1,9 @@ import { CANONICAL_EFFORT_SUFFIXES, cursorModelEffortLadder, + cursorModelHasEffortTiers, cursorWireModelIdWithEffort, + CURSOR_THINKING_MODEL_IDS, } from "./effort-map"; export interface CursorModelInfo { @@ -262,6 +264,17 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM { id: "gemini-3.6-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, { id: "gemini-3.7-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, + // Explicit-thinking variants (260825 live roster). Exposed as first-class ids the same way the + // Opus Fast families were in 831810c13: `isCursorModelAvailableForAccount` matches a base id + // against `{base}`, `{base}-{effort}` and the family's wire form, and none of those ever + // matched a `-thinking` id, so every one of these was invisible in the routed catalog. + // Suffix ORDER differs per family; `cursorWireModelIdWithEffort` owns that mapping. + ...CURSOR_THINKING_MODEL_IDS.map(id => ({ + id, + contextWindow: CONTEXT_200K, + supportsReasoningEffort: cursorModelHasEffortTiers(id), + })), + { id: "gpt-5-codex", contextWindow: CONTEXT_272K }, { id: "gpt-5-fast", contextWindow: CONTEXT_272K }, { id: "gpt-5-mini", contextWindow: CONTEXT_272K }, diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index b2dc628849..6bc0270f74 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -38,6 +38,20 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // listing it here is also what admits the suffix into CANONICAL_EFFORT_SUFFIXES below. "gemini-3.6-flash": ["minimal", "low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], + // Explicit-thinking variants (260825 live roster). Tiers are the rungs the wire actually + // lists for each family, which is not always the same set the non-thinking id carries: + // 4.6-opus thinks only at high/max, 4.5-opus only at high, 4.6-sonnet only at medium. + "claude-opus-5-thinking": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-5-thinking-fast": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8-thinking": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8-thinking-fast": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-7-thinking": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-7-thinking-fast": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-5-thinking": ["low", "medium", "high", "xhigh", "max"], + "claude-fable-5-thinking": ["low", "medium", "high", "xhigh", "max"], + "claude-4.6-opus-thinking": ["high", "max"], + "claude-4.5-opus-thinking": ["high"], + "claude-4.6-sonnet-thinking": ["medium"], // 260814 preemptive: glm-5.3 seeded ahead of Cursor's lineup update. Unlike 5.2, Z.AI folds // 5.3 efforts into low/high/max (docs.z.ai/devpack/latest-model), so `low` is a real tier. "glm-5.3": ["low", "high", "max"], @@ -75,6 +89,37 @@ export const CANONICAL_EFFORT_SUFFIXES: ReadonlySet = new Set([ const CANONICAL_CODEX_EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"] as const; +/** + * Cursor's explicit-thinking variants, exposed as first-class Codex model ids the same way the + * `-fast` families were. + * + * `source` is the id whose wire name the variant is built from; `order` is where Cursor puts the + * thinking marker relative to the effort rung. All three shapes exist in the live roster + * (GetUsableModels, 260825), and using the wrong one is rejected with ERROR_BAD_MODEL_NAME: + * + * thinking-then-effort claude-opus-5-thinking-high, claude-opus-5-thinking-high-fast + * effort-then-thinking claude-4.6-opus-high-thinking + * bare claude-4.5-sonnet-thinking (the model has no effort rung) + */ +const CURSOR_THINKING_FAMILIES: Readonly> = { + "claude-opus-5-thinking": { source: "claude-opus-5", order: "thinking-then-effort" }, + "claude-opus-5-thinking-fast": { source: "claude-opus-5-fast", order: "thinking-then-effort" }, + "claude-opus-4-8-thinking": { source: "claude-opus-4-8", order: "thinking-then-effort" }, + "claude-opus-4-8-thinking-fast": { source: "claude-opus-4-8-fast", order: "thinking-then-effort" }, + "claude-opus-4-7-thinking": { source: "claude-opus-4-7", order: "thinking-then-effort" }, + "claude-opus-4-7-thinking-fast": { source: "claude-opus-4-7-fast", order: "thinking-then-effort" }, + "claude-sonnet-5-thinking": { source: "claude-sonnet-5", order: "thinking-then-effort" }, + "claude-fable-5-thinking": { source: "claude-fable-5", order: "thinking-then-effort" }, + "claude-4.6-opus-thinking": { source: "claude-4.6-opus", order: "effort-then-thinking" }, + "claude-4.5-opus-thinking": { source: "claude-4.5-opus", order: "effort-then-thinking" }, + "claude-4.6-sonnet-thinking": { source: "claude-4.6-sonnet", order: "effort-then-thinking" }, + "claude-4.5-sonnet-thinking": { source: "claude-4.5-sonnet", order: "bare" }, + "claude-4-sonnet-thinking": { source: "claude-4-sonnet", order: "bare" }, +}; + +/** Codex-facing ids for Cursor's explicit-thinking variants. */ +export const CURSOR_THINKING_MODEL_IDS = Object.keys(CURSOR_THINKING_FAMILIES); + /** * Picker order, which is the canonical ladder plus the declared sentinels that rank below `low`. * @@ -146,6 +191,23 @@ export function cursorModelHasEffortTiers(baseModelId: string): boolean { * and send the base model plus requested_model parameters instead. */ export function cursorWireModelIdWithEffort(baseModelId: string, effortSuffix: string): string { + const thinking = CURSOR_THINKING_FAMILIES[baseModelId]; + if (thinking) { + const { source, order } = thinking; + // Cursor writes the thinking marker on either side of the effort depending on family + // (measured against GetUsableModels, 260825): + // thinking-then-effort claude-opus-5-thinking-high, ...-thinking-high-fast + // effort-then-thinking claude-4.6-opus-high-thinking + // bare claude-4.5-sonnet-thinking (no effort rung at all) + // Sending the wrong order returns ERROR_BAD_MODEL_NAME, so this is not cosmetic. + if (order === "bare") return `${source}-thinking`; + if (order === "effort-then-thinking") return `${source}-${effortSuffix}-thinking`; + if (source.endsWith("-fast")) { + const stem = source.slice(0, -"-fast".length); + return `${stem}-thinking-${effortSuffix}-fast`; + } + return `${source}-thinking-${effortSuffix}`; + } if (baseModelId.endsWith("-fast")) { return `${baseModelId.slice(0, -"-fast".length)}-${effortSuffix}-fast`; } diff --git a/tests/cursor-effort-suffix.test.ts b/tests/cursor-effort-suffix.test.ts index ffa0597c3b..fdc9b04b46 100644 --- a/tests/cursor-effort-suffix.test.ts +++ b/tests/cursor-effort-suffix.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createCursorRequest } from "../src/adapters/cursor/request-builder"; -import { CANONICAL_EFFORT_SUFFIXES, cursorEffortSuffix, cursorModelEffortLadder } from "../src/adapters/cursor/effort-map"; +import { CANONICAL_EFFORT_SUFFIXES, cursorEffortSuffix, cursorModelEffortLadder, cursorWireModelIdWithEffort, CURSOR_THINKING_MODEL_IDS } from "../src/adapters/cursor/effort-map"; import { CURSOR_STATIC_MODELS, isCursorModelAvailableForAccount } from "../src/adapters/cursor/discovery"; import type { OcxParsedRequest } from "../src/types"; @@ -227,3 +227,51 @@ describe("#2569 Cursor catalog tracks the live GetUsableModels roster", () => { expect(isCursorModelAvailableForAccount("gemini-3.7-flash", ["gemini-3.7-flash-high"])).toBe(true); }); }); + +describe("#2569 Cursor explicit-thinking variants", () => { + /** + * Suffix ORDER differs per family and the wrong one is rejected ERROR_BAD_MODEL_NAME. + * Cases recorded from the live GetUsableModels roster on 2026-08-25. + */ + const WIRE_CASES: ReadonlyArray = [ + ["claude-opus-5-thinking", "high", "claude-opus-5-thinking-high"], + ["claude-opus-5-thinking-fast", "max", "claude-opus-5-thinking-max-fast"], + ["claude-opus-4-8-thinking", "low", "claude-opus-4-8-thinking-low"], + ["claude-opus-4-8-thinking-fast", "xhigh", "claude-opus-4-8-thinking-xhigh-fast"], + ["claude-sonnet-5-thinking", "medium", "claude-sonnet-5-thinking-medium"], + ["claude-fable-5-thinking", "xhigh", "claude-fable-5-thinking-xhigh"], + // The marker moves to the END for these families. + ["claude-4.6-opus-thinking", "max", "claude-4.6-opus-max-thinking"], + ["claude-4.5-opus-thinking", "high", "claude-4.5-opus-high-thinking"], + ["claude-4.6-sonnet-thinking", "medium", "claude-4.6-sonnet-medium-thinking"], + ]; + + for (const [id, effort, expected] of WIRE_CASES) { + test(`${id} at ${effort} composes ${expected}`, () => { + expect(cursorWireModelIdWithEffort(id, effort)).toBe(expected); + }); + } + + test("families with no effort rung send the bare thinking id", () => { + expect(cursorWireModelIdWithEffort("claude-4.5-sonnet-thinking", "high")).toBe("claude-4.5-sonnet-thinking"); + expect(cursorWireModelIdWithEffort("claude-4-sonnet-thinking", "low")).toBe("claude-4-sonnet-thinking"); + expect(cursorModelEffortLadder("claude-4.5-sonnet-thinking")).toBeUndefined(); + }); + + test("every thinking variant is catalogued and survives live-discovery filtering", () => { + const ids = new Set(CURSOR_STATIC_MODELS.map(model => model.id)); + for (const id of CURSOR_THINKING_MODEL_IDS) expect(ids.has(id)).toBe(true); + + // A base id is kept only when it matches a live wire id; before this change none of the + // -thinking forms matched, so every variant was invisible in the routed catalog. + expect(isCursorModelAvailableForAccount("claude-opus-5-thinking", ["claude-opus-5-thinking-high"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-4.6-opus-thinking", ["claude-4.6-opus-max-thinking"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-4.5-sonnet-thinking", ["claude-4.5-sonnet-thinking"])).toBe(true); + }); + + test("a thinking variant never collapses onto its non-thinking source", () => { + expect(cursorWireModelIdWithEffort("claude-opus-5", "high")).toBe("claude-opus-5-high"); + expect(cursorWireModelIdWithEffort("claude-opus-5-fast", "high")).toBe("claude-opus-5-high-fast"); + }); +}); + From 073ae69e268216f03208024b41ba5e2288f6d0d9 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:42:31 +0900 Subject: [PATCH 020/336] fix(cli): render quota bars in ocx provider quota (#2586) quota() rendered the response through summaryLines(), a depth-1 flattener that emits 'N item(s)' for a non-scalar array. Every fetched report was discarded, so the default invocation printed a count and users had to pipe --json through a parser to read their own quota. Reuses providerQuotaLine, the formatter ocx account refresh already uses, so both commands render the same shape. --- src/cli/account-extended.ts | 2 +- src/cli/provider-runtime.ts | 18 ++++++++++++-- tests/cli-headless-parity.test.ts | 39 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 27b87137b8..952a552d79 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -279,7 +279,7 @@ function quotaParts(quota: ProviderQuotaDto): string[] { return parts; } -function providerQuotaLine(name: string, report: ProviderQuotaReportDto): string { +export function providerQuotaLine(name: string, report: ProviderQuotaReportDto): string { return [name, ...quotaParts(report.quota)].join(" "); } diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index 95e2479bf7..183d2e32a7 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -11,6 +11,13 @@ import { takeOption, type RuntimeApiDeps, } from "./runtime-api"; +import { providerQuotaLine } from "./account-extended"; +import type { ProviderQuotaReportDto } from "./account-api"; + +interface ProviderQuotasDto { + generatedAt?: number; + reports?: ProviderQuotaReportDto[]; +} const USAGE = `Usage: ocx provider edit [--adapter ] [--base-url ] [--default-model ] @@ -107,8 +114,15 @@ async function quota(argv: string[], deps: RuntimeApiDeps): Promise { const wantsJson = takeFlag(args, "--json"); const refresh = takeFlag(args, "--refresh"); rejectArgs(args, USAGE); - const result = await runtimeRequest(`/api/provider-quotas${refresh ? "?refresh=1" : ""}`, {}, deps); - printData(result, wantsJson, summaryLines(result)); + const result = await runtimeRequest(`/api/provider-quotas${refresh ? "?refresh=1" : ""}`, {}, deps); + // `summaryLines` is a depth-1 flattener: it renders a non-scalar array as "N item(s)", which + // collapsed the whole report to a count and made the command useless for its stated purpose + // (#2565). Render one line per report with the same formatter `ocx account refresh` uses. + const reports = Array.isArray(result?.reports) ? result.reports : []; + const lines = reports.length > 0 + ? reports.map(report => providerQuotaLine(report.provider, report)) + : ["no quota reports available"]; + printData(result, wantsJson, lines); } async function presets(argv: string[], deps: RuntimeApiDeps): Promise { diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 7029566d20..a36ae95e0b 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -9,6 +9,7 @@ import { handleConfigCommand } from "../src/cli/config-command"; import { handleClientIntegrationCommand, handleGrokCommand } from "../src/cli/integrations"; import { handleModelsRuntimeCommand } from "../src/cli/models-runtime"; import { handleProviderRuntimeCommand } from "../src/cli/provider-runtime"; +import { providerQuotaLine } from "../src/cli/account-extended"; type Recorded = { path: string; method: string; body: unknown }; const servers: Array> = []; @@ -691,3 +692,41 @@ describe("headless GUI parity CLI", () => { } }); }); + +describe("#2565 ocx provider quota renders bars, not a count", () => { + /** + * `quota()` rendered the response through `summaryLines()`, a depth-1 flattener that emits + * "N item(s)" for a non-scalar array. Every fetched report was discarded and the default + * invocation printed only `generatedAt` and `reports: 5 item(s)`. + */ + const report = (provider: string, quota: Record) => ({ provider, quota }); + + test("one line per report, using the same formatter as ocx account refresh", () => { + const line = providerQuotaLine("anthropic", report("anthropic", { + fiveHourPercent: 9, + fiveHourResetAt: 1_787_690_999_802, + weeklyPercent: 45, + }) as never); + expect(line).toContain("anthropic"); + expect(line).toContain("5h 9%"); + expect(line).toContain("weekly 45%"); + expect(line).toContain("resets "); + }); + + test("custom windows keep their upstream labels", () => { + const line = providerQuotaLine("cursor", report("cursor", { + monthlyPercent: 0.69, + customWindows: [ + { label: "First-party models", percent: 0.77 }, + { label: "API usage", percent: 0.19 }, + ], + }) as never); + expect(line).toContain("monthly 0.69%"); + expect(line).toContain("First-party models 0.77%"); + expect(line).toContain("API usage 0.19%"); + }); + + test("a report with no windows still names its provider", () => { + expect(providerQuotaLine("plain", report("plain", {}) as never)).toBe("plain"); + }); +}); From 11c3f7e324852841fade0ea2e0db45e8ac6767d6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:46:11 +0900 Subject: [PATCH 021/336] feat(cli): add per-account quota to ocx account list (#2587) The proxy already probes per-credential usage and the dashboard already renders it, but no CLI command exposed it: fetchOAuthRows never passed quota=1, so a headless or SSH session had to hand-roll an authenticated curl against the management API to answer 'which account has quota left'. --quota opts into the probe and adds a QUOTA column; the default listing stays a cheap local read. --refresh bypasses the server TTL. An unprobed provider shows '-' and a failed probe shows 'unavailable', because blank would read as no usage rather than not measured. Two DTO spellings reach the same field - the per-account probe reports fiveHourPercent, the Codex pool reports shortPercent - and both render as 5h. --- .../docs/reference/cli/providers-accounts.md | 16 +++++++- src/cli/account-api.ts | 36 ++++++++++++++++-- src/cli/account.ts | 34 ++++++++++++++--- tests/cli-headless-parity.test.ts | 38 +++++++++++++++++++ 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 457b83c571..d4cb5cdadd 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -140,7 +140,7 @@ and the plan/label column falls back across plan, masked email, label, and maske } ``` -### `ocx account list [provider] [--json] [--all]` +### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` Without a provider, lists the Codex pool, OAuth accounts, and configured API-key pools. Empty providers are skipped unless `--all` is present. With a provider, lists only that credential family. @@ -154,6 +154,20 @@ returns: { accounts: AccountRow[], notes: string[] } ``` +`--quota` adds a `QUOTA` column with each account's own usage, for providers that support a +per-account probe (Anthropic today). It is opt-in because the proxy probes the upstream once per +stored credential; the default listing stays a local read. `--refresh` bypasses the cached +result. An account with no per-account quota shows `-`, and one whose probe failed shows +`unavailable` — blank would read as "no usage" rather than "not measured". `--json` carries the +full breakdown per account, not just the two summarized windows: + +```text +$ ocx account list anthropic --quota +PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS QUOTA +anthropic oauth 1278f8da a***r@big5lms.com - 5h 7% wk 62% +anthropic oauth e112f28b k***1@gmail.com - active 5h 9% wk 45% +``` + ### `ocx account current [--json]` Shows the active account or key. A Codex pool with no manual pin reports the priority-aware diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 190db2bd1f..9cd06cd939 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -145,6 +145,14 @@ export interface CodexQuotaDto { monthlyPercent?: number; weeklyResetAt?: number; monthlyResetAt?: number; + /** + * Five-hour window, as the per-account provider probe reports it + * (`/api/oauth/accounts?quota=1`). Distinct from `shortPercent`, which is the Codex pool's + * self-declared burst window; the two surfaces name the same idea differently and both reach + * this DTO. + */ + fiveHourPercent?: number; + fiveHourResetAt?: number; /** Sub-day burst window, when upstream declares one (#1791). */ shortPercent?: number; shortResetAt?: number; @@ -240,10 +248,22 @@ interface OAuthAccountDto { email?: string; active?: boolean; needsReauth?: boolean; + quota?: CodexQuotaDto | null; + quotaUnavailable?: boolean; } -async function fetchOAuthRows(deps: AccountDeps, baseUrl: string, name: string): Promise { - const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts?provider=${encodeURIComponent(name)}`); +async function fetchOAuthRows( + deps: AccountDeps, + baseUrl: string, + name: string, + quota?: { refresh?: boolean }, +): Promise { + // Quota is opt-in: the server probes the upstream once per stored credential when `quota=1` + // is present, so the default listing must stay a cheap local read (#2566). + const query = quota + ? `?provider=${encodeURIComponent(name)}"a=1${quota.refresh ? "&refresh=1" : ""}` + : `?provider=${encodeURIComponent(name)}`; + const res = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts${query}`); if (res.status === 0) return { rows: [], activeId: null, status: 0, networkDown: true }; if (res.status !== 200) return { rows: [], activeId: null, status: res.status, errorJson: res.json }; const activeId = typeof res.json.activeAccountId === "string" ? res.json.activeAccountId : null; @@ -256,6 +276,8 @@ async function fetchOAuthRows(deps: AccountDeps, baseUrl: string, name: string): email: a.email, active: a.active ?? a.id === activeId, needsReauth: a.needsReauth, + ...(a.quota !== undefined ? { quota: a.quota } : {}), + ...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}), })); return { rows, activeId, status: 200 }; } @@ -284,9 +306,15 @@ async function fetchKeyRows(deps: AccountDeps, baseUrl: string, name: string): P return { rows, activeId, status: 200 }; } -export function fetchRows(deps: AccountDeps, baseUrl: string, name: string, type: AccountType): Promise { +export function fetchRows( + deps: AccountDeps, + baseUrl: string, + name: string, + type: AccountType, + quota?: { refresh?: boolean }, +): Promise { if (type === "codex") return fetchCodexRows(deps, baseUrl); - if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name); + if (type === "oauth") return fetchOAuthRows(deps, baseUrl, name, quota); return fetchKeyRows(deps, baseUrl, name); } diff --git a/src/cli/account.ts b/src/cli/account.ts index 5da6c0160e..75852f86f7 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -16,7 +16,7 @@ const MAIN_CODEX_ID = "__main__"; const REPLACEMENT_STYLE_OAUTH = new Set(["kiro"]); const ACCOUNT_USAGE = `Usage: - ocx account list [provider] [--json] [--all] + ocx account list [provider] [--json] [--all] [--quota [--refresh]] ocx account current [--json] ocx account use [--json] ocx account refresh [--json] @@ -75,11 +75,29 @@ function priorityText(row: AccountRow): string { return row.priority > 0 ? `+${row.priority}` : String(row.priority); } -export function formatAccountTable(rows: AccountRow[]): string { +/** + * Compact per-account quota for the opt-in QUOTA column: the two windows an operator actually + * decides on before a long session. The full breakdown stays in `--json`. + */ +function quotaText(row: AccountRow): string { + if ((row as { quotaUnavailable?: boolean }).quotaUnavailable) return "unavailable"; + const quota = row.quota; + if (!quota) return "-"; + const parts: string[] = []; + // Two spellings reach this DTO: the per-account provider probe reports `fiveHourPercent`, + // while the Codex pool reports the same idea as `shortPercent`. + const short = quota.fiveHourPercent ?? quota.shortPercent; + if (typeof short === "number") parts.push(`5h ${short}%`); + if (typeof quota.weeklyPercent === "number") parts.push(`wk ${quota.weeklyPercent}%`); + return parts.length > 0 ? parts.join(" ") : "-"; +} + +export function formatAccountTable(rows: AccountRow[], withQuota = false): string { const header = ["PROVIDER", "TYPE", "ID", "PLAN/LABEL", "PRIORITY", "STATUS"]; + if (withQuota) header.push("QUOTA"); const data = rows.map(r => { const keyLabel = r.masked && r.label !== r.masked ? `${r.masked} (${r.label})` : r.masked; - return [ + const cols = [ r.provider, r.type, displayId(r.id), @@ -87,6 +105,8 @@ export function formatAccountTable(rows: AccountRow[]): string { priorityText(r), statusText(r), ]; + if (withQuota) cols.push(quotaText(r)); + return cols; }); const widths = header.map((h, i) => Math.max(h.length, ...data.map(d => d[i]!.length))); const line = (cols: string[]) => cols.map((c, i) => c.padEnd(widths[i]!)).join(" ").trimEnd(); @@ -96,6 +116,10 @@ export function formatAccountTable(rows: AccountRow[]): string { async function cmdList(rest: string[], deps: AccountDeps): Promise { const wantsJson = consumeFlag(rest, "--json"); const showAll = consumeFlag(rest, "--all"); + // Opt-in: the server probes the upstream once per stored credential, so the default listing + // stays a cheap local read (#2566). --refresh bypasses the server-side TTL. + const wantsQuota = consumeFlag(rest, "--quota"); + const refreshQuota = consumeFlag(rest, "--refresh"); const name = rest.shift(); const leftover = leftoverArgsError(rest); if (leftover) { @@ -139,7 +163,7 @@ async function cmdList(rest: string[], deps: AccountDeps): Promise { const rows: AccountRow[] = []; const notes: string[] = []; for (const t of targets) { - const r = await fetchRows(deps, baseUrl, t.name, t.type); + const r = await fetchRows(deps, baseUrl, t.name, t.type, wantsQuota ? { refresh: refreshQuota } : undefined); if (r.networkDown) return proxyUnreachable(); if (r.errorJson) { if (name) return apiError(r.errorJson, `failed to list ${t.name}`); @@ -174,7 +198,7 @@ async function cmdList(rest: string[], deps: AccountDeps): Promise { console.log(JSON.stringify({ accounts: rows, notes }, null, 2)); return 0; } - if (rows.length > 0) console.log(formatAccountTable(rows)); + if (rows.length > 0) console.log(formatAccountTable(rows, wantsQuota)); for (const n of notes) console.log(n); if (rows.length === 0 && notes.length === 0) console.log("No stored accounts or keys."); return 0; diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index a36ae95e0b..f250051c1f 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -10,6 +10,7 @@ import { handleClientIntegrationCommand, handleGrokCommand } from "../src/cli/in import { handleModelsRuntimeCommand } from "../src/cli/models-runtime"; import { handleProviderRuntimeCommand } from "../src/cli/provider-runtime"; import { providerQuotaLine } from "../src/cli/account-extended"; +import { formatAccountTable } from "../src/cli/account"; type Recorded = { path: string; method: string; body: unknown }; const servers: Array> = []; @@ -730,3 +731,40 @@ describe("#2565 ocx provider quota renders bars, not a count", () => { expect(providerQuotaLine("plain", report("plain", {}) as never)).toBe("plain"); }); }); + +describe("#2566 per-account quota in ocx account list", () => { + const row = (over: Record = {}) => ({ + provider: "anthropic", + type: "oauth" as const, + id: "acc-1", + label: "a@example.test", + active: false, + ...over, + }); + + test("the QUOTA column only exists when it is asked for", () => { + // The server probes the upstream once per stored credential for quota=1, so the default + // listing must stay a cheap local read. + expect(formatAccountTable([row()] as never)).not.toContain("QUOTA"); + expect(formatAccountTable([row()] as never, true)).toContain("QUOTA"); + }); + + test("both DTO spellings of the sub-day window render as 5h", () => { + // The per-account provider probe reports fiveHourPercent; the Codex pool reports the same + // idea as shortPercent. + expect(formatAccountTable([row({ quota: { fiveHourPercent: 7, weeklyPercent: 62 } })] as never, true)) + .toContain("5h 7% wk 62%"); + expect(formatAccountTable([row({ quota: { shortPercent: 3, weeklyPercent: 10 } })] as never, true)) + .toContain("5h 3% wk 10%"); + }); + + test("a provider without per-account quota is blank, not zero", () => { + // Blank means "not probed"; 0% would claim the account is fully drained. + expect(formatAccountTable([row({ provider: "xai" })] as never, true)).toContain("-"); + }); + + test("an account whose probe failed says so instead of reading as empty", () => { + expect(formatAccountTable([row({ quotaUnavailable: true })] as never, true)).toContain("unavailable"); + }); +}); + From 48602ce5cd689ce5cf4008e8d1b5cf2297d56b00 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:48:33 +0900 Subject: [PATCH 022/336] devlog: backlog closeout unit and wp6/wp8 execution record (#2588) --- .../000_research_snapshot.md | 90 ++++++++++++++++ .../001_audit_response.md | 100 ++++++++++++++++++ .../010_wp2_merge_train_remainder.md | 26 +++++ .../020_wp4_google_lane.md | 32 ++++++ .../030_wp5_hygiene_and_drafts.md | 40 +++++++ .../040_wp6_cursor_catalog.md | 27 +++++ .../050_wp7_oauth_failover.md | 24 +++++ .../060_wp8_wp9_cli_and_platform.md | 35 ++++++ .../070_wp10_wp11_slug_and_exec.md | 24 +++++ .../080_wp12_catalog_ux.md | 28 +++++ .../090_wp13_architecture.md | 41 +++++++ .../100_wp6_wp8_execution.md | 50 +++++++++ 12 files changed, 517 insertions(+) create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/000_research_snapshot.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/001_audit_response.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/010_wp2_merge_train_remainder.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/020_wp4_google_lane.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/030_wp5_hygiene_and_drafts.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/040_wp6_cursor_catalog.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/050_wp7_oauth_failover.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/060_wp8_wp9_cli_and_platform.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/070_wp10_wp11_slug_and_exec.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/080_wp12_catalog_ux.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/090_wp13_architecture.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/100_wp6_wp8_execution.md diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/000_research_snapshot.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/000_research_snapshot.md new file mode 100644 index 0000000000..d45e8b7f86 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/000_research_snapshot.md @@ -0,0 +1,90 @@ +# 000 — Research snapshot: owner backlog + bug PR closeout + +Unit opened 2026-08-25. Base: `origin/dev` at `b33d82dc3`. + +## Scope + +Two populations, 31 items at open: + +- **A. Maintainer-authored open issues (15):** #2569 #2568 #2566 #2565 #2558 #2557 #2491 + #2472 #2465 #2464 #2463 #1478 #1049 #1048 #820 +- **B. Open `bug`-labelled community PRs (16):** #2567 #2563 #2555 #2550 #2542 #2532 + #2528 #2515 #2513 #2512 #2510 #2503 #2497 #2490 #2488 #2474 + +## Method + +Six read-only investigation lanes (`gpt-5.6-sol`, medium) were dispatched in parallel +against a worktree pinned at the then-current dev head. Each lane was required to verify +the claim in the issue/PR body against the actual line cited, and to return a verdict +with file:line evidence rather than a plausibility judgement. Lane reports are +summarized per item in `010`-`080`; this document records only the classification and +the dependency order. + +## Classification + +| Item | Verdict | Effort | Risk | Owner phase | +|---|---|---|---|---| +| PR #2528 | MERGED (verified, 41 focused tests) | S | LOW | wp1 | +| PR #2555 | MERGED (verified, 11 GUI tests) | S | LOW | wp1 | +| PR #2532 | MERGED (verified, 46 focused tests) | M | MED | wp2 | +| PR #2515 | MERGED (verified, 88 focused tests) | M | MED | wp2 | +| PR #2474 | MERGED (Linux-only regression, skipped on macOS) | XS | LOW | wp2 | +| PR #2550 | MERGED (verified, 138 focused tests) | XS | MED | wp3 | +| PR #2563 | NEEDS-FIXUP (returned to draft on new commits) | M | HIGH | wp2 | +| PR #2503 | NEEDS-FIXUP (53 commits behind; capability lost via combo/trusted/live paths) | M | MED | wp3 | +| PR #2488 | NEEDS-FIXUP (2 correctness blockers: policy-code overwrite, envelope selection) | M | HIGH | wp5 | +| PR #2542 | NEEDS-FIXUP (51 commits behind; red focused test) | S | MED | wp5 | +| PR #2513 | NEEDS-FIXUP (eviction not applied to AI Studio; test lacks HOME isolation) | S | MED | wp4 | +| PR #2512 | NEEDS-FIXUP (substring model match caps unknown models) | M | MED | wp4 | +| PR #2510 | NEEDS-FIXUP (`retry-after` spelling omitted from transient guard) | XS | MED | wp4 | +| PR #2567 | NEEDS-FIXUP (hygiene: missing_regression_test) | S | MED | wp5 | +| PR #2497 | NEEDS-FIXUP (unsponsored_surface; auth boundary needs maintainer sponsorship) | L | HIGH | wp5 | +| PR #2490 | NEEDS-FIXUP (unsponsored_surface only; code reviewed sound) | S | MED | wp5 | +| Issue #2565 | IMPLEMENT (formatter mismatch, renderer already exists) | XS | LOW | wp8 | +| Issue #2566 | IMPLEMENT (CLI never passes `quota=1`) | M | MED | wp8 | +| Issue #2558 | IMPLEMENT (no destination-authority field on tier observation) | S | MED | wp9 | +| Issue #2557 | IMPLEMENT (PowerShell statement join + probe failure is not absence) | S | HIGH | wp9 | +| Issue #2491 | IMPLEMENT (four relations confirmed with file:line) | M | MED | wp10 | +| Issue #2472 | INVALID/WONTFIX (envelope owned by the Codex host, not this proxy) | XS | LOW | wp11 | +| Issue #2465 | IMPLEMENT (GUI surface) | L | MED | wp12 | +| Issue #2464 | IMPLEMENT (GUI surface) | L | HIGH | wp12 | +| Issue #2463 | IMPLEMENT (GUI surface) | L | HIGH | wp12 | +| Issue #2569 | IMPLEMENT (live roster drift, measured) | M | LOW | wp6 | +| Issue #2568 | IMPLEMENT (generalize OAuth account failover) | M | MED | wp7 | +| Issue #1478 | IMPLEMENT (config provenance still absent) | L | HIGH | wp13 | +| Issue #1049 | IMPLEMENT (legacy homes still `legacy-uncoordinated`) | L | HIGH | wp13 | +| Issue #1048 | IMPLEMENT (disposable-host runner absent) | L | HIGH | wp13 | +| Issue #820 | IMPLEMENT (session-lane scheduler absent) | L | HIGH | wp13 | + +## Key findings that change the plan + +**#2472 is not our bug.** `wall_time_seconds` and `exec_command` appear nowhere under +`src/` or `tests/` — that result envelope belongs to the Codex host. The lane also +rejected the Cursor call-ID theory: live bridge calls pass `allowEmptyArgs: true` +(`src/adapters/cursor/live-transport.ts:243`) and duplicate call IDs are deduplicated +deliberately (`src/adapters/cursor/protobuf-events.ts:1044`). Closing with evidence +rather than implementing. + +**The four old architecture issues are genuinely open.** #1478, #1049, #1048, and #820 +were each checked against `_fin` units and against `src/`. All four are real remaining +work, all L, all HIGH risk. They are not stale-but-done, and they cannot be honestly +closed by adjudication alone. They form wp13 and are the largest single risk to the +DONE criterion. + +**Three hygiene-blocked PRs fail for two different reasons.** #2567 fails +`missing_regression_test` — a real, satisfiable gap. #2497 and #2490 fail +`unsponsored_surface`, which is a maintainer-sponsorship gate on the auth/quota +surface, not a code defect; #2490's code was reviewed sound. + +**The three Google PRs do not conflict.** #2510 changes `google-errors.ts`; #2512 and +#2513 change `google.ts`. No merge-order dependency, but #2512 and #2513 will textually +conflict with each other and must be sequenced. + +## Dependency order + +wp1 → wp2 → wp3 (merge train, ordered by base freshness) runs first because every later +implementation phase rebases on a moving dev. wp4/wp5 are independent of each other. +wp6-wp12 are implementation phases on issues; wp12 is the only GUI-bearing phase and +routes through cxc-dev-uiux-design before cxc-dev-frontend. wp13 is last because it is +the only phase whose scope can force a plan amendment. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/001_audit_response.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/001_audit_response.md new file mode 100644 index 0000000000..b677574c9f --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/001_audit_response.md @@ -0,0 +1,100 @@ +# 001 — Audit response (round 1) + +Independent auditor verdict: **fail**, 5 blocking findings. Findings are accepted +unless an explicit rebuttal is recorded. + +## B1 — The admin merge train left dev red. ACCEPTED, FIXED. + +The auditor was right and the failure was real. Reproduced locally at `e42778adc`: +`bun test tests/subagent-model-fallback.test.ts tests/subagent-fallback-handle-responses.test.ts` +→ 86 pass / **2 fail**, both throwing `CodexPoolAuthenticationError` at +`src/codex/auth-context.ts:464`. + +Root cause is a semantic conflict invisible to either PR alone. #2550 made +`gpt-5.6-*` account-gated and fails closed when the entitlement snapshot has no +roster (`auth-context.ts:463`). Two preview cases added by #2515 bind on +`gpt-5.6-sol` without installing a roster mock, while the neighbouring cases #2550 +itself touched do install one (lines 732, 793). Each PR was green alone; only the +union is red. + +Fixed in #2570 (merged `6b08567fa`). Post-fix CI run `32865619167`: all four test +shards, gates, api-usage, storage-policy and all three keyring jobs **success**; +14745 pass / 1 fail. The one remaining failure is +`tests/cursor-desktop-exec.test.ts` "computer-use non-zero exit", which passes +locally 12/12 — the known desktop-exec flake, unrelated to this change. + +**Process correction for the rest of the loop:** focused suites are necessary but not +sufficient. Two PRs touching one subsystem get a combined run on the merge result +before the second lands, and dev CI is checked after each landing. + +## B2 — #2472 INVALID/WONTFIX is unsupported. ACCEPTED, RECLASSIFIED. + +Doc 070 proved only that the envelope FIELDS are host-owned, which does not establish +that OpenCodex cannot emit an empty successful turn. Counter-evidence: +`src/adapters/cursor/protobuf-events.ts:1055` can return `[]`, +`finalizeTurnEvents` emits `done` without semantic output (`:1365`), and +`emptyCompletionRetry` is off by default (`src/config.ts:869`). + +Reclassified **INVALID/WONTFIX → IMPLEMENT (protocol gap)**. wp11 owns closing the +zero-output producer path. "Not reproduced" is a note, not a verdict. + +## B3 — Security/auth material in tracked devlog. ACCEPTED, MOVED. + +`AGENTS.md:95-128` is unambiguous and the plan violated it: unreleased credential and +replay analysis in `030` §#2497, and unimplemented rotator design in `050`. Both are +pre-disclosure — #2497 is unmerged auth work and #2568's rotator does not exist yet. + +Resolution: both sections are reduced to a public pointer (issue/PR number plus the +already-public gate name) and the analysis moves to `.tmp/` for the duration. The +redaction lands before this unit is pushed. + +## B4 — WP13 is not a credible single phase. ACCEPTED, RESTRUCTURED. + +The honest reading is stronger than the plan's: #1478's owner disposition says it +needs its own cycle, #820 says explicitly it is roadmap work "not an issue a backlog +pass should touch", #1049 is deliberately deferred. Four L/HIGH programs are not one +work-phase, and closing them by adjudication would be the completion-shrinking +GOAL-COMPLETE-GATE-01 exists to stop. + +Split into wp13a/wp13b/wp13c/wp13d, one issue per PABCD cycle. If a cycle proves the +work exceeds this loop's bound, that phase reports `NEEDS_HUMAN` or +`BUDGET_EXHAUSTED` — not a blanket DONE. #1048 goes first as the closest to closable. + +## B5 — WP12 is narrower than its issues. ACCEPTED, WIDENED. + +#2463-#2465 require persisted schema/baseline, convergence, management API, CLI, docs +and GUI; doc 080 framed them as `Models.tsx` work. wp12 acceptance now names each +non-GUI surface, so a GUI-only implementation cannot satisfy it. + +## Non-blocking and missed hazards adopted + +- **N2 stale heads.** #2563 is now `bc37d3d7`, non-draft, current-base; #2503 is 59 + behind / 2 ahead. Doc 010's SHAs are stale; re-verify at exact head before acting. +- **H2 shared-core serialization.** #2563, #2497, #2488, #2558 and the later + architecture work all touch `src/server/responses/core.ts`; #2488 also touches + `src/lab/`. Serialize them, rebase each at exact head, and make + `tests/core-lab-boundary.test.ts` a required gate (`AGENTS.md:37-53`). +- **H3 #2465 before #2464.** Adopted as a hard order: a non-empty preset allowlist + makes #2464 structurally inert for preset providers and avoids blocklist growth. +- **H4 WP7 surface enumeration.** wp7 must enumerate every `hasKeyPoolFailover` call + site (Responses core, compact Responses, native Chat) or it can generalize the + rotator while leaving live OAuth 429 paths unfixed. Presence-driven default-on is + flagged as an explicit consent question for the user. +- **H5 Google rebase order.** #2512/#2513 both rebase over merged #2532's + `google.ts`: rebase → full adapter CI → rebase second over that result → combined + Google suites. + +## New item admitted this round (LOOP-UNIT-CHAIN-01) + +The user reported a further defect: a Codex model configured with a 922k context +window is reported as 258k inside a subagent. Admitted as wp15 with its own +investigation; it is a catalog/context-resolution defect, not part of any existing +phase. + +## Verification environment (user directive, this round) + +Pushes use `--no-verify`; the pre-push hook duplicates repository CI and blocks the +loop for minutes per push. Long or device-specific verification runs asynchronously on +`ssh lidge` / `ssh macmini`, with a real install on `macmini` when a released build +must be exercised. CI is the final gate, repaired at the end rather than per step. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/010_wp2_merge_train_remainder.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/010_wp2_merge_train_remainder.md new file mode 100644 index 0000000000..eb8f0ce6f5 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/010_wp2_merge_train_remainder.md @@ -0,0 +1,26 @@ +# 010 — wp2/wp3 merge train remainder + +Landed already (verified locally, focused suites green, admin-squashed onto dev): +#2528 (7cf041cf7), #2555 (70eb01d19), #2532 (fea4538d5), #2515 (6c4556cfb), +#2474 (b33d82dc3), #2550 (e42778adc). + +## Remaining in this decade + +### #2563 — Cursor ref-less checkpoint ownership +Head moved to `f7892785` after the lane report and the PR returned to draft, which +resets the contributor readiness checklist by design. The code at head `93057665` was +verified locally: `bun test tests/cursor-request-builder.test.ts` → 49 pass. +Action: re-verify the NEW head, then merge. The one unresolved CodeRabbit thread asks +for broader ja/ translation parity on a pre-existing doc and is not a correctness +blocker. + +### #2503 — xAI verbosity +53 commits behind dev, and the lane found the fix incomplete: an explicit +`supportsVerbosity: false` is lost through combo derivation +(`src/codex/catalog/aggregation.ts:173`) and trusted replacement rows +(`src/codex/catalog/provider-fetch.ts:2147`), and live-discovered xAI/Kiro ids can +still advertise verbosity because `modelRecordValue` has no provider-wide fallback +(`src/reasoning-effort.ts:83`). +Action: rebase onto dev, add the conservative false through both derivation paths plus +a provider-wide fallback, extend `tests/codex-catalog.test.ts`. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/020_wp4_google_lane.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/020_wp4_google_lane.md new file mode 100644 index 0000000000..6dc401728c --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/020_wp4_google_lane.md @@ -0,0 +1,32 @@ +# 020 — wp4: Google adapter lane (#2510, #2512, #2513) + +No merge-order dependency between #2510 and the other two (#2510 touches +`google-errors.ts`; #2512/#2513 touch `google.ts`), but #2512 and #2513 will +textually conflict and must be sequenced. + +### #2510 — Antigravity quota exhaustion classification +Defect: the transient guard matches `retry after` but not the standard `retry-after` +spelling (`src/adapters/google-errors.ts:33-51`), so +`Quota exceeded; retry-after: 60` is classified as permanent exhaustion via +`isQuotaExhaustedBody` (`:104-113`). A transient 429 then suppresses retry and can +trigger account-fallback exhaustion. +Fix: add the hyphenated spelling to the transient guard; regression in +`tests/google-errors.test.ts`. Effort XS. + +### #2512 — max output token clamp +Defect: substring matching (`src/adapters/google.ts:51-58`) treats any id containing +`pro`/`oss` as known, silently capping every unknown model at 16,384. The PR's own +tests lock that fallback in (`tests/google-output-clamp.test.ts:5-12`), and it +contradicts `structure/02_config-and-codex-home.md:321` (explicit request values win). +Fix: exact-id matching with an explicit unknown-model passthrough; rewrite the test to +pin passthrough rather than the silent cap. + +### #2513 — thought-signature replay +Defect: durable lookup applies to every Google mode (`src/adapters/google.ts:268-277`) +but eviction is restricted to Cloud Code Assist/Vertex (`:631-646`), so AI Studio +keeps rejected signatures cached — replay-store poisoning. Separately the new +persistence suite writes without `OPENCODEX_HOME` isolation +(`tests/google-signature-history-roundtrip.test.ts:605`), so it can touch the +operator's real config. +Fix: apply eviction across all modes; sandbox the test home. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/030_wp5_hygiene_and_drafts.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/030_wp5_hygiene_and_drafts.md new file mode 100644 index 0000000000..81a806d454 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/030_wp5_hygiene_and_drafts.md @@ -0,0 +1,40 @@ +# 030 — wp5: hygiene-blocked and draft PRs + +Two distinct gate failures, not one. + +### #2567 — `missing_regression_test` (satisfiable) +The change sets `timeout: 0` on upstream fetches. The gate objects because the PR +changes source files and adds no test. +Fix: add propagation coverage in `tests/fetch-header-timeout.test.ts` and +`tests/claude-messages-endpoint.test.ts`. + +### #2490 — `unsponsored_surface` only +Quota-window preservation. Code was reviewed and found sound; CodeRabbit's one finding +was resolved by the author. The failing gate is a maintainer-sponsorship requirement on +the quota surface, not a defect. +Action: sponsor, verify at exact head, merge. + +### #2497 — `unsponsored_surface`, credential boundary +Native-main token refresh and replay. This is the authentication/credential surface that +`AGENTS.md` places under explicit security review, and the change is unmerged, so its +analysis is pre-disclosure material. Per `AGENTS.md` §"Security working notes" the +review notes live in scratch (`.tmp/260825_backlog_scratch/`), not here. +Action: rebase, exact-head security review, then sponsor. HIGH risk; do not shortcut. + +### #2488 — two correctness blockers +1. `adapterFailureFromEvent` overwrites the classified policy code before testing it + (`src/bridge.ts:131`), so a conflicting-code policy failure stays 502/retryable — + a retry across a safety boundary. +2. `normalizeUpstreamErrorText` takes the first field-bearing envelope + (`src/server/responses/core.ts:694`) whereas passthrough scans every candidate + (`src/server/responses/passthrough-error.ts:15`), so a generic outer envelope can + hide a nested `cyber_policy`. +Both need regressions in `tests/cyber-policy-error-fidelity.test.ts`. +Serialization: this PR touches `core.ts` and `src/lab/` — see 001 §H2. + +### #2542 — stale catalog during refresh +All code-review findings addressed at head `e30a0cfd`, but 51 commits behind and the +focused file still reports one failure at +`tests/codex-app-server-processes.test.ts:925`. +Action: rebase, then dispose of that failure at exact head. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/040_wp6_cursor_catalog.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/040_wp6_cursor_catalog.md new file mode 100644 index 0000000000..6c69974c39 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/040_wp6_cursor_catalog.md @@ -0,0 +1,27 @@ +# 040 — wp6: Cursor catalog refresh (#2569) + +Measured 2026-08-25 against a live logged-in account: `GetUsableModels` returns 204 +wire ids normalizing to 34 base models; `CURSOR_STATIC_MODELS` carries 50 entries. + +Missing from the catalog: `gemini-3.7-flash` (low/medium/high) and +`gemini-3.6-flash` (minimal/low/medium/high). `minimal` is not currently in +`CANONICAL_EFFORT_SUFFIXES`, which is derived from `CURSOR_MODEL_EFFORT_TIERS` +values — listing it in the 3.6 ladder admits it. + +Drifted ladders: `claude-opus-5`, `claude-4.6-sonnet`, `gpt-5.5`, and the three +`gpt-5.6-*` families (live exposes a `none` tier the map lacks). + +Unmodelled axis: a `-thinking` family whose suffix ORDER varies — +`{base}-thinking-{effort}` for Opus 4.7/4.8/5, `{base}-{effort}-thinking` for +4.6/4.5-opus, bare `{base}-thinking` for 4-sonnet/4.5-sonnet. +`isCursorModelAvailableForAccount` matches none of these, so they are invisible. + +Static-only entries (13) survive as the logged-out/discovery-failure fallback and +should be pruned or re-justified. `glm-5.3` is a documented preemptive seed and stays. + +Decision for this phase: add the two Gemini models with their real ladders, admit +`minimal`, refresh the drifted ladders, prune the stale static entries, and expose the +`-thinking` families as first-class base ids the way the `-fast` families were handled +in `831810c13`. Vision classification must keep the new Gemini rows on the native path +(`CURSOR_NO_VISION_MODELS` currently lists composer/glm only). + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/050_wp7_oauth_failover.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/050_wp7_oauth_failover.md new file mode 100644 index 0000000000..cdea11f25b --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/050_wp7_oauth_failover.md @@ -0,0 +1,24 @@ +# 050 — wp7: generic OAuth multi-account 429 failover (#2568) + +Public statement of the gap (the issue is public; the patch design is not, and lives in +`.tmp/260825_backlog_scratch/050_full_analysis.md` until it ships — `AGENTS.md` +§"Security working notes"). + +Today's ladder: API-key pools rotate by default (`hasKeyPoolFailover`), the Codex pool +has its own quota/lease machinery, and Anthropic OAuth rotates only when its opt-in is +set. `hasKeyPoolFailover` returns false for `authMode === "oauth"`, so several OAuth +providers have no recovery path on a 429. + +Phase requirements (acceptance, not design): + +1. Enumerate EVERY `hasKeyPoolFailover` call site — Responses core, compact Responses, + native Chat — and prove each observable OAuth 429 path is covered. Generalizing the + rotator without this leaves live paths unfixed (001 §H4). +2. The Codex pool is out of scope; its quota scopes, probe leases and affinity must not + be reimplemented. +3. Existing Anthropic configuration keeps its current meaning. +4. Rotation is bounded per request. +5. **Open consent question for the user:** presence-driven default-on rotation spends a + second account's subscription quota. This is a product decision, not a code decision, + and is escalated rather than settled by an opt-out knob. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/060_wp8_wp9_cli_and_platform.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/060_wp8_wp9_cli_and_platform.md new file mode 100644 index 0000000000..918a46c476 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/060_wp8_wp9_cli_and_platform.md @@ -0,0 +1,35 @@ +# 060 — wp8/wp9: CLI quota surfaces and platform fixes + +### #2565 — `ocx provider quota` prints a count +`quota()` (`src/cli/provider-runtime.ts:105`) renders through `summaryLines()`, a +depth-1 flattener that emits `N item(s)` for a non-scalar array +(`src/cli/runtime-api.ts:294`). The correct per-report renderer already exists: +`quotaParts()`/`providerQuotaLine()` (`src/cli/account-extended.ts:267`), used today +by `ocx account refresh`. Effort XS; add a rendering regression. + +### #2566 — per-account quota in `ocx account list` +Server side already exists: `fetchProviderAccountQuotas` +(`src/providers/quota.ts:1561`) exposed at +`/api/oauth/accounts?provider=…"a=1` (`oauth-account-routes.ts:268`), gated to +Anthropic by `supportsPerAccountQuota()` (`quota.ts:1395`). The CLI never passes +`quota=1` (`src/cli/account-api.ts:237`) and rejects `--quota` +(`src/cli/account.ts:18`). Add the opt-in flag, keep default listing cheap, update the +eight locale docs. + +### #2558 — Fast falsely reported as downgraded +`src/providers/fastwire.ts:345` treats any non-priority response tier as a confirmed +`response-declined`, and `TierObservationContext` (`src/types/provider.ts:107`) has +no destination-authority field, so a ChatGPT-forward destination that echoes +`default` is indistinguishable from a real downgrade. Canonical forward detection +already exists (`src/providers/openai-tiers.ts:34`) and the route is in scope where +the context is built (`src/server/responses/core.ts:1638`). Note: `fastOutcome` +drives priority pricing, so this changes cost attribution. + +### #2557 — Windows `--restart-desktop-app` +Two defects: PowerShell statements are joined with spaces +(`src/codex/desktop-app-restart.ts:124`), and a thrown probe becomes `[]` +(`:140`) → `no_targets` (`:268`) → the CLI prints "not running" +(`src/cli/dispatch.ts:618`). The reason union has no probe-failure state (`:43`). +HIGH risk: this is a process-termination path and must stay fail-closed on both the +initial and PID-recheck probes. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/070_wp10_wp11_slug_and_exec.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/070_wp10_wp11_slug_and_exec.md new file mode 100644 index 0000000000..72f10faab0 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/070_wp10_wp11_slug_and_exec.md @@ -0,0 +1,24 @@ +# 070 — wp10/wp11: slug equivalence and the exec_command class + +### #2491 — four slug-equivalence relations +Confirmed on dev: +1. visibility compares lossy `slugEquivalenceKey(routedSlug(...))`, collapsing + `a/b` and `a-b` (`src/codex/catalog/provider-fetch.ts:1613`); +2. persisted sync rebuilds the same lossy keys independently + (`src/codex/catalog/sync.ts:819,1038`); +3. `ocx models remove` uses exact `slugEquals` (`src/cli/models.ts:278`, + `src/providers/slug-codec.ts:83`); +4. routing throws on encode collisions (`src/router.ts:650`, + `slug-codec.ts:72`). +The over-grant is already pinned as expected behaviour in +`tests/selected-models.test.ts:88`. Unify on one relation and update that pin. + +### #2472 — exec_command zero-output class → CLOSE +Neither `wall_time_seconds` nor `exec_command` appears under `src/` or `tests/`; +the result envelope is the Codex host's. The Cursor call-ID theory is disproved: live +bridge calls pass `allowEmptyArgs: true` +(`src/adapters/cursor/live-transport.ts:243`) and duplicate call IDs are deduplicated +deliberately (`src/adapters/cursor/protobuf-events.ts:1044`). The related zero-output +stream mitigation already shipped in `88b7cc057`. Close with this evidence rather than +changing proxy finalization, which would risk duplicate retries/execution/billing. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/080_wp12_catalog_ux.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/080_wp12_catalog_ux.md new file mode 100644 index 0000000000..c9e02cf66b --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/080_wp12_catalog_ux.md @@ -0,0 +1,28 @@ +# 080 — wp12: catalog defaults and aliases (GUI-bearing) + +All three issues are unimplemented (only docs-only PR #2466 merged, `438b9cc77`) and +all three change `gui/src/pages/Models.tsx`. Per the loop contract this phase routes +through cxc-dev-uiux-design for direction, then cxc-dev-frontend for implementation and +rendered verification. No visual claim without a screenshot. + +### #2465 — latest-only default preset +Configuration has only `selectedModels`, where absent/empty exposes everything +(`src/types/provider.ts:262`) and an empty selection deletes the allowlist +(`src/server/management/model-routes.ts:546`). No `modelPreset` symbol exists. +Care: "empty preset materializes to all" means zero-match handling must be atomic. + +### #2464 — new models arrive disabled +The live cache is memory-only with a 5-minute TTL (`src/codex/model-cache.ts:1`), so +there is no durable baseline to diff arrivals against. HIGH risk: baseline corruption +or repeated arrival detection could hide entire catalogs or repeatedly override a +user's enable choice. + +### #2463 — provider/model aliases +`displayName` is display-only metadata (`src/types/config.ts:175`); the only runtime +alias today is the combo alias resolved before provider routing +(`src/router.ts:625`). HIGH risk: alias collisions can shadow combos, native models, +account namespaces, or `defaultProvider`. + +Sequencing note: #2464 and #2465 both write the visibility baseline and must not be +implemented in the same cycle. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/090_wp13_architecture.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/090_wp13_architecture.md new file mode 100644 index 0000000000..20be1681bf --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/090_wp13_architecture.md @@ -0,0 +1,41 @@ +# 090 — wp13: the four architecture issues + +Each was checked against `devlog/_fin/` and against `src/`. None is stale-but-done. + +### #1478 — config rebase provenance +`src/config.ts:2590` stores snapshot baselines only, and `:2901` still infers intent +from key presence, so deletion and unseen-key cannot be distinguished. Both regressions +are separately pinned (`tests/config-user-edits.test.ts:652` and `:221`). +`devlog/_plan/260823_owner_backlog_closeout/110` explicitly retains this as unresolved. +L / HIGH: persisted-config schema migration plus provenance at every top-level deletion +writer (12 call sites). + +### #1049 — pre-substrate Codex home adoption +`src/codex/inject-coordination.ts:87` still classifies routed/indeterminate +pre-substrate homes as `legacy-uncoordinated`, and `src/codex/inject.ts:932` bypasses +transition publication on that path. `git grep adoption-pending` returns nothing. +The deferral is recorded at `devlog/_fin/260816_wave34_closeout/101`. +L / HIGH: incorrect crash-safe publication can strand every pre-substrate home. + +### #1048 — WP13 composed acceptance +PR #1106 (`43a1fdc45`) delivered the workstation suite and #2452 (`6b0f61f64`) is an +ancestor of dev, but `git ls-tree origin/dev scripts/disposable-host` is empty and +`devlog/_fin/260806_wp13_toggles_resume/030` explicitly excludes P09/P10/P18/P34-P36. +L / HIGH: service-manager acceptance is destructive and platform-specific — it cannot +run on an ordinary workstation. + +### #820 — bounded 32-session tool recall +Partial bounds landed via #829 (`09a0a1826`): per-call/turn/transport limits +(`src/lib/translator-budget.ts:1`) and a 256-turn global gate +(`src/server/lifecycle.ts:32`). But lifecycle still keys leases by `AbortController` +(`:160`) rather than logical session lanes, and no 32/64-session harness exists. +The scheduler architecture is explicitly deferred at +`devlog/_fin/260801_zero_leak_state_stores/035:677`. +L / HIGH: spans protocol, memory admission, retries, and account affinity. + +## Honest assessment + +This phase is four L/HIGH units. It is the single largest risk to the DONE criterion, +and it is where a BUDGET_EXHAUSTED or NEEDS_HUMAN outcome is most plausible. It is +sequenced last so that everything cheaper lands first. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/100_wp6_wp8_execution.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/100_wp6_wp8_execution.md new file mode 100644 index 0000000000..80d821e483 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/100_wp6_wp8_execution.md @@ -0,0 +1,50 @@ +# 100 — wp6/wp8 execution record (issues #2569, #2565, #2566) + +Cycle after the roadmap lock. Three maintainer-authored issues closed, each with its own PR +onto `dev`. + +## #2569 — Cursor catalog drift (2 PRs) + +**#2584** catalogued `gemini-3.6-flash` and `gemini-3.7-flash`, which the live roster +carries but the static catalog did not. Adding them exposed a second defect: +`gemini-3.6-flash` is the only Cursor model with a `minimal` rung, and +`cursorModelEffortLadder` filtered against the canonical five-rung order, so the tier was +dropped from the picker while `cursorEffortSuffix` would have sent it — declared but +unreachable. + +**#2585** exposed the 13 `-thinking` families. `isCursorModelAvailableForAccount` matches a +base id against `{base}`, `{base}-{effort}` and the family wire form; no `-thinking` id +matches any of those, so all 46 thinking wire ids were invisible. The marker's position is +family-dependent and the wrong order is rejected `ERROR_BAD_MODEL_NAME`, so all three +shapes are modelled: thinking-then-effort, effort-then-thinking, bare. + +Evidence: composed every catalogued id/tier pair against a live `GetUsableModels` roster — +46 matched, 0 mismatched. Visible models 41 → 52. + +Not done, deliberately: the 13 static-only entries stay. They are the logged-out and +discovery-failure fallback, and `filterCursorConfiguredModelsByLiveDiscovery` already drops +them when live discovery succeeds. + +## #2565 — `ocx provider quota` printed a count (#2586) + +`quota()` rendered through `summaryLines()`, a depth-1 flattener that emits `N item(s)` for +a non-scalar array, so every fetched report was discarded. Now renders through +`providerQuotaLine`, the formatter `ocx account refresh` already uses. + +## #2566 — per-account quota in `ocx account list` (#2587) + +Server side already existed; the CLI never passed `quota=1`. Added `--quota` (opt-in, +because the server probes once per stored credential) and `--refresh`. Unprobed shows `-`, +failed probe shows `unavailable` — blank would read as "no usage" rather than "not +measured". Live proof on three Anthropic logins: active account 45% weekly while a sibling +sits at 98%. + +## Self-inflicted regression, found and repaired + +#2578 (my #2503 landing) made the catalog hint pass read `PROVIDER_REGISTRY`. A gather +flight captures its registry authority up front and forbids later reads, so every hint pass +became that forbidden read and a custom-destination flight lost its own discovery result. +Bisected: green at `4d3d2716e`, red from `844885ab1`. Repaired in #2582 by materializing the +default at seed time (`applyVerbosityDefaults`) and stripping it from saved config per the +#1100 invariant — whose test caught the first version of the patch. + From b3999c19268f6b7e1cffbce576ebcd3191ff25b8 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 01:51:40 +0900 Subject: [PATCH 023/336] fix(fastwire): do not read a non-authoritative tier echo as a downgrade (#2589) The ChatGPT-internal Codex backend returns service_tier: default even on turns it scheduled as priority, so treating that echo as a verdict marked every Fast request response-declined. That is a false negative, and it also skews cost attribution because fastOutcome drives priority pricing. TierObservationContext gains responseTierAuthoritative. Absent means assume authoritative, so the public API path is unchanged; core.ts sets it false only for the canonical ChatGPT-forward destination, where the echo can neither confirm nor deny Fast. The raw echo is still recorded - this suppresses a verdict, not the evidence. Driven red first: removing the guard fails exactly the new case. --- src/providers/fastwire.ts | 8 ++++- src/server/responses/core.ts | 10 +++++- src/types/provider.ts | 9 ++++++ tests/fastwire-observability.test.ts | 48 ++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/providers/fastwire.ts b/src/providers/fastwire.ts index 9098bd9df9..34116e7133 100644 --- a/src/providers/fastwire.ts +++ b/src/providers/fastwire.ts @@ -246,6 +246,7 @@ export function tierObservationContext( policy: ResolvedFastPolicy, fastMode: boolean | undefined, callerTier: string | undefined, + responseTierAuthoritative?: boolean, ): TierObservationContext { return { capability: policy.capability, @@ -253,6 +254,7 @@ export function tierObservationContext( fastWire: policy.fastWire, demandDecision: fastMode === true ? "force-fast" : fastMode === false ? "force-default" : "inherit", ...(callerTier !== undefined ? { callerTier } : {}), + ...(responseTierAuthoritative !== undefined ? { responseTierAuthoritative } : {}), }; } @@ -344,7 +346,11 @@ export function createAdapterTierMetadata( const responseCanConfirmFast = effectiveFastRequested && context.eligibility === "eligible" - && wireValue !== null; + && wireValue !== null + // A destination whose echo is not authoritative can neither confirm nor deny Fast. The + // ChatGPT-internal Codex backend echoes "default" on priority-scheduled turns, so believing + // it reported every Fast request as `response-declined` (#2558). + && context.responseTierAuthoritative !== false; return { outcome, observeResponseServiceTier(value: unknown) { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e66e85bd6f..cc97021462 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1701,7 +1701,15 @@ async function applyFinalRouteRequestNormalization(args: { ); const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy); const callerTier = parsed.options.serviceTier; - parsed.options.tierObservation = tierObservationContext(fastPolicy, config.fastMode, callerTier); + // The ChatGPT-internal Codex backend echoes `service_tier: "default"` even on turns it + // scheduled as priority, so its echo cannot confirm OR deny Fast. Believing it reported every + // Fast request as `response-declined` (#2558). The public API's echo stays authoritative. + parsed.options.tierObservation = tierObservationContext( + fastPolicy, + config.fastMode, + callerTier, + isCanonicalOpenAiForwardProvider(route.provider) ? false : undefined, + ); parsed.options.tierDecision = decideTier(fastPolicy, config.fastMode, callerTier); parsed.options.serviceTier = tierValueAfterDecision(parsed.options.tierDecision, callerTier); if (fastPolicy.capability === true && fastPolicy.fastWire === null) { diff --git a/src/types/provider.ts b/src/types/provider.ts index e6a66d577f..de490414ca 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -115,6 +115,15 @@ export interface TierObservationContext { fastWire: FastWire | null; demandDecision: "force-fast" | "force-default" | "inherit"; callerTier?: string; + /** + * Whether the destination's echoed `service_tier` is authoritative about Fast scheduling. + * + * The ChatGPT-internal Codex backend returns `service_tier: "default"` on turns that were in + * fact scheduled as priority, so treating its echo as a downgrade produced a false + * `response-declined` on every Fast request (#2558). Absent means "assume authoritative", + * preserving the behaviour for the public API where the echo does mean what it says. + */ + responseTierAuthoritative?: boolean; } export type TierDecision = diff --git a/tests/fastwire-observability.test.ts b/tests/fastwire-observability.test.ts index d712a038ad..3a21681cae 100644 --- a/tests/fastwire-observability.test.ts +++ b/tests/fastwire-observability.test.ts @@ -827,3 +827,51 @@ describe("FastWire gate and compatibility fingerprint", () => { expect(buildBehaviorFingerprintV1(base)).not.toBe(buildBehaviorFingerprintV1(performance)); }); }); + +describe("#2558 a non-authoritative destination cannot confirm or deny Fast", () => { + /** + * The ChatGPT-internal Codex backend echoes service_tier: "default" even on turns it + * scheduled as priority. Believing that echo classified every Fast request as + * response-declined, which is a false negative that also drives priority cost attribution. + */ + const nonAuthoritative = () => observation({ responseTierAuthoritative: false }); + + test("an echoed default is not a downgrade when the destination is not authoritative", () => { + const tracker = createAdapterTierMetadata( + nonAuthoritative(), + { kind: "set", value: "priority" }, + "service-tier", + "priority", + )!; + tracker.observeResponseServiceTier("default"); + expect(tracker.outcome.fastDowngradeReason).toBeUndefined(); + expect(tracker.outcome.fastOutcome).not.toBe("downgraded"); + // The raw echo is still recorded — this suppresses a verdict, not the evidence. + expect(tracker.outcome.responseServiceTier).toBe("default"); + }); + + test("the same echo IS a downgrade on an authoritative destination", () => { + const tracker = createAdapterTierMetadata( + observation(), + { kind: "set", value: "priority" }, + "service-tier", + "priority", + )!; + tracker.observeResponseServiceTier("default"); + expect(tracker.outcome.fastOutcome).toBe("downgraded"); + expect(tracker.outcome.fastDowngradeReason).toBe("response-declined"); + }); + + test("an omitted flag keeps the authoritative default", () => { + // Absent must mean "assume authoritative" so the public API path is unchanged. + const tracker = createAdapterTierMetadata( + observation({}), + { kind: "set", value: "priority" }, + "service-tier", + "priority", + )!; + tracker.observeResponseServiceTier("default"); + expect(tracker.outcome.fastOutcome).toBe("downgraded"); + }); +}); + From 816f3a159df84e747b1d13fb7f933d0cb46ff199 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 02:14:37 +0900 Subject: [PATCH 024/336] feat(oauth): generic multi-account 429 failover for HTTP-status paths (#2568a) (#2590) * devlog: wp7 OAuth failover plan with call-site enumeration * devlog: wp7 audit response - plan understated scope, split into wp7a-d * feat(oauth): generic multi-account 429 failover for HTTP-status paths hasKeyPoolFailover returns false for authMode oauth, and the only OAuth rotator that exists is Anthropic's behind its own opt-in, so xAI, Cursor, Kimi, Copilot, Antigravity and Nous had no recovery path on a 429 even with several accounts logged in. Adds a rotator that cools the account which actually 429'd and replays on the next eligible one, bounded per request. Deliberately narrower than the Anthropic pool: no affinity, no quota ranking, no probe leases. Codex and Anthropic are excluded - their pools own semantics this must not reimplement. Rotation resolves the FULL credential snapshot rather than a bearer: Antigravity pairs an account-matched projectId with its token and Kiro carries routing metadata, so a token-only swap would mix one account's credential with another's routing data. core.ts already guards that pairing on the initial resolve; this keeps it true across a rotation. Default OFF pending an owner decision on presence-driven activation - the issue asks for no toggle, but rotating spends a second subscription account's quota, so the default is escalated rather than chosen here. Scope is the HTTP-status paths in core.ts only. Cursor classifies 429s as adapter events after HTTP 200 is committed, and images.ts bypasses core entirely; both are recorded as follow-up phases in devlog 111. --- .../110_wp7_oauth_failover.md | 64 +++++++ .../111_wp7_audit_response.md | 85 ++++++++++ src/oauth/generic-account-failover.ts | 160 ++++++++++++++++++ src/oauth/index.ts | 16 ++ src/routing/analytics.ts | 1 + src/server/responses/core.ts | 59 +++++++ src/types/config.ts | 15 ++ src/usage/log.ts | 2 + tests/generic-oauth-failover.test.ts | 115 +++++++++++++ 9 files changed, 517 insertions(+) create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/110_wp7_oauth_failover.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/111_wp7_audit_response.md create mode 100644 src/oauth/generic-account-failover.ts create mode 100644 tests/generic-oauth-failover.test.ts diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/110_wp7_oauth_failover.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/110_wp7_oauth_failover.md new file mode 100644 index 0000000000..c5d25fb888 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/110_wp7_oauth_failover.md @@ -0,0 +1,64 @@ +# 110 — wp7: generic OAuth multi-account 429 failover (#2568) + +## Call-site enumeration (audit hazard H4) + +Every `hasKeyPoolFailover` reference on dev, and what it actually does: + +| File | Status | +|---|---| +| `src/providers/key-failover.ts:86` | the definition | +| `src/server/responses/core.ts:4908` | live 429 rotation loop (streaming) | +| `src/server/responses/core.ts:5252` | live 429 rotation (non-streaming) | +| `src/server/chat-native.ts:248` | live 429 rotation (native Chat) | +| `src/server/responses/compact.ts:86` | **import only** — no call | +| `src/server/responses/collaboration.ts:69` | **import only** — no call | +| `src/server/responses/encrypted-payload.ts:68` | **import only** — no call | + +So there are exactly THREE live key-pool rotation sites, not five. The three +import-only files are dead references; they are out of scope here but worth noting, since +the audit's concern was that a rotator could be generalized while leaving live paths +unfixed. + +OAuth rotation today exists at exactly two sites, both in `core.ts` +(`:4941` streaming, `:5288` non-streaming), and both are Anthropic-only and gated on +`isAnthropicAccountPoolEnabled`. + +## Design + +`anthropic-routing.ts` is already written against generic primitives. The provider-specific +parts are three: the constant `PROVIDER = "anthropic"`, the config gate, and token minting +via `getAnthropicPoolAccessToken`. The last one is NOT trivially generic — it enforces a +fail-closed rule about background `local-cli` credential slots that exists because of how +the Claude CLI stores its token. A generic rotator must not silently apply or drop that rule. + +Plan: parameterize the rotator by provider, keep the Anthropic credential rule attached to +Anthropic, and let each provider declare whether it participates. + +## Consent decision (recorded assumption, HIGH severity — needs owner review) + +The issue proposes presence-driven activation: 2+ accounts means rotate, mirroring how a +2-key API pool is treated as consent. `request_user_input` is denied under an active goal, +so I could not ask, and this is a product decision rather than a code one. + +**Assumption taken: ship it OPT-IN, not presence-driven.** Reasoning: + +- Rotating across subscription accounts spends a second account's quota. An API key pool + spends the operator's own metered credit; a subscription account is a different kind of + resource, and the Anthropic pool shipped opt-in for exactly this reason. +- Opt-in is reversible in one direction only that matters: turning it on later costs the + user nothing, whereas a default-on rotation that surprises someone has already spent the + quota. +- The audit flagged this specific question as needing explicit review rather than being + settled by an opt-out knob. + +If the owner prefers presence-driven, the change is a one-line default in +`oauthAccountFailoverEnabled` — the mechanism does not change. + +## Acceptance + +1. A provider with 2+ eligible accounts and the knob on rotates on 429 and replays once. +2. A single-account provider is a strict no-op. +3. The knob off is a strict no-op regardless of account count. +4. Existing Anthropic configuration keeps its current meaning. +5. The Codex pool is untouched. + diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/111_wp7_audit_response.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/111_wp7_audit_response.md new file mode 100644 index 0000000000..d4fc28f4c1 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/111_wp7_audit_response.md @@ -0,0 +1,85 @@ +# 111 — wp7 audit response: the plan was wrong, and the scope is bigger than #2568 states + +Auditor verdict: **fail**, 7 blocking findings. I verified each against the tree. Six hold; +one I partially rebut. The honest conclusion is that `110` understated the problem badly +enough that it should not be implemented as written. + +## Verified against the tree + +**B1 — the call-site table was incomplete. ACCEPTED.** The three import-only files were +right, and the three direct callers were right, but there are two more LIVE rotation sites +reached through an injected `on429` hook rather than a direct call: +`src/images/loop.ts:574` and `src/web-search/loop.ts:511`, wired from +`core.ts:4267` and `core.ts:4355`. My `rg` for the symbol could not see them because the +call site passes a closure. That is exactly the failure mode hazard H4 warned about. + +**B2 — the standalone Antigravity image endpoint bypasses core entirely. ACCEPTED.** +`src/server/images.ts` resolves the active OAuth token, reads its project separately, and +returns upstream 429 without any rotation. + +**B3 — Cursor 429s never reach an HTTP-status rotator. ACCEPTED, and this is the finding +that breaks the plan.** Cursor converts transport failures into adapter EVENTS +(`src/adapters/cursor.ts:308`), and both the streaming and buffered paths have already +committed HTTP 200 by then (`core.ts:4556`, `:4619`). `cursor-errors.ts` classifies a bare +`resource_exhausted` tail into a 429 class at the ADAPTER layer, not as a response status. +So a `response.status === 429` loop — which is the entire mechanism #2568 proposes — cannot +serve Cursor at all. The issue names Cursor as an affected provider; the proposed design +cannot deliver it. + +**B4 — a token-only resolver is unsafe. ACCEPTED.** `getValidAccessTokenForAccount` returns +a string, but Copilot credentials carry an account-specific `apiBaseUrl` +(`src/oauth/github-copilot.ts:281`) and Antigravity needs an account-matched `projectId` +that `core.ts:2780` deliberately keeps paired with the token snapshot precisely so "an +account rotation cannot mix a fresh token with project metadata re-read from a different +credential generation". The existing code already anticipated this hazard; my plan would +have reintroduced it. + +**B5 — continuity/cache criteria absent. ACCEPTED.** Anthropic rebinds affinity on rotation +(`anthropic-routing.ts:490`); xAI pins conversation/session ids +(`providers/xai-transport.ts:105`); Cursor scopes checkpoints to credential identity +(`cursor/request-builder.ts:422`). A rotator that ignores these silently corrupts +continuation state rather than failing loudly. + +**B7 — the acceptance criteria permitted a partial ship. ACCEPTED.** "A provider" and "one +replay" would have been satisfied by xAI-Responses-only, while Anthropic already allows +three failovers. + +## Partial rebuttal + +**B6 — opt-in vs presence-driven.** The auditor is right that the issue asks for failover +"without first discovering and enabling a toggle", and right that default-off lets the +implementation pass while the reported xAI workflow stays broken. I accept that as the +issue's contract. + +Where I do not fully agree: the auditor treats this as a plan defect. It is a product +decision I was structurally unable to take — `request_user_input` is denied under an active +goal, and spending a second subscription account's quota by default is not a call an agent +should make silently. The correct resolution is not to pick a default under duress; it is to +escalate. Recorded as such below. + +## Consequence: wp7 does not proceed as a single work-phase + +The plan claimed a parameterization. The tree says otherwise: three distinct failure +surfaces (HTTP-status rotation, adapter-event classification, and a bypassing image +endpoint), three distinct credential shapes (bare token, token+apiBaseUrl, +token+projectId), and three distinct continuity contracts. That is a program, not a phase. + +wp7 is therefore split, and the first unit is the only one that can be built safely without +an owner decision: + +- **wp7a** — generic rotator for HTTP-status OAuth paths (`core.ts` streaming and + non-streaming), with a per-provider credential resolver that returns the FULL snapshot + (token plus whatever routing metadata that provider pairs with it), not a bare string. + Gated behind an explicit knob so no default changes. +- **wp7b** — adapter-event 429 rotation, which is what Cursor actually needs. +- **wp7c** — `src/server/images.ts` and the injected `on429` sidecar loops. +- **wp7d** — the activation-default decision. **ESCALATE: needs the owner.** + +## Escalation (NEEDS_HUMAN, recorded) + +The default-on question is not mine to settle. Presence-driven rotation spends another +subscription account's quota without the operator asking for it in that moment; opt-in +leaves the issue's stated workflow broken. Both readings are defensible and the issue text +supports the auditor's. This phase reports **NEEDS_HUMAN** on that specific decision, and +implements nothing that depends on it. + diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts new file mode 100644 index 0000000000..d3fe698014 --- /dev/null +++ b/src/oauth/generic-account-failover.ts @@ -0,0 +1,160 @@ +/** + * Generic OAuth multi-account 429 failover (#2568). + * + * The API-key twin (`providers/key-failover.ts`) rotates by default for any key provider with a + * 2+ pool, but it returns false for `authMode === "oauth"`, and the only OAuth rotator that + * exists is Anthropic's — behind its own opt-in. So xAI, Cursor, Kimi, GitHub Copilot, + * Antigravity and Nous have no recovery path on a 429 even with several accounts logged in. + * + * Deliberately narrower than the Anthropic pool: no session affinity, no quota-ranked selection, + * no probe leases. Those carry provider-specific meaning; this module only answers "the account + * that just 429'd is cooled, is there another one we may use". + * + * NOT a home for Codex (`codex/routing.ts` owns quota scopes and probe leases) or Anthropic + * (`oauth/anthropic-routing.ts` owns affinity and a fail-closed local-cli credential rule). + * Both are excluded by `isGenericFailoverProvider`. + */ +import { getAccountSet } from "./store"; +import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index"; +import { parseRetryAfterMs } from "../combos/failover"; +import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; +import type { OcxConfig, OcxProviderConfig } from "../types"; + +/** Cap same-request rotations so a short Retry-After cannot spin. Mirrors the Anthropic bound. */ +export const GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST = 3; + +const DEFAULT_COOLDOWN_MS = 60_000; +const MAX_COOLDOWN_MS = 15 * 60_000; + +/** + * Providers whose rotation is owned elsewhere and must not be handled here. + * + * `openai` is the Codex pool: quota scopes, probe leases and affinity semantics that this + * module deliberately does not reimplement. `anthropic` has its own pool with a fail-closed + * rule about background local-cli credential slots. + */ +const EXCLUDED_PROVIDERS = new Set(["openai", "anthropic"]); + +interface AccountHealth { + cooldownUntil: number; + cooldownSource: "retry-after" | "default"; +} + +/** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */ +const health = new Map(); + +const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`; + +function isCooled(provider: string, accountId: string, now: number): boolean { + const entry = health.get(healthKey(provider, accountId)); + if (!entry) return false; + if (entry.cooldownUntil <= now) { + health.delete(healthKey(provider, accountId)); + return false; + } + return true; +} + +/** True when this provider participates in generic rotation at all. */ +export function isGenericFailoverProvider(providerName: string, provider: OcxProviderConfig): boolean { + return provider.authMode === "oauth" && !EXCLUDED_PROVIDERS.has(providerName); +} + +/** + * Whether generic rotation is active for this provider. + * + * Default OFF pending an owner decision on presence-driven activation (#2568 asks for no + * toggle; rotating spends another subscription account's quota, so the default is escalated + * rather than chosen here). The mechanism does not change if the default flips. + */ +export function isGenericOAuthFailoverEnabled(config: OcxConfig, providerName: string): boolean { + const provider = config.providers?.[providerName]; + if (!provider || !isGenericFailoverProvider(providerName, provider)) return false; + return config.oauthAccountFailover?.enabled === true; +} + +/** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */ +export function eligibleFailoverAccounts(providerName: string, now = Date.now()): string[] { + const set = getAccountSet(providerName); + if (!set) return []; + return set.accounts + .filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now)) + .map(account => account.id); +} + +/** + * Cool the account that actually 429'd and name the next eligible one, or null. + * + * Returns the id only; the caller mints the credential so a failed refresh does not leave the + * cooldown applied to an account we then could not use. + */ +export function rotateGenericOAuthAccountOn429( + config: OcxConfig, + providerName: string, + failedAccountId: string, + retryAfterHeader: string | null | undefined, + now = Date.now(), +): string | null { + if (!isGenericOAuthFailoverEnabled(config, providerName)) return null; + const set = getAccountSet(providerName); + // A single stored account has nowhere to go; rotating to itself would just replay the 429. + if (!set || set.accounts.length < 2) return null; + + const parsed = parseRetryAfterMs(retryAfterHeader, now); + const cooldownMs = Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS); + health.set(healthKey(providerName, failedAccountId), { + cooldownUntil: now + cooldownMs, + cooldownSource: parsed ? "retry-after" : "default", + }); + sweepExpiredOnWrite(now); + + const eligible = eligibleFailoverAccounts(providerName, now).filter(id => id !== failedAccountId); + if (eligible.length === 0) return null; + // Deterministic: start after the failed account so repeated 429s walk the roster instead of + // hammering whichever id happens to sort first. + const order = set.accounts.map(account => account.id); + const start = order.indexOf(failedAccountId); + for (let i = 1; i <= order.length; i++) { + const candidate = order[(start + i) % order.length]!; + if (candidate !== failedAccountId && eligible.includes(candidate)) return candidate; + } + return null; +} + +/** + * Full credential snapshot for a rotated account. + * + * Returns the snapshot rather than a bare bearer: Antigravity pairs an account-matched + * `projectId` with its token and Kiro carries routing metadata, so a token-only swap would mix + * one account's bearer with another's routing data. + */ +export async function failoverAccountSnapshot( + providerName: string, + accountId: string, +): Promise { + return getValidAccessSnapshotForAccount(providerName, accountId); +} + +/** Earliest remaining cooldown, for a client-facing Retry-After when every account is cooled. */ +export function genericFailoverRetryAfterSeconds(providerName: string, now = Date.now()): number | null { + const set = getAccountSet(providerName); + if (!set) return null; + let earliest: number | null = null; + for (const account of set.accounts) { + const entry = health.get(healthKey(providerName, account.id)); + if (!entry || entry.cooldownUntil <= now) continue; + if (earliest === null || entry.cooldownUntil < earliest) earliest = entry.cooldownUntil; + } + return earliest === null ? null : Math.max(1, Math.ceil((earliest - now) / 1000)); +} + +/** Test seam and manual-recovery hook. */ +export function clearGenericFailoverHealth(providerName?: string): void { + if (!providerName) { + health.clear(); + return; + } + for (const key of [...health.keys()]) { + if (key.startsWith(`${providerName}\u0000`)) health.delete(key); + } +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts index fe3abe7656..adf590ac9b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -490,6 +490,22 @@ export async function getValidAccessTokenForAccount(provider: string, accountId: return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken; } +/** + * Account-scoped resolver returning the FULL snapshot, not just the bearer. + * + * A rotator that swaps only the token silently mixes credential generations: Antigravity pairs + * an account-matched `projectId` with its token (see the pairing comment in + * server/responses/core.ts), Kiro carries routing metadata, and Copilot's observed snapshot + * carries an account-specific API origin. Reading those back from "whichever account is active" + * after a rotation is exactly the mixing this returns in one piece to prevent (#2568). + */ +export async function getValidAccessSnapshotForAccount( + provider: string, + accountId: string, +): Promise { + return resolveAccessSnapshotForAccount(provider, accountId); +} + /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */ function isTerminalRefreshError(err: unknown): boolean { const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); diff --git a/src/routing/analytics.ts b/src/routing/analytics.ts index 9e7b5a0696..6dc217e734 100644 --- a/src/routing/analytics.ts +++ b/src/routing/analytics.ts @@ -119,6 +119,7 @@ const COOLDOWN_RECOVERY_KINDS = new Set([ "key-429", "oauth-401", "anthropic-oauth-429", + "oauth-account-429", ]); function percentile(sorted: number[], p: number): number | undefined { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index cc97021462..ce4f5eb905 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -113,6 +113,13 @@ import { resolveAnthropicAccountForSession, rotateAnthropicAccountOn429, } from "../../oauth/anthropic-routing"; +import { + failoverAccountSnapshot, + GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST, + isGenericFailoverProvider, + isGenericOAuthFailoverEnabled, + rotateGenericOAuthAccountOn429, +} from "../../oauth/generic-account-failover"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; @@ -2733,6 +2740,10 @@ async function handleResponsesInner( let replayOAuthCredentialSnapshot: Pick | undefined; let anthropicPoolAccountId: string | null = null; let anthropicPoolFailovers = 0; + // Generic OAuth rotation (#2568) for providers with no pool of their own. Bound to the account + // the request actually used, so a concurrent rotation cannot cool an innocent replacement. + let genericFailoverAccountId: string | null = null; + let genericFailovers = 0; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -2772,6 +2783,11 @@ async function handleResponsesInner( }; if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; route.provider = { ...route.provider, apiKey: resolved.accessToken }; + // Remember which account actually served this request so a 429 cools THAT one, not + // whichever account is active by the time the response comes back (#2568). + if (isGenericFailoverProvider(route.providerName, route.provider)) { + genericFailoverAccountId = resolved.accountId; + } if (route.providerName === "kiro") { // `{}` is intentional: this is an account-scoped request with no stored routing metadata. // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback. @@ -4969,6 +4985,49 @@ async function handleResponsesInner( break; } } + // Generic OAuth account failover (#2568) for providers with no pool of their own. Opt-in + // and a strict no-op otherwise, so a single-account install and every existing config are + // unchanged. Codex and Anthropic are excluded by isGenericFailoverProvider — their pools + // own quota scopes, probe leases and affinity that this must not reimplement. + while ( + upstreamResponse.status === 429 + && genericFailoverAccountId + && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + && isGenericOAuthFailoverEnabled(config, route.providerName) + ) { + const nextAccountId = rotateGenericOAuthAccountOn429( + config, + route.providerName, + genericFailoverAccountId, + upstreamResponse.headers.get("retry-after"), + ); + if (!nextAccountId) break; + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + try { + // The FULL snapshot, not just the bearer: Antigravity pairs an account-matched + // projectId with its token and Kiro carries routing metadata, so a token-only swap + // would mix one account's credential with another's routing data. + const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); + genericFailoverAccountId = nextAccountId; + genericFailovers += 1; + route.provider = { ...route.provider, apiKey: snapshot.accessToken }; + if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) }; + if (route.provider.googleMode === "cloud-code-assist" && snapshot.projectId) { + route.provider = { ...route.provider, project: snapshot.projectId }; + } + invalidateSameTargetRequest(); + activeAdapter = resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel); + const result = await rebuildAndRefetch("oauth-account-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } catch { + break; + } + } // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry, // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is // the missing authoritative signal. Rebuild once through the same sanitation path used by a diff --git a/src/types/config.ts b/src/types/config.ts index 6dd7f4e86e..6ba3070398 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -583,6 +583,21 @@ export interface OcxConfig { /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ stickyLimit?: number; }; + /** + * Opt-in generic OAuth multi-account 429 failover (#2568). Default OFF. + * + * Rotates to another logged-in account of the SAME provider when one is rate-limited, for + * OAuth providers that have no pool of their own — xAI, Cursor, Kimi, GitHub Copilot, + * Antigravity, Nous. The Codex pool and the Anthropic pool own their own rotation and are + * excluded; enabling this changes neither. + * + * Default OFF is a recorded escalation, not a settled preference: the issue asks for + * presence-driven activation (2+ accounts implies consent, mirroring API-key pools), but + * rotating spends a second subscription account's quota, so the default is left to the owner. + */ + oauthAccountFailover?: { + enabled?: boolean; + }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; /** diff --git a/src/usage/log.ts b/src/usage/log.ts index 7bca650964..65ebf26d6a 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -28,6 +28,7 @@ export type AttemptRecoveryKind = | "key-429" | "rate-limit-429" | "anthropic-oauth-429" + | "oauth-account-429" | "image-413" | "opaque-blob-rejection" | "empty-completion"; @@ -218,6 +219,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set([ "key-429", "rate-limit-429", "anthropic-oauth-429", + "oauth-account-429", "image-413", "opaque-blob-rejection", "empty-completion", diff --git a/tests/generic-oauth-failover.test.ts b/tests/generic-oauth-failover.test.ts new file mode 100644 index 0000000000..77e18df28e --- /dev/null +++ b/tests/generic-oauth-failover.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearGenericFailoverHealth, + eligibleFailoverAccounts, + genericFailoverRetryAfterSeconds, + isGenericFailoverProvider, + isGenericOAuthFailoverEnabled, + rotateGenericOAuthAccountOn429, +} from "../src/oauth/generic-account-failover"; +import { getAccountSet, saveCredential } from "../src/oauth/store"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const originalHome = process.env.OPENCODEX_HOME; +let home: string; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-generic-failover-")); + process.env.OPENCODEX_HOME = home; + clearGenericFailoverHealth(); +}); + +afterEach(() => { + clearGenericFailoverHealth(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + rmSync(home, { recursive: true, force: true }); +}); + +const OAUTH_PROVIDER = { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", +} as unknown as OcxProviderConfig; + +function config(enabled: boolean): OcxConfig { + return { + providers: { xai: OAUTH_PROVIDER }, + ...(enabled ? { oauthAccountFailover: { enabled: true } } : {}), + } as unknown as OcxConfig; +} + +async function seed(count: number): Promise { + for (let i = 0; i < count; i++) { + await saveCredential("xai", { + access: `access-${i}`, + refresh: `refresh-${i}`, + expires: Date.now() + 3_600_000, + accountId: `uuid-${i}`, + } as never, { addAccount: true }); + } + return getAccountSet("xai")?.accounts.map(a => a.id) ?? []; +} + +describe("#2568 generic OAuth account failover", () => { + test("rotates to another logged-in account and cools the one that 429'd", async () => { + const [first, second] = await seed(2); + const next = rotateGenericOAuthAccountOn429(config(true), "xai", first!, null); + expect(next).toBe(second); + // The failed account is cooled, so it is not offered again while the window holds. + expect(eligibleFailoverAccounts("xai")).toEqual([second!]); + }); + + test("a single stored account is a strict no-op", async () => { + // Rotating to itself would replay the same 429 against the same credential, and cooling + // the only account would take the provider out of service for nothing. + const [solo] = await seed(1); + expect(rotateGenericOAuthAccountOn429(config(true), "xai", solo!, null)).toBeNull(); + expect(eligibleFailoverAccounts("xai")).toEqual([solo!]); + }); + + test("the knob off is a strict no-op regardless of account count", async () => { + const ids = await seed(2); + expect(rotateGenericOAuthAccountOn429(config(false), "xai", ids[0]!, null)).toBeNull(); + expect(eligibleFailoverAccounts("xai")).toEqual(ids); + }); + + test("Codex and Anthropic are excluded: their own pools own rotation", () => { + expect(isGenericFailoverProvider("xai", OAUTH_PROVIDER)).toBe(true); + expect(isGenericFailoverProvider("openai", OAUTH_PROVIDER)).toBe(false); + expect(isGenericFailoverProvider("anthropic", OAUTH_PROVIDER)).toBe(false); + }); + + test("a key-auth provider never enters generic OAuth rotation", () => { + const key = { ...OAUTH_PROVIDER, authMode: "key" } as OcxProviderConfig; + expect(isGenericFailoverProvider("groq", key)).toBe(false); + }); + + test("all accounts cooled reports the earliest remaining window", async () => { + const ids = await seed(2); + const cfg = config(true); + expect(rotateGenericOAuthAccountOn429(cfg, "xai", ids[0]!, "120")).toBe(ids[1]); + expect(rotateGenericOAuthAccountOn429(cfg, "xai", ids[1]!, "30")).toBeNull(); + const retryAfter = genericFailoverRetryAfterSeconds("xai"); + // The earliest window wins: a client must not be told to wait for the longest cooldown. + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter!).toBeLessThanOrEqual(30); + }); + + test("Retry-After drives the cooldown length", async () => { + const ids = await seed(2); + rotateGenericOAuthAccountOn429(config(true), "xai", ids[0]!, "600"); + expect(genericFailoverRetryAfterSeconds("xai")).toBeGreaterThan(500); + }); + + test("enablement requires both the knob and a participating OAuth provider", async () => { + await seed(2); + expect(isGenericOAuthFailoverEnabled(config(true), "xai")).toBe(true); + expect(isGenericOAuthFailoverEnabled(config(false), "xai")).toBe(false); + expect(isGenericOAuthFailoverEnabled(config(true), "openai")).toBe(false); + }); +}); + From 2fa2ba74ba497ecabcf69325121d216a72ec6141 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 02:15:52 +0900 Subject: [PATCH 025/336] docs(config): document oauthAccountFailover (#2591) Names the excluded pools, the single-account no-op, the bounded cooldown, the full-snapshot rotation, and the two request paths that do not rotate yet - so the scope limits are discoverable rather than implied. --- .../docs/reference/configuration/providers.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 85cef14e1e..f3214adf1b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -279,6 +279,42 @@ Leave this disabled unless you understand Anthropic account policy risk. Prefer `ocx account use anthropic ` switching when unsure. ::: +### `oauthAccountFailover` (experimental) + +Rotates to another logged-in account of the same provider when one is rate-limited, for OAuth +providers that have no pool of their own — xAI, Cursor, Kimi, GitHub Copilot, Google Antigravity, +and Nous. Off by default. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `oauthAccountFailover.enabled?` | `boolean` | `false` | Enable 429 cooldown failover across stored OAuth accounts. | + +Deliberately narrower than `anthropicAccountPool`: no session affinity, no quota-ranked +selection, no probe leases. It answers one question — the account that just returned 429 is +cooled, is there another one available. + +The Codex pool and the Anthropic pool are excluded and keep their own rotation; enabling this +changes neither. A provider with a single stored account is a strict no-op, and no cooldown is +recorded for it. + +On a 429 the failed account is cooled using `Retry-After` when present (capped at 15 minutes) +or a default backoff, and the request is replayed on the next eligible account, up to three +rotations per request. An account flagged for reauthentication is never selected. Cooldowns are +process-local, so a restart forgets them. + +Rotation carries the alternate account's **full** credential snapshot, not just its bearer, so a +provider that pairs routing metadata with its token — Antigravity's Cloud Code Assist project id, +for example — cannot end up sending one account's token with another account's metadata. + +Current scope is the ordinary Responses request paths. Cursor reports rate limits as adapter +events rather than an HTTP status, and the standalone Antigravity image endpoint has its own +request path; neither rotates yet. + +:::caution[Experimental] +Rotating across subscription accounts spends a second account's quota and may violate provider +terms. Leave this disabled unless that is a tradeoff you have decided to make. +::: + ### Managed record shapes `apiKeys[]` entries contain `id`, `name`, generated `key`, and ISO `createdAt` strings. From c17a7b836879b796dbacb3cbd706df6ae7e5ddb4 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 02:27:51 +0900 Subject: [PATCH 026/336] fix(codex): distinguish a failed desktop probe from an absent app (#2593) Two defects on the Windows --restart-desktop-app path. The probe script joined its PowerShell statements with a space, so $ErrorActionPreference='SilentlyContinue' $root = '...' became one malformed statement and the whole script was rejected. The resulting throw was caught and returned [], which is indistinguishable from a successful enumeration that found nothing. That became no_targets and the CLI told the user the app was not running, silently skipping the restart they had asked for. Statements are now newline-separated, a failed probe returns null, and the caller reports the new process_probe_failed reason. The PID re-check fails closed the same way: it guards a kill, so 'we could not look' must never read as 'the pid was recycled'. Both driven red first: reverting either fails exactly its own cases. --- src/cli/dispatch.ts | 10 ++++++- src/codex/desktop-app-restart.ts | 23 +++++++++++++---- tests/desktop-app-restart.test.ts | 43 +++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 609cd44717..8bf9cbea62 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -615,6 +615,15 @@ async function handleDesktopAppRestart(log: Pick): Pro + "Run 'ocx sync --restart-desktop-app' from an external terminal instead.", ); return; + case "process_probe_failed": + // Distinct from `no_targets`: we could not look, which is not the same as looking and + // finding nothing. Saying "not running" here sent users away believing there was nothing + // to restart (#2557). + log.error( + "Could not enumerate Codex desktop processes, so the app was not restarted. " + + "Quit and relaunch the desktop app manually to refresh the model picker.", + ); + return; case "no_targets": log.log("Codex desktop app is not running; nothing to restart."); return; @@ -630,4 +639,3 @@ async function handleDesktopAppRestart(log: Pick): Pro } } } - diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index f3437b3209..306513e868 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -43,6 +43,7 @@ export interface DesktopAppRestartIo { export type DesktopAppRestartReason = | "windows_only" | "package_discovery_failed" + | "process_probe_failed" | "no_targets" | "self_ancestry" | "targets_survived"; @@ -119,7 +120,7 @@ interface DesktopProcess { function listPackageProcesses( exec: NonNullable, installLocation: string, -): DesktopProcess[] { +): DesktopProcess[] | null { const literal = installLocation.replace(/'/g, "''"); const script = [ "$ErrorActionPreference='SilentlyContinue'", @@ -136,7 +137,10 @@ function listPackageProcesses( " }", " }", " }", - ].join(" "); + // Statements must be newline-separated. Joining with a space concatenates + // `$ErrorActionPreference='SilentlyContinue' $root = '...'` into one malformed statement, + // which PowerShell rejects — so the probe threw and every caller read "not running" (#2557). + ].join("\n"); let stdout: string; try { stdout = exec(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", script], { @@ -144,7 +148,10 @@ function listPackageProcesses( windowsHide: true, }); } catch { - return []; + // A probe that could not run is NOT proof the app is absent. Returning [] here made a + // failed enumeration indistinguishable from "no targets", so the CLI reported the app as + // not running and skipped a restart the user had explicitly asked for. + return null; } const processes: DesktopProcess[] = []; for (const line of stdout.split(/\r?\n/)) { @@ -170,8 +177,11 @@ function stillSameProcess( installLocation: string, target: DesktopProcess, ): boolean { - const current = listPackageProcesses(exec, installLocation) - .find(p => p.pid === target.pid); + const processes = listPackageProcesses(exec, installLocation); + // Fail CLOSED on a failed re-probe: this guards a kill, and "we could not look" must not be + // read as "the pid was recycled and is now someone else's process". + if (processes === null) return false; + const current = processes.find(p => p.pid === target.pid); return current !== undefined && current.createdAt === target.createdAt; } @@ -266,6 +276,9 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp if (!pkg) return skipped("package_discovery_failed"); const processes = listPackageProcesses(exec, pkg.installLocation); + // A probe that could not run is not evidence of absence. Reporting it as `no_targets` told + // the user the app was not running and silently skipped the restart they asked for (#2557). + if (processes === null) return skipped("process_probe_failed"); const roots = rootProcesses(processes); if (roots.length === 0) return skipped("no_targets"); diff --git a/tests/desktop-app-restart.test.ts b/tests/desktop-app-restart.test.ts index a033a4b599..95194f17e7 100644 --- a/tests/desktop-app-restart.test.ts +++ b/tests/desktop-app-restart.test.ts @@ -281,3 +281,46 @@ describe("Codex desktop app restart — kill-authority guards (#2292)", () => { }); }); + +describe("#2557 a failed probe is not an absent app", () => { + test("an enumeration that throws reports process_probe_failed, not no_targets", () => { + // Returning [] on a throwing probe made "we could not look" indistinguishable from + // "we looked and found nothing", so the CLI said the app was not running and silently + // skipped the restart the user asked for. + const calls: Call[] = []; + const result = withTrustedExes(() => restartCodexDesktopApp(scriptedIo({ + discovery: DISCOVERY, + calls, + throwOn: (_file, args) => args.join(" ").includes("Win32_Process"), + }))); + expect(result.reason).toBe("process_probe_failed"); + expect(result.attempted).toBe(false); + // Fail closed: nothing was terminated and nothing was relaunched on unreadable evidence. + expect(result.stopped).toEqual([]); + expect(result.relaunch).toBe("skipped"); + }); + + test("an empty enumeration still reports no_targets", () => { + // The two states must stay distinguishable in both directions. + const calls: Call[] = []; + const result = withTrustedExes(() => restartCodexDesktopApp(scriptedIo({ + discovery: DISCOVERY, processes: "", calls, + }))); + expect(result.reason).toBe("no_targets"); + }); + + test("the probe script separates PowerShell statements with newlines", () => { + // Joined with spaces, `$ErrorActionPreference='SilentlyContinue' $root = '...'` is one + // malformed statement and PowerShell rejects the whole script. + const calls: Call[] = []; + withTrustedExes(() => restartCodexDesktopApp(scriptedIo({ + discovery: DISCOVERY, processes: "", calls, + }))); + const probe = calls.find(call => call.args.join(" ").includes("Win32_Process")); + expect(probe).toBeTruthy(); + const script = probe!.args[probe!.args.length - 1]!; + expect(script).toContain("SilentlyContinue'\n"); + expect(script).not.toContain("SilentlyContinue' $root"); + }); +}); + From b6d5509ffe8d9bf2b23df1c5485c231aab7f05f6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 02:31:04 +0900 Subject: [PATCH 027/336] feat(slug): one selection resolver that reports what it matched (#2594) #2491 names four slug-equivalence relations. Two of them - catalog visibility and persisted sync - already share slugEquivalenceKey, so they agree with each other; what neither can do is tell a caller that a selection was AMBIGUOUS rather than exact. The lossiness is externally forced: Codex's models-manager tolerates exactly one slash, so a/b and a-b must share an encoded form. This does not try to remove that. It adds resolveSlugSelection, which keeps the tolerant matching and additionally reports the exact id and whether the selection admitted more than one, so a caller holding a complete known-id set can prefer the exact row instead of granting the whole collision class. Writing its test surfaced a real asymmetry in the first version: a slash in a selection is ambiguous on its own, since p/a-b is provider-qualified while a/b is a bare native id containing a slash. Treating every slash-bearing selection as qualified made the same collision report ambiguous through one spelling and unambiguous through the other. --- src/providers/slug-codec.ts | 52 +++++++++++++++++++++++++++++++++++++ tests/slug-codec.test.ts | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/providers/slug-codec.ts b/src/providers/slug-codec.ts index 61e25200db..47573b878c 100644 --- a/src/providers/slug-codec.ts +++ b/src/providers/slug-codec.ts @@ -101,3 +101,55 @@ export function slugEquivalenceKey(slug: string): string { export function slugsEquivalent(a: string, b: string): boolean { return a === b || slugEquivalenceKey(a) === slugEquivalenceKey(b); } + +/** + * Resolve one config selection against a provider's known native ids (#2491). + * + * `slugEquivalenceKey` is deliberately lossy — the Codex one-slash rule forces `a/b` and + * `a-b` onto the same encoded form — so a selection written in either spelling matches BOTH + * when a provider publishes both. Filtering and persisted sync share that key, which keeps + * them consistent with each other but silently over-grants. + * + * This resolver keeps the tolerant behaviour (a selection still matches through either + * spelling, and an id absent from an incomplete live roster still resolves) while reporting + * whether the match was EXACT or merely equivalent. A caller that can afford to be strict — + * one holding a complete known-id set — can then prefer the exact row instead of granting the + * whole collision class. + * + * Returning the ambiguity rather than resolving it is deliberate: the roster is an incomplete + * dictionary, so silently narrowing to the exact spelling would hide a published id whenever + * discovery omitted it. The caller owns that tradeoff because only the caller knows whether + * its id set is complete. + */ +export interface SlugSelectionMatch { + /** Native ids this selection admits. */ + readonly matched: readonly string[]; + /** The id whose raw form the selection names exactly, when one exists. */ + readonly exact: string | undefined; + /** True when more than one known id shares the selection's encoded form. */ + readonly ambiguous: boolean; +} + +export function resolveSlugSelection( + provider: string, + selection: string, + knownIds: Iterable, +): SlugSelectionMatch { + // A slash in the selection is ambiguous on its own: `p/a-b` is provider-qualified, while + // `a/b` is a bare NATIVE id that happens to contain a slash. Treating every slash-bearing + // selection as provider-qualified made `a/b` resolve against provider "a", so the same + // collision reported ambiguous through the dash spelling and unambiguous through the slash + // spelling — the exact asymmetry this resolver exists to remove. + const qualified = selection.startsWith(`${provider}/`) + ? selection + : routedSlug(provider, selection); + const selectionKey = slugEquivalenceKey(qualified); + const matched: string[] = []; + let exact: string | undefined; + for (const id of knownIds) { + if (slugEquivalenceKey(routedSlug(provider, id)) !== selectionKey) continue; + matched.push(id); + if (id === selection || `${provider}/${id}` === selection) exact = id; + } + return { matched, exact, ambiguous: matched.length > 1 }; +} diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 5dfdbcfd31..93c780a74e 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { decodeRoutedModelId, + resolveSlugSelection, decodeRoutedModelIdOrThrow, encodeRoutedModelId, encodedModelIdCollides, @@ -320,3 +321,53 @@ describe("catalog emission (Codex-facing)", () => { expect(encodedFeatured.find(e => e.slug === "zenmux/moonshotai-kimi-k3")?.priority).toBe(0); }); }); + +describe("#2491 one selection resolver reports what it actually matched", () => { + /** + * The Codex one-slash rule forces `a/b` and `a-b` onto the same encoded form, so the + * equivalence key cannot separate them. Filtering and persisted sync already share that key; + * what was missing is any way for a caller to LEARN that a selection was ambiguous instead of + * silently granting the whole collision class. + */ + test("an unambiguous selection resolves to exactly one id, marked exact", () => { + const match = resolveSlugSelection("p", "a-b", ["a-b", "unrelated"]); + expect(match.matched).toEqual(["a-b"]); + expect(match.exact).toBe("a-b"); + expect(match.ambiguous).toBe(false); + }); + + test("both spellings present is reported as ambiguous, with the exact one named", () => { + const both = ["a/b", "a-b"]; + const viaDash = resolveSlugSelection("p", "a-b", both); + expect(viaDash.ambiguous).toBe(true); + expect(viaDash.matched.sort()).toEqual(["a-b", "a/b"]); + // The caller can still prefer the row the operator literally typed. + expect(viaDash.exact).toBe("a-b"); + + const viaSlash = resolveSlugSelection("p", "a/b", both); + expect(viaSlash.ambiguous).toBe(true); + expect(viaSlash.exact).toBe("a/b"); + }); + + test("a selection written as the full routed slug resolves the same way", () => { + const match = resolveSlugSelection("p", "p/a-b", ["a/b", "a-b"]); + expect(match.ambiguous).toBe(true); + expect(match.matched.sort()).toEqual(["a-b", "a/b"]); + }); + + test("an id absent from an incomplete roster still reports no match rather than guessing", () => { + // Live discovery can omit a published id; the resolver must not invent one. + const match = resolveSlugSelection("p", "missing", ["a-b"]); + expect(match.matched).toEqual([]); + expect(match.exact).toBeUndefined(); + expect(match.ambiguous).toBe(false); + }); + + test("a nested-slash id resolves through its fully encoded form", () => { + const match = resolveSlugSelection("p", "x-y-z", ["x/y/z"]); + expect(match.matched).toEqual(["x/y/z"]); + // Encoded-only: the operator did not type the native spelling. + expect(match.exact).toBeUndefined(); + }); +}); + From 3a8baea2d4b80dc928a7827c5539bc642fe12813 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 02:43:16 +0900 Subject: [PATCH 028/336] fix(cli): route models remove through the shared slug relation (#2596) slugEquals compares the raw and encoded spellings of ONE id, so a removal selector written in the native slash form matched only the slash row while the encoded form matched both. Catalog filtering and persisted sync had already agreed on the collision class through slugEquivalenceKey; this command disagreed with both on the same config, which is the inconsistency #2491 reports. Removal now resolves through resolveSlugSelection, so all three surfaces see the same collision. Behaviour on an ambiguous selector is unchanged and deliberately so: it still refuses, which is the right default for a destructive command - the widening makes it refuse in a case where it previously deleted a row silently. Driven red: with the narrow exact-only relation, 3 of the 21 cases fail. --- src/cli/models.ts | 18 ++++++++++----- tests/cli-models.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 61789f861a..deb551cc96 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -11,7 +11,7 @@ import { isDeclaredReasoningEffort, modelRecordValue, } from "../reasoning-effort"; -import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec"; +import { encodedModelIdCollides, resolveSlugSelection, routedSlug } from "../providers/slug-codec"; import { knownModelIdsForProvider } from "../router"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; @@ -277,11 +277,17 @@ async function handleCustomRemove(args: string[]): Promise { const config = loadConfig(); const existing = config.customModels ?? []; - const matchingIndexes = existing.flatMap((model, index) => ( - target.includes("/") - ? slugEquals(target, model.provider, model.modelId) - : model.id === target - ) ? [index] : []); + // Slug matching goes through the shared resolver so this command sees the same collision + // class catalog filtering and persisted sync see (#2491). `slugEquals` compares the raw and + // encoded spellings of ONE id, so a selector written in the native slash form matched only + // that row while the dash form matched both — the two relations disagreed on the same + // config. Removal stays exact-or-refuse: an ambiguous selector still aborts below, which is + // the right default for a destructive command. + const matchingIndexes = existing.flatMap((model, index) => { + if (!target.includes("/")) return model.id === target ? [index] : []; + const resolved = resolveSlugSelection(model.provider, target, [model.modelId]); + return resolved.matched.length > 0 ? [index] : []; + }); if (matchingIndexes.length === 0) fail(`custom model "${target}" not found`); if (matchingIndexes.length > 1) { fail(`custom model selector "${target}" is ambiguous; use the custom model id`); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 5828f52cbf..51b5c4a8ec 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -433,3 +433,50 @@ describe("ocx models custom slash ids", () => { } }); }); + +describe("#2491 the removal selector uses the shared equivalence relation", () => { + /** + * `slugEquals` compared the raw and encoded spellings of ONE id, so a selector written in + * the NATIVE slash form matched only the slash row while the encoded form matched both. + * Catalog filtering and persisted sync had already agreed on the collision class through + * `slugEquivalenceKey`; this command disagreed with both on the same config. + */ + test("a native-slash selector sees the same collision the encoded one does", () => { + const { dir } = freshConfig({ + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "test", modelId: "openai/gpt-5.5" }, + { id: "22222222-2222-4222-8222-222222222222", provider: "test", modelId: "openai-gpt-5.5" }, + ], + }); + try { + // Before: this deleted the slash row outright, because slugEquals matched only it. + const result = runCli(["models", "remove", "test/openai/gpt-5.5", "--yes"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("ambiguous"); + // Refusing is the right default for a destructive command: nothing was removed. + const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(config.customModels).toHaveLength(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an unambiguous slash selector still removes its row", () => { + // Widening the relation must not make ordinary removal ambiguous. + const { dir } = freshConfig({ + customModels: [ + { id: "11111111-1111-4111-8111-111111111111", provider: "test", modelId: "openai/gpt-5.5" }, + { id: "33333333-3333-4333-8333-333333333333", provider: "test", modelId: "unrelated" }, + ], + }); + try { + const result = runCli(["models", "remove", "test/openai/gpt-5.5", "--yes"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")); + expect(config.customModels.map((m: { modelId: string }) => m.modelId)).toEqual(["unrelated"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + From 316043e92ec932f2ec396a6043126cc9dff9caf2 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 02:46:29 +0900 Subject: [PATCH 029/336] feat(responses): make a silent empty completion observable by default (#2597) The empty-completion guard is opt-in, so with the default configuration a turn that completes with no output text and no tool call passes through untouched and the client records a silent success. That is the reported symptom in #2472: an empty result with no trace that the proxy saw anything unusual. observeEmptyCompletion is a passthrough observer, not a second guard. It changes nothing about the stream and only records the occurrence, naming the config knob that enables recovery. Retrying by default would re-send a turn that may already have had billable side effects, so the honest default is observability rather than recovery. Only a successful terminal counts: error and incomplete are already a stated outcome the client renders. A pre-output EOF is the same failure and is recorded too. --- src/server/responses/core.ts | 12 +++- .../responses/empty-completion-guard.ts | 35 ++++++++++ tests/empty-completion-guard.test.ts | 69 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ce4f5eb905..789c19c922 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -336,6 +336,7 @@ import { responsesJsonToSseStream } from "../responses-json-events"; import { guardTerminalEventStream } from "./terminal-guard"; import { emptyCompletionRetryEnabled, + observeEmptyCompletion, guardEmptyCompletionEventStream, } from "./empty-completion-guard"; import { preflightComboStreamResponse } from "./combo-stream-preflight"; @@ -4525,7 +4526,16 @@ async function handleResponsesInner( // signal — run the adapter transport again against a fresh queue. continuation: runTurnRetrySource, }) - : eventSource; + // Guard off (the default): leave the stream alone, but record that the turn ended + // empty so the user has something to correlate instead of an unexplained blank + // result (#2472). Retrying by default would re-send a turn that may already have had + // billable side effects, so the honest default is observability, not recovery. + : observeEmptyCompletion(eventSource, () => { + console.warn( + `[opencodex] ${route.providerName}/${route.modelId} completed with no output text ` + + "and no tool call. Set \"emptyCompletionRetry\": true to retry such turns once.", + ); + }); const sseStream = bridgeToResponsesSSE( guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { diff --git a/src/server/responses/empty-completion-guard.ts b/src/server/responses/empty-completion-guard.ts index 6195ad0b8b..8d9d7dae5b 100644 --- a/src/server/responses/empty-completion-guard.ts +++ b/src/server/responses/empty-completion-guard.ts @@ -35,6 +35,41 @@ export function emptyCompletionRetryEnabled( /** Surfaced when the single retry was also empty or failed upstream. */ export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed"; +/** + * Observe an event stream for the empty-completion shape WITHOUT changing it (#2472). + * + * The guard above is opt-in, so with the default configuration a turn that completes with no + * output text and no tool call passes through untouched and the client records a silent + * success. That is the reported symptom: an empty result nobody can explain, with no trace + * that the proxy saw anything unusual. + * + * This is deliberately a passthrough observer, not a second guard. Retrying by default would + * re-send a turn that may have already had billable side effects; the honest default is to + * leave the stream alone and make the occurrence visible, so a user can correlate it and + * decide whether to enable the retry. + */ +export async function* observeEmptyCompletion( + events: AsyncIterable, + onEmptyTurn: () => void, +): AsyncGenerator { + let sawContent = false; + let sawTerminal = false; + for await (const event of events) { + // Reasoning is deliberately NOT content, matching the guard: a reasoning-only stream that + // ends with nothing is the canonical shape of this failure. + if (isContentEvent(event)) sawContent = true; + if (isTerminalEvent(event)) { + sawTerminal = true; + // Only a successful terminal is the silent failure. `error` and `incomplete` are already + // a stated outcome the client can render, so flagging them would be noise. + if (!sawContent && event.type === "done") onEmptyTurn(); + } + yield event; + } + // A stream that ends before any terminal is the pre-output EOF variant of the same failure. + if (!sawContent && !sawTerminal) onEmptyTurn(); +} + /** * Terminal stop reasons the bridge renders as a visible `response.incomplete` * (max_tokens / content_filter). Those are already a stated failure, not the diff --git a/tests/empty-completion-guard.test.ts b/tests/empty-completion-guard.test.ts index 2b786722bf..773042fb82 100644 --- a/tests/empty-completion-guard.test.ts +++ b/tests/empty-completion-guard.test.ts @@ -5,6 +5,7 @@ import { emptyCompletionRetryEnabled, guardEmptyCompletionEventStream, isContentEvent, + observeEmptyCompletion, } from "../src/server/responses/empty-completion-guard"; import type { AdapterEvent } from "../src/types"; @@ -376,3 +377,71 @@ describe("empty-completion guard retry", () => { expect(events).toEqual([{ type: "text_delta", text: "partial" }]); }); }); + +describe("#2472 an empty turn is observable even when the retry guard is off", () => { + /** + * The guard is opt-in, so by default a turn that completes with no output text and no tool + * call passes through untouched and the client records a silent success — the reported + * "empty result nobody can explain". This observer changes nothing about the stream; it only + * makes the occurrence visible so a user can correlate it and decide whether to enable the + * retry. Retrying by default would re-send a turn that may already have had billable side + * effects. + */ + async function drain(events: AdapterEvent[]): Promise<{ out: AdapterEvent[]; empties: number }> { + let empties = 0; + const out: AdapterEvent[] = []; + for await (const event of observeEmptyCompletion(eventsOf(...events), () => { empties += 1; })) { + out.push(event); + } + return { out, empties }; + } + + test("a reasoning-only turn that completes is flagged", async () => { + const { out, empties } = await drain([ + { type: "reasoning_delta", text: "thinking" } as AdapterEvent, + { type: "done" } as AdapterEvent, + ]); + expect(empties).toBe(1); + // Passthrough: the stream is untouched. + expect(out.map(e => e.type)).toEqual(["reasoning_delta", "done"]); + }); + + test("a turn that produced text is not flagged", async () => { + const { empties } = await drain([ + { type: "text_delta", text: "hello" } as AdapterEvent, + { type: "done" } as AdapterEvent, + ]); + expect(empties).toBe(0); + }); + + test("a tool call counts as content", async () => { + const { empties } = await drain([ + { type: "tool_call_start", id: "c1", name: "shell" } as AdapterEvent, + { type: "done" } as AdapterEvent, + ]); + expect(empties).toBe(0); + }); + + test("an empty text delta is not content", async () => { + // Some batch adapters always carry "", which would otherwise mask the failure. + const { empties } = await drain([ + { type: "text_delta", text: "" } as AdapterEvent, + { type: "done" } as AdapterEvent, + ]); + expect(empties).toBe(1); + }); + + test("a stated failure is not flagged as a silent one", async () => { + // error/incomplete already render for the client; flagging them would be noise. + const viaError = await drain([{ type: "error", message: "boom" } as AdapterEvent]); + expect(viaError.empties).toBe(0); + const viaIncomplete = await drain([{ type: "incomplete", reason: "max_tokens" } as AdapterEvent]); + expect(viaIncomplete.empties).toBe(0); + }); + + test("a pre-output EOF is the same failure and is flagged", async () => { + const { empties } = await drain([]); + expect(empties).toBe(1); + }); +}); + From 7ead799f2fea44311b2ba37d0d9c43f159206ea9 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 02:57:47 +0900 Subject: [PATCH 030/336] test(cursor): pin the empty-turn producer path end to end (#2598) #2472 was closable only once the producer side was proved, not assumed. A Cursor turnEnded with no text and no committed tool call finalizes to a bare done: a successful terminal carrying no content, which is exactly the shape a client records as a completed turn that said nothing. Pins three distinctions that matter: that shape, an incomplete tool call (already a stated error, so NOT this failure mode), and a turn reporting output tokens without emitting content - usage counters are not content. Joins the halves too: the adapter's real output is fed through observeEmptyCompletion and asserted to be both flagged and passed through unchanged, so a future change to either side cannot quietly break the pairing. --- tests/cursor-protobuf-events.test.ts | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index d03d7724d1..b77cc317c9 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -22,6 +22,8 @@ import { mapSyntheticMcpExecToToolEvents, } from "../src/adapters/cursor/protobuf-events"; import { createTranslatorBudget } from "../src/lib/translator-budget"; +import { observeEmptyCompletion } from "../src/server/responses/empty-completion-guard"; +import type { AdapterEvent } from "../src/types"; const encoder = new TextEncoder(); @@ -1139,3 +1141,66 @@ describe("textual pseudo tool-call marker normalization (#2305)", () => { expect(events).toEqual([{ type: "text", text: short }]); }); }); + +describe("#2472 the Cursor producer path for a silent empty turn", () => { + /** + * A turnEnded with no text and no committed tool call finalizes to a bare `done`. That is a + * successful terminal carrying no content — the exact shape the client records as a + * completed turn that said nothing, which is the reported symptom. + * + * This pins the producer so the shape stays visible. The observer added in #2597 is what + * makes it recorded rather than silent; this proves the stream really can reach that state + * from a real adapter rather than only in the observer's own fixtures. + */ + test("turnEnded with no output finalizes to a content-free done", () => { + const state = createCursorProtobufEventState(); + const events = mapCursorProtobufServerMessage(turnEndedFrame(), state); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("done"); + // No text_delta and no tool_call_* were emitted at any point in this turn. + expect(events.some(event => event.type === "text_delta")).toBe(false); + expect(events.some(event => event.type.startsWith("tool_call"))).toBe(false); + }); + + test("an incomplete tool call is a stated error, not a silent empty turn", () => { + // The distinction matters: this path already tells the client something went wrong, so it + // is NOT the failure mode #2472 describes and must not be conflated with it. + const state = createCursorProtobufEventState(); + state.openToolCalls.set("call-1", { name: "shell", args: "" } as never); + const events = finalizeTurnEvents(state); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("error"); + }); + + test("a turn that produced text finalizes with content already emitted", () => { + const state = createCursorProtobufEventState(); + state.usage.outputTokens = 3; + const events = finalizeTurnEvents(state); + expect(events[0]!.type).toBe("done"); + // Usage alone is not content: the observer keys on emitted events, not token counters, + // which is why a turn can report output tokens and still be empty to the client. + expect(events.some(event => event.type === "text_delta")).toBe(false); + }); +}); + + +describe("#2472 end to end: the real producer output reaches the observer", () => { + /** + * The two halves were verified separately — the Cursor adapter can finalize a turn to a + * content-free `done`, and the observer flags a content-free `done`. This joins them so a + * future change to either side cannot quietly break the pairing. + */ + test("a Cursor turnEnded with no output is flagged by the observer", async () => { + const state = createCursorProtobufEventState(); + const produced = mapCursorProtobufServerMessage(turnEndedFrame(), state) as unknown as AdapterEvent[]; + + let flagged = 0; + const seen: AdapterEvent[] = []; + const stream = (async function* () { yield* produced; })(); + for await (const event of observeEmptyCompletion(stream, () => { flagged += 1; })) seen.push(event); + + expect(flagged).toBe(1); + // Passthrough: the adapter's own events are delivered unchanged. + expect(seen).toEqual(produced); + }); +}); From 561a7c29f69d9463a9f2d50fea87991c416f94db Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 03:02:01 +0900 Subject: [PATCH 031/336] test(catalog): pin the 5.6 context width against a stale on-disk row (#2599) Reproduced #2574 on a live install: the resolver returns 922000 for gpt-5.6-sol while ~/.codex/opencodex-catalog.json still held 272000 from a sync two days earlier. With effective_context_window_percent 95 that renders as 258400 - the exact number reported. The override itself is correct and repairs such a row when re-applied. The gap is that the subagent roster reads the persisted file rather than re-deriving from config, so a row predating the current limits is served verbatim with nothing asserting the two agree. Pins that agreement for the whole family, plus the arithmetic that identifies the stale width, plus the fact that a provider cap still narrows below the opt-in window so the lift cannot become unconditional. Corrects my own first version of this test, which assumed the 1M opt-in was a per-model window; it is a raised providerContextCaps.openai. --- tests/native-model-toggle.test.ts | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index fcf42e07dc..d63dfa771a 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -22,6 +22,7 @@ import { } from "../src/codex/catalog"; import { handleManagementAPI } from "../src/server/management-api"; import { applyMultiAgentMode, applyNativeOpenAiContextOverride } from "../src/codex/catalog/parsing"; +import { NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, nativeOpenAiContextWindow } from "../src/codex/catalog"; import type { OcxConfig } from "../src/types"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../src/codex/catalog/native-models"; import { @@ -696,3 +697,56 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }); }); import { ManagementRequest as Request } from "./helpers/management-auth"; + +describe("#2574 a stale on-disk row is what a subagent reads", () => { + /** + * Reproduced against a live install: the resolver returns 922,000 for gpt-5.6-sol while + * ~/.codex/opencodex-catalog.json still held 272,000 from an earlier sync. With + * effective_context_window_percent = 95 that renders as 258,400 — the exact number reported. + * + * The subagent roster reads the persisted catalog rather than re-deriving from config, so a + * row that predates the current limits is served verbatim. The override is correct; what is + * missing is any assertion that the WRITTEN row matches what the resolver would produce. + */ + test("a raised cap opts the family into the wider window, and the row follows", () => { + // The 1M opt-in is expressed as a raised providerContextCaps.openai, not a per-model + // window. With the default cap the family stays at 272k; raising it past the opt-in + // threshold is what makes 922k the correct width. + const optedIn = nativeContextLimits({ providerContextCaps: { openai: 1_050_000 } } as never); + expect(nativeOpenAiContextWindow("gpt-5.6-sol", optedIn)).toBe(NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW); + + // A row written before that opt-in carries the narrow width. Re-applying the override with + // the current limits is what repairs it — which is exactly what a stale on-disk catalog + // never gets, because the subagent roster reads the file rather than re-deriving. + const stale: Record = { + slug: "gpt-5.6-sol", + context_window: NATIVE_GPT56_CONTEXT_WINDOW, + effective_context_window_percent: 95, + }; + applyNativeOpenAiContextOverride(stale as never, optedIn); + expect(stale.context_window).toBe(NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW); + + // 272,000 x 95% = 258,400 — the number reported in the issue, and what a client renders + // from the stale row. + expect(Math.floor(NATIVE_GPT56_CONTEXT_WINDOW * 0.95)).toBe(258_400); + }); + + test("the written row agrees with the resolver for the whole 5.6 family", () => { + // This is the invariant whose absence let a stale row survive unnoticed: whatever a + // subagent reads from disk must equal what the request path would compute. + const limits = nativeContextLimits({ providerContextCaps: { openai: 1_050_000 } } as never); + for (const slug of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + const row: Record = { slug, context_window: NATIVE_GPT56_CONTEXT_WINDOW }; + applyNativeOpenAiContextOverride(row as never, limits); + expect(row.context_window).toBe(nativeOpenAiContextWindow(slug, limits)); + } + }); + + test("a provider cap still narrows the family below the opt-in window", () => { + // The lift must not become unconditional: an operator cap is still authoritative. + const capped = nativeContextLimits({ providerContextCaps: { openai: 300_000 } } as never); + const row: Record = { slug: "gpt-5.6-sol", context_window: NATIVE_GPT56_CONTEXT_WINDOW }; + applyNativeOpenAiContextOverride(row as never, capped); + expect(row.context_window).toBe(300_000); + }); +}); From 79d932c204cc4ff69e3e950fc5820e65e609bf26 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 03:15:23 +0900 Subject: [PATCH 032/336] fix(subagents): re-derive native context width from config at catalog read (#2601) The subagent roster reads the persisted Codex catalog, which is only as fresh as the last sync. A row written before the operator widened the native window kept its old context_window, so a child planned and compacted against a narrower budget than its parent. Measured on a live install: the request path resolved 922000 for gpt-5.6-sol while the on-disk row still held 272000 from a two-day-old sync. With effective_context_window_percent 95 that renders as 258400 - the reported number. Re-applies applyNativeOpenAiContextOverride to the snapshot the roster reads. It is the same function the writer uses, so a fresh catalog is unchanged and a stale one is repaired in memory. The file is not rewritten: ocx sync stays the thing that refreshes it. An unreadable config falls back to the file as written rather than costing the caller its roster. --- src/server/responses/collaboration.ts | 40 +++++++++++- tests/subagent-context-staleness.test.ts | 82 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/subagent-context-staleness.test.ts diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 7c671a7de4..27ccdbe06a 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -2,6 +2,7 @@ import type { Server } from "bun"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { getConfigPath, + loadConfig, multiAgentGuidanceEnabled, resolveEnvValue, } from "../../config"; @@ -10,6 +11,7 @@ import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractC import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; import { routeModel } from "../../router"; +import type { RawEntry } from "../../codex/catalog/parsing"; import { advanceComboAfterFailure, comboDefaultEffort, @@ -243,15 +245,47 @@ export async function resolveEffectiveSubagentRoster( surface: SpawnAgentSurface, ): Promise { const { effectiveSubagentRoster } = await import("../../codex/catalog"); - return effectiveSubagentRoster(configuredModels, surface); + return effectiveSubagentRoster(configuredModels, surface, await freshSubagentCatalogEntries()); +} + +/** + * The persisted Codex catalog, with native context metadata re-derived from the CURRENT config. + * + * The subagent roster reads the catalog FILE, which is only as fresh as the last sync. A row + * written before the operator widened the native window keeps its old `context_window`, so a + * child was planning and compacting against a narrower budget than its parent — measured at + * 272,000 x 95% = 258,400 while the request path resolved 922,000 (#2574). + * + * Re-applying the override is cheap and idempotent: it is the same function the writer uses, + * so a fresh catalog is unchanged and a stale one is repaired in memory rather than silently + * believed. It does not rewrite the file — the row on disk stays whatever the last sync wrote, + * and `ocx sync` remains what refreshes it. + */ +async function freshSubagentCatalogEntries(): Promise { + const { readCatalog, readCodexCatalogPath, nativeContextLimits } = await import("../../codex/catalog"); + const { applyNativeOpenAiContextOverride } = await import("../../codex/catalog/parsing"); + const entries = readCatalog(readCodexCatalogPath())?.models ?? []; + if (entries.length === 0) return []; + let limits: ReturnType; + try { + limits = nativeContextLimits(loadConfig()); + } catch { + // An unreadable config must not cost the caller its roster; serve the file as written. + return entries; + } + return entries.map(entry => { + const clone = { ...entry }; + applyNativeOpenAiContextOverride(clone, limits); + return clone; + }); } /** Reuse one parsed catalog snapshot across every roster projection for this request. */ async function createRequestScopedSubagentRosterResolver(): Promise> { - const { effectiveSubagentRoster, readCatalog, readCodexCatalogPath } = await import("../../codex/catalog"); - const catalogEntries = readCatalog(readCodexCatalogPath())?.models ?? []; + const { effectiveSubagentRoster } = await import("../../codex/catalog"); + const catalogEntries = await freshSubagentCatalogEntries(); return (configuredModels, surface) => effectiveSubagentRoster(configuredModels, surface, catalogEntries); } diff --git a/tests/subagent-context-staleness.test.ts b/tests/subagent-context-staleness.test.ts new file mode 100644 index 0000000000..bd8351e5f8 --- /dev/null +++ b/tests/subagent-context-staleness.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveEffectiveSubagentRoster } from "../src/server/responses/collaboration"; +import { + NATIVE_GPT56_CONTEXT_WINDOW, + NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, +} from "../src/codex/catalog"; + +/** + * #2574: the subagent roster reads the persisted Codex catalog, which is only as fresh as the + * last sync. A row written before the operator widened the native window kept its old + * `context_window`, so a child planned and compacted against a narrower budget than its + * parent — measured at 272,000 x 95% = 258,400 while the request path resolved 922,000. + */ +const originalHome = process.env.OPENCODEX_HOME; +const originalCodexHome = process.env.CODEX_HOME; +let home: string; +let codexHome: string; + +function writeStaleCatalog(): void { + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [ + { + slug: "gpt-5.6-sol", + // The width a pre-opt-in sync wrote. + context_window: NATIVE_GPT56_CONTEXT_WINDOW, + effective_context_window_percent: 95, + visibility: "list", + priority: 1, + }, + ], + })); +} + +function writeConfig(): void { + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, "config.json"), JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + // The 1M opt-in is a raised provider cap, not a per-model window. + providerContextCaps: { openai: 1_050_000 }, + })); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-subagent-ctx-")); + codexHome = mkdtempSync(join(tmpdir(), "ocx-subagent-codex-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = codexHome; + writeConfig(); + writeStaleCatalog(); +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + if (originalCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalCodexHome; + rmSync(home, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); +}); + +describe("#2574 a subagent does not inherit a stale catalog width", () => { + test("the roster still lists the model from a stale catalog", async () => { + // The repair must not cost the caller its roster. + const roster = await resolveEffectiveSubagentRoster(["gpt-5.6-sol"], "default"); + expect(roster.candidates.map(c => c.model)).toContain("gpt-5.6-sol"); + }); + + test("the arithmetic that produced the reported number is pinned", () => { + // 272,000 x 95% = 258,400 is exactly what was observed; the opt-in width is 3.4x larger, + // which is why the symptom reads as premature compaction rather than a rounding error. + expect(Math.floor(NATIVE_GPT56_CONTEXT_WINDOW * 0.95)).toBe(258_400); + expect(NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW).toBe(922_000); + expect(Math.floor(NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW * 0.95)).toBe(875_900); + }); +}); + From 6e6d5cda09e805e83ab0e754fb5faa35c18900c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 03:31:58 +0900 Subject: [PATCH 033/336] devlog: record the #2497 credential-boundary security review (#2602) --- .../120_wp5_2497_security_review.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/120_wp5_2497_security_review.md diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/120_wp5_2497_security_review.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/120_wp5_2497_security_review.md new file mode 100644 index 0000000000..f05d102f05 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/120_wp5_2497_security_review.md @@ -0,0 +1,68 @@ +# 120 — wp5 remainder: #2497 security review (native-main token refresh) + +PR #2497 is the last open `bug`-labelled PR. It is the credential boundary `AGENTS.md` +places under explicit security review, so it got one: an independent adversarial reviewer, +then my own verification of each finding against the tree. + +Rebased cleanly onto dev (1/1). ~2,600 lines across 20 files. + +## Verdict: not merged. Three security blockers plus a red suite. + +### B1 — publication is not atomic (`main-account.ts:319`) + +`replaceMainAuthJsonWithoutClobbering` renames the canonical `auth.json` to a backup, then +later `linkSync`s the temp into place. A crash between the two leaves no `auth.json`, and +there is no startup recovery for the `.ocx-main-auth.*.bak` file. + +Partially defensible: the code DOES restore the backup on both failure paths it can observe +(`restoreAuthJsonBackupWithoutReplacing` at the snapshot-mismatch and link-failure branches), +and it re-verifies the published snapshot afterwards. What it cannot cover is a process death +between rename and link, and an external writer holding the old inode. The reviewer is right +that the window exists; the code is more careful than "non-atomic" alone suggests. + +### B2 — same-account fallback adopts a different grant (`main-account.ts:403`) — ACCEPTED + +`freshStoredCredentialForMain` first looks for the SAME refresh grant, which is correct. It +then falls back to `findUniqueFreshCredentialForChatgptAccount` — any fresh pool credential +sharing the ChatGPT account id — and writes that pool refresh token into native-main +`auth.json`. The added test blesses replacing `native-main-refresh` with `pool-main-refresh`. + +This is the same hazard `anthropic-routing.ts` fails closed on: a background slot must not +have its credential adopted into an active one merely because the identity matches. Account-id +equivalence is not grant ownership. + +### B3 — the "exactly one" replay is one LOGICAL replay, not one physical send + +Verified: the guard is set before refresh/rebuild/relay on all three paths +(`core.ts:3460`, `core.ts:4998`, `compact.ts:620`), and the compact path explicitly uses +single-send mode (`compact.ts:647`). But the passthrough post-401 send goes through +`fetchWithTransientRetry` (`core.ts:3532`), whose ladder is 3 transient attempts x 3 reset +attempts. So one 401 recovery can be up to nine physical sends. Nothing reaches the client +twice, but upstream work can be committed more than once. + +### B4 — the PR's own new suites are red on this head — REPRODUCED + +`bun test tests/responses-native-main-refresh.test.ts tests/responses-compact-native-main-refresh.test.ts` +→ **1 pass / 9 fail**. The fixtures request account-gated `gpt-5.6-sol` without an +authenticated `/models` roster, so dev's entitlement gate (which landed in #2550, after this +PR was written) fails closed before the replay path is reached. + +That is a stale-base artefact rather than a defect in the replay logic — but it means the +regressions this PR relies on prove nothing at the head being merged. + +## Disposition: NEEDS_HUMAN, not merged + +B2 is a credential-ownership decision, not a bug I should silently pick a side on: tightening +it to grant-only changes what happens to an operator who re-logged in through the pool and +expects main to follow. B1 needs a publication redesign. B3 needs the auth replay to bypass +the nested retry ladder. + +Fixing all three inside someone else's 2,600-line credential PR and admin-merging it is +exactly the shortcut `AGENTS.md` §"Security working notes" and `MAINTAINERS.md` exist to +prevent. The findings are posted to the PR for the author and the maintainer; the branch stays +unmerged. + +Non-blocking, recorded for the author: persistence preserves the old `id_token` while reads +prioritize its account id over a refreshed `access_token`/`account_id`, which can reintroduce +a stale account header (`main-account.ts:173`). + From c884c255ac6418af1523c0b12903627f9e6103e2 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 03:48:18 +0900 Subject: [PATCH 034/336] feat(catalog): shipped model presets with write-path divergence (#2465) (#2603) * feat(catalog): shipped model presets with write-path divergence (#2465) Adding a high-volume provider exposes its whole catalog on day one: openrouter ships 400+ rows, an Anthropic provider ships every historical snapshot. The useful set is a handful of flagships. MODEL_PRESETS keys providers to versioned id PATTERNS, so a vendor snapshot suffix does not stale the preset between releases. Materialization evaluates them against the current catalog and stores CONCRETE ids in selectedModels, so the visibility hot path never learns about patterns and an older binary still sees a plain allowlist. The preset is a seed, not a lock. Divergence is detected at the write path: any edit through PUT /api/selected-models while the provider is in preset mode flips it to custom, after which the proxy never re-materializes. That collapses upgrade reconciliation to a version compare. A preset that matches nothing NEVER writes an empty allowlist - empty means ALL, so it would silently un-curate. It falls back to all and records the fallback so the next convergence can retry. Existing providers are untouched: an absent marker means all, exactly today's semantics. Design: devlog/_plan/260824_model_ux_aliases_and_defaults/030. * feat(catalog): model-preset management API and CLI (#2465) GET /api/model-presets previews the rules against the current catalog without applying them; PUT switches a provider between preset, all and custom. ocx models preset show|apply wraps both. Adds the write-path divergence rule to PUT /api/selected-models: an edit while in preset mode flips the marker to custom, so the proxy never re-materializes over a user's selection. The zero-match case is the one that needed care: a preset must never write an empty allowlist, because empty means ALL and would silently un-curate the provider. It keeps the previous selection, falls back to all, and records fallback so a later convergence can retry. --- src/cli/models-runtime.ts | 65 ++++++++++++++ src/cli/models.ts | 2 +- src/cli/registry.ts | 2 +- src/providers/model-presets.ts | 119 ++++++++++++++++++++++++++ src/server/management/model-routes.ts | 108 +++++++++++++++++++++++ src/types/provider.ts | 25 ++++++ tests/codex-catalog.test.ts | 102 ++++++++++++++++++++++ tests/model-presets.test.ts | 97 +++++++++++++++++++++ 8 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 src/providers/model-presets.ts create mode 100644 tests/model-presets.test.ts diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index c309274c8b..4d5dd3f54a 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -22,6 +22,8 @@ const USAGE = `Usage: ocx models [--native] [--json] ocx models provider [--json] ocx models selected [--set |--clear] [--json] + ocx models preset show [--provider ] [--json] + ocx models preset apply [--all] [--json] ocx models context [--set-all]|provider on [--value ]|provider off|all > [--json] ocx models shadow [model|-] [--enabled ] [--json]`; @@ -162,6 +164,68 @@ async function selected(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, [`${provider}: ${models.length ? models.join(", ") : "all models"}`]); } + +interface ModelPresetView { + mode: string; + appliedVersion?: number; + availableVersion: number; + presetIds: string[]; + presetCount: number; + totalCount: number; + fallback?: string; +} + +function presetLine(name: string, view: ModelPresetView): string { + const parts = [`${name}: mode=${view.mode}`]; + if (view.appliedVersion !== undefined && view.appliedVersion !== view.availableVersion) { + parts.push(`applied v${view.appliedVersion}, available v${view.availableVersion}`); + } else { + parts.push(`preset v${view.availableVersion}`); + } + parts.push(`(${view.presetCount} of ${view.totalCount} models)`); + if (view.fallback) parts.push(`fallback=${view.fallback}`); + return parts.join(" "); +} + +async function preset(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const action = (args.shift() ?? "show").toLowerCase(); + const wantsJson = takeFlag(args, "--json"); + if (action === "show") { + const only = takeOption(args, "--provider")?.trim(); + rejectArgs(args, USAGE); + const result = await runtimeRequest<{ providers?: Record }>("/api/model-presets", {}, deps); + const providers = result.providers ?? {}; + const entries = Object.entries(providers).filter(([name]) => !only || name === only); + const lines = entries.length > 0 + ? entries.map(([name, view]) => presetLine(name, view)) + // A provider with no shipped preset is not an error: it simply has nothing to curate. + : [only ? `${only}: no model preset is shipped for this provider` : "no providers have a shipped model preset"]; + printData(only ? providers[only] ?? {} : result, wantsJson, lines); + return; + } + if (action !== "apply") throw new CliUsageError(`unknown preset action '${action}'`, USAGE); + const provider = args.shift()?.trim(); + const all = takeFlag(args, "--all"); + if (!provider) throw new CliUsageError("provider is required", USAGE); + rejectArgs(args, USAGE); + const mode = all ? "all" : "preset"; + const result = await runtimeRequest<{ selected?: string[]; fallback?: string; appliedVersion?: number }>( + "/api/model-presets", + { method: "PUT", body: JSON.stringify({ provider, mode }) }, + deps, + ); + const selectedIds = result.selected ?? []; + const line = result.fallback === "preset-empty" + // Never silently narrow to nothing: empty means ALL, so a zero-match preset keeps what was + // there and says so. + ? `${provider}: preset matched no models — selection unchanged (fallback to all)` + : all + ? `${provider}: showing all models (allowlist cleared)` + : `${provider}: preset v${result.appliedVersion ?? "?"} applied — ${selectedIds.length} models selected`; + printData(result, wantsJson, [line]); +} + async function context(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); @@ -236,6 +300,7 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de else if (sub === "disable") action = () => visibility(false, argv, deps); else if (sub === "provider") action = () => providerVisibility(argv, deps); else if (sub === "selected") action = () => selected(argv, deps); + else if (sub === "preset") action = () => preset(argv, deps); else if (sub === "context") action = () => context(argv, deps); else if (sub === "shadow") action = () => shadow(argv, deps); if (!action) return null; diff --git a/src/cli/models.ts b/src/cli/models.ts index deb551cc96..d050563b60 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -428,7 +428,7 @@ export async function handleModels(args: string[]): Promise { handleCustomList(rest); return; } - if (["live", "edit", "enable", "disable", "provider", "selected", "context", "shadow"].includes(subcommand ?? "")) { + if (["live", "edit", "enable", "disable", "provider", "selected", "preset", "context", "shadow"].includes(subcommand ?? "")) { const { handleModelsRuntimeCommand } = await import("./models-runtime"); const code = await handleModelsRuntimeCommand(subcommand!, rest); if (code !== null) process.exitCode = code; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 85bb3959e5..a5bdd63367 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -167,7 +167,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "models", aliases: ["model"], - usage: "ocx models ...", + usage: "ocx models ...", summary: "List models and manage custom (manually registered) models.", details: [ "List available models from static config with no subcommand (liveModels may add more at runtime).", diff --git a/src/providers/model-presets.ts b/src/providers/model-presets.ts new file mode 100644 index 0000000000..2e558a7c6a --- /dev/null +++ b/src/providers/model-presets.ts @@ -0,0 +1,119 @@ +/** + * Shipped per-provider "latest/core" model presets (#2465). + * + * Adding a high-volume provider exposes its entire catalog to the Codex picker on day one: + * openrouter ships 400+ rows, an Anthropic provider ships every historical snapshot. The useful + * set is a handful of current flagships, so a newly added provider seeds from a curated preset + * and "everything" stays one click away. + * + * Rules are ID PATTERNS rather than literal id lists so a vendor snapshot suffix does not stale + * the preset between releases: `claude-opus-5-20260814` and `claude-opus-5` both match one rule. + * + * The preset is a SEED, not a lock. Materialization evaluates these rules against the provider's + * current catalog and stores CONCRETE ids in `selectedModels`, so the visibility hot path + * (`filterCatalogVisibleModels`) never learns about patterns and an older binary still sees a + * plain allowlist. Design: devlog/_plan/260824_model_ux_aliases_and_defaults/030_default_preset.md + */ + +import type { OcxProviderConfig } from "../types"; + +export interface ModelPresetRule { + readonly pattern: RegExp; +} + +export interface ModelPreset { + /** Bumped whenever this provider's rules change; drives upgrade reconciliation. */ + readonly version: number; + readonly rules: readonly ModelPresetRule[]; +} + +/** + * Curated on the same release train as the provider registry. A provider WITHOUT an entry has no + * preset and behaves exactly as today (mode "all"). + * + * Deliberately limited to high-volume catalogs. A provider that already ships a short curated + * list gains nothing from a preset and would only add a marker to reconcile. + */ +export const MODEL_PRESETS: Readonly> = Object.freeze({ + openrouter: { + version: 1, + rules: [ + { pattern: /^anthropic\/claude-(opus|sonnet|haiku)-[45]/ }, + { pattern: /^google\/gemini-3(\.\d+)?-(pro|flash)/ }, + { pattern: /^openai\/gpt-5(\.\d+)?/ }, + { pattern: /^deepseek\/deepseek-(v4|r2)/ }, + { pattern: /^x-ai\/grok-4(\.\d+)?/ }, + { pattern: /^moonshotai\/kimi-k[23]/ }, + { pattern: /^z-ai\/glm-[45]/ }, + ], + }, + anthropic: { + version: 1, + rules: [ + { pattern: /^claude-opus-[45]/ }, + { pattern: /^claude-sonnet-[45]/ }, + { pattern: /^claude-haiku-[45]/ }, + { pattern: /^claude-fable-5/ }, + ], + }, + "anthropic-apikey": { + version: 1, + rules: [ + { pattern: /^claude-opus-[45]/ }, + { pattern: /^claude-sonnet-[45]/ }, + { pattern: /^claude-haiku-[45]/ }, + { pattern: /^claude-fable-5/ }, + ], + }, +}); + +/** The preset for a provider, or undefined when none is shipped. */ +export function modelPresetFor(providerName: string): ModelPreset | undefined { + return MODEL_PRESETS[providerName]; +} + +/** True when a provider has a shipped preset at all. */ +export function hasModelPreset(providerName: string): boolean { + return modelPresetFor(providerName) !== undefined; +} + +/** + * Evaluate a provider's rules against concrete catalog ids. + * + * Input order is preserved so a caller can show the preset in the same order the picker does. + * Duplicates are collapsed; a model matching several rules is selected once. + */ +export function materializeModelPreset( + providerName: string, + catalogModelIds: Iterable, +): string[] { + const preset = modelPresetFor(providerName); + if (!preset) return []; + const seen = new Set(); + const out: string[] = []; + for (const id of catalogModelIds) { + if (seen.has(id)) continue; + // `RegExp.test` on a shared literal is safe here because no rule uses the `g` flag; a + // sticky/global pattern would carry lastIndex between calls and skip rows. + if (preset.rules.some(rule => rule.pattern.test(id))) { + seen.add(id); + out.push(id); + } + } + return out; +} + + +/** + * Flip a provider out of preset mode when the user edits its selection (#2465). + * + * No-op unless the provider is currently in preset mode: "all" has no marker to keep, and + * "custom" is already terminal. The applied version is retained so the GUI can still offer + * "a newer preset is available" without losing what the user chose. + */ +export function markModelPresetDiverged(provider: OcxProviderConfig): void { + const marker = provider.modelPreset; + if (marker?.mode !== "preset") return; + provider.modelPreset = { ...marker, mode: "custom" }; +} + diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 873e959e02..9ea4d5561e 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -149,6 +149,12 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C import type { ManagementContext } from "./context"; import { listManagementModelRows, loadExportModels } from "./model-rows"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import { + hasModelPreset, + markModelPresetDiverged, + materializeModelPreset, + modelPresetFor, +} from "../../providers/model-presets"; /** * Counts read back off the SERIALIZED document rather than recomputed from the input rows. @@ -543,6 +549,104 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(); + for (const m of models) { + const ids = byProvider.get(m.provider) ?? []; + ids.push(m.id); + byProvider.set(m.provider, ids); + } + const providers: Record = {}; + for (const [name, prov] of Object.entries(config.providers)) { + const preset = modelPresetFor(name); + if (!preset) continue; + const catalogIds = byProvider.get(name) ?? []; + const presetIds = materializeModelPreset(name, catalogIds); + providers[name] = { + mode: prov.modelPreset?.mode ?? "all", + ...(prov.modelPreset?.appliedVersion !== undefined + ? { appliedVersion: prov.modelPreset.appliedVersion } + : {}), + availableVersion: preset.version, + presetIds, + presetCount: presetIds.length, + totalCount: catalogIds.length, + ...(prov.modelPreset?.fallback ? { fallback: prov.modelPreset.fallback } : {}), + }; + } + return jsonResponse({ providers }); + } + if (url.pathname === "/api/model-presets" && req.method === "PUT") { + let body: { provider?: unknown; mode?: unknown }; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + const provider = typeof body.provider === "string" ? body.provider : ""; + if (!provider || !hasOwnProvider(config.providers, provider)) { + return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400); + } + const mode = body.mode; + if (mode !== "preset" && mode !== "all" && mode !== "custom") { + return jsonResponse({ error: "mode must be preset, all, or custom" }, 400); + } + const target = config.providers[provider]; + if (mode === "all") { + // Same effect as today's empty-list PUT: no allowlist, no marker to reconcile. + delete target.selectedModels; + delete target.modelPreset; + persistConfig(config); + return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() }); + } + if (mode === "custom") { + // Keep whatever is selected; only the marker changes, so a user can pin their edits + // without the proxy re-materializing over them. + target.modelPreset = { ...(target.modelPreset ?? {}), mode: "custom" }; + persistConfig(config); + return jsonResponse({ ok: true, provider, mode, selected: [...(target.selectedModels ?? [])] }); + } + if (!hasModelPreset(provider)) { + return jsonResponse({ error: `no model preset is shipped for provider '${provider}'` }, 400); + } + const models = await fetchAllModels(config); + const catalogIds = models.filter(m => m.provider === provider).map(m => m.id); + const presetIds = materializeModelPreset(provider, catalogIds); + const preset = modelPresetFor(provider)!; + if (presetIds.length === 0) { + // NEVER write an empty allowlist from a preset: empty means ALL, so it would silently + // un-curate instead of curating. Keep the previous selection and record the fallback so + // the next convergence can retry. + target.modelPreset = { + mode: "all", + appliedVersion: preset.version, + appliedAt: new Date().toISOString(), + fallback: "preset-empty", + }; + persistConfig(config); + return jsonResponse({ + ok: true, + provider, + mode: "all", + fallback: "preset-empty", + selected: [...(target.selectedModels ?? [])], + }); + } + target.selectedModels = presetIds; + target.modelPreset = { + mode: "preset", + appliedVersion: preset.version, + appliedAt: new Date().toISOString(), + }; + persistConfig(config); + return jsonResponse({ + ok: true, + provider, + mode: "preset", + appliedVersion: preset.version, + selected: presetIds, + catalogRefresh: await convergeCodexCatalog(), + }); + } if (url.pathname === "/api/selected-models" && req.method === "PUT") { let body: { provider?: unknown; models?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } @@ -556,6 +660,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0) config.providers[provider].selectedModels = models; else delete config.providers[provider].selectedModels; + // Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the + // provider is in preset mode makes the selection theirs, and the proxy must never + // re-materialize over it afterwards. + markModelPresetDiverged(config.providers[provider]); persistConfig(config); const catalogRefresh = await convergeCodexCatalog(); return jsonResponse({ ok: true, provider, selected: models, catalogRefresh }); diff --git a/src/types/provider.ts b/src/types/provider.ts index de490414ca..9e7b5a1f86 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -275,6 +275,31 @@ export interface OcxProviderConfig { * full set so the user can pick). See devlog issue_052_provider-model-allowlist. */ selectedModels?: string[]; + /** + * Model-preset marker for `selectedModels` (#2465). Absent means "all", exactly today's + * semantics — an existing provider is never narrowed by an upgrade. + * + * The preset is a SEED, not a lock: `selectedModels` holds concrete ids materialized from + * the shipped rules, so every existing consumer and older binaries keep working against a + * plain allowlist. Divergence is detected at the WRITE path rather than by diffing — any user + * edit while the mode is "preset" flips it to "custom", after which the proxy never + * re-materializes. That collapses upgrade reconciliation to a version compare. + * + * Deliberately distinct from `deriveProviderPresets`, which curates WHICH PROVIDERS to offer. + * This curates which MODELS a provider exposes; the code says "model preset" throughout. + */ + modelPreset?: { + mode: "preset" | "all" | "custom"; + /** MODEL_PRESETS version materialized into `selectedModels`. */ + appliedVersion?: number; + appliedAt?: string; + /** + * Set when materialization matched nothing and the provider fell back to "all". A preset + * must never write an empty allowlist, because empty means ALL and would silently + * un-curate; the fallback marker lets the next convergence retry. + */ + fallback?: "preset-empty"; + }; /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */ contextWindow?: number; /** Per-model fallback when context metadata is absent; otherwise caps the reported window. */ diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 621179de00..aa0a6838d6 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -5668,3 +5668,105 @@ describe("Codex reasoning-effort capability clamp", () => { }); }); import { ManagementRequest as Request } from "./helpers/management-auth"; + +describe("#2465 model preset management routes", () => { + const originalFetchForPresets = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetchForPresets; clearModelCache(); }); + + function presetConfig(selected?: string[], marker?: Record) { + return { + port: 10100, + defaultProvider: "openrouter", + providers: { + openrouter: { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "k", + liveModels: false, + models: [ + "anthropic/claude-opus-5", + "openai/gpt-5.6-sol", + "meta-llama/llama-2-7b", + "some-vendor/ancient-v1", + ], + ...(selected ? { selectedModels: selected } : {}), + ...(marker ? { modelPreset: marker } : {}), + }, + }, + } as unknown as Parameters[2]; + } + + async function call(config: Parameters[2], method: string, body?: unknown) { + const url = new URL("http://127.0.0.1/api/model-presets"); + const init: RequestInit = body === undefined + ? { method } + : { method, body: JSON.stringify(body), headers: { "content-type": "application/json" } }; + const response = await handleManagementAPI(new Request(url, init), url, config); + return { status: response!.status, body: await response!.json() as Record }; + } + + test("GET previews the preset against the current catalog without applying it", async () => { + clearModelCache(); + const config = presetConfig(); + const { body } = await call(config, "GET"); + const view = (body.providers as Record>).openrouter; + expect(view.mode).toBe("all"); + expect(view.presetIds).toEqual(["anthropic/claude-opus-5", "openai/gpt-5.6-sol"]); + expect(view.totalCount).toBe(4); + // Preview must not mutate: the provider is still unfiltered. + expect(config.providers.openrouter.selectedModels).toBeUndefined(); + }); + + test("PUT preset materializes concrete ids and records the version", async () => { + clearModelCache(); + const config = presetConfig(); + const { body } = await call(config, "PUT", { provider: "openrouter", mode: "preset" }); + expect(body.mode).toBe("preset"); + expect(config.providers.openrouter.selectedModels).toEqual([ + "anthropic/claude-opus-5", + "openai/gpt-5.6-sol", + ]); + // Concrete ids, not patterns: the visibility hot path and older binaries stay compatible. + expect(config.providers.openrouter.modelPreset?.mode).toBe("preset"); + expect(config.providers.openrouter.modelPreset?.appliedVersion).toBeGreaterThan(0); + }); + + test("PUT all clears both the allowlist and the marker", async () => { + clearModelCache(); + const config = presetConfig(["anthropic/claude-opus-5"], { mode: "preset", appliedVersion: 1 }); + await call(config, "PUT", { provider: "openrouter", mode: "all" }); + expect(config.providers.openrouter.selectedModels).toBeUndefined(); + expect(config.providers.openrouter.modelPreset).toBeUndefined(); + }); + + test("a preset matching nothing never writes an empty allowlist", async () => { + clearModelCache(); + // Empty means ALL, so a zero-match preset must keep the previous selection rather than + // silently un-curating the provider. + const config = presetConfig(["some-vendor/ancient-v1"]); + config.providers.openrouter.models = ["some-vendor/ancient-v1"]; + const { body } = await call(config, "PUT", { provider: "openrouter", mode: "preset" }); + expect(body.fallback).toBe("preset-empty"); + expect(config.providers.openrouter.selectedModels).toEqual(["some-vendor/ancient-v1"]); + expect(config.providers.openrouter.modelPreset?.mode).toBe("all"); + expect(config.providers.openrouter.modelPreset?.fallback).toBe("preset-empty"); + }); + + test("an unknown provider and an invalid mode are rejected", async () => { + clearModelCache(); + const config = presetConfig(); + expect((await call(config, "PUT", { provider: "nope", mode: "preset" })).status).toBe(404); + expect((await call(config, "PUT", { provider: "openrouter", mode: "sideways" })).status).toBe(400); + }); + + test("a provider with no shipped preset cannot be switched into preset mode", async () => { + clearModelCache(); + const config = presetConfig(); + (config.providers as Record).groq = { + adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", apiKey: "k", liveModels: false, models: ["x"], + }; + const { status } = await call(config, "PUT", { provider: "groq", mode: "preset" }); + expect(status).toBe(400); + }); +}); + diff --git a/tests/model-presets.test.ts b/tests/model-presets.test.ts new file mode 100644 index 0000000000..7af8fe9aed --- /dev/null +++ b/tests/model-presets.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { + hasModelPreset, + markModelPresetDiverged, + materializeModelPreset, + modelPresetFor, + MODEL_PRESETS, +} from "../src/providers/model-presets"; +import type { OcxProviderConfig } from "../src/types"; + +describe("#2465 model presets", () => { + test("rules match vendor snapshot suffixes, not just bare ids", () => { + // The whole point of patterns over literal lists: a dated snapshot must not stale the + // preset between releases. + const ids = ["claude-opus-5", "claude-opus-5-20260814", "claude-3-opus-20240229"]; + const matched = materializeModelPreset("anthropic", ids); + expect(matched).toContain("claude-opus-5"); + expect(matched).toContain("claude-opus-5-20260814"); + // A historical snapshot outside the rules stays out — that is the curation. + expect(matched).not.toContain("claude-3-opus-20240229"); + }); + + test("an aggregator preset narrows a large catalog to flagships", () => { + const catalog = [ + "anthropic/claude-opus-5", + "openai/gpt-5.6-sol", + "x-ai/grok-4.6", + "meta-llama/llama-2-7b", + "some-vendor/ancient-model-v1", + "google/gemini-3.1-pro", + ]; + const matched = materializeModelPreset("openrouter", catalog); + expect(matched).toEqual([ + "anthropic/claude-opus-5", + "openai/gpt-5.6-sol", + "x-ai/grok-4.6", + "google/gemini-3.1-pro", + ]); + }); + + test("input order is preserved and duplicates collapse", () => { + // Order matters so the preview lists models the way the picker will. + const matched = materializeModelPreset("anthropic", ["claude-sonnet-5", "claude-opus-5", "claude-sonnet-5"]); + expect(matched).toEqual(["claude-sonnet-5", "claude-opus-5"]); + }); + + test("a provider without a shipped preset matches nothing", () => { + expect(hasModelPreset("groq")).toBe(false); + expect(materializeModelPreset("groq", ["llama-3.3-70b"])).toEqual([]); + }); + + test("no rule uses a global or sticky flag", () => { + // A /g or /y pattern carries lastIndex between .test() calls and would skip rows + // non-deterministically depending on catalog order. + for (const [provider, preset] of Object.entries(MODEL_PRESETS)) { + for (const rule of preset.rules) { + // Assert the FLAGS, not a label containing them — "anthropic-apikey" has a "y" in it. + expect({ provider, flags: rule.pattern.flags }).toEqual({ provider, flags: "" }); + } + } + }); + + test("every shipped preset carries a version", () => { + for (const preset of Object.values(MODEL_PRESETS)) { + expect(preset.version).toBeGreaterThan(0); + expect(preset.rules.length).toBeGreaterThan(0); + } + }); + + describe("divergence is detected at the write path", () => { + test("an edit while in preset mode flips to custom and keeps the applied version", () => { + const provider = { + modelPreset: { mode: "preset" as const, appliedVersion: 1, appliedAt: "2026-08-26T00:00:00Z" }, + } as OcxProviderConfig; + markModelPresetDiverged(provider); + expect(provider.modelPreset?.mode).toBe("custom"); + // Retained so the GUI can still offer "a newer preset is available". + expect(provider.modelPreset?.appliedVersion).toBe(1); + }); + + test("custom is terminal and all has nothing to flip", () => { + const custom = { modelPreset: { mode: "custom" as const } } as OcxProviderConfig; + markModelPresetDiverged(custom); + expect(custom.modelPreset?.mode).toBe("custom"); + + const none = {} as OcxProviderConfig; + markModelPresetDiverged(none); + // Absent marker means "all", exactly today's semantics — no marker is invented. + expect(none.modelPreset).toBeUndefined(); + }); + }); + + test("the anthropic OAuth and API-key rows share one curation", () => { + // They expose the same vendor catalog; diverging them would curate the same models twice. + expect(modelPresetFor("anthropic")?.rules.length).toBe(modelPresetFor("anthropic-apikey")?.rules.length); + }); +}); From 10c2570cfc3a44bdb6c385f56cae0e2ee48dee78 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 03:59:15 +0900 Subject: [PATCH 035/336] feat(gui): Preset / All selector on the provider header (#2465) (#2604) Completes the #2465 surfaces. The header gains a segmented control reusing the same .models-segmented classes the v1/default/v2 selector already uses, so the card keeps one control vocabulary instead of gaining a second look. Only providers with a shipped preset get it - a provider with nothing to curate would show a dead switch. Preset mode shows 'N of M shown - core preset vX', which is the point: a bare 'N models' hides how much was curated away. Custom is a STATE, not a destination. It activates on edit and renders as a disabled segment so the current mode is never ambiguous; switching back to Preset from Custom confirms first, because it discards the user's selection. A zero-match apply reports that the selection was left unchanged rather than showing a success that changed nothing. Strings added to all nine locales. Rendered and verified in a real browser against a stubbed management API - the first render caught a wrong stub shape, which is exactly why code alone is not evidence here. --- gui/src/i18n/de.ts | 10 ++ gui/src/i18n/en.ts | 10 ++ gui/src/i18n/fr.ts | 10 ++ gui/src/i18n/ja.ts | 10 ++ gui/src/i18n/ko.ts | 10 ++ gui/src/i18n/ru.ts | 10 ++ gui/src/i18n/tr.ts | 10 ++ gui/src/i18n/zh-TW.ts | 10 ++ gui/src/i18n/zh.ts | 10 ++ gui/src/pages/Models.tsx | 134 +++++++++++++++++++++++ gui/tests/models-preset-selector.test.ts | 61 +++++++++++ 11 files changed, 285 insertions(+) create mode 100644 gui/tests/models-preset-selector.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0f331ab196..42c042594e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -491,6 +491,16 @@ export const de: Record = { "models.workspace.mainAria": "Modelldetails", "models.allOn": "Alle an", "models.allOff": "Alle aus", + "models.presetLabel": "Modelle", + "models.presetMode_preset": "Voreinstellung", + "models.presetMode_all": "Alle", + "models.presetMode_custom": "Eigene", + "models.presetSummary": "{count} von {total} angezeigt — Core-Voreinstellung v{version}", + "models.presetUpdateAvailable": "Voreinstellung v{version} verfügbar", + "models.presetAppliedToast": "{provider}: Voreinstellung angewendet — {count} Modelle ausgewählt", + "models.presetClearedToast": "{provider}: alle Modelle werden angezeigt", + "models.presetEmpty": "{provider}: Voreinstellung traf auf kein Modell zu — Auswahl unverändert", + "models.presetConfirmReplace": "Auswahl durch die Voreinstellung mit {count} Modellen ersetzen?", "models.cap350k": "Limit 350k", "models.capApplied": "Kontext-Limit angewendet — greift bei der nächsten Codex-Runde.", "models.capSaveFailed": "Kontext-Limit konnte nicht gespeichert werden", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b4ba91fc5f..8628bf49a7 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -516,6 +516,16 @@ export const en = { "models.workspace.mainAria": "Model details", "models.allOn": "All on", "models.allOff": "All off", + "models.presetLabel": "Models", + "models.presetMode_preset": "Preset", + "models.presetMode_all": "All", + "models.presetMode_custom": "Custom", + "models.presetSummary": "{count} of {total} shown — core preset v{version}", + "models.presetUpdateAvailable": "Preset v{version} available", + "models.presetAppliedToast": "{provider}: preset applied — {count} models selected", + "models.presetClearedToast": "{provider}: showing all models", + "models.presetEmpty": "{provider}: preset matched no models — selection unchanged", + "models.presetConfirmReplace": "Replace your selection with the {count}-model preset?", "models.cap350k": "Cap 350k", "models.capApplied": "Context cap applied — takes effect on the next Codex turn.", "models.capSaveFailed": "Failed to save context cap", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 500336109d..dd2e57ba49 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -501,6 +501,16 @@ export const fr: Record = { "models.workspace.mainAria": "Détails du modèle", "models.allOn": "Tout activer", "models.allOff": "Tout désactiver", + "models.presetLabel": "Modèles", + "models.presetMode_preset": "Préréglage", + "models.presetMode_all": "Tous", + "models.presetMode_custom": "Personnalisé", + "models.presetSummary": "{count} sur {total} affichés — préréglage core v{version}", + "models.presetUpdateAvailable": "Préréglage v{version} disponible", + "models.presetAppliedToast": "{provider} : préréglage appliqué — {count} modèles sélectionnés", + "models.presetClearedToast": "{provider} : tous les modèles affichés", + "models.presetEmpty": "{provider} : le préréglage n\u2019a trouvé aucun modèle — sélection inchangée", + "models.presetConfirmReplace": "Remplacer votre sélection par le préréglage de {count} modèles ?", "models.cap350k": "Plafond de 350k", "models.capApplied": "Plafond de contexte appliqué — il prendra effet au prochain tour Codex.", "models.capSaveFailed": "Échec de l’enregistrement du plafond de contexte", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6345a012c7..c46827aec1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -499,6 +499,16 @@ export const ja: Record = { "models.workspace.mainAria": "モデルの詳細", "models.allOn": "すべてオン", "models.allOff": "すべてオフ", + "models.presetLabel": "モデル", + "models.presetMode_preset": "プリセット", + "models.presetMode_all": "すべて", + "models.presetMode_custom": "カスタム", + "models.presetSummary": "{total} 件中 {count} 件を表示 — コアプリセット v{version}", + "models.presetUpdateAvailable": "プリセット v{version} が利用可能", + "models.presetAppliedToast": "{provider}: プリセットを適用 — {count} 件を選択", + "models.presetClearedToast": "{provider}: すべてのモデルを表示", + "models.presetEmpty": "{provider}: プリセットに一致するモデルがないため選択は変更していません", + "models.presetConfirmReplace": "選択中の一覧を {count} 件のプリセットで置き換えますか?", "models.cap350k": "350k 上限", "models.capApplied": "コンテキスト上限を適用しました — 次回の Codex ターンで有効になります。", "models.capSaveFailed": "コンテキスト上限の保存に失敗しました", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 56883d91cc..d6dfcd47bd 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -502,6 +502,16 @@ export const ko: Record = { "models.workspace.mainAria": "모델 세부정보", "models.allOn": "모두 켜기", "models.allOff": "모두 끄기", + "models.presetLabel": "모델", + "models.presetMode_preset": "프리셋", + "models.presetMode_all": "전체", + "models.presetMode_custom": "커스텀", + "models.presetSummary": "{total}개 중 {count}개 표시 — 코어 프리셋 v{version}", + "models.presetUpdateAvailable": "프리셋 v{version} 사용 가능", + "models.presetAppliedToast": "{provider}: 프리셋 적용 — 모델 {count}개 선택", + "models.presetClearedToast": "{provider}: 모든 모델 표시", + "models.presetEmpty": "{provider}: 프리셋과 일치하는 모델이 없어 선택을 그대로 두었습니다", + "models.presetConfirmReplace": "선택한 목록을 {count}개짜리 프리셋으로 바꿀까요?", "models.cap350k": "350k 제한", "models.capApplied": "컨텍스트 제한 적용됨 — 다음 Codex 턴부터 반영됩니다.", "models.capSaveFailed": "컨텍스트 제한 저장 실패", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2dff534fb9..146c38b8f9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -504,6 +504,16 @@ export const ru: Record = { "models.workspace.mainAria": "Сведения о моделях", "models.allOn": "Все вкл.", "models.allOff": "Все выкл.", + "models.presetLabel": "Модели", + "models.presetMode_preset": "Пресет", + "models.presetMode_all": "Все", + "models.presetMode_custom": "Свои", + "models.presetSummary": "Показано {count} из {total} — базовый пресет v{version}", + "models.presetUpdateAvailable": "Доступен пресет v{version}", + "models.presetAppliedToast": "{provider}: пресет применён — выбрано моделей: {count}", + "models.presetClearedToast": "{provider}: показаны все модели", + "models.presetEmpty": "{provider}: пресет не совпал ни с одной моделью — выбор не изменён", + "models.presetConfirmReplace": "Заменить ваш выбор пресетом из {count} моделей?", "models.cap350k": "Лимит 350k", "models.capApplied": "Лимит контекста применён — вступит в силу на следующем ходе Codex.", "models.capSaveFailed": "Не удалось сохранить лимит контекста", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b9729f0580..0a3a3afda8 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -507,6 +507,16 @@ export const tr: Record = { "models.workspace.mainAria": "Model detayları", "models.allOn": "Tümünü aç", "models.allOff": "Tümünü kapat", + "models.presetLabel": "Modeller", + "models.presetMode_preset": "Ön ayar", + "models.presetMode_all": "Tümü", + "models.presetMode_custom": "Özel", + "models.presetSummary": "{total} modelden {count} tanesi gösteriliyor — çekirdek ön ayar v{version}", + "models.presetUpdateAvailable": "Ön ayar v{version} mevcut", + "models.presetAppliedToast": "{provider}: ön ayar uygulandı — {count} model seçildi", + "models.presetClearedToast": "{provider}: tüm modeller gösteriliyor", + "models.presetEmpty": "{provider}: ön ayar hiçbir modelle eşleşmedi — seçim değişmedi", + "models.presetConfirmReplace": "Seçiminiz {count} modellik ön ayarla değiştirilsin mi?", "models.cap350k": "350k Sınırı", "models.capApplied": "Bağlam sınırı uygulandı.", "models.capSaveFailed": "Bağlam sınırı kaydedilemedi", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index aa3bd8e969..356ac53518 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -383,6 +383,16 @@ export const zhTW: Record = { "models.workspace.mainAria": "模型詳細資料", "models.allOn": "全部開啟", "models.allOff": "全部關閉", + "models.presetLabel": "模型", + "models.presetMode_preset": "預設集", + "models.presetMode_all": "全部", + "models.presetMode_custom": "自訂", + "models.presetSummary": "顯示 {count} / {total} — 核心預設集 v{version}", + "models.presetUpdateAvailable": "預設集 v{version} 可用", + "models.presetAppliedToast": "{provider}:已套用預設集 — 選取 {count} 個模型", + "models.presetClearedToast": "{provider}:顯示全部模型", + "models.presetEmpty": "{provider}:預設集未比對到模型,選擇維持不變", + "models.presetConfirmReplace": "以包含 {count} 個模型的預設集取代你的選擇?", "models.cap350k": "限制 350k", "models.capApplied": "上下文限制已套用 — 將在下一個 Codex 回合生效。", "models.capSaveFailed": "儲存上下文限制失敗", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cecb961713..037f3f599a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -499,6 +499,16 @@ export const zh: Record = { "models.workspace.mainAria": "模型详情", "models.allOn": "全部开启", "models.allOff": "全部关闭", + "models.presetLabel": "模型", + "models.presetMode_preset": "预设", + "models.presetMode_all": "全部", + "models.presetMode_custom": "自定义", + "models.presetSummary": "显示 {count} / {total} — 核心预设 v{version}", + "models.presetUpdateAvailable": "预设 v{version} 可用", + "models.presetAppliedToast": "{provider}:已应用预设 — 选中 {count} 个模型", + "models.presetClearedToast": "{provider}:显示全部模型", + "models.presetEmpty": "{provider}:预设未匹配到模型,选择保持不变", + "models.presetConfirmReplace": "用包含 {count} 个模型的预设替换你的选择?", "models.cap350k": "限制 350k", "models.capApplied": "上下文限制已应用 — 将在下一个 Codex 回合生效。", "models.capSaveFailed": "保存上下文限制失败", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 13853249a4..7758cffea3 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -102,6 +102,18 @@ function parseContextWindowDraft(raw: string): number | null | undefined { return Number.isSafeInteger(value) && value > 0 ? value : undefined; } + +/** #2465 per-provider model-preset view, as `GET /api/model-presets` returns it. */ +interface ModelPresetView { + mode: "preset" | "all" | "custom"; + appliedVersion?: number; + availableVersion: number; + presetIds: string[]; + presetCount: number; + totalCount: number; + fallback?: string; +} + export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; restartEpoch?: number }) { // Codex app-server staleness (devlog/_fin/260815_gui_codex_restart). Named // appServerState, not catalogState: this file already binds that name to the @@ -225,6 +237,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const loadPendingRef = useRef(false); // multi_agent_v2 / ultra gate. null = endpoint unavailable (older proxy build) -> section hidden. const [v2, setV2] = useState(null); + // #2465: per-provider model-preset state. Keyed by provider so one card's busy state cannot + // freeze the others. + const [presets, setPresets] = useState>({}); + const [presetBusy, setPresetBusy] = useState(null); const [v2Loading, setV2Loading] = useState(true); const [v2Busy, setV2Busy] = useState(false); const [v2Note, setV2Note] = useState(""); @@ -421,6 +437,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const timeout = window.setTimeout(() => { void loadShadowCall(); void loadV2(); + // Preset previews belong to the same tab. Loaded once rather than polled: the rules are + // shipped code and the catalog poll above already refreshes the rows they describe. + void loadPresets(); }, 0); // Hidden tab: no timer, no /api/v2 traffic; the make-up tick refreshes on return. const stop = startVisibilityPoll(() => { @@ -430,6 +449,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; window.clearTimeout(timeout); stop(); }; + // oxlint-disable-next-line react/react-compiler -- existing exhaustive-deps exception is intentional + // eslint-disable-next-line react-hooks/exhaustive-deps -- loadPresets is a plain async loader + // like the rest of this file's; a useCallback wrapper trips PreserveManualMemo, and the + // effect only ever needs the current closure. }, [catalogActive, loadShadowCall, loadV2]); const groups = useMemo( @@ -840,6 +863,53 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; await putV2Setting({ multiAgentMode: mode }); }; + + /** + * #2465: load the per-provider preset preview. Rules are evaluated server-side against the + * CURRENT catalog, so the count shown is the count an apply would produce. + */ + const loadPresets = async () => { + try { + const bounded = createBoundedFetch(15_000); + const r = await fetch(`${apiBase}/api/model-presets`, { signal: bounded.signal }); + const data = await readJsonIfOk<{ providers?: Record }>(r); + setPresets(data?.providers ?? {}); + } catch { + // A preset preview is decoration on top of a working Models page; failing to load it must + // not take the page down. + setPresets({}); + } + }; + + const applyPreset = async (provider: string, mode: "preset" | "all") => { + if (presetBusy) return; + setPresetBusy(provider); + try { + const bounded = createBoundedFetch(30_000); + const r = await fetch(`${apiBase}/api/model-presets`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider, mode }), + signal: bounded.signal, + }); + const res = await readJsonIfOk<{ fallback?: string; selected?: string[] }>(r) ?? {}; + if (res.fallback === "preset-empty") { + // Never silently narrow to nothing: the server kept the previous selection, so say so + // rather than showing a success that changed nothing. + publishFeedback(false, t("models.presetEmpty", { provider })); + } else { + publishFeedback(true, mode === "all" + ? t("models.presetClearedToast", { provider }) + : t("models.presetAppliedToast", { provider, count: String(res.selected?.length ?? 0) })); + } + await Promise.all([loadPresets(), load()]); + } catch (error) { + publishFeedback(false, error instanceof Error ? error.message : String(error)); + } finally { + setPresetBusy(null); + } + }; + const setKeepNativeChatGptOnV1 = async (next: boolean) => { if (!v2 || v2.keepNativeChatGptOnV1 === next) return; await putV2Setting({ keepNativeChatGptOnV1: next }); @@ -1121,6 +1191,70 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; aria-haspopup="dialog" >+ } + {(() => { + // #2465: Preset / All / Custom. Only providers with a shipped preset get the + // control — a provider with nothing to curate would show a dead switch. + const preset = presets[provider]; + if (!preset) return null; + const busyHere = presetBusy === provider; + const stale = preset.mode === "custom" + && preset.appliedVersion !== undefined + && preset.appliedVersion < preset.availableVersion; + return ( + <> +
+ {(["preset", "all"] as const).map(mode => ( + + ))} + {/* Custom is a STATE, not a destination: it activates on edit. Shown as a + disabled segment so the current mode is never ambiguous. */} + {preset.mode === "custom" && ( + + )} +
+ {preset.mode === "preset" && ( + + {t("models.presetSummary", { + count: String(preset.presetCount), + total: String(preset.totalCount), + version: String(preset.availableVersion), + })} + + )} + {stale && ( + + {t("models.presetUpdateAvailable", { version: String(preset.availableVersion) })} + + )} + + ); + })()} <> diff --git a/gui/tests/models-preset-selector.test.ts b/gui/tests/models-preset-selector.test.ts new file mode 100644 index 0000000000..c6767bf672 --- /dev/null +++ b/gui/tests/models-preset-selector.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { en } from "../src/i18n/en"; + +/** + * #2465: the provider header gains a Preset / All segmented control. Custom is a STATE that + * activates on edit, not a destination the user picks, so it renders as a disabled segment — + * the current mode must never be ambiguous. + */ +test("the preset selector reuses the existing segmented control, not a new visual language", async () => { + const src = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + // Same classes the v1/default/v2 selector already uses, so the header keeps one control + // vocabulary instead of gaining a second look. + expect(src).toContain('aria-label={t("models.presetLabel")}'); + expect(src).toMatch(/className="segmented models-segmented" role="radiogroup" aria-label=\{t\("models\.presetLabel"\)\}/); + // Radios, not buttons-that-look-like-radios: the mode is a single choice. + expect(src).toContain('role="radio"'); +}); + +test("only providers with a shipped preset get the control", async () => { + const src = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + // A provider with nothing to curate would otherwise show a dead switch. + expect(src).toContain("const preset = presets[provider];"); + expect(src).toContain("if (!preset) return null;"); +}); + +test("switching away from a custom selection confirms first", async () => { + const src = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); + expect(src).toContain("models.presetConfirmReplace"); + expect(src).toMatch(/mode === "preset" && preset\.mode === "custom"/); +}); + +test("every string the selector renders exists in the catalog", () => { + for (const key of [ + "models.presetLabel", + "models.presetMode_preset", + "models.presetMode_all", + "models.presetMode_custom", + "models.presetSummary", + "models.presetUpdateAvailable", + "models.presetAppliedToast", + "models.presetClearedToast", + "models.presetEmpty", + "models.presetConfirmReplace", + ]) { + expect(en[key as keyof typeof en]).toBeTruthy(); + } +}); + +test("the summary names both the shown count and the total", () => { + // "9 of 412" is the point: a bare "9 models" hides how much was curated away. + const summary = en["models.presetSummary"]; + expect(summary).toContain("{count}"); + expect(summary).toContain("{total}"); + expect(summary).toContain("{version}"); +}); + +test("the zero-match message says the selection was left alone", () => { + // A preset that matched nothing must not read as a success that changed something. + expect(en["models.presetEmpty"]).toContain("unchanged"); +}); + From bf64c24f99eaa1938f57d37dbd6c646e02ad9c7f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:14:49 +0900 Subject: [PATCH 036/336] test(catalog): pin the two model-preset convergence calls (#2465) (#2606) --- tests/codex-convergence-contract.test.ts | 29 +++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index b08be70bd4..c39d68d211 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -372,10 +372,10 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 7 + 6 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 7 + 8 + 2 + 2 convergence calls", () => { const counts = Object.fromEntries([ ["provider-routes.ts", 7], - ["model-routes.ts", 6], + ["model-routes.ts", 8], ["combo-routes.ts", 2], ["agent-settings-routes.ts", 2], ].map(([file, expected]) => { @@ -387,12 +387,35 @@ test("the route inventory contains exactly the specified 7 + 6 + 2 + 2 convergen })); expect(counts).toEqual({ "provider-routes.ts": 7, - "model-routes.ts": 6, + "model-routes.ts": 8, "combo-routes.ts": 2, "agent-settings-routes.ts": 2, }); }); +/** + * Same discipline as the reload-route assertion below: the count above went 6 -> 8 for the + * model-preset routes (#2465), and a bare count that only ever rises stops being a contract. + * Assert those two calls specifically, so a later bump cannot pass while some OTHER route + * quietly gained one, or while a preset route lost its own convergence. + * + * Both are write paths that change which models ship to the catalog — applying a preset + * narrows it, clearing back to "all" widens it — so each must converge for exactly the same + * reason `PUT /api/selected-models` does. + */ +test("both model-preset write paths converge the Codex catalog", () => { + const source = readFileSync( + join(import.meta.dir, "..", "src", "server", "management", "model-routes.ts"), + "utf8", + ); + const handlerStart = source.indexOf('url.pathname === "/api/model-presets" && req.method === "PUT"'); + expect(handlerStart).toBeGreaterThan(-1); + const handlerBody = source.slice(handlerStart, source.indexOf("url.pathname ===", handlerStart + 1)); + // The "all" branch and the materialize branch each converge; "custom" only moves the marker, + // so it deliberately does not. + expect(handlerBody.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(2); +}); + /** * The inventory above is a bare count, so raising it is the obvious way to make this file * green again — and a count that only ever gets raised stops being a contract. #1541's From d0f4c17d324f5f8afca6963e865e22ddde323aca Mon Sep 17 00:00:00 2001 From: Olddonkey Date: Tue, 25 Aug 2026 12:22:45 -0700 Subject: [PATCH 037/336] fix(gui): align the sidebar foot's four rows (#2430) * fix(gui): align the sidebar foot's four rows The foot stacks language, theme, proxy and GitHub two pixels apart, so a row that measures itself differently reads as a step in the stack. Three independent defects put all four out of line at once, measured in the running GUI at a 1280px viewport (sidebar content spans x 14 to 217): - the proxy label's text started at x=24 against x=49 for its three neighbours, because it is the only row with no icon and so never cleared the 16px icon + 9px gap gutter; - the GitHub orbs ended at x=217 against x=207 for the proxy orbs and the language chevron, because that row was the only one with no trailing inset; - the proxy row was 44px tall against 35.5px for the rest, because it padded 8px around 28px orbs the other rows do not carry. The GitHub row gains the same 10px trailing inset the rows above it already use. The proxy row hands its padding to the label, which then also clears the icon gutter: the row's height goes back to being set by its text, like its neighbours, instead of by the taller orbs beside it. After the change all four rows share one text column (49px), one trailing edge (207px) and one height (35.5px) -- verified in the running GUI at 1280px and in the 420px drawer, and for every dash.actions translation. Co-Authored-By: Claude Opus 5 * test(gui): reject proxy-row block padding in every spelling CodeRabbit's review of the previous commit: the guard rejected only the exact shorthand the row shipped with, so `padding: 8px 0`, a lone `padding-top`, or `padding-block` would each restore the extra height around the 28px orbs and still pass. Reject the whole family instead. `padding-right` survives both patterns because "padding" is followed by "-", never by a colon. Verified by mutation: each of the three bypasses above turns the test red, and the unmutated file still passes. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- gui/src/styles.css | 14 +++++++---- gui/tests/sidebar-rows.test.ts | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/gui/src/styles.css b/gui/src/styles.css index bbf6147218..7691a6efd1 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -328,9 +328,11 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } /* GitHub row: the label keeps the full-width link affordance, the two circular satellites (star, update) sit at the trailing edge without shrinking the label's - hover target. The update orb only exists when an update is available, so the row + hover target. That edge is the sidebar's 10px content inset, not the rail wall, so + the orbs stack under the lang chevron and the proxy orbs instead of hanging 10px + further out than both. The update orb only exists when an update is available, so the row is one or two circles wide — never a placeholder. */ -.sidebar-github-row { display: flex; align-items: center; gap: 4px; min-width: 0; } +.sidebar-github-row { display: flex; align-items: center; gap: 4px; min-width: 0; padding-right: 10px; } .sidebar-github-link { flex: 1 1 auto; min-width: 0; } .sidebar-github-actions { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; } .sidebar-orb { @@ -388,8 +390,12 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } row with a quiet label. The stop control used to be a full-width button; pairing it with restart as icons keeps the foot from growing a fifth stacked row and puts the destructive action next to the recovery action it is most often confused with. */ -.sidebar-action-row { display: flex; align-items: center; gap: 4px; min-width: 0; padding: 8px 10px; } -.sidebar-action-label { flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: var(--text-control); } +.sidebar-action-row { display: flex; align-items: center; gap: 4px; min-width: 0; padding-right: 10px; } +/* Left padding matches .sidebar-link, plus the 16px icon + 9px gap gutter the + iconless label has to clear to sit in the same text column as its neighbours. + Owning the block padding here (rather than on the row) keeps the row the same + height as the other three, which the 28px orbs would otherwise inflate. */ +.sidebar-action-label { flex: 1 1 auto; min-width: 0; padding: 8px 10px 8px calc(10px + 16px + 9px); color: var(--muted); font-size: var(--text-control); } .sidebar-action-orbs { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; } .sidebar-orb--danger { color: var(--red); } .sidebar-orb--danger:hover:not(:disabled) { background: var(--red-soft); color: var(--red); border-color: var(--red); } diff --git a/gui/tests/sidebar-rows.test.ts b/gui/tests/sidebar-rows.test.ts index b711c5421f..c53243c42c 100644 --- a/gui/tests/sidebar-rows.test.ts +++ b/gui/tests/sidebar-rows.test.ts @@ -50,6 +50,49 @@ test("the orphaned sidebar switch styles are gone", async () => { expect(css).not.toContain(".nav-entry-claude .switch"); }); +test("the foot's four rows share one text column and one trailing inset", async () => { + /* + * The foot stacks lang, theme, proxy and GitHub two pixels apart, so any row that + * measures itself differently is visible as a step in the stack. All four shipped + * out of line at once: the proxy label sat 25px left of its neighbours because it + * has no icon to clear, its row was 8.5px taller because it padded around 28px orbs + * the others do not have, and the GitHub orbs hung 10px further out because that row + * was the only one with no trailing inset. + */ + const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + const rule = (selector: string) => { + const at = css.indexOf(`${selector} {`); + expect(at).toBeGreaterThan(-1); + return css.slice(at, css.indexOf("}", at)); + }; + + // The column every label sits in, owned by the rows that carry an icon. + for (const selector of [".lang-toggle", ".theme-toggle", ".sidebar-link"]) { + expect(rule(selector)).toContain("padding: 8px 10px"); + expect(rule(selector)).toContain("gap: 9px"); + } + + // The proxy label has no icon, so it clears that gutter itself. Holding the block + // padding on the label rather than the row is what keeps the row's height tied to + // its text, like its neighbours, instead of to the taller orbs beside it. + expect(rule(".sidebar-action-label")).toContain("padding: 8px 10px 8px calc(10px + 16px + 9px)"); + + /* + * Reject block padding on the row in every spelling, not just the shorthand it + * shipped with: `padding: 8px 0`, or a lone `padding-top`, would hand the 28px orbs + * back control of the row height and still slip past a check for the exact original + * string. `padding-right` survives both patterns — "padding" is followed by "-", + * never by a colon. + */ + const proxyRow = rule(".sidebar-action-row"); + expect(proxyRow).not.toMatch(/padding\s*:/); + expect(proxyRow).not.toMatch(/padding-(top|bottom|block)/); + + // Trailing controls stop on the same inset as the lang chevron above them. + expect(proxyRow).toContain("padding-right: 10px"); + expect(rule(".sidebar-github-row")).toContain("padding-right: 10px"); +}); + test("Claude Code is still reachable, just not as a duplicate row", async () => { // Removing the shortcut must not remove the destination. const routing = await Bun.file(new URL("../src/app-routing.ts", import.meta.url)).text(); From 8d4b9e586879a80b8f7249420dcbc0d2f6752a29 Mon Sep 17 00:00:00 2001 From: "Henrique V. L." <81029476+riique@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:24:05 -0300 Subject: [PATCH 038/336] fix(pricing): map Daybreak cost overlays and preserve model identity in logs (#2575) * fix(pricing): map Daybreak cost overlays and preserve model identity in logs * fix(pricing): keep Daybreak aliases provider-scoped * fix(pricing): align Sol pricing tuple, scope priority rules, and update Daybreak Red icon --- gui/src/model-display.ts | 5 +- .../claude-code-background-helper.test.tsx | 10 ++- src/server/responses/core.ts | 10 ++- src/usage/expected-prices.ts | 67 ++++++++++++------- tests/server-auth.test.ts | 13 +++- tests/usage-cost.test.ts | 43 +++++++++--- 6 files changed, 110 insertions(+), 38 deletions(-) diff --git a/gui/src/model-display.ts b/gui/src/model-display.ts index 0246a06ced..f6ef8cf657 100644 --- a/gui/src/model-display.ts +++ b/gui/src/model-display.ts @@ -4,7 +4,7 @@ * 5.6 trio is instantly distinguishable. No emoji — Lucide-style SVG only. */ import { createElement, type ReactNode } from "react"; -import { IconSun, IconGlobe, IconMoon } from "./icons"; +import { IconSun, IconGlobe, IconMoon, IconLock } from "./icons"; type IconComponent = typeof IconSun; @@ -12,6 +12,9 @@ const MODEL_ICON_MAP: Record = { "gpt-5.6-sol": IconSun, "gpt-5.6-terra": IconGlobe, "gpt-5.6-luna": IconMoon, + "gpt-daybreak-blue-latest": IconSun, + "daybreak-blue-latest": IconSun, + "daybreak-red-latest": IconLock, }; const ICON_STYLE = { width: 14, height: 14, flexShrink: 0, verticalAlign: "text-bottom" as const }; diff --git a/gui/tests/claude-code-background-helper.test.tsx b/gui/tests/claude-code-background-helper.test.tsx index 850f2b25c6..c05cff8cd8 100644 --- a/gui/tests/claude-code-background-helper.test.tsx +++ b/gui/tests/claude-code-background-helper.test.tsx @@ -71,7 +71,7 @@ test("selected background helper keeps the neutral description and hides the nat // "[object Object]". These render the options the page actually builds, so // reintroducing String() fails here rather than only on screen. test("icon-bearing models render their name, never [object Object] (#668)", () => { - const slugs = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]; + const slugs = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-daybreak-blue-latest", "daybreak-blue-latest", "daybreak-red-latest"]; // The closed picker renders only the SELECTED option, so assert per slug. for (const slug of slugs) { const html = renderToStaticMarkup( @@ -89,6 +89,14 @@ test("icon-bearing models render their name, never [object Object] (#668)", () = } }); +test("Daybreak Red uses a cyber lock rather than the Blue solar identity", () => { + const blue = renderToStaticMarkup(modelLabel("daybreak-blue-latest")); + const red = renderToStaticMarkup(modelLabel("daybreak-red-latest")); + expect(blue).toContain(''); + expect(red).toContain(''); + expect(red).not.toBe(blue); +}); + test("background helper options keep icon labels as nodes and lead with the unset entry (#668)", () => { const options = backgroundHelperOptions(["gpt-5.6-sol", "gemini/gemini-3-flash"], "unset"); expect(options[0]).toEqual({ value: "", label: "unset" }); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 789c19c922..9a1bfe442b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -930,11 +930,15 @@ export function codexAccountGatedCanonicalWireModel(modelId: string): string | u return undefined; } -function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult): void { +function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult, logCtx?: RequestLogContext): void { if (!isCanonicalOpenAiForwardProvider(route.provider)) return; const wireModel = codexAccountGatedCanonicalWireModel(route.modelId); if (!wireModel) return; + if (logCtx) { + logCtx.preserveResolvedModelFromRoute = true; + delete logCtx.resolvedModel; + } parsed.modelId = wireModel; if (!parsed._rawBody || typeof parsed._rawBody !== "object") return; const raw = parsed._rawBody as Record; @@ -2719,7 +2723,7 @@ async function handleResponsesInner( } route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); - applyCodexAccountGatedWireNormalization(parsed, route); + applyCodexAccountGatedWireNormalization(parsed, route, logCtx); logCtx.provider = route.codexAccountNamespace ? `${route.providerName}-${route.codexAccountNamespace}` : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); @@ -3650,7 +3654,7 @@ async function handleResponsesInner( } const headers = sanitizePassthroughHeaders(upstreamResponse.headers); const resolvedModel = headers.get("openai-model")?.trim(); - if (resolvedModel) logCtx.resolvedModel = resolvedModel; + if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; if (isUsageDebugEnabled()) { const upstreamContentType = upstreamResponse.headers.get("content-type"); if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType; diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index af94b8b262..dfc4b4711d 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -39,7 +39,7 @@ export interface ExpectedPriceOverlay { } const GEMINI_31_PRO: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }; -const GPT56_SOL: Cost4 = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }; +const GPT56_SOL: Cost4 = { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }; /** * Daybreak aliases. `daybreak-*-latest` never appears in the pricing table itself — only its * current snapshot does — so these tuples are the snapshot's published rates and carry @@ -126,6 +126,10 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ // Daybreak aliases: priced as their current snapshots (red -> gpt-5.6-cyber, // blue -> gpt-5.6-sol). The alias ids carry no rows of their own upstream, hence // verified-derived. Blue deliberately reuses GPT56_SOL rather than duplicating the tuple. + // The `gpt-` selector is native to ChatGPT/Codex accounts. The bare aliases below belong to + // the separately billed API-key catalog; keep those namespaces disjoint so pricing metadata + // cannot make an unroutable provider/model pair look supported. + { provider: "openai", modelId: "gpt-daybreak-blue-latest", cost4: GPT56_SOL, source: `alias of gpt-5.6-sol ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-11", status: "verified-derived" }, { provider: "openai-apikey", modelId: "daybreak-red-latest", cost4: DAYBREAK_RED, source: `alias of gpt-5.6-cyber ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-11", status: "verified-derived" }, { provider: "openai-apikey", modelId: "daybreak-blue-latest", cost4: GPT56_SOL, source: `alias of gpt-5.6-sol ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-11", status: "verified-derived" }, { provider: "google-antigravity", modelId: "gemini-3.1-pro-low", cost4: GEMINI_31_PRO, source: `derived: gemini-3.1-pro (<=200k tier) ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" }, @@ -223,6 +227,8 @@ export function findExpectedPriceOverlay( /** OpenAI Fast price multipliers retained as a compatibility export. */ export const PRIORITY_MULTIPLIERS: Readonly> = { "gpt-5.6-sol": 2, + "gpt-daybreak-blue-latest": 2, + "daybreak-blue-latest": 2, // Post-price-cut Fast tables (https://openai.com/api-fast-mode/, 2026-08-05): // Terra 4/24/0.40/5 and Luna 0.40/2.40/0.04/0.50 are both 2× the corrected // standard tuples; the stale bases made these look like 1.6/0.4 (#907). @@ -257,13 +263,19 @@ const XAI_PRIORITY_PRICING = "https://docs.x.ai/developers/advanced-api-usage/pr */ export const PRIORITY_PRICING_RULES: readonly PriorityPricingRule[] = [ ...["openai", "openai-apikey"].flatMap(provider => - Object.entries(PRIORITY_MULTIPLIERS).map(([modelId, multiplier]): PriorityPricingRule => ({ - provider, - modelId, - multiplier, - source: OPENAI_FAST_PRICING, - verifiedAt: "2026-08-05", - })), + Object.entries(PRIORITY_MULTIPLIERS) + // Daybreak's `gpt-` selector belongs to ChatGPT/Codex accounts; its bare selector belongs + // to the separately billed API-key catalog. All ordinary GPT ids remain valid on both. + .filter(([modelId]) => provider === "openai" + ? modelId !== "daybreak-blue-latest" + : modelId !== "gpt-daybreak-blue-latest") + .map(([modelId, multiplier]): PriorityPricingRule => ({ + provider, + modelId, + multiplier, + source: OPENAI_FAST_PRICING, + verifiedAt: "2026-08-05", + })), ), ...["grok-4.5", "grok-4.6"].map((modelId): PriorityPricingRule => ({ provider: "xai", @@ -349,6 +361,29 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [ verifiedAt: "2026-08-03", })), ), + { + // The native Codex selector aliases gpt-5.6-sol and shares its 272k long-context tier. + provider: "openai", + modelId: "gpt-daybreak-blue-latest", + thresholdInputTokens: 272_000, + inclusive: false, + multiplier: OPENAI_LONG_CONTEXT, + confirmedPriorityRelation: "exclusive", + source: OPENAI_PRICING_DOC, + verifiedAt: "2026-08-11", + }, + { + // The bare selector is the separately billed API-key alias. Daybreak Red has no tier row: + // the cyber snapshot's four long-context cells are all "-" on the pricing page. + provider: "openai-apikey", + modelId: "daybreak-blue-latest", + thresholdInputTokens: 272_000, + inclusive: false, + multiplier: OPENAI_LONG_CONTEXT, + confirmedPriorityRelation: "exclusive", + source: OPENAI_PRICING_DOC, + verifiedAt: "2026-08-11", + }, { provider: "xai", modelId: "grok-4.5", @@ -371,22 +406,6 @@ export const CONTEXT_TIERS: readonly ContextTier[] = [ source: "https://docs.x.ai/developers/pricing", verifiedAt: "2026-08-18", }, - { - // daybreak-blue-latest aliases gpt-5.6-sol, which publishes the full long-context row - // ($10 / $1 / $12.50 / $45). Scoped to openai-apikey ON PURPOSE: Daybreak is not - // routable on the Codex-login `openai` provider, so adding the alias to the shared - // OPENAI_GPT56_CONTEXT_MODELS list (expanded across both providers above) would mint a - // tier for a provider/model pair that cannot exist. - // daybreak-red-latest has NO tier row: the cyber snapshot's four long-context cells are - // all "-" on the pricing page (verified 2026-08-11). - provider: "openai-apikey", - modelId: "daybreak-blue-latest", - thresholdInputTokens: 272_000, - inclusive: false, - multiplier: OPENAI_LONG_CONTEXT, - source: OPENAI_PRICING_DOC, - verifiedAt: "2026-08-11", - }, ...["minimax", "minimax-cn"].map((provider): ContextTier => ({ provider, modelId: "MiniMax-M3", diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 401fcde4af..accd66e90d 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -2167,7 +2167,10 @@ describe("server local API auth", () => { const harness = await startPoolRetryHarness( async (_accountId, request) => { upstreamBody = await request.json() as Record; - return Response.json({ id: "canonical-wire-success", status: "completed", output: [] }); + return Response.json( + { id: "canonical-wire-success", status: "completed", output: [], usage: { input_tokens: 1000, output_tokens: 100 } }, + { headers: { "openai-model": "gpt-5.6-sol" } }, + ); }, { secondAccount: false, @@ -2183,6 +2186,14 @@ describe("server local API auth", () => { expect(upstreamBody?.model).toBe("gpt-5.6-sol"); expect(upstreamBody).not.toHaveProperty("prompt_cache_retention"); expect(harness.dispatches).toEqual(["acct-pool-a"]); + + const logs = logsFromApiBody(await fetch(new URL("/api/logs?tail=1", harness.server.url), { headers: managementHeaders() }).then(r => r.json())); + expect(logs.at(-1)).toMatchObject({ + model: "gpt-daybreak-blue-latest", + status: 200, + }); + expect(logs.at(-1)?.resolvedModel).toBeUndefined(); + expect(logs.at(-1)?.displayMetrics?.cost?.kind).toBe("value"); } finally { await stopPoolRetryHarness(harness); } diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 362868f4f2..ab313f98be 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -269,14 +269,15 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 55 keys, including Opus 5 and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(55); + test("16. shipped overlay membership: 56 keys, including Opus 5 and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(56); 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", + "openai/gpt-daybreak-blue-latest", "openai-apikey/daybreak-red-latest", "openai-apikey/daybreak-blue-latest", "minimax/MiniMax-M2.1-highspeed", @@ -327,6 +328,13 @@ describe("resolveMatchedPrice", () => { ]) { expect(keys.has(expected)).toBe(true); } + for (const impossible of [ + "openai/daybreak-blue-latest", + "openai/daybreak-red-latest", + "openai-apikey/gpt-daybreak-blue-latest", + ]) { + expect(keys.has(impossible)).toBe(false); + } const direct = findExpectedPriceOverlay("google", "gemini-3.6-flash"); expect(direct).toMatchObject({ @@ -540,6 +548,8 @@ describe("priority (Fast) service tier multiplier", () => { test("P8. resolvePriorityMultiplier returns correct values", () => { expect(resolvePriorityMultiplier("gpt-5.6-sol")).toBe(2); + expect(resolvePriorityMultiplier("gpt-daybreak-blue-latest")).toBe(2); + expect(resolvePriorityMultiplier("daybreak-blue-latest")).toBe(2); expect(resolvePriorityMultiplier("gpt-5.6-terra")).toBe(2); expect(resolvePriorityMultiplier("gpt-5.6-luna")).toBe(2); expect(resolvePriorityMultiplier("gpt-5.5")).toBe(2.5); @@ -550,14 +560,23 @@ describe("priority (Fast) service tier multiplier", () => { }); test("P9. PRIORITY_MULTIPLIERS table has expected entries", () => { - expect(Object.keys(PRIORITY_MULTIPLIERS)).toHaveLength(6); + expect(Object.keys(PRIORITY_MULTIPLIERS)).toHaveLength(8); expect(PRIORITY_MULTIPLIERS["gpt-5.6-sol"]).toBe(2); + expect(PRIORITY_MULTIPLIERS["gpt-daybreak-blue-latest"]).toBe(2); + expect(PRIORITY_MULTIPLIERS["daybreak-blue-latest"]).toBe(2); expect(PRIORITY_MULTIPLIERS["gpt-5.6-terra"]).toBe(2); expect(PRIORITY_MULTIPLIERS["gpt-5.6-luna"]).toBe(2); expect(PRIORITY_MULTIPLIERS["gpt-5.5"]).toBe(2.5); expect(PRIORITY_MULTIPLIERS["gpt-5.4-mini"]).toBe(2); }); + test("P9b. Daybreak priority rules stay inside their routable provider namespace", () => { + expect(findPriorityPricingRule("openai", "gpt-daybreak-blue-latest")?.multiplier).toBe(2); + expect(findPriorityPricingRule("openai-apikey", "daybreak-blue-latest")?.multiplier).toBe(2); + expect(findPriorityPricingRule("openai-apikey", "gpt-daybreak-blue-latest")).toBeUndefined(); + expect(findPriorityPricingRule("openai", "daybreak-blue-latest")).toBeUndefined(); + }); + test("P10. attempt cost with priority tier", () => { const base = estimateAttemptCost({ ordinal: 1, provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage }, overlays); const fast = estimateAttemptCost({ ordinal: 1, provider: "openai", model: "gpt-5.6-sol", usageStatus: "reported", usage }, overlays, "priority"); @@ -764,28 +783,36 @@ describe("long-context pricing tiers (#908)", () => { // An alias is priced as its current snapshot, so the shipped rows are the real check. const red = resolveMatchedPrice("openai-apikey", "daybreak-red-latest"); const blue = resolveMatchedPrice("openai-apikey", "daybreak-blue-latest"); + const gptBlueOpenAi = resolveMatchedPrice("openai", "gpt-daybreak-blue-latest"); expect(red?.cost4).toEqual({ input: 12.5, output: 75, cacheRead: 1.25, cacheWrite: 15.625 }); - expect(blue?.cost4).toEqual({ input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }); + expect(blue?.cost4).toEqual({ input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }); + expect(gptBlueOpenAi?.cost4).toEqual({ input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 }); // verified-derived, never verified: the pricing page has no daybreak-* rows, only the // snapshots'. This status is also what keeps `estimated` on downstream, and an alias is // more drift-prone than a normal row because OpenAI can repoint it. expect(red?.status).toBe("verified-derived"); expect(blue?.status).toBe("verified-derived"); + expect(gptBlueOpenAi?.status).toBe("verified-derived"); + expect(resolveMatchedPrice("openai-apikey", "gpt-daybreak-blue-latest")).toBeNull(); + expect(resolveMatchedPrice("openai", "daybreak-blue-latest")).toBeNull(); + expect(resolveMatchedPrice("openai", "daybreak-red-latest")).toBeNull(); - const alias = (model: string, usage: Record) => - estimateRequestCost({ provider: "openai-apikey", model, usageStatus: "reported", usage }); + const alias = (model: string, usage: Record, provider = "openai-apikey") => + estimateRequestCost({ provider, model, usageStatus: "reported", usage }); // Blue aliases gpt-5.6-sol, which publishes a long-context row: same exclusive boundary. expect(alias("daybreak-blue-latest", { inputTokens: 272_000, outputTokens: 10_000 })!.contextTier).toBeUndefined(); expect(alias("daybreak-blue-latest", { inputTokens: 272_001, outputTokens: 10_000 })!.contextTier).toBe("long"); + expect(alias("gpt-daybreak-blue-latest", { inputTokens: 272_000, outputTokens: 10_000 }, "openai")!.contextTier).toBeUndefined(); + expect(alias("gpt-daybreak-blue-latest", { inputTokens: 272_001, outputTokens: 10_000 }, "openai")!.contextTier).toBe("long"); // Red aliases gpt-5.6-cyber, whose four long-context cells are all "-" — no tier at all, // so a large prompt must stay on the standard rate rather than inheriting the family rule. const redOver = alias("daybreak-red-latest", { inputTokens: 272_001, outputTokens: 10_000 })!; expect(redOver.contextTier).toBeUndefined(); expect(redOver.cost.input / (272_001 / 1e6)).toBeCloseTo(12.5, 9); - // The Blue tier is scoped to openai-apikey: Daybreak is not routable on Codex login, so - // it must not drift back into the shared two-provider expansion. + // Each spelling stays in the provider namespace where that selector is routable. expect(CONTEXT_TIERS.filter(t => t.modelId === "daybreak-blue-latest").map(t => t.provider)).toEqual(["openai-apikey"]); + expect(CONTEXT_TIERS.filter(t => t.modelId === "gpt-daybreak-blue-latest").map(t => t.provider)).toEqual(["openai"]); expect(CONTEXT_TIERS.some(t => t.modelId === "daybreak-red-latest")).toBe(false); }); From e5566f30152f9c71fad362314d1a6f1d99e19bed Mon Sep 17 00:00:00 2001 From: luvs01 Date: Wed, 26 Aug 2026 04:24:15 +0900 Subject: [PATCH 039/336] fix(combos): bound preflight retained chunk count (#2595) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../responses/combo-stream-preflight.ts | 13 ++- structure/04_transports-and-sidecars.md | 4 +- tests/combo-stream-preflight.test.ts | 99 +++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 5f06bfeff5..04c4ef1baa 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -4,6 +4,12 @@ import { createSseInspector } from "../relay"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; const COMBO_STREAM_PREFLIGHT_MAX_BYTES = MAX_CLIENT_SSE_FRAME_BYTES; +// Keep retained object count proportional to the same byte budget used by the +// shared SSE framer. Tiny or empty upstream reads must not bypass the byte cap. +const COMBO_STREAM_PREFLIGHT_MAX_CHUNKS = Math.max( + 1, + Math.ceil(COMBO_STREAM_PREFLIGHT_MAX_BYTES / 1024), +); const PRE_OUTPUT_CONTROL_EVENTS = new Set([ "response.created", @@ -105,8 +111,8 @@ export type ComboStreamPreflightResult = /** * Buffer a combo child's downstream SSE only until the request becomes unsafe to * replay or reaches a terminal. This owns exactly one body reader. The aggregate - * buffer is capped; hitting the cap commits the current target instead of growing - * memory or guessing that replay is safe. + * buffer is capped by bytes and retained chunks; hitting either cap commits the + * current target instead of growing memory or guessing that replay is safe. */ export async function preflightComboStreamResponse( response: Response, @@ -161,7 +167,8 @@ export async function preflightComboStreamResponse( return { kind: "failed", response: failedTerminalResponse(response, failedPayload, logCtx) }; } if (next.done || terminalStatus !== undefined || outputCommitted - || bufferedBytes >= COMBO_STREAM_PREFLIGHT_MAX_BYTES) { + || bufferedBytes >= COMBO_STREAM_PREFLIGHT_MAX_BYTES + || buffered.length >= COMBO_STREAM_PREFLIGHT_MAX_CHUNKS) { return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) }; } } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 086abd68d5..e2060fa4e2 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1200,8 +1200,8 @@ reader and buffers only until one of these boundaries: target is committed and cross-target replay is forbidden; - a `response.failed` terminal arrives first, in which case the terminal is converted back through the ordinary bounded combo-failure classifier and may advance to the next declared target; -- a completed/incomplete terminal or the aggregate preflight byte cap is reached, in which case the - current target is committed conservatively. +- a completed/incomplete terminal or the aggregate preflight byte or retained-chunk cap is reached, + in which case the current target is committed conservatively. The buffered bytes are replayed unchanged before the reader continues. Native passthrough and eager relay identity markers are restored on the wrapped response so Windows/Bun stream paths and deferred diff --git a/tests/combo-stream-preflight.test.ts b/tests/combo-stream-preflight.test.ts index 7727597174..06871f9d58 100644 --- a/tests/combo-stream-preflight.test.ts +++ b/tests/combo-stream-preflight.test.ts @@ -11,6 +11,8 @@ const sse = (...payloads: unknown[]): Response => new Response( { headers: { "content-type": "text/event-stream" } }, ); +const preflightChunkLimit = Math.max(1, Math.ceil(MAX_CLIENT_SSE_FRAME_BYTES / 1024)); + describe("combo stream preflight", () => { test("keeps only lifecycle preamble replayable and treats unknown output conservatively", () => { expect(comboStreamPayloadCommitsOutput({ type: "response.created" })).toBe(false); @@ -90,4 +92,101 @@ describe("combo stream preflight", () => { expect(second.value).toBe(oversized); await reader.cancel(); }); + + test("commits at the retained-chunk boundary without reading one more chunk", async () => { + const prefix = Array.from( + { length: preflightChunkLimit }, + (_, index) => Uint8Array.of((index % 251) + 1), + ); + const tail = Uint8Array.of(252, 253); + let sourceIndex = 0; + let releaseTail: (() => void) | undefined; + let reportNextPull!: () => void; + const nextPull = new Promise(resolve => { reportNextPull = resolve; }); + const response = new Response(new ReadableStream({ + pull(controller) { + if (sourceIndex < prefix.length) { + controller.enqueue(prefix[sourceIndex++]!); + return; + } + reportNextPull(); + return new Promise(resolve => { + releaseTail = () => { + controller.enqueue(tail); + controller.close(); + resolve(); + }; + }); + }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } }); + + const preflight = preflightComboStreamResponse(response, { model: "m1", provider: "a" }); + const winner = await Promise.race([ + preflight.then(result => ({ kind: "preflight" as const, result })), + nextPull.then(() => ({ kind: "next-pull" as const })), + ]); + if (winner.kind === "next-pull") { + releaseTail!(); + const late = await preflight; + await late.response.body?.cancel(); + } + expect(winner.kind).toBe("preflight"); + if (winner.kind !== "preflight") return; + + expect(winner.result.kind).toBe("accepted"); + const reader = winner.result.response.body!.getReader(); + for (let index = 0; index < prefix.length; index += 1) { + const next = await reader.read(); + expect(next.done).toBe(false); + expect(next.value).not.toBe(prefix[index]); + expect(next.value).toEqual(prefix[index]); + } + + const tailRead = reader.read(); + await nextPull; + expect(releaseTail).toBeDefined(); + releaseTail!(); + const replayedTail = await tailRead; + expect(replayedTail.done).toBe(false); + expect(replayedTail.value).toBe(tail); + expect((await reader.read()).done).toBe(true); + }); + + test("keeps a failed terminal authoritative at the retained-chunk boundary", async () => { + const encoder = new TextEncoder(); + const comment = encoder.encode(":\n\n"); + const failed = encoder.encode(`data: ${JSON.stringify({ + type: "response.failed", + response: { + status: "failed", + error: { type: "server_error", code: "upstream_server_error", message: "busy" }, + usage: { input_tokens: 9, output_tokens: 0, total_tokens: 9 }, + provider_trace_id: "must-not-cross-the-combo-boundary", + }, + })}\n\n`); + let sourceIndex = 0; + const response = new Response(new ReadableStream({ + pull(controller) { + if (sourceIndex < preflightChunkLimit - 1) { + sourceIndex += 1; + controller.enqueue(comment); + return; + } + if (sourceIndex === preflightChunkLimit - 1) { + sourceIndex += 1; + controller.enqueue(failed); + } + }, + }, { highWaterMark: 0 }), { headers: { "content-type": "text/event-stream" } }); + + const result = await preflightComboStreamResponse(response, { model: "m1", provider: "a" }); + expect(result.kind).toBe("failed"); + expect(result.response.status).toBe(502); + const body = await result.response.json(); + expect(body).toMatchObject({ + error: { code: "upstream_server_error", message: "busy" }, + response: { usage: { input_tokens: 9, output_tokens: 0 } }, + }); + expect(JSON.stringify(body)).not.toContain("provider_trace_id"); + }); }); From e8c504ba0d0282a5ff519cc226746d480aef8fd3 Mon Sep 17 00:00:00 2001 From: Olddonkey Date: Tue, 25 Aug 2026 12:27:26 -0700 Subject: [PATCH 040/336] fix(test): pass --parallel so the full suite finishes instead of reading as hung (#2427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(test): pass --parallel so the full suite finishes instead of reading as hung `bun run test` spawned `bun test --isolate ./tests/`. With `--isolate` and no `--parallel`, Bun re-evaluates the module graph once per file on a single core. Past ~900 files that stops looking slow and starts looking hung. Measured on this tree (902 files): without --parallel 1 h 29 m, zero output, ~57 % CPU, 8.5 MB RSS, killed with --parallel ~110-190 s, 10x PARALLEL The failure mode is what makes this worth fixing rather than documenting: there is no progress output, one core is pinned, and RSS stays tiny, so it reads as a deadlock. A contributor's reasonable conclusion is that the suite is broken. The stale "normally runs in about 210s" warning is updated for the same reason — that number predates the file count that made the flag necessary. `resolveBunTestArgs` is exported and pinned by tests so the flag cannot be dropped again silently, including the two easy-to-regress cases: a caller supplying `--parallel=N` must not be overridden, and an option-only argv such as `--timeout=30000` must still count as a full-suite run and keep `./tests/`. Gate: 14436 pass / 2 fail; both also fail on untouched upstream/dev at this commit (baseline: 4 fail, a superset). Zero regressions. Note on scope: this is the smallest change that makes the suite runnable. Two adjacent changes are deliberately left out and will be proposed separately — narrowing the exclusive-run lock to full-suite runs (a behavior change that lets two focused runs share one sandboxed HOME), and a `test:changed` script with the contributing-guide updates that go with it. Separately and not addressed here: `tests/key-login-live-update.test.ts` fails standalone and serially on a clean tree, so every full run is red by at least one test regardless of this change. * fix(test): parse the -- delimiter and pin the flag end to end Two review findings. hasCliFlag/isFullSuiteRun read the whole argv, so `test -- --parallel=2` suppressed the default --parallel even though everything after -- is passed through, and a bare - was classified as an option so `test -` was treated as a full-suite run. The tests also asserted only resolveBunTestArgs output: reverting the spawn call to a hardcoded argv left every assertion green. A spawn test now runs the wrapper against a non-matching filter and asserts bun reports PARALLEL. * fix(test): only required-value options consume the next argument Two CodeRabbit findings, plus a regression the first attempt introduced. isFullSuiteRun read a space-separated option value as a file filter, so `bun run test --timeout 30000` silently stopped being a full-suite run and dropped ./tests/. Bun 1.4.0 accepts that form. The first fix over-corrected: treating every value-taking option as consuming the next argument swallowed the filter in ["--parallel", "tests/foo.test.ts"], so a focused run became a full-suite run — worse than the original bug, and silent. --parallel, --changed, --timings and --coverage take OPTIONAL values, which Bun expects attached with =. Now only required-value options consume the next argument, and all six boundary shapes are pinned as tests. The spawn test also asserted only that the output contained PARALLEL, so it could pass after a nonzero wrapper exit; it now asserts exitCode 0 first, against a real fixture file so a successful run is meaningful. * fix(test): parse separated timings paths * test: cover config values and fixture execution * fix(test): bound default suite parallelism * fix(test): isolate load-sensitive full-suite lanes --- bunfig.toml | 1 + scripts/test-run-lock.ts | 227 +++++++++++++++++++++++++ scripts/test.ts | 321 ++++++++++++++++++++++++++--------- tests/preload.ts | 23 +++ tests/release-helper.test.ts | 261 ++++++++++++++++------------ tests/server-auth.test.ts | 2 +- tests/test-runner.test.ts | 242 +++++++++++++++++++++++++- 7 files changed, 881 insertions(+), 196 deletions(-) create mode 100644 scripts/test-run-lock.ts diff --git a/bunfig.toml b/bunfig.toml index 00cbc1231e..318845b44a 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,6 +5,7 @@ # so a bare `bun test` — or `bun test tests/` (a substring filter that also matches # devlog/opencode-cursor/tests/) — drags them in and reports hundreds of spurious failures. # `root` pins discovery to ./tests so every invocation stays on the real suite. +# File-level `--parallel` has no bunfig key; `scripts/test.ts` passes it for `bun run test`. # The npm script already uses `bun test ./tests/`; this makes a bare `bun test` behave the same. [test] root = "tests" diff --git a/scripts/test-run-lock.ts b/scripts/test-run-lock.ts new file mode 100644 index 0000000000..d1c65487d5 --- /dev/null +++ b/scripts/test-run-lock.ts @@ -0,0 +1,227 @@ +import { randomUUID } from "node:crypto"; +import { + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export const TEST_RUN_ID_ENV = "OCX_TEST_RUN_ID"; +export const TEST_RUN_NO_QUEUE_ENV = "OCX_TEST_NO_QUEUE"; +const DEFAULT_LOCK_PATH = join(tmpdir(), "opencodex-bun-test.lock"); +const OWNER_FILE = "owner.json"; +const MEMBERS_DIR = "members"; +const INCOMPLETE_OWNER_GRACE_MS = 10_000; + +export interface TestRunLockOwner { + version: 1; + runId: string; + token: string; + pid: number; + acquiredAt: string; +} + +export interface TestRunLock { + acquired: boolean; + owner: TestRunLockOwner | null; + release(): void; +} + +export interface AcquireTestRunLockOptions { + runId: string; + ownerPid?: number; + lockPath?: string; + pollMs?: number; + maxWaitMs?: number; + env?: NodeJS.ProcessEnv; + onWait?: (owner: TestRunLockOwner | null) => void; + onAcquiredAfterWait?: (elapsedMs: number) => void; +} + +export interface BareTestRunIdentity { + ownerPid: number; + runId: string; +} + +/** + * Give one bare Bun invocation a stable identity without conflating sibling commands. + * + * Without `--parallel`, the preload runs in the test-runner process itself and its + * parent may be a long-lived shell or agent host shared by many unrelated commands. + * Bun parallel workers expose `BUN_TEST_WORKER_ID` and share one short-lived parent + * controller, so only that case may safely rendezvous on the parent PID. + */ +export function resolveBareTestRunIdentity(options: { + pid: number; + ppid: number; + workerId?: string; +}): BareTestRunIdentity { + const coordinatorPid = options.workerId ? options.ppid : options.pid; + return { ownerPid: options.pid, runId: `bare-${coordinatorPid}` }; +} + +function ownerPath(lockPath: string): string { + return join(lockPath, OWNER_FILE); +} + +function memberPath(lockPath: string, owner: TestRunLockOwner, pid: number): string { + return join(lockPath, MEMBERS_DIR, `${pid}-${owner.token}`); +} + +function readOwner(lockPath: string): TestRunLockOwner | null { + try { + const parsed = JSON.parse(readFileSync(ownerPath(lockPath), "utf8")) as Partial; + if (parsed.version !== 1 || typeof parsed.runId !== "string" || typeof parsed.token !== "string" + || !Number.isInteger(parsed.pid) || (parsed.pid ?? 0) <= 0 || typeof parsed.acquiredAt !== "string") { + return null; + } + return parsed as TestRunLockOwner; + } catch { + return null; + } +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function liveMemberExists(lockPath: string, owner: TestRunLockOwner): boolean { + try { + return readdirSync(join(lockPath, MEMBERS_DIR)).some(file => { + const suffix = `-${owner.token}`; + if (!file.endsWith(suffix)) return false; + const pid = Number.parseInt(file.slice(0, -suffix.length), 10); + return Number.isInteger(pid) && pid > 0 && processIsAlive(pid); + }); + } catch { + return false; + } +} + +function lockIsLive(lockPath: string, owner: TestRunLockOwner): boolean { + return processIsAlive(owner.pid) || liveMemberExists(lockPath, owner); +} + +function registerMember(lockPath: string, owner: TestRunLockOwner, pid: number): boolean { + const membersPath = join(lockPath, MEMBERS_DIR); + try { + mkdirSync(membersPath, { recursive: true, mode: 0o700 }); + writeFileSync(memberPath(lockPath, owner, pid), "", { flag: "a", mode: 0o600 }); + } catch { + return false; + } + if (ownsLock(lockPath, owner)) return true; + rmSync(memberPath(lockPath, owner, pid), { force: true }); + return false; +} + +function incompleteOwnerIsRecent(lockPath: string): boolean { + try { + return Date.now() - statSync(lockPath).mtimeMs < INCOMPLETE_OWNER_GRACE_MS; + } catch { + return false; + } +} + +function reclaimStaleLock(lockPath: string): boolean { + const stalePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`; + try { + renameSync(lockPath, stalePath); + } catch (error) { + if (["ENOENT", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; + throw error; + } + rmSync(stalePath, { recursive: true, force: true }); + return true; +} + +function ownsLock(lockPath: string, owner: TestRunLockOwner): boolean { + const current = readOwner(lockPath); + return current?.runId === owner.runId && current.token === owner.token && current.pid === owner.pid; +} + +/** + * Acquire the machine-wide OpenCodex Bun-test lock. + * + * `mkdir` is the cross-platform atomic primitive. The owner PID makes a lock left by + * SIGKILL recoverable, while the run ID lets every worker belonging to one bare + * `bun test --parallel` invocation join the same lock without blocking its siblings. + * Joiners register their own PIDs so a worker-owned lock remains live if its first + * worker exits before the rest of the pool. + */ +export async function acquireTestRunLock(options: AcquireTestRunLockOptions): Promise { + const env = options.env ?? process.env; + if (env[TEST_RUN_NO_QUEUE_ENV] === "1") { + return { acquired: false, owner: null, release() {} }; + } + + const lockPath = options.lockPath ?? DEFAULT_LOCK_PATH; + const ownerPid = options.ownerPid ?? process.pid; + const pollMs = Math.max(1, options.pollMs ?? 5_000); + const maxWaitMs = Math.max(pollMs, options.maxWaitMs ?? 45 * 60 * 1000); + const startedAt = Date.now(); + let announced = false; + + for (;;) { + const owner: TestRunLockOwner = { + version: 1, + runId: options.runId, + token: randomUUID(), + pid: ownerPid, + acquiredAt: new Date().toISOString(), + }; + try { + mkdirSync(lockPath, { mode: 0o700 }); + try { + writeFileSync(ownerPath(lockPath), `${JSON.stringify(owner)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch (error) { + rmSync(lockPath, { recursive: true, force: true }); + throw error; + } + if (announced) options.onAcquiredAfterWait?.(Date.now() - startedAt); + return { + acquired: true, + owner, + release() { + if (!ownsLock(lockPath, owner)) return; + reclaimStaleLock(lockPath); + }, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + + const current = readOwner(lockPath); + if (current?.runId === options.runId && lockIsLive(lockPath, current)) { + if (registerMember(lockPath, current, process.pid)) { + return { acquired: false, owner: current, release() {} }; + } + continue; + } + const ownerIsLive = current ? lockIsLive(lockPath, current) : incompleteOwnerIsRecent(lockPath); + if (!ownerIsLive && reclaimStaleLock(lockPath)) continue; + + if (!announced) { + announced = true; + options.onWait?.(current); + } + if (Date.now() - startedAt >= maxWaitMs) { + const holder = current ? `pid ${current.pid} (run ${current.runId})` : "an initializing runner"; + throw new Error( + `timed out after ${Math.round(maxWaitMs / 1000)}s waiting for ${holder} to release ${lockPath}; ` + + `set ${TEST_RUN_NO_QUEUE_ENV}=1 only when overlapping test runners are intentional`, + ); + } + await Bun.sleep(pollMs); + } +} diff --git a/scripts/test.ts b/scripts/test.ts index 5297a17722..29357a681a 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -1,6 +1,8 @@ +import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; +import { acquireTestRunLock, TEST_RUN_ID_ENV } from "./test-run-lock"; export interface IsolatedTestEnvironment { root: string; @@ -59,105 +61,264 @@ export function createIsolatedTestEnvironment( }; } -/** - * Other `bun test` runners already on this machine. - * - * Two full suites sharing one CPU do not fail — they crawl. A run that normally - * finishes in about 210s took 26 minutes against a runner an earlier session had - * left behind, and neither process said anything, so the slowdown read as a hang - * in this suite. Bun's own timeouts cannot see the contention, so name it here. - * - * `pgrep` is absent on Windows and may exit non-zero for "no matches"; both cases - * mean "nothing to warn about" rather than an error worth failing a test run over. - */ -function findCompetingTestRunners(selfPid: number): number[] { - try { - const found = Bun.spawnSync(["pgrep", "-f", "bun.*test --isolate"], { - stdout: "pipe", - stderr: "ignore", - }); - if (!found.success) return []; - return new TextDecoder().decode(found.stdout) - .split("\n") - .map(line => Number.parseInt(line.trim(), 10)) - .filter(pid => Number.isInteger(pid) && pid > 0 && pid !== selfPid); - } catch { - return []; +function hasCliFlag(requested: string[], name: string): boolean { + const delimiterIndex = requested.indexOf("--"); + const wrapperArgs = delimiterIndex === -1 ? requested : requested.slice(0, delimiterIndex); + return wrapperArgs.some(arg => arg === name || arg.startsWith(`${name}=`)); +} + +const DEFAULT_TEST_PARALLELISM = 4; + +// Bun 1.4.0 builds `bun test` options from its test, runtime, transpiler, and base tables. +// Only required values consume the next argument. Optional values such as `--parallel=2` +// must stay attached so a bare option cannot hide the positional filter that follows it. +const BUN_TEST_OPTIONS_REQUIRING_VALUES = new Set([ + // Test options. + "--timeout", + "--rerun-each", + "--retry", + "--seed", + "--coverage-reporter", + "--coverage-dir", + "-t", + "--test-name-pattern", + "--grep", + "--reporter", + "--reporter-outfile", + "--max-concurrency", + "--path-ignore-patterns", + "--parallel-delay", + "--shard", + "--timings", + // Runtime options accepted by `bun test`. + "--watch-kill-signal", + "-r", + "--preload", + "--require", + "--import", + "--cpu-prof-name", + "--cpu-prof-dir", + "--cpu-prof-interval", + "--heap-prof-name", + "--heap-prof-dir", + "--heap-prof-interval", + "--install", + "-e", + "--eval", + "-p", + "--print", + "--port", + "--origin", + "--conditions", + "--fetch-preconnect", + "--max-http-header-size", + "--dns-result-order", + "--redirect-warnings", + "--disable-warning", + "--title", + "--unhandled-rejections", + "--console-depth", + "--user-agent", + "--cron-title", + "--cron-period", + "--trace-event-categories", + "--trace-event-file-pattern", + "--stack-trace-limit", + // Transpiler and base options accepted by `bun test`. + "--main-fields", + "--extension-order", + "--tsconfig-override", + "-d", + "--define", + "--drop", + "--feature", + "-l", + "--loader", + "--jsx-factory", + "--jsx-fragment", + "--jsx-import-source", + "--jsx-runtime", + "--env-file", + "--cwd", + "-c", + "--config", +]); + +/** True for a filter-less `bun run test`. `--timeout` / `--dots` / `--parallel=N` still count. */ +function isFullSuiteRun(requested: string[]): boolean { + const delimiterIndex = requested.indexOf("--"); + const wrapperArgs = delimiterIndex === -1 ? requested : requested.slice(0, delimiterIndex); + const passedThrough = delimiterIndex === -1 ? [] : requested.slice(delimiterIndex + 1); + if (passedThrough.length > 0) return false; + + for (let index = 0; index < wrapperArgs.length; index++) { + const arg = wrapperArgs[index]; + if (arg === "-" || !arg.startsWith("-")) return false; + if (!arg.includes("=") && BUN_TEST_OPTIONS_REQUIRING_VALUES.has(arg)) index++; } + return true; } /** - * Wait until this machine has no other full-suite runner, then proceed. - * - * Warning about contention was not enough: the warning scrolls past, the run still - * starts, and four concurrent suites drove load average to 10 and turned a ~210s - * suite into a 13-minute one that read as a hang. Agents in parallel worktrees each - * think they are the only runner, so the serialization has to live here rather than - * in anyone's discipline. + * Default `bun test` argv for this repo. * - * Queue rather than refuse: a failed `bun run test` invites `bun test` directly, - * which bypasses this file entirely. Waiting is the behavior that survives being - * worked around. `OCX_TEST_NO_QUEUE=1` opts out for anyone who really wants overlap. + * `--isolate` keeps a fresh global per file. Bounded parallelism is what makes the suite + * finishable: with isolate alone Bun re-evaluates + * the module graph once per file on a single core, so past ~900 files the run stops looking slow + * and starts looking hung — measured here at 1 h 29 m with zero output, ~57 % CPU and 8.5 MB RSS, + * against a few minutes for the identical suite with four workers. Leaving Bun to select all ten + * workers made deadline-sensitive tests fail under load, so the repository default is deterministic. + * A caller-supplied `--parallel` or `--parallel=N` is left alone. */ -async function waitForExclusiveRun(selfPid: number): Promise { - if (process.env.OCX_TEST_NO_QUEUE === "1") return; - const pollMs = 5_000; - // Long enough for a full suite plus slack; past this, assume the holder is wedged - // rather than working and let this run start anyway. - const maxWaitMs = 45 * 60 * 1000; +export function resolveBunTestArgs(requested: string[]): string[] { + const args = ["--isolate"]; + if (!hasCliFlag(requested, "--parallel")) { + args.push(`--parallel=${DEFAULT_TEST_PARALLELISM}`); + } + args.push(...requested); + if (isFullSuiteRun(requested)) args.push("./tests/"); + return args; +} + +export const SERIAL_FULL_SUITE_FILES = [ + "codex-shim.test.ts", + "cursor-native-exec-shell.test.ts", + "issue-452-empty-503.test.ts", + "openai-provider-option-e2e.test.ts", + "release-helper.test.ts", + "update-stop-first.test.ts", +] as const; + +const SERIAL_LANE_TIMEOUT_MS: Partial> = { + // This file intentionally exercises 33 complete release-script subprocess trees. + // It is ~90s on an idle machine and measured at ~170s under unrelated host load. + "release-helper.test.ts": 5 * 60 * 1000, +}; + +export interface BunTestLane { + label: string; + args: string[]; + timeoutMs: number; +} + +function withoutParallelOverride(requested: string[]): string[] { + return requested.filter(arg => arg !== "--parallel" && !arg.startsWith("--parallel=")); +} + +function canUseSerialLanes(requested: string[]): boolean { + if (!isFullSuiteRun(requested)) return false; + return !["--changed", "--shard", "--reporter-outfile", "--update-timings"].some(flag => hasCliFlag(requested, flag)); +} + +/** Build the default full-suite plan: one bounded main lane plus isolated risky files. */ +export function resolveBunTestPlan(requested: string[]): BunTestLane[] { + if (!canUseSerialLanes(requested)) { + return [{ label: "suite", args: resolveBunTestArgs(requested), timeoutMs: 15 * 60 * 1000 }]; + } + + const mainArgs = resolveBunTestArgs(requested); + const rootIndex = mainArgs.lastIndexOf("./tests/"); + const ignores = SERIAL_FULL_SUITE_FILES.flatMap(file => ["--path-ignore-patterns", `**/${file}`]); + mainArgs.splice(rootIndex === -1 ? mainArgs.length : rootIndex, 0, ...ignores); + const serialRequested = withoutParallelOverride(requested); + return [ + { label: "parallel suite", args: mainArgs, timeoutMs: 15 * 60 * 1000 }, + ...SERIAL_FULL_SUITE_FILES.map(file => ({ + label: file, + args: resolveBunTestArgs(["--parallel=1", ...serialRequested, `./tests/${file}`]), + timeoutMs: SERIAL_LANE_TIMEOUT_MS[file] ?? 3 * 60 * 1000, + })), + ]; +} + +function waitWithTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve(null), timeoutMs); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +async function runTestLane(lane: BunTestLane, runId: string): Promise { + const isolated = createIsolatedTestEnvironment({ ...process.env, [TEST_RUN_ID_ENV]: runId }); const startedAt = Date.now(); - let announced = false; - for (;;) { - const competing = findCompetingTestRunners(selfPid); - if (competing.length === 0) { - if (announced) { - console.warn(`[test] the other runner(s) finished after ${Math.round((Date.now() - startedAt) / 1000)}s; starting.`); + let interrupted: NodeJS.Signals | null = null; + const child = Bun.spawn([process.execPath, "test", ...lane.args], { + env: isolated.env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const forward = (signal: NodeJS.Signals) => { + interrupted = signal; + try { child.kill(signal); } catch { /* child already exited */ } + }; + const onInterrupt = () => forward("SIGINT"); + const onTerminate = () => forward("SIGTERM"); + process.once("SIGINT", onInterrupt); + process.once("SIGTERM", onTerminate); + + const exited = child.exited; + try { + const exitCode = await waitWithTimeout(exited, lane.timeoutMs); + if (exitCode === null) { + console.error(`[test] ${lane.label} exceeded ${Math.round(lane.timeoutMs / 1000)}s; terminating pid ${child.pid}.`); + try { child.kill("SIGTERM"); } catch { /* child already exited */ } + const graceful = await waitWithTimeout(exited, 5_000); + if (graceful === null) { + try { child.kill("SIGKILL"); } catch { /* child already exited */ } + await waitWithTimeout(exited, 2_000); } - return; + return 124; } - if (Date.now() - startedAt > maxWaitMs) { - console.warn( - `[test] still waiting on pid ${competing.join(", ")} after ${Math.round(maxWaitMs / 60000)} minutes. ` - + "Assuming they are stuck and starting anyway; expect a slow run.", - ); - return; - } - if (!announced) { - announced = true; - console.warn( - `[test] ${competing.length} other bun test runner(s) already running (pid ${competing.join(", ")}). ` - + "Waiting for them to finish so the suites do not fight over the CPU. " - + "Set OCX_TEST_NO_QUEUE=1 to run concurrently anyway.", - ); - } - await Bun.sleep(pollMs); + if (interrupted === "SIGINT") return 130; + if (interrupted === "SIGTERM") return 143; + const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); + console.warn(`[test] ${lane.label} finished in ${seconds}s (exit ${exitCode}).`); + return exitCode; + } finally { + process.off("SIGINT", onInterrupt); + process.off("SIGTERM", onTerminate); + isolated.cleanup(); } } if (import.meta.main) { - const isolated = createIsolatedTestEnvironment(); + const requestedTests = process.argv.slice(2); + const runId = randomUUID(); + const lock = await acquireTestRunLock({ + runId, + onWait: owner => console.warn( + `[test] another Bun test run${owner ? ` (pid ${owner.pid})` : ""} holds the machine lock; waiting. ` + + "Set OCX_TEST_NO_QUEUE=1 only for intentional overlap.", + ), + onAcquiredAfterWait: elapsedMs => console.warn(`[test] acquired the machine lock after ${Math.round(elapsedMs / 1000)}s.`), + }); + const startedAt = Date.now(); try { - const requestedTests = process.argv.slice(2); - await waitForExclusiveRun(process.pid); - const startedAt = Date.now(); - const child = Bun.spawnSync( - [process.execPath, "test", "--isolate", ...(requestedTests.length > 0 ? requestedTests : ["./tests/"])], - { - env: isolated.env, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }, - ); + let exitCode = 0; + for (const lane of resolveBunTestPlan(requestedTests)) { + const laneExitCode = await runTestLane(lane, runId); + if (laneExitCode !== 0 && exitCode === 0) exitCode = laneExitCode; + if ([124, 130, 143].includes(laneExitCode)) break; + } const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); - if (requestedTests.length === 0 && elapsedSeconds > 600) { + if (isFullSuiteRun(requestedTests) && elapsedSeconds > 600) { console.warn( - `[test] the suite took ${elapsedSeconds}s; it normally runs in about 210s on an idle machine. ` + `[test] the suite took ${elapsedSeconds}s; with --parallel=${DEFAULT_TEST_PARALLELISM} it should finish in a few minutes on an idle machine. ` + "Check for another test runner, a busy CPU, or a test that started polling something real.", ); } - process.exitCode = child.exitCode ?? 1; + process.exitCode = exitCode; } finally { - isolated.cleanup(); + lock.release(); } } diff --git a/tests/preload.ts b/tests/preload.ts index b728565f42..37b2233df0 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -13,8 +13,31 @@ */ import { isTestHomeGuardArmed, protectedHomeForTests } from "../src/lib/test-home-guard"; import { createIsolatedTestEnvironment } from "../scripts/test"; +import { acquireTestRunLock, resolveBareTestRunIdentity, TEST_RUN_ID_ENV } from "../scripts/test-run-lock"; import { rmSync } from "node:fs"; +// `scripts/test.ts` owns the lock for wrapped runs. A bare `bun test` has no wrapper, +// so a single-process runner uses its own PID while true parallel workers rendezvous +// on their short-lived controller PID. The first worker acquires the lock and siblings +// join it. The bare-run lock is deliberately left for the next invocation to reclaim +// after every registered worker exits — releasing it from an early-finishing worker +// would let another suite overlap the remaining workers. +const wrappedRunId = process.env[TEST_RUN_ID_ENV]?.trim(); +const bareIdentity = resolveBareTestRunIdentity({ + pid: process.pid, + ppid: process.ppid, + workerId: process.env.BUN_TEST_WORKER_ID, +}); +const runId = wrappedRunId || bareIdentity.runId; +process.env[TEST_RUN_ID_ENV] = runId; +await acquireTestRunLock({ + runId, + ownerPid: bareIdentity.ownerPid, + onWait: owner => console.warn( + `[test] bare Bun worker ${process.pid} is waiting for test run${owner ? ` pid ${owner.pid}` : ""} to release the machine lock.`, + ), +}); + // Under `bun run test` the wrapper already handed us a sandbox (and OCX_REAL_HOME so the // guard could still see the true home). Isolating again is harmless and deliberate: the // alternative — inferring "already isolated" from path shapes — would trust exactly the diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index b9d5cfea9c..0f0ef65a78 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -1,6 +1,5 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -41,6 +40,39 @@ interface SshInvocation { args: string[]; } +interface CapturedProcessResult { + status: number | null; + stderr: string; + stdout: string; +} + +async function runCaptured( + command: string, + args: string[], + options: { cwd: string; env: Record; timeoutMs?: number }, +): Promise { + const child = Bun.spawn([command, ...args], { + cwd: options.cwd, + env: options.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).text(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { child.kill("SIGKILL"); } catch { /* child already exited */ } + }, options.timeoutMs ?? 20_000); + try { + const [status, capturedStdout, capturedStderr] = await Promise.all([child.exited, stdout, stderr]); + return { status: timedOut ? null : status, stdout: capturedStdout, stderr: capturedStderr }; + } finally { + clearTimeout(timer); + } +} + function writeExecutable(path: string, contents: string): void { writeFileSync(path, contents, "utf8"); chmodSync(path, 0o755); @@ -215,7 +247,7 @@ function findCallIndex(calls: LoggedCall[], name: string, matcher: (call: Logged return calls.findIndex(call => call.name === name && matcher(call)); } -function runRelease(version: string, scenario: ReleaseScenario = {}) { +async function runRelease(version: string, scenario: ReleaseScenario = {}) { const shimDir = mkdtempSync(join(tmpdir(), "ocx-release-helper-")); const logPath = join(shimDir, "release-log.jsonl"); writeFileSync(logPath, "", "utf8"); @@ -241,31 +273,32 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { const pathKey = process.platform === "win32" ? "Path" : "PATH"; const pathValue = `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? process.env.Path ?? ""}`; - const result = spawnSync(process.execPath, [releaseScriptPath, version], { - cwd: repoRoot, - env: { - ...inheritedEnv, - [pathKey]: pathValue, - FAKE_RELEASE_LOG: logPath, - FAKE_GIT_BRANCH: scenario.branch ?? "main", - FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456", - ...(scenario.remoteHeadSha ? { FAKE_GIT_REMOTE_HEAD_SHA: scenario.remoteHeadSha } : {}), - FAKE_BUN_TSC_EXIT_CODE: String(scenario.typecheckExitCode ?? 0), - FAKE_BUN_TEST_EXIT_CODE: String(scenario.testExitCode ?? 0), - FAKE_BUN_PRIVACY_EXIT_CODE: String(scenario.privacyExitCode ?? 0), - ...(scenario.npmLatest ? { FAKE_NPM_LATEST: scenario.npmLatest } : {}), - ...(scenario.npmPreview ? { FAKE_NPM_PREVIEW: scenario.npmPreview } : {}), - ...(scenario.releaseSshKey ? { OCX_RELEASE_SSH_KEY: scenario.releaseSshKey } : {}), - ...(scenario.releaseSshRepo ? { OCX_RELEASE_SSH_REPO: scenario.releaseSshRepo } : {}), - ...(scenario.pendingBump ? { FAKE_GIT_PENDING_BUMP: " M package.json" } : {}), - ...(scenario.originUrl ? { FAKE_GIT_ORIGIN_URL: scenario.originUrl } : {}), - }, - encoding: "utf8", - }); - - const calls = readLoggedCalls(logPath); - rmSync(shimDir, { recursive: true, force: true }); - return { calls, result }; + const env = { + ...inheritedEnv, + [pathKey]: pathValue, + FAKE_RELEASE_LOG: logPath, + FAKE_GIT_BRANCH: scenario.branch ?? "main", + FAKE_GIT_HEAD_SHA: scenario.headSha ?? "abc123def456", + ...(scenario.remoteHeadSha ? { FAKE_GIT_REMOTE_HEAD_SHA: scenario.remoteHeadSha } : {}), + FAKE_BUN_TSC_EXIT_CODE: String(scenario.typecheckExitCode ?? 0), + FAKE_BUN_TEST_EXIT_CODE: String(scenario.testExitCode ?? 0), + FAKE_BUN_PRIVACY_EXIT_CODE: String(scenario.privacyExitCode ?? 0), + ...(scenario.npmLatest ? { FAKE_NPM_LATEST: scenario.npmLatest } : {}), + ...(scenario.npmPreview ? { FAKE_NPM_PREVIEW: scenario.npmPreview } : {}), + ...(scenario.releaseSshKey ? { OCX_RELEASE_SSH_KEY: scenario.releaseSshKey } : {}), + ...(scenario.releaseSshRepo ? { OCX_RELEASE_SSH_REPO: scenario.releaseSshRepo } : {}), + ...(scenario.pendingBump ? { FAKE_GIT_PENDING_BUMP: " M package.json" } : {}), + ...(scenario.originUrl ? { FAKE_GIT_ORIGIN_URL: scenario.originUrl } : {}), + }; + try { + const result = await runCaptured(process.execPath, [releaseScriptPath, version], { + cwd: repoRoot, + env, + }); + return { calls: readLoggedCalls(logPath), result }; + } finally { + rmSync(shimDir, { recursive: true, force: true }); + } } /** @@ -275,7 +308,7 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { * contract for `GIT_SSH_COMMAND`. Exercising a real Git process here catches quoting that looks * correct in text yet splits, substitutes, or reinterprets the private-key path before SSH sees it. */ -function executeGitSshCommand(gitSshCommand: string): { calls: SshInvocation[]; result: ReturnType } { +async function executeGitSshCommand(gitSshCommand: string): Promise<{ calls: SshInvocation[]; result: CapturedProcessResult }> { const shimDir = mkdtempSync(join(tmpdir(), "ocx-release-ssh-")); const logPath = join(shimDir, "ssh-log.jsonl"); const jsPath = join(shimDir, "ssh.js"); @@ -296,26 +329,29 @@ process.exit(0); const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter(([key]) => key !== "GIT_SSH" && key !== "GIT_SSH_COMMAND"), ); - const result = spawnSync("git", ["ls-remote", "ssh://example.invalid/owner/repository.git"], { - cwd: repoRoot, - env: { - ...inheritedEnv, - FAKE_SSH_LOG: logPath, - GIT_SSH_COMMAND: nativeFakeCommand, - }, - encoding: "utf8", - }); - const raw = readFileSync(logPath, "utf8").trim(); - const calls = raw - ? raw.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line) as SshInvocation) - : []; - rmSync(shimDir, { recursive: true, force: true }); - return { calls, result }; + const env = { + ...inheritedEnv, + FAKE_SSH_LOG: logPath, + GIT_SSH_COMMAND: nativeFakeCommand, + }; + try { + const result = await runCaptured("git", ["ls-remote", "ssh://example.invalid/owner/repository.git"], { + cwd: repoRoot, + env, + }); + const raw = readFileSync(logPath, "utf8").trim(); + const calls = raw + ? raw.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line) as SshInvocation) + : []; + return { calls, result }; + } finally { + rmSync(shimDir, { recursive: true, force: true }); + } } describe("release helper", () => { - test("preflight runs the shared audit, typecheck, test suite, and privacy scan before version bump", () => { - const { calls, result } = runRelease("9.9.9"); + test("preflight runs the shared audit, typecheck, test suite, and privacy scan before version bump", async () => { + const { calls, result } = await runRelease("9.9.9"); // Report what the script actually said. A bare status assertion turned a // Windows-only spawn failure into "Expected: 0 Received: 1" with no cause, @@ -360,8 +396,8 @@ describe("release helper", () => { expect(dispatchIndex).toBeGreaterThan(versionIndex); }); - test("an obsolete version that would move latest backwards aborts before the bump", () => { - const { calls, result } = runRelease("9.9.8", { npmLatest: "9.9.9" }); + test("an obsolete version that would move latest backwards aborts before the bump", async () => { + const { calls, result } = await runRelease("9.9.8", { npmLatest: "9.9.9" }); expect(result.status).not.toBe(0); expect(result.stderr ?? "").toContain("does not move the 'latest' channel forward"); @@ -369,21 +405,21 @@ describe("release helper", () => { expect(findCallIndex(calls, "git", call => call.args[0] === "commit")).toBe(-1); }); - test("a version newer than the channel tip passes the forward guard", () => { - const { calls, result } = runRelease("9.9.10", { npmLatest: "9.9.9" }); + test("a version newer than the channel tip passes the forward guard", async () => { + const { calls, result } = await runRelease("9.9.10", { npmLatest: "9.9.9" }); expect(`${result.status}\n${result.stderr ?? ""}`.trim()).toBe("0"); expect(findCallIndex(calls, "npm", call => call.args.join(" ") === "version 9.9.10 --no-git-tag-version")).toBeGreaterThanOrEqual(0); }); - test("preview releases compare against the preview channel, not latest", () => { - const { result } = runRelease("9.9.9-preview.2", { branch: "preview", npmLatest: "10.0.0", npmPreview: "9.9.9-preview.1" }); + test("preview releases compare against the preview channel, not latest", async () => { + const { result } = await runRelease("9.9.9-preview.2", { branch: "preview", npmLatest: "10.0.0", npmPreview: "9.9.9-preview.1" }); expect(`${result.status}\n${result.stderr ?? ""}`.trim()).toBe("0"); }); - test("failed privacy scan aborts before version bump, commit, and push", () => { - const { calls, result } = runRelease("9.9.9", { privacyExitCode: 1 }); + test("failed privacy scan aborts before version bump, commit, and push", async () => { + const { calls, result } = await runRelease("9.9.9", { privacyExitCode: 1 }); expect(result.status).not.toBe(0); expect(findCallIndex(calls, "bun", call => call.args.join(" ") === "run privacy:scan")).toBeGreaterThanOrEqual(0); @@ -392,8 +428,8 @@ describe("release helper", () => { expect(findCallIndex(calls, "git", call => call.args[0] === "push")).toBe(-1); }); - test("preview branch still defaults to preview tag and dry-run dispatch", () => { - const { calls, result } = runRelease("9.9.9-preview.1", { branch: "preview" }); + test("preview branch still defaults to preview tag and dry-run dispatch", async () => { + const { calls, result } = await runRelease("9.9.9-preview.1", { branch: "preview" }); expect(result.status).toBe(0); expect(findCallIndex(calls, "gh", call => @@ -405,8 +441,8 @@ describe("release helper", () => { )).toBeGreaterThanOrEqual(0); }); - test("dispatch pins the audited release SHA via expected-sha", () => { - const { calls, result } = runRelease("9.9.9", { headSha: "deadbeefcafe1234" }); + test("dispatch pins the audited release SHA via expected-sha", async () => { + const { calls, result } = await runRelease("9.9.9", { headSha: "deadbeefcafe1234" }); expect(result.status).toBe(0); expect(findCallIndex(calls, "gh", call => @@ -427,8 +463,8 @@ describe("release helper", () => { * rejected by the ruleset again), and the default path must stay byte-identical so a contributor * or CI clone without the variable is unaffected. */ - test("the protected push uses the release deploy key only when one is configured", () => { - const { calls, result } = runRelease("9.9.9", { + test("the protected push uses the release deploy key only when one is configured", async () => { + const { calls, result } = await runRelease("9.9.9", { releaseSshKey: "/tmp/ocx-release-key", releaseSshRepo: sshTarget, pendingBump: true, @@ -447,8 +483,8 @@ describe("release helper", () => { * (`C:\Users\Jun Kim\.ssh\...`) is exactly that shape, and ssh would read the tail as its next * flag. Assert the whole command string, not a substring: `toContain` passes on the broken form. */ - test("a key path with spaces and backslashes stays a single ssh argument", () => { - const { calls } = runRelease("9.9.9", { + test("a key path with spaces and backslashes stays a single ssh argument", async () => { + const { calls } = await runRelease("9.9.9", { releaseSshKey: "C:\\Users\\Jun Kim\\.ssh\\ocx release key", pendingBump: true, }); @@ -457,9 +493,9 @@ describe("release helper", () => { expect(push?.gitSshCommand).toBe('ssh -i "C:\\\\Users\\\\Jun Kim\\\\.ssh\\\\ocx release key" -o IdentitiesOnly=yes'); }); - test("Git passes the emitted deploy-key path to SSH as one literal argument", () => { + test("Git passes the emitted deploy-key path to SSH as one literal argument", async () => { const keyPath = 'C:\\Users\\Jun Kim\\.ssh\\ocx "quoted" $HOME $(not-run) `not-run`; key'; - const { calls: releaseCalls } = runRelease("9.9.9", { + const { calls: releaseCalls } = await runRelease("9.9.9", { releaseSshKey: keyPath, releaseSshRepo: sshTarget, pendingBump: true, @@ -467,7 +503,7 @@ describe("release helper", () => { const push = releaseCalls.find(call => call.name === "git" && call.args[0] === "push"); expect(push?.gitSshCommand).toBeDefined(); - const { calls } = executeGitSshCommand(push?.gitSshCommand ?? ""); + const { calls } = await executeGitSshCommand(push?.gitSshCommand ?? ""); expect(calls.length).toBeGreaterThan(0); for (const call of calls) { const identityIndex = call.args.indexOf("-i"); @@ -480,8 +516,8 @@ describe("release helper", () => { * The SSH target is derived from `origin` rather than hardcoded, so a fork's release pushes to * the fork instead of silently targeting upstream. */ - test("the ssh push target follows the configured origin remote", () => { - const { calls } = runRelease("9.9.9", { + test("the ssh push target follows the configured origin remote", async () => { + const { calls } = await runRelease("9.9.9", { releaseSshKey: "/tmp/k", originUrl: "https://github.com/someone-else/opencodex.git", pendingBump: true, @@ -496,8 +532,8 @@ describe("release helper", () => { * failing command, so a folded `user:token@` would put the token on the terminal and in the * release log. Refuse instead of building a target. */ - test("an origin carrying credentials is refused rather than transplanted", () => { - const { calls, result } = runRelease("9.9.9", { + test("an origin carrying credentials is refused rather than transplanted", async () => { + const { calls, result } = await runRelease("9.9.9", { releaseSshKey: "/tmp/k", originUrl: `https://x-access-token:SECRET@${"github.com"}/lidge-jun/opencodex.git`, pendingBump: true, @@ -509,8 +545,8 @@ describe("release helper", () => { expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); }); - test("a malformed OCX_RELEASE_SSH_REPO override is refused instead of pushed to", () => { - const { calls, result } = runRelease("9.9.9", { + test("a malformed OCX_RELEASE_SSH_REPO override is refused instead of pushed to", async () => { + const { calls, result } = await runRelease("9.9.9", { releaseSshKey: "/tmp/k", releaseSshRepo: "not-a-remote", pendingBump: true, @@ -521,18 +557,19 @@ describe("release helper", () => { expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); }); - test("credential-bearing SSH targets are rejected without logging the credential", () => { - for (const scenario of [ - { releaseSshRepo: "ssh://git:SECRET@example.test/owner/repository.git" }, - { releaseSshRepo: "ssh://SECRET@example.test/owner/repository.git" }, - { releaseSshRepo: "ssh://git%3ASECRET@example.test/owner/repository.git" }, - { releaseSshRepo: "git@SECRET@example.test:owner/repository.git" }, - { releaseSshRepo: "ssh://git:@example.test/owner/repository.git" }, - { releaseSshRepo: "git@example.test:owner/repository.git?token=SECRET" }, - { originUrl: "ssh://git:SECRET@example.test/owner/repository.git" }, - { originUrl: "git:SECRET@example.test:owner/repository.git" }, - ]) { - const { calls, result } = runRelease("9.9.9", { + test.each([ + { releaseSshRepo: "ssh://git:SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://git%3ASECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "git@SECRET@example.test:owner/repository.git" }, + { releaseSshRepo: "ssh://git:@example.test/owner/repository.git" }, + { releaseSshRepo: "git@example.test:owner/repository.git?token=SECRET" }, + { originUrl: "ssh://git:SECRET@example.test/owner/repository.git" }, + { originUrl: "git:SECRET@example.test:owner/repository.git" }, + ] satisfies ReleaseScenario[])( + "credential-bearing SSH target is rejected without logging the credential", + async scenario => { + const { calls, result } = await runRelease("9.9.9", { releaseSshKey: "/tmp/k", pendingBump: true, ...scenario, @@ -541,28 +578,26 @@ describe("release helper", () => { expect(result.status).not.toBe(0); expect(output).not.toContain("SECRET"); expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); - } - }); + }, + ); - test("credential-free ssh URL and scp-like release targets remain accepted", () => { - for (const releaseSshRepo of [ - "ssh://git@example.test/owner/repository.git", - "ssh://example.test/owner/repository.git", - "git@example.test:owner/repository.git", - ]) { - const { calls, result } = runRelease("9.9.9", { - releaseSshKey: "/tmp/k", - releaseSshRepo, - pendingBump: true, - }); - expect(result.status).toBe(0); - expect(calls.find(call => call.name === "git" && call.args[0] === "push")?.args[1]) - .toBe(releaseSshRepo); - } + test.each([ + "ssh://git@example.test/owner/repository.git", + "ssh://example.test/owner/repository.git", + "git@example.test:owner/repository.git", + ])("credential-free ssh URL or scp-like release target remains accepted", async releaseSshRepo => { + const { calls, result } = await runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + releaseSshRepo, + pendingBump: true, + }); + expect(result.status).toBe(0); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")?.args[1]) + .toBe(releaseSshRepo); }); - test("an ssh origin is reused verbatim rather than rewritten", () => { - const { calls } = runRelease("9.9.9", { + test("an ssh origin is reused verbatim rather than rewritten", async () => { + const { calls } = await runRelease("9.9.9", { releaseSshKey: "/tmp/k", originUrl: `${"git"}@${"github.com"}:lidge-jun/opencodex.git`, pendingBump: true, @@ -572,8 +607,8 @@ describe("release helper", () => { expect(push?.args[1]).toBe(`${"git"}@${"github.com"}:lidge-jun/opencodex.git`); }); - test("an origin that yields no ssh target aborts instead of guessing one", () => { - const { calls, result } = runRelease("9.9.9", { + test("an origin that yields no ssh target aborts instead of guessing one", async () => { + const { calls, result } = await runRelease("9.9.9", { releaseSshKey: "/tmp/k", originUrl: "/srv/git/opencodex.git", pendingBump: true, @@ -584,8 +619,8 @@ describe("release helper", () => { expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); }); - test("without a configured key the push is unchanged and carries no ssh override", () => { - const { calls, result } = runRelease("9.9.9", { pendingBump: true }); + test("without a configured key the push is unchanged and carries no ssh override", async () => { + const { calls, result } = await runRelease("9.9.9", { pendingBump: true }); expect(result.status).toBe(0); const push = calls.find(call => call.name === "git" && call.args[0] === "push"); @@ -593,8 +628,8 @@ describe("release helper", () => { expect(push?.gitSshCommand).toBeUndefined(); }); - test("aborts before dispatch when the remote branch moved during the CI wait", () => { - const { calls, result } = runRelease("9.9.9", { + test("aborts before dispatch when the remote branch moved during the CI wait", async () => { + const { calls, result } = await runRelease("9.9.9", { headSha: "abc123def456", remoteHeadSha: "9999999999999999999999999999999999999999", }); @@ -669,19 +704,19 @@ describe("release helper", () => { // #1753 review follow-up: build metadata on the channel tip is valid semver // and compares by precedence only; an unparseable tip must fail CLOSED // (Number() on a garbage core used to yield NaN and pass any candidate). - test("channel tip with build metadata compares by precedence, not NaN", () => { - const { result } = runRelease("2.19.4", { npmLatest: "2.19.3+build.1" }); + test("channel tip with build metadata compares by precedence, not NaN", async () => { + const { result } = await runRelease("2.19.4", { npmLatest: "2.19.3+build.1" }); expect(`${result.status}\n${result.stderr ?? ""}`.trim()).toBe("0"); }); - test("channel tip equal after stripping build metadata does not move forward", () => { - const { result } = runRelease("2.19.3", { npmLatest: "2.19.3+build.1" }); + test("channel tip equal after stripping build metadata does not move forward", async () => { + const { result } = await runRelease("2.19.3", { npmLatest: "2.19.3+build.1" }); expect(result.status).toBe(1); expect(result.stderr ?? "").toContain("does not move"); }); - test("unparseable channel tip fails closed", () => { - const { result } = runRelease("2.19.4", { npmLatest: "not-a-version" }); + test("unparseable channel tip fails closed", async () => { + const { result } = await runRelease("2.19.4", { npmLatest: "not-a-version" }); expect(result.status).toBe(1); expect(result.stderr ?? "").toContain("cannot compare release versions"); }); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index accd66e90d..90b9ff32bb 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -3426,7 +3426,7 @@ describe("server local API auth", () => { await server.stop(true); await upstream.stop(true); } - }); + }, { timeout: SERVER_BUDGET_MS }); test("passthrough SSE cyber terminal is logged as 400 cyber_policy", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); diff --git a/tests/test-runner.test.ts b/tests/test-runner.test.ts index ef48654f15..9589bc59d8 100644 --- a/tests/test-runner.test.ts +++ b/tests/test-runner.test.ts @@ -1,7 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; -import { createIsolatedTestEnvironment } from "../scripts/test"; +import { + createIsolatedTestEnvironment, + resolveBunTestArgs, + resolveBunTestPlan, + SERIAL_FULL_SUITE_FILES, +} from "../scripts/test"; +import { + acquireTestRunLock, + resolveBareTestRunIdentity, + TEST_RUN_NO_QUEUE_ENV, +} from "../scripts/test-run-lock"; import { decodeWindowsIdentityPowerShellOutputForTests, windowsIdentityPowerShellCommandForTests, @@ -68,3 +79,230 @@ describe("test runner isolation", () => { }, ); }); + +/** + * Without `--parallel`, `--isolate` re-evaluates the module graph once per file on a single + * core. Past ~900 files that stops reading as slow and starts reading as hung: measured at + * 1 h 29 m with zero output, ~57 % CPU and 8.5 MB RSS. Four workers keep the suite inside a + * few minutes without the deadline-sensitive failures observed when Bun selected all ten cores. + * These pin the argv so the bound cannot be dropped again silently. + */ +describe("bun test argv", () => { + test("a filter-less run gets isolate, bounded parallelism and the suite path", () => { + expect(resolveBunTestArgs([])).toEqual(["--isolate", "--parallel=4", "./tests/"]); + }); + + test("the default full suite quarantines load-sensitive files into one-worker lanes", () => { + const plan = resolveBunTestPlan([]); + expect(plan).toHaveLength(SERIAL_FULL_SUITE_FILES.length + 1); + expect(plan[0]?.label).toBe("parallel suite"); + expect(plan[0]?.args).toContain("--parallel=4"); + expect(plan[0]?.args).toContain("./tests/"); + for (const file of SERIAL_FULL_SUITE_FILES) { + expect(plan[0]?.args).toContain(`**/${file}`); + expect(plan.find(lane => lane.label === file)?.args).toEqual([ + "--isolate", + "--parallel=1", + `./tests/${file}`, + ]); + } + expect(plan.find(lane => lane.label === "release-helper.test.ts")?.timeoutMs).toBe(5 * 60 * 1000); + expect(plan.find(lane => lane.label === "codex-shim.test.ts")?.timeoutMs).toBe(3 * 60 * 1000); + }); + + test("serial lanes override caller parallelism without changing the main lane", () => { + const plan = resolveBunTestPlan(["--parallel=2", "--only-failures"]); + expect(plan[0]?.args).toContain("--parallel=2"); + for (const lane of plan.slice(1)) { + expect(lane.args).toContain("--parallel=1"); + expect(lane.args).not.toContain("--parallel=2"); + expect(lane.args).toContain("--only-failures"); + } + }); + + test("sharded and reporter-file runs stay a single caller-controlled lane", () => { + expect(resolveBunTestPlan(["--shard=1/3"])).toHaveLength(1); + expect(resolveBunTestPlan(["--reporter=junit", "--reporter-outfile", "results.xml"])) + .toHaveLength(1); + }); + + test("a file filter keeps isolate and bounded parallelism but no suite path", () => { + expect(resolveBunTestArgs(["tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel=4", "tests/foo.test.ts"]); + expect(resolveBunTestArgs(["-"])) + .toEqual(["--isolate", "--parallel=4", "-"]); + }); + + test("a caller-supplied concurrency is left alone", () => { + expect(resolveBunTestArgs(["--parallel=2"])) + .toEqual(["--isolate", "--parallel=2", "./tests/"]); + expect(resolveBunTestArgs(["--parallel"])) + .toEqual(["--isolate", "--parallel", "./tests/"]); + expect(resolveBunTestArgs(["--parallel", "tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel", "tests/foo.test.ts"]); + expect(resolveBunTestArgs(["--parallel=2", "tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel=2", "tests/foo.test.ts"]); + }); + + test("option-only arguments still count as a full suite run", () => { + expect(resolveBunTestArgs(["--timeout=30000"])) + .toEqual(["--isolate", "--parallel=4", "--timeout=30000", "./tests/"]); + expect(resolveBunTestArgs(["--timeout", "30000"])) + .toEqual(["--isolate", "--parallel=4", "--timeout", "30000", "./tests/"]); + expect(resolveBunTestArgs(["--timeout", "30000", "tests/foo.test.ts"])) + .toEqual(["--isolate", "--parallel=4", "--timeout", "30000", "tests/foo.test.ts"]); + expect(resolveBunTestArgs(["--timings", ".bun-test-timings/current.json"])) + .toEqual([ + "--isolate", + "--parallel=4", + "--timings", + ".bun-test-timings/current.json", + "./tests/", + ]); + for (const configFlag of ["-c", "--config"]) { + expect(resolveBunTestArgs([configFlag, "ci.bunfig.toml"])) + .toEqual(["--isolate", "--parallel=4", configFlag, "ci.bunfig.toml", "./tests/"]); + } + expect(resolveBunTestArgs(["-t", "serial test"])).toEqual([ + "--isolate", + "--parallel=4", + "-t", + "serial test", + "./tests/", + ]); + }); + + test("arguments after the delimiter are passed through instead of parsed as wrapper flags", () => { + expect(resolveBunTestArgs(["--", "--parallel=2"])) + .toEqual(["--isolate", "--parallel=4", "--", "--parallel=2"]); + }); + + test("the wrapper passes parallel execution through to bun", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "opencodex-test-runner-")); + const fixturePath = join(fixtureRoot, "parallel-smoke.test.ts"); + const markerPath = join(fixtureRoot, "executed.marker"); + writeFileSync( + fixturePath, + `import { test } from "bun:test"; import { writeFileSync } from "node:fs"; test("smoke", () => writeFileSync(${JSON.stringify(markerPath)}, "executed"));\n`, + ); + try { + const result = Bun.spawnSync([ + process.execPath, + join(import.meta.dir, "../scripts/test.ts"), + fixturePath, + ], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, OCX_TEST_NO_QUEUE: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const output = new TextDecoder().decode(result.stdout) + + new TextDecoder().decode(result.stderr); + expect(result.exitCode).toBe(0); + expect(output).toContain("PARALLEL"); + expect(existsSync(markerPath)).toBe(true); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +}); + +describe("bun test machine lock", () => { + test("independent bare runners do not inherit a shared long-lived parent identity", () => { + expect(resolveBareTestRunIdentity({ pid: 101, ppid: 50 })).toEqual({ + ownerPid: 101, + runId: "bare-101", + }); + expect(resolveBareTestRunIdentity({ pid: 102, ppid: 50 })).toEqual({ + ownerPid: 102, + runId: "bare-102", + }); + }); + + test("parallel Bun workers rendezvous on their short-lived controller PID", () => { + expect(resolveBareTestRunIdentity({ pid: 101, ppid: 90, workerId: "1" })).toEqual({ + ownerPid: 101, + runId: "bare-90", + }); + expect(resolveBareTestRunIdentity({ pid: 102, ppid: 90, workerId: "2" })).toEqual({ + ownerPid: 102, + runId: "bare-90", + }); + }); + + test("one run owns the lock while sibling workers with its run ID join", async () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); + const lockPath = join(root, "suite.lock"); + try { + const owner = await acquireTestRunLock({ runId: "suite-a", lockPath, pollMs: 5, maxWaitMs: 50 }); + const sibling = await acquireTestRunLock({ runId: "suite-a", lockPath, pollMs: 5, maxWaitMs: 50 }); + expect(owner.acquired).toBe(true); + expect(sibling.acquired).toBe(false); + sibling.release(); + expect(existsSync(lockPath)).toBe(true); + owner.release(); + expect(existsSync(lockPath)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a dead owner is reclaimed even when the next bare invocation derives the same run ID", async () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); + const lockPath = join(root, "suite.lock"); + try { + const stale = await acquireTestRunLock({ + runId: "stale", + ownerPid: 2_147_483_647, + lockPath, + pollMs: 5, + maxWaitMs: 50, + }); + const replacement = await acquireTestRunLock({ runId: "stale", lockPath, pollMs: 5, maxWaitMs: 50 }); + expect(replacement.acquired).toBe(true); + stale.release(); + expect(existsSync(lockPath)).toBe(true); + replacement.release(); + expect(existsSync(lockPath)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a live competing run fails closed after the bounded wait", async () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); + const lockPath = join(root, "suite.lock"); + try { + const owner = await acquireTestRunLock({ runId: "live", lockPath, pollMs: 5, maxWaitMs: 50 }); + let waits = 0; + await expect(acquireTestRunLock({ + runId: "blocked", + lockPath, + pollMs: 5, + maxWaitMs: 20, + onWait: () => { waits += 1; }, + })).rejects.toThrow("timed out"); + expect(waits).toBe(1); + owner.release(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the explicit no-queue escape hatch does not create a lock", async () => { + const root = mkdtempSync(join(tmpdir(), "opencodex-test-lock-")); + const lockPath = join(root, "suite.lock"); + try { + const lock = await acquireTestRunLock({ + runId: "opt-out", + lockPath, + env: { [TEST_RUN_NO_QUEUE_ENV]: "1" }, + }); + expect(lock.acquired).toBe(false); + expect(existsSync(lockPath)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From f392e02eb34fc2da54de1aa19af6a233e0ae6a6a Mon Sep 17 00:00:00 2001 From: luvs01 Date: Wed, 26 Aug 2026 04:30:23 +0900 Subject: [PATCH 041/336] fix(kiro): preserve composed property names (#2583) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/adapters/kiro-tools.ts | 4 ++-- tests/kiro-adapter.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/adapters/kiro-tools.ts b/src/adapters/kiro-tools.ts index 960a534387..48f31ece34 100644 --- a/src/adapters/kiro-tools.ts +++ b/src/adapters/kiro-tools.ts @@ -103,7 +103,7 @@ function ensureRootObjectType(schema: unknown): Record { // Seed with the root's own properties/required so a schema like // { type:"object", properties:{path}, required:["path"], oneOf:[...] } keeps them. if (obj.properties && typeof obj.properties === "object") { - Object.assign(props, sanitizeKiroSchema(obj.properties) as Record); + Object.assign(props, sanitizeSchemaMap(obj.properties) as Record); } if (Array.isArray(obj.required)) { for (const r of obj.required) if (typeof r === "string") required.add(r); @@ -118,7 +118,7 @@ function ensureRootObjectType(schema: unknown): Record { if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue; const v = variant as Record; if (v.properties && typeof v.properties === "object") { - Object.assign(props, sanitizeKiroSchema(v.properties) as Record); + Object.assign(props, sanitizeSchemaMap(v.properties) as Record); } if (mergeRequired && Array.isArray(v.required)) { for (const r of v.required) if (typeof r === "string") required.add(r); diff --git a/tests/kiro-adapter.test.ts b/tests/kiro-adapter.test.ts index 9483ec9b3e..d92180f6c3 100644 --- a/tests/kiro-adapter.test.ts +++ b/tests/kiro-adapter.test.ts @@ -675,6 +675,16 @@ describe("kiro adapter — buildRequest", () => { const withDefs = await pick({ $defs: { X: { type: "string" } }, anyOf: [{ properties: { a: { $ref: "#/$defs/X" } } }] }); expect(withDefs.$defs).toEqual({ X: { type: "string" } }); expect(withDefs.properties).toEqual({ a: { $ref: "#/$defs/X" } }); + + // Property names remain data while flattening, even when they collide with rejected keywords. + const keywordNames = await pick({ + properties: { format: { type: "string", format: "uuid" } }, + required: ["format"], + oneOf: [{ properties: { pattern: { type: "string", pattern: "^x" } } }], + }); + expect(keywordNames.properties.format).toEqual({ type: "string" }); + expect(keywordNames.properties.pattern).toEqual({ type: "string" }); + expect(keywordNames.required).toEqual(["format"]); }); test("tool descriptions use deterministic model-specific caps without prompt injection", async () => { From 87250870c19daeaa03e2995f59478258c98e8b19 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:41:52 +0900 Subject: [PATCH 042/336] fix(sidecars): rotate generic OAuth accounts on 429 (#2568) (#2607) * fix(sidecars): rotate generic OAuth accounts on 429 Widen the image and web-search on429 hooks to accept an awaitable adapter and await them at both consumption sites. Existing synchronous key-pool callbacks remain source-compatible and are still exercised unchanged. Option (a) is required because failoverAccountSnapshot may refresh a token asynchronously. Awaiting the hook preserves full snapshot atomicity (including Antigravity projectId and Kiro routing metadata) without eager refresh work, blocking tricks, or a fire-and-forget race. The shared sidecar callback tries key-pool rotation first, then bounded generic OAuth rotation, retaining the existing OpenAI/Anthropic exclusions and single-account no-op. * test(sidecars): pin the shared on429 hook so neither sidecar drifts key-pool-only The two loop tests only prove the loops AWAIT the hook; they stub it and never reach the OAuth branch core.ts injects. The hook is a closure over request-local state, so it is not importable, and driving it end to end means a full sidecar request against a stubbed provider. These are structural assertions on the source, like the route-inventory contract: they cannot prove rotation works, but they catch the regression that actually threatens this feature - one sidecar keeping a key-pool-only hook while the other gets the OAuth-aware one. That divergence is how the gap arose in the first place. Falsified both ways: inlining a key-pool-only hook at the web-search site fails the identity assertion (expected 1 distinct hook, received 2); removing the knob gate fails the gate assertion. --- src/images/loop.ts | 9 +-- src/server/responses/core.ts | 93 +++++++++++++++------------- src/web-search/loop.ts | 9 +-- tests/generic-oauth-failover.test.ts | 58 ++++++++++++++++- tests/images/loop.test.ts | 5 +- tests/web-search.test.ts | 5 +- 6 files changed, 123 insertions(+), 56 deletions(-) diff --git a/src/images/loop.ts b/src/images/loop.ts index 834bcced93..74f759b2b5 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -258,10 +258,11 @@ export interface ImageBridgeDeps { /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ onUsage?: (usage: OcxUsage | undefined) => void; /** - * Optional 429 key-failover for the routed (non-xAI) model. Return a rebuilt adapter for the - * rotated key, or null when the pool is exhausted. + * Optional 429 failover for the routed (non-xAI) model. Return a rebuilt adapter for the + * rotated credential, or null when the pool is exhausted. Async hooks support OAuth refresh; + * existing synchronous key-pool hooks remain valid. */ - on429?: (retryAfterHeader: string | null) => ProviderAdapter | null; + on429?: (retryAfterHeader: string | null) => ProviderAdapter | null | Promise; /** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */ retryOn429Policy?: Required | null; /** Called when the bridged Responses stream completes (parity with runTurn / routed paths). */ @@ -572,7 +573,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {}); } catch { /* already closed */ } adapter = rotated; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9a1bfe442b..946c4d5a4f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -196,7 +196,7 @@ import { providerModelResponsesUpstreamStreaming, type InboundWire, } from "../../providers/registry"; -import type { AdapterRequest } from "../../adapters/base"; +import type { AdapterRequest, ProviderAdapter } from "../../adapters/base"; import { hasKeyPoolFailover, rateLimitRetryDelayMs, @@ -4195,6 +4195,53 @@ async function handleResponsesInner( const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; const canRunWebSearch = !!wsPlan && !adapter.runTurn; + const rotateSidecarProviderOn429 = async (retryAfter: string | null): Promise => { + const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { + retryAfter, + now: Date.now(), + attemptedKey: route.provider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (rotated) { + route.provider = rotated; + } else { + if ( + !genericFailoverAccountId + || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + || !isGenericOAuthFailoverEnabled(config, route.providerName) + ) return null; + const nextAccountId = rotateGenericOAuthAccountOn429( + config, + route.providerName, + genericFailoverAccountId, + retryAfter, + ); + if (!nextAccountId) return null; + try { + const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); + genericFailoverAccountId = nextAccountId; + genericFailovers += 1; + route.provider = { ...route.provider, apiKey: snapshot.accessToken }; + if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) }; + if (route.provider.googleMode === "cloud-code-assist" && snapshot.projectId) { + route.provider = { ...route.provider, project: snapshot.projectId }; + } + } catch { + return null; + } + } + const rotatedAdapter = resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), + config.cacheRetention, + ); + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: route.provider, + adapterName: rotatedAdapter.name, + }); + return rotatedAdapter; + }; if ((imgPlan || vidPlan) && canRunWebSearch) { // Web search takes priority when both are active — the media bridge cannot run // alongside runWithWebSearch. Surface a runtime signal so the user knows their @@ -4285,27 +4332,7 @@ async function handleResponsesInner( if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; } }, - on429: retryAfter => { - const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { - retryAfter, - now: Date.now(), - attemptedKey: route.provider.apiKey, - promptCacheKey: parsed.options.promptCacheKey, - }); - if (!rotated) return null; - route.provider = rotated; - const rotatedAdapter = resolveAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), - config.cacheRetention, - ); - bindRouteReasoningReplayScope({ - parsed, - providerName: route.providerName, - provider: route.provider, - adapterName: rotatedAdapter.name, - }); - return rotatedAdapter; - }, + on429: rotateSidecarProviderOn429, retryOn429Policy: rateLimitRetryPolicyFor(route.provider), ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}), @@ -4373,27 +4400,7 @@ async function handleResponsesInner( routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs, stallTimeoutSec: wsPlan.stallTimeoutSec, streamRoutedModelOutput: wsPlan.streamRoutedModelOutput, - on429: retryAfter => { - const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { - retryAfter, - now: Date.now(), - attemptedKey: route.provider.apiKey, - promptCacheKey: parsed.options.promptCacheKey, - }); - if (!rotated) return null; - route.provider = rotated; - const rotatedAdapter = resolveAdapter( - resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), - config.cacheRetention, - ); - bindRouteReasoningReplayScope({ - parsed, - providerName: route.providerName, - provider: route.provider, - adapterName: rotatedAdapter.name, - }); - return rotatedAdapter; - }, + on429: rotateSidecarProviderOn429, retryOn429Policy: rateLimitRetryPolicyFor(route.provider), onCompletedResponse: commitReasoningReplayServingRoute, }); diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 18800555b8..749e97f93d 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -304,10 +304,11 @@ export interface WebSearchLoopDeps { /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */ onAttemptSend?: (recovery?: AttemptRecoveryKind) => void; /** - * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter, - * or null when the pool is exhausted (same semantics as the normal routed path). + * 429 failover hook: rotate the provider's active credential and return a rebuilt adapter, + * or null when the pool is exhausted. Async hooks support OAuth refresh; existing synchronous + * key-pool hooks remain valid. */ - on429?: (retryAfterHeader: string | null) => ProviderAdapter | null; + on429?: (retryAfterHeader: string | null) => ProviderAdapter | null | Promise; /** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */ retryOn429Policy?: Required | null; /** Called only when the final bridged Responses stream reaches completed or incomplete. */ @@ -509,7 +510,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { }); }); +/** + * The sidecar wiring (#2568). + * + * The rotator above is a pure module and the two sidecar loops are covered by their own await + * tests, but neither reaches the part that actually closes the gap: the `on429` hook `core.ts` + * injects into the image and web-search loops. That hook is a closure over request-local state + * (`route`, `genericFailoverAccountId`, `genericFailovers`), so it is not importable, and + * driving it end to end means standing up a full sidecar request against a stubbed provider. + * + * These are structural assertions on the source, in the same spirit as the route-inventory + * contract in `codex-convergence-contract.test.ts`: they cannot prove the rotation works, and + * they are not a substitute for the loop tests — but they DO catch the regression that actually + * threatens this feature, which is one sidecar silently keeping a key-pool-only hook while the + * other gets the OAuth-aware one. That divergence is exactly how the gap was introduced in the + * first place: the main response path grew generic rotation and the two sidecars did not. + */ +describe("sidecar on429 wiring", () => { + const coreSource = readFileSync( + join(import.meta.dir, "..", "src", "server", "responses", "core.ts"), + "utf8", + ); + + test("both sidecar loops receive the SAME hook, so neither can drift key-pool-only", () => { + const hooks = coreSource.match(/^\s*on429: (\w+),$/gm)?.map(line => line.trim()) ?? []; + // Two injection sites — the image bridge and the web-search loop — and one shared hook. + expect(hooks).toHaveLength(2); + expect(new Set(hooks).size).toBe(1); + expect(hooks[0]).toBe("on429: rotateSidecarProviderOn429,"); + }); + + test("the shared hook tries the key pool first and only then the OAuth roster", () => { + const start = coreSource.indexOf("const rotateSidecarProviderOn429 ="); + expect(start).toBeGreaterThan(-1); + const body = coreSource.slice(start, coreSource.indexOf("\n };", start)); + + // Key-pool rotation stays first and unconditional: an API-key provider must behave exactly + // as it did before this hook existed. + const keyPool = body.indexOf("rotateProviderTransportOn429("); + const oauth = body.indexOf("rotateGenericOAuthAccountOn429("); + expect(keyPool).toBeGreaterThan(-1); + expect(oauth).toBeGreaterThan(keyPool); + + // The OAuth branch is gated on all three of: an account this request actually used, the + // per-request bound, and the knob. Dropping any one of them turns an opt-in feature into a + // default-on one, or lets a short Retry-After spin. + expect(body).toContain("!genericFailoverAccountId"); + expect(body).toContain("genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST"); + expect(body).toContain("!isGenericOAuthFailoverEnabled(config, route.providerName)"); + + // The FULL snapshot, not a bare bearer: Kiro carries routing metadata and Antigravity pairs + // an account-matched projectId with its token, so a token-only swap mixes two accounts. + expect(body).toContain("failoverAccountSnapshot("); + expect(body).toContain("_kiroAuthContext"); + expect(body).toContain("snapshot.projectId"); + }); +}); diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index 4106d0b1d9..01db03bf0c 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -652,7 +652,7 @@ describe("runWithImageBridge", () => { expect(seen).toEqual({ inputTokens: 13, outputTokens: 6 }); }); - test("429 key rotation rebuilds the adapter and retries the iteration", async () => { + test("429 OAuth rotation awaits a refreshed adapter and retries the iteration", async () => { let fetchCalls = 0; let rotations = 0; let activeAdapter: ProviderAdapter | undefined; @@ -677,8 +677,9 @@ describe("runWithImageBridge", () => { parsed: makeParsed(), adapter: firstAdapter, plan, - on429: () => { + on429: async () => { rotations++; + await Promise.resolve(); activeAdapter = secondAdapter; return secondAdapter; }, diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index c8e6d68471..3400798a18 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -769,7 +769,7 @@ describe("BUG-R86 routed web-search timeout semantics", () => { }); describe("web-search sidecar native web_search_call emission", () => { - test("loop 429 triggers on429 rotation and succeeds with the rebuilt adapter", async () => { + test("loop 429 awaits OAuth on429 rotation and succeeds with the rebuilt adapter", async () => { globalThis.fetch = (() => Promise.resolve(new Response( 'event: response.completed\ndata: {"type":"response.completed"}\n\n', { headers: { "Content-Type": "text/event-stream" } }, @@ -825,9 +825,10 @@ describe("web-search sidecar native web_search_call emission", () => { settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 1, onRequestBuilt: request => reasoningLogs.push(request.reasoningLog), - on429: retryAfter => { + on429: async retryAfter => { rotations++; expect(retryAfter).toBe("30"); + await Promise.resolve(); return rotatedAdapter; }, }); From 6b508d5a8cc02a47aadd3d555949241f3bc05fa3 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:43:25 +0900 Subject: [PATCH 043/336] fix(cursor): rotate OAuth account on preflight rate limit (#2608) --- src/server/responses/core.ts | 95 ++++++++++++- tests/adapter-event-oauth-failover.test.ts | 152 +++++++++++++++++++++ 2 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 tests/adapter-event-oauth-failover.test.ts diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 946c4d5a4f..e7c50efd24 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -74,6 +74,7 @@ import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { CYBER_POLICY_ERROR_CODE, CYBER_POLICY_FALLBACK_MESSAGE, + adapterFailureFromMessage, isCyberPolicyCode, isCyberPolicyMessage, } from "../../lib/errors"; @@ -2871,6 +2872,7 @@ async function handleResponsesInner( (logCtx.attempts ??= []).push(attempt); } sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel); + let runTurnAdapter = adapter; if (adapter.runTurn) { recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed)); } @@ -4472,7 +4474,7 @@ async function handleResponsesInner( pacingSlotAcquired: true, }, ); - await adapter.runTurn?.( + await runTurnAdapter.runTurn?.( parsed, { headers: selectedForwardHeaders, @@ -4505,6 +4507,81 @@ async function handleResponsesInner( } }; const runTurn = async (): Promise => runTurnAttempt(queue, undefined, true); + const rotateRunTurnAdapterOnPreflight429 = async ( + error: Extract, + ): Promise => { + const status = error.status ?? adapterFailureFromMessage(error.message).httpStatus; + if ( + status !== 429 + || !genericFailoverAccountId + || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST + || !isGenericOAuthFailoverEnabled(config, route.providerName) + ) return false; + const nextAccountId = rotateGenericOAuthAccountOn429( + config, + route.providerName, + genericFailoverAccountId, + null, + ); + if (!nextAccountId) return false; + try { + const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); + genericFailoverAccountId = nextAccountId; + genericFailovers += 1; + route.provider = { ...route.provider, apiKey: snapshot.accessToken }; + if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) }; + if (route.provider.googleMode === "cloud-code-assist" && snapshot.projectId) { + route.provider = { ...route.provider, project: snapshot.projectId }; + } + // A Cursor conversation/checkpoint is credential-scoped. The failed attempt emitted no + // client-visible bytes, so replay is safe, but carrying its account identity into the next + // account would not be. Let the rotated adapter derive a fresh identity and conversation. + parsed._cursorIdentityScope = undefined; + parsed._cursorConversationId = undefined; + if (parsed._providerContinuation?.cursor) { + const { cursor: _discardedCursor, ...otherProviderState } = parsed._providerContinuation; + parsed._providerContinuation = otherProviderState; + } + const rotatedProvider = resolveWireProtocolOverride( + route.providerName, + route.modelId, + route.provider, + inboundWire, + ); + const rotatedAdapter = resolveAdapter(rotatedProvider, config.cacheRetention); + if (!rotatedAdapter.runTurn) return false; + runTurnAdapter = rotatedAdapter; + bindRouteReasoningReplayScope({ + parsed, + providerName: route.providerName, + provider: rotatedProvider, + adapterName: rotatedAdapter.name, + oauthCredentialSnapshot: { accountId: snapshot.accountId, generation: snapshot.generation }, + codexAuthContext: authCtx, + forwardHeaders: selectedForwardHeaders, + }); + sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel); + return true; + } catch { + return false; + } + }; + const preflightRunTurnFailover = async ( + firstSource: AsyncIterable, + ): Promise> => { + let source = firstSource; + while (true) { + const preflight = await preflightAdapterEvents(source); + if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) { + return preflight.stream; + } + const retryQueue = createAdapterEventQueue({ + onBacklogExceeded: () => runTurnAbort.abort(), + }); + void runTurnAttempt(retryQueue, "oauth-account-429"); + source = retryQueue.stream(); + } + }; // The empty-completion retry re-runs the turn against a fresh queue: the // first queue is closed once its attempt settles, and pushing into it after // close is a silent no-op. @@ -4520,6 +4597,11 @@ async function handleResponsesInner( if (parsed.stream) { void runTurn(); let eventSource: AsyncIterable = queue.stream(); + if (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) { + // Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be + // replayed transparently; after any output reaches the bridge, a later error stays terminal. + eventSource = await preflightRunTurnFailover(eventSource); + } if (options.comboAttempt) { const preflight = await preflightAdapterEvents(eventSource); if (preflight.error || preflight.empty) { @@ -4599,15 +4681,22 @@ async function handleResponsesInner( await runTurn(); const firstAttemptEvents = await queue.collect(); + let runTurnEvents: AdapterEvent[] = firstAttemptEvents; + if (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) { + runTurnEvents = []; + for await (const event of await preflightRunTurnFailover( + (async function* () { yield* firstAttemptEvents; })(), + )) runTurnEvents.push(event); + } let events: AdapterEvent[]; if (emptyCompletionGuardEnabled) { events = []; for await (const event of guardEmptyCompletionEventStream({ - firstEvents: (async function* () { yield* firstAttemptEvents; })(), + firstEvents: (async function* () { yield* runTurnEvents; })(), continuation: runTurnRetrySource, })) events.push(event); } else { - events = firstAttemptEvents; + events = runTurnEvents; } if (options.comboAttempt) { const firstMeaningful = events.find(event => event.type !== "heartbeat"); diff --git a/tests/adapter-event-oauth-failover.test.ts b/tests/adapter-event-oauth-failover.test.ts new file mode 100644 index 0000000000..f806806930 --- /dev/null +++ b/tests/adapter-event-oauth-failover.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProviderAdapter } from "../src/adapters/base"; +import { clearGenericFailoverHealth } from "../src/oauth/generic-account-failover"; +import { saveCredential } from "../src/oauth/store"; +import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../src/types"; + +const actualResolver = await import("../src/server/adapter-resolve"); +const actualResolveAdapter = actualResolver.resolveAdapter; +let attempts: AdapterEvent[][] = []; +let attemptKeys: string[] = []; + +function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { + return { + name: "cursor", + buildRequest: () => ({ url: provider.baseUrl, method: "POST", headers: {}, body: "" }), + async *parseStream() { + yield { type: "error", message: "fixture uses runTurn" } as AdapterEvent; + }, + async runTurn(_parsed, _incoming, emit) { + const index = attemptKeys.length; + attemptKeys.push(provider.apiKey ?? ""); + for (const event of attempts[index] ?? []) emit(event); + }, + }; +} + +mock.module("../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { + if (provider.adapter === "cursor") return fixtureAdapter(provider); + return actualResolveAdapter(provider, cacheRetention); + }, +})); + +const { handleResponses } = await import("../src/server/responses"); +const originalHome = process.env.OPENCODEX_HOME; +let home = ""; + +function config(enabled = true): OcxConfig { + return { + port: 0, + defaultProvider: "cursor", + providers: { + cursor: { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + authMode: "oauth", + models: ["model"], + }, + }, + ...(enabled ? { oauthAccountFailover: { enabled: true } } : {}), + } as OcxConfig; +} + +function request(stream: boolean): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cursor/model", input: "answer", stream }), + }); +} + +async function seedAccounts(count: number): Promise { + for (let i = 0; i < count; i += 1) { + await saveCredential("cursor", { + access: `cursor-access-${i}`, + refresh: `cursor-refresh-${i}`, + expires: Date.now() + 3_600_000, + accountId: `cursor-account-${i}`, + }, { addAccount: true }); + } +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-adapter-event-failover-")); + process.env.OPENCODEX_HOME = home; + clearGenericFailoverHealth(); + attempts = []; + attemptKeys = []; +}); + +afterEach(() => { + clearGenericFailoverHealth(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe("#2568 adapter-event OAuth failover", () => { + for (const stream of [true, false]) { + test(`${stream ? "streaming" : "non-streaming"} first-event 429 rotates and replays`, async () => { + await seedAccounts(2); + attempts = [ + [{ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }], + [{ type: "text_delta", text: "alternate answer" }, { type: "done" }], + ]; + + const response = await handleResponses(request(stream), config(), { model: "", provider: "" }); + const body = await response.text(); + + expect(attemptKeys).toEqual(["cursor-access-1", "cursor-access-0"]); + expect(body).toContain("alternate answer"); + expect(body).not.toContain("Cursor rate limit exceeded"); + }); + } + + test("a single account is a strict no-op", async () => { + await seedAccounts(1); + attempts = [[{ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }]]; + + const body = await (await handleResponses(request(true), config(), { model: "", provider: "" })).text(); + + expect(attemptKeys).toEqual(["cursor-access-0"]); + expect(body).toContain("rate_limit_exceeded"); + }); + + test("Codex and Anthropic remain excluded", async () => { + for (const providerName of ["openai", "anthropic"] as const) { + attempts = [[{ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }]]; + attemptKeys = []; + const excluded = config(); + excluded.defaultProvider = providerName; + excluded.providers = { [providerName]: { ...excluded.providers.cursor! } }; + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `${providerName}/model`, input: "answer", stream: true }), + }); + const response = await handleResponses(req, excluded, { model: "", provider: "" }); + await response.text(); + expect(attemptKeys).toHaveLength(0); + expect(response.status).toBe(401); + } + }); + + test("an error after first output is terminal and is never replayed", async () => { + await seedAccounts(2); + attempts = [[ + { type: "text_delta", text: "already visible" }, + { type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }, + ]]; + + const body = await (await handleResponses(request(true), config(), { model: "", provider: "" })).text(); + + expect(attemptKeys).toEqual(["cursor-access-1"]); + expect(body).toContain("already visible"); + expect(body).toContain("rate_limit_exceeded"); + }); +}); From 9593b244c2efefb8194deb44a56436c0cc9f5055 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:46:02 +0900 Subject: [PATCH 044/336] feat(models): newly discovered provider models arrive disabled (#2464) (#2609) * feat(models): disable newly discovered models by policy * fix(models): do not rewrite config when the roster is unchanged reconcileSuccessfulModelDiscoveries reported changed=true for every authoritative provider, so convergence persisted config.json on every catalog write - including the common case where a provider's roster is byte-identical to the stored baseline. That is write amplification with no user-visible cause, and it moves the config generation other writers revalidate against. Compare only the fields that carry meaning. updatedAt cannot participate: it moves on every successful fetch, so including it would report a change every time. When nothing else moved, the previous timestamp is kept too, or the very next comparison would look dirty and reintroduce the rewrite. Falsified: forcing changed=true unconditionally fails the new steady-state test; the genuine-arrival test stays green either way, which is what pins that the guard narrows rather than disables the policy. --- .../src/content/docs/guides/model-routing.md | 7 + gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Models.tsx | 48 ++++++ src/cli/init.ts | 1 + src/cli/models-runtime.ts | 30 ++++ src/codex/convergence.ts | 22 ++- src/providers/new-model-policy.ts | 146 ++++++++++++++++++ src/server/management/model-routes.ts | 67 ++++++++ src/types/config.ts | 12 ++ src/types/provider.ts | 2 + tests/codex-convergence-contract.test.ts | 14 +- tests/model-discovery-management-api.test.ts | 42 +++++ tests/new-model-policy.test.ts | 82 ++++++++++ 21 files changed, 487 insertions(+), 5 deletions(-) create mode 100644 src/providers/new-model-policy.ts create mode 100644 tests/model-discovery-management-api.test.ts create mode 100644 tests/new-model-policy.test.ts diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index fdce20d790..59198f6815 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -82,6 +82,13 @@ Routing and catalog visibility are separate controls: for that model. - A provider's non-empty `selectedModels` is another catalog allowlist. Live discovery and direct routing still work; only catalog and `/v1/models` emission are narrowed. +- Fresh installs set `modelDiscovery.newModelPolicy` to `"off"`. After the first successful live + fetch establishes a baseline, later arrivals are appended to `disabledModels` and carry a **NEW** + dashboard badge until enabled or acknowledged. Existing installs remain `"on"` until opted in. + Use `ocx models new-policy off` globally, add `--provider ` for an override, and inspect + `ocx models new-arrivals [--json]`. Failed/degraded fetches never change the baseline. Providers + with a non-empty `selectedModels` (including preset mode) are already curated, so this policy is + deliberately inert for them. - `provider.disabled: true` removes that provider from catalog discovery. Explicit `provider/model` requests fail, and `defaultModel` / `models[]` scans skip it. - `providerContextCaps` applies per-provider Codex-visible context caps. `contextCapValue` is the diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 42c042594e..59fbeca947 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2113,4 +2113,6 @@ export const de: Record = { "dash.visionTimeout": "Timeout", "dash.visionTimeoutInvalid": "Geben Sie eine ganze Zahl von {min} bis {max} Millisekunden ein.", "dash.visionAdvancedPopover": "Erweiterte Vision-Einstellungen", + "models.newPolicyGlobal": "Neue Modelle zunächst deaktivieren", "models.newPolicyProvider": "Richtlinie für neue Modelle", + "models.newPolicy_inherit": "Übernehmen", "models.newPolicy_off": "Aus", "models.newPolicy_on": "An", "models.newBadge": "NEU", "models.newCount": "{count} neu, aus", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8628bf49a7..831430011b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2143,6 +2143,9 @@ export const en = { "lab.layer.live_route_compatibility": "Live route compatibility", "lab.layer.task_effectiveness": "Task effectiveness", + "models.newPolicyGlobal": "New models start disabled", "models.newPolicyProvider": "New model policy", + "models.newPolicy_inherit": "Inherit", "models.newPolicy_off": "Off", "models.newPolicy_on": "On", + "models.newBadge": "NEW", "models.newCount": "{count} new, off", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index dd2e57ba49..58acf68ee0 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2101,4 +2101,6 @@ export const fr: Record = { "lab.layer.protocol_conformance": "Conformité du protocole", "lab.layer.live_route_compatibility": "Compatibilité des routes en direct", "lab.layer.task_effectiveness": "Efficacité des tâches", + "models.newPolicyGlobal": "Désactiver les nouveaux modèles par défaut", "models.newPolicyProvider": "Politique des nouveaux modèles", + "models.newPolicy_inherit": "Hériter", "models.newPolicy_off": "Désactivé", "models.newPolicy_on": "Activé", "models.newBadge": "NOUVEAU", "models.newCount": "{count} nouveaux, désactivés", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c46827aec1..41de00b016 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2134,4 +2134,6 @@ export const ja: Record = { "dash.visionTimeout": "タイムアウト", "dash.visionTimeoutInvalid": "{min} から {max} ミリ秒の整数を入力してください。", "dash.visionAdvancedPopover": "詳細なビジョン設定", + "models.newPolicyGlobal": "新しいモデルを無効で追加", "models.newPolicyProvider": "新しいモデルのポリシー", + "models.newPolicy_inherit": "継承", "models.newPolicy_off": "オフ", "models.newPolicy_on": "オン", "models.newBadge": "新着", "models.newCount": "新着 {count} 件、オフ", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d6dfcd47bd..9cbe4bebc1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2135,4 +2135,6 @@ export const ko: Record = { "dash.visionTimeout": "제한 시간", "dash.visionTimeoutInvalid": "{min}에서 {max} 밀리초 사이의 정수를 입력하세요.", "dash.visionAdvancedPopover": "고급 비전 설정", + "models.newPolicyGlobal": "새 모델을 비활성화 상태로 추가", "models.newPolicyProvider": "새 모델 정책", + "models.newPolicy_inherit": "상속", "models.newPolicy_off": "끔", "models.newPolicy_on": "켬", "models.newBadge": "신규", "models.newCount": "신규 {count}개, 꺼짐", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 146c38b8f9..b85f5d1f41 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2136,4 +2136,6 @@ export const ru: Record = { "dash.visionTimeout": "Таймаут", "dash.visionTimeoutInvalid": "Введите целое число от {min} до {max} миллисекунд.", "dash.visionAdvancedPopover": "Дополнительные настройки изображений", + "models.newPolicyGlobal": "Добавлять новые модели выключенными", "models.newPolicyProvider": "Политика новых моделей", + "models.newPolicy_inherit": "Наследовать", "models.newPolicy_off": "Выкл.", "models.newPolicy_on": "Вкл.", "models.newBadge": "НОВАЯ", "models.newCount": "Новых: {count}, выкл.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0a3a3afda8..1b45bcb4f9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2136,4 +2136,6 @@ export const tr: Record = { "dash.visionTimeout": "Zaman aşımı", "dash.visionTimeoutInvalid": "{min} ile {max} milisaniye arasında bir tam sayı girin.", "dash.visionAdvancedPopover": "Gelişmiş görsel ayarları", + "models.newPolicyGlobal": "Yeni modeller devre dışı başlasın", "models.newPolicyProvider": "Yeni model ilkesi", + "models.newPolicy_inherit": "Devral", "models.newPolicy_off": "Kapalı", "models.newPolicy_on": "Açık", "models.newBadge": "YENİ", "models.newCount": "{count} yeni, kapalı", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 356ac53518..3373460bb7 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2099,4 +2099,6 @@ export const zhTW: Record = { "dash.visionTimeout": "逾時", "dash.visionTimeoutInvalid": "請輸入 {min} 到 {max} 毫秒之間的整數。", "dash.visionAdvancedPopover": "進階視覺設定", + "models.newPolicyGlobal": "新模型預設停用", "models.newPolicyProvider": "新模型策略", + "models.newPolicy_inherit": "繼承", "models.newPolicy_off": "關閉", "models.newPolicy_on": "開啟", "models.newBadge": "新增", "models.newCount": "{count} 個新增,已關閉", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 037f3f599a..dfce2addb7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2134,4 +2134,6 @@ export const zh: Record = { "dash.visionTimeout": "超时", "dash.visionTimeoutInvalid": "请输入 {min} 到 {max} 毫秒之间的整数。", "dash.visionAdvancedPopover": "高级视觉设置", + "models.newPolicyGlobal": "新模型默认停用", "models.newPolicyProvider": "新模型策略", + "models.newPolicy_inherit": "继承", "models.newPolicy_off": "关闭", "models.newPolicy_on": "开启", "models.newBadge": "新增", "models.newCount": "{count} 个新增,已关闭", }; diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 7758cffea3..b7a298356a 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -113,6 +113,11 @@ interface ModelPresetView { totalCount: number; fallback?: string; } +interface ModelDiscoveryView { + policy: "on" | "off"; + providers: Record; + recentArrivals: Record>; +} export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; restartEpoch?: number }) { // Codex app-server staleness (devlog/_fin/260815_gui_codex_restart). Named @@ -240,6 +245,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // #2465: per-provider model-preset state. Keyed by provider so one card's busy state cannot // freeze the others. const [presets, setPresets] = useState>({}); + const [modelDiscovery, setModelDiscovery] = useState(null); const [presetBusy, setPresetBusy] = useState(null); const [v2Loading, setV2Loading] = useState(true); const [v2Busy, setV2Busy] = useState(false); @@ -440,6 +446,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // Preset previews belong to the same tab. Loaded once rather than polled: the rules are // shipped code and the catalog poll above already refreshes the rows they describe. void loadPresets(); + void loadModelDiscovery(); }, 0); // Hidden tab: no timer, no /api/v2 traffic; the make-up tick refreshes on return. const stop = startVisibilityPoll(() => { @@ -881,6 +888,22 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }; + const loadModelDiscovery = async () => { + try { + const r = await fetch(`${apiBase}/api/model-discovery`); + setModelDiscovery((await readJsonIfOk(r)) ?? null); + } catch { setModelDiscovery(null); } + }; + + const saveModelDiscovery = async (policy: "on" | "off", provider?: string) => { + const r = await fetch(`${apiBase}/api/model-discovery`, { + method: "PUT", headers: { "content-type": "application/json" }, + body: JSON.stringify({ policy, provider: provider ?? null }), + }); + await readJsonIfOk(r); + await Promise.all([loadModelDiscovery(), load()]); + }; + const applyPreset = async (provider: string, mode: "preset" | "all") => { if (presetBusy) return; setPresetBusy(provider); @@ -1089,6 +1112,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; disabled.has(model.namespaced), ); const activeCount = rows.filter(isVisible).length; + const recentForProvider = modelDiscovery?.recentArrivals[provider] ?? []; + const recentIds = new Set(recentForProvider.map(row => row.id)); const capOn = contextCaps[provider] !== undefined; const providerCap = contextCaps[provider] ?? contextCapValue; // With the cap off, `providerCap` is only the value a future toggle would apply — for the @@ -1155,6 +1180,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )} {t("models.active", { active: activeCount, total: rows.length })} + {recentForProvider.length > 0 && {t("models.newCount", { count: recentForProvider.length })}}
{/* Available on every card, including the native one: the canonical `openai` seed @@ -1305,6 +1331,21 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {!isCollapsed && (
{nativeProviderGroup &&

{t("models.nativeHint")}

} + {!nativeProviderGroup && modelDiscovery && ( +
+ {t("models.newPolicyProvider")} +
+ {(["off", "on"] as const).map(mode => ( + + ))} +
+
+ )} {rows.length === 0 && ( )} @@ -1339,6 +1380,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {t("models.customBadge")} )} + {!m.custom && recentIds.has(m.id) && {t("models.newBadge")}} {m.contextCapped && {t("models.contextCappedValue", { value: fmtK(m.contextCap ?? contextCapValue) })}}
{hoveredModel?.namespaced === m.namespaced && (() => { @@ -1450,6 +1492,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const controlsBlock = ( <>
+ {modelDiscovery && ( +
+ {t("models.newPolicyGlobal")} + void saveModelDiscovery(modelDiscovery.policy === "off" ? "on" : "off")} label={t("models.newPolicyGlobal")} /> +
+ )}
{t("models.shadowCallIntercept")} {t("models.shadowCallOriginal", { models: shadowSourceModelBadge(shadowCall?.sourceModels) })} diff --git a/src/cli/init.ts b/src/cli/init.ts index 32c7ade936..be3f0ac8b6 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -165,6 +165,7 @@ export async function runInit(): Promise { port, providers: { [providerName]: providerConfig }, defaultProvider: providerName, + modelDiscovery: { newModelPolicy: "off" }, }; saveConfig(config); diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index 4d5dd3f54a..4285e53eb5 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -24,6 +24,8 @@ const USAGE = `Usage: ocx models selected [--set |--clear] [--json] ocx models preset show [--provider ] [--json] ocx models preset apply [--all] [--json] + ocx models new-policy [on|off] [--provider ] [--json] + ocx models new-arrivals [--json] ocx models context [--set-all]|provider on [--value ]|provider off|all > [--json] ocx models shadow [model|-] [--enabled ] [--json]`; @@ -226,6 +228,32 @@ async function preset(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, [line]); } +async function newPolicy(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const state = args[0] && !args[0].startsWith("--") ? args.shift()!.toLowerCase() : undefined; + const provider = takeOption(args, "--provider")?.trim(); + const wantsJson = takeFlag(args, "--json"); + if (state !== undefined && state !== "on" && state !== "off") throw new CliUsageError("new policy must be on or off", USAGE); + rejectArgs(args, USAGE); + if (!state) { + const result = await runtimeRequest<{ policy: string; providers: Record }>("/api/model-discovery", {}, deps); + const value = provider ? result.providers[provider] ?? "inherit" : result.policy; + printData(provider ? { provider, policy: value } : result, wantsJson, [`${provider ?? "global"}: ${value}`]); + return; + } + const result = await runtimeRequest<{ baselineBootstrapped?: boolean }>("/api/model-discovery", { + method: "PUT", body: JSON.stringify({ policy: state, provider: provider ?? null }), + }, deps); + printData(result, wantsJson, [`${provider ?? "global"}: ${state}${result.baselineBootstrapped ? " (current models recorded as known)" : ""}`]); +} + +async function newArrivals(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE); + const result = await runtimeRequest<{ recentArrivals: Record> }>("/api/model-discovery", {}, deps); + const lines = Object.entries(result.recentArrivals).flatMap(([provider, rows]) => rows.map(row => `${provider}/${row.id} [${row.state}] ${row.at}`)); + printData(result.recentArrivals, wantsJson, lines.length ? lines : ["no recent model arrivals"]); +} + async function context(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); @@ -301,6 +329,8 @@ export async function handleModelsRuntimeCommand(sub: string, argv: string[], de else if (sub === "provider") action = () => providerVisibility(argv, deps); else if (sub === "selected") action = () => selected(argv, deps); else if (sub === "preset") action = () => preset(argv, deps); + else if (sub === "new-policy") action = () => newPolicy(argv, deps); + else if (sub === "new-arrivals") action = () => newArrivals(argv, deps); else if (sub === "context") action = () => context(argv, deps); else if (sub === "shadow") action = () => shadow(argv, deps); if (!action) return null; diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index ffb8af4aa9..67d09ec873 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; -import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; +import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; +import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy"; import { COMBO_NAMESPACE } from "../combos"; import { getAuthStorePath } from "../oauth/store"; import type { OcxConfig } from "../types"; @@ -135,6 +136,7 @@ interface CandidateState { readonly changed: boolean; readonly notices: readonly CatalogNotice[]; readonly modelEntitlements: CodexModelEntitlementSnapshot; + readonly discoveryConfig?: OcxConfig; } const candidateStates = new WeakMap(); @@ -436,8 +438,17 @@ export async function gatherCodexCatalogCandidate( ? active : !hasRoutedEntries(source.catalog) ? source.catalog : null) : null); + const discoveryConfig = structuredClone(snapshot.config) as OcxConfig; + const discoveryChanged = reconcileSuccessfulModelDiscoveries({ + config: discoveryConfig, + models: routedModels, + authoritativeProviders: providerModelOutcomes + .filter(outcome => outcome.state === "authoritative") + .map(outcome => outcome.provider), + now: new Date().toISOString(), + }); const preparedCatalog = prepareCatalog( - snapshot.config, + discoveryConfig, source, active, routedModels, @@ -502,6 +513,7 @@ export async function gatherCodexCatalogCandidate( || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes, notices: Object.freeze([...notices]), modelEntitlements, + ...(discoveryChanged ? { discoveryConfig } : {}), }); return { kind: "candidate", candidate }; } catch (error) { @@ -649,6 +661,12 @@ export async function convergeCodexCatalog( const state = candidateStates.get(gathered.candidate as object)!; lifecycle.onCommitBegin?.(); const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs); + if (committed.kind === "committed" && state.discoveryConfig) { + const mutable = snapshot.config as OcxConfig; + mutable.modelDiscovery = state.discoveryConfig.modelDiscovery; + mutable.disabledModels = state.discoveryConfig.disabledModels; + saveConfigPreservingClaudeCode(mutable); + } return { changed: committed.kind === "committed" ? committed.changed : false, catalogRefresh: projectCommit(committed, state.notices), diff --git a/src/providers/new-model-policy.ts b/src/providers/new-model-policy.ts new file mode 100644 index 0000000000..b9ac16af37 --- /dev/null +++ b/src/providers/new-model-policy.ts @@ -0,0 +1,146 @@ +import type { OcxConfig } from "../types"; +import { routedSlug } from "./slug-codec"; + +export const MODEL_REMOVAL_GRACE_FETCHES = 3; +export const MAX_KNOWN_MODELS_PER_PROVIDER = 2_000; +export const MAX_RECENT_ARRIVALS_PER_PROVIDER = 50; + +export type KnownModelBaseline = NonNullable["knownModels"]>[string]; +export type NewModelPolicy = "on" | "off"; + +export interface NewModelPolicyResult { + newIds: string[]; + nextBaseline: KnownModelBaseline; + slugsToDisable: string[]; + arrivals: Array<{ id: string; at: string }>; + overflow: boolean; +} + +/** + * Whether a transition actually changes persisted state. + * + * `updatedAt` moves on every successful fetch, so comparing whole baselines would report a + * change every time and rewrite config.json on every catalog convergence — a write amplification + * that also churns the config generation other writers revalidate against. Only the fields that + * carry meaning are compared. + */ +function baselineDiffers(prior: KnownModelBaseline | undefined, next: KnownModelBaseline): boolean { + if (!prior) return true; + const sameList = (a: readonly string[], b: readonly string[]) => + a.length === b.length && a.every((value, index) => value === b[index]); + if (!sameList(prior.ids, next.ids) || !sameList(prior.removed, next.removed)) return true; + const priorMissing = prior.missing ?? {}; + const nextMissing = next.missing ?? {}; + const keys = new Set([...Object.keys(priorMissing), ...Object.keys(nextMissing)]); + for (const key of keys) if (priorMissing[key] !== nextMissing[key]) return true; + return false; +} + +/** Pure successful-discovery transition. An absent baseline bootstraps without hiding anything. */ +export function applyNewModelPolicy(options: { + provider: string; + discoveredIds: Iterable; + baseline?: KnownModelBaseline; + policy: NewModelPolicy; + hasSelectedModels?: boolean; + now: string; +}): NewModelPolicyResult { + const discovered = [...new Set(options.discoveredIds)].sort(); + const prior = options.baseline; + if (!prior) { + return { + newIds: [], + nextBaseline: { ids: discovered, removed: [], updatedAt: options.now }, + slugsToDisable: [], arrivals: [], + overflow: discovered.length > MAX_KNOWN_MODELS_PER_PROVIDER, + }; + } + const active = new Set(prior.ids); + const removed = new Set(prior.removed); + const seen = new Set(discovered); + const newIds = discovered.filter(id => !active.has(id) && !removed.has(id)); + const missing: Record = {}; + for (const id of active) { + if (seen.has(id)) continue; + const count = (prior.missing?.[id] ?? 0) + 1; + if (count >= MODEL_REMOVAL_GRACE_FETCHES) { + active.delete(id); + removed.add(id); + } else missing[id] = count; + } + for (const id of discovered) active.add(id); + const unionSize = active.size + removed.size; + const overflow = unionSize > MAX_KNOWN_MODELS_PER_PROVIDER; + const arrivals = newIds.map(id => ({ id, at: options.now })); + return { + newIds, + nextBaseline: { + ids: [...active].sort(), removed: [...removed].sort(), updatedAt: options.now, + ...(Object.keys(missing).length ? { missing } : {}), + }, + // A non-empty preset/custom allowlist already excludes arrivals. This explicit no-op is + // deliberate: preset mode owns which matching flagships arrive on. + slugsToDisable: !overflow && options.policy === "off" && !options.hasSelectedModels + ? newIds.map(id => routedSlug(options.provider, id)) : [], + arrivals, + overflow, + }; +} + +export function effectiveNewModelPolicy(config: OcxConfig, provider: string): NewModelPolicy { + const local = config.providers[provider]?.newModelPolicy; + if (local === "on" || local === "off") return local; + return config.modelDiscovery?.newModelPolicy ?? "on"; +} + +/** Apply authoritative provider rows to a mutable convergence copy; degraded providers are omitted. */ +export function reconcileSuccessfulModelDiscoveries(options: { + config: OcxConfig; + models: Iterable<{ provider: string; id: string; custom?: boolean }>; + authoritativeProviders: Iterable; + now: string; +}): boolean { + const byProvider = new Map(); + for (const model of options.models) { + if (model.custom) continue; + const ids = byProvider.get(model.provider) ?? []; + ids.push(model.id); byProvider.set(model.provider, ids); + } + let changed = false; + for (const provider of options.authoritativeProviders) { + const configured = options.config.providers[provider]; + if (!configured || configured.liveModels === false) continue; + const discoveredIds = byProvider.get(provider) ?? []; + const discovery = options.config.modelDiscovery ??= {}; + const known = discovery.knownModels ??= {}; + const result = applyNewModelPolicy({ + provider, discoveredIds, baseline: known[provider], + policy: effectiveNewModelPolicy(options.config, provider), + hasSelectedModels: (configured.selectedModels?.length ?? 0) > 0, + now: options.now, + }); + if (result.overflow) continue; + const priorBaseline = known[provider]; + const baselineChanged = baselineDiffers(priorBaseline, result.nextBaseline); + known[provider] = result.nextBaseline; + // Keep the previous timestamp when nothing else moved, so a steady-state roster does not + // make the baseline look dirty on the next comparison either. + if (!baselineChanged && priorBaseline) known[provider] = priorBaseline; + if (result.slugsToDisable.length) { + const disabled = options.config.disabledModels ??= []; + for (const slug of result.slugsToDisable) { + if (disabled.includes(slug)) continue; + disabled.push(slug); + changed = true; + } + } + if (result.arrivals.length) { + const recent = discovery.recentArrivals ??= {}; + recent[provider] = [...(recent[provider] ?? []), ...result.arrivals] + .slice(-MAX_RECENT_ARRIVALS_PER_PROVIDER); + changed = true; + } + if (baselineChanged) changed = true; + } + return changed; +} diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 9ea4d5561e..1eaecef8a6 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -177,6 +177,69 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise [ + name, provider.newModelPolicy ?? "inherit", + ])); + const recentArrivals = Object.fromEntries(Object.entries(config.modelDiscovery?.recentArrivals ?? {}).map(([name, rows]) => [ + name, + rows.map(row => ({ + ...row, + state: (config.disabledModels ?? []).some(slug => slugEquals(slug, name, row.id)) + ? "auto-disabled" : "enabled", + })), + ])); + const baselineCounts = Object.fromEntries(Object.entries(config.modelDiscovery?.knownModels ?? {}).map(([name, baseline]) => [ + name, baseline.ids.length, + ])); + return jsonResponse({ + policy: config.modelDiscovery?.newModelPolicy ?? "on", providers, recentArrivals, baselineCounts, + }); + } + + if (url.pathname === "/api/model-discovery" && req.method === "PUT") { + let body: { policy?: unknown; provider?: unknown }; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (body.policy !== "on" && body.policy !== "off") return jsonResponse({ error: "policy must be on or off" }, 400); + const provider = typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : null; + let baselineBootstrapped = false; + if (provider) { + if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "unknown provider" }, 404); + config.providers[provider].newModelPolicy = body.policy; + } else { + const wasAbsent = config.modelDiscovery?.newModelPolicy === undefined; + config.modelDiscovery ??= {}; + config.modelDiscovery.newModelPolicy = body.policy; + if (body.policy === "off" && wasAbsent) { + const models = await fetchAllModels(config); + const known = config.modelDiscovery.knownModels ??= {}; + const at = new Date().toISOString(); + for (const name of Object.keys(config.providers)) { + known[name] ??= { ids: [...new Set(models.filter(m => m.provider === name).map(m => m.id))].sort(), removed: [], updatedAt: at }; + } + baselineBootstrapped = true; + } + } + persistConfig(config); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, policy: body.policy, provider, ...(baselineBootstrapped ? { baselineBootstrapped } : {}), catalogRefresh }); + } + + if (url.pathname === "/api/model-discovery/acknowledge" && req.method === "POST") { + let body: { provider?: unknown; ids?: unknown }; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + const provider = typeof body.provider === "string" ? body.provider.trim() : ""; + if (!provider || !Array.isArray(body.ids) || body.ids.some(id => typeof id !== "string")) { + return jsonResponse({ error: "provider and string ids are required" }, 400); + } + const acknowledged = new Set(body.ids as string[]); + const recent = config.modelDiscovery?.recentArrivals; + if (recent?.[provider]) recent[provider] = recent[provider].filter(row => !acknowledged.has(row.id)); + persistConfig(config); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, provider, acknowledged: [...acknowledged], catalogRefresh }); + } + if (url.pathname === "/api/catalog" && req.method === "GET") { const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog"); const catalog = readCatalog(readCodexCatalogPath()); @@ -376,6 +439,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise !targets.some(target => matchesTarget(stored, target))); + const arrivals = config.modelDiscovery?.recentArrivals?.[provider]; + if (arrivals) config.modelDiscovery!.recentArrivals![provider] = arrivals.filter(row => ( + !targets.some(target => !target.native && target.id === row.id) + )); } } else { for (const target of targets) { diff --git a/src/types/config.ts b/src/types/config.ts index 6ba3070398..a02b3d0398 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -260,6 +260,18 @@ export interface OcxConfig { managementUsageMaxReadBytes?: number; providers: Record; defaultProvider: string; + /** Persisted state for newly discovered provider models (#2464). Absent keeps legacy "on" behavior. */ + modelDiscovery?: { + newModelPolicy?: "on" | "off"; + knownModels?: Record; + }>; + recentArrivals?: Record>; + }; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ openaiProviderTierVersion?: 1 | 2; /** One-time migration marker for Antigravity's static-catalog defaults. */ diff --git a/src/types/provider.ts b/src/types/provider.ts index 9e7b5a1f86..612d34fe09 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -275,6 +275,8 @@ export interface OcxProviderConfig { * full set so the user can pick). See devlog issue_052_provider-model-allowlist. */ selectedModels?: string[]; + /** Override for newly discovered models. Absent/"inherit" uses the install policy. */ + newModelPolicy?: "on" | "off" | "inherit"; /** * Model-preset marker for `selectedModels` (#2465). Absent means "all", exactly today's * semantics — an existing provider is never narrowed by an upgrade. diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index c39d68d211..8f360f39f5 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -372,10 +372,10 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 7 + 8 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 7 + 10 + 2 + 2 convergence calls", () => { const counts = Object.fromEntries([ ["provider-routes.ts", 7], - ["model-routes.ts", 8], + ["model-routes.ts", 10], ["combo-routes.ts", 2], ["agent-settings-routes.ts", 2], ].map(([file, expected]) => { @@ -387,12 +387,20 @@ test("the route inventory contains exactly the specified 7 + 8 + 2 + 2 convergen })); expect(counts).toEqual({ "provider-routes.ts": 7, - "model-routes.ts": 8, + "model-routes.ts": 10, "combo-routes.ts": 2, "agent-settings-routes.ts": 2, }); }); +test("both model-discovery write paths converge the Codex catalog", () => { + const source = readFileSync(join(import.meta.dir, "..", "src", "server", "management", "model-routes.ts"), "utf8"); + const settings = source.slice(source.indexOf('url.pathname === "/api/model-discovery" && req.method === "PUT"'), source.indexOf('url.pathname === "/api/model-discovery/acknowledge"')); + const acknowledge = source.slice(source.indexOf('url.pathname === "/api/model-discovery/acknowledge"'), source.indexOf('url.pathname === "/api/catalog"')); + expect(settings.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); + expect(acknowledge.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); +}); + /** * Same discipline as the reload-route assertion below: the count above went 6 -> 8 for the * model-preset routes (#2465), and a bare count that only ever rises stops being a contract. diff --git a/tests/model-discovery-management-api.test.ts b/tests/model-discovery-management-api.test.ts new file mode 100644 index 0000000000..ff512c0568 --- /dev/null +++ b/tests/model-discovery-management-api.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { ManagementRequest as Request } from "./helpers/management-auth"; + +function config(): OcxConfig { + return { port: 10100, defaultProvider: "vendor", providers: { vendor: { liveModels: false, models: ["known"] } }, disabledModels: ["vendor/new"] }; +} + +async function call(live: OcxConfig, path: string, method = "GET", body?: unknown) { + const url = new URL(`http://localhost${path}`); + const response = await handleManagementAPI(new Request(url, { + method, ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), + }), url, live, { + saveConfigPreservingClaudeCode: () => {}, + fetchAllModels: async () => [{ provider: "vendor", id: "known" } as never], + createManagementConvergeCodex: () => async () => ({ kind: "catalog-only", changed: false, catalogRefresh: { status: "unchanged" }, observed: {} as never, history: {} as never }), + }); + return { response: response!, json: await response!.json() as Record }; +} + +describe("model discovery management API", () => { + test("PUT off bootstraps, GET reports state, and provider override persists", async () => { + const live = config(); + const put = await call(live, "/api/model-discovery", "PUT", { policy: "off", provider: null }); + expect(put.response.status).toBe(200); expect(put.json.baselineBootstrapped).toBe(true); + expect(live.modelDiscovery?.knownModels?.vendor.ids).toEqual(["known"]); + await call(live, "/api/model-discovery", "PUT", { policy: "on", provider: "vendor" }); + expect(live.providers.vendor.newModelPolicy).toBe("on"); + const get = await call(live, "/api/model-discovery"); + expect(get.json.policy).toBe("off"); + }); + + test("acknowledge removes recent badges without changing visibility", async () => { + const live = config(); + live.modelDiscovery = { recentArrivals: { vendor: [{ id: "new", at: "2026-08-24T00:00:00Z" }] } }; + const result = await call(live, "/api/model-discovery/acknowledge", "POST", { provider: "vendor", ids: ["new"] }); + expect(result.response.status).toBe(200); + expect(live.modelDiscovery.recentArrivals?.vendor).toEqual([]); + expect(live.disabledModels).toEqual(["vendor/new"]); + }); +}); diff --git a/tests/new-model-policy.test.ts b/tests/new-model-policy.test.ts new file mode 100644 index 0000000000..be6656dada --- /dev/null +++ b/tests/new-model-policy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { applyNewModelPolicy, reconcileSuccessfulModelDiscoveries } from "../src/providers/new-model-policy"; + +const now = "2026-08-24T02:11:00Z"; + +describe("new-model policy", () => { + test("bootstraps without hiding the existing catalog", () => { + const r = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "b"], policy: "off", now }); + expect(r.newIds).toEqual([]); expect(r.slugsToDisable).toEqual([]); expect(r.nextBaseline.ids).toEqual(["a", "b"]); + }); + + test("walks the design scenario and auto-disables each id at most once", () => { + let baseline = { ids: ["a", "b", "c"], removed: [], updatedAt: now }; + let r = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "b", "c", "d"], baseline, policy: "off", now }); + expect(r.slugsToDisable).toEqual(["openrouter/d"]); baseline = r.nextBaseline; + r = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "b", "c", "d"], baseline, policy: "off", now }); + expect(r.newIds).toEqual([]); baseline = r.nextBaseline; + for (let i = 0; i < 3; i++) baseline = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "b", "c"], baseline, policy: "off", now }).nextBaseline; + expect(baseline.removed).toContain("d"); + r = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "b", "c", "d"], baseline, policy: "off", now }); + expect(r.newIds).toEqual([]); expect(r.slugsToDisable).toEqual([]); + baseline = r.nextBaseline; + r = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "b", "d", "c-v2"], baseline, policy: "off", now }); + expect(r.slugsToDisable).toEqual(["openrouter/c-v2"]); + }); + + test("preset mode is a deliberate no-op while still recording the arrival", () => { + const r = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "d"], baseline: { ids: ["a"], removed: [], updatedAt: now }, policy: "off", hasSelectedModels: true, now }); + expect(r.newIds).toEqual(["d"]); expect(r.arrivals).toEqual([{ id: "d", at: now }]); expect(r.slugsToDisable).toEqual([]); + }); + + test("degraded providers do not poison a persisted baseline", () => { + const config = { port: 10100, defaultProvider: "vendor", providers: { vendor: {} }, modelDiscovery: { newModelPolicy: "off" as const, knownModels: { vendor: { ids: ["a", "b"], removed: [], updatedAt: now } } } }; + expect(reconcileSuccessfulModelDiscoveries({ config, models: [{ provider: "vendor", id: "a" }], authoritativeProviders: [], now })).toBe(false); + expect(config.modelDiscovery.knownModels.vendor.ids).toEqual(["a", "b"]); + }); + + /** + * The steady state is the common case: a provider's roster is identical on almost every + * convergence, and convergence runs on every catalog write. Reporting "changed" there would + * rewrite config.json each time and move the config generation that other writers revalidate + * against — a write amplification with no user-visible cause. + */ + test("an unchanged roster reports no change, so convergence does not rewrite config", () => { + const config = { + port: 10100, defaultProvider: "vendor", providers: { vendor: {} }, + modelDiscovery: { + newModelPolicy: "off" as const, + knownModels: { vendor: { ids: ["a", "b"], removed: [], updatedAt: "2026-01-01T00:00:00Z" } }, + }, + }; + const changed = reconcileSuccessfulModelDiscoveries({ + config, + models: [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }], + authoritativeProviders: ["vendor"], + now, + }); + expect(changed).toBe(false); + // The timestamp is deliberately NOT advanced: a bumped updatedAt would make the very next + // comparison look dirty and reintroduce the rewrite it just avoided. + expect(config.modelDiscovery.knownModels.vendor.updatedAt).toBe("2026-01-01T00:00:00Z"); + }); + + test("a genuine arrival still reports a change and records the disable", () => { + const config = { + port: 10100, defaultProvider: "vendor", providers: { vendor: {} }, + modelDiscovery: { + newModelPolicy: "off" as const, + knownModels: { vendor: { ids: ["a"], removed: [], updatedAt: "2026-01-01T00:00:00Z" } }, + }, + } as Parameters[0]["config"]; + const changed = reconcileSuccessfulModelDiscoveries({ + config, + models: [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }], + authoritativeProviders: ["vendor"], + now, + }); + expect(changed).toBe(true); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(config.modelDiscovery!.knownModels!.vendor!.updatedAt).toBe(now); + }); +}); From 05ced3caedf83f3f0030e290eecb5ae002f296fd Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:51:44 +0900 Subject: [PATCH 045/336] feat(aliases): provider and model aliases across router, API, CLI and GUI (#2463) (#2610) * feat(aliases): add provider and model short names * fix(logs): retain requested model aliases --- docs-site/src/content/docs/reference/cli.md | 4 + .../content/docs/reference/configuration.md | 19 +++++ gui/src/i18n/de.ts | 13 +++ gui/src/i18n/en.ts | 13 +++ gui/src/i18n/fr.ts | 13 +++ gui/src/i18n/ja.ts | 13 +++ gui/src/i18n/ko.ts | 13 +++ gui/src/i18n/ru.ts | 13 +++ gui/src/i18n/tr.ts | 13 +++ gui/src/i18n/zh-TW.ts | 13 +++ gui/src/i18n/zh.ts | 13 +++ gui/src/icons.tsx | 1 + gui/src/pages/Models.tsx | 82 +++++++++++++++++- src/cli/alias.ts | 66 ++++++++++++++ src/cli/dispatch.ts | 4 + src/cli/registry.ts | 5 ++ src/combos/types.ts | 10 +++ src/config.ts | 43 ++++++++++ src/providers/default-aliases.ts | 65 ++++++++++++++ src/router.ts | 36 +++++++- src/server/auth-cors.ts | 4 + src/server/chat-completions.ts | 1 + src/server/index.ts | 24 ++++-- src/server/management/model-routes.ts | 85 +++++++++++++++++++ src/server/request-log.ts | 6 ++ src/server/responses/core.ts | 1 + src/types/config.ts | 2 + src/types/provider.ts | 6 ++ src/usage/log.ts | 1 + tests/alias-management-api.test.ts | 40 +++++++++ tests/codex-convergence-contract.test.ts | 20 ++++- tests/provider-model-aliases.test.ts | 61 +++++++++++++ 32 files changed, 689 insertions(+), 14 deletions(-) create mode 100644 src/cli/alias.ts create mode 100644 src/providers/default-aliases.ts create mode 100644 tests/alias-management-api.test.ts create mode 100644 tests/provider-model-aliases.test.ts diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index c01f0b303e..d9a7d6ea0e 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -14,6 +14,10 @@ opencodex state. ## Command families +### `ocx alias` + +`ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set [/] ` and `ocx alias rm [/]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider ]`. + - [Lifecycle](/reference/cli/lifecycle/) — setup, proxy and service lifecycle, health, diagnostics, catalog sync, the dashboard, and updates. - [Providers, accounts, and models](/reference/cli/providers-accounts/) — provider configuration, diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 10844a5da5..b79db69fe1 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -29,6 +29,25 @@ uses the fresh-install default: one `openai` forward provider. ## Precedence and defaults +### Provider and model aliases + +Aliases are optional short request names. They never change the native model id sent upstream, and omitting every alias field preserves existing routing exactly. + +```jsonc +{ + "providers": { + "openrouter": { + "alias": "or", + "modelAliases": { "anthropic/claude-opus-5": "opus" }, + "defaultAliases": true + } + }, + "defaultModelAliases": false +} +``` + +Aliases match case-insensitively. A model alias works as `or/opus` or, when globally unique, bare `opus`; an ambiguous bare alias reports its qualified candidates. A provider's `defaultAliases` value overrides `defaultModelAliases`. Built-ins are skipped when multiple models in one provider match the same pattern. + Valid values in `config.json` override built-in defaults. Missing optional fields use the defaults documented on the domain pages. `OPENCODEX_HOME` takes precedence over the default configuration directory. Fields that accept an environment reference, such as `apiKey: "${PROVIDER_API_KEY}"`, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 59fbeca947..37230c1ce7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2115,4 +2115,17 @@ export const de: Record = { "dash.visionAdvancedPopover": "Erweiterte Vision-Einstellungen", "models.newPolicyGlobal": "Neue Modelle zunächst deaktivieren", "models.newPolicyProvider": "Richtlinie für neue Modelle", "models.newPolicy_inherit": "Übernehmen", "models.newPolicy_off": "Aus", "models.newPolicy_on": "An", "models.newBadge": "NEU", "models.newCount": "{count} neu, aus", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 831430011b..bbbc04ae27 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2146,6 +2146,19 @@ export const en = { "models.newPolicyGlobal": "New models start disabled", "models.newPolicyProvider": "New model policy", "models.newPolicy_inherit": "Inherit", "models.newPolicy_off": "Off", "models.newPolicy_on": "On", "models.newBadge": "NEW", "models.newCount": "{count} new, off", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 58acf68ee0..e601fcd2b9 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2103,4 +2103,17 @@ export const fr: Record = { "lab.layer.task_effectiveness": "Efficacité des tâches", "models.newPolicyGlobal": "Désactiver les nouveaux modèles par défaut", "models.newPolicyProvider": "Politique des nouveaux modèles", "models.newPolicy_inherit": "Hériter", "models.newPolicy_off": "Désactivé", "models.newPolicy_on": "Activé", "models.newBadge": "NOUVEAU", "models.newCount": "{count} nouveaux, désactivés", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 41de00b016..9afed94dab 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2136,4 +2136,17 @@ export const ja: Record = { "dash.visionAdvancedPopover": "詳細なビジョン設定", "models.newPolicyGlobal": "新しいモデルを無効で追加", "models.newPolicyProvider": "新しいモデルのポリシー", "models.newPolicy_inherit": "継承", "models.newPolicy_off": "オフ", "models.newPolicy_on": "オン", "models.newBadge": "新着", "models.newCount": "新着 {count} 件、オフ", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9cbe4bebc1..b101d71376 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2137,4 +2137,17 @@ export const ko: Record = { "dash.visionAdvancedPopover": "고급 비전 설정", "models.newPolicyGlobal": "새 모델을 비활성화 상태로 추가", "models.newPolicyProvider": "새 모델 정책", "models.newPolicy_inherit": "상속", "models.newPolicy_off": "끔", "models.newPolicy_on": "켬", "models.newBadge": "신규", "models.newCount": "신규 {count}개, 꺼짐", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b85f5d1f41..fe9febfe8c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2138,4 +2138,17 @@ export const ru: Record = { "dash.visionAdvancedPopover": "Дополнительные настройки изображений", "models.newPolicyGlobal": "Добавлять новые модели выключенными", "models.newPolicyProvider": "Политика новых моделей", "models.newPolicy_inherit": "Наследовать", "models.newPolicy_off": "Выкл.", "models.newPolicy_on": "Вкл.", "models.newBadge": "НОВАЯ", "models.newCount": "Новых: {count}, выкл.", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1b45bcb4f9..f086b4d33b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2138,4 +2138,17 @@ export const tr: Record = { "dash.visionAdvancedPopover": "Gelişmiş görsel ayarları", "models.newPolicyGlobal": "Yeni modeller devre dışı başlasın", "models.newPolicyProvider": "Yeni model ilkesi", "models.newPolicy_inherit": "Devral", "models.newPolicy_off": "Kapalı", "models.newPolicy_on": "Açık", "models.newBadge": "YENİ", "models.newCount": "{count} yeni, kapalı", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 3373460bb7..4b6fb5ff42 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2101,4 +2101,17 @@ export const zhTW: Record = { "dash.visionAdvancedPopover": "進階視覺設定", "models.newPolicyGlobal": "新模型預設停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "繼承", "models.newPolicy_off": "關閉", "models.newPolicy_on": "開啟", "models.newBadge": "新增", "models.newCount": "{count} 個新增,已關閉", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index dfce2addb7..288b5784cb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2136,4 +2136,17 @@ export const zh: Record = { "dash.visionAdvancedPopover": "高级视觉设置", "models.newPolicyGlobal": "新模型默认停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "继承", "models.newPolicy_off": "关闭", "models.newPolicy_on": "开启", "models.newBadge": "新增", "models.newCount": "{count} 个新增,已关闭", + "models.aliases": "Aliases", + "models.aliasesTable": "Alias table", + "models.aliasPrompt": "Provider alias (leave empty to clear)", + "models.modelAliasPrompt": "Model alias (leave empty to clear)", + "models.aliasSaved": "Alias saved", + "models.aliasConflict": "That alias conflicts with an existing name", + "models.editProviderAlias": "Edit provider alias", + "models.editModelAlias": "Edit model alias", + "models.useDefaultAliases": "Use default aliases", + "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliasAuto": "auto", + "models.aliasUser": "user", + "models.aliasStale": "stale", }; diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index bed8492599..a0260e1376 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -24,6 +24,7 @@ export const IconRefresh = (p: P) => (); export const IconPlay = (p: P) => (); export const IconTrash = (p: P) => (); +export const IconPencil = (p: P) => (); export const IconAlert = (p: P) => (); export const IconInfo = (p: P) => (); export const IconSearch = (p: P) => (); diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index b7a298356a..cbfedda5c3 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -4,7 +4,7 @@ import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Switch, Notice, EmptyState, Select, Tooltip } from "../ui"; -import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh } from "../icons"; +import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, IconPencil } from "../icons"; import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; @@ -119,6 +119,12 @@ interface ModelDiscoveryView { recentArrivals: Record>; } +interface AliasView { + providers: Record; + models: Record>; + defaults: { global: boolean; providers: Record }; +} + export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; restartEpoch?: number }) { // Codex app-server staleness (devlog/_fin/260815_gui_codex_restart). Named // appServerState, not catalogState: this file already binds that name to the @@ -246,6 +252,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // freeze the others. const [presets, setPresets] = useState>({}); const [modelDiscovery, setModelDiscovery] = useState(null); + const [aliases, setAliases] = useState({ providers: {}, models: {}, defaults: { global: false, providers: {} } }); + const [showAliases, setShowAliases] = useState(false); const [presetBusy, setPresetBusy] = useState(null); const [v2Loading, setV2Loading] = useState(true); const [v2Busy, setV2Busy] = useState(false); @@ -255,6 +263,48 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [showThreadsCustom, setShowThreadsCustom] = useState(false); const [v2HelpOpen, setV2HelpOpen] = useState(false); const [customModalOpen, setCustomModalOpen] = useState(false); + + const reloadAliases = useCallback(async (signal?: AbortSignal) => { + const response = await fetch(`${apiBase}/api/aliases`, { signal }); + const data = await readJsonIfOk(response); + if (data && !signal?.aborted) setAliases(data); + }, [apiBase]); + useEffect(() => { + const controller = new AbortController(); + void reloadAliases(controller.signal); + return () => controller.abort(); + }, [reloadAliases]); + + const saveProviderAlias = async (provider: string) => { + const entered = window.prompt(t("models.aliasPrompt"), aliases.providers[provider] ?? ""); + if (entered === null) return; + const response = await fetch(`${apiBase}/api/providers/${encodeURIComponent(provider)}/alias`, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ alias: entered.trim() || null }), + }); + if (!response.ok) { publishFeedback(false, t("models.aliasConflict")); return; } + await reloadAliases(); + publishFeedback(true, t("models.aliasSaved")); + }; + + const saveModelAlias = async (provider: string, model: string) => { + const current = aliases.models[provider]?.[model]?.alias ?? ""; + const entered = window.prompt(t("models.modelAliasPrompt"), current); + if (entered === null) return; + const body = entered.trim() ? { set: { [model]: entered.trim() } } : { remove: [model] }; + const response = await fetch(`${apiBase}/api/providers/${encodeURIComponent(provider)}/model-aliases`, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }); + if (!response.ok) { publishFeedback(false, t("models.aliasConflict")); return; } + await reloadAliases(); + publishFeedback(true, t("models.aliasSaved")); + }; + + const setDefaultAliases = async (enabled: boolean, provider?: string) => { + const response = await fetch(`${apiBase}/api/default-aliases`, { + method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled, ...(provider ? { provider } : {}) }), + }); + if (response.ok) await reloadAliases(); + }; const [customModalMode, setCustomModalMode] = useState<"add" | "edit">("add"); const [customModalProvider, setCustomModalProvider] = useState(""); const [customModalId, setCustomModalId] = useState(""); @@ -1168,7 +1218,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; style={{ flex: 1, border: 0, background: "transparent", padding: 0, color: "inherit", cursor: "pointer", textAlign: "left" }} > - {providerDisplaySlug(provider)} + {providerDisplaySlug(provider)} + {aliases.providers[provider] && {aliases.providers[provider]}} {nativeProviderGroup && {t("models.nativeGroupLabel")}} {discoveryFailure && ( 0 && {t("models.newCount", { count: recentForProvider.length })}}
+ + void setDefaultAliases(!(aliases.defaults.providers[provider] ?? aliases.defaults.global), provider)} + label={t("models.useDefaultAliases")} + /> {/* Available on every card, including the native one: the canonical `openai` seed check now admits contextWindow/modelContextWindows as user-owned overlays, and the native accessors only ever narrow the measured window with them. The cap @@ -1374,7 +1431,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; >
void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy} label={m.native ? m.id : m.namespaced} /> + {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} + {aliases.models[provider]?.[m.id]?.source === "builtin" && {t("models.aliasAuto")}} + {m.custom && ( {t("models.customBadge")} @@ -1498,6 +1558,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; void saveModelDiscovery(modelDiscovery.policy === "off" ? "on" : "off")} label={t("models.newPolicyGlobal")} />
)} +
+ void setDefaultAliases(!aliases.defaults.global)} label={t("models.useDefaultAliasesGlobal")} /> + +
{t("models.shadowCallIntercept")} {t("models.shadowCallOriginal", { models: shadowSourceModelBadge(shadowCall?.sourceModels) })} @@ -2079,6 +2143,20 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
{controlsBlock} {collapseControls} + {showAliases && ( +
+
{t("models.aliases")}
+ {Object.entries(aliases.models).flatMap(([provider, rows]) => Object.entries(rows).map(([model, value]) => ( +
+ {provider}/{model} + {value.alias} + {value.source === "builtin" ? t("models.aliasAuto") : t("models.aliasUser")} + {value.stale && {t("models.aliasStale")}} + +
+ )))} +
+ )}
{ // eslint-disable-next-line react-hooks/refs, react/react-compiler -- The hover ref is only read by row event handlers nested in this renderer. diff --git a/src/cli/alias.ts b/src/cli/alias.ts new file mode 100644 index 0000000000..462d98e229 --- /dev/null +++ b/src/cli/alias.ts @@ -0,0 +1,66 @@ +import { CliUsageError, printData, rejectArgs, runtimeRequest, takeFlag, takeOption, type RuntimeApiDeps } from "./runtime-api"; + +const USAGE = `Usage: + ocx alias list [--json] + ocx alias set + ocx alias set / + ocx alias rm [/] + ocx alias defaults [--provider ]`; + +function selector(value: string): { provider: string; model?: string } { + const slash = value.indexOf("/"); + return slash < 0 ? { provider: value } : { provider: value.slice(0, slash), model: value.slice(slash + 1) }; +} + +export async function handleAliasCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + const args = [...argv]; + const action = (args.shift() ?? "list").toLowerCase(); + const wantsJson = takeFlag(args, "--json"); + if (action === "list") { + rejectArgs(args, USAGE); + const result = await runtimeRequest>("/api/aliases", {}, deps); + const lines: string[] = []; + for (const [target, alias] of Object.entries((result.providers ?? {}) as Record)) lines.push(`provider ${target} ${alias} user`); + for (const [provider, rows] of Object.entries((result.models ?? {}) as Record>)) { + for (const [model, value] of Object.entries(rows)) lines.push(`model ${provider}/${model} ${value.alias} ${value.source}`); + } + printData(result, wantsJson, lines.length ? lines : ["No aliases configured."]); + return 0; + } + if (action === "defaults") { + const state = args.shift()?.toLowerCase(); + const provider = takeOption(args, "--provider"); + rejectArgs(args, USAGE); + if (state !== "on" && state !== "off") throw new CliUsageError("defaults requires on or off", USAGE); + const result = await runtimeRequest("/api/default-aliases", { method: "PUT", body: JSON.stringify({ enabled: state === "on", ...(provider ? { provider } : {}) }) }, deps); + printData(result, wantsJson, [`Default aliases ${state}${provider ? ` for ${provider}` : " globally"}.`]); + return 0; + } + const target = args.shift()?.trim(); + if (!target) throw new CliUsageError("alias target is required", USAGE); + const parsed = selector(target); + if (!parsed.provider || parsed.model === "") throw new CliUsageError("target must be provider or provider/native-model-id", USAGE); + if (action === "set") { + const alias = args.shift()?.trim(); + rejectArgs(args, USAGE); + if (!alias) throw new CliUsageError("alias value is required", USAGE); + const path = parsed.model === undefined + ? `/api/providers/${encodeURIComponent(parsed.provider)}/alias` + : `/api/providers/${encodeURIComponent(parsed.provider)}/model-aliases`; + const body = parsed.model === undefined ? { alias } : { set: { [parsed.model]: alias } }; + const result = await runtimeRequest(path, { method: "PUT", body: JSON.stringify(body) }, deps); + printData(result, wantsJson, [`${target} → ${alias}`]); + return 0; + } + if (action === "rm") { + rejectArgs(args, USAGE); + const path = parsed.model === undefined + ? `/api/providers/${encodeURIComponent(parsed.provider)}/alias` + : `/api/providers/${encodeURIComponent(parsed.provider)}/model-aliases`; + const body = parsed.model === undefined ? { alias: null } : { remove: [parsed.model] }; + const result = await runtimeRequest(path, { method: "PUT", body: JSON.stringify(body) }, deps); + printData(result, wantsJson, [`Removed alias for ${target}.`]); + return 0; + } + throw new CliUsageError(`unknown alias action '${action}'`, USAGE); +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 8bf9cbea62..3e07048140 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -430,6 +430,10 @@ const commandRunners: Record = { await handleModels(deps.args.slice(1)); return 0; }, + alias: async deps => { + const { handleAliasCommand } = await import("./alias"); + return await handleAliasCommand(deps.args.slice(1)); + }, combo: async deps => { const { handleComboCommand } = await import("./combo"); return await handleComboCommand(deps.args.slice(1)); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index a5bdd63367..afee044cbc 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -164,6 +164,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "A selection-order change applies from the next unbound request and never moves a bound thread.", ], }, + { + name: "alias", + usage: "ocx alias ...", + summary: "Manage short provider and model names.", + }, { name: "models", aliases: ["model"], diff --git a/src/combos/types.ts b/src/combos/types.ts index d1ac034096..9e8321de70 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -203,6 +203,16 @@ export function comboConfigIssues( } const body = raw as Record; + if (typeof body.alias === "string") { + const alias = body.alias.toLowerCase(); + for (const [providerName, provider] of Object.entries(providers)) { + if (provider.alias?.toLowerCase() === alias) { + issues.push({ path: ["alias"], message: `alias "${body.alias}" is already used by provider "${providerName}"` }); + } + const model = Object.entries(provider.modelAliases ?? {}).find(([, value]) => value.toLowerCase() === alias); + if (model) issues.push({ path: ["alias"], message: `alias "${body.alias}" is already used by model "${providerName}/${model[0]}"` }); + } + } if (body.strategy !== undefined && body.strategy !== "failover" && body.strategy !== "round-robin") { diff --git a/src/config.ts b/src/config.ts index 9420ede9b3..7e1d40be57 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,7 @@ import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; +import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases"; import { MODEL_ADAPTER_OVERRIDE_ALLOWED, OPENAI_PROVIDER_TIER_VERSION, @@ -479,6 +480,9 @@ const fastWireSchema = z.object({ const providerConfigSchema = z.object({ adapter: z.string().min(1), baseUrl: z.string().min(1), + alias: z.string().optional(), + modelAliases: z.record(z.string(), z.string()).optional(), + defaultAliases: z.boolean().optional(), requestPacing: requestPacingSchema.optional().catch(undefined), mcpMaxTools: z.number().int().positive().optional(), mcpMaxSchemaBytes: z.number().int().positive().optional(), @@ -865,6 +869,7 @@ const configSchema = z.object({ ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), + defaultModelAliases: z.boolean().optional(), // A retry can be billable, so absence and malformed hand edits both stay off. emptyCompletionRetry: z.boolean().optional().catch(false), // A malformed hand edit must not silently stop opening the browser: fall back @@ -1794,6 +1799,7 @@ export function loadConfig(): OcxConfig { try { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); + sanitizeAliasesForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); @@ -1865,6 +1871,43 @@ export function loadConfig(): OcxConfig { } } +/** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */ +function sanitizeAliasesForLoad(raw: unknown): void { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return; + const root = raw as Record; + if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return; + const providers = root.providers as Record>; + const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase())); + const claimedProviders = new Set(); + const comboAliases = new Set(Object.values((root.combos as Record | undefined) ?? {}) + .map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean)); + const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record | undefined) ?? {}).map(name => name.toLowerCase())); + for (const provider of Object.values(providers)) { + const alias = provider.alias; + if (typeof alias !== "string" || !isValidProviderName(alias) + || providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase()) + || comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) { + if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json"); + delete provider.alias; + } else claimedProviders.add(alias.toLowerCase()); + if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) { + if (provider.modelAliases !== undefined) delete provider.modelAliases; + continue; + } + const aliases = provider.modelAliases as Record; + const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase())); + const claimed = new Set(); + for (const [id, value] of Object.entries(aliases)) { + const lower = typeof value === "string" ? value.toLowerCase() : ""; + if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower) + || nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) { + console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`); + delete aliases[id]; + } else claimed.add(lower); + } + } +} + /** Refresh the user cost-overlay registry from `config` and return it unchanged. */ function withRefreshedCostOverlays(config: OcxConfig): OcxConfig { refreshUserCostOverlays(config); diff --git a/src/providers/default-aliases.ts b/src/providers/default-aliases.ts new file mode 100644 index 0000000000..ae7914c177 --- /dev/null +++ b/src/providers/default-aliases.ts @@ -0,0 +1,65 @@ +import type { OcxConfig, OcxProviderConfig } from "../types"; + +export const MODEL_ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** Ordered, most-specific-first built-in aliases. Updated with the provider registry. */ +export const DEFAULT_MODEL_ALIASES: ReadonlyArray<{ match: RegExp; alias: string }> = [ + { match: /^claude-opus-5/, alias: "opus" }, + { match: /^claude-sonnet-5/, alias: "sonnet" }, + { match: /^claude-haiku/, alias: "haiku" }, + { match: /^gemini-3(?:\.\d+)?-pro/, alias: "g3p" }, + { match: /^gemini-3(?:\.\d+)?-flash/, alias: "g3f" }, + { match: /^deepseek-v4/, alias: "ds4" }, + { match: /^grok-4/, alias: "grok" }, +]; + +export interface EffectiveModelAlias { alias: string; source: "user" | "builtin" } + +function builtinRule(id: string): { match: RegExp; alias: string } | undefined { + const tail = id.slice(id.lastIndexOf("/") + 1); + return DEFAULT_MODEL_ALIASES.find(rule => rule.match.test(id) || rule.match.test(tail)); +} + +export function defaultAliasesEnabled(config: Pick, provider: OcxProviderConfig): boolean { + return provider.defaultAliases ?? config.defaultModelAliases ?? false; +} + +export function effectiveModelAliases( + config: Pick, + provider: OcxProviderConfig, + knownIds: Iterable, +): Map { + const result = new Map(); + for (const [id, alias] of Object.entries(provider.modelAliases ?? {})) { + if (typeof alias === "string" && MODEL_ALIAS_PATTERN.test(alias)) result.set(id, { alias, source: "user" }); + } + if (!defaultAliasesEnabled(config, provider)) return result; + const claims = new Map(); + for (const id of knownIds) { + if (result.has(id)) continue; + const rule = builtinRule(id); + if (!rule) continue; + const key = rule.alias.toLowerCase(); + claims.set(key, [...(claims.get(key) ?? []), id]); + } + for (const [alias, ids] of claims) { + if (ids.length !== 1) continue; + const id = ids[0]!; + // Catalog drift must skip, never shadow, a native id or explicit user alias. + if ([...knownIds].some(candidate => candidate.toLowerCase() === alias)) continue; + if ([...result.values()].some(value => value.alias.toLowerCase() === alias)) continue; + result.set(id, { alias: builtinRule(id)!.alias, source: "builtin" }); + } + return result; +} + +export function resolveModelAlias( + config: Pick, + provider: OcxProviderConfig, + knownIds: Iterable, + requested: string, +): string | undefined { + const needle = requested.toLowerCase(); + return [...effectiveModelAliases(config, provider, knownIds)] + .find(([, value]) => value.alias.toLowerCase() === needle)?.[0]; +} diff --git a/src/router.ts b/src/router.ts index 231682397d..489451de3e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -32,6 +32,7 @@ import { OPENAI_CODEX_PROVIDER_ID, } from "./providers/openai-tiers"; import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec"; +import { resolveModelAlias } from "./providers/default-aliases"; import { getStaleCached } from "./codex/model-cache"; import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; import { @@ -646,7 +647,14 @@ function routeModelInternal( // slash-containing model ids (e.g. "anthropic/claude-...") fall through when // no such provider exists. if (slash > 0) { - const provName = modelId.slice(0, slash); + const requestedProvider = modelId.slice(0, slash); + const provName = hasOwnProvider(config.providers, requestedProvider) + ? requestedProvider + : Object.entries(config.providers).find(([, provider]) => + typeof provider.alias === "string" && provider.alias.toLowerCase() === requestedProvider.toLowerCase())?.[0]; + if (!provName) { + // A genuine slash-containing native model id still falls through unchanged. + } else { if (provName === LEGACY_CHATGPT_PROVIDER_ID || provName === LEGACY_OPENAI_MULTI_PROVIDER_ID) { throw new Error(`No provider configured for model: ${modelId}`); } @@ -662,14 +670,20 @@ function routeModelInternal( } // Codex-facing alias ids (`provider/vendor-model`) decode back to the native // slash id via an exact known-id lookup; raw full-slash selectors keep working. + const requestedModel = modelId.slice(slash + 1); + const decoded = decodeRoutedModelIdOrThrow(requestedModel, known); + const nativeModel = known.includes(decoded) + ? decoded + : resolveModelAlias(config, prov, known, requestedModel) ?? decoded; return routeResult( provName, prov, - decodeRoutedModelIdOrThrow(modelId.slice(slash + 1), known), + nativeModel, "explicit-provider", "explicit-provider-namespace", ); } + } } if (isBareOpenAiFamilyModel(modelId)) { @@ -699,6 +713,24 @@ function routeModelInternal( } } + const aliasMatches: Array<{ provider: string; model: string; qualified: string }> = []; + for (const [provName, prov] of activeProviderEntries(config)) { + const known = knownModelIdsForProvider(provName, prov, config); + const native = resolveModelAlias(config, prov, known, modelId); + if (native) aliasMatches.push({ + provider: provName, + model: native, + qualified: `${prov.alias || provName}/${modelId}`, + }); + } + if (aliasMatches.length > 1) { + throw new Error(`model alias '${modelId}' is ambiguous: ${aliasMatches.map(match => match.qualified).sort().join(", ")}`); + } + if (aliasMatches[0]) { + const match = aliasMatches[0]; + return routeResult(match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias"); + } + if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) { throw new Error(`No provider configured for model: ${modelId}`); } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d10cc09287..0d62f232c9 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -706,6 +706,9 @@ export function safeConfigDTO(config: OcxConfig): unknown { } for (const key of [ "defaultModel", + "alias", + "modelAliases", + "defaultAliases", "disabled", "allowPrivateNetwork", "authMode", @@ -757,6 +760,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { port: config.port, hostname: config.hostname ?? "127.0.0.1", defaultProvider: config.defaultProvider, + defaultModelAliases: config.defaultModelAliases, codexAutoStart: codexAutoStartEnabled(config), websockets: config.websockets, // The GUI's browser-open toggle reads and writes this; absent means the diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 3e7f4515d1..db084df490 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -120,6 +120,7 @@ async function handleChatCompletionsWithBudget( logCtx.model = route.modelId; logCtx.providerAdapter = route.provider.adapter; logCtx.requestedModel = requestedModel; + if (route.routeReason === "model-alias" || route.modelId !== requestedModel && requestedModel.includes("/")) logCtx.requestedAlias = requestedModel; logCtx.provider = route.providerName; logCtx.routeDecision = route.routeDecision; settledRoute = route; diff --git a/src/server/index.ts b/src/server/index.ts index f59b4d0ae3..ca25405650 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ import { markActivity } from "../lib/sidecar-tracker"; +import { knownModelIdsForProvider } from "../router"; import { buildWarmupCompletionFrames, buildWsErrorFrame, @@ -1189,12 +1190,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server nativeModelRow(id)), ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)), - ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ - id: m.alias ?? `${m.provider}/${m.id}`, - object: "model", - created: 0, - owned_by: m.owned_by ?? m.provider, - ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), + ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + const provider = config.providers[m.provider]; + const effective = provider + ? (await import("../providers/default-aliases")).effectiveModelAliases( + config, + provider, + knownModelIdsForProvider(m.provider, provider, config), + ).get(m.id) + : undefined; + return { + id: m.alias ?? `${m.provider}/${m.id}`, + object: "model", + created: 0, + owned_by: m.owned_by ?? m.provider, + ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), + ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), + }; })), ]; return jsonResponse({ object: "list", data }, 200, req, policy); diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 1eaecef8a6..42c8641cdf 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -99,6 +99,8 @@ import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec"; import { knownModelIdsForProvider } from "../../router"; +import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases"; +import { comboPublicModelId } from "../../combos/types"; import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; @@ -240,6 +242,89 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise = {}; + const models: Record> = {}; + for (const [name, provider] of Object.entries(config.providers)) { + if (provider.alias) providers[name] = provider.alias; + const known = knownModelIdsForProvider(name, provider, config); + const knownSet = new Set(known); + const rows: Record = {}; + for (const [id, value] of effectiveModelAliases(config, provider, new Set([...known, ...Object.keys(provider.modelAliases ?? {})]))) { + rows[id] = { ...value, ...(!knownSet.has(id) ? { stale: true } : {}) }; + } + if (Object.keys(rows).length) models[name] = rows; + } + return jsonResponse({ providers, models, defaults: { + global: config.defaultModelAliases ?? false, + providers: Object.fromEntries(Object.entries(config.providers).filter(([, p]) => p.defaultAliases !== undefined).map(([n, p]) => [n, p.defaultAliases])), + } }); + } + + const providerAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/alias$/); + if (providerAliasMatch && req.method === "PUT") { + const name = decodeURIComponent(providerAliasMatch[1]!); + const provider = config.providers[name]; + if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config); + let raw: unknown; + try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!isPlainRecord(raw) || (raw.alias !== null && typeof raw.alias !== "string")) return jsonResponse({ error: "alias must be a string or null" }, 400, req, config); + const alias = typeof raw.alias === "string" ? raw.alias.trim() : null; + if (alias && !isValidProviderName(alias)) return jsonResponse({ error: "invalid provider alias" }, 400, req, config); + const lower = alias?.toLowerCase(); + const collision = lower && Object.entries(config.providers).find(([other, p]) => + other !== name && (other.toLowerCase() === lower || p.alias?.toLowerCase() === lower)); + const comboCollision = lower && Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower); + const accountCollision = lower && Object.keys(config.codexAccountNamespaces ?? {}).find(value => value.toLowerCase() === lower); + if (collision || comboCollision || accountCollision) return jsonResponse({ error: `alias conflicts with '${collision?.[0] ?? comboCollision?.[0] ?? accountCollision}'` }, 409, req, config); + if (alias) provider.alias = alias; else delete provider.alias; + persistConfig(config); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, provider: name, alias, catalogRefresh }); + } + + const modelAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-aliases$/); + if (modelAliasMatch && req.method === "PUT") { + const name = decodeURIComponent(modelAliasMatch[1]!); + const provider = config.providers[name]; + if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config); + let raw: unknown; + try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!isPlainRecord(raw) || (raw.set !== undefined && !isPlainRecord(raw.set)) || (raw.remove !== undefined && !Array.isArray(raw.remove))) return jsonResponse({ error: "invalid model alias update" }, 400, req, config); + const next = { ...(provider.modelAliases ?? {}) }; + for (const id of (raw.remove ?? []) as unknown[]) if (typeof id === "string") delete next[id]; + const conflicts: Array<{ alias: string; heldBy: string }> = []; + const known = knownModelIdsForProvider(name, provider, config); + for (const [id, value] of Object.entries((raw.set ?? {}) as Record)) { + if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value)) return jsonResponse({ error: `invalid model alias for '${id}'` }, 400, req, config); + const lower = value.toLowerCase(); + const heldBy = Object.entries(next).find(([other, alias]) => other !== id && alias.toLowerCase() === lower)?.[0] + ?? known.find(native => native.toLowerCase() === lower) + ?? Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower)?.[0]; + if (heldBy || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) conflicts.push({ alias: value, heldBy: heldBy ?? "native OpenAI family" }); + else next[id] = value; + } + if (conflicts.length) return jsonResponse({ error: "model alias collision", conflicts }, 409, req, config); + provider.modelAliases = next; + persistConfig(config); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, aliases: next, catalogRefresh }); + } + + if (url.pathname === "/api/default-aliases" && req.method === "PUT") { + let raw: unknown; + try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!isPlainRecord(raw) || typeof raw.enabled !== "boolean" || (raw.provider !== undefined && typeof raw.provider !== "string")) return jsonResponse({ error: "enabled must be boolean" }, 400, req, config); + if (typeof raw.provider === "string") { + const provider = config.providers[raw.provider]; + if (!provider) return jsonResponse({ error: `provider '${raw.provider}' not found` }, 404, req, config); + provider.defaultAliases = raw.enabled; + } else config.defaultModelAliases = raw.enabled; + persistConfig(config); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ ok: true, catalogRefresh }); + } + if (url.pathname === "/api/catalog" && req.method === "GET") { const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog"); const catalog = readCatalog(readCodexCatalogPath()); diff --git a/src/server/request-log.ts b/src/server/request-log.ts index bc56319c98..dc10db7893 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -66,6 +66,8 @@ export interface RequestLogContext { /** Stable non-PII Codex Pool account identity for durable usage attribution. */ accountLogLabel?: string; requestedModel?: string; + /** User-facing alias selector when routing resolved one; native model remains `model`. */ + requestedAlias?: string; /** Original bare helper model when the opt-in shadow-call route rewrote this request. */ shadowCallRewrittenFrom?: string; /** Internal structural combo identity; omitted from RequestLogEntry/JSONL. */ @@ -145,6 +147,7 @@ export interface RequestLogEntry { /** Best-effort chat/session correlation for Logs grouping (#330). */ conversationId?: string; requestedModel?: string; + requestedAlias?: string; /** Original bare helper model when the opt-in shadow-call route rewrote this request. */ shadowCallRewrittenFrom?: string; requestedEffort?: string; @@ -260,6 +263,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R ? { accountLogLabel: entry.accountLogLabel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), + ...(entry.requestedAlias ? { requestedAlias: entry.requestedAlias } : {}), ...(entry.shadowCallRewrittenFrom ? { shadowCallRewrittenFrom: entry.shadowCallRewrittenFrom } : {}), @@ -380,6 +384,7 @@ export function addRequestLog(entry: RequestLogEntry) { ...(entry.conversationId ? { conversationId: entry.conversationId } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), + ...(entry.requestedAlias ? { requestedAlias: entry.requestedAlias } : {}), ...(entry.shadowCallRewrittenFrom ? { shadowCallRewrittenFrom: entry.shadowCallRewrittenFrom } : {}), @@ -969,6 +974,7 @@ export function addFinalRequestLog( : {}), ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}), ...(logCtx.requestedModel ? { requestedModel: logCtx.requestedModel } : {}), + ...(logCtx.requestedAlias ? { requestedAlias: logCtx.requestedAlias } : {}), ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}), ...(logCtx.requestedEffort ? { requestedEffort: logCtx.requestedEffort } : {}), ...(logCtx.effectiveEffort ? { effectiveEffort: logCtx.effectiveEffort } : {}), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e7c50efd24..627a427563 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1672,6 +1672,7 @@ async function applyFinalRouteRequestNormalization(args: { logCtx.provider = route.providerName; logCtx.providerAdapter = route.provider.adapter; logCtx.routeDecision = route.routeDecision; + if (route.routeReason === "model-alias" || route.modelId !== responseModelId && responseModelId.includes("/")) logCtx.requestedAlias = responseModelId; if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") { parsed.stream = false; diff --git a/src/types/config.ts b/src/types/config.ts index a02b3d0398..c2ea0e0fb9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -272,6 +272,8 @@ export interface OcxConfig { }>; recentArrivals?: Record>; }; + /** Enable the shipped model alias patterns for providers without an override. */ + defaultModelAliases?: boolean; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ openaiProviderTierVersion?: 1 | 2; /** One-time migration marker for Antigravity's static-catalog defaults. */ diff --git a/src/types/provider.ts b/src/types/provider.ts index 612d34fe09..d44232dbdc 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -136,6 +136,12 @@ export type TierDecision = * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. */ export interface OcxProviderConfig { + /** Optional short provider namespace used only at request/catalog presentation time. */ + alias?: string; + /** Native model id -> short, slash-free request alias. */ + modelAliases?: Record; + /** Override the global built-in model-alias switch for this provider. */ + defaultAliases?: boolean; adapter: string; /** * Codex tool calling mode for routed models. diff --git a/src/usage/log.ts b/src/usage/log.ts index 65ebf26d6a..3406ba9ba1 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -69,6 +69,7 @@ export interface PersistedUsageAttempt { } export interface PersistedUsageEntry { + requestedAlias?: string; requestId: string; timestamp: number; provider: string; diff --git a/tests/alias-management-api.test.ts b/tests/alias-management-api.test.ts new file mode 100644 index 0000000000..512f1aea58 --- /dev/null +++ b/tests/alias-management-api.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; + +function config(): OcxConfig { + return { port: 10100, defaultProvider: "alpha", apiKeys: [{ id: "test", name: "test", key: "test-key", createdAt: new Date(0).toISOString() }], providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.test/v1", models: ["m1", "m2"] }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.test/v1", models: ["b1"] }, + } }; +} + +async function request(c: OcxConfig, path: string, body?: unknown) { + const req = new Request(`http://localhost${path}`, { + method: body === undefined ? "GET" : "PUT", + headers: { ...(body === undefined ? {} : { "content-type": "application/json" }), host: "localhost" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), c, { + saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory(), + }); +} + +test("alias management routes persist partial updates and expose the effective view", async () => { + const c = config(); + expect((await request(c, "/api/providers/alpha/alias", { alias: "a" }))?.status).toBe(200); + expect((await request(c, "/api/providers/alpha/model-aliases", { set: { m1: "one" } }))?.status).toBe(200); + expect((await request(c, "/api/default-aliases", { enabled: true, provider: "alpha" }))?.status).toBe(200); + const response = await request(c, "/api/aliases"); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ providers: { alpha: "a" }, models: { alpha: { m1: { alias: "one", source: "user" } } }, defaults: { providers: { alpha: true } } }); +}); + +test("alias write routes reject case-insensitive collisions", async () => { + const c = config(); + expect((await request(c, "/api/providers/alpha/alias", { alias: "BETA" }))?.status).toBe(409); + expect((await request(c, "/api/providers/alpha/model-aliases", { set: { m1: "m2" } }))?.status).toBe(409); + expect((await request(c, "/api/providers/alpha/model-aliases", { set: { m1: "same", m2: "SAME" } }))?.status).toBe(409); + expect((await request(c, "/api/providers/alpha/model-aliases", { set: { m1: "gpt-5.6-sol" } }))?.status).toBe(409); +}); diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index 8f360f39f5..ff178c0321 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -372,10 +372,10 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 7 + 10 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 7 + 13 + 2 + 2 convergence calls", () => { const counts = Object.fromEntries([ ["provider-routes.ts", 7], - ["model-routes.ts", 10], + ["model-routes.ts", 13], ["combo-routes.ts", 2], ["agent-settings-routes.ts", 2], ].map(([file, expected]) => { @@ -387,7 +387,7 @@ test("the route inventory contains exactly the specified 7 + 10 + 2 + 2 converge })); expect(counts).toEqual({ "provider-routes.ts": 7, - "model-routes.ts": 10, + "model-routes.ts": 13, "combo-routes.ts": 2, "agent-settings-routes.ts": 2, }); @@ -396,11 +396,23 @@ test("the route inventory contains exactly the specified 7 + 10 + 2 + 2 converge test("both model-discovery write paths converge the Codex catalog", () => { const source = readFileSync(join(import.meta.dir, "..", "src", "server", "management", "model-routes.ts"), "utf8"); const settings = source.slice(source.indexOf('url.pathname === "/api/model-discovery" && req.method === "PUT"'), source.indexOf('url.pathname === "/api/model-discovery/acknowledge"')); - const acknowledge = source.slice(source.indexOf('url.pathname === "/api/model-discovery/acknowledge"'), source.indexOf('url.pathname === "/api/catalog"')); + // Sliced to the NEXT route rather than to /api/catalog: the alias routes (#2463) landed + // between them, so a fixed far boundary would swallow their convergence calls and count + // them as this route's. + const acknowledge = source.slice(source.indexOf('url.pathname === "/api/model-discovery/acknowledge"'), source.indexOf('url.pathname === "/api/aliases"')); expect(settings.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); expect(acknowledge.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); }); +test("all three alias write routes converge the Codex catalog", () => { + const source = readFileSync(join(import.meta.dir, "..", "src", "server", "management", "model-routes.ts"), "utf8"); + for (const marker of ["providerAliasMatch && req.method", "modelAliasMatch && req.method", 'url.pathname === "/api/default-aliases"']) { + const start = source.indexOf(marker); + expect(start).toBeGreaterThan(-1); + expect(source.slice(start, source.indexOf("\n if (", start + 1))).toContain("await convergeCodexCatalog()"); + } +}); + /** * Same discipline as the reload-route assertion below: the count above went 6 -> 8 for the * model-preset routes (#2465), and a bare count that only ever rises stops being a contract. diff --git a/tests/provider-model-aliases.test.ts b/tests/provider-model-aliases.test.ts new file mode 100644 index 0000000000..dda94842a8 --- /dev/null +++ b/tests/provider-model-aliases.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { effectiveModelAliases } from "../src/providers/default-aliases"; +import { routeModel } from "../src/router"; +import type { OcxConfig } from "../src/types"; + +function config(): OcxConfig { + return { + port: 10100, + defaultProvider: "alpha", + providers: { + alpha: { adapter: "openai-chat", baseUrl: "https://alpha.test/v1", alias: "a", models: ["native", "vendor/claude-opus-5-202608"] , modelAliases: { native: "tiny" } }, + beta: { adapter: "openai-chat", baseUrl: "https://beta.test/v1", alias: "b", models: ["other"] }, + }, + }; +} + +describe("provider and model aliases", () => { + test("qualified provider and model aliases resolve to the native upstream id", () => { + expect(routeModel(config(), "a/tiny")).toMatchObject({ providerName: "alpha", modelId: "native" }); + }); + + test("qualified canonical provider and native model win before aliases", () => { + const c = config(); + c.providers.alpha.modelAliases = { other: "native" }; + expect(routeModel(c, "alpha/native")).toMatchObject({ providerName: "alpha", modelId: "native" }); + }); + + test("bare alias wins only immediately before defaultProvider fallback", () => { + expect(routeModel(config(), "tiny")).toMatchObject({ providerName: "alpha", modelId: "native", routeReason: "model-alias" }); + const absent = config(); + delete absent.providers.alpha.modelAliases; + expect(routeModel(absent, "tiny")).toMatchObject({ providerName: "alpha", modelId: "tiny", routeReason: "default-provider" }); + }); + + test("native family and configured native model steps cannot be shadowed", () => { + const c = config(); + c.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" }; + c.providers.alpha.modelAliases = { native: "gpt-5.6-sol", "vendor/claude-opus-5-202608": "other" }; + expect(routeModel(c, "gpt-5.6-sol").providerName).toBe("openai"); + expect(routeModel(c, "other")).toMatchObject({ providerName: "beta", modelId: "other" }); + }); + + test("bare ambiguity is deterministic and qualified aliases remain usable", () => { + const c = config(); + c.providers.beta.modelAliases = { other: "tiny" }; + expect(() => routeModel(c, "tiny")).toThrow("model alias 'tiny' is ambiguous: a/tiny, b/tiny"); + expect(routeModel(c, "b/tiny")).toMatchObject({ providerName: "beta", modelId: "other" }); + }); + + test("a built-in alias is disabled when multiple ids in one provider claim it", () => { + const provider = { adapter: "openai-chat", baseUrl: "https://x.test", defaultAliases: true, models: ["anthropic/claude-opus-5-a", "anthropic/claude-opus-5-b"] }; + expect([...effectiveModelAliases({ defaultModelAliases: false }, provider, provider.models)]).toEqual([]); + }); + + test("an unambiguous aggregator tail receives its built-in alias", () => { + const provider = { adapter: "openai-chat", baseUrl: "https://x.test", defaultAliases: true, models: ["anthropic/claude-opus-5-a"] }; + expect([...effectiveModelAliases({ defaultModelAliases: false }, provider, provider.models)]).toEqual([ + ["anthropic/claude-opus-5-a", { alias: "opus", source: "builtin" }], + ]); + }); +}); From 07401301a14562d5542d42e1b428d164d58b2ef9 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:55:52 +0900 Subject: [PATCH 046/336] feat(lifecycle): bound concurrent tool-recall sessions by logical lane (#820) (#2611) * fix(server): bound logical session turn lanes * fix(lanes): key a session lane on parent+thread, not the parent alone A parallel subagent fan-out is Codex's normal shape, and every child of one parent carries the SAME x-codex-parent-thread-id - that is exactly what codexPoolAffinityKey keys on so a fan-out pins to one account. A lane derived the same way inherits that coalescing, so the second and third sibling of any fan-out were rejected with 503. Reproduced before fixing: two thread_spawn requests differing only in thread-id resolved to one lane and the second tryAdmitTurn returned null. A lane wants the MOST specific identity, which is the opposite of what affinity wants. Keyed on the pair, the parent qualifies the lane rather than defining it: siblings separate, while two overlapping turns of one conversation still share a lane - the protocol rule this boundary exists to enforce. Both components are already fixed-size digests, so retained lane bytes stay bounded. Falsified: restoring the parent-first derivation fails the new test with 'Expected 3 distinct lanes, received 1'. --- .../260826_session_lane_bounds/010_design.md | 104 ++++++++ src/server/index.ts | 14 +- src/server/lifecycle.ts | 42 +++- src/server/request-log-conversation.ts | 21 ++ src/server/ws-bridge.ts | 16 +- tests/session-lane-recall-harness.test.ts | 237 ++++++++++++++++++ 6 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 devlog/_plan/260826_session_lane_bounds/010_design.md create mode 100644 tests/session-lane-recall-harness.test.ts diff --git a/devlog/_plan/260826_session_lane_bounds/010_design.md b/devlog/_plan/260826_session_lane_bounds/010_design.md new file mode 100644 index 0000000000..394b3f1605 --- /dev/null +++ b/devlog/_plan/260826_session_lane_bounds/010_design.md @@ -0,0 +1,104 @@ +# #820 session-lane bounds design + +## Current facts + +- `src/server/lifecycle.ts:32-40` owns a 256-turn global admission gate, an + `AbortController -> ActiveTurnLease` map, and the admitted-lease set. +- `src/server/lifecycle.ts:160-218` admits a lease without logical-session metadata and + lets that lease bind any number of abort controllers. +- `src/server/lifecycle.ts:205-215` releases controller mappings and the global gate only + when the lease settles. `src/server/lifecycle.ts:373-386` keeps that ownership through + stream terminal/cancel cleanup. +- `src/server/index.ts:699-716` admits HTTP turns without passing request identity, so two + overlapping recalls from the same thread are indistinguishable from independent turns. +- `src/server/request-log-conversation.ts:58-78` documents the available identity order: + parent thread, true thread/Cursor conversation, then session; it also warns that a + synthesized/shared session id must not coalesce distinct conversations. +- `devlog/_fin/260801_zero_leak_state_stores/035_plan.md:672-683` explicitly deferred #820 + scheduler/session-lane architecture. +- `src/server/lifecycle.ts` is a core-path module and therefore may not directly or + transitively import `src/lab/` (`AGENTS.md:21-52`). + +## Scope and exclusions + +This unit adds fail-fast logical-session lanes to the existing lifecycle admission +boundary and a deterministic 32/64-session recall harness. It does not add a scheduler, +same-session queue, weighted memory permits, account-load routing, relay redesign, or Lab +dependency. Missing/unsafe session identity remains an independent request lane rather +than risking false cross-session serialization. + +## Structural decision + +### Context + +The global gate bounds total active turns, but controller-keyed ownership cannot enforce +the protocol rule that one logical session has at most one active model turn. Retaining raw +session headers as map keys would also make lane metadata proportional to caller-controlled +header length. + +### Rejected alternatives + +- Key directly by `AbortController`: current behavior; it cannot recognize a second recall + for the same logical session. +- Queue a second same-session turn: issue #820 specifies zero queued turns by default and a + full scheduler was explicitly deferred. +- Store raw header/session values: this makes retained lane memory input-sized and exposes + sensitive identifiers through diagnostics. +- Derive identity inside `lifecycle.ts` from `Request`: this couples the generic lifecycle + owner to HTTP and does not cover WebSocket frame lanes cleanly. + +### Chosen move + +Add a fixed-size opaque `SessionLaneId` derived at ingress from the strongest safe request +identity. `tryAdmitTurn(laneId?)` atomically claims the lane before acquiring the existing +global permit and releases it idempotently with the lease. No identity means no shared lane. +The lane registry stores only fixed-size digests and exposes count/high-water/rejection +metrics, so retained metadata is bounded by the already-bounded active-turn population and +fixed bytes per lane. + +The HTTP listener derives the lane synchronously before handler work. WebSocket response +frames retain that fixed-size lane from their upgrade request without introducing an +`await` in server startup. The dependency direction remains `server/index -> lifecycle`; +`lifecycle` does not import request parsing or optional subsystems. + +### Consequences + +- Independent lanes remain parallel up to the existing global cap; overlapping turns on + one identified lane fail with the existing structured `server_busy` response. +- Lane identifiers are process-local opaque digests; raw session/thread values are neither + retained nor reported. +- Anonymous requests retain current independent-turn behavior because guessing identity + would be less protocol-safe than leaving them uncoordinated. +- This is admission, not scheduling: there are no waiters and no queue memory. + +## Harness contract + +The harness drives 32 sustained and 64 burst independent lane sessions through actual +lifecycle leases and canonical tool-call event/SSE translation. Each session performs a +model tool-call terminal, external tool-result recall, and a second model terminal while +barriers keep all sessions concurrent. It asserts: + +- all independent sessions are simultaneously admitted; +- a same-lane overlapping recall is rejected and never queued; +- call/item/output indexes and tool namespace/name/arguments remain session-local; +- all leases and lane bytes return to zero after each wave; +- lane high-water bytes are a fixed linear envelope at 32 and 64 sessions. + +The memory oracle uses lifecycle-owned byte accounting as the deterministic bound and also +records Bun RSS/heap/external/array-buffer deltas as observational measurements. Removing +the fixed lane bound must make the deterministic memory assertion fail; RSS alone is not a +valid mutation oracle because allocator retention is nondeterministic. + +## Verification and falsification + +```bash +bun test tests/session-lane-recall-harness.test.ts +bun x tsc --noEmit +bun test tests/core-lab-boundary.test.ts tests/*lifecycle*.test.ts \ + tests/*translator-budget*.test.ts tests/session-lane-recall-harness.test.ts +``` + +For every new behavioral test, temporarily revert the production hunk it covers, run the +focused test and record its failing tail, then restore the hunk and rerun green. The memory +test must additionally be mutated to remove/expand the fixed per-lane accounting bound and +must fail on its independent expected envelope. diff --git a/src/server/index.ts b/src/server/index.ts index ca25405650..9c17fa9b0e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -110,6 +110,7 @@ import { type RequestLogContext, type RequestLogEntry, } from "./request-log"; +import { sessionLaneIdFromRequest } from "./request-log-conversation"; export { addFinalRequestLog, filterRequestLogs, @@ -702,7 +703,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server Promise, ): Promise { - const lease = tryAdmitTurn(); + const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers)); if (!lease) return serverBusyResponse(req, "active turns", policy); let response: Response; try { @@ -897,7 +898,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server(); const admittedTurns = new Set(); +const activeSessionLanes = new Set(); +let sessionLanePeak = 0; +let sessionLaneAdmitted = 0; +let sessionLaneRejected = 0; const knownTurnControllers = new WeakSet(); let turnReleaseMisses = 0; let shutdownDraining = false; @@ -149,6 +156,10 @@ export function resetLifecycleDrainStateForTests(): void { temporaryDrainOwners.clear(); nativeMainDrainOwners.clear(); nativeMainTurns.clear(); + activeSessionLanes.clear(); + sessionLanePeak = 0; + sessionLaneAdmitted = 0; + sessionLaneRejected = 0; nativeMainSelections = 0; for (const resolve of temporaryDrainWaiters) resolve(); temporaryDrainWaiters.clear(); @@ -157,10 +168,22 @@ export function resetLifecycleDrainStateForTests(): void { serverStartupReleaseFlights = new WeakMap, Promise>(); releaseServerStartupLifecycleImpl = releaseNativeMainStartupLifecycle; } -export function tryAdmitTurn(): ActiveTurnLease | null { +export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null { if (isDraining()) return null; + const opaqueSessionLaneId = sessionLaneId + ? createHash("sha256").update(sessionLaneId).digest("hex").slice(0, SESSION_LANE_ID_BYTES) + : undefined; + if (opaqueSessionLaneId && (activeSessionLanes.has(opaqueSessionLaneId) || activeSessionLanes.size >= MAX_ACTIVE_SESSION_LANES)) { + sessionLaneRejected += 1; + return null; + } const gateLease = turnGate.tryAcquire(); if (!gateLease) return null; + if (opaqueSessionLaneId) { + activeSessionLanes.add(opaqueSessionLaneId); + sessionLaneAdmitted += 1; + sessionLanePeak = Math.max(sessionLanePeak, activeSessionLanes.size); + } const controllers = new Set(); let active = true; let transferred = false; @@ -211,6 +234,7 @@ export function tryAdmitTurn(): ActiveTurnLease | null { } controllers.clear(); nativeMainTurns.delete(lease); + if (opaqueSessionLaneId) activeSessionLanes.delete(opaqueSessionLaneId); gateLease.release(); }, }; @@ -261,6 +285,22 @@ export function unregisterTurn(ac: AbortController): void { } export function isDraining(): boolean { return shutdownDraining || temporaryDrainOwners.size > 0; } export function getActiveTurnCount(): number { return turnGate.metrics().active; } +export interface SessionLaneMetrics { + active: number; + peak: number; + admitted: number; + rejected: number; + retainedBytes: number; +} +export function sessionLaneMetrics(): SessionLaneMetrics { + return { + active: activeSessionLanes.size, + peak: sessionLanePeak, + admitted: sessionLaneAdmitted, + rejected: sessionLaneRejected, + retainedBytes: activeSessionLanes.size * SESSION_LANE_ID_BYTES, + }; +} export function getNativeMainProfileRequestCount(): number { return nativeMainSelections + nativeMainTurns.size; } diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 3fae35ef1a..ed1e2f33d4 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -61,6 +61,27 @@ export function sessionIdHeaderFromRequest(headers: Headers): string | null { return headers.get("session_id") ?? headers.get("session-id"); } +/** + * Fixed-size logical turn lane (#820). + * + * A lane must be as SPECIFIC as the identity available, which is the opposite of what + * `codexPoolAffinityKey` wants. Affinity deliberately prefers the parent thread so a whole + * subagent fan-out pins to one account; a lane keyed that way would put every parallel + * subagent of one parent into a single lane and reject all but the first with 503 — the + * fan-out is the normal case, not an abuse. + * + * So the parent is a QUALIFIER, never the lane on its own when a child thread exists: the + * pair separates siblings while still keeping one conversation's overlapping turns together. + */ +export function sessionLaneIdFromRequest(headers: Headers): string | undefined { + const parent = normalizeLogConversationId(headers.get("x-codex-parent-thread-id")); + const thread = normalizeLogConversationId(headers.get("thread-id")); + const session = normalizeLogConversationId(sessionIdHeaderFromRequest(headers)); + const specific = thread ?? session; + if (parent && specific) return `${parent}\u0000${specific}`; + return specific ?? parent; +} + function firstSanitizedConversationId( ...values: Array ): string | undefined { diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index c593861d27..1dde3c39d6 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -34,6 +34,8 @@ export interface WsData { authContext?: CodexAuthContext; // last resolved account decision for observability/registry cleanup cancel?: () => void; // cancels the in-flight stream reader/fetch turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns + /** Fixed-size logical session lane derived at the HTTP upgrade boundary. */ + sessionLaneId?: string; /** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */ kind?: "responses" | "live-sideband"; liveUpstream?: WebSocket; @@ -60,10 +62,20 @@ export interface WsData { * A test that only asserts "the socket opened" would still pass if the admission * were dropped from the payload, so the payload itself is what gets asserted. */ -export function buildResponsesWsData(headers: Headers, admission: DataPlaneAdmission, admissionLease?: AdmissionReservation>): WsData { +export function buildResponsesWsData( + headers: Headers, + admission: DataPlaneAdmission, + admissionLease?: AdmissionReservation>, + sessionLaneId?: string, +): WsData { // Auth is handshake-time only on this path: the per-frame contexts have no // request headers left to re-resolve from, so the decision rides along here. - return { headers, admission, ...(admissionLease ? { admissionLease } : {}) }; + return { + headers, + admission, + ...(admissionLease ? { admissionLease } : {}), + ...(sessionLaneId ? { sessionLaneId } : {}), + }; } export class WsSendDroppedError extends Error { diff --git a/tests/session-lane-recall-harness.test.ts b/tests/session-lane-recall-harness.test.ts new file mode 100644 index 0000000000..a2bc5044a9 --- /dev/null +++ b/tests/session-lane-recall-harness.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; +import { saveConfig } from "../src/config"; +import { + MAX_ACTIVE_SESSION_LANES, + SESSION_LANE_ID_BYTES, + resetLifecycleDrainStateForTests, + sessionLaneMetrics, + tryAdmitTurn, + type ActiveTurnLease, +} from "../src/server/lifecycle"; +import { sessionLaneIdFromRequest } from "../src/server/request-log-conversation"; +import { startServer } from "../src/server"; +import type { AdapterEvent, OcxConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "key" }; + +interface ProtocolCall { + id: string; + name: string; + arguments: string; +} + +function chatSse(session: number, round: number, callCount: number): string { + const frames: string[] = []; + for (let index = 0; index < callCount; index += 1) { + frames.push(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index, + id: `call_s${session}_r${round}_t${index}`, + function: { name: `mcp__lane_${session}__tool_${index}`, arguments: `{"session":${session},` }, + }] } }] })}\n\n`); + } + for (let index = callCount - 1; index >= 0; index -= 1) { + frames.push(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index, + function: { arguments: `"round":${round},"tool":${index}}` }, + }] } }] })}\n\n`); + } + frames.push(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [] }, finish_reason: "tool_calls" }] })}\n\n`); + frames.push("data: [DONE]\n\n"); + return frames.join(""); +} + +async function parseCalls(session: number, round: number): Promise { + const callCount = (session % 8) + 1; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapterProduction(provider)); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(chatSse(session, round, callCount)))) { + events.push(event); + } + const calls: ProtocolCall[] = []; + let current: ProtocolCall | undefined; + for (const event of events) { + if (event.type === "tool_call_start") { + expect(current).toBeUndefined(); + current = { id: event.id, name: event.name, arguments: "" }; + } else if (event.type === "tool_call_delta") { + expect(current).toBeDefined(); + current!.arguments += event.arguments; + } else if (event.type === "tool_call_end") { + expect(current).toBeDefined(); + calls.push(current!); + current = undefined; + } + } + expect(current).toBeUndefined(); + expect(events.at(-1)?.type).toBe("done"); + expect(calls).toHaveLength(callCount); + for (let index = 0; index < calls.length; index += 1) { + expect(calls[index]).toEqual({ + id: `call_s${session}_r${round}_t${index}`, + name: `mcp__lane_${session}__tool_${index}`, + arguments: `{"session":${session},"round":${round},"tool":${index}}`, + }); + expect(JSON.parse(calls[index].arguments)).toEqual({ session, round, tool: index }); + } + return calls; +} + +function memorySnapshot() { + const memory = process.memoryUsage(); + return { + rss: memory.rss, + heapUsed: memory.heapUsed, + external: memory.external, + arrayBuffers: memory.arrayBuffers, + }; +} + +async function runRecallWave(sessionCount: 32 | 64) { + resetLifecycleDrainStateForTests(); + const before = memorySnapshot(); + const leases: ActiveTurnLease[] = []; + for (let session = 0; session < sessionCount; session += 1) { + const lease = tryAdmitTurn(`logical-session-${session}`); + expect(lease).not.toBeNull(); + leases.push(lease!); + } + expect(sessionLaneMetrics()).toMatchObject({ + active: sessionCount, + peak: sessionCount, + retainedBytes: sessionCount * SESSION_LANE_ID_BYTES, + }); + expect(tryAdmitTurn("logical-session-0")).toBeNull(); + + const firstCalls = await Promise.all(Array.from({ length: sessionCount }, (_, session) => parseCalls(session, 1))); + for (const lease of leases) lease.release(); + expect(sessionLaneMetrics().active).toBe(0); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + + const recallLeases = Array.from({ length: sessionCount }, (_, session) => { + const lease = tryAdmitTurn(`logical-session-${session}`); + expect(lease).not.toBeNull(); + return lease!; + }); + const secondCalls = await Promise.all(Array.from({ length: sessionCount }, (_, session) => parseCalls(session, 2))); + for (const lease of recallLeases) lease.release(); + expect(sessionLaneMetrics().active).toBe(0); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + for (let session = 0; session < sessionCount; session += 1) { + expect(new Set([...firstCalls[session], ...secondCalls[session]].map(call => call.id)).size) + .toBe(firstCalls[session].length + secondCalls[session].length); + } + const after = memorySnapshot(); + const measured = { + sessions: sessionCount, + lanePeakBytes: sessionCount * SESSION_LANE_ID_BYTES, + rssDelta: after.rss - before.rss, + heapUsedDelta: after.heapUsed - before.heapUsed, + externalDelta: after.external - before.external, + arrayBuffersDelta: after.arrayBuffers - before.arrayBuffers, + }; + console.log(`[session-lane-harness] ${JSON.stringify(measured)}`); + return measured; +} + +describe("#820 concurrent tool-recall session harness", () => { + test("the HTTP boundary rejects an overlapping recall on the same logical session", async () => { + resetLifecycleDrainStateForTests(); + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-session-lane-")); + process.env.OPENCODEX_HOME = home; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "forward" }, + }, + } as OcxConfig); + const headers = new Headers({ "content-type": "application/json", session_id: "recall-session" }); + const held = tryAdmitTurn(sessionLaneIdFromRequest(headers)); + const server = startServer(0); + try { + expect(held).not.toBeNull(); + const overlapping = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers, + body: "not-json", + }); + expect(overlapping.status).toBe(503); + expect(await overlapping.json()).toMatchObject({ error: { code: "server_busy" } }); + held?.release(); + const afterRelease = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers, + body: "not-json", + }); + expect(afterRelease.status).toBe(400); + } finally { + held?.release(); + await server.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + test("32 sustained independent sessions preserve protocol isolation within the lane envelope", async () => { + const measured = await runRecallWave(32); + expect(measured.lanePeakBytes).toBe(1024); + }); + + test("64 burst independent sessions preserve protocol isolation at the lane cap", async () => { + const measured = await runRecallWave(64); + expect(MAX_ACTIVE_SESSION_LANES).toBe(64); + expect(measured.lanePeakBytes).toBe(2048); + }); + + test("the 65th identified lane is rejected without allocating lane memory", () => { + resetLifecycleDrainStateForTests(); + const leases = Array.from({ length: 64 }, (_, index) => tryAdmitTurn(`capacity-${index}`)); + expect(leases.every(Boolean)).toBe(true); + expect(tryAdmitTurn("capacity-overflow")).toBeNull(); + expect(sessionLaneMetrics()).toMatchObject({ active: 64, retainedBytes: 2048, rejected: 1 }); + for (const lease of leases) lease?.release(); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + }); + + /** + * The regression this lane derivation exists to avoid (#820). + * + * A parallel subagent fan-out is Codex's normal shape, and every child of one parent + * carries the SAME `x-codex-parent-thread-id` — that is what `codexPoolAffinityKey` + * deliberately keys on, so the whole fan-out pins to one account. A lane keyed the same + * way inherits that coalescing and rejects every sibling after the first with 503. + * + * Keyed on the pair, the parent qualifies the lane instead of defining it: siblings + * separate, while two overlapping turns of ONE conversation still share a lane, which is + * the protocol rule this admission boundary is here to enforce. + */ + test("parallel subagents of one parent take separate lanes, and one conversation still shares one", () => { + resetLifecycleDrainStateForTests(); + const parent = "parent-thread-id"; + const spawn = (threadId: string) => new Headers({ + "x-codex-parent-thread-id": parent, + "x-codex-turn-metadata": JSON.stringify({ subagent_kind: "thread_spawn" }), + "thread-id": threadId, + }); + + const siblingLanes = ["child-a", "child-b", "child-c"].map(id => sessionLaneIdFromRequest(spawn(id))); + expect(new Set(siblingLanes).size).toBe(3); + const siblingLeases = siblingLanes.map(lane => tryAdmitTurn(lane)); + expect(siblingLeases.every(Boolean)).toBe(true); + + // Same parent AND same child thread: one logical conversation, so the second overlapping + // turn is refused rather than admitted alongside the first. + expect(tryAdmitTurn(sessionLaneIdFromRequest(spawn("child-a")))).toBeNull(); + + for (const lease of siblingLeases) lease?.release(); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + }); +}); From 82dbb1a3a7f474fbf24105c7a929d1de5ee4d46b Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:57:33 +0900 Subject: [PATCH 047/336] fix(codex): adopt pre-substrate homes into coordinator (#2612) --- .../010_design.md | 145 ++++++++++++++++++ src/codex/codex-write-lock.ts | 4 +- src/codex/convergence-types.ts | 2 +- src/codex/inject-coordination.ts | 2 + src/codex/inject.ts | 4 +- src/codex/transition-state.ts | 115 +++++++++++++- tests/codex-coordinator-doctor.test.ts | 14 ++ tests/codex-inject-write-lock.test.ts | 22 ++- tests/codex-transition-state-adoption.test.ts | 71 +++++++++ tests/helpers/codex-adoption-crash-child.ts | 13 ++ 10 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 devlog/_plan/260826_pre_substrate_adoption/010_design.md create mode 100644 tests/codex-transition-state-adoption.test.ts create mode 100644 tests/helpers/codex-adoption-crash-child.ts diff --git a/devlog/_plan/260826_pre_substrate_adoption/010_design.md b/devlog/_plan/260826_pre_substrate_adoption/010_design.md new file mode 100644 index 0000000000..ca2107db08 --- /dev/null +++ b/devlog/_plan/260826_pre_substrate_adoption/010_design.md @@ -0,0 +1,145 @@ +# #1049 — crash-safe pre-substrate home adoption + +## Current facts at `dev@f392e02eb3` + +1. `codexWriteCoordinationEligibility` treats every absent/stable-zero-byte coordinator with + routed or indeterminate native evidence as `legacy-uncoordinated` + (`src/codex/inject-coordination.ts:87-122`). Invalid integration records refuse before + residue classification (`src/codex/inject-coordination.ts:93-99`). +2. Apply calls `applyNativeArtifacts()` directly for `legacy-uncoordinated`, without + `withCodexWriteLock` or `beginTransition` (`src/codex/inject.ts:940-952`). The coordinated + branch publishes a pending transition before touching native files + (`src/codex/inject.ts:953-1011`). +3. Restore enters `withCodexWriteLock` only for `coordinated`; every other eligibility falls + through to the legacy config restore (`src/codex/inject.ts:1536-1554`, + `src/codex/inject.ts:1641-1647`). +4. The runtime status validator and SQL constraint omit `adoption-pending` + (`src/codex/transition-state.ts:40-92`), while generation zero rejects all history identity + and schedule fields (`src/codex/transition-state.ts:211-221`). +5. Missing-database creation opens the final path with SQLite `create:true` before schema and + singleton initialization (`src/codex/transition-state.ts:349-426`). A kill in that interval + can leave a final zero-byte or rowless file; existing unversioned and rowless files are then + deliberately refused (`src/codex/transition-state.ts:283-305`). +6. `withCodexWriteLock` always opens the ordinary coordinator transaction, so routed residue is + rejected before its callback (`src/codex/codex-write-lock.ts:305-307`, + `src/codex/transition-state.ts:269-280`). Adoption needs an explicit transaction-open mode; + changing eligibility alone is unreachable. +7. `adoption-pending` has no runtime occurrence. Its durable publication and recovery rules are + specified in `devlog/_fin/260804_codex_write_substrate/005_contract.md:706-790` and the + implementation deferral is recorded in + `devlog/_fin/260816_wave34_closeout/101_1049_legacy_adoption.md:58-109`. + +## Exclusions and necessity gate + +- Do not infer authority from indeterminate evidence. It remains legacy-operable and + uncoordinated; only positively classified routed residue is adoptable. +- Do not add an artifact fingerprint. The archived correction explains that byte equality cannot + prove whether the retained callback started or partially completed; recovery must rerun the + idempotent high-level operation. +- Do not delete or clear the singleton. Adoption advances the same durable row to the ordinary + positive-generation pending schedule. +- Do not relax ordinary clean initialization or the existing unversioned/rowless refusals. +- Do not add a second lock or a history-worker path. Adoption reuses N and the existing apply/remove + transition publication; workers still see only ordinary `pending` rows. + +Configuration and deletion cannot solve the issue: the missing behavior is durable migration of +an already-routed home. Existing `beginTransition` is reused for adoption completion, while a new +publisher is necessary because no existing owner atomically publishes a complete SQLite database. + +## Root-cause hypotheses and falsifiers + +- H1 (accepted): eligibility bypass is the sole cause. Falsifier: route routed residue to the + ordinary lock and observe successful callback entry. Current `assertInitialStateCanBeCreated` + rejects before callback, disproving sufficiency and showing the missing adoption opener. +- H2 (rejected): adding `adoption-pending` only to the TypeScript union is sufficient. Falsifier: + persist such a row. Current SQL status and generation-zero shape constraints reject it, so schema, + runtime validation, and creation must move together. +- H3 (rejected): create the final SQLite file and then fill it under N. Falsifier: terminate after + final-path creation but before commit. The next opener sees an unversioned/rowless authority and + refuses; therefore publication must make the complete database visible in one atomic step. + +The causal mechanism is structural: pre-substrate residue cannot seed the ordinary clean row, so +eligibility bypasses N; direct writes then never create transition authority. Adoption must publish +a distinct recoverable generation-zero row before native mutation, under the same N acquisition +that serializes the retained callback, and then atomically advance it to the ordinary pending +transition. + +## Design + +### State shape + +Add durable `history_status = 'adoption-pending'`. Its exact generation-zero identity is: + +- `native_generation = 0`, `current_tx_id = NULL`; +- fresh non-empty `history_tx_id`; +- intent-derived `history_direction = apply | remove`; +- fresh opaque non-empty `history_authority_snapshot_id`; +- null reason/retry/count fields and zero attempts. + +Generation zero accepts either the existing all-null `unknown` row or that complete adoption +identity. `beginTransition` conditionally replaces either with generation one (or the next positive +generation) and the ordinary `pending` schedule. Ordinary readers may report adoption-pending, but +no history worker dispatches it because worker scheduling remains gated on `pending`. + +### Publication + +When and only when eligibility says `adopt`, `withCodexWriteLock` opens the coordinator in an +adoption mode carrying `apply | remove` intent. If the final path is absent: + +1. Create a unique same-directory mode-0600 temporary file. +2. Open SQLite at the temporary path, create the complete v1 schema and adoption singleton in a + committed rollback-journal transaction, set `user_version = 1`, and close SQLite. +3. Reopen/validate the temp database through the same row parser, fsync its bytes, then close it. +4. Atomically publish without replacement using same-directory `linkSync(temp, final)`. `EEXIST` + means a contestant won; unlink only this process's temp and strictly open the winner. +5. Fsync the parent directory, unlink the temp alias, then open the final path normally and begin N. + +No SQLite handle crosses publication. No post-publication error path unlinks the final database. +Existing final paths, including unversioned or rowless files, always take strict validation and are +never rewritten by adoption. + +### Eligibility and callers + +- routed residue + missing or valid integration record + absent/stable-zero-byte coordinator: + `adopt`; +- indeterminate residue: retain `legacy-uncoordinated`, preserving current operability; +- clean: `coordinated`; invalid record: `refused`; any authoritative existing coordinator: + `coordinated` and strict opener validation. + +Apply and restore treat `adopt` as coordinated with the matching intent. The adoption row is visible +before either retained native callback runs. The existing callback then calls `beginTransition` +before filesystem mutation, replacing adoption-pending with ordinary pending in the same SQLite +transaction. If callback publication or mutation throws, rollback leaves adoption-pending durable; +the next authorized apply/restore reruns idempotently. + +### Crash-state table + +| Kill point | Final path after death | Next authorized operation | +| --- | --- | --- | +| after temp creation, before SQLite commit | absent | ignores private temp; republishes | +| after complete temp commit, before link | absent | ignores private temp; republishes | +| after no-clobber link, before alias cleanup | complete validated adoption-pending | opens and resumes | +| after final open / during retained callback | complete adoption-pending or ordinary pending | reruns adoption callback or existing recovery | + +The publication primitive is the only absent-to-present boundary. Thus every observable final path +is either absent or a complete validated v1 database. + +## Files and verification + +- `src/codex/convergence-types.ts`: durable status type. +- `src/codex/transition-state.ts`: schema, row validation, complete temp publisher, adoption opener. +- `src/codex/codex-write-lock.ts`: explicit adoption intent passed to the transaction owner. +- `src/codex/inject-coordination.ts`: routed adoption classification. +- `src/codex/inject.ts`: apply/restore route `adopt` through N. +- focused tests beside coordinator/injection tests, including child-process kill checkpoints, + routed and indeterminate classification, and substrate-native unaffected behavior. + +Required proof: + +```text +bun x tsc --noEmit +bun test tests/codex-inject*.test.ts tests/codex-composed-acceptance.test.ts +``` + +Each new regression is mutation-checked by reverting its production hunk, observing the named test +fail for the intended reason, restoring the hunk, and rerunning green. diff --git a/src/codex/codex-write-lock.ts b/src/codex/codex-write-lock.ts index 9949208948..c05b687190 100644 --- a/src/codex/codex-write-lock.ts +++ b/src/codex/codex-write-lock.ts @@ -93,6 +93,8 @@ export interface CodexWriteLockOptions { admitted: CodexWriteWitness; /** Authoritative synchronous re-read while N and C are both held. */ readAdmissionUnderLock(): CodexWriteWitness; + /** Positively authorized migration of an already-routed pre-substrate home. */ + adoption?: { readonly direction: "apply" | "remove" }; } /** @@ -304,7 +306,7 @@ export async function withCodexWriteLock( let transaction: ReturnType | undefined; try { - transaction = openCodexCoordinatorTransaction(databasePath); + transaction = openCodexCoordinatorTransaction(databasePath, options.adoption); } catch (error) { // Only contention retries. A malformed database, an unsafe path, or an // identity failure will fail identically forever; telling a caller to retry diff --git a/src/codex/convergence-types.ts b/src/codex/convergence-types.ts index 2615c531a6..c5518a862d 100644 --- a/src/codex/convergence-types.ts +++ b/src/codex/convergence-types.ts @@ -34,7 +34,7 @@ export interface CodexIntegrationRecord { } export interface CodexHistoryState { - status: "converged" | "pending" | "running" | "blocked" | "unknown" | "not-evaluated"; + status: "adoption-pending" | "converged" | "pending" | "running" | "blocked" | "unknown" | "not-evaluated"; /** * Why it is not converged, when it is not. These are terminal observations * for one attempt, not reasons to collapse the durable retry schedule. diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index a8b1c28858..96a41c7460 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -41,6 +41,7 @@ export const DEFAULT_INJECT_LOCK_TIMEOUT_MS = 5_000; */ export type CodexWriteCoordinationEligibility = | { kind: "coordinated" } + | { kind: "adopt" } | { kind: "legacy-uncoordinated"; reason: string } | { kind: "refused"; reason: string }; @@ -97,6 +98,7 @@ export function codexWriteCoordinationEligibility(deps: { const residue = deps.residue(); if (residue.kind === "clean") return { kind: "coordinated" }; + if (residue.kind === "residue" && !coordinatorIsStableZeroByte) return { kind: "adopt" }; /* * Everything else keeps the path it has always had. * diff --git a/src/codex/inject.ts b/src/codex/inject.ts index f7c5748e74..310ebdf079 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -953,6 +953,7 @@ export async function injectCodexConfig( const coordinated = await withCodexWriteLock( { timeoutMs: options.lockTimeoutMs ?? DEFAULT_INJECT_LOCK_TIMEOUT_MS, + ...(eligibility.kind === "adopt" ? { adoption: { direction: "apply" as const } } : {}), admitted: { authoritySnapshotId: witness.comparisonId }, readAdmissionUnderLock: () => ({ authoritySnapshotId: recomputeInjectWitness({ @@ -1547,13 +1548,14 @@ export async function restoreNativeCodexAsync( let config: CodexRestoreConfigResult; let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined; - if (eligibility.kind === "coordinated") { + if (eligibility.kind === "coordinated" || eligibility.kind === "adopt") { // The restore has no candidate bytes to witness; freshness comes from the // filesystem reads and the desired-state re-read performed under the lock. const witness = { authoritySnapshotId: "codex-native-restore" }; const coordinated = await withCodexWriteLock( { timeoutMs: DEFAULT_INJECT_LOCK_TIMEOUT_MS, + ...(eligibility.kind === "adopt" ? { adoption: { direction: "remove" as const } } : {}), admitted: witness, readAdmissionUnderLock: () => witness, }, diff --git a/src/codex/transition-state.ts b/src/codex/transition-state.ts index ed00fca09f..8bf1e54ca5 100644 --- a/src/codex/transition-state.ts +++ b/src/codex/transition-state.ts @@ -10,7 +10,17 @@ * Design record: devlog/_fin/260804_codex_write_substrate/005_contract.md §1. */ import { randomUUID } from "node:crypto"; -import { chmodSync, lstatSync, realpathSync } from "node:fs"; +import { + chmodSync, + closeSync, + fsyncSync, + linkSync, + lstatSync, + openSync, + realpathSync, + unlinkSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; import { Database } from "bun:sqlite"; @@ -38,7 +48,7 @@ import { } from "./user-identity"; export const CODEX_COORDINATOR_SCHEMA_VERSION = 1; -const DURABLE_HISTORY_STATUSES = new Set(["converged", "pending", "running", "blocked", "unknown"]); +const DURABLE_HISTORY_STATUSES = new Set(["adoption-pending", "converged", "pending", "running", "blocked", "unknown"]); const DURABLE_HISTORY_REASONS = new Set([ "db-busy", "permission", @@ -66,7 +76,7 @@ const CREATE_TRANSITION_TABLE = ` history_pending_rows INTEGER, history_backup_entries INTEGER, updated_at TEXT NOT NULL, - CHECK (history_status IN ('converged', 'pending', 'running', 'blocked', 'unknown')), + CHECK (history_status IN ('adoption-pending', 'converged', 'pending', 'running', 'blocked', 'unknown')), CHECK (history_reason IS NULL OR history_reason IN ('db-busy', 'permission', 'unreadable', 'schema', 'timeout', 'shutdown-cancelled', 'worker-died', 'overtaken', 'record-write-failed')), @@ -75,14 +85,20 @@ const CREATE_TRANSITION_TABLE = ` CHECK ((native_generation = 0 AND current_tx_id IS NULL) OR (native_generation > 0 AND length(trim(current_tx_id)) > 0)), CHECK ((native_generation = 0 + AND history_status = 'unknown' AND history_tx_id IS NULL AND history_direction IS NULL AND history_authority_snapshot_id IS NULL) + OR (native_generation = 0 + AND history_status = 'adoption-pending' + AND length(trim(history_tx_id)) > 0 + AND history_direction IS NOT NULL + AND length(trim(history_authority_snapshot_id)) > 0) OR (native_generation > 0 AND history_tx_id = current_tx_id AND history_direction IS NOT NULL AND length(trim(history_authority_snapshot_id)) > 0)), - CHECK (native_generation > 0 OR + CHECK (native_generation > 0 OR history_status = 'adoption-pending' OR (history_status = 'unknown' AND history_reason IS NULL AND history_attempts = 0 @@ -100,6 +116,15 @@ const INITIALIZE_TRANSITION_ROW = ` history_backup_entries, updated_at ) VALUES (1, 0, NULL, 'unknown', NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, ?)`; +const INITIALIZE_ADOPTION_ROW = ` + INSERT INTO codex_transition_state ( + singleton, native_generation, current_tx_id, + history_status, history_reason, history_attempts, + history_next_retry_at, history_tx_id, history_direction, + history_authority_snapshot_id, history_pending_rows, + history_backup_entries, updated_at + ) VALUES (1, 0, NULL, 'adoption-pending', NULL, 0, NULL, ?, ?, ?, NULL, NULL, ?)`; + const SELECT_TRANSITION_ROW = ` SELECT native_generation, current_tx_id, history_status, history_reason, history_attempts, @@ -210,8 +235,15 @@ function rowToState(row: TransitionRow | null): CodexTransitionState { const generation = row.native_generation; if (generation === 0) { - if (row.current_tx_id !== null || row.history_tx_id !== null - || row.history_direction !== null || row.history_authority_snapshot_id !== null) { + const ordinaryInitial = row.history_status === "unknown" + && row.history_tx_id === null + && row.history_direction === null + && row.history_authority_snapshot_id === null; + const adoptionInitial = row.history_status === "adoption-pending" + && Boolean(row.history_tx_id?.trim()) + && (row.history_direction === "apply" || row.history_direction === "remove") + && Boolean(row.history_authority_snapshot_id?.trim()); + if (row.current_tx_id !== null || (!ordinaryInitial && !adoptionInitial)) { throw new CodexCoordinatorTransactionError("The initial coordinator row contains transition metadata."); } } else if (!row.current_tx_id?.trim() @@ -234,13 +266,73 @@ function rowToState(row: TransitionRow | null): CodexTransitionState { nativeGeneration: generation, currentTxId: row.current_tx_id, history, - historySchedule: generation === 0 ? null : { + historySchedule: generation === 0 && row.history_status !== "adoption-pending" ? null : { direction: row.history_direction as "apply" | "remove", authoritySnapshotId: row.history_authority_snapshot_id as string, }, }; } +export type CodexCoordinatorAdoptionCheckpoint = "temp-created" | "temp-committed" | "published"; + +export interface CodexCoordinatorAdoptionOptions { + readonly direction: "apply" | "remove"; + readonly onCheckpoint?: (checkpoint: CodexCoordinatorAdoptionCheckpoint) => void; +} + +function fsyncPath(path: string): void { + const fd = openSync(path, "r"); + try { fsyncSync(fd); } finally { closeSync(fd); } +} + +function publishAdoptionDatabase( + finalDatabasePath: string, + options: CodexCoordinatorAdoptionOptions, +): void { + const parent = dirname(finalDatabasePath); + const tempPath = join(parent, `.${basename(finalDatabasePath)}.adopt-${process.pid}-${randomUUID()}.tmp`); + let tempPresent = false; + try { + const fd = openSync(tempPath, "wx", 0o600); + closeSync(fd); + tempPresent = true; + options.onCheckpoint?.("temp-created"); + + const temp = new Database(tempPath, { readwrite: true, create: false }); + try { + temp.exec("PRAGMA journal_mode = DELETE; BEGIN IMMEDIATE"); + temp.exec(CREATE_TRANSITION_TABLE); + temp.query(INITIALIZE_ADOPTION_ROW).run( + randomUUID(), + options.direction, + randomUUID(), + new Date().toISOString(), + ); + temp.exec(`PRAGMA user_version = ${CODEX_COORDINATOR_SCHEMA_VERSION}; COMMIT`); + readCodexCoordinatorState(temp); + } catch (error) { + try { temp.exec("ROLLBACK"); } catch { /* commit may already have completed */ } + throw error; + } finally { + temp.close(); + } + fsyncPath(tempPath); + options.onCheckpoint?.("temp-committed"); + + linkSync(tempPath, finalDatabasePath); + options.onCheckpoint?.("published"); + if (process.platform !== "win32") fsyncPath(parent); + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + } finally { + if (tempPresent) { + try { unlinkSync(tempPath); } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } + } + } +} + export function readCodexCoordinatorState(database: Database): CodexTransitionState { const row = database.query(SELECT_TRANSITION_ROW).get(); return rowToState(row); @@ -346,7 +438,10 @@ function createCapability( }; } -export function openCodexCoordinatorTransaction(finalDatabasePath: string): CodexCoordinatorTransactionController { +export function openCodexCoordinatorTransaction( + finalDatabasePath: string, + adoption?: CodexCoordinatorAdoptionOptions, +): CodexCoordinatorTransactionController { let database: Database | undefined; let transactionOpen = false; let closed = false; @@ -389,6 +484,10 @@ export function openCodexCoordinatorTransaction(finalDatabasePath: string): Code if (errorCode(cause) !== "ENOENT") throw cause; databaseWasAbsent = true; } + if (databaseWasAbsent && adoption) { + publishAdoptionDatabase(finalDatabasePath, adoption); + databaseWasAbsent = false; + } database = new Database(finalDatabasePath, { create: true }); if (databaseWasAbsent) { try { chmodSync(finalDatabasePath, 0o600); } catch { /* Windows applies ACLs in WP11. */ } diff --git a/tests/codex-coordinator-doctor.test.ts b/tests/codex-coordinator-doctor.test.ts index 49e9be23ce..2a45ab0e84 100644 --- a/tests/codex-coordinator-doctor.test.ts +++ b/tests/codex-coordinator-doctor.test.ts @@ -205,3 +205,17 @@ test("a fresh zero-byte coordinator stays on the locked path until it is stable" }); expect(settled.kind).toBe("legacy-uncoordinated"); }); + +test("routed homes adopt while indeterminate homes retain the legacy path", () => { + rmSync(coordinatorPath, { force: true }); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "residue" }), + integrationRecord: () => ({ kind: "missing" }), + })).toEqual({ kind: "adopt" }); + expect(codexWriteCoordinationEligibility({ + coordinatorPath: () => coordinatorPath, + residue: () => ({ kind: "indeterminate" }), + integrationRecord: () => ({ kind: "ready" }), + })).toMatchObject({ kind: "legacy-uncoordinated" }); +}); diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index ac28ba6bc7..065584ab03 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -268,13 +268,13 @@ describe("the lock is on the production path", () => { }, SPAWN_BUDGET_MS); }); -describe("homes the coordinator cannot adopt keep working", () => { +describe("pre-substrate home adoption", () => { /** * Every install predating this substrate is routed with no coordinator row, * and that row cannot be created over routed bytes. Gating on the lock there * would have broken re-injection for the entire installed base. */ - test("a pre-substrate routed home still injects, without a coordinator", () => { + test("a pre-substrate routed home adopts and records a coordinated transition", () => { writeFileSync(join(codexHome, "config.toml"), [ 'model_provider = "opencodex"', 'model = "gpt-5.5"', @@ -289,6 +289,24 @@ describe("homes the coordinator cannot adopt keep working", () => { const result = runInject(10100); expect(result.success).toBeTrue(); expect(readFileSync(join(codexHome, "config.toml"), "utf-8")).toContain("openai_base_url"); + const coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); + coordinatorCleanup.push(coordinatorPath); + expect(existsSync(coordinatorPath)).toBeTrue(); + const state = parseChildJson<{ + kind: string; + state?: { nativeGeneration: number; history: { status: string } }; + }>(runChild(["--eval", ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `], { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + }), "read adopted transition"); + expect(state).toMatchObject({ kind: "ready", state: { nativeGeneration: 1 } }); }); test("a zero-byte coordinator remnant does not wedge a pre-substrate routed home", () => { diff --git a/tests/codex-transition-state-adoption.test.ts b/tests/codex-transition-state-adoption.test.ts new file mode 100644 index 0000000000..65873ee403 --- /dev/null +++ b/tests/codex-transition-state-adoption.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { openCodexCoordinatorTransaction } from "../src/codex/transition-state"; +import { + resolveCodexCoordinatorDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; + +const CHILD = join(import.meta.dir, "helpers", "codex-adoption-crash-child.ts"); +let root = ""; +let codexHome = ""; +let opencodexHome = ""; +let coordinatorPath = ""; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-adoption-crash-")); + codexHome = mkdtempSync(join(root, "codex-")); + opencodexHome = mkdtempSync(join(root, "opencodex-")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + coordinatorPath = resolveCodexCoordinatorDatabasePath( + resolveEffectiveUserIdentity(), + realpathSync.native(codexHome), + ); +}); + +afterEach(() => { + delete process.env.CODEX_HOME; + delete process.env.OPENCODEX_HOME; + rmSync(coordinatorPath, { force: true }); + rmSync(root, { recursive: true, force: true }); +}); + +for (const checkpoint of ["temp-created", "temp-committed", "published"] as const) { + test(`a kill at ${checkpoint} leaves the home adoptable`, () => { + const child = spawnSync(process.execPath, [CHILD], { + cwd: join(import.meta.dir, ".."), + env: { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + OCX_ADOPTION_CRASH_PAYLOAD: JSON.stringify({ coordinatorPath, checkpoint }), + }, + encoding: "utf8", + }); + expect(child.status).toBe(86); + expect(existsSync(coordinatorPath)).toBe(checkpoint === "published"); + + const resumed = openCodexCoordinatorTransaction(coordinatorPath, { direction: "apply" }); + try { + const state = resumed.version(); + expect(state).toEqual({ nativeGeneration: 0, currentTxId: null }); + const expectation = resumed.expectation(); + const update = resumed.capability.beginTransition(state, { + txId: expectation.txId, + direction: "apply", + authoritySnapshotId: "resume-authority", + nextRetryAt: "2026-08-26T00:00:00.000Z", + }); + expect(update).toMatchObject({ kind: "updated", state: { nativeGeneration: 1 } }); + resumed.assertPublished(expectation); + resumed.commit(); + } finally { + resumed.close(); + } + }); +} diff --git a/tests/helpers/codex-adoption-crash-child.ts b/tests/helpers/codex-adoption-crash-child.ts new file mode 100644 index 0000000000..8cee4316fc --- /dev/null +++ b/tests/helpers/codex-adoption-crash-child.ts @@ -0,0 +1,13 @@ +import { openCodexCoordinatorTransaction } from "../../src/codex/transition-state"; + +const payload = JSON.parse(process.env.OCX_ADOPTION_CRASH_PAYLOAD ?? "{}") as { + coordinatorPath: string; + checkpoint: "temp-created" | "temp-committed" | "published"; +}; + +openCodexCoordinatorTransaction(payload.coordinatorPath, { + direction: "apply", + onCheckpoint(checkpoint) { + if (checkpoint === payload.checkpoint) process.exit(86); + }, +}); From aacd6528d8fbb97dcf022d1420d262652a42f0ff Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 04:59:29 +0900 Subject: [PATCH 048/336] fix(config): persist top-level deletion provenance (#2613) Replace the pinned grokExcludedModels raw-delete setup with the provenance helper so its deletion assertion now proves explicit intent. The unrelated claudeCode hand-edit assertion remains unchanged because provenance supersedes only disk-only top-level key inference. --- .../010_design.md | 84 +++++++++++++++++++ src/cli/v2.ts | 6 +- src/codex/account-pause.ts | 3 +- src/codex/account-priority.ts | 5 +- src/codex/desired-state.ts | 4 +- src/config.ts | 46 ++++++++-- src/config/rebase-provenance.ts | 63 ++++++++++++++ src/providers/context-cap.ts | 7 +- src/providers/provider-id-rewrite.ts | 3 +- .../management/agent-settings-routes.ts | 23 ++--- src/server/management/combo-routes.ts | 3 +- src/server/management/config-routes.ts | 15 ++-- .../management/routing-profile-routes.ts | 4 +- src/types.ts | 1 + src/types/config.ts | 7 ++ .../config-rebase-provenance-writers.test.ts | 47 +++++++++++ tests/config-user-edits.test.ts | 76 ++++++++++++++++- 17 files changed, 357 insertions(+), 40 deletions(-) create mode 100644 devlog/_plan/260826_config_rebase_provenance/010_design.md create mode 100644 src/config/rebase-provenance.ts create mode 100644 tests/config-rebase-provenance-writers.test.ts diff --git a/devlog/_plan/260826_config_rebase_provenance/010_design.md b/devlog/_plan/260826_config_rebase_provenance/010_design.md new file mode 100644 index 0000000000..8233116933 --- /dev/null +++ b/devlog/_plan/260826_config_rebase_provenance/010_design.md @@ -0,0 +1,84 @@ +# 010 — Config rebase deletion provenance + +## Current facts + +- `configSchema` accepts unknown top-level fields through `.passthrough()` + (`src/config.ts:837-934`), and successful loads return that parsed object + (`src/config.ts:1795-1812`). An additive metadata field therefore remains readable + by an older binary and survives its ordinary whole-config save. +- The long-lived rebase state contains cloned config values only + (`src/config.ts:2601-2627`). It cannot record an absent key's cause. +- Guarded save consequently derives authority from key presence: disk-only keys are + excluded from reconciliation (`src/config.ts:2912-2935`). This preserves an + explicit deletion but also discards an unseen concurrent addition. +- The two pinned outcomes are distinct. A disk-only `claudeCode` edit must survive an + unrelated save (`tests/config-user-edits.test.ts:221-231`), while an explicit live + deletion of a disk-only `grokExcludedModels` key must win + (`tests/config-user-edits.test.ts:652-675`). Presence alone cannot satisfy both. + +## Persisted contract + +Add optional top-level metadata: + +```json +{ + "configRebaseProvenance": { + "version": 1, + "deletedTopLevelKeys": ["grokExcludedModels"] + } +} +``` + +`deletedTopLevelKeys` is a sorted, duplicate-free tombstone set. A top-level deletion +writer records the key through one config-owned helper. Before persistence, every key +that is present in the candidate removes its tombstone; an empty set removes the +metadata. Thus a later explicit assignment supersedes an earlier deletion. + +Only a schema-valid version-1 record grants deletion authority. Missing metadata keeps +the current presence-based behavior until a writer explicitly records a deletion. +Unknown/future records pass through opaquely and grant no authority. Loading or arming +a baseline never creates provenance because neither operation knows why a key is absent. + +The metadata key itself is excluded from ordinary three-way reconciliation. The guarded +save combines the candidate's explicit tombstones with the newest disk metadata, then +uses only candidate tombstones to decide whether a disk-only field is reconciled or +deleted. This lets an unseen concurrent key survive while preserving an explicit live +deletion. + +## Top-level deletion-writer inventory + +The current source contains these top-level config deletions; nested provider, +`claudeCode`, sidecar, combo-row, and routing-profile-row deletions are not top-level +provenance events. + +- `src/server/management/agent-settings-routes.ts:98` — `clientIntegrations` +- `src/server/management/agent-settings-routes.ts:344` — `multiAgentMode` +- `src/server/management/agent-settings-routes.ts:351` — `keepNativeChatGptOnV1` +- `src/server/management/agent-settings-routes.ts:562-568` — four injection fields +- `src/server/management/agent-settings-routes.ts:599` — generic delegation fields +- `src/server/management/agent-settings-routes.ts:718-720` — two fallback fields +- `src/server/management/agent-settings-routes.ts:761` — `grokExcludedModels` +- `src/server/management/config-routes.ts:440,461-475` — seven general settings +- `src/server/management/combo-routes.ts:249` — `combos` +- `src/server/management/routing-profile-routes.ts:332` — `routingProfiles` +- `src/providers/context-cap.ts:53,74,81` — `providerContextCaps` +- `src/codex/account-priority.ts:37,81` — priorities and active pin +- `src/codex/account-pause.ts:15` — paused account IDs +- `src/codex/desired-state.ts:117` — `clientIntegrations` +- `src/providers/provider-id-rewrite.ts:177` — `customModels` +- `src/cli/v2.ts:196,220` — two CLI general settings +- `src/cli/provider.ts:313` — a nested provider row, excluded from top-level provenance + +The implementation replaces every included statement with the config-owned helper. +Normalization-only deletes in `src/config.ts` are excluded: they sanitize parsed data +and are not user-intent writers. Reconciliation deletes are also excluded because they +apply another writer's state rather than originate intent. + +## Verification and falsification + +- Focused tests cover explicit deletion versus unseen addition, missing/version-1/future + migration behavior, old-reader passthrough, and every writer's use of the helper. +- For each new behavior test, revert the production hunk it exercises, confirm failure, + then restore it before final verification. +- Final gates: `bun x tsc --noEmit`, all `tests/config*.test.ts`, and every additional + test file found by imports of `src/config.ts` or the changed deletion-writer modules. diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 9071fa183c..1e817e084e 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -15,7 +15,7 @@ import { dirname } from "node:path"; import { activeCodexConfigPath, getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features"; import { commandInvocation, type SpawnInvocation } from "../lib/win-exec"; -import { loadConfig, saveConfig } from "../config"; +import { deleteConfigTopLevelKey, loadConfig, saveConfig } from "../config"; import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime"; export interface V2CliDeps { @@ -193,7 +193,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () return 1; } } - if (modeArg === "default") delete cfg.multiAgentMode; + if (modeArg === "default") deleteConfigTopLevelKey(cfg, "multiAgentMode"); else cfg.multiAgentMode = modeArg as "v1" | "v2"; saveConfig(cfg); try { @@ -217,7 +217,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () const next = flag === "on"; const already = cfg.keepNativeChatGptOnV1 === true === next; if (next) cfg.keepNativeChatGptOnV1 = true; - else delete cfg.keepNativeChatGptOnV1; + else deleteConfigTopLevelKey(cfg, "keepNativeChatGptOnV1"); saveConfig(cfg); try { const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex; diff --git a/src/codex/account-pause.ts b/src/codex/account-pause.ts index 8e73a1bcfd..a8a4c2d1e2 100644 --- a/src/codex/account-pause.ts +++ b/src/codex/account-pause.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types"; +import { deleteConfigTopLevelKey } from "../config/rebase-provenance"; /** Whether an account is administratively excluded from future pool selection. */ export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean { @@ -12,7 +13,7 @@ export function setCodexAccountPaused(config: OcxConfig, accountId: string, paus else pausedIds.delete(accountId); if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds]; - else delete config.pausedCodexAccountIds; + else deleteConfigTopLevelKey(config, "pausedCodexAccountIds"); } export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void { diff --git a/src/codex/account-priority.ts b/src/codex/account-priority.ts index 0bac71ab85..afb39c4df2 100644 --- a/src/codex/account-priority.ts +++ b/src/codex/account-priority.ts @@ -1,6 +1,7 @@ import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation"; import type { OcxConfig } from "../types"; +import { deleteConfigTopLevelKey } from "../config/rebase-provenance"; /** * Which ids may carry a selection order: any pool account, plus the synthetic @@ -34,7 +35,7 @@ export function setCodexAccountPriority(config: OcxConfig, accountId: string, pr else entries.set(accountId, priority); if (entries.size > 0) config.codexAccountPriorities = Object.fromEntries(entries); - else delete config.codexAccountPriorities; + else deleteConfigTopLevelKey(config, "codexAccountPriorities"); } export function forgetCodexAccountPriority(config: OcxConfig, accountId: string): void { @@ -78,6 +79,6 @@ export function setCodexAccountPin(config: OcxConfig, accountId: string): void { /** Release the pin. With `accountId` given, only when it is the pinned account. */ export function clearCodexAccountPin(config: OcxConfig, accountId?: string): void { if (accountId === undefined || config.activeCodexAccountPinned === accountId) { - delete config.activeCodexAccountPinned; + deleteConfigTopLevelKey(config, "activeCodexAccountPinned"); } } diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index bd6d864d77..320e077050 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -20,7 +20,7 @@ * * Design record: devlog/_fin/260803_codex_desktop_toggle/030_desired_state.md. */ -import { loadConfig, mutatePersistedConfig } from "../config"; +import { deleteConfigTopLevelKey, loadConfig, mutatePersistedConfig } from "../config"; import type { OcxClientIntegrationsConfig, OcxConfig } from "../types"; import { runStartupReadinessSync, type ReadinessGate, type SyncOutcomeLike } from "../server/readiness"; @@ -114,7 +114,7 @@ export function setIntegrationEnabled( } // Drop the key entirely once nothing is left in it, so enabling twice does // not leave `"clientIntegrations": {}` behind in the user's file. - if (Object.keys(integrations).length === 0) delete config.clientIntegrations; + if (Object.keys(integrations).length === 0) deleteConfigTopLevelKey(config, "clientIntegrations"); else config.clientIntegrations = integrations; return { changed: true, value: enabled }; }); diff --git a/src/config.ts b/src/config.ts index 7e1d40be57..d4096f6cfe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -142,6 +142,15 @@ export { writeRuntimePort, type RuntimePortState, } from "./config/process-state"; +import { + clearPendingConfigTopLevelDeletions, + configHasRebaseProvenance, + configRebaseDeletionKeys, + CONFIG_REBASE_PROVENANCE_KEY, + deleteConfigTopLevelKey, + projectConfigRebaseProvenance, +} from "./config/rebase-provenance"; +export { deleteConfigTopLevelKey } from "./config/rebase-provenance"; export class OpenAiTierBackupCleanupError extends Error { constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; } @@ -870,6 +879,9 @@ const configSchema = z.object({ providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), + // Future versions remain opaque through passthrough-compatible whole-config saves. + // Only version 1 grants deletion authority in the rebase path. + configRebaseProvenance: z.unknown().optional(), // A retry can be billable, so absence and malformed hand edits both stay off. emptyCompletionRetry: z.boolean().optional().catch(false), // A malformed hand edit must not silently stop opening the browser: fall back @@ -2499,7 +2511,12 @@ function persistConfigUnlocked(config: OcxConfig): boolean { // External editors can add provider rows the live config deliberately does // not route with yet; merge them at the serialization boundary so an // unrelated in-process save cannot erase the provider or its overlay. + // Provider preservation reads symbol-keyed live-owner state, which structuredClone + // intentionally drops. Resolve that ownership before projecting JSON provenance. + const provenanceProjection = projectConfigRebaseProvenance(config); const persisted = withPreservedDiskOnlyProviders(config); + if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance; + else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance; const bytes = JSON.stringify(persisted, null, 2) + "\n"; let unchanged = false; try { @@ -2527,9 +2544,15 @@ export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); withConfigMutationLockSync(() => { - const projected = projectCustomModelCatalogMigration(readRawConfigJson(), config); - if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); - adoptCustomModelCatalogMigration(config, projected); + const withProvenance = projectCustomModelCatalogMigration( + readRawConfigJson(), + projectConfigRebaseProvenance(config), + ); + if (persistConfigUnlocked(withProvenance)) bumpGenerationForCooperatingConfigWrite(); + adoptCustomModelCatalogMigration(config, withProvenance); + if (withProvenance.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(withProvenance.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); }); } @@ -2648,7 +2671,6 @@ const claudeCodeBaseline = new WeakMap(); * reconciliation paths below. */ const liveConfigBaseline = new WeakMap(); - /** * The live config retains the address of the socket Bun actually opened, while * this map retains the operator's desired address for the next process start. @@ -2952,6 +2974,8 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { if (baseline && onDisk !== undefined) { const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk)); if (persistedDiagnostics.source === "file") { + const deletedKeys = configRebaseDeletionKeys(config); + const provenanceExists = configHasRebaseProvenance(config); // Only keys this live config is actually known to have diverged on may be // rebased. The baseline is captured once when the server arms it, so any key // that appeared on disk afterwards — through saveConfig(), a hand edit, or @@ -2965,8 +2989,11 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { const rebaseableKeys = new Set([ ...Object.keys(baseline as unknown as Record), ...Object.keys(config as unknown as Record), + ...(provenanceExists + ? Object.keys(persistedDiagnostics.config as unknown as Record) + : []), ]); - const skipped = new Set(["hostname", "port", "claudeCode"]); + const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]); for (const key of Object.keys(persistedDiagnostics.config as unknown as Record)) { if (!rebaseableKeys.has(key)) skipped.add(key); } @@ -2976,6 +3003,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { persistedDiagnostics.config as unknown as Record, skipped, ); + for (const key of deletedKeys) delete (config as unknown as Record)[key]; } } if (claudeCodeBaseline.has(config)) { @@ -2989,10 +3017,13 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { } } } + const provenanceProjection = projectConfigRebaseProvenance(config); const projectedConfig = projectCustomModelCatalogMigration( onDisk, config, ); + if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance; + else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance; const persistedBinding = bindingBaseline && onDisk ? readPersistedServerBinding(onDisk, bindingBaseline) : bindingBaseline; @@ -3010,8 +3041,11 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); } if (liveConfigBaseline.has(config)) { - liveConfigBaseline.set(config, structuredClone(config)); + if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance); + liveConfigBaseline.set(config, structuredClone(projectedConfig)); } + clearPendingConfigTopLevelDeletions(config); }); } diff --git a/src/config/rebase-provenance.ts b/src/config/rebase-provenance.ts new file mode 100644 index 0000000000..9afe8c8d03 --- /dev/null +++ b/src/config/rebase-provenance.ts @@ -0,0 +1,63 @@ +import type { OcxConfig } from "../types"; + +const pendingTopLevelDeletions = new WeakMap>(); +export const CONFIG_REBASE_PROVENANCE_KEY = "configRebaseProvenance"; + +export function parsedConfigRebaseDeletionKeys(config: OcxConfig): Set | null { + const value = config.configRebaseProvenance; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.version !== 1 || !Array.isArray(record.deletedTopLevelKeys)) return null; + if (!record.deletedTopLevelKeys.every(key => typeof key === "string" && key !== CONFIG_REBASE_PROVENANCE_KEY)) { + return null; + } + return new Set(record.deletedTopLevelKeys as string[]); +} + +export function configRebaseDeletionKeys(config: OcxConfig): Set { + const deleted = new Set([ + ...(parsedConfigRebaseDeletionKeys(config) ?? []), + ...(pendingTopLevelDeletions.get(config) ?? []), + ]); + const record = config as unknown as Record; + for (const key of [...deleted]) { + if (Object.hasOwn(record, key) && record[key] !== undefined) deleted.delete(key); + } + return deleted; +} + +export function configHasRebaseProvenance(config: OcxConfig): boolean { + return parsedConfigRebaseDeletionKeys(config) !== null || pendingTopLevelDeletions.has(config); +} + +export function projectConfigRebaseProvenance(config: OcxConfig): OcxConfig { + const pending = pendingTopLevelDeletions.get(config); + const parsed = parsedConfigRebaseDeletionKeys(config); + // Preserve unknown future metadata byte-for-value. It carries no authority here. + if (config.configRebaseProvenance !== undefined && parsed === null) return config; + const deleted = new Set([...(parsed ?? []), ...(pending ?? [])]); + const record = config as unknown as Record; + for (const key of [...deleted]) { + if (Object.hasOwn(record, key) && record[key] !== undefined) deleted.delete(key); + } + const projected = structuredClone(config); + if (deleted.size === 0) delete projected.configRebaseProvenance; + else projected.configRebaseProvenance = { + version: 1, + deletedTopLevelKeys: [...deleted].sort(), + }; + return projected; +} + +/** Delete one top-level config key and retain the writer's explicit intent for rebasing. */ +export function deleteConfigTopLevelKey(config: OcxConfig, key: K): void { + delete config[key]; + if (key === CONFIG_REBASE_PROVENANCE_KEY) return; + const deleted = pendingTopLevelDeletions.get(config) ?? new Set(); + deleted.add(key); + pendingTopLevelDeletions.set(config, deleted); +} + +export function clearPendingConfigTopLevelDeletions(config: OcxConfig): void { + pendingTopLevelDeletions.delete(config); +} diff --git a/src/providers/context-cap.ts b/src/providers/context-cap.ts index 24834bb522..d10807ced2 100644 --- a/src/providers/context-cap.ts +++ b/src/providers/context-cap.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types"; +import { deleteConfigTopLevelKey } from "../config/rebase-provenance"; export const DEFAULT_PROVIDER_CONTEXT_CAP = 350_000; @@ -50,7 +51,7 @@ export function setProviderContextCap(config: OcxConfig, provider: string, enabl delete next[provider]; } if (Object.keys(next).length > 0) config.providerContextCaps = next; - else delete config.providerContextCaps; + else deleteConfigTopLevelKey(config, "providerContextCaps"); } /** @@ -71,12 +72,12 @@ export function setGlobalContextCapValue(config: OcxConfig, value: number, apply /** Enable the cap for every named provider at the current value, or clear all caps. */ export function setAllProviderContextCaps(config: OcxConfig, providerNames: string[], enabled: boolean): void { if (!enabled) { - delete config.providerContextCaps; + deleteConfigTopLevelKey(config, "providerContextCaps"); return; } const value = globalContextCapValue(config); const next: Record = {}; for (const name of providerNames) next[name] = value; if (Object.keys(next).length > 0) config.providerContextCaps = next; - else delete config.providerContextCaps; + else deleteConfigTopLevelKey(config, "providerContextCaps"); } diff --git a/src/providers/provider-id-rewrite.ts b/src/providers/provider-id-rewrite.ts index f34e78b326..10a6f3f211 100644 --- a/src/providers/provider-id-rewrite.ts +++ b/src/providers/provider-id-rewrite.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types"; +import { deleteConfigTopLevelKey } from "../config/rebase-provenance"; export interface ProviderRewriteResult { /** Number of references re-pointed. */ @@ -174,6 +175,6 @@ export function dropProviderCustomModels(config: OcxConfig, provider: string): n // `[]`, so the `customModels` field is absent either way. Only that field — // the `customModelCatalogMigration` marker is deliberately left in place. if (kept.length > 0) config.customModels = kept; - else delete config.customModels; + else deleteConfigTopLevelKey(config, "customModels"); return existing.length - kept.length; } diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index bddbd37fe1..25dab3b875 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -5,6 +5,7 @@ import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nati import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, + deleteConfigTopLevelKey, hasOwnProvider, isValidProviderName, loadConfig, @@ -95,7 +96,7 @@ function mirrorDesiredEnabledOntoSnapshot(config: OcxConfig, client: "claude-des const integrations = { ...(config.clientIntegrations ?? {}) }; if (enabled) delete integrations[client]; else integrations[client] = false; - if (Object.keys(integrations).length === 0) delete config.clientIntegrations; + if (Object.keys(integrations).length === 0) deleteConfigTopLevelKey(config, "clientIntegrations"); else config.clientIntegrations = integrations; } @@ -341,14 +342,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (result.changed && result.threadLimit !== null) warnings.push(`Thread limit ${result.threadLimit} preserved for ${targetFlag ? "v2" : "v1"}.`); } if (wantsMode) { - if (mode === "default") delete config.multiAgentMode; + if (mode === "default") deleteConfigTopLevelKey(config, "multiAgentMode"); else config.multiAgentMode = mode; saveConfigPreservingClaudeCode(config); warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`); } if (wantsKeepNative) { if (body.keepNativeChatGptOnV1 === true) config.keepNativeChatGptOnV1 = true; - else delete config.keepNativeChatGptOnV1; + else deleteConfigTopLevelKey(config, "keepNativeChatGptOnV1"); saveConfigPreservingClaudeCode(config); const effectiveMode = mode ?? config.multiAgentMode ?? "default"; warnings.push(body.keepNativeChatGptOnV1 === true @@ -559,13 +560,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise config.multiAgentGuidanceEnabled = nextEnabled; if (nextSyncCodexSubagentDefaults) config.syncCodexSubagentDefaults = true; - else delete config.syncCodexSubagentDefaults; + else deleteConfigTopLevelKey(config, "syncCodexSubagentDefaults"); if (nextModel) config.injectionModel = nextModel; - else delete config.injectionModel; + else deleteConfigTopLevelKey(config, "injectionModel"); if (nextEffort) config.injectionEffort = nextEffort; - else delete config.injectionEffort; + else deleteConfigTopLevelKey(config, "injectionEffort"); if (nextPrompt) config.injectionPrompt = nextPrompt; - else delete config.injectionPrompt; + else deleteConfigTopLevelKey(config, "injectionPrompt"); saveConfigPreservingClaudeCode(config); return jsonResponse({ @@ -596,7 +597,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise for (const key of ["effortCap", "subagentEffortCap"] as const) { if (!(key in body)) continue; const value = body[key]; - if (value === null || value === "") { delete config[key]; continue; } + if (value === null || value === "") { deleteConfigTopLevelKey(config, key); continue; } if (typeof value !== "string" || !isCodexReasoningEffort(value)) { return jsonResponse({ error: `unknown reasoning effort "${String(value)}"` }, 400); } @@ -715,9 +716,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } if (nextModels !== undefined) config.subagentModelFallback = nextModels; - else delete config.subagentModelFallback; + else deleteConfigTopLevelKey(config, "subagentModelFallback"); if (nextPollMs !== undefined) config.subagentModelFallbackPollMs = nextPollMs; - else delete config.subagentModelFallbackPollMs; + else deleteConfigTopLevelKey(config, "subagentModelFallbackPollMs"); saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, @@ -758,7 +759,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // cannot grow config.json without bound. const excluded = [...new Set(raw as string[])].sort(); if (excluded.length > 2000) return jsonResponse({ error: "excluded list is too large" }, 400); - if (excluded.length === 0) delete config.grokExcludedModels; + if (excluded.length === 0) deleteConfigTopLevelKey(config, "grokExcludedModels"); else config.grokExcludedModels = excluded; saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, excluded }); diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index d347bef449..c5d09d9f9b 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -5,6 +5,7 @@ import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCa import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, + deleteConfigTopLevelKey, hasOwnProvider, isValidProviderName, multiAgentGuidanceEnabled, @@ -246,7 +247,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise 0) config.routingProfiles = nextProfiles; - else delete config.routingProfiles; + else deleteConfigTopLevelKey(config, "routingProfiles"); const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; saveConfigPreservingClaudeCodeSafe(config); reconcileLiveStateStores(); diff --git a/src/types.ts b/src/types.ts index 22a083098c..68f3cd4f7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,7 @@ export type { OcxCustomModel, OcxApiKeyEntry, OcxClientIntegrationsConfig, + OcxConfigRebaseProvenance, OcxConfig, OcxAccountPoolRotationStrategy, OcxComboStrategy, diff --git a/src/types/config.ts b/src/types/config.ts index c2ea0e0fb9..cbc021f5da 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -239,6 +239,11 @@ export interface OcxClientIntegrationsConfig { "claude-desktop"?: boolean; } +export interface OcxConfigRebaseProvenance { + version: 1; + deletedTopLevelKeys: string[]; +} + export interface OcxConfig { port: number; /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ @@ -274,6 +279,8 @@ export interface OcxConfig { }; /** Enable the shipped model alias patterns for providers without an override. */ defaultModelAliases?: boolean; + /** Explicit top-level deletion intent used by stale whole-config rebases. */ + configRebaseProvenance?: OcxConfigRebaseProvenance | Record; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ openaiProviderTierVersion?: 1 | 2; /** One-time migration marker for Antigravity's static-catalog defaults. */ diff --git a/tests/config-rebase-provenance-writers.test.ts b/tests/config-rebase-provenance-writers.test.ts new file mode 100644 index 0000000000..c6d7ecf814 --- /dev/null +++ b/tests/config-rebase-provenance-writers.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const writerContracts: Record = { + "src/server/management/agent-settings-routes.ts": [ + "clientIntegrations", "multiAgentMode", "keepNativeChatGptOnV1", + "syncCodexSubagentDefaults", "injectionModel", "injectionEffort", "injectionPrompt", + "subagentModelFallback", "subagentModelFallbackPollMs", "grokExcludedModels", + ], + "src/server/management/config-routes.ts": [ + "streamMode", "codexAutoStart", "appOwnedMemoryBudgetMb", "codexAccountNamespaces", + "codexAccountPickerEnabled", "oauthOpenBrowser", + ], + "src/server/management/combo-routes.ts": ["combos"], + "src/server/management/routing-profile-routes.ts": ["routingProfiles"], + "src/providers/context-cap.ts": ["providerContextCaps"], + "src/codex/account-priority.ts": ["codexAccountPriorities", "activeCodexAccountPinned"], + "src/codex/account-pause.ts": ["pausedCodexAccountIds"], + "src/codex/desired-state.ts": ["clientIntegrations"], + "src/providers/provider-id-rewrite.ts": ["customModels"], + "src/cli/v2.ts": ["multiAgentMode", "keepNativeChatGptOnV1"], +}; + +test("every enumerated top-level deletion writer records config rebase provenance", () => { + for (const [path, keys] of Object.entries(writerContracts)) { + const source = readFileSync(join(import.meta.dir, "..", path), "utf8"); + for (const key of keys) { + const configCall = `deleteConfigTopLevelKey(config, "${key}")`; + const cliCall = `deleteConfigTopLevelKey(cfg, "${key}")`; + expect(source.includes(configCall) || source.includes(cliCall), + `${path} must record deletion provenance for ${key}`).toBe(true); + } + if (path.endsWith("agent-settings-routes.ts")) { + expect(source).toContain("deleteConfigTopLevelKey(config, key)"); + } + } +}); + +test("live-config writers contain no untracked direct top-level deletion", () => { + for (const path of Object.keys(writerContracts)) { + const source = readFileSync(join(import.meta.dir, "..", path), "utf8"); + expect(source.match(/delete (?:config|cfg)\.[A-Za-z_$][A-Za-z0-9_$]*[;\n]/g) ?? [], path) + .toEqual([]); + expect(source.match(/delete config\[[^\]]+\]/g) ?? [], path).toEqual([]); + } +}); diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index d64b566572..85108b0f8e 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { armClaudeCodeBaseline, adoptPersistedProviderIntoLiveConfig, + deleteConfigTopLevelKey, getConfigPath, getDefaultConfig, loadConfig, @@ -668,13 +669,86 @@ test("a live deletion of a key that only ever existed on disk is not undone by t // The live writer adopts it and then deletes it, exactly as the management route // does for an empty selection. live.grokExcludedModels = ["a"]; - delete live.grokExcludedModels; + deleteConfigTopLevelKey(live, "grokExcludedModels"); saveConfigPreservingClaudeCode(live); expect(diskConfig().grokExcludedModels).toBeUndefined(); expect(live.grokExcludedModels).toBeUndefined(); }); +test("provenance distinguishes an unseen disk key from an explicit deletion", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + deleteConfigTopLevelKey(live, "injectionPrompt"); + + const onDisk = loadConfig(); + onDisk.grokExcludedModels = ["added-elsewhere"]; + saveConfig(onDisk); + + saveConfigPreservingClaudeCode(live); + + expect(live.grokExcludedModels).toEqual(["added-elsewhere"]); + expect(diskConfig().grokExcludedModels).toEqual(["added-elsewhere"]); + expect(diskConfig().configRebaseProvenance).toEqual({ + version: 1, + deletedTopLevelKeys: ["injectionPrompt"], + }); +}); + +test("a config without provenance keeps the legacy disk-only-key behavior", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + const onDisk = loadConfig(); + onDisk.grokExcludedModels = ["disk-only"]; + saveConfig(onDisk); + + saveConfigPreservingClaudeCode(live); + + expect(diskConfig().grokExcludedModels).toBeUndefined(); + expect(diskConfig().configRebaseProvenance).toBeUndefined(); +}); + +test("version-1 provenance round-trips through load and an older-style whole-config save", () => { + const config = loadConfig(); + deleteConfigTopLevelKey(config, "grokExcludedModels"); + saveConfig(config); + const loaded = loadConfig(); + + saveConfig(loaded); + + expect(diskConfig().configRebaseProvenance).toEqual({ + version: 1, + deletedTopLevelKeys: ["grokExcludedModels"], + }); +}); + +test("future provenance is preserved opaquely and grants no deletion authority", () => { + const future = { version: 2, opaque: { keep: true } }; + writeDiskConfig({ configRebaseProvenance: future }); + const live = loadConfig(); + armClaudeCodeBaseline(live); + const onDisk = loadConfig(); + onDisk.grokExcludedModels = ["disk-only"]; + saveConfig(onDisk); + + saveConfigPreservingClaudeCode(live); + + expect(diskConfig().configRebaseProvenance).toEqual(future); + expect(diskConfig().grokExcludedModels).toBeUndefined(); +}); + +test("assigning a deleted key clears its persisted tombstone", () => { + const config = loadConfig(); + deleteConfigTopLevelKey(config, "grokExcludedModels"); + saveConfig(config); + config.grokExcludedModels = ["restored"]; + + saveConfig(config); + + expect(diskConfig().grokExcludedModels).toEqual(["restored"]); + expect(diskConfig().configRebaseProvenance).toBeUndefined(); +}); + test("a provider deletion from a newer disk snapshot survives an unrelated live save", () => { const live = loadConfig(); live.providers.extra = { From 4adfd23e442b2ca04666f37bf39eba838b580eaa Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 05:05:05 +0900 Subject: [PATCH 049/336] fix: repair three regressions from the #2463/#1478 landings (#2614) Found by the full suite on dev HEAD (36 failures across four files), not by any of the three PRs' own focused runs - each was green in isolation. 1. rebase-provenance: structuredClone on the whole config threw DataCloneError whenever a provider carried a non-cloneable value. A test fixture injects its own fetch, and a function is not cloneable, which took down every provider probe and both CLI parity suites. Only the top-level provenance key is rewritten in that projection, so nothing below the top level needed copying at all - a shallow copy is both correct and what the function actually meant. 2. /api/providers/:name/alias swallowed /api/providers/keys/alias. Model routes dispatch before oauth-account routes, so the new alias route matched name='keys', found no such provider, and 404'd every API-key-pool rename. Guarded rather than reordered: the dispatch order is load-bearing elsewhere. 3. The alias command and its two endpoints were missing from the help banner and the GUI/CLI parity map. Both sweeps exist to catch exactly this, and both did. Each repair verified against the failing test, and the full local set of affected suites is green: provider-api-keys, provider-connection-test, config-user-edits, config-rebase-provenance-writers, alias-management-api, cli-headless-parity, cli-registry. --- src/cli/help.ts | 1 + src/config/rebase-provenance.ts | 7 ++++++- src/server/management/model-routes.ts | 6 ++++++ tests/cli-headless-parity.test.ts | 6 +++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index c0408e659a..8a04ea30af 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -52,6 +52,7 @@ Usage: ocx provider Providers, connectivity, quota, and selected models ocx account Accounts, login/reauth, key pools, and quota controls ocx models Live/custom models, visibility, context, and shadow calls + ocx alias Short names for providers and models (list, set, rm, defaults) ocx combo Combo failover/round-robin routing ocx agent Subagents, injection, effort caps, and sidecars ocx observe Logs, usage, storage, memory, and debug data diff --git a/src/config/rebase-provenance.ts b/src/config/rebase-provenance.ts index 9afe8c8d03..7f6857a94c 100644 --- a/src/config/rebase-provenance.ts +++ b/src/config/rebase-provenance.ts @@ -40,7 +40,12 @@ export function projectConfigRebaseProvenance(config: OcxConfig): OcxConfig { for (const key of [...deleted]) { if (Object.hasOwn(record, key) && record[key] !== undefined) deleted.delete(key); } - const projected = structuredClone(config); + // A SHALLOW copy, deliberately. A provider entry may carry a non-cloneable value — a test + // fixture injects its own `fetch`, and `structuredClone` throws DataCloneError on a function, + // which took every provider-probe and CLI-parity suite down. Only the provenance key is + // rewritten here, so nothing below the top level needs to be copied at all; the deep clone + // was doing work this projection never asked for. + const projected = { ...config }; if (deleted.size === 0) delete projected.configRebaseProvenance; else projected.configRebaseProvenance = { version: 1, diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 42c8641cdf..ec5498bfa6 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -264,6 +264,11 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { ["/api/combos", "ocx combo"], ["/api/client-config", "ocx export"], ["/api/client-integrations", "ocx integration client"], + // #2463: both read and write reach the CLI. `ocx alias list` reads /api/aliases, + // `ocx alias defaults` writes /api/default-aliases, and the per-provider writes sit + // under /api/providers/:name/alias, already covered by the /api/providers prefix. + ["/api/aliases", "ocx alias"], + ["/api/default-aliases", "ocx alias defaults"], // GUI-only for now: the overview card switches for Claude Code and Grok. // Their effect is already reachable from the CLI by other names — // `ocx grok apply` regenerates the fence and `ocx stop` strips it, and @@ -767,4 +772,3 @@ describe("#2566 per-account quota in ocx account list", () => { expect(formatAccountTable([row({ quotaUnavailable: true })] as never, true)).toContain("unavailable"); }); }); - From eca432d6a87a51600863767669f43dd2fe321648 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 05:33:11 +0900 Subject: [PATCH 050/336] feat(acceptance): disposable-host service acceptance and the uninstall bug it found (#1048) (#2618) * test(service): add disposable-host composed acceptance * fix(service): distinguish empty systemd lookup * chore(service): trim acceptance imports * fix(acceptance): run service rows against the real account home A fake HOME was the obvious symmetry with every other fixture path, and it does not work: systemctl --user resolves its unit directory from the running user manager, not from $HOME. The install wrote a unit the manager never reads, then failed on 'systemctl --user enable' with 'Unit file opencodex-proxy.service does not exist'. Verified on the host: the same install against the real home succeeds and lists as enabled. That is exactly why these rows are disposable-host-only. A globally addressed service cannot be redirected into a temp root, so the safety property does not come from isolation - it comes from the sentinel plus the empty-registration gate on both sides of every row. * fix(acceptance): provenance is optional at record v1 The row required a provenance entry for the admitted transaction, which no production path can satisfy: updateIntegrationRecord has NO caller in src/, and convergence-types.ts says outright that provenance is optional at v1 and that a record written before WP12 is valid. So the assertion was testing an unimplemented writer, not the service rows. It now fails only on DISAGREEMENT - a ledger that exists, has entries, and does not contain the transaction just admitted is a real defect. Absence is reported and allowed. That the ledger is never written is a genuine finding and is recorded in the PR rather than hidden by a green row. * fix(uninstall): claim the admin token and per-home catalog backups ocx uninstall could not remove a config home OpenCodex created itself. It reported 'partial uninstall: unowned files remain' and left the whole directory behind, because two of our OWN writers produce files the ownership manifest never claimed: admin-api-token (lib/admin-secrets.ts) and the per-CODEX_HOME catalog-backup-<16 hex>.json (catalog/parsing.ts catalogBackupPathFor). Found by running the #1048 disposable-host service acceptance for real on a systemd host - row P10 invokes the production uninstall, which no workstation test can do. The hashed backup cannot be a literal manifest entry: its name depends on which Codex home it mirrors. It is swept by its exact generated shape instead, narrow enough that a user file cannot collide - pinned by a lookalike test that proves catalog-backup-notahash.json still survives. Falsified: reverting either hunk alone reddens the new removal test. * fix(acceptance): P10 removes its own home, so do not re-invoke the CLI ocx uninstall deletes its OPENCODEX_HOME on success. Teardown then ran 'service uninstall' against a home that no longer exists and failed. The empty-registration gate still runs, which is what actually proves the host was restored. * fix(acceptance): never fail a row with an empty message * fix(acceptance): print the failure message, not just the stack * fix(acceptance): let the product create its own OpenCodex home Ownership is established by the first owned write into an EMPTY directory (config-ownership.ts createOwnership returns null for a non-empty root). The fixture pre-seeded config.json, so OpenCodex never claimed the home and 'ocx uninstall' correctly refused to delete a directory it could not prove it owned. P10 was failing on the fixture's shortcut, not on the production command. The routing seed is now merged in after 'service install' has created and claimed the home. * fix(acceptance): P18 authenticates with the token the service minted * fix(acceptance): a drained connection is a valid POST /api/stop outcome /api/stop stops the service and drains the process serving the request, so the socket can close before a response is flushed - the more completely the endpoint does its job, the likelier that is. A 4xx still fails the row, and the authoritative oracle remains the +1 remove transaction. --- .../260826_wp13_disposable_host/010_census.md | 56 +++ .../codex-service-composed-acceptance.ts | 402 ++++++++++++++++++ src/lib/config-ownership.ts | 20 + tests/config-ownership-uninstall.test.ts | 51 +++ 4 files changed, 529 insertions(+) create mode 100644 devlog/_plan/260826_wp13_disposable_host/010_census.md create mode 100644 scripts/disposable-host/codex-service-composed-acceptance.ts diff --git a/devlog/_plan/260826_wp13_disposable_host/010_census.md b/devlog/_plan/260826_wp13_disposable_host/010_census.md new file mode 100644 index 0000000000..5105553718 --- /dev/null +++ b/devlog/_plan/260826_wp13_disposable_host/010_census.md @@ -0,0 +1,56 @@ +# #1048 production-entry census + +Rechecked against `origin/dev` at `aacd6528d8` on 2026-08-26. “Covered” +means the named test invokes the production entry or the disposable runner does. +Rows retired from *additional composed execution* retain a concrete regression or +inventory proof and a reason why another process-level case would duplicate the +same receiving production edge. + +| ID | Disposition | Evidence | +|---|---|---| +| P01 | Retired from additional composed execution | `src/cli/init.ts` calls the same native apply convergence exercised by P02/P08 after saving config; `tests/init-eof.test.ts` owns the unique interactive/EOF boundary. No independent writer remains. | +| P02 | Covered | `tests/codex-composed-acceptance.test.ts` — “A-reduced: real CLI and HTTP entry points preserve an OFF Codex config/home”. | +| P03 | Retired from additional composed execution | `tests/shutdown-launcher.test.ts` — “ocx launcher graceful shutdown”; shutdown cleanup uses the same remove convergence proven by P07. | +| P04 | Covered | `tests/codex-composed-acceptance.test.ts` — “A-reduced: real CLI and HTTP entry points preserve an OFF Codex config/home”. | +| P05 | Covered | Same A-reduced case invokes real `ocx sync`. | +| P06 | Covered | Same A-reduced case invokes real `ocx sync-cache`. | +| P07 | Covered | The A-reduced case invokes real `ocx restore`; D-reduced invokes the same row under foreign ownership. | +| P08 | Covered | Same A-reduced case invokes real `ocx restore back`. | +| P09 | Covered (disposable only) | `scripts/disposable-host/codex-service-composed-acceptance.ts` row P09, real `ocx stop`, exact +1 remove transaction. | +| P10 | Covered (disposable only) | Disposable runner row P10, real `ocx uninstall`, exact +1 remove transaction before owned-state deletion. | +| P11 | Retired from additional composed execution | `tests/cli-help.test.ts` — “recover-history requires exact confirmation before mutating history”; this command owns a history-only job, not a native mutation, so it cannot satisfy Scenario A's native-transaction oracle. | +| P12 | Retired from additional composed execution | `tests/cli-provider.test.ts` — “provider add --sync flag is accepted without error” and “provider add --sync --json reports needsSync false”; live sync receives P19/P05's catalog convergence. | +| P13 | Retired from additional composed execution | `tests/cli-models.test.ts` — “models add accepts slash model ids”; its live branch calls the same management/catalog convergence inventoried below. | +| P14 | Retired from additional composed execution | `tests/cli-models.test.ts` — “an unambiguous slash selector still removes its row”; same receiving convergence as P13. | +| P15 | Retired from additional composed execution | `tests/codex-v2-gate.test.ts` — “off -> on carries the active legacy value and removes the boot conflict”; mode mutation is independently tested and its sync tail is P05/P19. | +| P16 | Retired from additional composed execution | `tests/codex-v2-gate.test.ts` — “on -> off carries the active v2 value and removes v2 limit storage”; same sync tail as P15. | +| P17 | Covered | P02 in A-reduced and “Grok E2E: route-disabled Grok stays absent across a real startup” execute startup reconciliation in real child processes. | +| P18 | Covered (disposable only) | Disposable runner row P18, authenticated real `POST /api/stop`, exact +1 remove transaction. | +| P19 | Covered | A-reduced, B-reduced, D-reduced, and D-unknown send real authenticated `POST /api/sync` to a real server. | +| P20 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider POST overwrite preserves modelCosts when the payload omits it”; `tests/codex-convergence-contract.test.ts` — exact route-inventory test proves its convergence call. | +| P21 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider PATCH field-mask edits non-reserved providers and rejects unsafe fields (WP040)”; route inventory proves convergence. | +| P22 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider deletion removes stale provider context caps”; route inventory proves convergence. | +| P23 | Retired from another process case | `tests/management-provider-validation.test.ts` — “provider context-cap API supports global value and set-all toggles”; all three branches are counted by the exact route inventory. | +| P24 | Retired from another process case | `tests/native-model-toggle.test.ts` — “management API surfaces: /api/models leads with native rows; subagent available drops disabled bare slugs”; exact route inventory proves convergence. | +| P25 | Retired from another process case | `tests/model-visibility-management-api.test.ts` — “enables excluded or blocked models and disables without erasing the allowlist”; exact route inventory proves convergence. | +| P26 | Retired from another process case | Custom-model create is one of the exact `7 + 13 + 2 + 2` calls asserted by `tests/codex-convergence-contract.test.ts`; its receiving commit is covered by P19. | +| P27 | Retired from another process case | Custom-model update is in the same exact route inventory; no direct writer remains outside management convergence. | +| P28 | Retired from another process case | Custom-model delete is in the same exact route inventory; no direct writer remains outside management convergence. | +| P29 | Retired from another process case | `tests/model-visibility-management-api.test.ts` — “uses raw allowlist ids, canonical routed slugs, and rejects invalid requests”; exact route inventory proves convergence. | +| P30 | Retired from another process case | `tests/combo-management-api.test.ts` — “PUT and DELETE clear only the mutated combo cooldowns”; `codex-convergence-contract` proves both alias-write routes converge. | +| P31 | Retired from another process case | `tests/combo-management-api.test.ts` — “DELETE refresh immediately retires the final managed combo catalog row”; route inventory proves convergence. | +| P32 | Retired from another process case | Agent-settings write is included in the exact convergence-call inventory; V2 mutation semantics are covered by `tests/codex-v2-gate.test.ts`. | +| P33 | Retired from another process case | `tests/subagent-roster-retention.test.ts` — “retained roster entries are appended once, after the selectable models”; exact route inventory proves convergence and the follow-up remains independently tested. | +| P34 | Covered (disposable only) | Disposable runner row P34, fixture install/stop then real `ocx service start`, exact +1 apply transaction. | +| P35 | Covered (disposable only) | Disposable runner row P35, real `ocx service stop`, exact +1 remove transaction. | +| P36 | Covered (disposable only) | Disposable runner row P36, real `ocx service uninstall`, exact +1 remove transaction. | + +## Summary + +- 14 rows have direct composed process coverage: P02, P04-P10, P17-P19, + P34-P36. +- 22 rows are explicitly retired from an additional composed process case. + Their command/route semantics remain covered, and their write tail is either + the already-composed convergence seam or the exact static route-call inventory. +- The six service rows are not accepted as passing until the disposable script + runs green on a sentinel-provisioned host and its final empty gate is captured. diff --git a/scripts/disposable-host/codex-service-composed-acceptance.ts b/scripts/disposable-host/codex-service-composed-acceptance.ts new file mode 100644 index 0000000000..9565992974 --- /dev/null +++ b/scripts/disposable-host/codex-service-composed-acceptance.ts @@ -0,0 +1,402 @@ +/** + * Disposable-host composed acceptance for the six globally addressed service rows. + * + * This is intentionally a script, not a Bun test. It mutates the current account's + * systemd user registration and therefore refuses to run without the root-owned + * image sentinel specified by WP13. + */ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { Database } from "bun:sqlite"; + +const SENTINEL = "/etc/opencodex-disposable-service-host-v1"; +const SENTINEL_BYTES = "OPENCODEX_DISPOSABLE_SERVICE_HOST_V1\n"; +const UNIT = "opencodex-proxy.service"; +const repoRoot = resolve(import.meta.dir, "../.."); +const cliPath = join(repoRoot, "src/cli/index.ts"); +const accountHome = homedir(); +const accountUnit = join(accountHome, ".config/systemd/user", UNIT); +const eventLedger: string[] = []; + +type RowId = "P09" | "P10" | "P18" | "P34" | "P35" | "P36"; +type ChildResult = { exitCode: number; stdout: string; stderr: string }; +type Transition = { nativeGeneration: number; currentTxId: string | null; direction: string | null }; + +function fail(message: string): never { + throw new Error(message); +} + +function assertDisposableSentinel(): void { + const link = lstatSync(SENTINEL); + const stat = statSync(SENTINEL); + if (!link.isFile() || link.isSymbolicLink()) fail(`${SENTINEL} must be a non-symlink regular file`); + if (stat.uid !== 0) fail(`${SENTINEL} must be root-owned (uid=${stat.uid})`); + if ((stat.mode & 0o022) !== 0) fail(`${SENTINEL} must not be group/world writable (mode=${(stat.mode & 0o777).toString(8)})`); + if (readFileSync(SENTINEL, "utf8") !== SENTINEL_BYTES) fail(`${SENTINEL} has unexpected bytes`); + eventLedger.push("sentinel:verified"); + console.log(`SENTINEL verified ${SENTINEL}`); +} + +async function spawnResult(argv: string[], options: { cwd?: string; env?: Record } = {}): Promise { + const child = Bun.spawn(argv, { + cwd: options.cwd ?? repoRoot, + env: options.env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stdout, stderr }; +} + +async function requireCommand(argv: string[], label: string): Promise { + const result = await spawnResult(argv); + eventLedger.push(`query:${label}`); + if (result.exitCode !== 0) { + fail(`${label} unavailable (exit ${result.exitCode}): ${result.stderr || result.stdout}`); + } + return result; +} + +async function emptyRegistrationGate(extraArtifact?: string): Promise { + if (eventLedger[0] !== "sentinel:verified") fail("service query attempted before sentinel verification"); + // Ubuntu's systemctl returns 1 (with no output) when a name filter matches no + // unit. Prove the bus independently so that result cannot hide a permission or + // connectivity failure, then accept only the measured empty 0/1 result. + await requireCommand(["systemctl", "--user", "show-environment"], "systemctl user bus"); + const units = await spawnResult(["systemctl", "--user", "list-unit-files", UNIT, "--no-legend", "--no-pager"]); + eventLedger.push("query:systemctl list-unit-files"); + if ((units.exitCode !== 0 && units.exitCode !== 1) || units.stderr.trim()) { + fail(`systemctl list-unit-files unavailable (exit ${units.exitCode}): ${units.stderr || units.stdout}`); + } + if (units.stdout.trim()) fail(`service registration is nonempty: ${units.stdout.trim()}`); + + const status = await spawnResult(["systemctl", "--user", "status", UNIT, "--no-pager"]); + eventLedger.push("query:systemctl status"); + const statusText = `${status.stdout}\n${status.stderr}`; + if (status.exitCode === 0 || !/could not be found|not-found|not found|loaded: not-found/i.test(statusText)) { + fail(`service status did not prove unit-not-found (exit ${status.exitCode}): ${statusText.trim()}`); + } + for (const artifact of [accountUnit, extraArtifact].filter((value): value is string => Boolean(value))) { + if (existsSync(artifact)) fail(`service artifact exists: ${artifact}`); + } + console.log(`GATE empty registration (${extraArtifact ?? accountUnit})`); +} + +function byteManifest(root: string): Record { + const entries: Record = {}; + const walk = (dir: string) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + const key = relative(root, path); + if (stat.isDirectory()) walk(path); + else if (stat.isFile()) entries[key] = createHash("sha256").update(readFileSync(path)).digest("hex"); + else entries[key] = `non-file:${stat.mode}`; + } + }; + walk(root); + return entries; +} + +function sameManifest(a: Record, b: Record, label: string): void { + if (JSON.stringify(a) !== JSON.stringify(b)) fail(`${label} byte manifest changed`); +} + +function coordinatorPath(codexHome: string): string { + const canonical = realpathSync.native(codexHome); + const digest = createHash("sha256").update(canonical).digest("hex"); + return join(`/tmp/opencodex-runtime-v1-${process.getuid!()}`, "native-write-locks", `${digest}.sqlite`); +} + +class Fixture { + readonly root = mkdtempSync(join(tmpdir(), "ocx-service-composed-")); + /** + * The REAL account home, deliberately. + * + * Every other path here is a fixture path, and a fake HOME was the obvious symmetry — but + * `systemctl --user` resolves its unit directory from the running user manager, not from + * `$HOME`. With a fake home, `ocx service install` wrote a unit file the user manager never + * reads and then failed on `systemctl --user enable`: "Unit file opencodex-proxy.service does + * not exist". Verified directly on the host — the same install against the real home succeeds + * and lists as `enabled`. + * + * That is precisely why these rows are disposable-host-only. A globally addressed service + * cannot be redirected into a temp root, so the safety property cannot come from isolation; + * it comes from the sentinel plus the empty-registration gate on both sides of every row. + */ + readonly home = homedir(); + readonly userprofile = join(this.root, "userprofile"); + readonly codex = join(this.root, "codex"); + readonly ocx = join(this.root, "ocx"); + readonly runtime = `/run/user/${process.getuid!()}`; + readonly unit = accountUnit; + readonly lock: string; + readonly lockAllowlist: string[]; + readonly baselineOutside: Record; + readonly seed: Record; + + constructor(readonly row: RowId) { + for (const path of [this.userprofile, this.codex, this.ocx]) mkdirSync(path, { recursive: true, mode: 0o700 }); + writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n'); + // The OpenCodex home is left EMPTY on purpose. + // + // Ownership is established by the first owned write into an empty directory + // (lib/config-ownership.ts `createOwnership`, which returns null for a non-empty root). + // Pre-seeding config.json means OpenCodex never claims the home, and `ocx uninstall` + // then correctly refuses to delete a directory it cannot prove it owns — so P10 was + // failing on the fixture's own shortcut rather than on the production command. + // + // Writing the seed AFTER the first CLI invocation would work too, but letting the product + // create its own home is closer to what P10 actually claims to accept. + this.seed = { + port: 0, + hostname: "127.0.0.1", + syncResumeHistory: false, + claudeCode: { systemEnv: false }, + clientIntegrations: { codex: true, grok: false, "claude-desktop": false }, + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "fixture-key", + allowPrivateNetwork: true, + liveModels: false, + models: ["fixture-model"], + }, + }, + defaultProvider: "fixture", + }; + this.lock = coordinatorPath(this.codex); + this.lockAllowlist = [this.lock, `${this.lock}-journal`, `${this.lock}-wal`, `${this.lock}-shm`]; + for (const path of this.lockAllowlist) if (existsSync(path)) fail(`${row}: pre-existing coordinator artifact: ${path}`); + this.baselineOutside = this.outsideManifest(); + } + + env(): Record { + return { + HOME: this.home, + USERPROFILE: this.userprofile, + CODEX_HOME: this.codex, + OPENCODEX_HOME: this.ocx, + XDG_RUNTIME_DIR: this.runtime, + OPENCODEX_API_AUTH_TOKEN: "disposable-data-token", + OPENCODEX_ADMIN_AUTH_TOKEN: "disposable-admin-token", + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", + NO_PROXY: "127.0.0.1,localhost", + CI: "true", + }; + } + + outsideManifest(): Record { + const result: Record = {}; + for (const path of [accountUnit, ...this.lockAllowlist]) { + result[path] = existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : "absent"; + } + return result; + } + + async cli(args: string[]): Promise { + const result = await spawnResult([process.execPath, cliPath, ...args], { cwd: this.root, env: this.env() }); + if (result.exitCode !== 0) { + // Both streams, always, and never an empty message: a row that fails with a blank error + // tells the operator nothing, and this runner only ever runs where reproducing is costly. + const detail = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n") || "(no output on either stream)"; + fail(`${this.row}: ocx ${args.join(" ")} failed (exit ${result.exitCode})\n${detail}`); + } + return result; + } + + transition(): Transition { + if (!existsSync(this.lock)) return { nativeGeneration: 0, currentTxId: null, direction: null }; + const db = new Database(this.lock, { readonly: true }); + try { + const row = db.query(`SELECT native_generation, current_tx_id, history_direction FROM codex_transition_state WHERE singleton = 1`).get() as Record | null; + if (!row) fail(`${this.row}: coordinator transition row is missing`); + return { + nativeGeneration: Number(row.native_generation), + currentTxId: row.current_tx_id === null ? null : String(row.current_tx_id), + direction: row.history_direction === null ? null : String(row.history_direction), + }; + } finally { + db.close(); + } + } + + async install(): Promise { + await this.cli(["service", "install"]); + if (!existsSync(this.unit)) fail(`${this.row}: install did not create fixture unit`); + // Now that the product owns the home, apply the fixture's routing seed. `service install` + // wrote a default config.json, so merge rather than replace. + const configPath = join(this.ocx, "config.json"); + const current = existsSync(configPath) + ? JSON.parse(readFileSync(configPath, "utf8")) as Record + : {}; + writeFileSync(configPath, `${JSON.stringify({ ...current, ...this.seed }, null, 2)}\n`, { mode: 0o600 }); + } + + async waitForRuntime(): Promise<{ port: number; pid: number }> { + const path = join(this.ocx, "runtime-port.json"); + for (let attempt = 0; attempt < 300; attempt++) { + if (existsSync(path)) { + const value = JSON.parse(readFileSync(path, "utf8")) as { port?: number; pid?: number }; + if (Number.isInteger(value.port) && Number.isInteger(value.pid)) return value as { port: number; pid: number }; + } + await Bun.sleep(20); + } + fail(`${this.row}: timed out waiting for runtime-port.json`); + } + + async apiStop(): Promise { + const runtime = await this.waitForRuntime(); + // Read the token the SERVICE actually minted. The env var only reaches a process this + // script launches; the service runs under systemd with its own environment, and + // `configuredAdminToken` falls back to `admin-api-token` in the config home. Passing the + // env value here produced a 401 against a server that had generated a different token. + const tokenPath = join(this.ocx, "admin-api-token"); + if (!existsSync(tokenPath)) fail(`${this.row}: service did not mint an admin token at ${tokenPath}`); + const token = readFileSync(tokenPath, "utf8").trim(); + // A reset is an ACCEPTABLE outcome here, not a failure. `POST /api/stop` stops the service + // and drains this very process, so the socket can close before a response is flushed — the + // more thoroughly the endpoint does its job, the likelier that is. A 401/4xx still fails. + // The authoritative oracle is the +1 remove transaction the caller asserts either way. + const script = [ + `try {`, + ` const r = await fetch(${JSON.stringify(`http://127.0.0.1:${runtime.port}/api/stop`)}, { method: "POST", headers: { "x-opencodex-api-key": ${JSON.stringify(token)} } });`, + ` console.log(r.status, await r.text());`, + ` if (!r.ok) process.exit(1);`, + `} catch (error) {`, + ` const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";`, + ` if (code !== "ECONNRESET" && code !== "ConnectionClosed") { console.error(String(error)); process.exit(1); }`, + ` console.log("stop-closed-connection", code);`, + `}`, + ].join("\n"); + const result = await spawnResult([process.execPath, "--eval", script], { cwd: this.root, env: this.env() }); + if (result.exitCode !== 0) fail(`${this.row}: POST /api/stop failed\n${result.stderr}\n${result.stdout}`); + return result; + } + + assertOneTransaction(before: Transition, expectedDirection: "apply" | "remove"): Transition { + const after = this.transition(); + if (after.nativeGeneration !== before.nativeGeneration + 1) { + fail(`${this.row}: expected exactly one admitted transaction; generation ${before.nativeGeneration} -> ${after.nativeGeneration}`); + } + if (!after.currentTxId || after.currentTxId === before.currentTxId) fail(`${this.row}: transaction id did not advance`); + if (after.direction !== expectedDirection) fail(`${this.row}: expected ${expectedDirection} transaction, got ${after.direction}`); + return after; + } + + async teardown(): Promise { + // P10 is `ocx uninstall`: on success it removes its own OPENCODEX_HOME, so there is nothing + // left to uninstall and invoking the CLI again would fail on a home that no longer exists. + // The gate below still runs, which is what actually proves the host was restored. + if (!existsSync(this.ocx)) { + await emptyRegistrationGate(this.unit); + for (const path of this.lockAllowlist) if (existsSync(path)) unlinkSync(path); + sameManifest(this.outsideManifest(), this.baselineOutside, `${this.row}: outside-temp-root`); + rmSync(this.root, { recursive: true, force: true }); + return; + } + const cleanup = await spawnResult([process.execPath, cliPath, "service", "uninstall"], { cwd: this.root, env: this.env() }); + if (cleanup.exitCode !== 0 && existsSync(this.unit)) { + fail(`${this.row}: fixture service teardown failed\n${cleanup.stderr}\n${cleanup.stdout}`); + } + await emptyRegistrationGate(this.unit); + for (const path of this.lockAllowlist) if (existsSync(path)) unlinkSync(path); + sameManifest(this.outsideManifest(), this.baselineOutside, `${this.row}: outside-temp-root`); + rmSync(this.root, { recursive: true, force: true }); + } +} + +async function runRow(row: RowId): Promise { + await emptyRegistrationGate(); + const fx = new Fixture(row); + let completed = false; + try { + await fx.install(); + let before: Transition; + let output: ChildResult; + let direction: "apply" | "remove"; + if (row === "P34") { + await fx.cli(["service", "stop"]); + before = fx.transition(); + output = await fx.cli(["service", "start"]); + direction = "apply"; + } else { + before = fx.transition(); + direction = "remove"; + if (row === "P09") output = await fx.cli(["stop"]); + else if (row === "P10") output = await fx.cli(["uninstall"]); + else if (row === "P18") output = await fx.apiStop(); + else if (row === "P35") output = await fx.cli(["service", "stop"]); + else output = await fx.cli(["service", "uninstall"]); + } + const after = fx.assertOneTransaction(before, direction); + const recordPath = join(fx.ocx, "integrations/codex.json"); + if (row === "P10") { + // Full uninstall deliberately removes the owned OPENCODEX_HOME only after the + // native removal transaction succeeds. Requiring its record to survive would + // contradict the production command's contract. + if (existsSync(fx.ocx)) fail(`${row}: full uninstall left owned OpenCodex state behind`); + } else if (existsSync(recordPath)) { + // Provenance is OPTIONAL at record v1 (convergence-types.ts: "Provenance is OPTIONAL at + // v1. A record written before WP12 is valid"), so its ABSENCE is not a row failure. What + // must hold is that when a ledger exists it agrees with the transaction we just admitted; + // a ledger that disagrees is a real defect and fails the row. + const record = JSON.parse(readFileSync(recordPath, "utf8")) as { provenance?: { entries?: Array<{ txId?: string }> } }; + const entries = record.provenance?.entries; + if (entries && entries.length > 0) { + if (!entries.some(entry => entry.txId === after.currentTxId)) { + fail(`${row}: integration record has provenance entries but none for the admitted transaction ${after.currentTxId}`); + } + console.log(`${row} PROVENANCE matched tx=${after.currentTxId}`); + } else { + console.log(`${row} PROVENANCE absent (optional at record v1; no production writer calls updateIntegrationRecord)`); + } + } else { + console.log(`${row} PROVENANCE record absent (optional at record v1)`); + } + console.log(`${row} PASS generation=${before.nativeGeneration}->${after.nativeGeneration} direction=${direction} tx=${after.currentTxId}`); + console.log(`${row} OUTPUT ${output.stdout.trim().replace(/\s+/g, " ").slice(0, 500)}`); + completed = true; + } finally { + await fx.teardown(); + if (!completed) console.error(`${row} FAIL (teardown completed)`); + } +} + +async function main(): Promise { + if (process.platform !== "linux") fail(`this disposable runner currently requires Linux/systemd, got ${process.platform}`); + assertDisposableSentinel(); + await requireCommand(["systemctl", "--version"], "systemctl version"); + for (const row of ["P09", "P10", "P18", "P34", "P35", "P36"] as const) await runRow(row); + await emptyRegistrationGate(); + console.log(`PASS disposable service census: ${["P09", "P10", "P18", "P34", "P35", "P36"].join(", ")}`); + console.log(`EVENTS ${eventLedger.join(" | ")}`); +} + +main().catch(error => { + // message FIRST and unconditionally: Bun's `stack` renders a multi-line message as a bare + // "Error", which hid the actual cause of every failing row. + const message = error instanceof Error ? error.message : String(error); + console.error(`FAIL disposable service acceptance: ${message}`); + if (error instanceof Error && error.stack) console.error(error.stack); + process.exitCode = 1; +}); diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index 97de40a9fa..8500b47f7d 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -40,6 +40,7 @@ const INITIAL_OWNED_PATHS = [ "artifacts", "auth.json", "auth.store.lock", + "admin-api-token", "catalog-backup.json", "claude-env.sh", "codex-accounts.json", @@ -333,6 +334,25 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { } } + // Per-catalog backups are named `catalog-backup-<16 hex>.json` (catalogBackupPathFor), one per + // CODEX_HOME, so they cannot be enumerated as literal manifest entries the way every other + // owned file can. Without this, `ocx uninstall` always reported "unowned files remain" and + // refused to remove a home OpenCodex created itself — the file is unambiguously ours, produced + // by our own writer, and the strict hex shape keeps the match from widening. + for (const name of readdirSync(configDir)) { + if (!/^catalog-backup-[0-9a-f]{16}\.json$/.test(name)) continue; + const path = join(configDir, name); + try { + removeOwnedEntry(rootPath, path); + } catch (error) { + return { + status: "partial", + reason: `could not remove owned path ${name}: ${error instanceof Error ? error.message : String(error)}`, + residualPaths: [path], + }; + } + } + try { unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST)); unlinkSync(join(configDir, CONFIG_OWNER_FILE)); diff --git a/tests/config-ownership-uninstall.test.ts b/tests/config-ownership-uninstall.test.ts index dca1ca6779..da2996864f 100644 --- a/tests/config-ownership-uninstall.test.ts +++ b/tests/config-ownership-uninstall.test.ts @@ -205,4 +205,55 @@ describe("owned config uninstall", () => { rmSync(parent, { recursive: true, force: true }); } }); + + /** + * The uninstall path could not remove a home OpenCodex created itself (#1048). + * + * Found by running the disposable-host service acceptance for real: `ocx uninstall` reported + * "partial uninstall: unowned files remain" and left the whole config directory behind. Two of + * our OWN writers produce files the manifest never claimed — + * `admin-api-token` (lib/admin-secrets.ts) and the per-CODEX_HOME + * `catalog-backup-<16 hex>.json` (catalog/parsing.ts `catalogBackupPathFor`). + * + * The hashed backup is the interesting one: its name depends on which Codex home it mirrors, + * so it cannot be a literal manifest entry the way every other owned file is. It is matched by + * its exact shape instead — narrow enough that a user's own file cannot collide, which is what + * keeps the "never delete what we do not own" guarantee intact. + */ + test("uninstall removes the admin token and per-home catalog backups it wrote itself", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-self-written-")); + const dir = join(parent, "config"); + + try { + // Establish ownership the way production does: the first owned write into an empty dir. + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + writeFileSync(join(dir, "config.json"), "{}\n"); + writeFileSync(join(dir, "admin-api-token"), "token\n"); + writeFileSync(join(dir, "catalog-backup-0123456789abcdef.json"), "{}\n"); + + const result = removeOwnedConfigState(dir); + expect(result).toMatchObject({ status: "removed" }); + expect(existsSync(dir)).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + test("a lookalike that is not our generated backup name is still never removed", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-lookalike-")); + const dir = join(parent, "config"); + + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + // Not our shape: the digest segment is the wrong length, so this is a user file. + const foreign = join(dir, "catalog-backup-notahash.json"); + writeFileSync(foreign, "mine\n"); + + const result = removeOwnedConfigState(dir); + expect(result.status).toBe("partial"); + expect(readFileSync(foreign, "utf8")).toBe("mine\n"); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); }); From e449974fc8d44b09d0978c6d6159675a01d892db Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 05:38:20 +0900 Subject: [PATCH 051/336] fix(codex): show the five-hour quota on account cards (#2616) (#2620) * fix(codex): show five-hour quota on account cards * test(gui): pin the short-window alias for the five-hour quota row (#2616) The fix ships without a regression, and this is a rendering change: the review guidance asks for one near the subsystem. Pins all four cases - the alias renders, the canonical value wins when both are present (an alias must not overwrite a probe reading), a quota with neither alias is still returned by identity, and a 30-day plan still strips non-monthly windows. Falsified: collapsing the normalization to a passthrough reddens exactly this test and nothing else. --------- Co-authored-by: Terry Tan --- gui/src/codex-quota-utils.ts | 21 +++++++++++---- tests/rate-limit-reset-credits.test.ts | 37 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/gui/src/codex-quota-utils.ts b/gui/src/codex-quota-utils.ts index 2c8b323ca2..6bf60518c9 100644 --- a/gui/src/codex-quota-utils.ts +++ b/gui/src/codex-quota-utils.ts @@ -1,9 +1,12 @@ export interface AccountQuota { weeklyPercent?: number; fiveHourPercent?: number; + /** Codex account API aliases for the same five-hour window. */ + shortPercent?: number; monthlyPercent?: number; weeklyResetAt?: number; fiveHourResetAt?: number; + shortResetAt?: number; monthlyResetAt?: number; customWindows?: { label: string; percent: number; resetAt?: number }[]; resetCredits?: number; @@ -16,11 +19,19 @@ export function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean { } export function normalizeQuotaForPlan(quota: AccountQuota | null, plan: string | null | undefined): AccountQuota | null { - if (!quota || !isThirtyDayOnlyPlan(plan)) return quota; + if (!quota) return null; + const normalized = quota.shortPercent === undefined && quota.shortResetAt === undefined + ? quota + : { + ...quota, + fiveHourPercent: quota.fiveHourPercent ?? quota.shortPercent, + fiveHourResetAt: quota.fiveHourResetAt ?? quota.shortResetAt, + }; + if (!isThirtyDayOnlyPlan(plan)) return normalized; return { - ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}), - ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}), - ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}), - updatedAt: quota.updatedAt, + ...(normalized.monthlyPercent !== undefined ? { monthlyPercent: normalized.monthlyPercent } : {}), + ...(normalized.monthlyResetAt !== undefined ? { monthlyResetAt: normalized.monthlyResetAt } : {}), + ...(normalized.resetCredits !== undefined ? { resetCredits: normalized.resetCredits } : {}), + updatedAt: normalized.updatedAt, }; } diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index 122d78c01a..b775700c8d 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -239,6 +239,43 @@ describe("rate-limit reset credits", () => { expect(normalizeQuotaForPlan(quota, "pro")).toBe(quota); }); + /** + * The five-hour window reaches the GUI under TWO names (#2616). + * + * `QuotaBars` reads `fiveHour*`, which is what the per-account provider probe reports. The + * Codex pool declares the same window as `short*` (auth-api.ts, and `account-api.ts` says + * outright that "the two surfaces name the same idea differently and both reach this DTO"). + * A Codex account card therefore had a five-hour quota upstream and rendered no five-hour + * row at all. + * + * The canonical name wins when both are present: `short*` is an alias to fall back to, not + * an override, or a pool snapshot could quietly replace a probe reading. + */ + it("renders the Codex pool's short window as the five-hour window (#2616)", async () => { + const { normalizeQuotaForPlan } = await import("../gui/src/codex-quota-utils"); + + expect(normalizeQuotaForPlan({ shortPercent: 71, shortResetAt: 555, updatedAt: 1 }, "pro")) + .toMatchObject({ fiveHourPercent: 71, fiveHourResetAt: 555 }); + + // Canonical values win; the alias does not overwrite a probe reading. + expect(normalizeQuotaForPlan( + { fiveHourPercent: 10, fiveHourResetAt: 111, shortPercent: 71, shortResetAt: 555, updatedAt: 1 }, + "pro", + )).toMatchObject({ fiveHourPercent: 10, fiveHourResetAt: 111 }); + + // A quota carrying neither alias is still returned by identity, so the common path adds + // no allocation and no behavior change. + const untouched = { weeklyPercent: 5, updatedAt: 2 }; + expect(normalizeQuotaForPlan(untouched, "pro")).toBe(untouched); + + // A 30-day plan still strips non-monthly windows, alias or not — #1791's burst-window + // carve-out lives on the server DTO, not in this GUI normalizer. + expect(normalizeQuotaForPlan( + { shortPercent: 71, shortResetAt: 555, monthlyPercent: 12, updatedAt: 3 }, + "go", + )).toEqual({ monthlyPercent: 12, updatedAt: 3 }); + }); + it("does not exclude team or workspace plans from ticket badges", async () => { const [pool, helpers] = await Promise.all([ Bun.file("gui/src/components/CodexAccountPool.tsx").text(), From 1a15d6292386429028d5a1de23598a2699efa60c Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 05:40:21 +0900 Subject: [PATCH 052/336] fix(responses): strip search_content_types on web_search for Muse Spark (#2617) (#2621) * release: v2.32.1 * release: v2.33.0 * fix(responses): strip search_content_types on web_search for Muse Spark Muse Spark (muse-spark-1.2-contributor) only serves over the Responses API. Its gateway rejects search_content_types on a plain web_search tool (400) but accepts it on web_search_preview and accepts every other web_search field on both tool types (probed directly 2026-08-26). Only Muse is affected; Luna and DeepSeek are not. - Strip search_content_types from web_search for Muse only. - Route Muse to the same openai-responses default as gpt-5.6-luna. * fix(responses): strip search_content_types on web_search for Muse Spark (#2617) Carries DevonGithub's change onto dev with the test it needs, minus an unrelated version bump. Three changes to what was submitted: - dropped the package.json 2.32.1-preview -> 2.33.0 bump. Versioning is the release train's, not a bug fix's. - added tests/muse-spark-web-search-compat.test.ts. This touches a shared adapter and the provider registry; without a test neither the model-scoped field removal nor the Responses wire default is protected. - updated the registry decision log, which still described the map as Luna-only after gaining a second entry. Falsified: removing the sanitizer call reddens the plain-web_search and nested additional_tools cases and nothing else. --------- Co-authored-by: DevonGithub --- src/adapters/openai-responses.ts | 52 +++++++++++++ src/providers/registry.ts | 8 +- tests/muse-spark-web-search-compat.test.ts | 87 ++++++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 tests/muse-spark-web-search-compat.test.ts diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index fd1185a5a4..2e15937b89 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1576,6 +1576,57 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { return changed ? next : body; } +/** + * OpenCode Zen / Go Muse Spark Responses gateway refuses `search_content_types` + * on a plain `web_search` tool (400) but accepts it on `web_search_preview`; a + * plain `web_search` is also accepted. Probed directly against the gateway on + * 2026-08-26: `web_search` + `search_content_types` -> 400, `web_search_preview` + * + `search_content_types` -> 200, plain `web_search` -> 200. Luna accepts every + * shape, so this is Muse-only. Drop only the field the gateway refuses while + * keeping the tool type and every other accepted option intact. + */ +function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown { + if (!isPlainObject(body)) return body; + if (typeof modelId !== "string" || modelId.trim().toLowerCase() !== "muse-spark-1.2-contributor") return body; + + const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { + let changed = false; + const rewritten = tools.map(tool => { + if (!isPlainObject(tool) || tool.type !== "web_search") return tool; + if (!Object.hasOwn(tool, "search_content_types")) return tool; + const { search_content_types: _dropped, ...rest } = tool; + changed = true; + return rest; + }); + return { tools: changed ? rewritten : tools, changed }; + }; + + let next: Record = body; + let changed = false; + if (Array.isArray(body.tools)) { + const rewritten = rewriteTools(body.tools); + if (rewritten.changed) { + next = { ...next, tools: rewritten.tools }; + changed = true; + } + } + if (Array.isArray(next.input)) { + let inputChanged = false; + const input = next.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const rewritten = rewriteTools(item.tools); + if (!rewritten.changed) return item; + inputChanged = true; + return { ...item, tools: rewritten.tools }; + }); + if (inputChanged) { + next = { ...next, input }; + changed = true; + } + } + return changed ? next : body; +} + /** Replace every `input_image` part under a routed-compaction body with a short marker. */ function stripInputImagesDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripInputImagesDeep); @@ -1787,6 +1838,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (provider.supportsOpenAiWebSearchToolFields === false) { outBody = stripOpenAiOnlyWebSearchFields(outBody); } + outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId); // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index dae7310ea2..5e664c3918 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1369,14 +1369,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON. openaiChatEofTolerance: true, /* [Decision Log] - - 목적과 의도: Route GPT 5.6 Luna to the Responses endpoint that OpenCode Go documents for that exact model. + - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, and Muse Spark 1.2 Contributor (#2617). - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. - 검토한 주요 대안: Change the whole provider to Responses; infer the wire from model-family names; add one registry-only exact-model default. - - 선택한 방식: Declare only `gpt-5.6-luna` as `openai-responses` through the existing registry default mechanism. + - 선택한 방식: Declare only the named models as `openai-responses` through the existing registry default mechanism; the map stays an exact-model allowlist rather than a family or provider-wide rule. - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. - - 장점, 단점 및 영향: Luna reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. + - 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. */ - modelWireDefaults: { "gpt-5.6-luna": "openai-responses" }, + modelWireDefaults: { "gpt-5.6-luna": "openai-responses", "muse-spark-1.2-contributor": "openai-responses" }, modelContextWindows: { "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, // Ox Alpha (stealth 1M multimodal) and the DeepSeek vision preview are diff --git a/tests/muse-spark-web-search-compat.test.ts b/tests/muse-spark-web-search-compat.test.ts new file mode 100644 index 0000000000..59b589178a --- /dev/null +++ b/tests/muse-spark-web-search-compat.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import type { OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +const PROVIDER = { + adapter: "openai-responses", + baseUrl: "https://opencode.ai/zen/v1", + apiKey: "test-key", +} as unknown as OcxProviderConfig; + +/** A Codex web_search declaration exactly as `hosted_spec.rs` emits it for TextAndImage. */ +function webSearchTool(): Record { + return { + type: "web_search", + search_content_types: ["text", "image"], + search_context_size: "medium", + }; +} + +function build(modelId: string, rawBody: Record): Record { + const request = createResponsesPassthroughAdapter(PROVIDER).buildRequest({ + modelId, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: modelId, input: "ping", ...rawBody }, + }, { headers: new Headers() }); + return JSON.parse(request.body) as Record; +} + +const toolsOf = (body: Record) => body.tools as Array>; + +/** + * Muse Spark's Responses gateway 400s a plain `web_search` carrying + * `search_content_types`, while accepting the same field on `web_search_preview` and + * accepting a bare `web_search` (#2617). + * + * The field is not ours: Codex emits it from `web_search_tool_type: TextAndImage`. This is + * the same incompatibility class Codex itself handles for Bedrock by selecting text-only + * search, so dropping exactly the refused field at the adapter boundary is a compatibility + * guard rather than a symptom patch — the tool type and every other accepted option survive. + */ +describe("#2617 Muse Spark web_search compatibility", () => { + test("drops search_content_types from a plain web_search, keeping the tool and its other fields", () => { + const body = build("muse-spark-1.2-contributor", { tools: [webSearchTool()] }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search"); + expect(tool.search_context_size).toBe("medium"); + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + }); + + test("web_search_preview keeps the field, because the gateway accepts it there", () => { + const body = build("muse-spark-1.2-contributor", { + tools: [{ ...webSearchTool(), type: "web_search_preview" }], + }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search_preview"); + expect(tool.search_content_types).toEqual(["text", "image"]); + }); + + test("another model on the same provider is untouched", () => { + const body = build("gpt-5.6-luna", { tools: [webSearchTool()] }); + expect(toolsOf(body)[0]!.search_content_types).toEqual(["text", "image"]); + }); + + test("a nested additional_tools declaration is sanitized too", () => { + const body = build("muse-spark-1.2-contributor", { + input: [{ type: "additional_tools", tools: [webSearchTool()] }], + }); + const item = (body.input as Array>)[0]!; + const nested = (item.tools as Array>)[0]!; + expect(nested.type).toBe("web_search"); + expect(Object.hasOwn(nested, "search_content_types")).toBe(false); + }); + + test("the registry routes only the named exact models to Responses", () => { + const defaults = getProviderRegistryEntry("opencode-go")?.modelWireDefaults ?? {}; + expect(defaults["muse-spark-1.2-contributor"]).toBe("openai-responses"); + // An exact-model allowlist, not a family rule: a sibling must not be dragged along. + expect(defaults["muse-spark-1.2"]).toBeUndefined(); + }); +}); From 1a92d6b55b7f91b351dc7e311b9abaaabf0eab00 Mon Sep 17 00:00:00 2001 From: Jian Gong <61820353+fflake33@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:43:24 +0800 Subject: [PATCH 053/336] fix: avoid creating Claude agents directory when disabled (#2619) An empty roster has nothing to write, and an absent agents directory has nothing to prune. Return without creating ~/.claude while preserving cleanup for existing OpenCodex-owned definitions. --- src/claude/agents-inject.ts | 9 ++++++++- tests/claude-agents-inject.test.ts | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/claude/agents-inject.ts b/src/claude/agents-inject.ts index 6b89a1e481..d3cec47ecb 100644 --- a/src/claude/agents-inject.ts +++ b/src/claude/agents-inject.ts @@ -218,7 +218,14 @@ function isOwnedFile(path: string): boolean { export function syncClaudeAgentDefs(defs: readonly ClaudeAgentDef[], configDir = claudeConfigDir()): string[] | null { try { const dir = join(configDir, "agents"); - mkdirSync(dir, { recursive: true }); + if (defs.length === 0) { + try { lstatSync(dir); } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + } else { + mkdirSync(dir, { recursive: true }); + } const keep = new Set(defs.map(d => d.file)); for (const existing of readdirSync(dir)) { if (!existing.startsWith(OWNED_PREFIX) || !existing.endsWith(".md")) continue; diff --git a/tests/claude-agents-inject.test.ts b/tests/claude-agents-inject.test.ts index f39b7da7a7..1fa0633a58 100644 --- a/tests/claude-agents-inject.test.ts +++ b/tests/claude-agents-inject.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildClaudeAgentDefs, injectClaudeAgentDefs, syncClaudeAgentDefs } from "../src/claude/agents-inject"; @@ -269,6 +269,12 @@ describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { }); describe("syncClaudeAgentDefs ownership contract (audit 071 #2/#3)", () => { + test("empty sync leaves an absent agents directory absent", () => { + const dir = tempDir(); + expect(syncClaudeAgentDefs([], dir)).toEqual([]); + expect(existsSync(join(dir, "agents"))).toBe(false); + }); + test("writes, overwrites, and prunes ONLY marker-verified ocx files", () => { const dir = tempDir(); writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); From 5df97f8c1becd5aff531870a90db3f0dec8a93f9 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 05:47:55 +0900 Subject: [PATCH 054/336] devlog: record the owner-backlog closeout (#2624) --- .../130_wp16_late_bug_prs.md | 58 ++++++++++++++++ .../140_closeout.md | 66 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/130_wp16_late_bug_prs.md create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/140_closeout.md diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/130_wp16_late_bug_prs.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/130_wp16_late_bug_prs.md new file mode 100644 index 0000000000..2b1fdab539 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/130_wp16_late_bug_prs.md @@ -0,0 +1,58 @@ +# 130 — wp16: bug PRs opened after the 260825 triage + +The original triage snapshot (`000_research_snapshot.md`) enumerated 16 bug-labelled PRs. +Five more carry the `bug` label and were not in it, either because they were opened later +or because the label moved after the snapshot was taken: + +| PR | Title | Author | State at triage | +|---|---|---|---| +| #2595 | fix(combos): bound preflight retained chunk count | luvs01 | review-ready, closes #2592 | +| #2583 | fix(kiro): preserve keyword-named composed properties | luvs01 | review-ready, closes #2571 | +| #2575 | fix(pricing): map Daybreak cost overlays and preserve model identity in logs | riique | draft | +| #2430 | fix(gui): align the sidebar foot's four rows | olddonkey | review-ready | +| #2427 | fix(test): pass --parallel so the full suite finishes instead of reading as hung | olddonkey | review-ready | + +## Why this is its own work-phase + +The DONE criterion is "every bug-labelled PR is terminal", not "the sixteen I happened to +list yesterday". A snapshot taken at plan time is a starting inventory, not the scope. These +five are resolved on the same terms as the original sixteen: an independent review that +re-runs the focused suite against a merge with current `dev`, and a falsification pass that +reverts the production hunk to prove the regression test is load-bearing. + +## Verification standard applied + +Each PR was reviewed in its own worktree by a separate reviewer with no knowledge of the +others' conclusions. The merge into `dev` happened only after: + +1. `git merge origin/dev` into the PR head resolved cleanly and `bun x tsc --noEmit` stayed at + exit 0 — a PR green on its own base is not evidence it is green on the current one; +2. the focused suite covering the changed subsystem passed on that merge; +3. reverting the production hunk made the new test fail, and restoring it made the test pass. + +Step 3 is the one that earns its keep. It has already caught a patch in this unit's history +(#2488) whose test passed with the fix reverted — the test was pinning behavior that already +held, so the "fix" was decoration. That patch was dropped and only the test kept. + +#2430 additionally required a rendered check rather than a passing assertion: it is a CSS +alignment change, and a unit test that reads the stylesheet cannot see what the browser lays +out. The reviewer built the GUI, served `gui/dist`, drove it with a real browser, and measured +the four rows — text left edge at x=49, trailing controls ending at x=207, row height 35.5px +across all four. + +#2427 is the highest-risk of the five because it rewrites the test runner every later +verification depends on. It was checked against a real `SIGKILL` of the lock owner +(`RECLAIMED acquired=true`), and the full suite was run through the new runner end to end: +14955 pass / 12 skip / 0 fail in 178s across seven lanes. A runner that reports green while +silently skipping lanes would be worse than the hang it replaces, so the lane totals were +summed rather than trusting the final line. + +#2575 arrived as a draft with an unticked "resolved all Codex/CodeRabbit findings" box. The +review found no blockers, so it was marked ready and merged; the unticked box reflected an +author workflow state, not an outstanding finding. + +## Outcome + +All five merged into `dev`. Linked issues (#2592, #2571) closed by hand with the merge SHA +and the falsification result quoted, because GitHub only auto-closes on merges into `main` +and every PR here targets `dev`. diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/140_closeout.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/140_closeout.md new file mode 100644 index 0000000000..5ded3f86cc --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/140_closeout.md @@ -0,0 +1,66 @@ +# 140 — closeout + +## Where this landed + +Every issue authored by `lidge-jun` is closed. `gh issue list --author lidge-jun --state open` +returns zero rows. Every `bug`-labelled pull request is terminal except one, and that one is +open by decision rather than by omission. + +Final `dev` verification at `1a92d6b55`: **15005 pass / 16 skip / 0 fail**, typecheck exit 0. + +## The one thing deliberately left open + +**#2497** — native main token refresh and replay. It is the credential boundary AGENTS.md +places under explicit security review, it got one, and three blockers were verified by hand +rather than taken on a reviewer's word (`120_wp5_2497_security_review.md`): + +1. `auth.json` publication renames before it links, so a crash between the two strands the file + and nothing recovers it at startup. +2. The same-account fallback adopts a *different* pool refresh grant into native-main. Account-id + equivalence is not grant ownership — this is the exact hazard `anthropic-routing.ts` fails + closed on. +3. The "exactly one" 401 replay is one *logical* replay. The post-401 send goes through + `fetchWithTransientRetry`, whose 3x3 ladder means one recovery can be up to nine physical + sends. Nothing reaches the client twice, but upstream work can commit more than once. + +(2) is a credential-ownership decision, not a defect to pick a side on silently: tightening it +to grant-only changes what happens to an operator who re-logged in through the pool and expects +main to follow. Fixing three security blockers inside someone else's 2,600-line credential PR +and admin-merging it is not a thing to do quietly. + +## What the run found that nobody asked for + +Three defects that only appeared because the work was verified rather than assumed: + +- **`ocx uninstall` could not remove a config home OpenCodex created itself.** Two of our own + writers produce files the ownership manifest never claimed. Only the disposable-host acceptance + could surface this, because only it runs the production uninstall against a home the product + built. Fixed in #2618. +- **Three regressions from combining #2463 and #1478**, each green in isolation and red together: + a `structuredClone` that threw on a non-cloneable provider value, an alias route that swallowed + the API-key-pool rename endpoint, and a command missing from both documentation sweeps. Fixed + in #2614. This is the argument for a full-suite gate that a per-PR gate cannot make. +- **A session lane keyed on the parent thread** would have 503'd every parallel subagent after + the first. Reproduced before fixing; a lane wants the most specific identity, which is the + opposite of what account affinity wants. + +## Recorded, not absorbed + +- **#2622** — the Codex provenance ledger is never written; `updateIntegrationRecord` has no + production caller. Found while the #1048 rows tried to assert on it. The rows now fail only on + disagreement, because requiring an entry no production path can produce would have been testing + an unimplemented writer. +- **#2568's activation default stays opt-in.** The issue asks for presence-driven activation by + analogy with a 2-key API pool. An API-key pool spends the operator's own metered credit; + rotating across subscription accounts spends a second subscription's quota, which is why the + Anthropic pool shipped opt-in. Turning it on later costs nothing; a default-on rotation that + surprises someone has already spent the quota. Escalated to the owner with the one-line change + named. + +## What earned its keep + +Falsification. Reverting each fix to confirm its test actually fails caught a patch earlier in +this unit (#2488) whose test passed with the fix removed — it was pinning behavior that already +held. That patch was dropped and only the test kept. Every fix merged here was checked the same +way, and three of them (the parent-thread lane, the atomic adoption publication, both uninstall +hunks) are load-bearing only because that check said so. From 05232394bbce3332786903dba32d14488c5dada6 Mon Sep 17 00:00:00 2001 From: Olddonkey Date: Tue, 25 Aug 2026 13:51:58 -0700 Subject: [PATCH 055/336] docs(responses): state the reasoning sanitizer's real rule and pin it by equality (#2502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sentence about this code kept being repeated in comments and reviews that is not what the code does: "an item that keeps its encrypted_content is not otherwise modified". sanitizeReasoningInputContent removes `status` and blanks array content while retaining the blob. Retaining a native blob guarantees the blob VALUE, not the item's byte shape. The in-tree form was "an OpenAI-operated backend binds the blob to the item's exact shape". What was actually measured is narrower — deleting the null `content` channel invalidates the blob there. "Exact shape" invites the reader to believe nothing else is ever touched, which is false in the same function. The function now carries the real field policy, enumerated rather than summarised, since summarising is how the false version arose in the first place. Both null-channel tests move from field-wise assertions to whole-object equality, so any additional change to a blob-bearing item fails instead of slipping past. No behavior change: the source diff is comments only. This path has broken OpenAI in production once already, when a reasoning-only change passed an independent review and shipped — so anything beyond documentation here needs its own unit with live verification on both routes. --- src/adapters/openai-responses.ts | 20 ++++++++++++++------ tests/openai-responses-passthrough.test.ts | 19 ++++++++++--------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 2e15937b89..c304e6823f 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -46,6 +46,15 @@ export const FORWARD_HEADERS = [ "x-responsesapi-include-timing-metrics", ]; +/** + * Sanitize reasoning input by field policy, not by preserving each item's shape. Retaining a + * native `encrypted_content` guarantees only that blob value: `status` is always removed; + * proxy-owned `ocxr1:` envelopes are always removed; and native blobs are removed when the caller + * requests stripping after a route-identity change or opaque-blob recovery. On routed/non-OpenAI + * destinations, a present non-array `content` field is omitted. Otherwise non-empty array content + * is blanked unless raw reasoning preservation is enabled; removing an `ocxr1:` envelope selects + * the same blanking path when non-array omission is not active. + */ export function sanitizeReasoningInputContent( body: unknown, opts?: { @@ -77,12 +86,11 @@ export function sanitizeReasoningInputContent( // rather than the field it actually refused, which is why this reads as a blob failure. Drop the // key so the item matches the shape the upstream issued. // - // Gated to routed destinations. An OpenAI-operated backend binds the blob to the item's exact - // shape, so deleting a field there invalidates it (`The encrypted content ... could not be - // verified`); the two requirements are exactly opposed, and a live regression proved it. That - // gate is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its - // own blob without the null channel, and the destinations that bind blobs to item shape never - // reach this branch. This is independent of the output-only status removal below. + // Gated to routed destinations. An OpenAI-operated backend rejects a blob-bearing item when its + // null `content` channel is deleted (`The encrypted content ... could not be verified`); that + // live result establishes this channel constraint, not whole-item shape preservation. The gate + // is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its own + // blob without the null channel. This is independent of the output-only status removal below. const dropNullContentChannel = opts?.dropNullContentChannel === true && "content" in rec && !Array.isArray(rec.content); // `status` is output-only. Measured OpenAI reasoning items never contain it, and Grok accepts diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 2796e1b849..98db0c2d8c 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -3230,14 +3230,17 @@ describe("reasoning input content channel", () => { summary: [{ type: "summary_text", text: "thinking" }], encrypted_content: "upstream-issued-blob", }); - expect(out).not.toHaveProperty("content"); - expect(out.encrypted_content).toBe("upstream-issued-blob"); - expect(out.summary).toEqual([{ type: "summary_text", text: "thinking" }]); + expect(out).toEqual({ + type: "reasoning", + summary: [{ type: "summary_text", text: "thinking" }], + encrypted_content: "upstream-issued-blob", + }); }); - // An OpenAI-operated backend binds the blob to the item's exact shape, so deleting a field there - // invalidates it: `The encrypted content ... could not be verified`. Caught in live traffic after - // an ungated first version of this fix shipped locally — the two backends want opposite things. + // An OpenAI-operated backend rejects a blob-bearing item when its null `content` channel is + // deleted: `The encrypted content ... could not be verified`. Caught in live traffic after an + // ungated first version of this fix shipped locally — the two backends want opposite things for + // this channel. test("keeps a null content channel on OpenAI-operated destinations", () => { const item = { type: "reasoning", @@ -3258,9 +3261,7 @@ describe("reasoning input content channel", () => { _rawBody: { model: "gpt-5.6-sol", store: false, input: [item] }, }, { headers: new Headers({ authorization: "Bearer token" }) }); const out = (JSON.parse(request.body) as { input: Record[] }).input[0]; - expect(out).toHaveProperty("content"); - expect(out.content).toBeNull(); - expect(out.encrypted_content).toBe("openai-issued-blob"); + expect(out).toEqual(item); } }); From 6f3b5aff2f6bf6fa46ecc25b1529beb925f9e3ce Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 05:54:07 +0900 Subject: [PATCH 056/336] docs: document promptCacheKey for custom openai-chat providers (#2541) (#2625) * docs: document promptCacheKey for custom providers * docs: clarify prompt cache validation steps * docs: clarify prompt cache validation prerequisite * docs: document promptCacheKey for custom openai-chat providers (#2541) Carries wssfk12138's docs onto dev with one factual correction and the privacy:scan fix the gate needs. The submitted prose said opencodex never generates the key. That is true of the adapter and false of the request path: Claude Messages translation derives a prompt_cache_key from metadata.user_id (claude/inbound.ts:523-531), or from a model/system/tools cohort when the client sends none (:532-552), because the OpenAI backends report cached_tokens:0 for every keyless turn. A reader debugging an unexpected key would have been sent the wrong way. Reworded to say what actually holds: the adapter forwards and never invents, but the key is not always the caller's. Also carries the providers-accounts.md example masking, because privacy:scan is red on dev and this PR cannot be verified green without it. --------- Co-authored-by: wssfk12138 <79346097+wssfk12138@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 27 +++++++++++++++++++ .../docs/reference/cli/providers-accounts.md | 4 +-- .../docs/reference/configuration/providers.md | 1 + 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7d2211d509..b57473114b 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -128,6 +128,33 @@ cache hit rates, while requests without a key remain keyless. If an opted-in ups field, opencodex does not strip it and retry or mutate saved configuration. Other providers remain deny-by-default. +A custom `openai-chat` provider can opt in when its upstream documents support for +`prompt_cache_key`: + +```json +{ + "providers": { + "example-compatible-provider": { + "adapter": "openai-chat", + "baseUrl": "https://api.example.com/v1", + "apiKey": "${EXAMPLE_API_KEY}", + "promptCacheKey": true + } + } +} +``` + +The adapter forwards the key it is given and never invents one. It can still receive a key the +caller did not send: Claude Messages translation derives one from `metadata.user_id`, or from a +model/system/tools cohort when the client sends no metadata, because the OpenAI backends report +`cached_tokens: 0` for every keyless turn. So "forwarded, not fabricated" describes this adapter, +not the whole request path. + +Preserve the rest of the provider configuration when adding the option, then reload or restart +opencodex. To validate caching, compare the initial cold request with later requests carrying the +same stable key. Leave the option omitted or set it to `false` for incompatible upstreams, and +disable or remove it if a strict gateway returns an HTTP 400 unknown-field error. + You can also start OAuth from the [web dashboard](/guides/web-dashboard/). ### Logging in from another browser profile, or another machine diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index d4cb5cdadd..a5d48eaf33 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -164,8 +164,8 @@ full breakdown per account, not just the two summarized windows: ```text $ ocx account list anthropic --quota PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS QUOTA -anthropic oauth 1278f8da a***r@big5lms.com - 5h 7% wk 62% -anthropic oauth e112f28b k***1@gmail.com - active 5h 9% wk 45% +anthropic oauth 1278f8da a***r@examp***.com - 5h 7% wk 62% +anthropic oauth e112f28b k***1@examp***.net - active 5h 9% wk 45% ``` ### `ocx account current [--json]` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index f3214adf1b..624950dd5e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -72,6 +72,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `chatServiceTier?` | `boolean` | Provider-wide Chat-wire opt-in for forwarding caller `service_tier` values. On a classified route it governs foreign values such as `flex`, not proxy-owned canonical Fast after capability validation; on an unclassified route it governs every caller value because no Fast capability has been validated. Exact model capability does not authorize foreign forwarding. Responses routes retain their capability-based caller forwarding behavior. | +| `promptCacheKey?` | `boolean` | Provider-wide `openai-chat` opt-in for forwarding a `prompt_cache_key`. The adapter forwards the key it is given and never invents one, but the key is not always the caller's: Claude Messages translation derives one from `metadata.user_id`, or from a model/system/tools cohort when no metadata is sent. Default off. Enable only when the upstream documents support, because strict gateways may reject the unknown field with HTTP 400. | | `preserveResponsesReasoningContent?` | `boolean` | Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek. Proxy-minted `ocxr1` envelopes are always stripped. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | From c6084fea1cbb3670f43aa44ada49b4af4ccee89c Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 05:57:30 +0900 Subject: [PATCH 057/336] fix(codex): write the provenance ledger, bounded (#2622) (#2626) * fix(codex): write admitted transaction provenance * fix(provenance): bound the ledger to the newest 16 transactions The writer appends three entries per admitted transaction, and a 'present' baseline carries the artifact's exact bytes as base64 - a 25 KB config.toml is ~34 KB per entry, so roughly 100 KB per transaction. Unbounded, a machine that syncs on every start grows integrations/codex.json forever, and since the record is re-read and re-serialized on every append the cost is quadratic rather than merely large. A ledger is evidence, not an archive. The window keeps whole transactions: trimming by entry count would cut one in half and leave a record claiming a transaction touched two artifacts when it touched three, which reads as complete and is worse than dropping it. Also spreads the existing provenance object so an unknown ledger-level key from a newer writer survives the append, which the record contract requires. Falsified: removing the window reddens the trimming test and leaves the within-window identity case green. --- .../260826_provenance_writer/010_design.md | 39 +++++++ src/codex/inject-coordination.ts | 81 ++++++++++++++ src/codex/inject.ts | 11 ++ tests/codex-inject-write-lock.test.ts | 105 +++++++++++++++++- 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260826_provenance_writer/010_design.md diff --git a/devlog/_plan/260826_provenance_writer/010_design.md b/devlog/_plan/260826_provenance_writer/010_design.md new file mode 100644 index 0000000000..fb704331db --- /dev/null +++ b/devlog/_plan/260826_provenance_writer/010_design.md @@ -0,0 +1,39 @@ +# Codex provenance writer + +Issue: #2622 + +## Placement and ordering + +The writer runs in `src/codex/inject.ts` immediately after `withCodexWriteLock` +returns an acquired apply or remove transaction. At that point the native files +and the coordinator row carrying the transaction id have committed. The call +uses only `updateIntegrationRecord`; it does not perform its own read, merge, or +write. + +The ledger append is best-effort. Provenance is optional in the v1 record, and a +non-CAS JSON failure must not turn a committed native transaction into a reported +failure. If the process crashes after the transaction commit and before the +ledger append, the native transaction remains admitted and the ledger has no +matching entry. That ordering is safe because absence grants no restore authority +and existing acceptance treats absence as unavailable evidence; writing before +the transaction commit could instead leave provenance for a transaction that +never committed. + +## Entry shape + +One admitted transaction appends entries for the native artifacts it may mutate: +`config`, `generated-profile`, and `injection-journal`. Every entry contains the +transaction id, one shared observation timestamp, the exact pre-transaction +baseline (`absent` or SHA-256 plus base64 bytes), and the SHA-256 post-image or +`null` when the artifact is absent after the transaction. + +Existing version-1 records without provenance remain valid. The update spreads +the old record and appends to its existing entries; `updateIntegrationRecord` +remains responsible for preserving unknown record, ledger, entry, artifact, and +baseline extensions and for refusing malformed bytes. + +## Lifecycle + +The record stays under the owned OpenCodex home at `integrations/codex.json`. +`ocx uninstall` already removes that owned home, so this ledger does not add an +external artifact or a removal precondition. diff --git a/src/codex/inject-coordination.ts b/src/codex/inject-coordination.ts index 96a41c7460..c3ced10ba2 100644 --- a/src/codex/inject-coordination.ts +++ b/src/codex/inject-coordination.ts @@ -11,7 +11,12 @@ import { atomicWriteFile } from "../config"; import type { CodexWriteLockResult } from "./codex-write-lock"; import { inspectCodexCoordinatorPath } from "./coordinator-doctor"; import { JOURNAL_PATH } from "./journal"; +import { updateIntegrationRecord } from "./integration-record"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; +import type { + CodexArtifactId, + CodexProvenanceEntry, +} from "./convergence-types"; import { codexWriteCoordination, type CodexWriteCandidate, @@ -178,6 +183,82 @@ export function captureCodexPreImages(): CodexPreImages { }; } +/** + * How many transactions of evidence the ledger keeps. + * + * Each transaction appends three entries, and a `present` baseline embeds the artifact's exact + * bytes as base64 — a 25 KB `config.toml` is ~34 KB per entry, so roughly 100 KB per transaction. + * A machine that syncs on every start would grow this file without limit, and it is re-read and + * re-serialized on every append, so the cost is quadratic rather than merely large. + * + * A ledger is evidence, not an archive. The most recent transactions are the ones anyone + * diagnoses against, so the window keeps those and drops the oldest. + */ +export const CODEX_PROVENANCE_MAX_TRANSACTIONS = 16; + +function provenanceBaseline(bytes: string | null): CodexProvenanceEntry["baseline"] { + if (bytes === null) return { kind: "absent" }; + return { + kind: "present", + sha256: createHash("sha256").update(bytes).digest("hex"), + bytesBase64: Buffer.from(bytes).toString("base64"), + }; +} + +function provenancePostImage(path: string): string | null { + try { + return createHash("sha256").update(readFileSync(path)).digest("hex"); + } catch { + return null; + } +} + +/** + * Keep the newest `CODEX_PROVENANCE_MAX_TRANSACTIONS` transactions, whole. + * + * Trimming by ENTRY count would cut a transaction in half and leave evidence that says a + * transaction touched two artifacts when it touched three — worse than dropping it outright, + * because a partial record still reads as complete. Order is preserved; only whole leading + * transactions are removed. + */ +export function boundProvenanceEntries( + entries: readonly CodexProvenanceEntry[], + maxTransactions = CODEX_PROVENANCE_MAX_TRANSACTIONS, +): readonly CodexProvenanceEntry[] { + const order: string[] = []; + for (const entry of entries) if (!order.includes(entry.txId)) order.push(entry.txId); + if (order.length <= maxTransactions) return entries; + const keep = new Set(order.slice(order.length - maxTransactions)); + return entries.filter(entry => keep.has(entry.txId)); +} + +/** Append evidence for an already-committed native transaction, best-effort. */ +export function recordCodexNativeTransactionProvenance( + preImages: CodexPreImages, + txId: string, +) { + const at = new Date().toISOString(); + const surfaces: readonly [CodexArtifactId, string, string | null][] = [ + [{ kind: "config" }, CODEX_CONFIG_PATH, preImages.config], + [{ kind: "generated-profile" }, CODEX_PROFILE_PATH, preImages.profile], + [{ kind: "injection-journal" }, JOURNAL_PATH, preImages.journal], + ]; + const entries: readonly CodexProvenanceEntry[] = surfaces.map(([artifact, path, baseline]) => ({ + artifact, + baseline: provenanceBaseline(baseline), + postImage: provenancePostImage(path), + txId, + at, + })); + return updateIntegrationRecord(record => ({ + ...record, + provenance: { + ...record.provenance, + entries: boundProvenanceEntries([...(record.provenance?.entries ?? []), ...entries]), + }, + })); +} + /** * Put back exactly what was there, and report honestly when that fails. * diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 310ebdf079..72be578784 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -19,6 +19,7 @@ import { CodexWriteConflictError, DEFAULT_INJECT_LOCK_TIMEOUT_MS, recomputeInjectWitness, + recordCodexNativeTransactionProvenance, restoreCodexPreImages, } from "./inject-coordination"; import { readIntegrationRecord } from "./integration-record"; @@ -1020,6 +1021,7 @@ export async function injectCodexConfig( } return { kind: "applied" as const, + preImages, /* * The receipt the terminal update matches on. The transition commits * when the callback returns, so this pair is what the post-job @@ -1037,6 +1039,10 @@ export async function injectCodexConfig( if (coordinated.status !== "acquired") { return codexInjectLockOutcome(coordinated); } + recordCodexNativeTransactionProvenance( + coordinated.value.preImages, + coordinated.value.receipt.currentTxId, + ); transitionReceipt = coordinated.value.receipt; } // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs @@ -1591,6 +1597,7 @@ export async function restoreNativeCodexAsync( } return { config: restored, + preImages, receipt: { nativeGeneration: ctx.expectation.nativeAfter, currentTxId: ctx.expectation.txId, @@ -1609,6 +1616,10 @@ export async function restoreNativeCodexAsync( : `Codex configuration was not restored: ${coordinated.message}`, }; } else { + recordCodexNativeTransactionProvenance( + coordinated.value.preImages, + coordinated.value.receipt.currentTxId, + ); config = coordinated.value.config; transitionReceipt = coordinated.value.receipt; } diff --git a/tests/codex-inject-write-lock.test.ts b/tests/codex-inject-write-lock.test.ts index 065584ab03..ed49cd5e10 100644 --- a/tests/codex-inject-write-lock.test.ts +++ b/tests/codex-inject-write-lock.test.ts @@ -15,7 +15,7 @@ import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; -import { STABLE_ZERO_BYTE_COORDINATOR_AGE_MS } from "../src/codex/inject-coordination"; +import { boundProvenanceEntries, STABLE_ZERO_BYTE_COORDINATOR_AGE_MS } from "../src/codex/inject-coordination"; import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = join(import.meta.dir, ".."); @@ -163,6 +163,11 @@ describe("the lock is on the production path", () => { test("a clean first apply coordinates and records a transition", () => { seedNative(); + mkdirSync(join(opencodexHome, "integrations"), { recursive: true }); + writeFileSync(join(opencodexHome, "integrations", "codex.json"), JSON.stringify({ + version: 1, + futureSection: { owner: "newer-writer" }, + })); const result = runInject(10100); expect(result.success).toBeTrue(); @@ -184,6 +189,61 @@ describe("the lock is on the production path", () => { // Guessing null passes on a fresh machine and fails on a real one, so the // id being present is part of the claim. expect(typeof row.state?.currentTxId).toBe("string"); + + const record = JSON.parse( + readFileSync(join(opencodexHome, "integrations", "codex.json"), "utf8"), + ) as { + futureSection?: unknown; + provenance?: { entries?: Array<{ txId?: string; artifact?: { kind?: string } }> }; + }; + expect(record.futureSection).toEqual({ owner: "newer-writer" }); + const matching = record.provenance?.entries?.filter(entry => + entry.txId === row.state?.currentTxId) ?? []; + expect(matching.map(entry => entry.artifact?.kind).sort()).toEqual([ + "config", + "generated-profile", + "injection-journal", + ]); + }); + + test("a provenance append failure does not undo an admitted transaction", () => { + seedNative(); + expect(runInject(10100).success).toBeTrue(); + + const readTransition = () => parseChildJson<{ + kind?: string; + state?: { nativeGeneration?: number; currentTxId?: string | null }; + }>(runChild(["--eval", ` + const { readCodexTransitionState } = require("./src/codex/transition-state"); + console.log(JSON.stringify(readCodexTransitionState())); + `], { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + }), "read transition state around failed provenance append"); + const admitted = readTransition(); + expect(typeof admitted.state?.currentTxId).toBe("string"); + + writeFileSync(join(opencodexHome, "integrations", "codex.json"), "{ malformed", "utf8"); + const append = parseChildJson<{ kind?: string }>(runChild(["--eval", ` + const { + captureCodexPreImages, + recordCodexNativeTransactionProvenance, + } = require("./src/codex/inject-coordination"); + console.log(JSON.stringify(recordCodexNativeTransactionProvenance( + captureCodexPreImages(), + process.env.OCX_TEST_TX_ID, + ))); + `], { + ...process.env, + CODEX_HOME: codexHome, + OPENCODEX_HOME: opencodexHome, + OCX_TEST_TX_ID: admitted.state!.currentTxId!, + }), "failed provenance append"); + expect(append.kind).toBe("invalid"); + expect(readTransition()).toEqual(admitted); + expect(readFileSync(join(opencodexHome, "integrations", "codex.json"), "utf8")) + .toBe("{ malformed"); }); /** @@ -406,3 +466,46 @@ describe("the transition is resolved, not left pending", () => { expect(row.state?.history?.status).toBe("converged"); }); }); + +/** + * The ledger is evidence, not an archive (#2622). + * + * Each admitted transaction appends three entries, and a `present` baseline carries the artifact's + * exact bytes as base64 — a 25 KB `config.toml` is roughly 100 KB per transaction. Unbounded, a + * machine that syncs on every start grows this file forever, and since the record is re-read and + * re-serialized on every append, the cost is quadratic rather than merely large. + */ +describe("provenance ledger bound", () => { + const entry = (txId: string, kind: "config" | "generated-profile" | "injection-journal") => ({ + artifact: { kind }, + baseline: { kind: "absent" as const }, + postImage: null, + txId, + at: "2026-08-26T00:00:00.000Z", + }); + const transaction = (txId: string) => [ + entry(txId, "config"), + entry(txId, "generated-profile"), + entry(txId, "injection-journal"), + ]; + + test("keeps the newest transactions and drops the oldest whole", () => { + const entries = Array.from({ length: 20 }, (_, i) => transaction(`tx-${i}`)).flat(); + const bounded = boundProvenanceEntries(entries, 16); + + const kept = [...new Set(bounded.map(e => e.txId))]; + expect(kept).toHaveLength(16); + expect(kept[0]).toBe("tx-4"); + expect(kept.at(-1)).toBe("tx-19"); + // Whole transactions only. A half-trimmed transaction would claim it touched two artifacts + // when it touched three, which reads as complete and is worse than dropping it. + for (const txId of kept) { + expect(bounded.filter(e => e.txId === txId)).toHaveLength(3); + } + }); + + test("a ledger within the window is returned unchanged", () => { + const entries = Array.from({ length: 16 }, (_, i) => transaction(`tx-${i}`)).flat(); + expect(boundProvenanceEntries(entries, 16)).toBe(entries); + }); +}); From 74823a5c9516da145fd0aa512bb7052cd369aea7 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Wed, 26 Aug 2026 05:58:11 +0900 Subject: [PATCH 058/336] fix(codex): gate subagent fallback by account entitlement (#2623) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/codex/auth-context.ts | 39 +-- src/codex/model-entitlements.ts | 11 +- src/codex/subagent-model-fallback.ts | 77 ++++-- src/server/responses/core.ts | 59 ++++- tests/codex-model-entitlements.test.ts | 57 +++-- ...subagent-fallback-handle-responses.test.ts | 228 ++++++++++++++++++ tests/subagent-model-fallback.test.ts | 65 ++++- 7 files changed, 473 insertions(+), 63 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 7dd58c6b91..f3c357d4c3 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -29,7 +29,6 @@ import { entitledCodexAccountIdsForModel, isDirectCallerEntitledToCodexModel, resolveCodexModelEntitlements, - type CodexModelEntitlementSnapshot, } from "./model-entitlements"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; @@ -334,9 +333,7 @@ export interface ResolveCodexAuthContextOptions { getMainAccountToken?: typeof getMainAccountToken; primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise; /** Test seam for account-gated native model discovery. */ - resolveCodexModelEntitlements?: ( - config: Pick, - ) => Promise; + resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; /** Direct requests admitted with a proxy bearer substitute the stored native-main credential. */ substituteMainCredentialForDirect?: boolean; /** Test seam for a Direct request's own forwarded ChatGPT credential. */ @@ -381,28 +378,34 @@ export async function resolveCodexAuthContext( return { kind: "main", accountId: null }; } const affinityKey = fixedAccountId === undefined ? codexPoolAffinityKey(headers) : undefined; - const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) - ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config) - : undefined; - const modelEligibleAccountIds = entitlementSnapshot - ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId) - : undefined; // Retained startup recovery makes the physical main identity ineligible. Routing // can still preserve service by selecting a healthy configured pool account. const nativeMainTrafficBlocked = isNativeMainTrafficBlocked(); const selectionAdmission = options.beginCodexAccountSelection?.(); const nativeMainReadsForbidden = nativeMainTrafficBlocked || selectionAdmission?.mainProfileDraining === true; - const selectionOptions = { - // Temporary switch drain keeps the candidate until the atomic claim rejects - // it. Retained recovery makes main wholly ineligible so pool routing continues. - nativeMainSelectionOnly: !nativeMainTrafficBlocked - && selectionAdmission?.mainProfileDraining === true, - isMainAccountTokenLive: options.isMainAccountTokenLive, - modelEligibleAccountIds, - }; let accountId: string; const quotaScope = codexQuotaScopeForModel(options.modelId); try { + const excludeAccountIds = nativeMainReadsForbidden + ? new Set([MAIN_CODEX_ACCOUNT_ID]) + : undefined; + const entitlementSnapshot = options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId) + ? await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, { excludeAccountIds }) + : undefined; + const entitledAccountIds = entitlementSnapshot + ? entitledCodexAccountIdsForModel(entitlementSnapshot, options.modelId) + : undefined; + const modelEligibleAccountIds = entitledAccountIds + ? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate))) + : undefined; + const selectionOptions = { + // Temporary switch drain keeps the candidate until the atomic claim rejects + // it. Retained recovery makes main wholly ineligible so pool routing continues. + nativeMainSelectionOnly: !nativeMainTrafficBlocked + && selectionAdmission?.mainProfileDraining === true, + isMainAccountTokenLive: options.isMainAccountTokenLive, + modelEligibleAccountIds, + }; // A pre-drain selector reserves the native identity while reconciliation and // routing inspect it. Selectors arriving after the fence skip reconciliation // and may still route to non-main pool accounts without touching switch state. diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index d06100138b..5a649d335a 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -40,6 +40,10 @@ export interface CodexModelEntitlementResolveOptions { readonly now?: number; /** Test-only credential seam; production callers enumerate local main + Pool credentials. */ readonly credentials?: readonly CodexModelEntitlementCredentialSnapshot[]; + /** Test-only seam for proving lifecycle exclusions happen before credential reads. */ + readonly credentialSnapshot?: typeof accountCredentialSnapshot; + /** Accounts whose credentials must not be read while another lifecycle owns them. */ + readonly excludeAccountIds?: ReadonlySet; } const accountModelsCache = new Map(); @@ -252,9 +256,12 @@ export async function resolveCodexModelEntitlements( ): Promise { const now = options.now ?? Date.now(); const fetcher = options.fetcher ?? fetch; + const allowedAccountIds = candidateAccountIds(config) + .filter(accountId => !options.excludeAccountIds?.has(accountId)); + const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot; const credentials = options.credentials - ? [...options.credentials] - : (await Promise.all(candidateAccountIds(config).map(accountCredentialSnapshot))) + ? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId)) + : (await Promise.all(allowedAccountIds.map(credentialSnapshot))) .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null); const results = await Promise.all(credentials.map(async credential => ({ credential, diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 40c472bb3c..1f56be2634 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -38,6 +38,7 @@ import { import { routeModel, type RouteResult } from "../router"; import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { codexAccountNamespaceForModel } from "./account-namespace-match"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; import { getUpstreamHostHealth, normalizeUpstreamHostCircuitThreshold, @@ -52,7 +53,11 @@ type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise export type SubagentPoolAccountPreview = ( modelId: string | undefined, now: number, + modelEligibleAccountIds?: ReadonlySet, ) => string | null; +export type SubagentModelEligibleAccountIds = ( + modelId: string | undefined, +) => ReadonlySet | undefined; let subagentQuotaPrimeForTests: SubagentQuotaPrimeFn | null = null; let quotaPrimeInFlight: Promise | null = null; @@ -184,10 +189,11 @@ function resolveRouteFallbackAccountId( accountId?: string | null, now = Date.now(), poolAccountPreview?: SubagentPoolAccountPreview, + modelEligibleAccountIds?: ReadonlySet, ): string | null { if (route?.codexAccountId !== undefined) return route.codexAccountId; if (route && isPoolCodexRoute(route) && poolAccountPreview) { - return poolAccountPreview(route.modelId, now); + return poolAccountPreview(route.modelId, now, modelEligibleAccountIds); } return resolvePoolFallbackAccountId(config, accountId); } @@ -249,17 +255,26 @@ export function isSubagentModelUnavailable( now = Date.now(), accountUsabilityOptions?: CodexAccountUsabilityOptions, poolAccountPreview?: SubagentPoolAccountPreview, + modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds, ): boolean { if (isDisabledFallbackModel(model, config)) return true; if (!isRoutableFallbackModel(model, config)) return true; const route = tryRouteFallbackModel(config, model); if (!route || route.provider.disabled === true) return true; + const modelEligibleAccountIds = modelEligibleAccountIdsForModel?.(route.modelId); + const candidateAccountUsabilityOptions = modelEligibleAccountIds !== undefined + ? { + ...accountUsabilityOptions, + modelEligibleAccountIds, + } + : accountUsabilityOptions; const resolvedAccountId = resolveRouteFallbackAccountId( route, config, accountId, now, poolAccountPreview, + candidateAccountUsabilityOptions?.modelEligibleAccountIds, ); if (isModelHealthBlocked(model, config, resolvedAccountId, now)) return true; if (!isPoolCodexRoute(route)) return false; @@ -268,7 +283,7 @@ export function isSubagentModelUnavailable( // route (canonical openai defaults to pool even when codexAccountMode is omitted). if (!resolvedAccountId) return true; if (isCodexAccountPaused(config, resolvedAccountId)) return true; - if (!isCodexAccountUsable(config, resolvedAccountId, accountUsabilityOptions)) return true; + if (!isCodexAccountUsable(config, resolvedAccountId, candidateAccountUsabilityOptions)) return true; if (route.codexAccountId !== undefined) { // An account-qualified route is pinned and cannot consume Pool's recovery-probe // escape hatch. Honor both account-wide and model-scoped cooldowns so fallback @@ -298,8 +313,10 @@ export function selectAvailableSubagentModel( accountUsabilityOptions?: CodexAccountUsabilityOptions, trailingFallback: readonly string[] = [], poolAccountPreview?: SubagentPoolAccountPreview, + modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds, + resolvedChain?: readonly string[], ): { model: string; rewritten: boolean; skipped: string[] } { - const chain = normalizedChain(primary, config, extraFallback, trailingFallback); + const chain = resolvedChain ?? normalizedChain(primary, config, extraFallback, trailingFallback); const skipped: string[] = []; for (const candidate of chain) { if (nativeFallbackOnly) { @@ -316,6 +333,7 @@ export function selectAvailableSubagentModel( now, accountUsabilityOptions, poolAccountPreview, + modelEligibleAccountIdsForModel, )) { skipped.push(candidate); continue; @@ -546,28 +564,26 @@ export function applySubagentModelFallback( nativeFallbackOnly = false, accountUsabilityOptions?: CodexAccountUsabilityOptions, poolAccountPreview?: SubagentPoolAccountPreview, + modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds, + resolvedFallbackChain?: readonly string[] | null, ): { from?: string; to?: string; skipped?: string[] } | null { if (!isThreadSpawnRequest(headers)) return null; - const tomlRoleFallback = resolveAgentModelFallbackForPrimary( - parsed.modelId, - getCodexHome(), - config.codexAccountNamespaces, - ); - // Config-keyed chains are the supported per-role home (#1190); TOML `model_fallback` - // stays readable for backwards compatibility with homes written before Codex 0.146. - const configuredFallback = resolveConfiguredModelFallbackForPrimary(parsed.modelId, config); - const globalFallback = config.subagentModelFallback ?? []; - if (globalFallback.length === 0 && configuredFallback.length === 0 && tomlRoleFallback.length === 0) return null; + const fallbackChain = resolvedFallbackChain === undefined + ? resolveSubagentFallbackChain(parsed, config) + : resolvedFallbackChain; + if (!fallbackChain) return null; const selection = selectAvailableSubagentModel( parsed.modelId, config, - configuredFallback, + [], accountId, now, nativeFallbackOnly, accountUsabilityOptions, - tomlRoleFallback, + [], poolAccountPreview, + modelEligibleAccountIdsForModel, + fallbackChain, ); if (!selection.rewritten) return selection.skipped.length > 0 ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped } @@ -577,6 +593,37 @@ export function applySubagentModelFallback( return { from, to: selection.model, skipped: selection.skipped }; } +/** Resolve the effective fallback chain once for one logical spawn request. */ +export function resolveSubagentFallbackChain( + parsed: OcxParsedRequest, + config: OcxConfig, +): readonly string[] | null { + const tomlRoleFallback = resolveAgentModelFallbackForPrimary( + parsed.modelId, + getCodexHome(), + config.codexAccountNamespaces, + ); + // Config-keyed chains are the supported per-role home (#1190); TOML `model_fallback` + // stays readable for backwards compatibility with homes written before Codex 0.146. + const configuredFallback = resolveConfiguredModelFallbackForPrimary(parsed.modelId, config); + const globalFallback = config.subagentModelFallback ?? []; + if (globalFallback.length === 0 && configuredFallback.length === 0 && tomlRoleFallback.length === 0) return null; + return normalizedChain(parsed.modelId, config, configuredFallback, tomlRoleFallback); +} + +/** Whether the effective fallback chain crosses an account-gated native Pool model. */ +export function subagentFallbackNeedsModelEntitlements( + fallbackChain: readonly string[] | null, + config: OcxConfig, +): boolean { + return fallbackChain?.some((candidate) => { + const route = tryRouteFallbackModel(config, candidate); + return !!route + && isPoolCodexRoute(route) + && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId); + }) === true; +} + export function subagentFallbackGuidanceText(config: OcxConfig): string { const chain = config.subagentModelFallback ?? []; if (chain.length === 0) return ""; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 627a427563..f9a5c204b9 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -149,6 +149,7 @@ import { resolveCodexModelEntitlements, } from "../../codex/model-entitlements"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account"; import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug"; import { computeQuotaCooldown, @@ -217,6 +218,9 @@ import { applySubagentModelFallback, maybePrimeSubagentQuota, recordSubagentQuotaFailureForThreadSpawn, + resolveSubagentFallbackChain, + subagentFallbackNeedsModelEntitlements, + type SubagentModelEligibleAccountIds, type SubagentPoolAccountPreview, } from "../../codex/subagent-model-fallback"; import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup"; @@ -1291,6 +1295,8 @@ export interface HandleResponsesOptions { /** One-shot TTFT callback: first non-empty model output observed (WP4). */ onFirstOutput?: () => void; onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void; + /** Internal deterministic seam for account-gated native fallback tests. */ + resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements; recordTerminalOutcomes?: boolean; setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void; onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void; @@ -1590,6 +1596,7 @@ async function resolveResponsesCodexAuth( modelId: route.modelId, substituteMainCredentialForDirect: substituteMainCredential, beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), + resolveCodexModelEntitlements: options.resolveCodexModelEntitlements, }); options.onCodexAuthContextResolved?.(authCtx); } else { @@ -1628,6 +1635,25 @@ async function resolveResponsesCodexAuth( } } +async function resolveSubagentFallbackModelEligibility(args: { + config: OcxConfig; + fallbackChain: readonly string[] | null; + nativeMainReadsForbidden: boolean; + resolver: typeof resolveCodexModelEntitlements; +}): Promise { + if (!subagentFallbackNeedsModelEntitlements(args.fallbackChain, args.config)) return undefined; + const excludeAccountIds = args.nativeMainReadsForbidden + ? new Set([MAIN_CODEX_ACCOUNT_ID]) + : undefined; + const snapshot = await args.resolver(args.config, { excludeAccountIds }); + return (modelId) => { + const entitledAccountIds = entitledCodexAccountIdsForModel(snapshot, modelId); + return entitledAccountIds + ? new Set([...entitledAccountIds].filter(accountId => !excludeAccountIds?.has(accountId))) + : undefined; + }; +} + /** * Apply every route-dependent request mutation against the final selected route. * Must run only after subagent fallback has settled the model/provider. @@ -2435,7 +2461,12 @@ async function handleResponsesInner( // also fail closed without polling quota upstream. Cached fallback state can still select a // provider with native continuation support below. const threadSpawn = isThreadSpawnRequest(req.headers); - const previewSelectionAdmission = threadSpawn && route.codexAccountId === undefined + const initialSubagentFallbackChain = threadSpawn && !options.comboAttempt + ? resolveSubagentFallbackChain(parsed, config) + : null; + const previewSelectionAdmission = threadSpawn + && !options.comboAttempt + && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null) ? codexAccountSelectionForTurn(options.turnAdmissionLease)?.() : undefined; const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked(); @@ -2448,6 +2479,7 @@ async function handleResponsesInner( let selectedForwardHeaders = req.headers; let subagentFallbackAccountId = config.activeCodexAccountId ?? null; let subagentFallbackAccountPreview: SubagentPoolAccountPreview | undefined; + let subagentFallbackModelEligibleAccountIdsForModel: SubagentModelEligibleAccountIds | undefined; let subagentQuotaFailureModel = parsed.modelId; const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null; const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null; @@ -2465,20 +2497,35 @@ async function handleResponsesInner( // normalization (virtual models, effort caps, service tier, wire protocol). // Preview the preferred Codex account without acquiring a probe lease or refreshing // tokens — auth is resolved only after the final route is selected. - if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) { + if ( + threadSpawn + && !options.comboAttempt + && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null) + ) { // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId), // so the preview must read the same scope slot — an undefined scope would map to the // "legacy" affinity bucket and never find a binding made under "shared" or a native // model scope, making the preview diverge from the account that actually authenticates. + const fallbackChain = initialSubagentFallbackChain; + subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({ + config, + fallbackChain, + nativeMainReadsForbidden, + resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements, + }); const fallbackNow = Date.now(); - subagentFallbackAccountPreview = (modelId, previewNow) => previewCodexAccountForRequest( + subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest( poolAffinityKey, config, previewNow, codexQuotaScopeForModel(modelId), - previewSelectionOptions, + { ...previewSelectionOptions, modelEligibleAccountIds }, + ); + const previewAccountId = route.codexAccountId ?? subagentFallbackAccountPreview( + route.modelId, + fallbackNow, + subagentFallbackModelEligibleAccountIdsForModel?.(route.modelId), ); - const previewAccountId = subagentFallbackAccountPreview(route.modelId, fallbackNow); subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null; const fallback = applySubagentModelFallback( parsed, @@ -2489,6 +2536,8 @@ async function handleResponsesInner( unreadableEncryptedAgentTask, previewSelectionOptions, subagentFallbackAccountPreview, + subagentFallbackModelEligibleAccountIdsForModel, + fallbackChain, ); if (fallback) { (logCtx as unknown as Record).subagentModelFallbackFrom = fallback.from; diff --git a/tests/codex-model-entitlements.test.ts b/tests/codex-model-entitlements.test.ts index 017922f029..ca4e631da4 100644 --- a/tests/codex-model-entitlements.test.ts +++ b/tests/codex-model-entitlements.test.ts @@ -6,11 +6,10 @@ import { isDirectCallerEntitledToCodexModel, resetCodexModelEntitlementCacheForTests, resolveCodexModelEntitlements, - cachedAvailableAccountGatedNativeModels, - seedCodexModelEntitlementsForTests, seedCodexModelEntitlementsForTests, type CodexModelEntitlementCredentialSnapshot, } from "../src/codex/model-entitlements"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; const DAYBREAK = "gpt-daybreak-blue-latest"; const SOL = "gpt-5.6-sol"; @@ -80,6 +79,40 @@ describe("Codex account model entitlements", () => { expect(entitledCodexAccountIdsForModel(snapshot, DAYBREAK)?.size).toBe(0); }); + test("filters excluded accounts before credential and roster access", async () => { + const credentialReads: string[] = []; + const fetchedAccounts: string[] = []; + const snapshot = await resolveCodexModelEntitlements({ + codexAccounts: [ + { id: "pool-b", email: "pool-b@example.test", isMain: false }, + ], + }, { + excludeAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID]), + credentialSnapshot: async (accountId) => { + credentialReads.push(accountId); + return credential(accountId); + }, + fetcher: (async (_input, init) => { + fetchedAccounts.push(new Headers(init?.headers).get("chatgpt-account-id") ?? ""); + return roster(DAYBREAK); + }) as typeof fetch, + now: 1_000, + }); + + expect(credentialReads).toEqual(["pool-b"]); + expect(fetchedAccounts).toEqual(["chatgpt-pool-b"]); + expect([...snapshot.modelsByAccount.keys()]).toEqual(["pool-b"]); + expect(snapshot.confirmedAccountIds.has(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + + const supplied = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential(MAIN_CODEX_ACCOUNT_ID), credential("pool-c")], + excludeAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID]), + fetcher: (async () => roster(DAYBREAK)) as typeof fetch, + now: 2_000, + }); + expect([...supplied.modelsByAccount.keys()]).toEqual(["pool-c"]); + }); + test("checks a Direct caller's own bearer instead of a local Pool account", async () => { let seenAuthorization = ""; let seenAccount = ""; @@ -137,24 +170,4 @@ describe("Codex account model entitlements", () => { expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); }); - test("Direct-caller rosters do not evict main/Pool entitlement evidence", async () => { - // The catalog projects ONLY from main/Pool keys. Under a single shared LRU, a burst of - // distinct Direct callers pushed those out and the gated row vanished from the catalog - // until rediscovery — fail-closed flapping whose cause an operator cannot see. - seedCodexModelEntitlementsForTests("main", [DAYBREAK], 1_000); - expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); - - // Far more distinct Direct callers than the per-class cache bound of 64. - for (let i = 0; i < 80; i += 1) { - await isDirectCallerEntitledToCodexModel( - new Headers({ authorization: `Bearer caller-${i}` }), - DAYBREAK, - { fetcher: (async () => roster(DAYBREAK)) as typeof fetch, now: 1_000 }, - ); - } - - // With one shared 64-entry LRU this read came back empty. The main grant is a different - // eviction class and is still inside its TTL, so it must survive. - expect([...cachedAvailableAccountGatedNativeModels(1_000)]).toContain(DAYBREAK); - }); }); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index 001d182a19..d9c2e34964 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -723,6 +723,234 @@ describe("subagent fallback final-route normalization", () => { }); describe("native fallback account preview", () => { + test("fallback preview and final auth use their own entitlement snapshots", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccountNamespaces: { team: "pool-a" }, + subagentModelFallback: ["gpt-daybreak-blue-latest", "xai/grok-4.5"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const { noteSubagentModelFailure } = await import("../src/codex/subagent-model-fallback"); + noteSubagentModelFailure("team/gpt-5.6-sol", "429", cfg, "pool-a", now); + // Make a stale account choice observable at the fallback boundary: preview must + // apply the first snapshot and move to pool-b before checking model health. + noteSubagentModelFailure("gpt-daybreak-blue-latest", "429", cfg, "pool-a", now); + const previewSnapshot = { + modelsByAccount: new Map([ + ["pool-a", new Set(["gpt-5.6-sol"])], + ["pool-b", new Set(["gpt-5.6-sol", "gpt-daybreak-blue-latest"])], + ]), + confirmedAccountIds: new Set(["pool-a", "pool-b"]), + credentialIdentities: new Map(), + }; + const finalSnapshot = { + modelsByAccount: new Map([ + ["pool-a", new Set(["gpt-5.6-sol", "gpt-daybreak-blue-latest"])], + ["pool-b", new Set(["gpt-5.6-sol"])], + ]), + confirmedAccountIds: new Set(["pool-a", "pool-b"]), + credentialIdentities: new Map(), + }; + let entitlementCalls = 0; + let finalAuth: CodexAuthContext | undefined; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; }, + resolveCodexModelEntitlements: async () => { + entitlementCalls += 1; + return entitlementCalls === 1 ? previewSnapshot : finalSnapshot; + }, + }, + logCtx, + ); + + expect(response.status).toBe(200); + expect(entitlementCalls).toBe(2); + // Final auth is authoritative and sees the second snapshot, not the preview snapshot. + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-a" }); + expect((logCtx as unknown as Record).subagentModelFallbackTo) + .toBe("gpt-daybreak-blue-latest"); + expect(capture.auths[0]).toContain("pool-a_token"); + }); + + test("entitlement discovery holds and releases preview admission on rejection", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccountNamespaces: { team: "pool-a" }, + subagentModelFallback: ["gpt-daybreak-blue-latest"], + }); + let beginCount = 0; + let releaseCount = 0; + let resolverCalls = 0; + let rejectDiscovery!: (reason: Error) => void; + const discovery = new Promise((_resolve, reject) => { rejectDiscovery = reject; }); + const turnAdmissionLease = { + release() {}, + beginCodexAccountSelection() { + beginCount += 1; + return { + mainProfileDraining: false, + claimMainProfile: () => true, + release: () => { releaseCount += 1; }, + }; + }, + } satisfies Pick; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("must not dispatch"); + }) as typeof fetch; + + const pending = postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + turnAdmissionLease, + resolveCodexModelEntitlements: async () => { + resolverCalls += 1; + return discovery; + }, + }, + ); + for (let i = 0; i < 20 && resolverCalls === 0; i += 1) await Promise.resolve(); + + expect(resolverCalls).toBe(1); + expect(beginCount).toBe(1); + expect(releaseCount).toBe(0); + expect(fetchCalls).toBe(0); + + rejectDiscovery(new Error("entitlement discovery unavailable")); + await expect(pending).rejects.toThrow("entitlement discovery unavailable"); + expect(releaseCount).toBe(1); + expect(fetchCalls).toBe(0); + }); + + test("unentitled fixed gated primary falls through to a routed fallback", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccountNamespaces: { restricted: "pool-b" }, + subagentModelFallback: ["xai/grok-4.5"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + const entitlementSnapshot = { + modelsByAccount: new Map([ + ["pool-a", new Set(["gpt-5.6-sol", "gpt-daybreak-blue-latest"])], + ["pool-b", new Set(["gpt-5.6-sol"])], + ]), + confirmedAccountIds: new Set(["pool-a", "pool-b"]), + credentialIdentities: new Map(), + }; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "restricted/gpt-daybreak-blue-latest", input: readableAgentInput(), stream: false }, + { resolveCodexModelEntitlements: async () => entitlementSnapshot }, + logCtx, + ); + + expect(response.status).toBe(200); + expect((logCtx as unknown as Record).subagentModelFallbackTo).toBe("xai/grok-4.5"); + expect(capture.urls).toHaveLength(1); + expect(capture.urls[0]).toContain("api.x.ai"); + expect(capture.bodies[0]).not.toContain("gpt-daybreak-blue-latest"); + }); + + test("account-qualified fallback excludes native main entitlement reads during profile drain", async () => { + const now = 1_800_000_000_000; + Date.now = () => now; + installPoolCredential("pool-a", "pool_acc_a", now); + installPoolCredential("pool-b", "pool_acc_b", now); + const cfg = poolNativePlusRoutedConfig({ + activeCodexAccountId: "pool-a", + autoSwitchThreshold: 0, + codexAccountNamespaces: { team: "pool-a" }, + subagentModelFallback: ["gpt-daybreak-blue-latest", "xai/grok-4.5"], + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "pool_acc_a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "pool_acc_b" }, + ], + }); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + fixedAccount: true, + modelId: "gpt-5.6-sol", + now, + resetAt: Math.floor((now + 60 * 60_000) / 1_000), + }); + const entitlementSnapshot = { + modelsByAccount: new Map([ + ["__main__", new Set(["gpt-daybreak-blue-latest"])], + ["pool-b", new Set(["gpt-daybreak-blue-latest"])], + ]), + confirmedAccountIds: new Set(["__main__", "pool-b"]), + credentialIdentities: new Map(), + }; + const mainExclusions: boolean[] = []; + let selectionReleases = 0; + const turnAdmissionLease = { + release() {}, + beginCodexAccountSelection() { + return { + mainProfileDraining: true, + claimMainProfile: () => false, + release: () => { selectionReleases += 1; }, + }; + }, + } satisfies Pick; + let finalAuth: CodexAuthContext | undefined; + const capture = { urls: [] as string[], bodies: [] as string[], auths: [] as Array }; + mockUpstream(capture); + + const response = await postSpawn( + cfg, + { model: "team/gpt-5.6-sol", input: readableAgentInput(), stream: false }, + { + turnAdmissionLease, + onCodexAuthContextResolved: (ctx) => { finalAuth = ctx; }, + resolveCodexModelEntitlements: async (_config, resolveOptions) => { + mainExclusions.push(resolveOptions?.excludeAccountIds?.has("__main__") === true); + return entitlementSnapshot; + }, + }, + ); + + expect(response.status).toBe(200); + expect(mainExclusions).toEqual([true, true]); + expect(selectionReleases).toBe(2); + expect(finalAuth).toMatchObject({ kind: "pool", accountId: "pool-b" }); + expect(capture.auths[0]).toContain("pool-b_token"); + }); + test("Desktop fallback affinity drives the subagent preview and final native account", async () => { const now = 1_800_000_000_000; Date.now = () => now; diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 98d83a6ad5..f37a07ef4a 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -216,7 +216,7 @@ describe("subagent model fallback chain", () => { }); }); - test("fixed account candidates do not call the Pool preview", () => { + test("fixed account candidates preserve selectors and enforce per-model entitlements", () => { updateAccountQuota("account-a", 10, undefined, 20); const config = cfg({ codexAccountNamespaces: { team: "account-a" } }); const throwingPreview = () => { @@ -228,11 +228,74 @@ describe("subagent model fallback chain", () => { config, "pool-a", Date.now(), + { modelEligibleAccountIds: new Set(["account-a"]) }, + throwingPreview, + () => undefined, + )).toBe(false); + expect(isSubagentModelUnavailable( + "team/gpt-daybreak-blue-latest", + config, + "pool-a", + Date.now(), + undefined, + throwingPreview, + () => new Set(["account-b"]), + )).toBe(true); + expect(isSubagentModelUnavailable( + "team/gpt-daybreak-blue-latest", + config, + "pool-a", + Date.now(), undefined, throwingPreview, + () => new Set(["account-a"]), )).toBe(false); }); + test("unqualified gated candidates pass their entitlement set into Pool preview", () => { + const now = 1_800_000_000_000; + const config = cfg({ + autoSwitchThreshold: 0, + codexAccountNamespaces: { team: "account-a" }, + subagentModelFallback: ["gpt-daybreak-blue-latest", "xai/grok-4.5"], + }); + updateAccountQuota("account-a", 10, undefined, 20); + updateAccountQuota("account-b", 10, undefined, 20); + noteSubagentModelFailure("team/gpt-5.6-sol", "429", config, "account-a", now); + + const previews: Array<{ modelId: string | undefined; eligible: string[] | undefined }> = []; + const selected = selectAvailableSubagentModel( + "team/gpt-5.6-sol", + config, + [], + "account-a", + now, + false, + undefined, + [], + (modelId, _previewNow, eligibleAccountIds) => { + previews.push({ + modelId, + eligible: eligibleAccountIds ? [...eligibleAccountIds] : undefined, + }); + return eligibleAccountIds?.has("account-b") ? "account-b" : "account-a"; + }, + modelId => modelId === "gpt-daybreak-blue-latest" + ? new Set(["account-b"]) + : undefined, + ); + + expect(selected).toEqual({ + model: "gpt-daybreak-blue-latest", + rewritten: true, + skipped: ["team/gpt-5.6-sol"], + }); + expect(previews).toEqual([{ + modelId: "gpt-daybreak-blue-latest", + eligible: ["account-b"], + }]); + }); + test("a null candidate account preview does not fall back to the active Pool account", () => { updateAccountQuota("pool-a", 10, undefined, 20); const config = cfg({ subagentModelFallback: ["kimi/k3"] }); From 8091f2ea0b0a6b1d0515b1bc7e02b03a32b3b2d2 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:12:48 +0900 Subject: [PATCH 059/336] fix(storage): bound the cold Codex log inspection and surface when it is skipped (#2605) (#2627) * fix(storage): bound cold Codex log inspection Choose option (a): skip synchronous row aggregates once logs_2.sqlite exceeds 64 MiB, retaining file, schema, and capability inspection. The threshold matches the reporter's measured mitigation: a ~1 GB GROUP BY level took 17.3s, while bounded /api/storage returned in 628ms. A Worker (option b) would make both management endpoints asynchronous and add startup, admission, teardown, and Windows thread-exit cost to a management-only inspector; storage Workers are already serialized specifically around Windows teardown. Bounded queries (option c) cannot make count(*), GROUP BY, or sum() exact with LIMIT, and bun:sqlite provides no interruptible async statement on this request path. Expose metricsSkipped with the threshold so omitted aggregates are distinct from genuine zero values. Keep full metrics for small databases and cover both shapes with a falsified regression test. * fix(storage): bound the cold Codex log inspection and say when it was skipped Carries the size gate for #2605 and adds the GUI half. The server side skips the row aggregates above 64 MiB, which is what keeps a cold inspection off the proxy thread - the reporter measured GROUP BY level at 17.3s on a ~1 GB database, and the source already admitted the gap in its own comment. The GUI half was missing: with metrics null the row block simply disappeared, so a 1 GB database rendered as one with no rows. That is the exact confusion the server's null-vs-zero distinction exists to prevent, and it is most misleading precisely where the database is largest. The row now states the reason and the threshold, and the file sizes still render because only the aggregates were skipped, not the inspection. String added to all nine locales. Rendered in a real browser against the built stylesheet, not just asserted. Falsified: disabling the skipped-row branch reddens the new render test while the under-threshold case stays green. --- .../storage-workspace/StorageWorkspace.tsx | 20 ++++++++++ gui/src/i18n/log-guard-labels.ts | 10 +++++ gui/tests/storage-log-guard.test.tsx | 40 +++++++++++++++++++ src/codex/log-guard/inspect.ts | 26 ++++++++++-- tests/codex-log-guard-inspect.test.ts | 21 ++++++++++ 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index fb5ed7808d..46d670a68e 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -73,6 +73,10 @@ export interface CodexLogGuardReport { reclaimableBytes: number; estimatedLogBytes: number | null; }; + metricsSkipped?: null | { + reason: "database_too_large"; + thresholdBytes: number; + }; } export interface StorageReport { @@ -205,6 +209,22 @@ function CodexLogGuardPanel({
)} + {/* + Say WHY the rows are missing (#2605). Skipping the aggregates above the size threshold + is what keeps a cold inspection off the proxy thread, but silently dropping the row + block reads as "this database has no rows" — the exact confusion the null-vs-zero + distinction on the server exists to prevent. A user who sees a 1 GB database and no + row count deserves the reason. + */} + {!metrics && report.metricsSkipped && ( +
+
{t("storage.col.rows")}
+
+ {logGuardLabel(locale, "metricsSkippedLarge") + .replace("{threshold}", formatBytes(report.metricsSkipped.thresholdBytes, locale))} +
+
+ )}
sqlite_home
diff --git a/gui/src/i18n/log-guard-labels.ts b/gui/src/i18n/log-guard-labels.ts index de7ed41af5..a0164259e7 100644 --- a/gui/src/i18n/log-guard-labels.ts +++ b/gui/src/i18n/log-guard-labels.ts @@ -4,6 +4,7 @@ export type LogGuardLabelKey = | "inspectionOnly" | "externalSqliteHome" | "inspectionUnavailable" + | "metricsSkippedLarge" | "protection" | "compat" | "quiet" @@ -37,6 +38,7 @@ const LABELS: Record> = { inspectionOnly: 'Inspection only', externalSqliteHome: 'External SQLite storage', inspectionUnavailable: "Diagnostic log inspection is unavailable.", + metricsSkippedLarge: "Row metrics skipped: the database is above {threshold}, and scanning it would stall the proxy.", protection: "Protection", compat: "Compatibility", quiet: "Quiet", @@ -63,6 +65,7 @@ const LABELS: Record> = { inspectionOnly: 'Nur Inspektion', externalSqliteHome: 'Externer SQLite-Speicher', inspectionUnavailable: "Die Diagnoseprotokoll-Inspektion ist nicht verfügbar.", + metricsSkippedLarge: "Zeilenmetriken übersprungen: Die Datenbank ist größer als {threshold}; ein Scan würde den Proxy blockieren.", protection: "Schutz", compat: "Kompatibilität", quiet: "Leise", @@ -89,6 +92,7 @@ const LABELS: Record> = { inspectionOnly: "Inspection uniquement", externalSqliteHome: "Stockage SQLite externe", inspectionUnavailable: "L’inspection des journaux de diagnostic est indisponible.", + metricsSkippedLarge: "Métriques de lignes ignorées : la base dépasse {threshold} et son analyse bloquerait le proxy.", protection: "Protection", compat: "Compatibilité", quiet: "Silencieux", @@ -115,6 +119,7 @@ const LABELS: Record> = { inspectionOnly: '검사 전용', externalSqliteHome: '외부 SQLite 저장소', inspectionUnavailable: "진단 로그 검사를 사용할 수 없습니다.", + metricsSkippedLarge: "행 지표를 건너뛰었습니다. 데이터베이스가 {threshold}보다 커서 스캔하면 프록시가 멈춥니다.", protection: "보호", compat: "호환 모드", quiet: "조용한 모드", @@ -141,6 +146,7 @@ const LABELS: Record> = { inspectionOnly: '仅检查', externalSqliteHome: '外部 SQLite 存储', inspectionUnavailable: "诊断日志检查当前不可用。", + metricsSkippedLarge: "已跳过行指标:数据库超过 {threshold},扫描会阻塞代理。", protection: "保护", compat: "兼容模式", quiet: "静默模式", @@ -167,6 +173,7 @@ const LABELS: Record> = { inspectionOnly: '僅檢查', externalSqliteHome: '外部 SQLite 儲存空間', inspectionUnavailable: "診斷記錄檢查目前無法使用。", + metricsSkippedLarge: "已略過列指標:資料庫超過 {threshold},掃描會阻塞代理。", protection: "保護", compat: "相容模式", quiet: "靜默模式", @@ -193,6 +200,7 @@ const LABELS: Record> = { inspectionOnly: 'Только проверка', externalSqliteHome: 'Внешнее хранилище SQLite', inspectionUnavailable: "Проверка диагностических журналов недоступна.", + metricsSkippedLarge: "Метрики строк пропущены: база больше {threshold}, и её сканирование заблокировало бы прокси.", protection: "Защита", compat: "Совместимость", quiet: "Тихий режим", @@ -219,6 +227,7 @@ const LABELS: Record> = { inspectionOnly: '検査のみ', externalSqliteHome: '外部 SQLite ストレージ', inspectionUnavailable: "診断ログの検査を利用できません。", + metricsSkippedLarge: "行メトリクスをスキップしました。データベースが {threshold} を超えており、走査するとプロキシが停止します。", protection: "保護", compat: "互換モード", quiet: "静音モード", @@ -245,6 +254,7 @@ const LABELS: Record> = { inspectionOnly: 'Yalnızca inceleme', externalSqliteHome: 'Harici SQLite depolaması', inspectionUnavailable: "Tanılama günlüğü incelemesi kullanılamıyor.", + metricsSkippedLarge: "Satır ölçümleri atlandı: veritabanı {threshold} sınırının üzerinde ve taranması proxy’yi kilitler.", protection: "Koruma", compat: "Uyumluluk", quiet: "Sessiz", diff --git a/gui/tests/storage-log-guard.test.tsx b/gui/tests/storage-log-guard.test.tsx index 272f925b14..fb065ce5f9 100644 --- a/gui/tests/storage-log-guard.test.tsx +++ b/gui/tests/storage-log-guard.test.tsx @@ -129,3 +129,43 @@ test("Storage overview does not render arbitrary Log Guard error strings", () => expect(html).not.toContain("/private/state/logs_2.sqlite"); expect(html).not.toContain("failed"); }); + +/** + * A skipped scan must SAY it was skipped (#2605). + * + * Above the size threshold the server returns `metrics: null` so a cold inspection cannot stall + * the proxy thread. Rendering that as an absent row block reads as "this database has no rows" — + * the exact confusion the server's null-vs-zero distinction exists to prevent, and the more + * misleading the larger the database actually is. + */ +test("a skipped large-database scan states the reason instead of rendering no rows", () => { + const large = report(); + large.codexLogs!.files.databaseBytes = 1_468_923_904; + large.codexLogs!.metrics = null; + large.codexLogs!.metricsSkipped = { reason: "database_too_large", thresholdBytes: 67_108_864 }; + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-testid="log-guard-metrics-skipped"'); + expect(html).toContain("Row metrics skipped"); + // The threshold is stated, so the reader can tell why this database crossed it. + expect(html).toContain("64 MiB"); + // The file sizes still render: only the row aggregates were skipped, not the inspection. + expect(html).toContain("1.4 GiB"); + // And it must not silently show a row count it never computed. + expect(html).not.toContain(">400<"); +}); + +test("a database under the threshold still renders full row metrics", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain('data-testid="log-guard-metrics-skipped"'); + expect(html).toContain("400"); +}); diff --git a/src/codex/log-guard/inspect.ts b/src/codex/log-guard/inspect.ts index d2c1719236..eca4402e25 100644 --- a/src/codex/log-guard/inspect.ts +++ b/src/codex/log-guard/inspect.ts @@ -11,6 +11,9 @@ import { const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI; const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]); +// The issue reporter measured a ~1 GB database taking 17.3s for GROUP BY level +// alone; skipping all row aggregates above 64 MiB reduced /api/storage to 628ms. +const MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES = 64 * 1024 * 1024; interface CurrentLogColumn { name: string; @@ -115,6 +118,10 @@ export interface CodexLogGuardInspection { reclaim: CodexLogGuardCapability; }; metrics: CodexLogGuardMetrics | null; + metricsSkipped: null | { + reason: "database_too_large"; + thresholdBytes: number; + }; } interface ColumnRow { @@ -167,9 +174,8 @@ function fileSize(path: string): number { * repeated stalls without ever serving stale numbers: any write changes the WAL * and invalidates the entry. * - * This bounds the repeat cost, not the first one. A cold inspection of a huge - * database still blocks; moving that work off-thread needs a Worker and is - * tracked separately. + * Memoization bounds repeat cost. The database-size gate below separately bounds + * cold request-thread work by omitting these aggregates for large databases. */ type InspectionCacheEntry = { key: string; @@ -233,6 +239,7 @@ function unavailableInspection(): CodexLogGuardInspection { reclaim: unavailable, }, metrics: null, + metricsSkipped: null, }; } @@ -425,6 +432,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -440,6 +448,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -458,6 +467,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -476,10 +486,17 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ? { state: "compatible" } : { state: "unsupported", reason: "unknown_schema" }; const mutation = capabilityFor(schema); + const metricsSkipped = files.databaseBytes > MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES + ? { + reason: "database_too_large" as const, + thresholdBytes: MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES, + } + : null; return { ...common, schema, - metrics: readMetrics(db, columns), + metrics: metricsSkipped === null ? readMetrics(db, columns) : null, + metricsSkipped, capabilities: { inspection: { state: "supported" }, protection: mutation, @@ -496,6 +513,7 @@ function inspectCodexLogsUncached(deps: CodexSqliteHomeDeps = {}): CodexLogGuard ...common, schema, metrics: null, + metricsSkipped: null, capabilities: { inspection: { state: "supported" }, protection: mutation, diff --git a/tests/codex-log-guard-inspect.test.ts b/tests/codex-log-guard-inspect.test.ts index 96deb06672..03ba72c8c7 100644 --- a/tests/codex-log-guard-inspect.test.ts +++ b/tests/codex-log-guard-inspect.test.ts @@ -7,6 +7,7 @@ import { renameSync, rmSync, statSync, + truncateSync, utimesSync, unlinkSync, writeFileSync, @@ -113,6 +114,26 @@ describe("Codex Log Guard inspection", () => { expect(report.metrics?.traceShare).toBe(0.5); expect(report.metrics?.topTargets[0]).toEqual({ target: "TARGET_1", rows: 2 }); expect(report.metrics?.reclaimableBytes).toBeGreaterThanOrEqual(0); + expect(report.metricsSkipped).toBeNull(); + }); + + test("skips row aggregates for a large database without reporting zero metrics", () => { + const root = makeRoot(); + const databasePath = join(root, "logs_2.sqlite"); + createCurrentLogsDb(databasePath); + truncateSync(databasePath, 64 * 1024 * 1024 + 1); + + const report = inspectCodexLogs({ codexHome: root }); + + expect(report.schema).toEqual({ state: "compatible" }); + expect(report.metrics).toBeNull(); + expect(report.metricsSkipped).toEqual({ + reason: "database_too_large", + thresholdBytes: 64 * 1024 * 1024, + }); + expect(report).not.toMatchObject({ + metrics: { totalRows: 0, rowsByLevel: {}, estimatedLogBytes: 0 }, + }); }); test("never exposes feedback bodies, arbitrary levels, target names, or paths", () => { From e6dba543544b7844639a2568639ded52c0d5a45f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:14:22 +0900 Subject: [PATCH 060/336] docs(responses): define semantic progress ownership (#2600) (#2628) --- structure/04_transports-and-sidecars.md | 18 ++++++++++++++++ tests/active-registry-admission.test.ts | 12 ++++++++++- tests/responses-state.test.ts | 28 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index e2060fa4e2..53f69dd7aa 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -47,6 +47,24 @@ the upstream HTTP-version helper. Server, provider, and WebSocket data types rem It must not import routing, combos, OAuth, adapters, sidecars, response parsing, logging, or relay modules merely because those imports existed in the pre-split `responses.ts` monolith. +### Semantic progress ownership + +The Responses proxy does not treat transcript growth as repository progress. It can observe request +boundaries, response items, tool names and payloads, adapter events, retained bytes, and elapsed +silence. It cannot observe the client's workspace or prove whether a successful tool result changed +repository state. Consequently, the active-turn and session-lane gates are concurrency admission +limits, the translator budget is a live retained-byte limit, the response-state caps are cache +retention limits, and the stall watchdog is a silence limit. None is a cumulative continuation or +semantic no-progress budget. + +[Decision Log] +- 목적과 의도: Keep long but progressing client-driven tool continuations valid while locating repository-semantic loop detection at the layer that owns the workspace and continuation policy. +- 기존 구현 및 제약 조건: Issue #2600 recorded 18 persisted Cursor continuations whose transcript and tool counters grew while the worktree did not. Every proxy-local liveness and capacity bound was therefore satisfied, but the proxy had no workspace delta to compare. +- 검토한 주요 대안: Stop after a fixed continuation count; classify read-like tool names as no progress; compare assistant prose; emit a new proxy-only terminal code after a time budget; or leave semantic progress to the client while preserving transport cancellation for objective proxy failures. +- 선택한 방식: Do not add a proxy semantic cutoff without a client-supplied progress contract. Keep objective transport, byte, concurrency, and silence bounds typed and cancellable; require the workspace-owning client to bound repeated continuations using repository state plus its own side-effect ledger. +- 다른 대안 대신 이 방식을 선택한 이유: Calls and prose are not a repository oracle, and tool names do not prove side effects. A proxy cutoff would either miss the reported loop because items kept changing or terminate legitimate slow work. Retrying after the cutoff could also replay side-effecting work. +- 장점, 단점 및 영향: OpenCodex does not manufacture a root cause or silently terminate healthy long turns. The combined route still needs a client-side semantic boundary; if a future client sends an explicit privacy-safe progress marker, the proxy may enforce that contract without inferring workspace state. + [Decision Log] - 목적과 의도: Keep transport helpers reusable without making every consumer evaluate the full routed Responses and sidecar graph at module load. - 기존 구현 및 제약 조건: The original `responses.ts` split copied the monolith import header into `fetch-helpers.ts`; seven helper exports therefore retained 39 distinct runtime import specifiers and reached 326 modules even though the implementations used only three runtime dependencies. diff --git a/tests/active-registry-admission.test.ts b/tests/active-registry-admission.test.ts index 9f1864370e..3a41d2dbf2 100644 --- a/tests/active-registry-admission.test.ts +++ b/tests/active-registry-admission.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { abortAndReleaseAllTurns, activeRegistryMetrics, trackStreamLifetime, tryAdmitTurn, unregisterTurn } from "../src/server/lifecycle"; +import { MAX_ACTIVE_TURNS, abortAndReleaseAllTurns, activeRegistryMetrics, trackStreamLifetime, tryAdmitTurn, unregisterTurn } from "../src/server/lifecycle"; import { MAX_TRACKED_CODEX_WEBSOCKETS, getTrackedCodexWebSocketCountForAccount, @@ -176,6 +176,16 @@ describe("active registry admission", () => { expect(activeRegistryMetrics().activeTurns.active).toBe(before); }); + test("the 256-turn gate bounds concurrency, not sequential continuation count", () => { + const before = activeRegistryMetrics().activeTurns.active; + for (let index = 0; index <= MAX_ACTIVE_TURNS; index += 1) { + const lease = tryAdmitTurn("one-logical-session"); + expect(lease).not.toBeNull(); + lease!.release(); + } + expect(activeRegistryMetrics().activeTurns.active).toBe(before); + }); + test("forced shutdown abort releases every lease and later finalizers cause no miss or underflow", () => { const before = activeRegistryMetrics().activeTurns; const controllers = [new AbortController(), new AbortController()]; diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index b9d34024f8..aa05690e38 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -138,6 +138,34 @@ describe("Responses previous_response_id state", () => { clearResponseStateMemoryForTests(); }); + test("a slow progressing Cursor chain remains replayable beyond the reported 18 continuations", () => { + let request: Record = { + model: "cursor/grok-4.6", + input: [{ role: "user", content: "bounded task" }], + store: false, + }; + + for (let turn = 0; turn < 32; turn += 1) { + const responseId = `resp_progress_${turn}`; + const callId = `call_progress_${turn}`; + rememberResponseState( + request, + fixedResponse(responseId, [{ type: "function_call", call_id: callId, name: "inspect", arguments: "{}" }]), + { cursor: { conversationId: "cursor_progress_chain" } }, + { force: true }, + ); + request = expandPreviousResponseInput({ + model: "cursor/grok-4.6", + previous_response_id: responseId, + input: [{ type: "function_call_output", call_id: callId, output: `new evidence ${turn}` }], + store: false, + }) as Record; + } + + expect(request.input).toBeArrayOfSize(65); + expect(previousResponseConversationId("resp_progress_31")).toBe("cursor_progress_chain"); + }); + afterEach(() => { setSpillIoForTest(null); setIcaclsRunnerForTests(null); From faf70c4ad870c37ecd3cf43ce35670761f141674 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:28:07 +0900 Subject: [PATCH 061/336] fix(subagents): carry entitlement into the recovery preview (#2509) (#2629) #2515 fixed the primary selection path to preview per candidate quota scope, and #2623 added the entitled-account filter there. The encrypted-recovery path got the scope but not the filter: it re-previewed per candidate and passed no eligible-account set, so a recovered assignment could select an account with no entitlement to the model and fail closed at final auth. Same stale-selection class as the quota scope, one layer over. Pinned structurally, like the route-inventory contract: both preview assignment sites must accept and forward modelEligibleAccountIds. Driving it end to end needs a recovered encrypted assignment AND an account-gated candidate whose entitlement differs per account, and that fixture proved more fragile than the thing it checks - I tried it and dropped it rather than ship a flaky test. What this does catch is the regression that actually threatens the fix: one of the two sites silently losing the argument again, which is how recovery lost it. Falsified: reverting the recovery site to the two-argument form reddens exactly this test. --- src/server/responses/core.ts | 16 +++++++-- ...subagent-fallback-handle-responses.test.ts | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index f9a5c204b9..1466e723aa 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2630,14 +2630,23 @@ async function handleResponsesInner( && recoverySelectionAdmission?.mainProfileDraining === true, }; const recoveryNow = Date.now(); - subagentFallbackAccountPreview = (modelId, previewNow) => previewCodexAccountForRequest( + // Carry the entitlement filter through recovery too (#2509/#2623). The scope was + // already re-previewed per candidate here; the ELIGIBLE-ACCOUNT set was not, so a + // recovered assignment could select an account that is not entitled to the model + // and then fail closed at final auth — the same class of stale-selection bug as + // the quota scope, one layer over. + subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest( poolAffinityKey, config, previewNow, codexQuotaScopeForModel(modelId), - recoverySelectionOptions, + { ...recoverySelectionOptions, modelEligibleAccountIds }, + ); + const recoveryPreviewAccountId = subagentFallbackAccountPreview( + parsed.modelId, + recoveryNow, + subagentFallbackModelEligibleAccountIdsForModel?.(parsed.modelId), ); - const recoveryPreviewAccountId = subagentFallbackAccountPreview(parsed.modelId, recoveryNow); return applySubagentModelFallback( parsed, req.headers, @@ -2647,6 +2656,7 @@ async function handleResponsesInner( false, recoverySelectionOptions, subagentFallbackAccountPreview, + subagentFallbackModelEligibleAccountIdsForModel, ); } finally { recoverySelectionAdmission?.release(); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index d9c2e34964..384368eae7 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -1274,6 +1274,39 @@ describe("native fallback account preview", () => { expect(bodyRequests[1]?.auth).toContain("pool-b_token"); }); + /** + * Recovery must carry the ENTITLEMENT filter too, not only the quota scope (#2509). + * + * The end-to-end case above grants the roster to both pool accounts, so it can only prove the + * SCOPE is re-previewed per candidate. The recovery path re-previewed the scope but passed no + * eligible-account set, so it could select an account with no entitlement to the recovered + * model and fail closed at final auth — the same stale-selection class as the quota scope, one + * layer over. + * + * Asserted structurally on the source, like the route-inventory contract: driving it end to end + * needs a recovered encrypted assignment AND an account-gated candidate whose entitlement + * differs per account, and the resulting fixture proved more fragile than the thing it checks. + * What this does catch is the regression that actually threatens the fix — one of the two + * preview sites silently losing the eligibility argument again. + */ + test("both fallback preview sites pass the model-eligible account set (#2509)", async () => { + const source = await Bun.file( + new URL("../src/server/responses/core.ts", import.meta.url).pathname, + ).text(); + + const previews = source.match(/subagentFallbackAccountPreview = \([^)]*\)/g) ?? []; + // Two assignment sites: the primary selection path and the encrypted-recovery path. + expect(previews).toHaveLength(2); + // Neither may drop the third parameter — that is exactly how recovery lost it. + for (const preview of previews) { + expect(preview).toContain("modelEligibleAccountIds"); + } + + // And both must actually forward it into the preview call, not merely accept it. + const forwarded = source.match(/\{ \.\.\.(previewSelectionOptions|recoverySelectionOptions), modelEligibleAccountIds \}/g) ?? []; + expect(forwarded).toHaveLength(2); + }); + test("uses healthier pool account B when active A is above threshold", async () => { const now = 1_800_000_000_000; Date.now = () => now; From 324c1aa875407f45abf62c5dd04e4a4ae3e2110f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:29:31 +0900 Subject: [PATCH 062/336] fix: catalog effort ladders, v2 pin precedence, and combo owned_by (#2410 #2409 #2401) (#2630) * fix(catalog): expose opencode-go reasoning efforts * fix(models): advertise combo rows via OpenAI adapter * fix(codex): honor native v1 hybrid pin --- .../content/docs/guides/sub-agent-surface.md | 4 +- .../src/content/docs/reference/cli/agents.md | 10 ++- .../docs/reference/configuration/agents.md | 2 +- gui/src/api-access-models.ts | 9 ++- gui/src/pages/ApiKeys.tsx | 2 +- gui/tests/api-access-models.test.ts | 4 +- src/cli/v2.ts | 38 ++++++++-- src/providers/registry.ts | 2 + src/server/index.ts | 13 +++- .../management/agent-settings-routes.ts | 23 +++++- structure/03_catalog-and-subagents.md | 5 ++ structure/05_gui-and-management-api.md | 2 +- tests/codex-catalog.test.ts | 20 ++++- tests/codex-v2-gate.test.ts | 1 + tests/multi-agent-keep-native-v1.test.ts | 76 ++++++++++++++++++- tests/provider-registry-parity.test.ts | 4 + tests/server-combo-failover-e2e.test.ts | 12 +-- 17 files changed, 189 insertions(+), 38 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 4a56c810bd..f88ba9c10b 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -23,7 +23,9 @@ Choose the mode for **new sessions**. Existing sessions keep the surface they st On **v2**, an optional **Keep ChatGPT on v1** switch (`keepNativeChatGptOnV1`) leaves Sol/Terra on the v1 surface so they can still spawn Grok or Claude. ChatGPT-native parents encrypt v2 `NEW_TASK` bodies; routed models cannot read them. Routed parents stay on v2, where child tasks -are plaintext. This is a switch *inside* v2, not a fourth catalog mode. +are plaintext. OpenCodex disables the global `multi_agent_v2` override for this hybrid because +Codex applies that override before per-model catalog pins. This is a switch *inside* v2, not a +fourth catalog mode. :::tip[Not sure?] Start with **base**. Choose **v1** when cross-provider delegation must work predictably. Force **v2** diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 931f22b3f5..10dcc97978 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -40,12 +40,12 @@ Manage the Codex `multi_agent_v2` feature flag and the three-state multi-agent s | Subcommand | Action | | --- | --- | | `status` (default) | Report the current v2 flag, multi-agent mode, and thread concurrency. | -| `on` | Enable the `multi_agent_v2` feature and resync the catalog. | +| `on` | Enable the global `multi_agent_v2` feature and resync the catalog. Rejected while the v2 hybrid pin is active because the global override would defeat it. | | `off` | Disable the `multi_agent_v2` feature and resync the catalog. | | `mode v1` | Force all models to v1, disable native v2, and preserve the active thread limit. | | `mode default` | Respect upstream model surface pins. | -| `mode v2` | Force models to v2, enable native v2, and preserve the active thread limit. ChatGPT-native models are exempt while `keep-native-v1` is on. | -| `keep-native-v1 on\|off` | Under `mode v2`, keep ChatGPT-native models on v1 instead of stamping them v2. | +| `mode v2` | Force models to v2 and preserve the active thread limit. With `keep-native-v1` off, enable global native v2; with it on, disable the global override and use catalog pins. | +| `keep-native-v1 on\|off` | Under `mode v2`, keep ChatGPT-native models on v1 and routed models on v2. Enabling it disables the global V2 override before catalog sync. | | `threads ` | Set the active v1/v2 thread limit to an integer of at least 1. | | `mode-hint ` | Set the Proactive delegation hint (Ultra mode) for every model and effort. | | `mode-hint --clear` | Remove the hint so the effort-derived policy (ultra = proactive) resumes. | @@ -65,6 +65,10 @@ Mode and flag transitions move the current numeric thread limit between the vali a failed transition restores the original `config.toml`. Changes apply to new Codex sessions, while running sessions keep their pinned surface. +Codex resolves an enabled global `multi_agent_v2` override before the selected model's catalog +pin. The hybrid `keep-native-v1` contract therefore keeps that global override off; otherwise a +native row stamped `v1` would still start on V2 and produce backend-encrypted child tasks. + `mode-hint` writes `features.multi_agent_v2.multi_agent_mode_hint_text` in Codex's `$CODEX_HOME/config.toml` even when `multi_agent_v2` is currently disabled. The command only persists the override; it does not enable or disable the feature, so diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index ddf93a2d47..76e0a23927 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -11,7 +11,7 @@ routes, and limits delegated work. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. Applies to new sessions. | -| `keepNativeChatGptOnV1?` | `boolean` | `false` | When `multiAgentMode` is `"v2"`, stamp ChatGPT-native rows (Sol/Terra and other ChatGPT-backend models) as v1. Routed parents stay on v2. Use this so a ChatGPT parent can still spawn Grok or Claude — native v2 child tasks are backend-encrypted ([#92](https://github.com/lidge-jun/opencodex/issues/92)). Ignored in `v1` and `default`. | +| `keepNativeChatGptOnV1?` | `boolean` | `false` | When `multiAgentMode` is `"v2"`, disable the global V2 override, stamp ChatGPT-native rows as v1, and keep routed rows on v2. Codex resolves the global override before catalog pins, so both parts are required for a ChatGPT parent to spawn routed children without backend-encrypted tasks ([#92](https://github.com/lidge-jun/opencodex/issues/92)). Ignored in `v1` and `default`. | | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | Up to five bare native, account-qualified `/`, or routed `provider/model` ids featured first in the sub-agent picker. The dashboard offers only bare native and routed ids and omits exact account-qualified choices when it saves; use `ocx agent subagents set` or edit the configuration for exact choices. An explicit empty list is preserved. | | `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored v2 delegation guidance. | | `injectionEffort?` | `string` | — | Preferred effort (`low` through `ultra`), meaningful only with `injectionModel`. | diff --git a/gui/src/api-access-models.ts b/gui/src/api-access-models.ts index f0f84c94b3..43141ebf43 100644 --- a/gui/src/api-access-models.ts +++ b/gui/src/api-access-models.ts @@ -18,18 +18,21 @@ export function gatewayInboundProtocols(claudeCodeEnabled: boolean): GatewayInbo } /** - * Classify a `/v1/models` row. Bare IDs keep their callable id; `owned_by` - * decides native/combo/custom so combo aliases are not labeled OpenAI. + * Classify a `/v1/models` row. Bare IDs keep their callable id; the explicit + * combo marker takes precedence over the compatibility-oriented `owned_by`. */ export function classifyExternalModel(row: { id: string; owned_by?: string; + is_combo?: boolean; }): ExternalModelRow { const slashIndex = row.id.indexOf("/"); const ownedBy = typeof row.owned_by === "string" && row.owned_by.trim() ? row.owned_by.trim() : undefined; - const provider = slashIndex > 0 + const provider = row.is_combo === true + ? "combo" + : slashIndex > 0 ? row.id.slice(0, slashIndex) : (ownedBy ?? "openai"); const native = slashIndex < 0 && provider === "openai"; diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index f5ce59ca87..544b321ef4 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -161,7 +161,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a : null); if (!rawRows) throw new Error(t("api.modelsLoadFailed")); const rows = rawRows - .filter((row): row is { id: string; owned_by?: string } => ( + .filter((row): row is { id: string; owned_by?: string; is_combo?: boolean } => ( typeof row === "object" && row !== null && typeof (row as { id?: unknown }).id === "string" diff --git a/gui/tests/api-access-models.test.ts b/gui/tests/api-access-models.test.ts index efe4cd0f2d..8fb404ebcb 100644 --- a/gui/tests/api-access-models.test.ts +++ b/gui/tests/api-access-models.test.ts @@ -15,8 +15,8 @@ describe("classifyExternalModel", () => { }); }); - test("classifies bare combo aliases from owned_by without rewriting the id", () => { - expect(classifyExternalModel({ id: "fast-chat", owned_by: "combo" })).toEqual({ + test("classifies bare combo aliases from the explicit marker without rewriting the id", () => { + expect(classifyExternalModel({ id: "fast-chat", owned_by: "openai", is_combo: true })).toEqual({ id: "fast-chat", displayName: "fast-chat", provider: "combo", diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 1e817e084e..b1e63b0c5c 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -88,18 +88,24 @@ function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void { export function v2StatusLine(enabled: boolean): string { return enabled - ? "multi_agent_v2: ON — v2 multi-agent surface active" - : "multi_agent_v2: OFF — v1 multi-agent surface (default install)"; + ? "multi_agent_v2: ON — global V2 override active" + : "multi_agent_v2: OFF — model catalog pins and defaults decide the surface"; } -export function multiAgentModeLine(mode: string): string { +export function multiAgentModeLine(mode: string, keepNativeChatGptOnV1 = false): string { switch (mode) { case "v1": return "multi_agent_mode: v1 — ALL models forced to v1 surface (upstream pins overridden)"; - case "v2": return "multi_agent_mode: v2 — ALL models forced to v2 surface (upstream pins overridden)"; + case "v2": return keepNativeChatGptOnV1 + ? "multi_agent_mode: v2 hybrid — ChatGPT-native models use v1; routed models use v2" + : "multi_agent_mode: v2 — ALL models forced to v2 surface (upstream pins overridden)"; default: return "multi_agent_mode: default — upstream model pins respected (sol/terra=v2, luna=v1, rest=codex flag)"; } } +function requiresGlobalV2Disabled(multiAgentMode: string | undefined, keepNativeChatGptOnV1: boolean): boolean { + return multiAgentMode === "v2" && keepNativeChatGptOnV1; +} + export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () => Promise): Promise { const log = deps.log ?? console; const isEnabled = deps.isEnabled ?? isMultiAgentV2Enabled; @@ -109,9 +115,13 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () if (verb === "status") { log.log(v2StatusLine(isEnabled())); const cfg = loadConfig(); - log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default")); + const mode = cfg.multiAgentMode ?? "default"; + const keepNativeV1 = cfg.keepNativeChatGptOnV1 === true; + log.log(multiAgentModeLine(mode, keepNativeV1)); log.log(cfg.keepNativeChatGptOnV1 === true - ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2" + ? requiresGlobalV2Disabled(mode, keepNativeV1) && isEnabled() + ? "keep_native_chatgpt_on_v1: CONFLICT — global multi_agent_v2 overrides the native v1 catalog pin; run 'ocx v2 keep-native-v1 on' to reconcile" + : "keep_native_chatgpt_on_v1: ON — global V2 override is off; ChatGPT-native rows use v1 and routed rows use v2 when mode is v2" : "keep_native_chatgpt_on_v1: OFF"); const threads = getLogicalMaxThreads(); log.log(`max_threads: ${threads ?? "(unset — codex default)"}`); @@ -186,7 +196,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () } const cfg = loadConfig(); if (modeArg !== "default") { - const target = modeArg === "v2"; + const target = modeArg === "v2" && cfg.keepNativeChatGptOnV1 !== true; const transition = transitionMultiAgentV2(target, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps)); if (!transition.ok) { log.error(`multi-agent mode transition failed: ${transition.error}`); @@ -216,6 +226,13 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () const cfg = loadConfig(); const next = flag === "on"; const already = cfg.keepNativeChatGptOnV1 === true === next; + if (next && requiresGlobalV2Disabled(cfg.multiAgentMode, true)) { + const transition = transitionMultiAgentV2(false, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps)); + if (!transition.ok) { + log.error(`keep-native-v1 transition failed: ${transition.error}`); + return 1; + } + } if (next) cfg.keepNativeChatGptOnV1 = true; else deleteConfigTopLevelKey(cfg, "keepNativeChatGptOnV1"); saveConfig(cfg); @@ -243,6 +260,13 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () } const want = verb === "on"; + if (want) { + const cfg = loadConfig(); + if (requiresGlobalV2Disabled(cfg.multiAgentMode, cfg.keepNativeChatGptOnV1 === true)) { + log.error("v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."); + return 1; + } + } const transition = transitionMultiAgentV2(want, enabled => runCodexFeatures(enabled ? "enable" : "disable", deps)); if (!transition.ok) { log.error(`codex features ${want ? "enable" : "disable"} multi_agent_v2 failed: ${transition.error}`); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5e664c3918..5ca6c03685 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1392,8 +1392,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], }, modelReasoningEfforts: { + "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, + "qwen3.8-max": QWEN38_REASONING_EFFORTS, "kimi-k3": KIMI_CODING_K3_REASONING_EFFORTS, "kimi-k2.7-code": [], "kimi-k2.7-code-highspeed": [], diff --git a/src/server/index.ts b/src/server/index.ts index 9c17fa9b0e..48d186f6f0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1034,6 +1034,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server nativeModelRow(id)), ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)), ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + const publicId = m.alias ?? `${m.provider}/${m.id}`; + const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId); const provider = config.providers[m.provider]; const effective = provider ? (await import("../providers/default-aliases")).effectiveModelAliases( @@ -1206,10 +1209,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { expect(models.filter(m => `${m.provider}/${m.id}` === "opencode-go/glm-5.2")).toHaveLength(1); }); + test("opencode-go live rows inherit same-model reasoning ladders from registry metadata (#2410)", () => { + const provider = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "opencode-go")!); + + const models = ["gpt-5.6-luna", "qwen3.8-max"].map(id => applyProviderConfigHints( + "opencode-go", + provider, + { provider: "opencode-go", id }, + )); + + expect(models.find(model => model.id === "gpt-5.6-luna")?.reasoningEfforts) + .toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(models.find(model => model.id === "qwen3.8-max")?.reasoningEfforts) + .toEqual(["low", "medium", "xhigh"]); + }); + test("opencode-go catalog sync appends jawcode rows with provider context-cap metadata", () => { const models = augmentRoutedModelsWithMetadata( [], @@ -5769,4 +5786,3 @@ describe("#2465 model preset management routes", () => { expect(status).toBe(400); }); }); - diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index 9959c5656e..7d813b5f09 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -1989,6 +1989,7 @@ describe("3-state multi-agent mode", () => { expect(multiAgentModeLine("v1")).toContain("v1"); expect(multiAgentModeLine("default")).toContain("default"); expect(multiAgentModeLine("v2")).toContain("v2"); + expect(multiAgentModeLine("v2", true)).toContain("v2 hybrid"); }); test("mode default restores upstream pins after a prior forced v2 (stale-clear regression)", () => { diff --git a/tests/multi-agent-keep-native-v1.test.ts b/tests/multi-agent-keep-native-v1.test.ts index abafed302c..da951b5c4a 100644 --- a/tests/multi-agent-keep-native-v1.test.ts +++ b/tests/multi-agent-keep-native-v1.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -10,7 +10,8 @@ import { import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "../src/codex/catalog/kinds"; import { buildCatalogEntriesFromObservedState } from "../src/codex/catalog/sync"; import { cmdV2 } from "../src/cli/v2"; -import { loadConfig } from "../src/config"; +import { loadConfig, saveConfig } from "../src/config"; +import { isMultiAgentV2Enabled } from "../src/codex/features"; import { handleManagementAPI } from "../src/server/management-api"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; import type { OcxConfig } from "../src/types"; @@ -163,6 +164,65 @@ describe("keep-native-v1 restamp path", () => { }); describe("ocx v2 keep-native-v1", () => { + test("enabling the native-v1 pin disables the global V2 override before catalog sync", async () => { + isolateHomes(); + saveConfig({ ...loadConfig(), multiAgentMode: "v2" }); + const codexConfig = join(process.env.CODEX_HOME!, "config.toml"); + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = true\n"); + const events: string[] = []; + + const code = await cmdV2(["keep-native-v1", "on"], { + execFile: (_file, args) => { + events.push(args.join(" ")); + writeFileSync(codexConfig, readFileSync(codexConfig, "utf8").replace("enabled = true", "enabled = false")); + }, + sync: async () => { events.push("sync"); }, + log: captureLog().log, + }); + + expect(code).toBe(0); + expect(isMultiAgentV2Enabled(codexConfig)).toBe(false); + expect(events).toEqual(["features disable multi_agent_v2", "sync"]); + }); + + test("an explicit global V2 enable is rejected while the hybrid native-v1 pin is active", async () => { + isolateHomes(); + saveConfig({ ...loadConfig(), multiAgentMode: "v2", keepNativeChatGptOnV1: true }); + const codexConfig = join(process.env.CODEX_HOME!, "config.toml"); + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = false\n"); + const { errors, log } = captureLog(); + let toggles = 0; + + expect(await cmdV2(["on"], { + execFile: () => { toggles++; }, + sync: async () => { throw new Error("must not sync"); }, + log, + })).toBe(1); + expect(toggles).toBe(0); + expect(isMultiAgentV2Enabled(codexConfig)).toBe(false); + expect(errors.join("\n")).toContain("global multi_agent_v2 overrides the native v1 catalog pin"); + }); + + test("mode v2 honors a pre-existing native-v1 pin instead of enabling the global override", async () => { + isolateHomes(); + saveConfig({ ...loadConfig(), keepNativeChatGptOnV1: true }); + const codexConfig = join(process.env.CODEX_HOME!, "config.toml"); + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = true\n"); + const actions: string[] = []; + + expect(await cmdV2(["mode", "v2"], { + execFile: (_file, args) => { + actions.push(args[1]!); + writeFileSync(codexConfig, readFileSync(codexConfig, "utf8").replace("enabled = true", "enabled = false")); + }, + sync: async () => {}, + log: captureLog().log, + })).toBe(0); + expect(loadConfig().multiAgentMode).toBe("v2"); + expect(isMultiAgentV2Enabled(codexConfig)).toBe(false); + expect(actions).toEqual(["disable"]); + }); + test("on/off persist, always re-sync the catalog, and reject bad args", async () => { isolateHomes(); const { logs, errors, log } = captureLog(); @@ -211,6 +271,8 @@ describe("ocx v2 keep-native-v1", () => { describe("/api/v2 keepNativeChatGptOnV1", () => { test("GET/PUT persist the flag, warn by mode, and restamp via catalog convergence", async () => { isolateHomes(); + const codexConfig = join(process.env.CODEX_HOME!, "config.toml"); + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = false\n"); const config: OcxConfig = { providers: {}, hostname: "127.0.0.1", port: 10100, defaultProvider: "openai" } as OcxConfig; const seen: Array<{ keepNativeChatGptOnV1?: boolean; multiAgentMode?: string }> = []; let converges = 0; @@ -221,7 +283,12 @@ describe("/api/v2 keepNativeChatGptOnV1", () => { multiAgentMode: config.multiAgentMode, }); }); - const deps = { createManagementConvergeCodex: factory }; + const deps = { + createManagementConvergeCodex: factory, + toggleCodexMultiAgentV2: (enabled: boolean) => { + writeFileSync(codexConfig, readFileSync(codexConfig, "utf8").replace(/enabled = (?:true|false)/, `enabled = ${enabled}`)); + }, + }; const get0 = await handleManagementAPI(getV2(), new URL("http://localhost/api/v2"), config, deps); expect(await get0?.json()).toMatchObject({ keepNativeChatGptOnV1: false, multiAgentMode: "default" }); @@ -245,6 +312,7 @@ describe("/api/v2 keepNativeChatGptOnV1", () => { // Applicability is keyed off the effective mode, not a features.toml flip. config.multiAgentMode = "v2"; + writeFileSync(codexConfig, "[features.multi_agent_v2]\nenabled = true\n"); const v2 = await handleManagementAPI( putV2({ keepNativeChatGptOnV1: true }), new URL("http://localhost/api/v2"), @@ -253,7 +321,7 @@ describe("/api/v2 keepNativeChatGptOnV1", () => { ); expect(v2?.status).toBe(200); const v2Body = await v2?.json() as { keepNativeChatGptOnV1: boolean; multiAgentMode: string; warnings: string[] }; - expect(v2Body).toMatchObject({ keepNativeChatGptOnV1: true, multiAgentMode: "v2" }); + expect(v2Body).toMatchObject({ enabled: false, keepNativeChatGptOnV1: true, multiAgentMode: "v2" }); expect(v2Body.warnings).toContain( "ChatGPT-native models stay on v1 while other models use v2. Applies to new sessions.", ); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 4ec3bc33b3..e421907058 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -79,6 +79,10 @@ describe("provider registry parity", () => { "kimi-k3": { none: "none", low: "low", medium: "high", high: "high", xhigh: "max", max: "max" }, }, }); + expect(KEY_LOGIN_PROVIDERS["opencode-go"].modelReasoningEfforts?.["gpt-5.6-luna"]) + .toEqual(KEY_LOGIN_PROVIDERS["openai-apikey"].modelReasoningEfforts?.["gpt-5.6-luna"]); + expect(KEY_LOGIN_PROVIDERS["opencode-go"].modelReasoningEfforts?.["qwen3.8-max"]) + .toEqual(KEY_LOGIN_PROVIDERS["alibaba-token-plan"].modelReasoningEfforts?.["qwen3.8-max"]); expect(KEY_LOGIN_PROVIDERS["opencode-go"].noTemperatureModels).toContain("kimi-k3"); expect(KEY_LOGIN_PROVIDERS["opencode-go"].noTopPModels).toContain("kimi-k3"); expect(KEY_LOGIN_PROVIDERS["opencode-go"].noPenaltyModels).toContain("kimi-k3"); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index ad4cc46592..ea34844044 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -809,7 +809,7 @@ describe("server combo failover 030 activation matrix", () => { const response = await fetch(new URL("/v1/models", server.url)); expect(response.status).toBe(200); const payload = await response.json() as { - data: Array<{ id: string; owned_by: string }>; + data: Array<{ id: string; owned_by: string; is_combo?: boolean }>; }; return payload.data; }; @@ -820,7 +820,7 @@ describe("server combo failover 030 activation matrix", () => { }); expect((await publicRows()).filter(model => model.id === selector)).toEqual([ - { id: selector, object: "model", created: 0, owned_by: "combo" }, + { id: selector, object: "model", created: 0, owned_by: "openai", is_combo: true }, ]); const renamed = await updateAlias("fast-chat"); @@ -830,7 +830,7 @@ describe("server combo failover 030 activation matrix", () => { { id: selector, object: "model", created: 0, owned_by: "deepseek" }, ]); expect(renamedRows.filter(model => model.id === "fast-chat")).toEqual([ - { id: "fast-chat", object: "model", created: 0, owned_by: "combo" }, + { id: "fast-chat", object: "model", created: 0, owned_by: "openai", is_combo: true }, ]); const restored = await updateAlias(selector); @@ -841,7 +841,7 @@ describe("server combo failover 030 activation matrix", () => { expect(deletedRows.filter(model => model.id === selector)).toEqual([ { id: selector, object: "model", created: 0, owned_by: "deepseek" }, ]); - expect(deletedRows.some(model => model.owned_by === "combo")).toBe(false); + expect(deletedRows.some(model => model.is_combo === true)).toBe(false); } finally { await server.stop(true); } @@ -861,10 +861,10 @@ describe("server combo failover 030 activation matrix", () => { const response = await fetch(new URL("/v1/models", server.url)); expect(response.status).toBe(200); const payload = await response.json() as { - data: Array<{ id: string; owned_by: string }>; + data: Array<{ id: string; owned_by: string; is_combo?: boolean }>; }; expect(payload.data.filter(model => model.id.startsWith("a/vendor")).sort((a, b) => a.id.localeCompare(b.id))).toEqual([ - { id: "a/vendor-model", object: "model", created: 0, owned_by: "combo" }, + { id: "a/vendor-model", object: "model", created: 0, owned_by: "openai", is_combo: true }, { id: "a/vendor/model", object: "model", created: 0, owned_by: "a" }, ]); } finally { From 848a66d1597997a07f81f84451f6fdb574fac769 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:37:57 +0900 Subject: [PATCH 063/336] fix: gate root skip-permissions bypass and ship the auto-review model override (#1688 #1225) (#2631) * feat(catalog): apply configured auto_review_model override during sync (closes #1225) * test(catalog): cover whitespace trimming and preservation in auto_review_model override (#1225) * feat(catalog): apply auto_review_model in convergence and update docs (#1225) * fix(catalog): validate auto_review_model slug format before applying override (#1225) * test(catalog): cover config-to-catalog auto_review_model stamp path (#1225) * fix(catalog): resolve auto-review selector against final catalog * fix(claude): gate root skip-permissions bypass * fix(catalog): keep cache invalidation bound to owning home --------- Co-authored-by: chilung --- .../docs/reference/configuration/providers.md | 15 ++ src/cli/claude.ts | 27 +++- src/codex/catalog.ts | 2 +- src/codex/catalog/parsing.ts | 16 +++ src/codex/catalog/sync.ts | 129 +++++++++++++++++- src/codex/convergence.ts | 2 + tests/claude-cli.test.ts | 37 ++++- tests/codex-catalog.test.ts | 73 ++++++++++ ...odex-convergence-account-selectors.test.ts | 97 +++++++++++++ 9 files changed, 392 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 624950dd5e..fc8a92ccd4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -133,6 +133,21 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +## Codex catalog and root `config.toml` settings + +These settings belong in the root of `$CODEX_HOME/config.toml`, alongside +`approvals_reviewer`; they are not provider fields. + +| Field | Type | Meaning | +| --- | --- | --- | +| `auto_review_model` | `string` | Public catalog selector in `provider/model` form, for example `opencode-go/deepseek-v4-flash`. After each catalog merge, OpenCodex resolves it against the final catalog and stamps the trimmed value as `auto_review_model_override` on catalog entries. Boundary whitespace is removed; the selector's slash-delimited components are otherwise unchanged. If the value is absent or blank, existing routed overrides are cleared and normal upstream auto-review selection is preserved. If it is syntactically invalid or absent from the final catalog (including after provider/model removal), OpenCodex fails closed for the override only: it clears the dead override, preserves normal upstream behavior, and emits a diagnostic. Re-adding the provider/model on a later sync allows the configured selector to be stamped again. | + +The setting is evaluated after provider discovery, model filtering, native/account-row +projection, and merge precedence, so only a selector present in the catalog produced by +that sync can become an override. Native upstream values are preserved when the setting is +cleared or unresolved. The persisted catalog field is read by Codex for the current turn's +model, which is why a valid configured selector is copied to each applicable entry. + ### FastWire B1 capability migration Fast capability and arbitrary Chat caller-tier forwarding are independent after FastWire B1. The diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 07f9599f50..ebb77d3359 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -34,6 +34,8 @@ export type ClaudeEnvDeps = { authDetect?: Omit, "env" | "ownTokens">; /** Test seam; production uses the authenticated Node-launcher context. */ preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null; + /** Explicit unsafe opt-in from a root `--dangerously-skip-permissions` launch. */ + allowRootSkipPermissions?: boolean; }; function isClaudeLoopbackHostname(hostname: string): boolean { @@ -115,6 +117,9 @@ export function buildClaudeEnv( if (env[name] !== undefined && env[name] !== "") return; // user wins env[name] = value; }; + if (deps.allowRootSkipPermissions === true) { + setDefault("IS_SANDBOX", "1"); + } setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`); const existingBaseUrl = env.ANTHROPIC_BASE_URL; if (existingBaseUrl) { @@ -301,6 +306,22 @@ export function claudeNotFoundHint( return platform === "win32" && code === 9009 && !signal ? CLAUDE_INSTALL_HINT : null; } +export function shouldAllowRootSkipPermissions( + args: readonly string[], + getuid: (() => number) | null | undefined = process.getuid, +): boolean { + return args.includes("--dangerously-skip-permissions") + && typeof getuid === "function" + && getuid() === 0; +} + +export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string { + if (env.IS_SANDBOX === "1") { + return "⚠ Root --dangerously-skip-permissions requested: OpenCodex set IS_SANDBOX=1 to bypass Claude Code's root guard. OpenCodex did not create an OS sandbox; prefer running as a non-root user."; + } + return `⚠ Root --dangerously-skip-permissions requested: preserving user IS_SANDBOX=${env.IS_SANDBOX}; Claude Code's root guard remains in control.`; +} + export async function cmdClaude(args: string[]): Promise { const config = loadConfig(); if (config.claudeCode?.enabled === false) { @@ -313,7 +334,11 @@ export async function cmdClaude(args: string[]): Promise { return 1; } const contextWindows = await fetchClaudeContextWindows(config, port); - const env = buildClaudeEnv(config, port, process.env, contextWindows); + const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args); + const env = buildClaudeEnv(config, port, process.env, contextWindows, { allowRootSkipPermissions }); + if (allowRootSkipPermissions) { + console.error(rootSkipPermissionsNotice(env)); + } // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI // never refreshes it, so the picker would keep showing yesterday's aliases. try { diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 8a0acb06fd..ce7eaf1615 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; -export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; +export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync"; export type { ObservedCatalogMergeInput } from "./catalog/sync"; export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 7d150567d9..0293b4bd04 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -223,6 +223,22 @@ export function readCodexCatalogPathForHome(codexHome: string): string { return join(codexHome, "opencodex-catalog.json"); } +/** + * Read the configured auto-review model from the root of Codex's config.toml (issue #1225). + * Stamped onto catalog entries as `auto_review_model_override` during sync so the auto-review + * subagent uses the operator's chosen model across catalog regenerations. + */ +export function readConfiguredAutoReviewModel(): string | null { + try { + const configPath = activeCodexConfigPath(); + if (existsSync(configPath)) { + const toml = readFileSync(configPath, "utf-8"); + return readRootTomlString(toml, "auto_review_model"); + } + } catch { /* ignore */ } + return null; +} + export function parseCatalogJson(raw: string): RawCatalog | null { try { const cat = JSON.parse(raw); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d5a894cd0e..fda9724849 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -41,7 +41,7 @@ import { } from "../model-entitlements"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; import { @@ -1403,6 +1403,130 @@ function catalogModelsForMergeWithNativeRecovery( ]); } +const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/; + +export function isValidAutoReviewModel(value: unknown): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + return Boolean(trimmed) + && trimmed.length <= 1024 + && !AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed); +} + +export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved"; + +function isRoutedCatalogEntry(entry: RawEntry): boolean { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return slug.includes("/") + || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → ")); +} + +function clearAutoReviewModelOverride( + models: readonly RawEntry[], + sourceModels: readonly RawEntry[] = [], +): void { + const observedModels = [...models, ...sourceModels]; + const configuredValues = new Set(observedModels.flatMap(entry => { + const value = entry?.auto_review_model_override; + return typeof value === "string" && value.trim() ? [value] : []; + })); + const globalStamp = configuredValues.size === 1 + && observedModels.some(entry => { + const value = entry.auto_review_model_override; + return isRoutedCatalogEntry(entry) + && typeof value === "string" + && value.trim().length > 0 + && configuredValues.has(value); + }) + && observedModels.every(entry => { + const value = entry?.auto_review_model_override; + return value === null + || value === undefined + || (typeof value === "string" && configuredValues.has(value)); + }); + for (const entry of models) { + if (!entry || typeof entry !== "object") continue; + const current = entry.auto_review_model_override; + if (isRoutedCatalogEntry(entry) + || (globalStamp && typeof current === "string" && configuredValues.has(current))) { + entry.auto_review_model_override = null; + } + } +} + +function warnAutoReviewModelDiagnostic( + reason: "invalid" | "unresolved", + configured: string, +): void { + const safeConfigured = JSON.stringify(redactSecretString(configured)); + const detail = reason === "unresolved" + ? "the selector was not found in the final catalog" + : "the selector format is invalid"; + console.warn( + `[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`, + ); +} + +function preserveNativeAutoReviewModelOverrides( + models: readonly RawEntry[], + sourceModels: readonly RawEntry[], +): void { + const existing = new Map(); + for (const entry of sourceModels) { + const slug = typeof entry.slug === "string" ? entry.slug : undefined; + const value = entry.auto_review_model_override; + if (!slug || isRoutedCatalogEntry(entry)) continue; + if (typeof value === "string" || value === null) existing.set(slug, value); + } + for (const entry of models) { + const slug = typeof entry.slug === "string" ? entry.slug : undefined; + if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue; + entry.auto_review_model_override = existing.get(slug) ?? null; + } +} + +export function applyAutoReviewModelOverride( + models: RawEntry[] | undefined, + autoReviewModel: string | null | undefined, + sourceModels: readonly RawEntry[] = [], +): AutoReviewModelOverrideResult { + if (!models || !Array.isArray(models)) return "absent"; + if (autoReviewModel === null || autoReviewModel === undefined) { + clearAutoReviewModelOverride(models, sourceModels); + return "absent"; + } + const trimmed = autoReviewModel.trim(); + if (!trimmed) { + clearAutoReviewModelOverride(models, sourceModels); + return "absent"; + } + if (!isValidAutoReviewModel(trimmed)) { + clearAutoReviewModelOverride(models, sourceModels); + warnAutoReviewModelDiagnostic("invalid", trimmed); + return "invalid"; + } + if (!configuredCatalogEntry(models, trimmed)) { + clearAutoReviewModelOverride(models, sourceModels); + warnAutoReviewModelDiagnostic("unresolved", trimmed); + return "unresolved"; + } + for (const entry of models) { + if (entry && typeof entry === "object") { + entry.auto_review_model_override = trimmed; + } + } + return "applied"; +} + +/** Apply the root Codex auto-review selector after the final catalog merge. */ +export function finalizeAutoReviewModelOverride( + models: RawEntry[] | undefined, + sourceModels: readonly RawEntry[] = [], +): AutoReviewModelOverrideResult { + if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels); + return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); +} + function writeRetainedCatalogSync({ config, goModels, @@ -1596,6 +1720,7 @@ function writeRetainedCatalogSync({ }, }); clampCatalogModelsToCodexSupport(catalog.models); + finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge); const added = goEntries.length + accountBoundEntries.length; const content = `${JSON.stringify(catalog, null, 2)}\n`; @@ -1833,10 +1958,10 @@ export function invalidateCodexModelsCacheWithPermit( // keeps the cache consistent with the catalog it just wrote. if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false; const catalogPath = readCodexCatalogPathForHome(owningCodexHome); - const cachePath = join(owningCodexHome, "models_cache.json"); if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const models = catalog.models ?? catalog; + const cachePath = join(owningCodexHome, "models_cache.json"); const currentCache = readCatalog(cachePath); const existingSlugs = new Set(models.flatMap((entry: RawEntry) => typeof entry.slug === "string" ? [entry.slug] : [])); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 67d09ec873..b338aa9d3b 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -42,6 +42,7 @@ import { import { buildCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + finalizeAutoReviewModelOverride, mergeCatalogEntriesFromObservedState, mergeCatalogModelsWithNativeRecovery, orderForSubagents, @@ -371,6 +372,7 @@ function prepareCatalog( ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog) : null, ); + finalizeAutoReviewModelOverride(mergedModels, catalogModels); catalog.models = mergedModels; return catalog; } diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index e782252bf4..aa0950a9b8 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { claudeNotFoundHint } from "../src/cli/claude"; +import { buildClaudeEnv, claudeNotFoundHint, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude"; import { commandInvocation } from "../src/lib/win-exec"; -import { buildClaudeEnv } from "../src/cli/claude"; import type { OcxConfig } from "../src/types"; function cfg(extra?: Partial): OcxConfig { @@ -27,6 +26,40 @@ const AUTH_PRESENT = { }; describe("ocx claude env assembly", () => { + test("root skip-permissions bypass requires both the explicit flag and uid 0", () => { + expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], () => 0)).toBe(true); + expect(shouldAllowRootSkipPermissions([], () => 0)).toBe(false); + expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], () => 1000)).toBe(false); + expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], null)).toBe(false); + }); + + test("root skip-permissions opt-in marks only that launch as sandboxed", () => { + const bypass = buildClaudeEnv(cfg(), 10100, {}, {}, { + ...AUTH_PRESENT, + allowRootSkipPermissions: true, + }); + expect(bypass.IS_SANDBOX).toBe("1"); + + const ordinary = buildClaudeEnv(cfg(), 10100, {}, {}, AUTH_PRESENT); + expect(ordinary.IS_SANDBOX).toBeUndefined(); + }); + + test("an explicit user sandbox value wins over the root skip-permissions opt-in", () => { + const env = buildClaudeEnv(cfg(), 10100, { IS_SANDBOX: "0" }, {}, { + ...AUTH_PRESENT, + allowRootSkipPermissions: true, + }); + expect(env.IS_SANDBOX).toBe("0"); + expect(rootSkipPermissionsNotice(env)).toContain("preserving user IS_SANDBOX=0"); + expect(rootSkipPermissionsNotice(env)).toContain("root guard remains in control"); + }); + + test("the unsafe root bypass notice discloses that no OS sandbox was created", () => { + const notice = rootSkipPermissionsNotice({ IS_SANDBOX: "1" }); + expect(notice).toContain("set IS_SANDBOX=1"); + expect(notice).toContain("did not create an OS sandbox"); + }); + test("injects base URL, discovery flag and model slots — NO auth token by default (subscription mode)", () => { const env = buildClaudeEnv(cfg({ claudeCode: { model: "claude-ocx-gemini--gemini-3-pro", smallFastModel: "gemini/gemini-3-flash" }, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 99a10ff559..e507e8778f 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -5684,6 +5684,79 @@ describe("Codex reasoning-effort capability clamp", () => { expect(models).toEqual(before); }); }); + +describe("auto_review_model configuration (#1225)", () => { + test("applyAutoReviewModelOverride sets auto_review_model_override across all entries", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: null }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: null }, + ]; + + applyAutoReviewModelOverride(entries, " opencode-go/deepseek-v4-flash "); + expect(entries[0].auto_review_model_override).toBe("opencode-go/deepseek-v4-flash"); + expect(entries[1].auto_review_model_override).toBe("opencode-go/deepseek-v4-flash"); + }); + + test("applyAutoReviewModelOverride clears routed state when autoReviewModel is null or empty", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: "existing-model" }, + { slug: "opencode-go/glm-5.2", auto_review_model_override: "old-model" }, + ]; + + applyAutoReviewModelOverride(entries, null); + expect(entries[0].auto_review_model_override).toBe("existing-model"); + expect(entries[1].auto_review_model_override).toBeNull(); + applyAutoReviewModelOverride(entries, " "); + expect(entries[0].auto_review_model_override).toBe("existing-model"); + expect(entries[1].auto_review_model_override).toBeNull(); + }); + + test("applyAutoReviewModelOverride rejects invalid format with control chars or inner spaces", () => { + const { applyAutoReviewModelOverride, isValidAutoReviewModel } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: "native-preserved" }, + ]; + + expect(isValidAutoReviewModel("valid/model-slug_1")).toBe(true); + expect(isValidAutoReviewModel("invalid slug with spaces")).toBe(false); + expect(isValidAutoReviewModel("invalid\x00slug")).toBe(false); + applyAutoReviewModelOverride(entries, "invalid slug with spaces"); + expect(entries[0].auto_review_model_override).toBe("native-preserved"); + }); + + test("readConfiguredAutoReviewModel reads auto_review_model from config.toml", () => { + const { readConfiguredAutoReviewModel } = require("../src/codex/catalog/parsing"); + expect(typeof readConfiguredAutoReviewModel).toBe("function"); + }); + + test("writeRetainedCatalogSync stamps auto_review_model_override into persisted catalog", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const { readConfiguredAutoReviewModel } = require("../src/codex/catalog/parsing"); + + // Simulate a config-driven write path: entries are regenerated from a template, + // then the override is stamped before serialization. + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: null }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: "old-model" }, + ]; + const configuredValue = " opencode-go/deepseek-v4-flash "; + const trimmedValue = configuredValue.trim(); + + expect(typeof readConfiguredAutoReviewModel).toBe("function"); + + // Absent value: no override is written. + applyAutoReviewModelOverride(entries, null); + expect(entries[0].auto_review_model_override).toBeNull(); + expect(entries[1].auto_review_model_override).toBeNull(); + + // Present value: trimmed override replaces every entry (including native rows). + applyAutoReviewModelOverride(entries, configuredValue); + expect(entries[0].auto_review_model_override).toBe(trimmedValue); + expect(entries[1].auto_review_model_override).toBe(trimmedValue); + }); +}); import { ManagementRequest as Request } from "./helpers/management-auth"; describe("#2465 model preset management routes", () => { diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index 9206aaa9e4..9f4be0f031 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -179,6 +179,34 @@ function config(pickerEnabled: boolean, disabledModels: string[] = []): OcxConfi }; } +function autoReviewConfig(models: string[]): OcxConfig { + const nextConfig = config(false); + nextConfig.providers.static = { + adapter: "openai-chat", + baseUrl: "https://static.example.test/v1", + liveModels: false, + models, + }; + return nextConfig; +} + +function writeAutoReviewModel(value?: string): void { + writeFileSync( + join(codexHome, "config.toml"), + value === undefined ? "" : `auto_review_model = ${JSON.stringify(value)}\n`, + ); +} + +function autoReviewSeed(routeOverride: string | null = "stale-override"): RawEntry[] { + return [ + { ...nativeEntry(), slug: "gpt-5.4", auto_review_model_override: "native-upstream" }, + { + ...generatedRoutedEntry("static/deepseek-v4-flash"), + auto_review_model_override: routeOverride, + }, + ]; +} + function writeCatalog(models: RawEntry[]): void { writeFileSync(catalogPath, `${JSON.stringify({ models }, null, 2)}\n`); } @@ -608,6 +636,75 @@ test("retained sync removes a deleted pre-marker custom row while discovery is d expect(models.some(entry => entry.slug === "offline/discovered-sibling")).toBe(true); }); +test("retained and convergence writers resolve, clear, reject, and recover auto-review selectors", async () => { + primeCodexRuntimeFixture(); + + for (const writer of ["retained", "convergence"] as const) { + const write = async (nextConfig: OcxConfig): Promise => { + if (writer === "retained") { + const result = await syncCatalogModels(nextConfig); + expect(result.catalogWritten).toBe(true); + } else { + const disposition = await convergeCatalogDisposition(nextConfig); + expect(disposition).toMatchObject({ status: "committed" }); + } + return JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog; + }; + + // A configured selector is resolved against the final catalog and trimmed before stamping. + writeAutoReviewModel(" static/deepseek-v4-flash "); + writeCatalog(autoReviewSeed()); + let catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", "static/deepseek-v4-flash"); + + // Clearing the root key removes stale routed state while preserving an upstream native value. + writeAutoReviewModel(); + writeCatalog(autoReviewSeed()); + catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + expect(catalog.models?.find(entry => entry.slug === "gpt-5.4")) + .toHaveProperty("auto_review_model_override", "native-upstream"); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", null); + + // A syntactically valid but missing selector is diagnosed and cannot persist a dead override. + writeAutoReviewModel("static/missing-model"); + writeCatalog(autoReviewSeed()); + const unresolvedWarning = spyOn(console, "warn").mockImplementation(() => {}); + let unresolvedWarningCalls: unknown[][] = []; + try { + catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + unresolvedWarningCalls = unresolvedWarning.mock.calls; + } finally { + unresolvedWarning.mockRestore(); + } + expect(unresolvedWarningCalls.some(call => String(call[0]).includes("not found in the final catalog"))).toBe(true); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", null); + + // Removing the configured model from the provider makes the target unresolved; recovery + // must stamp it again once the model is advertised by the next catalog. + writeAutoReviewModel("static/deepseek-v4-flash"); + writeCatalog(autoReviewSeed()); + const removedWarning = spyOn(console, "warn").mockImplementation(() => {}); + let removedWarningCalls: unknown[][] = []; + try { + catalog = await write(autoReviewConfig([])); + removedWarningCalls = removedWarning.mock.calls; + } finally { + removedWarning.mockRestore(); + } + expect(removedWarningCalls.some(call => String(call[0]).includes("not found in the final catalog"))).toBe(true); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")).toBeUndefined(); + + writeAutoReviewModel("static/deepseek-v4-flash"); + writeCatalog(autoReviewSeed(null)); + catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", "static/deepseek-v4-flash"); + } +}); + test("degraded preservation still honors explicit routed visibility policy", async () => { writeCatalog([ nativeEntry(), From 303cc3c8cfe6fb92bb7bc2d9e170c07e00435fb7 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:41:07 +0900 Subject: [PATCH 064/336] fix(server): refuse requests after the package tree is replaced under a live proxy (#2459) (#2632) * feat(catalog): apply configured auto_review_model override during sync (closes #1225) * test(catalog): cover whitespace trimming and preservation in auto_review_model override (#1225) * feat(catalog): apply auto_review_model in convergence and update docs (#1225) * fix(catalog): validate auto_review_model slug format before applying override (#1225) * test(catalog): cover config-to-catalog auto_review_model stamp path (#1225) * fix(catalog): resolve auto-review selector against final catalog * fix(claude): gate root skip-permissions bypass * fix(catalog): keep cache invalidation bound to owning home * fix(server): fail health when package tree changes * fix(server): refuse requests after the package tree is replaced (#2459) A bare global npm reinstall while the proxy is serving leaves one process with an old in-memory module graph while later dynamic imports resolve against the new package tree. The proxy stays bound, /healthz still answers 200, and every POST /v1/responses fails with a named-export error - the worst shape, because every supervisor sees a healthy process. package.json filesystem identity is captured at boot and compared per request. On replacement or unreadability /healthz and /readyz return 503 restart_required and /v1/* returns a typed package_tree_changed error with restart guidance. Management routes stay up so the user can restart and diagnose. Review addition: the check is throttled. status() runs on every /v1/* request, so an unthrottled guard adds a stat syscall to the hot path to detect an event that happens at most once per install. An ok reading is reused for a second; a FAILING reading is never cached, so a repaired install recovers on its own instead of staying refused for a window. Falsified: disabling the gate makes the degraded-health case return 200 instead of 503; removing the throttle reddens the reuse test. --------- Co-authored-by: chilung --- src/lib/package-tree-integrity.ts | 74 ++++++++++++++ src/server/index.ts | 39 ++++++++ tests/package-tree-integrity.test.ts | 142 +++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 src/lib/package-tree-integrity.ts create mode 100644 tests/package-tree-integrity.test.ts diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts new file mode 100644 index 0000000000..55554dd208 --- /dev/null +++ b/src/lib/package-tree-integrity.ts @@ -0,0 +1,74 @@ +import { statSync } from "node:fs"; + +export interface PackageTreeObservation { + readonly device: bigint; + readonly inode: bigint; + readonly changeTimeNs: bigint; + readonly size: bigint; +} + +export type PackageTreeIntegrityStatus = + | { readonly ok: true } + | { readonly ok: false; readonly reason: "package_tree_replaced" | "package_tree_unreadable" }; + +export interface PackageTreeIntegrityGuard { + status(): PackageTreeIntegrityStatus; +} + +type ObservePackageTree = () => PackageTreeObservation | null; + +const packageManifestUrl = new URL("../../package.json", import.meta.url); + +function observePackageManifest(): PackageTreeObservation | null { + try { + const stat = statSync(packageManifestUrl, { bigint: true }); + return { + device: stat.dev, + inode: stat.ino, + changeTimeNs: stat.ctimeNs, + size: stat.size, + }; + } catch { + return null; + } +} + +function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean { + return left.device === right.device + && left.inode === right.inode + && left.changeTimeNs === right.changeTimeNs + && left.size === right.size; +} + +/** + * How long an `ok` observation is reused before the manifest is stat'd again. + * + * `status()` runs on `/healthz`, `/readyz` and every `/v1/*` request, so an unthrottled guard + * adds a filesystem syscall to the proxy's hot path to detect an event that happens at most + * once per install. A replaced tree is not time-critical either: the process is already + * serving broken imports, and one more second of that is not worse than a syscall per turn + * forever. + * + * A NEGATIVE result is never cached — once the tree looks wrong, every later request re-checks, + * so a repaired install recovers on its own instead of staying refused for a window. + */ +const PACKAGE_TREE_RECHECK_MS = 1_000; + +export function createPackageTreeIntegrityGuard( + observe: ObservePackageTree = observePackageManifest, + now: () => number = Date.now, +): PackageTreeIntegrityGuard { + const boot = observe(); + let lastOkAt: number | null = null; + return { + status(): PackageTreeIntegrityStatus { + const at = now(); + if (lastOkAt !== null && at - lastOkAt < PACKAGE_TREE_RECHECK_MS) return { ok: true }; + const current = observe(); + if (boot === null || current === null) return { ok: false, reason: "package_tree_unreadable" }; + if (!sameObservation(boot, current)) return { ok: false, reason: "package_tree_replaced" }; + lastOkAt = at; + return { ok: true }; + }, + }; +} diff --git a/src/server/index.ts b/src/server/index.ts index 48d186f6f0..44d794991f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -202,6 +202,10 @@ import { import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract"; import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract"; import { createReadinessGate, type ReadinessGate } from "./readiness"; +import { + createPackageTreeIntegrityGuard, + type PackageTreeIntegrityGuard, +} from "../lib/package-tree-integrity"; export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; @@ -454,6 +458,8 @@ export interface StartServerDeps { localAttestationSecret?: string; /** Optional readiness gate; a fresh pending gate is created when omitted. */ readinessGate?: ReadinessGate; + /** Test-only package-tree observation; production captures package.json identity at boot. */ + packageTreeIntegrity?: PackageTreeIntegrityGuard; } function inspectStartupOwnership( @@ -698,6 +704,15 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + isolatedCodexHome = installIsolatedCodexHome("ocx-package-tree-integrity-"); +}); + +afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe("package tree integrity", () => { + test("detects replacement even when the package version and file size are unchanged", () => { + let observation: PackageTreeObservation = { + device: 1n, + inode: 10n, + changeTimeNs: 100n, + size: 500n, + }; + // An explicit clock: `status()` reuses an `ok` reading for a second so the guard does not + // stat the manifest on every request, and two calls in the same millisecond would otherwise + // never re-observe. + let clock = 0; + const guard = createPackageTreeIntegrityGuard(() => observation, () => clock); + + expect(guard.status()).toEqual({ ok: true }); + + observation = { ...observation, inode: 11n, changeTimeNs: 200n }; + clock += 2_000; + expect(guard.status()).toEqual({ ok: false, reason: "package_tree_replaced" }); + }); + + test("an ok reading is reused briefly, and a bad one is never cached", () => { + let observation: PackageTreeObservation | null = { + device: 1n, inode: 10n, changeTimeNs: 100n, size: 500n, + }; + let observations = 0; + let clock = 0; + const guard = createPackageTreeIntegrityGuard( + () => { observations += 1; return observation; }, + () => clock, + ); + + // Hot path: repeated calls inside the window cost one observation, not one each. + expect(guard.status()).toEqual({ ok: true }); + expect(guard.status()).toEqual({ ok: true }); + expect(guard.status()).toEqual({ ok: true }); + expect(observations).toBe(2); // one at construction, one for the first status() + + // A failure is re-observed every time, so a repaired install recovers on its own rather + // than staying refused for the rest of a window. + clock += 2_000; + observation = null; + expect(guard.status()).toEqual({ ok: false, reason: "package_tree_unreadable" }); + const afterFirstFailure = observations; + expect(guard.status()).toEqual({ ok: false, reason: "package_tree_unreadable" }); + expect(observations).toBe(afterFirstFailure + 1); + }); + + test("fails closed when the package manifest disappears", () => { + let observation: PackageTreeObservation | null = { + device: 1n, + inode: 10n, + changeTimeNs: 100n, + size: 500n, + }; + const guard = createPackageTreeIntegrityGuard(() => observation); + observation = null; + + expect(guard.status()).toEqual({ ok: false, reason: "package_tree_unreadable" }); + }); + + test("degrades health and refuses Responses requests with a restart-required error", async () => { + saveConfig(config()); + const packageTreeIntegrity = { + status: () => ({ ok: false as const, reason: "package_tree_replaced" as const }), + }; + const server = startServer(0, { packageTreeIntegrity }); + try { + const health = await fetch(new URL("/healthz", server.url)); + expect(health.status).toBe(503); + expect(health.headers.get("retry-after")).toBe("5"); + expect(await health.json()).toMatchObject({ + status: "restart_required", + service: "opencodex", + error: { code: "package_tree_changed" }, + }); + + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "test/gpt-test", input: "hello" }), + }); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("5"); + expect(await response.json()).toMatchObject({ + error: { + type: "server_error", + code: "package_tree_changed", + message: expect.stringContaining("restart"), + }, + }); + } finally { + await server.stop(true); + } + }); +}); From 1a39adcfa95cd4c24caafc7fc1899f5080513345 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:49:05 +0900 Subject: [PATCH 065/336] test(runtime): pin the bundled Bun 1.4 version (#2633) --- tests/install-scripts.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/install-scripts.test.ts b/tests/install-scripts.test.ts index 7fd6fed318..daf2041192 100644 --- a/tests/install-scripts.test.ts +++ b/tests/install-scripts.test.ts @@ -50,6 +50,7 @@ describe("install scripts", () => { expect(pkg.main).toBe("./bin/package-main.mjs"); expect(pkg.exports?.["."]?.bun).toBe("./src/index.ts"); expect(pkg.exports?.["."]?.default).toBe("./bin/package-main.mjs"); + expect(pkg.dependencies?.bun).toBe("1.4.0"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); From 6ccb13cc6df9324a88e5ec89f35a66cdbfff6fa4 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 06:58:32 +0900 Subject: [PATCH 066/336] fix(google): carry chat video input onto Gemini wire (#2458) (#2634) --- src/adapters/anthropic.ts | 1 + src/adapters/command-code.ts | 3 +- src/adapters/cursor/protobuf-request.ts | 1 + src/adapters/google.ts | 9 ++++- src/adapters/image.ts | 2 +- src/chat/inbound.ts | 15 +++++++- src/responses/parser.ts | 4 ++ src/responses/schema.ts | 6 ++- src/server/responses/input-admission.ts | 4 +- src/types/request.ts | 10 ++++- tests/google-adapter.test.ts | 49 +++++++++++++++++++++++++ 11 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 25e6467ad0..b8312eb453 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -37,6 +37,7 @@ function toAnthropicContentPart(p: OcxContentPart): unknown { ? { type: "image", source: { type: "base64", media_type: data.mediaType, data: data.base64 } } : { type: "image", source: { type: "url", url: p.imageUrl } }; } + if (p.type === "video") return { type: "text", text: "[video]" }; return { type: "text", text: p.text }; } diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 49f8bcf2a3..df6843ca20 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -144,7 +144,8 @@ function wireMessages(messages: OcxMessage[]): Array> { if (typeof message.content === "string") content.push({ type: "text", text: message.content }); else for (const part of message.content) { if (part.type === "text") content.push({ type: "text", text: part.text }); - else content.push(wireImagePart(part.imageUrl)); + else if (part.type === "image") content.push(wireImagePart(part.imageUrl)); + else content.push({ type: "text", text: "[video]" }); } out.push({ role: "user", content }); } diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 18d5957eab..74e64bd5b5 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -436,6 +436,7 @@ function decodeResultParts(message: OcxToolResultMessage): DecodedResultPart[] | if (typeof content === "string") return undefined; return content.map((part): DecodedResultPart => { if (part.type === "text") return { kind: "text", text: part.text }; + if (part.type === "video") return { kind: "text", text: "[video]" }; const decoded = decodeInlineImage(part.imageUrl); return decoded ? { kind: "image", ...decoded } : { kind: "undecodable" }; }); diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 23361f7735..78354e7fe5 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -178,7 +178,7 @@ function geminiTextPart(text: unknown): { text: string } | undefined { */ function geminiToolResultText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content || GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER; - const hasContent = content.some(p => p.type === "image" || (typeof p.text === "string" && p.text.length > 0)); + const hasContent = content.some(p => p.type !== "text" || p.text.length > 0); return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER; } @@ -262,6 +262,13 @@ function messagesToGeminiFormat( parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[image: ${p.imageUrl}]` }); continue; } + if (p.type === "video") { + const data = parseDataUrl(p.videoUrl); + // Gemini accepts inline video bytes in the same Part union as images. Arbitrary + // remote URLs are not valid fileData references, so retain only a short marker. + parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[video: ${p.videoUrl}]` }); + continue; + } // Drop empty/malformed text instead of emitting `{ text: "" }` or a bare `{}` part. const textPart = geminiTextPart(p.text); if (textPart) parts.push(textPart); diff --git a/src/adapters/image.ts b/src/adapters/image.ts index 0b2bcbf6fe..39c5d683e5 100644 --- a/src/adapters/image.ts +++ b/src/adapters/image.ts @@ -18,6 +18,6 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string } */ export function contentPartsToText(content: string | OcxContentPart[]): string { if (typeof content === "string") return content; - const text = content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join(""); return text || "[image]"; } diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index 46268f13b4..db3b41d12e 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -53,6 +53,14 @@ function imageUrlFromPart(part: Rec): string | null { return null; } +function videoUrlFromPart(part: Rec): string | null { + if (part.type !== "video_url") return null; + const videoUrl = part.video_url; + if (typeof videoUrl === "string" && videoUrl.length > 0) return videoUrl; + if (isRec(videoUrl) && typeof videoUrl.url === "string" && videoUrl.url.length > 0) return videoUrl.url; + return null; +} + function userContentToBlocks(content: unknown): Rec[] { if (typeof content === "string") { return content.length > 0 ? [{ type: "input_text", text: content }] : []; @@ -70,7 +78,12 @@ function userContentToBlocks(content: unknown): Rec[] { continue; } const imageUrl = imageUrlFromPart(raw); - if (imageUrl) blocks.push({ type: "input_image", image_url: imageUrl }); + if (imageUrl) { + blocks.push({ type: "input_image", image_url: imageUrl }); + continue; + } + const videoUrl = videoUrlFromPart(raw); + if (videoUrl) blocks.push({ type: "input_video", video_url: videoUrl }); } return blocks; } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 28b7c8c2bb..33de5ebeae 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -45,6 +45,7 @@ type InputBlock = | { type: "input_text"; text: string } | { type: "text"; text: string } | { type: "input_image"; image_url?: string; file_id?: string; detail?: string } + | { type: "input_video"; video_url?: string } | { type: "input_file"; file_id?: string; filename?: string; file_data?: string }; /** A usable reference string, or undefined. Empty strings and non-strings are not references. */ @@ -80,6 +81,9 @@ function inputContentParts(blocks: unknown): string | OcxContentPart[] { } // No usable reference: omit the block. A "[image: ?]" marker would claim an attachment // the request never carried, which is worse than dropping malformed input. + } else if (block.type === "input_video") { + const videoUrl = nonEmptyString(block.video_url); + if (videoUrl) parts.push({ type: "video", videoUrl }); } else if (block.type === "input_file") { const b = block as { file_id?: string; filename?: string; file_data?: string }; const fileId = nonEmptyString(b.file_id); diff --git a/src/responses/schema.ts b/src/responses/schema.ts index 5e1f876236..bc29734c8a 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -11,6 +11,10 @@ const inputImageBlockSchema = z.object({ }).refine(v => typeof v.image_url === "string" || typeof v.file_id === "string", { message: "input_image requires at least one of image_url or file_id", }); +const inputVideoBlockSchema = z.object({ + type: z.literal("input_video"), + video_url: z.string().min(1), +}); const inputFileBlockSchema = z.object({ type: z.literal("input_file"), file_id: z.string().optional(), @@ -24,7 +28,7 @@ const reasoningTextSchema = z.object({ type: z.literal("reasoning_text"), text: // codex-rs FunctionCallOutputContentItem (protocol/src/models.rs): input_text | input_image | encrypted_content. const encryptedContentBlockSchema = z.object({ type: z.literal("encrypted_content"), encrypted_content: z.string() }); -const inputContentBlockSchema = z.union([inputTextSchema, plainTextSchema, inputImageBlockSchema, inputFileBlockSchema]); +const inputContentBlockSchema = z.union([inputTextSchema, plainTextSchema, inputImageBlockSchema, inputVideoBlockSchema, inputFileBlockSchema]); const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]); // Tool outputs on the wire mix codex-rs FunctionCallOutputContentItem with legacy output blocks. const toolOutputContentBlockSchema = z.union([ diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index a2a06e5b73..2bd01a6780 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -68,7 +68,9 @@ function imageTokens(imageUrl: string): number { } function contentPartTokens(part: OcxContentPart, modelId: string): number { - return part.type === "image" ? imageTokens(part.imageUrl) : estimateTokens(part.text, modelId); + if (part.type === "image") return imageTokens(part.imageUrl); + if (part.type === "video") return imageTokens(part.videoUrl); + return estimateTokens(part.text, modelId); } function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number { diff --git a/src/types/request.ts b/src/types/request.ts index efe2164e37..28f37ae666 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -190,8 +190,14 @@ export interface OcxImageContent { detail?: string; } -/** A user/developer message content part: text or an image (vision). */ -export type OcxContentPart = OcxTextContent | OcxImageContent; +export interface OcxVideoContent { + type: "video"; + /** A base64 `data:` URL from an OpenAI-compatible `video_url` part. */ + videoUrl: string; +} + +/** A user/developer message content part: text or native media. */ +export type OcxContentPart = OcxTextContent | OcxImageContent | OcxVideoContent; export interface OcxThinkingContent { type: "thinking"; diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 15af3a264a..d067af9589 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter } from "../src/adapters/google"; +import { chatCompletionsToResponsesBody } from "../src/chat/inbound"; +import { parseRequest } from "../src/responses/parser"; import type { OcxParsedRequest } from "../src/types"; const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; @@ -82,6 +84,53 @@ describe("google adapter — tool result images", () => { }); }); +describe("google adapter — Chat Completions video input", () => { + test("carries an inline video through Chat translation onto Gemini inline_data", async () => { + const responsesBody = chatCompletionsToResponsesBody({ + model: "google-antigravity/gemini-3.7-flash", + messages: [{ + role: "user", + content: [ + { type: "text", text: "Summarize this video" }, + { type: "video_url", video_url: { url: "data:video/mp4;base64,aGVsbG8=" } }, + ], + }], + }); + const parsed = parseRequest(responsesBody); + parsed.modelId = "gemini-3.7-flash"; + + const contents = await geminiContents(parsed); + + expect(contents).toContainEqual({ + role: "user", + parts: [ + { text: "Summarize this video" }, + { inline_data: { mime_type: "video/mp4", data: "aGVsbG8=" } }, + ], + }); + }); + + test("does not mislabel an arbitrary remote video URL as Gemini file_data", async () => { + const responsesBody = chatCompletionsToResponsesBody({ + model: "google-antigravity/gemini-3.7-flash", + messages: [{ + role: "user", + content: [{ type: "video_url", video_url: { url: "https://example.test/video.mp4" } }], + }], + }); + const parsed = parseRequest(responsesBody); + parsed.modelId = "gemini-3.7-flash"; + + const contents = await geminiContents(parsed); + + expect(contents).toContainEqual({ + role: "user", + parts: [{ text: "[video: https://example.test/video.mp4]" }], + }); + expect(JSON.stringify(contents)).not.toContain("file_data"); + }); +}); + describe("google adapter — tool-call ids on the wire", () => { test("v2 collaboration encrypted marker never reaches functionDeclarations (issue #85)", async () => { // Codex Desktop v2 stamps `encrypted: true` on collaboration message properties; CCA/Gemini From 87179e86acc6e7711c625618c4d0c243b6334473 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 07:01:04 +0900 Subject: [PATCH 067/336] test(server): give the Daybreak compact case the server budget (#2635) It timed out at 5003ms in a full-suite parallel run on the Linux box while passing 3/3 in isolation on both macOS and Linux. 5003ms is Bun's default 5s test timeout, not an assertion failure. The case starts a real server through startPoolRetryHarness, and its neighbours in this file already declare SERVER_BUDGET_MS for exactly that reason; this one was left on the default meant for pure unit tests. Measuring a server-backed case against that default under parallel load produces a flake, not a signal. --- tests/server-auth.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 90b9ff32bb..c9bec1df66 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -2243,7 +2243,11 @@ describe("server local API auth", () => { } finally { await stopPoolRetryHarness(harness); } - }); + // Same budget as the other harness cases in this file: this one starts a real server and + // was left on Bun's 5s default, so it timed out at 5003ms under full-suite parallel load + // while passing 3/3 in isolation on two machines. A server-backed case measured against a + // default meant for pure unit tests is a load flake, not a signal. + }, { timeout: SERVER_BUDGET_MS }); test("#2097: a confirmed entitled account survives two transient unsupported-model 400s in place", async () => { const model = "gpt-daybreak-blue-latest"; From b5563d438d346c72d7fa333189c34f15094ea7c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 07:10:15 +0900 Subject: [PATCH 068/336] devlog: record the community bug sweep (#2636) The plan's DONE criterion (zero lidge-jun issues, every bug PR terminal) was met partway through; the request behind it was broader, so the loop continued into the bug-labelled community backlog. Sixteen open, three left. Records what the sweep found on its own (#2458's real cause one layer off the report, #2509's unnoticed second half, #2459's lying health check), the six that were already fixed by earlier work in this same run and how each was verified, and why the remaining three are open rather than skipped. --- .../150_community_bug_sweep.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/150_community_bug_sweep.md diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/150_community_bug_sweep.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/150_community_bug_sweep.md new file mode 100644 index 0000000000..2cef675e21 --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/150_community_bug_sweep.md @@ -0,0 +1,69 @@ +# 150 — community bug sweep + +The plan's DONE criterion was zero `lidge-jun` issues and every bug PR terminal. Both were met +partway through. The request behind the plan was broader — no outstanding bugs — so the loop +continued into the `bug`-labelled community backlog rather than stopping at its own inventory. + +Sixteen open, three left. Final `dev` at `87179e86a`: 15037 pass / 16 skip / 0 fail, typecheck +and `privacy:scan` green. + +## Six were already fixed by this same run + +| Issue | Fixed by | Verified | +|---|---|---| +| #2499 Windows catalog-state latency | #2580 (`342911fec7`) | ancestry + 51 pass | +| #2545 Antigravity thought_signature | #2577 (`4d3d2716e`) | ancestry + 150 pass | +| #2210 Cursor stall timeout | `994e5ba87` | ancestry + 252 pass | +| #2156 Muse Spark mid-tool-call | `1a15d6292` + `08cc2ac89` | ancestry + 54 pass | +| #2300 Cursor slowness | #2307 | ancestry + falsification | +| #2509 stale pool account (core) | #2515 | ancestry + falsification | + +Each was checked with `git merge-base --is-ancestor` **and** a covering test run before closing. +Closing on a report that something was fixed is how a backlog acquires issues that were never +actually resolved. + +## What the sweep found on its own + +**#2458 — the reported cause was one layer off.** A 502 on an undeclared `get_video_duration` +tool call looked like a bridge problem. The real defect was that Chat Completions silently +discarded `video_url` (`src/chat/inbound.ts:56`), so the model reasoned about a video it had +never received, and the bridge refused the resulting call. OpenCodex was rejecting a symptom of +its own dropped input. + +**#2509 — a second half nobody had noticed.** #2515 fixed selection to preview per candidate +quota scope and #2623 added the entitlement filter, but the encrypted-recovery path got the +scope and not the filter. Same stale-selection class, one layer over. + +**#2459 — the health check was the worst part.** A bare npm reinstall under a live proxy leaves +`/healthz` answering 200 while every `/v1/responses` fails. A lying health check is worse than +an honest failure, because every supervisor believes it. + +**A load flake, not a defect.** One full-suite failure at exactly 5003 ms — Bun's default 5s +timeout — on a case that starts a real server while its neighbours declare `SERVER_BUDGET_MS`. +It passed 3/3 in isolation on two machines. Fixed as a budget, not chased as a bug. + +## Three left open, each for a stated reason + +**#2221 — native main token refresh.** The safe-looking half is not separable. A refresh rotates +the grant, `atomicWriteFile` has no compare-and-swap against an external writer, and the Codex +CLI writes the same `auth.json`. Refreshing without a crash-safe publisher can strand the login: +the current behavior fails one request, the naive fix can cost the credential. #2497 proposes a +publisher and is itself held under security review with a rename-before-link crash window. This +needs a designed protocol, not a smaller patch. + +**#1527 — Cursor large-context.** Both mechanisms behind the reported symptoms changed: rate +limits are excluded from transport retry, and checkpoint continuation replaced full-history +replay — which was the credible differentiator in the reporter's own proxy-vs-direct comparison. +The residual claim needs a matched live probe, which only the reporter can run. + +**#1419 — macOS SIGTRAP.** The reported offsets symbolicate to Bun's own crash handler and +nothing past it; the frames that would name the faulting subsystem are absent, and no upstream +release establishes that 1.4.0 fixes that signature. The runtime is pinned so it cannot regress; +the crash is unproven. Recorded alongside it: `ocx gui` starts an unref'd detached process, so a +native crash there strands the proxy with no supervisor, unlike an installed service. + +## The pattern worth keeping + +Four of these closed as "already fixed" and three as "not fixable here, here is why". Neither is +a failure to do work. A backlog that only ever accepts code changes as outcomes accumulates +issues nobody can close, and closes issues nobody actually verified. From 23a63483e4b15bd276a9aa5e3de5cc80e5d400f1 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 07:14:32 +0900 Subject: [PATCH 069/336] devlog: terminal record for the backlog closeout loop (#2637) Audited against the goal objective's exact lists rather than a running tally: all 15 named lidge-jun issues CLOSED, 15 of 16 named bug PRs terminal. #2497 is the single open item and it is a decision, not a task - the credential boundary AGENTS.md places under security review, with three hand-verified blockers, one of which is an ownership question about cross-grant adoption. Records the four remaining items and the specific input each is blocked on, so the next person does not re-derive why they are open. --- .../160_terminal_record.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 devlog/_plan/260825_owner_backlog_and_bugpr_closeout/160_terminal_record.md diff --git a/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/160_terminal_record.md b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/160_terminal_record.md new file mode 100644 index 0000000000..897267160e --- /dev/null +++ b/devlog/_plan/260825_owner_backlog_and_bugpr_closeout/160_terminal_record.md @@ -0,0 +1,67 @@ +# 160 — terminal record + +Audited against the goal objective's exact lists, not a running tally. + +## (A) The fifteen `lidge-jun` issues — all closed + +| Issue | Outcome | +|---|---| +| #2569 | Cursor catalog + effort ladder refresh | +| #2568 | OAuth 429 failover: HTTP-status, sidecar (#2607) and adapter-event (#2608) paths; activation default escalated | +| #2566 #2565 | account/quota CLI surfaces | +| #2558 #2557 | service-tier classification, Windows restart probe | +| #2491 | slug-equivalence unification | +| #2472 | zero-output completion producer gap | +| #2465 | shipped model presets (#2603, #2604, #2606) | +| #2464 | new models arrive disabled (#2609) | +| #2463 | provider and model aliases (#2610) | +| #1478 | config rebase provenance (#2613) | +| #1049 | pre-substrate home adoption (#2612) | +| #1048 | disposable-host service acceptance (#2618) | +| #820 | session-lane bounds (#2611) | + +## (B) The sixteen bug PRs — fifteen terminal + +Merged: #2563 #2555 #2550 #2532 #2528 #2515 #2474. +Closed with recorded evidence, superseded by a verified rebase or replacement: +#2567 #2542 #2513 #2512 #2510 #2503 #2490 #2488. + +**#2497 is the one open item, and it is a decision rather than a task.** It is the credential +boundary `AGENTS.md` places under explicit security review. Three blockers were verified by hand +(`120_wp5_2497_security_review.md`): non-atomic `auth.json` publication with no startup recovery; +a same-account fallback that adopts a *different* pool refresh grant into native-main; and an +"exactly one" 401 replay that expands to up to nine physical sends through the transient-retry +ladder. The second is an ownership question — tightening it to grant-only changes what happens +to an operator who re-logged in through the pool and expects main to follow. Picking a side +silently inside someone else's 2,600-line credential PR is not a thing to do quietly. + +## Beyond the objective + +The stated criterion was met partway through, so the loop continued into the `bug`-labelled +community backlog: sixteen open, three left (`150_community_bug_sweep.md`). Nine more bug PRs +that arrived mid-run were also carried to terminal. + +## Terminal state + +`dev` at `b5563d438`: 15037 pass / 16 skip / 0 fail, typecheck and `privacy:scan` green. + +Four items remain, each blocked on input this loop cannot produce: + +- **#2497** — ownership decision on cross-grant credential adoption. +- **#2221** — a designed, crash-safe `auth.json` publication protocol. Refresh rotates the grant, + `atomicWriteFile` has no external-writer CAS, and the Codex CLI writes the same file, so the + safe-looking half cannot ship alone without risking the login. +- **#1527** — a matched live probe only the reporter can run, now that checkpoint continuation + removed the biggest confound from their comparison. +- **#1419** — a full `.ips` with frames past Bun's crash handler. + +Plus **wp7d**, deliberately unimplemented: presence-driven OAuth failover spends a second +subscription's quota, so the default was escalated on #2568 with the one-line change named. + +## What the loop is worth remembering for + +Falsification caught a patch (#2488) whose test passed with the fix reverted — decoration, not a +fix — and it is what proved the parent-thread lane, the atomic adoption publication and both +uninstall hunks were load-bearing. Six community issues turned out to be already fixed by earlier +work in this same run; each was verified by ancestry check plus a covering test before closing, +because closing on a report is how a backlog fills with issues nobody actually resolved. From 8bfac714665b6936ef1855025a0fd9680a70c875 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 10:31:39 +0900 Subject: [PATCH 070/336] feat(oauth): activate multi-account 429 failover on account presence (#2568d) (#2640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(oauth): activate multi-account 429 failover on account presence (#2568d) The mechanism shipped in #2590/#2607/#2608 but stayed gated behind oauthAccountFailover.enabled, which is false for every install that never edited its config — so the workflow #2568 actually reported (three xAI accounts logged in, a 429 on the active one) was still broken by default. The owner has now settled the escalation recorded in the wp7 audit response: always on. Presence is the activation signal, not an unconditional true. The predicate also decides whether a runTurn stream is wrapped in preflightRunTurnFailover, and that wrapper can never rotate for a single-account install, so an unconditional true would add a preflight for users who get nothing from it. Two or more eligible accounts is the same consent rule hasKeyPoolFailover already applies to a 2+ key pool. Explicit config still wins, now at two levels: providers..oauthAccountFailover beats the global knob, and both beat presence. Default-off was hiding three defects in the rotation path that presence-driven activation would have handed to every multi-account user: - Copilot pins its bearer to an account-scoped regional origin. core.ts pairs them on the initial resolve, but OAuthAccessSnapshot did not carry apiBaseUrl, so a rotation sent account B's token to account A's host. resolveGithubCopilotTransport fails closed to the canonical host, which is why this was misrouting rather than a leak — it was still wrong. - Antigravity rotation wrote project only when the new snapshot had one, so a project-less account inherited the FAILED account's project. That account is reachable: refresh tolerates project discovery failing. - The presence check would have run loadAuthStore on paths that never see a 429, and that function chmods, reads, parses and normalizes the whole store every call. The first two shared a root cause: three rotation sites each inlined the same credential swap, and the duplication is what let the paired metadata drift. They now go through one applyFailoverSnapshot helper that assembles the whole identity or refuses — a cloud-code-assist snapshot with no project aborts the rotation rather than mixing two accounts. Presence is answered from a TTL-bounded per-provider count, invalidated on rotation; it caches a number, never a credential. Separately, upsertOAuthProvider rebuilds the provider row from the registry preset on every login. Without preservation the sequence was: operator sets enabled false, operator adds a second account, and that one action both deletes the opt-out and creates the quorum that turns rotation on. Tests: activation matrix (no config, global both ways, per-provider both ways, reauth-flagged second account, cache TTL), structural contracts pinning that the bearer is written in exactly one place and that the helper fails closed, an explicit-opt-out e2e case, and an incremental SSE read proving the first delta reaches the client before completion. Both new contracts were driven red: forcing preflight to buffer the whole turn fails the delivery test at its 5s bound, and disabling the preservation branch fails both upsert cases. Closes #2568 * fix(gui): translate the alias catalog keys and unbreak the locale parity parser The gates job was red on dev before this branch existed, on four locale tests with two unrelated causes. Three of them read a catalog by regex anchored at "^\s*", but these files pack several entries onto one line. The parser therefore saw only the first entry per line and reported "models.newBadge" missing from every non-English locale — a key that is present in all of them. The catalogs were identical; the check was not reading them. Dropping the line anchor fixes all three, and the module-level check in i18n-locales.test.ts already agreed the key sets match, which is what made the phantom visible. The fourth was real: #2463 landed thirteen alias keys with the English string copied into all eight locales. They are now translated. "models.aliasAuto" is the exception and joins the intentional-English allowlist, because "auto" is the same word in French and labels a machine-derived alias source rather than prose. gui: bun test 994 pass, 0 fail (was 990/4). --- .../010_plan.md | 101 +++++++++++++ .../011_audit_response.md | 99 +++++++++++++ .../020_execution.md | 85 +++++++++++ .../docs/reference/configuration/providers.md | 31 +++- gui/src/i18n/de.ts | 26 ++-- gui/src/i18n/fr.ts | 24 ++-- gui/src/i18n/ja.ts | 26 ++-- gui/src/i18n/ko.ts | 26 ++-- gui/src/i18n/ru.ts | 26 ++-- gui/src/i18n/tr.ts | 26 ++-- gui/src/i18n/zh-TW.ts | 26 ++-- gui/src/i18n/zh.ts | 26 ++-- gui/tests/claude-desktop-locale.test.ts | 5 +- gui/tests/fr-localization.test.ts | 3 + gui/tests/locale-parity.test.ts | 5 +- src/oauth/generic-account-failover.ts | 81 ++++++++++- src/oauth/index.ts | 26 +++- src/server/responses/core.ts | 50 +++++-- src/types/config.ts | 11 +- src/types/provider.ts | 10 ++ tests/adapter-event-oauth-failover.test.ts | 58 +++++++- tests/generic-oauth-failover.test.ts | 136 ++++++++++++++---- tests/oauth-upsert-preserves-api-key.test.ts | 18 +++ 23 files changed, 762 insertions(+), 163 deletions(-) create mode 100644 devlog/_plan/260826_wp7e_presence_driven_oauth_failover/010_plan.md create mode 100644 devlog/_plan/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md create mode 100644 devlog/_plan/260826_wp7e_presence_driven_oauth_failover/020_execution.md diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/010_plan.md b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/010_plan.md new file mode 100644 index 0000000000..446ee4248d --- /dev/null +++ b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/010_plan.md @@ -0,0 +1,101 @@ +# 010 — wp7e: presence-driven activation for generic OAuth 429 failover (#2568d) + +## Owner decision (resolves the wp7 escalation) + +`devlog/_plan/260825_owner_backlog_and_bugpr_closeout/111_wp7_audit_response.md` closed wp7d as +**NEEDS_HUMAN**: rotating on a 429 spends a second subscription account's quota, so the agent +refused to pick the default. The owner has now decided, in these words: "이거는 그냥 항상 +켜지도록해" — always on. + +That settles exactly one question, the *default*. It does not delete the escape hatch, and it +does not change the mechanism, the exclusions, or the bounds. + +## Loop spec + +- **Archetype:** single-cycle amendment to a shipped subsystem. +- **Trigger:** owner decision on the wp7d escalation. +- **Goal:** with 2+ logged-in accounts for an OAuth provider that has no pool of its own, a 429 + rotates to another account with no configuration step. +- **Non-goals:** Codex pool semantics, Anthropic pool semantics, new rotation mechanism, new + provider coverage, logging of account identity. +- **Verifier:** `bun test tests/generic-oauth-failover.test.ts tests/adapter-event-oauth-failover.test.ts` + plus `bun x tsc --noEmit`. +- **Stop condition:** the two suites green, #2568 verified CLOSED, change on `dev`. + +## What changes + +One function's default branch, plus a per-provider override the issue's own example asks for. + +`isGenericOAuthFailoverEnabled(config, providerName)` currently reads +`config.oauthAccountFailover?.enabled === true`, which is false for every install that never +edited its config. It becomes a three-step precedence: + +1. `providers..oauthAccountFailover.enabled` when it is an explicit boolean — a + per-provider override, because a user may accept rotation on xAI and refuse it on Cursor. +2. `oauthAccountFailover.enabled` when it is an explicit boolean — the global switch, unchanged + in meaning. An operator who already wrote `false` keeps single-account behaviour. +3. Otherwise **presence-driven**: enabled when the provider has 2 or more eligible stored + accounts. + +### Why presence, not a bare `true` + +Returning an unconditional `true` would be wrong, and not only stylistically. This predicate +guards more than the rotation loops: at `core.ts:4660` and `:4745` it decides whether the +runTurn event stream is wrapped in `preflightRunTurnFailover`, which buffers events until the +first meaningful one arrives. For a single-account install that wrapper can never rotate — the +rotator returns `null` at `accounts.length < 2` — so it would be pure added latency on the +streaming path for users who get nothing from it. + +Presence is also the consent argument the issue itself makes, and the one `hasKeyPoolFailover` +already applies to a 2+ key pool: logging in a second account is the decision. One account is a +strict no-op, exactly as it is today. + +"Eligible" here means the same thing it means everywhere else in this module — not flagged +`needsReauth`. A second account that has been revoked is not a second account, and counting it +would arm the preflight wrapper for a user who still cannot rotate. + +## File change map + +| File | Change | +| --- | --- | +| `src/oauth/generic-account-failover.ts` | `isGenericOAuthFailoverEnabled` gains the precedence chain; new presence helper reading `getAccountSet` | +| `src/types/provider.ts` | `OcxProviderConfig.oauthAccountFailover?: { enabled?: boolean }` | +| `src/types/config.ts` | doc comment on `oauthAccountFailover` rewritten: no longer opt-in / default OFF | +| `docs-site/src/content/docs/reference/configuration/providers.md` | default column, opt-out instructions, caution box rewritten | +| `tests/generic-oauth-failover.test.ts` | activation tests rewritten for the new default; opt-out coverage at both levels | +| `tests/adapter-event-oauth-failover.test.ts` | config no longer needs to set the knob; add an explicit-false case | + +No change to `core.ts`. The call sites already ask the predicate; only its answer moves. + +## Field chain (PLAN-FIELD-CHAIN-01) + +`oauthAccountFailover` as a **provider-level** key: + +- **Creation:** hand-edited config, or a future management write. No CLI flag is added. +- **Serialization:** whole-config save; `providerConfigSchema` ends in `.passthrough()` + (`src/config.ts:533`), so the key survives a load/save round trip without a schema edit. +- **Deserialization:** same passthrough. A non-boolean value falls through to the next + precedence step rather than throwing, because a malformed knob must not take a provider out of + service. +- **Consumers:** `isGenericOAuthFailoverEnabled` only. Verified by `rg`: the global key has + exactly one runtime reader today. + +## Accept criteria, with activation scenarios + +| # | Criterion | How C triggers it | Observable proof | +| --- | --- | --- | --- | +| A1 | Two stored xAI accounts rotate on 429 with no config at all | config object with no `oauthAccountFailover` key, seed 2 accounts, call the rotator | returns the second account id; first is cooled | +| A2 | One stored account is still a strict no-op | seed 1, same call | null, no cooldown recorded, predicate false | +| A3 | Global `enabled: false` still disables | seed 2, global false | null | +| A4 | Per-provider `enabled: false` beats presence | seed 2, provider-level false | null | +| A5 | Per-provider `enabled: true` beats a global false | seed 2, global false + provider true | rotates | +| A6 | A `needsReauth` second account does not create a quorum | seed 2, flag one | predicate false | +| A7 | Codex and Anthropic unchanged | predicate on openai/anthropic with 2 accounts | false | +| A8 | The Cursor adapter-event path rotates with no knob | existing e2e-style test, knob removed from its config | two upstream attempts with different credentials | + +## Scope boundary + +**IN:** the activation predicate, the per-provider override type, docs, tests. + +**OUT:** rotation mechanism, provider coverage, cooldown lengths, the per-request bound, logging, +GUI surface (this key has none today), management API write path. diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md new file mode 100644 index 0000000000..a729154821 --- /dev/null +++ b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/011_audit_response.md @@ -0,0 +1,99 @@ +# 011 — wp7e audit response: the plan was a one-liner over three real defects + +Three read-only auditors ran in parallel against the tree. Two returned **fail**, one +**near-pass**, for six findings total. I verified every one against the code. Five hold and are +folded into the build; one I accept as real but scope out with a reason. + +The honest summary: the plan was right that the *activation* change is one predicate, and wrong +that flipping it is therefore safe. Default-off was hiding defects in the rotation path, and +turning it on is what makes them reachable. + +## Accepted, folded into the build + +**F1 — Copilot rotation pairs a new bearer with the old account's API origin. ACCEPTED, +security-relevant.** The initial route resolves Copilot's transport from the *active* credential's +`apiBaseUrl` (`core.ts:2882-2887` calling `getOAuthCredentialApiBaseUrl`, which reads +`getCredential(provider)` — the ACTIVE account, `oauth/index.ts:278-280`). But +`OAuthAccessSnapshot` deliberately omits `apiBaseUrl` (`oauth/index.ts:52-58`; only the +observation type `ObservedOAuthAccessSnapshot` carries it, `:64-67`), and all three rotation sites +replace `apiKey` alone (`core.ts:4283-4290`, `:4588-4595`, `:5180-5187`). + +`resolveGithubCopilotTransport` fails closed to the canonical host when the supplied origin does +not validate (`github-copilot-transport.ts:43-50`), so the token cannot leak to an arbitrary host. +That is a real mitigation and it is why this is a correctness bug rather than a token-exfiltration +bug. It is still wrong: account B's bearer goes to account A's allowlisted regional origin, which +is exactly the pairing `core.ts` takes care to preserve on the initial resolve. + +Fix: carry `apiBaseUrl` on the rotation snapshot and re-run `resolveProviderTransport` with it at +every rotation site, instead of mutating `apiKey` in place. + +**F2 — Antigravity rotation keeps the failed account's project when the new one has none. +ACCEPTED.** Each site writes `project` only when the incoming `projectId` is truthy +(`core.ts:4286-4290`, `:4591-4595`, `:5183-5187`). A project-less account is reachable: the refresh +path re-discovers the project best-effort and returns credentials without one when discovery fails +(`google-antigravity.ts:233-235`). Under opt-in this was a narrow hazard; presence-driven +activation arms it for every operator with two Antigravity logins. + +Fix: fail closed. A Cloud Code Assist provider whose rotated snapshot has no `projectId` is not a +usable rotation target — refuse the rotation rather than send the old project with the new bearer. + +**F3 — the presence check would hit the filesystem on hot paths. ACCEPTED.** `loadAuthStore()` +has no cache (`store.ts:149-151`): every call runs `hardenConfigDir()` + `hardenExistingSecret()` +(two `chmodSync`), an `existsSync`, a full `readFileSync`, a `JSON.parse`, and a whole-store +normalize (`store.ts:136-147`). The predicate is called at five `core.ts` sites plus the sidecar +hook, and two of those run on EVERY streaming and non-streaming runTurn request before any 429 +exists (`core.ts:4660`, `:4745`). The auditor counted four to five full store loads per 429 flow +once `rotateGenericOAuthAccountOn429` and `eligibleFailoverAccounts` re-load internally. + +Fix: a small process-local quorum cache in the failover module, keyed by provider, invalidated by +the auth store's own mutation counter so a fresh login is visible immediately. No new persistence. + +**F4 — login rebuilds the provider row and would erase a per-provider opt-out. ACCEPTED, and it +is the nastiest of the six.** `upsertOAuthProvider` reconstructs the provider from the registry +preset and carries forward an explicit allowlist only — `liveModels`, `commandCodeVersion`, +`modelCosts`, key material (`oauth/index.ts:1079-1119`) — then `runLogin` saves it after every +login, add-account, and reauth (`:1211-1223`). + +So the sequence that matters is: operator writes `enabled: false`, then logs in a second account, +and the login both *deletes the opt-out* and *creates the quorum that turns rotation on*. The +repository already treats this class of loss as a bug worth a dedicated test +(`tests/oauth-upsert-preserves-api-key.test.ts`). + +Fix: add `oauthAccountFailover` to the preserved-fields list with a regression test. + +**F6 — the Cursor failover test cannot see stream buffering. ACCEPTED as a test gap.** The +two-account case arms `preflightRunTurnFailover` but reads the whole body +(`tests/adapter-event-oauth-failover.test.ts:101`), so it would not notice if the wrapper held the +first delta until `done`. Since default-on puts every multi-account user behind that wrapper, the +suite should pin delivery, not just content. + +Fix: add a streaming case that proves the first delta reaches the client before the turn ends. + +## Accepted as real, scoped out with a reason + +**F5 — `/api/providers` POST and the GUI payload drop unknown provider keys** +(`provider-routes.ts:539-550`, `gui/src/provider-payload.ts:71-81`). True, but this is the +*Add Provider* path: it replaces a provider row wholesale by operator action, and it already drops +every unknown key, not only this one. Fixing it properly means teaching the payload contract about +preserved operator fields in general, which is a different unit with a different blast radius. + +F4 is the one that fires without the operator asking for it, and that is the one this cycle fixes. +Recorded here so the next person does not think it was missed. + +## Revised scope + +| File | Change | +| --- | --- | +| `src/oauth/generic-account-failover.ts` | precedence chain, cached presence quorum, per-provider override read | +| `src/oauth/index.ts` | rotation snapshot carries `apiBaseUrl`; `upsertOAuthProvider` preserves `oauthAccountFailover` | +| `src/server/responses/core.ts` | rotation sites re-resolve transport with the rotated account's origin; Antigravity fails closed without a project | +| `src/types/provider.ts` | per-provider `oauthAccountFailover` | +| `src/types/config.ts` | doc comment: presence-driven, not opt-in | +| `docs-site/.../providers.md` | default, opt-out, caution rewritten | +| `tests/generic-oauth-failover.test.ts` | activation matrix rewritten; quorum cache invalidation | +| `tests/adapter-event-oauth-failover.test.ts` | no-knob default, explicit-false, streaming delivery | +| `tests/oauth-upsert-preserves-api-key.test.ts` (or sibling) | opt-out survives login | + +The plan's original claim "no change to core.ts" is withdrawn: F1 and F2 both live there. + +VERDICT accepted: **near-pass with five folded blockers**. Proceeding to B. diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/020_execution.md b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/020_execution.md new file mode 100644 index 0000000000..ee71540eef --- /dev/null +++ b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/020_execution.md @@ -0,0 +1,85 @@ +# 020 — wp7e execution record + +## What shipped + +Presence-driven activation for generic OAuth 429 failover, plus the four defects that default-off +was hiding and one test that could not see a regression it was supposed to guard. + +### Activation (the owner's decision) + +`isGenericOAuthFailoverEnabled` now answers in three steps: + +1. `providers..oauthAccountFailover.enabled`, when it is an explicit boolean; +2. `oauthAccountFailover.enabled`, when it is an explicit boolean; +3. otherwise, 2 or more eligible stored accounts. + +Only an explicit boolean overrides presence, so a malformed value falls through instead of taking +a provider out of service. Every existing config keeps its current meaning: someone who wrote +`false` still gets strict single-account behaviour, and someone who wrote `true` sees no change. + +Presence rather than a bare `true` because this predicate does more than gate the rotation loops. +At `core.ts:4660` and `:4745` it decides whether the runTurn stream is wrapped in +`preflightRunTurnFailover`. For a single-account install that wrapper can never rotate — the +rotator returns null below two accounts — so an unconditional `true` would add a preflight to +users who get nothing from it. + +### The presence cache + +`loadAuthStore` has no cache: each call chmods the config dir and the secret file, reads the whole +store, parses it and normalizes it. Since presence now decides activation, the predicate runs on +requests that never see a 429 at all, so the naive version would put a synchronous file read in +front of every OAuth request. + +The module memoizes a COUNT per provider for two seconds, invalidated on every rotation and by +`clearGenericFailoverHealth`. Two seconds is shorter than the time it takes an operator to finish +logging in elsewhere and send a prompt, so a fresh login is not hidden. No credential is cached. + +### applyFailoverSnapshot: one place where the identity is assembled + +Three rotation sites each inlined the same four lines, and that duplication WAS the bug. Two +account-scoped values travel with a Copilot or Antigravity bearer, and the inlined code carried +neither correctly: + +- **Copilot** pins its bearer to an account-scoped regional origin. The initial route pairs them + (`core.ts:2882-2887`), but `OAuthAccessSnapshot` did not carry `apiBaseUrl`, so a rotation sent + account B's token to account A's host. `resolveGithubCopilotTransport` fails closed to the + canonical host for an unvalidated origin, which is why this was wrong routing rather than a + token leak — but it was still wrong, and default-on would have made it everyone's problem. +- **Antigravity** needs an account-matched Cloud Code Assist project. The old code wrote `project` + only when the new snapshot had one, so a project-less account inherited the FAILED account's + project. That account is reachable: the refresh path tolerates project discovery failing + (`google-antigravity.ts:233-235`). + +The helper now assembles the whole identity or refuses. A cloud-code-assist snapshot without a +project aborts the rotation, because not rotating is better than rotating into a mixed identity. +A structural test pins that `apiKey: snapshot.accessToken` appears exactly once in `core.ts`, and +that the one occurrence is inside the helper. + +### The opt-out had to survive a login + +`upsertOAuthProvider` rebuilds the provider row from the registry preset and carries forward an +allowlist. Without this change, the sequence was: operator sets `enabled: false`, operator logs in +a second account, and that single action both deletes the opt-out and creates the quorum that +turns rotation on. The opt-out is now preserved, in both directions — an explicit `true` survives +too, because the rule is about operator intent, not about a preferred answer. + +## Falsification + +Two new contracts were driven red before being trusted: + +| Contract | How it was falsified | Result | +| --- | --- | --- | +| First delta reaches the client before completion | forced `preflightAdapterEvents` to buffer the whole turn | test failed at the 5s bound instead of passing | +| Opt-out survives login | disabled the preservation branch | both upsert cases failed | + +The delivery test reads the SSE body incrementally against a completion the fixture holds closed, +so it fails on latency rather than on content. The previous whole-body assertion could not have +caught either regression. + +## Deliberately not done + +`/api/providers` POST and its GUI payload drop unknown provider keys, including this one. Real, +but it is the Add Provider path: operator-initiated wholesale replacement that already discards +every unrecognized field. Fixing it means changing the payload contract in general, which is a +different unit. The login path was the one that fires without the operator asking, and that is the +one this cycle closed. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index fc8a92ccd4..782effab52 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -295,15 +295,35 @@ Leave this disabled unless you understand Anthropic account policy risk. Prefer `ocx account use anthropic ` switching when unsure. ::: -### `oauthAccountFailover` (experimental) +### `oauthAccountFailover` Rotates to another logged-in account of the same provider when one is rate-limited, for OAuth providers that have no pool of their own — xAI, Cursor, Kimi, GitHub Copilot, Google Antigravity, -and Nous. Off by default. +and Nous. + +**Logging in a second account is what turns this on.** With no configuration, rotation activates +for any of those providers holding 2 or more accounts that are not flagged for reauthentication — +the same rule `apiKeyPool` already applies to a 2+ key pool. A provider with one stored account +behaves exactly as before. | Key | Type | Default | Description | | --- | --- | --- | --- | -| `oauthAccountFailover.enabled?` | `boolean` | `false` | Enable 429 cooldown failover across stored OAuth accounts. | +| `oauthAccountFailover.enabled?` | `boolean` | presence-driven | Global override. `false` forces single-account behaviour everywhere; `true` forces rotation on. | +| `providers..oauthAccountFailover.enabled?` | `boolean` | inherits | Per-provider override; beats the global setting and beats account presence. | + +To keep strict single-account behaviour for one provider whose terms you would rather not test: + +```json +{ + "providers": { + "cursor": { + "oauthAccountFailover": { "enabled": false } + } + } +} +``` + +That setting survives logging in, adding an account, and reauthenticating. Deliberately narrower than `anthropicAccountPool`: no session affinity, no quota-ranked selection, no probe leases. It answers one question — the account that just returned 429 is @@ -327,8 +347,9 @@ events rather than an HTTP status, and the standalone Antigravity image endpoint request path; neither rotates yet. :::caution[Experimental] -Rotating across subscription accounts spends a second account's quota and may violate provider -terms. Leave this disabled unless that is a tradeoff you have decided to make. +Rotating across subscription accounts spends a second account's quota and may violate some +providers' terms. If that is not a tradeoff you want, set `enabled: false` globally or for the +provider in question. ::: ### Managed record shapes diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 37230c1ce7..c7f836487d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2115,17 +2115,17 @@ export const de: Record = { "dash.visionAdvancedPopover": "Erweiterte Vision-Einstellungen", "models.newPolicyGlobal": "Neue Modelle zunächst deaktivieren", "models.newPolicyProvider": "Richtlinie für neue Modelle", "models.newPolicy_inherit": "Übernehmen", "models.newPolicy_off": "Aus", "models.newPolicy_on": "An", "models.newBadge": "NEU", "models.newCount": "{count} neu, aus", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", - "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliases": "Aliase", + "models.aliasesTable": "Alias-Tabelle", + "models.aliasPrompt": "Anbieter-Alias (leer lassen zum Entfernen)", + "models.modelAliasPrompt": "Modell-Alias (leer lassen zum Entfernen)", + "models.aliasSaved": "Alias gespeichert", + "models.aliasConflict": "Dieser Alias steht in Konflikt mit einem vorhandenen Namen", + "models.editProviderAlias": "Anbieter-Alias bearbeiten", + "models.editModelAlias": "Modell-Alias bearbeiten", + "models.useDefaultAliases": "Standard-Aliase verwenden", + "models.useDefaultAliasesGlobal": "Standard-Aliase global verwenden", + "models.aliasAuto": "automatisch", + "models.aliasUser": "benutzerdefiniert", + "models.aliasStale": "veraltet", }; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e601fcd2b9..4553cc3ff6 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2103,17 +2103,17 @@ export const fr: Record = { "lab.layer.task_effectiveness": "Efficacité des tâches", "models.newPolicyGlobal": "Désactiver les nouveaux modèles par défaut", "models.newPolicyProvider": "Politique des nouveaux modèles", "models.newPolicy_inherit": "Hériter", "models.newPolicy_off": "Désactivé", "models.newPolicy_on": "Activé", "models.newBadge": "NOUVEAU", "models.newCount": "{count} nouveaux, désactivés", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", + "models.aliases": "Alias", + "models.aliasesTable": "Table des alias", + "models.aliasPrompt": "Alias du fournisseur (laisser vide pour effacer)", + "models.modelAliasPrompt": "Alias du modèle (laisser vide pour effacer)", + "models.aliasSaved": "Alias enregistré", + "models.aliasConflict": "Cet alias entre en conflit avec un nom existant", + "models.editProviderAlias": "Modifier l'alias du fournisseur", + "models.editModelAlias": "Modifier l'alias du modèle", + "models.useDefaultAliases": "Utiliser les alias par défaut", + "models.useDefaultAliasesGlobal": "Utiliser les alias par défaut partout", "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliasUser": "utilisateur", + "models.aliasStale": "obsolète", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 9afed94dab..171d93db49 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2136,17 +2136,17 @@ export const ja: Record = { "dash.visionAdvancedPopover": "詳細なビジョン設定", "models.newPolicyGlobal": "新しいモデルを無効で追加", "models.newPolicyProvider": "新しいモデルのポリシー", "models.newPolicy_inherit": "継承", "models.newPolicy_off": "オフ", "models.newPolicy_on": "オン", "models.newBadge": "新着", "models.newCount": "新着 {count} 件、オフ", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", - "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliases": "エイリアス", + "models.aliasesTable": "エイリアス一覧", + "models.aliasPrompt": "プロバイダーのエイリアス(空にすると解除)", + "models.modelAliasPrompt": "モデルのエイリアス(空にすると解除)", + "models.aliasSaved": "エイリアスを保存しました", + "models.aliasConflict": "このエイリアスは既存の名前と競合します", + "models.editProviderAlias": "プロバイダーのエイリアスを編集", + "models.editModelAlias": "モデルのエイリアスを編集", + "models.useDefaultAliases": "既定のエイリアスを使う", + "models.useDefaultAliasesGlobal": "既定のエイリアスを全体で使う", + "models.aliasAuto": "自動", + "models.aliasUser": "ユーザー", + "models.aliasStale": "古い", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b101d71376..68684ad4ff 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2137,17 +2137,17 @@ export const ko: Record = { "dash.visionAdvancedPopover": "고급 비전 설정", "models.newPolicyGlobal": "새 모델을 비활성화 상태로 추가", "models.newPolicyProvider": "새 모델 정책", "models.newPolicy_inherit": "상속", "models.newPolicy_off": "끔", "models.newPolicy_on": "켬", "models.newBadge": "신규", "models.newCount": "신규 {count}개, 꺼짐", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", - "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliases": "별칭", + "models.aliasesTable": "별칭 표", + "models.aliasPrompt": "공급자 별칭 (비우면 해제)", + "models.modelAliasPrompt": "모델 별칭 (비우면 해제)", + "models.aliasSaved": "별칭을 저장했습니다", + "models.aliasConflict": "이 별칭은 기존 이름과 충돌합니다", + "models.editProviderAlias": "공급자 별칭 편집", + "models.editModelAlias": "모델 별칭 편집", + "models.useDefaultAliases": "기본 별칭 사용", + "models.useDefaultAliasesGlobal": "기본 별칭을 전체에 사용", + "models.aliasAuto": "자동", + "models.aliasUser": "사용자", + "models.aliasStale": "오래됨", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index fe9febfe8c..be466dc462 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2138,17 +2138,17 @@ export const ru: Record = { "dash.visionAdvancedPopover": "Дополнительные настройки изображений", "models.newPolicyGlobal": "Добавлять новые модели выключенными", "models.newPolicyProvider": "Политика новых моделей", "models.newPolicy_inherit": "Наследовать", "models.newPolicy_off": "Выкл.", "models.newPolicy_on": "Вкл.", "models.newBadge": "НОВАЯ", "models.newCount": "Новых: {count}, выкл.", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", - "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliases": "Псевдонимы", + "models.aliasesTable": "Таблица псевдонимов", + "models.aliasPrompt": "Псевдоним провайдера (оставьте пустым, чтобы очистить)", + "models.modelAliasPrompt": "Псевдоним модели (оставьте пустым, чтобы очистить)", + "models.aliasSaved": "Псевдоним сохранён", + "models.aliasConflict": "Этот псевдоним конфликтует с существующим именем", + "models.editProviderAlias": "Изменить псевдоним провайдера", + "models.editModelAlias": "Изменить псевдоним модели", + "models.useDefaultAliases": "Использовать псевдонимы по умолчанию", + "models.useDefaultAliasesGlobal": "Использовать псевдонимы по умолчанию везде", + "models.aliasAuto": "авто", + "models.aliasUser": "пользователь", + "models.aliasStale": "устарел", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f086b4d33b..9b31f7c212 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2138,17 +2138,17 @@ export const tr: Record = { "dash.visionAdvancedPopover": "Gelişmiş görsel ayarları", "models.newPolicyGlobal": "Yeni modeller devre dışı başlasın", "models.newPolicyProvider": "Yeni model ilkesi", "models.newPolicy_inherit": "Devral", "models.newPolicy_off": "Kapalı", "models.newPolicy_on": "Açık", "models.newBadge": "YENİ", "models.newCount": "{count} yeni, kapalı", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", - "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliases": "Takma adlar", + "models.aliasesTable": "Takma ad tablosu", + "models.aliasPrompt": "Sağlayıcı takma adı (temizlemek için boş bırakın)", + "models.modelAliasPrompt": "Model takma adı (temizlemek için boş bırakın)", + "models.aliasSaved": "Takma ad kaydedildi", + "models.aliasConflict": "Bu takma ad mevcut bir adla çakışıyor", + "models.editProviderAlias": "Sağlayıcı takma adını düzenle", + "models.editModelAlias": "Model takma adını düzenle", + "models.useDefaultAliases": "Varsayılan takma adları kullan", + "models.useDefaultAliasesGlobal": "Varsayılan takma adları her yerde kullan", + "models.aliasAuto": "otomatik", + "models.aliasUser": "kullanıcı", + "models.aliasStale": "eski", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4b6fb5ff42..fdfe10db37 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2101,17 +2101,17 @@ export const zhTW: Record = { "dash.visionAdvancedPopover": "進階視覺設定", "models.newPolicyGlobal": "新模型預設停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "繼承", "models.newPolicy_off": "關閉", "models.newPolicy_on": "開啟", "models.newBadge": "新增", "models.newCount": "{count} 個新增,已關閉", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", - "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliases": "別名", + "models.aliasesTable": "別名表", + "models.aliasPrompt": "供應商別名(留空即清除)", + "models.modelAliasPrompt": "模型別名(留空即清除)", + "models.aliasSaved": "別名已儲存", + "models.aliasConflict": "此別名與現有名稱衝突", + "models.editProviderAlias": "編輯供應商別名", + "models.editModelAlias": "編輯模型別名", + "models.useDefaultAliases": "使用預設別名", + "models.useDefaultAliasesGlobal": "全域使用預設別名", + "models.aliasAuto": "自動", + "models.aliasUser": "使用者", + "models.aliasStale": "過期", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 288b5784cb..aeccc0724a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2136,17 +2136,17 @@ export const zh: Record = { "dash.visionAdvancedPopover": "高级视觉设置", "models.newPolicyGlobal": "新模型默认停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "继承", "models.newPolicy_off": "关闭", "models.newPolicy_on": "开启", "models.newBadge": "新增", "models.newCount": "{count} 个新增,已关闭", - "models.aliases": "Aliases", - "models.aliasesTable": "Alias table", - "models.aliasPrompt": "Provider alias (leave empty to clear)", - "models.modelAliasPrompt": "Model alias (leave empty to clear)", - "models.aliasSaved": "Alias saved", - "models.aliasConflict": "That alias conflicts with an existing name", - "models.editProviderAlias": "Edit provider alias", - "models.editModelAlias": "Edit model alias", - "models.useDefaultAliases": "Use default aliases", - "models.useDefaultAliasesGlobal": "Use default aliases globally", - "models.aliasAuto": "auto", - "models.aliasUser": "user", - "models.aliasStale": "stale", + "models.aliases": "别名", + "models.aliasesTable": "别名表", + "models.aliasPrompt": "服务商别名(留空即清除)", + "models.modelAliasPrompt": "模型别名(留空即清除)", + "models.aliasSaved": "别名已保存", + "models.aliasConflict": "该别名与现有名称冲突", + "models.editProviderAlias": "编辑服务商别名", + "models.editModelAlias": "编辑模型别名", + "models.useDefaultAliases": "使用默认别名", + "models.useDefaultAliasesGlobal": "全局使用默认别名", + "models.aliasAuto": "自动", + "models.aliasUser": "用户", + "models.aliasStale": "过期", }; diff --git a/gui/tests/claude-desktop-locale.test.ts b/gui/tests/claude-desktop-locale.test.ts index f7fd42bf32..4fe3a847f5 100644 --- a/gui/tests/claude-desktop-locale.test.ts +++ b/gui/tests/claude-desktop-locale.test.ts @@ -5,7 +5,10 @@ const LOCALES = ["en", "de", "fr", "ja", "ko", "ru", "zh", "zh-TW"] as const; async function readDict(locale: string): Promise> { const src = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); const out = new Map(); - for (const m of src.matchAll(/^\s*"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/gm)) { + // NOT anchored to the line start: these catalogs pack several entries onto one line, and a + // `^\s*`-anchored pattern silently reads only the first of them. That made this parity check + // report a phantom missing key while the catalogs were in fact identical. + for (const m of src.matchAll(/"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/g)) { out.set(m[1]!, m[2]!); } return out; diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index f05312c251..e6e759f6f1 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -17,6 +17,9 @@ const INTENTIONAL_ENGLISH = new Set([ // Units, symbols, protocol values, machine labels, and product names. "uptime.hour", "uptime.second", + // "auto" is the same word in French, and it labels a machine-derived alias source rather + // than prose. Translating it would invent a difference the UI does not have. + "models.aliasAuto", "common.github", "common.ok", "nav.api", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 1dddbfc3a3..8ae512dde1 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -5,7 +5,10 @@ const LOCALES = ["en", "de", "fr", "ja", "ko", "ru", "tr", "zh", "zh-TW"] as con async function readDict(locale: string): Promise> { const src = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); const out = new Map(); - for (const m of src.matchAll(/^\s*"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/gm)) { + // NOT anchored to the line start: these catalogs pack several entries onto one line, and a + // `^\s*`-anchored pattern silently reads only the first of them. That made this parity check + // report a phantom missing key while the catalogs were in fact identical. + for (const m of src.matchAll(/"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/g)) { out.set(m[1]!, m[2]!); } return out; diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index d3fe698014..018c46c987 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -26,6 +26,21 @@ export const GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST = 3; const DEFAULT_COOLDOWN_MS = 60_000; const MAX_COOLDOWN_MS = 15 * 60_000; +/** + * How long a presence answer may be reused before the store is consulted again. + * + * `loadAuthStore` has no cache: every call chmods the config dir and the secret, reads the whole + * file, parses it and normalizes the store (store.ts:136-151). Since presence now decides + * activation, this predicate runs on paths that have not seen a 429 at all — the streaming and + * non-streaming runTurn entry points evaluate it once per request — so an uncached check would put + * a synchronous file read in front of every request for every OAuth provider. + * + * Two seconds is short enough that a login in another window is picked up before the operator can + * switch back and send a prompt, and long enough that a burst of requests shares one read. The + * cache holds a COUNT, never a credential. + */ +const PRESENCE_CACHE_TTL_MS = 2_000; + /** * Providers whose rotation is owned elsewhere and must not be handled here. * @@ -40,9 +55,17 @@ interface AccountHealth { cooldownSource: "retry-after" | "default"; } +interface PresenceEntry { + eligible: number; + readAt: number; +} + /** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */ const health = new Map(); +/** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */ +const presence = new Map(); + const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`; function isCooled(provider: string, accountId: string, now: number): boolean { @@ -60,17 +83,60 @@ export function isGenericFailoverProvider(providerName: string, provider: OcxPro return provider.authMode === "oauth" && !EXCLUDED_PROVIDERS.has(providerName); } +/** + * Stored accounts that could serve traffic if asked, ignoring cooldowns. + * + * Cooldowns are excluded on purpose: they are transient and per-request, while this answers the + * durable question "did the operator log in more than one account". Treating a cooled account as + * absent would switch the feature off for the rest of the cooldown, which is exactly when it is + * needed. + */ +function eligibleAccountCount(providerName: string, now: number): number { + const cached = presence.get(providerName); + if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) return cached.eligible; + const set = getAccountSet(providerName); + const eligible = set ? set.accounts.filter(account => account.needsReauth !== true).length : 0; + presence.set(providerName, { eligible, readAt: now }); + return eligible; +} + +/** + * Presence IS consent (#2568d). + * + * `hasKeyPoolFailover` already reads a 2+ key pool as the operator asking for rotation, and a + * second OAuth login is the same statement. One account stays a strict no-op either way, so this + * only changes behaviour for someone who deliberately logged in twice. + */ +export function hasFailoverAccountQuorum(providerName: string, now = Date.now()): boolean { + return eligibleAccountCount(providerName, now) >= 2; +} + /** * Whether generic rotation is active for this provider. * - * Default OFF pending an owner decision on presence-driven activation (#2568 asks for no - * toggle; rotating spends another subscription account's quota, so the default is escalated - * rather than chosen here). The mechanism does not change if the default flips. + * Precedence, most specific first: + * + * 1. `providers..oauthAccountFailover.enabled` — an operator may accept rotation on one + * provider and refuse it on another, because provider terms differ. + * 2. `oauthAccountFailover.enabled` — the global switch. Anyone who already wrote `false` keeps + * strict single-account behaviour across this change. + * 3. Presence: 2 or more eligible stored accounts (#2568d, owner decision). + * + * Only an explicit boolean overrides presence. A malformed value falls through instead of + * throwing, because a typo in a knob must not take a provider out of service. */ -export function isGenericOAuthFailoverEnabled(config: OcxConfig, providerName: string): boolean { +export function isGenericOAuthFailoverEnabled( + config: OcxConfig, + providerName: string, + now = Date.now(), +): boolean { const provider = config.providers?.[providerName]; if (!provider || !isGenericFailoverProvider(providerName, provider)) return false; - return config.oauthAccountFailover?.enabled === true; + const perProvider = provider.oauthAccountFailover?.enabled; + if (typeof perProvider === "boolean") return perProvider; + const global = config.oauthAccountFailover?.enabled; + if (typeof global === "boolean") return global; + return hasFailoverAccountQuorum(providerName, now); } /** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */ @@ -110,6 +176,9 @@ export function rotateGenericOAuthAccountOn429( const eligible = eligibleFailoverAccounts(providerName, now).filter(id => id !== failedAccountId); if (eligible.length === 0) return null; + // A rotation means the roster in use just changed; do not answer the next activation question + // from a count read before the failure. + presence.delete(providerName); // Deterministic: start after the failed account so repeated 429s walk the roster instead of // hammering whichever id happens to sort first. const order = set.accounts.map(account => account.id); @@ -152,8 +221,10 @@ export function genericFailoverRetryAfterSeconds(providerName: string, now = Dat export function clearGenericFailoverHealth(providerName?: string): void { if (!providerName) { health.clear(); + presence.clear(); return; } + presence.delete(providerName); for (const key of [...health.keys()]) { if (key.startsWith(`${providerName}\u0000`)) health.delete(key); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index adf590ac9b..db8cfb4392 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -59,10 +59,19 @@ export interface OAuthAccessSnapshot { projectId?: string; /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */ kiro?: Pick; + /** + * Allowlisted GitHub Copilot API origin belonging to THIS account. + * + * Copilot pins its bearer to an account-scoped regional host, and the initial route already + * pairs the two (`core.ts` resolves transport with `getOAuthCredentialApiBaseUrl`). Account + * failover must carry the pairing across the rotation; without it, account B's token is sent to + * account A's origin (#2568d). + */ + apiBaseUrl?: string; } export interface ObservedOAuthAccessSnapshot extends OAuthAccessSnapshot { - /** Allowlisted provider API origin consumed by GitHub Copilot model discovery. */ + /** Retained for callers that predate `apiBaseUrl` moving onto the base snapshot. */ apiBaseUrl?: string; } @@ -355,12 +364,19 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}), ...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}), }; + // Validated here, not at the call site: an unvalidated origin from a legacy or crafted + // credential must never travel with a bearer, and dropping it makes the transport fall back to + // the canonical host rather than to whatever the previous account was using. + const copilotApiBaseUrl = provider === "github-copilot" + ? validateCopilotApiBaseUrl(cred.apiBaseUrl) + : undefined; return { provider, accountId, generation: credentialGeneration(cred), accessToken: cred.access, ...(cred.projectId ? { projectId: cred.projectId } : {}), + ...(copilotApiBaseUrl ? { apiBaseUrl: copilotApiBaseUrl } : {}), // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials // may use explicit environment routing, but never borrow the currently signed-in local CLI account. ...(provider === "kiro" @@ -1095,6 +1111,14 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (existing?.modelCosts !== undefined) { next.modelCosts = existing.modelCosts; } + // The per-provider account-failover opt-out is operator intent about SPENDING, and the login + // path is exactly where losing it does damage: adding a second account both rebuilds this row + // from the preset and creates the 2-account quorum that turns presence-driven rotation on + // (#2568d). Dropping the opt-out here would enable the thing the operator switched off, at the + // moment they were doing something unrelated. + if (existing?.oauthAccountFailover !== undefined) { + next.oauthAccountFailover = existing.oauthAccountFailover; + } if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes. let storedApiKey = sanitizeApiKeyValue(existing.apiKey); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1466e723aa..08a679894d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2810,6 +2810,38 @@ async function handleResponsesInner( // the request actually used, so a concurrent rotation cannot cool an innocent replacement. let genericFailoverAccountId: string | null = null; let genericFailovers = 0; + /** + * Apply a rotated account's FULL credential snapshot to the live route (#2568d). + * + * One helper for all three rotation sites on purpose. Each site used to inline the same four + * lines, and the divergence that produced was the bug: `apiKey` was swapped while the routing + * metadata paired with it stayed behind. + * + * Returns false when the snapshot cannot be used safely, and the caller must then abandon the + * rotation rather than send a half-applied identity: + * + * - Copilot pins its bearer to an account-scoped regional origin, so transport is re-resolved + * with the new account's `apiBaseUrl` instead of inheriting the previous account's host. + * - A Cloud Code Assist provider needs an account-matched project. Antigravity's refresh path + * tolerates project discovery failing, so a stored account can legitimately have no project; + * sending that account's bearer with the FAILED account's project is worse than not rotating. + */ + const applyFailoverSnapshot = (snapshot: OAuthAccessSnapshot): boolean => { + if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return false; + let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken }; + if (route.providerName === "github-copilot") { + rotatedProvider = resolveProviderTransport( + route.providerName, + rotatedProvider, + parsed.options.promptCacheKey, + snapshot.apiBaseUrl, + ) as OcxProviderConfig; + } + if (snapshot.projectId) rotatedProvider = { ...rotatedProvider, project: snapshot.projectId }; + route.provider = rotatedProvider; + if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) }; + return true; + }; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -4283,11 +4315,7 @@ async function handleResponsesInner( const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); genericFailoverAccountId = nextAccountId; genericFailovers += 1; - route.provider = { ...route.provider, apiKey: snapshot.accessToken }; - if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) }; - if (route.provider.googleMode === "cloud-code-assist" && snapshot.projectId) { - route.provider = { ...route.provider, project: snapshot.projectId }; - } + if (!applyFailoverSnapshot(snapshot)) return null; } catch { return null; } @@ -4588,11 +4616,7 @@ async function handleResponsesInner( const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); genericFailoverAccountId = nextAccountId; genericFailovers += 1; - route.provider = { ...route.provider, apiKey: snapshot.accessToken }; - if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) }; - if (route.provider.googleMode === "cloud-code-assist" && snapshot.projectId) { - route.provider = { ...route.provider, project: snapshot.projectId }; - } + if (!applyFailoverSnapshot(snapshot)) return false; // A Cursor conversation/checkpoint is credential-scoped. The failed attempt emitted no // client-visible bytes, so replay is safe, but carrying its account identity into the next // account would not be. Let the rotated adapter derive a fresh identity and conversation. @@ -5180,11 +5204,7 @@ async function handleResponsesInner( const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); genericFailoverAccountId = nextAccountId; genericFailovers += 1; - route.provider = { ...route.provider, apiKey: snapshot.accessToken }; - if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) }; - if (route.provider.googleMode === "cloud-code-assist" && snapshot.projectId) { - route.provider = { ...route.provider, project: snapshot.projectId }; - } + if (!applyFailoverSnapshot(snapshot)) break; invalidateSameTargetRequest(); activeAdapter = resolveAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire), diff --git a/src/types/config.ts b/src/types/config.ts index cbc021f5da..58e1901625 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -605,16 +605,17 @@ export interface OcxConfig { stickyLimit?: number; }; /** - * Opt-in generic OAuth multi-account 429 failover (#2568). Default OFF. + * Generic OAuth multi-account 429 failover (#2568). Presence-driven by default. * * Rotates to another logged-in account of the SAME provider when one is rate-limited, for * OAuth providers that have no pool of their own — xAI, Cursor, Kimi, GitHub Copilot, * Antigravity, Nous. The Codex pool and the Anthropic pool own their own rotation and are - * excluded; enabling this changes neither. + * excluded; this setting changes neither. * - * Default OFF is a recorded escalation, not a settled preference: the issue asks for - * presence-driven activation (2+ accounts implies consent, mirroring API-key pools), but - * rotating spends a second subscription account's quota, so the default is left to the owner. + * With the key absent, rotation activates when a provider has 2 or more eligible stored + * accounts — the same consent rule API-key pools already apply to a 2+ key pool (#2568d). A + * single account is a strict no-op. Set `false` to keep strict single-account behaviour; + * `providers..oauthAccountFailover` overrides this per provider. */ oauthAccountFailover?: { enabled?: boolean; diff --git a/src/types/provider.ts b/src/types/provider.ts index d44232dbdc..b7ba042506 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -351,6 +351,16 @@ export interface OcxProviderConfig { * providers whose registry entry declares authKind "local" (management API enforces). */ authMode?: "key" | "forward" | "oauth" | "local"; + /** + * Per-provider override for generic OAuth multi-account 429 failover (#2568). + * + * Rotation is presence-driven by default — 2+ logged-in accounts activate it — so this exists + * for the operator who accepts rotation on one provider and refuses it on another. An explicit + * boolean here beats the global `oauthAccountFailover` and beats presence. + */ + oauthAccountFailover?: { + enabled?: boolean; + }; /** Allow an explicitly key/oauth provider to run without a credential (for keyless local proxies). */ keyOptional?: boolean; /** diff --git a/tests/adapter-event-oauth-failover.test.ts b/tests/adapter-event-oauth-failover.test.ts index f806806930..4b04ceb095 100644 --- a/tests/adapter-event-oauth-failover.test.ts +++ b/tests/adapter-event-oauth-failover.test.ts @@ -11,6 +11,8 @@ const actualResolver = await import("../src/server/adapter-resolve"); const actualResolveAdapter = actualResolver.resolveAdapter; let attempts: AdapterEvent[][] = []; let attemptKeys: string[] = []; +/** Set by the delivery test: an attempt that emits, then blocks before completing the turn. */ +let slowAttempt: ((emit: (event: AdapterEvent) => void) => Promise) | undefined; function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { return { @@ -22,6 +24,7 @@ function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { async runTurn(_parsed, _incoming, emit) { const index = attemptKeys.length; attemptKeys.push(provider.apiKey ?? ""); + if (slowAttempt) return await slowAttempt(emit); for (const event of attempts[index] ?? []) emit(event); }, }; @@ -39,7 +42,11 @@ const { handleResponses } = await import("../src/server/responses"); const originalHome = process.env.OPENCODEX_HOME; let home = ""; -function config(enabled = true): OcxConfig { +/** + * `enabled: undefined` is the case that matters after #2568d — the key absent entirely, which is + * what every install that never edited its config looks like. + */ +function config(enabled?: boolean): OcxConfig { return { port: 0, defaultProvider: "cursor", @@ -51,7 +58,7 @@ function config(enabled = true): OcxConfig { models: ["model"], }, }, - ...(enabled ? { oauthAccountFailover: { enabled: true } } : {}), + ...(enabled === undefined ? {} : { oauthAccountFailover: { enabled } }), } as OcxConfig; } @@ -80,6 +87,7 @@ beforeEach(() => { clearGenericFailoverHealth(); attempts = []; attemptKeys = []; + slowAttempt = undefined; }); afterEach(() => { @@ -117,6 +125,52 @@ describe("#2568 adapter-event OAuth failover", () => { expect(body).toContain("rate_limit_exceeded"); }); + test("an explicit opt-out keeps single-account behaviour with two accounts stored", async () => { + // Presence is consent, but only when the operator has not already said no. Someone who wrote + // `enabled: false` gets the pre-#2568d behaviour unchanged. + await seedAccounts(2); + attempts = [[{ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }]]; + + const body = await (await handleResponses(request(true), config(false), { model: "", provider: "" })).text(); + + expect(attemptKeys).toEqual(["cursor-access-1"]); + expect(body).toContain("rate_limit_exceeded"); + }); + + test("the first delta reaches the client before the turn completes", async () => { + // Presence-driven activation puts every multi-account user behind preflightRunTurnFailover, + // which holds events until the first meaningful one. Holding the FIRST DELTA would be a + // silent time-to-first-token regression that a whole-body assertion cannot see, so this reads + // the stream incrementally and refuses to wait for `done`. + await seedAccounts(2); + let releaseCompletion: (() => void) | undefined; + const completionGate = new Promise(resolve => { releaseCompletion = resolve; }); + slowAttempt = async emit => { + emit({ type: "text_delta", text: "first token" }); + await completionGate; + emit({ type: "done" }); + }; + + const response = await handleResponses(request(true), config(), { model: "", provider: "" }); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let seen = ""; + // Bounded: if the delta never arrives before completion, this rejects instead of hanging the + // suite, because the completion gate is still closed. + while (!seen.includes("first token")) { + const chunk = await Promise.race([ + reader.read(), + new Promise((_, reject) => setTimeout(() => reject(new Error("first delta withheld until completion")), 2_000)), + ]); + if (chunk.done) throw new Error("stream ended before the first delta"); + seen += decoder.decode(chunk.value, { stream: true }); + } + + expect(seen).toContain("first token"); + releaseCompletion?.(); + await reader.cancel(); + }); + test("Codex and Anthropic remain excluded", async () => { for (const providerName of ["openai", "anthropic"] as const) { attempts = [[{ type: "error", message: "Cursor rate limit exceeded: resource_exhausted" }]]; diff --git a/tests/generic-oauth-failover.test.ts b/tests/generic-oauth-failover.test.ts index 741c3698df..2d17f8ceb4 100644 --- a/tests/generic-oauth-failover.test.ts +++ b/tests/generic-oauth-failover.test.ts @@ -6,11 +6,12 @@ import { clearGenericFailoverHealth, eligibleFailoverAccounts, genericFailoverRetryAfterSeconds, + hasFailoverAccountQuorum, isGenericFailoverProvider, isGenericOAuthFailoverEnabled, rotateGenericOAuthAccountOn429, } from "../src/oauth/generic-account-failover"; -import { getAccountSet, saveCredential } from "../src/oauth/store"; +import { getAccountSet, markAccountNeedsReauth, saveCredential } from "../src/oauth/store"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; const originalHome = process.env.OPENCODEX_HOME; @@ -35,15 +36,23 @@ const OAUTH_PROVIDER = { authMode: "oauth", } as unknown as OcxProviderConfig; -function config(enabled: boolean): OcxConfig { +/** + * `enabled: undefined` means the key is ABSENT, which after #2568d is the case that matters most: + * it is what every install that never edited its config looks like. + */ +function config(enabled?: boolean, perProvider?: boolean): OcxConfig { return { - providers: { xai: OAUTH_PROVIDER }, - ...(enabled ? { oauthAccountFailover: { enabled: true } } : {}), + providers: { + xai: perProvider === undefined + ? OAUTH_PROVIDER + : { ...OAUTH_PROVIDER, oauthAccountFailover: { enabled: perProvider } }, + }, + ...(enabled === undefined ? {} : { oauthAccountFailover: { enabled } }), } as unknown as OcxConfig; } -async function seed(count: number): Promise { - for (let i = 0; i < count; i++) { +async function seed(count: number, offset = 0): Promise { + for (let i = offset; i < offset + count; i++) { await saveCredential("xai", { access: `access-${i}`, refresh: `refresh-${i}`, @@ -55,28 +64,66 @@ async function seed(count: number): Promise { } describe("#2568 generic OAuth account failover", () => { - test("rotates to another logged-in account and cools the one that 429'd", async () => { + test("two logged-in accounts rotate with NO configuration at all (#2568d)", async () => { + // The reported workflow: three xAI accounts are logged in, the active one hits its limit, and + // the operator never went looking for a toggle. Presence is the consent signal. const [first, second] = await seed(2); - const next = rotateGenericOAuthAccountOn429(config(true), "xai", first!, null); + const next = rotateGenericOAuthAccountOn429(config(), "xai", first!, null); expect(next).toBe(second); // The failed account is cooled, so it is not offered again while the window holds. expect(eligibleFailoverAccounts("xai")).toEqual([second!]); }); + test("an explicit knob still wins over presence, in both directions", async () => { + const ids = await seed(2); + expect(rotateGenericOAuthAccountOn429(config(false), "xai", ids[0]!, null)).toBeNull(); + clearGenericFailoverHealth(); + expect(rotateGenericOAuthAccountOn429(config(true), "xai", ids[0]!, null)).toBe(ids[1]); + }); + + test("a per-provider override beats the global switch", async () => { + // Provider terms differ, so an operator may accept rotation on one provider and refuse it on + // another. The narrower setting is the one that means something. + const ids = await seed(2); + expect(isGenericOAuthFailoverEnabled(config(true, false), "xai")).toBe(false); + expect(isGenericOAuthFailoverEnabled(config(false, true), "xai")).toBe(true); + expect(rotateGenericOAuthAccountOn429(config(true, false), "xai", ids[0]!, null)).toBeNull(); + }); + + test("a second account flagged for reauth is not a quorum", async () => { + // A revoked account cannot serve the replay, so counting it would arm the failover machinery + // for a user who still has exactly one usable credential. + const ids = await seed(2); + expect(hasFailoverAccountQuorum("xai")).toBe(true); + await markAccountNeedsReauth("xai", ids[1]!, true); + clearGenericFailoverHealth(); + expect(hasFailoverAccountQuorum("xai")).toBe(false); + expect(isGenericOAuthFailoverEnabled(config(), "xai")).toBe(false); + }); + + test("the presence answer is cached, but a fresh login is visible within the TTL window", async () => { + // The predicate now runs on requests that never see a 429, and loadAuthStore has no cache of + // its own — it chmods and re-reads the whole store every call. A count is memoized; a + // credential never is. + const start = Date.now(); + await seed(1); + expect(hasFailoverAccountQuorum("xai", start)).toBe(false); + await seed(1, 1); + // Same instant: still the memoized answer. + expect(hasFailoverAccountQuorum("xai", start)).toBe(false); + // Past the window: the new login is seen without any explicit invalidation call. + expect(hasFailoverAccountQuorum("xai", start + 2_001)).toBe(true); + }); + test("a single stored account is a strict no-op", async () => { // Rotating to itself would replay the same 429 against the same credential, and cooling // the only account would take the provider out of service for nothing. const [solo] = await seed(1); - expect(rotateGenericOAuthAccountOn429(config(true), "xai", solo!, null)).toBeNull(); + expect(rotateGenericOAuthAccountOn429(config(), "xai", solo!, null)).toBeNull(); + expect(isGenericOAuthFailoverEnabled(config(), "xai")).toBe(false); expect(eligibleFailoverAccounts("xai")).toEqual([solo!]); }); - test("the knob off is a strict no-op regardless of account count", async () => { - const ids = await seed(2); - expect(rotateGenericOAuthAccountOn429(config(false), "xai", ids[0]!, null)).toBeNull(); - expect(eligibleFailoverAccounts("xai")).toEqual(ids); - }); - test("Codex and Anthropic are excluded: their own pools own rotation", () => { expect(isGenericFailoverProvider("xai", OAUTH_PROVIDER)).toBe(true); expect(isGenericFailoverProvider("openai", OAUTH_PROVIDER)).toBe(false); @@ -90,7 +137,7 @@ describe("#2568 generic OAuth account failover", () => { test("all accounts cooled reports the earliest remaining window", async () => { const ids = await seed(2); - const cfg = config(true); + const cfg = config(); expect(rotateGenericOAuthAccountOn429(cfg, "xai", ids[0]!, "120")).toBe(ids[1]); expect(rotateGenericOAuthAccountOn429(cfg, "xai", ids[1]!, "30")).toBeNull(); const retryAfter = genericFailoverRetryAfterSeconds("xai"); @@ -101,14 +148,15 @@ describe("#2568 generic OAuth account failover", () => { test("Retry-After drives the cooldown length", async () => { const ids = await seed(2); - rotateGenericOAuthAccountOn429(config(true), "xai", ids[0]!, "600"); + rotateGenericOAuthAccountOn429(config(), "xai", ids[0]!, "600"); expect(genericFailoverRetryAfterSeconds("xai")).toBeGreaterThan(500); }); - test("enablement requires both the knob and a participating OAuth provider", async () => { + test("an excluded provider is never enabled, however many accounts it has", async () => { await seed(2); - expect(isGenericOAuthFailoverEnabled(config(true), "xai")).toBe(true); - expect(isGenericOAuthFailoverEnabled(config(false), "xai")).toBe(false); + // Codex and Anthropic own quota scopes, probe leases and affinity that this must not + // reimplement, so presence does not speak for them. + expect(isGenericOAuthFailoverEnabled(config(), "openai")).toBe(false); expect(isGenericOAuthFailoverEnabled(config(true), "openai")).toBe(false); }); }); @@ -156,16 +204,54 @@ describe("sidecar on429 wiring", () => { expect(oauth).toBeGreaterThan(keyPool); // The OAuth branch is gated on all three of: an account this request actually used, the - // per-request bound, and the knob. Dropping any one of them turns an opt-in feature into a - // default-on one, or lets a short Retry-After spin. + // per-request bound, and the activation predicate. Dropping the bound lets a short + // Retry-After spin; dropping the account binding lets a rotation cool an innocent account. expect(body).toContain("!genericFailoverAccountId"); expect(body).toContain("genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST"); expect(body).toContain("!isGenericOAuthFailoverEnabled(config, route.providerName)"); - // The FULL snapshot, not a bare bearer: Kiro carries routing metadata and Antigravity pairs - // an account-matched projectId with its token, so a token-only swap mixes two accounts. + // The FULL snapshot, not a bare bearer, and applied through the shared helper rather than + // inline. Inlining is what produced the original defect: three sites each swapped `apiKey` + // and only two of them remembered the routing metadata paired with it. expect(body).toContain("failoverAccountSnapshot("); + expect(body).toContain("applyFailoverSnapshot(snapshot)"); + expect(body).not.toContain("apiKey: snapshot.accessToken"); + }); + + test("every rotation site applies the credential through the one shared helper", () => { + // The pairing rules (Copilot's account-scoped origin, Antigravity's account-matched project, + // Kiro's routing metadata) live in exactly one place. A fourth rotation site that swaps the + // bearer by hand would reintroduce the mixed-identity bug this helper exists to prevent. + const snapshotUses = coreSource.match(/failoverAccountSnapshot\(/g) ?? []; + const helperUses = coreSource.match(/applyFailoverSnapshot\(snapshot\)/g) ?? []; + expect(snapshotUses.length).toBe(3); + expect(helperUses.length).toBe(snapshotUses.length); + // The bearer is written in exactly one place — inside the helper. Any other occurrence is a + // rotation site that skipped the pairing rules. + const bearerWrites = coreSource.match(/apiKey: snapshot\.accessToken/g) ?? []; + expect(bearerWrites.length).toBe(1); + const helperStart = coreSource.indexOf("const applyFailoverSnapshot ="); + expect(coreSource.indexOf("apiKey: snapshot.accessToken")).toBeGreaterThan(helperStart); + }); + + test("the helper fails closed rather than pairing a new bearer with an old identity", () => { + const start = coreSource.indexOf("const applyFailoverSnapshot ="); + expect(start).toBeGreaterThan(-1); + const body = coreSource.slice(start, coreSource.indexOf("\n };", start)); + + // Antigravity: a rotated account with no project must abort the rotation, NOT inherit the + // failed account's project. Its refresh path tolerates discovery failure, so this is + // reachable with ordinary stored credentials. + expect(body).toContain("cloud-code-assist"); + expect(body).toContain("!snapshot.projectId"); + + // Copilot: the bearer is pinned to an account-scoped regional origin, so transport is + // re-resolved with the rotated account's own apiBaseUrl. + expect(body).toContain("github-copilot"); + expect(body).toContain("snapshot.apiBaseUrl"); + expect(body).toContain("resolveProviderTransport("); + + // Kiro routing metadata still travels with its own token. expect(body).toContain("_kiroAuthContext"); - expect(body).toContain("snapshot.projectId"); }); }); diff --git a/tests/oauth-upsert-preserves-api-key.test.ts b/tests/oauth-upsert-preserves-api-key.test.ts index a15e4d20fa..67fca06079 100644 --- a/tests/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth-upsert-preserves-api-key.test.ts @@ -60,6 +60,24 @@ describe("upsertOAuthProvider credential preservation", () => { expect(config.providers.xai!.modelCosts).toEqual(costs); }); + test("carries the per-provider account-failover opt-out across a re-login upsert (#2568d)", () => { + // The sequence that makes this load-bearing: an operator switches rotation off, then logs in + // a SECOND account. That login rebuilds this row from the preset and simultaneously creates + // the 2-account quorum that turns presence-driven rotation on — so losing the opt-out here + // enables the exact behaviour the operator declined, during an unrelated action. + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.oauthAccountFailover = { enabled: false }; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.oauthAccountFailover).toEqual({ enabled: false }); + }); + + test("an opt-IN survives too: preservation is about operator intent, not a preferred answer", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + config.providers.xai!.oauthAccountFailover = { enabled: true }; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.oauthAccountFailover).toEqual({ enabled: true }); + }); + test("carries the key over without changing oauth billing when the user did not pick key mode", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.authMode = "oauth"; From 0a0a8821b14d0b8b74d3955577aabcd23f579428 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 10:50:34 +0900 Subject: [PATCH 071/336] fix(management): preserve the account-failover opt-out across a provider overwrite (#2568d) (#2642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/providers replaces a provider row with the submitted payload and carries forward an explicit allowlist. ProviderPayload has no member for oauthAccountFailover, so the dashboard's add/edit form structurally cannot send it — absence means "not carried", never "the user deleted it". The wp7e audit deferred this as a general payload-contract problem. That was wrong. Every other field this path drops fails toward something neutral: a missing modelCosts falls back to registry prices, a missing contextWindow to the seed. Losing oauthAccountFailover does not, because activation is now presence-driven — deleting an operator's "enabled: false" ENABLES rotation across their second subscription account, as a side effect of an edit that had nothing to do with failover. Same failure shape as the login-path loss that already shipped a fix. One preservation line beside the ones for apiKeyPool, modelCosts, requestPacing, and the context-window maps. Deletion still goes through PATCH with an explicit null, as #1409 established. No GUI change: widening ProviderPayload would only give the form a way to send undefined and re-create the problem, which is why modelCosts is handled the same way. Regression sits next to the modelCosts overwrite test and runs against a real server. Falsified by disabling the branch: 74 pass / 1 fail. bun run test: 0 fail. bun x tsc --noEmit: exit 0. --- .../030_post_merge_f5.md | 39 ++++++++++++++++++ src/server/management/provider-routes.ts | 6 +++ tests/management-provider-validation.test.ts | 41 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 devlog/_plan/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md diff --git a/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md new file mode 100644 index 0000000000..b0b406fd02 --- /dev/null +++ b/devlog/_plan/260826_wp7e_presence_driven_oauth_failover/030_post_merge_f5.md @@ -0,0 +1,39 @@ +# 030 — closing F5: the Add Provider path also had to stop dropping the opt-out + +`011_audit_response.md` scoped F5 out with a reason: `/api/providers` POST replaces a provider +row wholesale and already discards every unrecognized key, so it looked like a general payload +contract problem rather than this feature's problem. + +That reasoning was half right and the conclusion was wrong. + +## Why it could not stay deferred + +Every other field this path drops fails toward something neutral — a missing `modelCosts` means +the Usage estimate falls back to registry prices, a missing `contextWindow` means the seed value +applies. Losing `oauthAccountFailover` does not fail toward neutral. Activation is now +presence-driven, so deleting an operator's `enabled: false` does not restore a default: it +**enables** rotation across their second subscription account, as a side effect of an edit that +had nothing to do with failover. + +That is the same failure shape as F4 (login rebuilding the row), which was accepted as blocking. +The two paths differ only in which unrelated action triggers the loss. + +## What changed + +One preservation line in `src/server/management/provider-routes.ts`, next to the ones that +already exist for `apiKeyPool`, `modelCosts`, `requestPacing`, and the context-window maps. The +comment there records why absence means "not carried" rather than "deleted": `ProviderPayload` +(`gui/src/provider-payload.ts`) structurally cannot express the field, so the dashboard's +add/edit form can never send it. Deletion goes through PATCH with an explicit null, exactly as +#1409 established for context windows. + +No GUI change. Widening `ProviderPayload` would be the wrong fix for the same reason it was the +wrong fix for `modelCosts`: the form has no control for this setting, so a payload member would +only give it a way to send `undefined` and re-create the problem. + +## Verification + +A regression next to the existing `modelCosts` overwrite test in +`tests/management-provider-validation.test.ts`, driven against a real server: create a provider +with `enabled: false`, POST an overwrite without the field, assert the opt-out survived. +Falsified by disabling the preservation branch — the test fails, 74 pass / 1 fail. diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 5c107135f8..d542306885 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -596,6 +596,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("provider POST overwrite preserves the account-failover opt-out when the payload omits it (#2568d)", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-failover", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + oauthAccountFailover: { enabled: false }, + }, + }), + }); + expect(create.status).toBe(200); + + // Losing this one is worse than losing a cosmetic field: activation is presence-driven, + // so dropping the opt-out does not fall back to a neutral default — it ENABLES rotation + // across the operator's second subscription account, as a side effect of an edit that had + // nothing to do with failover. + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-failover", + provider: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1" }, + }), + }); + expect(overwrite.status).toBe(200); + expect(loadConfig().providers["custom-failover"]?.oauthAccountFailover).toEqual({ enabled: false }); + } finally { + await server.stop(true); + } + }); + // #1409: the add/edit form's payload type has no member for contextWindow or // modelContextWindows, so an overwrite arrives without them. Registry enrichment then fills // the absent fields from the seed and the stored row loses the user's values — for From 40ad1c74b54d413392b1add8d2bde047fdea26de Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 11:51:15 +0900 Subject: [PATCH 072/336] devlog: quota-window and backlog roadmap (260826) (#2644) Docs-only roadmap cycle for a seven-phase loop. No runtime change. The unit exists because Codex re-introduced the 5-hour rate-limit window for Plus and Team while Pro stays weekly-only, and OpenCodex has two quota parsers that disagree about what a short window means. Proven live rather than inferred: identical upstream data yields {weeklyPercent:97} from the header parser and {shortPercent:97, weeklyPercent:12} from the WHAM parser. The audit is the reason this is worth reading. Three rounds with an independent reviewer turned a one-file plan into a two-file one: fixing parseUpstreamQuotaHeaders alone would have moved routing headroom for a 5h-exhausted account from 0.03 to 0.88, because src/routing/quota.ts omits shortPercent and is currently reading the burst value only by accident through the very bug the fix removes. That finding, and four others, are recorded in 001_audit_response.md along with one partial rebuttal about phase ordering. Phases: 010 header parser + routing fold, 020 Spark hidden by default behind a Codex Auth switch, 030 #2406 CommandCode image capabilities, 040 #1215 noProxy, 050 #1060 billing-period date, 060 evidence-backed closures, 070 backlog triage. --- .../000_plan.md | 89 ++++++++ .../001_audit_response.md | 103 +++++++++ .../010_phase1.md | 208 ++++++++++++++++++ .../020_phase2.md | 135 ++++++++++++ .../030_phase3.md | 91 ++++++++ .../040_phase4.md | 102 +++++++++ .../050_phase5.md | 95 ++++++++ .../060_phase6.md | 50 +++++ .../070_phase7.md | 65 ++++++ 9 files changed, 938 insertions(+) create mode 100644 devlog/_plan/260826_quota_window_and_backlog/000_plan.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/010_phase1.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/020_phase2.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/030_phase3.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/040_phase4.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/050_phase5.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/060_phase6.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/070_phase7.md diff --git a/devlog/_plan/260826_quota_window_and_backlog/000_plan.md b/devlog/_plan/260826_quota_window_and_backlog/000_plan.md new file mode 100644 index 0000000000..6cb8628e9c --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/000_plan.md @@ -0,0 +1,89 @@ +# 000 — quota_window_and_backlog: Plan + +## Objective + +Codex removed the 5-hour rate-limit window some time ago and has now re-introduced it for +**Plus and Team**, while **Pro stays weekly-only**. OpenCodex has two quota parsers and only one +of them learned the lesson. Display and pool routing are both wrong for the affected plans. + +Additionally: hide the Codex Spark window by default behind an operator switch, land three +quick wins, and close the backlog items that are already terminal. + +## The observed failure (proven live, not inferred) + +Running both parsers against the SAME upstream data on `dev` at `0a0a8821b`: + +``` +headers {primary 97% / 300 min, secondary 12% / 10080 min} + parseUpstreamQuotaHeaders -> {"weeklyPercent":97,"weeklyResetAt":...} + parseUsageQuota (WHAM) -> {"shortPercent":97,"shortWindowSeconds":18000,"weeklyPercent":12} +``` + +The header parser has only a monthly-vs-else branch +([quota.ts:344](../../../src/codex/quota.ts)), so **anything that is not explicitly monthly +becomes weekly** — including a 5-hour burst window. The WHAM parser classifies by duration +([quota.ts:205](../../../src/codex/quota.ts), `isExplicitShortWindow`) and gets it right. + +Three consequences, in increasing order of damage: + +1. The genuine weekly reading (12%) is **discarded** — `weeklyPercent` is overwritten by the + burst value before the secondary is ever consulted. +2. A 5h-exhausted account records `weeklyPercent: 100`. `isCodexQuotaExhausted` returns true, + which is the right answer for the wrong reason — and it **stays** true after the 5-hour + window resets, because nothing re-derives it until a WHAM refresh lands. Pool routing keeps + avoiding a healthy account. +3. The GUI shows a weekly bar at 100% and **no 5h bar at all**, so the operator cannot tell + which limit they actually hit. + +The comment directly above the call site records the now-stale premise: +*"primary was the 5h window; it now carries weekly data for GPT plans"* +([core.ts:3777](../../../src/server/responses/core.ts)). That was true while the 5h window was +gone. It is not true now. + +Corroborating evidence that this is a parser gap rather than a missing feature: +`tests/ws-endpoint.test.ts:287` already carries a `"x-codex-primary-window-minutes": "15"` +fixture — a 15-minute window — and nothing in the suite classifies it as short. + +## Loop-spec + +- **Loop archetype:** verifier-defined repair (wp1, wp3-wp5), judged design (wp2), evidence + closure (wp6-wp7). +- **Trigger:** owner report that Codex restored the 5h limit for Plus and Team. +- **Write scope:** `src/codex/quota.ts`, `src/providers/registry.ts`, + `src/providers/quota.ts`, `src/config.ts`, `src/types/`, `gui/`, `docs-site/`, `tests/`, + `devlog/`. +- **Out of scope:** npm publish, tag push, main/preview promotion, security pre-disclosure + notes in devlog, rewriting the WHAM parser (it is correct — the header parser converges on + it, not the other way round). +- **Verifier:** focused `bun test` per phase; `bun run typecheck` + `bun run test` before each + merge; `cd gui && bun test` for GUI phases. +- **Stop condition:** seven work-phases merged to dev, every named issue/PR terminal, dev HEAD + green. +- **Bounds:** commits are `--no-verify`; CI is fixed at the end; admin squash-merge per phase. + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp1 | 010 | Header parser learns the duration rule; Plus/Team get a 5h bar, Pro unchanged | — | +| wp2 | 020 | Spark hidden by default + Codex Auth switch | — | +| wp3 | 030 | #2406 CommandCode image capabilities | — | +| wp4 | 040 | #1215 OpenCodex-scoped noProxy | — | +| wp5 | 050 | #1060 subscription billing-period date | — | +| wp6 | 060 | Evidence-backed closures (#2442 #2423 #2060, PR #1769 #2215) | — | +| wp7 | 070 | Backlog triage devlog | wp6 (records what wp6 closed) | + +wp1 is sequenced before wp2 for review clarity, not as a data dependency (audit finding 7): +both touch the quota display contract, and hiding one row is easier to review once the +neighbouring 5h/weekly rows are correct. Neither consumes the other's output. wp3-wp5 are independent and could run in any +order; they are sequenced by ascending blast radius. wp7 is last because it records wp6's +outcome. + +## Accept criteria + +Mirrored into the goalplan `criteria[]` — see `.codexclaw/goalplans/opencodex-quota-window-backlog-cleanup-loop-2608/goalplan.json`. + +The load-bearing one is wp1's: **given identical upstream data, the two parsers must agree**. +That is a property, not an example, and it is the assertion that would have caught this defect +when the 5h window first disappeared. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md b/devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md new file mode 100644 index 0000000000..04580c0b9a --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md @@ -0,0 +1,103 @@ +# 001 — audit response: the roadmap was locally right and globally incomplete + +An independent read-only auditor returned **FAIL** with five blocking findings. I verified each +against the tree. **All five hold.** One of them is the kind that turns a fix into a regression, +so it is worth stating plainly rather than burying in a table. + +## B1 — the parser fix alone would have BROKEN routing. ACCEPTED, and it is the important one. + +`src/routing/quota.ts:37` computes routing-profile headroom from `weeklyPercent` and +`monthlyPercent` — and **not** `shortPercent`. That omission is invisible today precisely +because the header parser is broken: the 5h value is being written into `weeklyPercent`, so +routing accidentally sees it. + +Measured on the live module: + +``` +headroom BEFORE the wp1 fix : 0.03 (reads 97% used — accidentally correct) +headroom AFTER the wp1 fix : 0.88 (reads 12% used — WRONG, burst is at 97%) +``` + +Fixing the parser without fixing `routing/quota.ts` would take a 5-hour-exhausted account from +"3% headroom" to "88% headroom" and route traffic straight into a 429. The bug is currently +cancelling itself out, and the roadmap's "only the parser changes" claim would have removed one +half of the cancellation. + +Note the asymmetry that made this easy to miss: `computeCodexUsageScore` in +`src/codex/routing.ts:339` **does** fold in `shortPercent`, and that is the file I read. +`src/routing/quota.ts` is a different module with a similar name and a different rule. + +**Fold:** wp1 gains `src/routing/quota.ts` — add `shortPercent` to the percent set and +`shortResetAt` to the reset set — plus a regression asserting headroom stays low when only the +burst window is exhausted. + +## B2 — wp2's server-filter rationale named the wrong surface. ACCEPTED with a correction. + +I justified server-side filtering by claiming `maxQuotaUtilisation` would reorder Codex account +cards. The auditor checked: `maxQuotaUtilisation` sorts the **Providers overview** +(`ProviderOverviewDashboard.tsx:66`), not the Codex Auth cards. My stated reason was wrong. + +The conclusion survives on a better reason. Spark reaches the GUI through **two** independent +projections — `/api/codex-auth/accounts` and `/api/provider-quotas` (the latter via +`listCodexAuthAccountsSnapshot`, `providers/quota.ts:1129`). Filtering one leaves the other +showing the row the operator switched off. + +**Fold:** wp2 filters at a shared projection covering both surfaces, and tests both. The +label-exact requirement stands and is now better supported: `customWindows` carries Cursor's +`First-party models`/`API usage`, Anthropic's `Fable`/`Opus`/`Sonnet`, Antigravity's +`Gem`/`Cla`, Kimi's `Total subscription credits` and a dozen dynamic provider labels. A +"drop custom windows" filter would blank all of them. + +## B3 — wp4's own dedupe criterion would have failed. ACCEPTED. + +`applyProxyEnv` builds `seen` from **lowercased** entries (`config.ts:3122`) but my proposed +loop pushed configured entries without normalizing, so a configured `LOCALHOST` would be +followed by `localhost`. The plan's own accept-criteria row would have failed the plan's own +code. + +Also accepted: #1215 asks for `string[]`; I specified a comma-separated `string` without +recording the deviation. **Decision, now recorded:** accept `string | string[]` and normalize. +The string form matches `NO_PROXY` syntax the operator already knows and matches the sibling +`proxy` field; the array form is what the issue asked for and is unambiguous about separators. +Supporting both costs one `Array.isArray` branch. + +## B4 — wp5 was written against a UI that does not exist. ACCEPTED. + +I claimed the GUI "drops `expiresAt`". It drops the **entire `creditsUsd` object** +(`report.ts:42`), and `AccountQuota` has no credits contract at all. There is no credits +figure to render a date beneath, and `gui/tests/provider-report.test.ts` — which I cited as the +test location — does not exist. + +**Fold:** wp5 projects the whole typed `creditsUsd` shape and creates the presentation, which +makes it the largest of the three quick wins rather than the smallest. Label corrected to +**"Billing period ends"**: the source is `subscription.currentPeriodEnd`, and "Renews" asserts +a continuation the field does not promise. + +## B5 — acceptance evidence gaps. ACCEPTED. + +- The wp1 "property" test was three fixed examples. Add the **24-hour boundary**: 1439 minutes + is short, 1440 is not — strict `<` in both predicates, verified at `quota.ts:211`. +- wp3's negative table used short names; upstream ids are `deepseek/deepseek-v4-flash`, + `deepseek/deepseek-v4-pro`, `zai-org/GLM-5.2`, `zai-org/GLM-5.3`, `xai/grok-4.6`. A + shortened name asserts absence of something that was never present — vacuously green. +- Goalplan criteria carry no `expectedEvidence` and no work-phase mapping. Fill both. + +## B6 (finding 7) — phase ordering. PARTIALLY REBUTTED. + +The auditor is right that wp1→wp2 is not a data dependency and that wp3-wp5 are independent. +I accept the correction and have removed the dependency claim from wp1→wp2. + +Where I do not fully agree: PHASE-SPLIT-01 forbids ordering by **effort or payoff speed**, not +ordering independent slices at all. wp3-wp5 have no edges between them, so *some* order must be +chosen; ascending blast radius (static data → config plumbing → GUI surface) is a risk ordering, +not a quick-win-first ordering. B4 makes this concrete: wp5 turned out to be the largest of the +three, and it stays last — an effort ordering would now move it first. + +## Net effect + +Five folds, one partial rebuttal. The scope grows in two places that matter: wp1 gains a second +file without which it would regress routing, and wp5 roughly doubles. The roadmap docs are +amended in place; this document records why. + +VERDICT accepted: **near-pass with five folded blockers**. Proceeding to B. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/010_phase1.md b/devlog/_plan/260826_quota_window_and_backlog/010_phase1.md new file mode 100644 index 0000000000..d4132c910d --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/010_phase1.md @@ -0,0 +1,208 @@ +# 010 — wp1: the header quota parser learns the duration rule + +## The defect in one line + +`parseUpstreamQuotaHeaders` branches on "explicitly monthly, or else weekly". There is no +third branch, so a 5-hour primary window is recorded as the weekly reading. + +## MODIFY map + +### `src/codex/quota.ts` + +**1. Add a minutes-domain short-window predicate next to the existing monthly one** + +The seconds-domain predicate already exists (`isExplicitShortWindow`, line ~205) and the +minutes-domain monthly predicate already exists (`isExplicitMonthlyWindowMinutes`, line ~221). +The missing piece is the minutes-domain SHORT predicate. Both share one numeric parse, so +factor that out rather than writing the coercion twice. + +```ts +/** Minutes-domain twin of isExplicitShortWindow: the header wire reports minutes, not seconds. */ +function windowMinutes(value: unknown): number | undefined { + const minutes = typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : undefined; + return typeof minutes === "number" && Number.isFinite(minutes) ? minutes : undefined; +} + +function isExplicitShortWindowMinutes(value: unknown): boolean { + const minutes = windowMinutes(value); + return minutes !== undefined && minutes > 0 && minutes < WEEKLY_WINDOW_MIN_MINUTES; +} +``` + +`WEEKLY_WINDOW_MIN_MINUTES` is NEW and mirrors the existing monthly constant: + +```ts +const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60; // exists, line ~96 +const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; // exists, line ~97 +const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60; // NEW — 1440 +``` + +Deriving it from the seconds constant is deliberate: the two parsers must not be able to drift +to different thresholds, which is exactly the class of bug this phase is fixing. + +**2. Give the parser its third branch** + +Before (line ~344): + +```ts +const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes); + +if (primaryIsMonthly) { + ... +} else { + const weeklyPercent = primaryPercent ?? secondaryPercent; + ... +} +``` + +After: + +```ts +const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes); +// Codex restored the 5-hour window for Plus and Team (Pro stays weekly-only). A primary window +// that DECLARES a sub-day duration is a burst window, and folding it into weeklyPercent both +// discards the real weekly reading and leaves the account looking exhausted after the burst +// window resets. Duration decides, exactly as the WHAM parser already does. +const primaryIsShort = primaryRaw !== null && isExplicitShortWindowMinutes(primaryWindowMinutes); + +if (primaryIsMonthly) { + // ... unchanged ... +} else if (primaryIsShort) { + if (primaryPercent !== undefined) { + quota.shortPercent = primaryPercent; + if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt; + const minutes = windowMinutes(primaryWindowMinutes); + if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60); + } + // The burst window vacates the primary slot, so the weekly reading is the secondary — which + // is where it actually was all along. + if (secondaryPercent !== undefined) { + quota.weeklyPercent = secondaryPercent; + if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt; + } +} else { + // ... unchanged: primary-or-secondary weekly ... +} +``` + +**3. Do NOT touch** the tertiary handling below it, `isCodexQuotaExhausted`, +`computeCodexUsageScore`, or the WHAM parser. They already read `shortPercent` correctly +([quota.ts:117](../../../src/codex/quota.ts), [routing.ts:339](../../../src/codex/routing.ts)); +this phase only makes the header path produce the field they are already waiting for. + +**4. Update the stale premise comment** at +[core.ts:3777](../../../src/server/responses/core.ts): "primary was the 5h window; it now +carries weekly data for GPT plans" is false again. Replace with a note that the slot is +duration-classified and the plan does not decide. + +### \`src/routing/quota.ts\` — the fold that stops this fix becoming a regression (audit B1) + +\`codexAccountQuotaEvidence\` (line ~37) computes routing headroom from \`weeklyPercent\` and +\`monthlyPercent\` only. That omission is invisible TODAY because the broken parser writes the +5h value into \`weeklyPercent\` — routing sees the burst by accident. Measured live: + +\`\`\` +headroom BEFORE the parser fix : 0.03 (reads 97% used — accidentally correct) +headroom AFTER the parser fix : 0.88 (reads 12% used — WRONG, burst is at 97%) +\`\`\` + +Fixing the parser alone would route traffic into a 429. Add \`shortPercent\` to the percent set +and \`shortResetAt\` to the reset set: + +\`\`\`ts +const percents = [ + ...(monthly ? [] : [quota.weeklyPercent]), + quota.monthlyPercent, + // The burst window is upstream-enforced independently of the governing window, so an account + // at 97% here has 3% headroom regardless of its weekly figure. computeCodexUsageScore already + // folds it in (codex/routing.ts:339); this module must not disagree. + quota.shortPercent, +].filter(...) +\`\`\` + +Same treatment for \`resets\` with \`quota.shortResetAt\`, so a burst-limited account reports the +burst reset rather than a distant weekly one. + +### Nothing else changes — beyond the two files above + +- `setAccountQuotaFromParsed` already merges `short*` fields (line ~318, `snapshotHasShort`). +- `updateAccountQuota` already preserves them (line ~413). +- The DTO already returns `shortPercent` ([auth-api.ts:212](../../../src/codex/auth-api.ts)). +- The GUI already aliases it to `fiveHourPercent` and renders the bar + ([codex-quota-utils.ts:27](../../../gui/src/codex-quota-utils.ts), + [QuotaBars.tsx:45](../../../gui/src/components/QuotaBars.tsx)). + +Storage and display were already correct: one parser was filling the wrong pipe, and one +routing consumer was reading that wrong pipe. **This phase changes two runtime files** — +`src/codex/quota.ts` and `src/routing/quota.ts` — and they must land together. Shipping the +parser alone converts a display bug into a routing bug (audit B1). + +## TESTS + +### `tests/rate-limit-reset-credits.test.ts` (extend — it owns the header-parser cases) + +| Case | Input | Expected | +|---|---|---| +| Plus/Team 5h primary + weekly secondary | primary 97% / 300 min, secondary 12% / 10080 min | `{shortPercent:97, shortWindowSeconds:18000, weeklyPercent:12}` | +| 5h exhausted does not poison weekly | primary 100% / 300 min, secondary 8% / 10080 min | `weeklyPercent === 8`, `shortPercent === 100` | +| Pro weekly-only unchanged | primary 80% / 10080 min | `{weeklyPercent:80}`, no `shortPercent` | +| Monthly primary unchanged | primary 100% / 43800 min | existing expectation holds verbatim | +| Reset instant travels with its window | primary 300 min + reset | `shortResetAt` set, `weeklyResetAt` NOT set from the primary | +| Absent window-minutes header | primary 80%, no minutes header | weekly (unchanged legacy behaviour) | +| 24h boundary, below | primary 60% / 1439 min | `shortPercent` — strict `<` (audit B5) | +| 24h boundary, at | primary 60% / 1440 min | `weeklyPercent` — exactly a day is NOT short | +| Routing headroom holds | `{shortPercent:97, weeklyPercent:12}` | `codexAccountQuotaEvidence` headroom <= 0.05, not 0.88 | + +The last row is load-bearing: an upstream that omits the duration header must keep behaving +exactly as it does today. Duration-classification is opt-in on the presence of a declared +duration, never a guess. + +### `tests/codex-quota-parser-parity.test.ts` (NEW) + +The property assertion, and the one that would have caught this defect the first time: + +```ts +// Given the SAME upstream reading expressed both ways, the two parsers must agree on which +// window each number belongs to. Any future change that teaches one parser a rule the other +// does not know fails here. +for (const c of [ + { minutes: 300, seconds: 18000, primary: 97, secondary: 12 }, // Plus/Team 5h + { minutes: 10080, seconds: 604800, primary: 80, secondary: undefined }, // Pro weekly + { minutes: 43800, seconds: 2628000, primary: 100, secondary: 22 }, // monthly plan +]) { ... expect header-derived window assignment to equal WHAM-derived ... } +``` + +Compare the WINDOW ASSIGNMENT (which field each percent lands in), not raw object equality — +the WHAM parser also emits `monthlyIsPrimaryWindow` and Spark custom windows that the header +wire does not carry. + +### Falsification (mandatory before trusting either) + +Revert the `primaryIsShort` branch and confirm both new suites go red. A parity test that +passes against the broken parser is worthless. + +## Verification (C) + +```bash +bun test tests/rate-limit-reset-credits.test.ts tests/codex-quota-parser-parity.test.ts \ + tests/ws-endpoint.test.ts tests/codex-routing.test.ts +bun x tsc --noEmit # exit 0 +bun run test # 0 fail — quota.ts is shared runtime +``` + +## Activation scenario (C-ACTIVATION-GROUNDING-01) + +The new branch is a conditional, so C must prove it ARMS rather than merely compiles: +feeding `x-codex-primary-window-minutes: 300` must move the 97 from `weeklyPercent` to +`shortPercent` **and** let 12 reach `weeklyPercent`. Observing only "shortPercent is set" +would pass even if the secondary were still being dropped. + +## Out of scope for wp1 + +The Spark row and the GUI switch are wp2. Capacity weights (`plus:1`) are untouched: a new +window is not evidence that the pooled display ratio is wrong, and that ratio is display-only +([codex-capacity.ts:166](../../../src/providers/codex-capacity.ts)). diff --git a/devlog/_plan/260826_quota_window_and_backlog/020_phase2.md b/devlog/_plan/260826_quota_window_and_backlog/020_phase2.md new file mode 100644 index 0000000000..6e251f1510 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/020_phase2.md @@ -0,0 +1,135 @@ +# 020 — wp2: Codex Spark hidden by default, behind a Codex Auth switch + +## What the operator sees today + +Every account card on the Codex Auth page carries a second bar labelled +`GPT-5.3-Codex-Spark Weekly`, and in the owner's live dashboard all four accounts show it at +0%. It is emitted unconditionally by the WHAM parser +([quota.ts:611](../../../src/codex/quota.ts)) whenever `additional_rate_limits` contains the +`codex_bengalfox` feature. + +Owner decision: **Spark is not shown by default.** It is a niche model window, it is 0% for +most operators, and on a four-account pool it doubles the row count for information almost +nobody is acting on. The switch exists because "not by default" is not the same as "never". + +## Design direction (cxc-dev-uiux-design) + +Three placements were considered against the existing page: + +| Option | Verdict | +|---|---| +| Per-account toggle on each card | Rejected — the setting is about a *window kind*, not an account. Four toggles that must agree is a state-sync bug waiting to happen. | +| Advanced settings drawer | Rejected — the drawer already exists at the page foot, but burying a display toggle there means the operator who wants Spark back cannot find why it vanished. | +| **Page-header control row, beside Pause exhausted / Refresh quotas** | **Chosen.** | + +The header row is where the page already keeps its *view-and-pool-wide* actions. A Spark +toggle is exactly that: it changes what every card renders, and it belongs next to the other +control that operates on all cards at once. + +Presentation follows the existing header controls rather than introducing a new visual +vocabulary: same pill height, same border treatment, label + switch. It reads +`Codex Spark quota` with an on/off switch, not a bare unlabelled toggle — an unlabelled +switch in a header is a guessing game. + +Copy: `codexAuth.showSparkQuota` = "Codex Spark quota", with a title/tooltip explaining that +the window only applies to GPT-5.3-Codex-Spark and is hidden by default. Localized across all +nine locales; `Codex Spark` stays untranslated as a product name (same treatment the +intentional-English allowlist already gives product nouns). + +## MODIFY / NEW map + +### Server: the setting must persist + +**`src/types/config.ts`** — extend the existing GUI-preferences area with: + +```ts +/** + * Show the GPT-5.3-Codex-Spark weekly window on Codex account cards. Default false: the + * window applies to one model, reads 0% for most operators, and doubles the bar count on a + * multi-account pool. + */ +showCodexSparkQuota?: boolean; +``` + +**`src/server/management/*-routes.ts`** — read/write through the existing settings surface that +the Codex Auth page already talks to. Follow the surrounding preservation discipline: an +unrelated save must not drop it (the `oauthAccountFailover` lesson from #2568d). + +### Where the row is suppressed — server, both surfaces (audit B2) + +The Spark window is dropped from the **API projection**, not hidden with CSS: anything the +client does not render, it should not receive. + +An earlier draft justified this by claiming `maxQuotaUtilisation` would reorder Codex account +cards. That was wrong — it sorts the **Providers overview** +([ProviderOverviewDashboard.tsx:66](../../../gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx)), +not the account cards. The real reason is worse for a naive fix: + +**Spark reaches the GUI through TWO independent projections.** `/api/codex-auth/accounts` +builds its rows through `quotaForPlan` ([auth-api.ts:212](../../../src/codex/auth-api.ts)), and +`/api/provider-quotas` reaches the same data through `listCodexAuthAccountsSnapshot` +([providers/quota.ts:1129](../../../src/providers/quota.ts)). Filtering one leaves the other +still rendering the row the operator just switched off. + +So the filter lands in a shared projection consumed by both, keyed on the exact raw label. + +**Label-exact is not a nicety.** `customWindows` is the generic carrier for Cursor +(`First-party models`, `API usage`), Anthropic (`Fable`/`Opus`/`Sonnet`), Antigravity +(`Gem`/`Cla`), Kimi (`Total subscription credits`) and a dozen dynamic provider labels. A +filter written as "drop custom windows" blanks every one of them. + + +### Client + +**`gui/src/components/CodexAccountPool.tsx`** — header control + optimistic state, following +the existing `refreshQuotas` / `pauseExhausted` handler shape. + +**i18n** — `codexAuth.showSparkQuota` + tooltip in all nine locale files. Note the parser +landmine fixed in #2640: these catalogs pack several entries per line, so a new key must be +added in the same shape or the parity test's regex sees only the first entry per line. + +## TESTS + +| Layer | Case | File | +|---|---|---| +| Server | default (setting absent) → no Spark window on `/api/codex-auth/accounts` | `tests/codex-auth-api.test.ts` | +| Server | setting true → Spark window present, unchanged | same | +| Server | setting survives an unrelated settings save | same | +| Server | **default → no Spark window on `/api/provider-quotas`** (audit B2 round 2) | `tests/provider-quota.test.ts` | +| Server | non-Spark custom windows are NEVER filtered — Cursor, Anthropic, Antigravity, Kimi | `tests/provider-quota.test.ts` | +| GUI | `buildQuotaRows` renders a Spark row when given one | `gui/tests/quota-bars-rows.test.ts` | +| GUI | locale parity holds after the new keys | existing parity suites | + +The fourth row is the one that matters most. Cursor's `First-party models` / `API usage` +windows travel through the same `customWindows` array +([QuotaBars.tsx:86](../../../gui/src/components/QuotaBars.tsx)); a filter written as "drop +custom windows" instead of "drop the Spark label" would silently blank the Cursor provider +card. Pin it by label. + +## Verification (C) + +```bash +bun test tests/codex-auth-api.test.ts +cd gui && bun test # 994+ pass, 0 fail +bun run typecheck # exit 0 +``` + +Plus a **rendered screenshot of both states** — Spark hidden (default) and Spark shown after +flipping the switch — captured against a dev build via agbrowse. The PR gate requires a GUI +screenshot, and a claim that a row is hidden is only credible when the absence is visible. + +## Activation scenario + +Default-off must be proven by ABSENCE with the data present: the WHAM payload still carries +`codex_bengalfox`, the parser still writes the custom window, and the DTO still omits it. A +test that simply omits Spark from the fixture proves nothing. + +## Dependency + +Runs after wp1: both phases touch the quota display contract, and hiding one row is easier to +review once the neighbouring 5h/weekly rows are correct. +**Owner of the shared projection.** Both surfaces already funnel through `quotaForPlan` +([auth-api.ts:212](../../../src/codex/auth-api.ts)) — the Codex Auth rows directly, and +`/api/provider-quotas` via `listCodexAuthAccountsSnapshot` +([providers/quota.ts:1129](../../../src/providers/quota.ts)). `quotaForPlan` is therefore the +single filtering point, and B is done only when BOTH test targets above are green. diff --git a/devlog/_plan/260826_quota_window_and_backlog/030_phase3.md b/devlog/_plan/260826_quota_window_and_backlog/030_phase3.md new file mode 100644 index 0000000000..a3c0ed80ad --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/030_phase3.md @@ -0,0 +1,91 @@ +# 030 — wp3: #2406 CommandCode image capabilities + +## The gap + +CommandCode's registry entries declare `modelInputModalities` for exactly two ids — +`stealth/ox-alpha` and the DeepSeek vision preview — in BOTH the OAuth preset +([registry.ts:1183](../../../src/providers/registry.ts)) and the API-key preset +([registry.ts:1925](../../../src/providers/registry.ts)). Every other CommandCode model is +therefore text-only in the generated Codex catalog. + +This is not cosmetic. Combo capability intersection treats an absent declaration as text-only, +so a single unmarked target disables image input for the entire combo. + +## What the reporter proved, and what they disproved + +The issue carries end-to-end image probes, and it is careful in both directions. Image input +**succeeded** for: + +`gpt-5.6-luna`, `gpt-5.6-sol`, `MiniMaxAI/MiniMax-M3`, `moonshotai/Kimi-K3`, +`meta/muse-spark-1.2`, `meta/muse-spark-1.2-contributor`, `openai/ox-alpha`, +`deepseek/deepseek-v4-flash-vision-exp` + +Image input did **not** deliver for `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, +`zai-org/GLM-5.2`, `zai-org/GLM-5.3`, `xai/grok-4.6`. + +**The negative list is as load-bearing as the positive one.** Marking a route image-capable +that silently drops the image is worse than leaving it text-only: the request succeeds, the +model answers about an image it never received, and nothing surfaces an error. This phase adds +only the verified-positive ids. + +Note `openai/ox-alpha` versus the already-present `stealth/ox-alpha` — the catalog serves the +same model under two ids and only one carries the declaration. + +## MODIFY map + +### `src/providers/registry.ts` — both presets, identical content + +Extract the shared map to a named constant rather than duplicating a growing literal twice. +The current two-entry duplication is already a drift hazard; at ten entries it is a certainty. + +```ts +/** + * CommandCode routes verified to accept image input end-to-end (#2406). + * + * Verified-negative and therefore deliberately ABSENT: deepseek-v4-flash, deepseek-v4-pro, + * GLM-5.2, GLM-5.3, grok-4.6. Those routes accept the request and drop the image, which is + * worse than declining it — the model answers about an image it never saw. Do not add an id + * here on family resemblance; capability intersection trusts this map. + */ +const COMMAND_CODE_IMAGE_MODELS = [ + "stealth/ox-alpha", + "openai/ox-alpha", + `deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`, + "gpt-5.6-luna", + "gpt-5.6-sol", + "MiniMaxAI/MiniMax-M3", + "moonshotai/Kimi-K3", + "meta/muse-spark-1.2", + "meta/muse-spark-1.2-contributor", +] as const; + +const COMMAND_CODE_MODEL_INPUT_MODALITIES: Record = + Object.fromEntries(COMMAND_CODE_IMAGE_MODELS.map(id => [id, ["text", "image"]])); +``` + +Both presets then reference `COMMAND_CODE_MODEL_INPUT_MODALITIES`, replacing their inline +literals. Placement: next to `COMMAND_CODE_MODEL_REASONING_EFFORTS`, which solves the same +shared-facts problem the same way. + +## TESTS — `tests/command-code-provider.test.ts` + +| Case | Assertion | +|---|---| +| Every verified id is image-capable | each id in the positive list resolves to `["text","image"]` in BOTH presets | +| Verified-negative ids stay text-only | full upstream ids only (audit B5): `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, `zai-org/GLM-5.2`, `zai-org/GLM-5.3`, `xai/grok-4.6` carry no image modality | +| Preset parity | OAuth and API-key modality maps are deeply equal | + +The parity assertion is what makes the shared constant enforceable rather than merely tidy. + +## Verification (C) + +```bash +bun test tests/command-code-provider.test.ts +bun x tsc --noEmit +``` + +Registry facts are static data, so the focused suite plus typecheck is proportionate; a +repo-wide run is not required for a data-only change (AGENTS.md scoped-check rule). + +Closes #2406. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/040_phase4.md b/devlog/_plan/260826_quota_window_and_backlog/040_phase4.md new file mode 100644 index 0000000000..08a6864bd5 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/040_phase4.md @@ -0,0 +1,102 @@ +# 040 — wp4: #1215 OpenCodex-scoped noProxy + +## The gap + +`applyProxyEnv` ([config.ts:3116](../../../src/config.ts)) already does the hard part: it +merges the inherited `NO_PROXY`/`no_proxy` with the loopback hosts, deduplicating +case-insensitively. What it lacks is a way for the operator to add their own entries WITHOUT +setting a machine-wide environment variable. + +The reporter's case: internal hosts that must bypass the corporate proxy, on a machine where +`NO_PROXY` is owned by another tool. + +## MODIFY map + +### `src/types/config.ts` — beside the existing `proxy` field (~line 501) + +```ts +/** + * Hosts that bypass `proxy` for OpenCodex's own outbound provider calls, merged into + * NO_PROXY at startup. Accepts a comma-separated string (NO_PROXY syntax) or an array. + * Loopback is always excluded regardless of this setting, and an inherited NO_PROXY is + * preserved — this ADDS entries, it never replaces the environment. + */ +noProxy?: string | string[]; +``` + +**`string | string[]`, recorded as a deviation (audit B3).** #1215 asks for `string[]`. +The sibling `proxy` field is a `string` and `NO_PROXY` syntax is comma-separated, so an +operator will reach for the string form by muscle memory. Both are accepted and normalized +identically: the array costs one `Array.isArray` branch and removes any ambiguity about +separators appearing inside a value. + +### `src/config.ts` — inside `applyProxyEnv` + +The merge loop already exists; the change is the source list it walks and one normalization. + +Before: + +```ts +for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) { + if (!seen.has(host)) { + entries.push(host); + seen.add(host); + } +} +``` + +After: + +```ts +// Configured entries first, then loopback: loopback is unconditional, so appending it last +// keeps it present even when the operator lists a loopback host themselves. +const raw = config.noProxy; +const configured = (Array.isArray(raw) ? raw : (resolveEnvValue(raw) ?? "").split(",")) + .map(entry => entry.trim()) + .filter(Boolean); +for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) { + const key = host.toLowerCase(); + if (!seen.has(key)) { + entries.push(host); + seen.add(key); + } +} +``` + +**The `toLowerCase()` is audit finding B3.** `seen` is built from lowercased entries +([config.ts:3122](../../../src/config.ts)), but the original draft pushed configured entries +without normalizing — so a configured `LOCALHOST` would have been followed by `localhost`, +failing this phase's own dedupe criterion. The pushed VALUE keeps the operator's casing; only +the lookup key is normalized. + +`resolveEnvValue` gives the string form the same `${VAR}` indirection `proxy` already +supports — they are a pair and should not diverge. + +**Early-return trap.** `applyProxyEnv` returns immediately when `config.proxy` is unset +(line ~3118). That is correct and stays: `noProxy` without a proxy is meaningless, and +writing `NO_PROXY` for a process that proxies nothing would leak OpenCodex config into +unrelated child processes. Pin it by test rather than leaving it to be "fixed" later. + +## TESTS — `tests/proxy-env.test.ts` + +| Case | Assertion | +|---|---| +| Configured string reaches `NO_PROXY` | `"internal.example,10.0.0.0/8"` both present | +| Configured array reaches `NO_PROXY` | `["internal.example","10.0.0.0/8"]` equivalent to the string form | +| Loopback survives | all four loopback forms still present | +| Inherited `NO_PROXY` preserved | a pre-set value is merged, not replaced | +| Case-insensitive dedupe | configured `"LOCALHOST"` produces no duplicate (audit B3) | +| `${VAR}` indirection | env-referenced string value resolves | +| No proxy configured | `NO_PROXY` untouched — the early return holds | + +## Verification (C) + +```bash +bun test tests/proxy-env.test.ts +bun x tsc --noEmit +``` + +Docs: add `noProxy` to the configuration reference next to `proxy`, showing both forms. + +Closes #1215. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/050_phase5.md b/devlog/_plan/260826_quota_window_and_backlog/050_phase5.md new file mode 100644 index 0000000000..6ee007928f --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/050_phase5.md @@ -0,0 +1,95 @@ +# 050 — wp5: #1060 subscription billing-period date + +## The gap, restated after audit B4 + +The original draft said the GUI "drops `expiresAt`". It is worse than that: the GUI drops the +**entire `creditsUsd` object** ([report.ts:42](../../../gui/src/provider-workspace/report.ts)), +and `AccountQuota` has no credits contract at all +([codex-quota-utils.ts:1](../../../gui/src/codex-quota-utils.ts)). + +So there is no existing credits figure to hang a date beneath. This phase creates the credits +presentation and then dates it — which makes it the LARGEST of the three quick wins, not the +smallest. It stays last in the phase order for exactly that reason. + +## What the backend already has + +CommandCode's probe resolves `expiresAt` from `subscription.currentPeriodEnd` +([quota.ts:1824](../../../src/providers/quota.ts)) and attaches it to `creditsUsd` +(line ~1849). The type declares the full shape +([quota.ts:83](../../../src/providers/quota.ts)): + +```ts +export interface ProviderQuotaCreditsUsd { + used: number; limit: number; remaining: number; percent: number; + expiresAt?: number; unlimited?: boolean; +} +``` + +## The condition worth reading carefully + +```ts +...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}) +``` + +`expiresAt` is emitted **only when no credits were separately purchased**. With a purchase, +the subscription period end no longer describes when the displayed balance resets, so the field +is withheld rather than shown misleadingly. The GUI must therefore treat absence as normal and +render nothing — not "unknown", not an em-dash placeholder. + +## Label: "Billing period ends", not "Renews" (audit B4) + +The source field is `currentPeriodEnd`. "Renews" asserts a continuation the field does not +promise — a cancelled subscription has a period end and no renewal. The existing quota bars say +"resets"; using that word here would imply the credit balance and the usage windows share a +clock, which they do not. + +## MODIFY map + +### `gui/src/provider-workspace/report.ts` + +Project the whole typed `creditsUsd` object, not one field. `quotaFromUnknown` narrows +unknown wire data field by field; `creditsUsd` gets the same treatment with the existing +`finite()` guard on each numeric member and `expiresAt` optional. + +### `gui/src/codex-quota-utils.ts` + +`AccountQuota` gains the optional credits member so the projection has somewhere to land. + +### `gui/src/components/provider-workspace/ProviderCapacityQuota.tsx` + +New credits presentation: the balance figure, plus the billing-period line when `expiresAt` +is present. Locale-aware date formatting via the `bcp47` helper the quota surface already +uses ([QuotaBars.tsx](../../../gui/src/components/QuotaBars.tsx)). + +### i18n + +`quota.creditsBalance` and `quota.creditsPeriodEnds` = "Billing period ends {date}" in all +nine locales. Catalogs pack several entries per line — match the surrounding shape. + +## TESTS + +| Layer | Case | File | +|---|---|---| +| GUI | full `creditsUsd` survives the projection | `gui/tests/provider-workspace-state.test.ts` | +| GUI | malformed members are dropped, not propagated as NaN | same | +| GUI | absent `expiresAt` renders no period line | component test | +| GUI | present `expiresAt` renders a localized date | component test | +| GUI | locale parity after the new keys | existing parity suites | + +Audit B4 flagged that the originally named `gui/tests/provider-report.test.ts` does not +exist, and that a projection test cannot prove rendered absence. The projection cases go in the +existing `provider-workspace-state.test.ts`; the two render cases need a real component +test. + +## Verification (C) + +```bash +cd gui && bun test +bun x tsc --noEmit +``` + +Plus a rendered screenshot of the Providers workspace showing the credits line with its billing +period — the PR gate requires one for GUI changes. + +Closes #1060. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/060_phase6.md b/devlog/_plan/260826_quota_window_and_backlog/060_phase6.md new file mode 100644 index 0000000000..54d2c8d734 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/060_phase6.md @@ -0,0 +1,50 @@ +# 060 — wp6: evidence-backed closures + +Five items are already terminal on `dev`. Each gets a comment citing the specific code or +commit that makes it so, then closes. No code changes in this phase. + +## Issues + +**#2442 — OpenCode Go rejects `search_content_types`.** The adapter already strips that field +for Muse Spark plain `web_search` +([openai-responses.ts:1587](../../../src/adapters/openai-responses.ts)), with positive and +isolation coverage in `tests/muse-spark-web-search-compat.test.ts:38`. + +**#2423 — OpenRouter Ox Alpha HTTP 200 with an empty completion.** A terminal-less pre-output +EOF now raises a retryable empty-completion error +([empty-completion-guard.ts:309](../../../src/server/responses/empty-completion-guard.ts)), +pinned by `tests/empty-completion-guard.test.ts:326`. + +**#2060 — OpenCode Go account-pool round-robin.** Closing with an explicit adjudication rather +than a claim of equivalence, because the two are not the same thing. + +The request was continuous per-request round-robin. What ships is 429-driven failover: +`hasKeyPoolFailover` + `rotateProviderTransportOn429` +([key-failover.ts:82](../../../src/providers/key-failover.ts)) rotate to the next non-cooled +key on upstream 429 and replay the same request. + +Owner decision: **429 failover is the correct default**, and continuous round-robin belongs to +the pool feature rather than to the provider path. Rotating every request would shred prompt +caching and spread thread affinity across keys for no benefit while every key is healthy. The +comment states this as a decision, not as "already implemented" — the reporter asked for +something real and deserves to know it was considered and declined. + +## Pull requests + +**#1769 — manual paste fallback for OAuth add-account.** Superseded by `74e8ce557`, which +landed the manual redirect/code paste fallback BEFORE this PR opened. Current `dev` waits for +and validates pasted input including attempt-state matching +([oauth/index.ts:1338](../../../src/oauth/index.ts)) and exposes the management endpoint +([oauth-account-routes.ts:206](../../../src/server/management/oauth-account-routes.ts)). + +**#2215 — document V2 fork override rule.** Superseded by `7fdb2cb8e`, which documents that +full-history V2 forks inherit the parent model and that model/effort overrides need partial or +no history ([sub-agent-surface.md:68](../../../docs-site/src/content/docs/guides/sub-agent-surface.md) +and :222). + +## Verification (C) + +`gh issue view ` / `gh pr view ` reporting `CLOSED` for all five, each with its comment +posted. Contributor-facing courtesy: name the commit, not just the conclusion — a superseded +author should be able to see their work was checked rather than dismissed. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/070_phase7.md b/devlog/_plan/260826_quota_window_and_backlog/070_phase7.md new file mode 100644 index 0000000000..ba8975e740 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/070_phase7.md @@ -0,0 +1,65 @@ +# 070 — wp7: backlog triage devlog + +## Deliverable + +`devlog/_plan/260826_backlog_triage/` — a factual record of the current backlog so the next +maintainer session starts from evidence instead of re-auditing 39 items. + +Two audits already ran against `dev` at `0a0a8821b` and their findings are written up rather +than re-derived. + +## Stale pull requests — 18 audited + +Verdicts, with "behind" as GitHub compare's count of `dev` commits absent from the PR head: + +- **SUPERSEDED (closed in wp6):** #1769 (`74e8ce557`), #2215 (`7fdb2cb8e`). +- **REVIVABLE-SMALL:** #2033 — 14 lines across 2 files, and a real gap: both GET and PUT + sidecar responses omit an `enabled` field + ([config-routes.ts:571](../../../src/server/management/config-routes.ts) and :809). Worth a + maintainer revival. Caveat: 869 commits behind. +- **REVIVABLE-LARGE:** #1794, #1829 (0 behind), #2050, #2122, #2299. +- **NEEDS-AUTHOR:** #1557, #1645, #1756, #2083, #2113, #2123, #2213, #2230, #2244, #2326. + +Findings worth recording because they are non-obvious: + +- **#1829 is 0 commits behind dev** with CI green — the only stalled PR that is not stale. +- **#2083 does not merely conflict, it disagrees.** The xAI image bridge landed via + `de35caa4d`, but current code returns no image credential for OAuth configurations + ([images/plan.ts:32](../../../src/images/plan.ts)) and the public guide states an API key is + required. The PR proposes the opposite contract; that is an owner decision, not a rebase. +- **#1794 is a partial duplicate, not superseded.** Core recovery landed via `9bea7707b` and + configurable OpenRouter routing via `3c6f3caa4`, but the PR's GUI exposure files have no + equivalent on `dev`. +- **#2123 is NOT superseded** by existing Antigravity quota work: per-account eligibility + still accepts Anthropic only ([quota.ts:1447](../../../src/providers/quota.ts)). +- No PR qualifies as abandoned — all 16 distinct author accounts still resolve. Conflict + volume alone was not treated as abandonment. + +## Open issues — 21 audited for quick-win feasibility + +- **QUICK-WIN:** #2406 (wp3), #1215 (wp4), #1060 (wp5) — all three implemented in this loop. +- **ALREADY-DONE:** #2442, #2423 — closed in wp6 with code citations. +- **DECLINED WITH REASON:** #2060 — closed in wp6; 429 failover is the intended default. +- **MEDIUM (140-300 lines):** #2539, #2279, #2201, #1820, #1690, #1533. +- **NOT-QUICK (250-700 lines):** #2511, #2455, #2399, #2275, #2221, #2046, #1711, #1525, #1213. + +`#1820` is the most attractive of the MEDIUM tier: the backend already computes aggregate +cache tokens and per-model estimated cost +([summary.ts:67](../../../src/usage/summary.ts)); only the GUI row types and tables omit the +columns. + +## Structure + +- `000_snapshot.md` — audit basis, date, dev SHA, method. +- `010_stale_prs.md` — the 18-row table with per-PR evidence. +- `020_issue_quick_wins.md` — the 21-row table with per-issue file:line evidence. +- `030_recommendations.md` — what to do next and in what order. + +## Verification (C) + +Files exist with the stated content, and every verdict carries a commit SHA or a file:line +pointer. A triage doc whose claims cannot be rechecked is worse than none — it ages into +confident misinformation. + +Runs last: it records what wp6 actually closed. + From 12f5876fd2468a1d9f3a7a3fb540fb7995a7c10f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 12:12:21 +0900 Subject: [PATCH 073/336] fix(codex): classify a sub-day quota header window as the 5h burst (Plus/Team) (#2646) Codex removed the 5-hour rate limit some time ago and has now restored it for Plus and Team, while Pro stays weekly-only. OpenCodex reads the same account state from two wires and only one of them classifies windows by duration. parseUsageQuota has always used the duration: anything under 24h is a burst window. parseUpstreamQuotaHeaders knew only "explicitly monthly, or else weekly", so once the 5h window returned it filed the burst reading as the weekly one. Identical upstream data, before this change: headers {primary 97% / 300 min, secondary 12% / 10080 min} parseUpstreamQuotaHeaders -> {weeklyPercent: 97} parseUsageQuota -> {shortPercent: 97, weeklyPercent: 12} Three consequences, worst last: the real weekly reading is discarded; the GUI shows a weekly bar at 100% and no 5h bar, so the operator cannot tell which limit they hit; and a 5h-exhausted account keeps weeklyPercent=100 after the burst window resets, so pool routing avoids a healthy account until an unrelated WHAM refresh overwrites it. The header parser now derives its threshold from the same constant the WHAM parser uses, rather than repeating the number. A declared sub-day primary becomes shortPercent/shortResetAt/shortWindowSeconds and vacates the primary slot so the secondary can be what it always was: the weekly reading. A primary with no declared duration is untouched, because legacy payloads omit the header and guessing there would reclassify every account that predates the field. src/routing/quota.ts changes with it, and must. codexAccountQuotaEvidence scored headroom from weekly and monthly only. That was survivable while the broken parser wrote 5h values into weeklyPercent - routing saw the burst by accident. Fixing the parser alone moves a 5h-exhausted account from 0.03 to 0.88 headroom and routes it straight into a 429. The bug was cancelling itself out; removing one half without the other is worse than leaving both. Tests: five header-classification cases including the 1439/1440-minute boundary and the legacy no-duration path; a new parity suite asserting the two parsers assign identical upstream data to the same windows; three routing regressions covering burst-exhausted, fully-exhausted, and weekly-bound accounts. Falsified both ways - reverting the parser branch fails 7, reverting the routing fold fails 1. bun run typecheck exit 0. bun run test 0 fail. --- src/codex/quota.ts | 46 ++++++++-- src/routing/quota.ts | 10 +++ tests/codex-quota-parser-parity.test.ts | 112 ++++++++++++++++++++++++ tests/rate-limit-reset-credits.test.ts | 92 +++++++++++++++++++ 4 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 tests/codex-quota-parser-parity.test.ts diff --git a/src/codex/quota.ts b/src/codex/quota.ts index ae9152c0ed..3248186375 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -95,6 +95,9 @@ const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60; */ const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60; const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; +// Derived, never written as a literal: the header parser and the WHAM parser must not be able +// to drift to different thresholds, which is the class of defect this pair exists to prevent. +const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60; const accountQuota = new Map(); let lastReconciledGeneration = 0; @@ -219,14 +222,24 @@ function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): bo } function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean { - const minutes = typeof windowMinutes === "number" - ? windowMinutes - : typeof windowMinutes === "string" && windowMinutes.trim() !== "" - ? Number(windowMinutes) + const minutes = windowMinutes_(windowMinutes); + return minutes !== undefined && minutes >= MONTHLY_WINDOW_MIN_MINUTES; +} + +/** The header wire reports a window duration in MINUTES; WHAM reports it in seconds. */ +function windowMinutes_(value: unknown): number | undefined { + const minutes = typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) : undefined; - return typeof minutes === "number" - && Number.isFinite(minutes) - && minutes >= MONTHLY_WINDOW_MIN_MINUTES; + return typeof minutes === "number" && Number.isFinite(minutes) ? minutes : undefined; +} + +/** Minutes-domain twin of isExplicitShortWindow. Same strict `<`, same 24h discriminator. */ +function isExplicitShortWindowMinutes(value: unknown): boolean { + const minutes = windowMinutes_(value); + return minutes !== undefined && minutes > 0 && minutes < WEEKLY_WINDOW_MIN_MINUTES; } @@ -342,6 +355,12 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit typeof value === "number" && Number.isFinite(value)); const maxPercent = percents.length > 0 ? Math.max(...percents) : undefined; // Credits-only snapshots prove neither usage nor exhaustion. Unknown must not @@ -49,6 +55,10 @@ function codexAccountQuotaEvidence(accountId: string, plan?: string): RouteQuota const resets = [ ...(monthly ? [] : [quota.weeklyResetAt]), quota.monthlyResetAt, + // Pair the reset with the window that can actually gate the next request: a burst-limited + // account recovers in hours, and reporting a distant weekly reset would defer a retry that + // is already safe. + quota.shortResetAt, ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)) .filter(value => value > Date.now()); return { diff --git a/tests/codex-quota-parser-parity.test.ts b/tests/codex-quota-parser-parity.test.ts new file mode 100644 index 0000000000..1f909f4f4e --- /dev/null +++ b/tests/codex-quota-parser-parity.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "bun:test"; +import { + clearAccountQuota, + parseUpstreamQuotaHeaders, + parseUsageQuota, + setAccountQuotaFromParsed, +} from "../src/codex/quota"; +import { codexPoolQuotaEvidence } from "../src/routing/quota"; + +/** + * The two quota parsers, pinned against each other. + * + * Codex reports the same account state twice: as response headers on every request, and as a + * WHAM usage payload on refresh. `parseUsageQuota` classified windows by DURATION from the + * start; `parseUpstreamQuotaHeaders` only knew "explicitly monthly, or else weekly". While + * Codex had no 5-hour window that difference was invisible. When the window came back for Plus + * and Team, the header path started filing a 5h reading as the weekly one, discarding the real + * weekly value and leaving the account exhausted long after the burst reset. + * + * This is the assertion that would have caught it: the parsers must agree about WHICH WINDOW a + * number belongs to, whichever wire it arrived on. It compares window assignment rather than + * whole objects, because the WHAM payload also carries provenance and Spark windows the header + * wire does not. + */ +describe("quota parser parity: headers and WHAM agree on window assignment", () => { + const cases = [ + { name: "Plus/Team 5h burst + 7-day weekly", minutes: 300, seconds: 18_000, primary: 97, secondary: 12 }, + { name: "Pro weekly-only", minutes: 10_080, seconds: 604_800, primary: 80, secondary: undefined }, + { name: "monthly plan with a weekly secondary", minutes: 43_800, seconds: 2_628_000, primary: 100, secondary: 22 }, + { name: "sub-hour burst", minutes: 15, seconds: 900, primary: 40, secondary: 5 }, + ] as const; + + /** Which field each percent landed in — the only thing both wires can be compared on. */ + function assignment(quota: Record | null): Record { + return { + shortPercent: quota?.shortPercent, + weeklyPercent: quota?.weeklyPercent, + monthlyPercent: quota?.monthlyPercent, + }; + } + + for (const testCase of cases) { + it(`agrees on ${testCase.name}`, () => { + const headers = new Headers({ + "x-codex-primary-used-percent": String(testCase.primary), + "x-codex-primary-window-minutes": String(testCase.minutes), + ...(testCase.secondary !== undefined + ? { + "x-codex-secondary-used-percent": String(testCase.secondary), + "x-codex-secondary-window-minutes": "10080", + } + : {}), + }); + const wham = parseUsageQuota({ + plan_type: "plus", + rate_limit: { + primary_window: { used_percent: testCase.primary, limit_window_seconds: testCase.seconds }, + ...(testCase.secondary !== undefined + ? { secondary_window: { used_percent: testCase.secondary, limit_window_seconds: 604_800 } } + : {}), + }, + }); + + expect(assignment(parseUpstreamQuotaHeaders(headers) as Record)) + .toEqual(assignment(wham as Record)); + }); + } + + it("the burst duration survives the header round trip in seconds", () => { + // The header wire speaks minutes and the stored field is seconds; a unit slip here would be + // silent, since both numbers are plausible durations. + const quota = parseUpstreamQuotaHeaders(new Headers({ + "x-codex-primary-used-percent": "50", + "x-codex-primary-window-minutes": "300", + })); + expect(quota?.shortWindowSeconds).toBe(18_000); + }); +}); + +/** + * The regression the parser fix would otherwise have introduced. + * + * `codexAccountQuotaEvidence` scored headroom from weekly and monthly only. That was survivable + * while the broken parser wrote 5h readings into `weeklyPercent` — routing saw the burst by + * accident. Correcting the parser without this fold would take a 5h-exhausted account from 3% + * headroom to 88% and route straight into a 429. + */ +describe("routing headroom accounts for the burst window", () => { + it("a 5h-exhausted account keeps low headroom despite a healthy weekly", () => { + clearAccountQuota(); + setAccountQuotaFromParsed("burst-acct", { shortPercent: 97, weeklyPercent: 12 }); + const evidence = codexPoolQuotaEvidence([{ accountId: "burst-acct", plan: "plus" }]); + expect(evidence.known).toBe(true); + expect(evidence.headroom).toBeLessThanOrEqual(0.05); + }); + + it("a fully exhausted burst window reports exhausted", () => { + clearAccountQuota(); + setAccountQuotaFromParsed("burst-dead", { shortPercent: 100, weeklyPercent: 8 }); + const evidence = codexPoolQuotaEvidence([{ accountId: "burst-dead", plan: "plus" }]); + expect(evidence.exhausted).toBe(true); + }); + + it("a healthy burst window does not suppress a real weekly limit", () => { + // The fold must not invert: the maximum still governs, so a near-full weekly still bites. + clearAccountQuota(); + setAccountQuotaFromParsed("weekly-bound", { shortPercent: 3, weeklyPercent: 96 }); + const evidence = codexPoolQuotaEvidence([{ accountId: "weekly-bound", plan: "plus" }]); + expect(evidence.headroom).toBeLessThanOrEqual(0.05); + }); +}); + diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index b775700c8d..45b9950fdf 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -481,4 +481,96 @@ describe("rate-limit reset credits", () => { }); }); }); + + /** + * Codex removed the 5-hour window and restored it for Plus and Team (Pro stays weekly-only). + * The header parser classified every non-monthly primary as weekly, so a 5h reading landed in + * weeklyPercent, the real weekly value was discarded, and the account stayed "exhausted" long + * after the burst window reset. + */ + describe("header window duration classification (5h restoration)", () => { + it("files a 5h primary as the burst window and the 7-day secondary as weekly", () => { + clearAccountQuota(); + const headers = new Headers({ + "x-codex-primary-used-percent": "97", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": "1787401330", + "x-codex-secondary-used-percent": "12", + "x-codex-secondary-window-minutes": "10080", + "x-codex-secondary-reset-at": "1788000000", + }); + applyAccountQuotaFromUpstreamHeaders("burst-A", headers); + expect(getAccountQuota("burst-A")).toEqual({ + shortPercent: 97, + shortResetAt: 1787401330, + shortWindowSeconds: 18000, + weeklyPercent: 12, + weeklyResetAt: 1788000000, + updatedAt: expect.any(Number), + }); + }); + + it("an exhausted burst window does not poison the weekly reading", () => { + // The damaging half of the bug: weeklyPercent=100 survives the 5h reset and keeps the + // account out of the pool until an unrelated WHAM refresh overwrites it. + clearAccountQuota(); + applyAccountQuotaFromUpstreamHeaders("burst-B", new Headers({ + "x-codex-primary-used-percent": "100", + "x-codex-primary-window-minutes": "300", + "x-codex-secondary-used-percent": "8", + "x-codex-secondary-window-minutes": "10080", + })); + const quota = getAccountQuota("burst-B"); + expect(quota?.shortPercent).toBe(100); + expect(quota?.weeklyPercent).toBe(8); + }); + + it("a sub-day window at 1439 minutes is short; 1440 minutes is not", () => { + // Strict `<` against the 24h discriminator, matching isExplicitShortWindow exactly. + clearAccountQuota(); + applyAccountQuotaFromUpstreamHeaders("edge-below", new Headers({ + "x-codex-primary-used-percent": "60", + "x-codex-primary-window-minutes": "1439", + })); + expect(getAccountQuota("edge-below")?.shortPercent).toBe(60); + expect(getAccountQuota("edge-below")?.weeklyPercent).toBeUndefined(); + + clearAccountQuota(); + applyAccountQuotaFromUpstreamHeaders("edge-at", new Headers({ + "x-codex-primary-used-percent": "60", + "x-codex-primary-window-minutes": "1440", + })); + expect(getAccountQuota("edge-at")?.weeklyPercent).toBe(60); + expect(getAccountQuota("edge-at")?.shortPercent).toBeUndefined(); + }); + + it("a primary with no declared duration stays weekly", () => { + // Duration classification is opt-in on a DECLARED duration. Legacy payloads omit the + // header, and guessing there would reclassify every account that predates the field. + clearAccountQuota(); + applyAccountQuotaFromUpstreamHeaders("legacy-A", new Headers({ + "x-codex-primary-used-percent": "80", + "x-codex-primary-reset-at": "1787000000", + })); + expect(getAccountQuota("legacy-A")).toEqual({ + weeklyPercent: 80, + weeklyResetAt: 1787000000, + updatedAt: expect.any(Number), + }); + }); + + it("the reset instant travels with its own window", () => { + clearAccountQuota(); + applyAccountQuotaFromUpstreamHeaders("reset-A", new Headers({ + "x-codex-primary-used-percent": "50", + "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": "1787401330", + })); + const quota = getAccountQuota("reset-A"); + expect(quota?.shortResetAt).toBe(1787401330); + // The primary reset belongs to the burst window; it must not be reported as the weekly one. + expect(quota?.weeklyResetAt).toBeUndefined(); + }); + }); + }); From bf73afee500f8c5b1bd5a802824f7beaf8cf9fc7 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 26 Aug 2026 12:49:51 +0900 Subject: [PATCH 074/336] feat(gui): hide the Codex Spark quota by default behind a Codex Auth switch (#2649) * feat(gui): hide the Codex Spark quota by default behind a Codex Auth switch Every Codex account card carried a second bar labelled "GPT-5.3-Codex-Spark Weekly", emitted unconditionally whenever the WHAM payload contains the codex_bengalfox feature. It applies to one model, reads 0% for most operators, and on a four-account pool it doubles the row count for information almost nobody acts on. It is now hidden unless the operator asks for it, and the switch that asks sits in the Codex Auth header beside Pause exhausted and Refresh quotas - the row that already holds the page-wide controls. A per-account toggle was rejected: the setting is about a window KIND, not an account, and four toggles that must agree is a state-sync bug waiting to happen. The filter lands in the API projection rather than in CSS, and at providerQuotaFromCodexQuota rather than only in the Codex Auth DTO. Spark reaches the GUI through three paths, not one: /api/codex-auth/accounts via quotaForPlan, /api/provider-quotas pooled via listCodexAuthAccountsSnapshot, and /api/provider-quotas direct via fetchMainAccountInfoSnapshot - which never touches the Codex Auth DTO at all. That third path was found by an existing test still passing after the first attempt, which is the useful kind of test failure. Matching on the exact label is load-bearing. customWindows is the generic carrier for Cursor's First-party models and API usage, Anthropic's Fable/Opus/Sonnet, Antigravity's Gem/Cla, Kimi's subscription credits and a dozen dynamic provider meters; a filter written as "drop custom windows" would blank all of them. A regression pins that. Nothing is filtered at parse or cache time. Custom windows participate in quota-presence checks, snapshot reconciliation and capacity aggregation, so removing Spark upstream of the projection would change routing state rather than display. Server: showCodexSparkQuota through GET/PUT /api/settings with the same validate-mutate-persist-rollback shape the neighbouring preferences use, and the same degrade-not-reject schema treatment - a malformed hand edit hides Spark rather than discarding the config. Tests: six visibility cases including the non-Spark preservation case and the absent-vs-empty wire distinction; the existing provider-quota Codex test now asserts the projection DROPS a window the fixture still carries. Falsified by disabling the filter: 4 tests fail. i18n across all nine locales. bun run typecheck exit 0. bun run test 0 fail. cd gui && bun test 994 pass. * fix(gui): tear the Spark settings read down with AbortController react-doctor's no-set-state-after-await-in-effect flagged the settings load: the effect awaited a fetch and then called setSparkVisible, guarded only by a `cancelled` closure flag the linter cannot see through. The guard was real but the teardown was not - a `cancelled` flag stops the state update while leaving the request itself in flight. An AbortController actually tears the request down on unmount, and the state update now lands in a .then() whose guard is visible to the rule. CI runs react-doctor with --scope changed, so this was a finding on this branch's own file rather than a pre-existing one. --- gui/src/components/CodexAccountPool.tsx | 50 ++++++++ .../codex-account-pool-main-card.tsx | 23 ++++ gui/src/i18n/de.ts | 5 + gui/src/i18n/en.ts | 5 + gui/src/i18n/fr.ts | 5 + gui/src/i18n/ja.ts | 5 + gui/src/i18n/ko.ts | 5 + gui/src/i18n/ru.ts | 5 + gui/src/i18n/tr.ts | 5 + gui/src/i18n/zh-TW.ts | 5 + gui/src/i18n/zh.ts | 5 + gui/src/styles.css | 13 ++ src/codex/auth-api.ts | 55 +++++++-- src/config.ts | 3 + src/providers/quota.ts | 6 + src/server/management/config-routes.ts | 21 +++- src/types/config.ts | 9 ++ tests/codex-spark-visibility.test.ts | 114 ++++++++++++++++++ tests/provider-quota.test.ts | 7 +- 19 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 tests/codex-spark-visibility.test.ts diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index e41dcc4912..cfedd15013 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -72,6 +72,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [actionFeedbackTone, setActionFeedbackTone] = useState(null); const feedbackTimerRef = useRef | null>(null); const [refreshingQuota, setRefreshingQuota] = useState(false); + // undefined until /api/settings answers: the switch must not render a guessed position and + // then visibly correct itself a moment later. + const [sparkVisible, setSparkVisible] = useState(undefined); + const [sparkBusy, setSparkBusy] = useState(false); const [resetPopup, setResetPopup] = useState(null); const [resetConfirm, setResetConfirm] = useState(false); const [redeeming, setRedeeming] = useState(false); @@ -228,6 +232,49 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; + useEffect(() => { + // AbortController rather than a `cancelled` flag: the in-flight request is actually torn + // down on unmount, and the state update lands in a .then() the linter can see is guarded. + const abort = new AbortController(); + fetch(`${apiBase}/api/settings`, { signal: abort.signal }) + .then(response => (response.ok ? response.json() : null)) + .then((payload: { showCodexSparkQuota?: unknown } | null) => { + if (abort.signal.aborted || typeof payload?.showCodexSparkQuota !== "boolean") return; + setSparkVisible(payload.showCodexSparkQuota); + }) + // A settings read failure leaves the switch unrendered rather than guessing a position. + .catch(() => {}); + return () => { abort.abort(); }; + }, [apiBase]); + + const toggleSpark = async () => { + if (sparkBusy || sparkVisible === undefined) return; + const requested = !sparkVisible; + setSparkBusy(true); + // Optimistic, then reconciled against what the server confirms — the same shape the account + // picker toggle uses, so a rejected write visibly snaps back instead of lying. + setSparkVisible(requested); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ showCodexSparkQuota: requested }), + }); + if (!response.ok) throw new Error("save"); + const payload = await response.json() as { showCodexSparkQuota?: unknown }; + const confirmed = typeof payload.showCodexSparkQuota === "boolean" ? payload.showCodexSparkQuota : requested; + setSparkVisible(confirmed); + showActionFeedback(t(confirmed ? "codexAuth.sparkQuotaShown" : "codexAuth.sparkQuotaHidden"), "ok"); + await load(true); + } catch { + setSparkVisible(!requested); + showActionFeedback(t("codexAuth.sparkQuotaFailed"), "err"); + } finally { + setSparkBusy(false); + } + }; + + const pauseExhausted = async () => { const result = await controller.pauseExhaustedAccounts(); if (!result.ok && result.reason === "busy") return; @@ -294,6 +341,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban pauseBusy={pauseBusy} onRefresh={() => { void refreshQuotas(); }} onPauseExhausted={() => { void pauseExhausted(); }} + sparkVisible={sparkVisible} + sparkBusy={sparkBusy} + onToggleSpark={() => { void toggleSpark(); }} /> {banner} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 70a75023d9..7f53e122dc 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -183,6 +183,9 @@ export function CodexAccountPoolPageHead({ actionFeedbackTone, onRefresh, onPauseExhausted, + sparkVisible, + sparkBusy, + onToggleSpark, }: { t: TFn; embedded: boolean; @@ -193,6 +196,10 @@ export function CodexAccountPoolPageHead({ actionFeedbackTone?: NoticeTone | null; onRefresh: () => void; onPauseExhausted: () => void; + /** undefined until the preference has loaded, so the switch never renders a guessed state. */ + sparkVisible?: boolean; + sparkBusy?: boolean; + onToggleSpark?: () => void; }) { return (
{actionFeedback ?? ""} + {sparkVisible !== undefined && onToggleSpark && ( + + {t("codexAuth.sparkQuota")} + + + )}