Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 2 additions & 28 deletions open-sse/translator/request/openai-to-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { safeParseJSON } from "../helpers/jsonUtil.ts";
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts";
import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts";
import { enforceToolResultAdjacency } from "./openai-to-claude/toolResultAdjacency.ts";

// Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer
// adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget.
Expand Down Expand Up @@ -339,34 +340,7 @@ export function openaiToClaudeRequest(model, body, stream) {
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;
});
result.messages = enforceToolResultAdjacency(result.messages);

// Add cache_control to last assistant message
for (let i = result.messages.length - 1; i >= 0; i--) {
Expand Down
106 changes: 106 additions & 0 deletions open-sse/translator/request/openai-to-claude/toolResultAdjacency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
type ClaudeContentBlock = Record<string, unknown>;
type ClaudeMessage = {
role: string;
content: ClaudeContentBlock[];
};

// Anthropic requires each user tool_result turn to immediately follow the
// assistant turn containing the matching tool_use. OpenAI-compatible clients can
// send intervening user text before a later role:"tool" message, so repair the
// ordering here and drop true orphan results.
export function enforceToolResultAdjacency(messages: ClaudeMessage[]): ClaudeMessage[] {
const assistantByToolUseId = indexAssistantToolUses(messages);
const resultsByAssistant = new Map<ClaudeMessage, ClaudeContentBlock[]>();
const strippedMessages: ClaudeMessage[] = [];

for (const msg of messages) {
stripAndCollectToolResults(
msg,
assistantByToolUseId,
resultsByAssistant,
strippedMessages
);
}

return insertAdjacentToolResults(strippedMessages, resultsByAssistant);
}

function indexAssistantToolUses(messages: ClaudeMessage[]): Map<string, ClaudeMessage> {
const assistantByToolUseId = new Map<string, ClaudeMessage>();
for (const msg of messages) {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
for (const block of msg.content) {
if (block.type === "tool_use" && block.id && !assistantByToolUseId.has(String(block.id))) {
assistantByToolUseId.set(String(block.id), msg);
}
}
}
return assistantByToolUseId;
}

function stripAndCollectToolResults(
msg: ClaudeMessage,
assistantByToolUseId: Map<string, ClaudeMessage>,
resultsByAssistant: Map<ClaudeMessage, ClaudeContentBlock[]>,
strippedMessages: ClaudeMessage[]
): void {
if (msg.role !== "user" || !Array.isArray(msg.content)) {
strippedMessages.push(msg);
return;
}

const remainingBlocks: ClaudeContentBlock[] = [];
for (const block of msg.content) {
if (block.type !== "tool_result") {
remainingBlocks.push(block);
} else {
collectMatchedToolResult(block, assistantByToolUseId, resultsByAssistant);
}
}

if (remainingBlocks.length > 0) {
strippedMessages.push({ ...msg, content: remainingBlocks });
}
}

function collectMatchedToolResult(
block: ClaudeContentBlock,
assistantByToolUseId: Map<string, ClaudeMessage>,
resultsByAssistant: Map<ClaudeMessage, ClaudeContentBlock[]>
): void {
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
const assistant = toolUseId ? assistantByToolUseId.get(toolUseId) : undefined;
if (!assistant) return;

const grouped = resultsByAssistant.get(assistant) ?? [];
if (grouped.some((toolResult) => toolResult.tool_use_id === toolUseId)) return;

grouped.push(block);
resultsByAssistant.set(assistant, grouped);
}

function insertAdjacentToolResults(
messages: ClaudeMessage[],
resultsByAssistant: Map<ClaudeMessage, ClaudeContentBlock[]>
): ClaudeMessage[] {
const reordered: ClaudeMessage[] = [];
for (const msg of messages) {
reordered.push(msg);
const adjacentResults = orderedResultsForAssistant(msg, resultsByAssistant);
if (adjacentResults.length > 0) reordered.push({ role: "user", content: adjacentResults });
}
return reordered;
}

function orderedResultsForAssistant(
msg: ClaudeMessage,
resultsByAssistant: Map<ClaudeMessage, ClaudeContentBlock[]>
): ClaudeContentBlock[] {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [];

const grouped = resultsByAssistant.get(msg) ?? [];
return msg.content.flatMap((block) => {
if (block.type !== "tool_use" || !block.id) return [];
return grouped.filter((toolResult) => toolResult.tool_use_id === String(block.id));
});
}
64 changes: 64 additions & 0 deletions tests/unit/translator-openai-to-claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,70 @@ test("OpenAI -> Claude converts multimodal content, tool declarations, tool call
});
});

test("OpenAI -> Claude does not leave tool results separated from their tool use", () => {
const result = openaiToClaudeRequest(
"claude-4-sonnet",
{
messages: [
{ role: "user", content: "Start" },
{
role: "assistant",
content: "Calling tool",
tool_calls: [
{
id: "call_weather",
type: "function",
function: {
name: "weather.get",
arguments: '{"city":"Tokyo"}',
},
},
],
},
{ role: "user", content: "Please wait before using that result." },
{
role: "tool",
tool_call_id: "call_weather",
content: "20C",
},
],
},
false
);

const toolResultIndex = result.messages.findIndex(
(message) =>
message.role === "user" &&
message.content.some(
(block) => block.type === "tool_result" && block.tool_use_id === "call_weather"
)
);

assert.notEqual(toolResultIndex, -1, "expected the delayed tool_result to be preserved");
const previousMessage = result.messages[toolResultIndex - 1];
assert.equal(previousMessage?.role, "assistant");
assert.ok(
previousMessage.content.some(
(block) => block.type === "tool_use" && block.id === "call_weather"
),
"tool_result must immediately follow its matching tool_use"
);

const waitMessageIndex = result.messages.findIndex(
(message) =>
message.role === "user" &&
message.content.some(
(block) =>
block.type === "text" &&
block.text === "Please wait before using that result."
)
);
assert.ok(
waitMessageIndex > toolResultIndex,
"intervening user text should be moved after the repaired tool_result turn"
);
});

test("OpenAI -> Claude maps tool_choice and injects response_format instructions into system", () => {
const schemaResult = openaiToClaudeRequest(
"claude-4-sonnet",
Expand Down
Loading