import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { describe, expect, it, vi } from 'vitest'; async function loadFactory(): Promise { return import('./index.js'); } function getUseOAuth(client: unknown): boolean | undefined { const obj = client as { useOAuth?: boolean }; return obj.useOAuth; } function getConstructorName(value: unknown): string { const obj = value as { constructor?: { name?: string } }; return obj.constructor?.name ?? ''; } describe('createClientFromConfig', () => { it('creates AnthropicClient for anthropic provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'anthropic', model: 'claude-sonnet-4-5-20250514', api_key: 'sk-ant-test', }); expect(client.constructor.name).toBe('AnthropicClient'); }); it('creates RotatingModelClient for anthropic when api_keys are provided', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'anthropic', model: 'claude-sonnet-4-5-20250514', api_keys: ['sk-ant-1', 'sk-ant-2'], }); expect(client.constructor.name).toBe('RotatingModelClient'); }); it('creates OpenAIClient for openai provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'openai', model: 'gpt-4o', api_key: 'sk-test', }); expect(client.constructor.name).toBe('OpenAIClient'); }); it('creates RotatingModelClient for openai when api_keys are provided', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'openai', model: 'gpt-4o', api_keys: ['sk-1', 'sk-2'], }); expect(client.constructor.name).toBe('RotatingModelClient'); }); it('creates OllamaClient for ollama provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'ollama', model: 'llama3.2:1b', endpoint: 'http://localhost:11434', }); expect(client.constructor.name).toBe('OllamaClient'); }); it('creates OllamaClient with num_gpu option', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'ollama', model: 'llama3.2:1b', num_gpu: 0, }); expect(client.constructor.name).toBe('OllamaClient'); }); it('creates LlamaCppClient for llamacpp provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'llamacpp', model: 'ministral-reasoning', endpoint: 'http://localhost:8080', }); expect(client.constructor.name).toBe('LlamaCppClient'); }); it('defaults llamacpp endpoint to localhost:8080', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'llamacpp', model: 'test-model', }); expect(client.constructor.name).toBe('LlamaCppClient'); }); it('creates GeminiClient for gemini provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'gemini', model: 'gemini-2.5-pro', api_key: 'test-key', }); expect(client.constructor.name).toBe('GeminiClient'); }); it('throws for unknown provider', async () => { const { createClientFromConfig } = await loadFactory(); expect(() => createClientFromConfig({ provider: 'unknown' as 'anthropic', model: 'test', })).toThrow('Unknown model provider: unknown'); }); it('creates OpenAIClient with OpenRouter baseURL for openrouter provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'openrouter', model: 'meta-llama/llama-3.1-70b', api_key: 'test-key', }); expect(client.constructor.name).toBe('OpenAIClient'); }); it('creates OpenAIClient for vercel provider', async () => { const prev = process.env.AI_GATEWAY_API_KEY; process.env.AI_GATEWAY_API_KEY = 'test-key'; try { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'vercel', model: 'openai/gpt-4.1', endpoint: 'https://ai-gateway.vercel.sh/v1', }); expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.AI_GATEWAY_API_KEY; } else { process.env.AI_GATEWAY_API_KEY = prev; } } }); it('creates OpenAIClient with Zhipu AI baseURL for zhipuai provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'zhipuai', model: 'glm-4.5', api_key: 'test-key', }); expect(client.constructor.name).toBe('OpenAIClient'); }); it('creates OpenAIClient for zhipuai when using auth_token', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'zhipuai', model: 'glm-4.5', auth_token: 'oauth-access-token', }); expect(client.constructor.name).toBe('OpenAIClient'); }); it('creates OpenAIClient for zhipuai when use_oauth is enabled and ZAI_API_KEY is set', async () => { const prev = process.env.ZAI_API_KEY; process.env.ZAI_API_KEY = 'zai-api-key'; try { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'zhipuai', model: 'glm-4.7', use_oauth: true, }); expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.ZAI_API_KEY; } else { process.env.ZAI_API_KEY = prev; } } }); it('creates OpenAIClient for zhipuai using ZHIPUAI_AUTH_TOKEN env var', async () => { const prev = process.env.ZHIPUAI_AUTH_TOKEN; process.env.ZHIPUAI_AUTH_TOKEN = 'oauth-access-token'; try { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'zhipuai', model: 'glm-4.5', }); expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.ZHIPUAI_AUTH_TOKEN; } else { process.env.ZHIPUAI_AUTH_TOKEN = prev; } } }); it('creates OpenAIClient for zhipuai using ZAI_API_KEY env var without use_oauth', async () => { const prev = process.env.ZAI_API_KEY; process.env.ZAI_API_KEY = 'zai-api-key'; try { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'zhipuai', model: 'glm-4.7', }); expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.ZAI_API_KEY; } else { process.env.ZAI_API_KEY = prev; } } }); it('creates OpenAIClient for minimax provider', async () => { const prev = process.env.MINIMAX_API_KEY; process.env.MINIMAX_API_KEY = 'test-key'; try { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'minimax', model: 'MiniMax-M1', }); expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.MINIMAX_API_KEY; } else { process.env.MINIMAX_API_KEY = prev; } } }); it('creates OpenAIClient for moonshot provider', async () => { const prev = process.env.MOONSHOT_API_KEY; process.env.MOONSHOT_API_KEY = 'test-key'; try { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'moonshot', model: 'moonshot-v1-8k', }); expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.MOONSHOT_API_KEY; } else { process.env.MOONSHOT_API_KEY = prev; } } }); it('creates BedrockClient for bedrock provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'bedrock', model: 'anthropic.claude-3-sonnet', }); expect(client.constructor.name).toBe('BedrockClient'); }); it('creates GitHubModelsClient for github provider', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'github', model: 'claude-sonnet-4.5', }); expect(client.constructor.name).toBe('GitHubModelsClient'); }); it('auto-maps Anthropic model names to GitHub equivalents for github provider', async () => { // User might copy-paste the Anthropic model name into a github fallback block const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'github', model: 'claude-sonnet-4-5-20250929', }); expect(client.constructor.name).toBe('GitHubModelsClient'); }); it('auth_mode overrides use_oauth for OpenAI', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'openai', model: 'gpt-4o', auth_mode: 'api_key', use_oauth: true, api_key: 'sk-test', }); expect(client.constructor.name).toBe('OpenAIClient'); expect(getUseOAuth(client)).toBe(false); }); it('auth_mode oauth selects Anthropic auth_token without requiring api_key', async () => { const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'anthropic', model: 'claude-sonnet-4-5-20250514', auth_mode: 'oauth', auth_token: 'tok-test', }); expect(client.constructor.name).toBe('AnthropicClient'); }); it('auth_mode api_key throws for OpenAI when no key is available', async () => { const prev = process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY; try { const { createClientFromConfig } = await loadFactory(); expect(() => createClientFromConfig({ provider: 'openai', model: 'gpt-4o', auth_mode: 'api_key', })).toThrow(/OpenAI API key not configured/i); } finally { if (prev === undefined) { delete process.env.OPENAI_API_KEY; } else { process.env.OPENAI_API_KEY = prev; } } }); it('auth_mode oauth uses stored OpenAI OAuth tokens', async () => { const originalHome = process.env.HOME; const homeDir = mkdtempSync(join(tmpdir(), 'flynn-clientfactory-openai-')); process.env.HOME = homeDir; try { vi.resetModules(); const { storeOpenAIAuth } = await import('../auth/openai.js'); storeOpenAIAuth({ access_token: 'at', refresh_token: 'rt', expires_at: Date.now() + 60_000, created_at: new Date().toISOString(), }); const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'openai', model: 'gpt-4o', auth_mode: 'oauth', }); expect(client.constructor.name).toBe('OpenAIClient'); expect(getUseOAuth(client)).toBe(true); } finally { process.env.HOME = originalHome; } }); }); describe('anthropicToGitHubModel', () => { it('maps claude-sonnet-4-20250514 to claude-sonnet-4', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-sonnet-4-20250514')).toBe('claude-sonnet-4'); }); it('maps claude-sonnet-4-5-20250929 to claude-sonnet-4.5', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-sonnet-4-5-20250929')).toBe('claude-sonnet-4.5'); }); it('maps claude-opus-4-20250514 to claude-opus-4', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-opus-4-20250514')).toBe('claude-opus-4'); }); it('maps claude-opus-4-5-20250918 to claude-opus-4.5', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-opus-4-5-20250918')).toBe('claude-opus-4.5'); }); it('maps claude-opus-4-6-20250715 to claude-opus-4.6', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-opus-4-6-20250715')).toBe('claude-opus-4.6'); }); it('maps claude-3-5-haiku-20241022 to claude-haiku-4.5', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-3-5-haiku-20241022')).toBe('claude-haiku-4.5'); }); it('maps claude-haiku-4-5-20251001 to claude-haiku-4.5', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-haiku-4-5-20251001')).toBe('claude-haiku-4.5'); }); it('strips date suffix and converts trailing version number with dot for unknown models', async () => { // "claude-sonnet-5-7-20260101" → strip date → "claude-sonnet-5-7" → dot → "claude-sonnet-5.7" const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-sonnet-5-7-20260101')).toBe('claude-sonnet-5.7'); }); it('strips date suffix for models without sub-version', async () => { // "claude-sonnet-5-20260101" → strip date → "claude-sonnet-5" (no trailing -N to dot-convert) const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('claude-sonnet-5-20260101')).toBe('claude-sonnet-5'); }); it('returns undefined for models without date suffix', async () => { const { anthropicToGitHubModel } = await loadFactory(); expect(anthropicToGitHubModel('llama3.2:1b')).toBeUndefined(); }); }); describe('createAutoFallbackClient', () => { it('creates a GitHubModelsClient for anthropic provider', async () => { const { createAutoFallbackClient } = await loadFactory(); const client = createAutoFallbackClient({ provider: 'anthropic', model: 'claude-sonnet-4-20250514', }); expect(client).toBeDefined(); expect(getConstructorName(client)).toBe('GitHubModelsClient'); }); it('returns undefined for non-anthropic providers', async () => { const { createAutoFallbackClient } = await loadFactory(); expect(createAutoFallbackClient({ provider: 'openai', model: 'gpt-4o' })).toBeUndefined(); expect(createAutoFallbackClient({ provider: 'ollama', model: 'llama3.2:1b' })).toBeUndefined(); expect(createAutoFallbackClient({ provider: 'gemini', model: 'gemini-2.5-pro' })).toBeUndefined(); }); it('returns undefined for unmappable anthropic models', async () => { const { createAutoFallbackClient } = await loadFactory(); expect(createAutoFallbackClient({ provider: 'anthropic', model: 'custom-model' })).toBeUndefined(); }); });