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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

### Fixed

- Claude Code 的 `Read` 工具参数里如果 `pages` 传成空字符串或空白字符串,会在 Codex → Anthropic 转换时被自动剔除,避免 GPT-5.5 反复触发 `Read tool validation error: Invalid pages parameter: ""` 并重试隔离工作树;对应单测覆盖流式与非流式两条路径,以及非空 PDF 页码范围保留(`src/translation/codex-to-anthropic.ts`、`tests/unit/translation/codex-to-anthropic-read-pages.test.ts`)。
- Dashboard egress log details now include Codex request `reasoning` and optional `service_tier`, so `/admin/logs` can show the actual reasoning effort sent upstream instead of only `model` / `stream` / `useWebSocket` (`src/routes/shared/proxy-egress-log.ts`, `tests/unit/routes/shared/proxy-egress-log.test.ts`).
- implicit-resume 区分"真 missing tool call"与"client 自包含 full replay":`evaluateImplicitResume` 现在新增 `inlineFunctionCallIds` 入参,由 `buildProxySessionContext` 通过 `getInlineFunctionCallIds(codexRequest.input)` 在调用前收集 input 里所有 `function_call` 项的 call_id。当 input 里的 function_call_output 全部能在 input 内找到对应 function_call(典型 Codex CLI `/compact` 或客户端 fallback 自包含 replay 场景),返回 `reason: "self_contained_replay"` 而不是 `missing_tool_calls`,proxy 走正常透传不再触发 payload guard 413。混合场景(部分 inline 部分 storage 都找不到)仍判 `missing_tool_calls` 防真 runaway。新增 4 个 `evaluateImplicitResume`/`getInlineFunctionCallIds`/`isSelfContainedReplay` 单测覆盖纯 inline、混合、空 output、incremental turn 四类(`src/routes/shared/proxy-session-helpers.ts`、`src/routes/shared/proxy-session-context.ts`、`tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts`、`tests/unit/routes/shared/proxy-session-context.test.ts`)。
- 放宽 `/v1/responses` 的 missing-tool-call full-history replay guard 阈值:从 250KB / 80 items 提到 2MB / 1000 items。原阈值会把 Codex CLI `/compact` 之后正常的 client-driven full replay(实测 300-800KB / 100-800 items)误判成 runaway 风暴,连续 413 卡死对话。新阈值仍能挡真正失控的 multi-MB 重试循环,但不再误伤合法 fallback;测试侧把 padding 从 80 提到 1010 items 让 guard 仍能在新阈值下触发(`src/routes/shared/proxy-handler.ts`、`tests/integration/proxy-handler.test.ts`)。
Expand Down
51 changes: 46 additions & 5 deletions src/translation/codex-to-anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@ interface ResponseMetadata {
functionCallIds?: string[];
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function sanitizeToolInput(toolName: string, input: Record<string, unknown>): Record<string, unknown> {
if (toolName !== "Read") return input;
if (typeof input.pages !== "string" || input.pages.trim() !== "") return input;

const sanitized: Record<string, unknown> = { ...input };
delete sanitized.pages;
return sanitized;
}

function sanitizeFunctionCallArguments(toolName: string, argumentsJson: string): string {
try {
const parsed: unknown = JSON.parse(argumentsJson);
if (!isRecord(parsed)) return argumentsJson;

const sanitized = sanitizeToolInput(toolName, parsed);
return sanitized === parsed ? argumentsJson : JSON.stringify(sanitized);
} catch {
return argumentsJson;
}
}

function resolveCacheUsage(
inputTokens: number,
cachedTokens: number | undefined,
Expand Down Expand Up @@ -79,7 +104,8 @@ export async function* streamCodexToAnthropic(
let textBlockStarted = false;
let thinkingBlockStarted = false;
const functionCallIds = new Set<string>();
const callIdsWithDeltas = new Set<string>();
const callIdsWithForwardedDeltas = new Set<string>();
const functionCallNames = new Map<string, string>();

const publishFunctionCallId = (callId: string): void => {
if (functionCallIds.has(callId)) return;
Expand Down Expand Up @@ -172,6 +198,7 @@ export async function* streamCodexToAnthropic(
hasToolCalls = true;
hasContent = true;
publishFunctionCallId(evt.functionCallStart.callId);
functionCallNames.set(evt.functionCallStart.callId, evt.functionCallStart.name);

yield* closeThinkingIfOpen();
yield* closeTextIfOpen();
Expand All @@ -191,7 +218,14 @@ export async function* streamCodexToAnthropic(
}

if (evt.functionCallDelta) {
callIdsWithDeltas.add(evt.functionCallDelta.callId);
// Drop Read deltas and buffer until functionCallDone, where the full
// arguments can be sanitized atomically (e.g. stripping empty `pages`).
// Partial JSON cannot be safely rewritten mid-stream.
if (functionCallNames.get(evt.functionCallDelta.callId) === "Read") {
continue;
}

callIdsWithForwardedDeltas.add(evt.functionCallDelta.callId);
yield formatSSE("content_block_delta", {
type: "content_block_delta",
index: contentIndex,
Expand All @@ -203,11 +237,17 @@ export async function* streamCodexToAnthropic(
if (evt.functionCallDone) {
publishFunctionCallId(evt.functionCallDone.callId);
// Emit full arguments if no deltas were streamed
if (!callIdsWithDeltas.has(evt.functionCallDone.callId)) {
if (!callIdsWithForwardedDeltas.has(evt.functionCallDone.callId)) {
yield formatSSE("content_block_delta", {
type: "content_block_delta",
index: contentIndex,
delta: { type: "input_json_delta", partial_json: evt.functionCallDone.arguments },
delta: {
type: "input_json_delta",
partial_json: sanitizeFunctionCallArguments(
evt.functionCallDone.name,
evt.functionCallDone.arguments,
),
},
});
}
// Close this tool_use block
Expand Down Expand Up @@ -333,7 +373,8 @@ export async function collectCodexToAnthropicResponse(
functionCallIds.add(evt.functionCallDone.callId);
let parsedInput: Record<string, unknown> = {};
try {
parsedInput = JSON.parse(evt.functionCallDone.arguments) as Record<string, unknown>;
const parsed: unknown = JSON.parse(evt.functionCallDone.arguments);
parsedInput = isRecord(parsed) ? sanitizeToolInput(evt.functionCallDone.name, parsed) : {};
} catch { /* use empty object */ }
toolUseBlocks.push({
type: "tool_use",
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/translation/codex-to-anthropic-read-pages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import type { ExtractedEvent } from "@src/translation/codex-event-extractor.js";
import {
createCompleted,
createCreated,
createFunctionCallDelta,
createFunctionCallDone,
createFunctionCallStart,
createInProgress,
} from "@helpers/events.js";

let mockEvents: ExtractedEvent[] = [];

vi.mock("@src/translation/codex-event-extractor.js", async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
iterateCodexEvents: vi.fn(async function* () {
for (const evt of mockEvents) {
yield evt;
}
}),
};
});

import { collectCodexToAnthropicResponse, streamCodexToAnthropic } from "@src/translation/codex-to-anthropic.js";
import type { CodexApi } from "@src/proxy/codex-api.js";

const fakeCodexApi = {} as CodexApi;
const fakeResponse = new Response(null);

function readToolStream(args: string, deltas: string[] = []): ExtractedEvent[] {
return [
createCreated("resp_read"),
createInProgress("resp_read"),
createFunctionCallStart("call_read", "Read"),
...deltas.map((delta) => createFunctionCallDelta("call_read", delta)),
createFunctionCallDone("call_read", "Read", args),
createCompleted("resp_read", { input_tokens: 10, output_tokens: 5 }),
];
}

async function collectStreamInput(events: ExtractedEvent[]): Promise<Record<string, unknown>> {
mockEvents = events;
const chunks: string[] = [];
for await (const chunk of streamCodexToAnthropic(fakeCodexApi, fakeResponse, "gpt-5.5")) {
chunks.push(chunk);
}

const partialJson = chunks
.map((chunk) => {
const dataLine = chunk.trim().split("\n").find((line) => line.startsWith("data: "));
if (!dataLine) return "";
const data = JSON.parse(dataLine.slice(6)) as Record<string, unknown>;
const delta = data.delta;
if (typeof delta !== "object" || delta === null || Array.isArray(delta)) return "";
const partial = (delta as Record<string, unknown>).partial_json;
return typeof partial === "string" ? partial : "";
})
.join("");

return JSON.parse(partialJson) as Record<string, unknown>;
}

describe("Codex to Anthropic Read.pages sanitization", () => {
it("omits empty Read.pages from streamed tool arguments", async () => {
const input = await collectStreamInput(readToolStream(
'{"file_path":"package.json","pages":""}',
));

expect(input).toEqual({ file_path: "package.json" });
});

it("omits empty Read.pages when arguments arrive as streaming deltas", async () => {
const input = await collectStreamInput(readToolStream(
'{"file_path":"package.json","pages":""}',
['{"file_path":"package.json"', ',"pages":""}'],
));

expect(input).toEqual({ file_path: "package.json" });
});

it("omits empty Read.pages from collected tool input", async () => {
mockEvents = readToolStream('{"file_path":"package.json","pages":" "}');

const { response } = await collectCodexToAnthropicResponse(fakeCodexApi, fakeResponse, "gpt-5.5");
const toolBlock = response.content.find((block) => block.type === "tool_use");

expect(toolBlock?.input).toEqual({ file_path: "package.json" });
});

it("keeps non-empty Read.pages ranges", async () => {
mockEvents = readToolStream('{"file_path":"manual.pdf","pages":"1-2"}');

const { response } = await collectCodexToAnthropicResponse(fakeCodexApi, fakeResponse, "gpt-5.5");
const toolBlock = response.content.find((block) => block.type === "tool_use");

expect(toolBlock?.input).toEqual({ file_path: "manual.pdf", pages: "1-2" });
});
});
Loading