diff --git a/apps/gui/src/lib/simulation.ts b/apps/gui/src/lib/simulation.ts index 29ae4a6..f4d66a5 100644 --- a/apps/gui/src/lib/simulation.ts +++ b/apps/gui/src/lib/simulation.ts @@ -25,7 +25,7 @@ import { IActorProseGenerator, buildBufferEntryForIntent, } from "@omnia/actor"; -import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager } from "@omnia/llm"; +import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider } from "@omnia/llm"; import { ScenarioLoader } from "@omnia/scenario"; import type { @@ -231,9 +231,12 @@ class SimulationManager { const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || ""); const providerName = inst ? inst.providerName : "google-genai"; + const modelName = inst ? inst.modelName : undefined; if (providerName === "google-genai") { - return new GeminiProvider(key); + return new GeminiProvider(key, modelName); + } else if (providerName === "openrouter") { + return new OpenRouterProvider(key, modelName); } else { return new MockLLMProvider([]); } @@ -668,7 +671,9 @@ class SimulationManager { } if (inst.providerName === "google-genai") { - return new GeminiProvider(inst.apiKey); + return new GeminiProvider(inst.apiKey, inst.modelName); + } else if (inst.providerName === "openrouter") { + return new OpenRouterProvider(inst.apiKey, inst.modelName); } else { return new MockLLMProvider([]); } diff --git a/package.json b/package.json index 929135f..6307005 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@langchain/google-genai": "^2.2.0", + "@langchain/openrouter": "^0.4.3", "@types/node": "^20.19.43", "dotenv": "^17.4.2" } diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts index 7d500f5..0605902 100644 --- a/packages/llm/src/config.ts +++ b/packages/llm/src/config.ts @@ -2,6 +2,7 @@ import { z } from "zod"; const LLMConfigSchema = z.object({ GOOGLE_API_KEY: z.string().optional(), + OPENROUTER_API_KEY: z.string().optional(), }); export const llmConfig = LLMConfigSchema.parse(process.env); diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index ceaa1ef..6817141 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -2,4 +2,5 @@ export * from "./llm.js"; export * from "./config.js"; export * from "./providers/google-genai.js"; export * from "./providers/mock.js"; +export * from "./providers/openrouter.js"; export * from "./provider-manager.js"; diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts new file mode 100644 index 0000000..b57d1e9 --- /dev/null +++ b/packages/llm/src/providers/openrouter.ts @@ -0,0 +1,75 @@ +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"; + +export class OpenRouterProvider implements ILLMProvider { + providerName = "OpenRouter"; + private model: ChatOpenRouter; + lastCalls: LLMCallRecord[] = []; + + constructor(apiKey?: string, modelName?: string) { + 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.OPENROUTER_API_KEY; + } + + if (!key) { + throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider"); + } + + this.model = new ChatOpenRouter({ + apiKey: key, + model: model || "google/gemini-2.5-flash", + }); + } + + async generateStructuredResponse( + request: LLMRequest, + ): Promise>> { + 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; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = raw?.usage_metadata ? { + inputTokens: raw.usage_metadata.input_tokens || 0, + outputTokens: raw.usage_metadata.output_tokens || 0, + totalTokens: raw.usage_metadata.total_tokens || 0, + } : undefined; + + this.lastCalls.push({ + systemPrompt: request.systemPrompt, + userContext: request.userContext, + usage, + }); + + return { success: true, data: parsed, usage }; + } +} diff --git a/packages/llm/tests/openrouter.test.ts b/packages/llm/tests/openrouter.test.ts new file mode 100644 index 0000000..6aec82c --- /dev/null +++ b/packages/llm/tests/openrouter.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect, vi } from "vitest"; +import { z } from "zod"; +import { OpenRouterProvider } from "../src/providers/openrouter.js"; +import { llmConfig } from "../src/config.js"; + +// Mock the ChatOpenRouter class +vi.mock("@langchain/openrouter", () => { + return { + ChatOpenRouter: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + 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", + success: true, + }, + raw: { + usage_metadata: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }, + }; + }), + }; + }); + }, + }; +}); + +describe("OpenRouterProvider Unit Tests (Tier 1)", () => { + test("initializes successfully with a provided apiKey", () => { + const provider = new OpenRouterProvider("dummy-key"); + expect(provider.providerName).toBe("OpenRouter"); + }); + + test("initializes successfully with apiKey from config", () => { + // Save current config + const originalKey = llmConfig.OPENROUTER_API_KEY; + llmConfig.OPENROUTER_API_KEY = "env-dummy-key"; + + try { + const provider = new OpenRouterProvider(); + expect(provider.providerName).toBe("OpenRouter"); + } finally { + llmConfig.OPENROUTER_API_KEY = originalKey; + } + }); + + 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; + + try { + expect(() => new OpenRouterProvider()).toThrow( + "OPENROUTER_API_KEY is required to initialize OpenRouterProvider" + ); + } finally { + llmConfig.OPENROUTER_API_KEY = originalKey; + } + }); + + test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => { + const provider = new OpenRouterProvider("dummy-key"); + const TestSchema = z.object({ + name: z.string(), + success: z.boolean(), + }); + + const response = await provider.generateStructuredResponse({ + systemPrompt: "system prompt", + userContext: "user context", + schema: TestSchema, + }); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ + name: "mocked response", + success: true, + }); + + expect(response.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }); + + expect(provider.lastCalls.length).toBe(1); + expect(provider.lastCalls[0]).toEqual({ + systemPrompt: "system prompt", + userContext: "user context", + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60b38e4..ca9171b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,7 +209,10 @@ importers: dependencies: '@langchain/google-genai': specifier: ^2.2.0 - version: 2.2.0(@langchain/core@1.2.1(ws@8.21.0)) + version: 2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) + '@langchain/openrouter': + specifier: ^0.4.3 + version: 0.4.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)(zod@4.4.3) '@types/node': specifier: ^20.19.43 version: 20.19.43 @@ -340,7 +343,7 @@ importers: dependencies: '@langchain/google-genai': specifier: ^2.2.0 - version: 2.2.0(@langchain/core@1.2.1(ws@8.21.0)) + version: 2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) '@types/node': specifier: ^26.1.0 version: 26.1.0 @@ -1220,6 +1223,18 @@ packages: peerDependencies: '@langchain/core': ^1.2.0 + '@langchain/openai@1.5.3': + resolution: {integrity: sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.2.1 + + '@langchain/openrouter@0.4.3': + resolution: {integrity: sha512-LUul0i3VAlQBkhOg0EH1N20DUOxHGAZ1gqQtX1IRO/jYbDL3HZ7uHUupjMnerDaULiyAJZT3DxDw6Cr1QMT4ng==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.0.0 + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -2353,6 +2368,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -3064,6 +3083,26 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + openai@6.45.0: + resolution: {integrity: sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4460,12 +4499,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@langchain/core@1.2.1(ws@8.21.0)': + '@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.7.15(ws@8.21.0) + langsmith: 0.7.15(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) mustache: 4.2.0 p-queue: 6.6.2 zod: 4.4.3 @@ -4476,10 +4515,35 @@ snapshots: - openai - ws - '@langchain/google-genai@2.2.0(@langchain/core@1.2.1(ws@8.21.0))': + '@langchain/google-genai@2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))': dependencies: '@google/generative-ai': 0.24.1 - '@langchain/core': 1.2.1(ws@8.21.0) + '@langchain/core': 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + + '@langchain/openai@1.5.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)': + dependencies: + '@langchain/core': 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + js-tiktoken: 1.0.21 + openai: 6.45.0(ws@8.21.0)(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - ws + + '@langchain/openrouter@0.4.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@langchain/core': 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/openai': 1.5.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0) + eventsource-parser: 3.1.0 + openai: 6.45.0(ws@8.21.0)(zod@4.4.3) + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - ws + - zod '@mdx-js/mdx@3.1.1': dependencies: @@ -5746,6 +5810,8 @@ snapshots: eventemitter3@5.0.4: {} + eventsource-parser@3.1.0: {} + expand-template@2.0.3: {} expect-type@1.4.0: {} @@ -6125,10 +6191,11 @@ snapshots: klona@2.0.6: {} - langsmith@0.7.15(ws@8.21.0): + langsmith@0.7.15(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): dependencies: p-queue: 6.6.2 optionalDependencies: + openai: 6.45.0(ws@8.21.0)(zod@4.4.3) ws: 8.21.0 layout-base@1.0.2: {} @@ -6800,6 +6867,11 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + openai@6.45.0(ws@8.21.0)(zod@4.4.3): + optionalDependencies: + ws: 8.21.0 + zod: 4.4.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/web/docs/src/content/docs/architecture/llm-providers.md b/web/docs/src/content/docs/architecture/llm-providers.md index 8f3fb1d..70f9607 100644 --- a/web/docs/src/content/docs/architecture/llm-providers.md +++ b/web/docs/src/content/docs/architecture/llm-providers.md @@ -21,9 +21,10 @@ export interface ILLMProvider { } ``` -The codebase provides two primary implementations: +The codebase provides three 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. +2. **`OpenRouterProvider`:** The production provider utilizing OpenRouter via the `@langchain/openrouter` SDK, allowing routing through various third-party and local models. +3. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests. --- @@ -43,10 +44,10 @@ export interface LLMProviderInstance { ``` 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`). +* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"OpenRouter Claude Key"`). +* A provider type (e.g., `google-genai`, `openrouter`, `mock`). * An API key credential. -* A custom target model name (e.g., `gemini-2.5-flash` or `gemini-2.5-pro`). +* A custom target model name (e.g., `gemini-2.5-flash`, `anthropic/claude-3-5-sonnet`, or local model paths). * 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). @@ -59,10 +60,10 @@ During a simulation run, the engine executes four distinct LLM operations. To op | 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` | +| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` / `google/gemini-2.5-flash` | +| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` / `google/gemini-2.5-flash` | +| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` / `google/gemini-2.5-flash` | +| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` / `google/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. @@ -72,7 +73,7 @@ If no specific provider instance is mapped to a task, the task automatically rou 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`. +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` or `OPENROUTER_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.