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( 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/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/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/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/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/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/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 45a3340944a..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 @@ -178,6 +198,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 @@ -842,7 +869,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/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/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/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/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/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/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/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", diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f8bd26a990d..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", @@ -315,6 +316,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", @@ -518,6 +520,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", @@ -722,6 +725,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", @@ -954,6 +958,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", @@ -1504,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", @@ -1656,6 +1665,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", @@ -1917,6 +1927,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/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 653d91593ce..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); @@ -522,13 +525,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 +738,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. @@ -750,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] : [])]; @@ -781,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); }; @@ -789,9 +802,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 +823,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). @@ -824,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) { @@ -889,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)), @@ -998,7 +1020,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 @@ -1210,7 +1234,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti enforceCacheControlLimit(body, explicitLimit); normalizeTtlOrdering(body); - return { + const request = { url, method: "POST", headers, @@ -1224,6 +1248,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti fastSpeed?.value ?? null, ), }; + captureAnthropicRejectionPayloads(request, body, incoming?.translatorBudget); + return request; }, async *parseStream( @@ -1236,6 +1262,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti return; } + const replayCapture = createAnthropicReplayCapture(response, budget); const budgetEncoder = new TextEncoder(); let currentBlockType = ""; let currentToolCallId = ""; @@ -1270,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 { @@ -1294,6 +1323,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti try { parsed = JSON.parse(payload); } catch { + replayCapture?.invalidate(); debugDroppedFrame("anthropic", payload); continue; } @@ -1301,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": { @@ -1317,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 @@ -1336,36 +1371,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,8 +1440,8 @@ 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. - serverSearch.json += delta.partial_json; + // 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; } @@ -1463,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; } } @@ -1483,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/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/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/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/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/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/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/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