diff --git a/CHANGELOG.md b/CHANGELOG.md index 0992ce30..a6d05ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`)。 diff --git a/src/translation/codex-to-anthropic.ts b/src/translation/codex-to-anthropic.ts index cc8a1038..9133578d 100644 --- a/src/translation/codex-to-anthropic.ts +++ b/src/translation/codex-to-anthropic.ts @@ -28,6 +28,31 @@ interface ResponseMetadata { functionCallIds?: string[]; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sanitizeToolInput(toolName: string, input: Record): Record { + if (toolName !== "Read") return input; + if (typeof input.pages !== "string" || input.pages.trim() !== "") return input; + + const sanitized: Record = { ...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, @@ -79,7 +104,8 @@ export async function* streamCodexToAnthropic( let textBlockStarted = false; let thinkingBlockStarted = false; const functionCallIds = new Set(); - const callIdsWithDeltas = new Set(); + const callIdsWithForwardedDeltas = new Set(); + const functionCallNames = new Map(); const publishFunctionCallId = (callId: string): void => { if (functionCallIds.has(callId)) return; @@ -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(); @@ -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, @@ -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 @@ -333,7 +373,8 @@ export async function collectCodexToAnthropicResponse( functionCallIds.add(evt.functionCallDone.callId); let parsedInput: Record = {}; try { - parsedInput = JSON.parse(evt.functionCallDone.arguments) as Record; + 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", diff --git a/tests/unit/translation/codex-to-anthropic-read-pages.test.ts b/tests/unit/translation/codex-to-anthropic-read-pages.test.ts new file mode 100644 index 00000000..5e507f9c --- /dev/null +++ b/tests/unit/translation/codex-to-anthropic-read-pages.test.ts @@ -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; + 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> { + 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; + const delta = data.delta; + if (typeof delta !== "object" || delta === null || Array.isArray(delta)) return ""; + const partial = (delta as Record).partial_json; + return typeof partial === "string" ? partial : ""; + }) + .join(""); + + return JSON.parse(partialJson) as Record; +} + +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" }); + }); +});