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", () => { 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/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/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({ 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 } : {}) }; 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