mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge PR 2937 into release/v3.8.8
This commit is contained in:
@@ -28,6 +28,17 @@ function coerceToArray(v: unknown): unknown[] {
|
||||
}
|
||||
|
||||
const TOOL_SHIMS: Record<string, ShimFn> = {
|
||||
// Claude Code Read accepts `pages` only for PDFs and rejects an empty string.
|
||||
// Some non-Anthropic models emit optional `pages: ""` for ordinary files.
|
||||
// Buffer and emit one cleaned JSON delta so the client never sees the bad field.
|
||||
Read: (input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return input;
|
||||
const patched = { ...(input as Record<string, unknown>) };
|
||||
if (patched.pages === "" || (Array.isArray(patched.pages) && patched.pages.length === 0)) {
|
||||
delete patched.pages;
|
||||
}
|
||||
return patched;
|
||||
},
|
||||
submit_pr_review: (input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return input;
|
||||
const patched = { ...(input as Record<string, unknown>) };
|
||||
|
||||
@@ -9,6 +9,34 @@ function normalizeToolName(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function stripEmptyOptionalToolArgs(value, toolName) {
|
||||
if (value == null) return value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
// JSON-string cleanup is intentionally scoped to Claude Code's Read tool.
|
||||
// For arbitrary tools, empty strings/arrays may be valid user payloads.
|
||||
if (toolName !== "Read") return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) return value;
|
||||
const cleaned = stripEmptyOptionalToolArgs(parsed, toolName);
|
||||
return JSON.stringify(cleaned ?? {});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value) || typeof value !== "object") return value;
|
||||
|
||||
const cleaned = { ...value };
|
||||
for (const [key, entry] of Object.entries(cleaned)) {
|
||||
if (entry === "" || (Array.isArray(entry) && entry.length === 0)) {
|
||||
delete cleaned[key];
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate OpenAI chunk to Responses API events
|
||||
* @returns {Array} Array of events with { event, data } structure
|
||||
@@ -631,15 +659,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
|
||||
state.toolCallIndex++;
|
||||
|
||||
let argsToEmit = item.arguments;
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
// Fix #1674 & #1852: Strip empty string and array placeholders emitted by GPT-5.5 for optional fields
|
||||
const cleaned = { ...argsToEmit };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
|
||||
}
|
||||
argsToEmit = cleaned;
|
||||
}
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName);
|
||||
|
||||
const argsStr =
|
||||
argsToEmit != null
|
||||
@@ -681,14 +701,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
|
||||
// Only emit if arguments exist in the done event AND they weren't already streamed via deltas
|
||||
if (item.arguments != null && !buffered) {
|
||||
let argsToEmit = item.arguments;
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
const cleaned = { ...argsToEmit };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
if (v === "" || (Array.isArray(v) && v.length === 0)) delete cleaned[k];
|
||||
}
|
||||
argsToEmit = cleaned;
|
||||
}
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(item.arguments, toolName);
|
||||
|
||||
const argsStr = typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
|
||||
if (argsStr) {
|
||||
|
||||
@@ -242,6 +242,99 @@ test("Responses -> OpenAI: empty-name tool call is deferred until output_item.do
|
||||
);
|
||||
});
|
||||
|
||||
test("Responses -> OpenAI: preserves non-Read JSON-string tool arguments", () => {
|
||||
const state = {};
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_note", name: "save_note" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_note",
|
||||
name: "save_note",
|
||||
arguments: '{"text":"","tags":[]}',
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"text":"","tags":[]}');
|
||||
});
|
||||
|
||||
test("Responses -> OpenAI: preserves falsy JSON-string tool arguments while cleaning", () => {
|
||||
const state = {};
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_flag", name: "set_flag" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_flag", name: "set_flag", arguments: "false" },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, "false");
|
||||
});
|
||||
|
||||
test("Responses -> OpenAI: preserves non-object Read JSON-string arguments", () => {
|
||||
const state = {};
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_read", name: "Read" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_read", name: "Read", arguments: "null" },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, "null");
|
||||
});
|
||||
|
||||
test("Responses -> OpenAI: strips empty optional args from JSON-string output_item.done arguments", () => {
|
||||
const state = {};
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_read", name: "Read" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_read",
|
||||
name: "Read",
|
||||
arguments: '{"file_path":"/etc/hosts","offset":1,"limit":5,"pages":"","empty":[]}',
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
done.choices[0].delta.tool_calls[0].function.arguments,
|
||||
JSON.stringify({ file_path: "/etc/hosts", offset: 1, limit: 5 })
|
||||
);
|
||||
});
|
||||
|
||||
test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage are normalized", () => {
|
||||
const state = {};
|
||||
const added = openaiResponsesToOpenAIResponse(
|
||||
|
||||
@@ -12,7 +12,8 @@ const { coerceToArray } = __test as { coerceToArray: (v: unknown) => unknown[] }
|
||||
|
||||
// -------- Helper-level tests --------
|
||||
|
||||
test("hasToolCallShim: returns true for submit_pr_review only", () => {
|
||||
test("hasToolCallShim: returns true for registered shims", () => {
|
||||
assert.equal(hasToolCallShim("Read"), true);
|
||||
assert.equal(hasToolCallShim("submit_pr_review"), true);
|
||||
assert.equal(hasToolCallShim("some_other_tool"), false);
|
||||
assert.equal(hasToolCallShim(""), false);
|
||||
@@ -54,6 +55,26 @@ test("coerceToArray: stringified non-array -> []", () => {
|
||||
assert.deepEqual(coerceToArray('"a string"'), []);
|
||||
});
|
||||
|
||||
test("applyToolCallShimToBuffer: Read removes empty pages but preserves valid ranges", () => {
|
||||
const withEmptyPages = JSON.parse(
|
||||
applyToolCallShimToBuffer(
|
||||
"Read",
|
||||
JSON.stringify({ file_path: "/etc/hosts", offset: 1, limit: 5, pages: "" })
|
||||
)
|
||||
);
|
||||
assert.deepEqual(withEmptyPages, { file_path: "/etc/hosts", offset: 1, limit: 5 });
|
||||
|
||||
const withEmptyArrayPages = JSON.parse(
|
||||
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/tmp/a.pdf", pages: [] }))
|
||||
);
|
||||
assert.deepEqual(withEmptyArrayPages, { file_path: "/tmp/a.pdf" });
|
||||
|
||||
const withValidPages = JSON.parse(
|
||||
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/tmp/a.pdf", pages: "1-5" }))
|
||||
);
|
||||
assert.deepEqual(withValidPages, { file_path: "/tmp/a.pdf", pages: "1-5" });
|
||||
});
|
||||
|
||||
test("applyToolCallShimToBuffer: submit_pr_review with valid arrays preserved", () => {
|
||||
const raw = JSON.stringify({
|
||||
summary: "ok",
|
||||
@@ -147,6 +168,59 @@ function streamChunks(chunks: any[], state: any): any[] {
|
||||
return all;
|
||||
}
|
||||
|
||||
test("streaming: Read suppresses raw pages delta and emits cleaned input at finish", () => {
|
||||
const state = freshState();
|
||||
const chunks = [
|
||||
{
|
||||
id: "chatcmpl-read",
|
||||
model: "codex/gpt-5.5-high",
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_read",
|
||||
function: { name: "Read", arguments: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: '{"file_path":"/etc/hosts","offset":1,"limit":5,"pages":""}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
|
||||
];
|
||||
|
||||
const events = streamChunks(chunks, state);
|
||||
const inputDeltas = events.filter(
|
||||
(e) => e.type === "content_block_delta" && e.delta?.type === "input_json_delta"
|
||||
);
|
||||
|
||||
assert.equal(inputDeltas.length, 1, "expected exactly one cleaned Read delta");
|
||||
assert.equal(inputDeltas[0].delta.partial_json.includes('"pages"'), false);
|
||||
assert.deepEqual(JSON.parse(inputDeltas[0].delta.partial_json), {
|
||||
file_path: "/etc/hosts",
|
||||
offset: 1,
|
||||
limit: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("streaming: submit_pr_review with missing arrays gets corrective delta at finish", () => {
|
||||
const state = freshState();
|
||||
const chunks = [
|
||||
|
||||
Reference in New Issue
Block a user