diff --git a/apps/gui/src/app/config/page.tsx b/apps/gui/src/app/config/page.tsx index 7be8fd1..879d696 100644 --- a/apps/gui/src/app/config/page.tsx +++ b/apps/gui/src/app/config/page.tsx @@ -11,7 +11,7 @@ import { setProviderMapping, updateProviderInstance, } from "@/app/play/actions"; -import type { LLMProviderInstance } from "@/lib/provider-manager"; +import type { LLMProviderInstance } from "@omnia/llm"; interface ConfigStatus { apiKeySet: boolean; diff --git a/apps/gui/src/app/play/actions.ts b/apps/gui/src/app/play/actions.ts index a746bb1..8f9423e 100644 --- a/apps/gui/src/app/play/actions.ts +++ b/apps/gui/src/app/play/actions.ts @@ -4,8 +4,7 @@ import path from "path"; import fs from "fs"; import { simulationManager } from "@/lib/simulation"; import type { SimSnapshot } from "@/lib/simulation"; -import { ProviderManager } from "@/lib/provider-manager"; -import type { LLMProviderInstance } from "@/lib/provider-manager"; +import { ProviderManager, LLMProviderInstance } from "@omnia/llm"; function resolveScenarioPath(relative: string): string { const cwd = process.cwd(); diff --git a/apps/gui/src/components/play/PlayView.tsx b/apps/gui/src/components/play/PlayView.tsx index 55380ce..f1ac424 100644 --- a/apps/gui/src/components/play/PlayView.tsx +++ b/apps/gui/src/components/play/PlayView.tsx @@ -13,7 +13,7 @@ import { listProviderInstances, } from "@/app/play/actions"; import type { SimSnapshot } from "@/lib/simulation-types"; -import type { LLMProviderInstance } from "@/lib/provider-manager"; +import type { LLMProviderInstance } from "@omnia/llm"; function IntentTag({ intent, diff --git a/apps/gui/src/lib/simulation.ts b/apps/gui/src/lib/simulation.ts index 98ae128..29ae4a6 100644 --- a/apps/gui/src/lib/simulation.ts +++ b/apps/gui/src/lib/simulation.ts @@ -25,9 +25,8 @@ import { IActorProseGenerator, buildBufferEntryForIntent, } from "@omnia/actor"; -import { GeminiProvider, ILLMProvider, MockLLMProvider } from "@omnia/llm"; +import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager } from "@omnia/llm"; import { ScenarioLoader } from "@omnia/scenario"; -import { ProviderManager } from "./provider-manager"; import type { IntentInfo, diff --git a/packages/llm/package.json b/packages/llm/package.json index 1bd36fc..90fe3a3 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -7,6 +7,10 @@ ".": "./dist/index.js" }, "dependencies": { - "@types/node": "^26.1.0" + "@types/node": "^26.1.0", + "better-sqlite3": "^12.11.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13" } } diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index a9795b4..ceaa1ef 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -2,3 +2,4 @@ export * from "./llm.js"; export * from "./config.js"; export * from "./providers/google-genai.js"; export * from "./providers/mock.js"; +export * from "./provider-manager.js"; diff --git a/apps/gui/src/lib/provider-manager.ts b/packages/llm/src/provider-manager.ts similarity index 72% rename from apps/gui/src/lib/provider-manager.ts rename to packages/llm/src/provider-manager.ts index 54e441c..e032aaa 100644 --- a/apps/gui/src/lib/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -1,12 +1,27 @@ import Database from "better-sqlite3"; import path from "path"; import fs from "fs"; -import type { LLMProviderInstance } from "@omnia/llm"; +import type { LLMProviderInstance } from "./llm.js"; -export type { LLMProviderInstance }; +function getWorkspaceRoot() { + let current = process.cwd(); + while (current !== "/" && current !== path.parse(current).root) { + if ( + fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || + fs.existsSync(path.join(current, "package.json")) + ) { + if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) { + return current; + } + } + current = path.dirname(current); + } + return process.cwd(); +} function getSettingsDb() { - const dbDir = path.resolve(process.cwd(), "data"); + const wsRoot = getWorkspaceRoot(); + const dbDir = path.resolve(wsRoot, "data"); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } @@ -127,6 +142,7 @@ export class ProviderManager { static getActive(): LLMProviderInstance | null { const db = getSettingsDb(); try { + // Query the DB const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1`).get() as { id: string; name: string; @@ -135,7 +151,34 @@ export class ProviderManager { isActive: number; modelName?: string; } | undefined; - if (!row) return null; + + if (!row) { + // Check if there are any rows at all + const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number }; + if (totalCount.count === 0) { + // Database is completely empty! Check if GOOGLE_API_KEY env is set. + const envKey = process.env.GOOGLE_API_KEY; + if (envKey && envKey.trim()) { + // Auto-bootstrap default active instance from env + const id = "provider-default-env"; + db.prepare(` + INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, "Default (Env)", "google-genai", envKey, 1, "gemini-2.5-flash"); + + return { + id, + name: "Default (Env)", + providerName: "google-genai", + apiKey: envKey, + isActive: true, + modelName: "gemini-2.5-flash", + }; + } + } + return null; + } + return { id: row.id, name: row.name, @@ -144,6 +187,20 @@ export class ProviderManager { isActive: true, modelName: row.modelName || undefined, }; + } catch { + // Lock or write issue fallback: return an in-memory active key if env key exists + const envKey = process.env.GOOGLE_API_KEY; + if (envKey) { + return { + id: "provider-default-env-fallback", + name: "Default (Env Fallback)", + providerName: "google-genai", + apiKey: envKey, + isActive: true, + modelName: "gemini-2.5-flash", + }; + } + return null; } finally { db.close(); } diff --git a/packages/llm/src/providers/google-genai.ts b/packages/llm/src/providers/google-genai.ts index 83bff43..5d0320a 100644 --- a/packages/llm/src/providers/google-genai.ts +++ b/packages/llm/src/providers/google-genai.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js"; import { llmConfig } from "../config.js"; +import { ProviderManager } from "../provider-manager.js"; export class GeminiProvider implements ILLMProvider { providerName = "Gemini"; @@ -9,13 +10,30 @@ export class GeminiProvider implements ILLMProvider { lastCalls: LLMCallRecord[] = []; constructor(apiKey?: string, modelName?: string) { - const key = apiKey || llmConfig.GOOGLE_API_KEY; + let key = apiKey; + let model = modelName; + + if (!key) { + const active = ProviderManager.getActive(); + if (active) { + key = active.apiKey; + if (!model) { + model = active.modelName; + } + } + } + + if (!key) { + key = llmConfig.GOOGLE_API_KEY; + } + if (!key) { throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider"); } + this.model = new ChatGoogleGenerativeAI({ apiKey: key, - model: modelName || "gemini-2.5-flash", + model: model || "gemini-2.5-flash", }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88bfc7b..b295989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,6 +365,13 @@ importers: '@types/node': specifier: ^26.1.0 version: 26.1.0 + better-sqlite3: + specifier: ^12.11.1 + version: 12.11.1 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 packages/memory: dependencies: diff --git a/web/docs/src/content/docs/architecture/llm-providers.md b/web/docs/src/content/docs/architecture/llm-providers.md new file mode 100644 index 0000000..8f3fb1d --- /dev/null +++ b/web/docs/src/content/docs/architecture/llm-providers.md @@ -0,0 +1,97 @@ +--- +title: LLM Providers & Configuration +description: Details of the LLM provider instances, task routing, and self-bootstrapping setup in Omnia. +sidebar: + order: 5 +--- + +In Omnia, all non-player character behaviors, action validation, intent decoding, and time step logic are simulated using Large Language Models (LLMs). The LLM subsystem is built around **polymorphism, key instance management, and task provider routing**. + +## Core Interfaces + +All LLM providers implement the common `ILLMProvider` interface defined in `packages/llm/src/llm.ts`: + +```typescript +export interface ILLMProvider { + providerName: string; + generateStructuredResponse( + request: LLMRequest, + ): Promise>>; + lastCalls?: LLMCallRecord[]; +} +``` + +The codebase provides two primary implementations: +1. **`GeminiProvider`:** The production provider utilizing Google's Gemini Models via the `@langchain/google-genai` SDK. +2. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests. + +--- + +## LLM Provider Instances + +To support multiple different API keys, key rotation, and model variation, Omnia utilizes a **Provider Instance model** rather than relying on static configuration: + +```typescript +export interface LLMProviderInstance { + id: string; + name: string; + providerName: string; + apiKey: string; + isActive: boolean; + modelName?: string; +} +``` + +Users can register multiple provider instances in the **Configuration Page** under the GUI. Each instance is given: +* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"Experimental Gemini Pro"`). +* A provider type (e.g., `google-genai`, `mock`). +* An API key credential. +* A custom target model name (e.g., `gemini-2.5-flash` or `gemini-2.5-pro`). +* An **Active** status flag (one key is marked as globally active). + +Configurations are stored globally in `data/settings.db` (separated from specific simulation run databases like `data/sim-*.db` to keep key storage and audit logs isolated). + +--- + +## Task Provider Routing + +During a simulation run, the engine executes four distinct LLM operations. To optimize costs, latency, or model accuracy, you can route each of these tasks to different LLM provider instances: + +| Task Name | Key ID | Description | Default Model | +| :--- | :--- | :--- | :--- | +| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` | +| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` | +| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` | +| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` | + +If no specific provider instance is mapped to a task, the task automatically routes to the globally marked **Active** provider instance. + +--- + +## Automatic Bootstrapping (Environment Key Fallback) + +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**: + +1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY`. +2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Default (Env)`) in `settings.db`. +3. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` to keep execution fluent and error-free. + +--- + +## Developer Guide: Managing Mappings + +Configuration settings are managed through `ProviderManager` static methods: + +```typescript +// Query the active provider configuration +const activeConfig = ProviderManager.getActive(); + +// List all registered provider instances +const allConfigs = ProviderManager.list(); + +// Retrieve task-specific mappings +const mappings = ProviderManager.getMappings(); // e.g., { "actor-prose": "provider-123" } + +// Map a task to a provider instance +ProviderManager.setMapping("actor-prose", "provider-123"); +```