tayyabimam's picture
Fresh build completely clean of binaries
35ac985
Raw
History Blame Contribute Delete
11 kB
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GoogleProvider } from '../../providers/google.js';
describe('GoogleProvider', () => {
let provider: GoogleProvider;
beforeEach(() => {
provider = new GoogleProvider();
});
it('should have correct platform and name', () => {
expect(provider.platform).toBe('google');
expect(provider.name).toBe('Google AI Studio');
});
it('should call Gemini API and return OpenAI-compatible response', async () => {
const mockResponse = {
candidates: [{
content: { parts: [{ text: 'Hello from Gemini!' }] },
finishReason: 'STOP',
}],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
},
};
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse),
} as any);
const result = await provider.chatCompletion(
'test-key',
[{ role: 'user', content: 'Hi' }],
'gemini-2.5-pro',
);
expect(result.object).toBe('chat.completion');
expect(result.choices[0].message.content).toBe('Hello from Gemini!');
expect(result.choices[0].message.role).toBe('assistant');
expect(result.usage.prompt_tokens).toBe(10);
expect(result.usage.completion_tokens).toBe(5);
expect(result._routed_via?.platform).toBe('google');
});
it('should throw on API error', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: () => Promise.resolve({ error: { message: 'Rate limit exceeded' } }),
} as any);
await expect(
provider.chatCompletion('test-key', [{ role: 'user', content: 'Hi' }], 'gemini-2.5-pro')
).rejects.toThrow(/Rate limit exceeded/);
});
it('should validate key via models endpoint', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true } as any);
expect(await provider.validateKey('valid-key')).toBe(true);
vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: false, status: 401 } as any);
expect(await provider.validateKey('invalid-key')).toBe(false);
});
it('should translate system messages to systemInstruction', async () => {
let capturedBody: any;
vi.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {
capturedBody = JSON.parse((init as any).body);
return {
ok: true,
json: () => Promise.resolve({
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
}),
} as any;
});
await provider.chatCompletion(
'test-key',
[
{ role: 'system', content: 'You are helpful' },
{ role: 'user', content: 'Hi' },
],
'gemini-2.5-pro',
);
expect(capturedBody.systemInstruction).toEqual({ parts: [{ text: 'You are helpful' }] });
expect(capturedBody.contents).toHaveLength(1);
expect(capturedBody.contents[0].role).toBe('user');
});
it('should translate OpenAI tools/tool_choice to Gemini tools/toolConfig', async () => {
let capturedBody: any;
vi.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {
capturedBody = JSON.parse((init as any).body);
return {
ok: true,
json: () => Promise.resolve({
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
}),
} as any;
});
await provider.chatCompletion(
'test-key',
[{ role: 'user', content: 'Weather in Karachi?' }],
'gemini-2.5-pro',
{
tools: [{
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather for a city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
},
}],
tool_choice: {
type: 'function',
function: { name: 'get_weather' },
},
},
);
expect(capturedBody.tools[0].functionDeclarations[0].name).toBe('get_weather');
expect(capturedBody.toolConfig.functionCallingConfig.mode).toBe('ANY');
expect(capturedBody.toolConfig.functionCallingConfig.allowedFunctionNames).toEqual(['get_weather']);
});
it('should translate Gemini functionCall response to OpenAI tool_calls', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
candidates: [{
content: {
parts: [{
functionCall: {
id: 'call_123',
name: 'get_weather',
args: { city: 'Lahore' },
},
}],
},
finishReason: 'STOP',
}],
usageMetadata: {
promptTokenCount: 12,
candidatesTokenCount: 3,
totalTokenCount: 15,
},
}),
} as any);
const result = await provider.chatCompletion(
'test-key',
[{ role: 'user', content: 'What is the weather?' }],
'gemini-2.5-pro',
);
expect(result.choices[0].finish_reason).toBe('tool_calls');
expect(result.choices[0].message.content).toBeNull();
expect(result.choices[0].message.tool_calls?.[0].id).toBe('call_123');
expect(result.choices[0].message.tool_calls?.[0].function.name).toBe('get_weather');
expect(result.choices[0].message.tool_calls?.[0].function.arguments).toBe('{"city":"Lahore"}');
});
it('should preserve and pass through thought_signature', async () => {
let capturedBody: any;
vi.spyOn(global, 'fetch').mockImplementation(async (_url, init) => {
capturedBody = JSON.parse((init as any).body);
return {
ok: true,
json: () => Promise.resolve({
candidates: [{
content: {
parts: [{
thoughtSignature: 'sig_123',
functionCall: {
id: 'call_123',
name: 'get_weather',
args: { city: 'London' },
},
}],
},
finishReason: 'STOP',
}],
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
}),
} as any;
});
// 1. Check extraction
const result = await provider.chatCompletion(
'test-key',
[{ role: 'user', content: 'Weather?' }],
'gemini-2.5-pro',
);
expect(result.choices[0].message.tool_calls?.[0].thought_signature).toBe('sig_123');
// 2. Check injection in next turn
await provider.chatCompletion(
'test-key',
[
{ role: 'user', content: 'Weather?' },
{
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"London"}' },
thought_signature: 'sig_123',
}],
},
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp": 20}' },
],
'gemini-2.5-pro',
);
const assistantEntry = capturedBody.contents.find((c: any) => c.role === 'model');
expect(assistantEntry.parts[0].thoughtSignature).toBe('sig_123');
expect(assistantEntry.parts[0].functionCall.name).toBe('get_weather');
});
// ── Streaming ──────────────────────────────────────────────────────────────
// Build a Response-shaped object backed by a ReadableStream so the provider's
// `res.body.getReader()` path executes for real (Node 20+ has both globally).
function sseResponse(frames: string[]): any {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const f of frames) controller.enqueue(encoder.encode(f));
controller.close();
},
});
return { ok: true, body: stream };
}
async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
const out: T[] = [];
for await (const c of gen) out.push(c);
return out;
}
it('streams text deltas and emits a final stop chunk', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(sseResponse([
'data: {"candidates":[{"content":{"parts":[{"text":"Hel"}]}}]}\n\n',
'data: {"candidates":[{"content":{"parts":[{"text":"lo"}]}}]}\n\n',
'data: {"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}]}\n\n',
]));
const chunks = await collect(provider.streamChatCompletion(
'test-key',
[{ role: 'user', content: 'Hi' }],
'gemini-2.5-pro',
));
const text = chunks.map(c => c.choices[0].delta.content ?? '').join('');
expect(text).toBe('Hello');
expect(chunks[chunks.length - 1].choices[0].finish_reason).toBe('stop');
});
it('skips a malformed SSE frame instead of aborting the whole stream', async () => {
// Regression: previously an unguarded JSON.parse would propagate, killing
// the stream after a single bad chunk. Other providers (openai-compat,
// cohere, cloudflare) already protect this path with try/catch.
vi.spyOn(global, 'fetch').mockResolvedValueOnce(sseResponse([
'data: {"candidates":[{"content":{"parts":[{"text":"Hel"}]}}]}\n\n',
'data: {oops not json\n\n',
'data: {"candidates":[{"content":{"parts":[{"text":"lo"}]}}]}\n\n',
'data: [DONE]\n\n',
]));
const chunks = await collect(provider.streamChatCompletion(
'test-key',
[{ role: 'user', content: 'Hi' }],
'gemini-2.5-pro',
));
const text = chunks.map(c => c.choices[0].delta.content ?? '').join('');
expect(text).toBe('Hello');
expect(chunks[chunks.length - 1].choices[0].finish_reason).toBe('stop');
});
it('streams functionCall parts as tool_calls with finish_reason=tool_calls', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(sseResponse([
'data: {"candidates":[{"content":{"parts":[{"functionCall":{"id":"call_1","name":"get_weather","args":{"city":"Karachi"}}}]}}]}\n\n',
'data: {"candidates":[{"content":{"parts":[]},"finishReason":"STOP"}]}\n\n',
]));
const chunks = await collect(provider.streamChatCompletion(
'test-key',
[{ role: 'user', content: 'Weather?' }],
'gemini-2.5-pro',
));
const toolDeltas = chunks.flatMap(c => c.choices[0].delta.tool_calls ?? []);
expect(toolDeltas).toHaveLength(1);
expect(toolDeltas[0].function.name).toBe('get_weather');
expect(toolDeltas[0].function.arguments).toBe('{"city":"Karachi"}');
expect(chunks[chunks.length - 1].choices[0].finish_reason).toBe('tool_calls');
});
});