import { describe, expect, it, vi } from 'vitest'; import { of } from 'rxjs'; import { ProxyController } from '../../server/modules/proxy/proxy.controller'; function createReplyMock() { const reply: Record = {}; reply.status = vi.fn(() => reply); reply.header = vi.fn(() => reply); reply.send = vi.fn(() => reply); return reply; } describe('ProxyController Integration', () => { it('routes Claude OpenAI requests to protocol parity path', async () => { const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }), handleAnthropicMessages: vi.fn(), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.chatCompletions( { model: 'claude-sonnet-4-5', stream: false, messages: [{ role: 'user', content: 'hello' }], } as any, reply as any, ); expect(proxyService.handleChatCompletions).toHaveBeenCalledOnce(); expect(proxyService.handleAnthropicMessages).not.toHaveBeenCalled(); expect(reply.status).toHaveBeenCalledWith(200); }); it('returns stream response with SSE headers for parity stream path', async () => { const stream = of('data: {"ok":true}\n\n'); const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue(stream), handleAnthropicMessages: vi.fn(), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.chatCompletions( { model: 'claude-sonnet-4-5', stream: true, messages: [{ role: 'user', content: 'hello' }], } as any, reply as any, ); expect(reply.header).toHaveBeenCalledWith('Content-Type', 'text/event-stream'); expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(reply.header).toHaveBeenCalledWith('Connection', 'keep-alive'); expect(reply.send).toHaveBeenCalledWith(stream); }); it('supports OpenAI completions compatibility endpoint', async () => { const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue({ id: 'chatcmpl_test', object: 'chat.completion', created: 1700000000, model: 'gpt-4o', choices: [ { index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'hello from assistant', }, }, ], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, }, }), handleAnthropicMessages: vi.fn(), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.completions( { model: 'gpt-4o', prompt: 'hello world', stream: false, }, reply as any, ); expect(proxyService.handleChatCompletions).toHaveBeenCalledWith( expect.objectContaining({ model: 'gpt-4o', messages: [{ role: 'user', content: 'hello world' }], }), ); expect(reply.status).toHaveBeenCalledWith(200); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ object: 'text_completion', model: 'gpt-4o', choices: [ expect.objectContaining({ text: 'hello from assistant', logprobs: null, }), ], }), ); }); it('supports OpenAI responses compatibility endpoint with normalized input', async () => { const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue({ id: 'chatcmpl_resp', object: 'chat.completion', created: 1700000001, model: 'gpt-4o', choices: [ { index: 0, finish_reason: 'stop', message: { content: 'normalized response', }, }, ], usage: { prompt_tokens: 10, completion_tokens: 6, total_tokens: 16, }, }), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.responses( { model: 'gpt-4o', instructions: 'Follow the tool protocol', input: [ { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }], }, { type: 'function_call', id: 'call_1', name: 'search_docs', arguments: '{"query":"token"}', }, { type: 'function_call_output', call_id: 'call_1', output: { content: 'result: ok' }, }, ], }, reply as any, ); const callArg = proxyService.handleChatCompletions.mock.calls[0][0]; expect(callArg.messages[0]).toEqual({ role: 'system', content: 'Follow the tool protocol', }); expect(callArg.messages.some((message: { role: string }) => message.role === 'assistant')).toBe( true, ); expect(callArg.messages.some((message: { role: string }) => message.role === 'tool')).toBe( true, ); expect(reply.status).toHaveBeenCalledWith(200); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ object: 'text_completion', model: 'gpt-4o', choices: [ expect.objectContaining({ text: 'normalized response', logprobs: null, }), ], usage: expect.objectContaining({ prompt_tokens: 10, completion_tokens: 6, total_tokens: 16, }), }), ); }); it('supports OpenAI responses compatibility endpoint in stream mode with SSE headers', async () => { const stream = of('data: {"id":"chatcmpl_resp_stream"}\n\n'); const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue(stream), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.responses( { model: 'gpt-4o', instructions: 'stream output', input: 'hello', stream: true, }, reply as any, ); expect(reply.header).toHaveBeenCalledWith('Content-Type', 'text/event-stream'); expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache'); expect(reply.header).toHaveBeenCalledWith('Connection', 'keep-alive'); expect(reply.send).toHaveBeenCalledWith(stream); }); it('normalizes web_search_call in /v1/responses into builtin_web_search tool messages', async () => { const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue({ id: 'chatcmpl_resp_search', object: 'chat.completion', created: 1700000002, model: 'gpt-4o', choices: [{ index: 0, finish_reason: 'stop', message: { content: 'done' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.responses( { model: 'gpt-4o', input: [ { type: 'web_search_call', call_id: 'call_search_1', action: { query: 'gemini api' }, }, { type: 'function_call_output', call_id: 'call_search_1', output: { content: 'search result' }, }, ], }, reply as any, ); const callArg = proxyService.handleChatCompletions.mock.calls[0][0]; const assistantMessage = callArg.messages.find( (message: { role: string }) => message.role === 'assistant', ); const toolMessage = callArg.messages.find( (message: { role: string }) => message.role === 'tool', ); expect(assistantMessage?.tool_calls?.[0]?.function?.name).toBe('builtin_web_search'); expect(toolMessage?.name).toBe('builtin_web_search'); }); it('supports image generations endpoint', async () => { const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue({ choices: [ { message: { content: '![img](data:image/png;base64,AAAABBBB)', }, }, ], }), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.imageGenerations( { model: 'gemini-3-pro-image', prompt: 'draw a cat', }, reply as any, ); expect(reply.status).toHaveBeenCalledWith(200); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ data: [ expect.objectContaining({ b64_json: 'AAAABBBB', }), ], }), ); }); it('maps image generation upstream quota errors to 429', async () => { const proxyService = { handleChatCompletions: vi.fn().mockRejectedValue(new Error('429 quota exceeded')), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.imageGenerations( { model: 'gemini-3-pro-image', prompt: 'draw a dog', }, reply as any, ); expect(reply.status).toHaveBeenCalledWith(429); }); it('falls back to Gemini image generation when chat path hits project context error', async () => { const proxyService = { handleChatCompletions: vi .fn() .mockRejectedValue( new Error( 'You are currently configured to use a Google Cloud Project but lack a Gemini Code Assist license. (#3501)', ), ), handleGeminiGenerateContent: vi.fn().mockResolvedValue({ candidates: [ { content: { parts: [ { inlineData: { mimeType: 'image/png', data: 'FALLBACKIMG', }, }, ], }, }, ], }), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.imageGenerations( { model: 'gemini-3-pro-image', prompt: 'draw a fox', }, reply as any, ); expect(proxyService.handleChatCompletions).toHaveBeenCalledOnce(); expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledOnce(); expect(reply.status).toHaveBeenCalledWith(200); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ data: [ expect.objectContaining({ b64_json: 'FALLBACKIMG', }), ], }), ); }); it('supports image edits endpoint with supplementary image payload', async () => { const proxyService = { handleChatCompletions: vi.fn().mockResolvedValue({ choices: [ { message: { content: '![img](data:image/png;base64,CCCCDDDD)', }, }, ], }), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.imageEdits( { model: 'gemini-3-pro-image', prompt: 'make it brighter', image: 'data:image/png;base64,IMGBASE64', reference_images: ['data:image/jpeg;base64,REFBASE64'], }, { headers: { 'content-type': 'multipart/form-data; boundary=----parity', }, } as any, reply as any, ); const request = proxyService.handleChatCompletions.mock.calls[0][0]; expect(Array.isArray(request.messages[0].content)).toBe(true); expect(reply.status).toHaveBeenCalledWith(200); }); it('rejects image edits request without multipart boundary', async () => { const proxyService = { handleChatCompletions: vi.fn(), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.imageEdits( { model: 'gemini-3-pro-image', prompt: 'make it brighter', image: 'data:image/png;base64,IMGBASE64', }, { headers: { 'content-type': 'application/json', }, } as any, reply as any, ); expect(proxyService.handleChatCompletions).not.toHaveBeenCalled(); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith('Invalid `boundary` for `multipart/form-data` request'); }); it('supports audio transcriptions endpoint', async () => { const proxyService = { handleGeminiGenerateContent: vi.fn().mockResolvedValue({ candidates: [ { content: { parts: [{ text: 'transcribed text' }], }, }, ], }), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.audioTranscriptions( { model: 'gemini-2.5-flash', file: 'data:audio/mpeg;base64,QUJDRA==', }, { headers: { 'content-type': 'multipart/form-data; boundary=----parity', }, } as any, reply as any, ); expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledOnce(); expect(reply.status).toHaveBeenCalledWith(200); expect(reply.send).toHaveBeenCalledWith({ text: 'transcribed text' }); }); it('rejects audio transcription request without multipart boundary', async () => { const proxyService = { handleGeminiGenerateContent: vi.fn(), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.audioTranscriptions( { model: 'gemini-2.5-flash', file: 'data:audio/mpeg;base64,QUJDRA==', }, { headers: { 'content-type': 'application/json', }, } as any, reply as any, ); expect(proxyService.handleGeminiGenerateContent).not.toHaveBeenCalled(); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith('Invalid `boundary` for `multipart/form-data` request'); }); it('supports Anthropic messages endpoint', async () => { const proxyService = { handleAnthropicMessages: vi.fn().mockResolvedValue({ id: 'msg_1', type: 'message', }), }; const controller = new ProxyController(proxyService as any); const reply = createReplyMock(); await controller.anthropicMessages( { model: 'claude-sonnet-4-5', stream: false, messages: [{ role: 'user', content: 'hello' }], } as any, reply as any, ); expect(proxyService.handleAnthropicMessages).toHaveBeenCalledOnce(); expect(reply.status).toHaveBeenCalledWith(200); }); });