mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-06 02:07:00 +00:00
fix(sse): filter orphaned tool results after context compaction
When Claude Code compacts conversation context to fit within token limits, it may remove assistant messages containing tool_use/tool_calls while leaving the corresponding tool_result/function_call_output messages intact. This creates orphaned tool results that cause providers to reject requests with errors like "tool result's tool id not found" or "No tool call found for function call output".
This commit is contained in:
parent
9b255e643a
commit
8b2081837e
3 changed files with 244 additions and 0 deletions
|
|
@ -201,6 +201,24 @@ export function openaiResponsesToOpenAIRequest(
|
|||
});
|
||||
}
|
||||
|
||||
// Filter orphaned tool results (no matching tool_call in any assistant message)
|
||||
const allToolCallIds = new Set<string>();
|
||||
for (const m of messages) {
|
||||
const rec = toRecord(m);
|
||||
if (Array.isArray(rec.tool_calls)) {
|
||||
for (const tc of rec.tool_calls as any[]) {
|
||||
if (tc.id) allToolCallIds.add(String(tc.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
result.messages = messages.filter((m) => {
|
||||
const rec = toRecord(m);
|
||||
if (rec.role === "tool" && rec.tool_call_id) {
|
||||
return allToolCallIds.has(String(rec.tool_call_id));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
|
|
@ -339,6 +357,20 @@ export function openaiToOpenAIResponsesRequest(
|
|||
}
|
||||
}
|
||||
|
||||
// Filter orphaned function_call_output items (no matching function_call)
|
||||
// This happens when Claude Code compaction removes messages but leaves tool results
|
||||
const knownCallIds = new Set(
|
||||
input
|
||||
.filter((item: any) => item.type === "function_call" && item.call_id)
|
||||
.map((item: any) => item.call_id),
|
||||
);
|
||||
result.input = input.filter((item: any) => {
|
||||
if (item.type === "function_call_output" && item.call_id) {
|
||||
return knownCallIds.has(item.call_id);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no system message, keep empty instructions
|
||||
if (!hasSystemMessage) {
|
||||
result.instructions = "";
|
||||
|
|
|
|||
|
|
@ -123,6 +123,43 @@ export function openaiToClaudeRequest(model, body, stream) {
|
|||
|
||||
flushCurrentMessage();
|
||||
|
||||
// Remove assistant messages with empty content (can happen when all tool_use blocks were skipped)
|
||||
result.messages = result.messages.filter((msg) => {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filter orphaned tool_result blocks whose tool_use_id has no matching tool_use
|
||||
const allToolUseIds = new Set<string>();
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_use" && block.id) {
|
||||
allToolUseIds.add(String(block.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
msg.content = msg.content.filter((block) => {
|
||||
if (block.type === "tool_result" && block.tool_use_id) {
|
||||
return allToolUseIds.has(String(block.tool_use_id));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Remove user messages that became empty after orphan filtering
|
||||
result.messages = result.messages.filter((msg) => {
|
||||
if (msg.role === "user" && Array.isArray(msg.content) && msg.content.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Add cache_control to last assistant message
|
||||
for (let i = result.messages.length - 1; i >= 0; i--) {
|
||||
const message = result.messages[i];
|
||||
|
|
|
|||
175
tests/unit/orphaned-tool-filter.test.mjs
Normal file
175
tests/unit/orphaned-tool-filter.test.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } = await import(
|
||||
"../../open-sse/translator/request/openai-responses.ts"
|
||||
);
|
||||
|
||||
const { openaiToClaudeRequest } = await import(
|
||||
"../../open-sse/translator/request/openai-to-claude.ts"
|
||||
);
|
||||
|
||||
test("openaiResponsesToOpenAIRequest: filters orphaned tool messages", () => {
|
||||
const body = {
|
||||
model: "gpt-4",
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
{ type: "function_call_output", call_id: "call_orphan_1", output: "stale result" },
|
||||
{ type: "function_call", call_id: "call_valid_1", name: "read_file", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call_valid_1", output: "file contents" },
|
||||
],
|
||||
};
|
||||
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null);
|
||||
const toolMessages = result.messages.filter((m) => m.role === "tool");
|
||||
assert.equal(toolMessages.length, 1, "should have exactly 1 tool message");
|
||||
assert.equal(toolMessages[0].tool_call_id, "call_valid_1");
|
||||
});
|
||||
|
||||
test("openaiResponsesToOpenAIRequest: preserves all messages when no orphans", () => {
|
||||
const body = {
|
||||
model: "gpt-4",
|
||||
input: [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
{ type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call_1", output: "ok" },
|
||||
{ type: "function_call", call_id: "call_2", name: "write_file", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call_2", output: "done" },
|
||||
],
|
||||
};
|
||||
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null);
|
||||
const toolMessages = result.messages.filter((m) => m.role === "tool");
|
||||
assert.equal(toolMessages.length, 2, "both valid tool results should be preserved");
|
||||
});
|
||||
|
||||
test("openaiToOpenAIResponsesRequest: filters orphaned function_call_output", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "system", content: "You are helpful" },
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "tool", tool_call_id: "call_orphan_2", content: "stale" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "call_valid_2", type: "function", function: { name: "ls", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_valid_2", content: "files" },
|
||||
],
|
||||
};
|
||||
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null);
|
||||
const outputs = result.input.filter((i) => i.type === "function_call_output");
|
||||
assert.equal(outputs.length, 1, "should have exactly 1 function_call_output");
|
||||
assert.equal(outputs[0].call_id, "call_valid_2");
|
||||
});
|
||||
|
||||
test("openaiToOpenAIResponsesRequest: preserves all items when no orphans", () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: "call_a", type: "function", function: { name: "ls", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_a", content: "result" },
|
||||
],
|
||||
};
|
||||
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null);
|
||||
const outputs = result.input.filter((i) => i.type === "function_call_output");
|
||||
assert.equal(outputs.length, 1, "valid function_call_output should be preserved");
|
||||
});
|
||||
|
||||
test("openaiToClaudeRequest: filters orphaned tool_result blocks", () => {
|
||||
const body = {
|
||||
_disableToolPrefix: true,
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "tool", tool_call_id: "tu_orphan_1", content: "stale result" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "tu_valid_1", name: "read_file", input: {} }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "tu_valid_1", content: "file contents" },
|
||||
],
|
||||
};
|
||||
const result = openaiToClaudeRequest("claude-3", body, true);
|
||||
const toolResults = [];
|
||||
for (const msg of result.messages) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_result") toolResults.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(toolResults.length, 1, "should have exactly 1 tool_result");
|
||||
assert.equal(toolResults[0].tool_use_id, "tu_valid_1");
|
||||
});
|
||||
|
||||
test("openaiToClaudeRequest: removes empty user messages after orphan filtering", () => {
|
||||
const body = {
|
||||
_disableToolPrefix: true,
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "tool", tool_call_id: "tu_orphan_only", content: "stale" },
|
||||
{ role: "assistant", content: "I can help" },
|
||||
],
|
||||
};
|
||||
const result = openaiToClaudeRequest("claude-3", body, true);
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
assert.ok(msg.content.length > 0, "user message should not have empty content");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("openaiToClaudeRequest: removes empty assistant messages", () => {
|
||||
const body = {
|
||||
_disableToolPrefix: true,
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "tu_1", name: "", input: {} }],
|
||||
},
|
||||
{ role: "assistant", content: "actual response" },
|
||||
],
|
||||
};
|
||||
const result = openaiToClaudeRequest("claude-3", body, true);
|
||||
for (const msg of result.messages) {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
assert.ok(msg.content.length > 0, "assistant message should not have empty content");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("openaiToClaudeRequest: preserves valid tool pairs unchanged", () => {
|
||||
const body = {
|
||||
_disableToolPrefix: true,
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "tu_1", name: "read_file", input: {} }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "tu_1", content: "file data" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "tu_2", name: "write_file", input: {} }],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "tu_2", content: "written" },
|
||||
],
|
||||
};
|
||||
const result = openaiToClaudeRequest("claude-3", body, true);
|
||||
const toolResults = [];
|
||||
for (const msg of result.messages) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_result") toolResults.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(toolResults.length, 2, "both valid tool_results should be preserved");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue