mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 05:42:19 +03:00
fix(providers): add tool_use handling to claude-web stream parser (#9408)
This commit is contained in:
committed by
GitHub
parent
d969555417
commit
b840628de8
1
changelog.d/fixes/9408-claude-web-tool-use.md
Normal file
1
changelog.d/fixes/9408-claude-web-tool-use.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): add tool_use block handling to claude-web stream parser for OpenAI tool_calls projection (#9408)
|
||||
@@ -217,6 +217,20 @@ function messageText(content: unknown): string {
|
||||
return content.map(contentPartText).filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function buildPromptFromMessages(messages: unknown[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const candidate of messages) {
|
||||
if (!isRecord(candidate)) continue;
|
||||
const role = candidate.role;
|
||||
const text = messageText(candidate.content);
|
||||
if (!text) continue;
|
||||
if (role === "user" || role === "tool") {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
function latestUserPrompt(messages: unknown[]): string {
|
||||
let prompt = "";
|
||||
for (const candidate of messages) {
|
||||
@@ -308,7 +322,9 @@ export function transformToClaude(
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
const reasoningEffort = resolveClaudeWebReasoningEffort(body);
|
||||
const resolvedModel = model || DEFAULT_CLAUDE_MODEL;
|
||||
const resolvedTurn = turn ?? defaultTurn(latestUserPrompt(messages));
|
||||
const prompt =
|
||||
turn?.prompt ?? (buildPromptFromMessages(messages) || latestUserPrompt(messages));
|
||||
const resolvedTurn = turn ?? defaultTurn(prompt);
|
||||
|
||||
if (resolvedTurn.operation === "completion" && !resolvedTurn.prompt.trim()) {
|
||||
throw new Error("No user message found in request");
|
||||
|
||||
@@ -13,14 +13,22 @@ export interface ClaudeWebStreamOptions {
|
||||
}
|
||||
|
||||
type StreamPhase = "awaiting_message" | "in_message" | "stopped" | "failed";
|
||||
type BlockKind = "thinking" | "text" | "other";
|
||||
type BlockKind = "thinking" | "text" | "tool_use" | "other";
|
||||
const MAX_CLAUDE_WEB_SSE_PENDING_CHARS = 1024 * 1024;
|
||||
type SemanticEvent =
|
||||
| { kind: "content"; text: string }
|
||||
| { kind: "reasoning"; text: string }
|
||||
| { kind: "tool_call"; index: number; id: string; name: string; input: string }
|
||||
| { kind: "metadata"; eventType: string; data: Record<string, unknown> }
|
||||
| { kind: "finish"; stopReason: string };
|
||||
|
||||
interface ToolBlockInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
inputParts: string[];
|
||||
initialInput: string;
|
||||
}
|
||||
|
||||
const KNOWN_METADATA_EVENTS = new Set([
|
||||
"ping",
|
||||
"completion",
|
||||
@@ -193,6 +201,7 @@ function thinkingSummaryText(delta: Record<string, unknown>): string {
|
||||
interface ProtocolState {
|
||||
phase: StreamPhase;
|
||||
openBlocks: Map<number, BlockKind>;
|
||||
toolBlocks: Map<number, ToolBlockInfo>;
|
||||
stopReason: string;
|
||||
}
|
||||
|
||||
@@ -241,6 +250,7 @@ function handleMessageStart(state: ProtocolState): null {
|
||||
function blockKind(block: Record<string, unknown>): BlockKind {
|
||||
if (block.type === "thinking") return "thinking";
|
||||
if (block.type === "text") return "text";
|
||||
if (block.type === "tool_use") return "tool_use";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -252,17 +262,35 @@ function handleContentBlockStart(
|
||||
const index = requireBlockIndex(event);
|
||||
if (state.openBlocks.has(index)) protocolFailure(state, "Content block was opened twice");
|
||||
|
||||
const kind = blockKind(requireRecord(event.content_block, "content_block"));
|
||||
const contentBlock = requireRecord(event.content_block, "content_block");
|
||||
const kind = blockKind(contentBlock);
|
||||
state.openBlocks.set(index, kind);
|
||||
|
||||
if (kind === "tool_use") {
|
||||
const id = typeof contentBlock.id === "string" ? contentBlock.id : "";
|
||||
const name = typeof contentBlock.name === "string" ? contentBlock.name : "";
|
||||
let initialInput = "";
|
||||
if (contentBlock.input !== undefined) {
|
||||
try {
|
||||
initialInput = JSON.stringify(contentBlock.input);
|
||||
} catch {
|
||||
initialInput = "";
|
||||
}
|
||||
}
|
||||
state.toolBlocks.set(index, { id, name, inputParts: [], initialInput });
|
||||
return null;
|
||||
}
|
||||
|
||||
return kind === "thinking" ? { kind: "reasoning", text: "" } : null;
|
||||
}
|
||||
|
||||
function handleContentBlockDelta(
|
||||
event: Record<string, unknown>,
|
||||
state: ProtocolState
|
||||
): SemanticEvent {
|
||||
): SemanticEvent | null {
|
||||
assertInMessage(state, "content_block_delta");
|
||||
const block = state.openBlocks.get(requireBlockIndex(event));
|
||||
const index = requireBlockIndex(event);
|
||||
const block = state.openBlocks.get(index);
|
||||
if (!block) protocolFailure(state, "Content delta has no open block");
|
||||
|
||||
const delta = requireRecord(event.delta, "delta");
|
||||
@@ -275,14 +303,42 @@ function handleContentBlockDelta(
|
||||
if (delta.type === "thinking_summary_delta" && block === "thinking") {
|
||||
return { kind: "reasoning", text: thinkingSummaryText(delta) };
|
||||
}
|
||||
if (delta.type === "input_json_delta" && block === "tool_use") {
|
||||
const toolBlock = state.toolBlocks.get(index);
|
||||
if (!toolBlock) protocolFailure(state, "input_json_delta has no tool block state");
|
||||
if (typeof delta.partial_json === "string") {
|
||||
toolBlock.inputParts.push(delta.partial_json);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return protocolFailure(state, "Content delta type does not match its block");
|
||||
}
|
||||
|
||||
function handleContentBlockStop(event: Record<string, unknown>, state: ProtocolState): null {
|
||||
function handleContentBlockStop(
|
||||
event: Record<string, unknown>,
|
||||
state: ProtocolState
|
||||
): SemanticEvent | null {
|
||||
assertInMessage(state, "content_block_stop");
|
||||
if (!state.openBlocks.delete(requireBlockIndex(event))) {
|
||||
protocolFailure(state, "Content block stop has no open block");
|
||||
const index = requireBlockIndex(event);
|
||||
const kind = state.openBlocks.get(index);
|
||||
if (!kind) protocolFailure(state, "Content block stop has no open block");
|
||||
state.openBlocks.delete(index);
|
||||
|
||||
if (kind === "tool_use") {
|
||||
const toolBlock = state.toolBlocks.get(index);
|
||||
state.toolBlocks.delete(index);
|
||||
if (!toolBlock) protocolFailure(state, "Tool block stop has no tool state");
|
||||
|
||||
let inputStr = "";
|
||||
if (toolBlock.inputParts.length > 0) {
|
||||
inputStr = toolBlock.inputParts.join("");
|
||||
} else if (toolBlock.initialInput) {
|
||||
inputStr = toolBlock.initialInput;
|
||||
}
|
||||
|
||||
return { kind: "tool_call", index, id: toolBlock.id, name: toolBlock.name, input: inputStr };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -336,6 +392,7 @@ async function* parseClaudeWebEvents(
|
||||
const state: ProtocolState = {
|
||||
phase: "awaiting_message",
|
||||
openBlocks: new Map(),
|
||||
toolBlocks: new Map(),
|
||||
stopReason: "end_turn",
|
||||
};
|
||||
|
||||
@@ -447,6 +504,7 @@ async function createBufferedResponse(
|
||||
let assistantText = "";
|
||||
let reasoningText = "";
|
||||
let stopReason = "end_turn";
|
||||
const toolCalls: Array<{ id: string; name: string; input: string }> = [];
|
||||
const metadataEvents: Array<{ type: string; data: Record<string, unknown> }> = [];
|
||||
const control: StreamControl = { reader: null, cancelled: false };
|
||||
|
||||
@@ -454,12 +512,30 @@ async function createBufferedResponse(
|
||||
for await (const event of parseClaudeWebEvents(source, control)) {
|
||||
if (event.kind === "content") assistantText += event.text;
|
||||
if (event.kind === "reasoning") reasoningText += event.text;
|
||||
if (event.kind === "tool_call") {
|
||||
toolCalls.push({ id: event.id, name: event.name, input: event.input });
|
||||
}
|
||||
if (event.kind === "metadata") {
|
||||
metadataEvents.push({ type: event.eventType, data: event.data });
|
||||
}
|
||||
if (event.kind === "finish") stopReason = event.stopReason;
|
||||
}
|
||||
notifyComplete(options, { assistantText, stopReason });
|
||||
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: assistantText || null,
|
||||
...(reasoningText ? { reasoning_content: reasoningText } : {}),
|
||||
};
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: { name: tc.name, arguments: tc.input },
|
||||
}));
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
@@ -469,11 +545,7 @@ async function createBufferedResponse(
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: assistantText,
|
||||
...(reasoningText ? { reasoning_content: reasoningText } : {}),
|
||||
},
|
||||
message,
|
||||
finish_reason: openAiFinishReason(stopReason),
|
||||
logprobs: null,
|
||||
},
|
||||
@@ -569,6 +641,31 @@ async function queueSemanticEvent(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.kind === "tool_call") {
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
state,
|
||||
makeChunk(
|
||||
state.id,
|
||||
state.created,
|
||||
options,
|
||||
{
|
||||
tool_calls: [
|
||||
{
|
||||
index: event.index,
|
||||
id: event.id,
|
||||
type: "function",
|
||||
function: { name: event.name, arguments: event.input },
|
||||
},
|
||||
],
|
||||
},
|
||||
null
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.kind === "metadata") {
|
||||
state.pendingChunks.push(
|
||||
encodeStreamEvent(
|
||||
|
||||
253
tests/unit/probe-9408-tool-use-protocol.test.ts
Normal file
253
tests/unit/probe-9408-tool-use-protocol.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { createClaudeWebResponse } from "../../open-sse/executors/claude-web/stream.ts";
|
||||
|
||||
function byteStream(text: string): ReadableStream<Uint8Array> {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function frames(events: Array<Record<string, unknown>>, newline = "\n"): string {
|
||||
return events.map((event) => `data: ${JSON.stringify(event)}${newline}${newline}`).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduce #9408: Claude Web emits tool_use content blocks and the stream
|
||||
* parser has no handler for them, causing input_json_delta to be rejected as
|
||||
* a protocol violation → HTTP 502.
|
||||
*
|
||||
* Upstream event sequence:
|
||||
* message_start
|
||||
* → content_block_start(type:"tool_use", id:"toolu_xxx", name:"get_weather")
|
||||
* → ×3 content_block_delta(type:"input_json_delta", partial_json:"...")
|
||||
* → content_block_stop
|
||||
* → message_delta(stop_reason:"tool_use")
|
||||
* → message_stop
|
||||
*/
|
||||
describe("Claude Web tool_use protocol (#9408)", () => {
|
||||
it("converts tool_use blocks to tool_calls in buffered mode", async () => {
|
||||
const events = [
|
||||
{ type: "message_start", message: { model: "claude-sonnet-5" } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_9408_001",
|
||||
name: "get_weather",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"loca' },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: 'tion": "Sa' },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: 'n Francisco"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
const completions: Array<{ assistantText: string; stopReason: string }> = [];
|
||||
let failures = 0;
|
||||
|
||||
const response = await createClaudeWebResponse(byteStream(frames(events)), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: false,
|
||||
responseMetadata: {},
|
||||
onComplete: (result) => completions.push(result),
|
||||
onFailure: () => {
|
||||
failures += 1;
|
||||
},
|
||||
});
|
||||
|
||||
// Should NOT be 502 — the bug was that tool_use blocks caused protocol failure
|
||||
assert.equal(response.status, 200, "Expected 200, not 502 — tool_use should not crash");
|
||||
const body = (await response.json()) as {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string | null;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(body.choices[0].finish_reason, "tool_calls");
|
||||
assert.ok(body.choices[0].message.tool_calls, "Expected tool_calls in message");
|
||||
assert.equal(body.choices[0].message.tool_calls!.length, 1);
|
||||
assert.equal(body.choices[0].message.tool_calls![0].id, "toolu_9408_001");
|
||||
assert.equal(body.choices[0].message.tool_calls![0].type, "function");
|
||||
assert.equal(body.choices[0].message.tool_calls![0].function.name, "get_weather");
|
||||
// Content should be null when there's only a tool call
|
||||
assert.equal(body.choices[0].message.content, null);
|
||||
// Preserve upstream tool call ID — the input should parse correctly
|
||||
const parsed = JSON.parse(body.choices[0].message.tool_calls![0].function.arguments);
|
||||
assert.deepEqual(parsed, { location: "San Francisco" });
|
||||
assert.deepEqual(completions, [{ assistantText: "", stopReason: "tool_use" }]);
|
||||
assert.equal(failures, 0);
|
||||
});
|
||||
|
||||
it("converts tool_use blocks to tool_calls in streaming mode", async () => {
|
||||
const events = [
|
||||
{ type: "message_start", message: { model: "claude-sonnet-5" } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_9408_002",
|
||||
name: "search_code",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"initial"' },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: ',"limit":10}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
const completions: Array<{ assistantText: string; stopReason: string }> = [];
|
||||
let failures = 0;
|
||||
|
||||
const response = await createClaudeWebResponse(byteStream(frames(events)), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: true,
|
||||
responseMetadata: {},
|
||||
onComplete: (result) => completions.push(result),
|
||||
onFailure: () => {
|
||||
failures += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200, "Expected 200, not 502");
|
||||
const output = await response.text();
|
||||
// Verify it contains tool_calls in some chunk
|
||||
assert.match(output, /tool_calls/);
|
||||
// Verify finish_reason: tool_calls
|
||||
assert.match(output, /"finish_reason":"tool_calls"/);
|
||||
// Verify tool call id preserved
|
||||
assert.match(output, /"id":"toolu_9408_002"/);
|
||||
// Verify tool call name
|
||||
assert.match(output, /"name":"search_code"/);
|
||||
// Verify arguments contain the accumulated input
|
||||
assert.match(output, /"arguments":".*query.*initial.*limit.*10/);
|
||||
assert.deepEqual(completions, [{ assistantText: "", stopReason: "tool_use" }]);
|
||||
assert.equal(failures, 0);
|
||||
});
|
||||
|
||||
it("handles tool_use alongside text content", async () => {
|
||||
const events = [
|
||||
{ type: "message_start", message: { model: "claude-sonnet-5" } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text" } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "I'll look that up." },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_9408_003",
|
||||
name: "get_info",
|
||||
input: { topic: "weather" },
|
||||
},
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
const response = await createClaudeWebResponse(byteStream(frames(events)), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: false,
|
||||
responseMetadata: {},
|
||||
onComplete() {},
|
||||
onFailure() {},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string | null;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Should have text content AND tool calls
|
||||
assert.equal(body.choices[0].message.content, "I'll look that up.");
|
||||
assert.equal(body.choices[0].message.tool_calls!.length, 1);
|
||||
assert.equal(body.choices[0].message.tool_calls![0].id, "toolu_9408_003");
|
||||
});
|
||||
|
||||
it("rejects input_json_delta when no tool_use block is open", async () => {
|
||||
const events = [
|
||||
{ type: "message_start" },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text" } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: "{}" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
|
||||
const completions: Array<unknown> = [];
|
||||
let failures = 0;
|
||||
|
||||
const response = await createClaudeWebResponse(byteStream(frames(events)), {
|
||||
model: "claude-sonnet-5",
|
||||
stream: false,
|
||||
responseMetadata: {},
|
||||
onComplete: (result) => completions.push(result),
|
||||
onFailure: () => {
|
||||
failures += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 502, "input_json_delta without open tool_use should fail");
|
||||
assert.deepEqual(completions, []);
|
||||
assert.equal(failures, 1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user