diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 5c13e041b6..ffc3ad6e88 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -500,7 +500,7 @@ Le proxy traduit chaque requête Anthropic Messages API au format Codex Response | Texte assistant | `output_text` | | Assistant `tool_use` | `function_call` (`input` → JSON-stringifié `arguments`) | | Utilisateur `tool_result` | `function_call_output` (`is_error` → préfixe `[tool error]`) | -| Relecture de `thinking` / `redacted_thinking` | Ignorée | +| Relecture de `thinking` / `redacted_thinking` | Éléments `reasoning` avec enveloppes `ocxr1` bornées pour les signatures et les contenus masqués | | Outils fonctionnels | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, fonction nommée→`{type:"function",name}`, hébergée WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -516,7 +516,7 @@ Le proxy traduit chaque requête Anthropic Messages API au format Codex Response | `response.created` | `message_start` + `ping` | | Battement de coeur | `ping` | | Deltas de texte | `content_block_start` → `content_block_delta` (texte) → `content_block_stop` | -| Résumé ou texte de raisonnement | Bloc `thinking` avec signature synthétique | +| Résumé ou texte de raisonnement | Bloc `thinking` avec la signature relue, ou une enveloppe de secours `ocxr1` bornée | | Trames d'appel de fonction | Bloc `tool_use` avec `input_json_delta` | | Événement terminal | `message_delta` → `message_stop` | | EOF avant la borne | style 502 `api_error` | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5ed946c72a..b237955adb 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -513,7 +513,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | Assistant text | `output_text` | | Assistant `tool_use` | `function_call` (`input` → JSON-stringified `arguments`) | | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | -| `thinking` / `redacted_thinking` replay | Dropped | +| `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | | Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `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` | @@ -530,7 +530,7 @@ name. | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | Text deltas | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | -| Reasoning summary/text | `thinking` block with synthetic signature | +| Reasoning summary/text | `thinking` block with the replayed signature, or a bounded `ocxr1` fallback envelope | | Function-call frames | `tool_use` block with `input_json_delta` | | Terminal event | `message_delta` → `message_stop` | | EOF before terminal | 502-style `api_error` | diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 8c9f433956..43b8bcee24 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -368,7 +368,7 @@ Claude Code の `/effort` 設定はアダプターでも維持されます。 | Assistant テキスト | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 文字列に変換した `arguments`) | | ユーザー `tool_result` | `function_call_output`(`is_error` → `[tool error]` 接頭辞) | -| `thinking` / `redacted_thinking` 再生 | 破棄 | +| `thinking` / `redacted_thinking` 再生 | シグネチャと秘匿ペイロードを境界付き `ocxr1` エンベロープに保持した `reasoning` 項目 | | Function ツール | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`、`none`→`none`、`any`→`required`、名前指定関数→`{type:"function",name}`、ホスト型 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -384,7 +384,7 @@ role、`tool_use_id` のない `tool_result`、id/name のない `tool_use`、na | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | テキスト delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | -| 推論要約/テキスト | 合成シグネチャ付きの `thinking` ブロック | +| 推論要約/テキスト | 再生されたシグネチャ、または境界付き `ocxr1` フォールバックを持つ `thinking` ブロック | | Function-call フレーム | `input_json_delta` を持つ `tool_use` ブロック | | 終了イベント | `message_delta` → `message_stop` | | 終了前に EOF | 502 形式 `api_error` | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 1368cf5698..0964f2ff49 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -406,7 +406,7 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | Assistant 텍스트 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 문자열로 변환한 `arguments`) | | 사용자 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 접두사) | -| `thinking` / `redacted_thinking` 재생 | 버려요 | +| `thinking` / `redacted_thinking` 재생 | 서명과 비공개 페이로드를 제한된 `ocxr1` 봉투에 담은 `reasoning` 항목 | | Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, 이름 지정 함수→`{type:"function",name}`, 호스팅 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -422,7 +422,7 @@ role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | 텍스트 delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | -| 추론 요약/텍스트 | 합성 signature가 있는 `thinking` 블록 | +| 추론 요약/텍스트 | 재생된 서명 또는 제한된 `ocxr1` 폴백이 있는 `thinking` 블록 | | Function-call 프레임 | `input_json_delta`가 있는 `tool_use` 블록 | | 종료 이벤트 | `message_delta` → `message_stop` | | 종료 전에 EOF | 502 형식 `api_error` | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 3f6c07a4aa..1769642bd7 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -393,7 +393,7 @@ Claude Code — это лишь учётные данные для доступ | Текст ассистента | `output_text` | | `tool_use` ассистента | `function_call` (`input` → `arguments` в виде JSON-строки) | | `tool_result` пользователя | `function_call_output` (`is_error` → префикс `[tool error]`) | -| Повтор `thinking` / `redacted_thinking` | Отбрасывается | +| Повтор `thinking` / `redacted_thinking` | Элементы `reasoning` с ограниченными конвертами `ocxr1` для подписей и скрытых данных | | Function-инструменты | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, именованная функция→`{type:"function",name}`, размещённый WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -410,7 +410,7 @@ id/name; именованный `tool_choice` без имени. | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | Текстовые дельты | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | -| Резюме/текст рассуждений | Блок `thinking` с синтетической подписью | +| Резюме/текст рассуждений | Блок `thinking` с повторно переданной подписью или ограниченным резервным конвертом `ocxr1` | | Кадры function-call | Блок `tool_use` с `input_json_delta` | | Завершающее событие | `message_delta` → `message_stop` | | EOF до завершающего события | `api_error` в стиле 502 | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 5450d6b748..29d96506ac 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -582,7 +582,7 @@ dönüştürür: | Asistan metni | `output_text` | | Asistan `tool_use` | `function_call` (`input` → JSON dizgeleştirilmiş `arguments`) | | Kullanıcı `tool_result` | `function_call_output` (`is_error` → `[tool error]` öneki) | -| `thinking` / `redacted_thinking` tekrarı | Bırakılır | +| `thinking` / `redacted_thinking` tekrarı | İmzaları ve gizli yükleri sınırlı `ocxr1` zarflarında taşıyan `reasoning` öğeleri | | Fonksiyon araçları | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, adlandırılmış fonksiyon→`{type:"function",name}`, barındırılan WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -599,7 +599,7 @@ kimlik/ad içermeyen `tool_use`; ad içermeyen adlandırılmış `tool_choice`. | `response.created` | `message_start` + `ping` | | Kalp atışı (Heartbeat) | `ping` | | Metin farkları | `content_block_start` → `content_block_delta` (metin) → `content_block_stop` | -| Akıl yürütme özeti/metni | Sentetik imzalı `thinking` bloğu | +| Akıl yürütme özeti/metni | Tekrarlanan imzayı veya sınırlı bir `ocxr1` yedeğini taşıyan `thinking` bloğu | | Fonksiyon çağrısı çerçeveleri | `input_json_delta` ile `tool_use` bloğu | | Terminal olayı | `message_delta` → `message_stop` | | Terminalden önce EOF | 502 tarzı `api_error` | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 3bbe49646b..3e2824d3e9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -344,7 +344,7 @@ Claude Code 的 `/effort` 设置会完整保留并传递给适配器: | Assistant 文本 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 字符串化的 `arguments`) | | 用户 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 前缀) | -| 重放 `thinking` / `redacted_thinking` | 丢弃 | +| 重放 `thinking` / `redacted_thinking` | `reasoning` 项;签名和脱敏载荷保存在有界 `ocxr1` 信封中 | | Function 工具 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`,`none`→`none`,`any`→`required`,指定函数→`{type:"function",name}`,托管 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -361,7 +361,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | `response.created` | `message_start` + `ping` | | 心跳 | `ping` | | 文本增量 | `content_block_start` → `content_block_delta`(文本)→ `content_block_stop` | -| 推理摘要/文本 | 带合成签名的 `thinking` 块 | +| 推理摘要/文本 | 带重放签名或有界 `ocxr1` 回退信封的 `thinking` 块 | | Function-call 帧 | 带 `input_json_delta` 的 `tool_use` 块 | | 终止事件 | `message_delta` → `message_stop` | | 在终止事件前 EOF | 502 风格的 `api_error` | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index ccfb3b9ddd..5a6fbf3a86 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -420,7 +420,7 @@ Claude Code 的 `/effort` 設定會完整保留並傳遞給適配器: | Assistant 文字 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 字串化的 `arguments`) | | 使用者 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 字首) | -| 重放 `thinking` / `redacted_thinking` | 丟棄 | +| 重放 `thinking` / `redacted_thinking` | `reasoning` 項目;簽名與遮蔽載荷保存在有界 `ocxr1` 信封中 | | Function 工具 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`,`none`→`none`,`any`→`required`,指定名稱 function→`{type:"function",name}`,hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -437,7 +437,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | `response.created` | `message_start` + `ping` | | 心跳 | `ping` | | 文字增量 | `content_block_start` → `content_block_delta`(文字)→ `content_block_stop` | -| 推理摘要/文字 | 帶合成簽名的 `thinking` 塊 | +| 推理摘要/文字 | 帶重播簽名或有界 `ocxr1` 備援信封的 `thinking` 塊 | | Function-call 幀 | 帶 `input_json_delta` 的 `tool_use` 塊 | | 終止事件 | `message_delta` → `message_stop` | | 在終止事件前 EOF | 502 風格的 `api_error` | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d309073a3f..255c78e916 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -312,6 +312,7 @@ "claude-outbound.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", + "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", @@ -999,6 +1000,7 @@ "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", "reasoning-effort.test.ts": "codex-integration", + "reasoning-envelope.test.ts": "responses", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 3ac4731385..5e876ca6cb 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -4,8 +4,8 @@ * Design (devlog/260711_claude_inbound/010, 003_evidence.md): * - translate-and-replay: the produced body MUST pass the real responsesRequestSchema * parse so routing/OAuth/pool/failover are inherited unchanged. - * - thinking/redacted_thinking blocks on replay are DROPPED (v1 policy) — routed - * providers carry reasoning in Responses items/ocxr1 envelopes instead. + * - thinking/redacted_thinking replay is preserved in Responses reasoning items; + * signatures and redacted payloads travel in bounded ocxr1 envelopes. * - thinking.budget_tokens is NEVER forwarded raw; it maps to an effort tier. * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity). */ @@ -17,6 +17,7 @@ export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, e 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"; @@ -234,9 +235,26 @@ function assistantMessageToItems(content: unknown, input: Rec[]): void { input.push({ type: "function_call", call_id: raw.id, name: raw.name, arguments: JSON.stringify(raw.input ?? {}) }); break; } - case "thinking": - case "redacted_thinking": - break; // v1 policy: dropped on replay (003 evidence — safe for routed providers) + case "thinking": { + flush(); + 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); + 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 }); + 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; + } + 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] }) }); + break; + } default: break; } diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 48bb06c15a..8c0db5b7b8 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -5,8 +5,8 @@ * - Transport-only `ping` events may appear at any point, including before * message_start. Semantic framing stays message_start -> * (content_block_start -> deltas -> content_block_stop)* -> message_delta -> message_stop. - * - thinking blocks get thinking_delta(s) then ONE synthetic signature_delta just - * before content_block_stop (CCR precedent: Claude Code does not verify signatures). + * - thinking blocks get thinking_delta(s), then one signature_delta containing the + * genuine replay signature or a bounded ocxr1 fallback envelope. * - message_delta.usage is cumulative; message_start embeds a full message snapshot. * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200. */ @@ -20,6 +20,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { sseFieldOffset, sseFieldValue } from "../lib/sse-decoder"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../responses/reasoning-envelope"; type Rec = Record; @@ -214,6 +215,9 @@ interface OpenBlock { callId?: string; /** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */ reasoningPartKey?: string; + thinkingBuf?: string; + thinkingBufBytes?: number; + reasoningSig?: string; } /** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */ @@ -253,6 +257,11 @@ export function responsesSseToAnthropicSse( const bytes = queuedLiveFrameBytes.shift(); if (bytes !== undefined) translatorBudget.releaseRetained(bytes, { kind: "live_transient" }); }; + const releaseThinkingBuffer = (block: OpenBlock | null | undefined) => { + if (block?.kind !== "thinking") return; + translatorBudget.releaseRetained(block.thinkingBufBytes ?? 0, { kind: "reasoning" }); + block.thinkingBufBytes = 0; + }; return new ReadableStream({ start(controller) { @@ -296,13 +305,14 @@ export function responsesSseToAnthropicSse( open.webSearchArgsEmitted = true; } if (open.kind === "thinking") { - // Synthetic signature: Claude Code accepts it (003 E6); inbound drops replays anyway. + const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }); emit("content_block_delta", { type: "content_block_delta", index: open.index, - delta: { type: "signature_delta", signature: `ocx${Date.now()}` }, + delta: { type: "signature_delta", signature }, }); } emit("content_block_stop", { type: "content_block_stop", index: open.index }); + releaseThinkingBuffer(open); if (open.callId) translatorBudget.closeCall(open.callId); open = null; }; @@ -315,7 +325,7 @@ export function responsesSseToAnthropicSse( ? { type: "text", text: "" } : { type: "thinking", thinking: "", signature: "" }; emit("content_block_start", { type: "content_block_start", index, content_block: contentBlock }); - open = { kind, index }; + open = { kind, index, thinkingBuf: "", thinkingBufBytes: 0 }; }; const finish = (stopReason: string, usage: unknown) => { if (terminated) return; @@ -339,6 +349,7 @@ export function responsesSseToAnthropicSse( if (terminated) return; terminated = true; if (code === "translation_buffer_limit") { + releaseThinkingBuffer(open); if (open?.callId) translatorBudget.closeCall(open.callId); open = null; // No normal close frames are valid after overflow. Emit exactly one bounded @@ -374,8 +385,10 @@ export function responsesSseToAnthropicSse( case "response.output_text.delta": { if (typeof data.delta !== "string" || data.delta.length === 0) break; ensureBlock("text"); + const active = open; + if (!active || active.kind !== "text") break; emit("content_block_delta", { - type: "content_block_delta", index: open!.index, + type: "content_block_delta", index: active.index, delta: { type: "text_delta", text: data.delta }, }); break; @@ -384,6 +397,8 @@ export function responsesSseToAnthropicSse( case "response.reasoning_text.delta": { if (typeof data.delta !== "string" || data.delta.length === 0) break; ensureBlock("thinking"); + const active = open; + if (!active || active.kind !== "thinking") break; // The JSON path joins reasoning summary/content parts with "\n\n" // (responsesJsonToAnthropicMessage); mirror that at part and item boundaries // so multi-part summaries do not glue into one run-on paragraph. Frames @@ -395,15 +410,32 @@ export function responsesSseToAnthropicSse( // components while retaining item and part equality, rather than dropping item_id and // accidentally joining distinct malformed reasoning items. const partKey = `${boundedReasoningIdentity(data.item_id)}:${slot}`; - if (open!.reasoningPartKey !== undefined && open!.reasoningPartKey !== partKey) { + const needsPartSeparator = active.reasoningPartKey !== undefined + && active.reasoningPartKey !== partKey; + const appended = `${needsPartSeparator ? "\n\n" : ""}${data.delta}`; + const previous = active.thinkingBuf ?? ""; + const previousBytes = active.thinkingBufBytes ?? 0; + const nextBytes = appendedUtf8Bytes(previous, previousBytes, appended); + const scope = { kind: "reasoning" } as const; + const reservation = translatorBudget.reserveTransient(nextBytes, scope); + try { + active.thinkingBuf = previous + appended; + active.thinkingBufBytes = nextBytes; + reservation.commitRetained(); + translatorBudget.releaseRetained(previousBytes, scope); + } catch (error) { + reservation.release(); + throw error; + } + if (needsPartSeparator) { emit("content_block_delta", { - type: "content_block_delta", index: open!.index, + type: "content_block_delta", index: active.index, delta: { type: "thinking_delta", thinking: "\n\n" }, }); } - open!.reasoningPartKey = partKey; + active.reasoningPartKey = partKey; emit("content_block_delta", { - type: "content_block_delta", index: open!.index, + type: "content_block_delta", index: active.index, delta: { type: "thinking_delta", thinking: data.delta }, }); break; @@ -499,10 +531,9 @@ export function responsesSseToAnthropicSse( if (pair.completed) webSearchRequests++; break; } - if (!open) break; // Close the matching open block (message/reasoning items close implicitly on // the next block; function_call items must close here so tool input parses). - if (open.kind === "tool_use" && item.type === "function_call") { + if (open && open.kind === "tool_use" && item.type === "function_call") { if (open.bufferWebSearchArgs && !open.webSearchArgsEmitted) { const rawArgs = typeof item.arguments === "string" && item.arguments.length > 0 ? item.arguments @@ -518,8 +549,23 @@ export function responsesSseToAnthropicSse( } closeOpenBlock(); } - else if (open.kind === "text" && item.type === "message") closeOpenBlock(); - else if (open.kind === "thinking" && item.type === "reasoning") closeOpenBlock(); + 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 red = env?.red ?? []; + if (env?.sig && open?.kind !== "thinking") ensureBlock("thinking"); + if (open?.kind === "thinking") { + if (env?.sig) open.reasoningSig = env.sig; + closeOpenBlock(); + } + if (red.length > 0) ensureStarted(); + for (const data of red) { + const idx = blockIndex++; + emit("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "redacted_thinking", data } }); + emit("content_block_stop", { type: "content_block_stop", index: idx }); + } + } break; } case "response.completed": { @@ -704,6 +750,7 @@ export function responsesSseToAnthropicSse( fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit"); } else fail(500, err instanceof Error ? err.message : String(err)); } finally { + releaseThinkingBuffer(open); translatorBudget.releaseRetained(bufferBytes, { kind: "live_transient" }); if (pingTimer !== undefined) clearInterval(pingTimer); reader.releaseLock(); @@ -717,6 +764,7 @@ export function responsesSseToAnthropicSse( cancel(reason) { cancelled = true; while (queuedLiveFrameBytes.length > 0) releaseDeliveredFrame(); + releaseThinkingBuffer(open); if (open?.callId) translatorBudget.closeCall(open.callId); if (pingTimer !== undefined) clearInterval(pingTimer); return reader?.cancel(reason); @@ -756,9 +804,12 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R if (isRec(s) && typeof s.text === "string" && s.text.length > 0) parts.push(s.text); } } - if (parts.length > 0) { - content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: `ocx${Date.now()}` }); + const encrypted = typeof raw.encrypted_content === "string" ? raw.encrypted_content : ""; + const env = encrypted ? decodeReasoningEnvelope(encrypted) : null; + if (parts.length > 0 || env?.sig) { + content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }) }); } + for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); break; } case "function_call": { diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 1735f775fb..2a56563578 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -50,10 +50,11 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve if (red.length > 0) envelope.red = red; } const txt = (parsed as { txt?: unknown }).txt; - if (typeof txt === "string" && txt.length > 0) envelope.txt = 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 || envelope.txt || envelope.krc ? envelope : null; + return envelope.sig || envelope.red || hasTxt || envelope.krc ? envelope : null; } catch { return null; } diff --git a/tests/claude-integration/claude-code-thought-signature-scope.test.ts b/tests/claude-integration/claude-code-thought-signature-scope.test.ts index eb544dce97..b3d981c327 100644 --- a/tests/claude-integration/claude-code-thought-signature-scope.test.ts +++ b/tests/claude-integration/claude-code-thought-signature-scope.test.ts @@ -125,4 +125,16 @@ describe("Claude Code Anthropic inbound reasoning-replay scope", () => { const parsed = await drive({ promptCacheKey: " ", promptCacheKeyIsSharedCohort: false }); expect(parsed._reasoningReplayScope).toBeUndefined(); }); + + test("distinct session identities remain distinct and bounded", async () => { + const first = await drive({ promptCacheKey: "session-a", promptCacheKeyIsSharedCohort: false }); + const second = await drive({ promptCacheKey: "session-b", promptCacheKeyIsSharedCohort: false }); + const a = first._reasoningReplayScope?.clientThreadId; + const b = second._reasoningReplayScope?.clientThreadId; + expect(a).toBeDefined(); + expect(b).toBeDefined(); + expect(a).not.toBe(b); + expect(a).toBe("session-a"); + expect(b).toBe("session-b"); + }); }); diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 32207c9019..7227bbf2f1 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -84,12 +84,12 @@ describe("claude inbound translation", () => { expect(tools[1]).toEqual({ type: "web_search" }); const input = body.input as Record[]; - // user text, assistant text (thinking dropped), function_call, function_call_output, user tail - expect(input.map(i => i.type ?? i.role)).toEqual(["message", "message", "function_call", "function_call_output", "message"]); - expect(input[1].content).toEqual([{ type: "output_text", text: "Reading it now." }]); - expect(input[2]).toMatchObject({ call_id: "toolu_01", name: "Read", arguments: JSON.stringify({ file_path: "/README.md" }) }); - expect(input[3]).toMatchObject({ call_id: "toolu_01", output: [{ type: "input_text", text: "# hello" }] }); - const tail = input[4].content as Record[]; + // user text, reasoning, assistant text, function_call, function_call_output, user tail + expect(input.map(i => i.type ?? i.role)).toEqual(["message", "reasoning", "message", "function_call", "function_call_output", "message"]); + expect(input[2].content).toEqual([{ type: "output_text", text: "Reading it now." }]); + expect(input[3]).toMatchObject({ call_id: "toolu_01", name: "Read", arguments: JSON.stringify({ file_path: "/README.md" }) }); + expect(input[4]).toMatchObject({ call_id: "toolu_01", output: [{ type: "input_text", text: "# hello" }] }); + const tail = input[5].content as Record[]; expect(tail[0]).toEqual({ type: "input_text", text: "now summarize" }); expect(tail[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,aWc=" }); }); diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 299ce5135d..e78cc529d3 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -13,6 +13,7 @@ import { TRANSLATOR_MAX_CALL_ARGUMENT_BYTES, type TranslatorBudget, } from "../../src/lib/translator-budget"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; const streamBudgets = new WeakMap, TranslatorBudget>(); @@ -274,6 +275,8 @@ describe("claude outbound SSE", () => { "**A**\n\nOne.\n\n**B**\n\nTwo.", "Three.", ]); + expect(decodeReasoningEnvelope(thinkingBlocks[0].signature)?.txt) + .toBe("**A**\n\nOne.\n\n**B**\n\nTwo."); // Parity: the non-streaming translator joins the same summary parts identically. const json = responsesJsonToAnthropicMessage({ @@ -286,6 +289,59 @@ describe("claude outbound SSE", () => { expect(jsonThinking.thinking).toBe("**A**\n\nOne.\n\n**B**\n\nTwo."); }); + test("reasoning fallback buffering is bounded and releases its retained budget", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 8 * 1024 }); + let reasoningCommitted = 0; + let reasoningReleased = 0; + const trackedBudget: TranslatorBudget = { + openCall: id => budget.openCall(id), + closeCall: id => budget.closeCall(id), + reserveTransient(bytes, scope) { + const reservation = budget.reserveTransient(bytes, scope); + return { + commitRetained() { + reservation.commitRetained(); + if (scope.kind === "reasoning") reasoningCommitted += bytes; + }, + release: () => reservation.release(), + }; + }, + chargeRetained(bytes, scope) { + budget.chargeRetained(bytes, scope); + if (scope.kind === "reasoning") reasoningCommitted += bytes; + }, + releaseRetained(bytes, scope) { + budget.releaseRetained(bytes, scope); + if (scope.kind === "reasoning") reasoningReleased += bytes; + }, + observeAcceptedRequestCopy: bytes => budget.observeAcceptedRequestCopy(bytes), + observeExternallyCapped: (kind, bytes) => budget.observeExternallyCapped(kind, bytes), + snapshot: () => budget.snapshot(), + dispose: () => budget.dispose(), + }; + const frames = [ + sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), + ...Array.from({ length: 32 }, (_, index) => sse("response.reasoning_text.delta", { + item_id: "rs_1", + content_index: 0, + delta: `${index}:` + "x".repeat(512), + })), + ]; + const events = await collectEvents(responsesSseToAnthropicSse( + streamFromChunks(frames), + "m", + { translatorBudget: trackedBudget }, + )); + + expect(events.at(-1)).toMatchObject({ + name: "error", + data: { error: { type: "request_too_large", code: "translation_buffer_limit" } }, + }); + expect(budget.snapshot().overflows).toBe(1); + expect(reasoningCommitted).toBeGreaterThan(0); + expect(reasoningReleased).toBe(reasoningCommitted); + }); + test("same-part deltas and index-free reasoning frames never get a separator", async () => { const samePart = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), @@ -1109,4 +1165,32 @@ describe("sanitizeWebSearchInput (#381)", () => { data: { error: { type: "request_too_large", code: "translation_buffer_limit" } }, }); }, 60_000); + + test("redacted-only reasoning emits a standalone redacted_thinking block", async () => { + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ + sse("response.output_item.done", { + item: { type: "reasoning", id: "rs_red", encrypted_content: encodeReasoningEnvelope({ red: ["opaque"] }) }, + }), + sse("response.completed", { response: { status: "completed", usage: {} } }), + ].join("")), "m")); + expect(events.map(event => event.name)).toEqual([ + "message_start", "ping", "content_block_start", "content_block_stop", "message_delta", "message_stop", + ]); + expect(events[2].data.content_block).toEqual({ type: "redacted_thinking", data: "opaque" }); + }); + + test("signature-only reasoning emits an empty thinking block with the genuine signature", async () => { + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ + sse("response.output_item.done", { + item: { type: "reasoning", id: "rs_sig", encrypted_content: encodeReasoningEnvelope({ sig: "sig-only" }) }, + }), + sse("response.completed", { response: { status: "completed", usage: {} } }), + ].join("")), "m")); + expect(events.map(event => event.name)).toEqual([ + "message_start", "ping", "content_block_start", "content_block_delta", "content_block_stop", + "message_delta", "message_stop", + ]); + expect(events[2].data.content_block).toEqual({ type: "thinking", thinking: "", signature: "" }); + expect(events[3].data.delta).toEqual({ type: "signature_delta", signature: "sig-only" }); + }); }); diff --git a/tests/claude-integration/claude-source-envelope.test.ts b/tests/claude-integration/claude-source-envelope.test.ts new file mode 100644 index 0000000000..a78c9f1a15 --- /dev/null +++ b/tests/claude-integration/claude-source-envelope.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; + +describe("Claude source envelope boundaries", () => { + test("nested tool results retain only bounded structured content", () => { + const body = anthropicToResponsesBody({ + model: "m", + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "call-1", name: "lookup", input: { q: "x" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call-1", content: [ + { type: "text", text: "ok" }, + { type: "document", title: "report" }, + { type: "future_block", payload: "secret-payload" }, + ] }] }, + ], + }) as any; + expect(body.input.map((item: any) => item.type)).toEqual(["function_call", "function_call_output"]); + expect(body.input[1].output).toEqual([ + { type: "input_text", text: "ok" }, + { type: "input_text", text: "[document: report]" }, + ]); + expect(JSON.stringify(body)).not.toContain("secret-payload"); + }); + + test("malformed tool results fail closed instead of becoming an unpaired output", () => { + expect(() => anthropicToResponsesBody({ + model: "m", messages: [{ role: "user", content: [{ type: "tool_result", content: "secret-payload" }] }], + })).toThrow(/unknown|unpaired|tool/i); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6565f12821..928752d213 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -125,6 +125,7 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", + "claude-source-envelope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", @@ -1020,6 +1021,7 @@ "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", + "reasoning-envelope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", "tool-argument-integers.test.ts": "adapters", diff --git a/tests/responses/reasoning-envelope.test.ts b/tests/responses/reasoning-envelope.test.ts new file mode 100644 index 0000000000..75c51994f2 --- /dev/null +++ b/tests/responses/reasoning-envelope.test.ts @@ -0,0 +1,79 @@ +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"; + +describe("reasoning and tool/result envelopes", () => { + test("preserves ordered thinking blocks and genuine signatures", () => { + const body = anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [ + { type: "thinking", thinking: "first", signature: "sig-first" }, + { type: "tool_use", id: "call-1", name: "Read", input: {} }, + { type: "thinking", thinking: "second", signature: "sig-second" }, + ] }], + }) as any; + expect(body.input.map((item: any) => item.type)).toEqual(["reasoning", "function_call", "reasoning"]); + expect(body.input[0].encrypted_content).toBe(encodeReasoningEnvelope({ sig: "sig-first" })); + expect(body.input[2].encrypted_content).toBe(encodeReasoningEnvelope({ sig: "sig-second" })); + }); + + test("rejects malformed or nested OpenCodex signatures", () => { + for (const signature of [ + "ocxr1:not-base64!!!", + encodeReasoningEnvelope({ sig: "nested" }), + encodeReasoningEnvelope({ sig: "", txt: "nested-empty-signature" }), + ]) { + expect(() => anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "x", signature }] }], + })).toThrow(); + } + }); + + test("round-trips redacted thinking without exposing it as a genuine signature", () => { + const encoded = encodeReasoningEnvelope({ sig: "sig", red: ["red-a", "red-b"] }); + const message = responsesJsonToAnthropicMessage({ + output: [{ type: "reasoning", summary: [{ type: "summary_text", text: "visible" }], encrypted_content: encoded }], + }, "m") as any; + expect(message.content[0]).toMatchObject({ type: "thinking", signature: "sig" }); + expect(message.content.slice(1)).toEqual([ + { type: "redacted_thinking", data: "red-a" }, + { type: "redacted_thinking", data: "red-b" }, + ]); + }); + + test("owned fallback is bounded and decodable", () => { + const message = responsesJsonToAnthropicMessage({ + output: [{ type: "reasoning", summary: [{ type: "summary_text", text: "think" }] }], + }, "m") as any; + const signature = message.content[0].signature as string; + expect(signature.startsWith("ocxr1:")).toBe(true); + expect(decodeReasoningEnvelope(signature)).toEqual({ txt: "think" }); + }); + + test("preserves an explicitly empty fallback text", () => { + expect(decodeReasoningEnvelope(encodeReasoningEnvelope({ txt: "" }))).toEqual({ txt: "" }); + }); + + test("inbound preserves redacted-only reasoning when visible text is empty", () => { + const body = anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "opaque" }] }], + }) as any; + expect(body.input).toHaveLength(1); + expect(body.input[0].type).toBe("reasoning"); + expect(decodeReasoningEnvelope(body.input[0].encrypted_content)?.red).toEqual(["opaque"]); + }); + + test("drops an empty unsigned thinking block", () => { + const body = anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "" }] }], + }) as any; + expect(body.input).toEqual([]); + }); + + test("preserves signature-only reasoning in JSON output", () => { + const message = responsesJsonToAnthropicMessage({ + output: [{ type: "reasoning", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sig-only" }) }], + }, "m") as any; + expect(message.content).toEqual([{ type: "thinking", thinking: "", signature: "sig-only" }]); + }); +});