import type { Config, ModelConfig } from '../config/index.js'; import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js'; import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; import { logger } from '../logger.js'; /** * Resolve an API key from config or environment variable. * Throws a clear error naming the expected env var if neither source provides a key. */ function requireApiKey(cfg: ModelConfig, envVar: string): string { const key = cfg.api_key ?? process.env[envVar]; if (!key) { throw new Error( `API key required for ${cfg.provider}. ` + `Set ${envVar} environment variable or provide api_key in config.`, ); } return key; } /** * Create a ModelClient from a provider config entry. * Dispatches on the `provider` field so all tiers and fallback entries * use the correct client implementation. */ export function createClientFromConfig(cfg: ModelConfig): ModelClient { switch (cfg.provider) { case 'anthropic': return new AnthropicClient({ model: cfg.model, apiKey: cfg.api_key, authToken: cfg.auth_token, }); case 'openai': return new OpenAIClient({ model: cfg.model, apiKey: cfg.api_key, }); case 'ollama': return new OllamaClient({ model: cfg.model, host: cfg.endpoint, numGpu: cfg.num_gpu, }); case 'llamacpp': return new LlamaCppClient({ endpoint: cfg.endpoint ?? 'http://localhost:8080', model: cfg.model, authToken: cfg.auth_token, }); case 'gemini': return new GeminiClient({ model: cfg.model, apiKey: cfg.api_key, }); case 'openrouter': return new OpenAIClient({ model: cfg.model, apiKey: requireApiKey(cfg, 'OPENROUTER_API_KEY'), baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1', }); case 'zhipuai': return new OpenAIClient({ model: cfg.model, apiKey: requireApiKey(cfg, 'ZHIPUAI_API_KEY'), baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4', }); case 'xai': return new OpenAIClient({ model: cfg.model, apiKey: requireApiKey(cfg, 'XAI_API_KEY'), baseURL: cfg.endpoint ?? 'https://api.x.ai/v1', }); case 'bedrock': return new BedrockClient({ model: cfg.model, region: cfg.endpoint, accessKeyId: cfg.api_key, secretAccessKey: cfg.auth_token, }); case 'github': return new GitHubModelsClient({ model: anthropicToGitHubModel(cfg.model) ?? cfg.model, apiKey: cfg.api_key, endpoint: cfg.endpoint, 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}`); }); }, }); default: throw new Error(`Unknown model provider: ${(cfg as Record).provider}`); } } /** * Map an Anthropic model identifier to its GitHub Models equivalent. * Returns undefined if no mapping is known. * * Anthropic uses hyphens and date suffixes: claude-sonnet-4-5-20250929 * GitHub Copilot uses dots, no dates: claude-sonnet-4.5 */ export function anthropicToGitHubModel(anthropicModel: string): string | undefined { // Explicit mappings for known models const MAPPINGS: Record = { // Sonnet family 'claude-sonnet-4-20250514': 'claude-sonnet-4', 'claude-sonnet-4-5-20250929': 'claude-sonnet-4.5', // Opus family 'claude-opus-4-20250514': 'claude-opus-4', 'claude-opus-4-5-20250918': 'claude-opus-4.5', 'claude-opus-4-6-20250715': 'claude-opus-4.6', // Haiku family 'claude-3-5-haiku-20241022': 'claude-haiku-4.5', 'claude-haiku-4-5-20251001': 'claude-haiku-4.5', }; if (MAPPINGS[anthropicModel]) {return MAPPINGS[anthropicModel];} // Generic fallback: strip date suffix, then convert trailing -N to .N // only when preceded by another digit (i.e. "4-5" → "4.5", not "sonnet-5" → "sonnet.5") // e.g. "claude-sonnet-4-7-20260301" → "claude-sonnet-4-7" → "claude-sonnet-4.7" const dateMatch = anthropicModel.match(/^(.+)-\d{8}$/); if (dateMatch) { const base = dateMatch[1]; // Convert "claude-sonnet-4-5" → "claude-sonnet-4.5" (digit-hyphen-digit at end) const dotted = base.replace(/(\d)-(\d+)$/, '$1.$2'); return dotted; } 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}`); }); }, }); } export function createModelRouter(config: Config): ModelRouter { const models = config.models; const defaultClient = createClientFromConfig(models.default); const fastClient = models.fast ? createClientFromConfig(models.fast) : undefined; const complexClient = models.complex ? createClientFromConfig(models.complex) : undefined; const localClient = models.local ? createClientFromConfig(models.local) : undefined; // Build fallback chain — each entry references a tier name or 'local' const fallbackChain: ModelClient[] = []; for (const providerName of models.fallback_chain) { if (providerName === 'local' && localClient) { fallbackChain.push(localClient); } else if (providerName === 'default') { // Allows re-trying the default provider in the chain fallbackChain.push(defaultClient); } else if (providerName === 'fast' && fastClient) { fallbackChain.push(fastClient); } else if (providerName === 'complex' && complexClient) { fallbackChain.push(complexClient); } else if (models.local_providers?.[providerName]) { // Named provider from local_providers map fallbackChain.push(createClientFromConfig(models.local_providers[providerName])); } else { logger.warn(`Fallback chain entry "${providerName}" not found — skipping`); } } // 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(); 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(', '); logger.info(`Per-tier fallbacks configured for: ${tierNames}`); } if (autoFallbackTiers.length > 0) { logger.info(`Auto same-model fallback (via GitHub Models) for: ${autoFallbackTiers.join(', ')}`); } logger.info(`Model router: default=${models.default.provider}/${models.default.model}, ` + `fallback=[${models.fallback_chain.join(', ')}]`); // Build retry config if enabled const retryConfig: RetryConfig | undefined = config.retry.enabled ? { maxRetries: config.retry.max_retries, initialDelayMs: config.retry.initial_delay_ms, backoffMultiplier: config.retry.backoff_multiplier, maxDelayMs: config.retry.max_delay_ms, nonRetryablePatterns: DEFAULT_RETRY_CONFIG.nonRetryablePatterns, } : undefined; if (retryConfig) { logger.info(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`); } return new ModelRouter({ default: defaultClient, fast: fastClient, complex: complexClient, local: localClient, fallbackChain, tierFallbacks, retryConfig, labels: { default: `${models.default.provider}/${models.default.model}`, ...(models.fast ? { fast: `${models.fast.provider}/${models.fast.model}` } : {}), ...(models.complex ? { complex: `${models.complex.provider}/${models.complex.model}` } : {}), ...(models.local ? { local: `${models.local.provider}/${models.local.model}` } : {}), }, }); }