From 250bb87a8dc8ced5e573c214faa1acbd7e095864 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 22:05:45 +0530 Subject: [PATCH] refactor!(llm): implement new model registration system --- apps/gui/src/app/actions.ts | 4 +- .../src/lib/simulation/provider-resolver.ts | 85 +- .../src/lib/simulation/simulation-manager.ts | 40 +- packages/llm/README.md | 79 +- packages/llm/src/base-provider.ts | 112 ++ packages/llm/src/bootstrap.ts | 98 ++ packages/llm/src/config.ts | 31 +- packages/llm/src/db.ts | 75 ++ packages/llm/src/index.ts | 4 +- packages/llm/src/llm.ts | 75 +- packages/llm/src/model-lister.ts | 174 +-- packages/llm/src/provider-factory.ts | 21 + packages/llm/src/provider-manager.ts | 1039 +++-------------- packages/llm/src/providers/anthropic.ts | 194 +-- packages/llm/src/providers/deepseek.ts | 160 +-- packages/llm/src/providers/google-genai.ts | 208 ++-- packages/llm/src/providers/groq.ts | 159 +-- packages/llm/src/providers/mock.ts | 42 +- packages/llm/src/providers/ollama.ts | 148 +-- packages/llm/src/providers/openai.ts | 185 ++- packages/llm/src/providers/openrouter.ts | 183 ++- packages/llm/src/registry.ts | 79 ++ packages/llm/src/row-mapper.ts | 56 + packages/llm/tests/deepseek.test.ts | 44 +- packages/llm/tests/groq.test.ts | 44 +- packages/llm/tests/openai.test.ts | 52 +- packages/llm/tests/openrouter.test.ts | 47 +- packages/llm/tests/provider-manager.test.ts | 253 +++- 28 files changed, 1717 insertions(+), 1974 deletions(-) create mode 100644 packages/llm/src/base-provider.ts create mode 100644 packages/llm/src/bootstrap.ts create mode 100644 packages/llm/src/db.ts create mode 100644 packages/llm/src/provider-factory.ts create mode 100644 packages/llm/src/registry.ts create mode 100644 packages/llm/src/row-mapper.ts diff --git a/apps/gui/src/app/actions.ts b/apps/gui/src/app/actions.ts index b5e726c..966c199 100644 --- a/apps/gui/src/app/actions.ts +++ b/apps/gui/src/app/actions.ts @@ -7,7 +7,7 @@ import type { SimSnapshot } from "@/lib/simulation"; import { ProviderManager, ModelProviderInstance, - AVAILABLE_PROVIDERS, + getAvailableProviders as listAvailableProviders, ModelProviderMeta, ModelLister, ModelInfo, @@ -311,7 +311,7 @@ export async function setProviderMapping( } export async function getAvailableProviders(): Promise { - return AVAILABLE_PROVIDERS; + return listAvailableProviders(); } export async function regenerateEmbeddings( diff --git a/apps/gui/src/lib/simulation/provider-resolver.ts b/apps/gui/src/lib/simulation/provider-resolver.ts index f7c37cd..b6a0fc4 100644 --- a/apps/gui/src/lib/simulation/provider-resolver.ts +++ b/apps/gui/src/lib/simulation/provider-resolver.ts @@ -1,17 +1,9 @@ import { - GeminiProvider, MockLLMProvider, - OllamaProvider, - OllamaEmbeddingProvider, - ProviderManager, - OpenRouterProvider, - AnthropicProvider, - OpenAIProvider, - OpenAIEmbeddingProvider, - GroqProvider, - DeepSeekProvider, - GeminiEmbeddingProvider, MockEmbeddingProvider, + ProviderManager, + buildLLMProvider, + buildEmbeddingProvider, } from "@omnia/llm"; import type { ILLMProvider, @@ -47,77 +39,10 @@ export interface ProviderResolverOptions { } // --------------------------------------------------------------------------- -// Private builders +// Resolution logic // --------------------------------------------------------------------------- -function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider { - if (inst.providerName === "google-genai") { - return new GeminiProvider( - inst.apiKey, - inst.modelName, - inst.name, - inst.maxContext, - ); - } else if (inst.providerName === "openrouter") { - return new OpenRouterProvider( - inst.apiKey, - inst.modelName, - inst.name, - inst.maxContext, - ); - } else if (inst.providerName === "ollama") { - return new OllamaProvider( - inst.endpointUrl, - inst.modelName, - inst.name, - inst.maxContext, - ); - } else if (inst.providerName === "anthropic") { - return new AnthropicProvider( - inst.apiKey, - inst.modelName, - inst.name, - inst.maxContext, - ); - } else if (inst.providerName === "openai") { - return new OpenAIProvider( - inst.apiKey, - inst.modelName, - inst.name, - inst.maxContext, - ); - } else if (inst.providerName === "groq") { - return new GroqProvider( - inst.apiKey, - inst.modelName, - inst.name, - inst.maxContext, - ); - } else if (inst.providerName === "deepseek") { - return new DeepSeekProvider( - inst.apiKey, - inst.modelName, - inst.name, - inst.maxContext, - ); - } - return new MockLLMProvider([]); -} - -function buildEmbeddingProvider( - inst: ModelProviderInstance, -): IEmbeddingProvider { - if (inst.providerName === "google-genai") { - return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName); - } else if (inst.providerName === "ollama") { - return new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName); - } else if (inst.providerName === "openai") { - return new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName); - } - return new MockEmbeddingProvider(inst.modelName); -} - -// --------------------------------------------------------------------------- +/** // Public API // --------------------------------------------------------------------------- diff --git a/apps/gui/src/lib/simulation/simulation-manager.ts b/apps/gui/src/lib/simulation/simulation-manager.ts index dc0f159..d837ed9 100644 --- a/apps/gui/src/lib/simulation/simulation-manager.ts +++ b/apps/gui/src/lib/simulation/simulation-manager.ts @@ -5,11 +5,7 @@ import fs from "fs"; import { SQLiteRepository } from "@omnia/core"; import { BufferRepository, LedgerRepository } from "@omnia/memory"; import { Architect, AliasDeltaGenerator } from "@omnia/architect"; -import { - ProviderManager, - GeminiEmbeddingProvider, - MockEmbeddingProvider, -} from "@omnia/llm"; +import { ProviderManager, buildEmbeddingProvider } from "@omnia/llm"; import type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm"; import { ScenarioLoader } from "@omnia/scenario"; import type { SimSnapshot } from "../simulation-types"; @@ -398,14 +394,34 @@ export class SimulationManager { inst = ProviderManager.getActive("embedding"); } - const key = inst ? inst.apiKey : process.env.GOOGLE_API_KEY || ""; - const providerName = inst ? inst.providerName : "google-genai"; - const modelName = inst ? inst.modelName : undefined; + if (!inst) { + const envKey = process.env.GOOGLE_API_KEY || ""; + if (envKey) { + inst = { + id: "regen-env-fallback", + name: "Gemini Embed (Env)", + providerName: "google-genai", + apiKey: envKey, + isActive: true, + modelName: "gemini-embedding-001", + type: "embedding", + maxContext: 0, + }; + } else { + inst = { + id: "regen-mock-fallback", + name: "Mock Embed (Fallback)", + providerName: "mock", + apiKey: "", + isActive: true, + modelName: undefined, + type: "embedding", + maxContext: 0, + }; + } + } - const embeddingProvider: IEmbeddingProvider = - providerName === "google-genai" - ? new GeminiEmbeddingProvider(key, modelName) - : new MockEmbeddingProvider(modelName); + const embeddingProvider: IEmbeddingProvider = buildEmbeddingProvider(inst); for (const file of files) { const dbPath = path.join(DATA_DIR, file); diff --git a/packages/llm/README.md b/packages/llm/README.md index 341b7a5..1875c74 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -4,21 +4,16 @@ LLM abstraction layer providing pluggable, database-backed provider instances fo ## Architecture Overview -The system is built around three layers: +The system is built around four layers: -1. **Interfaces** — contracts that all providers implement -2. **Provider Manager** — SQLite-backed CRUD for persisted provider instances -3. **Provider Resolver** — runtime instantiation of concrete provider classes from stored instances +1. **Registry** — each provider class self-registers its metadata (id, envVar, capabilities, default model, etc.) via `static {}` blocks; `PROVIDER_REGISTRY` is derived from these registrations at runtime — there is no hand-maintained provider list +2. **Provider Manager** — SQLite-backed CRUD for persisted provider instances, with env-var bootstrap driven by the registry +3. **Provider Factory** — `buildLLMProvider(inst)` / `buildEmbeddingProvider(inst)` resolve a stored instance to a live provider class via the registry +4. **Interfaces** — contracts that all providers implement ```mermaid graph TD - subgraph Interfaces - ILP["ILLMProvider"] - IEP["IEmbeddingProvider"] - MPI["ModelProviderInstance"] - end - - subgraph Concrete Providers + subgraph Self-Registering Providers GP["GeminiProvider"] ORP["OpenRouterProvider"] MP["MockLLMProvider"] @@ -26,32 +21,31 @@ graph TD MEP["MockEmbeddingProvider"] end + subgraph Registry + PR["ProviderRegistry\n(derived, not authored)"] + end + subgraph Storage - PM["ProviderManager"] - DB[("settings.db\nprovider_instances")] - DBMAP[("settings.db\nprovider_mappings")] + PM["ProviderManager\n(db.ts + bootstrap.ts + row-mapper.ts)"] + DB[("settings.db")] end - subgraph Resolution - PR["resolveProviders()"] + subgraph Factory + PF["buildLLMProvider()\nbuildEmbeddingProvider()"] end - GP -->|implements| ILP - ORP -->|implements| ILP - MP -->|implements| ILP - GEP -->|implements| IEP - MEP -->|implements| IEP + GP -->|static block| PR + ORP -->|static block| PR + MP -->|static block| PR + GEP -->|static block| PR + MEP -->|static block| PR PM -->|reads/writes| DB - PM -->|reads/writes| DBMAP - PM -->|returns| MPI + PM -->|bootstrap from| PR - PR -->|queries| PM - PR -->|instantiates| GP - PR -->|instantiates| ORP - PR -->|instantiates| GEP - PR -->|fallback| MP - PR -->|fallback| MEP + PF -->|looks up| PR + PF -->|instantiates| GP + PF -->|instantiates| ORP ``` ## Core Interfaces @@ -140,11 +134,16 @@ Static metadata for each available provider type (used by the UI's provider pick | `defaultModel` | `string` | Default generative model | | `defaultEmbeddingModel` | `string` | Default embedding model | -The [`AVAILABLE_PROVIDERS`](src/llm.ts#L70-L103) constant exports all four provider metas. +Provider metadata is **self-declared** by each provider class in a `static {}` block and collected into `PROVIDER_REGISTRY` (derived, not authored). The `getAvailableProviders()` function and `AVAILABLE_PROVIDERS` helper in [`llm.ts`](src/llm.ts) read from the registry at call time. ## Provider Manager -[`ProviderManager`](src/provider-manager.ts) is a **static class** that provides full CRUD over provider instances, backed by a SQLite database (`data/settings.db` at the workspace root). +`ProviderManager` is a **static class** that provides full CRUD over provider instances, backed by a SQLite database (`data/settings.db` at the workspace root). Internally split across: + +- [`db.ts`](src/db.ts) — memoized DB handle + schema migrations (`PRAGMA user_version`) +- [`bootstrap.ts`](src/bootstrap.ts) — `seedFromEnvVars()` driven by `PROVIDER_REGISTRY` (one implementation, not duplicated) +- [`row-mapper.ts`](src/row-mapper.ts) — `mapRow()` + `synthInstance()` (written once, used everywhere) +- [`provider-manager.ts`](src/provider-manager.ts) — thin CRUD: `list`, `create`, `delete`, `setActive`, `update`, `getActive`, `getMappings`, `setMapping` ### Storage @@ -511,7 +510,7 @@ This sends the Zod schema to the model as a structured output constraint. The re | `GROQ_API_KEY` | No | Groq API key | | `DEEPSEEK_API_KEY` | No | DeepSeek API key | -Both are optional because providers can also be configured through the database via the GUI settings page. +Env var keys are derived from `PROVIDER_REGISTRY` — each provider's `envVar` field is read by `getLlmConfig()` to build the zod schema lazily. Adding a new provider with `envVar: "NEW_KEY"` automatically adds it to config validation. ## File Map @@ -519,12 +518,18 @@ Both are optional because providers can also be configured through the database packages/llm/ ├── src/ │ ├── index.ts # Re-exports everything -│ ├── llm.ts # Interfaces, types, AVAILABLE_PROVIDERS -│ ├── config.ts # Env var parsing (Zod) -│ ├── model-lister.ts # ModelLister with cache and API fetching -│ ├── provider-manager.ts # ProviderManager (SQLite CRUD) +│ ├── llm.ts # Interfaces, types, getAvailableProviders() +│ ├── registry.ts # ProviderRegistry (derived), registerProvider/registerGenerative/registerEmbedding +│ ├── base-provider.ts # BaseLLMProvider (shared generateStructuredResponse), resolveCredentials +│ ├── config.ts # Env var parsing (lazy, registry-derived Zod) +│ ├── model-lister.ts # ModelLister (cache + fetchWithTimeout), fetchOpenAICompatibleModels +│ ├── provider-factory.ts # buildLLMProvider() / buildEmbeddingProvider() (registry lookup) +│ ├── provider-manager.ts # ProviderManager (thin CRUD) +│ ├── db.ts # Memoized DB handle + migrations +│ ├── bootstrap.ts # seedFromEnvVars() (registry-driven) +│ ├── row-mapper.ts # mapRow() + synthInstance() │ └── providers/ -│ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider +│ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider (self-registering) │ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider │ ├── openrouter.ts # OpenRouterProvider │ ├── anthropic.ts # AnthropicProvider diff --git a/packages/llm/src/base-provider.ts b/packages/llm/src/base-provider.ts new file mode 100644 index 0000000..4540b25 --- /dev/null +++ b/packages/llm/src/base-provider.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import type { + ILLMProvider, + LLMRequest, + LLMResponse, + LLMCallRecord, +} from "./llm.js"; +import { ProviderManager } from "./provider-manager.js"; +import { getLlmConfig } from "./config.js"; + +export interface ResolvedCredentials { + key: string | undefined; + model: string | undefined; + providerInstanceName: string | undefined; + maxContext: number | undefined; +} + +export function resolveCredentials(opts: { + explicitKey?: string; + explicitModel?: string; + explicitProviderInstanceName?: string; + explicitMaxContext?: number; + providerId: string; + envVarName: string; + type: "generative" | "embedding"; +}): ResolvedCredentials { + let key = opts.explicitKey; + let model = opts.explicitModel; + let providerInstanceName = opts.explicitProviderInstanceName; + let maxContext = opts.explicitMaxContext; + + if (!key) { + const active = ProviderManager.getActive(opts.type); + if (active && active.providerName === opts.providerId) { + key = active.apiKey; + if (!model) model = active.modelName; + if (!providerInstanceName) providerInstanceName = active.name; + if (maxContext === undefined) maxContext = active.maxContext; + } + } + + if (!key) { + const cfg = getLlmConfig(); + key = cfg[opts.envVarName]; + if (!providerInstanceName && key) { + providerInstanceName = "Environment Variable"; + } + } + + return { key, model, providerInstanceName, maxContext }; +} + +export abstract class BaseLLMProvider implements ILLMProvider { + abstract providerName: string; + protected abstract readonly model: unknown; + protected abstract modelNameUsed: string; + protected abstract providerInstanceName?: string; + protected abstract maxContextUsed?: number; + protected abstract defaultMaxContext: number; + lastCalls: LLMCallRecord[] = []; + + async generateStructuredResponse( + request: LLMRequest, + ): Promise>> { + const structuredModel = ( + this.model as { + withStructuredOutput( + s: z.ZodTypeAny, + o: { includeRaw: true }, + ): { + invoke(m: unknown): Promise; + }; + } + ).withStructuredOutput(request.schema, { includeRaw: true }); + const result = (await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ])) as unknown as { + parsed?: z.infer; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = { + inputTokens: raw?.usage_metadata?.input_tokens || 0, + outputTokens: raw?.usage_metadata?.output_tokens || 0, + totalTokens: raw?.usage_metadata?.total_tokens || 0, + modelName: this.modelNameUsed, + providerInstanceName: this.providerInstanceName || "Default", + maxContext: + this.maxContextUsed !== undefined + ? this.maxContextUsed + : this.defaultMaxContext, + }; + + this.lastCalls.push({ + systemPrompt: request.systemPrompt, + userContext: request.userContext, + usage, + }); + + return { success: true, data: parsed, usage }; + } +} diff --git a/packages/llm/src/bootstrap.ts b/packages/llm/src/bootstrap.ts new file mode 100644 index 0000000..ea03dc8 --- /dev/null +++ b/packages/llm/src/bootstrap.ts @@ -0,0 +1,98 @@ +import type BetterSqlite3 from "better-sqlite3"; +import { ProviderRegistry } from "./registry.js"; + +export function seedFromEnvVars(db: BetterSqlite3.Database): boolean { + const totalCount = db + .prepare("SELECT COUNT(*) as count FROM provider_instances") + .get() as { count: number }; + if (totalCount.count > 0) { + return false; + } + + let hasActiveGenerative = false; + let hasActiveEmbedding = false; + + const insertStmt = db.prepare( + `INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + const insertMany = db.transaction( + ( + entries: { + id: string; + name: string; + providerId: string; + key: string; + isActive: number; + modelName: string; + type: "generative" | "embedding"; + maxContext: number; + }[], + ) => { + for (const e of entries) { + insertStmt.run( + e.id, + e.name, + e.providerId, + e.key, + e.isActive, + e.modelName, + e.type, + e.maxContext, + ); + } + }, + ); + + const entries: { + id: string; + name: string; + providerId: string; + key: string; + isActive: number; + modelName: string; + type: "generative" | "embedding"; + maxContext: number; + }[] = []; + + for (const def of ProviderRegistry.all()) { + if (!def.envVar) continue; + const key = process.env[def.envVar]?.trim(); + if (!key) continue; + + if (def.capabilities.generative) { + entries.push({ + id: `provider-default-${def.id}`, + name: `${def.displayName} (Env)`, + providerId: def.id, + key, + isActive: hasActiveGenerative ? 0 : 1, + modelName: def.defaultModel, + type: "generative", + maxContext: def.defaultMaxContext, + }); + if (!hasActiveGenerative) hasActiveGenerative = true; + } + + if (def.capabilities.embedding) { + const embedModel = def.defaultEmbeddingModel || ""; + entries.push({ + id: `provider-default-${def.id}-embed`, + name: `${def.displayName} Embed (Env)`, + providerId: def.id, + key, + isActive: hasActiveEmbedding ? 0 : 1, + modelName: embedModel, + type: "embedding", + maxContext: 0, + }); + if (!hasActiveEmbedding) hasActiveEmbedding = true; + } + } + + if (entries.length > 0) { + insertMany(entries); + } + return true; +} diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts index 881ecc2..1bc7fd4 100644 --- a/packages/llm/src/config.ts +++ b/packages/llm/src/config.ts @@ -1,12 +1,25 @@ import { z } from "zod"; +import { ProviderRegistry } from "./registry.js"; -const LLMConfigSchema = z.object({ - GOOGLE_API_KEY: z.string().optional(), - OPENROUTER_API_KEY: z.string().optional(), - ANTHROPIC_API_KEY: z.string().optional(), - OPENAI_API_KEY: z.string().optional(), - GROQ_API_KEY: z.string().optional(), - DEEPSEEK_API_KEY: z.string().optional(), -}); +let _config: Record | null = null; -export const llmConfig = LLMConfigSchema.parse(process.env); +export function getLlmConfig(): Record { + if (!_config) { + const envVars: string[] = []; + for (const def of ProviderRegistry.all()) { + if (def.envVar && !envVars.includes(def.envVar)) { + envVars.push(def.envVar); + } + } + const shape: Record> = {}; + for (const key of envVars) { + shape[key] = z.string().optional(); + } + _config = z.object(shape).parse(process.env); + } + return _config; +} + +export function resetLlmConfig(): void { + _config = null; +} diff --git a/packages/llm/src/db.ts b/packages/llm/src/db.ts new file mode 100644 index 0000000..2fef5ae --- /dev/null +++ b/packages/llm/src/db.ts @@ -0,0 +1,75 @@ +import Database from "better-sqlite3"; +import type BetterSqlite3 from "better-sqlite3"; +import path from "path"; +import fs from "fs"; + +let _db: BetterSqlite3.Database | null = null; +let _dbPathOverride: string | null = null; + +export function setDbPath(p: string | null) { + if (_dbPathOverride !== p) { + _db?.close(); + _db = null; + _dbPathOverride = p; + } +} + +function findDbPath(): string { + let current = process.cwd(); + while (current !== "/" && current !== path.parse(current).root) { + if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) { + const dbDir = path.resolve(current, "data"); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + return path.join(dbDir, "settings.db"); + } + current = path.dirname(current); + } + const dbDir = path.resolve(process.cwd(), "data"); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + return path.join(dbDir, "settings.db"); +} + +function runMigrations(db: BetterSqlite3.Database): void { + const version = db.pragma("user_version", { simple: true }) as number; + + if (version < 1) { + db.prepare( + ` + CREATE TABLE IF NOT EXISTS provider_instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + providerName TEXT NOT NULL, + apiKey TEXT NOT NULL, + isActive INTEGER NOT NULL DEFAULT 0, + modelName TEXT, + type TEXT NOT NULL DEFAULT 'generative', + maxContext INTEGER, + endpointUrl TEXT + ) + `, + ).run(); + db.pragma("user_version = 1"); + } + + db.prepare( + ` + CREATE TABLE IF NOT EXISTS provider_mappings ( + task TEXT PRIMARY KEY, + providerInstanceId TEXT NOT NULL + ) + `, + ).run(); +} + +export function getDb(): BetterSqlite3.Database { + if (!_db) { + const dbPath = _dbPathOverride ?? findDbPath(); + _db = new Database(dbPath); + runMigrations(_db); + } + return _db; +} diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index a62baa6..569d175 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,6 +1,9 @@ export * from "./llm.js"; export * from "./config.js"; +export * from "./registry.js"; +export * from "./provider-factory.js"; export * from "./model-lister.js"; +export * from "./provider-manager.js"; export * from "./providers/google-genai.js"; export * from "./providers/mock.js"; export * from "./providers/ollama.js"; @@ -9,4 +12,3 @@ export * from "./providers/anthropic.js"; export * from "./providers/openai.js"; export * from "./providers/groq.js"; export * from "./providers/deepseek.js"; -export * from "./provider-manager.js"; diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index 69a7c7c..7d136da 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ProviderRegistry } from "./registry.js"; export interface LLMRequest { systemPrompt: string; @@ -68,63 +69,21 @@ export interface ModelProviderMeta { defaultEmbeddingModel: string; } -export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [ - { - id: "google-genai", - displayName: "Google Gemini", - description: "Official Gemini integration using Google Gen AI SDK", - defaultModel: "gemini-2.5-flash", - defaultEmbeddingModel: "gemini-embedding-001", +export function getAvailableProviders(): ModelProviderMeta[] { + return ProviderRegistry.all().map((def) => ({ + id: def.id, + displayName: def.displayName, + description: def.description, + defaultModel: def.defaultModel, + defaultEmbeddingModel: def.defaultEmbeddingModel || "", + })); +} + +export const AVAILABLE_PROVIDERS = { + get count(): number { + return getAvailableProviders().length; }, - { - id: "openai", - displayName: "OpenAI", - description: "Official OpenAI integration using @langchain/openai SDK", - defaultModel: "gpt-4o-mini", - defaultEmbeddingModel: "text-embedding-3-small", + toArray(): ModelProviderMeta[] { + return getAvailableProviders(); }, - { - id: "anthropic", - displayName: "Anthropic Claude", - description: "Official Claude integration using @langchain/anthropic SDK", - defaultModel: "claude-3-5-sonnet-latest", - defaultEmbeddingModel: "", - }, - { - id: "groq", - displayName: "Groq", - description: "Official Groq integration using @langchain/groq SDK", - defaultModel: "llama-3.3-70b-versatile", - defaultEmbeddingModel: "", - }, - { - id: "deepseek", - displayName: "DeepSeek", - description: "Official DeepSeek integration using @langchain/deepseek SDK", - defaultModel: "deepseek-chat", - defaultEmbeddingModel: "", - }, - { - id: "openrouter", - displayName: "OpenRouter", - description: - "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models", - defaultModel: "google/gemini-2.5-flash", - defaultEmbeddingModel: "openai/text-embedding-3-small", - }, - { - id: "ollama", - displayName: "Ollama", - description: - "Local model runner — no API key required, uses the Ollama server base URL instead", - defaultModel: "llama3.1", - defaultEmbeddingModel: "nomic-embed-text", - }, - { - id: "mock", - displayName: "Mock LLM Provider", - description: "Stateless mock provider for testing and offline development", - defaultModel: "mock", - defaultEmbeddingModel: "mock-embeddings", - }, -]; +}; diff --git a/packages/llm/src/model-lister.ts b/packages/llm/src/model-lister.ts index 3950003..217fda3 100644 --- a/packages/llm/src/model-lister.ts +++ b/packages/llm/src/model-lister.ts @@ -3,6 +3,8 @@ * Results are cached in-memory with a 5-minute TTL to avoid repeated calls. */ +import { ProviderRegistry } from "./registry.js"; + export interface ModelInfo { id: string; name: string; @@ -14,10 +16,9 @@ interface CacheEntry { fetchedAt: number; } -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes -const FETCH_TIMEOUT_MS = 10_000; // 10 seconds +const CACHE_TTL_MS = 5 * 60 * 1000; +const FETCH_TIMEOUT_MS = 10_000; -// Cache key is "providerName:apiKey-or-endpoint" (we don't hash since it's in-process) const modelCache = new Map(); function cacheKey( @@ -28,7 +29,7 @@ function cacheKey( return `${providerName}:${endpointUrl || apiKey}`; } -async function fetchWithTimeout( +export async function fetchWithTimeout( url: string, init?: RequestInit, ): Promise { @@ -41,41 +42,7 @@ async function fetchWithTimeout( } } -async function fetchGeminiModels(apiKey: string): Promise { - const models: ModelInfo[] = []; - let pageToken: string | undefined; - - do { - const url = new URL( - "https://generativelanguage.googleapis.com/v1beta/models", - ); - url.searchParams.set("key", apiKey); - url.searchParams.set("pageSize", "100"); - if (pageToken) { - url.searchParams.set("pageToken", pageToken); - } - - const res = await fetchWithTimeout(url.toString()); - if (!res.ok) return models; - - const json = (await res.json()) as { - models?: { name: string; displayName?: string }[]; - nextPageToken?: string; - }; - - for (const m of json.models ?? []) { - // m.name is like "models/gemini-2.5-flash"; strip "models/" prefix - const id = m.name.replace(/^models\//, ""); - models.push({ id, name: m.displayName || id }); - } - - pageToken = json.nextPageToken; - } while (pageToken); - - return models; -} - -async function fetchOpenAICompatibleModels( +export async function fetchOpenAICompatibleModels( baseUrl: string, apiKey: string, ): Promise { @@ -98,146 +65,25 @@ async function fetchOpenAICompatibleModels( })); } -async function fetchAnthropicModels(apiKey: string): Promise { - const models: ModelInfo[] = []; - let afterId: string | undefined; - - do { - const url = new URL("https://api.anthropic.com/v1/models"); - url.searchParams.set("limit", "1000"); - if (afterId) { - url.searchParams.set("after_id", afterId); - } - - const res = await fetchWithTimeout(url.toString(), { - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - Accept: "application/json", - }, - }); - if (!res.ok) return models; - - const json = (await res.json()) as { - data?: { id: string; display_name?: string }[]; - has_more?: boolean; - last_id?: string; - }; - - for (const m of json.data ?? []) { - models.push({ id: m.id, name: m.display_name || m.id }); - } - - afterId = json.has_more ? json.last_id : undefined; - } while (afterId); - - return models; -} - -async function fetchOllamaModels(endpointUrl: string): Promise { - const base = endpointUrl.replace(/\/$/, ""); - const res = await fetchWithTimeout(`${base}/api/tags`); - if (!res.ok) return []; - - const json = (await res.json()) as { - models?: { name: string; model?: string }[]; - }; - - return (json.models ?? []).map((m) => ({ - id: m.name, - name: m.name, - })); -} - -async function fetchOpenRouterModels(apiKey: string): Promise { - const res = await fetchWithTimeout( - "https://openrouter.ai/api/v1/models", - apiKey - ? { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - } - : { headers: { Accept: "application/json" } }, - ); - if (!res.ok) return []; - - const json = (await res.json()) as { - data?: { id: string; name?: string; owned_by?: string }[]; - }; - - return (json.data ?? []).map((m) => ({ - id: m.id, - name: m.name || m.id, - ownedBy: m.owned_by, - })); -} - export class ModelLister { - /** - * List available models for a given provider. Results are cached for 5 minutes. - * - * @param providerName The provider ID (e.g. "openai", "google-genai") - * @param apiKey The API key for the provider (or "none" for Ollama) - * @param endpointUrl The endpoint URL (required for Ollama, ignored otherwise) - * @returns Array of ModelInfo objects, or [] on any error - */ static async listModels( providerName: string, apiKey: string, endpointUrl?: string, ): Promise { - if (providerName === "mock") { - return [{ id: "mock", name: "Mock Model" }]; - } - const key = cacheKey(providerName, apiKey, endpointUrl); const cached = modelCache.get(key); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.models; } + const def = ProviderRegistry.get(providerName); let models: ModelInfo[] = []; try { - switch (providerName) { - case "google-genai": - models = await fetchGeminiModels(apiKey); - break; - case "openai": - models = await fetchOpenAICompatibleModels( - "https://api.openai.com/v1", - apiKey, - ); - break; - case "anthropic": - models = await fetchAnthropicModels(apiKey); - break; - case "groq": - models = await fetchOpenAICompatibleModels( - "https://api.groq.com/openai/v1", - apiKey, - ); - break; - case "deepseek": - models = await fetchOpenAICompatibleModels( - "https://api.deepseek.com", - apiKey, - ); - break; - case "ollama": - models = await fetchOllamaModels( - endpointUrl || "http://localhost:11434", - ); - break; - case "openrouter": - models = await fetchOpenRouterModels(apiKey); - break; - default: - models = []; + if (def?.listModels) { + models = await def.listModels(apiKey, endpointUrl); } } catch { - // Network error, invalid key, timeout — return empty array for graceful degradation models = []; } @@ -245,7 +91,6 @@ export class ModelLister { return models; } - /** Invalidate the cache entry for a specific provider+key combination. */ static invalidateCache( providerName: string, apiKey: string, @@ -254,7 +99,6 @@ export class ModelLister { modelCache.delete(cacheKey(providerName, apiKey, endpointUrl)); } - /** Clear the entire model cache. */ static clearCache(): void { modelCache.clear(); } diff --git a/packages/llm/src/provider-factory.ts b/packages/llm/src/provider-factory.ts new file mode 100644 index 0000000..039ed2f --- /dev/null +++ b/packages/llm/src/provider-factory.ts @@ -0,0 +1,21 @@ +import type { + ILLMProvider, + IEmbeddingProvider, + ModelProviderInstance, +} from "./llm.js"; +import { MockLLMProvider, MockEmbeddingProvider } from "./providers/mock.js"; +import { ProviderRegistry } from "./registry.js"; + +export function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider { + const def = ProviderRegistry.get(inst.providerName); + return def?.generativeCreate?.(inst) ?? new MockLLMProvider([]); +} + +export function buildEmbeddingProvider( + inst: ModelProviderInstance, +): IEmbeddingProvider { + const def = ProviderRegistry.get(inst.providerName); + return ( + def?.embeddingCreate?.(inst) ?? new MockEmbeddingProvider(inst.modelName) + ); +} diff --git a/packages/llm/src/provider-manager.ts b/packages/llm/src/provider-manager.ts index c6fe5f0..f8d046c 100644 --- a/packages/llm/src/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -1,325 +1,19 @@ -import Database from "better-sqlite3"; -import path from "path"; -import fs from "fs"; import type { ModelProviderInstance } from "./llm.js"; +import { getDb } from "./db.js"; +import { mapRow, synthInstance, type DbRow } from "./row-mapper.js"; +import { seedFromEnvVars } from "./bootstrap.js"; +import { ProviderRegistry } from "./registry.js"; -let dbPathOverride: string | null = null; -let hasBootstrapped = false; - -export function setDbPathOverride(p: string | null) { - dbPathOverride = p; -} - -export function resetHasBootstrapped() { - hasBootstrapped = false; -} - -function getWorkspaceRoot() { - let current = process.cwd(); - while (current !== "/" && current !== path.parse(current).root) { - if ( - fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || - fs.existsSync(path.join(current, "package.json")) - ) { - if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) { - return current; - } - } - current = path.dirname(current); - } - return process.cwd(); -} - -function getSettingsDb() { - let dbPath: string; - if (dbPathOverride) { - dbPath = dbPathOverride; - } else { - const wsRoot = getWorkspaceRoot(); - const dbDir = path.resolve(wsRoot, "data"); - if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }); - } - dbPath = path.join(dbDir, "settings.db"); - } - const db = new Database(dbPath); - - db.prepare( - ` - CREATE TABLE IF NOT EXISTS provider_instances ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - providerName TEXT NOT NULL, - apiKey TEXT NOT NULL, - isActive INTEGER NOT NULL DEFAULT 0, - modelName TEXT, - type TEXT NOT NULL DEFAULT 'generative' - ) - `, - ).run(); - - try { - db.prepare( - `ALTER TABLE provider_instances ADD COLUMN modelName TEXT`, - ).run(); - } catch { - // ignore - } - - try { - db.prepare( - `ALTER TABLE provider_instances ADD COLUMN type TEXT NOT NULL DEFAULT 'generative'`, - ).run(); - } catch { - // ignore - } - - try { - db.prepare( - `ALTER TABLE provider_instances ADD COLUMN maxContext INTEGER`, - ).run(); - } catch { - // ignore - } - - try { - db.prepare( - `ALTER TABLE provider_instances ADD COLUMN endpointUrl TEXT`, - ).run(); - } catch { - // ignore - } - - // Auto-bootstrap environment variables if DB contains 0 instances - try { - if (!hasBootstrapped) { - const totalCount = db - .prepare(`SELECT COUNT(*) as count FROM provider_instances`) - .get() as { count: number }; - if (totalCount.count === 0) { - const googleKey = process.env.GOOGLE_API_KEY; - const openRouterKey = process.env.OPENROUTER_API_KEY; - const anthropicKey = process.env.ANTHROPIC_API_KEY; - const openaiKey = process.env.OPENAI_API_KEY; - const groqKey = process.env.GROQ_API_KEY; - const deepseekKey = process.env.DEEPSEEK_API_KEY; - let hasInsertedGenerative = false; - let hasInsertedEmbedding = false; - - if (googleKey && googleKey.trim()) { - const id = "provider-default-google"; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "Gemini (Env)", - "google-genai", - googleKey.trim(), - 1, - "gemini-2.5-flash", - "generative", - 32768, - ); - hasInsertedGenerative = true; - - const embedId = "provider-default-google-embed"; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - embedId, - "Gemini Embed (Env)", - "google-genai", - googleKey.trim(), - 1, - "gemini-embedding-001", - "embedding", - 0, - ); - hasInsertedEmbedding = true; - } - - if (anthropicKey && anthropicKey.trim()) { - const id = "provider-default-anthropic"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "Anthropic (Env)", - "anthropic", - anthropicKey.trim(), - isActive, - "claude-3-5-sonnet-latest", - "generative", - 200000, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - } - - if (openaiKey && openaiKey.trim()) { - const id = "provider-default-openai"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "OpenAI (Env)", - "openai", - openaiKey.trim(), - isActive, - "gpt-4o-mini", - "generative", - 128000, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - - const embedId = "provider-default-openai-embed"; - const isEmbedActive = hasInsertedEmbedding ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - embedId, - "OpenAI Embed (Env)", - "openai", - openaiKey.trim(), - isEmbedActive, - "text-embedding-3-small", - "embedding", - 0, - ); - if (isEmbedActive === 1) { - hasInsertedEmbedding = true; - } - } - - if (groqKey && groqKey.trim()) { - const id = "provider-default-groq"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "Groq (Env)", - "groq", - groqKey.trim(), - isActive, - "llama-3.3-70b-versatile", - "generative", - 8192, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - } - - if (deepseekKey && deepseekKey.trim()) { - const id = "provider-default-deepseek"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "DeepSeek (Env)", - "deepseek", - deepseekKey.trim(), - isActive, - "deepseek-chat", - "generative", - 64000, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - } - - if (openRouterKey && openRouterKey.trim()) { - const id = "provider-default-openrouter"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "OpenRouter (Env)", - "openrouter", - openRouterKey.trim(), - isActive, - "google/gemini-2.5-flash", - "generative", - 32768, - ); - } - } - hasBootstrapped = true; - } - } catch { - // ignore write lock issues or other DB errors during bootstrap - } - - return db; -} +export { setDbPath as setDbPathOverride } from "./db.js"; export class ProviderManager { static list(): ModelProviderInstance[] { - const db = getSettingsDb(); - try { - const rows = db.prepare(`SELECT * FROM provider_instances`).all() as { - id: string; - name: string; - providerName: string; - apiKey: string; - isActive: number; - modelName?: string; - type: string; - maxContext?: number; - endpointUrl?: string; - }[]; - return rows.map((r) => ({ - id: r.id, - name: r.name, - providerName: r.providerName, - apiKey: r.apiKey, - isActive: r.isActive === 1, - modelName: r.modelName || undefined, - type: (r.type as "generative" | "embedding") || "generative", - maxContext: - r.maxContext !== undefined && r.maxContext !== null - ? r.maxContext - : r.type === "embedding" - ? 0 - : 32768, - endpointUrl: r.endpointUrl || undefined, - })); - } finally { - db.close(); - } + const db = getDb(); + seedFromEnvVars(db); + const rows = db + .prepare("SELECT * FROM provider_instances") + .all() as DbRow[]; + return rows.map(mapRow); } static create( @@ -331,95 +25,77 @@ export class ProviderManager { maxContext?: number, endpointUrl?: string, ): ModelProviderInstance { - const db = getSettingsDb(); - try { - const id = "provider-" + Date.now(); - const activeCount = db - .prepare( - `SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?`, - ) - .get(type) as { count: number }; - const isActive = activeCount.count === 0 ? 1 : 0; + const db = getDb(); + const id = "provider-" + Date.now(); + const activeCount = db + .prepare( + "SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1 AND type = ?", + ) + .get(type) as { count: number }; + const isActive = activeCount.count === 0 ? 1 : 0; - const actualMaxContext = - maxContext !== undefined - ? maxContext - : type === "generative" - ? 32768 - : 0; + const actualMaxContext = + maxContext !== undefined ? maxContext : type === "generative" ? 32768 : 0; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - name, - providerName, - apiKey, - isActive, - modelName || null, - type, - actualMaxContext, - endpointUrl || null, - ); + db.prepare( + `INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + name, + providerName, + apiKey, + isActive, + modelName || null, + type, + actualMaxContext, + endpointUrl || null, + ); - return { - id, - name, - providerName, - apiKey, - isActive: isActive === 1, - modelName, - type, - maxContext: actualMaxContext, - endpointUrl, - }; - } finally { - db.close(); - } + return { + id, + name, + providerName, + apiKey, + isActive: isActive === 1, + modelName, + type, + maxContext: actualMaxContext, + endpointUrl, + }; } static delete(id: string): void { - const db = getSettingsDb(); - try { - const provider = db - .prepare(`SELECT isActive, type FROM provider_instances WHERE id = ?`) - .get(id) as { isActive: number; type: string } | undefined; - db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id); + const db = getDb(); + const provider = db + .prepare("SELECT isActive, type FROM provider_instances WHERE id = ?") + .get(id) as { isActive: number; type: string } | undefined; + db.prepare("DELETE FROM provider_instances WHERE id = ?").run(id); - if (provider && provider.isActive === 1) { - const next = db - .prepare(`SELECT id FROM provider_instances WHERE type = ? LIMIT 1`) - .get(provider.type) as { id: string } | undefined; - if (next) { - db.prepare( - `UPDATE provider_instances SET isActive = 1 WHERE id = ?`, - ).run(next.id); - } + if (provider && provider.isActive === 1) { + const next = db + .prepare("SELECT id FROM provider_instances WHERE type = ? LIMIT 1") + .get(provider.type) as { id: string } | undefined; + if (next) { + db.prepare( + "UPDATE provider_instances SET isActive = 1 WHERE id = ?", + ).run(next.id); } - } finally { - db.close(); } } static setActive(id: string): void { - const db = getSettingsDb(); - try { - const target = db - .prepare(`SELECT type FROM provider_instances WHERE id = ?`) - .get(id) as { type: string } | undefined; - if (target) { - db.prepare( - `UPDATE provider_instances SET isActive = 0 WHERE type = ?`, - ).run(target.type); - db.prepare( - `UPDATE provider_instances SET isActive = 1 WHERE id = ?`, - ).run(id); - } - } finally { - db.close(); + const db = getDb(); + const target = db + .prepare("SELECT type FROM provider_instances WHERE id = ?") + .get(id) as { type: string } | undefined; + if (target) { + db.prepare( + "UPDATE provider_instances SET isActive = 0 WHERE type = ?", + ).run(target.type); + db.prepare("UPDATE provider_instances SET isActive = 1 WHERE id = ?").run( + id, + ); } } @@ -433,520 +109,117 @@ export class ProviderManager { maxContext?: number, endpointUrl?: string, ): void { - const db = getSettingsDb(); - try { - const actualMaxContext = - maxContext !== undefined - ? maxContext - : type === "generative" - ? 32768 - : 0; - if (apiKey && apiKey.trim()) { - db.prepare( - ` - UPDATE provider_instances - SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ? - WHERE id = ? - `, - ).run( - name, - providerName, - apiKey, - modelName || null, - type, - actualMaxContext, - endpointUrl || null, - id, - ); - } else { - db.prepare( - ` - UPDATE provider_instances - SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ? - WHERE id = ? - `, - ).run( - name, - providerName, - modelName || null, - type, - actualMaxContext, - endpointUrl || null, - id, - ); - } - } finally { - db.close(); + const db = getDb(); + const actualMaxContext = + maxContext !== undefined ? maxContext : type === "generative" ? 32768 : 0; + + if (apiKey && apiKey.trim()) { + db.prepare( + `UPDATE provider_instances + SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ? + WHERE id = ?`, + ).run( + name, + providerName, + apiKey, + modelName || null, + type, + actualMaxContext, + endpointUrl || null, + id, + ); + } else { + db.prepare( + `UPDATE provider_instances + SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ? + WHERE id = ?`, + ).run( + name, + providerName, + modelName || null, + type, + actualMaxContext, + endpointUrl || null, + id, + ); } } static getActive( type: "generative" | "embedding" = "generative", ): ModelProviderInstance | null { - const db = getSettingsDb(); + const db = getDb(); try { + seedFromEnvVars(db); + const row = db .prepare( - `SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`, + "SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?", ) - .get(type) as - | { - id: string; - name: string; - providerName: string; - apiKey: string; - isActive: number; - modelName?: string; - type: string; - maxContext?: number; - endpointUrl?: string; - } - | undefined; + .get(type) as DbRow | undefined; - if (!row) { - const totalCount = db - .prepare(`SELECT COUNT(*) as count FROM provider_instances`) - .get() as { count: number }; - if (totalCount.count === 0) { - const googleKey = process.env.GOOGLE_API_KEY; - const openRouterKey = process.env.OPENROUTER_API_KEY; - const anthropicKey = process.env.ANTHROPIC_API_KEY; - const openaiKey = process.env.OPENAI_API_KEY; - const groqKey = process.env.GROQ_API_KEY; - const deepseekKey = process.env.DEEPSEEK_API_KEY; - let hasInsertedGenerative = false; - let hasInsertedEmbedding = false; - - if (googleKey && googleKey.trim()) { - const id = "provider-default-google"; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "Gemini (Env)", - "google-genai", - googleKey.trim(), - 1, - "gemini-2.5-flash", - "generative", - 32768, - ); - hasInsertedGenerative = true; - - const embedId = "provider-default-google-embed"; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - embedId, - "Gemini Embed (Env)", - "google-genai", - googleKey.trim(), - 1, - "gemini-embedding-001", - "embedding", - 0, - ); - hasInsertedEmbedding = true; - } - - if (anthropicKey && anthropicKey.trim()) { - const id = "provider-default-anthropic"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "Anthropic (Env)", - "anthropic", - anthropicKey.trim(), - isActive, - "claude-3-5-sonnet-latest", - "generative", - 200000, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - } - - if (openaiKey && openaiKey.trim()) { - const id = "provider-default-openai"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "OpenAI (Env)", - "openai", - openaiKey.trim(), - isActive, - "gpt-4o-mini", - "generative", - 128000, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - - const embedId = "provider-default-openai-embed"; - const isEmbedActive = hasInsertedEmbedding ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - embedId, - "OpenAI Embed (Env)", - "openai", - openaiKey.trim(), - isEmbedActive, - "text-embedding-3-small", - "embedding", - 0, - ); - if (isEmbedActive === 1) { - hasInsertedEmbedding = true; - } - } - - if (groqKey && groqKey.trim()) { - const id = "provider-default-groq"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "Groq (Env)", - "groq", - groqKey.trim(), - isActive, - "llama-3.3-70b-versatile", - "generative", - 8192, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - } - - if (deepseekKey && deepseekKey.trim()) { - const id = "provider-default-deepseek"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "DeepSeek (Env)", - "deepseek", - deepseekKey.trim(), - isActive, - "deepseek-chat", - "generative", - 64000, - ); - if (isActive === 1) { - hasInsertedGenerative = true; - } - } - - if (openRouterKey && openRouterKey.trim()) { - const id = "provider-default-openrouter"; - const isActive = hasInsertedGenerative ? 0 : 1; - db.prepare( - ` - INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - id, - "OpenRouter (Env)", - "openrouter", - openRouterKey.trim(), - isActive, - "google/gemini-2.5-flash", - "generative", - 32768, - ); - } - - const retryRow = db - .prepare( - `SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?`, - ) - .get(type) as - | { - id: string; - name: string; - providerName: string; - apiKey: string; - isActive: number; - modelName?: string; - type: string; - maxContext?: number; - endpointUrl?: string; - } - | undefined; - - if (retryRow) { - return { - id: retryRow.id, - name: retryRow.name, - providerName: retryRow.providerName, - apiKey: retryRow.apiKey, - isActive: true, - modelName: retryRow.modelName || undefined, - type: retryRow.type as "generative" | "embedding", - maxContext: - retryRow.maxContext !== undefined && - retryRow.maxContext !== null - ? retryRow.maxContext - : retryRow.type === "embedding" - ? 0 - : 32768, - endpointUrl: retryRow.endpointUrl || undefined, - }; - } - } - - // If there's no active row but some rows exist, return the first one as active, or update it - const firstRow = db - .prepare(`SELECT * FROM provider_instances WHERE type = ? LIMIT 1`) - .get(type) as - | { - id: string; - name: string; - providerName: string; - apiKey: string; - isActive: number; - modelName?: string; - type: string; - maxContext?: number; - endpointUrl?: string; - } - | undefined; - if (firstRow) { - db.prepare( - `UPDATE provider_instances SET isActive = 1 WHERE id = ?`, - ).run(firstRow.id); - return { - id: firstRow.id, - name: firstRow.name, - providerName: firstRow.providerName, - apiKey: firstRow.apiKey, - isActive: true, - modelName: firstRow.modelName || undefined, - type: firstRow.type as "generative" | "embedding", - maxContext: - firstRow.maxContext !== undefined && firstRow.maxContext !== null - ? firstRow.maxContext - : firstRow.type === "embedding" - ? 0 - : 32768, - endpointUrl: firstRow.endpointUrl || undefined, - }; - } - return null; + if (row) { + return mapRow(row); } - return { - id: row.id, - name: row.name, - providerName: row.providerName, - apiKey: row.apiKey, - isActive: true, - modelName: row.modelName || undefined, - type: (row.type as "generative" | "embedding") || "generative", - maxContext: - row.maxContext !== undefined && row.maxContext !== null - ? row.maxContext - : row.type === "embedding" - ? 0 - : 32768, - endpointUrl: row.endpointUrl || undefined, - }; - } catch { - const googleKey = process.env.GOOGLE_API_KEY; - if (type === "embedding") { - if (googleKey && googleKey.trim()) { - return { - id: "provider-default-env-embed-fallback", - name: "Gemini Embed (Env Fallback)", - providerName: "google-genai", - apiKey: googleKey.trim(), - isActive: true, - modelName: "gemini-embedding-001", - type: "embedding", - maxContext: 0, - }; - } - const openaiKey = process.env.OPENAI_API_KEY; - if (openaiKey && openaiKey.trim()) { - return { - id: "provider-default-env-embed-fallback", - name: "OpenAI Embed (Env Fallback)", - providerName: "openai", - apiKey: openaiKey.trim(), - isActive: true, - modelName: "text-embedding-3-small", - type: "embedding", - maxContext: 0, - }; - } - return null; + const firstRow = db + .prepare("SELECT * FROM provider_instances WHERE type = ? LIMIT 1") + .get(type) as DbRow | undefined; + + if (firstRow) { + db.prepare( + "UPDATE provider_instances SET isActive = 1 WHERE id = ?", + ).run(firstRow.id); + return mapRow(firstRow); } - // generative fallback - if (googleKey && googleKey.trim()) { - return { - id: "provider-default-env-fallback", - name: "Gemini (Env Fallback)", - providerName: "google-genai", - apiKey: googleKey.trim(), - isActive: true, - modelName: "gemini-2.5-flash", - type: "generative", - maxContext: 32768, - }; - } - const openaiKey = process.env.OPENAI_API_KEY; - if (openaiKey && openaiKey.trim()) { - return { - id: "provider-default-env-fallback", - name: "OpenAI (Env Fallback)", - providerName: "openai", - apiKey: openaiKey.trim(), - isActive: true, - modelName: "gpt-4o-mini", - type: "generative", - maxContext: 128000, - }; - } - const anthropicKey = process.env.ANTHROPIC_API_KEY; - if (anthropicKey && anthropicKey.trim()) { - return { - id: "provider-default-env-fallback", - name: "Anthropic (Env Fallback)", - providerName: "anthropic", - apiKey: anthropicKey.trim(), - isActive: true, - modelName: "claude-3-5-sonnet-latest", - type: "generative", - maxContext: 200000, - }; - } - const groqKey = process.env.GROQ_API_KEY; - if (groqKey && groqKey.trim()) { - return { - id: "provider-default-env-fallback", - name: "Groq (Env Fallback)", - providerName: "groq", - apiKey: groqKey.trim(), - isActive: true, - modelName: "llama-3.3-70b-versatile", - type: "generative", - maxContext: 8192, - }; - } - const deepseekKey = process.env.DEEPSEEK_API_KEY; - if (deepseekKey && deepseekKey.trim()) { - return { - id: "provider-default-env-fallback", - name: "DeepSeek (Env Fallback)", - providerName: "deepseek", - apiKey: deepseekKey.trim(), - isActive: true, - modelName: "deepseek-chat", - type: "generative", - maxContext: 64000, - }; - } - const openRouterKey = process.env.OPENROUTER_API_KEY; - if (openRouterKey && openRouterKey.trim()) { - return { - id: "provider-default-env-fallback", - name: "OpenRouter (Env Fallback)", - providerName: "openrouter", - apiKey: openRouterKey.trim(), - isActive: true, - modelName: "google/gemini-2.5-flash", - type: "generative", - maxContext: 32768, - }; - } return null; - } finally { - db.close(); + } catch { + return envFallback(type); } } static getMappings(): Record { - const db = getSettingsDb(); - try { - db.prepare( - ` - CREATE TABLE IF NOT EXISTS provider_mappings ( - task TEXT PRIMARY KEY, - providerInstanceId TEXT NOT NULL - ) - `, - ).run(); - const rows = db.prepare(`SELECT * FROM provider_mappings`).all() as { - task: string; - providerInstanceId: string; - }[]; - const mappings: Record = {}; - for (const row of rows) { - mappings[row.task] = row.providerInstanceId; - } - return mappings; - } finally { - db.close(); + const db = getDb(); + const rows = db.prepare("SELECT * FROM provider_mappings").all() as { + task: string; + providerInstanceId: string; + }[]; + const mappings: Record = {}; + for (const row of rows) { + mappings[row.task] = row.providerInstanceId; } + return mappings; } static setMapping(task: string, providerInstanceId: string): void { - const db = getSettingsDb(); - try { + const db = getDb(); + if (!providerInstanceId) { + db.prepare("DELETE FROM provider_mappings WHERE task = ?").run(task); + } else { db.prepare( - ` - CREATE TABLE IF NOT EXISTS provider_mappings ( - task TEXT PRIMARY KEY, - providerInstanceId TEXT NOT NULL - ) - `, - ).run(); - if (!providerInstanceId) { - db.prepare(`DELETE FROM provider_mappings WHERE task = ?`).run(task); - } else { - db.prepare( - ` - INSERT INTO provider_mappings (task, providerInstanceId) - VALUES (?, ?) - ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId - `, - ).run(task, providerInstanceId); - } - } finally { - db.close(); + `INSERT INTO provider_mappings (task, providerInstanceId) + VALUES (?, ?) + ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId`, + ).run(task, providerInstanceId); } } } + +function envFallback( + type: "generative" | "embedding", +): ModelProviderInstance | null { + const defs = [...ProviderRegistry.all()].sort( + (a, b) => a.fallbackPriority - b.fallbackPriority, + ); + for (const def of defs) { + if (!def.envVar) continue; + if (type === "generative" && !def.capabilities.generative) continue; + if (type === "embedding" && !def.capabilities.embedding) continue; + const key = process.env[def.envVar]?.trim(); + if (key) { + return synthInstance(def, type, key); + } + } + return null; +} diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index 03d7ebd..3e1b401 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -1,27 +1,86 @@ -import { z } from "zod"; import { ChatAnthropic } from "@langchain/anthropic"; -import { - ILLMProvider, - LLMRequest, - LLMResponse, - LLMCallRecord, -} from "../llm.js"; -import { llmConfig } from "../config.js"; -import { ProviderManager } from "../provider-manager.js"; +import { ILLMProvider } from "../llm.js"; +import type { ModelProviderInstance } from "../llm.js"; +import { BaseLLMProvider, resolveCredentials } from "../base-provider.js"; +import { registerProvider, registerGenerative } from "../registry.js"; +import { fetchWithTimeout, type ModelInfo } from "../model-lister.js"; -export class AnthropicProvider implements ILLMProvider { - static readonly providerId = "anthropic"; - static readonly displayName = "Anthropic Claude"; - static readonly description = - "Official Claude integration using @langchain/anthropic SDK"; - static readonly defaultModel = "claude-3-5-sonnet-latest"; +async function fetchAnthropicModels(apiKey: string): Promise { + const models: ModelInfo[] = []; + let afterId: string | undefined; + + do { + const url = new URL("https://api.anthropic.com/v1/models"); + url.searchParams.set("limit", "1000"); + if (afterId) { + url.searchParams.set("after_id", afterId); + } + + const res = await fetchWithTimeout(url.toString(), { + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + Accept: "application/json", + }, + }); + if (!res.ok) return models; + + const json = (await res.json()) as { + data?: { id: string; display_name?: string }[]; + has_more?: boolean; + last_id?: string; + }; + + for (const m of json.data ?? []) { + models.push({ id: m.id, name: m.display_name || m.id }); + } + + afterId = json.has_more ? json.last_id : undefined; + } while (afterId); + + return models; +} + +export class AnthropicProvider extends BaseLLMProvider { + static { + registerProvider({ + id: "anthropic", + displayName: "Anthropic Claude", + description: "Official Claude integration using @langchain/anthropic SDK", + envVar: "ANTHROPIC_API_KEY", + capabilities: { generative: true, embedding: false }, + defaultModel: "claude-3-5-sonnet-latest", + defaultMaxContext: 200000, + fallbackPriority: 2, + listModels: fetchAnthropicModels, + }); + registerGenerative( + "anthropic", + (inst: ModelProviderInstance) => + new AnthropicProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ), + ); + } + + static create(inst: ModelProviderInstance): ILLMProvider { + return new AnthropicProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); + } providerName = "Anthropic"; - private model: ChatAnthropic; - private modelNameUsed: string; - private providerInstanceName?: string; - private maxContextUsed?: number; - lastCalls: LLMCallRecord[] = []; + protected readonly model: ChatAnthropic; + protected modelNameUsed: string; + protected providerInstanceName?: string; + protected maxContextUsed?: number; + protected defaultMaxContext = 200000; constructor( apiKey?: string, @@ -29,86 +88,29 @@ export class AnthropicProvider implements ILLMProvider { providerInstanceName?: string, maxContext?: number, ) { - let key = apiKey; - let model = modelName; - this.providerInstanceName = providerInstanceName; - this.maxContextUsed = maxContext; - - if (!key) { - const active = ProviderManager.getActive("generative"); - if (active && active.providerName === AnthropicProvider.providerId) { - key = active.apiKey; - if (!model) { - model = active.modelName; - } - if (!this.providerInstanceName) { - this.providerInstanceName = active.name; - } - if (this.maxContextUsed === undefined) { - this.maxContextUsed = active.maxContext; - } - } - } - - if (!key) { - key = llmConfig.ANTHROPIC_API_KEY; - if (!this.providerInstanceName && key) { - this.providerInstanceName = "Environment Variable"; - } - } - + super(); + const { + key, + model, + providerInstanceName: resolvedName, + maxContext: resolvedMax, + } = resolveCredentials({ + explicitKey: apiKey, + explicitModel: modelName, + explicitProviderInstanceName: providerInstanceName, + explicitMaxContext: maxContext, + providerId: "anthropic", + envVarName: "ANTHROPIC_API_KEY", + type: "generative", + }); if (!key) { throw new Error( "ANTHROPIC_API_KEY is required to initialize AnthropicProvider", ); } - - this.modelNameUsed = model || AnthropicProvider.defaultModel; - this.model = new ChatAnthropic({ - apiKey: key, - model: this.modelNameUsed, - }); - } - - async generateStructuredResponse( - request: LLMRequest, - ): Promise>> { - const structuredModel = this.model.withStructuredOutput(request.schema, { - includeRaw: true, - }); - const result = (await structuredModel.invoke([ - { role: "system", content: request.systemPrompt }, - { role: "user", content: request.userContext }, - ])) as unknown as { - parsed?: z.infer; - raw?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - }; - }; - - const parsed = result?.parsed; - const raw = result?.raw; - - const usage = { - inputTokens: raw?.usage_metadata?.input_tokens || 0, - outputTokens: raw?.usage_metadata?.output_tokens || 0, - totalTokens: raw?.usage_metadata?.total_tokens || 0, - modelName: this.modelNameUsed, - providerInstanceName: this.providerInstanceName || "Default", - maxContext: - this.maxContextUsed !== undefined ? this.maxContextUsed : 200000, - }; - - this.lastCalls.push({ - systemPrompt: request.systemPrompt, - userContext: request.userContext, - usage, - }); - - return { success: true, data: parsed, usage }; + this.providerInstanceName = resolvedName; + this.maxContextUsed = resolvedMax; + this.modelNameUsed = model || "claude-3-5-sonnet-latest"; + this.model = new ChatAnthropic({ apiKey: key, model: this.modelNameUsed }); } } diff --git a/packages/llm/src/providers/deepseek.ts b/packages/llm/src/providers/deepseek.ts index 868d884..83942c6 100644 --- a/packages/llm/src/providers/deepseek.ts +++ b/packages/llm/src/providers/deepseek.ts @@ -1,27 +1,52 @@ -import { z } from "zod"; import { ChatDeepSeek } from "@langchain/deepseek"; -import { - ILLMProvider, - LLMRequest, - LLMResponse, - LLMCallRecord, -} from "../llm.js"; -import { llmConfig } from "../config.js"; -import { ProviderManager } from "../provider-manager.js"; +import { ILLMProvider } from "../llm.js"; +import type { ModelProviderInstance } from "../llm.js"; +import { BaseLLMProvider, resolveCredentials } from "../base-provider.js"; +import { registerProvider, registerGenerative } from "../registry.js"; +import { fetchOpenAICompatibleModels } from "../model-lister.js"; -export class DeepSeekProvider implements ILLMProvider { - static readonly providerId = "deepseek"; - static readonly displayName = "DeepSeek"; - static readonly description = - "Official DeepSeek integration using @langchain/deepseek SDK"; - static readonly defaultModel = "deepseek-chat"; +export class DeepSeekProvider extends BaseLLMProvider { + static { + registerProvider({ + id: "deepseek", + displayName: "DeepSeek", + description: + "Official DeepSeek integration using @langchain/deepseek SDK", + envVar: "DEEPSEEK_API_KEY", + capabilities: { generative: true, embedding: false }, + defaultModel: "deepseek-chat", + defaultMaxContext: 64000, + fallbackPriority: 4, + listModels: (apiKey) => + fetchOpenAICompatibleModels("https://api.deepseek.com", apiKey), + }); + registerGenerative( + "deepseek", + (inst: ModelProviderInstance) => + new DeepSeekProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ), + ); + } + + static create(inst: ModelProviderInstance): ILLMProvider { + return new DeepSeekProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); + } providerName = "DeepSeek"; - private model: ChatDeepSeek; - private modelNameUsed: string; - private providerInstanceName?: string; - private maxContextUsed?: number; - lastCalls: LLMCallRecord[] = []; + protected readonly model: ChatDeepSeek; + protected modelNameUsed: string; + protected providerInstanceName?: string; + protected maxContextUsed?: number; + protected defaultMaxContext = 64000; constructor( apiKey?: string, @@ -29,86 +54,29 @@ export class DeepSeekProvider implements ILLMProvider { providerInstanceName?: string, maxContext?: number, ) { - let key = apiKey; - let model = modelName; - this.providerInstanceName = providerInstanceName; - this.maxContextUsed = maxContext; - - if (!key) { - const active = ProviderManager.getActive("generative"); - if (active && active.providerName === DeepSeekProvider.providerId) { - key = active.apiKey; - if (!model) { - model = active.modelName; - } - if (!this.providerInstanceName) { - this.providerInstanceName = active.name; - } - if (this.maxContextUsed === undefined) { - this.maxContextUsed = active.maxContext; - } - } - } - - if (!key) { - key = llmConfig.DEEPSEEK_API_KEY; - if (!this.providerInstanceName && key) { - this.providerInstanceName = "Environment Variable"; - } - } - + super(); + const { + key, + model, + providerInstanceName: resolvedName, + maxContext: resolvedMax, + } = resolveCredentials({ + explicitKey: apiKey, + explicitModel: modelName, + explicitProviderInstanceName: providerInstanceName, + explicitMaxContext: maxContext, + providerId: "deepseek", + envVarName: "DEEPSEEK_API_KEY", + type: "generative", + }); if (!key) { throw new Error( "DEEPSEEK_API_KEY is required to initialize DeepSeekProvider", ); } - - this.modelNameUsed = model || DeepSeekProvider.defaultModel; - this.model = new ChatDeepSeek({ - apiKey: key, - model: this.modelNameUsed, - }); - } - - async generateStructuredResponse( - request: LLMRequest, - ): Promise>> { - const structuredModel = this.model.withStructuredOutput(request.schema, { - includeRaw: true, - }); - const result = (await structuredModel.invoke([ - { role: "system", content: request.systemPrompt }, - { role: "user", content: request.userContext }, - ])) as unknown as { - parsed?: z.infer; - raw?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - }; - }; - - const parsed = result?.parsed; - const raw = result?.raw; - - const usage = { - inputTokens: raw?.usage_metadata?.input_tokens || 0, - outputTokens: raw?.usage_metadata?.output_tokens || 0, - totalTokens: raw?.usage_metadata?.total_tokens || 0, - modelName: this.modelNameUsed, - providerInstanceName: this.providerInstanceName || "Default", - maxContext: - this.maxContextUsed !== undefined ? this.maxContextUsed : 64000, - }; - - this.lastCalls.push({ - systemPrompt: request.systemPrompt, - userContext: request.userContext, - usage, - }); - - return { success: true, data: parsed, usage }; + this.providerInstanceName = resolvedName; + this.maxContextUsed = resolvedMax; + this.modelNameUsed = model || "deepseek-chat"; + this.model = new ChatDeepSeek({ apiKey: key, model: this.modelNameUsed }); } } diff --git a/packages/llm/src/providers/google-genai.ts b/packages/llm/src/providers/google-genai.ts index fb2f32f..2511c31 100644 --- a/packages/llm/src/providers/google-genai.ts +++ b/packages/llm/src/providers/google-genai.ts @@ -1,31 +1,96 @@ -import { z } from "zod"; import { ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings, } from "@langchain/google-genai"; -import { +import type { ILLMProvider, - LLMRequest, - LLMResponse, - LLMCallRecord, IEmbeddingProvider, + ModelProviderInstance, } from "../llm.js"; -import { llmConfig } from "../config.js"; +import { + registerProvider, + registerGenerative, + registerEmbedding, +} from "../registry.js"; +import { fetchWithTimeout, type ModelInfo } from "../model-lister.js"; +import { BaseLLMProvider, resolveCredentials } from "../base-provider.js"; +import { getLlmConfig } from "../config.js"; import { ProviderManager } from "../provider-manager.js"; -export class GeminiProvider implements ILLMProvider { - static readonly providerId = "google-genai"; - static readonly displayName = "Google Gemini"; - static readonly description = - "Official Gemini integration using Google Gen AI SDK"; - static readonly defaultModel = "gemini-2.5-flash"; +async function fetchGeminiModels(apiKey: string): Promise { + const models: ModelInfo[] = []; + let pageToken: string | undefined; + + do { + const url = new URL( + "https://generativelanguage.googleapis.com/v1beta/models", + ); + url.searchParams.set("key", apiKey); + url.searchParams.set("pageSize", "100"); + if (pageToken) { + url.searchParams.set("pageToken", pageToken); + } + + const res = await fetchWithTimeout(url.toString()); + if (!res.ok) return models; + + const json = (await res.json()) as { + models?: { name: string; displayName?: string }[]; + nextPageToken?: string; + }; + + for (const m of json.models ?? []) { + const id = m.name.replace(/^models\//, ""); + models.push({ id, name: m.displayName || id }); + } + + pageToken = json.nextPageToken; + } while (pageToken); + + return models; +} + +export class GeminiProvider extends BaseLLMProvider { + static { + registerProvider({ + id: "google-genai", + displayName: "Google Gemini", + description: "Official Gemini integration using Google Gen AI SDK", + envVar: "GOOGLE_API_KEY", + capabilities: { generative: true, embedding: true }, + defaultModel: "gemini-2.5-flash", + defaultEmbeddingModel: "gemini-embedding-001", + defaultMaxContext: 32768, + fallbackPriority: 0, + listModels: fetchGeminiModels, + }); + registerGenerative( + "google-genai", + (inst: ModelProviderInstance) => + new GeminiProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ), + ); + } providerName = "Gemini"; - private model: ChatGoogleGenerativeAI; - private modelNameUsed: string; - private providerInstanceName?: string; - private maxContextUsed?: number; - lastCalls: LLMCallRecord[] = []; + protected readonly model: ChatGoogleGenerativeAI; + protected modelNameUsed: string; + protected providerInstanceName?: string; + protected maxContextUsed?: number; + protected readonly defaultMaxContext = 32768; + + static create(inst: ModelProviderInstance): ILLMProvider { + return new GeminiProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); + } constructor( apiKey?: string, @@ -33,113 +98,66 @@ export class GeminiProvider implements ILLMProvider { providerInstanceName?: string, maxContext?: number, ) { - let key = apiKey; - let model = modelName; - this.providerInstanceName = providerInstanceName; - this.maxContextUsed = maxContext; - - if (!key) { - const active = ProviderManager.getActive("generative"); - if (active && active.providerName === GeminiProvider.providerId) { - key = active.apiKey; - if (!model) { - model = active.modelName; - } - if (!this.providerInstanceName) { - this.providerInstanceName = active.name; - } - if (this.maxContextUsed === undefined) { - this.maxContextUsed = active.maxContext; - } - } - } - - if (!key) { - key = llmConfig.GOOGLE_API_KEY; - if (!this.providerInstanceName && key) { - this.providerInstanceName = "Environment Variable"; - } - } - + super(); + const { + key, + model, + providerInstanceName: pn, + maxContext: mc, + } = resolveCredentials({ + explicitKey: apiKey, + explicitModel: modelName, + explicitProviderInstanceName: providerInstanceName, + explicitMaxContext: maxContext, + providerId: "google-genai", + envVarName: "GOOGLE_API_KEY", + type: "generative", + }); if (!key) { throw new Error( "GOOGLE_API_KEY is required to initialize GeminiProvider", ); } - + this.providerInstanceName = pn; + this.maxContextUsed = mc; this.modelNameUsed = model || "gemini-2.5-flash"; this.model = new ChatGoogleGenerativeAI({ apiKey: key, model: this.modelNameUsed, }); } - - async generateStructuredResponse( - request: LLMRequest, - ): Promise>> { - const structuredModel = this.model.withStructuredOutput(request.schema, { - includeRaw: true, - }); - const result = (await structuredModel.invoke([ - { role: "system", content: request.systemPrompt }, - { role: "user", content: request.userContext }, - ])) as unknown as { - parsed?: z.infer; - raw?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - }; - }; - - const parsed = result?.parsed; - const raw = result?.raw; - - const usage = { - inputTokens: raw?.usage_metadata?.input_tokens || 0, - outputTokens: raw?.usage_metadata?.output_tokens || 0, - totalTokens: raw?.usage_metadata?.total_tokens || 0, - modelName: this.modelNameUsed, - providerInstanceName: this.providerInstanceName || "Default", - maxContext: - this.maxContextUsed !== undefined ? this.maxContextUsed : 32768, - }; - - this.lastCalls.push({ - systemPrompt: request.systemPrompt, - userContext: request.userContext, - usage, - }); - - return { success: true, data: parsed, usage }; - } } export class GeminiEmbeddingProvider implements IEmbeddingProvider { - static readonly providerId = "google-genai"; - static readonly displayName = "Google Gemini Embeddings"; + static { + registerEmbedding( + "google-genai", + (inst: ModelProviderInstance) => + new GeminiEmbeddingProvider(inst.apiKey, inst.modelName), + ); + } providerName = "Gemini"; private model: GoogleGenerativeAIEmbeddings; + static create(inst: ModelProviderInstance): IEmbeddingProvider { + return new GeminiEmbeddingProvider(inst.apiKey, inst.modelName); + } + constructor(apiKey?: string, modelName?: string) { let key = apiKey; let model = modelName; if (!key) { const active = ProviderManager.getActive("embedding"); - if (active) { + if (active && active.providerName === "google-genai") { key = active.apiKey; - if (!model) { - model = active.modelName; - } + if (!model) model = active.modelName; } } if (!key) { - key = llmConfig.GOOGLE_API_KEY; + key = getLlmConfig().GOOGLE_API_KEY; } if (!key) { diff --git a/packages/llm/src/providers/groq.ts b/packages/llm/src/providers/groq.ts index 20df4ba..e70f9cf 100644 --- a/packages/llm/src/providers/groq.ts +++ b/packages/llm/src/providers/groq.ts @@ -1,27 +1,51 @@ -import { z } from "zod"; import { ChatGroq } from "@langchain/groq"; -import { - ILLMProvider, - LLMRequest, - LLMResponse, - LLMCallRecord, -} from "../llm.js"; -import { llmConfig } from "../config.js"; -import { ProviderManager } from "../provider-manager.js"; +import { ILLMProvider } from "../llm.js"; +import type { ModelProviderInstance } from "../llm.js"; +import { BaseLLMProvider, resolveCredentials } from "../base-provider.js"; +import { registerProvider, registerGenerative } from "../registry.js"; +import { fetchOpenAICompatibleModels } from "../model-lister.js"; -export class GroqProvider implements ILLMProvider { - static readonly providerId = "groq"; - static readonly displayName = "Groq"; - static readonly description = - "Official Groq integration using @langchain/groq SDK"; - static readonly defaultModel = "llama-3.3-70b-versatile"; +export class GroqProvider extends BaseLLMProvider { + static { + registerProvider({ + id: "groq", + displayName: "Groq", + description: "Official Groq integration using @langchain/groq SDK", + envVar: "GROQ_API_KEY", + capabilities: { generative: true, embedding: false }, + defaultModel: "llama-3.3-70b-versatile", + defaultMaxContext: 8192, + fallbackPriority: 3, + listModels: (apiKey) => + fetchOpenAICompatibleModels("https://api.groq.com/openai/v1", apiKey), + }); + registerGenerative( + "groq", + (inst: ModelProviderInstance) => + new GroqProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ), + ); + } + + static create(inst: ModelProviderInstance): ILLMProvider { + return new GroqProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); + } providerName = "Groq"; - private model: ChatGroq; - private modelNameUsed: string; - private providerInstanceName?: string; - private maxContextUsed?: number; - lastCalls: LLMCallRecord[] = []; + protected readonly model: ChatGroq; + protected modelNameUsed: string; + protected providerInstanceName?: string; + protected maxContextUsed?: number; + protected defaultMaxContext = 8192; constructor( apiKey?: string, @@ -29,84 +53,27 @@ export class GroqProvider implements ILLMProvider { providerInstanceName?: string, maxContext?: number, ) { - let key = apiKey; - let model = modelName; - this.providerInstanceName = providerInstanceName; - this.maxContextUsed = maxContext; - - if (!key) { - const active = ProviderManager.getActive("generative"); - if (active && active.providerName === GroqProvider.providerId) { - key = active.apiKey; - if (!model) { - model = active.modelName; - } - if (!this.providerInstanceName) { - this.providerInstanceName = active.name; - } - if (this.maxContextUsed === undefined) { - this.maxContextUsed = active.maxContext; - } - } - } - - if (!key) { - key = llmConfig.GROQ_API_KEY; - if (!this.providerInstanceName && key) { - this.providerInstanceName = "Environment Variable"; - } - } - + super(); + const { + key, + model, + providerInstanceName: resolvedName, + maxContext: resolvedMax, + } = resolveCredentials({ + explicitKey: apiKey, + explicitModel: modelName, + explicitProviderInstanceName: providerInstanceName, + explicitMaxContext: maxContext, + providerId: "groq", + envVarName: "GROQ_API_KEY", + type: "generative", + }); if (!key) { throw new Error("GROQ_API_KEY is required to initialize GroqProvider"); } - - this.modelNameUsed = model || GroqProvider.defaultModel; - this.model = new ChatGroq({ - apiKey: key, - model: this.modelNameUsed, - }); - } - - async generateStructuredResponse( - request: LLMRequest, - ): Promise>> { - const structuredModel = this.model.withStructuredOutput(request.schema, { - includeRaw: true, - }); - const result = (await structuredModel.invoke([ - { role: "system", content: request.systemPrompt }, - { role: "user", content: request.userContext }, - ])) as unknown as { - parsed?: z.infer; - raw?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - }; - }; - - const parsed = result?.parsed; - const raw = result?.raw; - - const usage = { - inputTokens: raw?.usage_metadata?.input_tokens || 0, - outputTokens: raw?.usage_metadata?.output_tokens || 0, - totalTokens: raw?.usage_metadata?.total_tokens || 0, - modelName: this.modelNameUsed, - providerInstanceName: this.providerInstanceName || "Default", - maxContext: - this.maxContextUsed !== undefined ? this.maxContextUsed : 8192, - }; - - this.lastCalls.push({ - systemPrompt: request.systemPrompt, - userContext: request.userContext, - usage, - }); - - return { success: true, data: parsed, usage }; + this.providerInstanceName = resolvedName; + this.maxContextUsed = resolvedMax; + this.modelNameUsed = model || "llama-3.3-70b-versatile"; + this.model = new ChatGroq({ apiKey: key, model: this.modelNameUsed }); } } diff --git a/packages/llm/src/providers/mock.ts b/packages/llm/src/providers/mock.ts index 4db8383..b898133 100644 --- a/packages/llm/src/providers/mock.ts +++ b/packages/llm/src/providers/mock.ts @@ -6,13 +6,33 @@ import { LLMCallRecord, IEmbeddingProvider, } from "../llm.js"; +import type { ModelProviderInstance } from "../llm.js"; +import { + registerProvider, + registerGenerative, + registerEmbedding, +} from "../registry.js"; export class MockLLMProvider implements ILLMProvider { - static readonly providerId = "mock"; - static readonly displayName = "Mock LLM Provider"; - static readonly description = - "Stateless mock provider for testing and offline development"; - static readonly defaultModel = "mock"; + static { + registerProvider({ + id: "mock", + displayName: "Mock LLM Provider", + description: + "Stateless mock provider for testing and offline development", + capabilities: { generative: true, embedding: true }, + defaultModel: "mock", + defaultEmbeddingModel: "mock-embeddings", + defaultMaxContext: 0, + fallbackPriority: 1000, + listModels: () => Promise.resolve([{ id: "mock", name: "Mock Model" }]), + }); + registerGenerative("mock", () => new MockLLMProvider([])); + } + + static create(inst: ModelProviderInstance): ILLMProvider { + return new MockLLMProvider([]); + } providerName = "mock"; private callCount = 0; @@ -46,7 +66,17 @@ export class MockLLMProvider implements ILLMProvider { } export class MockEmbeddingProvider implements IEmbeddingProvider { - static readonly providerId = "mock"; + static { + registerEmbedding( + "mock", + (inst: ModelProviderInstance) => + new MockEmbeddingProvider(inst.modelName), + ); + } + + static create(inst: ModelProviderInstance): IEmbeddingProvider { + return new MockEmbeddingProvider(inst.modelName); + } providerName = "mock"; diff --git a/packages/llm/src/providers/ollama.ts b/packages/llm/src/providers/ollama.ts index 9db58e8..66ac0c3 100644 --- a/packages/llm/src/providers/ollama.ts +++ b/packages/llm/src/providers/ollama.ts @@ -1,27 +1,72 @@ -import { z } from "zod"; import { ChatOllama, OllamaEmbeddings } from "@langchain/ollama"; -import { - ILLMProvider, - LLMRequest, - LLMResponse, - LLMCallRecord, - IEmbeddingProvider, -} from "../llm.js"; +import { ILLMProvider, IEmbeddingProvider } from "../llm.js"; +import type { ModelProviderInstance } from "../llm.js"; import { ProviderManager } from "../provider-manager.js"; +import { BaseLLMProvider } from "../base-provider.js"; +import { + registerProvider, + registerGenerative, + registerEmbedding, +} from "../registry.js"; +import { fetchWithTimeout, type ModelInfo } from "../model-lister.js"; -export class OllamaProvider implements ILLMProvider { - static readonly providerId = "ollama"; - static readonly displayName = "Ollama"; - static readonly description = - "Local model runner supporting open-source LLMs via the Ollama server"; - static readonly defaultModel = "llama3.1"; +async function fetchOllamaModels(endpointUrl: string): Promise { + const base = endpointUrl.replace(/\/$/, ""); + const res = await fetchWithTimeout(`${base}/api/tags`); + if (!res.ok) return []; + + const json = (await res.json()) as { + models?: { name: string; model?: string }[]; + }; + + return (json.models ?? []).map((m) => ({ + id: m.name, + name: m.name, + })); +} + +export class OllamaProvider extends BaseLLMProvider { + static { + registerProvider({ + id: "ollama", + displayName: "Ollama", + description: + "Local model runner supporting open-source LLMs via the Ollama server", + capabilities: { generative: true, embedding: true }, + defaultModel: "llama3.1", + defaultEmbeddingModel: "nomic-embed-text", + defaultMaxContext: 32768, + fallbackPriority: 100, + listModels: (_apiKey, endpointUrl) => + fetchOllamaModels(endpointUrl || "http://localhost:11434"), + }); + registerGenerative( + "ollama", + (inst: ModelProviderInstance) => + new OllamaProvider( + inst.endpointUrl, + inst.modelName, + inst.name, + inst.maxContext, + ), + ); + } + + static create(inst: ModelProviderInstance): ILLMProvider { + return new OllamaProvider( + inst.endpointUrl, + inst.modelName, + inst.name, + inst.maxContext, + ); + } providerName = "Ollama"; - private model: ChatOllama; - private modelNameUsed: string; - private providerInstanceName?: string; - private maxContextUsed?: number; - lastCalls: LLMCallRecord[] = []; + protected readonly model: ChatOllama; + protected modelNameUsed: string; + protected providerInstanceName?: string; + protected maxContextUsed?: number; + protected defaultMaxContext = 32768; /** * Creates an OllamaProvider. @@ -41,6 +86,7 @@ export class OllamaProvider implements ILLMProvider { providerInstanceName?: string, maxContext?: number, ) { + super(); let url = baseUrl; let model = modelName; this.providerInstanceName = providerInstanceName; @@ -48,7 +94,7 @@ export class OllamaProvider implements ILLMProvider { if (!url || !model) { const active = ProviderManager.getActive("generative"); - if (active && active.providerName === OllamaProvider.providerId) { + if (active && active.providerName === "ollama") { if (!url) { url = active.endpointUrl; } @@ -64,59 +110,26 @@ export class OllamaProvider implements ILLMProvider { } } - this.modelNameUsed = model || OllamaProvider.defaultModel; + this.modelNameUsed = model || "llama3.1"; this.model = new ChatOllama({ baseUrl: url || "http://localhost:11434", model: this.modelNameUsed, }); } - - async generateStructuredResponse( - request: LLMRequest, - ): Promise>> { - const structuredModel = this.model.withStructuredOutput(request.schema, { - includeRaw: true, - }); - const result = (await structuredModel.invoke([ - { role: "system", content: request.systemPrompt }, - { role: "user", content: request.userContext }, - ])) as unknown as { - parsed?: z.infer; - raw?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - }; - }; - - const parsed = result?.parsed; - const raw = result?.raw; - - const usage = { - inputTokens: raw?.usage_metadata?.input_tokens || 0, - outputTokens: raw?.usage_metadata?.output_tokens || 0, - totalTokens: raw?.usage_metadata?.total_tokens || 0, - modelName: this.modelNameUsed, - providerInstanceName: this.providerInstanceName || "Default", - maxContext: - this.maxContextUsed !== undefined ? this.maxContextUsed : 32768, - }; - - this.lastCalls.push({ - systemPrompt: request.systemPrompt, - userContext: request.userContext, - usage, - }); - - return { success: true, data: parsed, usage }; - } } export class OllamaEmbeddingProvider implements IEmbeddingProvider { - static readonly providerId = "ollama"; - static readonly displayName = "Ollama Embeddings"; + static { + registerEmbedding( + "ollama", + (inst: ModelProviderInstance) => + new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName), + ); + } + + static create(inst: ModelProviderInstance): IEmbeddingProvider { + return new OllamaEmbeddingProvider(inst.endpointUrl, inst.modelName); + } providerName = "Ollama"; private model: OllamaEmbeddings; @@ -137,10 +150,7 @@ export class OllamaEmbeddingProvider implements IEmbeddingProvider { if (!url || !model) { const active = ProviderManager.getActive("embedding"); - if ( - active && - active.providerName === OllamaEmbeddingProvider.providerId - ) { + if (active && active.providerName === "ollama") { if (!url) { url = active.endpointUrl; } diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts index 0565504..f891c8d 100644 --- a/packages/llm/src/providers/openai.ts +++ b/packages/llm/src/providers/openai.ts @@ -1,28 +1,58 @@ -import { z } from "zod"; import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai"; -import { - ILLMProvider, - LLMRequest, - LLMResponse, - LLMCallRecord, - IEmbeddingProvider, -} from "../llm.js"; -import { llmConfig } from "../config.js"; +import { ILLMProvider, IEmbeddingProvider } from "../llm.js"; +import type { ModelProviderInstance } from "../llm.js"; +import { getLlmConfig } from "../config.js"; import { ProviderManager } from "../provider-manager.js"; +import { BaseLLMProvider, resolveCredentials } from "../base-provider.js"; +import { + registerProvider, + registerGenerative, + registerEmbedding, +} from "../registry.js"; +import { fetchOpenAICompatibleModels } from "../model-lister.js"; -export class OpenAIProvider implements ILLMProvider { - static readonly providerId = "openai"; - static readonly displayName = "OpenAI"; - static readonly description = - "Official OpenAI integration using @langchain/openai SDK"; - static readonly defaultModel = "gpt-4o-mini"; +export class OpenAIProvider extends BaseLLMProvider { + static { + registerProvider({ + id: "openai", + displayName: "OpenAI", + description: "Official OpenAI integration using @langchain/openai SDK", + envVar: "OPENAI_API_KEY", + capabilities: { generative: true, embedding: true }, + defaultModel: "gpt-4o-mini", + defaultEmbeddingModel: "text-embedding-3-small", + defaultMaxContext: 128000, + fallbackPriority: 1, + listModels: (apiKey) => + fetchOpenAICompatibleModels("https://api.openai.com/v1", apiKey), + }); + registerGenerative( + "openai", + (inst: ModelProviderInstance) => + new OpenAIProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ), + ); + } + + static create(inst: ModelProviderInstance): ILLMProvider { + return new OpenAIProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); + } providerName = "OpenAI"; - private model: ChatOpenAI; - private modelNameUsed: string; - private providerInstanceName?: string; - private maxContextUsed?: number; - lastCalls: LLMCallRecord[] = []; + protected readonly model: ChatOpenAI; + protected modelNameUsed: string; + protected providerInstanceName?: string; + protected maxContextUsed?: number; + protected defaultMaxContext = 128000; constructor( apiKey?: string, @@ -30,93 +60,45 @@ export class OpenAIProvider implements ILLMProvider { providerInstanceName?: string, maxContext?: number, ) { - let key = apiKey; - let model = modelName; - this.providerInstanceName = providerInstanceName; - this.maxContextUsed = maxContext; - - if (!key) { - const active = ProviderManager.getActive("generative"); - if (active && active.providerName === OpenAIProvider.providerId) { - key = active.apiKey; - if (!model) { - model = active.modelName; - } - if (!this.providerInstanceName) { - this.providerInstanceName = active.name; - } - if (this.maxContextUsed === undefined) { - this.maxContextUsed = active.maxContext; - } - } - } - - if (!key) { - key = llmConfig.OPENAI_API_KEY; - if (!this.providerInstanceName && key) { - this.providerInstanceName = "Environment Variable"; - } - } - + super(); + const { + key, + model, + providerInstanceName: resolvedName, + maxContext: resolvedMax, + } = resolveCredentials({ + explicitKey: apiKey, + explicitModel: modelName, + explicitProviderInstanceName: providerInstanceName, + explicitMaxContext: maxContext, + providerId: "openai", + envVarName: "OPENAI_API_KEY", + type: "generative", + }); if (!key) { throw new Error( "OPENAI_API_KEY is required to initialize OpenAIProvider", ); } - - this.modelNameUsed = model || OpenAIProvider.defaultModel; - this.model = new ChatOpenAI({ - apiKey: key, - model: this.modelNameUsed, - }); - } - - async generateStructuredResponse( - request: LLMRequest, - ): Promise>> { - const structuredModel = this.model.withStructuredOutput(request.schema, { - includeRaw: true, - }); - const result = (await structuredModel.invoke([ - { role: "system", content: request.systemPrompt }, - { role: "user", content: request.userContext }, - ])) as unknown as { - parsed?: z.infer; - raw?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - }; - }; - - const parsed = result?.parsed; - const raw = result?.raw; - - const usage = { - inputTokens: raw?.usage_metadata?.input_tokens || 0, - outputTokens: raw?.usage_metadata?.output_tokens || 0, - totalTokens: raw?.usage_metadata?.total_tokens || 0, - modelName: this.modelNameUsed, - providerInstanceName: this.providerInstanceName || "Default", - maxContext: - this.maxContextUsed !== undefined ? this.maxContextUsed : 128000, - }; - - this.lastCalls.push({ - systemPrompt: request.systemPrompt, - userContext: request.userContext, - usage, - }); - - return { success: true, data: parsed, usage }; + this.providerInstanceName = resolvedName; + this.maxContextUsed = resolvedMax; + this.modelNameUsed = model || "gpt-4o-mini"; + this.model = new ChatOpenAI({ apiKey: key, model: this.modelNameUsed }); } } export class OpenAIEmbeddingProvider implements IEmbeddingProvider { - static readonly providerId = "openai"; - static readonly displayName = "OpenAI Embeddings"; + static { + registerEmbedding( + "openai", + (inst: ModelProviderInstance) => + new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName), + ); + } + + static create(inst: ModelProviderInstance): IEmbeddingProvider { + return new OpenAIEmbeddingProvider(inst.apiKey, inst.modelName); + } providerName = "OpenAI"; private model: OpenAIEmbeddings; @@ -127,10 +109,7 @@ export class OpenAIEmbeddingProvider implements IEmbeddingProvider { if (!key) { const active = ProviderManager.getActive("embedding"); - if ( - active && - active.providerName === OpenAIEmbeddingProvider.providerId - ) { + if (active && active.providerName === "openai") { key = active.apiKey; if (!model) { model = active.modelName; @@ -139,7 +118,7 @@ export class OpenAIEmbeddingProvider implements IEmbeddingProvider { } if (!key) { - key = llmConfig.OPENAI_API_KEY; + key = getLlmConfig().OPENAI_API_KEY; } if (!key) { diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts index 830638a..b89057b 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -1,27 +1,77 @@ -import { z } from "zod"; import { ChatOpenRouter } from "@langchain/openrouter"; -import { - ILLMProvider, - LLMRequest, - LLMResponse, - LLMCallRecord, -} from "../llm.js"; -import { llmConfig } from "../config.js"; -import { ProviderManager } from "../provider-manager.js"; +import { ILLMProvider } from "../llm.js"; +import type { ModelProviderInstance } from "../llm.js"; +import { BaseLLMProvider, resolveCredentials } from "../base-provider.js"; +import { registerProvider, registerGenerative } from "../registry.js"; +import { fetchWithTimeout, type ModelInfo } from "../model-lister.js"; -export class OpenRouterProvider implements ILLMProvider { - static readonly providerId = "openrouter"; - static readonly displayName = "OpenRouter"; - static readonly description = - "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models"; - static readonly defaultModel = "google/gemini-2.5-flash"; +async function fetchOpenRouterModels(apiKey: string): Promise { + const res = await fetchWithTimeout( + "https://openrouter.ai/api/v1/models", + apiKey + ? { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + } + : { headers: { Accept: "application/json" } }, + ); + if (!res.ok) return []; + + const json = (await res.json()) as { + data?: { id: string; name?: string; owned_by?: string }[]; + }; + + return (json.data ?? []).map((m) => ({ + id: m.id, + name: m.name || m.id, + ownedBy: m.owned_by, + })); +} + +export class OpenRouterProvider extends BaseLLMProvider { + static { + registerProvider({ + id: "openrouter", + displayName: "OpenRouter", + description: + "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models", + envVar: "OPENROUTER_API_KEY", + capabilities: { generative: true, embedding: false }, + defaultModel: "google/gemini-2.5-flash", + defaultEmbeddingModel: "openai/text-embedding-3-small", + defaultMaxContext: 32768, + fallbackPriority: 5, + listModels: fetchOpenRouterModels, + }); + registerGenerative( + "openrouter", + (inst: ModelProviderInstance) => + new OpenRouterProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ), + ); + } + + static create(inst: ModelProviderInstance): ILLMProvider { + return new OpenRouterProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); + } providerName = "OpenRouter"; - private model: ChatOpenRouter; - private modelNameUsed: string; - private providerInstanceName?: string; - private maxContextUsed?: number; - lastCalls: LLMCallRecord[] = []; + protected readonly model: ChatOpenRouter; + protected modelNameUsed: string; + protected providerInstanceName?: string; + protected maxContextUsed?: number; + protected defaultMaxContext = 32768; constructor( apiKey?: string, @@ -29,86 +79,29 @@ export class OpenRouterProvider implements ILLMProvider { providerInstanceName?: string, maxContext?: number, ) { - let key = apiKey; - let model = modelName; - this.providerInstanceName = providerInstanceName; - this.maxContextUsed = maxContext; - - if (!key) { - const active = ProviderManager.getActive("generative"); - if (active && active.providerName === OpenRouterProvider.providerId) { - key = active.apiKey; - if (!model) { - model = active.modelName; - } - if (!this.providerInstanceName) { - this.providerInstanceName = active.name; - } - if (this.maxContextUsed === undefined) { - this.maxContextUsed = active.maxContext; - } - } - } - - if (!key) { - key = llmConfig.OPENROUTER_API_KEY; - if (!this.providerInstanceName && key) { - this.providerInstanceName = "Environment Variable"; - } - } - + super(); + const { + key, + model, + providerInstanceName: resolvedName, + maxContext: resolvedMax, + } = resolveCredentials({ + explicitKey: apiKey, + explicitModel: modelName, + explicitProviderInstanceName: providerInstanceName, + explicitMaxContext: maxContext, + providerId: "openrouter", + envVarName: "OPENROUTER_API_KEY", + type: "generative", + }); if (!key) { throw new Error( "OPENROUTER_API_KEY is required to initialize OpenRouterProvider", ); } - + this.providerInstanceName = resolvedName; + this.maxContextUsed = resolvedMax; this.modelNameUsed = model || "google/gemini-2.5-flash"; - this.model = new ChatOpenRouter({ - apiKey: key, - model: this.modelNameUsed, - }); - } - - async generateStructuredResponse( - request: LLMRequest, - ): Promise>> { - const structuredModel = this.model.withStructuredOutput(request.schema, { - includeRaw: true, - }); - const result = (await structuredModel.invoke([ - { role: "system", content: request.systemPrompt }, - { role: "user", content: request.userContext }, - ])) as unknown as { - parsed?: z.infer; - raw?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - }; - }; - }; - - const parsed = result?.parsed; - const raw = result?.raw; - - const usage = { - inputTokens: raw?.usage_metadata?.input_tokens || 0, - outputTokens: raw?.usage_metadata?.output_tokens || 0, - totalTokens: raw?.usage_metadata?.total_tokens || 0, - modelName: this.modelNameUsed, - providerInstanceName: this.providerInstanceName || "Default", - maxContext: - this.maxContextUsed !== undefined ? this.maxContextUsed : 32768, - }; - - this.lastCalls.push({ - systemPrompt: request.systemPrompt, - userContext: request.userContext, - usage, - }); - - return { success: true, data: parsed, usage }; + this.model = new ChatOpenRouter({ apiKey: key, model: this.modelNameUsed }); } } diff --git a/packages/llm/src/registry.ts b/packages/llm/src/registry.ts new file mode 100644 index 0000000..9152f1e --- /dev/null +++ b/packages/llm/src/registry.ts @@ -0,0 +1,79 @@ +import type { + ILLMProvider, + IEmbeddingProvider, + ModelProviderInstance, + ModelProviderMeta, +} from "./llm.js"; +import type { ModelInfo } from "./model-lister.js"; + +export interface ProviderDefinition { + id: string; + displayName: string; + description: string; + envVar?: string; + capabilities: { generative: boolean; embedding: boolean }; + defaultModel: string; + defaultEmbeddingModel?: string; + defaultMaxContext: number; + fallbackPriority: number; + listModels?: (apiKey: string, endpointUrl?: string) => Promise; + generativeCreate?: (inst: ModelProviderInstance) => ILLMProvider; + embeddingCreate?: (inst: ModelProviderInstance) => IEmbeddingProvider; +} + +const _entries = new Map(); + +type ProviderMeta = Omit< + ProviderDefinition, + "generativeCreate" | "embeddingCreate" +>; + +export function registerProvider(meta: ProviderMeta) { + const existing = _entries.get(meta.id); + _entries.set(meta.id, { + ...existing, + ...meta, + generativeCreate: existing?.generativeCreate, + embeddingCreate: existing?.embeddingCreate, + }); +} + +export function registerGenerative( + id: string, + createFn: (inst: ModelProviderInstance) => ILLMProvider, +) { + const existing = _entries.get(id); + if (existing) { + existing.generativeCreate = createFn; + } else { + _entries.set(id, { id, generativeCreate: createFn } as ProviderDefinition); + } +} + +export function registerEmbedding( + id: string, + createFn: (inst: ModelProviderInstance) => IEmbeddingProvider, +) { + const existing = _entries.get(id); + if (existing) { + existing.embeddingCreate = createFn; + } else { + _entries.set(id, { id, embeddingCreate: createFn } as ProviderDefinition); + } +} + +export const ProviderRegistry = { + all: (): ProviderDefinition[] => [..._entries.values()], + get: (id: string): ProviderDefinition | undefined => _entries.get(id), + has: (id: string): boolean => _entries.has(id), +} as const; + +export function toProviderMeta(def: ProviderDefinition): ModelProviderMeta { + return { + id: def.id, + displayName: def.displayName, + description: def.description, + defaultModel: def.defaultModel, + defaultEmbeddingModel: def.defaultEmbeddingModel || "", + }; +} diff --git a/packages/llm/src/row-mapper.ts b/packages/llm/src/row-mapper.ts new file mode 100644 index 0000000..89584b4 --- /dev/null +++ b/packages/llm/src/row-mapper.ts @@ -0,0 +1,56 @@ +import type { ModelProviderInstance } from "./llm.js"; +import type { ProviderDefinition } from "./registry.js"; + +export type DbRow = { + id: string; + name: string; + providerName: string; + apiKey: string; + isActive: number; + modelName?: string; + type: string; + maxContext?: number; + endpointUrl?: string; +}; + +export function mapRow(r: DbRow): ModelProviderInstance { + return { + id: r.id, + name: r.name, + providerName: r.providerName, + apiKey: r.apiKey, + isActive: r.isActive === 1, + modelName: r.modelName || undefined, + type: (r.type as "generative" | "embedding") || "generative", + maxContext: + r.maxContext !== undefined && r.maxContext !== null + ? r.maxContext + : r.type === "embedding" + ? 0 + : 32768, + endpointUrl: r.endpointUrl || undefined, + }; +} + +export function synthInstance( + def: ProviderDefinition, + type: "generative" | "embedding", + apiKey: string, +): ModelProviderInstance { + const modelName = + type === "embedding" ? def.defaultEmbeddingModel : def.defaultModel; + return { + id: `provider-default-env-fallback-${def.id}-${type}`, + name: + type === "embedding" + ? `${def.displayName} Embed (Env Fallback)` + : `${def.displayName} (Env Fallback)`, + providerName: def.id, + apiKey, + isActive: true, + modelName, + type, + maxContext: type === "embedding" ? 0 : def.defaultMaxContext, + endpointUrl: undefined, + }; +} diff --git a/packages/llm/tests/deepseek.test.ts b/packages/llm/tests/deepseek.test.ts index 4a4d06e..2c6ccc9 100644 --- a/packages/llm/tests/deepseek.test.ts +++ b/packages/llm/tests/deepseek.test.ts @@ -1,7 +1,34 @@ import { describe, test, expect, vi } from "vitest"; import { z } from "zod"; + +const mockConfig: Record = {}; + +vi.mock("../src/config.js", () => ({ + getLlmConfig: () => mockConfig, + resetLlmConfig: () => { + for (const key of Object.keys(mockConfig)) { + delete mockConfig[key]; + } + }, +})); + +const { getActiveMock } = vi.hoisted(() => ({ + getActiveMock: vi.fn().mockReturnValue(null), +})); + +vi.mock("../src/provider-manager.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ProviderManager: { + ...actual.ProviderManager, + getActive: getActiveMock, + }, + }; +}); + import { DeepSeekProvider } from "../src/providers/deepseek.js"; -import { llmConfig } from "../src/config.js"; // Mock the ChatDeepSeek class vi.mock("@langchain/deepseek", () => { @@ -41,27 +68,30 @@ describe("DeepSeekProvider Unit Tests (Tier 1)", () => { }); test("initializes successfully with apiKey from config", () => { - const originalKey = llmConfig.DEEPSEEK_API_KEY; - llmConfig.DEEPSEEK_API_KEY = "env-dummy-key"; + const originalKey = process.env.DEEPSEEK_API_KEY; + process.env.DEEPSEEK_API_KEY = "env-dummy-key"; + mockConfig.DEEPSEEK_API_KEY = "env-dummy-key"; try { const provider = new DeepSeekProvider(); expect(provider.providerName).toBe("DeepSeek"); } finally { - llmConfig.DEEPSEEK_API_KEY = originalKey; + process.env.DEEPSEEK_API_KEY = originalKey; + delete mockConfig.DEEPSEEK_API_KEY; } }); test("throws error if no API key is provided or in config", () => { - const originalKey = llmConfig.DEEPSEEK_API_KEY; - llmConfig.DEEPSEEK_API_KEY = undefined; + const originalKey = process.env.DEEPSEEK_API_KEY; + process.env.DEEPSEEK_API_KEY = undefined; + mockConfig.DEEPSEEK_API_KEY = undefined; try { expect(() => new DeepSeekProvider()).toThrow( "DEEPSEEK_API_KEY is required to initialize DeepSeekProvider", ); } finally { - llmConfig.DEEPSEEK_API_KEY = originalKey; + process.env.DEEPSEEK_API_KEY = originalKey; } }); diff --git a/packages/llm/tests/groq.test.ts b/packages/llm/tests/groq.test.ts index 0587a3c..f1d2ed0 100644 --- a/packages/llm/tests/groq.test.ts +++ b/packages/llm/tests/groq.test.ts @@ -1,7 +1,34 @@ import { describe, test, expect, vi } from "vitest"; import { z } from "zod"; + +const mockConfig: Record = {}; + +vi.mock("../src/config.js", () => ({ + getLlmConfig: () => mockConfig, + resetLlmConfig: () => { + for (const key of Object.keys(mockConfig)) { + delete mockConfig[key]; + } + }, +})); + +const { getActiveMock } = vi.hoisted(() => ({ + getActiveMock: vi.fn().mockReturnValue(null), +})); + +vi.mock("../src/provider-manager.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ProviderManager: { + ...actual.ProviderManager, + getActive: getActiveMock, + }, + }; +}); + import { GroqProvider } from "../src/providers/groq.js"; -import { llmConfig } from "../src/config.js"; // Mock the ChatGroq class vi.mock("@langchain/groq", () => { @@ -41,27 +68,30 @@ describe("GroqProvider Unit Tests (Tier 1)", () => { }); test("initializes successfully with apiKey from config", () => { - const originalKey = llmConfig.GROQ_API_KEY; - llmConfig.GROQ_API_KEY = "env-dummy-key"; + const originalKey = process.env.GROQ_API_KEY; + process.env.GROQ_API_KEY = "env-dummy-key"; + mockConfig.GROQ_API_KEY = "env-dummy-key"; try { const provider = new GroqProvider(); expect(provider.providerName).toBe("Groq"); } finally { - llmConfig.GROQ_API_KEY = originalKey; + process.env.GROQ_API_KEY = originalKey; + delete mockConfig.GROQ_API_KEY; } }); test("throws error if no API key is provided or in config", () => { - const originalKey = llmConfig.GROQ_API_KEY; - llmConfig.GROQ_API_KEY = undefined; + const originalKey = process.env.GROQ_API_KEY; + process.env.GROQ_API_KEY = undefined; + mockConfig.GROQ_API_KEY = undefined; try { expect(() => new GroqProvider()).toThrow( "GROQ_API_KEY is required to initialize GroqProvider", ); } finally { - llmConfig.GROQ_API_KEY = originalKey; + process.env.GROQ_API_KEY = originalKey; } }); diff --git a/packages/llm/tests/openai.test.ts b/packages/llm/tests/openai.test.ts index efaaca8..b09f2a7 100644 --- a/packages/llm/tests/openai.test.ts +++ b/packages/llm/tests/openai.test.ts @@ -1,10 +1,37 @@ import { describe, test, expect, vi } from "vitest"; import { z } from "zod"; + +const mockConfig: Record = {}; + +vi.mock("../src/config.js", () => ({ + getLlmConfig: () => mockConfig, + resetLlmConfig: () => { + for (const key of Object.keys(mockConfig)) { + delete mockConfig[key]; + } + }, +})); + +const { getActiveMock } = vi.hoisted(() => ({ + getActiveMock: vi.fn().mockReturnValue(null), +})); + +vi.mock("../src/provider-manager.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ProviderManager: { + ...actual.ProviderManager, + getActive: getActiveMock, + }, + }; +}); + import { OpenAIProvider, OpenAIEmbeddingProvider, } from "../src/providers/openai.js"; -import { llmConfig } from "../src/config.js"; // Mock the ChatOpenAI and OpenAIEmbeddings classes vi.mock("@langchain/openai", () => { @@ -53,27 +80,30 @@ describe("OpenAIProvider Unit Tests (Tier 1)", () => { }); test("initializes successfully with apiKey from config", () => { - const originalKey = llmConfig.OPENAI_API_KEY; - llmConfig.OPENAI_API_KEY = "env-dummy-key"; + const originalKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "env-dummy-key"; + mockConfig.OPENAI_API_KEY = "env-dummy-key"; try { const provider = new OpenAIProvider(); expect(provider.providerName).toBe("OpenAI"); } finally { - llmConfig.OPENAI_API_KEY = originalKey; + process.env.OPENAI_API_KEY = originalKey; + delete mockConfig.OPENAI_API_KEY; } }); test("throws error if no API key is provided or in config", () => { - const originalKey = llmConfig.OPENAI_API_KEY; - llmConfig.OPENAI_API_KEY = undefined; + const originalKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = undefined; + mockConfig.OPENAI_API_KEY = undefined; try { expect(() => new OpenAIProvider()).toThrow( "OPENAI_API_KEY is required to initialize OpenAIProvider", ); } finally { - llmConfig.OPENAI_API_KEY = originalKey; + process.env.OPENAI_API_KEY = originalKey; } }); @@ -116,14 +146,16 @@ describe("OpenAIEmbeddingProvider Unit Tests (Tier 1)", () => { }); test("initializes successfully with apiKey from config", () => { - const originalKey = llmConfig.OPENAI_API_KEY; - llmConfig.OPENAI_API_KEY = "env-dummy-key"; + const originalKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "env-dummy-key"; + mockConfig.OPENAI_API_KEY = "env-dummy-key"; try { const provider = new OpenAIEmbeddingProvider(); expect(provider.providerName).toBe("OpenAI"); } finally { - llmConfig.OPENAI_API_KEY = originalKey; + process.env.OPENAI_API_KEY = originalKey; + delete mockConfig.OPENAI_API_KEY; } }); diff --git a/packages/llm/tests/openrouter.test.ts b/packages/llm/tests/openrouter.test.ts index 7a5ebf5..7f0fee8 100644 --- a/packages/llm/tests/openrouter.test.ts +++ b/packages/llm/tests/openrouter.test.ts @@ -1,7 +1,34 @@ import { describe, test, expect, vi } from "vitest"; import { z } from "zod"; + +const mockConfig: Record = {}; + +vi.mock("../src/config.js", () => ({ + getLlmConfig: () => mockConfig, + resetLlmConfig: () => { + for (const key of Object.keys(mockConfig)) { + delete mockConfig[key]; + } + }, +})); + +const { getActiveMock } = vi.hoisted(() => ({ + getActiveMock: vi.fn().mockReturnValue(null), +})); + +vi.mock("../src/provider-manager.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ProviderManager: { + ...actual.ProviderManager, + getActive: getActiveMock, + }, + }; +}); + import { OpenRouterProvider } from "../src/providers/openrouter.js"; -import { llmConfig } from "../src/config.js"; // Mock the ChatOpenRouter class vi.mock("@langchain/openrouter", () => { @@ -14,7 +41,6 @@ vi.mock("@langchain/openrouter", () => { withStructuredOutput = vi.fn().mockImplementation(() => { return { invoke: vi.fn().mockImplementation(async () => { - // Return a mock output that matches the includeRaw: true structure return { parsed: { name: "mocked response", @@ -42,29 +68,30 @@ describe("OpenRouterProvider Unit Tests (Tier 1)", () => { }); test("initializes successfully with apiKey from config", () => { - // Save current config - const originalKey = llmConfig.OPENROUTER_API_KEY; - llmConfig.OPENROUTER_API_KEY = "env-dummy-key"; + const originalKey = process.env.OPENROUTER_API_KEY; + process.env.OPENROUTER_API_KEY = "env-dummy-key"; + mockConfig.OPENROUTER_API_KEY = "env-dummy-key"; try { const provider = new OpenRouterProvider(); expect(provider.providerName).toBe("OpenRouter"); } finally { - llmConfig.OPENROUTER_API_KEY = originalKey; + process.env.OPENROUTER_API_KEY = originalKey; + delete mockConfig.OPENROUTER_API_KEY; } }); test("throws error if no API key is provided or in config", () => { - // Save current config - const originalKey = llmConfig.OPENROUTER_API_KEY; - llmConfig.OPENROUTER_API_KEY = undefined; + const originalKey = process.env.OPENROUTER_API_KEY; + process.env.OPENROUTER_API_KEY = undefined; + mockConfig.OPENROUTER_API_KEY = undefined; try { expect(() => new OpenRouterProvider()).toThrow( "OPENROUTER_API_KEY is required to initialize OpenRouterProvider", ); } finally { - llmConfig.OPENROUTER_API_KEY = originalKey; + process.env.OPENROUTER_API_KEY = originalKey; } }); diff --git a/packages/llm/tests/provider-manager.test.ts b/packages/llm/tests/provider-manager.test.ts index 98dad37..4223f0c 100644 --- a/packages/llm/tests/provider-manager.test.ts +++ b/packages/llm/tests/provider-manager.test.ts @@ -1,24 +1,27 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import fs from "fs"; import path from "path"; -import { - ProviderManager, - setDbPathOverride, - resetHasBootstrapped, -} from "../src/index.js"; +import { ProviderManager, setDbPathOverride } from "../src/index.js"; describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => { let tempDbPath: string; - let originalGoogle: string | undefined; - let originalOpenRouter: string | undefined; + let savedEnv: Record; beforeEach(() => { - originalGoogle = process.env.GOOGLE_API_KEY; - originalOpenRouter = process.env.OPENROUTER_API_KEY; + savedEnv = { + GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + GROQ_API_KEY: process.env.GROQ_API_KEY, + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, + }; delete process.env.GOOGLE_API_KEY; delete process.env.OPENROUTER_API_KEY; - - resetHasBootstrapped(); + delete process.env.ANTHROPIC_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.GROQ_API_KEY; + delete process.env.DEEPSEEK_API_KEY; // Generate a unique temp database path for this test run tempDbPath = path.resolve( @@ -37,47 +40,223 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => { // ignore } } - if (originalGoogle !== undefined) { - process.env.GOOGLE_API_KEY = originalGoogle; - } else { - delete process.env.GOOGLE_API_KEY; - } - if (originalOpenRouter !== undefined) { - process.env.OPENROUTER_API_KEY = originalOpenRouter; - } else { - delete process.env.OPENROUTER_API_KEY; + for (const [key, value] of Object.entries(savedEnv)) { + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } } }); - test("auto-bootstraps Gemini and OpenRouter when database is empty and environment variables are present", () => { - process.env.GOOGLE_API_KEY = "mock-google-key-123"; - process.env.OPENROUTER_API_KEY = "mock-openrouter-key-456"; + test("auto-bootstraps all environment-variable providers when database is empty", () => { + process.env.GOOGLE_API_KEY = "mock-google-key"; + process.env.OPENROUTER_API_KEY = "mock-openrouter-key"; + process.env.ANTHROPIC_API_KEY = "mock-anthropic-key"; + process.env.OPENAI_API_KEY = "mock-openai-key"; + process.env.GROQ_API_KEY = "mock-groq-key"; + process.env.DEEPSEEK_API_KEY = "mock-deepseek-key"; const list = ProviderManager.list(); - expect(list.length).toBe(3); - const gemini = list.find((p) => p.providerName === "google-genai"); - expect(gemini).toBeDefined(); - expect(gemini?.name).toBe("Gemini (Env)"); - expect(gemini?.apiKey).toBe("mock-google-key-123"); - expect(gemini?.modelName).toBe("gemini-2.5-flash"); - expect(gemini?.isActive).toBe(true); // first inserted is active + const providers = list.map((p) => p.providerName); + expect(providers).toContain("google-genai"); + expect(providers).toContain("openrouter"); + expect(providers).toContain("anthropic"); + expect(providers).toContain("openai"); + expect(providers).toContain("groq"); + expect(providers).toContain("deepseek"); - const openrouter = list.find((p) => p.providerName === "openrouter"); - expect(openrouter).toBeDefined(); - expect(openrouter?.name).toBe("OpenRouter (Env)"); - expect(openrouter?.apiKey).toBe("mock-openrouter-key-456"); - expect(openrouter?.modelName).toBe("google/gemini-2.5-flash"); - expect(openrouter?.isActive).toBe(false); // second inserted is inactive + // Gemini should have both generative + embedding (2 entries) + const geminiEntries = list.filter((p) => p.providerName === "google-genai"); + expect(geminiEntries.length).toBe(2); + expect(geminiEntries.some((p) => p.type === "generative")).toBe(true); + expect(geminiEntries.some((p) => p.type === "embedding")).toBe(true); + + // OpenAI should have both generative + embedding (2 entries) + const openaiEntries = list.filter((p) => p.providerName === "openai"); + expect(openaiEntries.length).toBe(2); + expect(openaiEntries.some((p) => p.type === "generative")).toBe(true); + expect(openaiEntries.some((p) => p.type === "embedding")).toBe(true); + + // First generative provider inserted should be active + const activeGenerative = list.filter( + (p) => p.type === "generative" && p.isActive, + ); + expect(activeGenerative.length).toBe(1); + + // First embedding provider inserted should be active + const activeEmbedding = list.filter( + (p) => p.type === "embedding" && p.isActive, + ); + expect(activeEmbedding.length).toBe(1); }); + test("getActive returns null when no providers exist and no env vars", () => { + const active = ProviderManager.getActive("generative"); + expect(active).toBeNull(); + const activeEmbed = ProviderManager.getActive("embedding"); + expect(activeEmbed).toBeNull(); + }); + + test("getActive falls back to env var when DB operations fail or return null", () => { + process.env.GOOGLE_API_KEY = "mock-google-key-123"; + // DB is empty, getActive should bootstrap and find or create from env + const active = ProviderManager.getActive("generative"); + expect(active).not.toBeNull(); + expect(active?.providerName).toBe("google-genai"); + expect(active?.apiKey).toBe("mock-google-key-123"); + expect(active?.type).toBe("generative"); + + const activeEmbed = ProviderManager.getActive("embedding"); + expect(activeEmbed).not.toBeNull(); + expect(activeEmbed?.providerName).toBe("google-genai"); + expect(activeEmbed?.type).toBe("embedding"); + }); + + test("getActive returns first instance of type when none is active", () => { + // Manually create instances without any env var bootstrap + const inst1 = ProviderManager.create("Test Gemini", "google-genai", "key1"); + const inst2 = ProviderManager.create( + "Test OpenAI", + "openai", + "key2", + "gpt-4o", + "generative", + 128000, + ); + expect(inst1.isActive).toBe(true); // first created auto-activates + expect(inst2.isActive).toBe(false); + + // Deactivate both + ProviderManager.setActive("__nonexistent__"); // no-op for nonexistent + + // Deactivate inst1 by setting another as active, then delete that + ProviderManager.setActive(inst2.id); + expect( + ProviderManager.list().find((p) => p.id === inst2.id)?.isActive, + ).toBe(true); + expect( + ProviderManager.list().find((p) => p.id === inst1.id)?.isActive, + ).toBe(false); + + // Delete the active one → auto-promotes inst1 + ProviderManager.delete(inst2.id); + const promoted = ProviderManager.list().find((p) => p.id === inst1.id); + expect(promoted?.isActive).toBe(true); + }); + + test("setActive correctly deactivates siblings and activates target", () => { + const inst1 = ProviderManager.create( + "First Gemini", + "google-genai", + "key1", + undefined, + "generative", + ); + const inst2 = ProviderManager.create( + "Second Gemini", + "google-genai", + "key2", + undefined, + "generative", + ); + + expect(inst1.isActive).toBe(true); + expect(inst2.isActive).toBe(false); + + ProviderManager.setActive(inst2.id); + + const list = ProviderManager.list(); + const updated1 = list.find((p) => p.id === inst1.id); + const updated2 = list.find((p) => p.id === inst2.id); + expect(updated1?.isActive).toBe(false); + expect(updated2?.isActive).toBe(true); + }); + + test("getMappings returns empty object initially, setMapping persists mappings", () => { + const mappings = ProviderManager.getMappings(); + expect(mappings).toEqual({}); + + const inst = ProviderManager.create( + "Test Provider", + "google-genai", + "key1", + ); + + ProviderManager.setMapping("actor-prose", inst.id); + ProviderManager.setMapping("embeddings", inst.id); + + const updated = ProviderManager.getMappings(); + expect(updated["actor-prose"]).toBe(inst.id); + expect(updated["embeddings"]).toBe(inst.id); + }); + + test("setMapping with empty providerInstanceId deletes the mapping", () => { + const inst = ProviderManager.create( + "Test Provider", + "google-genai", + "key1", + ); + + ProviderManager.setMapping("test-task", inst.id); + expect(ProviderManager.getMappings()["test-task"]).toBe(inst.id); + + ProviderManager.setMapping("test-task", ""); + expect(ProviderManager.getMappings()["test-task"]).toBeUndefined(); + }); + + test("create returns instance with correct fields and endpointUrl support", () => { + const inst = ProviderManager.create( + "Ollama Local", + "ollama", + "", + "llama3.1", + "generative", + 32768, + "http://localhost:11434", + ); + + expect(inst.id).toMatch(/^provider-/); + expect(inst.name).toBe("Ollama Local"); + expect(inst.providerName).toBe("ollama"); + expect(inst.modelName).toBe("llama3.1"); + expect(inst.endpointUrl).toBe("http://localhost:11434"); + }); + + test("update preserves apiKey when not provided", () => { + const inst = ProviderManager.create( + "Original", + "openai", + "original-key", + "gpt-4o", + "generative", + 128000, + ); + + ProviderManager.update( + inst.id, + "Renamed", + "openai", + undefined, // no apiKey → preserve existing + "gpt-4o-mini", + "generative", + 64000, + ); + + const updated = ProviderManager.list().find((p) => p.id === inst.id); + expect(updated?.name).toBe("Renamed"); + expect(updated?.apiKey).toBe("original-key"); // preserved + expect(updated?.modelName).toBe("gpt-4o-mini"); + expect(updated?.maxContext).toBe(64000); + }); test("treats bootstrapped instances as normal provider instances (editable and deletable)", () => { process.env.GOOGLE_API_KEY = "mock-google-key-123"; // Trigger bootstrap const list = ProviderManager.list(); expect(list.length).toBe(2); - const bootstrapped = list.find((p) => p.name === "Gemini (Env)"); + const bootstrapped = list.find((p) => p.name === "Google Gemini (Env)"); expect(bootstrapped).toBeDefined(); if (!bootstrapped) return; expect(bootstrapped.isActive).toBe(true);