Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/ja/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ WebSocket が無効になっている場合、アップグレード試行では

これらのエンドポイントは、Claude Code および互換性のあるクライアントによって使用される Anthropic Messages 言語を話します。ほとんどのリクエストはレスポンスに変換され、通常どおりルーティングされてから、Anthropic JSON または Anthropic SSE に変換されます。

変換される Messages リクエストでは、推論の再送もリクエスト共通の変換バジェットを使います。
この制限にはエンコード・デコード時のコピー分も含まれます。超過時は
`translation_buffer_limit` を伴う HTTP 413 を返し、署名や不透明な推論データを切り詰めません。
ネイティブ Anthropic パススルーには、別の本文サイズ制限が適用されます。

ネイティブ Anthropic パススルーは、次のすべてが当てはまる場合にのみ適格です。

- ネイティブ パススルーはクロード コード設定で無効になっていません。
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/ko/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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가 비활성화되어 있지 않습니다.
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
lidge-jun marked this conversation as resolved.

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
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ru/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions docs-site/src/content/docs/zh-cn/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 配置中尚未禁用原生透传;
Expand Down
64 changes: 40 additions & 24 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 21 additions & 6 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";



Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -240,19 +241,24 @@ 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;
}
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:
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down
Loading
Loading