fix(providers): add tool_use handling to claude-web stream parser (#9408)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-04 23:28:37 -03:00
committed by GitHub
parent d969555417
commit b840628de8
4 changed files with 380 additions and 13 deletions

View File

@@ -0,0 +1 @@
- fix(providers): add tool_use block handling to claude-web stream parser for OpenAI tool_calls projection (#9408)

View File

@@ -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");

View File

@@ -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(

View 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);
});
});