/** * @license * Copyright 2025 AionUi (aionui.com) * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ClientFactory, normalizeNewApiBaseUrl } from '@/common/api/ClientFactory'; import { OpenAIRotatingClient } from '@/common/api/OpenAIRotatingClient'; import { GeminiRotatingClient } from '@/common/api/GeminiRotatingClient'; import { AnthropicRotatingClient } from '@/common/api/AnthropicRotatingClient'; import { AuthType } from '@office-ai/aioncli-core'; // Mock rotating clients vi.mock('@/common/api/OpenAIRotatingClient'); vi.mock('@/common/api/GeminiRotatingClient'); vi.mock('@/common/api/AnthropicRotatingClient'); // Mock utility functions vi.mock('@/common/utils/platformAuthType', () => ({ getProviderAuthType: vi.fn((provider) => provider.auth_type || provider.authType || AuthType.USE_OPENAI), })); vi.mock('@/common/utils/platformConstants', () => ({ isNewApiPlatform: vi.fn((platform) => platform === 'new-api'), })); describe('ClientFactory', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('normalizeNewApiBaseUrl', () => { it('adds /v1 suffix for OpenAI', () => { const result = normalizeNewApiBaseUrl('https://api.example.com', AuthType.USE_OPENAI); expect(result).toBe('https://api.example.com/v1'); }); it('strips existing /v1 and re-adds for OpenAI', () => { const result = normalizeNewApiBaseUrl('https://api.example.com/v1', AuthType.USE_OPENAI); expect(result).toBe('https://api.example.com/v1'); }); it('strips /v1beta and adds /v1 for OpenAI', () => { const result = normalizeNewApiBaseUrl('https://api.example.com/v1beta', AuthType.USE_OPENAI); expect(result).toBe('https://api.example.com/v1'); }); it('returns root URL for Anthropic', () => { const result = normalizeNewApiBaseUrl('https://api.example.com/v1', AuthType.USE_ANTHROPIC); expect(result).toBe('https://api.example.com'); }); it('returns root URL for Gemini', () => { const result = normalizeNewApiBaseUrl('https://api.example.com/v1', AuthType.USE_GEMINI); expect(result).toBe('https://api.example.com'); }); it('removes trailing slashes', () => { const result = normalizeNewApiBaseUrl('https://api.example.com///', AuthType.USE_OPENAI); expect(result).toBe('https://api.example.com/v1'); }); it('handles empty base URL', () => { const result = normalizeNewApiBaseUrl('', AuthType.USE_OPENAI); expect(result).toBe(''); }); }); describe('createRotatingClient', () => { const mockProvider = { id: 'test-provider', platform: 'openai', api_key: 'sk-test-key', base_url: 'https://api.openai.com', use_model: 'gpt-4', authType: AuthType.USE_OPENAI, }; it('creates OpenAIRotatingClient for USE_OPENAI', async () => { const client = await ClientFactory.createRotatingClient(mockProvider); expect(OpenAIRotatingClient).toHaveBeenCalled(); expect(client).toBeInstanceOf(OpenAIRotatingClient); }); it('creates AnthropicRotatingClient for USE_ANTHROPIC', async () => { const anthropicProvider = { ...mockProvider, authType: AuthType.USE_ANTHROPIC, }; const client = await ClientFactory.createRotatingClient(anthropicProvider); expect(AnthropicRotatingClient).toHaveBeenCalled(); expect(client).toBeInstanceOf(AnthropicRotatingClient); }); it('creates GeminiRotatingClient for USE_GEMINI', async () => { const geminiProvider = { ...mockProvider, platform: 'gemini', authType: AuthType.USE_GEMINI, base_url: 'https://generativelanguage.googleapis.com', use_model: 'gemini-3-pro-image-preview', }; const client = await ClientFactory.createRotatingClient(geminiProvider); expect(GeminiRotatingClient).toHaveBeenCalled(); expect(client).toBeInstanceOf(GeminiRotatingClient); const calls = (GeminiRotatingClient as any).mock.calls; expect(calls[0][1]).toMatchObject({ model: 'gemini-3-pro-image-preview', baseURL: 'https://generativelanguage.googleapis.com', }); expect(calls[0][3]).toBe(AuthType.USE_GEMINI); }); it('normalizes base URL for new-api platform', async () => { const newApiProvider = { ...mockProvider, platform: 'new-api', base_url: 'https://gateway.example.com/v1', }; await ClientFactory.createRotatingClient(newApiProvider); const calls = (OpenAIRotatingClient as any).mock.calls; expect(calls.length).toBeGreaterThan(0); // Client should receive normalized URL const config = calls[0][1]; expect(config.baseURL).toBe('https://gateway.example.com/v1'); }); it('does not normalize base URL for non-new-api platform', async () => { const standardProvider = { ...mockProvider, platform: 'openai', base_url: 'https://api.openai.com/v1beta', }; await ClientFactory.createRotatingClient(standardProvider); const calls = (OpenAIRotatingClient as any).mock.calls; const config = calls[0][1]; expect(config.baseURL).toBe('https://api.openai.com/v1beta'); }); it('passes timeout option to client', async () => { await ClientFactory.createRotatingClient(mockProvider, { timeout: 30000 }); const calls = (OpenAIRotatingClient as any).mock.calls; const config = calls[0][1]; expect(config.timeout).toBe(30000); }); it('passes rotating options to client', async () => { await ClientFactory.createRotatingClient(mockProvider, { rotatingOptions: { maxRetries: 5, retryDelay: 2000 }, }); const calls = (OpenAIRotatingClient as any).mock.calls; const rotatingOpts = calls[0][2]; expect(rotatingOpts.maxRetries).toBe(5); expect(rotatingOpts.retryDelay).toBe(2000); }); it('adds default HTTP-Referer and X-Title headers for OpenAI', async () => { await ClientFactory.createRotatingClient(mockProvider); const calls = (OpenAIRotatingClient as any).mock.calls; const config = calls[0][1]; expect(config.defaultHeaders).toEqual({ 'HTTP-Referer': 'https://aionui.com', 'X-Title': 'AionUi', }); }); it('handles proxy option for OpenAI', async () => { await ClientFactory.createRotatingClient(mockProvider, { proxy: 'http://proxy.example.com:8080' }); const calls = (OpenAIRotatingClient as any).mock.calls; const config = calls[0][1]; expect(config.httpAgent).toBeDefined(); }); it('passes model to Anthropic config', async () => { const anthropicProvider = { ...mockProvider, authType: AuthType.USE_ANTHROPIC, use_model: 'claude-sonnet-4-20250514', }; await ClientFactory.createRotatingClient(anthropicProvider); const calls = (AnthropicRotatingClient as any).mock.calls; const config = calls[0][1]; expect(config.model).toBe('claude-sonnet-4-20250514'); }); it('passes model to Gemini config', async () => { const geminiProvider = { ...mockProvider, authType: AuthType.USE_GEMINI, use_model: 'gemini-3-pro-image-preview', }; await ClientFactory.createRotatingClient(geminiProvider); const calls = (GeminiRotatingClient as any).mock.calls; const config = calls[0][1]; expect(config.model).toBe('gemini-3-pro-image-preview'); }); it('defaults to OpenAI client for unknown auth type', async () => { const unknownProvider = { ...mockProvider, authType: 'UNKNOWN' as AuthType, }; await ClientFactory.createRotatingClient(unknownProvider); expect(OpenAIRotatingClient).toHaveBeenCalled(); }); }); });