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 src/translation/codex-event-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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":
Expand Down
69 changes: 57 additions & 12 deletions src/translation/codex-to-anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
}

Expand All @@ -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,
Expand Down Expand Up @@ -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<string>();

// Collect tool calls
Expand All @@ -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;
Expand Down Expand Up @@ -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,
},
Expand Down
27 changes: 27 additions & 0 deletions src/translation/incomplete-stop-reason.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 3 additions & 1 deletion src/types/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions src/types/codex-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/translation/incomplete-stop-reason.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading