diff --git a/src/daemon/clientFactory.test.ts b/src/daemon/clientFactory.test.ts index 428ab38..d80f57c 100644 --- a/src/daemon/clientFactory.test.ts +++ b/src/daemon/clientFactory.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { createClientFromConfig } from './index.js'; +import { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient } from './index.js'; import { AnthropicClient } from '../models/anthropic.js'; import { OpenAIClient } from '../models/openai.js'; import { OllamaClient } from '../models/local/ollama.js'; import { LlamaCppClient } from '../models/local/llamacpp.js'; import { GeminiClient } from '../models/gemini.js'; import { BedrockClient } from '../models/bedrock.js'; +import { GitHubModelsClient } from '../models/github.js'; describe('createClientFromConfig', () => { it('creates AnthropicClient for anthropic provider', () => { @@ -94,3 +95,45 @@ describe('createClientFromConfig', () => { expect(client).toBeInstanceOf(BedrockClient); }); }); + +describe('anthropicToGitHubModel', () => { + it('maps claude-sonnet-4-20250514 to claude-sonnet-4', () => { + expect(anthropicToGitHubModel('claude-sonnet-4-20250514')).toBe('claude-sonnet-4'); + }); + + it('maps claude-opus-4-20250514 to claude-opus-4', () => { + expect(anthropicToGitHubModel('claude-opus-4-20250514')).toBe('claude-opus-4'); + }); + + it('maps claude-3-5-haiku-20241022 to claude-haiku-4', () => { + expect(anthropicToGitHubModel('claude-3-5-haiku-20241022')).toBe('claude-haiku-4'); + }); + + it('strips date suffix for unknown versioned models', () => { + expect(anthropicToGitHubModel('claude-sonnet-5-20260101')).toBe('claude-sonnet-5'); + }); + + it('returns undefined for models without date suffix', () => { + expect(anthropicToGitHubModel('llama3.2:1b')).toBeUndefined(); + }); +}); + +describe('createAutoFallbackClient', () => { + it('creates a GitHubModelsClient for anthropic provider', () => { + const client = createAutoFallbackClient({ + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + }); + expect(client).toBeInstanceOf(GitHubModelsClient); + }); + + it('returns undefined for non-anthropic providers', () => { + 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', () => { + expect(createAutoFallbackClient({ provider: 'anthropic', model: 'custom-model' })).toBeUndefined(); + }); +}); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 8ef79f7..6fed3a1 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -131,6 +131,50 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { } } +/** + * Map an Anthropic model identifier to its GitHub Models equivalent. + * Returns undefined if no mapping is known. + */ +export function anthropicToGitHubModel(anthropicModel: string): string | undefined { + // Mapping from Anthropic versioned names → GitHub Copilot short names + const MAPPINGS: Record = { + 'claude-sonnet-4-20250514': 'claude-sonnet-4', + 'claude-opus-4-20250514': 'claude-opus-4', + 'claude-3-5-haiku-20241022': 'claude-haiku-4', + }; + + if (MAPPINGS[anthropicModel]) return MAPPINGS[anthropicModel]; + + // Try stripping date suffix (e.g. "claude-sonnet-4-20260101" → "claude-sonnet-4") + const dateMatch = anthropicModel.match(/^(.+)-\d{8}$/); + if (dateMatch) return dateMatch[1]; + + return undefined; +} + +/** + * For a given tier config using the Anthropic provider, create a GitHub Models + * client with the equivalent model as an automatic same-model fallback. + * Returns undefined if no mapping exists or the tier isn't Anthropic. + */ +export function createAutoFallbackClient(tierConfig: { provider: string; model: string }): ModelClient | undefined { + if (tierConfig.provider !== 'anthropic') return undefined; + + const githubModel = anthropicToGitHubModel(tierConfig.model); + if (!githubModel) return undefined; + + return new GitHubModelsClient({ + model: githubModel, + onLoginRequired: async () => { + const { loginGitHub } = await import('../auth/index.js'); + return loginGitHub((userCode, verificationUri) => { + console.log(`GitHub login required. Visit: ${verificationUri}`); + console.log(`Enter code: ${userCode}`); + }); + }, + }); +} + function createModelRouter(config: Config): ModelRouter { const models = config.models; @@ -160,25 +204,52 @@ function createModelRouter(config: Config): ModelRouter { } } - // Build per-tier fallbacks from inline fallback config + // Build per-tier fallbacks from inline fallback config + auto same-model fallbacks. + // Auto-fallback: when a tier uses Anthropic, automatically insert a GitHub Models + // client for the same model *before* any user-configured inline fallbacks. + // This ensures the same model is tried via an alternative provider before + // degrading to the global fallback chain (which may be a much weaker local model). const tierFallbacks = new Map(); - if (models.default.fallback) { - tierFallbacks.set('default', [createClientFromConfig(models.default.fallback)]); - } - if (models.fast?.fallback) { - tierFallbacks.set('fast', [createClientFromConfig(models.fast.fallback)]); - } - if (models.complex?.fallback) { - tierFallbacks.set('complex', [createClientFromConfig(models.complex.fallback)]); - } - if (models.local?.fallback) { - tierFallbacks.set('local', [createClientFromConfig(models.local.fallback)]); + + const tierConfigs: { tier: ModelTier; cfg: typeof models.default | undefined }[] = [ + { tier: 'default', cfg: models.default }, + { tier: 'fast', cfg: models.fast }, + { tier: 'complex', cfg: models.complex }, + { tier: 'local', cfg: models.local }, + ]; + + const autoFallbackTiers: string[] = []; + for (const { tier, cfg } of tierConfigs) { + if (!cfg) continue; + + const fallbackList: ModelClient[] = []; + + // Auto same-model fallback (only when user hasn't configured an inline fallback) + if (!cfg.fallback) { + const autoClient = createAutoFallbackClient(cfg); + if (autoClient) { + fallbackList.push(autoClient); + autoFallbackTiers.push(tier); + } + } + + // User-configured inline fallback + if (cfg.fallback) { + fallbackList.push(createClientFromConfig(cfg.fallback)); + } + + if (fallbackList.length > 0) { + tierFallbacks.set(tier, fallbackList); + } } if (tierFallbacks.size > 0) { const tierNames = Array.from(tierFallbacks.keys()).join(', '); console.log(`Per-tier fallbacks configured for: ${tierNames}`); } + if (autoFallbackTiers.length > 0) { + console.log(`Auto same-model fallback (via GitHub Models) for: ${autoFallbackTiers.join(', ')}`); + } console.log(`Model router: default=${models.default.provider}/${models.default.model}, ` + `fallback=[${models.fallback_chain.join(', ')}]`);