mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
refactor!(llm): Remove bootstrapper in favor of setup-provider
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
193
packages/llm/src/bin/setup-provider.ts
Normal file
193
packages/llm/src/bin/setup-provider.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"))) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
126
packages/llm/tests/cli.test.ts
Normal file
126
packages/llm/tests/cli.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user