kimi-code/packages/kosong/test/google-genai.test.ts

1884 lines
62 KiB
TypeScript

import {
APIConnectionError,
APIContextOverflowError,
APIProviderRateLimitError,
APIStatusError,
APITimeoutError,
ChatProviderError,
} from '#/errors';
import type { Message, StreamedMessagePart, ToolCall } from '#/message';
import {
convertGoogleGenAIError,
GoogleGenAIChatProvider,
GoogleGenAIStreamedMessage,
messagesToGoogleGenAIContents,
} from '#/providers/google-genai';
import type { GenerateOptions } from '#/provider';
import type { Tool } from '#/tool';
import { describe, it, expect, vi } from 'vitest';
function makeGenerateContentResponse() {
return {
candidates: [
{
content: { parts: [{ text: 'Hello' }], role: 'model' },
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
},
modelVersion: 'gemini-2.5-flash',
};
}
function createProvider(
options?: Partial<{ model: string; vertexai: boolean; stream: boolean }>,
): GoogleGenAIChatProvider {
return new GoogleGenAIChatProvider({
model: options?.model ?? 'gemini-2.5-flash',
apiKey: 'test-key',
vertexai: options?.vertexai,
stream: options?.stream,
});
}
/** Capture the request body by mocking the client's generateContentStream. */
async function captureRequestBody(
provider: GoogleGenAIChatProvider,
systemPrompt: string,
tools: Tool[],
history: Message[],
options?: GenerateOptions,
): Promise<Record<string, unknown>> {
let capturedBody: Record<string, unknown> | undefined;
const mockModels = (provider as any)._client.models as Record<string, unknown>;
async function* mockStream() {
yield makeGenerateContentResponse();
}
mockModels['generateContentStream'] = vi.fn().mockImplementation((params: unknown) => {
capturedBody = params as Record<string, unknown>;
return Promise.resolve(mockStream());
});
mockModels['generateContent'] = vi.fn().mockImplementation((params: unknown) => {
capturedBody = params as Record<string, unknown>;
return Promise.resolve(makeGenerateContentResponse());
});
const stream = await provider.generate(systemPrompt, tools, history, options);
for await (const part of stream) {
void part;
}
if (capturedBody === undefined) {
throw new Error('Expected provider.generate() to call a Google GenAI model endpoint');
}
return capturedBody;
}
/** Collect all parts from a StreamedMessage. */
async function collectParts(msg: {
[Symbol.asyncIterator](): AsyncIterator<StreamedMessagePart>;
}): Promise<StreamedMessagePart[]> {
const parts: StreamedMessagePart[] = [];
for await (const part of msg) {
parts.push(part);
}
return parts;
}
const ADD_TOOL: Tool = {
name: 'add',
description: 'Add two integers.',
parameters: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
};
const MUL_TOOL: Tool = {
name: 'multiply',
description: 'Multiply two integers.',
parameters: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
};
describe('GoogleGenAIChatProvider', () => {
describe('message conversion', () => {
it('simple user message with system prompt', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hello!' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, 'You are helpful.', [], history);
expect(body['contents']).toEqual([{ parts: [{ text: 'Hello!' }], role: 'user' }]);
const config = body['config'] as Record<string, unknown>;
expect(config['systemInstruction']).toBe('You are helpful.');
});
it('maps json_schema response format to response config', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] },
];
const schema = {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
additionalProperties: false,
};
const body = await captureRequestBody(provider, '', [], history, {
responseFormat: {
type: 'json_schema',
jsonSchema: {
name: 'contact',
schema,
strict: true,
},
},
});
const config = body['config'] as Record<string, unknown>;
expect(config['responseMimeType']).toBe('application/json');
expect(config['responseJsonSchema']).toEqual(schema);
});
it('replaces native responseSchema when applying json_schema response format', async () => {
const provider = createProvider().withGenerationKwargs({
responseSchema: {
type: 'object',
properties: { old: { type: 'string' } },
},
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] },
];
const schema = {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
additionalProperties: false,
};
const body = await captureRequestBody(provider, '', [], history, {
responseFormat: {
type: 'json_schema',
jsonSchema: {
name: 'contact',
schema,
strict: true,
},
},
});
const config = body['config'] as Record<string, unknown>;
expect(config['responseSchema']).toBeUndefined();
expect(config['responseJsonSchema']).toEqual(schema);
});
it('system messages in history are wrapped and emitted as user content', () => {
// Regression: Google GenAI's Content.role only accepts "user" or
// "model", so a `system` message sitting in the replay history (from
// session restore or cross-provider migration) would be rejected by
// the API. messagesToGoogleGenAIContents must transform it into a
// user turn wrapped in <system>...</system> tags so the information
// survives without provoking a 400.
const messages: Message[] = [
{
role: 'system',
content: [{ type: 'text', text: 'You are helpful.' }],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: 'Hi' }],
toolCalls: [],
},
];
const contents = messagesToGoogleGenAIContents(messages);
// The system turn is wrapped as a user turn, then merged with the
// following real user turn — Gemini/Vertex would reject the two
// consecutive user Contents the wrap would otherwise produce. The
// <system>…</system> tags keep the boundary legible within the merged
// turn.
expect(contents).toHaveLength(1);
const merged = contents[0] as unknown as {
role: string;
parts: Array<{ text?: string }>;
};
expect(merged.role).toBe('user');
expect(merged.parts).toHaveLength(2);
expect(merged.parts[0]!.text).toBe('<system>You are helpful.</system>');
expect(merged.parts[1]!.text).toBe('Hi');
// No emitted content carries the unsupported "system" role.
for (const c of contents) {
expect((c as unknown as { role: string }).role).not.toBe('system');
}
});
it('empty system messages in history are dropped', () => {
// A system message with no textual content contributes nothing; it
// would be pointless (and arguably confusing to models) to emit an
// empty <system></system> user turn, so we skip it entirely.
const messages: Message[] = [
{
role: 'system',
content: [],
toolCalls: [],
},
{
role: 'user',
content: [{ type: 'text', text: 'Hi' }],
toolCalls: [],
},
];
const contents = messagesToGoogleGenAIContents(messages);
expect(contents).toHaveLength(1);
expect((contents[0] as unknown as { role: string }).role).toBe('user');
});
it('multi-turn conversation with assistant mapped to model', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: '2+2 equals 4.' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'And 3+3?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['contents']).toEqual([
{ parts: [{ text: 'What is 2+2?' }], role: 'user' },
{ parts: [{ text: '2+2 equals 4.' }], role: 'model' },
{ parts: [{ text: 'And 3+3?' }], role: 'user' },
]);
});
it('merges consecutive user messages into one Content (post-compaction shape)', () => {
// After compaction the history is `[kept user prompts, user-role summary,
// injected reminders]` — all role 'user'. Gemini/Vertex require strictly
// alternating user/model turns and reject consecutive user Contents, so
// the converter must collapse them into a single user Content.
const contents = messagesToGoogleGenAIContents([
{ role: 'user', content: [{ type: 'text', text: 'Earlier prompt' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'Conversation summary' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'A reminder' }], toolCalls: [] },
]);
expect(contents).toEqual([
{
role: 'user',
parts: [
{ text: 'Earlier prompt' },
{ text: 'Conversation summary' },
{ text: 'A reminder' },
],
},
]);
});
it('merges a trailing user turn into the preceding tool-result Content', () => {
// A user turn arriving right after a tool result (e.g. steering) would
// otherwise produce two consecutive user Contents (the function-response
// turn and the steer text), which Gemini/Vertex rejects.
const toolCall: ToolCall = {
type: 'function',
id: 'call_1',
name: 'add',
arguments: '{"a": 2, "b": 3}',
};
const contents = messagesToGoogleGenAIContents([
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{ role: 'assistant', content: [], toolCalls: [toolCall] },
{ role: 'tool', content: [{ type: 'text', text: '5' }], toolCallId: 'call_1', toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'Now multiply' }], toolCalls: [] },
]);
expect(contents.map((c) => c.role)).toEqual(['user', 'model', 'user']);
const last = contents.at(-1)!;
expect(last.parts.some((p) => p.functionResponse !== undefined)).toBe(true);
expect(last.parts.some((p) => p.text === 'Now multiply')).toBe(true);
});
it('multi-turn conversation with system prompt sets systemInstruction', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: '2+2 equals 4.' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'And 3+3?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, 'You are a math tutor.', [], history);
expect(body['contents']).toEqual([
{ parts: [{ text: 'What is 2+2?' }], role: 'user' },
{ parts: [{ text: '2+2 equals 4.' }], role: 'model' },
{ parts: [{ text: 'And 3+3?' }], role: 'user' },
]);
const config = body['config'] as Record<string, unknown>;
expect(config['systemInstruction']).toBe('You are a math tutor.');
});
it('tool definitions use parametersJsonSchema', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [ADD_TOOL, MUL_TOOL], history);
const config = body['config'] as Record<string, unknown>;
expect(config['tools']).toEqual([
{
functionDeclarations: [
{
name: 'add',
description: 'Add two integers.',
parametersJsonSchema: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
},
],
},
{
functionDeclarations: [
{
name: 'multiply',
description: 'Multiply two integers.',
parametersJsonSchema: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
},
],
},
]);
});
it('tool call and tool result packed into user Content', async () => {
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_abc123',
name: 'add', arguments: '{"a": 2, "b": 3}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll add those numbers for you." }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [{ type: 'text', text: '5' }],
toolCallId: 'call_abc123',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['contents']).toEqual([
{ parts: [{ text: 'Add 2 and 3' }], role: 'user' },
{
parts: [
{ text: "I'll add those numbers for you." },
{ functionCall: { name: 'add', args: { a: 2, b: 3 } } },
],
role: 'model',
},
{
parts: [
{
functionResponse: {
name: 'add',
response: { output: '5' },
parts: [],
},
},
],
role: 'user',
},
]);
});
it('tool call with thought_signature_b64 emits thoughtSignature on outbound functionCall', async () => {
// Round-trip: a previous turn returned a tool call with thoughtSignature
// (decoded into ToolCall.extras.thought_signature_b64). When we send
// the assistant message back, the converter must put the original
// signature back into the functionCall part so Gemini can resume the
// reasoning chain.
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll add those." }],
toolCalls: [
{
type: 'function',
id: 'add_call_sig',
name: 'add', arguments: '{"a": 2, "b": 3}',
extras: { thought_signature_b64: 'dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==' },
},
],
},
];
const body = await captureRequestBody(provider, '', [], history);
const contents = body['contents'] as Array<{ parts: unknown[]; role: string }>;
const assistantParts = contents.find((c) => c.role === 'model')!.parts;
const fnCallPart = assistantParts.find(
(p) => (p as Record<string, unknown>)['functionCall'] !== undefined,
) as { functionCall: Record<string, unknown>; thoughtSignature?: unknown } | undefined;
expect(fnCallPart).toMatchObject({
functionCall: { name: 'add', args: { a: 2, b: 3 } },
thoughtSignature: 'dGhvdWdodF9zaWduYXR1cmVfZGF0YQ==',
});
});
it('tool message with image_url result yields functionResponse + inline data part', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'tc_001',
name: 'fetch_image', arguments: '{}',
},
],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'Found image:' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,iVBORw0KGgo=' } },
],
toolCalls: [],
toolCallId: 'tc_001',
},
];
const contents = messagesToGoogleGenAIContents(messages);
// Should have assistant Content + one user Content with 2 parts
expect(contents).toHaveLength(2);
const userContent = contents[1] as unknown as {
role: string;
parts: Array<Record<string, unknown>>;
};
expect(userContent.role).toBe('user');
expect(userContent.parts.length).toBeGreaterThanOrEqual(2);
const fnResp = userContent.parts.find((p) => 'functionResponse' in p) as
| { functionResponse: { name: string; response: { output: string } } }
| undefined;
expect(fnResp).toMatchObject({
functionResponse: {
name: 'fetch_image',
response: { output: 'Found image:' },
},
});
const inlineData = userContent.parts.find((p) => 'inlineData' in p) as
| { inlineData: { mimeType: string; data: string } }
| undefined;
expect(inlineData).toMatchObject({
inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' },
});
});
it('tool message with audio_url and video_url results yields independent parts', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'tc_002',
name: 'fetch_media', arguments: '{}',
},
],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'Got audio and video:' },
{ type: 'audio_url', audioUrl: { url: 'https://example.com/sound.mp3' } },
{ type: 'video_url', videoUrl: { url: 'https://example.com/clip.mp4' } },
],
toolCalls: [],
toolCallId: 'tc_002',
},
];
const contents = messagesToGoogleGenAIContents(messages);
expect(contents).toHaveLength(2);
const userContent = contents[1] as unknown as {
role: string;
parts: Array<Record<string, unknown>>;
};
expect(userContent.role).toBe('user');
// functionResponse + audio + video
expect(userContent.parts).toHaveLength(3);
const fnResp = userContent.parts.find((p) => 'functionResponse' in p) as
| { functionResponse: { response: { output: string } } }
| undefined;
expect(fnResp).toMatchObject({
functionResponse: { response: { output: 'Got audio and video:' } },
});
const fileDataParts = userContent.parts.filter((p) => 'fileData' in p) as Array<{
fileData: { fileUri: string; mimeType: string };
}>;
expect(fileDataParts).toHaveLength(2);
const mimeTypes = fileDataParts.map((p) => p.fileData.mimeType).toSorted();
expect(mimeTypes).toEqual(['audio/mpeg', 'video/mp4']);
});
it('forwards video_url parts in regular messages as fileData', () => {
const messages: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'Please inspect this clip.' },
{ type: 'video_url', videoUrl: { url: 'https://example.com/demo.mp4' } },
],
toolCalls: [],
},
];
const contents = messagesToGoogleGenAIContents(messages);
expect(contents).toHaveLength(1);
expect(contents[0]).toEqual({
role: 'user',
parts: [
{ text: 'Please inspect this clip.' },
{ fileData: { fileUri: 'https://example.com/demo.mp4', mimeType: 'video/mp4' } },
],
});
});
it('parallel tool calls packed into single user Content', async () => {
const provider = createProvider();
// Mirror Python COMMON_CASES.parallel_tool_calls: multi-ContentPart
// tool results with a <system-reminder> prefix proving the provider
// concatenates text parts into `response.output`.
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Calculate 2+3 and 4*5' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll calculate both." }],
toolCalls: [
{
type: 'function',
id: 'call_add',
name: 'add', arguments: '{"a": 2, "b": 3}',
},
{
type: 'function',
id: 'call_mul',
name: 'multiply', arguments: '{"a": 4, "b": 5}',
},
],
},
{
role: 'tool',
content: [
{
type: 'text',
text: '<system-reminder>This is a system reminder</system-reminder>',
},
{ type: 'text', text: '5' },
],
toolCallId: 'call_add',
toolCalls: [],
},
{
role: 'tool',
content: [
{
type: 'text',
text: '<system-reminder>This is a system reminder</system-reminder>',
},
{ type: 'text', text: '20' },
],
toolCallId: 'call_mul',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [ADD_TOOL, MUL_TOOL], history);
// Snapshot of the expected wire format:
// - exactly 3 contents in order (user, model with 2 functionCalls, user with 2 functionResponses bundled)
// - both tool results are N:1 packed into ONE user Content
// - text parts are concatenated into `response.output` (system-reminder + result)
// - functionCall / functionResponse never include an `id` field
expect(body['contents']).toEqual([
{ parts: [{ text: 'Calculate 2+3 and 4*5' }], role: 'user' },
{
parts: [
{ text: "I'll calculate both." },
{ functionCall: { name: 'add', args: { a: 2, b: 3 } } },
{ functionCall: { name: 'multiply', args: { a: 4, b: 5 } } },
],
role: 'model',
},
{
parts: [
{
functionResponse: {
name: 'add',
response: {
output: '<system-reminder>This is a system reminder</system-reminder>5',
},
parts: [],
},
},
{
functionResponse: {
name: 'multiply',
response: {
output: '<system-reminder>This is a system reminder</system-reminder>20',
},
parts: [],
},
},
],
role: 'user',
},
]);
});
});
describe('vertexai message conversion', () => {
it('vertexai provider converts messages the same way', async () => {
const provider = createProvider({ vertexai: true });
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hello!' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, 'You are helpful.', [], history);
expect(body['contents']).toEqual([{ parts: [{ text: 'Hello!' }], role: 'user' }]);
const config = body['config'] as Record<string, unknown>;
expect(config['systemInstruction']).toBe('You are helpful.');
});
});
describe('generation kwargs', () => {
it('applies temperature and maxOutputTokens', async () => {
const provider = createProvider().withGenerationKwargs({
temperature: 0.7,
maxOutputTokens: 2048,
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const config = body['config'] as Record<string, unknown>;
expect(config['temperature']).toBe(0.7);
expect(config['maxOutputTokens']).toBe(2048);
});
it('withMaxCompletionTokens sets maxOutputTokens on the cloned provider', async () => {
const original = createProvider();
const provider = original.withMaxCompletionTokens(1024);
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const config = body['config'] as Record<string, unknown>;
expect(provider).not.toBe(original);
expect(config['maxOutputTokens']).toBe(1024);
expect(provider.maxCompletionTokens).toBe(1024);
});
});
describe('tool name inference from tool_call_id (orphan tool messages)', () => {
// When a tool message arrives without a preceding assistant message
// carrying the tool_call (e.g. after history compaction), the provider
// falls back to parsing the name out of the tool_call_id. Google IDs
// produced by this provider have the shape "{tool_name}_{id_suffix}"
// where the suffix is a single non-underscored token, so stripping the
// first underscore truncates multi-word tool names such as
// `fetch_image_<id>` down to `fetch`.
function firstFunctionResponseName(history: Message[]): string | undefined {
const contents = messagesToGoogleGenAIContents(history);
for (const content of contents) {
for (const part of content.parts) {
if (part.functionResponse) return part.functionResponse.name;
}
}
return undefined;
}
it('preserves underscores in multi-word tool names like fetch_image', () => {
const history: Message[] = [
{
role: 'tool',
content: [{ type: 'text', text: 'ok' }],
toolCallId: 'fetch_image_abc123',
toolCalls: [],
},
];
expect(firstFunctionResponseName(history)).toBe('fetch_image');
});
it('preserves underscores in read_file_<id>', () => {
const history: Message[] = [
{
role: 'tool',
content: [{ type: 'text', text: 'ok' }],
toolCallId: 'read_file_xyz',
toolCalls: [],
},
];
expect(firstFunctionResponseName(history)).toBe('read_file');
});
it('handles single-word tool names with a trailing suffix', () => {
const history: Message[] = [
{
role: 'tool',
content: [{ type: 'text', text: 'ok' }],
toolCallId: 'simple_abc',
toolCalls: [],
},
];
expect(firstFunctionResponseName(history)).toBe('simple');
});
it('returns the whole id when there is no underscore to split on', () => {
const history: Message[] = [
{
role: 'tool',
content: [{ type: 'text', text: 'ok' }],
toolCallId: 'bareid',
toolCalls: [],
},
];
expect(firstFunctionResponseName(history)).toBe('bareid');
});
});
describe('no id in functionCall or functionResponse', () => {
it('does not include id in functionCall or functionResponse parts', () => {
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: 'Sure.' }],
toolCalls: [
{
type: 'function',
id: 'call_xyz',
name: 'add', arguments: '{"a": 2, "b": 3}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: '5' }],
toolCallId: 'call_xyz',
toolCalls: [],
},
];
const contents = messagesToGoogleGenAIContents(history);
for (const content of contents) {
for (const part of content.parts) {
if (part.functionCall) {
expect(part.functionCall).not.toHaveProperty('id');
}
if (part.functionResponse) {
expect(part.functionResponse).not.toHaveProperty('id');
}
}
}
});
});
describe('with thinking', () => {
it('non-gemini-3 model uses thinkingBudget', async () => {
const provider = createProvider({ model: 'gemini-2.5-flash' }).withThinking('high');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const config = body['config'] as Record<string, unknown>;
expect(config['thinkingConfig']).toEqual({
includeThoughts: true,
thinkingBudget: 32_000,
});
});
it('gemini-3 model uses thinkingLevel', async () => {
const provider = createProvider({ model: 'gemini-3-pro-preview' }).withThinking('high');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const config = body['config'] as Record<string, unknown>;
expect(config['thinkingConfig']).toEqual({
includeThoughts: true,
thinkingLevel: 'HIGH',
});
});
it('thinking effort off for non-gemini-3 disables thinking', async () => {
const provider = createProvider({ model: 'gemini-2.5-flash' }).withThinking('off');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const config = body['config'] as Record<string, unknown>;
expect(config['thinkingConfig']).toEqual({
includeThoughts: false,
thinkingBudget: 0,
});
});
describe('Gemini 3 thinking effort mapping', () => {
async function captureThinkingConfig(
effort: 'off' | 'low' | 'medium' | 'high',
): Promise<Record<string, unknown> | undefined> {
const provider = createProvider({ model: 'gemini-3-pro-preview' }).withThinking(effort);
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const config = body['config'] as Record<string, unknown>;
return config['thinkingConfig'] as Record<string, unknown> | undefined;
}
it('off minimizes thinking and hides thoughts (not just default config)', async () => {
const thinkingConfig = await captureThinkingConfig('off');
// Gemini 3 cannot be fully disabled, but we should request the lowest
// available level (MINIMAL) and suppress thought output.
expect(thinkingConfig).toEqual({
includeThoughts: false,
thinkingLevel: 'MINIMAL',
});
});
it('low maps to LOW', async () => {
const thinkingConfig = await captureThinkingConfig('low');
expect(thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: 'LOW',
});
});
it('medium maps to MEDIUM (not HIGH)', async () => {
const thinkingConfig = await captureThinkingConfig('medium');
expect(thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: 'MEDIUM',
});
});
it('high maps to HIGH', async () => {
const thinkingConfig = await captureThinkingConfig('high');
expect(thinkingConfig).toEqual({
includeThoughts: true,
thinkingLevel: 'HIGH',
});
});
});
});
describe('provider properties', () => {
it('has correct name and model', () => {
const provider = createProvider();
expect(provider.name).toBe('google_genai');
expect(provider.modelName).toBe('gemini-2.5-flash');
});
it('thinkingEffort is null by default', () => {
const provider = createProvider();
expect(provider.thinkingEffort).toBeNull();
});
it('thinkingEffort reflects budget for non-gemini-3', () => {
const provider = createProvider().withThinking('high');
expect(provider.thinkingEffort).toBe('high');
});
it('withThinking returns a new instance', () => {
const provider = createProvider();
const newProvider = provider.withThinking('high');
expect(newProvider).toBeInstanceOf(GoogleGenAIChatProvider);
expect(newProvider).not.toBe(provider);
});
});
describe('base URL forwarding', () => {
// The @google/genai SDK exposes the effective endpoint through its internal
// ApiClient. `getCustomBaseUrl()` returns exactly the `httpOptions.baseUrl`
// handed to the client, so it is the most direct signal that a configured
// base URL survived provider construction — the alternative being a silent
// fallback to generativelanguage.googleapis.com.
function customBaseUrl(provider: GoogleGenAIChatProvider): string | undefined {
const client = (
provider as unknown as {
_client: { apiClient: { getCustomBaseUrl(): string | undefined } };
}
)._client;
return client.apiClient.getCustomBaseUrl();
}
it('forwards baseUrl to the Google GenAI SDK client', () => {
const provider = new GoogleGenAIChatProvider({
model: 'gemini-2.5-flash',
apiKey: 'test-key',
baseUrl: 'https://qianxun.example/v1beta',
});
expect(customBaseUrl(provider)).toBe('https://qianxun.example/v1beta');
});
it('leaves the SDK default endpoint in place when no baseUrl is set', () => {
const provider = new GoogleGenAIChatProvider({
model: 'gemini-2.5-flash',
apiKey: 'test-key',
});
expect(customBaseUrl(provider)).toBeUndefined();
});
it('forwards baseUrl and defaultHeaders together without dropping either', () => {
const provider = new GoogleGenAIChatProvider({
model: 'gemini-2.5-flash',
apiKey: 'test-key',
baseUrl: 'https://qianxun.example/v1beta',
defaultHeaders: { 'User-Agent': 'kimi-code-cli/test' },
});
const client = (
provider as unknown as {
_client: {
apiClient: {
getCustomBaseUrl(): string | undefined;
getHeaders(): Record<string, string>;
};
};
}
)._client;
expect(client.apiClient.getCustomBaseUrl()).toBe('https://qianxun.example/v1beta');
expect(client.apiClient.getHeaders()).toMatchObject({
'User-Agent': 'kimi-code-cli/test',
});
});
it('forwards baseUrl in vertexai mode', () => {
const provider = new GoogleGenAIChatProvider({
model: 'gemini-1.5-pro',
apiKey: 'test-key',
vertexai: true,
baseUrl: 'https://qianxun.example/vertex',
});
expect(customBaseUrl(provider)).toBe('https://qianxun.example/vertex');
});
});
describe('response parsing (non-stream)', () => {
it('yields text from non-stream response', async () => {
const provider = createProvider({ stream: false });
((provider as any)._client.models as Record<string, unknown>)['generateContent'] = vi
.fn()
.mockResolvedValue(makeGenerateContentResponse());
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts = await collectParts(stream);
expect(parts).toEqual([{ type: 'text', text: 'Hello' }]);
expect(stream.usage).toEqual({
inputOther: 10,
output: 5,
inputCacheRead: 0,
inputCacheCreation: 0,
});
});
});
describe('streaming', () => {
it('defaults to stream mode', () => {
const provider = createProvider();
expect((provider as any)._stream).toBe(true);
});
it('can be set to non-stream mode', () => {
const provider = createProvider({ stream: false });
expect((provider as any)._stream).toBe(false);
});
it('calls generateContentStream when stream is true', async () => {
const provider = createProvider({ stream: true });
const mockModels = (provider as any)._client.models as Record<string, unknown>;
async function* mockStream() {
yield makeGenerateContentResponse();
}
const streamFn = vi.fn().mockImplementation(() => Promise.resolve(mockStream()));
const nonStreamFn = vi.fn();
mockModels['generateContentStream'] = streamFn;
mockModels['generateContent'] = nonStreamFn;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
await collectParts(result);
expect(streamFn).toHaveBeenCalledOnce();
expect(nonStreamFn).not.toHaveBeenCalled();
});
it('calls generateContent when stream is false', async () => {
const provider = createProvider({ stream: false });
const mockModels = (provider as any)._client.models as Record<string, unknown>;
const streamFn = vi.fn();
const nonStreamFn = vi.fn().mockResolvedValue(makeGenerateContentResponse());
mockModels['generateContentStream'] = streamFn;
mockModels['generateContent'] = nonStreamFn;
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
await collectParts(result);
expect(nonStreamFn).toHaveBeenCalledOnce();
expect(streamFn).not.toHaveBeenCalled();
});
it('yields text chunks from stream', async () => {
async function* mockStream() {
yield { candidates: [{ content: { parts: [{ text: 'Hello' }] } }] };
yield { candidates: [{ content: { parts: [{ text: ' world' }] } }] };
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toEqual([
{ type: 'text', text: 'Hello' },
{ type: 'text', text: ' world' },
]);
});
it('yields think parts from stream', async () => {
async function* mockStream() {
yield {
candidates: [{ content: { parts: [{ text: 'thinking...', thought: true }] } }],
};
yield {
candidates: [{ content: { parts: [{ text: 'visible answer' }] } }],
};
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toEqual([
{ type: 'think', think: 'thinking...' },
{ type: 'text', text: 'visible answer' },
]);
});
it('yields function call from stream', async () => {
async function* mockStream() {
yield {
candidates: [
{
content: {
parts: [
{
functionCall: {
name: 'add',
id: 'call_1',
args: { a: 2, b: 3 },
},
},
],
},
},
],
};
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toEqual([
{
type: 'function',
id: 'add_call_1',
name: 'add', arguments: '{"a":2,"b":3}',
},
]);
});
it('yields function call with thought signature from stream', async () => {
async function* mockStream() {
yield {
candidates: [
{
content: {
parts: [
{
functionCall: { name: 'search', id: 'fc_1', args: { q: 'test' } },
thoughtSignature: 'sig_abc123',
},
],
},
},
],
};
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toEqual([
{
type: 'function',
id: 'search_fc_1',
name: 'search', arguments: '{"q":"test"}',
extras: { thought_signature_b64: 'sig_abc123' },
},
]);
});
it('accumulates usage from last chunk', async () => {
async function* mockStream() {
yield { candidates: [{ content: { parts: [{ text: 'chunk1' }] } }] };
yield {
candidates: [{ content: { parts: [{ text: 'chunk2' }] } }],
usageMetadata: {
promptTokenCount: 20,
candidatesTokenCount: 10,
cachedContentTokenCount: 5,
},
};
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
await collectParts(msg);
expect(msg.usage).toEqual({
inputOther: 15,
output: 10,
inputCacheRead: 5,
inputCacheCreation: 0,
});
});
it('extracts responseId from stream chunk', async () => {
async function* mockStream() {
yield {
responseId: 'resp-abc',
candidates: [{ content: { parts: [{ text: 'hi' }] } }],
};
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
await collectParts(msg);
expect(msg.id).toBe('resp-abc');
});
it('handles multiple parts in a single stream chunk', async () => {
async function* mockStream() {
yield {
candidates: [
{
content: {
parts: [
{ text: 'thinking step', thought: true },
{ text: 'answer' },
{
functionCall: { name: 'calc', id: 'fc_1', args: { x: 1 } },
},
],
},
},
],
};
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toHaveLength(3);
expect(parts[0]).toEqual({ type: 'think', think: 'thinking step' });
expect(parts[1]).toEqual({ type: 'text', text: 'answer' });
expect(parts[2]).toMatchObject({
type: 'function',
name: 'calc',
});
});
it('yields unique ids for parallel function calls without id in same chunk', async () => {
async function* mockStream() {
yield {
candidates: [
{
content: {
parts: [
{
functionCall: {
name: 'read_file',
args: { path: '/a.txt' },
},
},
{
functionCall: {
name: 'read_file',
args: { path: '/b.txt' },
},
},
],
},
},
],
};
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toHaveLength(2);
const ids = parts.map((p) => (p as ToolCall).id);
// The two tool calls must have distinct IDs
expect(ids[0]).not.toBe(ids[1]);
});
it('handles empty stream gracefully', async () => {
async function* mockStream() {
// no chunks
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toEqual([]);
expect(msg.id).toBeNull();
expect(msg.usage).toBeNull();
});
it('handles chunk with no candidates', async () => {
async function* mockStream() {
yield { usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 0 } };
yield { candidates: [{ content: { parts: [{ text: 'ok' }] } }] };
}
const msg = new GoogleGenAIStreamedMessage(mockStream(), true);
const parts = await collectParts(msg);
expect(parts).toEqual([{ type: 'text', text: 'ok' }]);
});
it('end-to-end: provider.generate returns streaming response', async () => {
const provider = createProvider({ stream: true });
const mockModels = (provider as any)._client.models as Record<string, unknown>;
async function* mockStream() {
yield {
responseId: 'resp-e2e',
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
};
yield {
candidates: [{ content: { parts: [{ text: ' World' }] } }],
usageMetadata: {
promptTokenCount: 8,
candidatesTokenCount: 4,
},
};
}
mockModels['generateContentStream'] = vi
.fn()
.mockImplementation(() => Promise.resolve(mockStream()));
const result = await provider.generate(
'system',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts = await collectParts(result);
expect(parts).toEqual([
{ type: 'text', text: 'Hello' },
{ type: 'text', text: ' World' },
]);
expect(result.id).toBe('resp-e2e');
expect(result.usage).toEqual({
inputOther: 8,
output: 4,
inputCacheRead: 0,
inputCacheCreation: 0,
});
});
});
describe('tool result validation for assistant tool calls', () => {
it('throws on unexpected extra tool results', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [{ type: 'text', text: 'Running tools' }],
toolCalls: [
{
type: 'function',
id: 'call_known',
name: 'add', arguments: '{"a":1,"b":2}',
},
],
},
// A tool result that matches the assistant's known tool call
{
role: 'tool',
content: [{ type: 'text', text: '3' }],
toolCallId: 'call_known',
toolCalls: [],
},
// An "extra" tool result whose id is NOT in expectedToolCallIds.
// Previously dropped by the buggy be-tolerant branch; must now be
// preserved.
{
role: 'tool',
content: [{ type: 'text', text: '42' }],
toolCallId: 'call_extra_unknown',
toolCalls: [],
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(/Unexpected tool responses/);
});
it('throws on missing tool results', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'call_a',
name: 'tool_a', arguments: '{}',
},
{
type: 'function',
id: 'call_b',
name: 'tool_b', arguments: '{}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: 'A' }],
toolCallId: 'call_a',
toolCalls: [],
},
{
role: 'tool',
content: [{ type: 'text', text: 'orphaned' }],
toolCallId: 'call_missing_elsewhere',
toolCalls: [],
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(/Missing tool responses/);
});
it('throws on duplicate tool results', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'call_a',
name: 'tool_a', arguments: '{}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: 'A1' }],
toolCallId: 'call_a',
toolCalls: [],
},
{
role: 'tool',
content: [{ type: 'text', text: 'A2' }],
toolCallId: 'call_a',
toolCalls: [],
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(/Duplicate tool response/);
});
});
describe('abort signal handling', () => {
it('throws AbortError synchronously when signal is already aborted on entry', async () => {
const provider = createProvider({ stream: true });
const mockModels = (provider as any)._client.models as Record<string, unknown>;
// If the provider forwards to the SDK at all despite a pre-aborted
// signal, this spy will flag the regression.
const streamFn = vi.fn();
mockModels['generateContentStream'] = streamFn;
const controller = new AbortController();
controller.abort();
await expect(
provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
{ signal: controller.signal },
),
).rejects.toMatchObject({ name: 'AbortError' });
expect(streamFn).not.toHaveBeenCalled();
});
it('rejects promptly if the signal aborts before the first stream response resolves', async () => {
const provider = createProvider({ stream: true });
const mockModels = (provider as unknown as { _client: { models: Record<string, unknown> } })
._client.models;
const controller = new AbortController();
mockModels['generateContentStream'] = vi.fn().mockImplementation(
() =>
new Promise<AsyncGenerator>(() => {
// Intentionally never resolves: reproduces the "stuck before first
// chunk" window where cancellation must still win the race.
}),
);
const pending = provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
{ signal: controller.signal },
);
controller.abort();
const result = await Promise.race([
pending.then(
() => ({ settled: 'resolved' as const }),
(error: unknown) => ({ settled: 'rejected' as const, error }),
),
new Promise<{ settled: 'timeout' }>((resolve) =>
setTimeout(() => {
resolve({ settled: 'timeout' });
}, 100),
),
]);
expect(result.settled).toBe('rejected');
if (result.settled === 'rejected') {
expect(result.error).toBeInstanceOf(DOMException);
expect((result.error as DOMException).name).toBe('AbortError');
}
});
it('throws AbortError at the next chunk boundary when aborted mid-stream', async () => {
const provider = createProvider({ stream: true });
const mockModels = (provider as any)._client.models as Record<string, unknown>;
const controller = new AbortController();
async function* mockStream() {
yield { candidates: [{ content: { parts: [{ text: 'chunk-1' }] } }] };
// Simulate the caller aborting between chunks
controller.abort();
yield { candidates: [{ content: { parts: [{ text: 'chunk-2' }] } }] };
}
mockModels['generateContentStream'] = vi
.fn()
.mockImplementation(() => Promise.resolve(mockStream()));
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
{ signal: controller.signal },
);
const received: StreamedMessagePart[] = [];
let caught: unknown;
try {
for await (const part of result) {
received.push(part);
}
} catch (error) {
caught = error;
}
// The first chunk should have been yielded before the abort triggered.
expect(received).toEqual([{ type: 'text', text: 'chunk-1' }]);
// The post-abort chunk must never be observed — the second loop
// iteration checks the signal and throws AbortError.
expect(caught).toBeInstanceOf(DOMException);
expect((caught as DOMException).name).toBe('AbortError');
});
it('non-stream path throws AbortError if signal fires before iteration', async () => {
const provider = createProvider({ stream: false });
const mockModels = (provider as any)._client.models as Record<string, unknown>;
mockModels['generateContent'] = vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'done' }] } }],
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
});
const controller = new AbortController();
const result = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
{ signal: controller.signal },
);
// Abort AFTER generate() resolves but BEFORE we drain the iterator.
controller.abort();
let caught: unknown;
try {
for await (const _ of result) {
// drain — but the first pre-check inside the generator should fire
}
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DOMException);
expect((caught as DOMException).name).toBe('AbortError');
});
});
});
describe('convertGoogleGenAIError (unit)', () => {
it('maps a network-keyword Error to APIConnectionError', () => {
const result = convertGoogleGenAIError(new Error('network connection lost'));
expect(result).toBeInstanceOf(APIConnectionError);
});
it('maps a "fetch failed" TypeError to APIConnectionError', () => {
const result = convertGoogleGenAIError(new TypeError('fetch failed'));
expect(result).toBeInstanceOf(APIConnectionError);
});
it('maps a timeout-keyword Error to APITimeoutError (priority over network)', () => {
const result = convertGoogleGenAIError(new Error('connection timed out'));
expect(result).toBeInstanceOf(APITimeoutError);
});
it('extracts numeric code property as APIStatusError', () => {
const error = new Error('api failure');
(error as Error & { code: number }).code = 503;
const result = convertGoogleGenAIError(error);
expect(result).toBeInstanceOf(APIStatusError);
expect((result as APIStatusError).statusCode).toBe(503);
});
it('normalizes numeric 429 code property as APIProviderRateLimitError', () => {
const error = new Error('too many requests');
(error as Error & { code: number }).code = 429;
const result = convertGoogleGenAIError(error);
expect(result).toBeInstanceOf(APIProviderRateLimitError);
expect((result as APIProviderRateLimitError).statusCode).toBe(429);
});
it('normalizes numeric context overflow errors', () => {
const error = new Error(
'input token count 131072 exceeds the maximum number of tokens allowed',
);
(error as Error & { code: number }).code = 422;
const result = convertGoogleGenAIError(error);
expect(result).toBeInstanceOf(APIContextOverflowError);
expect((result as APIContextOverflowError).statusCode).toBe(422);
});
it('falls through to ChatProviderError for plain Error without keywords or code', () => {
const result = convertGoogleGenAIError(new Error('something obscure'));
expect(result.constructor).toBe(ChatProviderError);
expect(result.message).toContain('something obscure');
});
it('handles non-Error values by stringifying them', () => {
const result = convertGoogleGenAIError('a bare string failure');
expect(result.constructor).toBe(ChatProviderError);
expect(result.message).toContain('a bare string failure');
});
});
describe('messagesToGoogleGenAIContents - error branches', () => {
it('throws when toolCall arguments is a JSON array (not object)', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
toolCalls: [
{
type: 'function',
id: 'tc_arr',
name: 'foo', arguments: '[1,2,3]',
},
],
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(
/Tool call arguments must be a JSON object/,
);
});
it('throws when tool response is missing toolCallId', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'tc_1',
name: 'foo', arguments: '{}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: 'result' }],
toolCalls: [],
// toolCallId missing!
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(
/Tool response is missing `toolCallId`/,
);
});
it('throws on duplicate tool responses for same id', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'tc_dup',
name: 'foo', arguments: '{}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: 'r1' }],
toolCallId: 'tc_dup',
toolCalls: [],
},
{
role: 'tool',
content: [{ type: 'text', text: 'r2' }],
toolCallId: 'tc_dup',
toolCalls: [],
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(/Duplicate tool response/);
});
it('throws when expected tool response is missing', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'tc_expected',
name: 'foo', arguments: '{}',
},
{
type: 'function',
id: 'tc_missing',
name: 'bar', arguments: '{}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: 'only one' }],
toolCallId: 'tc_expected',
toolCalls: [],
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(/Missing tool responses for ids/);
});
it('throws on unexpected tool response for unknown id', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'tc_known',
name: 'foo', arguments: '{}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: 'r' }],
toolCallId: 'tc_known',
toolCalls: [],
},
{
role: 'tool',
content: [{ type: 'text', text: 'stray' }],
toolCallId: 'tc_unexpected',
toolCalls: [],
},
];
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(/Unexpected tool responses/);
});
});
describe('messagesToGoogleGenAIContents - extra branches', () => {
it('throws when assistant tool_call has malformed JSON arguments', () => {
const messages: Message[] = [
{
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
toolCalls: [
{
type: 'function',
id: 'tc_bad',
name: 'foo', arguments: 'not valid {json',
},
],
},
];
// Provider rejects malformed JSON arguments rather than silently sending
// garbage to Gemini.
expect(() => messagesToGoogleGenAIContents(messages)).toThrow(/Tool call arguments/);
});
it('media URL with png extension picks image/png mime type', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [{ type: 'image_url', imageUrl: { url: 'https://example.com/photo.png' } }],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const contents = body['contents'] as Array<{ parts: Array<Record<string, unknown>> }>;
const fileData = contents[0]!.parts[0]!['fileData'] as { mimeType: string };
expect(fileData.mimeType).toBe('image/png');
});
it('media URL with jpg extension picks image/jpeg mime type', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [{ type: 'image_url', imageUrl: { url: 'https://example.com/photo.jpg' } }],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const contents = body['contents'] as Array<{ parts: Array<Record<string, unknown>> }>;
const fileData = contents[0]!.parts[0]!['fileData'] as { mimeType: string };
expect(fileData.mimeType).toBe('image/jpeg');
});
it('media URL with mp3 extension picks audio/mpeg mime type', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [{ type: 'audio_url', audioUrl: { url: 'https://example.com/song.mp3' } }],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const contents = body['contents'] as Array<{ parts: Array<Record<string, unknown>> }>;
const fileData = contents[0]!.parts[0]!['fileData'] as { mimeType: string };
expect(fileData.mimeType).toBe('audio/mpeg');
});
it('data: URL without comma falls back to file data with full URL', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [{ type: 'image_url', imageUrl: { url: 'data:image/png' } }],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const contents = body['contents'] as Array<{ parts: Array<Record<string, unknown>> }>;
expect(contents[0]!.parts[0]).toMatchObject({
fileData: { fileUri: 'data:image/png' },
});
});
});