Skip to content

Commit 307073b

Browse files
authored
feat: support content + toolCalls together in fixture responses (#92)
## Summary - Adds `ContentWithToolCallsResponse` type to `FixtureResponse` union, allowing fixtures to specify both `content` and `toolCalls` in a single response - Implements combined streaming and non-streaming support across all 4 provider formats: OpenAI Chat Completions, OpenAI Responses API, Anthropic Messages, and Gemini - Updates stream-collapse functions to preserve both content and toolCalls when recording responses that contain both - Content is streamed first, then tool calls, with the appropriate tool-call finish reason per provider (`tool_calls`, `tool_use`, `FUNCTION_CALL`) ## Test plan - [ ] 17 new tests covering streaming + non-streaming for all 4 providers, plus stream-collapse coexistence for OpenAI/Anthropic/Gemini/Ollama - [ ] Full suite: 2043 tests pass, 0 failures - [ ] Lint and format clean
2 parents e5a156e + 6a17828 commit 307073b

File tree

9 files changed

+1433
-193
lines changed

9 files changed

+1433
-193
lines changed

src/__tests__/content-with-toolcalls.test.ts

Lines changed: 545 additions & 0 deletions
Large diffs are not rendered by default.

src/__tests__/stream-collapse.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,7 +1568,7 @@ describe("collapseOllamaNDJSON with tool_calls", () => {
15681568
expect(result.content).toBeUndefined();
15691569
});
15701570

1571-
it("returns toolCalls (not content) when both tool_calls and text are present", () => {
1571+
it("preserves both content and toolCalls when both tool_calls and text are present", () => {
15721572
const body = [
15731573
JSON.stringify({
15741574
model: "llama3",
@@ -1594,11 +1594,11 @@ describe("collapseOllamaNDJSON with tool_calls", () => {
15941594
].join("\n");
15951595

15961596
const result = collapseOllamaNDJSON(body);
1597-
// When toolCalls are present, they take priority over content
1597+
// When toolCalls are present alongside content, both are preserved
15981598
expect(result.toolCalls).toBeDefined();
15991599
expect(result.toolCalls).toHaveLength(1);
16001600
expect(result.toolCalls![0].name).toBe("get_weather");
1601-
expect(result.content).toBeUndefined();
1601+
expect(result.content).toBe("Let me check the weather.");
16021602
});
16031603

16041604
it("extracts multiple tool_calls across chunks", () => {

src/gemini.ts

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
import {
2121
isTextResponse,
2222
isToolCallResponse,
23+
isContentWithToolCallsResponse,
2324
isErrorResponse,
2425
generateToolCallId,
2526
flattenHeaders,
@@ -256,24 +257,22 @@ function buildGeminiTextStreamChunks(
256257
return chunks;
257258
}
258259

260+
function parseToolCallPart(tc: ToolCall, logger: Logger): GeminiPart {
261+
let argsObj: Record<string, unknown>;
262+
try {
263+
argsObj = JSON.parse(tc.arguments || "{}") as Record<string, unknown>;
264+
} catch {
265+
logger.warn(`Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`);
266+
argsObj = {};
267+
}
268+
return { functionCall: { name: tc.name, args: argsObj, id: tc.id || generateToolCallId() } };
269+
}
270+
259271
function buildGeminiToolCallStreamChunks(
260272
toolCalls: ToolCall[],
261273
logger: Logger,
262274
): GeminiResponseChunk[] {
263-
const parts: GeminiPart[] = toolCalls.map((tc) => {
264-
let argsObj: Record<string, unknown>;
265-
try {
266-
argsObj = JSON.parse(tc.arguments || "{}") as Record<string, unknown>;
267-
} catch {
268-
logger.warn(
269-
`Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`,
270-
);
271-
argsObj = {};
272-
}
273-
return {
274-
functionCall: { name: tc.name, args: argsObj, id: tc.id || generateToolCallId() },
275-
};
276-
});
275+
const parts: GeminiPart[] = toolCalls.map((tc) => parseToolCallPart(tc, logger));
277276

278277
// Gemini sends all tool calls in a single response chunk
279278
return [
@@ -320,21 +319,85 @@ function buildGeminiTextResponse(content: string, reasoning?: string): GeminiRes
320319
}
321320

322321
function buildGeminiToolCallResponse(toolCalls: ToolCall[], logger: Logger): GeminiResponseChunk {
323-
const parts: GeminiPart[] = toolCalls.map((tc) => {
324-
let argsObj: Record<string, unknown>;
325-
try {
326-
argsObj = JSON.parse(tc.arguments || "{}") as Record<string, unknown>;
327-
} catch {
328-
logger.warn(
329-
`Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`,
330-
);
331-
argsObj = {};
322+
const parts: GeminiPart[] = toolCalls.map((tc) => parseToolCallPart(tc, logger));
323+
324+
return {
325+
candidates: [
326+
{
327+
content: { role: "model", parts },
328+
finishReason: "FUNCTION_CALL",
329+
index: 0,
330+
},
331+
],
332+
usageMetadata: {
333+
promptTokenCount: 0,
334+
candidatesTokenCount: 0,
335+
totalTokenCount: 0,
336+
},
337+
};
338+
}
339+
340+
function buildGeminiContentWithToolCallsStreamChunks(
341+
content: string,
342+
toolCalls: ToolCall[],
343+
chunkSize: number,
344+
logger: Logger,
345+
): GeminiResponseChunk[] {
346+
const chunks: GeminiResponseChunk[] = [];
347+
348+
if (content.length === 0) {
349+
chunks.push({
350+
candidates: [
351+
{
352+
content: { role: "model", parts: [{ text: "" }] },
353+
index: 0,
354+
},
355+
],
356+
});
357+
} else {
358+
for (let i = 0; i < content.length; i += chunkSize) {
359+
const slice = content.slice(i, i + chunkSize);
360+
chunks.push({
361+
candidates: [
362+
{
363+
content: { role: "model", parts: [{ text: slice }] },
364+
index: 0,
365+
},
366+
],
367+
});
332368
}
333-
return {
334-
functionCall: { name: tc.name, args: argsObj, id: tc.id || generateToolCallId() },
335-
};
369+
}
370+
371+
const parts: GeminiPart[] = toolCalls.map((tc) => parseToolCallPart(tc, logger));
372+
373+
chunks.push({
374+
candidates: [
375+
{
376+
content: { role: "model", parts },
377+
finishReason: "FUNCTION_CALL",
378+
index: 0,
379+
},
380+
],
381+
usageMetadata: {
382+
promptTokenCount: 0,
383+
candidatesTokenCount: 0,
384+
totalTokenCount: 0,
385+
},
336386
});
337387

388+
return chunks;
389+
}
390+
391+
function buildGeminiContentWithToolCallsResponse(
392+
content: string,
393+
toolCalls: ToolCall[],
394+
logger: Logger,
395+
): GeminiResponseChunk {
396+
const parts: GeminiPart[] = [
397+
{ text: content },
398+
...toolCalls.map((tc) => parseToolCallPart(tc, logger)),
399+
];
400+
338401
return {
339402
candidates: [
340403
{
@@ -549,6 +612,47 @@ export async function handleGemini(
549612
return;
550613
}
551614

615+
// Content + tool calls response (must be checked before isTextResponse / isToolCallResponse)
616+
if (isContentWithToolCallsResponse(response)) {
617+
const journalEntry = journal.add({
618+
method: req.method ?? "POST",
619+
path,
620+
headers: flattenHeaders(req.headers),
621+
body: completionReq,
622+
response: { status: 200, fixture },
623+
});
624+
if (!streaming) {
625+
const body = buildGeminiContentWithToolCallsResponse(
626+
response.content,
627+
response.toolCalls,
628+
logger,
629+
);
630+
res.writeHead(200, { "Content-Type": "application/json" });
631+
res.end(JSON.stringify(body));
632+
} else {
633+
const chunks = buildGeminiContentWithToolCallsStreamChunks(
634+
response.content,
635+
response.toolCalls,
636+
chunkSize,
637+
logger,
638+
);
639+
const interruption = createInterruptionSignal(fixture);
640+
const completed = await writeGeminiSSEStream(res, chunks, {
641+
latency,
642+
streamingProfile: fixture.streamingProfile,
643+
signal: interruption?.signal,
644+
onChunkSent: interruption?.tick,
645+
});
646+
if (!completed) {
647+
if (!res.writableEnded) res.destroy();
648+
journalEntry.response.interrupted = true;
649+
journalEntry.response.interruptReason = interruption?.reason();
650+
}
651+
interruption?.cleanup();
652+
}
653+
return;
654+
}
655+
552656
// Text response
553657
if (isTextResponse(response)) {
554658
const journalEntry = journal.add({

src/helpers.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
FixtureResponse,
55
TextResponse,
66
ToolCallResponse,
7+
ContentWithToolCallsResponse,
78
ErrorResponse,
89
EmbeddingResponse,
910
SSEChunk,
@@ -50,6 +51,17 @@ export function isToolCallResponse(r: FixtureResponse): r is ToolCallResponse {
5051
return "toolCalls" in r && Array.isArray((r as ToolCallResponse).toolCalls);
5152
}
5253

54+
export function isContentWithToolCallsResponse(
55+
r: FixtureResponse,
56+
): r is ContentWithToolCallsResponse {
57+
return (
58+
"content" in r &&
59+
typeof (r as ContentWithToolCallsResponse).content === "string" &&
60+
"toolCalls" in r &&
61+
Array.isArray((r as ContentWithToolCallsResponse).toolCalls)
62+
);
63+
}
64+
5365
export function isErrorResponse(r: FixtureResponse): r is ErrorResponse {
5466
return (
5567
"error" in r &&
@@ -254,6 +266,130 @@ export function buildToolCallCompletion(toolCalls: ToolCall[], model: string): C
254266
};
255267
}
256268

269+
export function buildContentWithToolCallsChunks(
270+
content: string,
271+
toolCalls: ToolCall[],
272+
model: string,
273+
chunkSize: number,
274+
): SSEChunk[] {
275+
const id = generateId();
276+
const created = Math.floor(Date.now() / 1000);
277+
const chunks: SSEChunk[] = [];
278+
279+
// Role chunk
280+
chunks.push({
281+
id,
282+
object: "chat.completion.chunk",
283+
created,
284+
model,
285+
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
286+
});
287+
288+
// Content chunks
289+
for (let i = 0; i < content.length; i += chunkSize) {
290+
const slice = content.slice(i, i + chunkSize);
291+
chunks.push({
292+
id,
293+
object: "chat.completion.chunk",
294+
created,
295+
model,
296+
choices: [{ index: 0, delta: { content: slice }, finish_reason: null }],
297+
});
298+
}
299+
300+
// Tool call chunks — one initial chunk per tool call, then argument chunks
301+
for (let tcIdx = 0; tcIdx < toolCalls.length; tcIdx++) {
302+
const tc = toolCalls[tcIdx];
303+
const tcId = tc.id || generateToolCallId();
304+
305+
// Initial tool call chunk (id + function name)
306+
chunks.push({
307+
id,
308+
object: "chat.completion.chunk",
309+
created,
310+
model,
311+
choices: [
312+
{
313+
index: 0,
314+
delta: {
315+
tool_calls: [
316+
{
317+
index: tcIdx,
318+
id: tcId,
319+
type: "function",
320+
function: { name: tc.name, arguments: "" },
321+
},
322+
],
323+
},
324+
finish_reason: null,
325+
},
326+
],
327+
});
328+
329+
// Argument streaming chunks
330+
const args = tc.arguments;
331+
for (let i = 0; i < args.length; i += chunkSize) {
332+
const slice = args.slice(i, i + chunkSize);
333+
chunks.push({
334+
id,
335+
object: "chat.completion.chunk",
336+
created,
337+
model,
338+
choices: [
339+
{
340+
index: 0,
341+
delta: {
342+
tool_calls: [{ index: tcIdx, function: { arguments: slice } }],
343+
},
344+
finish_reason: null,
345+
},
346+
],
347+
});
348+
}
349+
}
350+
351+
// Finish chunk
352+
chunks.push({
353+
id,
354+
object: "chat.completion.chunk",
355+
created,
356+
model,
357+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
358+
});
359+
360+
return chunks;
361+
}
362+
363+
export function buildContentWithToolCallsCompletion(
364+
content: string,
365+
toolCalls: ToolCall[],
366+
model: string,
367+
): ChatCompletion {
368+
return {
369+
id: generateId(),
370+
object: "chat.completion",
371+
created: Math.floor(Date.now() / 1000),
372+
model,
373+
choices: [
374+
{
375+
index: 0,
376+
message: {
377+
role: "assistant",
378+
content,
379+
refusal: null,
380+
tool_calls: toolCalls.map((tc) => ({
381+
id: tc.id || generateToolCallId(),
382+
type: "function" as const,
383+
function: { name: tc.name, arguments: tc.arguments },
384+
})),
385+
},
386+
finish_reason: "tool_calls",
387+
},
388+
],
389+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
390+
};
391+
}
392+
257393
// ─── HTTP helpers ─────────────────────────────────────────────────────────
258394

259395
export function readBody(req: http.IncomingMessage): Promise<string> {

0 commit comments

Comments
 (0)