feat: LLMProviderInstance over LLMProvider

allows for using multiple keys of the same provider
implemented per llm call providerinstance mapping
This commit is contained in:
2026-07-09 18:40:52 +05:30
parent 4ef52f926e
commit 46b12cd668
8 changed files with 771 additions and 88 deletions

View File

@@ -1,7 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { getConfigStatus } from "@/app/play/actions";
import { useEffect, useState, useCallback } from "react";
import {
getConfigStatus,
listProviderInstances,
createProviderInstance,
deleteProviderInstance,
setActiveProviderInstance,
getProviderMappings,
setProviderMapping,
} from "@/app/play/actions";
import type { LLMProviderInstance } from "@/lib/provider-manager";
interface ConfigStatus {
apiKeySet: boolean;
@@ -12,37 +21,106 @@ interface ConfigStatus {
export default function ConfigPage() {
const [config, setConfig] = useState<ConfigStatus | null>(null);
const [instances, setInstances] = useState<LLMProviderInstance[]>([]);
const [mappings, setMappings] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
const [newName, setNewName] = useState("");
const [newProvider, setNewProvider] = useState("google-genai");
const [newKey, setNewKey] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const result = await getConfigStatus();
if (!cancelled) {
setConfig(result);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
const loadInstances = useCallback(async () => {
try {
const list = await listProviderInstances();
setInstances(list);
} catch {
// ignore
}
load();
return () => {
cancelled = true;
};
}, []);
const loadMappings = useCallback(async () => {
try {
const maps = await getProviderMappings();
setMappings(maps);
} catch {
// ignore
}
}, []);
const loadAll = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await getConfigStatus();
setConfig(result);
await loadInstances();
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [loadInstances, loadMappings]);
useEffect(() => {
loadAll();
}, [loadAll]);
const handleAddInstance = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newKey.trim()) return;
try {
setLoading(true);
await createProviderInstance(newName, newProvider, newKey);
setNewName("");
setNewKey("");
await loadInstances();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleDeleteInstance = async (id: string) => {
if (!confirm("Are you sure you want to delete this provider instance?")) return;
try {
setLoading(true);
await deleteProviderInstance(id);
await loadInstances();
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleSetActiveInstance = async (id: string) => {
try {
setLoading(true);
await setActiveProviderInstance(id);
await loadInstances();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
try {
setLoading(true);
await setProviderMapping(task, providerInstanceId);
await loadMappings();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
return (
<div className="config-page">
<h1>Configuration</h1>
@@ -53,19 +131,145 @@ export default function ConfigPage() {
{config && !loading && (
<>
<section className="config-section">
<h2>LLM Provider</h2>
<div className="config-row">
<span className="config-label">Provider</span>
<span className="config-value">Google Gemini</span>
<h2>LLM Provider Instances</h2>
<div className="instances-container">
{instances.length === 0 ? (
<div className="config-hint">
No custom LLM provider instances configured. Defaulting to the environment <code>GOOGLE_API_KEY</code> if present.
</div>
) : (
<table className="scenario-table">
<thead>
<tr>
<th>Friendly Name</th>
<th>Provider Type</th>
<th>API Key Preview</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{instances.map((inst) => (
<tr key={inst.id}>
<td><strong>{inst.name}</strong></td>
<td><code>{inst.providerName}</code></td>
<td>
<code>
{inst.apiKey.substring(0, 10)}...{inst.apiKey.substring(inst.apiKey.length - 4)}
</code>
</td>
<td>
{inst.isActive ? (
<span className="status-pill active">Active</span>
) : (
<span className="status-pill inactive">Inactive</span>
)}
</td>
<td>
<div className="action-buttons">
{!inst.isActive && (
<button onClick={() => handleSetActiveInstance(inst.id)} className="btn-sm">
Make Active
</button>
)}
<button onClick={() => handleDeleteInstance(inst.id)} className="btn-sm delete-btn">
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<form onSubmit={handleAddInstance} className="add-instance-form">
<h3>Add LLM Provider Instance</h3>
<div className="form-fields">
<div className="field">
<label htmlFor="instName">Friendly Name</label>
<input
id="instName"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Gemini - Production"
required
/>
</div>
<div className="field">
<label htmlFor="instProvider">Provider Type</label>
<select
id="instProvider"
value={newProvider}
onChange={(e) => setNewProvider(e.target.value)}
>
<option value="google-genai">Google Gemini (Gemini-2.5-flash)</option>
<option value="mock">Mock LLM Provider</option>
</select>
</div>
<div className="field">
<label htmlFor="instKey">API Key</label>
<input
id="instKey"
type="password"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="AIzaSy..."
required
/>
</div>
<button type="submit" disabled={loading} className="btn-add">
Add Instance
</button>
</div>
</form>
</section>
<section className="config-section">
<h2>Task Provider Routing</h2>
<p className="config-hint" style={{ background: "#eff6ff", border: "1px solid #bfdbfe", color: "#1e3a8a", margin: "1rem 0" }}>
Configure which LLM Provider Key Instance should handle each specific simulation task. Mappings default to the currently <strong>Active</strong> instance if not specified.
</p>
<div className="mappings-grid">
{[
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters." },
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules." },
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC)." },
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock." },
].map((task) => (
<div key={task.key} className="mapping-card">
<div className="mapping-info">
<strong>{task.label}</strong>
<span className="text-gray" style={{ fontSize: "0.75rem", marginTop: "0.125rem" }}>{task.desc}</span>
</div>
<select
value={mappings[task.key] || ""}
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
>
<option value="">-- Use Active Key (Default) --</option>
{instances.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
</option>
))}
</select>
</div>
))}
</div>
</section>
<section className="config-section">
<h2>Environment Variables Default</h2>
<div className="config-row">
<span className="config-label">Model</span>
<span className="config-label">Default Model</span>
<span className="config-value">
<code>{config.model}</code>
</span>
</div>
<div className="config-row">
<span className="config-label">API Key</span>
<span className="config-label">Default API Key (.env)</span>
<span
className={
config.apiKeySet
@@ -78,21 +282,13 @@ export default function ConfigPage() {
: "✗ NOT SET"}
</span>
</div>
{!config.apiKeySet && (
<div className="config-hint">
Add <code>GOOGLE_API_KEY=your_key</code> to the{" "}
<code>.env</code> file in the project root, then restart the
server.
</div>
)}
</section>
<section className="config-section">
<h2>Available Scenarios</h2>
{config.availableScenarios.length === 0 ? (
<p className="config-hint">
No scenarios found in{" "}
<code>content/demo/scenarios/</code>.
No scenarios found in <code>content/demo/scenarios/</code>.
</p>
) : (
<table className="scenario-table">
@@ -215,6 +411,142 @@ export default function ConfigPage() {
font-family: monospace;
font-size: 0.875rem;
}
/* Pill and List Styles */
.status-pill {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.status-pill.active {
background: #dcfce7;
color: #15803d;
}
.status-pill.inactive {
background: #f3f4f6;
color: #4b5563;
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-sm:hover {
background: #2563eb;
}
.btn-sm.delete-btn {
background: #ef4444;
}
.btn-sm.delete-btn:hover {
background: #dc2626;
}
.add-instance-form {
margin-top: 1.5rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.25rem;
}
.add-instance-form h3 {
font-size: 0.95rem;
margin-bottom: 0.75rem;
color: #111;
}
.form-fields {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
align-items: flex-end;
}
@media (min-width: 768px) {
.form-fields {
grid-template-columns: 1fr 1fr 1.5fr auto;
}
}
.form-fields .field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-fields .field label {
font-size: 0.75rem;
font-weight: 500;
}
.form-fields .field input,
.form-fields .field select {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
}
.btn-add {
padding: 0.375rem 1rem;
font-size: 0.8125rem;
background: #10b981;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-add:hover {
background: #059669;
}
/* Task Provider Routing Styles */
.mappings-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1rem;
}
@media (min-width: 768px) {
.mappings-grid {
grid-template-columns: 1fr 1fr;
}
}
.mapping-card {
border: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.75rem;
}
.mapping-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8125rem;
}
.mapping-info strong {
font-size: 0.875rem;
color: #111;
}
.mapping-card select {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
width: 100%;
}
.text-gray {
color: #6b7280;
}
`}</style>
</div>
);

View File

@@ -4,6 +4,8 @@ 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";
function resolveScenarioPath(relative: string): string {
const cwd = process.cwd();
@@ -30,6 +32,7 @@ type ActionResult =
export async function startSimulation(input: {
scenario?: string;
playEntity?: string;
providerInstanceId?: string;
}): Promise<ActionResult> {
try {
const scenarioFile =
@@ -43,6 +46,7 @@ export async function startSimulation(input: {
const snapshot = await simulationManager.create(
resolved,
input.playEntity || undefined,
input.providerInstanceId,
);
if (snapshot.status === "error") {
@@ -229,3 +233,34 @@ export async function deleteSimulation(simId: string): Promise<
};
}
}
export async function listProviderInstances(): Promise<LLMProviderInstance[]> {
return ProviderManager.list();
}
export async function createProviderInstance(
name: string,
providerName: string,
apiKey: string,
): Promise<LLMProviderInstance> {
return ProviderManager.create(name, providerName, apiKey);
}
export async function deleteProviderInstance(id: string): Promise<void> {
ProviderManager.delete(id);
}
export async function setActiveProviderInstance(id: string): Promise<void> {
ProviderManager.setActive(id);
}
export async function getProviderMappings(): Promise<Record<string, string>> {
return ProviderManager.getMappings();
}
export async function setProviderMapping(
task: string,
providerInstanceId: string,
): Promise<void> {
ProviderManager.setMapping(task, providerInstanceId);
}

View File

@@ -10,8 +10,10 @@ import {
getConfigStatus,
getScenarioEntities,
deleteSimulation,
listProviderInstances,
} from "@/app/play/actions";
import type { SimSnapshot } from "@/lib/simulation-types";
import type { LLMProviderInstance } from "@/lib/provider-manager";
function IntentTag({
intent,
@@ -304,9 +306,12 @@ export function PlayView() {
const [availableEntities, setAvailableEntities] = useState<{ id: string; name: string }[]>([]);
const [selectedEntity, setSelectedEntity] = useState("");
// Load scenarios on mount
const [providerInstances, setProviderInstances] = useState<LLMProviderInstance[]>([]);
const [selectedProviderInstance, setSelectedProviderInstance] = useState("");
// Load scenarios and provider instances on mount
useEffect(() => {
async function loadScenarios() {
async function loadScenariosAndProviders() {
try {
const configStatus = await getConfigStatus();
setScenarios(configStatus.availableScenarios);
@@ -316,9 +321,21 @@ export function PlayView() {
} catch {
// ignore
}
try {
const providersList = await listProviderInstances();
setProviderInstances(providersList);
const active = providersList.find(p => p.isActive);
if (active) {
setSelectedProviderInstance(active.id);
} else if (providersList.length > 0) {
setSelectedProviderInstance(providersList[0].id);
}
} catch {
// ignore
}
}
loadScenarios();
}, []);
loadScenariosAndProviders();
}, [snapshot]);
// Fetch entities when selectedScenario changes
useEffect(() => {
@@ -355,6 +372,7 @@ export function PlayView() {
const result = await startSimulation({
scenario: (form.get("scenario") as string) || undefined,
playEntity: (form.get("playEntity") as string) || undefined,
providerInstanceId: selectedProviderInstance || undefined,
});
if (!result.ok) {
@@ -473,6 +491,25 @@ export function PlayView() {
))}
</select>
</div>
<div className="field">
<label htmlFor="llmInstance">LLM Key / Instance</label>
<select
id="llmInstance"
value={selectedProviderInstance}
onChange={(e) => setSelectedProviderInstance(e.target.value)}
disabled={providerInstances.length === 0}
>
{providerInstances.length === 0 ? (
<option value="">Default (from Env variable)</option>
) : (
providerInstances.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.providerName}) {p.isActive ? " [Active]" : ""}
</option>
))
)}
</select>
</div>
<button type="submit" disabled={loading}>
{loading ? "Starting..." : "Start Simulation"}
</button>

View File

@@ -0,0 +1,165 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { LLMProviderInstance } from "@omnia/llm";
export type { LLMProviderInstance };
function getSettingsDb() {
const dbDir = path.resolve(process.cwd(), "data");
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const dbPath = path.join(dbDir, "settings.db");
const db = new Database(dbPath);
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
)
`).run();
return db;
}
export class ProviderManager {
static list(): LLMProviderInstance[] {
const db = getSettingsDb();
try {
const rows = db.prepare(`SELECT * FROM provider_instances`).all() as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
}[];
return rows.map((r) => ({
id: r.id,
name: r.name,
providerName: r.providerName,
apiKey: r.apiKey,
isActive: r.isActive === 1,
}));
} finally {
db.close();
}
}
static create(name: string, providerName: string, apiKey: string): LLMProviderInstance {
const db = getSettingsDb();
try {
const id = "provider-" + Date.now();
const activeCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances WHERE isActive = 1`).get() as { count: number };
const isActive = activeCount.count === 0 ? 1 : 0;
db.prepare(`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive)
VALUES (?, ?, ?, ?, ?)
`).run(id, name, providerName, apiKey, isActive);
return { id, name, providerName, apiKey, isActive: isActive === 1 };
} finally {
db.close();
}
}
static delete(id: string): void {
const db = getSettingsDb();
try {
const provider = db.prepare(`SELECT isActive FROM provider_instances WHERE id = ?`).get(id) as { isActive: number } | undefined;
db.prepare(`DELETE FROM provider_instances WHERE id = ?`).run(id);
if (provider && provider.isActive === 1) {
const next = db.prepare(`SELECT id FROM provider_instances LIMIT 1`).get() as { id: string } | undefined;
if (next) {
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(next.id);
}
}
} finally {
db.close();
}
}
static setActive(id: string): void {
const db = getSettingsDb();
try {
db.prepare(`UPDATE provider_instances SET isActive = 0`).run();
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(id);
} finally {
db.close();
}
}
static getActive(): LLMProviderInstance | null {
const db = getSettingsDb();
try {
const row = db.prepare(`SELECT * FROM provider_instances WHERE isActive = 1`).get() as {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: number;
} | undefined;
if (!row) return null;
return {
id: row.id,
name: row.name,
providerName: row.providerName,
apiKey: row.apiKey,
isActive: true,
};
} finally {
db.close();
}
}
static getMappings(): Record<string, string> {
const db = getSettingsDb();
try {
db.prepare(`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`).run();
const rows = db.prepare(`SELECT * FROM provider_mappings`).all() as {
task: string;
providerInstanceId: string;
}[];
const mappings: Record<string, string> = {};
for (const row of rows) {
mappings[row.task] = row.providerInstanceId;
}
return mappings;
} finally {
db.close();
}
}
static setMapping(task: string, providerInstanceId: string): void {
const db = getSettingsDb();
try {
db.prepare(`
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
)
`).run();
if (!providerInstanceId) {
db.prepare(`DELETE FROM provider_mappings WHERE task = ?`).run(task);
} else {
db.prepare(`
INSERT INTO provider_mappings (task, providerInstanceId)
VALUES (?, ?)
ON CONFLICT(task) DO UPDATE SET providerInstanceId = excluded.providerInstanceId
`).run(task, providerInstanceId);
}
} finally {
db.close();
}
}
}

View File

@@ -25,8 +25,9 @@ import {
IActorProseGenerator,
buildBufferEntryForIntent,
} from "@omnia/actor";
import { GeminiProvider } from "@omnia/llm";
import { GeminiProvider, ILLMProvider, MockLLMProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import { ProviderManager } from "./provider-manager";
import type {
IntentInfo,
@@ -66,6 +67,7 @@ interface SavedState {
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
log: LogEntry[];
providerMappings: Record<string, string>;
}
function loadSessionState(db: Database.Database, id: string): SavedState | null {
@@ -96,7 +98,10 @@ interface SimSession {
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
llmProvider: GeminiProvider;
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
@@ -104,6 +109,7 @@ interface SimSession {
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
providerMappings: Record<string, string>;
}
class SimulationManager {
@@ -112,9 +118,20 @@ class SimulationManager {
async create(
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
): Promise<SimSnapshot> {
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
let activeInstance = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId)
: ProviderManager.getActive();
if (!activeInstance) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey);
}
}
if (!activeInstance) {
return {
id: "",
status: "error",
@@ -125,7 +142,7 @@ class SimulationManager {
entities: [],
log: [],
entityIndex: 0,
error: "GOOGLE_API_KEY is not set. Add it to your .env file.",
error: "No active LLM Provider Instance found. Please configure a key in Settings first.",
};
}
@@ -202,9 +219,37 @@ class SimulationManager {
}
}
const llmProvider = new GeminiProvider(apiKey);
const architect = new Architect(llmProvider, coreRepo);
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
const list = ProviderManager.list();
const active = ProviderManager.getActive() || activeInstance;
const mappings = ProviderManager.getMappings();
const resolveProviderForTask = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst) {
inst = active;
}
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
const providerName = inst ? inst.providerName : "google-genai";
if (providerName === "google-genai") {
return new GeminiProvider(key);
} else {
return new MockLLMProvider([]);
}
};
const actorProvider = resolveProviderForTask("actor-prose");
const validatorProvider = resolveProviderForTask("llm-validator");
const decoderProvider = resolveProviderForTask("intent-decoder");
const timedeltaProvider = resolveProviderForTask("timedelta");
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
@@ -219,12 +264,16 @@ class SimulationManager {
entities: entityInfos,
playerEntityId,
entityIndex: 0,
llmProvider,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
architect,
aliasGenerator,
log: [],
status: "running",
aliasDoneForTurn: false,
providerMappings: mappings,
};
this.sessions.set(id, session);
@@ -300,15 +349,13 @@ class SimulationManager {
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
const playerActor = new ActorAgent(
session.llmProvider,
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
20,
new FixedProseGenerator(prose),
);
const startCallIdx = session.llmProvider.lastCalls.length;
const result = await playerActor.act(worldState, entity);
const endCallIdx = session.llmProvider.lastCalls.length;
const entry: LogEntry = {
turn: session.turn,
@@ -323,8 +370,8 @@ class SimulationManager {
},
};
if (endCallIdx > startCallIdx) {
const call = session.llmProvider.lastCalls[startCallIdx];
if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
const call = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
entry.decoderPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
@@ -441,10 +488,12 @@ class SimulationManager {
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const actor = new ActorAgent(session.llmProvider, session.bufferRepo, 20);
const startCallIdx = session.llmProvider.lastCalls.length;
const actor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
20,
);
const result = await actor.act(worldState, entity);
const endCallIdx = session.llmProvider.lastCalls.length;
const entry: LogEntry = {
turn: session.turn,
@@ -455,28 +504,22 @@ class SimulationManager {
timestamp: worldState.clock.get().toISOString(),
};
if (endCallIdx - startCallIdx >= 2) {
const actorCall = session.llmProvider.lastCalls[startCallIdx];
const decoderCall = session.llmProvider.lastCalls[startCallIdx + 1];
if (session.actorProvider.lastCalls && session.actorProvider.lastCalls.length > 0) {
const actorCall = session.actorProvider.lastCalls[session.actorProvider.lastCalls.length - 1];
entry.rawPrompt = {
systemPrompt: actorCall.systemPrompt,
userContext: actorCall.userContext,
};
entry.usage = actorCall.usage;
}
if (session.decoderProvider.lastCalls && session.decoderProvider.lastCalls.length > 0) {
const decoderCall = session.decoderProvider.lastCalls[session.decoderProvider.lastCalls.length - 1];
entry.decoderPrompt = {
systemPrompt: decoderCall.systemPrompt,
userContext: decoderCall.userContext,
};
entry.decoderUsage = decoderCall.usage;
} else if (endCallIdx - startCallIdx === 1) {
const call = session.llmProvider.lastCalls[startCallIdx];
entry.rawPrompt = {
systemPrompt: call.systemPrompt,
userContext: call.userContext,
};
entry.usage = call.usage;
}
for (const intent of result.intents.intents) {
@@ -604,17 +647,47 @@ class SimulationManager {
return null;
}
const apiKey = process.env.GOOGLE_API_KEY;
if (!apiKey) {
db.close();
throw new Error("GOOGLE_API_KEY is not set.");
}
const list = ProviderManager.list();
const active = ProviderManager.getActive();
const mappings = state.providerMappings || {};
const resolveProviderForTask = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst = mappedId ? list.find((p) => p.id === mappedId) : null;
if (!inst) {
inst = active;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create("Default (Env)", "google-genai", envKey);
}
}
if (!inst) {
throw new Error(`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`);
}
if (inst.providerName === "google-genai") {
return new GeminiProvider(inst.apiKey);
} else {
return new MockLLMProvider([]);
}
};
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const llmProvider = new GeminiProvider(apiKey);
const architect = new Architect(llmProvider, coreRepo);
const aliasGenerator = new AliasDeltaGenerator(llmProvider);
const actorProvider = resolveProviderForTask("actor-prose");
const validatorProvider = resolveProviderForTask("llm-validator");
const decoderProvider = resolveProviderForTask("intent-decoder");
const timedeltaProvider = resolveProviderForTask("timedelta");
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
@@ -629,7 +702,10 @@ class SimulationManager {
entities: state.entities || [],
playerEntityId: state.playerEntityId,
entityIndex: state.entityIndex,
llmProvider,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
architect,
aliasGenerator,
log: state.log || [],
@@ -637,6 +713,7 @@ class SimulationManager {
error: state.error,
waitingEntity: state.waitingEntity,
aliasDoneForTurn: state.aliasDoneForTurn || false,
providerMappings: mappings,
};
this.sessions.set(id, session);
@@ -710,6 +787,7 @@ class SimulationManager {
waitingEntity: session.waitingEntity,
aliasDoneForTurn: session.aliasDoneForTurn,
log: session.log,
providerMappings: session.providerMappings,
};
session.db.prepare(`

View File

@@ -68,15 +68,29 @@ export class ActorAgent {
private decoder: IntentDecoder;
private generator: IActorProseGenerator;
private llmProvider: ILLMProvider;
constructor(
private llmProvider: ILLMProvider,
llmProvider: ILLMProvider | { actor: ILLMProvider; decoder: ILLMProvider },
bufferRepo?: BufferRepository,
memoryLimit?: number,
generator?: IActorProseGenerator,
) {
let actorProv: ILLMProvider;
let decoderProv: ILLMProvider;
if ("actor" in llmProvider && "decoder" in llmProvider) {
actorProv = llmProvider.actor;
decoderProv = llmProvider.decoder;
} else {
actorProv = llmProvider;
decoderProv = llmProvider;
}
this.promptBuilder = new ActorPromptBuilder(bufferRepo, memoryLimit);
this.decoder = new IntentDecoder(llmProvider);
this.generator = generator ?? new LLMActorProseGenerator(llmProvider);
this.decoder = new IntentDecoder(decoderProv);
this.generator = generator ?? new LLMActorProseGenerator(actorProv);
this.llmProvider = actorProv;
}
/**

View File

@@ -12,9 +12,23 @@ export class Architect {
private validator: LLMValidator;
private timeDeltaGenerator: TimeDeltaGenerator;
constructor(llmProvider: ILLMProvider, private repo?: SQLiteRepository) {
this.validator = new LLMValidator(llmProvider);
this.timeDeltaGenerator = new TimeDeltaGenerator(llmProvider);
constructor(
llmProvider: ILLMProvider | { validator: ILLMProvider; timedelta: ILLMProvider },
private repo?: SQLiteRepository,
) {
let valProv: ILLMProvider;
let timeProv: ILLMProvider;
if ("validator" in llmProvider && "timedelta" in llmProvider) {
valProv = llmProvider.validator;
timeProv = llmProvider.timedelta;
} else {
valProv = llmProvider;
timeProv = llmProvider;
}
this.validator = new LLMValidator(valProv);
this.timeDeltaGenerator = new TimeDeltaGenerator(timeProv);
}
/**

View File

@@ -36,3 +36,11 @@ export interface ILLMProvider {
): Promise<LLMResponse<z.infer<T>>>;
lastCalls?: LLMCallRecord[];
}
export interface LLMProviderInstance {
id: string;
name: string;
providerName: string;
apiKey: string;
isActive: boolean;
}