refactor!(llm): implement new model registration system

This commit is contained in:
2026-07-16 22:05:45 +05:30
parent 8ad94d3fc2
commit 250bb87a8d
28 changed files with 1717 additions and 1974 deletions

View File

@@ -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<ModelProviderMeta[]> {
return AVAILABLE_PROVIDERS;
return listAvailableProviders();
}
export async function regenerateEmbeddings(

View File

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

View File

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

View File

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

View File

@@ -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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
const structuredModel = (
this.model as {
withStructuredOutput(
s: z.ZodTypeAny,
o: { includeRaw: true },
): {
invoke(m: unknown): Promise<unknown>;
};
}
).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<T>;
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 };
}
}

View File

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

View File

@@ -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<string, string | undefined> | null = null;
export const llmConfig = LLMConfigSchema.parse(process.env);
export function getLlmConfig(): Record<string, string | undefined> {
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<string, z.ZodOptional<z.ZodString>> = {};
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;
}

75
packages/llm/src/db.ts Normal file
View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { ProviderRegistry } from "./registry.js";
export interface LLMRequest<T extends z.ZodTypeAny> {
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",
},
];
};

View File

@@ -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<string, CacheEntry>();
function cacheKey(
@@ -28,7 +29,7 @@ function cacheKey(
return `${providerName}:${endpointUrl || apiKey}`;
}
async function fetchWithTimeout(
export async function fetchWithTimeout(
url: string,
init?: RequestInit,
): Promise<Response> {
@@ -41,41 +42,7 @@ async function fetchWithTimeout(
}
}
async function fetchGeminiModels(apiKey: string): Promise<ModelInfo[]> {
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<ModelInfo[]> {
@@ -98,146 +65,25 @@ async function fetchOpenAICompatibleModels(
}));
}
async function fetchAnthropicModels(apiKey: string): Promise<ModelInfo[]> {
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<ModelInfo[]> {
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<ModelInfo[]> {
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<ModelInfo[]> {
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();
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<ModelInfo[]> {
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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
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<T>;
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 });
}
}

View File

@@ -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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
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<T>;
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 });
}
}

View File

@@ -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<ModelInfo[]> {
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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
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<T>;
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) {

View File

@@ -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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
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<T>;
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 });
}
}

View File

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

View File

@@ -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<ModelInfo[]> {
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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
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<T>;
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;
}

View File

@@ -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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
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<T>;
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) {

View File

@@ -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<ModelInfo[]> {
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<T extends z.ZodTypeAny>(
request: LLMRequest<T>,
): Promise<LLMResponse<z.infer<T>>> {
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<T>;
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 });
}
}

View File

@@ -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<ModelInfo[]>;
generativeCreate?: (inst: ModelProviderInstance) => ILLMProvider;
embeddingCreate?: (inst: ModelProviderInstance) => IEmbeddingProvider;
}
const _entries = new Map<string, ProviderDefinition>();
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 || "",
};
}

View File

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

View File

@@ -1,7 +1,34 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
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<typeof import("../src/provider-manager.js")>();
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;
}
});

View File

@@ -1,7 +1,34 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
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<typeof import("../src/provider-manager.js")>();
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;
}
});

View File

@@ -1,10 +1,37 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
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<typeof import("../src/provider-manager.js")>();
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;
}
});

View File

@@ -1,7 +1,34 @@
import { describe, test, expect, vi } from "vitest";
import { z } from "zod";
const mockConfig: Record<string, string | undefined> = {};
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<typeof import("../src/provider-manager.js")>();
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;
}
});

View File

@@ -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<string, string | undefined>;
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);