fix: strip empty Read pages argument in OpenAI-to-Claude translator (#1354)
Some OpenAI-compatible providers emit optional string tool parameters as empty strings (e.g. pages: "") instead of omitting them. Claude Code rejects pages: "" as invalid, breaking the Read tool for non-PDF files routed through 9router. Add sanitizeToolArguments() that parses tool-call arguments and removes known optional empty-string fields before emitting input_json_delta back to Claude format. Currently handles the Read tool pages field specifically. Includes regression test. Fixes #1278 Co-authored-by: JoJo <noreply@github.com>
This commit is contained in:
@@ -4,6 +4,23 @@ 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) {
|
||||
try {
|
||||
const args = JSON.parse(argsJson);
|
||||
if (typeof args === "object" && args !== null) {
|
||||
if (toolName === "Read" && args.pages === "") {
|
||||
delete args.pages;
|
||||
}
|
||||
return JSON.stringify(args);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON yet (streaming chunk) — return as-is
|
||||
}
|
||||
return argsJson;
|
||||
}
|
||||
|
||||
// Helper: stop thinking block if started
|
||||
function stopThinkingBlock(state, results) {
|
||||
if (!state.thinkingBlockStarted) return;
|
||||
@@ -170,10 +187,11 @@ 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: tc.function.arguments }
|
||||
delta: { type: "input_json_delta", partial_json: sanitized }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
|
||||
import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.js";
|
||||
|
||||
describe("openaiToClaudeRequest", () => {
|
||||
describe("response_format handling", () => {
|
||||
@@ -121,4 +122,41 @@ describe("openaiToClaudeRequest", () => {
|
||||
expect(systemText).toContain("You must respond with valid JSON");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openaiToClaudeResponse", () => {
|
||||
it("omits empty Read pages tool argument before emitting Claude input deltas", () => {
|
||||
const state = { toolCalls: new Map() };
|
||||
const chunk = {
|
||||
id: "chatcmpl-test",
|
||||
model: "gpt-test",
|
||||
choices: [{
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: "call_read",
|
||||
function: {
|
||||
name: "Read",
|
||||
arguments: JSON.stringify({
|
||||
file_path: "/tmp/example.txt",
|
||||
offset: 0,
|
||||
limit: 120,
|
||||
pages: ""
|
||||
})
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
const result = openaiToClaudeResponse(chunk, state);
|
||||
const inputDelta = result.find(event => event.delta?.type === "input_json_delta");
|
||||
|
||||
expect(inputDelta).toBeDefined();
|
||||
expect(JSON.parse(inputDelta.delta.partial_json)).toEqual({
|
||||
file_path: "/tmp/example.txt",
|
||||
offset: 0,
|
||||
limit: 120
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user