diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js index b681e3b4..6a46551d 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.js @@ -4,21 +4,40 @@ import { FORMATS } from "../formats.js"; // Prefix for Claude OAuth tool names (must match request translator) const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_"; -// Strip optional empty-string tool arguments that some providers emit. -// Claude Code's Read tool rejects pages: "" but accepts pages being absent. -function sanitizeToolArguments(toolName, argsJson) { +// Sanitize tool call arguments to fix bad params from non-Anthropic models +function sanitizeToolArgs(toolName, argsJson) { try { const args = JSON.parse(argsJson); - if (typeof args === "object" && args !== null) { - if (toolName === "Read" && args.pages === "") { - delete args.pages; - } - return JSON.stringify(args); - } + const name = toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX) + ? toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length) + : toolName; + if (name === "Read") sanitizeReadArgs(args); + return JSON.stringify(args); } catch { - // Not valid JSON yet (streaming chunk) — return as-is + return argsJson; } - return argsJson; +} + +function sanitizeReadArgs(args) { + if (typeof args.limit === "string" && /^\d+$/.test(args.limit)) args.limit = Number(args.limit); + if (typeof args.offset === "string" && /^-?\d+$/.test(args.offset)) args.offset = Number(args.offset); + + if (typeof args.limit === "number") { + if (args.limit > 2000) args.limit = 2000; + if (args.limit < 1) delete args.limit; + } + if (typeof args.offset === "number" && args.offset < 0) args.offset = 0; + + if ("pages" in args && !isValidPdfPagesArg(args.file_path, args.pages)) { + delete args.pages; + } +} + +function isValidPdfPagesArg(filePath, pages) { + return typeof filePath === "string" && + filePath.toLowerCase().endsWith(".pdf") && + typeof pages === "string" && + /^\d+(?:-\d+)?$/.test(pages); } // Helper: stop thinking block if started @@ -54,32 +73,32 @@ export function openaiToClaudeResponse(chunk, state) { if (chunk.usage && typeof chunk.usage === "object") { const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0; const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0; - + // Extract cache tokens from prompt_tokens_details const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens; const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens; const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0; const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0; - + // input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens // Because OpenAI's prompt_tokens includes all prompt-side tokens const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens; - + state.usage = { input_tokens: inputTokens, output_tokens: outputTokens }; - + // Add cache_read_input_tokens if present if (cacheReadTokens > 0) { state.usage.cache_read_input_tokens = cacheReadTokens; } - + // Add cache_creation_input_tokens if present if (cacheCreateTokens > 0) { state.usage.cache_creation_input_tokens = cacheCreateTokens; } - + // Note: completion_tokens_details.reasoning_tokens is already included in output_tokens // No need to add separately as Claude expects total output_tokens } @@ -165,13 +184,13 @@ export function openaiToClaudeResponse(chunk, state) { const toolBlockIndex = state.nextBlockIndex++; state.toolCalls.set(idx, { id: tc.id, name: tc.function?.name || "", blockIndex: toolBlockIndex }); - + // Strip prefix from tool name for response let toolName = tc.function?.name || ""; if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); } - + results.push({ type: "content_block_start", index: toolBlockIndex, @@ -187,12 +206,9 @@ export function openaiToClaudeResponse(chunk, state) { if (tc.function?.arguments) { const toolInfo = state.toolCalls.get(idx); if (toolInfo) { - const sanitized = sanitizeToolArguments(toolInfo.name, tc.function.arguments); - results.push({ - type: "content_block_delta", - index: toolInfo.blockIndex, - delta: { type: "input_json_delta", partial_json: sanitized } - }); + // Buffer args instead of streaming — sanitize at finish to fix bad params + if (!state.toolArgBuffers) state.toolArgBuffers = new Map(); + state.toolArgBuffers.set(idx, (state.toolArgBuffers.get(idx) || "") + tc.function.arguments); } } } @@ -203,7 +219,17 @@ export function openaiToClaudeResponse(chunk, state) { stopThinkingBlock(state, results); stopTextBlock(state, results); - for (const [, toolInfo] of state.toolCalls) { + for (const [idx, toolInfo] of state.toolCalls) { + // Emit buffered + sanitized args as single delta before stop + const buffered = state.toolArgBuffers?.get(idx); + if (buffered) { + const sanitized = sanitizeToolArgs(toolInfo.name, buffered); + results.push({ + type: "content_block_delta", + index: toolInfo.blockIndex, + delta: { type: "input_json_delta", partial_json: sanitized } + }); + } results.push({ type: "content_block_stop", index: toolInfo.blockIndex @@ -212,7 +238,7 @@ export function openaiToClaudeResponse(chunk, state) { // Mark finish for later usage injection in stream.js state.finishReason = choice.finish_reason; - + // Use tracked usage (will be estimated in stream.js if not valid) const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 }; results.push({ @@ -238,4 +264,3 @@ function convertFinishReason(reason) { // Register register(FORMATS.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse); - diff --git a/tests/unit/openai-to-claude-response-tools.test.js b/tests/unit/openai-to-claude-response-tools.test.js new file mode 100644 index 00000000..51d5a0f8 --- /dev/null +++ b/tests/unit/openai-to-claude-response-tools.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.js"; + +function createState() { + return { toolCalls: new Map(), nextBlockIndex: 0 }; +} + +function getInputJsonDelta(events) { + return events.find((event) => event.type === "content_block_delta" && event.delta?.type === "input_json_delta")?.delta.partial_json; +} + +describe("openaiToClaudeResponse tool argument sanitization", () => { + it("drops invalid Read pages and clamps numeric bounds", () => { + const state = createState(); + + openaiToClaudeResponse({ + id: "chatcmpl-test-read", + model: "test-model", + choices: [{ delta: { tool_calls: [{ index: 0, id: "toolu_read", function: { name: "Read" } }] } }], + }, state); + + const events = openaiToClaudeResponse({ + id: "chatcmpl-test-read", + model: "test-model", + choices: [{ + delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ file_path: "F:/repo/file.js", offset: -5, limit: 999999999, pages: "" }) } }] }, + finish_reason: "tool_calls", + }], + }, state); + + expect(JSON.parse(getInputJsonDelta(events))).toEqual({ + file_path: "F:/repo/file.js", + offset: 0, + limit: 2000, + }); + }); + + it("keeps valid PDF pages", () => { + const state = createState(); + + openaiToClaudeResponse({ + id: "chatcmpl-test-pdf", + model: "test-model", + choices: [{ delta: { tool_calls: [{ index: 0, id: "toolu_pdf", function: { name: "proxy_Read" } }] } }], + }, state); + + const events = openaiToClaudeResponse({ + id: "chatcmpl-test-pdf", + model: "test-model", + choices: [{ + delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ file_path: "F:/repo/doc.pdf", pages: "1-3" }) } }] }, + finish_reason: "tool_calls", + }], + }, state); + + expect(JSON.parse(getInputJsonDelta(events))).toEqual({ + file_path: "F:/repo/doc.pdf", + pages: "1-3", + }); + }); +});