From 947b9b6ab6f259eae060c576dbc368a58d54a487 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:16:36 -0700 Subject: [PATCH 01/14] chore(release): open dev at 2.88.0 before releasing 2.87.0 (#7248) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 27029e21b8c..ffe3177df6b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.87.0" +version = "2.88.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index bf22aa60fb7..e2646bca247 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.87.0" +version = "2.88.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 3f2e7ce5a4f..50ce2387eff 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.87.0", + "version": "2.88.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 0ea7b8da08e..5b09a0263be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.87.0", + "version": "2.88.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From d93965b0245d11eb8e3cab0b188cad5d83ca81ea Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 07:50:05 -0700 Subject: [PATCH 02/14] fix(ci): keep carry-attribution references whole at the window cap (#7252) The carry-attribution window is capped at 80 characters and could cut a reference in half. The #7223 subject "carry ... (#7195, #6767" was read as #676, an unrelated PR, so the hygiene gate demanded a co-author trailer for the wrong person (seen on the 2.87.0 promotion #7249). When no sentence boundary falls inside the window, the window now finishes a number that started before the cap. Sentence and newline bounds, qualified-reference handling, and the exclusion of references that start after the cap are unchanged. --- .github/scripts/pr-carry-attribution.cjs | 11 ++++++- .github/scripts/pr-carry-attribution.test.cjs | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pr-carry-attribution.cjs b/.github/scripts/pr-carry-attribution.cjs index fa4894330d8..401b3b50ba2 100644 --- a/.github/scripts/pr-carry-attribution.cjs +++ b/.github/scripts/pr-carry-attribution.cjs @@ -49,7 +49,16 @@ const SENTENCE_END_RE = /[.!?](?:\s|$)|\n/; function carryWindow(text, from) { const slice = text.slice(from, from + 80); const end = slice.search(SENTENCE_END_RE); - return end === -1 ? slice : slice.slice(0, end); + if (end !== -1) return slice.slice(0, end); + + // Finish only the number already started in this window (including a bare + // hash at the cap). The bounded suffix check and forward digit walk keep + // untrusted text linear and leave owner/repo qualifiers intact for REF_RE. + let to = from + slice.length; + if (/#\d*$/.test(slice)) { + while (to < text.length && text[to] >= "0" && text[to] <= "9") to++; + } + return text.slice(from, to); } const TRAILER_RE = /^[ \t]*co-authored-by:[ \t]*(.+)$/gim; diff --git a/.github/scripts/pr-carry-attribution.test.cjs b/.github/scripts/pr-carry-attribution.test.cjs index 8d94fc707ba..0f4781dfe67 100644 --- a/.github/scripts/pr-carry-attribution.test.cjs +++ b/.github/scripts/pr-carry-attribution.test.cjs @@ -4,6 +4,7 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const { assessCarryAttribution, + carryWindow, referencedCarryNumbers, } = require("./pr-carry-attribution.cjs"); @@ -291,6 +292,38 @@ describe("assessCarryAttribution", () => { assert.deepEqual(failures[0].paths, ["#3193"]); }); + it("keeps the complete references from the #7223 subject", () => { + const subject = "carry Combo visibility, workspace panes, Windows picker and Gentle Shell (#7195, #6767"; + assert.deepEqual([...referencedCarryNumbers(subject)], [7195, 6767]); + }); + + it("finishes a number or hash at the cap without admitting the next reference", () => { + for (const reference of ["#6767", "other/repo#6767"]) { + for (let included = 1; included <= 5; included++) { + const padding = " ".repeat(80 - reference.length + 5 - included); + const text = padding + reference + " #3192"; + assert.equal(carryWindow(text, 0), padding + reference); + assert.deepEqual( + [...referencedCarryNumbers("carry" + text)], + reference.startsWith("#") ? [6767] : [], + ); + } + } + }); + + it("excludes references starting at or beyond the cap and after a newline", () => { + for (const padding of [80, 81]) { + assert.deepEqual([...referencedCarryNumbers("carry" + " ".repeat(padding) + "#3192")], []); + } + assert.deepEqual([...referencedCarryNumbers("carry #3193\n#3192")], [3193]); + }); + + it("completes a long numeric suffix without scanning the following text", () => { + const digits = "7".repeat(100_000); + const text = " ".repeat(79) + "#" + digits; + assert.equal(carryWindow(text + " #3192", 0), text); + }); + it("reads a trailer that only exists on a branch commit", () => { assert.deepEqual( assessCarryAttribution( From a02cecd96e792225ea22f6369d6a82323c95d564 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 08:18:20 -0700 Subject: [PATCH 03/14] fix: carry account recovery, server-tool buffer, and usage corrections (#7230, #7231, #7232, #7233) (#7236) Carries four vadymhimself follow-ups onto dev: - #7230: the combo sibling check peeks the round-robin ring without advancing it, while the failed account's cooldown is still recorded. - #7231: a Codex account move after a 402 is recorded as quota recovery, separate from 429 rate recovery, through persistence, metrics, analytics and Logs labels. - #7232: the Anthropic server-tool input buffer is capped on the incoming fragment (4,096 code units), so one oversized fragment cannot exceed the bound. - #7233: refused-then-served usage rows are priced from the served attempt, and client cancels keep an unknown cost. A usage-less refusal never changes a row's pricing classification. A row with a leading refusal summarizes exactly like the same row without it, under dev's existing partial-cost contract. Co-authored-by: Vadym O --- .../src/content/docs/guides/web-dashboard.md | 9 ++- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 1 + src/adapters/anthropic.ts | 2 +- src/lib/request-failure-model.ts | 2 + src/oauth/generic-account-failover.ts | 11 ++- src/routing/analytics.ts | 1 + src/server/responses/core-codex-account.ts | 4 +- src/server/responses/core-combo.ts | 5 +- src/usage/summary.ts | 12 ++- src/usage/telemetry-contract.ts | 1 + structure/dashboard-and-usage.md | 4 +- structure/providers-and-adapters.md | 2 +- structure/providers/generic-account-pool.md | 8 ++ structure/transports/responses.md | 2 +- .../claude-translated-server-tools.test.ts | 11 +++ tests/lib/failure-stage-model.test.ts | 4 +- ...neric-oauth-combo-account-cooldown.test.ts | 18 +++++ .../responses/responses-account-label.test.ts | 34 ++++++++- .../usage/usage-partly-measured-cost.test.ts | 73 ++++++++++++++++++- 30 files changed, 194 insertions(+), 21 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 77d8d4fcd7a..252f7650d39 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -149,6 +149,9 @@ its request and attempt failure cause is `transport-ambiguous`, with resend perm `refused-ambiguous`: the upstream may already have processed the turn. A genuine provider 429 remains `rate-limit` with resend permission `permitted`. +A Codex move to another account after HTTP 402 appears as **Account quota exhausted (402)** +in the attempt's recovery details and counts as a quota recovery in exported metrics. + Logs filters combine surface, intercepted requests, provider, exact model, status, time, speed, and conversation ID over the currently loaded request ring. Provider and model choices also include fallback attempts; model matching ignores case and surrounding spaces @@ -207,8 +210,10 @@ without a matching price or usable usage are counted as excluded beside that amo reports pricing coverage fields. Usage that was only partly measured (`unreported` or `unsupported`) is not priced, and one excluded part excludes the whole request from that row's priced count. A leg that reached no upstream, or that upstream refused with an HTTP error before any output, spent -nothing and does not; a leg whose response began (or ended without a status) but reported no usage -may have been billed, so it does. A row with only excluded requests shows an em dash with that count +nothing and does not; a client cancellation (499), or a leg whose response began or ended without +a status but reported no usage, may have been billed, so it does. A refusal followed by a measured +successful send contributes that send's cost to the summary even when the aggregate usage status +is `unreported`. A row with only excluded requests shows an em dash with that count instead of a zero-dollar estimate, and a displayed zero-dollar value is a confirmed priced estimate. An older proxy that reports no coverage fields keeps a bare em dash with no count, so missing coverage stays distinct from an all-excluded row and from a confirmed zero estimate. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3b784e086f7..22c5edac19d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1148,6 +1148,7 @@ export const de: Record = { "logs.detail.attempt.recovery.key401": "API-Schlüssel erneut authentifiziert", "logs.detail.attempt.recovery.oauthAccount403": "Kontovalidierung erforderlich (403)", "logs.detail.attempt.recovery.oauthAccount429": "Konto rate-limitiert (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Kontingent des Kontos erschöpft (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Veralteter verschlüsselter Zustand verworfen", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Reasoning-Aufwand reduziert", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Schnellmodus abgelehnt, erneut mit Standardtempo versucht", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a10c82ade62..350a21304ae 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1206,6 +1206,7 @@ export const en = { "logs.detail.attempt.recovery.key401": "API key re-authentication", "logs.detail.attempt.recovery.oauthAccount403": "Account validation required (403)", "logs.detail.attempt.recovery.oauthAccount429": "Account rate-limited (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Account quota exhausted (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Stale encrypted state dropped", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Reasoning effort downgraded", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Fast mode refused, retried at standard speed", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index d61681fcfc9..a17e39b0bd0 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1182,6 +1182,7 @@ export const fr: Record = { "logs.detail.attempt.recovery.key401": "Ré-authentification de la clé API", "logs.detail.attempt.recovery.oauthAccount403": "Validation du compte requise (403)", "logs.detail.attempt.recovery.oauthAccount429": "Compte limité (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Quota du compte épuisé (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "État chiffré obsolète abandonné", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Effort de raisonnement réduit", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Mode rapide refusé, nouvel essai à vitesse standard", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1ae152fdee0..08799fa8f3f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1089,6 +1089,7 @@ export const ja: Record = { "logs.detail.attempt.recovery.key401": "API キーの再認証", "logs.detail.attempt.recovery.oauthAccount403": "アカウントの検証が必要 (403)", "logs.detail.attempt.recovery.oauthAccount429": "アカウントのレート制限 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "アカウントのクォータ枯渇 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "古い暗号化状態を破棄", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "推論強度を下げて再試行", "logs.detail.attempt.recovery.anthropicFastDowngrade": "高速モードが拒否され、標準速度で再試行", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a7faa3c9e47..0781408ca97 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1187,6 +1187,7 @@ export const ko: Record = { "logs.detail.attempt.recovery.key401": "API 키 재인증", "logs.detail.attempt.recovery.oauthAccount403": "계정 확인 필요 (403)", "logs.detail.attempt.recovery.oauthAccount429": "계정 속도 제한 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "계정 할당량 소진 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "만료된 암호화 상태 제거", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "추론 강도 하향", "logs.detail.attempt.recovery.anthropicFastDowngrade": "빠른 모드 거부, 표준 속도로 재시도", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index dac0f93fdc8..5d0f1dd1761 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -1201,6 +1201,7 @@ export const pt: Record = { "logs.detail.attempt.recovery.key401": "Reautenticação da chave de API", "logs.detail.attempt.recovery.oauthAccount403": "Validação da conta necessária (403)", "logs.detail.attempt.recovery.oauthAccount429": "Conta com limite de taxa (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Cota da conta esgotada (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Estado criptografado obsoleto descartado", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Esforço de raciocínio reduzido", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Modo rápido recusado, repetido em velocidade padrão", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8603d20b578..ad8863b0cce 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1165,6 +1165,7 @@ export const ru: Record = { "logs.detail.attempt.recovery.key401": "Повторная аутентификация API-ключа", "logs.detail.attempt.recovery.oauthAccount403": "Требуется проверка аккаунта (403)", "logs.detail.attempt.recovery.oauthAccount429": "Ограничение частоты для аккаунта (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Квота аккаунта исчерпана (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Устаревшее зашифрованное состояние отброшено", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Уровень рассуждения понижен", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Быстрый режим отклонён, повтор со стандартной скоростью", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 008e8be47e4..147e954d1c9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1192,6 +1192,7 @@ export const tr: Record = { "logs.detail.attempt.recovery.key401": "API anahtarı yeniden doğrulandı", "logs.detail.attempt.recovery.oauthAccount403": "Hesap doğrulaması gerekli (403)", "logs.detail.attempt.recovery.oauthAccount429": "Hesap hız sınırına takıldı (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Hesap kotası tükendi (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Eski şifreli durum bırakıldı", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Akıl yürütme düzeyi düşürüldü", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Hızlı mod reddedildi, standart hızda yeniden denendi", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 0f432ac4923..18fa8b93963 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1186,6 +1186,7 @@ export const vi: Record = { "logs.detail.attempt.recovery.key401": "Xác thực lại khóa API", "logs.detail.attempt.recovery.oauthAccount403": "Cần xác minh tài khoản (403)", "logs.detail.attempt.recovery.oauthAccount429": "Tài khoản bị giới hạn tần suất (429)", + "logs.detail.attempt.recovery.oauthAccount402": "Tài khoản đã hết hạn mức (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "Đã bỏ trạng thái mã hóa cũ", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "Đã giảm mức suy luận", "logs.detail.attempt.recovery.anthropicFastDowngrade": "Chế độ nhanh bị từ chối, thử lại ở tốc độ chuẩn", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1bc86ff154c..783ea79a4fc 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3002,6 +3002,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.key401": "API 金鑰重新驗證", "logs.detail.attempt.recovery.oauthAccount403": "需要驗證帳號 (403)", "logs.detail.attempt.recovery.oauthAccount429": "帳號速率受限 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "帳號配額已用盡 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "已捨棄過期加密狀態", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "已降低推理強度", "logs.detail.attempt.recovery.anthropicFastDowngrade": "快速模式遭拒,改以標準速度重試", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a0240c48491..dacb18d70ea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1160,6 +1160,7 @@ export const zh: Record = { "logs.detail.attempt.recovery.key401": "API 密钥重新认证", "logs.detail.attempt.recovery.oauthAccount403": "需要验证账号 (403)", "logs.detail.attempt.recovery.oauthAccount429": "账号速率受限 (429)", + "logs.detail.attempt.recovery.oauthAccount402": "账号配额已用尽 (402)", "logs.detail.attempt.recovery.opaqueBlobRejection": "已丢弃过期加密状态", "logs.detail.attempt.recovery.reasoningEffortDowngrade": "已降低推理强度", "logs.detail.attempt.recovery.anthropicFastDowngrade": "快速模式被拒,按标准速度重试", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 922634b69ec..a1f5858f394 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -355,6 +355,7 @@ const RECOVERY_KIND_KEYS = { "rate-limit-429": "logs.detail.attempt.recovery.rateLimit429", "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", "oauth-account-429": "logs.detail.attempt.recovery.oauthAccount429", + "oauth-account-402": "logs.detail.attempt.recovery.oauthAccount402", "image-413": "logs.detail.attempt.recovery.image413", "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", "console-go-upload-retry": "logs.detail.attempt.recovery.consoleGoUpload", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 653d91593ce..140bd336f3b 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1416,7 +1416,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming && serverSearch.json.length < 4096) { // Capped: an input past 4 KiB loses its label (query / url), never the call. - serverSearch.json += delta.partial_json; + serverSearch.json += delta.partial_json.slice(0, 4096 - serverSearch.json.length); } break; } diff --git a/src/lib/request-failure-model.ts b/src/lib/request-failure-model.ts index 7ac0cb9ba61..dc924ac2874 100644 --- a/src/lib/request-failure-model.ts +++ b/src/lib/request-failure-model.ts @@ -223,6 +223,8 @@ const RECOVERY_KIND_CAUSE = { "rate-limit-429": "rate-limit", "anthropic-oauth-429": "rate-limit", "oauth-account-429": "rate-limit", + // A 402 is the account's quota, not a transient limit; only another account can serve it. + "oauth-account-402": "quota-exhausted", "image-413": "payload-too-large", // The gateway rejects a body it accepts seconds later and the replay is byte-identical, so // nothing about the payload was wrong; the origin declined to take it at that moment. diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index c8cbd8e042c..9fca39601c3 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -483,7 +483,8 @@ export function noteGenericPoolSelection( * 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. + * cooldown applied to an account we then could not use. `commit: false` still cools the failed + * account but only peeks the round-robin ring, for callers that will not dispatch the answer. */ export function rotateGenericOAuthAccountOn429( config: OcxConfig, @@ -492,9 +493,10 @@ export function rotateGenericOAuthAccountOn429( retryAfterHeader: string | null | undefined, now = Date.now(), requestedModelId?: string | null, + commit = true, ): string | null { return rotateGenericOAuthAccountOnRefusal(config, providerName, failedAccountId, "rate", - retryAfterHeader, now, requestedModelId); + retryAfterHeader, now, requestedModelId, undefined, commit); } /** Kiro adds account-scoped refusal classes; all other providers retain their 429 policy. */ @@ -507,6 +509,7 @@ export function rotateGenericOAuthAccountOnRefusal( now = Date.now(), requestedModelId?: string | null, monthlyCooldownMs?: number, + commit = true, ): string | null { if (kind === "other" || (providerName !== "kiro" && kind !== "rate")) return null; if (!isGenericOAuthFailoverEnabled(config, providerName)) return null; @@ -566,8 +569,8 @@ export function rotateGenericOAuthAccountOnRefusal( } if (strategy === "round-robin") { // PICK here, not peek: the failure already happened and this answer is the one being used, - // so the ring genuinely advances. - return pickRoundRobinAccount( + // so the ring genuinely advances. A caller that only asks whether a sibling exists peeks. + return (commit ? pickRoundRobinAccount : peekRoundRobinAccount)( genericPoolKey(providerName), candidates, genericStickyLimit(config, providerName), diff --git a/src/routing/analytics.ts b/src/routing/analytics.ts index 5c4411c3b8c..3eb2e540a14 100644 --- a/src/routing/analytics.ts +++ b/src/routing/analytics.ts @@ -122,6 +122,7 @@ const COOLDOWN_RECOVERY_KINDS = new Set([ "oauth-account-403", "anthropic-oauth-429", "oauth-account-429", + "oauth-account-402", ]); function percentile(sorted: number[], p: number): number | undefined { diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index de3152da035..7f73f04c4ce 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -975,8 +975,8 @@ export async function retryCodexPoolOnAlternateAccount( const maxRetrySends = retrySameConfirmedAccount ? 7 : 1; let retrySendCount = 0; // The gated-model 400 has no roster kind; it stays unattributed as before. - const moveRecovery: AttemptRecoveryKind | undefined = outcomeStatus === 429 || outcomeStatus === 402 - ? "oauth-account-429" + const moveRecovery: AttemptRecoveryKind | undefined = outcomeStatus === 429 ? "oauth-account-429" + : outcomeStatus === 402 ? "oauth-account-402" : isTransientUpstreamStatus(outcomeStatus) ? "transient-5xx" : undefined; let upstreamResponse: Response; try { diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 3104433d4c7..ce76e36637c 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -144,7 +144,8 @@ function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId * through `accountLogLabel`, but the per-request account budget can end the pool's own rotation * before the last refused account is recorded. Hand that account to the pool's rotator, which * cools it under the pool's own Retry-After policy, and spare the target only when the pool still - * has an eligible sibling. A lone account or an all-cooled pool returns null and cools the target. + * has an eligible sibling. The rotator only peeks round-robin here: nothing is sent to that sibling. + * A lone account or an all-cooled pool returns null and cools the target. */ function cooledGenericOAuthAccountLabel( config: OcxConfig, providerName: string, modelId: string, label: string | undefined, retryAfter: string | undefined, @@ -155,7 +156,7 @@ function cooledGenericOAuthAccountLabel( const matches = getAccountSet(providerName)?.accounts.filter(account => oauthAccountLogLabel(account.id, base) === label) ?? []; return matches.length === 1 - && rotateGenericOAuthAccountOn429(config, providerName, matches[0]!.id, retryAfter, Date.now(), modelId) + && rotateGenericOAuthAccountOn429(config, providerName, matches[0]!.id, retryAfter, Date.now(), modelId, false) ? label : undefined; } diff --git a/src/usage/summary.ts b/src/usage/summary.ts index cc1406a7688..eefdf747bdf 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -665,8 +665,12 @@ function addEstimatedCost( entry: Pick, costInfo: EntryCostInfo, ): void { - if (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" - || (!entry.usage && !entry.attempts?.length)) { + // A row of attempts reads `unreported` when any leg lacks usage. Once one leg is measured the row + // is metered and the attempt-level pricing decides, matching the model and provider breakdowns. + const measuredAttempt = entry.attempts?.some(attempt => attempt.usage && isMeasuredStatus(attempt.usageStatus)); + if (!measuredAttempt + && (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" + || (!entry.usage && !entry.attempts?.length))) { totals.unmeteredRequests += 1; return; } @@ -822,7 +826,9 @@ function blankRequestCounts(): UsageRequestCounts { function spentNothing(attempt: NonNullable[number] | undefined): boolean { if (!attempt || attempt.usage) return false; if (attempt.sendCount === 0 || attempt.locallyAnswered === true) return true; - return attempt.status >= 400 && attempt.streamAborted !== true && attempt.firstOutputMs === undefined; + // A 499 is the client hanging up on a send upstream may already be generating for. + return attempt.status >= 400 && attempt.status !== 499 + && attempt.streamAborted !== true && attempt.firstOutputMs === undefined; } function bumpRequestCounts(counts: UsageRequestCounts, facts: number, amount = 1): void { diff --git a/src/usage/telemetry-contract.ts b/src/usage/telemetry-contract.ts index 157d372f7c7..69247569cdf 100644 --- a/src/usage/telemetry-contract.ts +++ b/src/usage/telemetry-contract.ts @@ -32,6 +32,7 @@ export const ATTEMPT_RECOVERY_KIND_ROSTER = Object.freeze([ "rate-limit-429", "anthropic-oauth-429", "oauth-account-429", + "oauth-account-402", "image-413", "console-go-upload-retry", "opaque-blob-rejection", diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 2752f7534c8..e4023469732 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -290,7 +290,7 @@ neither the old byte window nor the parsed-entry cap can discard an earlier pref surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibility setting for bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response includes an `accounts` breakdown keyed by stable non-PII `accountLogLabel`; cards join it to the management account DTO for 30-day tokens, API-equivalent cost and coverage. New main-pool rows use `main`; legacy bare `openai` rows remain ambiguous. -A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 before any output (no `streamAborted`, no `firstOutputMs`) is a known zero. A usage-less attempt whose response began, aborted, or has no error status may have been billed and stays unpriced. +A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 other than a 499 client cancel before any output (no `streamAborted`, no `firstOutputMs`) is a known zero, and a row with any measured attempt counts priced or unpriced, never unmetered, whatever its aggregate status. A usage-less attempt whose response began, aborted, was cancelled by the client, or has no error status may have been billed and stays unpriced. Normal dashboard pages use the document as their vertical scroller. In `gui/src/styles.css`, body horizontal overflow is clipped without creating a second scrollport; the mobile open-drawer state also locks document overflow. The shared sidebar stays viewport-height through the end of Codex and Claude account pages. `gui/tests/shared-scroll-browser.ts` checks the built entry and App styles with short/long content, both themes, desktop chrome, collapsed navigation and mobile widths. The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. Ledger read failures instead return `500 { error: "read_failed" }`. Shared GUI usage admission reads that body before classifying HTTP failure and also rejects the legacy HTTP-200 envelope, so every shared cache retains its last valid report rather than fabricating zero totals. > Decision record: [ADR-0106](decisions/ADR-0106-usage-read-failure-contract.md) @@ -381,7 +381,7 @@ roster the exporter itself iterates. The count is deliberately not restated here: it was written as eight, a bounded label value was added, and the documentation then contradicted the output it describes. A logical request increments once, physical sends sum the finalized attempt counts, and each distinct -recovery kind already retained on an attempt contributes once to its coarse class. The Antigravity validation-refusal sibling resend records `oauth-account-403`, which Logs labels from the shared recovery roster. +recovery kind already retained on an attempt contributes once to its coarse class. The Antigravity validation-refusal sibling resend records `oauth-account-403`, which Logs labels from the shared recovery roster. A Codex HTTP 402 account move records `oauth-account-402`, maps to `quota-exhausted` and the `quota` metric class, and has a localized Logs label; HTTP 429 remains a rate-limit recovery. `opencodex_request_failures_total` counts the cause the recorder derived and never re-derives one, and it labels a counter only: no histogram carries a cause. HTTP 200 never overrides a failed terminal event. Duration observes every valid finalized duration; TTFT observes diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index e4f670bafbd..5b08085a0d5 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -203,7 +203,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; a search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing, and the replayed pair is dropped by the inbound translation, so a result without `encrypted_content` never reaches Anthropic. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; a search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing, and the replayed pair is dropped by the inbound translation, so a result without `encrypted_content` never reaches Anthropic. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input loses its query or URL label without losing the result. Citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/structure/providers/generic-account-pool.md b/structure/providers/generic-account-pool.md index 1ba25dc9ce3..26846e9af1b 100644 --- a/structure/providers/generic-account-pool.md +++ b/structure/providers/generic-account-pool.md @@ -23,3 +23,11 @@ are excluded. `tests/oauth/generic-session-affinity.test.ts` and `tests/server/server-generic-oauth-affinity.test.ts` exercise these boundaries with synthetic accounts. `src/types/provider.ts` declares the optional `sessionAffinity` mode; omission keeps existing behavior. + +## Combo sibling probes + +`src/server/responses/core-combo.ts` asks `src/oauth/generic-account-failover.ts` whether a refused +account has an eligible sibling before cooling the whole target. This probe records the refused +account's cooldown but passes `commit: false`, so it only peeks at a round-robin pool. An unsent +sibling does not consume a sticky turn. Dispatching callers retain the default committed pick. +`tests/oauth/generic-oauth-combo-account-cooldown.test.ts` covers this distinction. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b1e586d3e89..771530b8d2f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -434,7 +434,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, `core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, `core-opaque-recovery.ts` and `core-replay.ts`. `core-options.ts` owns the public option types -and small composition contracts, including [finite model-refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence). Existing public helper names are re-exported by `core.ts`. After an admitted Anthropic OAuth pool rotation, `adapter-dispatch.ts` rebinds reasoning replay to the committed credential snapshot; `adapter-continuation.ts` rebinds both its continuation clone and the outer request that persists the response. The replacement send strips the previous account's private state, while persisted ownership and the next turn follow the serving account. Codex alternate-account sends in `core-codex-account.ts` record `oauth-account-429` for quota recovery and `transient-5xx` for transient recovery, preserving the first response's wire status when sealing its attempt; gated-model moves keep their existing unattributed kind. +and small composition contracts, including [finite model-refusal evidence](../providers-and-adapters.md#combo-model-refusal-evidence). Existing public helper names are re-exported by `core.ts`. After an admitted Anthropic OAuth pool rotation, `adapter-dispatch.ts` rebinds reasoning replay to the committed credential snapshot; `adapter-continuation.ts` rebinds both its continuation clone and the outer request that persists the response. The replacement send strips the previous account's private state, while persisted ownership and the next turn follow the serving account. Codex alternate-account sends in `core-codex-account.ts` record `oauth-account-402` for HTTP 402 quota recovery, `oauth-account-429` for HTTP 429 rate-limit recovery, and `transient-5xx` for transient recovery, preserving the first response's wire status when sealing its attempt; gated-model moves keep their existing unattributed kind. Adapter construction remains with the existing registry; `fetch-helpers.ts` remains a leaf. For Kiro OAuth with load settings, `request-transport.ts` acquires a lease on the admitted account and transfers it before a reactive replacement send; cancellation permanently fences the request holder so recovery cannot install a late lease after abort cleanup. `core.ts` and `core-lifetime.ts` release the lease on returned-body completion, error, or cancellation, outside the inner admission `finally`. Mutable values are not copied across phases. A phase exposes only the values consumed by later diff --git a/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 75e45c572cf..35b385f3d0a 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -175,6 +175,17 @@ describe("server-side search across responses", () => { expect(starts[1]).toMatchObject({ tool_use_id: starts[0]!.id, content: [{ type: "web_search_result", url: "https://bun.sh/blog" }] }); }); + test("a single input fragment past 4 KiB is not retained beyond the cap", async () => { + const query = "q".repeat(5000); + const { clientSse } = await claudeTurn("https://api.anthropic.com", [ + ...SEARCH_TURN.slice(0, 2), + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }, + ...SEARCH_TURN.slice(4), + ]); + expect(blockStarts(clientSse).map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + expect(clientSse).not.toContain(query); + }); + test("a replayed search pair without encrypted_content never reaches Anthropic", async () => { const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ { role: "user", content: "What did Bun release?" }, diff --git a/tests/lib/failure-stage-model.test.ts b/tests/lib/failure-stage-model.test.ts index 893f85b6178..078942991fd 100644 --- a/tests/lib/failure-stage-model.test.ts +++ b/tests/lib/failure-stage-model.test.ts @@ -199,8 +199,10 @@ describe("recovery kinds speak the shared dictionary", () => { * the durable log were forbidden and unfunded. */ test("no recorded recovery classifies as a resend that cannot help", () => { + // Except the Codex 402 account move: re-asking the exhausted account is futile, and the move + // to another account is a route decision this table explicitly does not make. const futile = ATTEMPT_RECOVERY_KIND_ROSTER .filter(kind => causeDisposition(causeForRecoveryKind(kind)) === "resend-is-futile"); - expect(futile).toEqual([]); + expect(futile).toEqual(["oauth-account-402"]); }); }); diff --git a/tests/oauth/generic-oauth-combo-account-cooldown.test.ts b/tests/oauth/generic-oauth-combo-account-cooldown.test.ts index 0ec326ed748..b8ec163c75d 100644 --- a/tests/oauth/generic-oauth-combo-account-cooldown.test.ts +++ b/tests/oauth/generic-oauth-combo-account-cooldown.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearComboSelectionState, clearComboTargetCooldowns, isComboTargetInCooldown } from "../../src/combos"; import { clearGenericFailoverHealth, eligibleFailoverAccounts } from "../../src/oauth/generic-account-failover"; +import { genericPoolKey, peekRoundRobinAccount } from "../../src/oauth/pool-kernel"; import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; import { clearAccountQuotaCache } from "../../src/providers/quota"; import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; @@ -129,3 +130,20 @@ test("every generic OAuth account spent still cools the combo target", async () expect(sends).toEqual(["1", "2", "B"]); expect(isComboTargetInCooldown("pooled", TARGET)).toBe(true); }, 20_000); + +test("the combo's sibling check does not advance a round-robin pool onto an unsent account", async () => { + await seed(5); + upstream(4); + const roundRobin: OcxConfig = { ...config, pool: { kernel: true }, providers: { ...config.providers, + xai: { ...config.providers.xai!, oauthAccountFailover: { strategy: "round-robin", stickyLimit: 3 } } } }; + const ids = getAccountSet("xai")!.accounts.map(account => account.id); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/pooled", input: "Answer briefly", stream: false }), + }), roundRobin, { model: "", provider: "" }); + expect(await response.text()).toContain("fallback answer"); + expect(sends).not.toContain("5"); + expect(isComboTargetInCooldown("pooled", TARGET)).toBe(false); + // Account 5 was only probed for eligibility, never sent; the ring must not stick to it. + expect(peekRoundRobinAccount(genericPoolKey("xai"), ids, 3)).not.toBe(ids[4]); +}, 20_000); diff --git a/tests/responses/responses-account-label.test.ts b/tests/responses/responses-account-label.test.ts index c127c0ae2c0..a88745c707c 100644 --- a/tests/responses/responses-account-label.test.ts +++ b/tests/responses/responses-account-label.test.ts @@ -11,7 +11,8 @@ import { clearThreadAccountMap, getCodexUpstreamHealth, } from "../../src/codex/routing"; -import type { RequestLogContext } from "../../src/server/request-log"; +import { addFinalRequestLog, type RequestLogContext } from "../../src/server/request-log"; +import { createRequestMetricsOwner } from "../../src/server/request-metrics"; import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -336,6 +337,37 @@ describe("Responses account usage attribution", () => { }); }); + test("a pre-stream 402 move is recorded and counted as a quota recovery, not a rate limit", async () => { + await withPoolHome(async () => { + takeSpendHome(); + const config = poolConfig(["pool-a", "pool-b"]); + for (const id of ["pool-a", "pool-b"]) { + savePoolCredential(id); + updateAccountQuota(id, id === "pool-a" ? 10 : 20); + } + let sends = 0; + globalThis.fetch = (async () => ++sends === 1 + ? Response.json({ error: { message: "payment required" } }, { status: 402 }) + : completedResponse("pool-b-response")) as typeof fetch; + + const metrics = createRequestMetricsOwner(123); + const logCtx: RequestLogContext = { model: "", provider: "", inboundProtocol: "responses", requestMetricsRecorder: metrics }; + const response = await handleResponses(request(), config, logCtx, {}); + expect(response.status).toBe(200); + expect(logCtx.attempts?.map(attempt => [attempt.status, attempt.recoveryKinds])).toEqual([ + [402, []], + [0, ["oauth-account-402"]], + ]); + + addFinalRequestLog("codex-402-move", Date.now() - 1_000, logCtx, 200, { + terminalStatus: "completed", closeReason: "terminal", + }, () => {}); + const recoveries = metrics.snapshot().split("\n").filter(line => line.startsWith("opencodex_recoveries_total{")); + expect(recoveries.find(line => line.includes('recovery="quota"'))?.endsWith(" 1")).toBe(true); + expect(recoveries.find(line => line.includes('recovery="rate_limit"'))?.endsWith(" 0") ?? true).toBe(true); + }); + }); + test("a quota message wrapped in HTTP 502 cools the account and retries an alternate", async () => { await withPoolHome(async () => { takeSpendHome(); diff --git a/tests/usage/usage-partly-measured-cost.test.ts b/tests/usage/usage-partly-measured-cost.test.ts index 2bafc408a0d..29bb87f49f9 100644 --- a/tests/usage/usage-partly-measured-cost.test.ts +++ b/tests/usage/usage-partly-measured-cost.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { computeEntryCost } from "../../src/usage/summary"; +import { computeEntryCost, summarizeUsage } from "../../src/usage/summary"; +import { aggregateAttemptUsage } from "../../src/server/request-log"; import type { PersistedUsageEntry } from "../../src/usage/log"; type Attempt = NonNullable[number]; @@ -31,3 +32,73 @@ test("an upstream refusal without usage remains a known zero alongside measured expect(result.isPriced).toBe(true); expect(result.costTotal).toBeGreaterThan(0); }); + +test("a refused retry and a measured send total the measured cost on the summary", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [{ ...withoutUsage, status: 429, usageStatus: "unreported" }, { ...measured, ordinal: 2 }]; + // The row request-log writes for a non-combo retry: status and usage aggregate its attempts. + const aggregate = aggregateAttemptUsage(attempts); + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + const { costTotal } = computeEntryCost(entry); + expect(summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)).summary).toMatchObject({ + requests: 1, pricedRequests: 1, unpricedRequests: 0, unmeteredRequests: 0, estimatedCostUsd: costTotal, + }); +}); + +test("a send the client cancelled before output is an unknown cost, not a known zero", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [measured, { ...withoutUsage, ordinal: 2, status: 499, usageStatus: "unreported" }]; + const aggregate = aggregateAttemptUsage(attempts); + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + expect(computeEntryCost(entry).isPriced).toBe(false); + const { summary, models } = summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)); + expect(summary).toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 1, unmeteredRequests: 0 }); + expect(models).toEqual([expect.objectContaining({ model: "gpt-5.5", pricedRequests: 0, unpricedRequests: 1 })]); +}); + +test("a refusal cannot hide a measured attempt with no matching price", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [ + { ...withoutUsage, status: 429, usageStatus: "unreported" }, + { ...measured, ordinal: 2, provider: "unknown-provider", model: "unknown-model" }, + { ...measured, ordinal: 3 }, + ]; + const entryFor = (legs: Attempt[]): PersistedUsageEntry => { + const aggregate = aggregateAttemptUsage(legs); + return { ...row(legs), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + }; + const withRefusal = entryFor(attempts); + const withoutRefusal = entryFor(attempts.slice(1)); + expect(withRefusal.usageStatus).toBe("unreported"); + expect(withoutRefusal.usageStatus).toBe("reported"); + const cost = computeEntryCost(withRefusal); + const baselineCost = computeEntryCost(withoutRefusal); + expect(baselineCost.isPriced).toBe(true); + expect(baselineCost.costTotal).toBeGreaterThan(0); + expect(cost.attemptEstimates?.map(Boolean)).toEqual([false, false, true]); + expect({ ...cost, attemptEstimates: cost.attemptEstimates?.slice(1) }).toEqual(baselineCost); + const summarize = (entry: PersistedUsageEntry) => summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)); + const actual = summarize(withRefusal); + const baseline = summarize(withoutRefusal); + const classification = ({ pricedRequests, unpricedRequests, unmeteredRequests, estimatedCostUsd }: typeof actual.summary) => + ({ pricedRequests, unpricedRequests, unmeteredRequests, estimatedCostUsd }); + expect(classification(baseline.summary)).toEqual({ + pricedRequests: 1, unpricedRequests: 0, unmeteredRequests: 0, estimatedCostUsd: baselineCost.costTotal, + }); + expect(classification(actual.summary)).toEqual(classification(baseline.summary)); + // Compare model usage and pricing; the refusal adds an unreported physical attempt. + const models = (result: typeof actual) => result.models.map(({ provider, model, requests, inputTokens, + outputTokens, totalTokens, pricedRequests, unpricedRequests, priceCoverageRatio, estimatedCostUsd }) => + ({ provider, model, requests, inputTokens, outputTokens, totalTokens, + pricedRequests, unpricedRequests, priceCoverageRatio, estimatedCostUsd })) + .sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`)); + expect(models(actual)).toEqual(models(baseline)); +}); + +test("a row of known-zero refusals stays unmetered", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [{ ...withoutUsage, status: 429, usageStatus: "unreported" }]; + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28) }; + expect(summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)).summary) + .toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 0, unmeteredRequests: 1 }); +}); From 520865e31a0cd6f5b9b3dd0918686271725748c1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 08:32:03 -0700 Subject: [PATCH 04/14] fix(startup): skip unused native Codex ownership probes (#7184) When the Codex startup intent gate is closed (Codex OFF, a sibling, or a hub without local integration), startup now skips the two native-Codex service-manager ownership inspections and native-main preparation. On Windows those inspections run synchronous Task Scheduler/service children before the runtime record is published, and the disabled lifecycle never uses their answers. Enabled startup keeps both ownership decisions, including the fresh second listing that detects a concurrent installer. ACL hardening, live-owner discovery and startup deadlines are unchanged. The claim is limited to removing unnecessary probes; the reported 45-second owner-start timeout is not shown to be resolved. --- scripts/test-layout/layout.json | 1 + src/server/index.ts | 14 +++--- structure/runtime.md | 4 +- .../bearer-admission-routed-provider.test.ts | 14 ++++-- ...ex-service-manager-probe-hardening.test.ts | 7 ++- tests/fixtures/test-layout-expected.json | 1 + tests/server/server-startup-codex-off.test.ts | 47 +++++++++++++++++++ 7 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 tests/server/server-startup-codex-off.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f8bd26a990d..679a0bb95c7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1656,6 +1656,7 @@ "server-request-body-size.test.ts": "server", "server-search.test.ts": "server", "server-startup-reconcile-resilience.test.ts": "server", + "server-startup-codex-off.test.ts": "server", "server-stop-config-hardening.test.ts": "server", "server-xai-chat-reasoning-streaming.test.ts": "server", "server-xai-header-parity.test.ts": "server", diff --git a/src/server/index.ts b/src/server/index.ts index d7e60c1c953..0850f3c1761 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -267,17 +267,17 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe startupOwnershipHomes = homes; startupOwnershipStatePaths = statePaths; } catch { /* inspection below stays unknown */ } - const startupCacheOwnership = inspectStartupOwnership( + const startupCacheOwnership = shouldSyncCodexOnStart(config) ? inspectStartupOwnership( deps, startupOwnershipHomes, startupOwnershipStatePaths, startupWindowsTaskListingCache, - ); + ) : null; // Startup cache invalidation is best-effort and applies only when startup sync is enabled. // Check OFF before taking K: on Windows K resolves SID + LocalAppData through PowerShell, // and run 35093667426 exceeded healthy controls by 33.8 s against that 30 s child budget. // The permit callback still re-reads intent under K to close a concurrent disable race. - if (shouldSyncCodexOnStart(config) && startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) { + if (startupCacheOwnership?.ownership === "owned" && startupOwnershipHomes !== null) { try { const startupCodexHome = startupOwnershipHomes.codexHome; // #1046: record whether this actually rewrote the cache. `handleStart` ORs this @@ -549,12 +549,12 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe // clients; no Codex request can use this lifecycle in that state. // Re-probe here instead of trusting the earlier cache decision: startup work // between the two sites must not widen the service-install race. - const nativeOwnership = inspectStartupOwnership( + const nativeOwnership = shouldSyncCodexOnStart(config) ? inspectStartupOwnership( deps, startupOwnershipHomes, startupOwnershipStatePaths, - ); - const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign" + ) : null; + const preparedNativeMainLifecycle = nativeOwnership !== null && nativeOwnership.ownership !== "foreign" && startupOwnershipHomes !== null ? prepareNativeMainStartupLifecycle( deps.nativeMainStartup, @@ -600,7 +600,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe return retryPreparedNativeMainLifecycle.start(); }, }; - const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config) + const nativeMainLifecycle: NativeMainStartupLifecycle = nativeOwnership !== null ? nativeOwnership.ownership === "owned" ? preparedNativeMainLifecycle ? preparedNativeMainLifecycle.start() diff --git a/structure/runtime.md b/structure/runtime.md index 57f8ea1c825..669a2fb256b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -202,8 +202,8 @@ independent. Ordinary stop drains the spend reporters after listener teardown be reference. `src/server/index/spend-ledger-lifecycle.ts` also holds the lease through failed-start listener rollback. Enforced seeds remain live until reporters close and settlement plus send-ID forgetting are durable; scope totals survive forgetting. SQLite and the OS release a crashed owner; no PID, timestamp, TTL or lock-file deletion participates in recovery. Startup passes the provider roster and top-level pool aliases with the spend policy; live state reconciliation refreshes both without opening an unused ledger. Under the [request-start policy](transports/responses-spend.md#durable-spend-reservations), existing requests retain their initial enforcement mode, numeric ceilings and L; changed limits apply to later requests. HTTP admission checks [historical continuity](transports/responses-spend.md#historical-pool-continuity-and-rollback) even without a root; failed accounting reads release the active-turn lease. Rooted continuity denials record exactly one workflow refusal event before response formatting. -An explicit Codex integration OFF skips startup cache invalidation before the user-scoped catalog -serialization lock is resolved. Explicit `sync` and `sync-cache` retain their catalog-only override. +An explicit Codex integration OFF skips startup ownership probes, native-main preparation and cache invalidation before resolving the catalog lock. +Enabled startup retains separate fresh ownership decisions. Explicit `sync` and `sync-cache` retain their catalog-only override. `startServer` composes up to four sockets in one synchronous startup transaction: the public data listener, optional unauthenticated data-loopback and hub-management listeners, and the optional `hub-link` listener, which opens only for a recorded link and persists its concrete `127.0.0.1:`. HTTP Responses/Messages apply the [caller header contract](data-planes/inbound-compat.md#http-caller-conversation-identity) before turn admission and dispatch. Linked-machine data uses the [connection-bound relay contract](remote-link.md#connection-bound-relay-authentication); client-local credentials and routing policy remain unchanged. The data-loopback socket serves a fixed data-plane allowlist: Responses and its compact sibling, diff --git a/tests/codex-integration/bearer-admission-routed-provider.test.ts b/tests/codex-integration/bearer-admission-routed-provider.test.ts index 2ab77af91bd..21d036a0544 100644 --- a/tests/codex-integration/bearer-admission-routed-provider.test.ts +++ b/tests/codex-integration/bearer-admission-routed-provider.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import http2 from "node:http2"; import { tmpdir } from "node:os"; @@ -17,10 +17,13 @@ import { handleNativeProfileAPI } from "../../src/codex/native-profile-api"; import type { NativeProfileManager } from "../../src/codex/native-profile-manager"; import type { OcxConfig } from "../../src/types"; import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { resetVisionDescriptionCache } from "../../src/vision"; import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +// Every case starts a real server; on Windows its config-dir ACL work alone can exceed 5 s. +setDefaultTimeout(SERVER_BUDGET_MS); import { saveCodexAccountCredential } from "../../src/codex/account-store"; /** @@ -195,7 +198,7 @@ beforeEach(() => { }) as typeof fetch; }); -afterEach(() => { +afterEach(async () => { resetVisionDescriptionCache(); closeRequestHistoryIndex(); clearComboTargetCooldowns(); @@ -209,8 +212,9 @@ afterEach(() => { else process.env.CODEX_HOME = previousCodexHome; if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken; - if (ocxHome) removeTreeWithRetry(ocxHome); - if (codexHome) removeTreeWithRetry(codexHome); + // Startup's config-dir ACL hardening may still hold the homes on Windows; settle it first. + if (ocxHome) await removeTreeAfterBackgroundWork(ocxHome); + if (codexHome) await removeTreeAfterBackgroundWork(codexHome); ocxHome = ""; codexHome = ""; }); diff --git a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts index d7b8736ef75..9b1ea42da06 100644 --- a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts +++ b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts @@ -230,7 +230,7 @@ describe("Windows ownership probe hardening regressions", () => { ...getDefaultConfig(), port: 0, hostname: "127.0.0.1", - clientIntegrations: { codex: false }, + clientIntegrations: { codex: true }, subagentModels: [], }, null, 2)); @@ -278,7 +278,10 @@ describe("Windows ownership probe hardening regressions", () => { winswStatus: () => "nonexistent", }); ownerships.push(answer.ownership); - return answer; + // Exercise enabled startup's two real inspections without taking the unrelated + // user-scoped catalog lock (which resolves Windows identity through PowerShell). + // Preserve each raw verdict above so a stale second listing still fails this test. + return ownerships.length === 1 ? { ownership: "unknown", reason: "cache writes outside this fixture" } : answer; } finally { setTrustedWindowsSystemDirectoryResolverForTests(null); } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fcca881b055..e8e81b968a1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1126,6 +1126,7 @@ "server-opencode-go-goal-streaming.test.ts": "server", "server-rate-limit-retry-e2e.test.ts": "server", "server-request-body-size.test.ts": "server", "server-search.test.ts": "server", "server-startup-reconcile-resilience.test.ts": "server", "server-stop-config-hardening.test.ts": "server", + "server-startup-codex-off.test.ts": "server", "server-xai-chat-reasoning-streaming.test.ts": "server", "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", "service-auth-qualified-localhost.test.ts": "service", "service-child-ownership.test.ts": "service", diff --git a/tests/server/server-startup-codex-off.test.ts b/tests/server/server-startup-codex-off.test.ts new file mode 100644 index 00000000000..699ce9421b5 --- /dev/null +++ b/tests/server/server-startup-codex-off.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { installIsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +test("Codex OFF starts without service-manager probes or native-main preparation", async () => { + const priorHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-start-codex-off-")); + const codex = installIsolatedCodexHome("ocx-start-codex-off-client-"); + process.env.OPENCODEX_HOME = home; + let inspections = 0; + let preparations = 0; + let server: ReturnType | undefined; + try { + saveConfig({ + providers: { fixture: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true } }, + defaultProvider: "fixture", port: 0, hostname: "127.0.0.1", + clientIntegrations: { codex: false }, checkForUpdates: false, + syncResumeHistory: false, claudeCode: { intercept: { enabled: false } }, + }); + server = startServer(0, { + inspectNativeCodexOwnership: () => { + inspections++; + return { ownership: "owned", reason: "unused Codex OFF probe" }; + }, + nativeMainStartup: { + get manager() { + preparations++; + throw new Error("Codex OFF must not prepare a native manager"); + }, + }, + }); + expect(inspections).toBe(0); + expect(preparations).toBe(0); + expect((await fetch(`http://127.0.0.1:${server.port}/healthz`)).status).toBe(200); + } finally { + await server?.stop(true); + codex.restore(); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + removeTreeWithRetry(home); + } +}); From 5d3a2fc0b8dd0697fb8cc04f7dc4c1e0fefa2c8e Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 08:37:23 -0700 Subject: [PATCH 05/14] fix(claude): replay Anthropic server-tool exchanges on the translated lane (carry #7205) (#7251) Carries vadymhimself #7205, including its newest head 53a3ca2f3a. On the translated Claude lane, an Anthropic server-tool exchange (web search/fetch) from history is replayed only into the assistant turn that produced it. An anchor that occurs exactly once replays. An ambiguous (repeated) anchor drops the exchange, so a later identical turn can no longer take over an earlier exchange. A result is replayed only after its matching call with the same id and type earlier in the turn. A call must carry a plain-object input with a nonblank query or url. Orphan results never synthesize a call. Anything that cannot be proven valid is dropped, which is the pre-#7205 behaviour. The anchor turn's text is preserved byte-for-byte. Co-authored-by: Vadym O --- .../src/content/docs/guides/claude-code.md | 2 +- src/adapters/anthropic.ts | 66 ++--- src/bridge/internal.ts | 8 +- src/bridge/response-json.ts | 4 +- src/bridge/sse.ts | 13 +- src/claude/inbound-content-options.ts | 66 +++++ src/claude/outbound.ts | 18 +- src/protocols/encoders/adapter-events.ts | 10 +- src/protocols/encoders/messages.ts | 4 +- src/server/claude-messages.ts | 3 +- src/server/responses/core-options.ts | 2 + src/server/responses/request-prepare.ts | 1 + src/server/responses/response-effects.ts | 1 + src/types.ts | 1 + src/types/request.ts | 12 +- structure/clients/claude-desktop.md | 2 +- structure/providers-and-adapters.md | 2 +- .../claude-translated-server-tools.test.ts | 236 +++++++++++++++++- 18 files changed, 386 insertions(+), 65 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 159886a85e0..eb1b9b9d14a 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -962,7 +962,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | | `tool_reference` in a tool result | Text `Tool loaded: ` in the paired result; preserves loaded-tool names without adding declarations or enabling translated server-side deferral | | `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | -| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. Each search or fetch comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result` | +| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. A search or fetch with a matching call and complete input comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result`, verbatim. Orphan results are dropped without synthesizing a call, while the surrounding text is preserved. When the client sends a complete pair back in its history, the next `api.anthropic.com` request carries it in place only if the following block uniquely identifies its original turn. Repeated anchors, unmatched or duplicate call/result ids, malformed call input, or a result appearing before its call drop the exchange. Call input must be an object with a nonempty search query or fetch URL string; other destinations never see the exchange | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 140bd336f3b..b71e9a5b090 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -522,13 +522,13 @@ function streamedToolArgumentsParse(assembled: string): boolean { } } -/** A string field of a server tool's streamed input (web_search `query`, web_fetch `url`); "" when it did not parse. */ -function serverToolInputField(json: string, key: "query" | "url"): string { +/** A server tool's complete streamed input; undefined when it cannot be recovered. */ +function serverToolInput(json: string): Record | undefined { try { - const value = (JSON.parse(json) as Record | null)?.[key]; - return typeof value === "string" ? value : ""; + const input = JSON.parse(json) as unknown; + return isAnthropicRecord(input) ? input : undefined; } catch { - return ""; + return undefined; } } @@ -735,7 +735,16 @@ function orphanToolResultContent(msg: OcxToolResultMessage): string | unknown[] function messagesToAnthropicFormat( parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }, + serverReplay?: ReadonlyMap[]>, ): { system: string | undefined; messages: unknown[] } { + // The client's server-tool exchanges go back ahead of the block they preceded. Their anchors are + // unique in the caller's history (replayedAnthropicServerBlocks drops repeated ones); each is used once. + const pendingReplay = new Map(serverReplay); + const replayed = (anchor: string) => { + const blocks = pendingReplay.get(anchor) ?? []; + pendingReplay.delete(anchor); + return blocks; + }; // One allocator for the whole request: a tool_result must resolve to the SAME wire id its // call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed // first so a rewritten id can never squat on an id another call legitimately owns. @@ -789,9 +798,13 @@ function messagesToAnthropicFormat( for (const part of aMsg.content) { if (part.type === "text") { const text = (part as OcxTextContent).text; + for (const b of replayed(`text:${text}`)) appendPreface(b); if (text) appendPreface({ type: "text", text }); } else if (part.type === "thinking") { const t = part as OcxThinkingContent; + // Server blocks that preceded this reasoning keep their place, even when it is dropped below. + for (const data of t.redacted ?? []) for (const b of replayed(`redacted:${data}`)) appendPreface(b); + for (const b of replayed(`thinking:${t.signature}`)) appendPreface(b); // History minted under another serving identity (or already rejected as opaque) is not // this destination's to verify: drop its opaque blocks, as the Responses passthrough does. if (parsed._stripReasoningEncryptedContent === true) continue; @@ -806,6 +819,7 @@ function messagesToAnthropicFormat( } } else if (part.type === "toolCall") { const tc = part as OcxToolCall; + for (const b of replayed(`call:${tc.id}`)) appendPreface(b); const flatName = namespacedToolName(tc.namespace, tc.name); // Normalized here, and identically for the matching tool_result above, so a history // replayed from another provider path keeps its call/result pairing (#1767). @@ -998,7 +1012,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)"); } - const { system, messages } = messagesToAnthropicFormat(parsed, toolNames); + // Only api.anthropic.com runs Anthropic's server tools, so only it gets their history back. + const { system, messages } = messagesToAnthropicFormat(parsed, toolNames, + usesNativeAnthropicEndpoint(provider) ? parsed._anthropicServerReplay : undefined); // Before image normalization, so the framing block is present for every downstream pass. if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages); // Primary image layer: resize/re-encode to fit Anthropic limits without dropping @@ -1336,36 +1352,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // no result in this response and must not surface as a completed search. serverSearch = { id: block.id, json: "", streaming: true }; } - if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") { + if ((block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") && typeof block.tool_use_id === "string") { + // The begin/end pair the sidecar emits, plus both blocks verbatim so the Claude client holds a + // replayable copy. Without this response's matching call and complete input, drop the + // exchange instead of inventing a call that could poison the client's next request. + const fetch = block.type === "web_fetch_tool_result"; + const input = serverSearch?.id === block.tool_use_id ? serverToolInput(serverSearch.json) : undefined; + if (!input) break; const hits = Array.isArray(block.content) ? block.content as Array<{ url?: unknown; title?: unknown }> : undefined; - // The begin/end pair the sidecar emits. A result whose call came in an earlier response has no query here. - const query = serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "query") : ""; yield { type: "web_search_call_begin", id: block.tool_use_id }; yield { type: "web_search_call_end", id: block.tool_use_id, - queries: query ? [query] : [], - // A failed search carries one error object instead of the hit list. - status: hits ? "completed" : "failed", + queries: typeof input.query === "string" && input.query.length > 0 ? [input.query] : [], + // A failed call carries one error object instead of the hit list / fetched page. + status: (fetch ? (block.content as { type?: unknown } | undefined)?.type === "web_fetch_result" : hits) ? "completed" : "failed", sources: (hits ?? []).flatMap(h => typeof h.url === "string" ? [{ url: h.url, ...(typeof h.title === "string" ? { title: h.title } : {}) }] : []), - }; - serverSearch = undefined; - } - if (block.type === "web_fetch_tool_result" && typeof block.tool_use_id === "string") { - // Relayed as the same cell, opened at the result for the same reason; the result is - // carried verbatim (a web_fetch_result document or a web_fetch_tool_error). - const result = block.content ?? { type: "web_fetch_tool_error", error_code: "unavailable" }; - const url = (result as { url?: unknown }).url; - yield { type: "web_search_call_begin", id: block.tool_use_id }; - yield { - type: "web_search_call_end", - id: block.tool_use_id, - queries: [], - status: (result as { type?: unknown }).type === "web_fetch_result" ? "completed" : "failed", - fetch: { - url: typeof url === "string" ? url : serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "url") : "", - result, + anthropic: { + use: { type: "server_tool_use", id: block.tool_use_id, name: fetch ? "web_fetch" : "web_search", input }, + result: block as Record, }, }; serverSearch = undefined; @@ -1415,7 +1421,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti yield { type: "tool_call_delta", arguments: delta.partial_json }; } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming && serverSearch.json.length < 4096) { - // Capped: an input past 4 KiB loses its label (query / url), never the call. + // Capped: input that cannot be recovered drops its exchange instead of inventing a call. serverSearch.json += delta.partial_json.slice(0, 4096 - serverSearch.json.length); } break; diff --git a/src/bridge/internal.ts b/src/bridge/internal.ts index 45e70a610c4..4c2f7e2352d 100644 --- a/src/bridge/internal.ts +++ b/src/bridge/internal.ts @@ -1,5 +1,6 @@ import type { AdapterEvent, + AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, @@ -186,9 +187,12 @@ export function adapterFailureFromEvent(event: Extract { +export function webSearchAction(queries: string[], anthropic?: AnthropicServerBlocks): Record { // Anthropic's server-side web_fetch opens one page instead of searching. - if (fetch) return { type: "open_page", url: fetch.url }; + if (anthropic?.use.name === "web_fetch") { + const url = (anthropic.result.content as { url?: unknown } | undefined)?.url ?? (anthropic.use.input as { url?: unknown }).url; + return { type: "open_page", url: typeof url === "string" ? url : "" }; + } const first = queries[0] ?? ""; return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] }; } diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index 6ac31fb3e24..72d0bdbc0f1 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -547,9 +547,9 @@ function buildResponseJSONWithBudget( const safeSources = safeWebSearchSources(e.sources); pushOutput({ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed", - action: webSearchAction(e.queries, e.fetch), + action: webSearchAction(e.queries, e.anthropic), ...(safeSources.length > 0 ? { sources: safeSources } : {}), - ...(e.fetch ? { fetch_result: e.fetch.result } : {}), + ...(e.anthropic ? { anthropic_blocks: e.anthropic } : {}), }); if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 4f929edceef..79bd5a4c4db 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1,6 +1,7 @@ import { anthropicProgressItems } from "../responses/anthropic-progress"; import type { AdapterEvent, + AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, @@ -685,15 +686,15 @@ export function bridgeToResponsesSSE( // leaves a "Searching the web" spinner spinning forever. // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so // downstream translators (claude outbound) can fill web_search_tool_result content. - // `fetch` (Anthropic's server-side web_fetch) makes the cell a page open and rides its result - // verbatim as `fetch_result`, for the same claude outbound translator. - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: { url: string; result: unknown }) => { + // `anthropic` (Anthropic's server-side call) rides verbatim as `anthropic_blocks`, for the same + // claude outbound translator; a web_fetch makes the cell a page open. + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => { if (!currentWebSearch) return; const item = { type: "web_search_call", id: currentWebSearch.itemId, status, - action: webSearchAction(queries, fetch), + action: webSearchAction(queries, anthropic), ...(sources && sources.length > 0 ? { sources } : {}), - ...(fetch ? { fetch_result: fetch.result } : {}), + ...(anthropic ? { anthropic_blocks: anthropic } : {}), }; emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item }); retainFinishedItem(item as OutputItem); @@ -1231,7 +1232,7 @@ export function bridgeToResponsesSSE( currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; } const safeSources = safeWebSearchSources(event.sources); - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.fetch); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.anthropic); // Queue this search's sources for the next assistant message (dedup by URL). if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 1358de6e1a8..25139ea20f4 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -87,6 +87,72 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { return tools.length > 0 ? tools : undefined; } +/** Validate original call/result order without inventing inputs or pairing a later call backwards. */ +function replayableServerRun(run: readonly Rec[]): boolean { + const pending = new Map(); + const completed = new Set(); + for (const block of run) { + if (block.type === "server_tool_use") { + const { id, name, input } = block; + if (typeof id !== "string" || !id.startsWith("srvtoolu_") || pending.has(id) || completed.has(id) + || (name !== "web_search" && name !== "web_fetch") || !isRec(input)) return false; + const prototype = Object.getPrototypeOf(input); + const required = input[name === "web_search" ? "query" : "url"]; + if ((prototype !== Object.prototype && prototype !== null) + || typeof required !== "string" || required.trim().length === 0) return false; + pending.set(id, block); + } else { + const id = block.tool_use_id; + if (typeof id !== "string") return false; + const call = pending.get(id); + if (!call || block.type !== `${call.name}_tool_result` + || (Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string"))) return false; + pending.delete(id); + completed.add(id); + } + } + return completed.size > 0 && pending.size === 0; +} + +/** + * Completed Anthropic server-tool exchanges (`server_tool_use` + `web_search_tool_result` / + * `web_fetch_tool_result`) in the caller's assistant history. The Responses body drops them, so + * an Anthropic destination re-inserts them out of body, keyed by the block that follows each run + * in its message: `call:`, `text:`, `thinking:` or + * `redacted:`, all of which survive translation. An anchor that occurs more than once in the + * assistant history (string content included) cannot name one owner, so its run stays dropped, + * as it was before replay existed. A run with none, an unpaired call, a + * non-Anthropic id, or a search hit without `encrypted_content` is not replayable and stays dropped. + */ +export function replayedAnthropicServerBlocks(raw: unknown): Map | undefined { + if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined; + const out = new Map(); + const seen = new Map(); + const count = (anchor: string) => seen.set(anchor, (seen.get(anchor) ?? 0) + 1); + for (const message of raw.messages) { + if (!isRec(message) || message.role !== "assistant") continue; + if (typeof message.content === "string") count(`text:${message.content}`); + if (!Array.isArray(message.content)) continue; + let run: Rec[] = []; + for (const block of message.content.filter(isRec)) { + if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") { + // The caller's breakpoints are dropped on this lane, as for the declared tools above. + run.push({ ...block, cache_control: undefined }); + continue; + } + const anchor = block.type === "tool_use" ? `call:${String(block.id)}` + : block.type === "text" ? `text:${String(block.text)}` + : block.type === "thinking" ? `thinking:${String(block.signature)}` + : block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined; + if (anchor) count(anchor); + if (anchor && replayableServerRun(run)) out.set(anchor, run); + run = []; + } + } + for (const anchor of out.keys()) if ((seen.get(anchor) ?? 0) > 1) out.delete(anchor); + return out.size > 0 ? out : undefined; +} + export function toolChoiceToResponses(choice: unknown, body: Rec): void { if (!isRec(choice)) return; if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index c920d9acdb8..19c92d43603 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -223,16 +223,24 @@ export function sanitizeWebSearchInput(input: unknown): Rec { /** * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use * input (query/queries) and the web_search_tool_result content (hits, or the error - * object when the search failed). An item carrying `fetch_result` is Anthropic's - * server-side web_fetch and maps to web_fetch + its web_fetch_tool_result, verbatim. - * Shared by the SSE and JSON translation paths. + * object when the search failed). An item carrying `anthropic_blocks` came from Anthropic's + * own server-side call and maps back to those blocks verbatim (`srvtoolu_` id, + * `encrypted_content`, web_fetch), so the client can replay them on its next turn. + * Shared by the SSE, JSON and direct translation paths. */ export function webSearchPairFromItem(item: Rec): { - id: string; name: "web_search" | "web_fetch"; resultType: string; input: Rec; resultContent: unknown; completed: boolean; + id: string; name: string; resultType: string; input: Rec; resultContent: unknown; completed: boolean; } { const action = isRec(item.action) ? item.action : {}; - const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; const completed = item.status !== "failed"; + const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {}; + if (isRec(use) && isRec(result) && typeof use.id === "string" && isRec(use.input) + && (use.name === "web_search" || use.name === "web_fetch") + && result.tool_use_id === use.id && result.type === `${use.name}_tool_result`) { + return { id: use.id, name: use.name, resultType: result.type, input: use.input, resultContent: result.content, completed }; + } + const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; + // An item emitted before `anthropic_blocks` carried a server web_fetch as `fetch_result`. if (item.fetch_result !== undefined) { const input = { url: typeof action.url === "string" ? action.url : "" }; return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed }; diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index bb1be5ad2d9..cf240923bef 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -28,7 +28,7 @@ * completed-response effects belong to the caller, which folds the same events with * `buildResponseJSON` at the terminal through `beforeTerminal`. */ -import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types"; +import type { AdapterEvent, AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types"; import { normalizeDeclaredToolName } from "../../types"; import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers"; import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors"; @@ -61,7 +61,7 @@ export interface ClientWebSearch { status: "completed" | "failed"; queries: string[]; sources: { url: string; title?: string }[]; - fetch?: { url: string; result: unknown }; + anthropic?: AnthropicServerBlocks; } /** @@ -412,9 +412,9 @@ export function encodeAdapterEventStream( budget.closeCall(call.callId); currentToolCall = null; }; - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: ClientWebSearch["fetch"]) => { + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => { if (!currentWebSearch) return; - writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(fetch ? { fetch } : {}) }); + writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(anthropic ? { anthropic } : {}) }); currentWebSearch = null; }; const appendToolArgs = (call: OpenToolCall, fragment: string) => { @@ -689,7 +689,7 @@ export function encodeAdapterEventStream( if (currentWebSearch) closeCurrentWebSearch("completed", []); currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id }; } - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.fetch); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.anthropic); break; } case "done": { diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index 9aee92aee3b..9eee75fdb15 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -345,9 +345,9 @@ function createAnthropicMessageWriter( type: "web_search_call", id: search.itemId, status: search.status, - action: webSearchAction(search.queries, search.fetch), + action: webSearchAction(search.queries, search.anthropic), ...(search.sources.length > 0 ? { sources: search.sources } : {}), - ...(search.fetch ? { fetch_result: search.fetch.result } : {}), + ...(search.anthropic ? { anthropic_blocks: search.anthropic } : {}), }); const toolIndex = blockIndex++; emit("content_block_start", { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index d450d49ba5d..2d06e4e4db8 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -33,7 +33,7 @@ import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../cl import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; -import { declaredAnthropicServerTools } from "../claude/inbound-content-options"; +import { declaredAnthropicServerTools, replayedAnthropicServerBlocks } from "../claude/inbound-content-options"; import { claudeCodeForIngress } from "../claude/intercept/model-bindings"; import { classifyInterceptClient } from "../claude/intercept/client-class"; import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; @@ -1133,6 +1133,7 @@ async function handleClaudeMessagesWithBudget( nativeReasoningMint, // Out of body like the replay above: the core hands these to an Anthropic destination only. anthropicServerTools: declaredAnthropicServerTools(anthropicBody), + anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody), claudeGoAffinity: { sessionLane: claudeGoSessionLane }, claudeNativeSessionId, stripClaudeMainAuthForNoncanonicalForward: true, diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index daac9b1cebe..3ee37545491 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -177,6 +177,8 @@ export interface HandleResponsesOptions { nativeReasoningMint?: { owner?: NativeReasoningOwner }; /** The Claude client's typed `web_search_*` / `web_fetch_*` declarations; kept only for `inboundWire: "anthropic"`. */ anthropicServerTools?: readonly Record[]; + /** The Claude client's replayable server-tool exchanges (`replayedAnthropicServerBlocks`); same scope. */ + anthropicServerReplay?: ReadonlyMap[]>; /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */ droidDefaultEffort?: string; /** PF-07: the Chat source a combo child may send natively; set only by the Chat ingress. */ diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 71f1ec132e1..e7ebf8eedf8 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -476,6 +476,7 @@ export async function prepareResponsesRequest( parsed._nativeReasoningReplay = options.nativeReasoningReplay; parsed._nativeReasoningMint = options.nativeReasoningMint; parsed._anthropicServerTools = options.anthropicServerTools; + parsed._anthropicServerReplay = options.anthropicServerReplay; } parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // The body may have been rebuilt since the inbound observation (previous-response diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index 9b29aae01c4..e901594e401 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -90,6 +90,7 @@ export function createResponsesEffects( delete parsed.context.tools; delete parsed._webSearch; delete parsed._anthropicServerTools; + delete parsed._anthropicServerReplay; delete parsed.options.toolChoice; delete parsed.options.parallelToolCalls; // The compaction turn is a plain prose summary; a surviving structured-output format diff --git a/src/types.ts b/src/types.ts index 2abde60e2af..f94998ed276 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,7 @@ export type { OcxProviderContinuationOwner, OcxProviderContinuationState, AdapterEvent, + AnthropicServerBlocks, OcxUrlCitation, OcxUsage, } from "./types/request"; diff --git a/src/types/request.ts b/src/types/request.ts index 229d42c9ed5..6e014690b6c 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -68,6 +68,11 @@ export interface OcxParsedRequest { * them, and only for api.anthropic.com; every other destination never sees them. */ _anthropicServerTools?: readonly Record[]; + /** + * Completed server-tool exchanges from the Claude client's history, keyed by the block that + * followed them (`call:` / `text:`). Same audience as `_anthropicServerTools`. + */ + _anthropicServerReplay?: ReadonlyMap[]>; /** * Boundary between replayed history and this turn's newly appended input. Usually the * items the proxy restored from local previous_response_id state; also set when the @@ -414,6 +419,9 @@ export interface OcxProviderContinuationState { [provider: string]: Record | undefined; } +/** One Anthropic server-side call as the API returned it: `server_tool_use` + its `*_tool_result`. */ +export interface AnthropicServerBlocks { use: Record; result: Record } + export type AdapterEvent = | { type: "heartbeat"; replayUnsafe?: true; preflightReady?: true } | { type: "text_delta"; text: string; phase?: OcxMessagePhase; @@ -443,8 +451,8 @@ export type AdapterEvent = | { type: "web_search_call_begin"; id: string } | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[]; - /** Anthropic's server-side web_fetch (adapters/anthropic.ts): the opened page and its `web_fetch_tool_result` content, verbatim. */ - fetch?: { url: string; result: unknown }; + /** Anthropic's server-side call (adapters/anthropic.ts): its `server_tool_use` and result block, verbatim. */ + anthropic?: AnthropicServerBlocks; } | { type: "done"; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 32f81b346b2..ad90f16699d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -569,7 +569,7 @@ Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_ `src/claude/inbound.ts` bounds the text-carrier skill-directory probe to 4,096 UTF-16 code units, plus one character to recognize the terminating newline. A longer first line is preserved intact instead of being scanned or stubbed; normal POSIX, Windows, mixed and UNC separators retain their basename matching. The existing 10,000-character payload threshold and `claudeCode.blockedSkills` policy remain: `claude-api` is blocked by default, and an explicit empty list disables elision. Native Anthropic passthrough and tool-call/result pairing are unchanged. `tests/claude-integration/claude-inbound.test.ts` covers the exact 4,096/4,097 boundary and a long newline-free carrier. -`src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. The same module's `declaredAnthropicServerTools` reads the caller's typed `web_search_*` / `web_fetch_*` declarations unchanged; `src/server/claude-messages.ts` hands them to the Responses core out of body, like the native reasoning replay, and the core keeps them on `parsed._anthropicServerTools` only for `inboundWire: "anthropic"`, so the translated body and every non-Anthropic leg stay as before. `tests/claude-integration/claude-translated-server-tools.test.ts` covers the Anthropic wire, a third-party Anthropic-compatible endpoint and a GPT leg. The translated Messages lane preserves the caller's output cap for non-canonical Responses gateways; canonical ChatGPT parameter stripping remains in its adapter. An explicit operator `cacheRetention` wins; otherwise `src/claude/inbound.ts` selects long retention only when every declared caller breakpoint requests one hour, including nested content and top-level automatic caching. Mixed TTLs retain the default. +`src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. The same module's `declaredAnthropicServerTools` reads the caller's typed `web_search_*` / `web_fetch_*` declarations unchanged; `src/server/claude-messages.ts` hands them to the Responses core out of body, like the native reasoning replay, and the core keeps them on `parsed._anthropicServerTools` only for `inboundWire: "anthropic"`, so the translated body and every non-Anthropic leg stay as before. `replayedAnthropicServerBlocks` does the same for the completed `server_tool_use` + `web_search_tool_result` / `web_fetch_tool_result` runs in the caller's assistant history, which the translated body drops: each run is keyed by the block that follows it (a `tool_use` id, text, a thinking signature or redacted thinking data); an anchor that repeats anywhere in the assistant history, string content included, names no single owner, so its run stays dropped. Each result must follow exactly one matching server call in the same assistant turn, with the same id and tool type; duplicate ids, orphan results and reversed pairs stay dropped. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string. Validation walks the original block sequence once and never pairs a result with a later call. The adapter also drops a streamed result whose matching call or complete input is unavailable, preserving the text without synthesizing an empty-input call. The map travels as `parsed._anthropicServerReplay`. `tests/claude-integration/claude-translated-server-tools.test.ts` covers the Anthropic wire, a third-party Anthropic-compatible endpoint and a GPT leg. The translated Messages lane preserves the caller's output cap for non-canonical Responses gateways; canonical ChatGPT parameter stripping remains in its adapter. An explicit operator `cacheRetention` wins; otherwise `src/claude/inbound.ts` selects long retention only when every declared caller breakpoint requests one hour, including nested content and top-level automatic caching. Mixed TTLs retain the default. `src/claude/outbound.ts` carries a Responses provider's own `encrypted_content` in an `ocxr1:` `nat` envelope (`src/responses/reasoning-envelope.ts`) only when the serving attempt has committed and `src/server/responses/core-replay.ts` supplied a durable destination and credential owner. The `nat.tag` HMAC binds the exact blob to that owner and the installation's persisted salt. `src/claude/inbound.ts` restores the blob and item id only for the same client-facing model; each route binding verifies the tag against its effective endpoint, including `responsesPath`, and generation-bearing credential, then removes a mismatch from both the raw reasoning item and the parser's thinking-signature carrier before send. Missing owner or tag fails closed, including direct-forward credentials and OAuth generation changes. Native Anthropic ingress projects away every `nat` on both native branches, dropping blob-only blocks and leaving signed or visible-text fields intact. A thinking block that closes before serving commit has no `nat`. Translated bodies that reason request `include: ["reasoning.encrypted_content"]`; a body without reasoning does not. Anthropic `sig` and `red` envelopes keep their existing behavior. `tests/claude-integration/claude-native-reasoning-provenance.test.ts` covers route ownership and native projection. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 5b08085a0d5..28ed67a9c70 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -203,7 +203,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; a search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing, and the replayed pair is dropped by the inbound translation, so a result without `encrypted_content` never reaches Anthropic. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input loses its query or URL label without losing the result. Citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` or `web_fetch_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result) carrying `anthropic`: that `server_tool_use` with its complete streamed input and the result block, verbatim. An orphan result, a result with a different call id, or a result whose call input cannot be parsed is dropped with no synthetic call; the surrounding text is preserved. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input that cannot be recovered drops its exchange under the same rule. The bridge and the direct encoder keep both on the `web_search_call` item as `anthropic_blocks` (a fetch is an `open_page` action), and the Claude outbound renders them back unchanged, with the original `srvtoolu_` id, `encrypted_content` and `page_age`; a fetch is not counted in `web_search_requests`. A search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing. On the next turn, `messagesToAnthropicFormat` re-inserts the client's replayed pairs (`parsed._anthropicServerReplay`) ahead of the `tool_use`, text or thinking block that followed them, for `api.anthropic.com` only. An anchor must occur exactly once across assistant history, counting string and block content alike; repeated anchors drop their exchanges and preserve the other turns. Replay requires one-to-one call/result ids and matching tool types within the same assistant turn, with the call preceding its result in the original sequence. A single forward pass tracks pending calls and rejects reversed pairs, duplicate ids and orphan results. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string; malformed input, a pair without `encrypted_content`, or a pair with no following anchor stays dropped. Stored `anthropic_blocks` restore only when call id, result id, tool/result types and input object agree; malformed metadata falls back to the rebuilt pair. Citations, a deferred search and `pause_turn` are not resumed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 35b385f3d0a..005cebd1517 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { webSearchPairFromItem } from "../../src/claude/outbound"; import { handleClaudeMessages } from "../../src/server/claude-messages"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -168,21 +169,39 @@ describe("server-side search across responses", () => { expect(blockStarts(clientSse).map(b => b.type)).toEqual(["tool_use"]); }); - test("a result whose call came in an earlier response still reaches the client by its tool_use_id", async () => { - const { clientSse } = await claudeTurn("https://api.anthropic.com", [MESSAGE_START, ...SEARCH_TURN.slice(5, 10), ...MESSAGE_END]); - const starts = blockStarts(clientSse); - expect(starts.map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); - expect(starts[1]).toMatchObject({ tool_use_id: starts[0]!.id, content: [{ type: "web_search_result", url: "https://bun.sh/blog" }] }); + test("an orphan result with a unique anchor never synthesizes or replays a server call", async () => { + const mismatched = SEARCH_TURN.map(f => f.type === "content_block_start" && f.index === 1 + ? { ...f, content_block: { ...(f.content_block as Record), tool_use_id: "srvtoolu_orphan" } } : f); + const truncated = SEARCH_TURN.map((f, i) => i === 2 + ? { ...f, delta: { type: "input_json_delta", partial_json: `{"query":"${"x".repeat(4096)}` } } : f); + for (const frames of [ + [MESSAGE_START, ...SEARCH_TURN.slice(5, 10), ...MESSAGE_END], + [MESSAGE_START, ...fetchTurn(FETCH_RESULT).slice(4)], + mismatched, + truncated, + ]) { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", frames)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Search" }, { role: "assistant", content: turn }, { role: "user", content: "More?" }, + ]); + const messages = wire.messages as { content: Record[] }[]; + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain("web_search_tool_result"); + expect(JSON.stringify(messages)).not.toContain("web_fetch_tool_result"); + expect(turn).toEqual([{ type: "text", text: "Bun shipped." }]); + } }); - test("a single input fragment past 4 KiB is not retained beyond the cap", async () => { + test("a single input fragment past 4 KiB drops its unprovable exchange at the cap", async () => { const query = "q".repeat(5000); const { clientSse } = await claudeTurn("https://api.anthropic.com", [ ...SEARCH_TURN.slice(0, 2), { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }, ...SEARCH_TURN.slice(4), ]); - expect(blockStarts(clientSse).map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + expect(blockStarts(clientSse)).toEqual([{ type: "text", text: "" }]); + expect(clientContent(clientSse)).toEqual([{ type: "text", text: "Bun shipped." }]); expect(clientSse).not.toContain(query); }); @@ -260,3 +279,206 @@ describe("anthropic adapter: typed web tools beside function tools", () => { expect(tools.map(t => t.type ?? "function")).toEqual(["function", "web_fetch_20250910"]); }); }); + +/** The client's content blocks rebuilt from its SSE, as Claude Code stores them for the next turn. */ +function clientContent(clientSse: string): Record[] { + const blocks: Record[] = []; + const inputs: string[] = []; + for (const f of clientFrames(clientSse)) { + const i = f.index as number; + const delta = f.delta as Record | undefined; + if (f.type === "content_block_start") blocks[i] = { ...(f.content_block as Record) }; + else if (delta?.type === "input_json_delta") inputs[i] = (inputs[i] ?? "") + delta.partial_json; + else if (delta?.type === "text_delta") blocks[i]!.text = String(blocks[i]!.text) + delta.text; + } + return blocks.map((b, i) => inputs[i] ? { ...b, input: JSON.parse(inputs[i]!) } : b); +} + +describe("a server-tool exchange the client replays reaches Anthropic in place", () => { + const followUp = (assistant: unknown[], next: unknown = "More?") => [ + { role: "user", content: "What did Bun release?" }, { role: "assistant", content: assistant }, { role: "user", content: next }, + ]; + + test("web_search pair before text: original srvtoolu_ id and encrypted_content, only to api.anthropic.com", async () => { + const turn1 = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp(turn1)); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content.slice(0, 2)).toEqual([ + { type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: { query: "bun release" } }, + SEARCH_TURN[5]!.content_block as Record, + ]); + expect(content[2]).toMatchObject({ type: "text", text: "Bun shipped." }); + expect(content).toHaveLength(3); + for (const [baseUrl, target] of [["https://compat.example.com", "anth"], ["https://api.anthropic.com", "gpt"]] as const) { + const other = JSON.stringify((await claudeTurn(baseUrl, OK_TURN, target, followUp(turn1))).wire); + expect(other).not.toContain("srvtoolu_1"); + expect(other).toContain("Bun shipped."); + } + }); + + test("web_fetch pair before a client tool_use keeps its place ahead of the tool_use", async () => { + const turn1 = clientContent((await claudeTurn("https://api.anthropic.com", fetchTurn(FETCH_RESULT))).clientSse); + const toolUse = { type: "tool_use", id: "toolu_1", name: "Read", input: {} }; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", + followUp([...turn1.slice(0, 2), toolUse], [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }])); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content).toEqual([ + { type: "server_tool_use", id: "srvtoolu_f", name: "web_fetch", input: { url: "https://bun.sh/blog" } }, + { type: "web_fetch_tool_result", tool_use_id: "srvtoolu_f", content: FETCH_RESULT }, + toolUse, + ]); + }); + + test("a repeated anchor drops its run instead of guessing an owner", async () => { + const [use, result] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse).slice(0, 2); + const thinking = (signature: string) => ({ type: "thinking", thinking: `t ${signature}`, signature }); + const second = [{ ...use, id: "srvtoolu_2" }, { ...result, tool_use_id: "srvtoolu_2" }]; + const third = [{ ...use, id: "srvtoolu_3" }, { ...result, tool_use_id: "srvtoolu_3" }]; + const read = (id: string) => ({ type: "tool_use", id, name: "Read", input: {} }); + // Shape of the maintainer's counterexample on #7236: an earlier string-content turn with the same text, + // then repeated text anchors around identical searches, identical calls and signed thinking. + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "What did Bun release?" }, + { role: "assistant", content: [ + thinking("SigOne0123456789abcdef"), use, result, { type: "text", text: "Bun shipped." }, + ...second, { type: "text", text: "Bun shipped." }, thinking("SigTwo0123456789abcdef"), ...third, read("toolu_a"), read("toolu_b"), + ] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_a", content: "ok" }, { type: "tool_result", tool_use_id: "toolu_b", content: "ok" }] }, + ]); + const ids = (wire.messages as { content: unknown }[]) + .map(m => Array.isArray(m.content) ? m.content.map(b => { const r = b as Record; return String(r.id ?? r.tool_use_id ?? r.type); }) : []); + expect(ids[1]).toEqual(["text"]); + // Only srvtoolu_3, anchored on the unique call toolu_a, replays; the text-anchored runs are ambiguous. + expect(ids[3]).toEqual(["thinking", "text", "text", "thinking", "srvtoolu_3", "srvtoolu_3", "toolu_a", "toolu_b"]); + }); + + test("a string-form assistant cannot steal a later identical text anchor", async () => { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "Search" }, { role: "assistant", content: turn }, + { role: "user", content: "More?" }, + ]); + const messages = wire.messages as { content: Record[] }[]; + expect(messages[3]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain("web_search_tool_result"); + }); + + test("repeated identical calls drop ambiguous server exchanges and preserve assistant turns", async () => { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const call = { type: "tool_use", id: "toolu_repeat", name: "Read", input: {} }; + const result = [{ type: "tool_result", tool_use_id: "toolu_repeat", content: "ok" }]; + const first = [...turn, call]; + const second = turn.map(b => b.id === "srvtoolu_1" ? { ...b, id: "srvtoolu_2" } + : b.tool_use_id === "srvtoolu_1" ? { ...b, tool_use_id: "srvtoolu_2" } : b); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "Search" }, { role: "assistant", content: first }, + { role: "user", content: result }, { role: "assistant", content: [...second, call] }, + { role: "user", content: result }, + ]); + const messages = wire.messages as { content: Record[] }[]; + expect(messages[3]!.content).toEqual([{ type: "text", text: "Bun shipped." }, call]); + expect(messages[5]!.content).toEqual([{ type: "text", text: "Bun shipped." }, call]); + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + }); + + test("repeated string, thinking and redacted anchors drop exchanges and preserve signed blocks", async () => { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const [use, result, text] = turn; + const thinking = { type: "thinking", thinking: "checking", signature: "SigRepeat0123456789abcdef" }; + const redacted = { type: "redacted_thinking", data: "RedactedRepeat0123456789" }; + const pair = (id: string) => [{ ...use, id }, { ...result, tool_use_id: id }]; + const assistant = [thinking, ...pair("srvtoolu_text"), text, redacted, + ...pair("srvtoolu_thinking"), thinking, ...pair("srvtoolu_redacted"), redacted, + ...pair("srvtoolu_text2"), text]; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "Search" }, { role: "assistant", content: assistant }, + { role: "user", content: "More?" }, + ]); + const messages = wire.messages as { role: string; content: Record[] }[]; + // Reasoning splits this source turn into adjacent translated assistant messages. + const later = messages.slice(3).filter(m => m.role === "assistant").flatMap(m => m.content); + expect(later).toEqual([thinking, text, redacted, thinking, redacted, text]); + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + }); + + test("a pair between thinking blocks stays between them", async () => { + const [use, result] = (await claudeTurn("https://api.anthropic.com", SEARCH_TURN).then(r => clientContent(r.clientSse))).slice(0, 2); + const thinking = (signature: string) => ({ type: "thinking", thinking: `t ${signature}`, signature }); + const redacted = { type: "redacted_thinking", data: "RedactedPayload0123456789" }; + const second = [{ ...use, id: "srvtoolu_2" }, { ...result, tool_use_id: "srvtoolu_2" }]; + const assistant = [thinking("SigOne0123456789abcdef"), use, result, thinking("SigTwo0123456789abcdef"), ...second, redacted]; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([...assistant, { type: "text", text: "Bun shipped." }])); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content.map(b => b.id ?? b.tool_use_id ?? b.type)).toEqual([ + "thinking", "srvtoolu_1", "srvtoolu_1", "thinking", "srvtoolu_2", "srvtoolu_2", "redacted_thinking", "text", + ]); + }); + + test("duplicate or unmatched server-tool ids never replay an orphan result", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const orphan = { ...result, tool_use_id: "srvtoolu_orphan" }; + for (const blocks of [[use, orphan], [use, use, result, orphan], [use, use, result, result]]) { + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([...blocks, text])); + expect((wire.messages as { content: unknown }[])[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + } + }); + + test("malformed server-tool input drops the exchange without changing its unique text anchor", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + for (const [name, field, resultType] of [["web_search", "query", "web_search_tool_result"], ["web_fetch", "url", "web_fetch_tool_result"]] as const) { + for (const input of [undefined, null, [], "invalid", {}, { [field]: 42 }, { [field]: "" }, { [field]: " " }]) { + const call = { ...use, name, input }; + const matched = { ...result, type: resultType, ...(name === "web_fetch" ? { content: FETCH_RESULT } : {}) }; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([call, matched, text])); + const messages = wire.messages as { content: unknown }[]; + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain(resultType); + } + } + }); + + test("a result before its call drops the exchange instead of replaying the reversed pair", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([result, use, text])); + const messages = wire.messages as { content: unknown }[]; + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain("web_search_tool_result"); + }); + + test("overlapping calls replay in source order when each result follows its own call", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const secondUse = { ...use, id: "srvtoolu_second" }; + const secondResult = { ...result, tool_use_id: "srvtoolu_second" }; + const turn = [use, secondUse, secondResult, result, text]; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp(turn)); + expect((wire.messages as { content: unknown }[])[1]!.content).toEqual(turn); + }); +}); + +test("a malformed anthropic_blocks falls back to the rebuilt pair", () => { + const use = { type: "server_tool_use", id: "srvtoolu_stored", name: "web_search", input: { query: "stored" } }; + for (const anthropic_blocks of [{}, { use: {}, result: {} }, { use: { id: 1, name: "web_search" }, result: { type: "web_search_tool_result" } }, + { use, result: { type: "web_search_tool_result", tool_use_id: "srvtoolu_other" } }, + { use, result: { type: "web_fetch_tool_result", tool_use_id: use.id } }, + { use: { ...use, input: undefined }, result: { type: "web_search_tool_result", tool_use_id: use.id } }, + ]) { + const pair = webSearchPairFromItem({ type: "web_search_call", id: "ws_1", action: { type: "search", query: "q" }, anthropic_blocks }); + expect(pair).toMatchObject({ id: "ws_1", name: "web_search", resultType: "web_search_tool_result", input: { query: "q" } }); + } +}); + +test("an item emitted before anthropic_blocks still maps its fetch_result to a web_fetch pair", () => { + const pair = webSearchPairFromItem({ + type: "web_search_call", id: "srvtoolu_old", action: { type: "open_page", url: "https://bun.sh/blog" }, + fetch_result: FETCH_RESULT, + }); + expect(pair).toMatchObject({ id: "srvtoolu_old", name: "web_fetch", resultType: "web_fetch_tool_result", input: { url: "https://bun.sh/blog" }, resultContent: FETCH_RESULT }); +}); From 2a3fd00985c4f825af9ac41547ee7ac42fce4570 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 09:01:54 -0700 Subject: [PATCH 06/14] fix(google): stop leaked Gemini planning from spreading through replayed history (#7254) * fix(google): stop replayed Gemini progress text from spreading leaked planning * fix(google): scope progress trimming to pre-call text and real sentence ends * fix(google): end the progress lead at other scripts sentence terminators --- .../src/content/docs/guides/providers.md | 7 ++ scripts/test-layout/layout.json | 1 + src/adapters/google-progress-history.ts | 53 +++++++++++ src/adapters/google.ts | 18 +++- structure/providers/google.md | 23 +++++ .../google/gemini-progress-history.test.ts | 89 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 7 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/adapters/google-progress-history.ts create mode 100644 tests/adapters/google/gemini-progress-history.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 45a3340944a..6345506e0b7 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -178,6 +178,13 @@ this does not turn off the model's internal reasoning. A thought signature is co metadata and can accompany ordinary text, so its presence alone does not hide that text. OpenCodex preserves unmarked answer text even when its wording sounds like planning or analysis. +Gemini models, Argon in particular, sometimes skip thinking on a tool-calling turn and write their +planning into the visible progress text, for example a progress line followed by "Let's read +google.ts next." Once that text is sent back as history, the model tends to repeat the pattern +on every later turn. To stop it from spreading, OpenCodex sends Gemini only the first sentence of +the text that came before a tool call in earlier turns. Final answers are sent back in full, and +what your client displays is not changed. + The built-in `google-antigravity` model list is Gemini 3.8 Flash (`gemini-3.8-flash`), Gemini 4 Argon (`gemini-4-argon`), Claude Opus 5.5 (`claude-opus-5-5`), and Claude Sonnet 5.5 (`claude-sonnet-5-5`), each with low, medium, and high efforts. What you actually see is your diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 679a0bb95c7..ef6d6d5c638 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -954,6 +954,7 @@ "gcp-adc.test.ts": "adapters/google", "gemini-37-flash-migration.test.ts": "adapters/google", "gemini-4-argon-antigravity.test.ts": "adapters/google", + "gemini-progress-history.test.ts": "adapters/google", "antigravity-account-roster.test.ts": "adapters/google", "gemini-inline.test.ts": "images", "gemini-web-search.test.ts": "adapters/google", diff --git a/src/adapters/google-progress-history.ts b/src/adapters/google-progress-history.ts new file mode 100644 index 00000000000..68802f9145c --- /dev/null +++ b/src/adapters/google-progress-history.ts @@ -0,0 +1,53 @@ +/** + * Gemini progress-text history trimming. + * + * Gemini models, observed on Gemini 4 Argon over Cloud Code Assist, sometimes skip their thinking + * channel on a tool-calling turn and plan in the visible text instead. The provider sends that + * planning as an ordinary text part without a thought marker, for example a progress line in the + * user's language followed by "Let's read google.ts 580-1700 first." Once one turn like that is + * replayed as history, the model copies the pattern on the turns that follow, so the leak grows + * through the rest of the session. Replaying the same 300K-token request with the leaked tails + * removed from the history stopped the leak, while stronger system-prompt wording did not. + * + * The fix is on the history side only. Text that precedes the tool call of a replayed model turn is + * intermediate progress, and the Google brevity instruction already asks for a single short line + * there, so it is replayed as its lead sentence. A turn Codex marks `final_answer` and text after a + * tool call are replayed whole. Visible output is never rewritten, final + * answers (turns without tool calls) are replayed in full, and the split is punctuation-based so + * it works the same in every language without matching any wording. + */ + +/** + * Sentence end: ASCII terminal punctuation followed by whitespace or the end (so "google.ts" and + * "3.5" are not boundaries), a script's own sentence terminator (CJK 。!?, Devanagari + * ।॥, Arabic ۔؟, Armenian ։, Ethiopic ።, Myanmar ။), or a line break. Closing quotes, brackets and + * Markdown emphasis right after the punctuation belong to the sentence. + */ +const SENTENCE_END = /[.!?]["'\u201D\u2019)\]*_`]*(?=\s|$)|[\u3002\uFF01\uFF1F\u0964\u0965\u06D4\u061F\u0589\u1362\u104B]["'\u201D\u2019\u300D\u300F)\]*_`]*|\n/g; + +/** A boundary that only closes a leading list marker ("1.", "a.") or a common abbreviation ("e.g."). */ +const NOT_A_SENTENCE = /^(?:\d{1,3}|[a-z])\.$|(?:^|\s)(?:e\.g|i\.e|etc|vs)\.$/i; + +/** Whether replayed progress text for this model is trimmed to its lead sentence. */ +export function trimsGeminiProgressHistory(modelId: string): boolean { + return /gemini/i.test(modelId); +} + +/** + * The lead sentence of a replayed progress text, or undefined when nothing usable remains. + * Text without a sentence boundary is already a single sentence and is returned whole. + */ +export function geminiProgressLead(text: string): string | undefined { + const trimmed = text.trim(); + if (trimmed.length === 0) return undefined; + SENTENCE_END.lastIndex = 0; + let end = trimmed.length; + for (let match = SENTENCE_END.exec(trimmed); match; match = SENTENCE_END.exec(trimmed)) { + const boundary = match.index + match[0].length; + if (!NOT_A_SENTENCE.test(trimmed.slice(0, boundary).trim())) { end = boundary; break; } + } + const lead = trimmed.slice(0, end).trim(); + // A code-fence opener is not a sentence; replaying it alone would leave an unclosed fence. + if (lead.length === 0 || lead.startsWith("```")) return undefined; + return lead; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 0bed1927387..5093fe09179 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -44,6 +44,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { geminiProgressLead, trimsGeminiProgressHistory } from "./google-progress-history"; import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between @@ -466,11 +467,26 @@ function messagesToGeminiFormat( const aMsg = msg as OcxAssistantMessage; const parts: unknown[] = []; const toolCalls: Array<{ wireCallId: string; wireName: string }> = []; + // Progress text before a Gemini tool call replays as its lead sentence, so planning the + // model leaked into visible text is not copied forward (google-progress-history.ts). + const trimProgress = trimsGeminiProgressHistory(identityModelId) + && aMsg.phase !== "final_answer" + && aMsg.content.some(part => part.type === "toolCall"); + let progressLeadKept = false; + let afterToolCall = false; for (const p of aMsg.content) { if (p.type === "text") { - const textPart = geminiTextPart((p as OcxTextContent).text); + let text: unknown = (p as OcxTextContent).text; + if (trimProgress && !afterToolCall) { + if (progressLeadKept || typeof text !== "string") continue; + text = geminiProgressLead(text); + if (text === undefined) continue; + progressLeadKept = true; + } + const textPart = geminiTextPart(text); if (textPart) parts.push(textPart); } else if (p.type === "toolCall") { + afterToolCall = true; const tc = p as OcxToolCall; // Preserve the thought signature on the function-call part so Antigravity/Gemini-3 // reasoning continuity survives history-driven (stateless) turns, not just same-process diff --git a/structure/providers/google.md b/structure/providers/google.md index 074909d87dd..ca74f843867 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -131,6 +131,29 @@ valid requests. > Decision record: [ADR-5008](../decisions/ADR-5008-google-opening-functioncall-repair.md) +## Gemini progress-text history + +Gemini sometimes skips its thinking channel on a tool-calling turn and plans in visible text +instead: a progress line followed by unmarked planning such as "Let's read google.ts next." +Replayed whole, that tail is copied by every later turn of the session. When +`messagesToGeminiFormat` serializes an assistant turn that contains a tool call for a model id +containing `gemini`, the text before the turn's first tool call is replayed as the lead sentence +of its first non-empty text part; the other text parts before the call are dropped. Text after a +tool call and turns with Responses `phase: "final_answer"` are replayed whole. +`src/adapters/google-progress-history.ts` owns the split: ASCII `.`, `!` or `?` followed by +whitespace, a script's own sentence terminator (CJK, Devanagari, Arabic, Armenian, Ethiopic, +Myanmar), or a line break, with trailing closing quotes, +brackets and Markdown emphasis kept. Boundaries that only close a leading list marker or +`e.g.`/`i.e.`/`etc.`/`vs.` are skipped. It never matches wording. + +Final answers (assistant turns without tool calls), non-Gemini models such as Claude on +Antigravity, tool calls, their signatures and client-visible output are unchanged. A leak in the +current response is still delivered as ordinary text; the trim keeps it from propagating. +The trade-off is that facts a model states only in pre-call text, beyond its first sentence, are +not replayed; the call, its result and any final answer still are. +`tests/adapters/google/gemini-progress-history.test.ts` covers CCA and AI Studio Gemini, the +`final_answer` and Claude exemptions, text after a call and the split. + ## Structured output on generateContent A caller's Responses `text.format` reaches the Gemini wire as diff --git a/tests/adapters/google/gemini-progress-history.test.ts b/tests/adapters/google/gemini-progress-history.test.ts new file mode 100644 index 00000000000..afad0fd6614 --- /dev/null +++ b/tests/adapters/google/gemini-progress-history.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter as createGoogleAdapterProduction } from "../../../src/adapters/google"; +import { geminiProgressLead } from "../../../src/adapters/google-progress-history"; +import { ANTIGRAVITY_MODEL_EFFORTS } from "../../../src/providers/antigravity-models"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +// Shape of a live Gemini 4 Argon tool turn (2026-10-17): a progress line in the user's language +// with the model's planning appended as ordinary, unmarked text. Replayed whole, later turns +// copied the tail; replayed as the lead sentence, they did not. +const LEAKED = "앞선 배치에서 잘린 google.ts를 마저 읽겠습니다. Let's read google.ts 580-1700 first."; +const LEAD = "앞선 배치에서 잘린 google.ts를 마저 읽겠습니다."; + +const antigravity = { + adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", googleMode: "cloud-code-assist", + project: "proj-123", apiKey: "ya29.token", modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, +} as OcxProviderConfig; +const aiStudio = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", apiKey: "k" } as OcxProviderConfig; + +function history(phase?: "commentary" | "final_answer"): OcxParsedRequest["context"]["messages"] { + return [ + { role: "user", content: "google.ts를 읽어줘" }, + { role: "assistant", ...(phase ? { phase } : {}), content: [ + { type: "text", text: LEAKED }, + { type: "text", text: "Maybe the classifier is wrong." }, + { type: "toolCall", id: "call_1", name: "exec", arguments: { cmd: "cat google.ts" } }, + { type: "text", text: "After the call. Kept whole." }, + ] }, + { role: "toolResult", toolCallId: "call_1", toolName: "exec", content: [{ type: "text", text: "ok" }] }, + { role: "assistant", content: [{ type: "text", text: "Final answer. It keeps every sentence." }] }, + { role: "user", content: "계속" }, + ] as unknown as OcxParsedRequest["context"]["messages"]; +} + +async function modelTexts(provider: OcxProviderConfig, modelId: string, phase?: "commentary" | "final_answer"): Promise { + const parsed = { + modelId, stream: false, options: {}, + context: { messages: history(phase), systemPrompt: [], tools: [] }, + } as unknown as OcxParsedRequest; + const req = await withTestTranslatorBudget(createGoogleAdapterProduction(provider)).buildRequest(parsed); + const body = JSON.parse(req.body); + const contents = (body.request ?? body).contents as Array<{ role: string; parts: Array<{ text?: string }> }>; + return contents.filter(c => c.role === "model") + .map(c => c.parts.filter(p => typeof p.text === "string").map(p => p.text!)); +} + +describe("Gemini progress-text history", () => { + test("Argon tool turns replay only the lead sentence before the call; final answers stay whole", async () => { + expect(await modelTexts(antigravity, "gemini-4-argon")).toEqual([ + [LEAD, "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); + expect(await modelTexts(antigravity, "gemini-4-argon", "commentary")).toEqual([ + [LEAD, "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); + }); + + test("a turn Codex marks final_answer is replayed whole", async () => { + expect(await modelTexts(antigravity, "gemini-4-argon", "final_answer")).toEqual([ + [LEAKED, "Maybe the classifier is wrong.", "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); + }); + + test("AI Studio Gemini gets the same history", async () => { + expect(await modelTexts(aiStudio, "gemini-3.5-flash")).toEqual([ + [LEAD, "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); + }); + + test("Claude on Antigravity keeps its full progress text", async () => { + expect(await modelTexts(antigravity, "claude-sonnet-5-5")).toEqual([ + [LEAKED, "Maybe the classifier is wrong.", "After the call. Kept whole."], ["Final answer. It keeps every sentence."], + ]); + }); + + test("the lead split is punctuation-based and language-neutral", () => { + expect(geminiProgressLead("Reading the adapter now! Wait, maybe the encoder too.")).toBe("Reading the adapter now!"); + expect(geminiProgressLead("1. Read google.ts next.\n2. Then the encoder.")).toBe("1. Read google.ts next."); + expect(geminiProgressLead("google.ts와 chat.ts를 읽겠습니다")).toBe("google.ts와 chat.ts를 읽겠습니다"); + expect(geminiProgressLead("次に google.ts を読みます。Let's go.")).toBe("次に google.ts を読みます。"); + expect(geminiProgressLead("Done. Let's read google.ts next.")).toBe("Done."); + expect(geminiProgressLead("確認します。Let's inspect the encoder next.")).toBe("確認します。"); + expect(geminiProgressLead("मैं फ़ाइल पढ़ूँगा। Let's read google.ts next.")).toBe("मैं फ़ाइल पढ़ूँगा।"); + expect(geminiProgressLead("سأقرأ الملف۔ Let's read google.ts next.")).toBe("سأقرأ الملف۔"); + expect(geminiProgressLead("Reading **the docs.** Let's inspect the encoder next.")).toBe("Reading **the docs.**"); + expect(geminiProgressLead("Check e.g. the 3.5 encoder first. Then more.")).toBe("Check e.g. the 3.5 encoder first."); + expect(geminiProgressLead(" \n ")).toBeUndefined(); + expect(geminiProgressLead("```js\nconst x = 1;\n```")).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e8e81b968a1..9ae6ad80eb6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -686,7 +686,7 @@ "flash-route-image-modalities.test.ts": "providers", "format-result.test.ts": "web-search", "forward-admission-separation.test.ts": "providers", "fresh-connection-optout.test.ts": "responses", "fulfill-video.test.ts": "videos", "gather-routed-models-single-flight.test.ts": "codex-integration", - "gcp-adc.test.ts": "adapters/google", "gemini-37-flash-migration.test.ts": "adapters/google", "gemini-4-argon-antigravity.test.ts": "adapters/google", "antigravity-account-roster.test.ts": "adapters/google", + "gcp-adc.test.ts": "adapters/google", "gemini-37-flash-migration.test.ts": "adapters/google", "gemini-4-argon-antigravity.test.ts": "adapters/google", "gemini-progress-history.test.ts": "adapters/google", "antigravity-account-roster.test.ts": "adapters/google", "gemini-inline.test.ts": "images", "gemini-web-search.test.ts": "adapters/google", "generic-session-affinity.test.ts": "oauth", "server-generic-oauth-affinity.test.ts": "server", From 97a12fd6d33478e18578cc56893d784c0f908259 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Sat, 17 Oct 2026 10:35:10 +0900 Subject: [PATCH 07/14] docs(chatgpt): clarify the app-server shim transport limit (#7227) Co-authored-by: Ingwannu --- .../src/content/docs/guides/chatgpt-desktop.md | 17 ++++++++++++++--- structure/clients/chatgpt-desktop.md | 8 ++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md index 887493f1935..458acdfbedf 100644 --- a/docs-site/src/content/docs/guides/chatgpt-desktop.md +++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md @@ -126,6 +126,17 @@ app-server until you run one of them. A single output line longer than 8 MiB is passed through unparsed rather than buffered. This standalone shim does not rewrite conversation metadata or route model calls. -Other app gates or upstream refusals can still prevent sending. Evidence reported -on an exhausted Plus account also used an intercept, so it does not establish -that this shim alone resolves every desktop send lock. +Other app gates or upstream refusals can still prevent sending. An attached launcher +or a passing filter self-test does not prove that the app's gate data crosses this pipe. + +Reporter observations in [#6196](https://github.com/lidge-jun/opencodex/issues/6196) +on October 2 and 13, 2026 describe an attached shim with Send still disabled at +natural quota exhaustion. On Desktop 26.1007.30912 with OpenCodex 2.84.0, the +reporter observed no eligible rate-limit RPC messages while the renderer's gate +used a separate usage transport. This shim cannot rewrite data that never reaches +its stdout filter. These are build-specific field reports, not a new live +verification or proof that every Desktop build uses the same transport. + +The remaining signed-in composer behavior stays tracked in #6196. Earlier success +with a combined shim/intercept configuration does not establish shim-only +sufficiency, and the closed, unmerged #6365 intercept is not a shipped remedy. diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index dca30b1d079..3cfec90def8 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -31,6 +31,14 @@ plain-quota evidence: a cleared plain reached type or a usage window at 100% (`u RPC, `used_percent` in the web usage snapshot; every gate field is read in both spellings). Usage percentages, resets and window durations remain accurate. Unrelated messages and malformed lines remain byte-identical; changed lines are reserialized. +This is a stdout-projection boundary, not a guarantee that every Desktop composer +uses that transport. The post-closure reports in +[#6196](https://github.com/lidge-jun/opencodex/issues/6196) describe an attached shim +with no eligible rate-limit RPC messages and a still-locked composer on specific +macOS builds. A launcher override or synthetic rewrite test does not establish +original-app acceptance. The separate renderer usage transport is outside this +module's scope; the reported remaining lock stays open without reviving the +closed local-CA intercept design. A per-line rewrite exception preserves that line. A failure in the framing/rewrite machinery preserves buffered bytes and switches the rest of the stream to raw passthrough. Output-write failures propagate; they are not rewrite failures. From 7a29b5308c655140b11c1fea0d2fd8e60c508db7 Mon Sep 17 00:00:00 2001 From: Juan de Urtubey Date: Fri, 16 Oct 2026 18:46:50 -0700 Subject: [PATCH 08/14] fix(catalog): preserve OpenAI API display name overrides (#7259) Co-authored-by: Juan de Urtubey --- .../docs/reference/configuration/providers.md | 4 ++- src/codex/catalog/routed-gather.ts | 4 ++- ...model-display-names-management-api.test.ts | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 700e060ca9b..a8e4499e9a6 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -672,7 +672,9 @@ The effective label order is operator `modelDisplayNames`, then provider catalog normal `provider/model` fallback. The routed selector remains `xai/grok-4.6`, while the upstream wire model remains `grok-4.6`. Labels are display only. They do not change authentication, adapter behavior, routing, billing, or upstream request construction. Removing a map entry resets only its -label. A management client can set or reset one label with +label. For built-in OpenAI API models, use +`providers.openai-apikey.modelDisplayNames`; the label survives catalog refreshes. +A management client can set or reset one label with `PUT /api/providers/:provider/model-display-names` and a body of `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }`; send `displayName: null` to reset it. Provider `PATCH` does not edit this map. Use this dedicated `PUT` endpoint to change or remove labels. diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index 99f7e64d3db..13bf24a7064 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -102,7 +102,7 @@ import type { CatalogTrustedOpenAiApiPolicySnapshot, } from "../convergence-types"; import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture"; -import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredModelDisplayName, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints"; import { resolveComboCatalogMember } from "./combo-member"; import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture"; import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models"; @@ -886,10 +886,12 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows( undefined, metadataConfigDir, ); + const displayName = configuredModelDisplayName(configured, id); return { provider: OPENAI_API_PROVIDER_ID, id, owned_by: OPENAI_API_PROVIDER_ID, + ...(displayName ? { displayName } : {}), ...(contextWindow ? { contextWindow } : {}), ...(maxInputTokens ? { maxInputTokens } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), diff --git a/tests/codex-integration/model-display-names-management-api.test.ts b/tests/codex-integration/model-display-names-management-api.test.ts index ea11600e833..95819bb6316 100644 --- a/tests/codex-integration/model-display-names-management-api.test.ts +++ b/tests/codex-integration/model-display-names-management-api.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { buildCatalogEntries, gatherRoutedModels } from "../../src/codex/catalog"; import { clearModelCache, getFreshCached, setCached } from "../../src/codex/model-cache"; import type { CatalogDisposition } from "../../src/codex/convergence-types"; import { listManagementModelRows, toExportModel } from "../../src/server/management/model-rows"; @@ -32,6 +33,38 @@ afterEach(() => { }); describe("model display name management rows", () => { + test("OpenAI API labels survive trusted reconstruction into the Codex catalog", async () => { + const apiConfig: OcxConfig = { + port: 10100, + defaultProvider: "openai-apikey", + providers: { + "openai-apikey": { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + liveModels: false, + modelDisplayNames: { "gpt-6-astra": "6 Astra", "gpt-6.1-sol": "6.1 Sol" }, + }, + }, + }; + const catalog = async () => buildCatalogEntries(null, [], await gatherRoutedModels(apiConfig)); + const labeled = await catalog(); + for (const [slug, displayName] of [ + ["openai-apikey/gpt-6-astra", "6 Astra"], + ["openai-apikey/gpt-6.1-sol", "6.1 Sol"], + ]) { + expect(labeled.filter(entry => entry.slug === slug)).toHaveLength(1); + expect(labeled.find(entry => entry.slug === slug)?.display_name).toBe(displayName); + } + + delete apiConfig.providers["openai-apikey"]!.modelDisplayNames; + const original = await catalog(); + for (const entry of labeled) { + const baseline = original.find(row => row.slug === entry.slug); + expect(baseline).toBeDefined(); + expect({ ...entry, display_name: baseline!.display_name }).toEqual(baseline); + } + }); + test("reports operator, provider, and fallback display name sources without provider secrets", async () => { const operatorConfig = config({ "model-a": "Operator Name" }); operatorConfig.providers[DISPLAY_PROVIDER].apiKey = "sk-secret-not-for-rows"; From f190fc40906323a93a16729209d00414f8b602fe Mon Sep 17 00:00:00 2001 From: "Vadym O." Date: Sat, 17 Oct 2026 09:46:55 +0800 Subject: [PATCH 09/14] fix(routing): price refused-then-served rows in routing analytics (#7253) successCostUsd passed the aggregate `unreported` status to estimateRequestCost, so a usage-less 429 before a measured send stayed unpriced in /api/routing-analytics while /api/usage priced it. It now reuses computeEntryCost, the same attempt-aware pricing. Co-authored-by: Claude Code --- src/routing/analytics.ts | 21 ++++++--------------- tests/routing/routing-analytics.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/routing/analytics.ts b/src/routing/analytics.ts index 3eb2e540a14..b6c8c7db1d5 100644 --- a/src/routing/analytics.ts +++ b/src/routing/analytics.ts @@ -12,7 +12,7 @@ */ import type { PersistedUsageEntry } from "../usage/log"; -import { estimateRequestCost, serviceTierContext } from "../usage/cost"; +import { computeEntryCost } from "../usage/summary"; import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer"; export const ANALYTICS_MAX_ROWS = 50_000; @@ -159,19 +159,10 @@ function cooldownTriggering(entry: PersistedUsageEntry | null, status: number): }); } -function successCostUsd( - row: Pick, - entry: PersistedUsageEntry, -): number | null { - if (!entry.usage) return null; - const estimate = estimateRequestCost({ - provider: row.provider, - model: row.model, - usage: entry.usage, - usageStatus: entry.usageStatus, - serviceTier: serviceTierContext(entry), - }); - return estimate ? estimate.cost.total : null; +/** Same attempt-aware pricing as /api/usage, so a refused retry before a measured send is priced. */ +function successCostUsd(entry: PersistedUsageEntry): number | null { + const { isPriced, costTotal } = computeEntryCost(entry); + return isPriced ? costTotal : null; } export async function computeRoutingAnalytics( @@ -246,7 +237,7 @@ export async function computeRoutingAnalytics( if (kind === "success") { const entry = parseEntry(row.rowJson); if (entry) { - rowCostUsd = successCostUsd(row, entry); + rowCostUsd = successCostUsd(entry); if (rowCostUsd !== null) { costTotalUsd += rowCostUsd; costCount += 1; diff --git a/tests/routing/routing-analytics.test.ts b/tests/routing/routing-analytics.test.ts index 68e00e77fdb..882f5f49e21 100644 --- a/tests/routing/routing-analytics.test.ts +++ b/tests/routing/routing-analytics.test.ts @@ -12,6 +12,7 @@ import { } from "../../src/usage/log"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { computeRoutingAnalytics } from "../../src/routing/analytics"; +import { computeEntryCost } from "../../src/usage/summary"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -172,6 +173,24 @@ describe("routing analytics (RI-03)", () => { expect(result.priceCoverage).toBe(0); }); + test("a refused retry before a measured send is priced, matching /api/usage", async () => { + const send = { provider: "openai", model: "gpt-5.5", adapter: "openai-responses", durationMs: 10, sendCount: 1, recoveryKinds: [] }; + const attempts = [ + { ...send, ordinal: 1, status: 429, usageStatus: "unreported" as const }, + { ...send, ordinal: 2, status: 200, usageStatus: "reported" as const, usage: { inputTokens: 100, outputTokens: 10 } }, + ]; + const row = entry("r1", { + timestamp: 1, status: 200, durationMs: 20, provider: "openai", model: "gpt-5.5", + usageStatus: "unreported", usage: { inputTokens: 100, outputTokens: 10 }, attempts, + }); + appendUsageEntry(row); + + const result = await computeRoutingAnalytics({}); + expect(result.priceCoverage).toBe(1); + expect(result.estimatedCostUsdTotalSuccessful).toBe(computeEntryCost(row).costTotal); + expect(result.estimatedCostUsdTotalSuccessful).toBeGreaterThan(0); + }); + test("filters scope the analysis", async () => { appendUsageEntry(entry("r1", { timestamp: 1, status: 200, durationMs: 100, provider: "a" })); appendUsageEntry(entry("r2", { timestamp: 2, status: 200, durationMs: 100, provider: "b", model: "m2" })); From 43a3e397952716e44026ad62773e4278201cd1a2 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:59:43 -0700 Subject: [PATCH 10/14] fix(cli): preserve startup with IPv6 loopback disabled (#7262) * fix(cli): skip unavailable loopback families during owner discovery * fix(cli): retain owner probes after loopback route errors --- .../docs/getting-started/quickstart.md | 4 + scripts/test-layout/layout.json | 1 + src/cli/cross-home-owner.ts | 18 ++- src/cli/loopback-family.ts | 31 ++++ structure/runtime.md | 2 + tests/cli/cross-home-loopback-family.test.ts | 145 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 src/cli/loopback-family.ts create mode 100644 tests/cli/cross-home-loopback-family.test.ts diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index e8db9304b51..92e0ed79459 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -154,6 +154,10 @@ It stops before publishing a new runtime or changing those settings. Retry start local connectivity recovers; an installed background service retries through its service manager. A timeout does not permanently turn the new process into a secondary instance. +IPv6 does not need to be enabled on loopback. Startup checks whether each loopback +address can be bound locally and skips an unavailable family. Timeouts on an available +family still stop startup until ownership can be verified. + ## Next - [How It Works](/getting-started/how-it-works/) — what happens to each request. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ef6d6d5c638..e10bd5345f9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -518,6 +518,7 @@ "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "sibling-home-client-sync.test.ts": "cli", + "cross-home-loopback-family.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", diff --git a/src/cli/cross-home-owner.ts b/src/cli/cross-home-owner.ts index 3d420e47598..a296d7698cb 100644 --- a/src/cli/cross-home-owner.ts +++ b/src/cli/cross-home-owner.ts @@ -26,6 +26,7 @@ import { } from "../server/proxy-liveness"; import { directLocalHttpFetch } from "../server/direct-local-http"; import type { RuntimePortState } from "../config/process-state"; +import { loopbackFamilyAvailability } from "./loopback-family"; const MAX_HINT_BYTES = 256 * 1024; // Far above any real Grok config; discovery must not stall startup when Grok sync is off. @@ -128,13 +129,19 @@ function loopbackPort(raw: string | null): number | null { } } +type CrossHomeOwnerOptions = { + homeDir?: string; + io?: LivenessIo; + loopbackAvailabilityFn?: typeof loopbackFamilyAvailability; +}; + /** Returns only a different process with an identity-checked /healthz response. */ -export async function findCrossHomeOwner(options: { homeDir?: string; io?: LivenessIo } = {}): Promise { +export async function findCrossHomeOwner(options: CrossHomeOwnerOptions = {}): Promise { const verdict = await findCrossHomeOwnerDetailed(options); return verdict.kind === "owner" ? verdict.port : null; } -export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io?: LivenessIo } = {}): Promise { +export async function findCrossHomeOwnerDetailed(options: CrossHomeOwnerOptions = {}): Promise { const io = options.io ?? {}; const nowFn = io.nowFn ?? Date.now; const timeoutMs = io.timeoutMs ?? START_OWNERSHIP_LIVENESS.timeoutMs ?? 1_500; @@ -205,6 +212,8 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io } const queue = [...candidates]; + const availability = new Map>(); + const checkAvailability = options.loopbackAvailabilityFn ?? loopbackFamilyAvailability; const probed = new Set(); for (const port of queue) { if (probed.has(port)) continue; @@ -225,6 +234,11 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io noteIndeterminate(port, "ownership discovery ran out of its shared time budget"); break; } + // Disabled loopback IPv6 can route ::1 off-host and time out. An ephemeral + // local bind proves address absence without treating an available family's + // timeout as empty. Unknown bind failures retain the existing probes. + if (!availability.has(hostname)) availability.set(hostname, checkAvailability(hostname)); + if (availability.get(hostname) === "unavailable") continue; const identity = await proxyIdentityAt(port, { hostname }, probeIo); if (identity === null) { // A null identity is not "free": distinguish a unanimous connect refusal diff --git a/src/cli/loopback-family.ts b/src/cli/loopback-family.ts new file mode 100644 index 00000000000..fdded605e57 --- /dev/null +++ b/src/cli/loopback-family.ts @@ -0,0 +1,31 @@ +/** A failed connect cannot distinguish an absent loopback address from a slow owner. */ +export type LoopbackFamilyAvailability = "available" | "unavailable" | "unknown"; + +function bindLoopback(hostname: string): { stop(): void } { + return Bun.listen({ hostname, port: 0, socket: { data(socket) { socket.end(); } } }); +} + +/** + * Ask the kernel whether the concrete local address can be bound, independently of + * routing/connect timeouts. Only address/family absence permits skipping discovery; + * permissions, resource exhaustion and other failures leave owner evidence unknown. + * The ephemeral listener is closed synchronously and never accepts client traffic. + */ +export function loopbackFamilyAvailability( + hostname: string, + bindFn: (hostname: string) => { stop(): void } = bindLoopback, +): LoopbackFamilyAvailability { + const address = hostname === "[::1]" ? "::1" : hostname; + if (address !== "::1" && address !== "127.0.0.1") return "unknown"; + let listener: { stop(): void }; + try { + listener = bindFn(address); + } catch (error) { + const code = (error as { code?: unknown } | null)?.code; + return code === "EADDRNOTAVAIL" || code === "EAFNOSUPPORT" + || code === "EPROTONOSUPPORT" + ? "unavailable" : "unknown"; + } + listener.stop(); + return "available"; +} diff --git a/structure/runtime.md b/structure/runtime.md index 669a2fb256b..e83b550e076 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -165,6 +165,8 @@ their own files. `src/grok/inject.ts` holds the shared canonical config-write lo ## Lifecycle +Cross-home discovery in `src/cli/cross-home-owner.ts` checks concrete loopback address availability through `src/cli/loopback-family.ts` before probing each family. A synchronous ephemeral bind is immediately closed; only address/family-unavailable errors exclude that address. This prevents a disabled IPv6 loopback's off-host `::1` connect timeout from blocking startup. Other bind errors retain discovery, and a timeout on an available or unknown family still produces an indeterminate owner verdict. Live owners on either available family retain identity and attestation checks. + Startup catalog sync and native restore apply the [retired-native policy](catalog.md#shared-catalog). Foreground exit cleanup in `src/cli/index.ts` delegates restoration through the existing sibling/recycle teardown gates; approved external URLs follow [custom-provider consent](codex-provider-routing.md), while unapproved provider configuration and history remain unchanged. Guarded tests isolate the owner registry used by cross-home discovery beneath their selected home, including children without a registry override; production retains the shared locator. See [test state isolation](ops/test-sandbox-cleanup.md). `src/server/background-lifecycle.ts` registers low-quota protection for each live server, flushes pending saves before releasing its owner, binds its own event ledger to management requests, and unregisters it on failed startup or stop; with no pending save, owner release remains synchronous. The protection writer loads config persistence only when a threshold schedules a save; see [configuration](config.md#codex-pool-low-quota-protection). Codex quota processing has shared and Reserve scopes; retired model evidence is suppressed as described in [OpenAI quota ownership](providers/openai-tiers.md#public-provider-contract). diff --git a/tests/cli/cross-home-loopback-family.test.ts b/tests/cli/cross-home-loopback-family.test.ts new file mode 100644 index 00000000000..83e57cea2fb --- /dev/null +++ b/tests/cli/cross-home-loopback-family.test.ts @@ -0,0 +1,145 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findCrossHomeOwnerDetailed, markCrossHomeSibling } from "../../src/cli/cross-home-owner"; +import { loopbackFamilyAvailability, type LoopbackFamilyAvailability } from "../../src/cli/loopback-family"; +import { resetSiblingStartForTests, siblingOfLivePort } from "../../src/codex/sibling-start"; +import { LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof } from "../../src/lib/local-management-attestation"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const envKeys = ["OPENCODEX_HOME", "CODEX_HOME", "GROK_HOME", "OCX_OWNER_REGISTRY_DIR"] as const; +const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); +const roots: string[] = []; +const port = 10102; +const ownerPid = process.pid + 1; +const secret = "A".repeat(43); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "ocx-loopback-family-")); + roots.push(root); + const home = join(root, "home"); + for (const key of envKeys) { + const dir = join(root, key.toLowerCase()); + mkdirSync(dir, { recursive: true }); + process.env[key] = dir; + } + mkdirSync(join(home, ".opencodex"), { recursive: true }); + writeFileSync(join(process.env.CODEX_HOME!, "config.toml"), + `model_provider = "opencodex"\n[model_providers.opencodex]\nbase_url = "http://127.0.0.1:${port}/v1"\n`); + return { home }; +} + +afterEach(() => { + resetSiblingStartForTests(); + for (const key of envKeys) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +test.each(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EPROTONOSUPPORT"])( + "%s from an IPv6 bind proves the family unavailable", code => { + expect(loopbackFamilyAvailability("[::1]", hostname => { + expect(hostname).toBe("::1"); + throw Object.assign(new Error("unavailable"), { code }); + })).toBe("unavailable"); + }, +); + +test.each(["EACCES", "EPERM", "EMFILE", "EADDRINUSE", "ETIMEDOUT", "EINVAL", "ENETUNREACH"])( + "%s from a bind does not prove address absence", code => { + expect(loopbackFamilyAvailability("[::1]", () => { + throw Object.assign(new Error("ambiguous"), { code }); + })).toBe("unknown"); + }, +); + +test("successful availability check closes the ephemeral listener synchronously", () => { + let closed = 0; + expect(loopbackFamilyAvailability("127.0.0.1", () => ({ stop() { closed++; } }))).toBe("available"); + expect(closed).toBe(1); + expect(loopbackFamilyAvailability("example.com", () => { throw new Error("must not bind"); })).toBe("unknown"); +}); + +test("the real kernel bind can check IPv4 loopback availability", () => { + expect(loopbackFamilyAvailability("127.0.0.1")).toBe("available"); +}); + +function probes(ipv6: LoopbackFamilyAvailability, liveHost?: string) { + const requests: string[] = []; + const checked: string[] = []; + return { + requests, checked, + loopbackAvailabilityFn: (hostname: string) => { + checked.push(hostname); + return hostname === "[::1]" ? ipv6 : "available" as const; + }, + io: { + attempts: 1, timeoutMs: 50, + fetchFn: (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + requests.push(url.hostname); + if (url.hostname === liveHost) { + const headers = new Headers(); + const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + if (challenge) headers.set(LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof(secret, challenge, ownerPid, port)); + return Response.json({ service: "opencodex", status: "ok", version: "0.0.0", uptime: 1, pid: ownerPid }, { headers }); + } + // Reproduce the reported connect behavior: the disabled ::1 route times + // out while IPv4 refuses. Absence is proven by the bind check, not this. + throw Object.assign(new Error("connect failed"), { + code: url.hostname === "[::1]" ? "ETIMEDOUT" : "ECONNREFUSED", + }); + }) as typeof fetch, + }, + }; +} + +test("IPv4 refusal plus unavailable IPv6 permits startup without dialing the timeout route", async () => { + const fx = fixture(); + const probe = probes("unavailable"); + expect(await markCrossHomeSibling({ homeDir: fx.home, ...probe })).toBe(false); + expect(siblingOfLivePort()).toBeNull(); + expect(probe.requests.length).toBeGreaterThan(0); + expect(probe.requests.every(host => host === "127.0.0.1")).toBe(true); + expect(probe.checked).toEqual(["127.0.0.1", "[::1]"]); +}); + +test.each(["available", "unknown"] as const)("IPv6 %s plus timeout still refuses startup", async availability => { + const fx = fixture(); + const probe = probes(availability); + expect((await findCrossHomeOwnerDetailed({ homeDir: fx.home, ...probe })).kind).toBe("indeterminate"); + await expect(markCrossHomeSibling({ homeDir: fx.home, ...probe })).rejects.toThrow("refusing startup"); + expect(probe.requests).toContain("[::1]"); +}); + +test("an unregistered live IPv4 listener still blocks startup when IPv6 is unavailable", async () => { + const fx = fixture(); + await expect(markCrossHomeSibling({ homeDir: fx.home, ...probes("unavailable", "127.0.0.1") })) + .rejects.toThrow("refusing startup"); +}); + +test("a network-unreachable bind does not hide an unregistered live IPv6 owner", async () => { + const fx = fixture(); + const probe = probes("unknown", "[::1]"); + const loopbackAvailabilityFn = (hostname: string) => loopbackFamilyAvailability(hostname, () => { + if (hostname === "[::1]") throw Object.assign(new Error("route failure"), { code: "ENETUNREACH" }); + return { stop() {} }; + }); + await expect(markCrossHomeSibling({ homeDir: fx.home, ...probe, loopbackAvailabilityFn })) + .rejects.toThrow("refusing startup"); + expect(probe.requests).toContain("[::1]"); +}); + +test.each(["127.0.0.1", "[::1]"])("an attested live %s owner still makes the start a sibling", async host => { + const fx = fixture(); + writeFileSync(join(fx.home, ".opencodex", "runtime-port.json"), JSON.stringify({ + pid: ownerPid, port, attestationSecret: secret, hostname: host, + })); + expect(await markCrossHomeSibling({ homeDir: fx.home, ...probes("available", host) })).toBe(true); + expect(siblingOfLivePort()).toBe(port); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9ae6ad80eb6..cf66ca95741 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -414,6 +414,7 @@ "cli-restore-back.test.ts": "cli", "cli-route-policy-not-found.test.ts": "cli", "cli-start-auxiliary-bind.test.ts": "cli", "cli-start-journal-order.test.ts": "cli", "sibling-home-client-sync.test.ts": "cli", "cli-status-hub-state.test.ts": "cli", + "cross-home-loopback-family.test.ts": "cli", "cli-status-json.test.ts": "cli", "cli-status-oauth-health.test.ts": "cli", "cli-stop-json.test.ts": "cli", "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-update-badge.test.ts": "cli", From 4b71bdb4d39c8c700071c01b483d9d760b0ee34a Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 19:02:52 -0700 Subject: [PATCH 11/14] fix(chat): return gateway request-history IDs (#7261) * fix(chat): expose canonical request-history IDs Return the existing gateway request ID on Chat replies owned by a handler or logged admission refusal, preserving unlogged rejection behavior. Co-authored-by: daemons2000 <1758467+daemons2000@users.noreply.github.com> * test(chat): preserve CORS policy guard with receipt wrapper --------- Co-authored-by: daemons2000 <1758467+daemons2000@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 7 + scripts/test-layout/layout.json | 1 + src/server/index/serve-options.ts | 16 +- structure/data-planes/inbound-compat.md | 9 +- tests/fixtures/test-layout-expected.json | 1 + .../chat-request-id-endpoint.test.ts | 177 ++++++++++++++++++ .../loopback-listener-admission.test.ts | 6 +- 7 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 tests/responses/chat-request-id-endpoint.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index b16260d19ee..6d245819c6e 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -437,6 +437,13 @@ it is on the wire. Responses rejected at authentication or origin admission never reach this wrapper and carry no id. +Logged `POST /v1/chat/completions` replies also carry `x-opencodex-request-id`, exposed through +CORS, for JSON, streaming, handler errors and logged pre-handler refusals. This is the gateway +history id, independent of the response body's completion id. Caller and upstream values cannot +replace it. Rejections without a request-log owner, including authentication, origin, drain and +active-turn refusals, carry no id. Streaming headers identify the eventual history row before +final usage is saved. + Logged `POST /v1/messages` replies carry the same OCX request-history id in **both** `request-id` and `x-opencodex-request-id`. This applies to native and translated replies, streaming and JSON, and logged refusals. Claude Code can use its persisted `requestId` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e10bd5345f9..c7b7f40cfb5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -315,6 +315,7 @@ "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", + "chat-request-id-endpoint.test.ts": "responses", "chat-completions-pool-mode.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-developer-position.test.ts": "responses", diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 72af800ba1a..56557344e91 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1618,11 +1618,17 @@ export function createServeOptions(ctx: ServeOptionsContext) { // Promote a caller `x-session-id` before admission, as Responses and Messages do (#6520): // the ChatGPT bridge forwards only canonical session headers, so the raw marker is lost. const sessionReq = withCallerSessionIdentity(req, admission); - return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors( - await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }), - req, - policy, - ), { requestId, start, logCtx }); + // Only expose an id when the handler or a logged refusal owns its history row. + let ownsRequestLog = false; + const response = await runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => { + ownsRequestLog = true; + return withCors( + await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }), + req, + policy, + ); + }, { requestId, start, logCtx, onLogged: () => { ownsRequestLog = true; } }); + return ownsRequestLog ? withRequestLogId(response, requestId) : response; } if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") { diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index ca39ab063b2..39feeb31e8a 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -320,7 +320,14 @@ its defaults and exclusions are owned by [Responses transport](../transports/res The provider summary default applies at Responses ingress; native Chat and Anthropic inbound preferences keep their existing handling. Raw content is never renamed to a summary. See [bridge contract](../providers/chat-compat.md). -## Messages request-log correlation +## Chat and Messages request-log correlation + +The Chat Completions HTTP boundary in `src/server/index/serve-options.ts` publishes its generated +request-history id as `x-opencodex-request-id`, exposed through CORS. JSON, SSE, handler errors +and logged pre-handler refusals share the final history row's id; caller and upstream ids cannot +replace it. Unlogged auth, origin, drain and active-turn refusals receive no correlation id. +Stream headers identify the eventual row, not completed persistence. +`tests/responses/chat-request-id-endpoint.test.ts` covers this boundary. The Messages HTTP boundary in `src/server/index/serve-options.ts` publishes its generated request-log id in both `request-id` and `x-opencodex-request-id`. Native, translated, streamed, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index cf66ca95741..70e6fe2d522 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -293,6 +293,7 @@ "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", "chat-completions-pool-mode.test.ts": "responses", + "chat-request-id-endpoint.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-developer-position.test.ts": "responses", "protocol-contract.test.ts": "responses", "protocol-features.test.ts": "responses", "protocol-opaque-state.test.ts": "responses", "protocol-baseline.test.ts": "responses", diff --git a/tests/responses/chat-request-id-endpoint.test.ts b/tests/responses/chat-request-id-endpoint.test.ts new file mode 100644 index 00000000000..5c8030bd8f5 --- /dev/null +++ b/tests/responses/chat-request-id-endpoint.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { MAX_ACTIVE_TURNS, tryAdmitTurn } from "../../src/server/lifecycle"; +import { MAX_CONCURRENT_INBOUND_BODY_BYTES, withRaisedInboundBodyAdmission } from "../../src/server/inbound-body-admission"; +import { readRecentUsageEntries } from "../../src/usage/log"; +import { admitWorkflowTurn, chargeWorkflowSends, DEFAULT_WORKFLOW_BUDGET_POLICY, resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown"; +import type { OcxConfig } from "../../src/types"; + +let fixtureHome = ""; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome | undefined; +let proxy: ReturnType | undefined; +let upstream: ReturnType | undefined; +const origin = "http://fixture.example"; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + fixtureHome = mkdtempSync(join(tmpdir(), "ocx-chat-request-id-")); + process.env.OPENCODEX_HOME = fixtureHome; + codexHome = installIsolatedCodexHome("ocx-chat-id-codex-"); + resetWorkflowBudgetsForTest(); +}); + +afterEach(async () => { + await proxy?.stop(true); + proxy = undefined; + await upstream?.stop(true); + upstream = undefined; + resetWorkflowBudgetsForTest(); + await drainAndRemoveFixtureRoots({ + roots: [{ path: fixtureHome }, ...(codexHome ? [{ path: codexHome.path }] : [])], + restoreEnvironment() { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome?.restoreEnvironment(); + }, + }); + codexHome = undefined; +}); + +function launch(authenticated = false, status = 200, override: Partial = {}, hang = false) { + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { + if (status !== 200) return Response.json({ error: { message: "fixture refusal" } }, { status }); + const frame = { id: "shared-provider-id", choices: [{ index: 0, delta: { content: "fixture answer" }, finish_reason: "stop" }], + usage: { prompt_tokens: 11, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 3 } } }; + const body = hang ? new ReadableStream({ start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ ...frame, choices: [{ index: 0, delta: { content: "OPEN" }, finish_reason: null }], usage: undefined })}\n\n`)); + } }, { highWaterMark: 0 }) : `data: ${JSON.stringify(frame)}\n\ndata: [DONE]\n\n`; + return new Response(body, { headers: { + "content-type": "text/event-stream", "x-opencodex-request-id": "upstream-controlled", + } }); + } }); + saveConfig({ port: 0, hostname: authenticated ? "0.0.0.0" : "127.0.0.1", defaultProvider: "mock", + codexAutoStart: false, codexShimAutoRestore: false, connectTimeoutMs: 1000, + apiKeys: [{ id: "fixture-key-id", name: "fixture", key: "fixture-data-key", createdAt: "2026-01-01T00:00:00Z" }], + providers: { mock: { adapter: "openai-chat", baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + apiKey: "fixture-provider-key", allowPrivateNetwork: true, liveModels: false, models: ["echo"] } }, + corsAllowOrigins: [origin], ...override, + } as OcxConfig); + proxy = startServer(0); +} + +function send(stream = false, headers: Record = {}, invalid = false, signal?: AbortSignal) { + return fetch(`http://127.0.0.1:${proxy!.port}/v1/chat/completions`, { method: "POST", signal, headers: { + "content-type": "application/json", authorization: "Bearer fixture-data-key", origin, + "x-opencodex-request-id": "caller-controlled", ...headers, + }, body: JSON.stringify({ model: "mock/echo", stream, messages: invalid ? null : [{ role: "user", content: "fixture question" }] }) }); +} + +async function durableRow(response: Response, expectedStatus: number) { + expect(response.status).toBe(expectedStatus); + const id = response.headers.get("x-opencodex-request-id"); + expect(id).toMatch(/^ocx-[a-f0-9]{32}$/); + expect(response.headers.get("access-control-allow-origin")).toBe(origin); + const exposed = response.headers.get("access-control-expose-headers")!.toLowerCase().split(",").map(name => name.trim()); + expect(exposed.filter(name => name === "x-opencodex-request-id")).toHaveLength(1); + const text = await response.text(); + let rows = readRecentUsageEntries(100, fixtureHome).filter(row => row.requestId === id); + for (let i = 0; i < 100 && rows.length === 0; i++) { + await Bun.sleep(10); + rows = readRecentUsageEntries(100, fixtureHome).filter(row => row.requestId === id); + } + expect(rows).toHaveLength(1); + expect(rows[0]!.status).toBe(expectedStatus); + expect(rows[0]!.inboundProtocol).toBe("chat"); + return { row: rows[0]!, text, id }; +} + +for (const authenticated of [false, true]) { + for (const stream of [false, true]) test(`${authenticated ? "authenticated" : "loopback"} Chat ${stream ? "SSE" : "JSON"} correlates to its history row`, async () => { + launch(authenticated); + const { row, text } = await durableRow(await send(stream), 200); + expect(text).toContain("fixture answer"); + if (stream) expect(text).toContain("[DONE]"); + else expect(JSON.parse(text).choices[0].message.content).toBe("fixture answer"); + expect(row.usage).toEqual({ inputTokens: 11, outputTokens: 5, cachedInputTokens: 3 }); + }); +} + +test("concurrent replies with identical upstream completion IDs have distinct history IDs", async () => { + launch(); + const replies = await Promise.all(Array.from({ length: 3 }, () => send())); + const records = await Promise.all(replies.map(reply => durableRow(reply, 200))); + expect(new Set(records.map(record => record.id)).size).toBe(3); +}); + +test("handler-recorded invalid request has a history ID", async () => { + launch(); + await durableRow(await send(false, {}, true), 400); +}); + +test("upstream HTTP error has a history ID", async () => { + launch(false, 503); + await durableRow(await send(), 503); +}); + +test("pre-handler workflow refusal has the logged history ID", async () => { + launch(); + const admitted = admitWorkflowTurn("fixture-root", "interactive"); + expect(admitted?.admitted).toBe(true); + if (admitted?.admitted) admitted.lease.release(); + chargeWorkflowSends("fixture-root", DEFAULT_WORKFLOW_BUDGET_POLICY.maxPhysicalSends); + const response = await send(false, { "x-codex-parent-thread-id": "fixture-root" }); + const { row } = await durableRow(response, 429); + expect(response.headers.get("access-control-expose-headers")).toContain("x-opencodex-local-refusal"); + expect(row.errorCode).toBe(response.headers.get("x-opencodex-local-refusal")); +}); + +test("pre-handler body-capacity refusal has a history ID", async () => { + launch(false, 200, { maxInboundBodyBytes: MAX_CONCURRENT_INBOUND_BODY_BYTES }); + const held = await withRaisedInboundBodyAdmission(new Request("http://localhost/v1/chat/completions", { + method: "POST", body: "{}", + }), "/v1/chat/completions", MAX_CONCURRENT_INBOUND_BODY_BYTES, + async () => new Response(new ReadableStream({}, { highWaterMark: 0 }))); + try { await durableRow(await send(), 503); } + finally { await held.body!.cancel("fixture released"); } +}); + +test("stream cancellation keeps its eventual history ID", async () => { + launch(false, 200, {}, true); + const client = new AbortController(); + try { + const response = await send(true, {}, false, client.signal); + const id = response.headers.get("x-opencodex-request-id"); + expect(id).toMatch(/^ocx-[a-f0-9]{32}$/); + client.abort(); + await response.body!.cancel().catch(() => {}); + let row = readRecentUsageEntries(100, fixtureHome).find(entry => entry.requestId === id); + for (let i = 0; i < 100 && !row; i++) { + await Bun.sleep(10); + row = readRecentUsageEntries(100, fixtureHome).find(entry => entry.requestId === id); + } + expect(row?.status).toBe(499); + expect(row?.inboundProtocol).toBe("chat"); + } finally { client.abort(); } +}); + +for (const rejection of ["auth", "origin", "active-turn"] as const) test(`${rejection} refusal has no fabricated history ID`, async () => { + launch(true); + const leases = rejection === "active-turn" ? Array.from({ length: MAX_ACTIVE_TURNS }, () => tryAdmitTurn()) : []; + try { + if (leases.length) expect(leases.every(Boolean)).toBe(true); + const before = readRecentUsageEntries(100, fixtureHome).length; + const response = await send(false, rejection === "auth" ? { authorization: "Bearer invalid-fixture-key" } + : rejection === "origin" ? { origin: "https://untrusted.invalid" } : {}); + expect(response.status).toBe(rejection === "auth" ? 401 : rejection === "origin" ? 403 : 503); + expect(response.headers.has("x-opencodex-request-id")).toBe(false); + await response.text(); + expect(readRecentUsageEntries(100, fixtureHome)).toHaveLength(before); + } finally { for (const lease of leases) lease?.release(); } +}); diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index 4f980240ea0..438b79bfe73 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -132,13 +132,13 @@ describe("local client inference wires on the loopback listener (#4236)", () => // It is now served on the loopback listener, so the public config must not be the source // of its CORS headers — the same rule the Anthropic routes above already follow. const chatStart = source.indexOf('url.pathname === "/v1/chat/completions"'); - const nextRoute = source.indexOf("url.pathname === \"/v1/live\"", chatStart); + const nextRoute = source.indexOf('url.pathname === "/v1/audio/transcriptions"', chatStart); expect(chatStart).toBeGreaterThan(-1); const branch = source.slice(chatStart, nextRoute); // The handler receives the caller-session-promoted request (#6520); CORS still uses req. expect(branch).toContain("handleChatCompletions(sessionReq, config, logCtx"); - expect(branch).toContain("req,\n policy,\n )"); - expect(branch).not.toContain("req,\n config,\n )"); + expect(branch).toMatch(/return withCors\(\s*await handleChatCompletions\([^\n]+\),\s*req,\s*policy,\s*\)/); + expect(branch).not.toMatch(/,\s*req,\s*config,\s*\)/); }); }); From bdb03fb492a85b92b56eb4b63435385efc90fd9f Mon Sep 17 00:00:00 2001 From: YayoiNanoka Date: Sat, 17 Oct 2026 10:05:36 +0800 Subject: [PATCH 12/14] fix(responses): reclaim least recently used idle WebSocket at capacity (#7095) * fix(responses): reclaim oldest idle WebSocket at capacity * fix(responses): preserve tool ownership during idle reclamation * test(responses): track response tool ownership in core contracts * test(responses): cover disconnect and reclaimed continuation recovery * fix(responses): preserve WebSocket turn and client tool ownership * test(responses): separate socket admission and close waits * test(responses): retry capacity upgrades after close release --------- Co-authored-by: GOAT Co-authored-by: JUN --- .../content/docs/guides/codex-integration.md | 8 + scripts/test-layout/layout.json | 3 + src/codex/websocket-activity.ts | 161 +++++++ src/codex/websocket-registry.ts | 26 +- src/server/index/live-sideband.ts | 5 +- src/server/index/websocket-handler.ts | 22 +- src/server/lifecycle.ts | 14 +- src/server/responses-undeclared-tool-guard.ts | 2 +- .../responses/combo-stream-preflight.ts | 5 +- src/server/responses/compaction-recovery.ts | 2 + src/server/responses/core-lifetime.ts | 5 +- src/server/responses/passthrough-execution.ts | 5 +- .../responses/response-tool-ownership.ts | 21 + src/server/ws-bridge.ts | 2 + structure/transports/responses.md | 2 +- structure/transports/streaming-health.md | 32 ++ .../active-registry-admission.test.ts | 71 ++- .../websocket-capacity-activity.test.ts | 454 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 5 +- tests/helpers/responses-core-source.ts | 1 + .../responses/responses-core-modules.test.ts | 13 +- .../responses/ws-capacity-reclamation.test.ts | 328 +++++++++++++ .../ws-capacity-tool-ownership.test.ts | 254 ++++++++++ 23 files changed, 1417 insertions(+), 24 deletions(-) create mode 100644 src/codex/websocket-activity.ts create mode 100644 src/server/responses/response-tool-ownership.ts create mode 100644 tests/codex-integration/websocket-capacity-activity.test.ts create mode 100644 tests/responses/ws-capacity-reclamation.test.ts create mode 100644 tests/responses/ws-capacity-tool-ownership.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index d4f611149c4..82cf7aaf446 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -427,6 +427,14 @@ provider advertises `supports_websockets = true` only when `"websockets": true`; built-in provider may try WebSocket first, and a disabled proxy returns `426` so Codex falls back to HTTP/SSE. +The proxy admits up to 128 client-facing Responses WebSocket connections. When a new connection +finds all 128 slots occupied, it closes the least recently used idle connection to make room. Connections +with an active response, pending client tool or approval results, a native steering/injection +session, or buffered output are protected. If no idle connection can be released, the new +connection receives the existing `503` capacity response. A reclaimed client can reconnect for +later requests using the existing continuation handling. This happens only under connection +pressure, needs no setting, and does not change voice connections or upstream connection reuse. + If a canonical ChatGPT forward continuation references expired or missing local replay state, opencodex returns `previous_response_not_found` before sending anything upstream. Codex's WebSocket client recognizes this error and can reconnect with its full retained context, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c7b7f40cfb5..ca3cea00b67 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -724,6 +724,7 @@ "codex-warmup.test.ts": "codex-integration", "codex-web-search-switch.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", + "websocket-capacity-activity.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", "coding-agent-tool-result-images.test.ts": "adapters", "cold-spawn-warmup.test.ts": "ci-workflows", @@ -1921,6 +1922,8 @@ "winsw.test.ts": "service", "workflow-budget.test.ts": "lib", "ws-ambiguous-resend.test.ts": "responses", "ws-pre-output-refusal.test.ts": "responses", "responses-pool-overload-failover.test.ts": "responses", "compact-transient-alternate.test.ts": "responses", + "ws-capacity-reclamation.test.ts": "responses", + "ws-capacity-tool-ownership.test.ts": "responses", "ws-endpoint.test.ts": "responses", "ws-failure-stage.test.ts": "responses", "ws-native-injection.test.ts": "responses", diff --git a/src/codex/websocket-activity.ts b/src/codex/websocket-activity.ts new file mode 100644 index 00000000000..5173cec7067 --- /dev/null +++ b/src/codex/websocket-activity.ts @@ -0,0 +1,161 @@ +import { createHash } from "node:crypto"; +import { isAuthorizedProviderExecutedCall, type ProviderExecutedCallType } from "../server/responses-undeclared-tool-guard"; + +// Logical recency avoids clock adjustments and ties between messages in one millisecond. +let activityOrder = 0; +const MAX_PENDING_RESULTS = 1024; +const MAX_RESULT_ID_LENGTH = 1024; +const resultTypes: Readonly> = { + function_call: "function_call_output", + custom_tool_call: "custom_tool_call_output", + local_shell_call: "function_call_output", + shell_call: "shell_call_output", + computer_call: "computer_call_output", + apply_patch_call: "apply_patch_call_output", + tool_search_call: "tool_search_output", + mcp_approval_request: "mcp_approval_response", +}; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function resultKey(type: string, id: string): string { + return createHash("sha256").update(JSON.stringify([type, id])).digest("hex"); +} + +/** Content-free activity owned by one admitted incoming Responses socket. */ +export class CodexWebSocketActivity { + lastUsed = ++activityOrder; + closing = false; + private turns = 0; + private pendingResults = new Set(); + private returnedResults = new Set(); + // Hashed identities only; null marks a route-authorized provider-owned call. + private argumentCalls = new Map(); + // Unknown/oversized identities cannot safely prove that client work finished. + private untrackedResults = false; + + get idle(): boolean { + return !this.closing && this.turns === 0 && this.pendingResults.size === 0 && !this.untrackedResults; + } + + touch(): void { + this.lastUsed = ++activityOrder; + } + + /** Each turn retains its own busy reference, including superseded async cleanup. */ + beginTurn(input: unknown): () => void { + this.turns += 1; + this.touch(); + this.acceptResults(input); + // Ordinary creates start a new response even when its upstream omits response.created. + // Prior results settle old waits, but cannot suppress new calls reusing their IDs. + this.returnedResults.clear(); + this.argumentCalls.clear(); + let finished = false; + return () => { + if (finished) return; + finished = true; + this.turns -= 1; + if (this.turns === 0) this.touch(); + }; + } + + /** Called only after admission or native control accepts the client's results. */ + acceptResults(input: unknown): void { + if (this.untrackedResults) return; + if (Array.isArray(input)) { + for (const item of input) { + if (!record(item) || typeof item.type !== "string") continue; + const id = item.type === "mcp_approval_response" ? item.approval_request_id : item.call_id; + if (typeof id === "string" && id.length <= MAX_RESULT_ID_LENGTH) { + // Ordinary Codex shell results use function_call_output; native steering + // also accepts its explicit local_shell_call_output continuation stub. + const type = item.type === "local_shell_call_output" ? "function_call_output" : item.type; + const key = resultKey(type, id); + if (this.pendingResults.delete(key)) { + if (this.returnedResults.size >= MAX_PENDING_RESULTS) { this.protectUntracked(); return; } + else this.returnedResults.add(key); + } + } + } + } + } + + /** Preserve tool waits even when the response terminal omits its output array. */ + observePayload(payload: string, providerExecuted?: ReadonlySet): void { + if (this.untrackedResults) return; + let frame: unknown; + try { frame = JSON.parse(payload); } catch { return; } + if (!record(frame)) return; + // Native injection can return a result before the same terminal repeats its call. + if (frame.type === "response.created") { + this.returnedResults.clear(); + this.argumentCalls.clear(); + } + if (frame.type === "response.output_item.added") this.rememberArgumentCall(frame.item, providerExecuted); + if (frame.type === "response.function_call_arguments.done" || frame.type === "response.custom_tool_call_input.done") { + const type = frame.type === "response.function_call_arguments.done" ? "function_call" : "custom_tool_call"; + const call = typeof frame.item_id === "string" && frame.item_id.length <= MAX_RESULT_ID_LENGTH + ? this.argumentCalls.get(resultKey("item", frame.item_id)) : undefined; + if (!call || call.type !== type) this.protectUntracked(); + else if (call.result !== null) this.observeResultKey(call.result); + } + if (frame.type === "response.output_item.done") this.observeOutput(frame.item, providerExecuted); + if (["response.completed", "response.incomplete", "response.failed"].includes(String(frame.type)) + && record(frame.response) && Array.isArray(frame.response.output)) { + for (const item of frame.response.output) this.observeOutput(item, providerExecuted); + } + } + + private rememberArgumentCall(item: unknown, providerExecuted?: ReadonlySet): void { + if (!record(item) || (item.type !== "function_call" && item.type !== "custom_tool_call")) return; + if (typeof item.id !== "string" || !item.id.length || item.id.length > MAX_RESULT_ID_LENGTH) return; + const key = resultKey("item", item.id); + const hosted = providerExecuted && isAuthorizedProviderExecutedCall(item, providerExecuted); + if (!hosted && (typeof item.call_id !== "string" || !item.call_id.length || item.call_id.length > MAX_RESULT_ID_LENGTH)) { + if (this.argumentCalls.has(key)) this.protectUntracked(); + return; + } + const result = hosted ? null : resultKey(resultTypes[item.type]!, item.call_id as string); + const previous = this.argumentCalls.get(key); + if (previous) { + if (previous.type !== item.type || previous.result !== result) this.protectUntracked(); + return; + } + if (this.argumentCalls.size >= MAX_PENDING_RESULTS) { this.protectUntracked(); return; } + // An unfinished argument stream alone does not prove the client started work. + this.argumentCalls.set(key, { type: item.type, result }); + } + + private observeOutput(item: unknown, providerExecuted?: ReadonlySet): void { + if (this.untrackedResults) return; + if (!record(item) || typeof item.type !== "string") return; + // Hosted execution may publish both the call and its result in the output stream. + this.acceptResults([item]); + if (!Object.hasOwn(resultTypes, item.type)) return; + if (providerExecuted && isAuthorizedProviderExecutedCall(item, providerExecuted)) return; + if (item.type === "tool_search_call" && item.execution !== "client") return; + const id = item.type === "mcp_approval_request" ? item.id + : item.type === "local_shell_call" || item.type === "tool_search_call" ? item.call_id ?? item.id : item.call_id; + if (typeof id !== "string" || !id.length || id.length > MAX_RESULT_ID_LENGTH) { + this.protectUntracked(); + return; + } + this.observeResultKey(resultKey(resultTypes[item.type]!, id)); + } + + private observeResultKey(key: string): void { + if (this.pendingResults.has(key) || this.returnedResults.has(key)) return; + if (this.pendingResults.size >= MAX_PENDING_RESULTS) this.protectUntracked(); + else this.pendingResults.add(key); + } + + private protectUntracked(): void { + this.untrackedResults = true; + this.pendingResults.clear(); + this.returnedResults.clear(); + this.argumentCalls.clear(); + } +} diff --git a/src/codex/websocket-registry.ts b/src/codex/websocket-registry.ts index 91af86aecb4..405179972b0 100644 --- a/src/codex/websocket-registry.ts +++ b/src/codex/websocket-registry.ts @@ -1,23 +1,47 @@ import type { ServerWebSocket } from "bun"; import type { WsData } from "../server/ws-bridge"; import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission"; +import { CodexWebSocketActivity } from "./websocket-activity"; export const MAX_TRACKED_CODEX_WEBSOCKETS = 128; const websocketGate = createAdmissionGate("codex_websockets", MAX_TRACKED_CODEX_WEBSOCKETS); +const admittedSockets = new Set>(); + +function reclaimOldestIdleSocket(): void { + let oldest: ServerWebSocket | undefined; + for (const ws of admittedSockets) { + const activity = ws.data.responsesActivity; + if (!activity?.idle || ws.data.nativeControl || ws.readyState !== 1 || ws.getBufferedAmount() !== 0) continue; + if (!oldest || activity.lastUsed < oldest.data.responsesActivity!.lastUsed) oldest = ws; + } + if (!oldest) return; + // Fence late frames before closing. Only the actual close callback releases admission. + oldest.data.responsesActivity!.closing = true; + try { + oldest.close(1001, "Idle Responses connection reclaimed"); + } catch { + // A failed/delayed close never manufactures a free slot or retries another victim. + if (oldest.readyState === 1) oldest.data.responsesActivity!.closing = false; + } +} export function tryReserveCodexWebSocket(): AdmissionReservation> | null { + if (websocketGate.metrics().active >= MAX_TRACKED_CODEX_WEBSOCKETS) reclaimOldestIdleSocket(); const gateLease = websocketGate.tryAcquire(); if (!gateLease) return null; let socket: ServerWebSocket | undefined; let active = true; return { bind(value) { - if (!active) return; + if (!active || socket) return; socket = value; + socket.data.responsesActivity = new CodexWebSocketActivity(); + admittedSockets.add(socket); }, release() { if (!active) return; active = false; + if (socket) admittedSockets.delete(socket); socket = undefined; gateLease.release(); }, diff --git a/src/server/index/live-sideband.ts b/src/server/index/live-sideband.ts index 8c4b639eb5f..d0840bdc626 100644 --- a/src/server/index/live-sideband.ts +++ b/src/server/index/live-sideband.ts @@ -40,8 +40,9 @@ export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; * * What must never happen is neither: an immortal socket plus a destination that silently accepts * the orphaned delta. `tests/responses/ws-endpoint.test.ts` holds exactly that pair together. - * Raising the timeout off 0 is still worth doing for its own reasons (a dead peer holds a socket - * forever today), and Bun caps the value at 255 seconds, well inside the bound below. + * Responses admission separately reclaims its least recently used idle connection under + * capacity pressure. That is not a time limit, so expired-reference validation remains required. + * Keeping this shared timeout at 0 also preserves quiet live/voice sessions. */ export const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; /** Ceiling a finite websocket idle timeout must stay under, in seconds. See above. */ diff --git a/src/server/index/websocket-handler.ts b/src/server/index/websocket-handler.ts index b0e4057517e..e39f2e71403 100644 --- a/src/server/index/websocket-handler.ts +++ b/src/server/index/websocket-handler.ts @@ -89,6 +89,7 @@ import { import type { ServeOptionsContext } from "./serve-options"; import type { RequestMetricsRecorder } from "../request-metrics"; import { resolveInboundBodyLimitBytes } from "../request-decompress"; +import { responseToolOwnership } from "../responses/response-tool-ownership"; /** * The WebSocket half of the Bun.serve options, split out of serve-options.ts to keep that file @@ -184,6 +185,8 @@ export function createWebsocketHandler( } return; } + if (ws.data.responsesActivity?.closing) return; + ws.data.responsesActivity?.touch(); const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength; if (rawBytes > MAX_WS_FRAME_BYTES) { sendJsonFrame(ws, buildWsErrorFrame(413, { @@ -223,6 +226,7 @@ export function createWebsocketHandler( if (frame.type === "response.inject") { if (!ws.data.nativeControl?.inject) throw new NativeSteeringError("injection_not_supported", "Native injection is disabled or unavailable on this route."); ws.data.nativeControl.inject(frame); + ws.data.responsesActivity?.acceptResults(frame.input); return; } if (frame.type === "response.steer") { @@ -230,7 +234,10 @@ export function createWebsocketHandler( ws.data.nativeControl.steer(frame); return; } - if (ws.data.nativeControl?.continue(frame)) return; + if (ws.data.nativeControl?.continue(frame)) { + ws.data.responsesActivity?.acceptResults(frame.input); + return; + } } catch (error) { sendJsonFrame(ws, buildWsErrorFrame(400, { type: "invalid_request_error", @@ -294,6 +301,7 @@ export function createWebsocketHandler( } // Only a genuinely admitted turn may receive steering or continuations. + const finishActivity = ws.data.responsesActivity?.beginTurn(frame.input); ws.data.nativeControl = nativeControl; const payload: Record = { ...frame }; delete payload.type; @@ -346,11 +354,16 @@ export function createWebsocketHandler( terminalRecorder = recorder; }, }); + const observeLog = nativeControl?.relayActive + ? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start)) + : (payload: string) => inspectResponseLogSsePayload(logCtx, payload); + const providerExecuted = responseToolOwnership(response); await sendResponseToWebSocket(ws, response, isCurrent, { untilEof: nativeControl?.relayActive === true, - onSsePayload: nativeControl?.relayActive - ? createNativeSteeringLogObserver(logCtx, () => recordFirstOutput(logCtx, start)) - : payload => inspectResponseLogSsePayload(logCtx, payload), + onSsePayload: payload => { + ws.data.responsesActivity?.observePayload(payload, providerExecuted); + observeLog(payload); + }, onTerminal: status => { terminalRecorder?.(status, logCtx.terminalHttpStatus); finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), { @@ -392,6 +405,7 @@ export function createWebsocketHandler( /* socket already gone or send dropped */ } } finally { + finishActivity?.(); turnAdmissionLease.release(); if (ws.data.nativeControl === nativeControl) ws.data.nativeControl = undefined; if (!logged && turnAbort.signal.aborted) finalizeLog(499); diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 97d1d5caa72..c9e6670ee14 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -38,6 +38,7 @@ export interface ActiveTurnLease extends AdmissionLease { attach(lease: AdmissionLease): void; bindAbortSignal(signal: AbortSignal): () => void; bindAbortController(ac: AbortController): void; + unbindAbortController(ac: AbortController): void; beginCodexAccountSelection(): CodexAccountSelectionAdmission; isTransferred(): boolean; } @@ -231,6 +232,17 @@ export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal) activeTurns.set(ac, lease); lease.bindAbortSignal(ac.signal); }, + unbindAbortController(ac) { + if (!controllers.delete(ac)) return; + if (activeTurns.get(ac) === lease) activeTurns.delete(ac); + const listener = abortListeners.get(ac.signal); + if (listener) { + ac.signal.removeEventListener("abort", listener); + abortListeners.delete(ac.signal); + } + // A completed child relay cannot retire its still-running parent turn. + if (controllers.size === 0) lease.release(); + }, beginCodexAccountSelection() { const mainProfileDraining = nativeMainDrainOwners.size > 0; let selectionActive = active && !mainProfileDraining; @@ -327,7 +339,7 @@ export function unregisterTurn(ac: AbortController): void { turnReleaseMisses += 1; return; } - lease.release(); + lease.unbindAbortController(ac); } export function isDraining(): boolean { return shutdownDraining || temporaryDrainOwners.size > 0; } export function getActiveTurnCount(): number { return turnGate.metrics().active; } diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index e0a8b7029ad..d64a802b2ab 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -298,7 +298,7 @@ export function collectProviderExecutedCallTypes(body: unknown): Set, callTypes: ProviderExecutedCallTypes, ): boolean { diff --git a/src/server/responses/combo-stream-preflight.ts b/src/server/responses/combo-stream-preflight.ts index 390097b0762..5cdc1b4ddb6 100644 --- a/src/server/responses/combo-stream-preflight.ts +++ b/src/server/responses/combo-stream-preflight.ts @@ -4,6 +4,7 @@ import { comboFailureDecision } from "../../combos"; import { httpStatusFromTerminalError } from "../../lib/errors"; import type { RequestFailureStage } from "../../lib/request-failure-model"; import type { RequestLogContext } from "../request-log"; +import { carryResponseToolOwnership } from "./response-tool-ownership"; import { createSseInspector } from "../relay"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; @@ -198,11 +199,11 @@ function replayBufferedResponse( reader.cancel(reason).catch(() => undefined); }, }); - return new Response(body, { + return carryResponseToolOwnership(response, new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers, - }); + })); } function failedTerminalResponse( diff --git a/src/server/responses/compaction-recovery.ts b/src/server/responses/compaction-recovery.ts index 1b23aa6744a..f35c6a8d39f 100644 --- a/src/server/responses/compaction-recovery.ts +++ b/src/server/responses/compaction-recovery.ts @@ -1,4 +1,5 @@ import type { AdapterEvent, OcxConfig } from "../../types"; +import { carryResponseToolOwnership } from "./response-tool-ownership"; import { routeConcreteModel, type RouteResult } from "../../router"; import { copyPlainData } from "../../lib/plain-data"; import { jsonUtf8Bytes } from "../../lib/json-byte-size"; @@ -78,6 +79,7 @@ async function bufferedJson(response: Response, signal: AbortSignal): Promise<{ headers.delete("content-length"); headers.delete("content-encoding"); const replacement = new Response(bytes.bytes, { status: response.status, statusText: response.statusText, headers }); + carryResponseToolOwnership(response, replacement); if (isNonReplayableResponse(response)) markResponseNonReplayable(replacement); try { const json: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.bytes)); diff --git a/src/server/responses/core-lifetime.ts b/src/server/responses/core-lifetime.ts index 2dd337ae534..4050f4be44f 100644 --- a/src/server/responses/core-lifetime.ts +++ b/src/server/responses/core-lifetime.ts @@ -1,4 +1,5 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; +import { carryResponseToolOwnership } from "./response-tool-ownership"; import { finalizeTranslatorBudgetResponse } from "../../lib/translator-budget"; import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; import { @@ -50,7 +51,7 @@ export function finalizeOwnedTranslatorBudget(response: Response, budget: Transl if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(finalizedResponse); if (isNonReplayableResponse(response)) markResponseNonReplayable(finalizedResponse); if (runTurnAdapterSseResponses.has(response)) runTurnAdapterSseResponses.add(finalizedResponse); - return finalizedResponse; + return carryResponseToolOwnership(response, finalizedResponse); } /** Release a serving-account slot when the client body ends, errors or is cancelled. */ @@ -75,7 +76,7 @@ export function finalizeAccountLease(response: Response, release: () => void, on if (isEagerRelaySseResponse(response)) markEagerRelaySseResponse(wrapped); if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(wrapped); if (runTurnAdapterSseResponses.has(response)) runTurnAdapterSseResponses.add(wrapped); - return wrapped; + return carryResponseToolOwnership(response, wrapped); } diff --git a/src/server/responses/passthrough-execution.ts b/src/server/responses/passthrough-execution.ts index c41c1c3acc9..5d72e561493 100644 --- a/src/server/responses/passthrough-execution.ts +++ b/src/server/responses/passthrough-execution.ts @@ -14,6 +14,7 @@ import { guardDirectPassthroughBodyInactivity } from "../../lib/response-body-in import { resolveStallTimeoutMs } from "../../stall-timeout"; import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health"; import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; +import { attachResponseToolOwnership } from "./response-tool-ownership"; /** Owns the native host lease across dispatch, recovery, and response construction. */ export async function executePassthroughResponse( @@ -50,11 +51,11 @@ export async function executePassthroughResponse( // Delivery has already classified SSE (including a missing upstream content type) // and consumed bounded JSON/errors. Guard only its remaining direct body, outside // the relay/lifetime wrappers so their prefetch cannot arm our inactivity clock. - return guardDirectPassthroughBodyInactivity( + return attachResponseToolOwnership(guardDirectPassthroughBodyInactivity( response, nativeExchange.upstream.signal, resolveStallTimeoutMs(requestContext.config.stallTimeoutSec, { localUpstream: nativeExchange.localUpstream }), - ); + ), nativeExchange.providerExecutedCallTypes); } finally { if (nativeHostState.lease) { releaseUpstreamHostAdmission(nativeHostState.lease); diff --git a/src/server/responses/response-tool-ownership.ts b/src/server/responses/response-tool-ownership.ts new file mode 100644 index 00000000000..323095a24da --- /dev/null +++ b/src/server/responses/response-tool-ownership.ts @@ -0,0 +1,21 @@ +import type { ProviderExecutedCallType } from "../responses-undeclared-tool-guard"; + +// Route-authorized execution ownership follows only the response selected for delivery. +// It never becomes a wire header or mutable state shared by competing route attempts. +const providerCalls = new WeakMap>(); + +export function attachResponseToolOwnership(response: Response, types: ReadonlySet): Response { + if (types.size > 0) providerCalls.set(response, new Set(types)); + return response; +} + +export function responseToolOwnership(response: Response): ReadonlySet | undefined { + return providerCalls.get(response); +} + +/** Carry ownership only when rebuilding the same selected response body. */ +export function carryResponseToolOwnership(source: Response, target: Response): Response { + const types = providerCalls.get(source); + if (types) providerCalls.set(target, types); + return target; +} diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index da2d2c50883..4009a48c1d7 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -11,6 +11,7 @@ import { BoundedSseFrameBuffer } from "./sse-frame-buffer"; import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite"; import { safeResponseHeaders } from "./safe-response-headers"; import type { AudioSocketTarget } from "./audio-dictation"; +import type { CodexWebSocketActivity } from "../codex/websocket-activity"; export { safeResponseHeaders } from "./safe-response-headers"; @@ -19,6 +20,7 @@ type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void; type ResponsesPayloadObserver = (payload: string) => void; export interface WsData { + responsesActivity?: CodexWebSocketActivity; nativeControl?: NativeResponseControl; /** Content-free per-turn explanation; never a model capability assertion. */ nativeSteeringUnavailable?: string; diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 771530b8d2f..97b4e0b747d 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -71,7 +71,7 @@ silence. It cannot observe the client's workspace or prove whether a successful repository state. Consequently, the active-turn and session-lane gates are concurrency admission limits, the translator budget follows [live retained-byte ownership](byte-accounting.md#stream-buffer-accounting), 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. The turn lease in `src/server/lifecycle.ts` subscribes to HTTP ingress cancellation, internal decision cancellation and every registered controller. Cancellation snapshots only that turn's controllers, releases its admission, session-lane reference, attached workflow leases and native-main ownership, then aborts the snapshot with the cancellation reason. It does not await upstream work or owner finalization, even when the owner remains suspended; other turns retain their controllers and admission. Late controllers abort immediately without registering or transferring ownership, and cancelled selections cannot claim a native-main account. Live/Realtime acquisition binds ingress cancellation until WebSocket upgrade transfers ownership. Normal completion detaches the listeners; late finalizers remain idempotent and do not release newly admitted turns. `tests/codex-integration/active-turn-disconnect.test.ts` covers these cancellation and ownership boundaries. +semantic no-progress budget. The turn lease in `src/server/lifecycle.ts` subscribes to HTTP ingress cancellation, internal decision cancellation and every registered controller. Cancellation snapshots only that turn's controllers, releases its admission, session-lane reference, attached workflow leases and native-main ownership, then aborts the snapshot with the cancellation reason. It does not await upstream work or owner finalization, even when the owner remains suspended; other turns retain their controllers and admission. Late controllers abort immediately without registering or transferring ownership, and cancelled selections cannot claim a native-main account. Live/Realtime acquisition binds ingress cancellation until WebSocket upgrade transfers ownership. Normal controller completion detaches only that controller and its listener; the last owner or an explicit parent release settles the lease. A completed combo relay cannot retire its parent WebSocket turn or cancel the next attempt, and a completed child's late abort cannot cancel former siblings. Late finalizers remain idempotent. `tests/codex-integration/active-registry-admission.test.ts` covers these shared-owner boundaries. `tests/codex-integration/active-turn-disconnect.test.ts` covers these cancellation and ownership boundaries. > Decision record: [ADR-0031](../decisions/ADR-0031-responses-http-sse.md) > Decision record: [ADR-0032](../decisions/ADR-0032-responses-http-sse.md) diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index a3a8becb95a..d93dad0b0bb 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -256,6 +256,38 @@ egress. HTTP/SSE fallback retains Bun fetch's own proxy rules, which do not cons The endpoint handles `response.create`, ignores `response.processed`, supports warmup `generate: false`, and feeds the same request pipeline as HTTP/SSE. +Incoming Responses connections share a 128-socket admission limit. Only when a new upgrade +finds that limit full, the handler reclaims the least recently used eligible idle connection. +Message handling and turn completion refresh a monotonic use order; connection age alone +does not choose the victim. Eligibility requires an open socket, no active turn, no pending +client tool or approval result, no retained native steering/injection owner, and no buffered +outbound bytes. A superseded turn remains active until its own cleanup finishes. + +Tool and approval identities are tracked across output items and terminal snapshots. Function argument +and custom-tool input completion also establish waits, correlated through `response.output_item.added`, +even if EOF precedes the completed item or terminal. An added item with unfinished arguments alone +establishes no wait. Only matching client or upstream results retire waits, suppressing completion echoes. +Tracking retains bounded identity hashes; overflow or ambiguous completion identities protect the +connection until close. A terminal does not make a connection idle while its client tool runs. + +The tracker reuses the final route's provider-executed tool classification, including declarations +injected into the outbound request. `src/server/responses/response-tool-ownership.ts` carries that +classification with the selected Response through body wrappers; failed combo attempts cannot +authorize the winning response's calls. Hosted xAI search therefore creates no client-result wait. +Each admitted ordinary turn starts a fresh returned-result deduplication scope after settling prior +results, even if its upstream omits `response.created`. Native control keeps same-response echo +suppression. `tests/responses/ws-capacity-tool-ownership.test.ts` covers these ownership boundaries. + +The victim is marked closing before sending close code 1001 with reason +`Idle Responses connection reclaimed`. Only its ordinary close callback releases the +admission lease. After requesting closure, the current upgrade immediately attempts reservation once. +If every socket is protected, the close fails, or the callback has not yet released the lease, +that upgrade receives the existing HTTP 503 `Codex WebSockets capacity reached` response. +A later upgrade can acquire the released slot; admission never exceeds the limit. Reclamation +does not delete saved continuation state; a client can reconnect through the existing upgrade +and replay path. This policy adds no idle timer or configuration and does not change the +separate Live/Realtime sockets or upstream connection reuse. Client disconnect releases socket admission independently of upstream cleanup: the eager relay may continue its existing bounded post-cancel drain for up to 15 seconds to observe a real terminal while a replacement socket already completes new work. + Registry-declared per-model compatibility hints (`modelResponsesUpstreamStreaming`) may ask the upstream Responses endpoint for bounded JSON on ANY client transport — WebSocket or ordinary HTTP/SSE. The bridge reframes that JSON into the same Responses event sequence diff --git a/tests/codex-integration/active-registry-admission.test.ts b/tests/codex-integration/active-registry-admission.test.ts index 18d79349cee..e531dcc718c 100644 --- a/tests/codex-integration/active-registry-admission.test.ts +++ b/tests/codex-integration/active-registry-admission.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { MAX_ACTIVE_TURNS, abortAndReleaseAllTurns, activeRegistryMetrics, trackStreamLifetime, tryAdmitTurn, unregisterTurn } from "../../src/server/lifecycle"; +import { MAX_ACTIVE_TURNS, abortAndReleaseAllTurns, activeRegistryMetrics, registerTurn, trackStreamLifetime, tryAdmitTurn, unregisterTurn } from "../../src/server/lifecycle"; import { workflowBudgetSnapshot } from "../../src/lib/workflow-budget"; import { MAX_TRACKED_CODEX_WEBSOCKETS, @@ -243,6 +243,75 @@ describe("active registry admission", () => { expect(attachedReleases).toBe(1); }); + test("a completed child keeps its parent admitted and cannot cancel the next child", () => { + const before = activeRegistryMetrics().activeTurns; + const lease = tryAdmitTurn()!; + const parent = new AbortController(); + const first = new AbortController(); + const next = new AbortController(); + let released = 0; + lease.attach({ release() { released += 1; } }); + try { + registerTurn(parent, lease); + registerTurn(first, lease); + unregisterTurn(first); + expect(activeRegistryMetrics().activeTurns.active).toBe(before.active + 1); + expect(released).toBe(0); + registerTurn(next, lease); + expect(next.signal.aborted).toBe(false); + first.abort(new Error("late cleanup of completed child")); + unregisterTurn(first); + expect(parent.signal.aborted).toBe(false); + expect(next.signal.aborted).toBe(false); + expect(activeRegistryMetrics().activeTurns.active).toBe(before.active + 1); + unregisterTurn(next); + expect(released).toBe(0); + unregisterTurn(parent); + expect(released).toBe(1); + expect(activeRegistryMetrics().activeTurns.active).toBe(before.active); + expect(activeRegistryMetrics().activeTurns.releaseMisses).toBe(before.releaseMisses); + } finally { lease.release(); } + }); + + test("registering the same sole controller twice still releases on its one completion", () => { + const before = activeRegistryMetrics().activeTurns; + const lease = tryAdmitTurn()!; + const controller = new AbortController(); + try { + registerTurn(controller, lease); + registerTurn(controller, lease); + unregisterTurn(controller); + expect(activeRegistryMetrics().activeTurns.active).toBe(before.active); + unregisterTurn(controller); + expect(activeRegistryMetrics().activeTurns.releaseMisses).toBe(before.releaseMisses); + } finally { lease.release(); } + }); + + test("parent cancellation after child completion still releases and aborts the active child", () => { + const before = activeRegistryMetrics().activeTurns; + const lease = tryAdmitTurn()!; + const parent = new AbortController(); + const completed = new AbortController(); + const pending = new AbortController(); + const reason = new Error("parent turn superseded"); + let released = 0; + lease.attach({ release() { released += 1; } }); + try { + registerTurn(parent, lease); + registerTurn(completed, lease); + unregisterTurn(completed); + registerTurn(pending, lease); + parent.abort(reason); + expect(pending.signal.reason).toBe(reason); + expect(completed.signal.aborted).toBe(false); + expect(released).toBe(1); + expect(activeRegistryMetrics().activeTurns.active).toBe(before.active); + unregisterTurn(parent); + unregisterTurn(pending); + expect(activeRegistryMetrics().activeTurns.releaseMisses).toBe(before.releaseMisses); + } finally { lease.release(); } + }); + 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) { diff --git a/tests/codex-integration/websocket-capacity-activity.test.ts b/tests/codex-integration/websocket-capacity-activity.test.ts new file mode 100644 index 00000000000..4744b38777d --- /dev/null +++ b/tests/codex-integration/websocket-capacity-activity.test.ts @@ -0,0 +1,454 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { ServerWebSocket } from "bun"; +import { CodexWebSocketActivity } from "../../src/codex/websocket-activity"; +import { + MAX_TRACKED_CODEX_WEBSOCKETS, codexWebSocketAdmissionMetrics, + registerCodexWebSocket, tryReserveCodexWebSocket, unregisterCodexWebSocket, +} from "../../src/codex/websocket-registry"; +import type { AdmissionReservation } from "../../src/lib/admission"; +import { createWebsocketHandler } from "../../src/server/index/websocket-handler"; +import type { ServeOptionsContext } from "../../src/server/index/serve-options"; +import { MAX_ACTIVE_TURNS, tryAdmitTurn } from "../../src/server/lifecycle"; +import type { NativeResponseControl } from "../../src/server/responses/native-response-control"; +import { sendResponseToWebSocket, type WsData } from "../../src/server/ws-bridge"; + +type Frame = Record; +const leases: AdmissionReservation>[] = []; +const sockets: ServerWebSocket[] = []; +const handler = createWebsocketHandler({ config: {}, deps: {} } as ServeOptionsContext); + +afterEach(() => { + for (const ws of sockets.splice(0)) unregisterCodexWebSocket(ws); + for (const lease of leases.splice(0)) lease.release(); +}); + +function reserve() { + const lease = tryReserveCodexWebSocket(); + if (lease) leases.push(lease); + return lease; +} + +function admittedSocket(mode: "normal" | "delayed" | "throw" = "normal") { + const lease = reserve(); + expect(lease).not.toBeNull(); + const sent: Frame[] = []; + const closed: Array<{ code?: number; reason?: string }> = []; + let state = 1; + let buffered = 0; + const ws = { + data: { admissionLease: lease! } as WsData, + get readyState() { return state; }, + getBufferedAmount: () => buffered, + send(text: string) { sent.push(JSON.parse(text)); return 1; }, + close(code?: number, reason?: string) { + closed.push({ code, reason }); + if (mode === "throw") throw new Error("fixture close failure"); + if (mode === "normal") finishClose(); + }, + } as unknown as ServerWebSocket; + function finishClose() { + state = 3; + handler.close(ws, 1000, "fixture close"); + } + lease!.bind(ws); + registerCodexWebSocket(ws); + sockets.push(ws); + return { ws, sent, closed, finishClose, activity: ws.data.responsesActivity!, + setBuffered(bytes: number) { buffered = bytes; } }; +} + +function output(activity: CodexWebSocketActivity, item: Frame) { + activity.observePayload(JSON.stringify({ type: "response.output_item.done", item })); +} + +const argumentCompletions = [ + ["function_call", "response.function_call_arguments.done", "function_call_output"], + ["custom_tool_call", "response.custom_tool_call_input.done", "custom_tool_call_output"], +] as const; + +describe("Responses final tool arguments", () => { + test.each(argumentCompletions)("%s stays protected after EOF until the real handler accepts its result", async (type, done, result) => { + const client = admittedSocket(); + const finish = client.activity.beginTurn([]); + const argumentField = type === "function_call" ? "arguments" : "input"; + const frames = [ + { type: "response.output_item.added", item: { type, id: "item", call_id: "call", name: "fixture_tool", [argumentField]: "" } }, + { type: done, item_id: "item", [argumentField]: "{}" }, + ]; + await sendResponseToWebSocket(client.ws, new Response( + frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }, + ), () => true, { onSsePayload: payload => client.activity.observePayload(payload) }); + finish(); + expect(client.sent.map(frame => frame.type)).toEqual(["response.output_item.added", done, "error"]); + expect(client.sent.at(-1)).toMatchObject({ error: { message: "Upstream stream ended before response terminal event" } }); + expect(client.ws.data.cancel).toBeUndefined(); + const others = Array.from({ length: MAX_TRACKED_CODEX_WEBSOCKETS - 1 }, () => admittedSocket()); + const finishOthers = others.map(other => other.activity.beginTurn([])); + try { + expect(reserve()).toBeNull(); + expect(client.closed).toEqual([]); + let accepted = false; + client.ws.data.nativeControl = { inject: () => { accepted = true; } } as unknown as NativeResponseControl; + handler.message(client.ws, JSON.stringify({ type: "response.inject", input: [{ type: result, call_id: "call", output: "done" }] })); + expect(accepted).toBe(true); + client.ws.data.nativeControl = undefined; + expect(reserve()).not.toBeNull(); + expect(client.closed).toEqual([{ code: 1001, reason: "Idle Responses connection reclaimed" }]); + } finally { + client.ws.data.nativeControl = undefined; + for (const end of finishOthers) end(); + } + }); + + test.each(argumentCompletions)("unfinished %s arguments do not invent pending client work", (type) => { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + activity.observePayload(JSON.stringify({ type: "response.output_item.added", item: { type, id: "item", call_id: "call" } })); + finish(); + expect(activity.idle).toBe(true); + }); + + test.each(argumentCompletions)("%s completion echoes stay settled and reused identities belong to the next turn", (type, done, resultType) => { + const activity = new CodexWebSocketActivity(); + const call = { type, id: "reused-item", call_id: "reused-call" }; + const result = { type: resultType, call_id: call.call_id, output: "done" }; + const added = JSON.stringify({ type: "response.output_item.added", item: call }); + const completed = JSON.stringify({ type: done, item_id: call.id }); + for (const created of [true, false]) { + const finish = activity.beginTurn([result]); + if (created) activity.observePayload(JSON.stringify({ type: "response.created", response: { id: "response" } })); + activity.observePayload(added); + activity.observePayload(added); // A repeated identity is not a new call. + activity.observePayload(completed); + finish(); + expect(activity.idle).toBe(false); + activity.acceptResults([result]); + activity.observePayload(completed); + output(activity, call); + activity.observePayload(JSON.stringify({ type: "response.completed", response: { output: [call] } })); + expect(activity.idle).toBe(true); + } + }); + + test("native response.created resets argument identities without suppressing a reused call", () => { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + for (const id of ["first", "second"]) { + activity.observePayload(JSON.stringify({ type: "response.created", response: { id } })); + activity.observePayload(JSON.stringify({ type: "response.output_item.added", item: { type: "function_call", id: "item", call_id: id } })); + activity.observePayload(JSON.stringify({ type: "response.function_call_arguments.done", item_id: "item" })); + if (id === "first") activity.acceptResults([{ type: "function_call_output", call_id: id }]); + } + finish(); + expect(activity.idle).toBe(false); + activity.acceptResults([{ type: "function_call_output", call_id: "second" }]); + expect(activity.idle).toBe(true); + }); + + test("hosted custom input completion stays idle only with this response's provider authorization", () => { + const activity = new CodexWebSocketActivity(); + const hosted = new Set([{ itemType: "custom_tool_call", callIdPrefix: "xs_call-" }]); + const added = JSON.stringify({ type: "response.output_item.added", item: { type: "custom_tool_call", id: "item", call_id: "xs_call-1" } }); + const done = JSON.stringify({ type: "response.custom_tool_call_input.done", item_id: "item" }); + const first = activity.beginTurn([]); + activity.observePayload(added, hosted); + activity.observePayload(done, hosted); + first(); + expect(activity.idle).toBe(true); + const second = activity.beginTurn([]); + activity.observePayload(added); + activity.observePayload(done); + second(); + expect(activity.idle).toBe(false); + activity.acceptResults([{ type: "custom_tool_call_output", call_id: "xs_call-1" }]); + expect(activity.idle).toBe(true); + }); + + test("unknown, oversized and mismatched final argument identities remain protected", () => { + for (const item of [ + undefined, + { type: "function_call", id: "", call_id: "call" }, + { type: "function_call", id: "item", call_id: "" }, + { type: "function_call", id: "item", call_id: "x".repeat(1025) }, + { type: "function_call", id: "x".repeat(1025), call_id: "call" }, + { type: "custom_tool_call", id: "item", call_id: "call" }, + ]) { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + activity.observePayload(JSON.stringify({ type: "response.output_item.added", item })); + activity.observePayload(JSON.stringify({ type: "response.function_call_arguments.done", item_id: item?.id ?? "unknown" })); + finish(); + activity.acceptResults([{ type: "function_call_output", call_id: "call" }]); + expect(activity.idle).toBe(false); + } + }); + + test("conflicting or over-budget argument identities conservatively protect the socket", () => { + for (const conflict of [true, false]) { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + for (let index = 0; index < (conflict ? 2 : 1025); index += 1) { + activity.observePayload(JSON.stringify({ type: "response.output_item.added", item: { + type: "function_call", id: conflict ? "conflict" : `item-${index}`, call_id: `call-${index}`, + } })); + } + finish(); + expect(activity.idle).toBe(false); + } + }); + + test.each(argumentCompletions)("invalid %s redefinitions cannot reuse a prior result identity", (type, done, resultType) => { + for (const returnedFirst of [false, true]) { + for (const call_id of [undefined, "", "x".repeat(1025)]) { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + const emit = (frame: Frame) => activity.observePayload(JSON.stringify(frame)); + const result = { type: resultType, call_id: "prior", output: "done" }; + emit({ type: "response.output_item.added", item: { type, id: "item", call_id: "prior" } }); + emit({ type: done, item_id: "item" }); + if (returnedFirst) activity.acceptResults([result]); + emit({ type: "response.output_item.added", item: { type, id: "item", call_id } }); + emit({ type: done, item_id: "item" }); + finish(); + expect(activity.idle).toBe(false); + if (!returnedFirst) activity.acceptResults([result]); + expect(activity.idle).toBe(false); + } + } + }); +}); + +describe("Responses socket activity", () => { + test("overlapping superseded turns each keep their own idempotent busy reference", () => { + const activity = new CodexWebSocketActivity(); + const first = activity.beginTurn([]); + const second = activity.beginTurn([]); + second(); + second(); + expect(activity.idle).toBe(false); + first(); + expect(activity.idle).toBe(true); + const third = activity.beginTurn([]); + first(); + expect(activity.idle).toBe(false); + third(); + expect(activity.idle).toBe(true); + }); + + test.each([ + ["function_call", "function_call_output"], + ["custom_tool_call", "custom_tool_call_output"], + ["local_shell_call", "function_call_output"], + ["shell_call", "shell_call_output"], + ["computer_call", "computer_call_output"], + ["apply_patch_call", "apply_patch_call_output"], + ["tool_search_call", "tool_search_output"], + ])("protects client %s work until its %s arrives", (callType, resultType) => { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + output(activity, { type: callType, call_id: "client-call", execution: "client" }); + activity.observePayload(JSON.stringify({ type: "response.completed", response: { output: [] } })); + finish(); + expect(activity.idle).toBe(false); + const continueTurn = activity.beginTurn([{ type: resultType, call_id: "client-call", output: "done" }]); + expect(activity.idle).toBe(false); + continueTurn(); + expect(activity.idle).toBe(true); + }); + + test("terminal-only tool calls and partial result batches retain all outstanding work", () => { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + output(activity, { type: "function_call", call_id: "first" }); + activity.observePayload(JSON.stringify({ type: "response.completed", response: { output: [ + { type: "function_call", call_id: "first" }, + { type: "custom_tool_call", call_id: "second" }, + { type: "mcp_approval_request", id: "approval" }, + ] } })); + finish(); + activity.beginTurn([{ type: "function_call_output", call_id: "first", output: "done" }])(); + expect(activity.idle).toBe(false); + activity.beginTurn([{ type: "function_call_output", call_id: "second", output: "wrong type" }, + { type: "mcp_approval_response", approval_request_id: "unrelated", approve: true }])(); + expect(activity.idle).toBe(false); + activity.beginTurn([{ type: "custom_tool_call_output", call_id: "second", output: "done" }])(); + expect(activity.idle).toBe(false); + activity.beginTurn([{ type: "mcp_approval_response", approval_request_id: "approval", approve: false }])(); + expect(activity.idle).toBe(true); + }); + + test("hosted tools and malformed observations do not invent client work", () => { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + for (const type of ["web_search_call", "file_search_call", "code_interpreter_call", "mcp_call", "tool_search_call", "message"]) { + output(activity, { type, id: "hosted-item" }); + } + output(activity, { type: "tool_search_call", execution: "server", call_id: "hosted-search" }); + for (const payload of ["bad JSON", "null", "[]", JSON.stringify({ type: "response.completed", response: null })]) { + activity.observePayload(payload); + } + finish(); + expect(activity.idle).toBe(true); + }); + + test("returned native results ignore terminal echoes but cannot mask a later call reusing its ID", () => { + const activity = new CodexWebSocketActivity(); + const call = { type: "function_call", call_id: "reused" }; + const result = { type: "function_call_output", call_id: "reused", output: "done" }; + const finish = activity.beginTurn([]); + activity.observePayload(JSON.stringify({ type: "response.created", response: { id: "first" } })); + output(activity, call); + activity.acceptResults([result]); + activity.observePayload(JSON.stringify({ type: "response.completed", response: { output: [call] } })); + finish(); + expect(activity.idle).toBe(true); + const finishNext = activity.beginTurn([result]); // Full replay repeats a historical result. + activity.observePayload(JSON.stringify({ type: "response.created", response: { id: "second" } })); + output(activity, call); + finishNext(); + expect(activity.idle).toBe(false); + activity.acceptResults([result]); + expect(activity.idle).toBe(true); + }); + + test("hosted shell output clears its call without waiting for a client result", () => { + const activity = new CodexWebSocketActivity(); + const finish = activity.beginTurn([]); + const call = { type: "shell_call", call_id: "hosted-shell", action: { commands: ["pwd"] } }; + const result = { type: "shell_call_output", call_id: "hosted-shell", output: [] }; + output(activity, call); + output(activity, result); + activity.observePayload(JSON.stringify({ type: "response.completed", response: { output: [call, result] } })); + finish(); + expect(activity.idle).toBe(true); + }); + + test.each(["response.inject", "response.create"])("real handler accounts for accepted native results on %s", type => { + const client = admittedSocket(); + const finish = client.activity.beginTurn([]); + const call = { type: "function_call", call_id: "native-call" }; + output(client.activity, call); + let accepted = 0; + const accept = () => { accepted += 1; return true; }; + client.ws.data.nativeControl = { relayActive: true, inject: accept, continue: accept } as unknown as NativeResponseControl; + handler.message(client.ws, JSON.stringify({ type, input: [ + { type: "function_call_output", call_id: "native-call", output: "done" }, + ] })); + expect(accepted).toBe(1); + expect(client.activity.idle).toBe(false); + client.activity.observePayload(JSON.stringify({ type: "response.completed", response: { output: [call] } })); + finish(); + expect(client.activity.idle).toBe(true); + expect(client.sent).toHaveLength(0); + }); + + test.each(["response.inject", "response.create"])("rejected native results on %s cannot release a client tool wait", type => { + const client = admittedSocket(); + output(client.activity, { type: "function_call", call_id: "native-call" }); + const reject = () => { throw new Error("fixture rejected result"); }; + client.ws.data.nativeControl = { relayActive: true, inject: reject, continue: reject } as unknown as NativeResponseControl; + handler.message(client.ws, JSON.stringify({ type, input: [ + { type: "function_call_output", call_id: "native-call", output: "done" }, + ] })); + expect(client.sent).toHaveLength(1); + expect(client.sent[0]).toMatchObject({ type: "error", status: 400 }); + expect(client.activity.idle).toBe(false); + }); + + test("unknown and oversized client result identities remain conservatively protected", () => { + for (const callId of [undefined, "", "x".repeat(1025)]) { + const activity = new CodexWebSocketActivity(); + output(activity, { type: "function_call", call_id: callId }); + activity.beginTurn([{ type: "function_call_output", call_id: callId, output: "done" }])(); + expect(activity.idle).toBe(false); + } + const activity = new CodexWebSocketActivity(); + const results = Array.from({ length: 1025 }, (_, index) => { + const call_id = `bounded-${index}`; + output(activity, { type: "function_call", call_id }); + return { type: "function_call_output", call_id, output: "done" }; + }); + activity.beginTurn(results)(); + expect(activity.idle).toBe(false); + }); + + test("real handler warmup and turn-capacity refusal do not consume pending client results", () => { + const client = admittedSocket(); + output(client.activity, { type: "function_call", call_id: "pending" }); + const input = [{ type: "function_call_output", call_id: "pending", output: "done" }]; + handler.message(client.ws, JSON.stringify({ type: "response.create", generate: false, input })); + expect(client.sent.map(frame => frame.type)).toEqual(["response.created", "response.completed"]); + expect(client.activity.idle).toBe(false); + const turns = Array.from({ length: MAX_ACTIVE_TURNS }, () => tryAdmitTurn()); + try { + expect(turns.every(Boolean)).toBe(true); + handler.message(client.ws, JSON.stringify({ type: "response.create", model: "fixture", input })); + expect(client.sent.at(-1)).toMatchObject({ type: "error", status: 503 }); + expect(client.activity.idle).toBe(false); + } finally { + for (const turn of turns) turn?.release(); + } + }); +}); + +describe("Responses WebSocket capacity reclamation", () => { + test("below capacity leaves idle connections open; at capacity reclaims least recently used", () => { + const clients = Array.from({ length: MAX_TRACKED_CODEX_WEBSOCKETS }, () => admittedSocket()); + expect(clients.every(client => client.closed.length === 0)).toBe(true); + clients[0].activity.beginTurn([])(); + const before = codexWebSocketAdmissionMetrics(); + expect(reserve()).not.toBeNull(); + expect(clients[0].closed).toHaveLength(0); + expect(clients[1].closed).toEqual([{ code: 1001, reason: "Idle Responses connection reclaimed" }]); + expect(clients.slice(2).every(client => client.closed.length === 0)).toBe(true); + expect(codexWebSocketAdmissionMetrics()).toMatchObject({ active: MAX_TRACKED_CODEX_WEBSOCKETS, peak: MAX_TRACKED_CODEX_WEBSOCKETS, + admitted: before.admitted + 1, rejected: before.rejected }); + }); + + test("skips active turns, client tools, buffered output and native control owners", () => { + const clients = Array.from({ length: MAX_TRACKED_CODEX_WEBSOCKETS }, () => admittedSocket()); + const finish = clients[0].activity.beginTurn([]); + output(clients[1].activity, { type: "function_call", call_id: "pending" }); + clients[2].setBuffered(1); + clients[3].ws.data.nativeControl = { relayActive: true } as NativeResponseControl; + expect(reserve()).not.toBeNull(); + expect(clients.slice(0, 4).every(client => client.closed.length === 0)).toBe(true); + expect(clients[4].closed).toHaveLength(1); + finish(); + }); + + test("all busy or unbound reservations leave the cap intact and reject new admission", () => { + const clients = Array.from({ length: MAX_TRACKED_CODEX_WEBSOCKETS - 1 }, () => admittedSocket()); + const finish = clients.map(client => client.activity.beginTurn([])); + expect(reserve()).not.toBeNull(); // An upgrade reserved before its open callback. + const before = codexWebSocketAdmissionMetrics(); + expect(reserve()).toBeNull(); + expect(clients.every(client => client.closed.length === 0)).toBe(true); + expect(codexWebSocketAdmissionMetrics()).toMatchObject({ active: MAX_TRACKED_CODEX_WEBSOCKETS, rejected: before.rejected + 1 }); + for (const end of finish) end(); + }); + + test.each(["delayed", "throw"] as const)("%s close cannot free admission before the close callback", mode => { + const victim = admittedSocket(mode); + const others = Array.from({ length: MAX_TRACKED_CODEX_WEBSOCKETS - 1 }, () => admittedSocket()); + const finish = others.map(client => client.activity.beginTurn([])); + expect(reserve()).toBeNull(); + expect(victim.closed).toHaveLength(1); + expect(victim.activity.closing).toBe(mode === "delayed"); + expect(codexWebSocketAdmissionMetrics().active).toBe(MAX_TRACKED_CODEX_WEBSOCKETS); + handler.message(victim.ws, JSON.stringify({ type: "response.create", generate: false })); + // A closer that throws while still OPEN must not silently disable the old client. + expect(victim.sent).toHaveLength(mode === "delayed" ? 0 : 2); + expect(victim.ws.data.turnId).toBe(mode === "delayed" ? undefined : 1); + expect(reserve()).toBeNull(); + expect(victim.closed).toHaveLength(mode === "delayed" ? 1 : 2); + expect(others.every(client => client.closed.length === 0)).toBe(true); + victim.finishClose(); + victim.finishClose(); + expect(codexWebSocketAdmissionMetrics().active).toBe(MAX_TRACKED_CODEX_WEBSOCKETS - 1); + expect(reserve()).not.toBeNull(); + expect(codexWebSocketAdmissionMetrics().active).toBe(MAX_TRACKED_CODEX_WEBSOCKETS); + for (const end of finish) end(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 70e6fe2d522..60301857e80 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -559,7 +559,8 @@ "codex-user-identity.test.ts": "codex-integration", "codex-v2-gate.test.ts": "codex-integration", "codex-agent-thread-limit.test.ts": "codex-integration", "codex-warmup.test.ts": "codex-integration", "codex-web-search-switch.test.ts": "codex-integration", - "codex-websocket-registry.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", + "codex-websocket-registry.test.ts": "codex-integration", + "websocket-capacity-activity.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", "coding-agent-tool-result-images.test.ts": "adapters", "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-codex-exhaustion-cooldown.test.ts": "codex-integration", @@ -1279,6 +1280,8 @@ "winsw.test.ts": "service", "workflow-budget.test.ts": "lib", "ws-ambiguous-resend.test.ts": "responses", "ws-pre-output-refusal.test.ts": "responses", "responses-pool-overload-failover.test.ts": "responses", "compact-transient-alternate.test.ts": "responses", + "ws-capacity-reclamation.test.ts": "responses", + "ws-capacity-tool-ownership.test.ts": "responses", "ws-endpoint.test.ts": "responses", "ws-failure-stage.test.ts": "responses", "ws-native-injection.test.ts": "responses", diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index d487d4609b5..ad3bba4675a 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -47,6 +47,7 @@ export const RESPONSES_CORE_MODULES = [ "request-transport.ts", "request-sidecar-auth.ts", "response-effects.ts", + "response-tool-ownership.ts", "request-send-budget.ts", "passthrough-execution.ts", "passthrough-dispatch.ts", diff --git a/tests/responses/responses-core-modules.test.ts b/tests/responses/responses-core-modules.test.ts index ed1c3fb5a8f..b640d36d7a4 100644 --- a/tests/responses/responses-core-modules.test.ts +++ b/tests/responses/responses-core-modules.test.ts @@ -85,14 +85,15 @@ describe("Responses core module boundaries", () => { expect(ingress.indexOf("await executePassthroughResponse(")) .toBeLessThan(ingress.indexOf("const sidecarPlans = await executeResponsesSidecars(")); expect(native).toContain('if (nativeExchange instanceof Response || nativeExchange.kind === "adapter-handoff") return nativeExchange;'); - // Delivery is awaited inside the try, and its direct body is wrapped before - // the return. What matters is that both awaits stay inside the lease owner, - // not that the delivery call is itself the return expression. + // Delivery is awaited inside the try, and its direct body is wrapped with + // tool ownership before return. Both awaits must stay inside the lease owner; + // the delivery call itself need not be the return expression. expect(native).toContain("const response = await deliverPassthroughResponse("); - expect(native).toContain("return guardDirectPassthroughBodyInactivity("); + const guardedReturn = "return attachResponseToolOwnership(guardDirectPassthroughBodyInactivity("; + expect(native).toContain(guardedReturn); expect(native.indexOf("await deliverPassthroughResponse(")) - .toBeLessThan(native.indexOf("return guardDirectPassthroughBodyInactivity(")); - expect(native.indexOf("return guardDirectPassthroughBodyInactivity(")) + .toBeLessThan(native.indexOf(guardedReturn)); + expect(native.indexOf(guardedReturn)) .toBeLessThan(native.indexOf("} finally {")); expect(native.indexOf("admissionState.pendingHostAdmissionLease = null;")) .toBeLessThan(native.indexOf("await preparePassthroughExchange(")); diff --git a/tests/responses/ws-capacity-reclamation.test.ts b/tests/responses/ws-capacity-reclamation.test.ts new file mode 100644 index 00000000000..1a6da6f4abe --- /dev/null +++ b/tests/responses/ws-capacity-reclamation.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { codexWebSocketAdmissionMetrics, MAX_TRACKED_CODEX_WEBSOCKETS } from "../../src/codex/websocket-registry"; +import { + clearResponseStateForTests, clearResponseStateMemoryForTests, RESPONSE_TTL_MS, + responseStateMetrics, sweepExpiredResponseStates, +} from "../../src/responses/state"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +const MODEL = "fixture/capacity-model"; +type Frame = Record; +type Client = { socket: WebSocket; frames: Frame[]; closed?: CloseEvent; error?: Event }; +let home: string; +let codex: IsolatedCodexHome; +let proxy: ReturnType | undefined; +let upstream: ReturnType | undefined; +let finishStream: (() => void) | undefined; +let upstreamRequests = 0; +let upstreamCancellations = 0; +let heldUpstreamSignal: AbortSignal | undefined; +const upstreamBodies: Frame[] = []; +const clients: Client[] = []; +const refusedHandshakes = new Map(); +let closeReleaseDelayMs = 0; + +async function until(condition: () => boolean, description: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() >= deadline) throw new Error(`Timed out: ${description}`); + await Bun.sleep(2); + } +} + +function sse(event: Frame): Uint8Array { + return new TextEncoder().encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-ws-capacity-")); + process.env.OPENCODEX_HOME = home; + clearResponseStateMemoryForTests(); + codex = installIsolatedCodexHome("ocx-ws-capacity-codex-"); + upstreamRequests = 0; + upstreamCancellations = 0; + heldUpstreamSignal = undefined; + upstreamBodies.length = 0; + finishStream = undefined; + refusedHandshakes.clear(); + closeReleaseDelayMs = 0; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") { + throw new Error("Capacity fixture forbids external requests"); + } + if (url.port === String(upstream?.port) && String(init?.body).includes("hold-fixture-stream")) { + heldUpstreamSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + } + return originalFetch(input, init); + }) as typeof fetch; + upstream = Bun.serve({ + // Only proxy cancellation may end a held response; Bun's default idle timeout must not. + hostname: "127.0.0.1", port: 0, idleTimeout: 0, + async fetch(request) { + const body = await request.json() as Frame; + upstreamRequests++; + upstreamBodies.push(body); + const id = `resp_capacity_${upstreamRequests}`; + const tool = JSON.stringify(body.input).includes("request-fixture-tool"); + const hold = JSON.stringify(body.input).includes("hold-fixture-stream"); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(sse({ type: "response.created", response: { id, status: "in_progress", output: [] } })); + const output = tool + ? [{ type: "function_call", id: "fc_capacity", call_id: "call_capacity", name: "fixture_tool", arguments: "{}" }] + : [{ type: "message", id: "msg_capacity", role: "assistant", content: [{ type: "output_text", text: "capacity fixture completed" }] }]; + const finish = () => { + if (tool) controller.enqueue(sse({ type: "response.output_item.done", output_index: 0, item: output[0] })); + controller.enqueue(sse({ type: "response.completed", response: { id, status: "completed", output, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } })); + controller.close(); + }; + if (hold) finishStream = finish; else finish(); + }, + cancel() { upstreamCancellations++; }, + }); + return new Response(stream, { headers: { "content-type": "text/event-stream" } }); + }, + }); + saveConfig({ + port: 0, hostname: "127.0.0.1", codexAutoStart: false, websockets: true, + defaultProvider: "fixture", providers: { fixture: { + adapter: "openai-responses", baseUrl: `${upstream.url}v1`, apiKey: "sk-capacity-fixture", upstreamWebsocket: false, + allowPrivateNetwork: true, statelessResponses: true, + } }, + } as OcxConfig); + expect(codexWebSocketAdmissionMetrics().active).toBe(0); + // Observe actual upgrade refusals; delay only the fixture close callback in the delayed row. + const originalServe = Bun.serve; + const serveSpy = spyOn(Bun, "serve").mockImplementation(((options: any) => originalServe({ + ...options, + async fetch(request: Request, server: any) { + const response = await options.fetch(request, server); + const attempt = new URL(request.url).searchParams.get("capacity-attempt"); + if (attempt !== null && response) refusedHandshakes.set(attempt, + { status: response.status, retryAfter: response.headers.get("retry-after") }); + return response; + }, + websocket: { ...options.websocket, close(...args: any[]) { + if (closeReleaseDelayMs) setTimeout(() => options.websocket.close(...args), closeReleaseDelayMs); + else options.websocket.close(...args); + } }, + })) as typeof Bun.serve); + try { proxy = startServer(0); } finally { serveSpy.mockRestore(); } +}); + +afterEach(async () => { + try { + for (const client of clients) client.socket.close(); + await proxy?.stop(true); + await upstream?.stop(true); + await until(() => codexWebSocketAdmissionMetrics().active === 0, "all socket reservations released"); + } finally { + clients.length = 0; + proxy = undefined; + upstream = undefined; + globalThis.fetch = originalFetch; + clearResponseStateForTests(); + codex.restore(); + removeTreeWithRetry(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + } +}); + +async function connect(retriesRemaining = MAX_TRACKED_CODEX_WEBSOCKETS): Promise { + const url = new URL("/v1/responses", proxy!.url); + url.protocol = "ws:"; + const attempt = String(clients.length); + url.searchParams.set("capacity-attempt", attempt); + const client: Client = { socket: new WebSocket(url), frames: [] }; + clients.push(client); + client.socket.addEventListener("message", event => client.frames.push(JSON.parse(String(event.data)))); + client.socket.addEventListener("close", event => { client.closed = event; }); + client.socket.addEventListener("error", event => { client.error = event; }); + await until(() => client.socket.readyState === WebSocket.OPEN || Boolean(client.error || client.closed), "socket handshake"); + if (client.socket.readyState !== WebSocket.OPEN) { + expect(refusedHandshakes.get(attempt)).toEqual({ status: 503, retryAfter: "1" }); + await until(() => codexWebSocketAdmissionMetrics().active < MAX_TRACKED_CODEX_WEBSOCKETS, + "reclaimed socket releases admission before retry"); + expect(retriesRemaining).toBeGreaterThan(0); + return connect(retriesRemaining - 1); + } + return client; +} + +async function fill(): Promise { + const opened: Client[] = []; + for (let i = 0; i < MAX_TRACKED_CODEX_WEBSOCKETS; i++) opened.push(await connect()); + return opened; +} + +async function exchange(client: Client, fields: Frame, terminal = "response.completed"): Promise { + const start = client.frames.length; + client.socket.send(JSON.stringify({ type: "response.create", model: MODEL, input: "fixture input", ...fields })); + await until(() => client.frames.slice(start).some(frame => frame.type === terminal || frame.type === "error") + || Boolean(client.closed), `socket ${terminal}`); + const result = client.frames.slice(start).find(frame => frame.type === terminal || frame.type === "error"); + expect(result?.type).toBe(terminal); + return result!; +} + +function expectBounded(): void { + const metrics = codexWebSocketAdmissionMetrics(); + expect(metrics.active).toBe(MAX_TRACKED_CODEX_WEBSOCKETS); + expect(metrics.peak).toBe(MAX_TRACKED_CODEX_WEBSOCKETS); + expect(metrics.releaseMisses).toBe(0); +} + +test.each([0, 20])("the 129th handshake reclaims the least recently used idle socket and reconnects cleanly; close delay=%s", async delay => { + closeReleaseDelayMs = delay; + const opened = await fill(); + expect(opened.every(client => client.socket.readyState === WebSocket.OPEN)).toBe(true); + await exchange(opened[0]!, { generate: false }); + expect(upstreamRequests).toBe(0); + const replacement = await connect(); + await until(() => Boolean(opened[1]!.closed), "least recently used idle socket closed"); + expect(opened[1]!.closed?.code).toBe(1001); + if (delay) expect([...refusedHandshakes.values()].some(response => response.status === 503)).toBe(true); + expect(opened[0]!.socket.readyState).toBe(WebSocket.OPEN); + expectBounded(); + await exchange(replacement, { input: "fresh request after capacity recovery" }); + expect(upstreamRequests).toBe(1); + const reconnected = await connect(); + await exchange(reconnected, { input: "resubmitted full conversation" }); + expect(upstreamRequests).toBe(2); + expectBounded(); +}, 15_000); + +test("concurrent handshakes replace idle sockets without over-admitting or leaking leases", async () => { + const opened = await fill(); + const added = await Promise.all(Array.from({ length: 16 }, () => connect())); + await until(() => opened.filter(client => client.closed).length === 16, "all reclaimed clients closed"); + expect(opened.slice(0, 16).every(client => client.closed?.code === 1001)).toBe(true); + expect(opened.slice(16).every(client => client.socket.readyState === WebSocket.OPEN)).toBe(true); + expect(added.every(client => client.socket.readyState === WebSocket.OPEN)).toBe(true); + expectBounded(); +}, 15_000); + +test("capacity pressure preserves a streaming response until it completes", async () => { + const opened = await fill(); + await exchange(opened[0]!, { input: "hold-fixture-stream" }, "response.created"); + // Refresh every idle socket so the busy connection is the oldest by activity too. + for (const client of opened.slice(1)) await exchange(client, { generate: false }); + await connect(); + await until(() => Boolean(opened[1]!.closed), "oldest eligible idle socket closed"); + expect(opened[0]!.socket.readyState).toBe(WebSocket.OPEN); + finishStream!(); + await until(() => opened[0]!.frames.some(frame => frame.type === "response.completed"), "protected stream completes"); + expect(opened[0]!.frames.some(frame => frame.type === "error")).toBe(false); + expectBounded(); +}, 15_000); + +test("client tool execution stays protected, and all-busy saturation keeps the 503 refusal", async () => { + const opened = await fill(); + for (const client of opened) { + await exchange(client, { input: "request-fixture-tool", tools: [{ type: "function", name: "fixture_tool", + parameters: { type: "object", properties: {} } }] }); + } + const rejectedBefore = codexWebSocketAdmissionMetrics().rejected; + const response = await fetch(new URL("/v1/responses", proxy!.url), { headers: { + connection: "upgrade", upgrade: "websocket", "sec-websocket-version": "13", + "sec-websocket-key": Buffer.from("capacity-fixture").toString("base64"), + } }); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + expect(await response.text()).toContain("Codex WebSockets capacity reached"); + expect(codexWebSocketAdmissionMetrics().rejected).toBe(rejectedBefore + 1); + expect(opened.every(client => client.socket.readyState === WebSocket.OPEN)).toBe(true); + await exchange(opened[0]!, { input: [{ type: "function_call_output", call_id: "call_capacity", output: "fixture tool result" }] }); + await connect(); + await until(() => Boolean(opened[0]!.closed), "completed tool workflow becomes reclaimable"); + expect(opened.slice(1).every(client => client.socket.readyState === WebSocket.OPEN)).toBe(true); + expectBounded(); +}, 20_000); + +test.each([false, true])("disconnect releases admission and allows a fresh request; abrupt=%s", async abrupt => { + const baseline = codexWebSocketAdmissionMetrics(); + const client = await connect(); + expect(codexWebSocketAdmissionMetrics().active).toBe(baseline.active + 1); + if (abrupt) { + await exchange(client, { input: "hold-fixture-stream" }, "response.created"); + expect(heldUpstreamSignal).toBeDefined(); + expect(heldUpstreamSignal!.aborted).toBe(false); + // Bun's client API terminates the real TCP connection without a closing handshake. + client.socket.terminate(); + } else client.socket.close(1000, "fixture normal disconnect"); + await until(() => codexWebSocketAdmissionMetrics().active === baseline.active, + "disconnected socket releases its reservation", 10_000); + await until(() => Boolean(client.closed), "client observes close", 10_000); + expect(client.closed?.code).toBe(abrupt ? 1006 : 1000); + const replacement = await connect(); + await exchange(replacement, { input: "fresh request after disconnect" }); + expect(upstreamRequests).toBe(abrupt ? 2 : 1); + replacement.socket.close(); + await until(() => codexWebSocketAdmissionMetrics().active === baseline.active, "replacement reservation released"); + expect(codexWebSocketAdmissionMetrics().releaseMisses).toBe(baseline.releaseMisses); + // Eager relay preserves a real terminal during its existing 15-second discard-drain. + // Socket admission and the replacement request must recover before that separate cleanup. + if (abrupt) { + await until(() => heldUpstreamSignal!.aborted, "abandoned upstream request aborted", 20_000); + await until(() => upstreamCancellations === 1, "upstream observed request cancellation"); + } +}, 25_000); + +test("failed HTTP upgrade releases its reservation before the next handshake", async () => { + const baseline = codexWebSocketAdmissionMetrics(); + const response = await fetch(new URL("/v1/responses", proxy!.url), { headers: { + connection: "upgrade", upgrade: "websocket", "sec-websocket-version": "13", + // Missing Sec-WebSocket-Key reaches Bun's unsuccessful upgrade after reservation. + } }); + expect(response.status).toBe(426); + expect(await response.text()).toContain("WebSocket upgrade failed"); + expect(codexWebSocketAdmissionMetrics()).toMatchObject({ active: baseline.active, + admitted: baseline.admitted + 1, rejected: baseline.rejected, releaseMisses: baseline.releaseMisses }); + const replacement = await connect(); + await exchange(replacement, { input: "fresh request after failed handshake" }); + expect(upstreamRequests).toBe(1); + expect(codexWebSocketAdmissionMetrics().active).toBe(baseline.active + 1); +}); + +test.each([false, true])("stateless continuation after capacity reclamation preserves full history; expired=%s", async expired => { + const original = await connect(); + const firstInput = { role: "user", content: [{ type: "input_text", text: "Remember the fixture number 47." }] }; + const first = await exchange(original, { input: [firstInput], store: false }); + const previousId = first.response.id; + expect(typeof previousId).toBe("string"); + expect(previousId.length).toBeGreaterThan(0); + await until(() => responseStateMetrics().count === 1, "first response retains real continuation state"); + for (let i = 1; i < MAX_TRACKED_CODEX_WEBSOCKETS; i++) await connect(); + const reconnected = await connect(); + await until(() => original.closed?.code === 1001, "socket holding the response ID reclaimed"); + if (expired) { + expect(sweepExpiredResponseStates(Date.now() + RESPONSE_TTL_MS + 60_000)).toBe(1); + expect(responseStateMetrics().count).toBe(0); + } + const delta = { role: "user", content: [{ type: "input_text", text: "Use that number in the next answer." }] }; + const outcome = await exchange(reconnected, { previous_response_id: previousId, input: [delta], store: false }, + expired ? "error" : "response.completed"); + if (expired) { + expect(outcome).toMatchObject({ type: "error", status: 400, error: { code: "previous_response_not_found" } }); + expect(upstreamRequests).toBe(1); // Never forward the delta without its missing prefix. + await exchange(reconnected, { input: [firstInput, ...first.response.output, delta], store: false }); + } + expect(upstreamRequests).toBe(2); // One initial response and one complete continuation, without a duplicate execution. + expect(upstreamBodies[1]!.previous_response_id).toBeUndefined(); + expect(upstreamBodies[1]!.input).toHaveLength(3); + expect(upstreamBodies[1]!.input).toMatchObject([firstInput, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "capacity fixture completed" }] }, delta]); + expectBounded(); +}, 15_000); diff --git a/tests/responses/ws-capacity-tool-ownership.test.ts b/tests/responses/ws-capacity-tool-ownership.test.ts new file mode 100644 index 00000000000..468a6487381 --- /dev/null +++ b/tests/responses/ws-capacity-tool-ownership.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import type { ServerWebSocket } from "bun"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { + codexWebSocketAdmissionMetrics, MAX_TRACKED_CODEX_WEBSOCKETS, tryReserveCodexWebSocket, +} from "../../src/codex/websocket-registry"; +import type { AdmissionReservation } from "../../src/lib/admission"; +import { runOptionalShutdownHooks } from "../../src/lib/optional-shutdown-hooks"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { createWebsocketHandler } from "../../src/server/index/websocket-handler"; +import type { ServeOptionsContext } from "../../src/server/index/serve-options"; +import { clearRequestLogsForTests } from "../../src/server/request-log"; +import { setRelayPlatformForTests } from "../../src/server/responses/passthrough-delivery"; +import type { WsData } from "../../src/server/ws-bridge"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +type Frame = Record; +type Handler = ReturnType; +type Client = { ws: ServerWebSocket; handler: Handler; sent: Frame[]; closed: number[] }; +const originalFetch = globalThis.fetch; +const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; +const clients: Client[] = []; +const extraLeases: AdmissionReservation>[] = []; +const finishBusy: Array<() => void> = []; +let home: TempHome; +let releaseSpendHome: () => void; +let savedProxy: Record; +let upstream: (body: Frame, url: URL) => Response; +let outbound: Frame[]; + +async function until(condition: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!condition()) { + if (Date.now() >= deadline) throw new Error("WebSocket tool ownership fixture did not settle"); + await Bun.sleep(1); + } +} + +beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + home = createTempHome("ocx-ws-tool-ownership-"); + releaseSpendHome = acquireOwnedSpendHome(); + savedProxy = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + for (const key of proxyKeys) delete process.env[key]; + outbound = []; + upstream = () => { throw new Error("Unexpected upstream request in tool ownership fixture"); }; + globalThis.fetch = (async (input, init) => { + const body = JSON.parse(String(init?.body)) as Frame; + outbound.push(body); + return upstream(body, new URL(input instanceof Request ? input.url : String(input))); + }) as typeof fetch; + clearRequestLogsForTests(); + expect(codexWebSocketAdmissionMetrics().active).toBe(0); +}); + +afterEach(async () => { + try { + for (const client of clients) client.ws.close(1000, "fixture teardown"); + await until(() => clients.every(({ ws }) => !ws.data.cancel && !ws.data.nativeControl)); + } finally { + for (const finish of finishBusy.splice(0)) finish(); + for (const lease of extraLeases.splice(0)) lease.release(); + clients.length = 0; + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + try { + runOptionalShutdownHooks(); + } finally { + try { releaseSpendHome(); } + finally { + setRelayPlatformForTests(undefined); + globalThis.fetch = originalFetch; + for (const key of proxyKeys) { + if (savedProxy[key] === undefined) delete process.env[key]; + else process.env[key] = savedProxy[key]; + } + home.remove(); + } + } + } + expect(codexWebSocketAdmissionMetrics().active).toBe(0); +}); + +function config(baseUrl = "https://api.x.ai/v1", injectXSearch = false): OcxConfig { + return { port: 0, defaultProvider: "fixture", websockets: true, providers: { fixture: { + adapter: "openai-responses", baseUrl, authMode: "key", apiKey: "fixture-tool-ownership-key", upstreamWebsocket: false, + ...(injectXSearch ? { xaiResponsesXSearch: true, supportsOpenAiWebSearchToolFields: false } : {}), + } } } as OcxConfig; +} + +function client(settings = config()): Client { + const lease = tryReserveCodexWebSocket(); + expect(lease).not.toBeNull(); + const handler = createWebsocketHandler({ config: settings, deps: {} } as ServeOptionsContext); + const sent: Frame[] = []; + const closed: number[] = []; + let state = 1; + const ws = { + data: { admissionLease: lease!, headers: new Headers({ "thread-id": `tool-ownership-${clients.length}` }) } as WsData, + get readyState() { return state; }, + getBufferedAmount: () => 0, + send(text: string) { sent.push(JSON.parse(text)); return 1; }, + close(code = 1000, reason = "") { + if (state === 3) return; + state = 3; + closed.push(code); + handler.close(ws, code, reason); + }, + } as unknown as ServerWebSocket; + handler.open(ws); + const result = { ws, handler, sent, closed }; + clients.push(result); + return result; +} + +async function turn(target: Client, fields: Frame): Promise { + const start = target.sent.length; + target.handler.message(target.ws, JSON.stringify({ type: "response.create", model: "fixture/grok-4.6", input: "fixture input", ...fields })); + await until(() => target.sent.slice(start).some(frame => frame.type === "error" || frame.type === "response.completed") + && !target.ws.data.cancel && !target.ws.data.nativeControl); + const frames = target.sent.slice(start); + expect(frames.filter(frame => frame.type === "error")).toEqual([]); + expect(frames.at(-1)).toMatchObject({ type: "response.completed", response: { status: "completed" } }); + return frames; +} + +function fillBusyAndReserve(): AdmissionReservation> | null { + while (codexWebSocketAdmissionMetrics().active < MAX_TRACKED_CODEX_WEBSOCKETS) { + finishBusy.push(client().ws.data.responsesActivity!.beginTurn([])); + } + const lease = tryReserveCodexWebSocket(); + if (lease) extraLeases.push(lease); + expect(codexWebSocketAdmissionMetrics().active).toBe(MAX_TRACKED_CODEX_WEBSOCKETS); + return lease; +} + +const hostedCall = { type: "custom_tool_call", id: "ctc_search", call_id: "xs_call-1", + name: "x_keyword_search", input: "{}", status: "completed" }; +const clientTool = { type: "function", name: "x_keyword_search", parameters: { type: "object", properties: {} } }; + +test.each([false, true])("completed xAI hosted search is reclaimable; route-injected declaration=%s", async injected => { + const target = client(config(injected ? "https://cli-chat-proxy.grok.com/v1" : "https://api.x.ai/v1", injected)); + upstream = () => Response.json({ id: "resp_hosted", status: "completed", output: [hostedCall] }); + const frames = await turn(target, { tools: injected + ? [{ type: "web_search", external_web_access: true }] + : [{ type: "x_search" }] }); + expect(outbound).toHaveLength(1); + expect(outbound[0].tools.some((tool: Frame) => tool.type === "x_search")).toBe(true); + expect(frames.find(frame => frame.type === "response.output_item.done")?.item).toMatchObject(hostedCall); + expect(fillBusyAndReserve()).not.toBeNull(); + expect(target.closed).toEqual([1001]); + expect(clients.slice(1).every(other => other.ws.readyState === 1)).toBe(true); +}); + +test.each([ + ["https://api.x.ai/v1", false], + ["https://provider.example/v1", true], +] as const)("declared client custom tool remains protected at %s; x_search declaration=%s", async (baseUrl, declareHosted) => { + const target = client(config(baseUrl)); + upstream = () => Response.json({ id: "resp_client", status: "completed", output: [hostedCall] }); + const frames = await turn(target, { tools: [clientTool, ...(declareHosted ? [{ type: "x_search" }] : [])] }); + expect(frames.find(frame => frame.type === "response.output_item.done")?.item).toMatchObject(hostedCall); + expect(fillBusyAndReserve()).toBeNull(); + expect(target.closed).toEqual([]); +}); + +function toolStream(id: string, call: Frame, includeCreated: boolean): Response { + const frames = [ + ...(includeCreated ? [{ type: "response.created", response: { id, status: "in_progress", output: [] } }] : []), + { type: "response.output_item.done", output_index: 0, item: call }, + { type: "response.completed", response: { id, status: "completed", output: [call] } }, + ]; + return new Response(frames.map(frame => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); +} + +test.each([false, true])("a reused call ID on an ordinary next turn stays protected; response.created=%s", async includeCreated => { + const target = client(config("https://provider.example/v1")); + const tools = [{ type: "function", name: "fixture_tool", parameters: { type: "object", properties: {} } }]; + const firstCall = { type: "function_call", id: "fc_first", call_id: "call_0", name: "fixture_tool", arguments: '{"step":1}' }; + const nextCall = { ...firstCall, id: "fc_next", arguments: '{"step":2}' }; + upstream = () => toolStream("resp_first", firstCall, true); + await turn(target, { tools }); + expect(target.ws.data.responsesActivity!.idle).toBe(false); + upstream = () => toolStream("resp_next", nextCall, includeCreated); + const frames = await turn(target, { tools, input: [{ type: "function_call_output", call_id: "call_0", output: "first result" }] }); + expect(outbound).toHaveLength(2); + expect(frames.some(frame => frame.type === "response.created")).toBe(includeCreated); + expect(frames.find(frame => frame.type === "response.output_item.done")?.item).toMatchObject(nextCall); + expect(fillBusyAndReserve()).toBeNull(); + expect(target.closed).toEqual([]); +}); + +function twoProviderConfig(): OcxConfig { + const settings = config(); + settings.providers.ordinary = config("https://provider.example/v1").providers.fixture!; + return settings; +} + +test("switching provider on the same socket does not retain the prior turn's hosted authorization", async () => { + const target = client(twoProviderConfig()); + const hosts: string[] = []; + upstream = (_body, url) => { + hosts.push(url.host); + return Response.json({ id: `resp_switch_${hosts.length}`, status: "completed", output: [hostedCall] }); + }; + await turn(target, { tools: [{ type: "x_search" }] }); + expect(target.ws.data.responsesActivity!.idle).toBe(true); + const frames = await turn(target, { model: "ordinary/fixture-model", tools: [clientTool, { type: "x_search" }] }); + expect(hosts).toEqual(["api.x.ai", "provider.example"]); + expect(frames.find(frame => frame.type === "response.output_item.done")?.item).toMatchObject(hostedCall); + expect(fillBusyAndReserve()).toBeNull(); + expect(target.closed).toEqual([]); +}); + +test.each([ + ["darwin", false], ["darwin", true], ["win32", false], ["win32", true], +] as const)("combo fallback uses the final response's tool ownership; relay=%s hosted final=%s", async (platform, hostedFinal) => { + // Exercise the eager Windows relay without changing filesystem/platform identity. + setRelayPlatformForTests(platform); + const settings = twoProviderConfig(); + const targets = [{ provider: "fixture", model: "grok-4.6" }, { provider: "ordinary", model: "fixture-model" }]; + if (hostedFinal) targets.reverse(); + settings.combos = { ownership: { strategy: "failover", targets } }; + const target = client(settings); + const hosts: string[] = []; + upstream = (_body, url) => { + hosts.push(url.host); + if (hosts.length === 1) { + const response = { id: "resp_failed", status: "failed", output: [], + error: { type: "server_error", code: "upstream_server_error", message: "fixture first target unavailable" } }; + return new Response(`data: ${JSON.stringify({ type: "response.failed", response })}\n\n`, + { headers: { "content-type": "text/event-stream" } }); + } + return toolStream("resp_fallback", hostedCall, true); + }; + const frames = await turn(target, { model: "combo/ownership", tools: [clientTool, { type: "x_search" }] }); + expect(hosts).toEqual(hostedFinal ? ["provider.example", "api.x.ai"] : ["api.x.ai", "provider.example"]); + expect(outbound).toHaveLength(2); + expect(frames.find(frame => frame.type === "response.output_item.done")?.item).toMatchObject(hostedCall); + const replacement = fillBusyAndReserve(); + if (hostedFinal) { + expect(replacement).not.toBeNull(); + expect(target.closed).toEqual([1001]); + } else { + expect(replacement).toBeNull(); + expect(target.closed).toEqual([]); + } +}); From 0036a885031b2a158b9277380d5b8d504e033b29 Mon Sep 17 00:00:00 2001 From: Northstar Date: Sat, 17 Oct 2026 10:10:34 +0800 Subject: [PATCH 13/14] fix(opencode-zen): route Claude through Anthropic Messages (#7235) * fix(opencode-zen): route Claude through Anthropic Messages * fix(messages): preserve Zen rate-limit guidance on native requests * fix(opencode-zen): address rate-limit types and localized routing guidance Co-authored-by: norths7ar * fix(opencode-zen): narrow quota exclusions for rate-limit guidance * fix(opencode-zen): share structured errors across translated and native lanes --------- Co-authored-by: JUN --- .../src/content/docs/fr/guides/providers.md | 2 + .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 + .../src/content/docs/ko/guides/providers.md | 2 + .../src/content/docs/ru/guides/providers.md | 2 + .../src/content/docs/tr/guides/providers.md | 2 + .../content/docs/zh-cn/guides/providers.md | 2 + .../content/docs/zh-tw/guides/providers.md | 2 + src/config/opencode-zen-upgrade.ts | 17 +++--- src/providers/opencode-zen-rate-limit.ts | 9 +++- src/server/messages-native.ts | 19 +++++-- src/server/responses/adapter-dispatch.ts | 2 + src/types/opencode-zen.ts | 12 +++-- src/types/wire.ts | 6 ++- structure/config.md | 2 +- structure/data-planes/protocol-paths.md | 2 +- structure/providers-and-adapters.md | 2 +- .../messages-native.test.ts | 53 ++++++++++++++++++- .../config-opencode-zen-upgrade.test.ts | 26 ++++++++- .../opencode-zen-gemini-wire.test.ts | 37 ++++++++++++- .../providers/opencode-zen-rate-limit.test.ts | 41 ++++++++++++++ 21 files changed, 218 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 1e5af166a64..ea1114dcdaa 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -396,6 +396,8 @@ des indications propres au fournisseur ainsi qu'un `Retry-After` synthétique ; reste prioritaire. L'attente et la nouvelle tentative avec la même clé restent facultatives et s'activent avec [`retryOn429`](/fr/reference/configuration/). +Sur les routes officielles `https://opencode.ai/zen/v1` et `https://opencode.ai/inference/v1` du fournisseur canonique `opencode-zen` avec authentification par clé API, Gemini utilise automatiquement l’API native Google en mode `googleMode: ai-studio` (par défaut). Les identifiants Zen sont conservés, y compris Flash sans suffixe `-tiered`. Claude utilise automatiquement Anthropic Messages, quel que soit `googleMode`. Au chargement des configurations enregistrées, seules les anciennes surcharges Chat/Responses admissibles de ces modèles natifs sont ignorées, ainsi que les préférences admissibles de l’outil hébergé `image_generation` propres à Responses. Les fournisseurs personnalisés, les routes sans authentification par clé et les autres préférences restent inchangés. + **Le niveau sans clé `opencode-free` est actuellement fermé aux clients tiers.** Zen refuse toute requête qui arrive sans en-tête `x-opencode-session` et répond avec le type d'erreur `MissingSessionID` et le message « OpenCode's free tier can only be used in OpenCode ». Le contrôle porte uniquement sur la présence de diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 6345506e0b7..5fc5864481d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -849,7 +849,7 @@ headers. That is separate from the keyless desktop quota OpenCode advertises synthetic `Retry-After`; an upstream `Retry-After` still takes precedence. Same-key wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). -Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key, the default `googleMode` (`ai-studio`), and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. Older Gemini Chat/Responses overrides and Responses-only hosted-tool preferences are ignored when reading saved configurations. +Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key, the default `googleMode` (`ai-studio`), and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. Claude models on these API-key routes use Anthropic Messages automatically, regardless of `googleMode`. When loading saved configurations, OpenCodex ignores eligible older Gemini and Claude Chat/Responses overrides only for native models in the canonical keyed `opencode-zen` provider on these official Zen routes. It also ignores eligible Responses-only `image_generation` hosted-tool preferences; custom providers, non-key routes, and other preferences remain unchanged. **The keyless `opencode-free` tier is currently closed to third-party clients.** Zen refuses any request that arrives without an `x-opencode-session` header, answering with error type diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 3323995b0fc..f539c96b981 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -284,6 +284,8 @@ Chat リクエストは設定済みの Bearer キーを使います。公開一 **OpenCode Zen**(`opencode-zen`)とキー不要の **OpenCode Free** プリセットは `https://opencode.ai/zen/v1` を共有します。このゲートウェイ上の無料モデルは、しばしばおおよそ毎分 15–20 リクエストの短時間レート制限に当たります(コミュニティ計測。OpenCode は RPM を公表しません)。Zen は `Retry-After` / `X-RateLimit-*` ヘッダーなしの汎用 429 を返すことがあります。これはキー不要デスクトップ枠(`opencode-free` で Big Pickle/無料モデル約 200 回 / 5 時間)とは別です。Zen がそのような 429 で `Retry-After` を省略した場合、opencodex はクライアント向けエラーに案内を足し、合成 `Retry-After` を付けます(上流の `Retry-After` があればそれが優先されます)。同一キーの待機再試行は [`retryOn429`](/ja/reference/configuration/) でオプトインします。 +正規の `opencode-zen` プロバイダーで API キー認証と公式ルート `https://opencode.ai/zen/v1` または `https://opencode.ai/inference/v1` を使用すると、Gemini は既定の `googleMode: ai-studio` で Google ネイティブ API を自動的に使用します。Zen のモデル ID は、`-tiered` 接尾辞のない Flash ID もそのまま保持します。Claude は `googleMode` に関係なく Anthropic Messages を使用します。保存済み設定の読み込み時、これらのネイティブモデルの適格な旧 Chat/Responses オーバーライドと、適格な Responses 専用 `image_generation` ホストツール設定のみを無視します。カスタムプロバイダー、キー認証以外のルート、その他の設定は変更しません。 + **キー不要の `opencode-free` 枠は、現在サードパーティのクライアントに閉じられています。** Zen は `x-opencode-session` ヘッダーを伴わないリクエストをすべて拒否し、エラータイプ `MissingSessionID` と "OpenCode's free tier can only be used in OpenCode" というメッセージを返します。関門はヘッダーの有無だけを見るため、プロキシは値をでっち上げれば通過できますが、opencodex はそうしません。セッション識別子とバージョン付きの `opencode/` User-Agent を作って送ることは、自分が OpenCode クライアントであると主張することであり、OpenCode はこのキー不要の枠についてサードパーティ連携の契約を公開していません。その方法で得た HTTP 200 は許可ではなく、突破された関門にすぎません。そこで opencodex は回避せずに制限を報告します。`opencode-free` へのリクエストは、上流の関門を説明するエラーを返します。 同じモデルに至るサポートされた経路は、[opencode.ai/auth](https://opencode.ai/auth) で発行した OpenCode Zen API キーを使う **`opencode-zen`** プリセットです。OpenCode が後にキー不要の枠へのサードパーティ経路を公開すれば、opencodex もそれに従えます。それまでこのプリセットは制限を記録する役割を担います。上流の規約: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/)。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index e4cd967e63c..71e0fcb6d1f 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -282,6 +282,8 @@ Chat 요청은 설정한 Bearer 키를 사용하며 공개 모델 목록은 키 **OpenCode Zen**(`opencode-zen`)과 키 없는 **OpenCode Free** 프리셋은 `https://opencode.ai/zen/v1`을 공유합니다. 그 게이트웨이의 무료 모델은 종종 분당 약 15–20회 요청의 짧은 창 속도 제한에 걸립니다(커뮤니티 측정; OpenCode는 RPM을 공개하지 않음). Zen은 `Retry-After` / `X-RateLimit-*` 헤더 없는 일반 429를 반환할 수 있습니다. 이는 키 없는 데스크톱 할당량(`opencode-free`에서 약 5시간당 Big Pickle/무료 모델 200회)과 별개입니다. Zen이 그런 429에서 `Retry-After`를 생략하면 opencodex는 클라이언트 오류에 안내를 더하고 합성 `Retry-After`를 붙입니다(업스트림 `Retry-After`가 있으면 그것이 우선). 동일 키 대기 재시도는 [`retryOn429`](/ko/reference/configuration/)로 선택합니다. +정식 `opencode-zen` 제공자에서 API 키 인증과 공식 경로 `https://opencode.ai/zen/v1` 또는 `https://opencode.ai/inference/v1`을 사용하면 Gemini는 기본 `googleMode: ai-studio`에서 Google 네이티브 API를 자동으로 사용합니다. Zen 모델 ID는 `-tiered` 접미사가 없는 Flash ID도 그대로 유지합니다. Claude는 `googleMode`와 관계없이 Anthropic Messages를 사용합니다. 저장된 설정을 읽을 때 이 네이티브 모델의 조건에 맞는 기존 Chat/Responses 재정의와 조건에 맞는 Responses 전용 `image_generation` 호스팅 도구 선호 설정만 무시합니다. 사용자 지정 제공자, 키 인증이 아닌 경로, 그 밖의 선호 설정은 그대로 유지합니다. + **키 없는 `opencode-free` 등급은 현재 서드파티 클라이언트에 닫혀 있습니다.** Zen은 `x-opencode-session` 헤더가 없는 요청을 모두 거부하고, 오류 타입 `MissingSessionID`와 "OpenCode's free tier can only be used in OpenCode" 메시지를 돌려줍니다. 관문은 헤더가 있는지만 보기 때문에 프록시가 값을 지어내 통과할 수도 있지만, opencodex는 그렇게 하지 않습니다. 세션 식별자와 버전이 붙은 `opencode/` User-Agent를 만들어 보내는 것은 자신이 OpenCode 클라이언트라고 주장하는 일이고, OpenCode는 이 키 없는 등급에 서드파티 연동 계약을 공개한 적이 없습니다. 그렇게 받아낸 HTTP 200은 허가가 아니라 뚫린 관문일 뿐입니다. 그래서 opencodex는 우회하지 않고 제한을 그대로 알립니다. `opencode-free`로 보낸 요청은 업스트림 관문을 설명하는 오류를 돌려받습니다. 같은 모델로 가는 지원 경로는 [opencode.ai/auth](https://opencode.ai/auth)에서 발급받은 OpenCode Zen API 키를 쓰는 **`opencode-zen`** 프리셋입니다. 나중에 OpenCode가 키 없는 등급의 서드파티 경로를 공개하면 opencodex도 따라갈 수 있고, 그때까지 이 프리셋은 제한을 기록해 두는 역할을 합니다. 업스트림 약관: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/). diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 85d9ab24996..6234f0f3e46 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -305,6 +305,8 @@ Zen может отвечать общими 429 без заголовков `Re в ошибку клиента и синтетический `Retry-After`; при наличии upstream `Retry-After` он имеет приоритет. Повтор с тем же ключом по-прежнему включается через [`retryOn429`](/ru/reference/configuration/). +На официальных маршрутах `https://opencode.ai/zen/v1` и `https://opencode.ai/inference/v1` канонического провайдера `opencode-zen` с аутентификацией API-ключом Gemini автоматически использует нативный API Google в стандартном режиме `googleMode: ai-studio`. Идентификаторы моделей Zen сохраняются, включая Flash без суффикса `-tiered`. Claude использует Anthropic Messages независимо от `googleMode`. При загрузке сохранённой конфигурации игнорируются только допустимые старые переопределения Chat/Responses для этих нативных моделей и допустимые настройки размещённого инструмента `image_generation`, предназначенные только для Responses. Пользовательские провайдеры, маршруты без аутентификации ключом и остальные настройки не меняются. + **Бесключевой уровень `opencode-free` сейчас закрыт для сторонних клиентов.** Zen отклоняет любой запрос без заголовка `x-opencode-session`, возвращая тип ошибки `MissingSessionID` и сообщение «OpenCode's free tier can only be used in OpenCode». Проверяется только наличие заголовка, поэтому diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 1c18a2fc04e..e74ecfe739f 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -436,6 +436,8 @@ ekler; bir yukarı akış `Retry-After`'ı yine de önceliklidir. Aynı anahtarl bekle ve yeniden dene özelliği [`retryOn429`](/tr/reference/configuration/) aracılığıyla isteğe bağlı kalır. +Standart `opencode-zen` sağlayıcısının API anahtarıyla kimlik doğrulanan resmi `https://opencode.ai/zen/v1` ve `https://opencode.ai/inference/v1` yollarında Gemini, varsayılan `googleMode: ai-studio` modunda Google'ın yerel API'sini otomatik kullanır. Zen model kimlikleri, `-tiered` eki olmayan Flash kimlikleri dahil korunur. Claude, `googleMode` değerinden bağımsız olarak Anthropic Messages kullanır. Kaydedilmiş yapılandırmalar yüklenirken yalnızca bu yerel modellerin uygun eski Chat/Responses geçersiz kılmaları ve uygun, yalnızca Responses'a özgü `image_generation` barındırılan araç tercihleri yok sayılır. Özel sağlayıcılar, anahtar kimlik doğrulaması kullanmayan yollar ve diğer tercihler değişmez. + **Anahtarsız `opencode-free` katmanı şu anda üçüncü taraf istemcilere kapalıdır.** Zen, `x-opencode-session` başlığı olmadan gelen her isteği reddeder ve `MissingSessionID` hata tipiyle "OpenCode's free tier can only be used in OpenCode" mesajını döndürür. Kapıda diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index dbc1a20d9b9..c6ef72b88a3 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -281,6 +281,8 @@ MiniMax 和 MiniMax (CN) 的提供商卡片也会在配置的密钥有有效 Cod **OpenCode Zen**(`opencode-zen`)与免密钥的 **OpenCode Free** 预设共用 `https://opencode.ai/zen/v1`。该网关上的免费模型常会触发约每分钟 15–20 次请求的短窗口限流(社区观测;OpenCode 未公布 RPM)。Zen 可能返回不带 `Retry-After` / `X-RateLimit-*` 的通用 429。这与免密钥桌面配额(`opencode-free` 上约每 5 小时 200 次 Big Pickle/免费模型请求)是分开的。当这类 429 省略 `Retry-After` 时,opencodex 会在客户端错误中补充说明并附带合成的 `Retry-After`;若上游已提供 `Retry-After`,则仍以它为准。同密钥等待重试仍可通过 [`retryOn429`](/zh-cn/reference/configuration/) 选择开启。 +在规范 `opencode-zen` 提供商中,使用 API 密钥认证和官方路由 `https://opencode.ai/zen/v1` 或 `https://opencode.ai/inference/v1` 时,Gemini 会在默认 `googleMode: ai-studio` 下自动使用 Google 原生 API。Zen 模型 ID 保持不变,包括没有 `-tiered` 后缀的 Flash ID。Claude 使用 Anthropic Messages,不受 `googleMode` 影响。加载已保存的配置时,仅忽略这些原生模型符合条件的旧 Chat/Responses 覆盖设置,以及符合条件的 Responses 专用 `image_generation` 托管工具偏好。自定义提供商、非密钥认证路由及其他偏好保持不变。 + **免密钥的 `opencode-free` 层级目前对第三方客户端关闭。** Zen 会拒绝任何不带 `x-opencode-session` 头的请求,返回错误类型 `MissingSessionID` 和消息 "OpenCode's free tier can only be used in OpenCode"。这道关卡只检查该头是否存在,因此代理完全可以编一个值蒙混过去,但 opencodex 不这么做。伪造会话标识并附上带版本号的 `opencode/` User-Agent,等于声称自己就是 OpenCode 客户端,而 OpenCode 并未公布这一免密钥层级的第三方集成约定;用这种方式换来的 HTTP 200 是绕过了准入检查,而不是获得了许可。因此 opencodex 选择如实报告限制:发往 `opencode-free` 的请求会返回一条解释上游关卡的错误。 通往同一批模型的受支持路径,是使用 [opencode.ai/auth](https://opencode.ai/auth) 获取的 OpenCode Zen API 密钥、走带密钥的 **`opencode-zen`** 预设。若 OpenCode 之后公布了免密钥层级的第三方接入方式,opencodex 可以跟进;在此之前,这个预设的作用是记录该限制。上游条款:[opencode.ai/docs/zen](https://opencode.ai/docs/zen/)。 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index c0d81d33176..4d0f008be09 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -345,6 +345,8 @@ Bearer 金鑰;公開模型清單無法驗證金鑰有效性。 error 加入 provider guidance 與 synthetic `Retry-After`;若上游有 `Retry-After`,仍以上游值為準。 same-key wait-and-retry 仍需透過 [`retryOn429`](/zh-tw/reference/configuration/) 明確 opt-in。 +在標準 `opencode-zen` 提供者中,使用 API 金鑰驗證及官方路由 `https://opencode.ai/zen/v1` 或 `https://opencode.ai/inference/v1` 時,Gemini 會在預設 `googleMode: ai-studio` 下自動使用 Google 原生 API。Zen 模型 ID 維持不變,包括沒有 `-tiered` 後綴的 Flash ID。Claude 使用 Anthropic Messages,不受 `googleMode` 影響。載入已儲存的設定時,僅忽略這些原生模型符合條件的舊 Chat/Responses 覆寫設定,以及符合條件的 Responses 專用 `image_generation` 託管工具偏好。自訂提供者、非金鑰驗證路由及其他偏好維持不變。 + **無 key 的 `opencode-free` tier 目前對第三方 client 關閉。** Zen 會拒絕任何沒有帶 `x-opencode-session` header 的 request,回傳 error type `MissingSessionID` 與訊息 "OpenCode's free tier can only be used in OpenCode"。這道 gate 只檢查該 header 是否存在,因此 proxy 大可捏造一個值通過,但 opencodex 不這麼做。 diff --git a/src/config/opencode-zen-upgrade.ts b/src/config/opencode-zen-upgrade.ts index 974e69ebe23..f3b1da60582 100644 --- a/src/config/opencode-zen-upgrade.ts +++ b/src/config/opencode-zen-upgrade.ts @@ -1,4 +1,4 @@ -import { openCodeZenBaseUrl } from "../types/opencode-zen"; +import { openCodeZenBaseUrl, openCodeZenKeyBaseUrl, type OpenCodeZenDestination } from "../types/opencode-zen"; import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; function record(value: unknown): Record | undefined { @@ -6,8 +6,9 @@ function record(value: unknown): Record | undefined { ? value as Record : undefined; } -function isGeminiModel(model: string): boolean { - return model.trim().toLowerCase().startsWith("gemini-"); +function isNativeZenModel(model: string, provider: OpenCodeZenDestination): boolean { + const folded = model.trim().toLowerCase(); + return folded.startsWith("claude-") || (folded.startsWith("gemini-") && openCodeZenBaseUrl(provider) !== undefined); } /** Repair legacy Zen wire overrides and dependent preferences in file-backed reads only. */ @@ -15,7 +16,7 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { const providers = record(record(parsed)?.providers); const provider = providers && Object.hasOwn(providers, "opencode-zen") ? record(providers["opencode-zen"]) : undefined; - if (!provider || !openCodeZenBaseUrl(provider)) return; + if (!provider || !openCodeZenKeyBaseUrl(provider)) return; const overrides = record(provider.modelAdapters) ?? {}; const preferences = record(provider.modelPreferHostedTools); let preferencesChanged = false; @@ -25,7 +26,7 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { // Capture the old wire before deleting overrides. An explicit non-Responses entry // must not inherit a provider-wide Responses default for this migration. const oldAdapter = Object.hasOwn(overrides, model) ? overrides[model] : provider.adapter; - if (!isGeminiModel(model) || oldAdapter !== "openai-responses" + if (!isNativeZenModel(model, provider) || oldAdapter !== "openai-responses" || !Array.isArray(tools) || tools.length === 0 || tools.some(tool => tool !== "image_generation") || isHostedToolUnsupportedForModel(model, "image_generation", undefined, unsupported)) continue; delete preferences[model]; @@ -37,16 +38,16 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { for (const [model, adapter] of Object.entries(overrides)) { // Retain invalid preference/wire pairs so repeated normalization cannot admit them. if (preferences && Object.hasOwn(preferences, model)) continue; - if (isGeminiModel(model) && (adapter === "openai-chat" || adapter === "openai-responses")) { + if (isNativeZenModel(model, provider) && (adapter === "openai-chat" || adapter === "openai-responses")) { delete overrides[model]; overridesChanged = true; } } if (overridesChanged) { if (Object.keys(overrides).length === 0) delete provider.modelAdapters; - console.warn("OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."); + console.warn("OpenCode Zen Gemini and Claude models now use their native APIs; obsolete Chat/Responses overrides were ignored while loading config.json."); } if (preferencesChanged) { - console.warn("OpenCode Zen Gemini models now use the native Google API; legacy Responses-only hosted-tool preferences were ignored while loading config.json."); + console.warn("OpenCode Zen Gemini and Claude models now use their native APIs; legacy Responses-only hosted-tool preferences were ignored while loading config.json."); } } diff --git a/src/providers/opencode-zen-rate-limit.ts b/src/providers/opencode-zen-rate-limit.ts index 383c9f70ae6..788ab730b37 100644 --- a/src/providers/opencode-zen-rate-limit.ts +++ b/src/providers/opencode-zen-rate-limit.ts @@ -70,6 +70,9 @@ export function enrichOpenCodeZenRateLimitMessage( message: string, opts: { status: number; + /** Parsed upstream error type, when available. */ + upstreamErrorType?: string | null; + upstreamErrorCode?: string | null; providerName?: string; baseUrl?: string; adapter?: string; @@ -84,7 +87,9 @@ export function enrichOpenCodeZenRateLimitMessage( ): string { if (opts.status !== 429) return message; if (!isOpenCodeZenRateLimitProvider(opts)) return message; - if (!/rate\s*limit/i.test(message)) return message; + const errorText = `${opts.upstreamErrorType ?? ""} ${opts.upstreamErrorCode ?? ""} ${message}`.replace(/[_-]/g, " "); + if (/\b(?:quota|billing)\b|\busage\s+limit\b|\binsufficient\s+(?:credits?|balance|funds)\b|\b(?:credits?|balance)\s+(?:exhausted|depleted)\b/i.test(errorText)) return message; + if (opts.upstreamErrorType !== "rate_limit_error" && !/rate\s*limit/i.test(message)) return message; if (message.includes(ENRICHMENT_MARKER)) return message; const upstreamRetry = validateClientRetryAfterHeader( @@ -165,7 +170,7 @@ export function enrichOpenCodeZenFreeTierMessage( } /** - * Single entry point for Zen upstream-error guidance on the Responses wire: short-window + * Single entry point for Zen upstream-error guidance on Responses and native Messages: short-window * rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op * outside its own case, so the composition is safe for every other upstream failure. */ diff --git a/src/server/messages-native.ts b/src/server/messages-native.ts index e67683f2578..eccee246fc8 100644 --- a/src/server/messages-native.ts +++ b/src/server/messages-native.ts @@ -44,6 +44,7 @@ import { classifyError } from "../lib/errors"; import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite"; import { redactSecretString } from "../lib/redact"; import { resolveClientRetryAfter } from "../lib/retry-after"; +import { enrichOpenCodeZenUpstreamMessage } from "../providers/opencode-zen-rate-limit"; import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget"; import { applyReplayRefusalClientHeaders, @@ -845,7 +846,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions) upstream.abort(); return fail(499, "Client cancelled request", "api_error"); } - return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers); + return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog, route.providerName, activeProvider), response.headers); } const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; @@ -999,7 +1000,7 @@ function isNativeOverflowError(error: Rec, message: string): boolean { * Messages path, so a client sees the same contract on either lane: transient 5xx become 529 * with a retry hint, and a proxy-side replay refusal keeps its no-retry marking. */ -function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response { +function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog, providerName: string, provider: OcxProviderConfig): Response { let upstreamType: string | undefined; let upstreamCode: string | undefined; let upstreamMessage: string | undefined; @@ -1013,8 +1014,20 @@ function nativeMessagesErrorResponse(response: Response, bodyText: string, finis } } catch { /* keep generic classification */ } const detail = formatAnthropicErrorBody(response.status, response.headers, bodyText); - const message = upstreamMessage + const rawMessage = upstreamMessage ?? (detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`); + const message = enrichOpenCodeZenUpstreamMessage(rawMessage, { + status: response.status, + providerName, + baseUrl: provider.baseUrl, + adapter: provider.adapter, + authMode: provider.authMode, + hasApiKey: Boolean(provider.apiKey?.trim()), + upstreamRetryAfter: response.headers.get("retry-after"), + upstreamErrorType: upstreamType, + upstreamErrorCode: upstreamCode, + supportsHttpSameKeyRetry: true, + }); const classified = classifyError( response.status, upstreamType ?? (response.status === 401 ? "authentication_error" diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index afb473ab583..75c5bbe9348 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1877,6 +1877,8 @@ export async function prepareAdapterExchange( authMode: route.provider.authMode, hasApiKey: Boolean(route.provider.apiKey?.trim()), upstreamRetryAfter, + upstreamErrorType: normalized.type, + upstreamErrorCode: normalized.code, // This recovery path is the HTTP Responses wire; custom runTurn transports // never reach enrichOpenCodeZenUpstreamMessage here. supportsHttpSameKeyRetry: true, diff --git a/src/types/opencode-zen.ts b/src/types/opencode-zen.ts index 0452a7bc966..c3d7fedd18c 100644 --- a/src/types/opencode-zen.ts +++ b/src/types/opencode-zen.ts @@ -1,14 +1,14 @@ -/** Fixed-key OpenCode Zen destinations exposing the native Gemini endpoint. */ +/** Fixed-key OpenCode Zen destinations supporting native model protocols. */ export interface OpenCodeZenDestination { readonly baseUrl?: unknown; readonly authMode?: unknown; readonly googleMode?: unknown; } -export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { +/** Match the official Zen destination and API-key authentication. */ +export function openCodeZenKeyBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { if (!provider || typeof provider.baseUrl !== "string") return undefined; if (provider.authMode !== undefined && provider.authMode !== "key") return undefined; - if (provider.googleMode !== undefined && provider.googleMode !== "ai-studio") return undefined; try { const url = new URL(provider.baseUrl.trim()); const pathname = url.pathname.replace(/\/+$/, ""); @@ -20,6 +20,12 @@ export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined) } } +/** Gemini's native endpoint applies only in AI Studio mode. */ +export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { + if (provider?.googleMode !== undefined && provider.googleMode !== "ai-studio") return undefined; + return openCodeZenKeyBaseUrl(provider); +} + /** Keep the selected destination; Zen omits AI Studio's /v1beta segment. */ export function openCodeZenGeminiEndpoint(provider: OpenCodeZenDestination, modelId: string): string | undefined { const baseUrl = openCodeZenBaseUrl(provider); diff --git a/src/types/wire.ts b/src/types/wire.ts index c00c010e854..6c43fb9b5cc 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,5 +1,5 @@ import { isOpenCodeGoGeminiDestination, OPENCODE_GO_BASE_URL, OPENCODE_GO_MODEL_WIRES, OPENCODE_GO_LEGACY_NAME_PINS, type OpenCodeGoDestination } from "./opencode-go"; -import { openCodeZenBaseUrl, type OpenCodeZenDestination } from "./opencode-zen"; +import { openCodeZenBaseUrl, openCodeZenKeyBaseUrl, type OpenCodeZenDestination } from "./opencode-zen"; /** * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the @@ -106,7 +106,9 @@ const CREDENTIAL_PINNED_ADAPTERS: Readonly> | undefined { if (Object.hasOwn(CREDENTIAL_PINNED_ADAPTERS, providerName)) return CREDENTIAL_PINNED_ADAPTERS[providerName]; - if (providerName === "opencode-zen" && openCodeZenBaseUrl(provider)) return { "gemini-": "google" }; + if (providerName === "opencode-zen" && openCodeZenKeyBaseUrl(provider)) { + return openCodeZenBaseUrl(provider) ? { "gemini-": "google", "claude-": "anthropic" } : { "claude-": "anthropic" }; + } if (!provider || !Object.hasOwn(WIRE_ADAPTER_PIN_PREFIXES, providerName)) return undefined; const rule = WIRE_ADAPTER_PIN_PREFIXES[providerName]!; return normalizedEndpoint(provider.baseUrl) === rule.endpoint ? rule.prefixes : undefined; diff --git a/structure/config.md b/structure/config.md index 34dbbefb924..e00cfec7524 100644 --- a/structure/config.md +++ b/structure/config.md @@ -434,7 +434,7 @@ capture/route time and explicit false or empty declarations retain their field-s `src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-wire-upgrade.ts` shares the Zen upgrade in `src/config/opencode-zen-upgrade.ts` and normalizes saved Chat/Responses overrides for newly pinned Go Gemini and Qwen models, including previously valid Responses-only hosted-tool preferences. Go uses validation's historical fixed-destination and key-auth rules; its older Anthropic pins and overridable Responses defaults retain normal validation. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers, accounts and routing. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Unsupported authentication modes and invalid override values retain normal validation. +ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-wire-upgrade.ts` shares the Zen Gemini and Claude upgrade in `src/config/opencode-zen-upgrade.ts` and normalizes saved Chat/Responses overrides for newly pinned Go Gemini and Qwen models, including previously valid Responses-only hosted-tool preferences. Go uses validation's historical fixed-destination and key-auth rules; its older Anthropic pins and overridable Responses defaults retain normal validation. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers, accounts and routing. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Unsupported authentication modes and invalid override values retain normal validation. Provider `projectContext` accepts `"off"` or `"on"` only for native `command-code`; `src/config/schema/leaf-validators.ts` rejects other adapters on load, while `src/config/provider-validation.ts` and `src/server/auth-cors.ts` reject them on management writes. This editor-owned outbound-file field follows [provider and adapter selection](providers-and-adapters.md). The Google tool-schema policy uses a closed enum at this boundary. Unknown values fail config load, diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index f7d0c65fd3e..6b682b88ded 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -389,7 +389,7 @@ managed-client steps already applied to the body (alias/modelMap resolution, `oc directives). The caller-forward passthrough is decided earlier, on the caller's own credential, and returns before this point; the two branches share no credential and no header. The body sent is `envelope.freshBody()` when a source envelope exists, otherwise the ingress's own body. -`src/server/messages-native.ts` is imported lazily, only for an eligible route. +`src/server/messages-native.ts` is imported lazily, only for an eligible route. Native Messages errors reuse the shared OpenCode Zen guidance before resolving client retry headers. A Zen rate-limit 429, identified by message or explicit `rate_limit_error` type, without an upstream retry hint receives the same 15-second hint as the translated path; quota/billing and spent-usage-limit errors remain unchanged and valid upstream retry headers retain precedence. Both native Messages and `src/server/responses/adapter-dispatch.ts` forward parsed error types and codes to the shared guidance. Configured admission-key model scope also applies to separately billed Advisor models before managed native key or OAuth dispatch. `src/server/messages-native-scope.ts` resolves active diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 28ed67a9c70..215c8970652 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -563,7 +563,7 @@ same way. `src/types/wire.ts` owns accepted wire enumerations such as the per-pr config load, management writes and fetch, so no boundary accepts a value another rejects. It also hard-pins OpenCode Go's Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. The existing `opencode-go` preset pins saved URLs to its canonical endpoint; its inference alias supplies destination metadata and affinity to differently named rows. `src/types/opencode-go.ts` shares exact native pins (including Qwen3.7 Plus/3.8 Max/3.8 Flash) and overridable Responses defaults (including GPT 6 Luna); new native pins use the pinned canonical Go key destination even in pure policy captures of saved rows, while the five legacy name pins retain compatibility; unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. Every `dsh-account` model is pinned to the `dsh-account` adapter whatever `baseUrl` says, because that adapter is the [DSH account token's destination boundary](providers/dsh-account.md#destinations). -For canonical `opencode-zen`, `src/types/wire.ts` pins `gemini-*` to `google`, including when a Chat/Responses override is configured. `src/types/opencode-zen.ts` shares the destination check with `src/adapters/google.ts`: only `https://opencode.ai/zen/v1` and `https://opencode.ai/inference/v1` qualify, with key auth and AI Studio mode (both may be unset), no URL credentials, query or fragment, and optional trailing slashes. The adapter appends `/models/:generateContent` or `:streamGenerateContent?alt=sse` to the selected base, sends `x-goog-api-key`, and skips AI Studio's `-tiered` model renames. The pin does not apply to `opencode-free`, OpenCode Go, custom destinations, OAuth/forward auth, Vertex or Cloud Code Assist. +For canonical `opencode-zen`, `src/types/wire.ts` pins `gemini-*` to `google` and `claude-*` to `anthropic`, including when a Chat/Responses override is configured. `src/types/opencode-zen.ts` shares the destination check with `src/adapters/google.ts`: only `https://opencode.ai/zen/v1` and `https://opencode.ai/inference/v1` qualify, with key auth (which may be unset), no URL credentials, query or fragment, and optional trailing slashes. Claude uses the existing Anthropic Messages adapter and does not depend on the Google mode setting. Zen rate-limit guidance in `src/providers/opencode-zen-rate-limit.ts` accepts an explicit `rate_limit_error` even without matching message text, excludes quota/billing and spent-usage-limit failures using structured type/code and message cues, and preserves valid upstream retry headers. The Google adapter appends `/models/:generateContent` or `:streamGenerateContent?alt=sse` to the selected base, sends `x-goog-api-key`, and skips AI Studio's `-tiered` model renames. These pins do not apply to `opencode-free`, OpenCode Go, custom destinations or OAuth/forward auth. The Gemini pin additionally requires AI Studio mode (which may be unset), excluding Vertex and Cloud Code Assist. `src/types/config.ts` declares the optional per-phase `memoryModels` setting; `src/types/request.ts` carries the selected phase through combo handoffs without changing the diff --git a/tests/claude-integration/messages-native.test.ts b/tests/claude-integration/messages-native.test.ts index 583f21667e7..a2e5e65357b 100644 --- a/tests/claude-integration/messages-native.test.ts +++ b/tests/claude-integration/messages-native.test.ts @@ -5,7 +5,7 @@ * key) instead of the Responses replay. Everything here is a local fixture: no real credential * or service is reached. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -397,6 +397,57 @@ describe("managed native Messages", () => { expect(seen.map(entry => entry.headers.get("x-api-key"))).toEqual(["fixture-key-alpha", "fixture-key-beta"]); }); + for (const { providerName, upstreamRetryAfter, expectedRetryAfter, upstreamMessage = "Rate limit exceeded", upstreamCode, expectedGuidance = providerName === "opencode-zen" } of [ + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "15" }, + { providerName: "opencode-zen", upstreamRetryAfter: "37", expectedRetryAfter: "37" }, + { providerName: "anth", upstreamRetryAfter: undefined, expectedRetryAfter: "2" }, + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "15", upstreamMessage: "Too Many Requests" }, + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "2", upstreamMessage: "The usage limit has been reached", expectedGuidance: false }, + { providerName: "opencode-zen", upstreamRetryAfter: undefined, expectedRetryAfter: "2", upstreamCode: "insufficient_quota", expectedGuidance: false }, + ]) test(`native ${providerName} 429 preserves pacing policy with Retry-After ${upstreamRetryAfter ?? "absent"} and ${upstreamMessage}`, async () => { + const config = fixtureConfig(1); + if (providerName !== "anth") { + config.providers[providerName] = config.providers.anth!; + delete config.providers.anth; + config.defaultProvider = providerName; + config.providers[providerName]!.baseUrl = "https://opencode.ai/zen/v1"; + } + saveConfig(config); + // The Zen preset pins its public URL; intercept fetch so no external service is contacted. + const fetchMock = spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + seen.push({ path: new URL(request.url).pathname, headers: request.headers, body: await request.json() as Record }); + return Response.json({ type: "error", error: { type: "rate_limit_error", code: upstreamCode, message: upstreamMessage } }, + { status: 429, headers: upstreamRetryAfter ? { "retry-after": upstreamRetryAfter } : {} }); + }); + let result: Awaited>; + try { + result = await send(config, { ...SOURCE_BODY, model: `${providerName}/claude-x`, stream: false }); + } finally { + fetchMock.mockRestore(); + } + const { requestId, response, text } = result; + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe(expectedRetryAfter); + expect(seen).toHaveLength(1); + expect(seen[0]!.path).toBe(providerName === "opencode-zen" ? "/zen/v1/messages" : "/v1/messages"); + const row = rowFor(requestId); + expect(row.protocolTrace?.mode).toBe("native"); + const envelope = JSON.parse(text); + expect(envelope).toMatchObject({ type: "error", error: { type: "rate_limit_error" } }); + const message = envelope.error.message; + if (expectedGuidance) { + expect(message).toContain("15-20 requests per minute"); + expect(message).toContain("Slow the request pace"); + expect(message).toContain("providers.opencode-zen.retryOn429"); + if (upstreamRetryAfter) expect(message).not.toContain("Try again in 15s"); + else expect(message).toContain("Try again in 15s"); + } else { + expect(message).toBe(upstreamMessage); + } + expect(row.upstreamError).toBe(message); + expect(text).not.toContain("fixture-key-alpha"); + }); test("an upstream error answers in Anthropic shape without the key", async () => { const config = fixtureConfig(startUpstream(() => Response.json( { type: "error", error: { type: "invalid_request_error", message: "bad fixture" } }, { status: 400 }))); diff --git a/tests/config/config-opencode-zen-upgrade.test.ts b/tests/config/config-opencode-zen-upgrade.test.ts index bda564bc285..02f5b46b6af 100644 --- a/tests/config/config-opencode-zen-upgrade.test.ts +++ b/tests/config/config-opencode-zen-upgrade.test.ts @@ -7,8 +7,32 @@ import { handleConfigCommand } from "../../src/cli/config-command"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; import { createTempHome } from "../helpers/temp-home"; +test("Claude saved overrides and Responses-only preferences migrate without changing unrelated models", () => { + const raw = candidate({ "claude-sonnet-5-5": "openai-chat", "claude-opus-5-5": "openai-responses", "qwen-model": "openai-chat" }, { + modelPreferHostedTools: { "claude-opus-5-5": ["image_generation"] }, + }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"].modelAdapters).toEqual({ "qwen-model": "openai-chat" }); + expect(raw.providers["opencode-zen"].modelPreferHostedTools).toBeUndefined(); + expect(validateConfigCandidate(raw).ok).toBe(true); + } finally { warn.mockRestore(); } +}); + +test.each(["vertex", "cloud-code-assist"])("Claude migration preserves Google mode %s and Gemini overrides", googleMode => { + const raw = candidate({ "claude-opus-5-5": "openai-responses", "gemini-3.7-flash": "openai-responses" }, { googleMode }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"].modelAdapters).toEqual({ "gemini-3.7-flash": "openai-responses" }); + expect(raw.providers["opencode-zen"].googleMode).toBe(googleMode); + expect(validateConfigCandidate(raw).ok).toBe(true); + } finally { warn.mockRestore(); } +}); + const BASES = ["https://opencode.ai/zen/v1", "https://opencode.ai/inference/v1"]; -const WARNING = "OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."; +const WARNING = "OpenCode Zen Gemini and Claude models now use their native APIs; obsolete Chat/Responses overrides were ignored while loading config.json."; function candidate(modelAdapters: unknown, overrides: Record = {}) { return { diff --git a/tests/providers/opencode-zen-gemini-wire.test.ts b/tests/providers/opencode-zen-gemini-wire.test.ts index fb5dd4cdc59..ac4b043612e 100644 --- a/tests/providers/opencode-zen-gemini-wire.test.ts +++ b/tests/providers/opencode-zen-gemini-wire.test.ts @@ -45,6 +45,16 @@ function expectWire(providerName: string, provider: OcxProviderConfig, modelId: } describe("OpenCode Zen Gemini destination and wire policy", () => { + test("Claude uses Messages across runtime, static and Fast policy", () => { + for (const baseUrl of BASES) for (const model of ["claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-5-5", "Claude-future"]) { + for (const googleMode of [undefined, "ai-studio", "vertex", "cloud-code-assist"] as const) for (const adapter of ["openai-chat", "openai-responses"] as const) { + expectWire("opencode-zen", zen({ baseUrl, googleMode, modelAdapters: { [model]: adapter } }), model, "anthropic"); + } + expectWire("renamed-zen", zen({ baseUrl }), model, "openai-chat"); + expectWire("opencode-zen", zen({ baseUrl: "https://custom.example.test/v1" }), model, "openai-chat"); + expectWire("opencode-zen", zen({ baseUrl, authMode: "oauth" }), model, "openai-chat"); + } + }); test.each(BASES)("pins Gemini models at %s across runtime, static and Fast policy", baseUrl => { for (const model of MODELS) for (const override of ["openai-chat", "openai-responses"]) { const provider = zen({ baseUrl, modelAdapters: { [model]: override }, supportsServiceTier: true }); @@ -103,13 +113,13 @@ describe("OpenCode Zen Gemini destination and wire policy", () => { const authority = captureFastPolicyAuthority("opencode-zen", provider, true); provider.baseUrl = "https://custom.example.test/v1"; expect(Object.isFrozen(prefixes)).toBe(true); - expect(prefixes).toEqual({ "gemini-": "google" }); + expect(prefixes).toEqual({ "gemini-": "google", "claude-": "anthropic" }); expect(resolveFastPolicy(authority, "gemini-3.7-flash").adapter).toBe("google"); expect(fastPolicyForModel(provider, "gemini-3.7-flash", "opencode-zen").adapter).toBe("openai-responses"); }); test("Zen siblings and other provider identities retain their existing wires", () => { - for (const model of ["claude-sonnet-5-5", "gpt-6.1-sol", "deepseek-v4-flash", "not-gemini-3.7-flash"]) { + for (const model of ["gpt-6.1-sol", "deepseek-v4-flash", "not-gemini-3.7-flash"]) { expect(openCodeZenGeminiEndpoint(zen(), model)).toBeUndefined(); expectWire("opencode-zen", zen(), model, "openai-chat"); expectWire("opencode-zen", zen({ modelAdapters: { [model]: "openai-responses" } }), model, "openai-responses"); @@ -171,6 +181,29 @@ describe("OpenCode Zen Gemini Responses translation", () => { globalThis.fetch = originalFetch; }); + test("translated Claude uses parsed rate-limit types and preserves upstream retry hints", async () => { + releaseSpendHome = acquireOwnedSpendHome(); + for (const retryAfter of [undefined, "37"]) { + let sends = 0; + globalThis.fetch = (async () => { + sends++; + return Response.json({ type: "error", error: { type: "rate_limit_error", message: "Too Many Requests" } }, + { status: 429, headers: retryAfter ? { "retry-after": retryAfter } : {} }); + }) as typeof fetch; + const config = { providers: { "opencode-zen": zen() } } as unknown as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "opencode-zen/claude-sonnet-5-5", input: "hello", stream: false }), + }), config, { model: "", provider: "" }, { inboundWire: "responses" }); + const text = await response.text(); + expect(response.status, text).toBe(429); + expect(response.headers.get("retry-after")).toBe(retryAfter ?? "15"); + expect(text).toContain("15-20 requests per minute"); + expect(text.includes("Try again in 15s")).toBe(!retryAfter); + expect(sends).toBe(1); + } + }); + // Routing canonicalizes the fixed Zen preset to /zen/v1; both accepted endpoint // spellings are exercised without that registry normalization in the adapter cases above. for (const baseUrl of [BASES[0]!]) for (const stream of [true, false]) { diff --git a/tests/providers/opencode-zen-rate-limit.test.ts b/tests/providers/opencode-zen-rate-limit.test.ts index e8e90d94162..5857f822c02 100644 --- a/tests/providers/opencode-zen-rate-limit.test.ts +++ b/tests/providers/opencode-zen-rate-limit.test.ts @@ -78,6 +78,47 @@ describe("opencode-zen rate-limit guidance (#1145)", () => { })).toBe(String(OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC)); }); + test("explicit rate-limit types receive guidance without matching message text", () => { + const enriched = enrichOpenCodeZenUpstreamMessage("Too Many Requests", { + status: 429, providerName: "opencode-zen", upstreamErrorType: "rate_limit_error", + }); + expect(enriched).toContain("Try again in 15s"); + expect(resolveClientRetryAfter({ status: 429, message: enriched })).toBe("15"); + }); + + test("rate limits mentioning a plan or load balancer still receive guidance", () => { + for (const message of ["Rate limit exceeded for credit-based plan", "load balancer rate limit", "balanced throughput"]) { + expect(enrichOpenCodeZenUpstreamMessage(message, { + status: 429, providerName: "opencode-zen", upstreamErrorType: "rate_limit_error", + })).toContain("Try again in 15s"); + } + }); + + test("quota errors never receive short-window pacing guidance", () => { + for (const [upstreamErrorType, message] of [ + ["insufficient_quota", "Rate limit exceeded"], + ["rate_limit_error", "Monthly quota exceeded; rate limit exceeded"], + ["insufficient_quota", "Too Many Requests"], + ["rate_limit_error", "Rate limit exceeded: insufficient credit"], + ["rate_limit_error", "Rate limit exceeded: balance exhausted"], + ["billing_error", "Too Many Requests"], + ["rate_limit_error", "The usage limit has been reached"], + ["usage_limit_reached", "Too Many Requests"], + ]) { + expect(enrichOpenCodeZenUpstreamMessage(message!, { + status: 429, providerName: "opencode-zen", upstreamErrorType, + })).toBe(message!); + } + }); + + test("structured quota and billing codes suppress pacing guidance", () => { + for (const upstreamErrorCode of ["insufficient_quota", "billing_hard_limit_reached", "usage_limit_reached"]) { + expect(enrichOpenCodeZenUpstreamMessage("Rate limit exceeded", { + status: 429, providerName: "opencode-zen", upstreamErrorType: "rate_limit_error", upstreamErrorCode, + })).toBe("Rate limit exceeded"); + } + }); + test("enrichOpenCodeZenRateLimitMessage skips synthetic hint when upstream Retry-After is set", () => { const enriched = enrichOpenCodeZenRateLimitMessage( "Provider error 429: Rate limit exceeded.", From 7761e04edbaccb3d88e9edf910b639d271ec661c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 19:27:03 -0700 Subject: [PATCH 14/14] fix(anthropic): recover rejected thinking without local prefix validation (#7147) * fix(responses): recover Anthropic thinking-signature binding rejection (cherry picked from commit 015014504210e6b974454ffd8f59efb8551532a5) * fix(responses): recover thinking signatures within hosted search iterations (cherry picked from commit 8b801dbc764ce901c69411bc2c7499c3c5705e6a) * fix(search): preserve Anthropic thinking-bound conversation fidelity (cherry picked from commit 98fc937d3d1116eae24255be9734c87cdfc64359) * fix(anthropic): scope recovery memos to serialized opaque payloads Carry the bounded payload memo from #7098 without its local prefix validator. Keep legal history edits upstream-owned, and preserve newly issued thinking after a repaired send. Co-authored-by: lcxhh * fix(responses): share thinking recovery across media and empty retries Use each existing dispatch, pacing and send-budget owner. Retry only the current inference, keep completed media side effects, and retain structured replacement refusals. Ordinary continuations after output stay ineligible. Co-authored-by: lcxhh * docs(anthropic): document bounded thinking signature recovery Describe serialized-payload memos, sidecar and empty-retry boundaries, and upstream-owned legal history edits. Co-authored-by: lcxhh * docs(anthropic): correct empty-answer tool catalog contract * test(responses): register Anthropic thinking recovery module * fix(anthropic): contain replay capture pressure and media budget refusals --------- Co-authored-by: lcxhh Co-authored-by: GOAT --- .../src/content/docs/guides/providers.md | 20 + scripts/test-layout/layout.json | 5 + src/adapters/anthropic-rejection-memo.ts | 71 ++ src/adapters/anthropic-replay.ts | 135 ++++ src/adapters/anthropic-tool-catalog.ts | 11 + src/adapters/anthropic.ts | 53 +- src/images/loop.ts | 93 ++- src/responses/reasoning-replay-cache.ts | 54 +- src/server/responses/adapter-continuation.ts | 52 +- src/server/responses/adapter-delivery.ts | 59 +- src/server/responses/adapter-dispatch.ts | 26 +- .../responses/anthropic-thinking-recovery.ts | 153 ++++ src/server/responses/core-replay.ts | 6 +- src/server/responses/sidecar-execution.ts | 50 +- src/server/responses/sidecar-send-budget.ts | 4 +- src/web-search/anthropic-history.ts | 59 ++ src/web-search/loop.ts | 249 ++++-- src/web-search/progress-stream.ts | 12 +- structure/INDEX.md | 6 + structure/data-planes/images.md | 2 + structure/manifest.json | 13 + structure/providers-and-adapters.md | 4 +- structure/runtime.md | 4 +- .../transports/anthropic-thinking-recovery.md | 117 +++ structure/transports/responses.md | 2 +- ...anthropic-message-stop-termination.test.ts | 14 +- .../anthropic-progress-updates.test.ts | 21 +- .../anthropic/anthropic-replay.test.ts | 110 +++ ...anthropic-sidecar-account-failover.test.ts | 6 +- .../reasoning-replay-identity.test.ts | 203 +++++ tests/fixtures/test-layout-expected.json | 7 +- tests/helpers/responses-core-source.ts | 1 + tests/providers/umans-provider.test.ts | 10 +- .../responses-anthropic-legal-history.test.ts | 150 ++++ ...ponses-anthropic-thinking-recovery.test.ts | 153 ++++ ...hropic-thinking-signature-recovery.test.ts | 607 ++++++++++++++ ...search-thinking-signature-recovery.test.ts | 759 ++++++++++++++++++ .../inference-client-encoder-delivery.test.ts | 6 +- 38 files changed, 3138 insertions(+), 169 deletions(-) create mode 100644 src/adapters/anthropic-rejection-memo.ts create mode 100644 src/adapters/anthropic-replay.ts create mode 100644 src/adapters/anthropic-tool-catalog.ts create mode 100644 src/server/responses/anthropic-thinking-recovery.ts create mode 100644 src/web-search/anthropic-history.ts create mode 100644 structure/transports/anthropic-thinking-recovery.md create mode 100644 tests/adapters/anthropic/anthropic-replay.test.ts create mode 100644 tests/responses/responses-anthropic-legal-history.test.ts create mode 100644 tests/responses/responses-anthropic-thinking-recovery.test.ts create mode 100644 tests/responses/responses-anthropic-thinking-signature-recovery.test.ts create mode 100644 tests/responses/responses-web-search-thinking-signature-recovery.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 5fc5864481d..bbbf9a61325 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -87,6 +87,26 @@ modalities are declared; Kimi omits `capabilities` for unknown or text-only mode a supported capability field keep their existing configuration shape. After updating opencodex, regenerate or refresh the client configuration managed by opencodex to receive the updated metadata. +## Replayed Anthropic thinking signatures + +On the Responses endpoint, an Anthropic-compatible upstream can reject a replayed thinking +signature because it belongs to a different conversation prefix. When the error explicitly +identifies this signature-binding problem, opencodex can remove replayed signed/redacted thinking +and retry once within the request's send budget. This covers HTTP 400 refusals and initial stream +errors before output or tool activity. Hosted web search and image/video bridges share one recovery +limit across their model iterations: only the rejected inference is resent, while completed sidecar +calls and tool results are retained. An empty-completion retry shares its request's allowance; +ordinary continuations after output cannot use it. A failed replacement is +returned without another signature retry. Other errors keep their existing handling. + +Sidecar recovery is disabled when enforced spend accounting cannot establish receipts for earlier +ordinary sidecar sends. It never increases a caller or Combo send allowance. + +Only a successful, nonempty completion remembers the rejection for that conversation and serving +identity for five minutes. Only the refused opaque payloads are suppressed when they can be +identified; newly issued thinking remains replayable. Plaintext history remains available; a failed or cancelled retry does +not disable thinking replay on later requests. + ## Auth modes Provider configs accept three `authMode` values (`key` is the default). The built-in registry also diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ca3cea00b67..edab985cdac 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -219,6 +219,7 @@ "anthropic-tail-guard.test.ts": "adapters/anthropic", "anthropic-progress-updates.test.ts": "adapters/anthropic", "anthropic-thinking-signature.test.ts": "adapters/anthropic", + "anthropic-replay.test.ts": "adapters/anthropic", "anthropic-opaque-strip.test.ts": "adapters/anthropic", "anthropic-tool-call-id.test.ts": "adapters/anthropic", "anthropic-messages-passthrough.test.ts": "adapters/anthropic", @@ -1508,6 +1509,10 @@ "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", + "responses-anthropic-thinking-signature-recovery.test.ts": "responses", + "responses-web-search-thinking-signature-recovery.test.ts": "responses", + "responses-anthropic-thinking-recovery.test.ts": "responses", + "responses-anthropic-legal-history.test.ts": "responses", "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", diff --git a/src/adapters/anthropic-rejection-memo.ts b/src/adapters/anthropic-rejection-memo.ts new file mode 100644 index 00000000000..1d24ec9d76f --- /dev/null +++ b/src/adapters/anthropic-rejection-memo.ts @@ -0,0 +1,71 @@ +import { createHash } from "node:crypto"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import type { AdapterRequest } from "./base"; +import type { OcxParsedRequest } from "../types"; + +type Block = Record; +type Policy = { mode: "blanket" } | { mode: "selective"; fingerprints: ReadonlySet }; +const requests = new WeakMap>(); +const responses = new WeakMap>(); +const policies = new WeakMap(); +const owner = (parsed: OcxParsedRequest) => parsed._reasoningReplayScope ?? parsed; +/** Domain-separated opaque bytes, deliberately independent of plaintext and untrusted hints. */ +export function anthropicOpaqueFingerprint(block: Block): string | undefined { + const value = block.type === "thinking" ? block.signature : block.type === "redacted_thinking" ? block.data : undefined; + if (typeof value !== "string" || value.length === 0) return; + return createHash("sha256").update(String(block.type)).update("\0").update(value).digest("hex"); +} +export function captureAnthropicRejectionPayloads(request: AdapterRequest, body: Block, budget?: TranslatorBudget): void { + requests.delete(request); + if (!budget || !Array.isArray(body.messages)) return; + let retained = 0; + let complete = false; + const fingerprints = new Set(); + try { + for (const message of body.messages) { + if (!message || typeof message !== "object") return; + if (typeof message.content === "string") continue; + if (!Array.isArray(message.content)) return; + for (const block of message.content) { + if (!block || typeof block !== "object") return; + if (block.type !== "thinking" && block.type !== "redacted_thinking") continue; + const value = block.type === "thinking" ? block.signature : block.data; + if (typeof value !== "string" || value.length === 0) return; + const temporary = budget.reserveTransient(Buffer.byteLength(value) * 2, { kind: "reasoning" }); + let fingerprint: string | undefined; + try { fingerprint = anthropicOpaqueFingerprint(block); } finally { temporary.release(); } + if (!fingerprint) return; + if (!fingerprints.has(fingerprint)) { + if (fingerprints.size >= 256) return; + // Includes Set/hex storage and later defensive copies; owned until turn disposal. + budget.chargeRetained(256, { kind: "reasoning" }); retained += 256; + fingerprints.add(fingerprint); + } + } + } + if (fingerprints.size) { requests.set(request, fingerprints); complete = true; } + } catch { /* An uncollectable set keeps the legacy blanket policy. */ } + finally { if (!complete) budget.releaseRetained(retained, { kind: "reasoning" }); } +} +export function bindAnthropicRejectionResponse(request: AdapterRequest | undefined, response: Response): void { + const fingerprints = request && requests.get(request); + if (fingerprints) responses.set(response, fingerprints); else responses.delete(response); +} +export function rejectedAnthropicPayloads(response: Response | undefined): ReadonlySet | undefined { + const value = response && responses.get(response); + return value && new Set(value); +} +export function bindAnthropicRejectionPolicy(parsed: OcxParsedRequest, policy: Policy | undefined): void { + if (policy) policies.set(owner(parsed), policy); else policies.delete(owner(parsed)); +} +export function anthropicRejectionPolicy(parsed: OcxParsedRequest): Policy | undefined { return policies.get(owner(parsed)); } + +/** Filter only refused opaque bytes; newly issued thinking in this request remains replayable. */ +export function keepAnthropicReplayBlock(parsed: OcxParsedRequest, block: Block): boolean { + const fingerprint = anthropicOpaqueFingerprint(block); + if (!fingerprint) return true; + if (parsed._stripReasoningEncryptedContent === true) return false; + const policy = anthropicRejectionPolicy(parsed); + return policy ? policy.mode === "selective" && !policy.fingerprints.has(fingerprint) + : true; +} diff --git a/src/adapters/anthropic-replay.ts b/src/adapters/anthropic-replay.ts new file mode 100644 index 00000000000..e91f933d654 --- /dev/null +++ b/src/adapters/anthropic-replay.ts @@ -0,0 +1,135 @@ +import { isConformingToolCallId } from "./tool-call-id"; +import type { AdapterEvent, OcxAssistantMessage } from "../types"; +import { isTranslatorBudgetExceededError, type TranslatorBudget, type TranslatorTransientReservation } from "../lib/translator-budget"; +import { jsonUtf8Bytes } from "../lib/json-byte-size"; + +type Block = Record; +export interface AnthropicReplaySnapshot { readonly content: readonly Block[]; release(): void } +const enabled = new WeakSet(); +const terminals = new WeakMap(); +const pending = new WeakMap(); +const owners = new WeakMap(); +const issued = new WeakSet(); +const messages = new WeakMap(); +export function enableAnthropicReplayCapture(response: Response): () => void { + enabled.add(response); + return () => { enabled.delete(response); pending.get(response)?.release(); pending.delete(response); }; +} +export function takeAnthropicReplay(event: object): AnthropicReplaySnapshot | undefined { + const snapshot = terminals.get(event); terminals.delete(event); + const owner = owners.get(event); if (owner) pending.delete(owner); owners.delete(event); return snapshot; +} +export function bindAnthropicReplay(message: OcxAssistantMessage, snapshot: AnthropicReplaySnapshot): void { + if (!issued.has(snapshot)) throw new Error("Untrusted Anthropic replay snapshot"); + messages.set(message, snapshot); +} +/** Top-level copies let cache-control decoration leave the captured provider response untouched. */ +export function anthropicReplayContent(message: OcxAssistantMessage): Block[] | undefined { + const snapshot = messages.get(message); + if (snapshot && !issued.has(snapshot)) throw new Error("Expired Anthropic replay snapshot"); + return snapshot?.content.map(block => ({ ...block })); +} + +/** Capture only explicitly opted-in search streams; no client data can manufacture a snapshot. */ +export function createAnthropicReplayCapture(response: Response, budget: TranslatorBudget) { + if (!enabled.delete(response)) return undefined; + let bytes = 0, valid = true, transferred = false; + let active: number | undefined; + let started = false, stopped = false; + const toolIds = new Set(); + const blocks: Array<{ value: Block; text: string[]; thinking: string[]; args: string[] }> = []; + const release = () => { if (bytes) budget.releaseRetained(bytes, { kind: "retained_collectors" }); bytes = 0; }; + const invalidate = () => { valid = false; release(); blocks.length = 0; toolIds.clear(); active = undefined; }; + const retain = (value: unknown) => { + // Charge each frame once, before retaining its values. This conservative linear bound also + // covers array/object overhead and replacement signatures without serializing a growing block. + try { + const size = jsonUtf8Bytes(value) * 4 + 64; + budget.chargeRetained(size, { kind: "retained_collectors" }); bytes += size; + return true; + } catch (error) { + if (!isTranslatorBudgetExceededError(error)) throw error; + invalidate(); + return false; + } + }; + return { + invalidate, + accept(data: Record): void { + if (!valid) return; + const type = data.type; + if (type === "ping") return; + if (type === "message_start") { + const message = data.message as Block | undefined; + if (started || (message?.content !== undefined && (!Array.isArray(message.content) || message.content.length > 0))) invalidate(); + else started = true; + return; + } + if (!started || stopped) { invalidate(); return; } + if (type === "message_stop") { stopped = true; if (active !== undefined) invalidate(); return; } + if (type === "content_block_start") { + const block = data.content_block as Block | undefined; + if (active !== undefined || data.index !== blocks.length || !block + || !(block.type === "text" && typeof block.text === "string" + || block.type === "thinking" && typeof block.thinking === "string" + || block.type === "redacted_thinking" && typeof block.data === "string" + || block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string" + && !!block.input && typeof block.input === "object" && !Array.isArray(block.input))) { invalidate(); return; } + if (block.type === "tool_use") { + const id = block.id as string; + if (!isConformingToolCallId(id) || toolIds.has(id)) { invalidate(); return; } + toolIds.add(id); + } + if (!retain(block)) return; + active = blocks.length; + blocks.push({ value: { ...block, ...(Array.isArray(block.citations) ? { citations: [...block.citations] } : {}) }, text: typeof block.text === "string" ? [block.text] : [], + thinking: typeof block.thinking === "string" ? [block.thinking] : [], args: [] }); + } else if (type === "content_block_delta") { + const delta = data.delta as Block | undefined; + if (active === undefined || data.index !== active || !delta) { invalidate(); return; } + const block = blocks[active]!; + if (!retain(delta)) return; + if (delta.type === "text_delta" && block.value.type === "text" && typeof delta.text === "string") block.text.push(delta.text); + else if (delta.type === "thinking_delta" && block.value.type === "thinking" && typeof delta.thinking === "string") block.thinking.push(delta.thinking); + else if (delta.type === "signature_delta" && block.value.type === "thinking" && typeof delta.signature === "string") block.value.signature = delta.signature; + else if (delta.type === "input_json_delta" && block.value.type === "tool_use" && typeof delta.partial_json === "string") block.args.push(delta.partial_json); + else if (delta.type === "citations_delta" && block.value.type === "text" && delta.citation && typeof delta.citation === "object") { + const citations = block.value.citations; + if (citations !== undefined && !Array.isArray(citations)) { invalidate(); return; } + if (citations === undefined) block.value.citations = [delta.citation]; + else (citations as unknown[]).push(delta.citation); + } else invalidate(); + } else if (type === "content_block_stop") { + if (active === undefined || data.index !== active) { invalidate(); return; } + active = undefined; + } else if (type !== "message_start" && type !== "message_delta" && type !== "message_stop" && type !== "ping") invalidate(); + }, + finish(event: AdapterEvent): void { + if (!valid || !stopped || active !== undefined || event.type !== "done") { invalidate(); return; } + let reservation: TranslatorTransientReservation | undefined; + try { + reservation = budget.reserveTransient(bytes * 2, { kind: "retained_collectors" }); + const content = blocks.map(block => { + const value = block.value; + if (value.type === "text") value.text = block.text.join(""); + if (value.type === "thinking") { + value.thinking = block.thinking.join(""); + if (typeof value.signature !== "string" || value.signature.length === 0) throw new Error("incomplete thinking signature"); + } + if (value.type === "tool_use" && block.args.length) { + value.input = JSON.parse(block.args.join("")); + if (!value.input || typeof value.input !== "object" || Array.isArray(value.input)) throw new Error("invalid tool input"); + } + return Object.freeze(value); + }); + const snapshot = { content: Object.freeze(content), release: () => { issued.delete(snapshot); release(); } }; + issued.add(snapshot); + terminals.set(event, snapshot); owners.set(event, response); pending.set(response, snapshot); + blocks.length = 0; toolIds.clear(); + transferred = true; + } catch { invalidate(); } + finally { reservation?.release(); } + }, + discard(): void { if (!transferred) invalidate(); }, + }; +} diff --git a/src/adapters/anthropic-tool-catalog.ts b/src/adapters/anthropic-tool-catalog.ts new file mode 100644 index 00000000000..34bd84cd07d --- /dev/null +++ b/src/adapters/anthropic-tool-catalog.ts @@ -0,0 +1,11 @@ +import { isAllowedToolChoice, toolChoiceToolPredicate, type OcxParsedRequest } from "../types"; +import { rejectsForcedToolChoice } from "./anthropic-model-contract"; + +/** The catalog actually advertised on the wire, independently of whether calling it is enabled. */ +export function anthropicDeclaredTools(parsed: OcxParsedRequest) { + const tools = parsed.context.tools ?? []; + const choice = parsed.options.toolChoice; + return isAllowedToolChoice(choice) + || (rejectsForcedToolChoice(parsed.modelId) && typeof choice === "object" && choice !== null && "name" in choice) + ? tools.filter(toolChoiceToolPredicate(choice, tools)) : tools; +} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index b71e9a5b090..441f3e23e4c 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1,3 +1,6 @@ +import { captureAnthropicRejectionPayloads, keepAnthropicReplayBlock } from "./anthropic-rejection-memo"; +import { anthropicDeclaredTools } from "./anthropic-tool-catalog"; +import { anthropicReplayContent, createAnthropicReplayCapture } from "./anthropic-replay"; import { withAnthropicProgressUpdates } from "./anthropic-progress"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { createAdapterTierMetadata, type AdapterTierMetadata } from "../providers/fastwire"; @@ -17,7 +20,7 @@ import type { OcxToolResultMessage, OcxUsage, } from "../types"; -import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; +import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName } from "../types"; import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic"; import { parseDataUrl } from "./image"; import { enforceAnthropicImageLimits } from "./anthropic-image-guard"; @@ -283,7 +286,7 @@ function normalizeTtlOrdering(body: Record): void { } } -function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { +export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string { if (typeof signature !== "string" || signature.length < 16) return false; if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false; return /^[A-Za-z0-9+/_=-]+$/.test(signature); @@ -759,8 +762,8 @@ function messagesToAnthropicFormat( } } const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools( - parsed.context.tools, - parsed.options.toolChoice, + anthropicDeclaredTools(parsed), + undefined, tool => toolNames.toWire(namespacedToolName(tool.namespace, tool.name)), ); const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])]; @@ -790,6 +793,7 @@ function messagesToAnthropicFormat( case "assistant": { const aMsg = msg as OcxAssistantMessage; const preface: unknown[] = []; + const toolUses: unknown[] = []; const originalOrder: unknown[] = []; const appendPreface = (block: unknown) => { preface.push(block); originalOrder.push(block); }; @@ -838,9 +842,15 @@ function messagesToAnthropicFormat( } // Anthropic treats text/thinking after tool_use as ending the tool turn, which makes // earlier tool_use ids look unpaired (#620 / common multi-step history shape). - // Signed native blocks bind the source ordering, including updates between tool calls. - // Keep the legacy repair only for unsigned/cross-provider history. - const content = signed ? originalOrder : [...preface, ...toolUses]; + const captured = anthropicReplayContent(aMsg); + if (captured) { + const expected = toolUses as Array<{ id: string; name: string }>; + const actual = captured.filter(block => block.type === "tool_use"); + if (actual.length !== expected.length || actual.some((block, index) => block.id !== expected[index]?.id || block.name !== expected[index]?.name)) { + throw new Error("Anthropic search replay tool identity changed"); + } + } + const content = (captured ?? (signed ? originalOrder : [...preface, ...toolUses])).filter(block => keepAnthropicReplayBlock(parsed, block as Record)); if (content.length === 0) break; messages.push({ role: "assistant", content }); if (toolUseIds.length > 0) { @@ -903,9 +913,7 @@ function messagesToAnthropicFormat( function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }): unknown[] | undefined { if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined; - const tools = isAllowedToolChoice(parsed.options.toolChoice) - ? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)) - : parsed.context.tools; + const tools = anthropicDeclaredTools(parsed); if (tools.length === 0) return undefined; const converted = tools.map(t => ({ name: toolNames.toWire(namespacedToolName(t.namespace, t.name)), @@ -1226,7 +1234,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti enforceCacheControlLimit(body, explicitLimit); normalizeTtlOrdering(body); - return { + const request = { url, method: "POST", headers, @@ -1240,6 +1248,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti fastSpeed?.value ?? null, ), }; + captureAnthropicRejectionPayloads(request, body, incoming?.translatorBudget); + return request; }, async *parseStream( @@ -1252,6 +1262,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti return; } + const replayCapture = createAnthropicReplayCapture(response, budget); const budgetEncoder = new TextEncoder(); let currentBlockType = ""; let currentToolCallId = ""; @@ -1286,11 +1297,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage)); return; } - yield { + const done: AdapterEvent = { type: "done", usage: usageFromAnthropic(pendingUsage), ...(pendingStopReason ? { stopReason: pendingStopReason } : {}), }; + replayCapture?.finish(done); + yield done; }; try { @@ -1310,6 +1323,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti try { parsed = JSON.parse(payload); } catch { + replayCapture?.invalidate(); debugDroppedFrame("anthropic", payload); continue; } @@ -1317,10 +1331,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // it and the `data.type` read below crashed the stream. Drop a non-record frame the same // way an unparseable one is dropped, so the message_stop check still governs the outcome. if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + replayCapture?.invalidate(); debugDroppedFrame("anthropic", payload); continue; } const data = parsed as Record; + replayCapture?.accept({ ...data, type: record.event || data.type }); switch (record.event || data.type) { case "message_start": { @@ -1333,6 +1349,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti case "content_block_start": { const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string; tool_use_id?: unknown; content?: unknown } | undefined; if (!block) break; + // Even an unmodeled server tool or empty block is semantic progress. + // Fence replays before a later refusal; unknown blocks must not look like pings. + yield { type: "heartbeat", replayUnsafe: true }; currentBlockType = block.type; if (block.type === "thinking") { // Preserve even a display:omitted block boundary. The bridge can then @@ -1469,8 +1488,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "error": { - const err = data.error as { message?: string } | undefined; - yield { type: "error", message: err?.message ?? "Anthropic error" }; + const err = data.error as { message?: unknown; type?: unknown; code?: unknown; status?: unknown } | undefined; + yield { type: "error", message: typeof err?.message === "string" ? err.message : "Anthropic error", + ...(typeof err?.type === "string" ? { errorType: err.type } : {}), + ...(typeof err?.code === "string" ? { code: err.code } : {}), + ...(typeof err?.status === "number" && Number.isInteger(err.status) && err.status >= 400 && err.status <= 599 + ? { status: err.status } : {}), + }; return; } } @@ -1489,6 +1513,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // one-terminal-event contract for consumers that keep draining the generator. return; } finally { + replayCapture?.discard(); if (currentToolCallId) budget.closeCall(currentToolCallId); } if (!emittedDone) { diff --git a/src/images/loop.ts b/src/images/loop.ts index 9d729678cd8..77ec229f71b 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -1,3 +1,5 @@ +import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery"; +import { bindAnthropicRejectionResponse } from "../adapters/anthropic-rejection-memo"; /** * Media bridge agentic loop — supports both image and video generation sidecars. * @@ -23,7 +25,7 @@ import { bridgeToResponsesSSE } from "../bridge"; import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { readBoundedResponseBody } from "../lib/bounded-body"; -import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; +import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry"; import { rateLimitRetryDelayMs } from "../providers/key-failover"; import { releaseProviderRequestSlot, sendTrackingRequestSlot, type ProviderRequestSlot } from "../providers/request-pacing"; import { @@ -33,7 +35,7 @@ import { TRANSLATOR_MAX_TURN_BYTES, TranslatorBudgetExceededError, } from "../lib/translator-budget"; -import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream"; +import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "../web-search/progress-stream"; import { fulfillImageCall } from "./fulfill"; import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult, createVideoBudget } from "./fulfill-video"; import { submitVideoJob } from "./xai-video-client"; @@ -270,17 +272,18 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] return parts; } -function jsonError(status: number, message: string): Response { - return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), { +function jsonError(status: number, message: string, event?: Extract): Response { + return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null, + ...(event?.retryable === false ? { retryable: false } : {}) } }), { status, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) }, }); } /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a * non-2xx jsonError; later (already-streaming) iterations surface it as an in-stream error event. */ class LoopError extends Error { - constructor(readonly status: number, message: string) { + constructor(readonly status: number, message: string, readonly event?: Extract) { super(message); this.name = "LoopError"; } @@ -291,6 +294,7 @@ class LoopError extends Error { * metadata, and the optional image/video bridge plans. */ export interface ImageBridgeDeps { + thinkingRecovery?: ReturnType; parsed: OcxParsedRequest; clientToolCatalogRestriction?: ClientToolCatalogRestriction; adapter: ProviderAdapter; @@ -321,7 +325,7 @@ export interface ImageBridgeDeps { /** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */ fetchImpl?: typeof globalThis.fetch; /** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */ - fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof globalThis.fetch; + fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch; /** Reserve the routed provider's next request-start slot before each adapter dispatch. */ waitForRequestSlot?: (signal?: AbortSignal) => Promise; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ @@ -464,6 +468,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise Promise; /** runTurn events are already bounded; consume them without replaying or charging twice. */ collectedEvents?: AdapterEvent[]; } @@ -634,7 +640,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {}); } catch { /* closed */ } + cachedRequest = undefined; + prepared = await fetchOnce(adapter, "opaque-blob-rejection"); + } // Final headers have arrived. Clear only the deadline timer before ANY body read. headerDeadline.clear(); if (!prepared.response.ok) { + if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") { + const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal); + throw new LoopError(event.status ?? prepared.response.status, event.message, event); + } let body: Awaited>; try { body = await readBoundedResponseBody(prepared.response, { signal }); @@ -762,9 +778,21 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + cachedRequest = undefined; + headerDeadline = clearableDeadline(connectTimeoutMs, signal); + try { return await fetchOnce(adapter, "opaque-blob-rejection"); } + catch (error) { + if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge"); + if (headerDeadline.didExpire()) throw new LoopError(504, + `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`); + throw error; + } finally { headerDeadline.clear(); deps.onIterationEnd?.(); } + } }; } catch (error) { if (isTranslatorBudgetExceededError(error)) throw error; + if (error instanceof SendBudgetExhaustedError) throw new LoopError(429, error.message, + { type: "error", status: 429, errorType: "rate_limit_error", code: error.code, message: error.message, retryable: false }); if (headerDeadline.didExpire()) { throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during image-bridge`); } @@ -791,14 +819,31 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise