import { AnthropicProvider } from './providers/anthropic.js'; import { OpenAICompatibleProvider } from './providers/openai-compatible.js'; import { installDbCostSink } from './costs.js'; import { AiNotConfiguredError, type CompletionRequest, type Completion, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest } from './types.js'; export interface RouterConfig { anthropicApiKey?: string; openaiApiKey?: string; localBaseUrl?: string; localApiKey?: string; /** role → "provider:model" or "model" overrides, e.g. { research: 'anthropic:claude-opus-5' } */ overrides?: Partial>; persistCosts?: boolean; } export const ROLES: Role[] = ['normalize', 'classify', 'summarize', 'resolve', 'research', 'vision', 'embed']; /** Read router config from process.env (server-side only). AI_MODEL_ overrides the default model. */ export function configFromEnv(env: NodeJS.ProcessEnv = process.env): RouterConfig { const overrides: Partial> = {}; for (const r of ROLES) { const v = env[`AI_MODEL_${r.toUpperCase()}`]; if (v) overrides[r] = v; } return { anthropicApiKey: env.ANTHROPIC_API_KEY, openaiApiKey: env.OPENAI_API_KEY, localBaseUrl: env.LOCAL_LLM_BASE_URL, localApiKey: env.LOCAL_LLM_API_KEY, overrides, persistCosts: env.NODE_ENV !== 'test', }; } /** * Picks a provider per role (§168): Anthropic for everything but embeddings when configured, * OpenAI(-compatible) otherwise, embeddings only via OpenAI-compatible endpoints. Callers never * see vendor SDKs; a missing provider surfaces as AiNotConfiguredError which the UI renders as * "AI provider not configured". */ export class ModelRouter implements ModelProvider { readonly id = 'router'; readonly providers: ModelProvider[] = []; private readonly overrides: Partial>; constructor(cfg: RouterConfig) { this.overrides = cfg.overrides ?? {}; const perProviderModels = (pid: string): Partial> => { const out: Partial> = {}; for (const [role, spec] of Object.entries(this.overrides) as Array<[Role, string]>) { const [p, m] = spec.includes(':') ? (spec.split(':', 2) as [string, string]) : [null, spec]; if (!p || p === pid) out[role] = m; } return out; }; if (cfg.anthropicApiKey) this.providers.push(new AnthropicProvider({ apiKey: cfg.anthropicApiKey, models: perProviderModels('anthropic') })); if (cfg.openaiApiKey) this.providers.push(new OpenAICompatibleProvider({ id: 'openai', apiKey: cfg.openaiApiKey, models: perProviderModels('openai') })); if (cfg.localBaseUrl) this.providers.push(new OpenAICompatibleProvider({ id: 'local', apiKey: cfg.localApiKey ?? 'local', baseURL: cfg.localBaseUrl, models: perProviderModels('local') })); if (cfg.persistCosts) void installDbCostSink(); } get configured(): boolean { return this.providers.length > 0; } /** Provider for a role: explicit "provider:" override first, else first provider supporting the role. */ providerFor(role: Role): ModelProvider { const spec = this.overrides[role]; if (spec?.includes(':')) { const pid = spec.split(':', 1)[0]; const p = this.providers.find((x) => x.id === pid); if (p) return p; } const p = this.providers.find((x) => x.supports(role)); if (!p) throw new AiNotConfiguredError(role === 'embed' ? 'No embeddings provider configured (set OPENAI_API_KEY)' : 'AI provider not configured (set ANTHROPIC_API_KEY or OPENAI_API_KEY)'); return p; } supports(role: Role): boolean { return this.providers.some((p) => p.supports(role)); } modelFor(role: Role): string { return this.providerFor(role).modelFor(role); } complete(role: Role, req: CompletionRequest): Promise { return this.providerFor(role).complete(role, req); } stream(role: Role, req: CompletionRequest): AsyncIterable { return this.providerFor(role).stream(role, req); } runTools(role: Role, req: ToolAgentRequest): AsyncIterable { return this.providerFor(role).runTools(role, req); } extract(role: Role, req: ExtractRequest): Promise> { return this.providerFor(role).extract(role, req); } vision(req: ExtractRequest): Promise> { return this.providerFor('vision').vision(req); } embed(req: EmbedRequest): Promise { return this.providerFor('embed').embed(req); } /** Table of role → provider/model for diagnostics and /admin. */ table(): Array<{ role: Role; provider: string | null; model: string | null }> { return ROLES.map((role) => { try { const p = this.providerFor(role); return { role, provider: p.id, model: p.modelFor(role) }; } catch { return { role, provider: null, model: null }; } }); } } let _router: ModelRouter | null = null; /** Process-wide router built from env. */ export function getRouter(): ModelRouter { if (!_router) _router = new ModelRouter(configFromEnv()); return _router; } export function resetRouter(): void { _router = null; }