diff --git a/src/daemon/clientFactory.test.ts b/src/daemon/clientFactory.test.ts index 2824e07..f290d67 100644 --- a/src/daemon/clientFactory.test.ts +++ b/src/daemon/clientFactory.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient } from './index.js'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { describe, expect, it, vi } from 'vitest'; + import { AnthropicClient } from '../models/anthropic.js'; import { OpenAIClient } from '../models/openai.js'; import { OllamaClient } from '../models/local/ollama.js'; @@ -8,114 +11,130 @@ import { GeminiClient } from '../models/gemini.js'; import { BedrockClient } from '../models/bedrock.js'; import { GitHubModelsClient } from '../models/github.js'; +async function loadFactory(): Promise { + return import('./index.js'); +} + describe('createClientFromConfig', () => { - it('creates AnthropicClient for anthropic provider', () => { + 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).toBeInstanceOf(AnthropicClient); + expect(client.constructor.name).toBe('AnthropicClient'); }); - it('creates OpenAIClient for openai provider', () => { + 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).toBeInstanceOf(OpenAIClient); + expect(client.constructor.name).toBe('OpenAIClient'); }); - it('creates OllamaClient for ollama provider', () => { + 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).toBeInstanceOf(OllamaClient); + expect(client.constructor.name).toBe('OllamaClient'); }); - it('creates OllamaClient with num_gpu option', () => { + 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).toBeInstanceOf(OllamaClient); + expect(client.constructor.name).toBe('OllamaClient'); }); - it('creates LlamaCppClient for llamacpp provider', () => { + 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).toBeInstanceOf(LlamaCppClient); + expect(client.constructor.name).toBe('LlamaCppClient'); }); - it('defaults llamacpp endpoint to localhost:8080', () => { + it('defaults llamacpp endpoint to localhost:8080', async () => { + const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'llamacpp', model: 'test-model', }); - expect(client).toBeInstanceOf(LlamaCppClient); + expect(client.constructor.name).toBe('LlamaCppClient'); }); - it('creates GeminiClient for gemini provider', () => { + 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).toBeInstanceOf(GeminiClient); + expect(client.constructor.name).toBe('GeminiClient'); }); - it('throws for unknown provider', () => { + 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', () => { + 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).toBeInstanceOf(OpenAIClient); + expect(client.constructor.name).toBe('OpenAIClient'); }); - it('creates OpenAIClient with Zhipu AI baseURL for zhipuai provider', () => { + 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).toBeInstanceOf(OpenAIClient); + expect(client.constructor.name).toBe('OpenAIClient'); }); - it('creates OpenAIClient for zhipuai when using auth_token', () => { + 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).toBeInstanceOf(OpenAIClient); + expect(client.constructor.name).toBe('OpenAIClient'); }); - it('creates OpenAIClient for zhipuai when use_oauth is enabled and ZAI_API_KEY is set', () => { + 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).toBeInstanceOf(OpenAIClient); + expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.ZAI_API_KEY; @@ -125,16 +144,17 @@ describe('createClientFromConfig', () => { } }); - it('creates OpenAIClient for zhipuai using ZHIPUAI_AUTH_TOKEN env var', () => { + 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).toBeInstanceOf(OpenAIClient); + expect(client.constructor.name).toBe('OpenAIClient'); } finally { if (prev === undefined) { delete process.env.ZHIPUAI_AUTH_TOKEN; @@ -144,92 +164,180 @@ describe('createClientFromConfig', () => { } }); - it('creates BedrockClient for bedrock provider', () => { + it('creates BedrockClient for bedrock provider', async () => { + const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'bedrock', model: 'anthropic.claude-3-sonnet', }); - expect(client).toBeInstanceOf(BedrockClient); + expect(client.constructor.name).toBe('BedrockClient'); }); - it('creates GitHubModelsClient for github provider', () => { + it('creates GitHubModelsClient for github provider', async () => { + const { createClientFromConfig } = await loadFactory(); const client = createClientFromConfig({ provider: 'github', model: 'claude-sonnet-4.5', }); - expect(client).toBeInstanceOf(GitHubModelsClient); + expect(client.constructor.name).toBe('GitHubModelsClient'); }); - it('auto-maps Anthropic model names to GitHub equivalents for github provider', () => { + 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).toBeInstanceOf(GitHubModelsClient); + 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((client as any).useOAuth).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((client as any).useOAuth).toBe(true); + } finally { + process.env.HOME = originalHome; + } }); }); describe('anthropicToGitHubModel', () => { - it('maps claude-sonnet-4-20250514 to claude-sonnet-4', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + it('creates a GitHubModelsClient for anthropic provider', async () => { + const { createAutoFallbackClient } = await loadFactory(); const client = createAutoFallbackClient({ provider: 'anthropic', model: 'claude-sonnet-4-20250514', }); - expect(client).toBeInstanceOf(GitHubModelsClient); + expect(client).toBeDefined(); + expect((client as any).constructor.name).toBe('GitHubModelsClient'); }); - it('returns undefined for non-anthropic providers', () => { + 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', () => { + it('returns undefined for unmappable anthropic models', async () => { + const { createAutoFallbackClient } = await loadFactory(); expect(createAutoFallbackClient({ provider: 'anthropic', model: 'custom-model' })).toBeUndefined(); }); }); diff --git a/src/daemon/models.ts b/src/daemon/models.ts index a3bf1df..4b94b21 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -3,7 +3,20 @@ import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClie import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; import { logger } from '../logger.js'; import { getZaiApiKey } from '../auth/zai.js'; -import { getAnthropicApiKey } from '../auth/anthropic.js'; +import { getAnthropicApiKey, getAnthropicAuthToken } from '../auth/anthropic.js'; +import { getOpenAIApiKey, loadStoredOpenAIAuth } from '../auth/openai.js'; + +type AuthMode = 'auto' | 'api_key' | 'oauth'; + +function getEffectiveAuthMode(cfg: ModelConfig): AuthMode { + if (cfg.auth_mode) { + return cfg.auth_mode; + } + if (cfg.use_oauth) { + return 'oauth'; + } + return 'auto'; +} /** * Resolve an API key from config or environment variable. @@ -45,23 +58,115 @@ function resolveAuthCredential(cfg: ModelConfig, apiKeyEnvVar: string, authToken export function createClientFromConfig(cfg: ModelConfig): ModelClient { switch (cfg.provider) { case 'anthropic': - if (!cfg.api_key && !getAnthropicApiKey()) { + { + const authMode = getEffectiveAuthMode(cfg); + + if (authMode === 'oauth') { + const token = cfg.auth_token ?? getAnthropicAuthToken(); + if (!token) { + throw new Error( + 'Anthropic auth token not configured (auth_mode: oauth). ' + + 'Set ANTHROPIC_AUTH_TOKEN, run `flynn anthropic-auth --token`, or provide auth_token in config.', + ); + } + return new AnthropicClient({ + model: cfg.model, + authToken: token, + }); + } + + if (authMode === 'api_key') { + const apiKey = cfg.api_key ?? getAnthropicApiKey(); + if (!apiKey) { + throw new Error( + 'Anthropic API key not configured (auth_mode: api_key). ' + + 'Set ANTHROPIC_API_KEY, run `flynn anthropic-auth`, or provide api_key in config.', + ); + } + return new AnthropicClient({ + model: cfg.model, + apiKey, + }); + } + + // auto: prefer API key, then token + const apiKey = cfg.api_key ?? getAnthropicApiKey(); + if (apiKey) { + return new AnthropicClient({ + model: cfg.model, + apiKey, + }); + } + + const token = cfg.auth_token ?? getAnthropicAuthToken(); + if (token) { + return new AnthropicClient({ + model: cfg.model, + authToken: token, + }); + } + throw new Error( - 'Anthropic API key not configured. ' + - 'Set ANTHROPIC_API_KEY, run `flynn anthropic-auth`, or provide api_key in config.', + 'Anthropic credentials not configured (auth_mode: auto). ' + + 'Set ANTHROPIC_API_KEY (or run `flynn anthropic-auth`), ' + + 'or set ANTHROPIC_AUTH_TOKEN (or run `flynn anthropic-auth --token`).', ); } - return new AnthropicClient({ - model: cfg.model, - apiKey: cfg.api_key ?? getAnthropicApiKey() ?? undefined, - authToken: cfg.auth_token, - }); case 'openai': - return new OpenAIClient({ - model: cfg.model, - apiKey: cfg.api_key, - useOAuth: Boolean(cfg.use_oauth), - }); + { + const authMode = getEffectiveAuthMode(cfg); + + if (authMode === 'oauth') { + const existing = loadStoredOpenAIAuth(); + if (!existing) { + throw new Error( + 'OpenAI OAuth is not configured (auth_mode: oauth). ' + + 'Run `flynn openai-auth` to authenticate.', + ); + } + return new OpenAIClient({ + model: cfg.model, + useOAuth: true, + }); + } + + if (authMode === 'api_key') { + const apiKey = cfg.api_key ?? getOpenAIApiKey(); + if (!apiKey) { + throw new Error( + 'OpenAI API key not configured (auth_mode: api_key). ' + + 'Set OPENAI_API_KEY, run `flynn openai-key`, or provide api_key in config.', + ); + } + return new OpenAIClient({ + model: cfg.model, + apiKey, + }); + } + + // auto: prefer API key, then OAuth + const apiKey = cfg.api_key ?? getOpenAIApiKey(); + if (apiKey) { + return new OpenAIClient({ + model: cfg.model, + apiKey, + }); + } + + const existing = loadStoredOpenAIAuth(); + if (existing) { + return new OpenAIClient({ + model: cfg.model, + useOAuth: true, + }); + } + + throw new Error( + 'OpenAI credentials not configured (auth_mode: auto). ' + + 'Set OPENAI_API_KEY (or run `flynn openai-key`), ' + + 'or run `flynn openai-auth` for OAuth.', + ); + } case 'ollama': return new OllamaClient({ model: cfg.model,