diff --git a/package.json b/package.json index 62750c1..15ec7a7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "watch": "tsc -b --watch", "test": "vitest run --project unit", "test:watch": "vitest --project unit", - "test:evals": "vitest run --project evals" + "test:evals": "vitest run --project evals", + "setup-provider": "node packages/llm/dist/bin/setup-provider.js" }, "keywords": [], "author": "sortedcord", diff --git a/packages/llm/README.md b/packages/llm/README.md index 1875c74..a333ea4 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -141,8 +141,8 @@ Provider metadata is **self-declared** by each provider class in a `static {}` b `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) +- [`bin/setup-provider.ts`](src/bin/setup-provider.ts) — CLI tool to set up provider instances in the database +- [`row-mapper.ts`](src/row-mapper.ts) — `mapRow()` (written once, used everywhere) - [`provider-manager.ts`](src/provider-manager.ts) — thin CRUD: `list`, `create`, `delete`, `setActive`, `update`, `getActive`, `getMappings`, `setMapping` ### Storage @@ -190,47 +190,31 @@ CREATE TABLE IF NOT EXISTS provider_mappings ( - **Auto-promotion on delete** — if the deleted instance was active, the first remaining instance of the same type is promoted. - **Auto-activation on create** — if no active instance exists for the type, the new instance is automatically activated. -### Environment Variable Bootstrap +### Manual Seeding via CLI -On first database access (and if the `provider_instances` table is empty), the manager auto-seeds instances from environment variables: +Rather than automatically bootstrapping from environment variables at runtime, which adds runtime complexity, you can quickly seed the database using the CLI setup tool: -```mermaid -flowchart TD - A["getSettingsDb() called"] --> B{"DB has 0 rows?"} - B -- No --> Z["Return DB"] - B -- Yes --> C{"GOOGLE_API_KEY set?"} - C -- Yes --> D["Insert 'Gemini (Env)'\ntype: generative, active: true"] - D --> E["Insert 'Gemini Embed (Env)'\ntype: embedding, active: true"] - E --> F{"OPENROUTER_API_KEY set?"} - C -- No --> F - F -- Yes --> G["Insert 'OpenRouter (Env)'\ntype: generative\nactive: only if no Google key"] - F -- No --> Z - G --> Z +#### Seeding All Environment-Variable Providers + +```bash +pnpm setup-provider --all ``` -This same bootstrap logic is **duplicated** inside `getActive()` as a safety net — if the DB is empty at query time, it re-attempts the same env-var seeding. +This command auto-detects and inserts provider instances into `data/settings.db` for any registered providers whose corresponding environment variables (such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, etc.) are defined. -### Fallback Chain in `getActive()` - -When no active row is found for the requested type: +#### Creating a Specific Provider Instance +```bash +pnpm setup-provider --provider google-genai --key YOUR_API_KEY [--name "My Gemini"] [--model gemini-2.5-flash] [--type generative] [--max-context 32768] [--endpoint url] ``` -1. DB query for isActive=1 AND type= - ├── Found → return it - └── Not found - ├── DB is empty → bootstrap from env vars → retry query - │ ├── Found → return it - │ └── Still empty → promote first row of same type - │ ├── Found → activate & return - │ └── None → return null - └── DB has rows but none active for this type - → promote first row of same type (same as above) -2. On any DB error (catch block) → direct env var fallback - ├── GOOGLE_API_KEY → synthetic "Gemini (Env Fallback)" instance - ├── OPENROUTER_API_KEY → synthetic "OpenRouter (Env Fallback)" instance - └── Neither → return null -``` +### Credential Resolution Cascade + +When initializing a provider (e.g. `new GeminiProvider()`): + +1. **Explicit Credentials** — If `apiKey`, `modelName`, etc. are passed directly to the constructor, they are used. +2. **Active DB Instance** — If not explicitly passed, it looks up the active DB instance via `ProviderManager.getActive()`. If found and matches the provider, its API key and configuration are used. +3. **Environment Fallback** — If there is no matching active DB instance, it resolves the key directly from the corresponding environment variable (e.g., `GOOGLE_API_KEY`) via `resolveCredentials`. ## Available Providers @@ -526,8 +510,9 @@ packages/llm/ │ ├── 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() +│ ├── row-mapper.ts # mapRow() +│ ├── bin/ +│ │ └── setup-provider.ts # CLI tool to set up provider instances in the database │ └── providers/ │ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider (self-registering) │ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider @@ -541,6 +526,7 @@ packages/llm/ │ ├── mock.test.ts │ ├── openrouter.test.ts │ ├── model-lister.test.ts # ModelLister cache and fetch logic unit tests -│ └── provider-manager.test.ts +│ ├── provider-manager.test.ts +│ └── cli.test.ts # Integration tests for setup-provider CLI tool └── package.json ``` diff --git a/packages/llm/package.json b/packages/llm/package.json index 90fe3a3..63656ce 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -6,6 +6,9 @@ "exports": { ".": "./dist/index.js" }, + "scripts": { + "setup-provider": "node ./dist/bin/setup-provider.js" + }, "dependencies": { "@types/node": "^26.1.0", "better-sqlite3": "^12.11.1" diff --git a/packages/llm/src/bin/setup-provider.ts b/packages/llm/src/bin/setup-provider.ts new file mode 100644 index 0000000..e329231 --- /dev/null +++ b/packages/llm/src/bin/setup-provider.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env node +import { ProviderRegistry, ProviderManager } from "../index.js"; +import dotenv from "dotenv"; +import path from "path"; +import fs from "fs"; + +// Load dotenv from workspace root +function loadEnv() { + let current = process.cwd(); + while (current !== "/" && current !== path.parse(current).root) { + if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) { + dotenv.config({ path: path.join(current, ".env") }); + return; + } + current = path.dirname(current); + } + dotenv.config(); +} + +loadEnv(); + +function printHelp() { + console.log(` +Usage: + node packages/llm/dist/bin/setup-provider.js [options] + +Options: + --provider ID of the provider (e.g. google-genai, openai, anthropic, groq, etc.) + --name Display name for the instance (default: provider displayName) + --key API key (default: loaded from the provider's env variable, e.g. GOOGLE_API_KEY) + --model Model name (default: provider's default model) + --type "generative" | "embedding" (default: "generative") + --max-context Max context window tokens (default: provider's default max context) + --endpoint Custom endpoint URL (optional) + --all Auto-detect and seed all providers whose environment variables are set + -h, --help Show this help message + +Registered Providers: +${ProviderRegistry.all() + .map((p) => ` - ${p.id} (${p.displayName}) [Env: ${p.envVar || "None"}]`) + .join("\n")} +`); +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes("-h") || args.includes("--help") || args.length === 0) { + printHelp(); + process.exit(0); + } + + const options: Record = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith("--")) { + const key = arg.slice(2); + const nextVal = args[i + 1]; + if (nextVal && !nextVal.startsWith("--")) { + options[key] = nextVal; + i++; + } else { + options[key] = "true"; + } + } + } + + if (options.all === "true") { + // Seed all providers from environment variables + const existing = ProviderManager.list(); + let seededCount = 0; + + for (const def of ProviderRegistry.all()) { + if (!def.envVar) continue; + const key = process.env[def.envVar]?.trim(); + if (!key) continue; + + if (def.capabilities.generative) { + const hasGen = existing.some( + (p) => p.providerName === def.id && p.type === "generative", + ); + if (!hasGen) { + const name = `${def.displayName} (CLI)`; + ProviderManager.create( + name, + def.id, + key, + def.defaultModel, + "generative", + def.defaultMaxContext, + ); + console.log(`Created generative instance: ${name}`); + seededCount++; + } + } + + if (def.capabilities.embedding) { + const hasEmbed = existing.some( + (p) => p.providerName === def.id && p.type === "embedding", + ); + if (!hasEmbed) { + const name = `${def.displayName} Embed (CLI)`; + ProviderManager.create( + name, + def.id, + key, + def.defaultEmbeddingModel || "", + "embedding", + 0, + ); + console.log(`Created embedding instance: ${name}`); + seededCount++; + } + } + } + + if (seededCount === 0) { + console.log( + "No new provider instances seeded. (Either already existed or env vars not set)", + ); + } else { + console.log(`Successfully seeded ${seededCount} provider instance(s).`); + } + process.exit(0); + } + + const providerId = options.provider; + if (!providerId) { + console.error("Error: --provider or --all is required."); + printHelp(); + process.exit(1); + } + + const def = ProviderRegistry.get(providerId); + if (!def) { + console.error(`Error: Provider '${providerId}' is not registered.`); + console.error( + `Available providers: ${ProviderRegistry.all() + .map((p) => p.id) + .join(", ")}`, + ); + process.exit(1); + } + + const type = (options.type === "embedding" ? "embedding" : "generative") as + "generative" | "embedding"; + + // Resolve key + let apiKey: string | undefined = options.key; + if (!apiKey && def.envVar) { + apiKey = process.env[def.envVar]?.trim(); + } + if (!apiKey) { + console.error( + `Error: API Key is required. Please set ${def.envVar || "the environment variable"} or pass --key .`, + ); + process.exit(1); + } + + // Resolve model + const defaultModel = + type === "embedding" ? def.defaultEmbeddingModel || "" : def.defaultModel; + const modelName = options.model || defaultModel; + + // Resolve name + const name = options.name || `${def.displayName} (CLI)`; + + // Resolve maxContext + const maxContext = options["max-context"] + ? parseInt(options["max-context"], 10) + : type === "embedding" + ? 0 + : def.defaultMaxContext; + + const endpointUrl = options.endpoint; + + const instance = ProviderManager.create( + name, + def.id, + apiKey, + modelName, + type, + maxContext, + endpointUrl, + ); + + console.log(`Successfully created provider instance:`); + console.log(JSON.stringify(instance, null, 2)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/llm/src/bootstrap.ts b/packages/llm/src/bootstrap.ts deleted file mode 100644 index ea03dc8..0000000 --- a/packages/llm/src/bootstrap.ts +++ /dev/null @@ -1,98 +0,0 @@ -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/db.ts b/packages/llm/src/db.ts index 2fef5ae..ca97fae 100644 --- a/packages/llm/src/db.ts +++ b/packages/llm/src/db.ts @@ -15,6 +15,13 @@ export function setDbPath(p: string | null) { } function findDbPath(): string { + if (process.env.OMNIA_DB_PATH) { + const dir = path.dirname(process.env.OMNIA_DB_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return process.env.OMNIA_DB_PATH; + } let current = process.cwd(); while (current !== "/" && current !== path.parse(current).root) { if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) { diff --git a/packages/llm/src/provider-manager.ts b/packages/llm/src/provider-manager.ts index f8d046c..813c3ba 100644 --- a/packages/llm/src/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -1,15 +1,12 @@ 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"; +import { mapRow, type DbRow } from "./row-mapper.js"; export { setDbPath as setDbPathOverride } from "./db.js"; export class ProviderManager { static list(): ModelProviderInstance[] { const db = getDb(); - seedFromEnvVars(db); const rows = db .prepare("SELECT * FROM provider_instances") .all() as DbRow[]; @@ -150,8 +147,6 @@ export class ProviderManager { ): ModelProviderInstance | null { const db = getDb(); try { - seedFromEnvVars(db); - const row = db .prepare( "SELECT * FROM provider_instances WHERE isActive = 1 AND type = ?", @@ -175,7 +170,7 @@ export class ProviderManager { return null; } catch { - return envFallback(type); + return null; } } @@ -205,21 +200,3 @@ export class ProviderManager { } } } - -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/row-mapper.ts b/packages/llm/src/row-mapper.ts index 89584b4..f2d8bfd 100644 --- a/packages/llm/src/row-mapper.ts +++ b/packages/llm/src/row-mapper.ts @@ -1,5 +1,4 @@ import type { ModelProviderInstance } from "./llm.js"; -import type { ProviderDefinition } from "./registry.js"; export type DbRow = { id: string; @@ -31,26 +30,3 @@ export function mapRow(r: DbRow): ModelProviderInstance { 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/cli.test.ts b/packages/llm/tests/cli.test.ts new file mode 100644 index 0000000..f3c5acd --- /dev/null +++ b/packages/llm/tests/cli.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import Database from "better-sqlite3"; + +describe("setup-provider CLI Tool Tests", () => { + let tempDbPath: string; + let scriptPath: string; + + beforeEach(() => { + // Generate a unique temp database path + tempDbPath = path.resolve( + process.cwd(), + `test-cli-${Date.now()}-${Math.random().toString(36).substring(2)}.db`, + ); + scriptPath = path.resolve( + process.cwd(), + "packages/llm/dist/bin/setup-provider.js", + ); + }); + + afterEach(() => { + if (fs.existsSync(tempDbPath)) { + try { + fs.unlinkSync(tempDbPath); + } catch { + // ignore + } + } + }); + + test("prints help message when --help or -h is passed", () => { + const stdout = execSync(`node ${scriptPath} --help`).toString(); + expect(stdout).toContain("Usage:"); + expect(stdout).toContain("Options:"); + expect(stdout).toContain("Registered Providers:"); + }); + + test("creates a provider instance successfully via CLI flags", () => { + const cmd = `node ${scriptPath} --provider google-genai --key mock-key-abc --name "Test Gemini" --model "gemini-2.5-flash"`; + const stdout = execSync(cmd, { + env: { ...process.env, OMNIA_DB_PATH: tempDbPath }, + }).toString(); + + expect(stdout).toContain("Successfully created provider instance:"); + expect(stdout).toContain("Test Gemini"); + expect(stdout).toContain("google-genai"); + expect(stdout).toContain("mock-key-abc"); + + // Read the SQLite db directly to verify + const db = new Database(tempDbPath); + const rows = db.prepare("SELECT * FROM provider_instances").all() as { + name: string; + providerName: string; + apiKey: string; + modelName: string; + isActive: number; + }[]; + expect(rows.length).toBe(1); + expect(rows[0].name).toBe("Test Gemini"); + expect(rows[0].providerName).toBe("google-genai"); + expect(rows[0].apiKey).toBe("mock-key-abc"); + expect(rows[0].modelName).toBe("gemini-2.5-flash"); + expect(rows[0].isActive).toBe(1); + db.close(); + }); + + test("fails when required key is missing and env var is not set", () => { + let error: { status?: number; stderr?: Buffer } | undefined; + try { + execSync(`node ${scriptPath} --provider google-genai`, { + env: { ...process.env, OMNIA_DB_PATH: tempDbPath, GOOGLE_API_KEY: "" }, + stdio: "pipe", + }); + } catch (e) { + error = e as { status?: number; stderr?: Buffer }; + } + expect(error).toBeDefined(); + expect(error?.status).toBe(1); + expect(error?.stderr?.toString()).toContain("Error: API Key is required"); + }); + + test("seeds from environment variables when using --all", () => { + const cmd = `node ${scriptPath} --all`; + const stdout = execSync(cmd, { + env: { + ...process.env, + OMNIA_DB_PATH: tempDbPath, + GOOGLE_API_KEY: "mock-google-key-all", + OPENAI_API_KEY: "", + ANTHROPIC_API_KEY: "", + GROQ_API_KEY: "", + DEEPSEEK_API_KEY: "", + OPENROUTER_API_KEY: "", + }, + }).toString(); + + expect(stdout).toContain( + "Created generative instance: Google Gemini (CLI)", + ); + expect(stdout).toContain( + "Created embedding instance: Google Gemini Embed (CLI)", + ); + + const db = new Database(tempDbPath); + const rows = db.prepare("SELECT * FROM provider_instances").all() as { + type: string; + apiKey: string; + modelName: string; + }[]; + // Should have both generative and embedding instances + expect(rows.length).toBe(2); + const gen = rows.find((r) => r.type === "generative"); + const embed = rows.find((r) => r.type === "embedding"); + + expect(gen).toBeDefined(); + expect(gen?.apiKey).toBe("mock-google-key-all"); + expect(gen?.modelName).toBe("gemini-2.5-flash"); + + expect(embed).toBeDefined(); + expect(embed?.apiKey).toBe("mock-google-key-all"); + expect(embed?.modelName).toBe("gemini-embedding-001"); + db.close(); + }); +}); diff --git a/packages/llm/tests/provider-manager.test.ts b/packages/llm/tests/provider-manager.test.ts index 4223f0c..bec29c0 100644 --- a/packages/llm/tests/provider-manager.test.ts +++ b/packages/llm/tests/provider-manager.test.ts @@ -49,47 +49,10 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => { } }); - test("auto-bootstraps all environment-variable providers when database is empty", () => { + test("returns empty list when database is empty and no auto-bootstraps", () => { 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(); - - 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"); - - // 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); + expect(list.length).toBe(0); }); test("getActive returns null when no providers exist and no env vars", () => { @@ -99,19 +62,10 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => { expect(activeEmbed).toBeNull(); }); - test("getActive falls back to env var when DB operations fail or return null", () => { + test("getActive returns null when DB is empty", () => { 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"); + expect(active).toBeNull(); }); test("getActive returns first instance of type when none is active", () => { @@ -250,20 +204,25 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => { 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"; + test("treats created instances as normal provider instances (editable and deletable)", () => { + const inst = ProviderManager.create( + "Google Gemini (Env)", + "google-genai", + "mock-google-key-123", + "gemini-2.5-flash", + "generative", + ); - // Trigger bootstrap const list = ProviderManager.list(); - expect(list.length).toBe(2); - const bootstrapped = list.find((p) => p.name === "Google Gemini (Env)"); - expect(bootstrapped).toBeDefined(); - if (!bootstrapped) return; - expect(bootstrapped.isActive).toBe(true); + expect(list.length).toBe(1); + const created = list.find((p) => p.id === inst.id); + expect(created).toBeDefined(); + if (!created) return; + expect(created.isActive).toBe(true); // Edit name and key ProviderManager.update( - bootstrapped.id, + created.id, "My Gemini Key", "google-genai", "new-secret-key", @@ -271,8 +230,8 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => { ); const listAfterUpdate = ProviderManager.list(); - expect(listAfterUpdate.length).toBe(2); - const updated = listAfterUpdate.find((p) => p.id === bootstrapped.id); + expect(listAfterUpdate.length).toBe(1); + const updated = listAfterUpdate.find((p) => p.id === created.id); expect(updated).toBeDefined(); if (!updated) return; expect(updated.name).toBe("My Gemini Key"); @@ -280,8 +239,8 @@ describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => { expect(updated.modelName).toBe("gemini-2.5-pro"); // Delete instance - ProviderManager.delete(bootstrapped.id); + ProviderManager.delete(created.id); const listAfterDelete = ProviderManager.list(); - expect(listAfterDelete.length).toBe(1); + expect(listAfterDelete.length).toBe(0); }); }); diff --git a/web/docs/src/content/docs/architecture/llm-providers.md b/web/docs/src/content/docs/architecture/llm-providers.md index fbb45d8..3244e06 100644 --- a/web/docs/src/content/docs/architecture/llm-providers.md +++ b/web/docs/src/content/docs/architecture/llm-providers.md @@ -71,14 +71,27 @@ If no specific provider instance is mapped to a task, the task automatically rou --- -## Automatic Bootstrapping (Environment Key Fallback) +## CLI Setup & Seeding -To maintain backwards-compatibility and support headless runs, live evaluation suites, and automated unit tests without requiring database pre-configuration, the config manager supports **self-bootstrapping**: +Rather than automatically bootstrapping from environment variables at runtime, which adds runtime complexity, you can quickly seed the database using the CLI setup tool: -1. When any database connection is initialized via the provider manager, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` and `OPENROUTER_API_KEY`. -2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Gemini (Env)`) in `settings.db`. -3. If `process.env.OPENROUTER_API_KEY` is present, it automatically creates and saves a default provider instance (`OpenRouter (Env)`) in `settings.db`. -4. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` (`Gemini (Env Fallback)` or `OpenRouter (Env Fallback)`) to keep execution fluent and error-free. +### Seeding All Environment-Variable Providers + +```bash +pnpm setup-provider --all +``` + +This command auto-detects and inserts provider instances into `data/settings.db` for any registered providers whose corresponding environment variables (such as `GOOGLE_API_KEY`, `OPENAI_API_KEY`, etc.) are defined. + +### Creating a Specific Provider Instance + +```bash +pnpm setup-provider --provider google-genai --key YOUR_API_KEY [--name "My Gemini"] [--model gemini-2.5-flash] [--type generative] [--max-context 32768] [--endpoint url] +``` + +### Environment Variable Fallback + +If the database contains no active provider instances, the LLM providers (e.g. `GeminiProvider`, `OpenAIProvider`, etc.) will fall back directly to reading their keys from environment variables (e.g. `GOOGLE_API_KEY`, `OPENAI_API_KEY`) via `resolveCredentials`. ---