diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 174a14b..cf84114 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -1,10 +1,10 @@ import { Lifecycle } from './lifecycle.js'; -import type { Config, ModelConfig } from '../config/index.js'; +import { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient, createModelRouter } from './models.js'; +import type { Config } from '../config/index.js'; import type { AudioTranscriptionConfig } from '../models/media.js'; import type { Attachment } from '../channels/types.js'; import { isSupportedAudio, transcribeAudio } from '../models/media.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 { ModelRouter } from '../models/index.js'; import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js'; import { OutboundAttachmentCollector } from '../backends/native/attachments.js'; import { SessionStore, SessionManager, parseDuration } from '../session/index.js'; @@ -67,254 +67,6 @@ function loadSystemPrompt(config: Config): string { return result.prompt; } -/** - * 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: cfg.api_key ?? process.env.OPENROUTER_API_KEY, - baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1', - }); - case 'zhipuai': - return new OpenAIClient({ - model: cfg.model, - apiKey: cfg.api_key ?? process.env.ZHIPUAI_API_KEY, - baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4', - }); - case 'xai': - return new OpenAIClient({ - model: cfg.model, - apiKey: cfg.api_key ?? process.env.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 { - console.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(', '); - 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(', ')}]`); - - // 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) { - console.log(`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}` } : {}), - }, - }); -} - /** * Create the unified message handler for the channel registry. * Each channel+sender pair gets its own AgentOrchestrator backed by a persistent session. @@ -1085,3 +837,4 @@ export async function startDaemon(config: Config): Promise { } export { Lifecycle } from './lifecycle.js'; +export { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient, createModelRouter } from './models.js'; diff --git a/src/daemon/models.ts b/src/daemon/models.ts new file mode 100644 index 0000000..a266659 --- /dev/null +++ b/src/daemon/models.ts @@ -0,0 +1,251 @@ +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'; + +/** + * 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: cfg.api_key ?? process.env.OPENROUTER_API_KEY, + baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1', + }); + case 'zhipuai': + return new OpenAIClient({ + model: cfg.model, + apiKey: cfg.api_key ?? process.env.ZHIPUAI_API_KEY, + baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4', + }); + case 'xai': + return new OpenAIClient({ + model: cfg.model, + apiKey: cfg.api_key ?? process.env.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 { + console.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(', '); + 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(', ')}]`); + + // 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) { + console.log(`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}` } : {}), + }, + }); +}