| 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('converts an image_url data URL into a Gemini inlineData part (#118)', async () => {
|
| const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
|
| ok: true,
|
| json: () => Promise.resolve({
|
| candidates: [{ content: { parts: [{ text: 'a cat' }] }, finishReason: 'STOP' }],
|
| usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
|
| }),
|
| } as any);
|
|
|
| await provider.chatCompletion('test-key', [
|
| { role: 'user', content: [
|
| { type: 'text', text: 'what is this?' },
|
| { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' } },
|
| ] as any },
|
| ], 'gemini-2.5-flash');
|
|
|
| const body = JSON.parse((fetchSpy.mock.calls[0][1] as any).body);
|
| const parts = body.contents[0].parts;
|
| expect(parts).toContainEqual({ text: 'what is this?' });
|
| expect(parts).toContainEqual({ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' } });
|
| });
|
|
|
| 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;
|
| });
|
|
|
|
|
| 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');
|
|
|
|
|
| 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');
|
| });
|
|
|
|
|
|
|
|
|
| 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 () => {
|
|
|
|
|
|
|
| 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');
|
| });
|
| });
|
|
|