refactor!(llm): Remove bootstrapper in favor of setup-provider

This commit is contained in:
Tom Pike
2026-07-16 22:21:59 +05:30
committed by Aditya Gupta
parent 250bb87a8d
commit 61ea9fe237
11 changed files with 398 additions and 255 deletions

View File

@@ -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",

View File

@@ -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=<requested>
├── 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
```

View File

@@ -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"

View File

@@ -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> ID of the provider (e.g. google-genai, openai, anthropic, groq, etc.)
--name <name> Display name for the instance (default: provider displayName)
--key <key> API key (default: loaded from the provider's env variable, e.g. GOOGLE_API_KEY)
--model <model> Model name (default: provider's default model)
--type <type> "generative" | "embedding" (default: "generative")
--max-context <num> Max context window tokens (default: provider's default max context)
--endpoint <url> 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<string, string> = {};
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 <id> 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 <apiKey>.`,
);
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);
});

View File

@@ -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;
}

View File

@@ -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"))) {

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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`.
---