diff --git a/changelog.d/fixes/9408-claude-web-tool-use.md b/changelog.d/fixes/9408-claude-web-tool-use.md new file mode 100644 index 0000000000..ed6f8f258f --- /dev/null +++ b/changelog.d/fixes/9408-claude-web-tool-use.md @@ -0,0 +1 @@ +- fix(providers): add tool_use block handling to claude-web stream parser for OpenAI tool_calls projection (#9408) diff --git a/open-sse/executors/claude-web/payload.ts b/open-sse/executors/claude-web/payload.ts index 74a88ab1b4..6966e9d95c 100644 --- a/open-sse/executors/claude-web/payload.ts +++ b/open-sse/executors/claude-web/payload.ts @@ -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"); diff --git a/open-sse/executors/claude-web/stream.ts b/open-sse/executors/claude-web/stream.ts index 617a0d99fa..82281bb02b 100644 --- a/open-sse/executors/claude-web/stream.ts +++ b/open-sse/executors/claude-web/stream.ts @@ -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 } | { 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 { interface ProtocolState { phase: StreamPhase; openBlocks: Map; + toolBlocks: Map; stopReason: string; } @@ -241,6 +250,7 @@ function handleMessageStart(state: ProtocolState): null { function blockKind(block: Record): 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, 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, state: ProtocolState): null { +function handleContentBlockStop( + event: Record, + 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 }> = []; 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 = { + 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( diff --git a/tests/unit/probe-9408-tool-use-protocol.test.ts b/tests/unit/probe-9408-tool-use-protocol.test.ts new file mode 100644 index 0000000000..835b79b17e --- /dev/null +++ b/tests/unit/probe-9408-tool-use-protocol.test.ts @@ -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 { + const bytes = new TextEncoder().encode(text); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +function frames(events: Array>, 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 = []; + 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); + }); +});