diff --git a/docs-site/src/content/docs/fr/guides/pi.md b/docs-site/src/content/docs/fr/guides/pi.md index eab8960063..030d91679c 100644 --- a/docs-site/src/content/docs/fr/guides/pi.md +++ b/docs-site/src/content/docs/fr/guides/pi.md @@ -27,6 +27,9 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -41,6 +44,8 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés } ``` +Les fournisseurs Pi générés activent `compat.sendSessionAffinityHeaders`. Conservez ce réglage lors de la fusion ou de la modification manuelle du fournisseur : Pi transmet un identifiant de session stable, dont OpenCodex dérive l’affinité pour la destination canonique OpenCode Go. Pi peut omettre cet identifiant lorsque `cacheRetention` vaut `none`. + Les identifiants de modèle sont les sélecteurs canoniques du proxy : les modèles routés apparaissent donc sous la forme `provider/model` (`anthropic/claude-opus-5`) et les slugs natifs OpenAI restent sans préfixe (`gpt-5.6-sol`). Le `name` suffixe — `(anthropic)`, `(native)`, `(routed)` — permet de distinguer, dans le sélecteur de Pi, deux modèles de même nom diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index c44b97f12a..f44e4be381 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -27,6 +27,9 @@ export line, and how many models carry authoritative context limits. "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -41,6 +44,8 @@ export line, and how many models carry authoritative context limits. } ``` +Generated Pi providers enable `compat.sendSessionAffinityHeaders`. Keep this flag when merging or manually editing the provider: Pi supplies a stable session identity and OpenCodex derives canonical OpenCode Go affinity from it. Pi may omit the identity when `cacheRetention` is `none`. + Model ids are the proxy's canonical selectors, so routed models appear as `provider/model` (`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed (`gpt-5.6-sol`). The `name` suffix — `(anthropic)`, `(native)`, `(routed)` — is what makes two same-named models from diff --git a/docs-site/src/content/docs/ja/guides/pi.md b/docs-site/src/content/docs/ja/guides/pi.md index 788fe48c60..9b637e84e4 100644 --- a/docs-site/src/content/docs/ja/guides/pi.md +++ b/docs-site/src/content/docs/ja/guides/pi.md @@ -23,6 +23,9 @@ ocx export --client pi "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -37,6 +40,8 @@ ocx export --client pi } ``` +生成される Pi プロバイダーでは `compat.sendSessionAffinityHeaders` が有効です。設定をマージしたり手動で編集したりする際も、このフラグを保持してください。Pi が送る安定したセッション識別子から、OpenCodex が正規の OpenCode Go 接続先用の affinity を生成します。`cacheRetention` が `none` の場合、Pi は識別子を送信しないことがあります。 + モデル ID はプロキシの正規セレクターであるため、ルーティングされたモデルは `provider/model` (`anthropic/claude-opus-5`) として表示され、ネイティブ OpenAI スラグはプレフィックスなし (`gpt-5.6-sol`) のままになります。 `name` サフィックス (`(anthropic)`、`(native)`、`(routed)`) により、異なるアップストリームの 2 つの同じ名前のモデルが Pi のピッカーで区別できるようになります。 ## どこへ行くのか diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 68d7ce5c75..8f0a03bc09 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -131,6 +131,11 @@ WebSocket が無効になっている場合、アップグレード試行では これらのエンドポイントは、Claude Code および互換性のあるクライアントによって使用される Anthropic Messages 言語を話します。ほとんどのリクエストはレスポンスに変換され、通常どおりルーティングされてから、Anthropic JSON または Anthropic SSE に変換されます。 +変換される Messages リクエストでは、推論の再送もリクエスト共通の変換バジェットを使います。 +この制限にはエンコード・デコード時のコピー分も含まれます。超過時は +`translation_buffer_limit` を伴う HTTP 413 を返し、署名や不透明な推論データを切り詰めません。 +ネイティブ Anthropic パススルーには、別の本文サイズ制限が適用されます。 + ネイティブ Anthropic パススルーは、次のすべてが当てはまる場合にのみ適格です。 - ネイティブ パススルーはクロード コード設定で無効になっていません。 diff --git a/docs-site/src/content/docs/ko/guides/pi.md b/docs-site/src/content/docs/ko/guides/pi.md index 648d71060e..6bda9c2b36 100644 --- a/docs-site/src/content/docs/ko/guides/pi.md +++ b/docs-site/src/content/docs/ko/guides/pi.md @@ -27,6 +27,9 @@ ocx export --client pi "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -41,6 +44,8 @@ ocx export --client pi } ``` +생성된 Pi provider에는 `compat.sendSessionAffinityHeaders`가 활성화됩니다. provider를 병합하거나 직접 수정할 때 이 설정을 유지하세요. Pi가 안정적인 세션 식별자를 보내면 OpenCodex가 이를 바탕으로 정규 OpenCode Go 대상의 affinity를 계산합니다. `cacheRetention`이 `none`이면 Pi가 식별자를 보내지 않을 수 있습니다. + 모델 id는 프록시의 정규 선택자이므로, 라우팅된 모델은 `provider/model` (`anthropic/claude-opus-5`) 형태로 나타나고, 네이티브 OpenAI slug는 접두사 없이 (`gpt-5.6-sol`) 유지됩니다. `name` 접미사인 `(anthropic)`, `(native)`, `(routed)`는 diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index f7ff7f5f27..7837ae4d22 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -174,6 +174,11 @@ SSE 객체, choice delta, `finish_reason`이 있는 종료 choice, `data: [DONE] 이 엔드포인트는 Claude Code와 호환 클라이언트가 사용하는 Anthropic Messages 방언을 말합니다. 대부분의 요청은 Responses로 변환되어 일반적으로 라우팅된 뒤, Anthropic JSON 또는 Anthropic SSE로 다시 변환됩니다. +변환되는 Messages 요청의 reasoning 재전송은 요청 전체의 번역 예산을 공유합니다. 이 예산에는 +인코딩·디코딩 과정에서 생기는 복사본도 포함됩니다. 한도를 초과하면 `translation_buffer_limit`과 +HTTP 413을 반환하며, 한도에 맞추려고 서명이나 불투명 reasoning 데이터를 자르지 않습니다. +네이티브 Anthropic passthrough에는 별도의 본문 크기 제한이 적용됩니다. + 네이티브 Anthropic passthrough는 다음이 모두 참일 때만 적용됩니다. - Claude Code 설정에서 native passthrough가 비활성화되어 있지 않습니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 03c56f329b..b1d6029ca9 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -195,6 +195,10 @@ header and does not guarantee a provider cache hit. **Auth:** `key` (`x-api-key` by default, or `Authorization: Bearer` with `apiKeyTransport: "bearer"`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max). - Converts messages to Anthropic content blocks (text, base64 image, `tool_use`, `thinking`). +- Translated Anthropic Messages reasoning replay shares the request translation budget, including + encoding/decoding copy overhead. Requests exceeding it return HTTP 413 with + `translation_buffer_limit`; signatures and opaque reasoning data are never truncated to fit. + Native Anthropic passthrough uses its separate body-size contract. - **Extended thinking math:** Anthropic requires `max_tokens > thinking.budget_tokens`. The adapter maps reasoning effort to a budget (minimal 1024 … max 32000), then computes a safe `max_tokens` with output headroom, and **drops `temperature`/`top_p`** when thinking is enabled (Anthropic forbids diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index cb97ad7076..0c7b8257bc 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -278,6 +278,12 @@ These endpoints speak the Anthropic Messages dialect used by Claude Code and com Most requests are translated to Responses, routed normally, then translated back to Anthropic JSON or Anthropic SSE. +On translated Messages requests, reasoning replay shares the request's translation budget. +Envelope admission includes encoding/decoding copy overhead, not just the original signature +length. Requests exceeding this budget return HTTP 413 with `translation_buffer_limit`; +signatures and opaque reasoning data are never truncated to make a request fit. Native +Anthropic passthrough retains its separate body-size contract. + Base64 and URL image sources are translated in user messages and nested tool results. File-backed images (`source.type: "file"`) require native Anthropic passthrough; translated routes return a fixed HTTP 400 error asking for base64 or URL input. OpenCodex does not resolve another provider's diff --git a/docs-site/src/content/docs/ru/guides/pi.md b/docs-site/src/content/docs/ru/guides/pi.md index 0960ecf49a..e36a73da7e 100644 --- a/docs-site/src/content/docs/ru/guides/pi.md +++ b/docs-site/src/content/docs/ru/guides/pi.md @@ -27,6 +27,9 @@ ocx export --client pi "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -41,6 +44,8 @@ ocx export --client pi } ``` +В создаваемой конфигурации Pi включён `compat.sendSessionAffinityHeaders`. Сохраняйте этот флаг при объединении или ручном редактировании провайдера: Pi передаёт стабильный идентификатор сессии, из которого OpenCodex формирует affinity для канонического OpenCode Go. При `cacheRetention: none` Pi может не передавать идентификатор. + Id моделей — это канонические селекторы прокси, поэтому маршрутизируемые модели появляются как `provider/model` (`anthropic/claude-opus-5`), а нативные slug OpenAI остаются без префикса (`gpt-5.6-sol`). Суффикс в `name` — `(anthropic)`, `(native)`, `(routed)` — как раз и позволяет diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 26d4db5709..a3ef007784 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -179,6 +179,12 @@ adapter, вместо тихого изменения смысла вернёт клиенты. Большинство запросов переводится в Responses, маршрутизируется обычным образом, а затем обратно в Anthropic JSON или Anthropic SSE. +Повторная передача reasoning в преобразуемых запросах Messages использует общий бюджет +преобразования запроса, включая копии при кодировании и декодировании. При превышении лимита +возвращается HTTP 413 с `translation_buffer_limit`; подписи и непрозрачные данные reasoning +не обрезаются для соблюдения лимита. Для нативного Anthropic passthrough действует отдельный +контракт ограничения размера тела. + Нативный Anthropic passthrough допустим только когда одновременно выполняются все условия: - native passthrough не отключён в конфигурации Claude Code; diff --git a/docs-site/src/content/docs/tr/guides/pi.md b/docs-site/src/content/docs/tr/guides/pi.md index 0741f7be51..fe6044de28 100644 --- a/docs-site/src/content/docs/tr/guides/pi.md +++ b/docs-site/src/content/docs/tr/guides/pi.md @@ -31,6 +31,9 @@ export line, and how many models carry authoritative context limits. "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -45,6 +48,8 @@ export line, and how many models carry authoritative context limits. } ``` +Oluşturulan Pi sağlayıcılarında `compat.sendSessionAffinityHeaders` etkinleştirilir. Sağlayıcıyı birleştirirken veya elle düzenlerken bu ayarı koruyun: Pi sabit bir oturum kimliği gönderir ve OpenCodex bu kimlikten kanonik OpenCode Go hedefi için oturum yakınlığı üretir. `cacheRetention` değeri `none` olduğunda Pi kimliği göndermeyebilir. + Model ids are the proxy's canonical selectors, so routed models appear as `provider/model` (`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed diff --git a/docs-site/src/content/docs/zh-cn/guides/pi.md b/docs-site/src/content/docs/zh-cn/guides/pi.md index ad868e3194..c9ebf7b4a6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/pi.md +++ b/docs-site/src/content/docs/zh-cn/guides/pi.md @@ -23,6 +23,9 @@ ocx export --client pi "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -37,6 +40,8 @@ ocx export --client pi } ``` +生成的 Pi 提供方配置启用了 `compat.sendSessionAffinityHeaders`。合并或手动编辑提供方时请保留该设置:Pi 提供稳定的会话标识,OpenCodex 据此为规范的 OpenCode Go 目标生成会话亲和标识。`cacheRetention` 为 `none` 时,Pi 可能不发送会话标识。 + 模型 id 是代理的规范选择器,因此已路由模型会显示为 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 会保持不带前缀(`gpt-5.6-sol`)。`name` 后缀 - `(anthropic)`、`(native)`、`(routed)` - 负责让两个同名但来自不同上游的模型在 Pi 的选择器中可区分。 ## 放置位置 diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 21948d6264..9736aeaff9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -149,6 +149,10 @@ choice 增量、带 `finish_reason` 的终止 choice,以及 `data: [DONE]`。 这些端点使用 Claude Code 和兼容客户端所采用的 Anthropic Messages 方言。大多数请求会被转换为 Responses,按常规路由,然后再转换回 Anthropic JSON 或 Anthropic SSE。 +转换后的 Messages 请求在重放推理数据时共享整个请求的转换预算,其中包含编码和解码产生的副本开销。 +超出预算时返回 HTTP 413 和 `translation_buffer_limit`,不会为了满足限制而截断签名或不透明推理数据。 +原生 Anthropic 透传使用独立的请求体大小限制。 + 只有在满足以下全部条件时,原生 Anthropic 透传才有资格启用: - Claude Code 配置中尚未禁用原生透传; diff --git a/docs-site/src/content/docs/zh-tw/guides/pi.md b/docs-site/src/content/docs/zh-tw/guides/pi.md index 0353338574..d8e9b62510 100644 --- a/docs-site/src/content/docs/zh-tw/guides/pi.md +++ b/docs-site/src/content/docs/zh-tw/guides/pi.md @@ -23,6 +23,9 @@ ocx export --client pi "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", @@ -37,6 +40,8 @@ ocx export --client pi } ``` +產生的 Pi 供應商設定會啟用 `compat.sendSessionAffinityHeaders`。合併或手動編輯供應商時請保留此設定:Pi 提供穩定的工作階段識別碼,OpenCodex 據此為標準 OpenCode Go 目標產生工作階段親和識別碼。當 `cacheRetention` 為 `none` 時,Pi 可能不傳送識別碼。 + 模型 id 是代理的規範選擇器,因此路由模型顯示為 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 保持無前綴(`gpt-5.6-sol`)。`name` 後綴 — `(anthropic)`、`(native)`、`(routed)` — 正是讓來自不同上游的兩個同名模型在 Pi 的 picker 中可區分的關鍵。 ## 放置位置 diff --git a/src/bridge.ts b/src/bridge.ts index ff044a5e52..20e7c3fe09 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -493,7 +493,7 @@ export function bridgeToResponsesSSE( const previousBytes = pendingSignatureBytes + pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0) + (hiddenText ? hiddenThinkingBytes : 0); - const encoded = encodeReasoningEnvelope(envelope); + const encoded = encodeReasoningEnvelope(envelope, budget); const reservation = budget?.reserveTransient(bytesOf(encoded), { kind: "reasoning" }); pendingSignature = undefined; pendingSignatureBytes = 0; @@ -533,7 +533,7 @@ export function bridgeToResponsesSSE( if (!hiddenRawReasoningText) return; rawReasoningForNextToolCall = hiddenRawReasoningText; const previousBytes = hiddenRawReasoningBytes; - const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText }); + const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText }, budget); const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" }); hiddenRawReasoningText = ""; hiddenRawReasoningBytes = 0; @@ -556,7 +556,7 @@ export function bridgeToResponsesSSE( const flushKiroRedactedReasoning = () => { if (!pendingKiroRedacted) return; const previousBytes = pendingKiroRedactedBytes; - const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted }); + const encrypted = encodeReasoningEnvelope({ krc: pendingKiroRedacted }, budget); const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" }); pendingKiroRedacted = undefined; pendingKiroRedactedBytes = 0; @@ -902,6 +902,16 @@ export function bridgeToResponsesSSE( gated = true; stepping = false; }; + const attemptTerminationCleanup = (action: () => void): boolean => { + try { + action(); + return !terminated && !closed; + } catch (error) { + if (!isTranslatorBudgetExceededError(error)) throw error; + terminateForTranslatorOverflow(error); + return false; + } + }; const step = async () => { if (stepping || closed) return; stepping = true; @@ -1415,10 +1425,12 @@ export function bridgeToResponsesSSE( return; } if (!terminated) { - flushHiddenRawReasoning(); - if (currentToolCall) failCurrentToolCall(); - if (currentWebSearch) closeCurrentWebSearch("failed", []); - releasePendingWebSources(); + if (!attemptTerminationCleanup(() => { + flushHiddenRawReasoning(); + if (currentToolCall) failCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); + })) return; const failure = responseError( 500, "proxy_error", @@ -1448,13 +1460,15 @@ export function bridgeToResponsesSSE( if (!terminated) { // The adapter generator ended without an explicit done/error event. Mark as incomplete // rather than completed so Codex can distinguish a clean finish from a truncated stream. - if (currentMsg) closeCurrentMessage(); - if (currentReasoning) closeCurrentReasoning(); - if (currentRawReasoning) closeCurrentRawReasoning(); - flushHiddenRawReasoning(); - if (currentToolCall) failCurrentToolCall(); - if (currentWebSearch) closeCurrentWebSearch("failed", []); - releasePendingWebSources(); + if (!attemptTerminationCleanup(() => { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) failCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); + })) return; options?.onUsage?.(undefined); await awaitThoughtSignatureDurability(); emit("response.incomplete", { @@ -1493,13 +1507,15 @@ export function bridgeToResponsesSSE( upstreamActivity = false; stallTicks = 0; } else if (++stallTicks >= maxStallTicks) { - if (currentMsg) closeCurrentMessage(); - if (currentReasoning) closeCurrentReasoning(); - if (currentRawReasoning) closeCurrentRawReasoning(); - flushHiddenRawReasoning(); - if (currentToolCall) failCurrentToolCall(); - if (currentWebSearch) closeCurrentWebSearch("failed", []); - releasePendingWebSources(); + if (!attemptTerminationCleanup(() => { + if (currentMsg) closeCurrentMessage(); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) failCurrentToolCall(); + if (currentWebSearch) closeCurrentWebSearch("failed", []); + releasePendingWebSources(); + })) return; // #1926 gap 2 residual: this beat callback is synchronous, so the durability // barrier is not awaited on the stall-timeout kill path. The in-memory store is // already updated; only a crash between here and the queued write loses it, @@ -1728,7 +1744,7 @@ function buildResponseJSONWithBudget( if (batchRedacted.length > 0) envelope.red = batchRedacted; const hidden = options?.hideThinkingSummary === true; if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) envelope.txt = currentSummaryReasoning; - const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined; + const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope, budget) : undefined; const sourceBytes = currentSummaryReasoningBytes + batchSignatureBytes + batchRedactedBytes; batchSignature = undefined; batchSignatureBytes = 0; @@ -1756,7 +1772,7 @@ function buildResponseJSONWithBudget( // Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip. pushOutput({ type: "reasoning", id: `rs_${uuid()}`, summary: [], - encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }), + encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }, budget), }, currentRawReasoningBytes, "reasoning"); currentRawReasoning = ""; currentRawReasoningBytes = 0; @@ -2044,7 +2060,7 @@ function buildResponseJSONWithBudget( // pushOutput reserves the item itself and releases the retained raw blob it replaces. pushOutput({ type: "reasoning", id: `rs_${uuid()}`, summary: [], - encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }), + encrypted_content: encodeReasoningEnvelope({ krc: batchKiroRedacted }, budget), }, batchKiroRedactedBytes, "reasoning"); batchKiroRedacted = undefined; batchKiroRedactedBytes = 0; diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 5e876ca6cb..c2e3ded9b2 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -18,6 +18,7 @@ import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; +import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; @@ -210,7 +211,7 @@ function userMessageToItems(content: unknown, input: Rec[], elide: SkillElisionC pushUserMessage(input, pending); } -function assistantMessageToItems(content: unknown, input: Rec[]): void { +function assistantMessageToItems(content: unknown, input: Rec[], budget: TranslatorBudget): void { if (typeof content === "string") { if (content.length > 0) input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: content }] }); return; @@ -240,11 +241,12 @@ function assistantMessageToItems(content: unknown, input: Rec[]): void { const thinking = typeof raw.thinking === "string" ? raw.thinking : ""; const signature = typeof raw.signature === "string" ? raw.signature : ""; if (signature.startsWith(OCX_REASONING_PREFIX)) { - const owned = decodeReasoningEnvelope(signature); + const owned = decodeReasoningEnvelope(signature, budget); if (!owned) throw new AnthropicRequestError("malformed ocxr1 reasoning signature"); if (Object.hasOwn(owned, "sig")) throw new AnthropicRequestError("OpenCodex reasoning continuity cannot be replayed as an Anthropic signature"); } - const encrypted = signature.length === 0 ? undefined : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature }); + const encrypted = signature.length === 0 ? undefined : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature }, budget); + if (encrypted) budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" }); if (thinking.length === 0 && !encrypted) break; input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: thinking.length > 0 ? [{ type: "summary_text", text: thinking }] : [], ...(encrypted ? { encrypted_content: encrypted } : {}) }); break; @@ -252,7 +254,11 @@ function assistantMessageToItems(content: unknown, input: Rec[]): void { case "redacted_thinking": { flush(); const data = typeof raw.data === "string" ? raw.data : ""; - if (data.length > 0) input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: [], encrypted_content: encodeReasoningEnvelope({ red: [data] }) }); + if (data.length > 0) { + const encrypted = encodeReasoningEnvelope({ red: [data] }, budget); + budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" }); + input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: [], encrypted_content: encrypted }); + } break; } default: @@ -294,7 +300,16 @@ export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig) * OUT-OF-BODY tuple (audit 133 R3#1 — an in-body marker would leak upstream through * the native Responses forward and 400). */ -export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig): ClaudeInboundTranslation { +export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig, budget?: TranslatorBudget): ClaudeInboundTranslation { + const activeBudget = budget ?? createTranslatorBudget(); + try { + return translateAnthropicRequest(raw, cc, activeBudget); + } finally { + if (!budget) activeBudget.dispose(); + } +} + +function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undefined, budget: TranslatorBudget): ClaudeInboundTranslation { if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object"); if (typeof raw.model !== "string" || raw.model.length === 0) { throw new AnthropicRequestError("model is required"); @@ -315,7 +330,7 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode for (const msg of raw.messages) { if (!isRec(msg)) throw new AnthropicRequestError("each message must be an object"); if (msg.role === "user") userMessageToItems(msg.content, input, elide); - else if (msg.role === "assistant") assistantMessageToItems(msg.content, input); + else if (msg.role === "assistant") assistantMessageToItems(msg.content, input, budget); else if (msg.role === "system") { const text = systemMessageText(msg.content); if (text.length > 0) systemParts.push(text); diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index ac06afac2d..1975d5b390 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -308,7 +308,7 @@ export function responsesSseToAnthropicSse( open.webSearchArgsEmitted = true; } if (open.kind === "thinking") { - const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }); + const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }, translatorBudget); emit("content_block_delta", { type: "content_block_delta", index: open.index, delta: { type: "signature_delta", signature }, @@ -561,7 +561,7 @@ export function responsesSseToAnthropicSse( else if (open && open.kind === "text" && item.type === "message") closeOpenBlock(); else if (item.type === "reasoning") { const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : ""; - const env = encrypted ? decodeReasoningEnvelope(encrypted) : null; + const env = encrypted ? decodeReasoningEnvelope(encrypted, translatorBudget) : null; const red = env?.red ?? []; if (env?.sig && open?.kind !== "thinking") ensureBlock("thinking"); if (open?.kind === "thinking") { @@ -785,7 +785,7 @@ export function responsesSseToAnthropicSse( } /** Non-streaming: /v1/responses JSON -> Anthropic message JSON. */ -export function responsesJsonToAnthropicMessage(json: unknown, model: string): Rec { +export function responsesJsonToAnthropicMessage(json: unknown, model: string, translatorBudget?: TranslatorBudget): Rec { const body = isRec(json) ? json : {}; const output = Array.isArray(body.output) ? body.output : []; const content: Rec[] = []; @@ -817,14 +817,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R } } const encrypted = typeof raw.encrypted_content === "string" ? raw.encrypted_content : ""; - const env = encrypted ? decodeReasoningEnvelope(encrypted) : null; + const env = encrypted ? decodeReasoningEnvelope(encrypted, translatorBudget) : null; // Legacy combined envelopes place redacted blocks before the signed block, // matching the Anthropic adapter. New bridge output uses separate items. for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); // env.txt may be locally hidden text. Do not expose it here or manufacture // a new signed continuity carrier; hidden-summary replay remains limited. if (parts.length > 0 || env?.sig) { - content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }) }); + content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }, translatorBudget) }); } break; } diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 8fb42f311d..6a94b74d70 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -695,6 +695,7 @@ export interface PiProviderBlock { baseUrl: string; api: string; apiKey: string; + compat?: { sendSessionAffinityHeaders: boolean }; models: PiModelEntry[]; } @@ -816,7 +817,7 @@ export interface GajaeGeneratedConfig { * model. The rest of this contract (omitting `cost`) is still ours rather than * a claim about Pi's acceptance. */ -function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { +function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = false): PiGeneratedConfig { const models: PiModelEntry[] = []; for (const model of normalizeExportModels(ctx.models)) { // Text is the one modality every routed model supports; anything richer must come @@ -859,6 +860,7 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { baseUrl: ctx.baseUrl, api: PI_API_DIALECT, apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + ...(sendSessionAffinityHeaders ? { compat: { sendSessionAffinityHeaders: true } } : {}), models, }, }, @@ -1031,7 +1033,7 @@ function buildOpencodeContribution(ctx: ExportContext): ManagedContribution { } function buildPiContribution(ctx: ExportContext): ManagedContribution { - const doc = buildPiClientConfig(ctx); + const doc = buildPiClientConfig(ctx, true); return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } @@ -1127,7 +1129,7 @@ export const EXPORT_CLIENTS: Record = { destination: env => piConfigPath(env), apiKeyEnv: "", exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.", - build: buildPiClientConfig, + build: ctx => buildPiClientConfig(ctx, true), format: "json", summarize: summarizePi, buildContribution: buildPiContribution, diff --git a/src/lib/json-byte-size.ts b/src/lib/json-byte-size.ts new file mode 100644 index 0000000000..d6398718fd --- /dev/null +++ b/src/lib/json-byte-size.ts @@ -0,0 +1,61 @@ +import { TRANSLATOR_MAX_TURN_BYTES, TranslatorBudgetExceededError } from "./translator-budget"; + +/** Measure plain JSON data without allocating its serialized string or UTF-8 copy. */ +export function jsonUtf8Bytes(value: unknown, limit = TRANSLATOR_MAX_TURN_BYTES): number { + let bytes = 0; + const add = (count: number) => { + if (count > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit); + bytes += count; + }; + const string = (text: string) => { + // Every UTF-16 code unit needs at least one JSON UTF-8 byte; reject large inputs + // before walking them. Escapes and unpaired surrogates are counted below. + if (text.length + 2 > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit); + add(2); + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 0x22 || code === 0x5c || code === 8 || code === 9 || code === 10 || code === 12 || code === 13) add(2); + else if (code < 0x20) add(6); + else if (code < 0x80) add(1); + else if (code < 0x800) add(2); + else if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { add(4); i++; } + else add(6); + } else if (code >= 0xdc00 && code <= 0xdfff) add(6); + else add(3); + } + }; + const visit = (item: unknown): void => { + if (item === null) { add(4); return; } + if (typeof item === "string") { string(item); return; } + if (typeof item === "boolean") { add(item ? 4 : 5); return; } + if (typeof item === "number") { add(Number.isFinite(item) ? String(item).length : 4); return; } + if (Array.isArray(item)) { + add(2); + for (let i = 0; i < item.length; i++) { + if (i > 0) add(1); + if (item[i] === undefined) add(4); + else visit(item[i]); + } + return; + } + if (typeof item === "object" && item !== null) { + add(2); + let first = true; + for (const key of Object.keys(item)) { + const field = (item as Record)[key]; + if (field === undefined) continue; + if (!first) add(1); + first = false; + string(key); + add(1); + visit(field); + } + return; + } + throw new TypeError("Expected plain JSON data for translation sizing"); + }; + visit(value); + return bytes; +} diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 2a56563578..ba20e800ed 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -12,6 +12,9 @@ * passthrough scrub strips ocxr1 envelopes before native forwarding. */ +import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget"; +import { jsonUtf8Bytes } from "../lib/json-byte-size"; + export const OCX_REASONING_PREFIX = "ocxr1:"; export interface ReasoningEnvelope { @@ -32,30 +35,61 @@ export interface ReasoningEnvelope { krc?: string; } -export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string { - return OCX_REASONING_PREFIX + Buffer.from(JSON.stringify(envelope), "utf-8").toString("base64"); +export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: TranslatorBudget): string { + const activeBudget = budget ?? createTranslatorBudget(); + try { + const jsonBytes = jsonUtf8Bytes(envelope); + const base64Bytes = 4 * Math.ceil(jsonBytes / 3); + // Reserve before materialization: UTF-16 JSON, UTF-8 buffer, base64 string, + // and the prefixed result may coexist. Returned-value ownership stays with + // callers, whose existing retained accounting must not be charged twice here. + const reservation = activeBudget.reserveTransient( + Math.max( + 3 * jsonBytes + 4 * base64Bytes + 2 * OCX_REASONING_PREFIX.length, + 8 * (OCX_REASONING_PREFIX.length + base64Bytes), + ), + { kind: "reasoning" }, + ); + try { + return OCX_REASONING_PREFIX + Buffer.from(JSON.stringify(envelope), "utf-8").toString("base64"); + } finally { + reservation.release(); + } + } finally { + if (!budget) activeBudget.dispose(); + } } /** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */ -export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnvelope | null { +export function decodeReasoningEnvelope(encryptedContent: string, budget?: TranslatorBudget): ReasoningEnvelope | null { if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null; + const activeBudget = budget ?? createTranslatorBudget(); try { - const parsed: unknown = JSON.parse(Buffer.from(encryptedContent.slice(OCX_REASONING_PREFIX.length), "base64").toString("utf-8")); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - const obj = parsed as { sig?: unknown; red?: unknown }; - const envelope: ReasoningEnvelope = {}; - if (typeof obj.sig === "string") envelope.sig = obj.sig; - if (Array.isArray(obj.red)) { - const red = obj.red.filter((r): r is string => typeof r === "string"); - if (red.length > 0) envelope.red = red; + // Also bound already-encoded replay before slicing, decoding, or parsing it. + // Eight bytes per code unit conservatively covers the string/buffer copies. + const reservation = activeBudget.reserveTransient(8 * encryptedContent.length, { kind: "reasoning" }); + try { + const parsed: unknown = JSON.parse(Buffer.from(encryptedContent.slice(OCX_REASONING_PREFIX.length), "base64").toString("utf-8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const obj = parsed as { sig?: unknown; red?: unknown }; + const envelope: ReasoningEnvelope = {}; + if (typeof obj.sig === "string") envelope.sig = obj.sig; + if (Array.isArray(obj.red)) { + const red = obj.red.filter((r): r is string => typeof r === "string"); + if (red.length > 0) envelope.red = red; + } + const txt = (parsed as { txt?: unknown }).txt; + const hasTxt = typeof txt === "string"; + if (hasTxt) envelope.txt = txt; + const krc = (parsed as { krc?: unknown }).krc; + if (typeof krc === "string" && krc.length > 0) envelope.krc = krc; + return envelope.sig || envelope.red || hasTxt || envelope.krc ? envelope : null; + } catch { + return null; + } finally { + reservation.release(); } - const txt = (parsed as { txt?: unknown }).txt; - const hasTxt = typeof txt === "string"; - if (hasTxt) envelope.txt = txt; - const krc = (parsed as { krc?: unknown }).krc; - if (typeof krc === "string" && krc.length > 0) envelope.krc = krc; - return envelope.sig || envelope.red || hasTxt || envelope.krc ? envelope : null; - } catch { - return null; + } finally { + if (!budget) activeBudget.dispose(); } } diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index e7fd04f42d..7e69010636 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -25,6 +25,8 @@ import { estimateTokens } from "../lib/token-estimate"; import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; +import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport"; +import { normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation"; import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; import { @@ -136,6 +138,8 @@ async function handleChatCompletionsWithBudget( let chatNativeRoute: ReturnType | null = null; try { const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody)); + route.provider = resolveOpenCodeGoTransport(route.provider, + sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); // Settle the wire once so every branch below reads the adapter this model will // actually use, not the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat"); @@ -237,6 +241,9 @@ async function handleChatCompletionsWithBudget( return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error"); } const headers = new Headers({ "content-type": "application/json" }); + // Internal bridge metadata; the Go resolver scopes and hashes it before upstream use. + const openCodeSession = req.headers.get("x-opencode-session"); + if (openCodeSession) headers.set("x-opencode-session", openCodeSession); for (const name of FORWARD_HEADERS) { if (name === "authorization" && !directRoute) continue; const value = req.headers.get(name); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 20bc14e195..f6906de7e0 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -7,6 +7,7 @@ * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape. */ import { FORWARD_HEADERS } from "../adapters/openai-responses"; +import { jsonUtf8Bytes } from "../lib/json-byte-size"; import { sseFieldValue } from "../lib/sse-decoder"; import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; @@ -753,13 +754,13 @@ async function handleClaudeMessagesWithBudget( }; delete anthropicBody.thinking; } - const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode); + const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode, translatorBudget); internalBody = translation.body; // The Anthropic translator builds its body from model/input/store/stream plus sampling // fields only, so the caller intent is applied to the TRANSLATED body rather than the // inbound one. if (fastRow) internalBody.service_tier = "priority"; - translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" }); + translatorBudget.chargeRetained(jsonUtf8Bytes(internalBody), { kind: "request_copies" }); cacheKeySource = translation.cacheKeySource; } catch (err) { const overflow = isTranslatorBudgetExceededError(err); @@ -862,13 +863,26 @@ async function handleClaudeMessagesWithBudget( headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); } } - const internalBodyJson = JSON.stringify(internalBody); - translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" }); - const internalReq = new Request("http://localhost/v1/responses", { - method: "POST", - headers, - body: internalBodyJson, - }); + let internalReq: Request; + try { + // The UTF-16 JSON string and the Request's UTF-8 body coexist until dispatch. + const bodyBytes = jsonUtf8Bytes(internalBody); + const reservation = translatorBudget.reserveTransient(3 * bodyBytes, { kind: "request_copies" }); + try { + internalReq = new Request("http://localhost/v1/responses", { + method: "POST", + headers, + body: JSON.stringify(internalBody), + }); + } finally { + reservation.release(); + } + translatorBudget.chargeRetained(bodyBytes, { kind: "request_copies" }); + } catch (err) { + if (!isTranslatorBudgetExceededError(err)) throw err; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 413, { closeReason: "non_stream" }); + return anthropicErrorResponse(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } // Request-log wiring mirrors the /v1/responses route: native passthrough finalizes // via the terminal callbacks; routed streams get the Responses-vocabulary log tap @@ -1006,7 +1020,13 @@ async function handleClaudeMessagesWithBudget( } return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error"); } - const message = responsesJsonToAnthropicMessage(json, requestedModel); + let message: Rec; + try { + message = responsesJsonToAnthropicMessage(json, requestedModel, translatorBudget); + } catch (err) { + if (!isTranslatorBudgetExceededError(err)) throw err; + return anthropicErrorResponse(413, "upstream translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } if ((message as Rec).type === "error") { return new Response(JSON.stringify(message), { status: 529, diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 312af7ac43..594b444c8b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2304,7 +2304,8 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). - route.provider = resolveOpenCodeGoTransport(route.provider, sessionLaneIdFromRequest(req.headers)); + route.provider = resolveOpenCodeGoTransport(route.provider, + sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index c568bb0151..256bd1eae5 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1480,6 +1480,23 @@ Unsupported constraints remain in `description` as model guidance instead of dis ## Reasoning display parity (hideThinkingSummary) +Reasoning-envelope serialization uses preflight byte sizing and transient reservations before +creating JSON, UTF-8, or base64 copies. Encoding also admits the matching decode projection, so +a successfully encoded standalone envelope fits the standalone decoder's limit. Callers retain +ownership of returned values; the helper releases only its temporary reservation. Inbound +Anthropic translation carries one budget across all assistant blocks and accounts for retained +envelopes until the response lifecycle disposes it. Standalone translation owns a temporary +budget and disposes it on success or failure. Final translated-request sizing uses plain-JSON +measurement rather than allocating a serialized copy just to measure it. + +[Decision Log] +- 목적과 의도: Keep reasoning replay bounded while preserving opaque values exactly. +- 기존 구현 및 제약 조건: Reasoning continuity needs JSON/base64 envelopes, and existing callers already own retained accounting and typed overflow handling. +- 검토한 주요 대안: Per-field truncation, an independent fixed field limit, or shared transient admission plus cumulative inbound ownership. +- 선택한 방식: Reserve conservative copy projections in the envelope helpers and use the existing request budget across inbound blocks. +- 다른 대안 대신 이 방식을 선택한 이유: Truncation changes signed values; one field limit does not describe aggregate ownership. Existing budget errors retain the established HTTP and stream error contracts. +- 장점, 단점 및 영향: Normal replay is unchanged; envelope admission includes copy overhead and is stricter than a raw-string length ceiling. These are translator accounting limits, not a process-wide RSS guarantee. + `hideThinkingSummary` (request reasoning summary absent/"none" — the routed catalog default) is honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_raw_delta` (openai-chat `reasoning_content`, kiro tags). Hidden reasoning emits an envelope-only reasoning diff --git a/tests/clients/prime-client.test.ts b/tests/clients/prime-client.test.ts index c88c77508a..6c7c0a2f76 100644 --- a/tests/clients/prime-client.test.ts +++ b/tests/clients/prime-client.test.ts @@ -37,18 +37,14 @@ function context(): ExportContext { } describe("Prime Agent client config", () => { - /** - * The load-bearing claim of this client: Prime Agent is the pi coding agent - * under a different brand, so it reads the SAME models.json contract rather - * than a lookalike. Locking the two documents together is what keeps that - * claim true — if a future Pi-only change diverges, this fails here instead - * of silently shipping Prime users a config their agent rejects. - */ - test("generates byte-for-byte the document Pi generates", () => { - const prime = buildClientConfigText("prime", context()); - const pi = buildClientConfigText("pi", context()); - expect(prime.format).toBe("json"); - expect(prime.text).toBe(pi.text); + test("shares Pi's model contract without opting Prime into session headers", () => { + const prime = buildClientConfig("prime", context()) as PiGeneratedConfig; + const pi = buildClientConfig("pi", context()) as PiGeneratedConfig; + expect(pi.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true }); + delete pi.providers[OPENCODE_PROVIDER_ID]!.compat; + expect(prime).toEqual(pi); + expect(buildClientContribution("prime", context()).fragments[0]!.value) + .toEqual(prime.providers[OPENCODE_PROVIDER_ID]); }); test("adds only providers.opencodex, wired to the loopback proxy", () => { diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 6a71813e9f..c5a5840b82 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -11,6 +11,7 @@ import { LOOPBACK_API_KEY_PLACEHOLDER, SCHEMA_REQUIRED_OUTPUT_BUDGET, buildClientConfig, + buildClientContribution, buildClientConfigText, isExportClientId, normalizeExportModels, @@ -315,6 +316,8 @@ describe("Pi serializer (accept criterion 2)", () => { expect(provider.baseUrl).toBe(BASE_URL); expect(provider.api).toBe("openai-completions"); expect(provider.apiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + expect(provider.compat?.sendSessionAffinityHeaders).toBe(true); + expect(buildClientContribution("pi", ctx()).fragments[0]!.value).toEqual(provider); }); test("cost is omitted on every entry — zeros would assert routed models are free", () => { @@ -899,7 +902,7 @@ describe("EXPORT_CLIENTS registry", () => { `); }); - test("pi bytes are unchanged, to the last newline", () => { + test("pi bytes include session affinity, to the last newline", () => { const built = buildClientConfigText("pi", ctx({ config: cfg() })); expect(built.format).toBe("json"); expect(built.text).toBe(`{ @@ -908,6 +911,9 @@ describe("EXPORT_CLIENTS registry", () => { "baseUrl": "http://127.0.0.1:10100/v1", "api": "openai-completions", "apiKey": "opencodex-loopback", + "compat": { + "sendSessionAffinityHeaders": true + }, "models": [ { "id": "anthropic/claude-opus-5", diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index 00684f065b..ab28c8475f 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -3,6 +3,7 @@ import { providerConfigSeed } from "../../src/providers/derive"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { handleResponses } from "../../src/server/responses/core"; +import { handleChatCompletions } from "../../src/server/chat-completions"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; const MUSE_MODEL = "muse-spark-1.3-contributor"; @@ -52,6 +53,8 @@ async function captureRequest(input: { model?: string; child?: string; provider?: OcxProviderConfig; + nativeChat?: boolean; + headers?: Record; } = {}): Promise<{ url: string; headers: Headers }> { const providerName = input.providerName ?? "opencode-go"; const model = input.model ?? MUSE_MODEL; @@ -65,10 +68,18 @@ async function captureRequest(input: { const config = { providers: { [providerName]: input.provider ?? opencodeGo() }, } as unknown as OcxConfig; - const response = await handleResponses( + const response = input.nativeChat ? await handleChatCompletions( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: input.headers ?? codexHeaders(input.child), + body: JSON.stringify({ model: `${providerName}/${model}`, messages: [{ role: "user", content: "ping" }], stream: false }), + }), + config, + { model: "", provider: "" }, + ) : await handleResponses( new Request("http://localhost/v1/responses", { method: "POST", - headers: codexHeaders(input.child), + headers: input.headers ?? codexHeaders(input.child), body: JSON.stringify({ model: `${providerName}/${model}`, input: "ping", stream: false }), }), config, @@ -77,6 +88,7 @@ async function captureRequest(input: { ); expect(response.status).toBe(200); + await response.text(); expect(requests).toHaveLength(1); return requests[0]!; } @@ -85,6 +97,85 @@ describe("OpenCode Go session affinity (#3344)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); + test("native Chat ingress preserves stable Go affinity and separates conversations", async () => { + const provider = opencodeGo(); + const input = { nativeChat: true, model: "omen-alpha", provider }; + const first = await captureRequest(input); + const continued = await captureRequest(input); + const sibling = await captureRequest({ ...input, child: "child-thread-b" }); + expect(first.url).toBe("https://opencode.ai/zen/go/v1/chat/completions"); + expect(first.headers.get(SESSION_HEADER)).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(continued.headers.get(SESSION_HEADER)).toBe(first.headers.get(SESSION_HEADER)); + expect(sibling.headers.get(SESSION_HEADER)).not.toBe(first.headers.get(SESSION_HEADER)); + expect(provider.headers?.[SESSION_HEADER]).toBeUndefined(); + }); + + test("native Chat honors configured session headers on renamed Go providers", async () => { + const captured = await captureRequest({ + nativeChat: true, model: "omen-alpha", providerName: "renamed-go", + provider: opencodeGo({ headers: { "X-OpenCode-Session": "operator-session" } }), + }); + expect(captured.headers.get(SESSION_HEADER)).toBe("operator-session"); + }); + + test("uses a Pi session header without Codex headers on native and bridged Chat", async () => { + const headers = { "content-type": "application/json", "x-opencode-session": "pi-conversation-a" }; + const chat = await captureRequest({ nativeChat: true, model: "omen-alpha", headers }); + const bridged = await captureRequest({ nativeChat: true, model: MUSE_MODEL, headers }); + expect(chat.headers.get(SESSION_HEADER)).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(chat.headers.get(SESSION_HEADER)).not.toContain("pi-conversation-a"); + expect(bridged.headers.get(SESSION_HEADER)).toBe(chat.headers.get(SESSION_HEADER)); + }); + + // Fixed vectors independently calculated with SHA-256, including the domain separator. + for (const [session, expected] of [ + ["client-session-a", "ocx_516d593899f34b7baca2db37c7b0c8c5"], + ["ocx_0123456789abcdef0123456789abcdef", "ocx_60bcbfb9a85d3dc23b9b2b1cef3b0882"], + ] as const) { + test(`treats inbound ${session.startsWith("ocx_") ? "ocx-prefixed" : "raw"} identity as client input on every ingress`, async () => { + const headers = { "content-type": "application/json", [SESSION_HEADER]: session }; + const native = await captureRequest({ nativeChat: true, model: "omen-alpha", headers }); + const bridged = await captureRequest({ nativeChat: true, model: MUSE_MODEL, headers }); + const responses = await captureRequest({ model: MUSE_MODEL, headers }); + expect(native.url).toEndWith("/chat/completions"); + expect(bridged.url).toEndWith("/responses"); + for (const request of [native, bridged, responses]) { + expect(request.headers.get(SESSION_HEADER)).toBe(expected); + expect(request.headers.get(SESSION_HEADER)).not.toBe(session); + } + const override = await captureRequest({ + nativeChat: true, model: "omen-alpha", headers, + provider: opencodeGo({ headers: { "X-OpenCode-Session": session } }), + }); + expect(override.headers.get(SESSION_HEADER)).toBe(session); + }); + } + + test("operator override precedes the Codex lane, which precedes client fallback on every ingress", async () => { + const headers = { ...codexHeaders(), [SESSION_HEADER]: "different-client-fallback" }; + for (const ingress of [ + { nativeChat: true, model: "omen-alpha" }, + { nativeChat: true, model: MUSE_MODEL }, + { model: MUSE_MODEL }, + ]) { + const codex = await captureRequest({ ...ingress, headers }); + expect(codex.headers.get(SESSION_HEADER)).toBe("ocx_67b70584fb755130286eff5488a3be9d"); + const operator = await captureRequest({ + ...ingress, headers, + provider: opencodeGo({ headers: { "X-OpenCode-Session": "different-operator-override" } }), + }); + expect(operator.headers.get(SESSION_HEADER)).toBe("different-operator-override"); + } + }); + + test("native Chat does not send Go affinity to an unrelated destination", async () => { + const captured = await captureRequest({ + nativeChat: true, model: "omen-alpha", providerName: "custom-go", + provider: opencodeGo({ baseUrl: "https://opencode.ai.evil.test/zen/go/v1" }), + }); + expect(captured.headers.has(SESSION_HEADER)).toBe(false); + }); + test("sends one stable opaque session header on Responses and Chat wires", async () => { const responses = await captureRequest({ model: MUSE_MODEL }); const chat = await captureRequest({ model: CHAT_MODEL }); diff --git a/tests/responses/reasoning-envelope.test.ts b/tests/responses/reasoning-envelope.test.ts index 2469b8e46b..6ffc2d750f 100644 --- a/tests/responses/reasoning-envelope.test.ts +++ b/tests/responses/reasoning-envelope.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, test } from "bun:test"; -import { anthropicToResponsesBody } from "../../src/claude/inbound"; -import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; -import { responsesJsonToAnthropicMessage } from "../../src/claude/outbound"; +import { describe, expect, spyOn, test } from "bun:test"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; +import { anthropicToResponsesBody, anthropicToResponsesTranslation } from "../../src/claude/inbound"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX, type ReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { responsesJsonToAnthropicMessage, responsesSseToAnthropicSse } from "../../src/claude/outbound"; +import { createTranslatorBudget, TranslatorBudgetExceededError, translatorObservedBufferSnapshot } from "../../src/lib/translator-budget"; +import { jsonUtf8Bytes } from "../../src/lib/json-byte-size"; +import * as budgets from "../../src/lib/translator-budget"; describe("reasoning and tool/result envelopes", () => { test("preserves ordered thinking blocks and genuine signatures", () => { @@ -77,3 +82,300 @@ describe("reasoning and tool/result envelopes", () => { expect(message.content).toEqual([{ type: "thinking", thinking: "", signature: "sig-only" }]); }); }); + +describe("reasoning allocation admission", () => { + test.each(["ascii", "\"\\\n\u0000", "한글😀", "\ud800", "\udc00", ""])('sizes JSON strings exactly: %j', value => { + const data = { sig: value, red: [value, ""], txt: value, krc: value, omitted: undefined }; + const expected = Buffer.byteLength(JSON.stringify(data)); + expect(jsonUtf8Bytes(data, expected)).toBe(expected); + expect(() => jsonUtf8Bytes(data, expected - 1)).toThrow(TranslatorBudgetExceededError); + }); + + test("sizes the translated plain-JSON vocabulary", () => { + const data = { arr: [undefined, null, true, false, 0, -0, 1e30, NaN, Infinity, { text: "x" }], absent: undefined }; + expect(jsonUtf8Bytes(data)).toBe(Buffer.byteLength(JSON.stringify(data))); + }); + + test.each([{ sig: "opaque" }, { red: ["one", "two"] }, { txt: "hidden" }, { krc: "opaque" }, { sig: "s", red: ["r"], txt: "t", krc: "k" }])( + "rejects before JSON/Buffer materialization and admits the exact projected boundary: %j", envelope => { + const json = JSON.stringify(envelope); + const size = Buffer.byteLength(json); + const base64Bytes = 4 * Math.ceil(size / 3); + const limit = Math.max(3 * size + 4 * base64Bytes + 2 * OCX_REASONING_PREFIX.length, 8 * (OCX_REASONING_PREFIX.length + base64Bytes)); + const budget = createTranslatorBudget({ maxTurnBytes: limit - 1 }); + const stringify = spyOn(JSON, "stringify"); + const from = spyOn(Buffer, "from"); + let error: unknown; + let serializations = 0; + let allocations = 0; + try { encodeReasoningEnvelope(envelope, budget); } catch (caught) { error = caught; } + finally { + serializations = stringify.mock.calls.length; + allocations = from.mock.calls.length; + stringify.mockRestore(); from.mockRestore(); + } + expect(error).toBeInstanceOf(TranslatorBudgetExceededError); + expect(serializations).toBe(0); + expect(allocations).toBe(0); + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + const exact = createTranslatorBudget({ maxTurnBytes: limit }); + try { + const encoded = encodeReasoningEnvelope(envelope, exact); + expect(encoded).toBe(OCX_REASONING_PREFIX + Buffer.from(json).toString("base64")); + expect(decodeReasoningEnvelope(encoded, exact)).toEqual(envelope); + expect(exact.snapshot().currentBytes).toBe(0); + } finally { exact.dispose(); } + }, + ); + + test("bounds preencoded replay before decoding and preserves native blobs", () => { + const encoded = encodeReasoningEnvelope({ txt: "" }); + const budget = createTranslatorBudget({ maxTurnBytes: encoded.length * 8 - 1 }); + const from = spyOn(Buffer, "from"); + let error: unknown; + let allocations = 0; + try { decodeReasoningEnvelope(encoded, budget); } catch (caught) { error = caught; } + finally { allocations = from.mock.calls.length; from.mockRestore(); } + expect(error).toBeInstanceOf(TranslatorBudgetExceededError); + expect(allocations).toBe(0); + expect(decodeReasoningEnvelope("native-opaque", budget)).toBeNull(); + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + const exact = createTranslatorBudget({ maxTurnBytes: encoded.length * 8 }); + try { expect(decodeReasoningEnvelope(encoded, exact)).toEqual({ txt: "" }); } + finally { exact.dispose(); } + }); + + test.each(["thinking", "redacted_thinking", "owned"])('accounts cumulatively for %s blocks across messages', type => { + const before = translatorObservedBufferSnapshot().currentBytes; + const block = type === "redacted_thinking" ? { type, data: "r" } + : { type: "thinking", thinking: "", signature: type === "owned" ? encodeReasoningEnvelope({ txt: "t" }) : "s" }; + const budget = createTranslatorBudget({ maxTurnBytes: 256 }); + try { + expect(() => anthropicToResponsesTranslation({ model: "m", messages: Array.from({ length: 8 }, () => ({ role: "assistant", content: [block] })) }, undefined, budget)) + .toThrow(TranslatorBudgetExceededError); + expect(budget.snapshot().highWaterBytes).toBeLessThanOrEqual(256); + } finally { budget.dispose(); } + expect(translatorObservedBufferSnapshot().currentBytes).toBe(before); + }); + + test.each(["thinking", "redacted_thinking", "owned"])("handler maps %s admission failure to 413 without dispatch and disposes its budget", async type => { + const { handleClaudeMessages } = await import("../../src/server/claude-messages"); + const payload = "fixture".repeat(128); + const signature = type === "owned" ? encodeReasoningEnvelope({ txt: payload }) : payload; + const content = type === "redacted_thinking" ? { type, data: payload } + : { type: "thinking", thinking: "", signature }; + const request = new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", messages: [{ role: "assistant", content: [content] }] }), + }); + const beforeBytes = budgets.translatorObservedBufferSnapshot().currentBytes; + const beforeCount = budgets.translatorLiveBudgetCountForTests(); + const create = budgets.createTranslatorBudget; + const budget = create({ maxTurnBytes: 4096 }); + const reserve = spyOn(budget, "reserveTransient"); + const charge = spyOn(budget, "chargeRetained"); + const factory = spyOn(budgets, "createTranslatorBudget").mockReturnValue(budget); + const upstream = spyOn(globalThis, "fetch").mockImplementation(async () => { throw new Error("unexpected upstream dispatch"); }); + try { + const response = await handleClaudeMessages(request, { port: 0, providers: {} }, { model: "", provider: "" }); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ type: "error", error: { type: "request_too_large", code: "translation_buffer_limit" } }); + expect(upstream).not.toHaveBeenCalled(); + expect(reserve.mock.calls.some(([, scope]) => scope.kind === "reasoning")).toBe(true); + expect(charge.mock.calls.filter(([, scope]) => scope.kind === "request_copies")).toHaveLength(0); + expect(budgets.translatorObservedBufferSnapshot().currentBytes).toBe(beforeBytes); + expect(budgets.translatorLiveBudgetCountForTests()).toBe(beforeCount); + } finally { factory.mockRestore(); upstream.mockRestore(); reserve.mockRestore(); charge.mockRestore(); budget.dispose(); } + }); + test("final request-copy admission returns 413 before serialization and disposes the budget", async () => { + const { handleClaudeMessages } = await import("../../src/server/claude-messages"); + const request = new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", messages: [{ role: "user", content: "x".repeat(200) }] }), + }); + const beforeBytes = budgets.translatorObservedBufferSnapshot().currentBytes; + const beforeCount = budgets.translatorLiveBudgetCountForTests(); + const budget = budgets.createTranslatorBudget({ maxTurnBytes: 512 }); + const reserve = spyOn(budget, "reserveTransient"); + const charge = spyOn(budget, "chargeRetained"); + const factory = spyOn(budgets, "createTranslatorBudget").mockReturnValue(budget); + const stringify = spyOn(JSON, "stringify"); + const upstream = spyOn(globalThis, "fetch").mockImplementation(async () => { throw new Error("unexpected upstream dispatch"); }); + try { + const response = await handleClaudeMessages(request, { port: 0, providers: {} }, { model: "", provider: "" }); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ type: "error", error: { type: "request_too_large", code: "translation_buffer_limit" } }); + expect(charge.mock.calls.filter(([, scope]) => scope.kind === "request_copies")).toHaveLength(1); + expect(reserve.mock.calls.filter(([, scope]) => scope.kind === "request_copies")).toHaveLength(1); + expect(stringify.mock.calls.some(([value]) => value && typeof value === "object" && "input" in value)).toBe(false); + expect(upstream).not.toHaveBeenCalled(); + expect(budgets.translatorObservedBufferSnapshot().currentBytes).toBe(beforeBytes); + expect(budgets.translatorLiveBudgetCountForTests()).toBe(beforeCount); + } finally { + factory.mockRestore(); stringify.mockRestore(); upstream.mockRestore(); + reserve.mockRestore(); charge.mockRestore(); budget.dispose(); + } + }); + + test("successful Request construction retains only its UTF-8 body after releasing temporary copies", async () => { + const { handleClaudeMessages } = await import("../../src/server/claude-messages"); + const request = new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", messages: [{ role: "user", content: "hello" }] }), + }); + const budget = budgets.createTranslatorBudget({ maxTurnBytes: 4096 }); + const originalCharge = budget.chargeRetained.bind(budget); + const copies: Array<{ bytes: number; before: number; after: number }> = []; + const charge = spyOn(budget, "chargeRetained").mockImplementation((bytes, scope) => { + const before = budget.snapshot().currentBytes; + originalCharge(bytes, scope); + if (scope.kind === "request_copies") copies.push({ bytes, before, after: budget.snapshot().currentBytes }); + }); + const reserve = spyOn(budget, "reserveTransient"); + const factory = spyOn(budgets, "createTranslatorBudget").mockReturnValue(budget); + const stringify = spyOn(JSON, "stringify"); + try { + const response = await handleClaudeMessages(request, { port: 0, providers: {} }, { model: "", provider: "" }); + expect(response.status).toBe(404); // Serialization succeeded; the synthetic model is deliberately absent. + await response.text(); + const serialized = stringify.mock.calls.find(([value]) => value && typeof value === "object" && "input" in value)?.[0]; + expect(serialized).toBeDefined(); + const expected = Buffer.byteLength(JSON.stringify(serialized)); + expect(copies).toHaveLength(2); + expect(copies[1]!.bytes).toBe(expected); + expect(copies[1]!.before).toBe(copies[0]!.after); + expect(reserve.mock.calls.filter(([, scope]) => scope.kind === "request_copies").map(([bytes]) => bytes)).toEqual([3 * expected]); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { stringify.mockRestore(); factory.mockRestore(); reserve.mockRestore(); charge.mockRestore(); budget.dispose(); } + }); + + for (const event of [ + { type: "thinking_signature", signature: "r".repeat(256) }, + { type: "redacted_thinking", data: "r".repeat(256) }, + { type: "reasoning_raw_delta", text: "r".repeat(256) }, + { type: "kiro_redacted_reasoning", data: "r".repeat(256) }, + ] as const) { + for (const mode of ["batch", "stream"] as const) { + test(`${mode} ${event.type} admits envelope copies against the already charged turn`, async () => { + const budget = createTranslatorBudget({ maxTurnBytes: 4096 }); + budget.chargeRetained(2048, { kind: "request_copies" }); + const stringify = spyOn(JSON, "stringify"); + try { + const events: AdapterEvent[] = [event, { type: "done" }]; + if (mode === "batch") { + expect(() => buildResponseJSON(events, "fixture/model", { translatorBudget: budget, hideThinkingSummary: true })) + .toThrow(TranslatorBudgetExceededError); + } else { + async function* source() { yield* events; } + const wire = await new Response(bridgeToResponsesSSE(source(), "fixture/model", undefined, undefined, undefined, undefined, undefined, + { translatorBudget: budget, hideThinkingSummary: true })).text(); + expect(wire).toContain('"code":"translation_buffer_limit"'); + expect(wire).not.toContain('event: response.completed'); + } + expect(stringify.mock.calls.some(([value]) => value && typeof value === "object" + && ("sig" in value || "txt" in value || "red" in value || "krc" in value))).toBe(false); + } finally { stringify.mockRestore(); budget.dispose(); } + }); + } + } + + for (const encoded of [false, true]) { + test(`JSON outbound ${encoded ? "decoding" : "encoding"} uses the caller budget before allocation`, () => { + const item = encoded + ? { type: "reasoning", encrypted_content: encodeReasoningEnvelope({ sig: "r".repeat(256) }), summary: [] } + : { type: "reasoning", summary: [{ type: "summary_text", text: "r".repeat(256) }] }; + const budget = createTranslatorBudget({ maxTurnBytes: 4096 }); + budget.chargeRetained(2048, { kind: "request_copies" }); + const from = spyOn(Buffer, "from"); + try { + expect(() => responsesJsonToAnthropicMessage({ output: [item] }, "fixture/model", budget)).toThrow(TranslatorBudgetExceededError); + expect(from).not.toHaveBeenCalled(); + } finally { from.mockRestore(); budget.dispose(); } + }); + } + + for (const ending of ["throw", "eof", "stall"] as const) { + for (const overflow of [false, true]) { + test(`hidden reasoning ${ending} cleanup ${overflow ? "reports one budget failure" : "preserves its admitted terminal"}`, async () => { + const budget = createTranslatorBudget({ maxTurnBytes: overflow ? 4096 : 65536 }); + budget.chargeRetained(2048, { kind: "request_copies" }); + const accumulated = Promise.withResolvers(); + const pending = Promise.withResolvers>(); + let reads = 0; + let returns = 0; + let cancelled = 0; + let clears = 0; + let beat = () => {}; + const source: AsyncIterableIterator = { + [Symbol.asyncIterator]() { return this; }, + async next() { + if (++reads === 1) return { done: false, value: { type: "reasoning_raw_delta", text: "r".repeat(256) } }; + accumulated.resolve(); + if (ending === "throw") throw new Error("synthetic generator failure"); + if (ending === "eof") return { done: true, value: undefined }; + return pending.promise; + }, + async return() { returns++; pending.resolve({ done: true, value: undefined }); return { done: true, value: undefined }; }, + }; + const stringify = spyOn(JSON, "stringify"); + try { + const stream = bridgeToResponsesSSE(source, "fixture/model", undefined, undefined, undefined, + () => { cancelled++; }, 500, { + translatorBudget: budget, hideThinkingSummary: true, stallTimeoutSec: 1, + timers: { setInterval(callback) { beat = callback; return 1; }, clearInterval() { clears++; beat = () => {}; } }, + }); + const result = new Response(stream).text(); + await accumulated.promise; + if (ending === "stall") { beat(); beat(); beat(); } + const wire = await result; + const envelopes = stringify.mock.calls.filter(([value]) => value && typeof value === "object" && "txt" in value); + expect(wire.match(/data: \[DONE\]/g)).toHaveLength(1); + expect(wire).not.toContain("event: response.completed"); + expect(clears).toBe(1); + if (overflow) { + expect(envelopes).toHaveLength(0); + expect(wire.match(/event: response.failed/g)).toHaveLength(1); + expect(wire).toContain('"code":"translation_buffer_limit"'); + expect(wire).not.toContain("event: response.incomplete"); + expect(cancelled).toBe(1); + expect(returns).toBe(1); + } else { + expect(envelopes).toHaveLength(1); + expect(wire).not.toContain("translation_buffer_limit"); + expect(wire.match(new RegExp(`event: response.${ending === "throw" ? "failed" : "incomplete"}`, "g"))).toHaveLength(1); + expect(cancelled).toBe(ending === "eof" ? 0 : 1); + } + } finally { stringify.mockRestore(); pending.resolve({ done: true, value: undefined }); budget.dispose(); } + }); + } + } + + for (const encoded of [false, true]) { + test(`SSE outbound ${encoded ? "decoding" : "encoding"} admits against its live turn budget`, async () => { + const text = "r".repeat(512); + const events = encoded ? [{ type: "response.output_item.done", item: { + type: "reasoning", encrypted_content: encodeReasoningEnvelope({ sig: text }), summary: [], + } }] : [ + { type: "response.reasoning_summary_text.delta", delta: text }, + { type: "response.completed", response: { status: "completed", output: [] } }, + ]; + const frames = events.map(event => new TextEncoder().encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)); + const budget = createTranslatorBudget({ maxTurnBytes: 8192 }); + budget.chargeRetained(4096, { kind: "request_copies" }); + const reserve = spyOn(budget, "reserveTransient"); + const from = spyOn(Buffer, "from"); + try { + const upstream = new ReadableStream({ start(controller) { frames.forEach(frame => controller.enqueue(frame)); controller.close(); } }); + const wire = await new Response(responsesSseToAnthropicSse(upstream, "fixture/model", { translatorBudget: budget, pingIntervalMs: 0 })).text(); + expect(reserve.mock.calls.some(([bytes, scope]) => scope.kind === "reasoning" && bytes > 4096)).toBe(true); + expect(from).not.toHaveBeenCalled(); + expect(wire.match(/event: error/g)).toHaveLength(1); + expect(wire).toContain('"code":"translation_buffer_limit"'); + expect(wire).not.toContain("event: message_stop"); + } finally { reserve.mockRestore(); from.mockRestore(); budget.dispose(); } + }); + } + +});