diff --git a/packages/core/src/core/openaiContentGenerator.test.ts b/packages/core/src/core/openaiContentGenerator.test.ts deleted file mode 100644 index d2b28842..00000000 --- a/packages/core/src/core/openaiContentGenerator.test.ts +++ /dev/null @@ -1,3511 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { OpenAIContentGenerator } from './openaiContentGenerator.js'; -import type { Config } from '../config/config.js'; -import { AuthType } from './contentGenerator.js'; -import OpenAI from 'openai'; -import type { - GenerateContentParameters, - CountTokensParameters, - EmbedContentParameters, - CallableTool, - Content, -} from '@google/genai'; -import { Type, FinishReason } from '@google/genai'; - -// Mock OpenAI -vi.mock('openai'); - -// Mock logger modules -vi.mock('../telemetry/loggers.js', () => ({ - logApiResponse: vi.fn(), - logApiError: vi.fn(), -})); - -vi.mock('../utils/openaiLogger.js', () => ({ - openaiLogger: { - logInteraction: vi.fn(), - }, -})); - -// Mock tiktoken -vi.mock('tiktoken', () => ({ - get_encoding: vi.fn().mockReturnValue({ - encode: vi.fn().mockReturnValue(new Array(50)), // Mock 50 tokens - free: vi.fn(), - }), -})); - -describe('OpenAIContentGenerator', () => { - let generator: OpenAIContentGenerator; - let mockConfig: Config; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let mockOpenAIClient: any; - - beforeEach(() => { - // Reset mocks - vi.clearAllMocks(); - - // Mock environment variables - vi.stubEnv('OPENAI_BASE_URL', ''); - - // Mock config - mockConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - timeout: 120000, - maxRetries: 3, - samplingParams: { - temperature: 0.7, - max_tokens: 1000, - top_p: 0.9, - }, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - // Mock OpenAI client - mockOpenAIClient = { - chat: { - completions: { - create: vi.fn(), - }, - }, - embeddings: { - create: vi.fn(), - }, - }; - - vi.mocked(OpenAI).mockImplementation(() => mockOpenAIClient); - - // Create generator instance - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: false, - timeout: 120000, - maxRetries: 3, - samplingParams: { - temperature: 0.7, - max_tokens: 1000, - top_p: 0.9, - }, - }; - generator = new OpenAIContentGenerator(contentGeneratorConfig, mockConfig); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should initialize with basic configuration', () => { - expect(OpenAI).toHaveBeenCalledWith({ - apiKey: 'test-key', - baseURL: undefined, - timeout: 120000, - maxRetries: 3, - defaultHeaders: { - 'User-Agent': expect.stringMatching(/^QwenCode/), - }, - }); - }); - - it('should handle custom base URL', () => { - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - baseUrl: 'https://api.custom.com', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: false, - timeout: 120000, - maxRetries: 3, - }; - new OpenAIContentGenerator(contentGeneratorConfig, mockConfig); - - expect(OpenAI).toHaveBeenCalledWith({ - apiKey: 'test-key', - baseURL: 'https://api.custom.com', - timeout: 120000, - maxRetries: 3, - defaultHeaders: { - 'User-Agent': expect.stringMatching(/^QwenCode/), - }, - }); - }); - - it('should configure OpenRouter headers when using OpenRouter', () => { - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - baseUrl: 'https://openrouter.ai/api/v1', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: false, - timeout: 120000, - maxRetries: 3, - }; - new OpenAIContentGenerator(contentGeneratorConfig, mockConfig); - - expect(OpenAI).toHaveBeenCalledWith({ - apiKey: 'test-key', - baseURL: 'https://openrouter.ai/api/v1', - timeout: 120000, - maxRetries: 3, - defaultHeaders: { - 'User-Agent': expect.stringMatching(/^QwenCode/), - 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', - 'X-Title': 'Qwen Code', - }, - }); - }); - - it('should override timeout settings from config', () => { - const customConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - timeout: 300000, - maxRetries: 5, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - timeout: 300000, - maxRetries: 5, - }; - new OpenAIContentGenerator(contentGeneratorConfig, customConfig); - - expect(OpenAI).toHaveBeenCalledWith({ - apiKey: 'test-key', - baseURL: undefined, - timeout: 300000, - maxRetries: 5, - defaultHeaders: { - 'User-Agent': expect.stringMatching(/^QwenCode/), - }, - }); - }); - }); - - describe('generateContent', () => { - it('should generate content successfully', async () => { - const mockResponse = { - id: 'chatcmpl-123', - object: 'chat.completion', - created: 1677652288, - model: 'gpt-4', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'Hello! How can I help you?', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 15, - total_tokens: 25, - }, - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const result = await generator.generateContent(request, 'test-prompt-id'); - - expect(result.candidates).toHaveLength(1); - if ( - result.candidates && - result.candidates.length > 0 && - result.candidates[0] - ) { - const firstCandidate = result.candidates[0]; - if (firstCandidate.content) { - expect(firstCandidate.content.parts).toEqual([ - { text: 'Hello! How can I help you?' }, - ]); - } - } - expect(result.usageMetadata).toEqual({ - promptTokenCount: 10, - candidatesTokenCount: 15, - totalTokenCount: 25, - cachedContentTokenCount: 0, - }); - }); - - it('should handle system instructions', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - config: { - systemInstruction: 'You are a helpful assistant.', - }, - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'Hello' }, - ], - }), - ); - }); - - it('should handle function calls', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location": "New York"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'What is the weather?' }] }], - model: 'gpt-4', - config: { - tools: [ - { - callTool: vi.fn(), - tool: () => - Promise.resolve({ - functionDeclarations: [ - { - name: 'get_weather', - description: 'Get weather information', - parameters: { - type: Type.OBJECT, - properties: { location: { type: Type.STRING } }, - }, - }, - ], - }), - } as unknown as CallableTool, - ], - }, - }; - - const result = await generator.generateContent(request, 'test-prompt-id'); - - if ( - result.candidates && - result.candidates.length > 0 && - result.candidates[0] - ) { - const firstCandidate = result.candidates[0]; - if (firstCandidate.content) { - expect(firstCandidate.content.parts).toEqual([ - { - functionCall: { - id: 'call_123', - name: 'get_weather', - args: { location: 'New York' }, - }, - }, - ]); - } - } - }); - - it('should apply sampling parameters from config', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, - max_tokens: 1000, - top_p: 0.9, - }), - ); - }); - - it('should prioritize request-level parameters over config', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - config: { - temperature: 0.5, - maxOutputTokens: 500, - }, - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, // From config sampling params (higher priority) - max_tokens: 1000, // From config sampling params (higher priority) - top_p: 0.9, - }), - ); - }); - }); - - describe('generateContentStream', () => { - it('should handle streaming responses', async () => { - const mockStream = [ - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - created: 1677652288, - }, - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: ' there!' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }, - ]; - - // Mock async iterable - mockOpenAIClient.chat.completions.create.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - }, - }); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const stream = await generator.generateContentStream( - request, - 'test-prompt-id', - ); - const responses = []; - for await (const response of stream) { - responses.push(response); - } - - expect(responses).toHaveLength(2); - if ( - responses[0]?.candidates && - responses[0].candidates.length > 0 && - responses[0].candidates[0] - ) { - const firstCandidate = responses[0].candidates[0]; - if (firstCandidate.content) { - expect(firstCandidate.content.parts).toEqual([{ text: 'Hello' }]); - } - } - if ( - responses[1]?.candidates && - responses[1].candidates.length > 0 && - responses[1].candidates[0] - ) { - const secondCandidate = responses[1].candidates[0]; - if (secondCandidate.content) { - expect(secondCandidate.content.parts).toEqual([{ text: ' there!' }]); - } - } - expect(responses[1].usageMetadata).toEqual({ - promptTokenCount: 10, - candidatesTokenCount: 5, - totalTokenCount: 15, - cachedContentTokenCount: 0, - }); - }); - - it('should handle streaming tool calls', async () => { - const mockStream = [ - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_123', - function: { name: 'get_weather' }, - }, - ], - }, - finish_reason: null, - }, - ], - created: 1677652288, - }, - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '{"location": "NYC"}' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - created: 1677652288, - }, - ]; - - mockOpenAIClient.chat.completions.create.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - }, - }); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Weather?' }] }], - model: 'gpt-4', - }; - - const stream = await generator.generateContentStream( - request, - 'test-prompt-id', - ); - const responses = []; - for await (const response of stream) { - responses.push(response); - } - - // First response should contain the complete tool call (accumulated from streaming) - if ( - responses[0]?.candidates && - responses[0].candidates.length > 0 && - responses[0].candidates[0] - ) { - const firstCandidate = responses[0].candidates[0]; - if (firstCandidate.content) { - expect(firstCandidate.content.parts).toEqual([ - { - functionCall: { - id: 'call_123', - name: 'get_weather', - args: { location: 'NYC' }, - }, - }, - ]); - } - } - if ( - responses[1]?.candidates && - responses[1].candidates.length > 0 && - responses[1].candidates[0] - ) { - const secondCandidate = responses[1].candidates[0]; - if (secondCandidate.content) { - expect(secondCandidate.content.parts).toEqual([ - { - functionCall: { - id: 'call_123', - name: 'get_weather', - args: { location: 'NYC' }, - }, - }, - ]); - } - } - }); - }); - - describe('countTokens', () => { - it('should count tokens using tiktoken', async () => { - const request: CountTokensParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], - model: 'gpt-4', - }; - - const result = await generator.countTokens(request); - - expect(result.totalTokens).toBe(50); // Mocked value - }); - - it('should fall back to character approximation if tiktoken fails', async () => { - // Mock tiktoken to throw error - vi.doMock('tiktoken', () => ({ - get_encoding: vi.fn().mockImplementation(() => { - throw new Error('Tiktoken failed'); - }), - })); - - const request: CountTokensParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], - model: 'gpt-4', - }; - - const result = await generator.countTokens(request); - - // Should use character approximation (content length / 4) - expect(result.totalTokens).toBeGreaterThan(0); - }); - }); - - describe('embedContent', () => { - it('should generate embeddings for text content', async () => { - const mockEmbedding = { - data: [{ embedding: [0.1, 0.2, 0.3, 0.4] }], - model: 'text-embedding-ada-002', - usage: { prompt_tokens: 5, total_tokens: 5 }, - }; - - mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); - - const request: EmbedContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello world' }] }], - model: 'text-embedding-ada-002', - }; - - const result = await generator.embedContent(request); - - expect(result.embeddings).toHaveLength(1); - expect(result.embeddings?.[0]?.values).toEqual([0.1, 0.2, 0.3, 0.4]); - expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ - model: 'text-embedding-ada-002', - input: 'Hello world', - }); - }); - - it('should handle string content', async () => { - const mockEmbedding = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); - - const request: EmbedContentParameters = { - contents: 'Simple text', - model: 'text-embedding-ada-002', - }; - - await generator.embedContent(request); - - expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ - model: 'text-embedding-ada-002', - input: 'Simple text', - }); - }); - - it('should handle embedding errors', async () => { - const error = new Error('Embedding failed'); - mockOpenAIClient.embeddings.create.mockRejectedValue(error); - - const request: EmbedContentParameters = { - contents: 'Test text', - model: 'text-embedding-ada-002', - }; - - await expect(generator.embedContent(request)).rejects.toThrow( - 'Embedding failed', - ); - }); - }); - - describe('error handling', () => { - it('should handle API errors with proper error message', async () => { - const apiError = new Error('Invalid API key'); - mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await expect( - generator.generateContent(request, 'test-prompt-id'), - ).rejects.toThrow('Invalid API key'); - }); - - it('should estimate tokens on error for telemetry', async () => { - const apiError = new Error('API error'); - mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - try { - await generator.generateContent(request, 'test-prompt-id'); - } catch (error) { - // Error should be thrown but token estimation should have been attempted - expect(error).toBeInstanceOf(Error); - } - }); - - it('should preserve error status codes like 429', async () => { - // Create an error object with status property like OpenAI SDK would - const apiError = Object.assign(new Error('Rate limit exceeded'), { - status: 429, - }); - mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - try { - await generator.generateContent(request, 'test-prompt-id'); - expect.fail('Expected error to be thrown'); - } catch (error: unknown) { - // Should throw the original error object with status preserved - expect((error as Error & { status: number }).message).toBe( - 'Rate limit exceeded', - ); - expect((error as Error & { status: number }).status).toBe(429); - } - }); - }); - - describe('message conversion', () => { - it('should convert function responses to tool messages', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [ - { role: 'user', parts: [{ text: 'What is the weather?' }] }, - { - role: 'model', - parts: [ - { - functionCall: { - id: 'call_123', - name: 'get_weather', - args: { location: 'NYC' }, - }, - }, - ], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - id: 'call_123', - name: 'get_weather', - response: { temperature: '72F', condition: 'sunny' }, - }, - }, - ], - }, - ], - model: 'gpt-4', - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - { role: 'user', content: 'What is the weather?' }, - { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location":"NYC"}', - }, - }, - ], - }, - { - role: 'tool', - tool_call_id: 'call_123', - content: '{"temperature":"72F","condition":"sunny"}', - }, - ]), - }), - ); - }); - - it('should clean up orphaned tool calls', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [ - { - role: 'model', - parts: [ - { - functionCall: { - id: 'call_orphaned', - name: 'orphaned_function', - args: {}, - }, - }, - ], - }, - // No corresponding function response - ], - model: 'gpt-4', - }; - - await generator.generateContent(request, 'test-prompt-id'); - - // Should not include the orphaned tool call - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [], // Empty because orphaned tool call was cleaned up - }), - ); - }); - }); - - describe('finish reason mapping', () => { - it('should map OpenAI finish reasons to Gemini format', async () => { - const testCases = [ - { openai: 'stop', expected: FinishReason.STOP }, - { openai: 'length', expected: FinishReason.MAX_TOKENS }, - { openai: 'content_filter', expected: FinishReason.SAFETY }, - { openai: 'function_call', expected: FinishReason.STOP }, - { openai: 'tool_calls', expected: FinishReason.STOP }, - ]; - - for (const testCase of testCases) { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: testCase.openai, - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue( - mockResponse, - ); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const result = await generator.generateContent( - request, - 'test-prompt-id', - ); - if ( - result.candidates && - result.candidates.length > 0 && - result.candidates[0] - ) { - const firstCandidate = result.candidates[0]; - expect(firstCandidate.finishReason).toBe(testCase.expected); - } - } - }); - }); - - describe('logging integration', () => { - it('should log interactions when enabled', async () => { - const loggingConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - enableOpenAILogging: true, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: true, - }; - const loggingGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - loggingConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await loggingGenerator.generateContent(request, 'test-prompt-id'); - - // Verify logging was called - const { openaiLogger } = await import('../utils/openaiLogger.js'); - expect(openaiLogger.logInteraction).toHaveBeenCalled(); - }); - }); - - describe('timeout error detection', () => { - it('should detect various timeout error patterns', async () => { - const timeoutErrors = [ - new Error('timeout'), - new Error('Request timed out'), - new Error('Connection timeout occurred'), - new Error('ETIMEDOUT'), - new Error('ESOCKETTIMEDOUT'), - { code: 'ETIMEDOUT', message: 'Connection timed out' }, - { type: 'timeout', message: 'Request timeout' }, - new Error('deadline exceeded'), - ]; - - for (const error of timeoutErrors) { - mockOpenAIClient.chat.completions.create.mockRejectedValueOnce(error); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - try { - await generator.generateContent(request, 'test-prompt-id'); - // Should not reach here - expect(true).toBe(false); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - expect(errorMessage).toMatch(/timeout|Troubleshooting tips/); - } - } - }); - - it('should provide timeout-specific error messages', async () => { - const timeoutError = new Error('Request timeout'); - mockOpenAIClient.chat.completions.create.mockRejectedValue(timeoutError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await expect( - generator.generateContent(request, 'test-prompt-id'), - ).rejects.toThrow( - /Troubleshooting tips.*Reduce input length.*Increase timeout.*Check network/s, - ); - }); - }); - - describe('streaming error handling', () => { - it('should handle errors during streaming setup', async () => { - const setupError = new Error('Streaming setup failed'); - mockOpenAIClient.chat.completions.create.mockRejectedValue(setupError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await expect( - generator.generateContent(request, 'test-prompt-id'), - ).rejects.toThrow('Streaming setup failed'); - }); - - it('should handle timeout errors during streaming setup', async () => { - const timeoutError = new Error('Streaming setup timeout'); - mockOpenAIClient.chat.completions.create.mockRejectedValue(timeoutError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await expect( - generator.generateContentStream(request, 'test-prompt-id'), - ).rejects.toThrow( - /Streaming setup timeout troubleshooting.*Reduce input length/s, - ); - }); - - it('should handle errors during streaming with logging', async () => { - const loggingConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - enableOpenAILogging: true, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: true, - }; - const loggingGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - loggingConfig, - ); - - // Mock stream that throws an error - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - created: 1677652288, - }; - throw new Error('Stream error'); - }, - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockStream); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const stream = await loggingGenerator.generateContentStream( - request, - 'test-prompt-id', - ); - - // Consume the stream and expect error - await expect(async () => { - for await (const chunk of stream) { - // Stream will throw during iteration - console.log('Processing chunk:', chunk); // Use chunk to avoid warning - } - }).rejects.toThrow('Stream error'); - }); - }); - - describe('tool parameter conversion', () => { - it('should convert Gemini types to OpenAI JSON Schema types', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Test' }] }], - model: 'gpt-4', - config: { - tools: [ - { - callTool: vi.fn(), - tool: () => - Promise.resolve({ - functionDeclarations: [ - { - name: 'test_function', - description: 'Test function', - parameters: { - type: 'OBJECT', - properties: { - count: { - type: 'INTEGER', - minimum: '1', - maximum: '100', - }, - name: { - type: 'STRING', - minLength: '1', - maxLength: '50', - }, - score: { type: 'NUMBER', multipleOf: '0.1' }, - items: { - type: 'ARRAY', - minItems: '1', - maxItems: '10', - }, - }, - }, - }, - ], - }), - } as unknown as CallableTool, - ], - }, - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - tools: [ - { - type: 'function', - function: { - name: 'test_function', - description: 'Test function', - parameters: { - type: 'object', - properties: { - count: { type: 'integer', minimum: 1, maximum: 100 }, - name: { type: 'string', minLength: 1, maxLength: 50 }, - score: { type: 'number', multipleOf: 0.1 }, - items: { type: 'array', minItems: 1, maxItems: 10 }, - }, - }, - }, - }, - ], - }), - ); - }); - - it('should handle MCP tools with parametersJsonSchema', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Test' }] }], - model: 'gpt-4', - config: { - tools: [ - { - callTool: vi.fn(), - tool: () => - Promise.resolve({ - functionDeclarations: [ - { - name: 'list-items', - description: 'Get a list of items', - parametersJsonSchema: { - type: 'object', - properties: { - page_number: { - type: 'number', - description: 'Page number', - }, - page_size: { - type: 'number', - description: 'Number of items per page', - }, - }, - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - ], - }), - } as unknown as CallableTool, - ], - }, - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - tools: [ - { - type: 'function', - function: { - name: 'list-items', - description: 'Get a list of items', - parameters: { - type: 'object', - properties: { - page_number: { - type: 'number', - description: 'Page number', - }, - page_size: { - type: 'number', - description: 'Number of items per page', - }, - }, - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - }, - ], - }), - ); - }); - - it('should handle nested parameter objects', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Test' }] }], - model: 'gpt-4', - config: { - tools: [ - { - callTool: vi.fn(), - tool: () => - Promise.resolve({ - functionDeclarations: [ - { - name: 'nested_function', - description: 'Function with nested parameters', - parameters: { - type: 'OBJECT', - properties: { - config: { - type: 'OBJECT', - properties: { - nested_count: { type: 'INTEGER' }, - nested_array: { - type: 'ARRAY', - items: { type: 'STRING' }, - }, - }, - }, - }, - }, - }, - ], - }), - } as unknown as CallableTool, - ], - }, - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - tools: [ - { - type: 'function', - function: { - name: 'nested_function', - description: 'Function with nested parameters', - parameters: { - type: 'object', - properties: { - config: { - type: 'object', - properties: { - nested_count: { type: 'integer' }, - nested_array: { - type: 'array', - items: { type: 'string' }, - }, - }, - }, - }, - }, - }, - }, - ], - }), - ); - }); - }); - - describe('message cleanup and conversion', () => { - it('should handle complex conversation with multiple tool calls', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [ - { role: 'user', parts: [{ text: 'What tools are available?' }] }, - { - role: 'model', - parts: [ - { - functionCall: { - id: 'call_1', - name: 'list_tools', - args: { category: 'all' }, - }, - }, - ], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - id: 'call_1', - name: 'list_tools', - response: { tools: ['calculator', 'weather'] }, - }, - }, - ], - }, - { - role: 'model', - parts: [ - { - functionCall: { - id: 'call_2', - name: 'get_weather', - args: { location: 'NYC' }, - }, - }, - ], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - id: 'call_2', - name: 'get_weather', - response: { temperature: '22°C', condition: 'sunny' }, - }, - }, - ], - }, - ], - model: 'gpt-4', - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { role: 'user', content: 'What tools are available?' }, - { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { - name: 'list_tools', - arguments: '{"category":"all"}', - }, - }, - ], - }, - { - role: 'tool', - tool_call_id: 'call_1', - content: '{"tools":["calculator","weather"]}', - }, - { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_2', - type: 'function', - function: { - name: 'get_weather', - arguments: '{"location":"NYC"}', - }, - }, - ], - }, - { - role: 'tool', - tool_call_id: 'call_2', - content: '{"temperature":"22°C","condition":"sunny"}', - }, - ], - }), - ); - }); - - it('should clean up orphaned tool calls without corresponding responses', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [ - { role: 'user', parts: [{ text: 'Test' }] }, - { - role: 'model', - parts: [ - { - functionCall: { - id: 'call_orphaned', - name: 'orphaned_function', - args: {}, - }, - }, - ], - }, - { - role: 'model', - parts: [ - { - functionCall: { - id: 'call_valid', - name: 'valid_function', - args: {}, - }, - }, - ], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - id: 'call_valid', - name: 'valid_function', - response: { result: 'success' }, - }, - }, - ], - }, - ], - model: 'gpt-4', - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { role: 'user', content: 'Test' }, - { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_valid', - type: 'function', - function: { - name: 'valid_function', - arguments: '{}', - }, - }, - ], - }, - { - role: 'tool', - tool_call_id: 'call_valid', - content: '{"result":"success"}', - }, - ], - }), - ); - }); - - it('should merge consecutive assistant messages', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [ - { role: 'user', parts: [{ text: 'Hello' }] }, - { role: 'model', parts: [{ text: 'Part 1' }] }, - { role: 'model', parts: [{ text: 'Part 2' }] }, - { role: 'user', parts: [{ text: 'Continue' }] }, - ], - model: 'gpt-4', - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Part 1Part 2' }, - { role: 'user', content: 'Continue' }, - ], - }), - ); - }); - }); - - describe('error suppression functionality', () => { - it('should allow subclasses to suppress error logging', async () => { - class TestGenerator extends OpenAIContentGenerator { - protected override shouldSuppressErrorLogging(): boolean { - return true; // Always suppress for this test - } - } - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: false, - timeout: 120000, - maxRetries: 3, - samplingParams: { - temperature: 0.7, - max_tokens: 1000, - top_p: 0.9, - }, - }; - const testGenerator = new TestGenerator( - contentGeneratorConfig, - mockConfig, - ); - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - const apiError = new Error('Test error'); - mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await expect( - testGenerator.generateContent(request, 'test-prompt-id'), - ).rejects.toThrow(); - - // Error logging should be suppressed - expect(consoleSpy).not.toHaveBeenCalledWith( - 'OpenAI API Error:', - expect.any(String), - ); - - consoleSpy.mockRestore(); - }); - - it('should log errors when not suppressed', async () => { - const consoleSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - const apiError = new Error('Test error'); - mockOpenAIClient.chat.completions.create.mockRejectedValue(apiError); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await expect( - generator.generateContent(request, 'test-prompt-id'), - ).rejects.toThrow(); - - // Error logging should occur by default - expect(consoleSpy).toHaveBeenCalledWith( - 'OpenAI API Error:', - 'Test error', - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('edge cases and error scenarios', () => { - it('should handle malformed tool call arguments', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'test_function', - arguments: 'invalid json{', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Test' }] }], - model: 'gpt-4', - }; - - const result = await generator.generateContent(request, 'test-prompt-id'); - - // Should handle malformed JSON gracefully - if ( - result.candidates && - result.candidates.length > 0 && - result.candidates[0] - ) { - const firstCandidate = result.candidates[0]; - if (firstCandidate.content) { - expect(firstCandidate.content.parts).toEqual([ - { - functionCall: { - id: 'call_123', - name: 'test_function', - args: {}, // Should default to empty object - }, - }, - ]); - } - } - }); - - it('should handle streaming with malformed tool call arguments', async () => { - const mockStream = [ - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_123', - function: { name: 'test_function' }, - }, - ], - }, - finish_reason: null, - }, - ], - created: 1677652288, - }, - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: 'invalid json{' }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - created: 1677652288, - }, - ]; - - mockOpenAIClient.chat.completions.create.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - }, - }); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Test' }] }], - model: 'gpt-4', - }; - - const stream = await generator.generateContentStream( - request, - 'test-prompt-id', - ); - const responses = []; - for await (const response of stream) { - responses.push(response); - } - - // Should handle malformed JSON in streaming gracefully - const finalResponse = responses[responses.length - 1]; - if ( - finalResponse.candidates && - finalResponse.candidates.length > 0 && - finalResponse.candidates[0] - ) { - const firstCandidate = finalResponse.candidates[0]; - if (firstCandidate.content) { - expect(firstCandidate.content.parts).toEqual([ - { - functionCall: { - id: 'call_123', - name: 'test_function', - args: {}, // Should default to empty object - }, - }, - ]); - } - } - }); - - it('should handle empty or null content gracefully', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: null }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [], - model: 'gpt-4', - }; - - const result = await generator.generateContent(request, 'test-prompt-id'); - - expect(result.candidates).toHaveLength(1); - if ( - result.candidates && - result.candidates.length > 0 && - result.candidates[0] - ) { - const firstCandidate = result.candidates[0]; - if (firstCandidate.content) { - expect(firstCandidate.content.parts).toEqual([]); - } - } - }); - - it('should handle usage metadata estimation when breakdown is missing', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Test response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 100, - }, - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const result = await generator.generateContent(request, 'test-prompt-id'); - - expect(result.usageMetadata).toEqual({ - promptTokenCount: 70, // 70% of 100 - candidatesTokenCount: 30, // 30% of 100 - totalTokenCount: 100, - cachedContentTokenCount: 0, - }); - }); - - it('should handle cached token metadata', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Test response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - usage: { - prompt_tokens: 50, - completion_tokens: 25, - total_tokens: 75, - prompt_tokens_details: { - cached_tokens: 10, - }, - }, - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const result = await generator.generateContent(request, 'test-prompt-id'); - - expect(result.usageMetadata).toEqual({ - promptTokenCount: 50, - candidatesTokenCount: 25, - totalTokenCount: 75, - cachedContentTokenCount: 10, - }); - }); - }); - - describe('request/response logging conversion', () => { - it('should convert complex Gemini request to OpenAI format for logging', async () => { - const loggingConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - enableOpenAILogging: true, - samplingParams: { - temperature: 0.8, - max_tokens: 500, - }, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: true, - samplingParams: { - temperature: 0.8, - max_tokens: 500, - }, - }; - const loggingGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - loggingConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'test_function', - arguments: '{"param":"value"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - created: 1677652288, - model: 'gpt-4', - usage: { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - }, - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [ - { role: 'user', parts: [{ text: 'Test complex request' }] }, - { - role: 'model', - parts: [ - { - functionCall: { - id: 'prev_call', - name: 'previous_function', - args: { data: 'test' }, - }, - }, - ], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - id: 'prev_call', - name: 'previous_function', - response: { result: 'success' }, - }, - }, - ], - }, - ], - model: 'gpt-4', - config: { - systemInstruction: 'You are a helpful assistant', - temperature: 0.9, - tools: [ - { - callTool: vi.fn(), - tool: () => - Promise.resolve({ - functionDeclarations: [ - { - name: 'test_function', - description: 'Test function', - parameters: { type: 'object' }, - }, - ], - }), - } as unknown as CallableTool, - ], - }, - }; - - await loggingGenerator.generateContent(request, 'test-prompt-id'); - - // Verify that logging was called with properly converted request/response - const { openaiLogger } = await import('../utils/openaiLogger.js'); - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - expect.objectContaining({ - model: 'gpt-4', - messages: [ - { - role: 'system', - content: 'You are a helpful assistant', - }, - { - role: 'user', - content: 'Test complex request', - }, - { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'prev_call', - type: 'function', - function: { - name: 'previous_function', - arguments: '{"data":"test"}', - }, - }, - ], - }, - { - role: 'tool', - tool_call_id: 'prev_call', - content: '{"result":"success"}', - }, - ], - temperature: 0.8, // Config override - max_tokens: 500, // Config override - top_p: 1, // Default value - tools: [ - { - type: 'function', - function: { - name: 'test_function', - description: 'Test function', - parameters: { - type: 'object', - }, - }, - }, - ], - }), - expect.objectContaining({ - id: 'chatcmpl-123', - object: 'chat.completion', - created: 1677652288, - model: 'gpt-4', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: '', - tool_calls: [ - { - id: 'call_123', - type: 'function', - function: { - name: 'test_function', - arguments: '{"param":"value"}', - }, - }, - ], - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 100, - completion_tokens: 50, - total_tokens: 150, - }, - }), - ); - }); - }); - - describe('advanced streaming scenarios', () => { - it('should combine streaming responses correctly for logging', async () => { - const loggingConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - enableOpenAILogging: true, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: true, - }; - const loggingGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - loggingConfig, - ); - - const mockStream = [ - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - created: 1677652288, - }, - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: ' world' }, - finish_reason: null, - }, - ], - created: 1677652288, - }, - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: {}, - finish_reason: 'stop', - }, - ], - created: 1677652288, - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }, - ]; - - mockOpenAIClient.chat.completions.create.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - }, - }); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const stream = await loggingGenerator.generateContentStream( - request, - 'test-prompt-id', - ); - const responses = []; - for await (const response of stream) { - responses.push(response); - } - - // Verify logging was called with combined content - const { openaiLogger } = await import('../utils/openaiLogger.js'); - expect(openaiLogger.logInteraction).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - choices: [ - expect.objectContaining({ - message: expect.objectContaining({ - content: 'Hello world', // Combined text - }), - }), - ], - }), - ); - }); - - it('should handle streaming without choices', async () => { - const mockStream = [ - { - id: 'chatcmpl-123', - choices: [], - created: 1677652288, - }, - ]; - - mockOpenAIClient.chat.completions.create.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - }, - }); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const stream = await generator.generateContentStream( - request, - 'test-prompt-id', - ); - const responses = []; - for await (const response of stream) { - responses.push(response); - } - - expect(responses).toHaveLength(1); - expect(responses[0].candidates).toEqual([]); - }); - }); - - describe('embed content edge cases', () => { - it('should handle mixed content types in embed request', async () => { - const mockEmbedding = { - data: [{ embedding: [0.1, 0.2, 0.3] }], - model: 'text-embedding-ada-002', - usage: { prompt_tokens: 5, total_tokens: 5 }, - }; - - mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); - - const request: EmbedContentParameters = { - contents: 'Hello world Direct string Another part', - model: 'text-embedding-ada-002', - }; - - const result = await generator.embedContent(request); - - expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ - model: 'text-embedding-ada-002', - input: 'Hello world Direct string Another part', - }); - - expect(result.embeddings).toHaveLength(1); - expect(result.embeddings?.[0]?.values).toEqual([0.1, 0.2, 0.3]); - }); - - it('should handle empty content in embed request', async () => { - const mockEmbedding = { - data: [{ embedding: [] }], - }; - - mockOpenAIClient.embeddings.create.mockResolvedValue(mockEmbedding); - - const request: EmbedContentParameters = { - contents: [], - model: 'text-embedding-ada-002', - }; - - await generator.embedContent(request); - - expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ - model: 'text-embedding-ada-002', - input: '', - }); - }); - }); - - describe('system instruction edge cases', () => { - it('should handle array system instructions', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - config: { - systemInstruction: 'You are helpful\nBe concise', - }, - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { role: 'system', content: 'You are helpful\nBe concise' }, - { role: 'user', content: 'Hello' }, - ], - }), - ); - }); - - it('should handle object system instruction', async () => { - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - config: { - systemInstruction: { - parts: [{ text: 'System message' }, { text: 'Additional text' }], - } as Content, - }, - }; - - await generator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: [ - { role: 'system', content: 'System message\nAdditional text' }, - { role: 'user', content: 'Hello' }, - ], - }), - ); - }); - }); - - describe('sampling parameters edge cases', () => { - it('should handle undefined sampling parameters gracefully', async () => { - const configWithUndefined = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - samplingParams: { - temperature: undefined, - max_tokens: undefined, - top_p: undefined, - }, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - samplingParams: { - temperature: undefined, - max_tokens: undefined, - top_p: undefined, - }, - }; - const testGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - configWithUndefined, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - config: { - temperature: undefined, - maxOutputTokens: undefined, - topP: undefined, - }, - }; - - await testGenerator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.0, // Default value - top_p: 1.0, // Default value - // max_tokens should not be present when undefined - }), - ); - }); - - it('should handle all config-level sampling parameters', async () => { - const fullSamplingConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - samplingParams: { - temperature: 0.8, - max_tokens: 1500, - top_p: 0.95, - top_k: 40, - repetition_penalty: 1.1, - presence_penalty: 0.5, - frequency_penalty: 0.3, - }, - }), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - samplingParams: { - temperature: 0.8, - max_tokens: 1500, - top_p: 0.95, - top_k: 40, - repetition_penalty: 1.1, - presence_penalty: 0.5, - frequency_penalty: 0.3, - }, - }; - const testGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - fullSamplingConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await testGenerator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.8, - max_tokens: 1500, - top_p: 0.95, - top_k: 40, - repetition_penalty: 1.1, - presence_penalty: 0.5, - frequency_penalty: 0.3, - }), - ); - }); - }); - - describe('token counting edge cases', () => { - it('should handle tiktoken import failure with console warning', async () => { - // Mock tiktoken to fail on import - vi.doMock('tiktoken', () => { - throw new Error('Failed to import tiktoken'); - }); - - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const request: CountTokensParameters = { - contents: [{ role: 'user', parts: [{ text: 'Test content' }] }], - model: 'gpt-4', - }; - - const result = await generator.countTokens(request); - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringMatching(/Failed to load tiktoken.*falling back/), - expect.any(Error), - ); - - // Should use character approximation - expect(result.totalTokens).toBeGreaterThan(0); - - consoleSpy.mockRestore(); - }); - }); - - describe('metadata control', () => { - it('should include metadata when authType is QWEN_OAUTH', async () => { - const qwenConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'qwen-oauth', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('test-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'qwen-turbo', - apiKey: 'test-key', - authType: AuthType.QWEN_OAUTH, - enableOpenAILogging: false, - }; - const qwenGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - qwenConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'qwen-turbo', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'qwen-turbo', - }; - - await qwenGenerator.generateContent(request, 'test-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { - sessionId: 'test-session-id', - promptId: 'test-prompt-id', - }, - }), - ); - }); - - it('should include metadata when baseURL is dashscope openai compatible mode', async () => { - const dashscopeConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', // Not QWEN_OAUTH - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('dashscope-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'qwen-turbo', - apiKey: 'test-key', - baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - authType: AuthType.USE_OPENAI, - enableOpenAILogging: false, - }; - const dashscopeGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - dashscopeConfig, - ); - - // Debug: Check if the client was created with the correct baseURL - expect(vi.mocked(OpenAI)).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - }), - ); - - // Mock the client's baseURL property to return the expected value - Object.defineProperty(dashscopeGenerator['client'], 'baseURL', { - value: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - writable: true, - }); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'qwen-turbo', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'qwen-turbo', - }; - - await dashscopeGenerator.generateContent(request, 'dashscope-prompt-id'); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { - sessionId: 'dashscope-session-id', - promptId: 'dashscope-prompt-id', - }, - }), - ); - }); - - it('should NOT include metadata for regular OpenAI providers', async () => { - const regularConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('regular-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const regularGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - regularConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await regularGenerator.generateContent(request, 'regular-prompt-id'); - - // Should NOT include metadata - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.not.objectContaining({ - metadata: expect.any(Object), - }), - ); - }); - - it('should NOT include metadata for other auth types', async () => { - const otherAuthConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'gemini-api-key', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('other-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const otherGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - otherAuthConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await otherGenerator.generateContent(request, 'other-prompt-id'); - - // Should NOT include metadata - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.not.objectContaining({ - metadata: expect.any(Object), - }), - ); - }); - - it('should NOT include metadata for other base URLs', async () => { - // Mock environment to set a different base URL - vi.stubEnv('OPENAI_BASE_URL', 'https://api.openai.com/v1'); - - const otherBaseUrlConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('other-base-url-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const otherBaseUrlGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - otherBaseUrlConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await otherBaseUrlGenerator.generateContent( - request, - 'other-base-url-prompt-id', - ); - - // Should NOT include metadata - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.not.objectContaining({ - metadata: expect.any(Object), - }), - ); - }); - - it('should include metadata in streaming requests when conditions are met', async () => { - const qwenConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'qwen-oauth', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('streaming-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'qwen-turbo', - - apiKey: 'test-key', - - authType: AuthType.QWEN_OAUTH, - - enableOpenAILogging: false, - }; - - const qwenGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - qwenConfig, - ); - - const mockStream = [ - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - created: 1677652288, - }, - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: ' there!' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - }, - ]; - - mockOpenAIClient.chat.completions.create.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - }, - }); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'qwen-turbo', - }; - - const stream = await qwenGenerator.generateContentStream( - request, - 'streaming-prompt-id', - ); - - // Verify metadata was included in the streaming request - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { - sessionId: 'streaming-session-id', - promptId: 'streaming-prompt-id', - }, - }), - ); - - // Consume the stream to complete the test - const responses = []; - for await (const response of stream) { - responses.push(response); - } - expect(responses).toHaveLength(2); - }); - - it('should NOT include metadata in streaming requests when conditions are not met', async () => { - const regularConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('regular-streaming-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const regularGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - regularConfig, - ); - - const mockStream = [ - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: 'Hello' }, - finish_reason: null, - }, - ], - created: 1677652288, - }, - { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - delta: { content: ' there!' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - }, - ]; - - mockOpenAIClient.chat.completions.create.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - for (const chunk of mockStream) { - yield chunk; - } - }, - }); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - const stream = await regularGenerator.generateContentStream( - request, - 'regular-streaming-prompt-id', - ); - - // Verify metadata was NOT included in the streaming request - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.not.objectContaining({ - metadata: expect.any(Object), - }), - ); - - // Consume the stream to complete the test - const responses = []; - for await (const response of stream) { - responses.push(response); - } - expect(responses).toHaveLength(2); - }); - - it('should handle undefined sessionId gracefully', async () => { - const qwenConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'qwen-oauth', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue(undefined), // Undefined session ID - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'qwen-turbo', - - apiKey: 'test-key', - - authType: AuthType.QWEN_OAUTH, - - enableOpenAILogging: false, - }; - - const qwenGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - qwenConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'qwen-turbo', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'qwen-turbo', - }; - - await qwenGenerator.generateContent( - request, - 'undefined-session-prompt-id', - ); - - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: { - sessionId: undefined, - promptId: 'undefined-session-prompt-id', - }, - }), - ); - }); - - it('should handle undefined baseURL gracefully', async () => { - // Ensure no base URL is set - vi.stubEnv('OPENAI_BASE_URL', ''); - - const noBaseUrlConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('no-base-url-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const noBaseUrlGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - noBaseUrlConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await noBaseUrlGenerator.generateContent( - request, - 'no-base-url-prompt-id', - ); - - // Should NOT include metadata when baseURL is empty - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.not.objectContaining({ - metadata: expect.any(Object), - }), - ); - }); - - it('should handle undefined authType gracefully', async () => { - const undefinedAuthConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: undefined, // Undefined auth type - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('undefined-auth-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const undefinedAuthGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - undefinedAuthConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await undefinedAuthGenerator.generateContent( - request, - 'undefined-auth-prompt-id', - ); - - // Should NOT include metadata when authType is undefined - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.not.objectContaining({ - metadata: expect.any(Object), - }), - ); - }); - - it('should handle undefined config gracefully', async () => { - const undefinedConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue(undefined), // Undefined config - getSessionId: vi.fn().mockReturnValue('undefined-config-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const undefinedConfigGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - undefinedConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - model: 'gpt-4', - }; - - await undefinedConfigGenerator.generateContent( - request, - 'undefined-config-prompt-id', - ); - - // Should NOT include metadata when config is undefined - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.not.objectContaining({ - metadata: expect.any(Object), - }), - ); - }); - }); - - describe('cache control for DashScope', () => { - it('should add cache control to system message for DashScope providers', async () => { - // Mock environment to set dashscope base URL - vi.stubEnv( - 'OPENAI_BASE_URL', - 'https://dashscope.aliyuncs.com/compatible-mode/v1', - ); - - const dashscopeConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('dashscope-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'qwen-turbo', - - apiKey: 'test-key', - - authType: AuthType.QWEN_OAUTH, - - enableOpenAILogging: false, - }; - - const dashscopeGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - dashscopeConfig, - ); - - // Mock the client's baseURL property to return the expected value - Object.defineProperty(dashscopeGenerator['client'], 'baseURL', { - value: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - writable: true, - }); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'qwen-turbo', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - config: { - systemInstruction: 'You are a helpful assistant.', - }, - model: 'qwen-turbo', - }; - - await dashscopeGenerator.generateContent(request, 'dashscope-prompt-id'); - - // Should include cache control in system message - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ - role: 'system', - content: expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: 'You are a helpful assistant.', - cache_control: { type: 'ephemeral' }, - }), - ]), - }), - ]), - }), - ); - }); - - it('should add cache control to last message for DashScope providers', async () => { - // Mock environment to set dashscope base URL - vi.stubEnv( - 'OPENAI_BASE_URL', - 'https://dashscope.aliyuncs.com/compatible-mode/v1', - ); - - const dashscopeConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('dashscope-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'qwen-turbo', - - apiKey: 'test-key', - - authType: AuthType.QWEN_OAUTH, - - enableOpenAILogging: false, - }; - - const dashscopeGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - dashscopeConfig, - ); - - // Mock the client's baseURL property to return the expected value - Object.defineProperty(dashscopeGenerator['client'], 'baseURL', { - value: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - writable: true, - }); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'qwen-turbo', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello, how are you?' }] }], - model: 'qwen-turbo', - }; - - await dashscopeGenerator.generateContentStream( - request, - 'dashscope-prompt-id', - ); - - // Should include cache control in last message - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ - role: 'user', - content: expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: 'Hello, how are you?', - }), - ]), - }), - ]), - }), - ); - }); - - it('should NOT add cache control for non-DashScope providers', async () => { - const regularConfig = { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: 'openai', - enableOpenAILogging: false, - }), - getSessionId: vi.fn().mockReturnValue('regular-session-id'), - getCliVersion: vi.fn().mockReturnValue('1.0.0'), - } as unknown as Config; - - const contentGeneratorConfig = { - model: 'gpt-4', - - apiKey: 'test-key', - - authType: AuthType.USE_OPENAI, - - enableOpenAILogging: false, - }; - - const regularGenerator = new OpenAIContentGenerator( - contentGeneratorConfig, - regularConfig, - ); - - const mockResponse = { - id: 'chatcmpl-123', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Response' }, - finish_reason: 'stop', - }, - ], - created: 1677652288, - model: 'gpt-4', - }; - - mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); - - const request: GenerateContentParameters = { - contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], - config: { - systemInstruction: 'You are a helpful assistant.', - }, - model: 'gpt-4', - }; - - await regularGenerator.generateContent(request, 'regular-prompt-id'); - - // Should NOT include cache control (messages should be strings, not arrays) - expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ - role: 'system', - content: 'You are a helpful assistant.', - }), - expect.objectContaining({ - role: 'user', - content: 'Hello', - }), - ]), - }), - ); - }); - }); -}); diff --git a/packages/core/src/core/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator.ts deleted file mode 100644 index bdb64165..00000000 --- a/packages/core/src/core/openaiContentGenerator.ts +++ /dev/null @@ -1,1711 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - CountTokensResponse, - GenerateContentParameters, - CountTokensParameters, - EmbedContentResponse, - EmbedContentParameters, - Part, - Content, - Tool, - ToolListUnion, - CallableTool, - FunctionCall, - FunctionResponse, -} from '@google/genai'; -import { GenerateContentResponse, FinishReason } from '@google/genai'; -import type { - ContentGenerator, - ContentGeneratorConfig, -} from './contentGenerator.js'; -import { AuthType } from './contentGenerator.js'; -import OpenAI from 'openai'; -import { logApiError, logApiResponse } from '../telemetry/loggers.js'; -import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js'; -import type { Config } from '../config/config.js'; -import { openaiLogger } from '../utils/openaiLogger.js'; -import { safeJsonParse } from '../utils/safeJsonParse.js'; - -// Extended types to support cache_control -interface ChatCompletionContentPartTextWithCache - extends OpenAI.Chat.ChatCompletionContentPartText { - cache_control?: { type: 'ephemeral' }; -} - -type ChatCompletionContentPartWithCache = - | ChatCompletionContentPartTextWithCache - | OpenAI.Chat.ChatCompletionContentPartImage - | OpenAI.Chat.ChatCompletionContentPartRefusal; - -// OpenAI API type definitions for logging -interface OpenAIToolCall { - id: string; - type: 'function'; - function: { - name: string; - arguments: string; - }; -} - -interface OpenAIContentItem { - type: 'text'; - text: string; - cache_control?: { type: 'ephemeral' }; -} - -interface OpenAIMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string | null | OpenAIContentItem[]; - tool_calls?: OpenAIToolCall[]; - tool_call_id?: string; -} - -interface OpenAIUsage { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - prompt_tokens_details?: { - cached_tokens?: number; - }; -} - -interface OpenAIChoice { - index: number; - message: OpenAIMessage; - finish_reason: string; -} - -interface OpenAIResponseFormat { - id: string; - object: string; - created: number; - model: string; - choices: OpenAIChoice[]; - usage?: OpenAIUsage; -} - -/** - * @deprecated refactored to ./openaiContentGenerator - * use `createOpenAIContentGenerator` instead - * or extend `OpenAIContentGenerator` to add customized behavior - */ -export class OpenAIContentGenerator implements ContentGenerator { - protected client: OpenAI; - private model: string; - private contentGeneratorConfig: ContentGeneratorConfig; - private config: Config; - private streamingToolCalls: Map< - number, - { - id?: string; - name?: string; - arguments: string; - } - > = new Map(); - - constructor( - contentGeneratorConfig: ContentGeneratorConfig, - gcConfig: Config, - ) { - this.model = contentGeneratorConfig.model; - this.contentGeneratorConfig = contentGeneratorConfig; - this.config = gcConfig; - - const version = gcConfig.getCliVersion() || 'unknown'; - const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - - // Check if using OpenRouter and add required headers - const isOpenRouterProvider = this.isOpenRouterProvider(); - const isDashScopeProvider = this.isDashScopeProvider(); - - const defaultHeaders = { - 'User-Agent': userAgent, - ...(isOpenRouterProvider - ? { - 'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', - 'X-Title': 'Qwen Code', - } - : isDashScopeProvider - ? { - 'X-DashScope-CacheControl': 'enable', - 'X-DashScope-UserAgent': userAgent, - 'X-DashScope-AuthType': contentGeneratorConfig.authType, - } - : {}), - }; - - this.client = new OpenAI({ - apiKey: contentGeneratorConfig.apiKey, - baseURL: contentGeneratorConfig.baseUrl, - timeout: contentGeneratorConfig.timeout ?? 120000, - maxRetries: contentGeneratorConfig.maxRetries ?? 3, - defaultHeaders, - }); - } - - /** - * Hook for subclasses to customize error handling behavior - * @param error The error that occurred - * @param request The original request - * @returns true if error logging should be suppressed, false otherwise - */ - protected shouldSuppressErrorLogging( - _error: unknown, - _request: GenerateContentParameters, - ): boolean { - return false; // Default behavior: never suppress error logging - } - - /** - * Check if an error is a timeout error - */ - private isTimeoutError(error: unknown): boolean { - if (!error) return false; - - const errorMessage = - error instanceof Error - ? error.message.toLowerCase() - : String(error).toLowerCase(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const errorCode = (error as any)?.code; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const errorType = (error as any)?.type; - - // Check for common timeout indicators - return ( - errorMessage.includes('timeout') || - errorMessage.includes('timed out') || - errorMessage.includes('connection timeout') || - errorMessage.includes('request timeout') || - errorMessage.includes('read timeout') || - errorMessage.includes('etimedout') || // Include ETIMEDOUT in message check - errorMessage.includes('esockettimedout') || // Include ESOCKETTIMEDOUT in message check - errorCode === 'ETIMEDOUT' || - errorCode === 'ESOCKETTIMEDOUT' || - errorType === 'timeout' || - // OpenAI specific timeout indicators - errorMessage.includes('request timed out') || - errorMessage.includes('deadline exceeded') - ); - } - - private isOpenRouterProvider(): boolean { - const baseURL = this.contentGeneratorConfig.baseUrl || ''; - return baseURL.includes('openrouter.ai'); - } - - /** - * Determine if this is a DashScope provider. - * DashScope providers include QWEN_OAUTH auth type or specific DashScope base URLs. - * - * @returns true if this is a DashScope provider, false otherwise - */ - private isDashScopeProvider(): boolean { - const authType = this.contentGeneratorConfig.authType; - const baseUrl = this.contentGeneratorConfig.baseUrl; - - return ( - authType === AuthType.QWEN_OAUTH || - baseUrl === 'https://dashscope.aliyuncs.com/compatible-mode/v1' || - baseUrl === 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' - ); - } - - /** - * Check if cache control should be disabled based on configuration. - * - * @returns true if cache control should be disabled, false otherwise - */ - private shouldDisableCacheControl(): boolean { - return ( - this.config.getContentGeneratorConfig()?.disableCacheControl === true - ); - } - - /** - * Build metadata object for OpenAI API requests. - * - * @param userPromptId The user prompt ID to include in metadata - * @returns metadata object if shouldIncludeMetadata() returns true, undefined otherwise - */ - private buildMetadata( - userPromptId: string, - ): { metadata: { sessionId?: string; promptId: string } } | undefined { - if (!this.isDashScopeProvider()) { - return undefined; - } - - return { - metadata: { - sessionId: this.config.getSessionId?.(), - promptId: userPromptId, - }, - }; - } - - private async buildCreateParams( - request: GenerateContentParameters, - userPromptId: string, - streaming: boolean = false, - ): Promise[0]> { - let messages = this.convertToOpenAIFormat(request); - - // Add cache control to system and last messages for DashScope providers - // Only add cache control to system message for non-streaming requests - if (this.isDashScopeProvider() && !this.shouldDisableCacheControl()) { - messages = this.addDashScopeCacheControl( - messages, - streaming ? 'both' : 'system', - ); - } - - // Build sampling parameters with clear priority: - // 1. Request-level parameters (highest priority) - // 2. Config-level sampling parameters (medium priority) - // 3. Default values (lowest priority) - const samplingParams = this.buildSamplingParameters(request); - - const createParams: Parameters< - typeof this.client.chat.completions.create - >[0] = { - model: this.model, - messages, - ...samplingParams, - ...(this.buildMetadata(userPromptId) || {}), - }; - - if (request.config?.tools) { - createParams.tools = await this.convertGeminiToolsToOpenAI( - request.config.tools, - ); - } - - if (streaming) { - createParams.stream = true; - createParams.stream_options = { include_usage: true }; - } - - return createParams; - } - - async generateContent( - request: GenerateContentParameters, - userPromptId: string, - ): Promise { - const startTime = Date.now(); - const createParams = await this.buildCreateParams( - request, - userPromptId, - false, - ); - - try { - const completion = (await this.client.chat.completions.create( - createParams, - )) as OpenAI.Chat.ChatCompletion; - - const response = this.convertToGeminiFormat(completion); - const durationMs = Date.now() - startTime; - - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - response.responseId || 'unknown', - this.model, - durationMs, - userPromptId, - this.contentGeneratorConfig.authType, - response.usageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled - if (this.contentGeneratorConfig.enableOpenAILogging) { - const openaiRequest = createParams; - const openaiResponse = this.convertGeminiResponseToOpenAI(response); - await openaiLogger.logInteraction(openaiRequest, openaiResponse); - } - - return response; - } catch (error) { - const durationMs = Date.now() - startTime; - - // Identify timeout errors specifically - const isTimeoutError = this.isTimeoutError(error); - const errorMessage = isTimeoutError - ? `Request timeout after ${Math.round(durationMs / 1000)}s. Try reducing input length or increasing timeout in config.` - : error instanceof Error - ? error.message - : String(error); - - // Log API error event for UI telemetry - const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).requestID || 'unknown', - this.model, - errorMessage, - durationMs, - userPromptId, - this.contentGeneratorConfig.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).code, - ); - logApiError(this.config, errorEvent); - - // Log error interaction if enabled - if (this.contentGeneratorConfig.enableOpenAILogging) { - await openaiLogger.logInteraction( - createParams, - undefined, - error as Error, - ); - } - - // Allow subclasses to suppress error logging for specific scenarios - if (!this.shouldSuppressErrorLogging(error, request)) { - console.error('OpenAI API Error:', errorMessage); - } - - // Provide helpful timeout-specific error message - if (isTimeoutError) { - throw new Error( - `${errorMessage}\n\nTroubleshooting tips:\n` + - `- Reduce input length or complexity\n` + - `- Increase timeout in config: contentGenerator.timeout\n` + - `- Check network connectivity\n` + - `- Consider using streaming mode for long responses`, - ); - } - - throw error; - } - } - - async generateContentStream( - request: GenerateContentParameters, - userPromptId: string, - ): Promise> { - const startTime = Date.now(); - const createParams = await this.buildCreateParams( - request, - userPromptId, - true, - ); - - try { - const stream = (await this.client.chat.completions.create( - createParams, - )) as AsyncIterable; - - const originalStream = this.streamGenerator(stream); - - // Collect all responses for final logging (don't log during streaming) - const responses: GenerateContentResponse[] = []; - - // Return a new generator that both yields responses and collects them - const wrappedGenerator = async function* (this: OpenAIContentGenerator) { - try { - for await (const response of originalStream) { - responses.push(response); - yield response; - } - - const durationMs = Date.now() - startTime; - - // Get final usage metadata from the last response that has it - const finalUsageMetadata = responses - .slice() - .reverse() - .find((r) => r.usageMetadata)?.usageMetadata; - - // Log API response event for UI telemetry - const responseEvent = new ApiResponseEvent( - responses[responses.length - 1]?.responseId || 'unknown', - this.model, - durationMs, - userPromptId, - this.contentGeneratorConfig.authType, - finalUsageMetadata, - ); - - logApiResponse(this.config, responseEvent); - - // Log interaction if enabled (same as generateContent method) - if (this.contentGeneratorConfig.enableOpenAILogging) { - const openaiRequest = createParams; - // For streaming, we combine all responses into a single response for logging - const combinedResponse = - this.combineStreamResponsesForLogging(responses); - const openaiResponse = - this.convertGeminiResponseToOpenAI(combinedResponse); - await openaiLogger.logInteraction(openaiRequest, openaiResponse); - } - } catch (error) { - const durationMs = Date.now() - startTime; - - // Identify timeout errors specifically for streaming - const isTimeoutError = this.isTimeoutError(error); - const errorMessage = isTimeoutError - ? `Streaming request timeout after ${Math.round(durationMs / 1000)}s. Try reducing input length or increasing timeout in config.` - : error instanceof Error - ? error.message - : String(error); - - // Log API error event for UI telemetry - const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).requestID || 'unknown', - this.model, - errorMessage, - durationMs, - userPromptId, - this.contentGeneratorConfig.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).code, - ); - logApiError(this.config, errorEvent); - - // Log error interaction if enabled - if (this.contentGeneratorConfig.enableOpenAILogging) { - await openaiLogger.logInteraction( - createParams, - undefined, - error as Error, - ); - } - - // Provide helpful timeout-specific error message for streaming - if (isTimeoutError) { - throw new Error( - `${errorMessage}\n\nStreaming timeout troubleshooting:\n` + - `- Reduce input length or complexity\n` + - `- Increase timeout in config: contentGenerator.timeout\n` + - `- Check network stability for streaming connections\n` + - `- Consider using non-streaming mode for very long inputs`, - ); - } - - throw error; - } - }.bind(this); - - return wrappedGenerator(); - } catch (error) { - const durationMs = Date.now() - startTime; - - // Identify timeout errors specifically for streaming setup - const isTimeoutError = this.isTimeoutError(error); - const errorMessage = isTimeoutError - ? `Streaming setup timeout after ${Math.round(durationMs / 1000)}s. Try reducing input length or increasing timeout in config.` - : error instanceof Error - ? error.message - : String(error); - - // Log API error event for UI telemetry - const errorEvent = new ApiErrorEvent( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).requestID || 'unknown', - this.model, - errorMessage, - durationMs, - userPromptId, - this.contentGeneratorConfig.authType, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).code, - ); - logApiError(this.config, errorEvent); - - // Allow subclasses to suppress error logging for specific scenarios - if (!this.shouldSuppressErrorLogging(error, request)) { - console.error('OpenAI API Streaming Error:', errorMessage); - } - - // Provide helpful timeout-specific error message for streaming setup - if (isTimeoutError) { - throw new Error( - `${errorMessage}\n\nStreaming setup timeout troubleshooting:\n` + - `- Reduce input length or complexity\n` + - `- Increase timeout in config: contentGenerator.timeout\n` + - `- Check network connectivity and firewall settings\n` + - `- Consider using non-streaming mode for very long inputs`, - ); - } - - throw error; - } - } - - private async *streamGenerator( - stream: AsyncIterable, - ): AsyncGenerator { - // Reset the accumulator for each new stream - this.streamingToolCalls.clear(); - - for await (const chunk of stream) { - const response = this.convertStreamChunkToGeminiFormat(chunk); - - // Ignore empty responses, which would cause problems with downstream code - // that expects a valid response. - if ( - response.candidates?.[0]?.content?.parts?.length === 0 && - !response.usageMetadata - ) { - continue; - } - - yield response; - } - } - - /** - * Combine streaming responses for logging purposes - */ - private combineStreamResponsesForLogging( - responses: GenerateContentResponse[], - ): GenerateContentResponse { - if (responses.length === 0) { - return new GenerateContentResponse(); - } - - const lastResponse = responses[responses.length - 1]; - - // Find the last response with usage metadata - const finalUsageMetadata = responses - .slice() - .reverse() - .find((r) => r.usageMetadata)?.usageMetadata; - - // Combine all text content from the stream - const combinedParts: Part[] = []; - let combinedText = ''; - const functionCalls: Part[] = []; - - for (const response of responses) { - if (response.candidates?.[0]?.content?.parts) { - for (const part of response.candidates[0].content.parts) { - if ('text' in part && part.text) { - combinedText += part.text; - } else if ('functionCall' in part && part.functionCall) { - functionCalls.push(part); - } - } - } - } - - // Add combined text if any - if (combinedText) { - combinedParts.push({ text: combinedText }); - } - - // Add function calls - combinedParts.push(...functionCalls); - - // Create combined response - const combinedResponse = new GenerateContentResponse(); - combinedResponse.candidates = [ - { - content: { - parts: combinedParts, - role: 'model' as const, - }, - finishReason: - responses[responses.length - 1]?.candidates?.[0]?.finishReason || - FinishReason.FINISH_REASON_UNSPECIFIED, - index: 0, - safetyRatings: [], - }, - ]; - combinedResponse.responseId = lastResponse?.responseId; - combinedResponse.createTime = lastResponse?.createTime; - combinedResponse.modelVersion = this.model; - combinedResponse.promptFeedback = { safetyRatings: [] }; - combinedResponse.usageMetadata = finalUsageMetadata; - - return combinedResponse; - } - - async countTokens( - request: CountTokensParameters, - ): Promise { - // Use tiktoken for accurate token counting - const content = JSON.stringify(request.contents); - let totalTokens = 0; - - try { - const tikToken = await import('tiktoken'); - const encoding = tikToken.get_encoding('cl100k_base'); // GPT-4 encoding, but estimate for qwen - totalTokens = encoding.encode(content).length; - encoding.free(); - } catch (error) { - console.warn( - 'Failed to load tiktoken, falling back to character approximation:', - error, - ); - // Fallback: rough approximation using character count - totalTokens = Math.ceil(content.length / 4); // Rough estimate: 1 token ≈ 4 characters - } - - return { - totalTokens, - }; - } - - async embedContent( - request: EmbedContentParameters, - ): Promise { - // Extract text from contents - let text = ''; - if (Array.isArray(request.contents)) { - text = request.contents - .map((content) => { - if (typeof content === 'string') return content; - if ('parts' in content && content.parts) { - return content.parts - .map((part) => - typeof part === 'string' - ? part - : 'text' in part - ? (part as { text?: string }).text || '' - : '', - ) - .join(' '); - } - return ''; - }) - .join(' '); - } else if (request.contents) { - if (typeof request.contents === 'string') { - text = request.contents; - } else if ('parts' in request.contents && request.contents.parts) { - text = request.contents.parts - .map((part: Part) => - typeof part === 'string' ? part : 'text' in part ? part.text : '', - ) - .join(' '); - } - } - - try { - const embedding = await this.client.embeddings.create({ - model: 'text-embedding-ada-002', // Default embedding model - input: text, - }); - - return { - embeddings: [ - { - values: embedding.data[0].embedding, - }, - ], - }; - } catch (error) { - console.error('OpenAI API Embedding Error:', error); - throw new Error( - `OpenAI API error: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - private convertGeminiParametersToOpenAI( - parameters: Record, - ): Record | undefined { - if (!parameters || typeof parameters !== 'object') { - return parameters; - } - - const converted = JSON.parse(JSON.stringify(parameters)); - - const convertTypes = (obj: unknown): unknown => { - if (typeof obj !== 'object' || obj === null) { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(convertTypes); - } - - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - if (key === 'type' && typeof value === 'string') { - // Convert Gemini types to OpenAI JSON Schema types - const lowerValue = value.toLowerCase(); - if (lowerValue === 'integer') { - result[key] = 'integer'; - } else if (lowerValue === 'number') { - result[key] = 'number'; - } else { - result[key] = lowerValue; - } - } else if ( - key === 'minimum' || - key === 'maximum' || - key === 'multipleOf' - ) { - // Ensure numeric constraints are actual numbers, not strings - if (typeof value === 'string' && !isNaN(Number(value))) { - result[key] = Number(value); - } else { - result[key] = value; - } - } else if ( - key === 'minLength' || - key === 'maxLength' || - key === 'minItems' || - key === 'maxItems' - ) { - // Ensure length constraints are integers, not strings - if (typeof value === 'string' && !isNaN(Number(value))) { - result[key] = parseInt(value, 10); - } else { - result[key] = value; - } - } else if (typeof value === 'object') { - result[key] = convertTypes(value); - } else { - result[key] = value; - } - } - return result; - }; - - return convertTypes(converted) as Record | undefined; - } - - /** - * Converts Gemini tools to OpenAI format for API compatibility. - * Handles both Gemini tools (using 'parameters' field) and MCP tools (using 'parametersJsonSchema' field). - * - * Gemini tools use a custom parameter format that needs conversion to OpenAI JSON Schema format. - * MCP tools already use JSON Schema format in the parametersJsonSchema field and can be used directly. - * - * @param geminiTools - Array of Gemini tools to convert - * @returns Promise resolving to array of OpenAI-compatible tools - */ - private async convertGeminiToolsToOpenAI( - geminiTools: ToolListUnion, - ): Promise { - const openAITools: OpenAI.Chat.ChatCompletionTool[] = []; - - for (const tool of geminiTools) { - let actualTool: Tool; - - // Handle CallableTool vs Tool - if ('tool' in tool) { - // This is a CallableTool - actualTool = await (tool as CallableTool).tool(); - } else { - // This is already a Tool - actualTool = tool as Tool; - } - - if (actualTool.functionDeclarations) { - for (const func of actualTool.functionDeclarations) { - if (func.name && func.description) { - let parameters: Record | undefined; - - // Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema) - if (func.parametersJsonSchema) { - // MCP tool format - use parametersJsonSchema directly - if (func.parametersJsonSchema) { - // Create a shallow copy to avoid mutating the original object - const paramsCopy = { - ...(func.parametersJsonSchema as Record), - }; - parameters = paramsCopy; - } - } else if (func.parameters) { - // Gemini tool format - convert parameters to OpenAI format - parameters = this.convertGeminiParametersToOpenAI( - func.parameters as Record, - ); - } - - openAITools.push({ - type: 'function', - function: { - name: func.name, - description: func.description, - parameters, - }, - }); - } - } - } - } - - // console.log( - // 'OpenAI Tools Parameters:', - // JSON.stringify(openAITools, null, 2), - // ); - return openAITools; - } - - private convertToOpenAIFormat( - request: GenerateContentParameters, - ): OpenAI.Chat.ChatCompletionMessageParam[] { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; - - // Handle system instruction from config - if (request.config?.systemInstruction) { - const systemInstruction = request.config.systemInstruction; - let systemText = ''; - - if (Array.isArray(systemInstruction)) { - systemText = systemInstruction - .map((content) => { - if (typeof content === 'string') return content; - if ('parts' in content) { - const contentObj = content as Content; - return ( - contentObj.parts - ?.map((p: Part) => - typeof p === 'string' ? p : 'text' in p ? p.text : '', - ) - .join('\n') || '' - ); - } - return ''; - }) - .join('\n'); - } else if (typeof systemInstruction === 'string') { - systemText = systemInstruction; - } else if ( - typeof systemInstruction === 'object' && - 'parts' in systemInstruction - ) { - const systemContent = systemInstruction as Content; - systemText = - systemContent.parts - ?.map((p: Part) => - typeof p === 'string' ? p : 'text' in p ? p.text : '', - ) - .join('\n') || ''; - } - - if (systemText) { - messages.push({ - role: 'system' as const, - content: systemText, - }); - } - } - - // Handle contents - if (Array.isArray(request.contents)) { - for (const content of request.contents) { - if (typeof content === 'string') { - messages.push({ role: 'user' as const, content }); - } else if ('role' in content && 'parts' in content) { - // Check if this content has function calls or responses - const functionCalls: FunctionCall[] = []; - const functionResponses: FunctionResponse[] = []; - const textParts: string[] = []; - - for (const part of content.parts || []) { - if (typeof part === 'string') { - textParts.push(part); - } else if ('text' in part && part.text) { - textParts.push(part.text); - } else if ('functionCall' in part && part.functionCall) { - functionCalls.push(part.functionCall); - } else if ('functionResponse' in part && part.functionResponse) { - functionResponses.push(part.functionResponse); - } - } - - // Handle function responses (tool results) - if (functionResponses.length > 0) { - for (const funcResponse of functionResponses) { - messages.push({ - role: 'tool' as const, - tool_call_id: funcResponse.id || '', - content: - typeof funcResponse.response === 'string' - ? funcResponse.response - : JSON.stringify(funcResponse.response), - }); - } - } - // Handle model messages with function calls - else if (content.role === 'model' && functionCalls.length > 0) { - const toolCalls = functionCalls.map((fc, index) => ({ - id: fc.id || `call_${index}`, - type: 'function' as const, - function: { - name: fc.name || '', - arguments: JSON.stringify(fc.args || {}), - }, - })); - - messages.push({ - role: 'assistant' as const, - content: textParts.join('') || null, - tool_calls: toolCalls, - }); - } - // Handle regular text messages - else { - const role = - content.role === 'model' - ? ('assistant' as const) - : ('user' as const); - const text = textParts.join(''); - if (text) { - messages.push({ role, content: text }); - } - } - } - } - } else if (request.contents) { - if (typeof request.contents === 'string') { - messages.push({ role: 'user' as const, content: request.contents }); - } else if ('role' in request.contents && 'parts' in request.contents) { - const content = request.contents; - const role = - content.role === 'model' ? ('assistant' as const) : ('user' as const); - const text = - content.parts - ?.map((p: Part) => - typeof p === 'string' ? p : 'text' in p ? p.text : '', - ) - .join('\n') || ''; - messages.push({ role, content: text }); - } - } - - // Clean up orphaned tool calls and merge consecutive assistant messages - const cleanedMessages = this.cleanOrphanedToolCalls(messages); - const mergedMessages = - this.mergeConsecutiveAssistantMessages(cleanedMessages); - - return mergedMessages; - } - - /** - * Add cache control flag to specified message(s) for DashScope providers - */ - private addDashScopeCacheControl( - messages: OpenAI.Chat.ChatCompletionMessageParam[], - target: 'system' | 'last' | 'both' = 'both', - ): OpenAI.Chat.ChatCompletionMessageParam[] { - if (!this.isDashScopeProvider() || messages.length === 0) { - return messages; - } - - let updatedMessages = [...messages]; - - // Add cache control to system message if requested - if (target === 'system' || target === 'both') { - updatedMessages = this.addCacheControlToMessage( - updatedMessages, - 'system', - ); - } - - // Add cache control to last message if requested - if (target === 'last' || target === 'both') { - updatedMessages = this.addCacheControlToMessage(updatedMessages, 'last'); - } - - return updatedMessages; - } - - /** - * Helper method to add cache control to a specific message - */ - private addCacheControlToMessage( - messages: OpenAI.Chat.ChatCompletionMessageParam[], - target: 'system' | 'last', - ): OpenAI.Chat.ChatCompletionMessageParam[] { - const updatedMessages = [...messages]; - let messageIndex: number; - - if (target === 'system') { - // Find the first system message - messageIndex = messages.findIndex((msg) => msg.role === 'system'); - if (messageIndex === -1) { - return updatedMessages; - } - } else { - // Get the last message - messageIndex = messages.length - 1; - } - - const message = updatedMessages[messageIndex]; - - // Only process messages that have content - if ('content' in message && message.content !== null) { - if (typeof message.content === 'string') { - // Convert string content to array format with cache control - const messageWithArrayContent = { - ...message, - content: [ - { - type: 'text', - text: message.content, - cache_control: { type: 'ephemeral' }, - } as ChatCompletionContentPartTextWithCache, - ], - }; - updatedMessages[messageIndex] = - messageWithArrayContent as OpenAI.Chat.ChatCompletionMessageParam; - } else if (Array.isArray(message.content)) { - // If content is already an array, add cache_control to the last item - const contentArray = [ - ...message.content, - ] as ChatCompletionContentPartWithCache[]; - if (contentArray.length > 0) { - const lastItem = contentArray[contentArray.length - 1]; - if (lastItem.type === 'text') { - // Add cache_control to the last text item - contentArray[contentArray.length - 1] = { - ...lastItem, - cache_control: { type: 'ephemeral' }, - } as ChatCompletionContentPartTextWithCache; - } else { - // If the last item is not text, add a new text item with cache_control - contentArray.push({ - type: 'text', - text: '', - cache_control: { type: 'ephemeral' }, - } as ChatCompletionContentPartTextWithCache); - } - - const messageWithCache = { - ...message, - content: contentArray, - }; - updatedMessages[messageIndex] = - messageWithCache as OpenAI.Chat.ChatCompletionMessageParam; - } - } - } - - return updatedMessages; - } - - /** - * Clean up orphaned tool calls from message history to prevent OpenAI API errors - */ - private cleanOrphanedToolCalls( - messages: OpenAI.Chat.ChatCompletionMessageParam[], - ): OpenAI.Chat.ChatCompletionMessageParam[] { - const cleaned: OpenAI.Chat.ChatCompletionMessageParam[] = []; - const toolCallIds = new Set(); - const toolResponseIds = new Set(); - - // First pass: collect all tool call IDs and tool response IDs - for (const message of messages) { - if ( - message.role === 'assistant' && - 'tool_calls' in message && - message.tool_calls - ) { - for (const toolCall of message.tool_calls) { - if (toolCall.id) { - toolCallIds.add(toolCall.id); - } - } - } else if ( - message.role === 'tool' && - 'tool_call_id' in message && - message.tool_call_id - ) { - toolResponseIds.add(message.tool_call_id); - } - } - - // Second pass: filter out orphaned messages - for (const message of messages) { - if ( - message.role === 'assistant' && - 'tool_calls' in message && - message.tool_calls - ) { - // Filter out tool calls that don't have corresponding responses - const validToolCalls = message.tool_calls.filter( - (toolCall) => toolCall.id && toolResponseIds.has(toolCall.id), - ); - - if (validToolCalls.length > 0) { - // Keep the message but only with valid tool calls - const cleanedMessage = { ...message }; - ( - cleanedMessage as OpenAI.Chat.ChatCompletionMessageParam & { - tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; - } - ).tool_calls = validToolCalls; - cleaned.push(cleanedMessage); - } else if ( - typeof message.content === 'string' && - message.content.trim() - ) { - // Keep the message if it has text content, but remove tool calls - const cleanedMessage = { ...message }; - delete ( - cleanedMessage as OpenAI.Chat.ChatCompletionMessageParam & { - tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; - } - ).tool_calls; - cleaned.push(cleanedMessage); - } - // If no valid tool calls and no content, skip the message entirely - } else if ( - message.role === 'tool' && - 'tool_call_id' in message && - message.tool_call_id - ) { - // Only keep tool responses that have corresponding tool calls - if (toolCallIds.has(message.tool_call_id)) { - cleaned.push(message); - } - } else { - // Keep all other messages as-is - cleaned.push(message); - } - } - - // Final validation: ensure every assistant message with tool_calls has corresponding tool responses - const finalCleaned: OpenAI.Chat.ChatCompletionMessageParam[] = []; - const finalToolCallIds = new Set(); - - // Collect all remaining tool call IDs - for (const message of cleaned) { - if ( - message.role === 'assistant' && - 'tool_calls' in message && - message.tool_calls - ) { - for (const toolCall of message.tool_calls) { - if (toolCall.id) { - finalToolCallIds.add(toolCall.id); - } - } - } - } - - // Verify all tool calls have responses - const finalToolResponseIds = new Set(); - for (const message of cleaned) { - if ( - message.role === 'tool' && - 'tool_call_id' in message && - message.tool_call_id - ) { - finalToolResponseIds.add(message.tool_call_id); - } - } - - // Remove any remaining orphaned tool calls - for (const message of cleaned) { - if ( - message.role === 'assistant' && - 'tool_calls' in message && - message.tool_calls - ) { - const finalValidToolCalls = message.tool_calls.filter( - (toolCall) => toolCall.id && finalToolResponseIds.has(toolCall.id), - ); - - if (finalValidToolCalls.length > 0) { - const cleanedMessage = { ...message }; - ( - cleanedMessage as OpenAI.Chat.ChatCompletionMessageParam & { - tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; - } - ).tool_calls = finalValidToolCalls; - finalCleaned.push(cleanedMessage); - } else if ( - typeof message.content === 'string' && - message.content.trim() - ) { - const cleanedMessage = { ...message }; - delete ( - cleanedMessage as OpenAI.Chat.ChatCompletionMessageParam & { - tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; - } - ).tool_calls; - finalCleaned.push(cleanedMessage); - } - } else { - finalCleaned.push(message); - } - } - - return finalCleaned; - } - - /** - * Merge consecutive assistant messages to combine split text and tool calls - */ - private mergeConsecutiveAssistantMessages( - messages: OpenAI.Chat.ChatCompletionMessageParam[], - ): OpenAI.Chat.ChatCompletionMessageParam[] { - const merged: OpenAI.Chat.ChatCompletionMessageParam[] = []; - - for (const message of messages) { - if (message.role === 'assistant' && merged.length > 0) { - const lastMessage = merged[merged.length - 1]; - - // If the last message is also an assistant message, merge them - if (lastMessage.role === 'assistant') { - // Combine content - const combinedContent = [ - typeof lastMessage.content === 'string' ? lastMessage.content : '', - typeof message.content === 'string' ? message.content : '', - ] - .filter(Boolean) - .join(''); - - // Combine tool calls - const lastToolCalls = - 'tool_calls' in lastMessage ? lastMessage.tool_calls || [] : []; - const currentToolCalls = - 'tool_calls' in message ? message.tool_calls || [] : []; - const combinedToolCalls = [...lastToolCalls, ...currentToolCalls]; - - // Update the last message with combined data - ( - lastMessage as OpenAI.Chat.ChatCompletionMessageParam & { - content: string | null; - tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; - } - ).content = combinedContent || null; - if (combinedToolCalls.length > 0) { - ( - lastMessage as OpenAI.Chat.ChatCompletionMessageParam & { - content: string | null; - tool_calls?: OpenAI.Chat.ChatCompletionMessageToolCall[]; - } - ).tool_calls = combinedToolCalls; - } - - continue; // Skip adding the current message since it's been merged - } - } - - // Add the message as-is if no merging is needed - merged.push(message); - } - - return merged; - } - - private convertToGeminiFormat( - openaiResponse: OpenAI.Chat.ChatCompletion, - ): GenerateContentResponse { - const choice = openaiResponse.choices[0]; - const response = new GenerateContentResponse(); - - const parts: Part[] = []; - - // Handle text content - if (choice.message.content) { - parts.push({ text: choice.message.content }); - } - - // Handle tool calls - if (choice.message.tool_calls) { - for (const toolCall of choice.message.tool_calls) { - if (toolCall.function) { - let args: Record = {}; - if (toolCall.function.arguments) { - args = safeJsonParse(toolCall.function.arguments, {}); - } - - parts.push({ - functionCall: { - id: toolCall.id, - name: toolCall.function.name, - args, - }, - }); - } - } - } - - response.responseId = openaiResponse.id; - response.createTime = openaiResponse.created - ? openaiResponse.created.toString() - : new Date().getTime().toString(); - - response.candidates = [ - { - content: { - parts, - role: 'model' as const, - }, - finishReason: this.mapFinishReason(choice.finish_reason || 'stop'), - index: 0, - safetyRatings: [], - }, - ]; - - response.modelVersion = this.model; - response.promptFeedback = { safetyRatings: [] }; - - // Add usage metadata if available - if (openaiResponse.usage) { - const usage = openaiResponse.usage as OpenAIUsage; - - const promptTokens = usage.prompt_tokens || 0; - const completionTokens = usage.completion_tokens || 0; - const totalTokens = usage.total_tokens || 0; - const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0; - - // If we only have total tokens but no breakdown, estimate the split - // Typically input is ~70% and output is ~30% for most conversations - let finalPromptTokens = promptTokens; - let finalCompletionTokens = completionTokens; - - if (totalTokens > 0 && promptTokens === 0 && completionTokens === 0) { - // Estimate: assume 70% input, 30% output - finalPromptTokens = Math.round(totalTokens * 0.7); - finalCompletionTokens = Math.round(totalTokens * 0.3); - } - - response.usageMetadata = { - promptTokenCount: finalPromptTokens, - candidatesTokenCount: finalCompletionTokens, - totalTokenCount: totalTokens, - cachedContentTokenCount: cachedTokens, - }; - } - - return response; - } - - private convertStreamChunkToGeminiFormat( - chunk: OpenAI.Chat.ChatCompletionChunk, - ): GenerateContentResponse { - const choice = chunk.choices?.[0]; - const response = new GenerateContentResponse(); - - if (choice) { - const parts: Part[] = []; - - // Handle text content - if (choice.delta?.content) { - if (typeof choice.delta.content === 'string') { - parts.push({ text: choice.delta.content }); - } - } - - // Handle tool calls - only accumulate during streaming, emit when complete - if (choice.delta?.tool_calls) { - for (const toolCall of choice.delta.tool_calls) { - const index = toolCall.index ?? 0; - - // Get or create the tool call accumulator for this index - let accumulatedCall = this.streamingToolCalls.get(index); - if (!accumulatedCall) { - accumulatedCall = { arguments: '' }; - this.streamingToolCalls.set(index, accumulatedCall); - } - - // Update accumulated data - if (toolCall.id) { - accumulatedCall.id = toolCall.id; - } - if (toolCall.function?.name) { - // If this is a new function name, reset the arguments - if (accumulatedCall.name !== toolCall.function.name) { - accumulatedCall.arguments = ''; - } - accumulatedCall.name = toolCall.function.name; - } - if (toolCall.function?.arguments) { - // Check if we already have a complete JSON object - const currentArgs = accumulatedCall.arguments; - const newArgs = toolCall.function.arguments; - - // If current arguments already form a complete JSON and new arguments start a new object, - // this indicates a new tool call with the same name - let shouldReset = false; - if (currentArgs && newArgs.trim().startsWith('{')) { - try { - JSON.parse(currentArgs); - // If we can parse current arguments as complete JSON and new args start with {, - // this is likely a new tool call - shouldReset = true; - } catch { - // Current arguments are not complete JSON, continue accumulating - } - } - - if (shouldReset) { - accumulatedCall.arguments = newArgs; - } else { - accumulatedCall.arguments += newArgs; - } - } - } - } - - // Only emit function calls when streaming is complete (finish_reason is present) - if (choice.finish_reason) { - for (const [, accumulatedCall] of this.streamingToolCalls) { - // TODO: Add back id once we have a way to generate tool_call_id from the VLLM parser. - // if (accumulatedCall.id && accumulatedCall.name) { - if (accumulatedCall.name) { - let args: Record = {}; - if (accumulatedCall.arguments) { - args = safeJsonParse(accumulatedCall.arguments, {}); - } - - parts.push({ - functionCall: { - id: - accumulatedCall.id || - `call_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, - name: accumulatedCall.name, - args, - }, - }); - } - } - // Clear all accumulated tool calls - this.streamingToolCalls.clear(); - } - - response.candidates = [ - { - content: { - parts, - role: 'model' as const, - }, - finishReason: choice.finish_reason - ? this.mapFinishReason(choice.finish_reason) - : FinishReason.FINISH_REASON_UNSPECIFIED, - index: 0, - safetyRatings: [], - }, - ]; - } else { - response.candidates = []; - } - - response.responseId = chunk.id; - response.createTime = chunk.created - ? chunk.created.toString() - : new Date().getTime().toString(); - - response.modelVersion = this.model; - response.promptFeedback = { safetyRatings: [] }; - - // Add usage metadata if available in the chunk - if (chunk.usage) { - const usage = chunk.usage as OpenAIUsage; - - const promptTokens = usage.prompt_tokens || 0; - const completionTokens = usage.completion_tokens || 0; - const totalTokens = usage.total_tokens || 0; - const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0; - - // If we only have total tokens but no breakdown, estimate the split - // Typically input is ~70% and output is ~30% for most conversations - let finalPromptTokens = promptTokens; - let finalCompletionTokens = completionTokens; - - if (totalTokens > 0 && promptTokens === 0 && completionTokens === 0) { - // Estimate: assume 70% input, 30% output - finalPromptTokens = Math.round(totalTokens * 0.7); - finalCompletionTokens = Math.round(totalTokens * 0.3); - } - - response.usageMetadata = { - promptTokenCount: finalPromptTokens, - candidatesTokenCount: finalCompletionTokens, - totalTokenCount: totalTokens, - cachedContentTokenCount: cachedTokens, - }; - } - - return response; - } - - /** - * Build sampling parameters with clear priority: - * 1. Config-level sampling parameters (highest priority) - * 2. Request-level parameters (medium priority) - * 3. Default values (lowest priority) - */ - private buildSamplingParameters( - request: GenerateContentParameters, - ): Record { - const configSamplingParams = this.contentGeneratorConfig.samplingParams; - - const params = { - // Temperature: config > request > default - temperature: - configSamplingParams?.temperature !== undefined - ? configSamplingParams.temperature - : request.config?.temperature !== undefined - ? request.config.temperature - : 0.0, - - // Max tokens: config > request > undefined - ...(configSamplingParams?.max_tokens !== undefined - ? { max_tokens: configSamplingParams.max_tokens } - : request.config?.maxOutputTokens !== undefined - ? { max_tokens: request.config.maxOutputTokens } - : {}), - - // Top-p: config > request > default - top_p: - configSamplingParams?.top_p !== undefined - ? configSamplingParams.top_p - : request.config?.topP !== undefined - ? request.config.topP - : 1.0, - - // Top-k: config only (not available in request) - ...(configSamplingParams?.top_k !== undefined - ? { top_k: configSamplingParams.top_k } - : {}), - - // Repetition penalty: config only - ...(configSamplingParams?.repetition_penalty !== undefined - ? { repetition_penalty: configSamplingParams.repetition_penalty } - : {}), - - // Presence penalty: config only - ...(configSamplingParams?.presence_penalty !== undefined - ? { presence_penalty: configSamplingParams.presence_penalty } - : {}), - - // Frequency penalty: config only - ...(configSamplingParams?.frequency_penalty !== undefined - ? { frequency_penalty: configSamplingParams.frequency_penalty } - : {}), - }; - - return params; - } - - private mapFinishReason(openaiReason: string | null): FinishReason { - if (!openaiReason) return FinishReason.FINISH_REASON_UNSPECIFIED; - const mapping: Record = { - stop: FinishReason.STOP, - length: FinishReason.MAX_TOKENS, - content_filter: FinishReason.SAFETY, - function_call: FinishReason.STOP, - tool_calls: FinishReason.STOP, - }; - return mapping[openaiReason] || FinishReason.FINISH_REASON_UNSPECIFIED; - } - - /** - * Convert Gemini response format to OpenAI chat completion format for logging - */ - private convertGeminiResponseToOpenAI( - response: GenerateContentResponse, - ): OpenAIResponseFormat { - const candidate = response.candidates?.[0]; - const content = candidate?.content; - - let messageContent: string | null = null; - const toolCalls: OpenAIToolCall[] = []; - - if (content?.parts) { - const textParts: string[] = []; - - for (const part of content.parts) { - if ('text' in part && part.text) { - textParts.push(part.text); - } else if ('functionCall' in part && part.functionCall) { - toolCalls.push({ - id: part.functionCall.id || `call_${toolCalls.length}`, - type: 'function' as const, - function: { - name: part.functionCall.name || '', - arguments: JSON.stringify(part.functionCall.args || {}), - }, - }); - } - } - - messageContent = textParts.join('').trimEnd(); - } - - const choice: OpenAIChoice = { - index: 0, - message: { - role: 'assistant', - content: messageContent, - }, - finish_reason: this.mapGeminiFinishReasonToOpenAI( - candidate?.finishReason, - ), - }; - - if (toolCalls.length > 0) { - choice.message.tool_calls = toolCalls; - } - - const openaiResponse: OpenAIResponseFormat = { - id: response.responseId || `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: response.createTime - ? Number(response.createTime) - : Math.floor(Date.now() / 1000), - model: this.model, - choices: [choice], - }; - - // Add usage metadata if available - if (response.usageMetadata) { - openaiResponse.usage = { - prompt_tokens: response.usageMetadata.promptTokenCount || 0, - completion_tokens: response.usageMetadata.candidatesTokenCount || 0, - total_tokens: response.usageMetadata.totalTokenCount || 0, - }; - - if (response.usageMetadata.cachedContentTokenCount) { - openaiResponse.usage.prompt_tokens_details = { - cached_tokens: response.usageMetadata.cachedContentTokenCount, - }; - } - } - - return openaiResponse; - } - - /** - * Map Gemini finish reasons to OpenAI finish reasons - */ - private mapGeminiFinishReasonToOpenAI(geminiReason?: unknown): string { - if (!geminiReason) return 'stop'; - - switch (geminiReason) { - case 'STOP': - case 1: // FinishReason.STOP - return 'stop'; - case 'MAX_TOKENS': - case 2: // FinishReason.MAX_TOKENS - return 'length'; - case 'SAFETY': - case 3: // FinishReason.SAFETY - return 'content_filter'; - case 'RECITATION': - case 4: // FinishReason.RECITATION - return 'content_filter'; - case 'OTHER': - case 5: // FinishReason.OTHER - return 'stop'; - default: - return 'stop'; - } - } -}