feat: Added OpenRouter LLMProvider and setup bootstrapping

This commit is contained in:
2026-07-10 07:59:31 +05:30
8 changed files with 283 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

86
pnpm-lock.yaml generated
View File

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

View File

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