UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

721 lines (634 loc) 30.5 kB
// @vitest-environment edge-runtime import { FunctionDeclarationsTool } from '@google/generative-ai'; import OpenAI from 'openai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { OpenAIChatMessage } from '@/libs/model-runtime'; import * as imageToBase64Module from '@/utils/imageToBase64'; import * as debugStreamModule from '../utils/debugStream'; import { LobeGoogleAI } from './index'; const provider = 'google'; const bizErrorType = 'ProviderBizError'; const invalidErrorType = 'InvalidProviderAPIKey'; // Mock the console.error to avoid polluting test output vi.spyOn(console, 'error').mockImplementation(() => {}); let instance: LobeGoogleAI; beforeEach(() => { instance = new LobeGoogleAI({ apiKey: 'test' }); // 使用 vi.spyOn 来模拟 chat.completions.create 方法 vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockResolvedValue(new ReadableStream()), } as any); }); afterEach(() => { vi.clearAllMocks(); }); describe('LobeGoogleAI', () => { describe('init', () => { it('should correctly initialize with an API key', async () => { const instance = new LobeGoogleAI({ apiKey: 'test_api_key' }); expect(instance).toBeInstanceOf(LobeGoogleAI); // expect(instance.baseURL).toEqual(defaultBaseURL); }); }); describe('chat', () => { it('should return a StreamingTextResponse on successful API call', async () => { const result = await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); // Assert expect(result).toBeInstanceOf(Response); }); it('should handle text messages correctly', async () => { // 模拟 Google AI SDK 的 generateContentStream 方法返回一个成功的响应流 const mockStream = new ReadableStream({ start(controller) { controller.enqueue('Hello, world!'); controller.close(); }, }); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockResolvedValueOnce(mockStream), } as any); const result = await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); expect(result).toBeInstanceOf(Response); // 额外的断言可以加入,比如验证返回的流内容等 }); it('should withGrounding', () => { const data = [ { candidates: [{ content: { parts: [{ text: 'As' }], role: 'model' } }], usageMetadata: { promptTokenCount: 8, totalTokenCount: 8 }, modelVersion: 'gemini-2.0-flash', }, { candidates: [ { content: { parts: [{ text: ' of February 22, 2025, "Ne Zha ' }], role: 'model' }, safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'MEDIUM' }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, ], }, ], usageMetadata: { promptTokenCount: 8, totalTokenCount: 8 }, modelVersion: 'gemini-2.0-flash', }, { candidates: [ { content: { parts: [{ text: '2" has grossed the following:\n\n* **Worldwide:** $1' }], role: 'model', }, safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, ], }, ], usageMetadata: { promptTokenCount: 8, totalTokenCount: 8 }, modelVersion: 'gemini-2.0-flash', }, { candidates: [ { content: { parts: [ { text: '.66 billion\n* **China:** $1.82 billion (CN¥12.35 billion)\n* **US &', }, ], role: 'model', }, safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, ], }, ], usageMetadata: { promptTokenCount: 8, totalTokenCount: 8 }, modelVersion: 'gemini-2.0-flash', }, { candidates: [ { content: { parts: [{ text: ' Canada:** $24,744,753\n' }], role: 'model' }, finishReason: 'STOP', safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, ], groundingMetadata: { searchEntryPoint: { renderedContent: '<style>\n.container {\n align-items: center;\n border-radius: 8px;\n display: flex;\n font-family: Google Sans, Roboto, sans-serif;\n font-size: 14px;\n line-height: 20px;\n padding: 8px 12px;\n}\n.chip {\n display: inline-block;\n border: solid 1px;\n border-radius: 16px;\n min-width: 14px;\n padding: 5px 16px;\n text-align: center;\n user-select: none;\n margin: 0 8px;\n -webkit-tap-highlight-color: transparent;\n}\n.carousel {\n overflow: auto;\n scrollbar-width: none;\n white-space: nowrap;\n margin-right: -12px;\n}\n.headline {\n display: flex;\n margin-right: 4px;\n}\n.gradient-container {\n position: relative;\n}\n.gradient {\n position: absolute;\n transform: translate(3px, -9px);\n height: 36px;\n width: 9px;\n}\n@media (prefers-color-scheme: light) {\n .container {\n background-color: #fafafa;\n box-shadow: 0 0 0 1px #0000000f;\n }\n .headline-label {\n color: #1f1f1f;\n }\n .chip {\n background-color: #ffffff;\n border-color: #d2d2d2;\n color: #5e5e5e;\n text-decoration: none;\n }\n .chip:hover {\n background-color: #f2f2f2;\n }\n .chip:focus {\n background-color: #f2f2f2;\n }\n .chip:active {\n background-color: #d8d8d8;\n border-color: #b6b6b6;\n }\n .logo-dark {\n display: none;\n }\n .gradient {\n background: linear-gradient(90deg, #fafafa 15%, #fafafa00 100%);\n }\n}\n@media (prefers-color-scheme: dark) {\n .container {\n background-color: #1f1f1f;\n box-shadow: 0 0 0 1px #ffffff26;\n }\n .headline-label {\n color: #fff;\n }\n .chip {\n background-color: #2c2c2c;\n border-color: #3c4043;\n color: #fff;\n text-decoration: none;\n }\n .chip:hover {\n background-color: #353536;\n }\n .chip:focus {\n background-color: #353536;\n }\n .chip:active {\n background-color: #464849;\n border-color: #53575b;\n }\n .logo-light {\n display: none;\n }\n .gradient {\n background: linear-gradient(90deg, #1f1f1f 15%, #1f1f1f00 100%);\n }\n}\n</style>\n<div class="container">\n <div class="headline">\n <svg class="logo-light" width="18" height="18" viewBox="9 9 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">\n <path fill-rule="evenodd" clip-rule="evenodd" d="M42.8622 27.0064C42.8622 25.7839 42.7525 24.6084 42.5487 23.4799H26.3109V30.1568H35.5897C35.1821 32.3041 33.9596 34.1222 32.1258 35.3448V39.6864H37.7213C40.9814 36.677 42.8622 32.2571 42.8622 27.0064V27.0064Z" fill="#4285F4"/>\n <path fill-rule="evenodd" clip-rule="evenodd" d="M26.3109 43.8555C30.9659 43.8555 34.8687 42.3195 37.7213 39.6863L32.1258 35.3447C30.5898 36.3792 28.6306 37.0061 26.3109 37.0061C21.8282 37.0061 18.0195 33.9811 16.6559 29.906H10.9194V34.3573C13.7563 39.9841 19.5712 43.8555 26.3109 43.8555V43.8555Z" fill="#34A853"/>\n <path fill-rule="evenodd" clip-rule="evenodd" d="M16.6559 29.8904C16.3111 28.8559 16.1074 27.7588 16.1074 26.6146C16.1074 25.4704 16.3111 24.3733 16.6559 23.3388V18.8875H10.9194C9.74388 21.2072 9.06992 23.8247 9.06992 26.6146C9.06992 29.4045 9.74388 32.022 10.9194 34.3417L15.3864 30.8621L16.6559 29.8904V29.8904Z" fill="#FBBC05"/>\n <path fill-rule="evenodd" clip-rule="evenodd" d="M26.3109 16.2386C28.85 16.2386 31.107 17.1164 32.9095 18.8091L37.8466 13.8719C34.853 11.082 30.9659 9.3736 26.3109 9.3736C19.5712 9.3736 13.7563 13.245 10.9194 18.8875L16.6559 23.3388C18.0195 19.2636 21.8282 16.2386 26.3109 16.2386V16.2386Z" fill="#EA4335"/>\n </svg>\n <svg class="logo-dark" width="18" height="18" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">\n <circle cx="24" cy="23" fill="#FFF" r="22"/>\n <path d="M33.76 34.26c2.75-2.56 4.49-6.37 4.49-11.26 0-.89-.08-1.84-.29-3H24.01v5.99h8.03c-.4 2.02-1.5 3.56-3.07 4.56v.75l3.91 2.97h.88z" fill="#4285F4"/>\n <path d="M15.58 25.77A8.845 8.845 0 0 0 24 31.86c1.92 0 3.62-.46 4.97-1.31l4.79 3.71C31.14 36.7 27.65 38 24 38c-5.93 0-11.01-3.4-13.45-8.36l.17-1.01 4.06-2.85h.8z" fill="#34A853"/>\n <path d="M15.59 20.21a8.864 8.864 0 0 0 0 5.58l-5.03 3.86c-.98-2-1.53-4.25-1.53-6.64 0-2.39.55-4.64 1.53-6.64l1-.22 3.81 2.98.22 1.08z" fill="#FBBC05"/>\n <path d="M24 14.14c2.11 0 4.02.75 5.52 1.98l4.36-4.36C31.22 9.43 27.81 8 24 8c-5.93 0-11.01 3.4-13.45 8.36l5.03 3.85A8.86 8.86 0 0 1 24 14.14z" fill="#EA4335"/>\n </svg>\n <div class="gradient-container"><div class="gradient"></div></div>\n </div>\n <div class="carousel">\n <a class="chip" href="https://vertexaisearch.cloud.google.com/grounding-api-redirect/AQXblrycKK-4Q61T9-BeH_jYKcMfCwyI0-TGMMzPcvZuXVtBjnsxXJkWcxxay0giciDNQ5g4dfD8SdUuBIlBLFQE7Fuc8e50WZuKO9u3HVjQXMznQxtzcQ4fHUn1lDlsvKiurKnD-G-Sl6s7_8h3JNMJSsObKg79sP0vQ_f9N7ib5s3tuF35FglH1NLaiTvdpM1DVhaHZc2In94_hV3W-_k=">Nezha Reborn 2 box office</a>\n </div>\n</div>\n', }, groundingChunks: [ { web: { uri: 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/AQXblrz3Up-UZrEsLlT8zPkpwbakcjDZbojH5RuXL0HAa_0rHfG1WE5h6jADFSzcMxKNZcit_n7OaxnTvZNjp9WFL4NNJmjkqQRJoK_XdeVsnbshWJpm9TJL7KNNwzAl254th8cHxTsQIOPoNxsnrXeebIlMDVb8OuFWfCWUToiRxhv1_Vo=', title: 'screenrant.com', }, }, { web: { uri: 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/AQXblry4I3hWcwVL-mI75BJYSy72Lb97KF50N2p5PWvH8vuLQQgekFmlw9PDiJ3KouByidcMsja_7IJ3F1S0PguLC0r_uxbcAGfFvJzbiMNdWOhQ7xDSJqObd_mCUa-VFpYzm6cd', title: 'imdb.com', }, }, ], groundingSupports: [ { segment: { startIndex: 64, endIndex: 96, text: '* **Worldwide:** $1.66 billion', }, groundingChunkIndices: [0], confidenceScores: [0.95218265], }, { segment: { startIndex: 146, endIndex: 178, text: '* **US & Canada:** $24,744,753', }, groundingChunkIndices: [1], confidenceScores: [0.7182074], }, ], retrievalMetadata: {}, webSearchQueries: ['Nezha Reborn 2 box office'], }, }, ], usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 79, totalTokenCount: 86, promptTokensDetails: [{ modality: 'TEXT', tokenCount: 7 }], candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 79 }], }, modelVersion: 'gemini-2.0-flash', }, ]; const mockStream = new ReadableStream({ start(controller) { controller.enqueue('Hello, world!'); controller.close(); }, }); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockResolvedValueOnce(mockStream), } as any); }); it('should call debugStream in DEBUG mode', async () => { // 设置环境变量以启用DEBUG模式 process.env.DEBUG_GOOGLE_CHAT_COMPLETION = '1'; // 模拟 Google AI SDK 的 generateContentStream 方法返回一个成功的响应流 const mockStream = new ReadableStream({ start(controller) { controller.enqueue('Debug mode test'); controller.close(); }, }); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockResolvedValueOnce(mockStream), } as any); const debugStreamSpy = vi .spyOn(debugStreamModule, 'debugStream') .mockImplementation(() => Promise.resolve()); await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); expect(debugStreamSpy).toHaveBeenCalled(); // 清理环境变量 delete process.env.DEBUG_GOOGLE_CHAT_COMPLETION; }); describe('Error', () => { it('should throw InvalidGoogleAPIKey error on API_KEY_INVALID error', async () => { // 模拟 Google AI SDK 抛出异常 const message = `[GoogleGenerativeAI Error]: Error fetching from https://generativelanguage.googleapis.com/v1/models/gemini-pro:streamGenerateContent?alt=sse: [400 Bad Request] API key not valid. Please pass a valid API key. [{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"API_KEY_INVALID","domain":"googleapis.com","metadata":{"service":"generativelanguage.googleapis.com"}}]`; const apiError = new Error(message); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockRejectedValue(apiError), } as any); try { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); } catch (e) { expect(e).toEqual({ errorType: invalidErrorType, error: { message }, provider }); } }); it('should throw LocationNotSupportError error on location not support error', async () => { // 模拟 Google AI SDK 抛出异常 const message = `[GoogleGenerativeAI Error]: Error fetching from https://generativelanguage.googleapis.com/v1/models/gemini-pro:streamGenerateContent?alt=sse: [400 Bad Request] User location is not supported for the API use.`; const apiError = new Error(message); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockRejectedValue(apiError), } as any); try { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); } catch (e) { expect(e).toEqual({ errorType: 'LocationNotSupportError', error: { message }, provider }); } }); it('should throw BizError error', async () => { // 模拟 Google AI SDK 抛出异常 const message = `[GoogleGenerativeAI Error]: Error fetching from https://generativelanguage.googleapis.com/v1/models/gemini-pro:streamGenerateContent?alt=sse: [400 Bad Request] API key not valid. Please pass a valid API key. [{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"Error","domain":"googleapis.com","metadata":{"service":"generativelanguage.googleapis.com"}}]`; const apiError = new Error(message); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockRejectedValue(apiError), } as any); try { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); } catch (e) { expect(e).toEqual({ errorType: bizErrorType, error: [ { '@type': 'type.googleapis.com/google.rpc.ErrorInfo', 'domain': 'googleapis.com', 'metadata': { service: 'generativelanguage.googleapis.com', }, 'reason': 'Error', }, ], provider, }); } }); it('should throw DefaultError error', async () => { // 模拟 Google AI SDK 抛出异常 const message = `[GoogleGenerativeAI Error]: Error fetching from https://generativelanguage.googleapis.com/v1/models/gemini-pro:streamGenerateContent?alt=sse: [400 Bad Request] API key not valid. Please pass a valid API key. [{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"Error","domain":"googleapis.com","metadata":{"service":"generativelanguage.googleapis.com}}]`; const apiError = new Error(message); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockRejectedValue(apiError), } as any); try { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); } catch (e) { expect(e).toEqual({ errorType: bizErrorType, error: { message: `API key not valid. Please pass a valid API key. [{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"Error","domain":"googleapis.com","metadata":{"service":"generativelanguage.googleapis.com}}]`, statusCode: 400, statusCodeText: '[400 Bad Request]', }, provider, }); } }); it('should return GoogleBizError with an openai error response when APIError is thrown', async () => { // Arrange const apiError = new Error('Error message'); // 使用 vi.spyOn 来模拟 chat.completions.create 方法 vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockRejectedValue(apiError), } as any); // Act try { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); } catch (e) { expect(e).toEqual({ error: { message: 'Error message' }, errorType: bizErrorType, provider, }); } }); it('should throw AgentRuntimeError with NoOpenAIAPIKey if no apiKey is provided', async () => { try { new LobeGoogleAI({}); } catch (e) { expect(e).toEqual({ errorType: invalidErrorType }); } }); it('should return OpenAIBizError with the cause when OpenAI.APIError is thrown with cause', async () => { // Arrange const errorInfo = { stack: 'abc', cause: { message: 'api is undefined', }, }; const apiError = new OpenAI.APIError(400, errorInfo, 'module error', {}); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockRejectedValue(apiError), } as any); // Act try { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); } catch (e) { expect(e).toEqual({ error: { message: `400 {"stack":"abc","cause":{"message":"api is undefined"}}`, }, errorType: bizErrorType, provider, }); } }); it('should return AgentRuntimeError for non-OpenAI errors', async () => { // Arrange const genericError = new Error('Generic Error'); vi.spyOn(instance['client'], 'getGenerativeModel').mockReturnValue({ generateContentStream: vi.fn().mockRejectedValue(genericError), } as any); // Act try { await instance.chat({ messages: [{ content: 'Hello', role: 'user' }], model: 'text-davinci-003', temperature: 0, }); } catch (e) { expect(e).toEqual({ errorType: bizErrorType, provider, error: { message: 'Generic Error', }, }); } }); }); }); describe('private method', () => { describe('convertContentToGooglePart', () => { it('should handle text type messages', async () => { const result = await instance['convertContentToGooglePart']({ type: 'text', text: 'Hello', }); expect(result).toEqual({ text: 'Hello' }); }); it('should handle thinking type messages', async () => { const result = await instance['convertContentToGooglePart']({ type: 'thinking', thinking: 'Hello', signature: 'abc', }); expect(result).toEqual(undefined); }); it('should handle base64 type images', async () => { const base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='; const result = await instance['convertContentToGooglePart']({ type: 'image_url', image_url: { url: base64Image }, }); expect(result).toEqual({ inlineData: { data: 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', mimeType: 'image/png', }, }); }); it('should handle URL type images', async () => { const imageUrl = 'http://example.com/image.png'; const mockBase64 = 'mockBase64Data'; // Mock the imageUrlToBase64 function vi.spyOn(imageToBase64Module, 'imageUrlToBase64').mockResolvedValueOnce({ base64: mockBase64, mimeType: 'image/png', }); const result = await instance['convertContentToGooglePart']({ type: 'image_url', image_url: { url: imageUrl }, }); expect(result).toEqual({ inlineData: { data: mockBase64, mimeType: 'image/png', }, }); expect(imageToBase64Module.imageUrlToBase64).toHaveBeenCalledWith(imageUrl); }); it('should throw TypeError for unsupported image URL types', async () => { const unsupportedImageUrl = 'unsupported://example.com/image.png'; await expect( instance['convertContentToGooglePart']({ type: 'image_url', image_url: { url: unsupportedImageUrl }, }), ).rejects.toThrow(TypeError); }); }); describe('buildGoogleMessages', () => { it('get default result with gemini-pro', async () => { const messages: OpenAIChatMessage[] = [{ content: 'Hello', role: 'user' }]; const contents = await instance['buildGoogleMessages'](messages); expect(contents).toHaveLength(1); expect(contents).toEqual([{ parts: [{ text: 'Hello' }], role: 'user' }]); }); it('should not modify the length if model is gemini-1.5-pro', async () => { const messages: OpenAIChatMessage[] = [ { content: 'Hello', role: 'user' }, { content: 'Hi', role: 'assistant' }, ]; const contents = await instance['buildGoogleMessages'](messages); expect(contents).toHaveLength(2); expect(contents).toEqual([ { parts: [{ text: 'Hello' }], role: 'user' }, { parts: [{ text: 'Hi' }], role: 'model' }, ]); }); it('should use specified model when images are included in messages', async () => { const messages: OpenAIChatMessage[] = [ { content: [ { type: 'text', text: 'Hello' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } }, ], role: 'user', }, ]; // 调用 buildGoogleMessages 方法 const contents = await instance['buildGoogleMessages'](messages); expect(contents).toHaveLength(1); expect(contents).toEqual([ { parts: [{ text: 'Hello' }, { inlineData: { data: '...', mimeType: 'image/png' } }], role: 'user', }, ]); }); }); describe('buildGoogleTools', () => { it('should return undefined when tools is undefined or empty', () => { expect(instance['buildGoogleTools'](undefined)).toBeUndefined(); expect(instance['buildGoogleTools']([])).toBeUndefined(); }); it('should correctly convert ChatCompletionTool to GoogleFunctionCallTool', () => { const tools: OpenAI.ChatCompletionTool[] = [ { function: { name: 'testTool', description: 'A test tool', parameters: { type: 'object', properties: { param1: { type: 'string' }, param2: { type: 'number' }, }, required: ['param1'], }, }, type: 'function', }, ]; const googleTools = instance['buildGoogleTools'](tools); expect(googleTools).toHaveLength(1); expect((googleTools![0] as FunctionDeclarationsTool).functionDeclarations![0]).toEqual({ name: 'testTool', description: 'A test tool', parameters: { type: 'object', properties: { param1: { type: 'string' }, param2: { type: 'number' }, }, required: ['param1'], }, }); }); }); describe('convertOAIMessagesToGoogleMessage', () => { it('should correctly convert assistant message', async () => { const message: OpenAIChatMessage = { role: 'assistant', content: 'Hello', }; const converted = await instance['convertOAIMessagesToGoogleMessage'](message); expect(converted).toEqual({ role: 'model', parts: [{ text: 'Hello' }], }); }); it('should correctly convert user message', async () => { const message: OpenAIChatMessage = { role: 'user', content: 'Hi', }; const converted = await instance['convertOAIMessagesToGoogleMessage'](message); expect(converted).toEqual({ role: 'user', parts: [{ text: 'Hi' }], }); }); it('should correctly convert message with inline base64 image parts', async () => { const message: OpenAIChatMessage = { role: 'user', content: [ { type: 'text', text: 'Check this image:' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } }, ], }; const converted = await instance['convertOAIMessagesToGoogleMessage'](message); expect(converted).toEqual({ role: 'user', parts: [ { text: 'Check this image:' }, { inlineData: { data: '...', mimeType: 'image/png' } }, ], }); }); it.skip('should correctly convert message with image url parts', async () => { const message: OpenAIChatMessage = { role: 'user', content: [ { type: 'text', text: 'Check this image:' }, { type: 'image_url', image_url: { url: 'https://image-file.com' } }, ], }; const converted = await instance['convertOAIMessagesToGoogleMessage'](message); expect(converted).toEqual({ role: 'user', parts: [ { text: 'Check this image:' }, { inlineData: { data: '...', mimeType: 'image/png' } }, ], }); }); it('should correctly convert function call message', async () => { const message = { role: 'assistant', tool_calls: [ { id: 'call_1', function: { name: 'get_current_weather', arguments: JSON.stringify({ location: 'London', unit: 'celsius' }), }, type: 'function', }, ], } as OpenAIChatMessage; const converted = await instance['convertOAIMessagesToGoogleMessage'](message); expect(converted).toEqual({ role: 'function', parts: [ { functionCall: { name: 'get_current_weather', args: { location: 'London', unit: 'celsius' }, }, }, ], }); }); it('should correctly handle empty content', async () => { const message: OpenAIChatMessage = { role: 'user', content: '' as any, // explicitly set as empty string }; const converted = await instance['convertOAIMessagesToGoogleMessage'](message); expect(converted).toEqual({ role: 'user', parts: [{ text: '' }], }); }); }); }); });