From 0ab3a5839f16acb1216a002f110727fef81fa6c0 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Wed, 21 Oct 2026 09:46:57 +0800 Subject: [PATCH] fix(translation): report truncation from response.incomplete as max_tokens/refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit response.incomplete is a terminal event, but the Anthropic translator never handled it: usage was dropped, the stream closed via the generic path, and a truncated answer was reported as a clean end_turn — the client believes the model finished on its own. - extract incomplete_details.reason from response.incomplete (official values: max_output_tokens, content_filter) - map it via a dedicated pure helper (incompleteReasonToAnthropicStopReason): max_output_tokens -> max_tokens, content_filter -> refusal - apply in both streaming (final message_delta) and non-streaming (response body) paths; truncation wins over the tool_use/end_turn priority - add the same empty-stream guard as response.completed - accept refusal in AnthropicMessagesResponse stop_reason type - unit tests for the pure mapping (5 cases) --- src/translation/codex-event-extractor.ts | 5 ++ src/translation/codex-to-anthropic.ts | 69 +++++++++++++++---- src/translation/incomplete-stop-reason.ts | 27 ++++++++ src/types/anthropic.ts | 4 +- src/types/codex-events.ts | 7 ++ .../incomplete-stop-reason.test.ts | 34 +++++++++ 6 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 src/translation/incomplete-stop-reason.ts create mode 100644 tests/unit/translation/incomplete-stop-reason.test.ts diff --git a/src/translation/codex-event-extractor.ts b/src/translation/codex-event-extractor.ts index 1b4cae9f2..3ba0da4e5 100644 --- a/src/translation/codex-event-extractor.ts +++ b/src/translation/codex-event-extractor.ts @@ -109,6 +109,8 @@ export interface ExtractedEvent { reasoningDelta?: string; usage?: UsageInfo; error?: { code: string; message: string }; + /** Truncation/filter reason from response.incomplete (e.g. "max_output_tokens"). */ + incompleteReason?: string; functionCallStart?: FunctionCallStart; functionCallDelta?: FunctionCallDelta; functionCallDone?: FunctionCallDone; @@ -271,6 +273,9 @@ export async function* iterateCodexEvents( // Response was truncated/incomplete if (typed.response.id) extracted.responseId = typed.response.id; if (typed.response.usage) extracted.usage = typed.response.usage; + if (typed.response.incomplete_details?.reason) { + extracted.incompleteReason = typed.response.incomplete_details.reason; + } break; case "response.queued": diff --git a/src/translation/codex-to-anthropic.ts b/src/translation/codex-to-anthropic.ts index 563ce20fe..fcadd97e5 100644 --- a/src/translation/codex-to-anthropic.ts +++ b/src/translation/codex-to-anthropic.ts @@ -18,6 +18,7 @@ import type { AnthropicUsage, } from "../types/anthropic.js"; import { iterateCodexEvents, EmptyResponseError, type UsageInfo } from "./codex-event-extractor.js"; +import { incompleteReasonToAnthropicStopReason } from "./incomplete-stop-reason.js"; import { isRecord } from "./shared-utils.js"; import { codexApiErrorFromEvent } from "./codex-api-error-from-event.js"; @@ -97,6 +98,8 @@ export async function* streamCodexToAnthropic( let cachedTokens: number | undefined; let hasToolCalls = false; let hasContent = false; + /** Set when the stream ends with response.incomplete — drives the real stop_reason. */ + let incompleteReason: string | undefined; let contentIndex = 0; let textBlockStarted = false; let thinkingBlockStarted = false; @@ -148,6 +151,21 @@ export async function* streamCodexToAnthropic( } } + // Shared terminal-usage capture: both response.completed and + // response.incomplete carry the final usage object. + const captureUsage = (usage: UsageInfo): void => { + inputTokens = usage.input_tokens; + outputTokens = usage.output_tokens; + const adjusted = resolveCacheUsage(inputTokens, usage.cached_tokens, usageHint); + cachedTokens = adjusted.cacheReadTokens || undefined; + onUsage?.({ + input_tokens: inputTokens, + output_tokens: outputTokens, + cached_tokens: cachedTokens, + reasoning_tokens: usage.reasoning_tokens, + }); + }; + // 1. message_start yield formatSSE("message_start", { type: "message_start", @@ -341,16 +359,7 @@ export async function* streamCodexToAnthropic( case "response.completed": { if (evt.usage) { - inputTokens = evt.usage.input_tokens; - outputTokens = evt.usage.output_tokens; - const adjusted = resolveCacheUsage(inputTokens, evt.usage.cached_tokens, usageHint); - cachedTokens = adjusted.cacheReadTokens || undefined; - onUsage?.({ - input_tokens: inputTokens, - output_tokens: outputTokens, - cached_tokens: cachedTokens, - reasoning_tokens: evt.usage.reasoning_tokens, - }); + captureUsage(evt.usage); } onResponseCompleted?.(evt.responseId); // Inject error text if stream completed with no content @@ -364,6 +373,29 @@ export async function* streamCodexToAnthropic( } break; } + + case "response.incomplete": { + // response.incomplete is a terminal event too. Before this case + // existed it fell through the switch: usage was dropped, the stream + // ended via the generic close path, and a truncated answer was + // reported as a normal "end_turn" in the closing message_delta. + if (evt.usage) { + captureUsage(evt.usage); + } + onResponseCompleted?.(evt.responseId); + incompleteReason = evt.incompleteReason; + // Same empty-stream guard as response.completed: a truncated-but- + // empty stream still deserves an explicit hint instead of silence. + if (!hasContent) { + yield* ensureTextBlock(); + yield formatSSE("content_block_delta", { + type: "content_block_delta", + index: contentIndex, + delta: { type: "text_delta", text: "[Error] Codex returned an empty response. Please retry." }, + }); + } + break; + } } } @@ -375,9 +407,15 @@ export async function* streamCodexToAnthropic( // Anthropic API: input_tokens = uncached only, cache_read_input_tokens = cached // cacheCreationTokens here = inputTokens - cacheReadTokens = uncached portion const { cacheReadTokens, cacheCreationTokens } = resolveCacheUsage(inputTokens, cachedTokens, usageHint); + // Truncation/filter wins over the tool_use/end_turn priority: a response cut + // by max_output_tokens must not be reported as a clean end_turn, and + // content_filter maps to Anthropic's refusal stop_reason. + const stopReason = + incompleteReasonToAnthropicStopReason(incompleteReason) ?? + (hasToolCalls ? "tool_use" : "end_turn"); yield formatSSE("message_delta", { type: "message_delta", - delta: { stop_reason: hasToolCalls ? "tool_use" : "end_turn" }, + delta: { stop_reason: stopReason }, usage: { input_tokens: cacheCreationTokens, output_tokens: outputTokens, @@ -414,6 +452,8 @@ export async function collectCodexToAnthropicResponse( let outputTokens = 0; let cachedTokens: number | undefined; let responseId: string | null = null; + /** Set when the stream ends with response.incomplete — drives the real stop_reason. */ + let incompleteReason: string | undefined; const functionCallIds = new Set(); // Collect tool calls @@ -426,6 +466,7 @@ export async function collectCodexToAnthropicResponse( } if (evt.textDelta) fullText += evt.textDelta; if (evt.reasoningDelta) fullReasoning += evt.reasoningDelta; + if (evt.incompleteReason) incompleteReason = evt.incompleteReason; if (evt.usage) { inputTokens = evt.usage.input_tokens; outputTokens = evt.usage.output_tokens; @@ -500,7 +541,11 @@ export async function collectCodexToAnthropicResponse( role: "assistant", content, model, - stop_reason: hasToolCalls ? "tool_use" : "end_turn", + // Truncation/filter wins over tool_use/end_turn (mirrors the streaming + // path): a max_output_tokens cut must not be reported as a clean end_turn. + stop_reason: + incompleteReasonToAnthropicStopReason(incompleteReason) ?? + (hasToolCalls ? "tool_use" : "end_turn"), stop_sequence: null, usage, }, diff --git a/src/translation/incomplete-stop-reason.ts b/src/translation/incomplete-stop-reason.ts new file mode 100644 index 000000000..d01bb740f --- /dev/null +++ b/src/translation/incomplete-stop-reason.ts @@ -0,0 +1,27 @@ +/** + * Map a Codex `response.incomplete` reason to the Anthropic stop_reason. + * + * The Codex Responses API reports truncation via + * `response.incomplete_details.reason` (official values: "max_output_tokens", + * "content_filter"). Anthropic's Messages API uses its own stop_reason enum — + * passing the raw Codex reason through would produce an invalid value, while + * ignoring the event entirely reports a truncated answer as a normal + * "end_turn" and the client believes the model finished on its own. + * + * Returns null when the reason has no dedicated Anthropic equivalent (or is + * missing) — callers then keep their existing tool_use/end_turn priority. + */ +export type IncompleteStopReason = "max_tokens" | "refusal"; + +export function incompleteReasonToAnthropicStopReason( + reason: string | undefined | null, +): IncompleteStopReason | null { + switch (typeof reason === "string" ? reason.trim().toLowerCase() : "") { + case "max_output_tokens": + return "max_tokens"; + case "content_filter": + return "refusal"; + default: + return null; + } +} diff --git a/src/types/anthropic.ts b/src/types/anthropic.ts index 71546e02c..015fb1a02 100644 --- a/src/types/anthropic.ts +++ b/src/types/anthropic.ts @@ -206,7 +206,9 @@ export interface AnthropicMessagesResponse { role: "assistant"; content: AnthropicContentBlock[]; model: string; - stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null; + // "refusal" is a legal Anthropic stop_reason (API value) used when a Codex + // response.incomplete carries reason "content_filter". + stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "refusal" | null; stop_sequence: string | null; usage: AnthropicUsage; } diff --git a/src/types/codex-events.ts b/src/types/codex-events.ts index 56c7eac38..3f3a8b92e 100644 --- a/src/types/codex-events.ts +++ b/src/types/codex-events.ts @@ -11,6 +11,8 @@ import type { CodexSSEEvent } from "../proxy/codex-api.js"; export interface CodexResponseData { id?: string; + /** Truncation/filter reason from response.incomplete (official values: "max_output_tokens", "content_filter"). */ + incomplete_details?: { reason?: string }; usage?: { input_tokens: number; output_tokens: number; @@ -287,6 +289,11 @@ function parseResponseData(data: unknown): CodexResponseData | undefined { if (!isRecord(resp)) return undefined; const result: CodexResponseData = {}; if (typeof resp.id === "string") result.id = resp.id; + // Truncation reason rides on response.incomplete's response object. + const incompleteDetails = isRecord(resp.incomplete_details) ? resp.incomplete_details : undefined; + if (incompleteDetails && typeof incompleteDetails.reason === "string") { + result.incomplete_details = { reason: incompleteDetails.reason }; + } if (isRecord(resp.usage)) { result.usage = { input_tokens: typeof resp.usage.input_tokens === "number" ? resp.usage.input_tokens : 0, diff --git a/tests/unit/translation/incomplete-stop-reason.test.ts b/tests/unit/translation/incomplete-stop-reason.test.ts new file mode 100644 index 000000000..5ff2f0048 --- /dev/null +++ b/tests/unit/translation/incomplete-stop-reason.test.ts @@ -0,0 +1,34 @@ +/** + * incompleteReasonToAnthropicStopReason — pure mapping tests. + * + * The Codex Responses API reports truncation via + * `response.incomplete_details.reason`; the Anthropic Messages API has its + * own stop_reason enum. See src/translation/incomplete-stop-reason.ts. + */ +import { describe, it, expect } from "vitest"; +import { incompleteReasonToAnthropicStopReason } from "../../../src/translation/incomplete-stop-reason.js"; + +describe("incompleteReasonToAnthropicStopReason", () => { + it("maps max_output_tokens to max_tokens", () => { + expect(incompleteReasonToAnthropicStopReason("max_output_tokens")).toBe("max_tokens"); + }); + + it("maps content_filter to refusal", () => { + expect(incompleteReasonToAnthropicStopReason("content_filter")).toBe("refusal"); + }); + + it("is case-insensitive and trims whitespace", () => { + expect(incompleteReasonToAnthropicStopReason(" Max_Output_Tokens ")).toBe("max_tokens"); + expect(incompleteReasonToAnthropicStopReason("CONTENT_FILTER")).toBe("refusal"); + }); + + it("returns null for unknown reasons (callers keep tool_use/end_turn priority)", () => { + expect(incompleteReasonToAnthropicStopReason("context_window_exceeded")).toBeNull(); + expect(incompleteReasonToAnthropicStopReason("")).toBeNull(); + }); + + it("returns null for missing reasons", () => { + expect(incompleteReasonToAnthropicStopReason(undefined)).toBeNull(); + expect(incompleteReasonToAnthropicStopReason(null)).toBeNull(); + }); +});