From 387d75ee69c88cc46236790e01ad8a2c3750dbb8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 15:38:47 -0700 Subject: [PATCH 1/4] feat: add ContentWithToolCallsResponse type, type guard, and builders New FixtureResponse variant for fixtures that specify both content and toolCalls. Includes isContentWithToolCallsResponse guard, streaming builder (buildContentWithToolCallsChunks), and non-streaming builder (buildContentWithToolCallsCompletion) for OpenAI Chat Completions. --- src/helpers.ts | 136 +++++++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 16 +++++- 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/helpers.ts b/src/helpers.ts index 7689459..0f21470 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -4,6 +4,7 @@ import type { FixtureResponse, TextResponse, ToolCallResponse, + ContentWithToolCallsResponse, ErrorResponse, EmbeddingResponse, SSEChunk, @@ -50,6 +51,17 @@ export function isToolCallResponse(r: FixtureResponse): r is ToolCallResponse { return "toolCalls" in r && Array.isArray((r as ToolCallResponse).toolCalls); } +export function isContentWithToolCallsResponse( + r: FixtureResponse, +): r is ContentWithToolCallsResponse { + return ( + "content" in r && + typeof (r as ContentWithToolCallsResponse).content === "string" && + "toolCalls" in r && + Array.isArray((r as ContentWithToolCallsResponse).toolCalls) + ); +} + export function isErrorResponse(r: FixtureResponse): r is ErrorResponse { return ( "error" in r && @@ -254,6 +266,130 @@ export function buildToolCallCompletion(toolCalls: ToolCall[], model: string): C }; } +export function buildContentWithToolCallsChunks( + content: string, + toolCalls: ToolCall[], + model: string, + chunkSize: number, +): SSEChunk[] { + const id = generateId(); + const created = Math.floor(Date.now() / 1000); + const chunks: SSEChunk[] = []; + + // Role chunk + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + }); + + // Content chunks + for (let i = 0; i < content.length; i += chunkSize) { + const slice = content.slice(i, i + chunkSize); + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: slice }, finish_reason: null }], + }); + } + + // Tool call chunks — one initial chunk per tool call, then argument chunks + for (let tcIdx = 0; tcIdx < toolCalls.length; tcIdx++) { + const tc = toolCalls[tcIdx]; + const tcId = tc.id || generateToolCallId(); + + // Initial tool call chunk (id + function name) + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: tcIdx, + id: tcId, + type: "function", + function: { name: tc.name, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + + // Argument streaming chunks + const args = tc.arguments; + for (let i = 0; i < args.length; i += chunkSize) { + const slice = args.slice(i, i + chunkSize); + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index: tcIdx, function: { arguments: slice } }], + }, + finish_reason: null, + }, + ], + }); + } + } + + // Finish chunk + chunks.push({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }); + + return chunks; +} + +export function buildContentWithToolCallsCompletion( + content: string, + toolCalls: ToolCall[], + model: string, +): ChatCompletion { + return { + id: generateId(), + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content, + refusal: null, + tool_calls: toolCalls.map((tc) => ({ + id: tc.id || generateToolCallId(), + type: "function" as const, + function: { name: tc.name, arguments: tc.arguments }, + })), + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; +} + // ─── HTTP helpers ───────────────────────────────────────────────────────── export function readBody(req: http.IncomingMessage): Promise { diff --git a/src/types.ts b/src/types.ts index a5fce2a..b53b46e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,15 @@ export interface ToolCallResponse { finishReason?: string; } +export interface ContentWithToolCallsResponse { + content: string; + toolCalls: ToolCall[]; + reasoning?: string; + webSearches?: string[]; + role?: string; + finishReason?: string; +} + export interface ErrorResponse { error: { message: string; type?: string; code?: string }; status?: number; @@ -102,7 +111,12 @@ export interface EmbeddingResponse { embedding: number[]; } -export type FixtureResponse = TextResponse | ToolCallResponse | ErrorResponse | EmbeddingResponse; +export type FixtureResponse = + | TextResponse + | ToolCallResponse + | ContentWithToolCallsResponse + | ErrorResponse + | EmbeddingResponse; // Streaming physics From a01776ee35875dc77484cc78493227c07ac927e9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 15:38:55 -0700 Subject: [PATCH 2/4] feat: combined content+toolCalls support across all 4 providers Streaming and non-streaming support for ContentWithToolCallsResponse in OpenAI Chat Completions (server.ts), OpenAI Responses API (responses.ts), Anthropic Messages (messages.ts), and Gemini (gemini.ts). Content streams first, then tool calls, with correct finish reasons per provider (tool_calls, tool_use, FUNCTION_CALL). Includes shared helper extraction in responses.ts and parseToolCallPart refactor in gemini.ts. --- src/gemini.ts | 156 +++++++++++++--- src/messages.ts | 223 +++++++++++++++++++++++ src/responses.ts | 455 ++++++++++++++++++++++++++++++++--------------- src/server.ts | 44 +++++ 4 files changed, 711 insertions(+), 167 deletions(-) diff --git a/src/gemini.ts b/src/gemini.ts index de9e922..0f2e12e 100644 --- a/src/gemini.ts +++ b/src/gemini.ts @@ -20,6 +20,7 @@ import type { import { isTextResponse, isToolCallResponse, + isContentWithToolCallsResponse, isErrorResponse, generateToolCallId, flattenHeaders, @@ -256,24 +257,22 @@ function buildGeminiTextStreamChunks( return chunks; } +function parseToolCallPart(tc: ToolCall, logger: Logger): GeminiPart { + let argsObj: Record; + try { + argsObj = JSON.parse(tc.arguments || "{}") as Record; + } catch { + logger.warn(`Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`); + argsObj = {}; + } + return { functionCall: { name: tc.name, args: argsObj, id: tc.id || generateToolCallId() } }; +} + function buildGeminiToolCallStreamChunks( toolCalls: ToolCall[], logger: Logger, ): GeminiResponseChunk[] { - const parts: GeminiPart[] = toolCalls.map((tc) => { - let argsObj: Record; - try { - argsObj = JSON.parse(tc.arguments || "{}") as Record; - } catch { - logger.warn( - `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, - ); - argsObj = {}; - } - return { - functionCall: { name: tc.name, args: argsObj, id: tc.id || generateToolCallId() }, - }; - }); + const parts: GeminiPart[] = toolCalls.map((tc) => parseToolCallPart(tc, logger)); // Gemini sends all tool calls in a single response chunk return [ @@ -320,21 +319,85 @@ function buildGeminiTextResponse(content: string, reasoning?: string): GeminiRes } function buildGeminiToolCallResponse(toolCalls: ToolCall[], logger: Logger): GeminiResponseChunk { - const parts: GeminiPart[] = toolCalls.map((tc) => { - let argsObj: Record; - try { - argsObj = JSON.parse(tc.arguments || "{}") as Record; - } catch { - logger.warn( - `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, - ); - argsObj = {}; + const parts: GeminiPart[] = toolCalls.map((tc) => parseToolCallPart(tc, logger)); + + return { + candidates: [ + { + content: { role: "model", parts }, + finishReason: "FUNCTION_CALL", + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, + }; +} + +function buildGeminiContentWithToolCallsStreamChunks( + content: string, + toolCalls: ToolCall[], + chunkSize: number, + logger: Logger, +): GeminiResponseChunk[] { + const chunks: GeminiResponseChunk[] = []; + + if (content.length === 0) { + chunks.push({ + candidates: [ + { + content: { role: "model", parts: [{ text: "" }] }, + index: 0, + }, + ], + }); + } else { + for (let i = 0; i < content.length; i += chunkSize) { + const slice = content.slice(i, i + chunkSize); + chunks.push({ + candidates: [ + { + content: { role: "model", parts: [{ text: slice }] }, + index: 0, + }, + ], + }); } - return { - functionCall: { name: tc.name, args: argsObj, id: tc.id || generateToolCallId() }, - }; + } + + const parts: GeminiPart[] = toolCalls.map((tc) => parseToolCallPart(tc, logger)); + + chunks.push({ + candidates: [ + { + content: { role: "model", parts }, + finishReason: "FUNCTION_CALL", + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, }); + return chunks; +} + +function buildGeminiContentWithToolCallsResponse( + content: string, + toolCalls: ToolCall[], + logger: Logger, +): GeminiResponseChunk { + const parts: GeminiPart[] = [ + { text: content }, + ...toolCalls.map((tc) => parseToolCallPart(tc, logger)), + ]; + return { candidates: [ { @@ -549,6 +612,47 @@ export async function handleGemini( return; } + // Content + tool calls response (must be checked before isTextResponse / isToolCallResponse) + if (isContentWithToolCallsResponse(response)) { + const journalEntry = journal.add({ + method: req.method ?? "POST", + path, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: 200, fixture }, + }); + if (!streaming) { + const body = buildGeminiContentWithToolCallsResponse( + response.content, + response.toolCalls, + logger, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const chunks = buildGeminiContentWithToolCallsStreamChunks( + response.content, + response.toolCalls, + chunkSize, + logger, + ); + const interruption = createInterruptionSignal(fixture); + const completed = await writeGeminiSSEStream(res, chunks, { + latency, + streamingProfile: fixture.streamingProfile, + signal: interruption?.signal, + onChunkSent: interruption?.tick, + }); + if (!completed) { + if (!res.writableEnded) res.destroy(); + journalEntry.response.interrupted = true; + journalEntry.response.interruptReason = interruption?.reason(); + } + interruption?.cleanup(); + } + return; + } + // Text response if (isTextResponse(response)) { const journalEntry = journal.add({ diff --git a/src/messages.ts b/src/messages.ts index 7cd5547..a96cb33 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -21,6 +21,7 @@ import { generateToolUseId, isTextResponse, isToolCallResponse, + isContentWithToolCallsResponse, isErrorResponse, flattenHeaders, } from "./helpers.js"; @@ -415,6 +416,183 @@ function buildClaudeToolCallResponse(toolCalls: ToolCall[], model: string, logge }; } +function buildClaudeContentWithToolCallsStreamEvents( + content: string, + toolCalls: ToolCall[], + model: string, + chunkSize: number, + logger: Logger, + reasoning?: string, +): ClaudeSSEEvent[] { + const msgId = generateMessageId(); + const events: ClaudeSSEEvent[] = []; + + // message_start + events.push({ + type: "message_start", + message: { + id: msgId, + type: "message", + role: "assistant", + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }); + + let blockIndex = 0; + + // Optional thinking block + if (reasoning) { + events.push({ + type: "content_block_start", + index: blockIndex, + content_block: { type: "thinking", thinking: "" }, + }); + + for (let i = 0; i < reasoning.length; i += chunkSize) { + const slice = reasoning.slice(i, i + chunkSize); + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { type: "thinking_delta", thinking: slice }, + }); + } + + events.push({ + type: "content_block_stop", + index: blockIndex, + }); + + blockIndex++; + } + + // Text content block + events.push({ + type: "content_block_start", + index: blockIndex, + content_block: { type: "text", text: "" }, + }); + + for (let i = 0; i < content.length; i += chunkSize) { + const slice = content.slice(i, i + chunkSize); + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { type: "text_delta", text: slice }, + }); + } + + events.push({ + type: "content_block_stop", + index: blockIndex, + }); + + blockIndex++; + + // Tool use blocks + for (const tc of toolCalls) { + const toolUseId = tc.id || generateToolUseId(); + + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + logger.warn( + `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, + ); + argsObj = {}; + } + const argsJson = JSON.stringify(argsObj); + + events.push({ + type: "content_block_start", + index: blockIndex, + content_block: { + type: "tool_use", + id: toolUseId, + name: tc.name, + input: {}, + }, + }); + + for (let i = 0; i < argsJson.length; i += chunkSize) { + const slice = argsJson.slice(i, i + chunkSize); + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { type: "input_json_delta", partial_json: slice }, + }); + } + + events.push({ + type: "content_block_stop", + index: blockIndex, + }); + + blockIndex++; + } + + // message_delta + events.push({ + type: "message_delta", + delta: { stop_reason: "tool_use", stop_sequence: null }, + usage: { output_tokens: 0 }, + }); + + // message_stop + events.push({ type: "message_stop" }); + + return events; +} + +function buildClaudeContentWithToolCallsResponse( + content: string, + toolCalls: ToolCall[], + model: string, + logger: Logger, + reasoning?: string, +): object { + const contentBlocks: object[] = []; + + if (reasoning) { + contentBlocks.push({ type: "thinking", thinking: reasoning }); + } + + contentBlocks.push({ type: "text", text: content }); + + for (const tc of toolCalls) { + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + logger.warn( + `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, + ); + argsObj = {}; + } + contentBlocks.push({ + type: "tool_use", + id: tc.id || generateToolUseId(), + name: tc.name, + input: argsObj, + }); + } + + return { + id: generateMessageId(), + type: "message", + role: "assistant", + content: contentBlocks, + model, + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }; +} + // ─── SSE writer for Claude Messages API ───────────────────────────────────── interface ClaudeStreamOptions { @@ -608,6 +786,51 @@ export async function handleMessages( return; } + // Content + tool calls response (must be checked before text/tool-only branches) + if (isContentWithToolCallsResponse(response)) { + const journalEntry = journal.add({ + method: req.method ?? "POST", + path: req.url ?? "/v1/messages", + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: 200, fixture }, + }); + if (claudeReq.stream !== true) { + const body = buildClaudeContentWithToolCallsResponse( + response.content, + response.toolCalls, + completionReq.model, + logger, + response.reasoning, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const events = buildClaudeContentWithToolCallsStreamEvents( + response.content, + response.toolCalls, + completionReq.model, + chunkSize, + logger, + response.reasoning, + ); + const interruption = createInterruptionSignal(fixture); + const completed = await writeClaudeSSEStream(res, events, { + latency, + streamingProfile: fixture.streamingProfile, + signal: interruption?.signal, + onChunkSent: interruption?.tick, + }); + if (!completed) { + if (!res.writableEnded) res.destroy(); + journalEntry.response.interrupted = true; + journalEntry.response.interruptReason = interruption?.reason(); + } + interruption?.cleanup(); + } + return; + } + // Text response if (isTextResponse(response)) { if (response.webSearches?.length) { diff --git a/src/responses.ts b/src/responses.ts index 5b7e18f..def52d2 100644 --- a/src/responses.ts +++ b/src/responses.ts @@ -21,6 +21,7 @@ import { generateToolCallId, isTextResponse, isToolCallResponse, + isContentWithToolCallsResponse, isErrorResponse, flattenHeaders, } from "./helpers.js"; @@ -168,129 +169,20 @@ export function buildTextStreamEvents( reasoning?: string, webSearches?: string[], ): ResponsesSSEEvent[] { - const respId = responseId(); - const msgId = itemId(); - const created = Math.floor(Date.now() / 1000); - const events: ResponsesSSEEvent[] = []; - - let msgOutputIndex = 0; - const prefixOutputItems: object[] = []; - - // response.created - events.push({ - type: "response.created", - response: { - id: respId, - object: "response", - created_at: created, - model, - status: "in_progress", - output: [], - }, - }); - - // response.in_progress - events.push({ - type: "response.in_progress", - response: { - id: respId, - object: "response", - created_at: created, - model, - status: "in_progress", - output: [], - }, - }); - - if (reasoning) { - const reasoningEvents = buildReasoningStreamEvents(reasoning, model, chunkSize); - events.push(...reasoningEvents); - const doneEvent = reasoningEvents.find( - (e) => - e.type === "response.output_item.done" && - (e.item as { type: string })?.type === "reasoning", - ); - if (doneEvent) prefixOutputItems.push(doneEvent.item as object); - msgOutputIndex++; - } - - if (webSearches && webSearches.length > 0) { - const searchEvents = buildWebSearchStreamEvents(webSearches, msgOutputIndex); - events.push(...searchEvents); - const doneEvents = searchEvents.filter( - (e) => - e.type === "response.output_item.done" && - (e.item as { type: string })?.type === "web_search_call", - ); - for (const de of doneEvents) prefixOutputItems.push(de.item as object); - msgOutputIndex += webSearches.length; - } - - // output_item.added (message) - events.push({ - type: "response.output_item.added", - output_index: msgOutputIndex, - item: { - type: "message", - id: msgId, - status: "in_progress", - role: "assistant", - content: [], - }, - }); - - // content_part.added - events.push({ - type: "response.content_part.added", - output_index: msgOutputIndex, - content_index: 0, - part: { type: "output_text", text: "" }, - }); - - // text deltas - for (let i = 0; i < content.length; i += chunkSize) { - const slice = content.slice(i, i + chunkSize); - events.push({ - type: "response.output_text.delta", - item_id: msgId, - output_index: msgOutputIndex, - content_index: 0, - delta: slice, - }); - } - - // output_text.done - events.push({ - type: "response.output_text.done", - output_index: msgOutputIndex, - content_index: 0, - text: content, - }); - - // content_part.done - events.push({ - type: "response.content_part.done", - output_index: msgOutputIndex, - content_index: 0, - part: { type: "output_text", text: content }, - }); - - const msgItem = { - type: "message", - id: msgId, - status: "completed", - role: "assistant", - content: [{ type: "output_text", text: content }], - }; + const { respId, created, events, prefixOutputItems, nextOutputIndex } = buildResponsePreamble( + model, + chunkSize, + reasoning, + webSearches, + ); - // output_item.done - events.push({ - type: "response.output_item.done", - output_index: msgOutputIndex, - item: msgItem, - }); + const { events: msgEvents, msgItem } = buildMessageOutputEvents( + content, + chunkSize, + nextOutputIndex, + ); + events.push(...msgEvents); - // response.completed events.push({ type: "response.completed", response: { @@ -300,11 +192,7 @@ export function buildTextStreamEvents( model, status: "completed", output: [...prefixOutputItems, msgItem], - usage: { - input_tokens: 0, - output_tokens: 0, - total_tokens: 0, - }, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, }, }); @@ -524,16 +412,142 @@ function buildWebSearchStreamEvents( return events; } -// Non-streaming response builders +// ─── Shared streaming helpers ──────────────────────────────────────────────── -function buildTextResponse( - content: string, +interface PreambleResult { + respId: string; + created: number; + events: ResponsesSSEEvent[]; + prefixOutputItems: object[]; + nextOutputIndex: number; +} + +function buildResponsePreamble( model: string, + chunkSize: number, reasoning?: string, webSearches?: string[], -): object { +): PreambleResult { const respId = responseId(); + const created = Math.floor(Date.now() / 1000); + const events: ResponsesSSEEvent[] = []; + const prefixOutputItems: object[] = []; + let nextOutputIndex = 0; + + events.push({ + type: "response.created", + response: { + id: respId, + object: "response", + created_at: created, + model, + status: "in_progress", + output: [], + }, + }); + events.push({ + type: "response.in_progress", + response: { + id: respId, + object: "response", + created_at: created, + model, + status: "in_progress", + output: [], + }, + }); + + if (reasoning) { + const reasoningEvents = buildReasoningStreamEvents(reasoning, model, chunkSize); + events.push(...reasoningEvents); + const doneEvent = reasoningEvents.find( + (e) => + e.type === "response.output_item.done" && + (e.item as { type: string })?.type === "reasoning", + ); + if (doneEvent) prefixOutputItems.push(doneEvent.item as object); + nextOutputIndex++; + } + + if (webSearches && webSearches.length > 0) { + const searchEvents = buildWebSearchStreamEvents(webSearches, nextOutputIndex); + events.push(...searchEvents); + const doneEvents = searchEvents.filter( + (e) => + e.type === "response.output_item.done" && + (e.item as { type: string })?.type === "web_search_call", + ); + for (const de of doneEvents) prefixOutputItems.push(de.item as object); + nextOutputIndex += webSearches.length; + } + + return { respId, created, events, prefixOutputItems, nextOutputIndex }; +} + +interface MessageBlockResult { + events: ResponsesSSEEvent[]; + msgItem: object; +} + +function buildMessageOutputEvents( + content: string, + chunkSize: number, + outputIndex: number, +): MessageBlockResult { const msgId = itemId(); + const events: ResponsesSSEEvent[] = []; + + events.push({ + type: "response.output_item.added", + output_index: outputIndex, + item: { type: "message", id: msgId, status: "in_progress", role: "assistant", content: [] }, + }); + events.push({ + type: "response.content_part.added", + output_index: outputIndex, + content_index: 0, + part: { type: "output_text", text: "" }, + }); + + for (let i = 0; i < content.length; i += chunkSize) { + events.push({ + type: "response.output_text.delta", + item_id: msgId, + output_index: outputIndex, + content_index: 0, + delta: content.slice(i, i + chunkSize), + }); + } + + events.push({ + type: "response.output_text.done", + output_index: outputIndex, + content_index: 0, + text: content, + }); + events.push({ + type: "response.content_part.done", + output_index: outputIndex, + content_index: 0, + part: { type: "output_text", text: content }, + }); + + const msgItem = { + type: "message", + id: msgId, + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: content }], + }; + + events.push({ type: "response.output_item.done", output_index: outputIndex, item: msgItem }); + + return { events, msgItem }; +} + +// ─── Non-streaming response builders ──────────────────────────────────────── + +function buildOutputPrefix(content: string, reasoning?: string, webSearches?: string[]): object[] { const output: object[] = []; if (reasoning) { @@ -557,14 +571,18 @@ function buildTextResponse( output.push({ type: "message", - id: msgId, + id: itemId(), status: "completed", role: "assistant", content: [{ type: "output_text", text: content }], }); + return output; +} + +function buildResponseEnvelope(model: string, output: object[]): object { return { - id: respId, + id: responseId(), object: "response", created_at: Math.floor(Date.now() / 1000), model, @@ -574,15 +592,19 @@ function buildTextResponse( }; } +function buildTextResponse( + content: string, + model: string, + reasoning?: string, + webSearches?: string[], +): object { + return buildResponseEnvelope(model, buildOutputPrefix(content, reasoning, webSearches)); +} + function buildToolCallResponse(toolCalls: ToolCall[], model: string): object { - const respId = responseId(); - return { - id: respId, - object: "response", - created_at: Math.floor(Date.now() / 1000), + return buildResponseEnvelope( model, - status: "completed", - output: toolCalls.map((tc) => ({ + toolCalls.map((tc) => ({ type: "function_call", id: generateId("fc"), call_id: tc.id || generateToolCallId(), @@ -590,8 +612,114 @@ function buildToolCallResponse(toolCalls: ToolCall[], model: string): object { arguments: tc.arguments, status: "completed", })), - usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, - }; + ); +} + +export function buildContentWithToolCallsStreamEvents( + content: string, + toolCalls: ToolCall[], + model: string, + chunkSize: number, + reasoning?: string, + webSearches?: string[], +): ResponsesSSEEvent[] { + const { respId, created, events, prefixOutputItems, nextOutputIndex } = buildResponsePreamble( + model, + chunkSize, + reasoning, + webSearches, + ); + + const { events: msgEvents, msgItem } = buildMessageOutputEvents( + content, + chunkSize, + nextOutputIndex, + ); + events.push(...msgEvents); + + const fcOutputItems: object[] = []; + for (let idx = 0; idx < toolCalls.length; idx++) { + const tc = toolCalls[idx]; + const callId = tc.id || generateToolCallId(); + const fcId = generateId("fc"); + const fcOutputIndex = nextOutputIndex + 1 + idx; + const args = tc.arguments; + + events.push({ + type: "response.output_item.added", + output_index: fcOutputIndex, + item: { + type: "function_call", + id: fcId, + call_id: callId, + name: tc.name, + arguments: "", + status: "in_progress", + }, + }); + + for (let i = 0; i < args.length; i += chunkSize) { + events.push({ + type: "response.function_call_arguments.delta", + item_id: fcId, + output_index: fcOutputIndex, + delta: args.slice(i, i + chunkSize), + }); + } + + events.push({ + type: "response.function_call_arguments.done", + output_index: fcOutputIndex, + arguments: args, + }); + + const doneItem = { + type: "function_call", + id: fcId, + call_id: callId, + name: tc.name, + arguments: args, + status: "completed", + }; + events.push({ type: "response.output_item.done", output_index: fcOutputIndex, item: doneItem }); + fcOutputItems.push(doneItem); + } + + events.push({ + type: "response.completed", + response: { + id: respId, + object: "response", + created_at: created, + model, + status: "completed", + output: [...prefixOutputItems, msgItem, ...fcOutputItems], + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + }, + }); + + return events; +} + +function buildContentWithToolCallsResponse( + content: string, + toolCalls: ToolCall[], + model: string, + reasoning?: string, + webSearches?: string[], +): object { + const output = buildOutputPrefix(content, reasoning, webSearches); + for (const tc of toolCalls) { + output.push({ + type: "function_call", + id: generateId("fc"), + call_id: tc.id || generateToolCallId(), + name: tc.name, + arguments: tc.arguments, + status: "completed", + }); + } + return buildResponseEnvelope(model, output); } // ─── SSE writer for Responses API ─────────────────────────────────────────── @@ -776,6 +904,51 @@ export async function handleResponses( return; } + // Combined content + tool calls response + if (isContentWithToolCallsResponse(response)) { + const journalEntry = journal.add({ + method: req.method ?? "POST", + path: req.url ?? "/v1/responses", + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: 200, fixture }, + }); + if (responsesReq.stream !== true) { + const body = buildContentWithToolCallsResponse( + response.content, + response.toolCalls, + completionReq.model, + response.reasoning, + response.webSearches, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const events = buildContentWithToolCallsStreamEvents( + response.content, + response.toolCalls, + completionReq.model, + chunkSize, + response.reasoning, + response.webSearches, + ); + const interruption = createInterruptionSignal(fixture); + const completed = await writeResponsesSSEStream(res, events, { + latency, + streamingProfile: fixture.streamingProfile, + signal: interruption?.signal, + onChunkSent: interruption?.tick, + }); + if (!completed) { + if (!res.writableEnded) res.destroy(); + journalEntry.response.interrupted = true; + journalEntry.response.interruptReason = interruption?.reason(); + } + interruption?.cleanup(); + } + return; + } + // Text response if (isTextResponse(response)) { const journalEntry = journal.add({ diff --git a/src/server.ts b/src/server.ts index 07ac173..8e3ec15 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,8 +18,11 @@ import { buildToolCallChunks, buildTextCompletion, buildToolCallCompletion, + buildContentWithToolCallsChunks, + buildContentWithToolCallsCompletion, isTextResponse, isToolCallResponse, + isContentWithToolCallsResponse, isErrorResponse, flattenHeaders, } from "./helpers.js"; @@ -449,6 +452,47 @@ async function handleCompletions( return; } + // Content + tool calls response + if (isContentWithToolCallsResponse(response)) { + const journalEntry = journal.add({ + method: req.method ?? "POST", + path: req.url ?? COMPLETIONS_PATH, + headers: flattenHeaders(req.headers), + body, + response: { status: 200, fixture }, + }); + if (body.stream !== true) { + const completion = buildContentWithToolCallsCompletion( + response.content, + response.toolCalls, + body.model, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(completion)); + } else { + const chunks = buildContentWithToolCallsChunks( + response.content, + response.toolCalls, + body.model, + chunkSize, + ); + const interruption = createInterruptionSignal(fixture); + const completed = await writeSSEStream(res, chunks, { + latency, + streamingProfile: fixture.streamingProfile, + signal: interruption?.signal, + onChunkSent: interruption?.tick, + }); + if (!completed) { + if (!res.writableEnded) res.destroy(); + journalEntry.response.interrupted = true; + journalEntry.response.interruptReason = interruption?.reason(); + } + interruption?.cleanup(); + } + return; + } + // Text response if (isTextResponse(response)) { const journalEntry = journal.add({ From 22594b4e2e340781d65c3ea3d6b8e95376c39a73 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 15:39:02 -0700 Subject: [PATCH 3/4] feat: preserve both content and toolCalls in stream collapse functions When a recorded stream contains both content and tool calls, collapse functions now return both fields instead of dropping content. Updated collapseOpenAISSE, collapseAnthropicSSE, collapseGeminiSSE, and collapseOllamaNDJSON for record-and-replay fidelity. --- src/stream-collapse.ts | 45 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 1f555ab..c50e87c 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -3,7 +3,8 @@ * * Each function takes a raw streaming response body (SSE, NDJSON, or binary * EventStream) and collapses it into a non-streaming fixture response - * containing either `{ content }` or `{ toolCalls }`. + * containing `{ content }`, `{ toolCalls }`, or both when the stream includes + * text followed by tool calls. */ import { crc32 } from "node:zlib"; @@ -140,6 +141,7 @@ export function collapseOpenAISSE(body: string): CollapseResult { if (toolCallMap.size > 0) { const sorted = Array.from(toolCallMap.entries()).sort(([a], [b]) => a - b); return { + ...(content ? { content } : {}), toolCalls: sorted.map(([, tc]) => ({ name: tc.name, arguments: tc.arguments, @@ -229,6 +231,7 @@ export function collapseAnthropicSSE(body: string): CollapseResult { if (toolCallMap.size > 0) { const sorted = Array.from(toolCallMap.entries()).sort(([a], [b]) => a - b); return { + ...(content ? { content } : {}), toolCalls: sorted.map(([, tc]) => ({ name: tc.name, arguments: tc.arguments, @@ -260,6 +263,7 @@ export function collapseGeminiSSE(body: string): CollapseResult { const lines = body.split("\n\n").filter((l) => l.trim().length > 0); let content = ""; let droppedChunks = 0; + const toolCalls: ToolCall[] = []; for (const line of lines) { const dataLine = line.split("\n").find((l) => l.startsWith("data:")); @@ -284,32 +288,25 @@ export function collapseGeminiSSE(body: string): CollapseResult { const parts = candidateContent.parts as Array> | undefined; if (!parts || parts.length === 0) continue; - // Handle functionCall parts - const fnCallParts = parts.filter((p) => p.functionCall); - if (fnCallParts.length > 0) { - const toolCallMap = new Map(); - for (let i = 0; i < fnCallParts.length; i++) { - const fc = fnCallParts[i].functionCall as Record; - toolCallMap.set(i, { + for (const part of parts) { + if (part.functionCall) { + const fc = part.functionCall as Record; + toolCalls.push({ name: String(fc.name ?? ""), arguments: typeof fc.args === "string" ? (fc.args as string) : JSON.stringify(fc.args), }); - } - if (toolCallMap.size > 0) { - const sorted = Array.from(toolCallMap.entries()).sort(([a], [b]) => a - b); - return { - toolCalls: sorted.map(([, tc]) => ({ - name: tc.name, - arguments: tc.arguments, - })), - ...(droppedChunks > 0 ? { droppedChunks } : {}), - }; + } else if (typeof part.text === "string") { + content += part.text; } } + } - if (typeof parts[0].text === "string") { - content += parts[0].text; - } + if (toolCalls.length > 0) { + return { + ...(content ? { content } : {}), + toolCalls, + ...(droppedChunks > 0 ? { droppedChunks } : {}), + }; } return { content, ...(droppedChunks > 0 ? { droppedChunks } : {}) }; @@ -372,7 +369,11 @@ export function collapseOllamaNDJSON(body: string): CollapseResult { } if (toolCalls.length > 0) { - return { toolCalls, ...(droppedChunks > 0 ? { droppedChunks } : {}) }; + return { + ...(content ? { content } : {}), + toolCalls, + ...(droppedChunks > 0 ? { droppedChunks } : {}), + }; } return { content, ...(droppedChunks > 0 ? { droppedChunks } : {}) }; From 6a1782840a380bc7c8b453451a5c13f7896c7ec4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 15:39:09 -0700 Subject: [PATCH 4/4] test: 17 tests for combined content+toolCalls across all providers Type guard tests (5), streaming + non-streaming per provider (8), stream-collapse coexistence tests for OpenAI/Anthropic/Gemini/Ollama (4). --- src/__tests__/content-with-toolcalls.test.ts | 545 +++++++++++++++++++ src/__tests__/stream-collapse.test.ts | 6 +- 2 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/content-with-toolcalls.test.ts diff --git a/src/__tests__/content-with-toolcalls.test.ts b/src/__tests__/content-with-toolcalls.test.ts new file mode 100644 index 0000000..54dc897 --- /dev/null +++ b/src/__tests__/content-with-toolcalls.test.ts @@ -0,0 +1,545 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { isContentWithToolCallsResponse, isTextResponse, isToolCallResponse } from "../helpers.js"; +import { LLMock } from "../llmock.js"; +import type { SSEChunk } from "../types.js"; + +describe("isContentWithToolCallsResponse", () => { + it("returns true when both content and toolCalls are present", () => { + const r = { + content: "Hello", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }; + expect(isContentWithToolCallsResponse(r)).toBe(true); + }); + + it("returns false for text-only response", () => { + const r = { content: "Hello" }; + expect(isContentWithToolCallsResponse(r)).toBe(false); + }); + + it("returns false for tool-call-only response", () => { + const r = { toolCalls: [{ name: "get_weather", arguments: "{}" }] }; + expect(isContentWithToolCallsResponse(r)).toBe(false); + }); + + it("returns false for error response", () => { + const r = { error: { message: "fail" } }; + expect(isContentWithToolCallsResponse(r)).toBe(false); + }); + + it("existing guards still work for combined response", () => { + const r = { + content: "Hello", + toolCalls: [{ name: "get_weather", arguments: "{}" }], + }; + // Both existing guards would match — that's why we check combined first + expect(isTextResponse(r)).toBe(true); + expect(isToolCallResponse(r)).toBe(true); + }); +}); + +function parseSSEChunks(body: string): SSEChunk[] { + return body + .split("\n\n") + .filter((line) => line.startsWith("data: ") && !line.includes("[DONE]")) + .map((line) => JSON.parse(line.slice(6)) as SSEChunk); +} + +describe("OpenAI Chat Completions — content + toolCalls", () => { + let mock: LLMock | null = null; + + afterEach(async () => { + if (mock) { + await mock.stop(); + mock = null; + } + }); + + it("streams content chunks then tool call chunks with finish_reason tool_calls", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test combined" }, + response: { + content: "Let me check.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "test combined" }], + stream: true, + }), + }); + + const chunks = parseSSEChunks(await res.text()); + const contentChunks = chunks.filter((c) => c.choices?.[0]?.delta?.content); + const toolChunks = chunks.filter((c) => c.choices?.[0]?.delta?.tool_calls); + const finishChunk = chunks.find((c) => c.choices?.[0]?.finish_reason); + + expect(contentChunks.length).toBeGreaterThan(0); + expect(toolChunks.length).toBeGreaterThan(0); + expect(finishChunk!.choices[0].finish_reason).toBe("tool_calls"); + + const lastContentIdx = chunks.lastIndexOf(contentChunks.at(-1)!); + const firstToolIdx = chunks.indexOf(toolChunks[0]); + expect(lastContentIdx).toBeLessThan(firstToolIdx); + + const fullContent = contentChunks.map((c) => c.choices[0].delta.content).join(""); + expect(fullContent).toBe("Let me check."); + }); + + it("non-streaming returns both content and tool_calls", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test combined non-stream" }, + response: { + content: "Checking now.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "test combined non-stream" }], + stream: false, + }), + }); + + const body = await res.json(); + const msg = body.choices[0].message; + expect(msg.content).toBe("Checking now."); + expect(msg.tool_calls).toHaveLength(1); + expect(msg.tool_calls[0].function.name).toBe("get_weather"); + expect(body.choices[0].finish_reason).toBe("tool_calls"); + }); +}); + +function parseResponsesSSEEvents(body: string): Array<{ type: string; [key: string]: unknown }> { + return body + .split("\n\n") + .filter((block) => block.trim().length > 0) + .map((block) => { + const dataLine = block.split("\n").find((l) => l.startsWith("data: ")); + if (!dataLine) return null; + return JSON.parse(dataLine.slice(6)) as { type: string; [key: string]: unknown }; + }) + .filter(Boolean) as Array<{ type: string; [key: string]: unknown }>; +} + +describe("OpenAI Responses API — content + toolCalls", () => { + let mock: LLMock | null = null; + + afterEach(async () => { + if (mock) { + await mock.stop(); + mock = null; + } + }); + + it("streams text output then function_call output", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test responses combined" }, + response: { + content: "Sure.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/responses`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, + body: JSON.stringify({ + model: "gpt-4o", + input: [{ role: "user", content: "test responses combined" }], + stream: true, + }), + }); + + const events = parseResponsesSSEEvents(await res.text()); + + const textDelta = events.find((e) => e.type === "response.output_text.delta"); + const fcAdded = events.find( + (e) => + e.type === "response.output_item.added" && + (e.item as { type: string })?.type === "function_call", + ); + const completed = events.find((e) => e.type === "response.completed"); + const output = (completed!.response as { output: Array<{ type: string }> }).output; + + expect(textDelta).toBeDefined(); + const allTextDeltas = events + .filter((e) => e.type === "response.output_text.delta") + .map((e) => (e as unknown as { delta: string }).delta) + .join(""); + expect(allTextDeltas).toBe("Sure."); + expect(fcAdded).toBeDefined(); + + const types = output.map((o) => o.type); + expect(types).toContain("message"); + expect(types).toContain("function_call"); + expect(types.indexOf("message")).toBeLessThan(types.indexOf("function_call")); + }); + + it("non-streaming returns both message and function_call output", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test responses combined ns" }, + response: { + content: "Sure.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/responses`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer test" }, + body: JSON.stringify({ + model: "gpt-4o", + input: [{ role: "user", content: "test responses combined ns" }], + stream: false, + }), + }); + + const body = await res.json(); + const output = body.output as Array<{ type: string; content?: Array<{ text: string }> }>; + const msgItem = output.find((o) => o.type === "message"); + const fcItem = output.find((o) => o.type === "function_call"); + + expect(msgItem).toBeDefined(); + expect(msgItem!.content![0].text).toBe("Sure."); + expect(fcItem).toBeDefined(); + }); +}); + +function parseAnthropicSSEEvents(body: string): Array<{ type: string; [key: string]: unknown }> { + return body + .split("\n\n") + .filter((block) => block.trim().length > 0) + .map((block) => { + const dataLine = block.split("\n").find((l) => l.startsWith("data: ")); + if (!dataLine) return null; + return JSON.parse(dataLine.slice(6)) as { type: string; [key: string]: unknown }; + }) + .filter(Boolean) as Array<{ type: string; [key: string]: unknown }>; +} + +describe("Anthropic Messages — content + toolCalls", () => { + let mock: LLMock | null = null; + + afterEach(async () => { + if (mock) { + await mock.stop(); + mock = null; + } + }); + + it("streams text block then tool_use blocks with stop_reason tool_use", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test anthropic combined" }, + response: { + content: "Checking.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [{ role: "user", content: "test anthropic combined" }], + stream: true, + }), + }); + + const events = parseAnthropicSSEEvents(await res.text()); + + const textBlockStart = events.find( + (e) => + e.type === "content_block_start" && (e.content_block as { type: string })?.type === "text", + ); + const toolBlockStart = events.find( + (e) => + e.type === "content_block_start" && + (e.content_block as { type: string })?.type === "tool_use", + ); + const messageDelta = events.find((e) => e.type === "message_delta"); + + expect(textBlockStart).toBeDefined(); + expect(toolBlockStart).toBeDefined(); + expect((messageDelta!.delta as { stop_reason: string }).stop_reason).toBe("tool_use"); + + const textIdx = events.indexOf(textBlockStart!); + const toolIdx = events.indexOf(toolBlockStart!); + expect(textIdx).toBeLessThan(toolIdx); + }); + + it("non-streaming returns text and tool_use content blocks", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test anthropic combined ns" }, + response: { + content: "Checking.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-key", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-20250514", + max_tokens: 1024, + messages: [{ role: "user", content: "test anthropic combined ns" }], + stream: false, + }), + }); + + const body = await res.json(); + expect(body.content).toHaveLength(2); + expect(body.content[0].type).toBe("text"); + expect(body.content[0].text).toBe("Checking."); + expect(body.content[1].type).toBe("tool_use"); + expect(body.content[1].name).toBe("get_weather"); + expect(body.stop_reason).toBe("tool_use"); + }); +}); + +describe("Gemini — content + toolCalls", () => { + let mock: LLMock | null = null; + + afterEach(async () => { + if (mock) { + await mock.stop(); + mock = null; + } + }); + + it("streams text chunks then functionCall chunk with FUNCTION_CALL finishReason", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test gemini combined" }, + response: { + content: "Sure.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch( + `${mock.url}/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "test gemini combined" }] }], + }), + }, + ); + + const text = await res.text(); + const chunks = text + .split("\n\n") + .filter((block) => block.trim().length > 0) + .map((block) => { + const dataLine = block.split("\n").find((l) => l.startsWith("data: ")); + return dataLine ? JSON.parse(dataLine.slice(6)) : null; + }) + .filter(Boolean) as Array<{ + candidates: Array<{ + content: { parts: Array<{ text?: string; functionCall?: unknown }> }; + finishReason?: string; + }>; + }>; + + const textChunks = chunks.filter((c) => + c.candidates[0].content.parts.some((p) => p.text !== undefined), + ); + const fcChunks = chunks.filter((c) => + c.candidates[0].content.parts.some((p) => p.functionCall !== undefined), + ); + + expect(textChunks.length).toBeGreaterThan(0); + expect(fcChunks.length).toBeGreaterThan(0); + + const lastChunk = chunks[chunks.length - 1]; + expect(lastChunk.candidates[0].finishReason).toBe("FUNCTION_CALL"); + + const lastTextIdx = chunks.lastIndexOf(textChunks.at(-1)!); + const firstFcIdx = chunks.indexOf(fcChunks[0]); + expect(lastTextIdx).toBeLessThan(firstFcIdx); + }); + + it("non-streaming returns both text and functionCall parts", async () => { + mock = new LLMock({ port: 0 }); + mock.addFixture({ + match: { userMessage: "test gemini combined ns" }, + response: { + content: "Sure.", + toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }], + }, + }); + await mock.start(); + + const res = await fetch(`${mock.url}/v1beta/models/gemini-2.0-flash:generateContent`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "test gemini combined ns" }] }], + }), + }); + + const body = await res.json(); + const parts = body.candidates[0].content.parts; + const textParts = parts.filter((p: { text?: string }) => p.text !== undefined); + const fcParts = parts.filter((p: { functionCall?: unknown }) => p.functionCall !== undefined); + + expect(textParts.length).toBeGreaterThan(0); + expect(textParts[0].text).toBe("Sure."); + expect(fcParts.length).toBeGreaterThan(0); + expect(fcParts[0].functionCall.name).toBe("get_weather"); + expect(body.candidates[0].finishReason).toBe("FUNCTION_CALL"); + }); +}); + +import { + collapseOpenAISSE, + collapseAnthropicSSE, + collapseGeminiSSE, + collapseOllamaNDJSON, +} from "../stream-collapse.js"; + +describe("stream-collapse — content + toolCalls coexistence", () => { + it("OpenAI: preserves both content and toolCalls", () => { + const body = [ + `data: ${JSON.stringify({ id: "c1", choices: [{ delta: { role: "assistant" } }] })}`, + "", + `data: ${JSON.stringify({ id: "c1", choices: [{ delta: { content: "Hello" } }] })}`, + "", + `data: ${JSON.stringify({ + id: "c1", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: '{"city":"NYC"}' }, + }, + ], + }, + }, + ], + })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + + const result = collapseOpenAISSE(body); + expect(result.content).toBe("Hello"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("get_weather"); + }); + + it("Anthropic: preserves both content and toolCalls", () => { + const body = [ + `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: {} })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_abc", name: "get_weather", input: {} } })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"city":"NYC"}' } })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 1 })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "tool_use" } })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ].join("\n"); + + const result = collapseAnthropicSSE(body); + expect(result.content).toBe("Hello"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("get_weather"); + }); + + it("Gemini: preserves both content and toolCalls", () => { + const body = [ + `data: ${JSON.stringify({ + candidates: [{ content: { role: "model", parts: [{ text: "Hello" }] }, index: 0 }], + })}`, + "", + `data: ${JSON.stringify({ + candidates: [ + { + content: { + role: "model", + parts: [{ functionCall: { name: "get_weather", args: { city: "NYC" } } }], + }, + finishReason: "FUNCTION_CALL", + index: 0, + }, + ], + })}`, + "", + ].join("\n"); + + const result = collapseGeminiSSE(body); + expect(result.content).toBe("Hello"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("get_weather"); + }); + + it("Ollama: preserves both content and toolCalls", () => { + const body = [ + JSON.stringify({ + model: "llama3", + message: { + role: "assistant", + content: "Hello", + tool_calls: [{ function: { name: "get_weather", arguments: { city: "NYC" } } }], + }, + done: false, + }), + JSON.stringify({ model: "llama3", message: { role: "assistant", content: "" }, done: true }), + ].join("\n"); + + const result = collapseOllamaNDJSON(body); + expect(result.content).toBe("Hello"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("get_weather"); + }); +}); diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index b89c062..ddb7299 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -1568,7 +1568,7 @@ describe("collapseOllamaNDJSON with tool_calls", () => { expect(result.content).toBeUndefined(); }); - it("returns toolCalls (not content) when both tool_calls and text are present", () => { + it("preserves both content and toolCalls when both tool_calls and text are present", () => { const body = [ JSON.stringify({ model: "llama3", @@ -1594,11 +1594,11 @@ describe("collapseOllamaNDJSON with tool_calls", () => { ].join("\n"); const result = collapseOllamaNDJSON(body); - // When toolCalls are present, they take priority over content + // When toolCalls are present alongside content, both are preserved expect(result.toolCalls).toBeDefined(); expect(result.toolCalls).toHaveLength(1); expect(result.toolCalls![0].name).toBe("get_weather"); - expect(result.content).toBeUndefined(); + expect(result.content).toBe("Let me check the weather."); }); it("extracts multiple tool_calls across chunks", () => {