7 Commits

29 changed files with 3101 additions and 1129 deletions

View File

@@ -17,7 +17,7 @@
The <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code. Each character's knowledge, memory, and emotional state are subjective and partial by construction.
<p align="center">
<img src="./web/docs/src/assets/img/puppet.webp" />
<img src="./web/docs/src/assets/img/puppet.webp" alt="This pixel-art style image, set against a black background with the title 'The Puppet Master Paradox' at the top, depicts two identical, stylized puppets with white, round heads and orange patterned bodies facing each other. Between them hovers a small, glowing, four-pointed star, while each puppet has an orange-outlined speech bubble above it: the left one states, 'Good evening. Welcome to my humble bakery!', and the right one replies, 'Nice to meet you Assassin Bob. Wait... wha-', concluding with a small white 'X' icon in the bottom right corner." />
</p>
Single-agent or single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
@@ -41,7 +41,7 @@ Omnia answers every one of these failures with the same move: **pull the thing t
## What this buys you
<p align="center">
<img src="./web/docs/src/assets/img/features.webp" />
<img src="./web/docs/src/assets/img/features.webp" alt="This pixel-art image features a grid of six distinct panels, each with an orange-outlined, jagged border, illustrating different concepts: 'Emergent Deceit' shows a person hiding a sword behind their back while offering a rose to another person; 'Divergent Perceptions' depicts two figures sitting at a table with speech bubbles labeled 'A' and 'B'; 'Player Agnostic' shows three figures engaged in different activities—watering a plant, standing still, and juggling—under the plumbob symbol from the sims; 'State Validated Agency' displays a stylized symbol of a person partially inside a portal crossed out by a large red 'X'; 'Deterministic Time' shows four circular panels representing a day-night cycle connected by arrows and clock icons; and 'Bring Your Own Model' features a large, central omnia icon surrounded by various LLM model logos including chatgpt, mistral, deepseek, claude, etc****" />
</p>
The payoff is scenario complexity that **uni-agent systems structurally cannot represent, no matter how good the model gets**.

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -254,6 +254,7 @@ export async function createProviderInstance(
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): Promise<ModelProviderInstance> {
return ProviderManager.create(
name,
@@ -262,6 +263,7 @@ export async function createProviderInstance(
modelName,
type,
maxContext,
endpointUrl,
);
}
@@ -281,6 +283,7 @@ export async function updateProviderInstance(
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): Promise<void> {
ProviderManager.update(
id,
@@ -290,6 +293,7 @@ export async function updateProviderInstance(
modelName,
type,
maxContext,
endpointUrl,
);
}

View File

@@ -1,18 +1,5 @@
"use client";
import { BuilderView } from "@/components/builder/BuilderView";
export default function BuilderPage() {
return (
<div className="flex-1 overflow-y-auto w-full">
<div className="mx-auto max-w-[800px] px-10 py-12">
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
Scenario Builder
</h1>
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[300px] flex flex-col items-center justify-center">
<p className="text-body-md text-muted-foreground font-mono text-center">
Scenario builder interface coming soon...
</p>
</div>
</div>
</div>
);
return <BuilderView />;
}

View File

@@ -1,5 +1,5 @@
import { DashboardView } from "@/components/play/DashboardView";
import { HomeView } from "@/components/home/HomeView";
export default function Home() {
return <DashboardView />;
return <HomeView />;
}

View File

@@ -0,0 +1,18 @@
"use client";
export function BuilderView() {
return (
<div className="flex-1 overflow-y-auto w-full">
<div className="mx-auto max-w-[800px] px-10 py-12">
<h1 className="mb-6 text-headline-lg text-primary animate-fade-in">
Scenario Builder
</h1>
<div className="border border-border/30 bg-card p-6 shadow-[2px_2px_0_0_var(--border)] min-h-[300px] flex flex-col items-center justify-center">
<p className="text-body-md text-muted-foreground font-mono text-center">
Scenario builder interface coming soon...
</p>
</div>
</div>
</div>
);
}

View File

@@ -64,6 +64,7 @@ export function ProviderInstancesConfig({
"generative",
);
const [editMaxContext, setEditMaxContext] = useState<number>(32768);
const [editEndpointUrl, setEditEndpointUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -76,6 +77,7 @@ export function ProviderInstancesConfig({
setEditIsActive(false);
setEditType("generative");
setEditMaxContext(32768);
setEditEndpointUrl("");
} else if (selectedInstanceId === "new") {
setEditName("");
const defaultProvider = "google-genai";
@@ -86,6 +88,7 @@ export function ProviderInstancesConfig({
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
setEditIsActive(false);
setEditMaxContext(32768);
setEditEndpointUrl("");
} else {
const inst = instances.find((i) => i.id === selectedInstanceId);
if (inst) {
@@ -109,6 +112,7 @@ export function ProviderInstancesConfig({
? inst.maxContext
: 32768,
);
setEditEndpointUrl(inst.endpointUrl || "");
}
}
}, [selectedInstanceId, instances, availableProviders]);
@@ -149,7 +153,7 @@ export function ProviderInstancesConfig({
let targetInstanceId = selectedInstanceId;
if (selectedInstanceId === "new") {
if (!editKey.trim()) {
if (editProvider !== "ollama" && !editKey.trim()) {
setError("API Key is required for new instances.");
setLoading(false);
return;
@@ -157,10 +161,13 @@ export function ProviderInstancesConfig({
const created = await createProviderInstance(
editName,
editProvider,
editKey,
editProvider === "ollama" ? "none" : editKey,
editModel || undefined,
editType,
editType === "generative" ? editMaxContext : 0,
editProvider === "ollama"
? editEndpointUrl || "http://localhost:11434"
: undefined,
);
if (editIsActive) {
await setActiveProviderInstance(created.id);
@@ -194,10 +201,13 @@ export function ProviderInstancesConfig({
selectedInstanceId,
editName,
editProvider,
editKey || undefined,
editProvider === "ollama" ? "none" : editKey || undefined,
editModel || undefined,
editType,
editType === "generative" ? editMaxContext : 0,
editProvider === "ollama"
? editEndpointUrl || "http://localhost:11434"
: undefined,
);
if (editIsActive) {
await setActiveProviderInstance(selectedInstanceId);
@@ -336,11 +346,11 @@ export function ProviderInstancesConfig({
}
items={[
{
label: "Generative (Text Completion)",
label: "Generative (Text Generation)",
value: "generative",
},
{
label: "Embedding (Vector generation)",
label: "Embedding (Vector Embeddings)",
value: "embedding",
},
]}
@@ -351,10 +361,10 @@ export function ProviderInstancesConfig({
<SelectContent>
<SelectGroup>
<SelectItem value="generative">
Generative (Chat / Text Completion)
Generative (Text Generation)
</SelectItem>
<SelectItem value="embedding">
Embedding (Vector generation)
Embedding (Vector Embeddings)
</SelectItem>
</SelectGroup>
</SelectContent>
@@ -398,21 +408,36 @@ export function ProviderInstancesConfig({
</span>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="formKey">API Key</Label>
<Input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
/>
</div>
{editProvider !== "ollama" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="formKey">API Key</Label>
<Input
id="formKey"
type="password"
value={editKey}
onChange={(e) => setEditKey(e.target.value)}
placeholder={
selectedInstanceId === "new"
? "AIzaSy..."
: "•••••••• (unchanged)"
}
required={selectedInstanceId === "new"}
/>
</div>
)}
{editProvider === "ollama" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="formEndpoint">Endpoint URL</Label>
<Input
id="formEndpoint"
value={editEndpointUrl}
onChange={(e) => setEditEndpointUrl(e.target.value)}
placeholder="e.g. http://localhost:11434"
required
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="formModel">Model Name</Label>

View File

@@ -34,7 +34,7 @@ import {
SelectValue,
} from "@/components/ui/select";
export function DashboardView() {
export function HomeView() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loadingData, setLoadingData] = useState(true);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
import { HandoffEngine, checkHandoffTrigger } from "@omnia/memory";
import type { SimSession } from "./types";
/**
* Runs the HandoffEngine for every agent entity that has accumulated enough
* buffer entries to warrant a handoff (compression to long-term memory).
*/
export async function runHandoffResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const handoffEngine = new HandoffEngine(
session.handoffProvider,
session.embeddingProvider,
session.bufferRepo,
session.ledgerRepo,
);
const entities = Array.from(worldState.entities.values());
for (const entity of entities) {
if (!entity.isAgent) continue;
const bufferEntries = session.bufferRepo.listForOwner(entity.id);
const maxContext =
session.handoffProvider.maxContext !== undefined
? session.handoffProvider.maxContext
: 32768;
const trigger = checkHandoffTrigger(
entity,
bufferEntries,
worldState.clock.get(),
maxContext,
);
if (trigger !== "none") {
await handoffEngine.runHandoff(
entity,
bufferEntries,
worldState.clock.get(),
);
}
}
}
/**
* For every agent that shares a location with another entity they haven't
* previously encountered, generates a first-person alias description and
* persists it on the viewing entity.
*/
export async function runAliasResolution(session: SimSession): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entities = Array.from(worldState.entities.values());
for (const viewer of entities) {
if (!viewer.isAgent) continue;
if (!viewer.locationId) continue;
for (const target of entities) {
if (viewer.id === target.id) continue;
if (
target.locationId === viewer.locationId &&
!viewer.aliases.has(target.id)
) {
const alias = await session.aliasGenerator.generate(viewer, target);
viewer.aliases.set(target.id, alias);
session.coreRepo.saveEntity(viewer, worldState.id);
}
}
}
}

View File

@@ -0,0 +1,16 @@
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// Load .env from monorepo root or apps/gui/
const cwd = process.cwd();
const envCandidates = [
path.resolve(cwd, ".env"),
path.resolve(cwd, "../../.env"),
];
for (const c of envCandidates) {
if (fs.existsSync(c) && fs.statSync(c).isFile()) {
dotenv.config({ path: c });
break;
}
}

View File

@@ -0,0 +1,17 @@
/**
* Barrel entry point for the simulation module.
*
* Consumers import from "@/lib/simulation" exactly as before — no import
* paths need to change anywhere in the codebase.
*/
import { SimulationManager } from "./simulation-manager";
export const simulationManager = new SimulationManager();
export type {
SimSnapshot,
EntityInfo,
LogEntry,
IntentInfo,
WaitingContext,
} from "../simulation-types";

View File

@@ -0,0 +1,205 @@
import {
GeminiProvider,
MockLLMProvider,
OllamaProvider,
OllamaEmbeddingProvider,
ProviderManager,
OpenRouterProvider,
AnthropicProvider,
OpenAIProvider,
OpenAIEmbeddingProvider,
GeminiEmbeddingProvider,
MockEmbeddingProvider,
} from "@omnia/llm";
import type {
ILLMProvider,
IEmbeddingProvider,
ModelProviderInstance,
} from "@omnia/llm";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface ResolvedProviders {
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
}
export interface ProviderResolverOptions {
/**
* Pre-resolved generative instance to fall back to when ProviderManager has
* no active generative provider (e.g. when the caller already validated a
* specific provider during session creation).
*/
fallbackInstance?: ModelProviderInstance | null;
/**
* When true, throws an Error if no provider can be resolved for a task.
* When false (default), falls back silently to MockLLMProvider / MockEmbeddingProvider.
*/
required?: boolean;
}
// ---------------------------------------------------------------------------
// Private builders
// ---------------------------------------------------------------------------
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,
);
}
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
// ---------------------------------------------------------------------------
/**
* Resolves all six LLM + embedding providers needed for a simulation session.
*
* Resolution order for each generative task:
* 1. Task-specific mapping from ProviderManager (via `mappings[task]`)
* 2. ProviderManager active generative instance
* 3. `fallbackInstance` (if supplied)
* 4. GOOGLE_API_KEY env var → auto-creates a temporary GeminiProvider
* 5. Throws (if `required`) or returns MockLLMProvider
*/
export function resolveProviders(
mappings: Record<string, string>,
options: ProviderResolverOptions = {},
): ResolvedProviders {
const { fallbackInstance = null, required = false } = options;
const list = ProviderManager.list();
const activeGenerative =
ProviderManager.getActive("generative") ?? fallbackInstance ?? null;
const resolveGenerative = (task: string): ILLMProvider => {
const mappedId = mappings[task];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "generative") {
inst = activeGenerative;
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`,
);
}
return new MockLLMProvider([]);
}
return buildLLMProvider(inst);
};
const resolveEmbedding = (): IEmbeddingProvider => {
const mappedId = mappings["embeddings"];
let inst: ModelProviderInstance | null = mappedId
? (list.find((p) => p.id === mappedId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
inst = ProviderManager.getActive("embedding");
}
if (!inst) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
inst = ProviderManager.create(
"Default Embed (Env)",
"google-genai",
envKey,
"gemini-embedding-001",
"embedding",
);
}
}
if (!inst) {
if (required) {
throw new Error(
`No active Embedding Provider Instance found. Please configure an embedding key in Settings first.`,
);
}
return new MockEmbeddingProvider(undefined);
}
return buildEmbeddingProvider(inst);
};
return {
actorProvider: resolveGenerative("actor-prose"),
validatorProvider: resolveGenerative("llm-validator"),
decoderProvider: resolveGenerative("intent-decoder"),
timedeltaProvider: resolveGenerative("timedelta"),
handoffProvider: resolveGenerative("handoff"),
embeddingProvider: resolveEmbedding(),
};
}

View File

@@ -0,0 +1,139 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import type { SimSession, SavedState } from "./types";
import type { SimSnapshot } from "../simulation-types";
export const DATA_DIR = path.resolve(process.cwd(), "data");
// ---------------------------------------------------------------------------
// Low-level read/write helpers
// ---------------------------------------------------------------------------
export function loadSessionState(
db: Database.Database,
id: string,
): SavedState | null {
try {
db.prepare(
`CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)`,
).run();
const row = db
.prepare(`SELECT state_json FROM gui_meta WHERE id = ?`)
.get(id) as { state_json: string } | undefined;
return row ? (JSON.parse(row.state_json) as SavedState) : null;
} catch {
return null;
}
}
export function saveSession(session: SimSession): void {
const state: SavedState = {
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
turn: session.turn,
maxTurns: session.maxTurns,
entities: session.entities,
playerEntityId: session.playerEntityId,
entityIndex: session.entityIndex,
status: session.status,
error: session.error,
waitingEntity: session.waitingEntity,
aliasDoneForTurn: session.aliasDoneForTurn,
log: session.log,
providerMappings: session.providerMappings,
};
session.db
.prepare(
`CREATE TABLE IF NOT EXISTS gui_meta (
id TEXT PRIMARY KEY,
state_json TEXT
)`,
)
.run();
session.db
.prepare(
`INSERT INTO gui_meta (id, state_json)
VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json`,
)
.run(session.worldInstanceId, JSON.stringify(state));
}
// ---------------------------------------------------------------------------
// Session file management
// ---------------------------------------------------------------------------
export function deleteSessionFile(id: string): void {
const dbPath = path.join(DATA_DIR, `${id}.db`);
if (fs.existsSync(dbPath)) {
try {
fs.unlinkSync(dbPath);
} catch (err) {
console.error(`Failed to delete session file ${dbPath}:`, err);
}
}
}
/**
* Lists all saved simulation snapshots by scanning the data directory.
* Active in-memory sessions are snapshotted via the provided callback;
* inactive ones are read directly from their `.db` files.
*/
export function listSavedSessions(
activeSessions: Map<string, SimSession>,
snapshotFn: (session: SimSession) => SimSnapshot,
): SimSnapshot[] {
if (!fs.existsSync(DATA_DIR)) return [];
const snapshots: SimSnapshot[] = [];
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
for (const file of files) {
const id = file.replace(".db", "");
const dbPath = path.join(DATA_DIR, file);
const active = activeSessions.get(id);
if (active) {
snapshots.push(snapshotFn(active));
continue;
}
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
db.close();
if (state) {
snapshots.push({
id,
status: state.status,
turn: state.turn,
maxTurns: state.maxTurns,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
entities: state.entities || [],
log: state.log || [],
entityIndex: state.entityIndex,
waitingEntity: state.waitingEntity,
error: state.error,
});
}
} catch {
/* skip corrupt / in-use db files */
}
}
return snapshots.sort((a, b) => {
const tsA = parseInt(a.id.replace("sim-", ""), 10) || 0;
const tsB = parseInt(b.id.replace("sim-", ""), 10) || 0;
return tsB - tsA;
});
}

View File

@@ -0,0 +1,455 @@
import "./env"; // Must be first — loads .env before any code reads process.env
import Database from "better-sqlite3";
import path from "path";
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 type { ModelProviderInstance, IEmbeddingProvider } from "@omnia/llm";
import { ScenarioLoader } from "@omnia/scenario";
import type { SimSnapshot } from "../simulation-types";
import type { SimSession, EntityInfo } from "./types";
import { resolveProviders } from "./provider-resolver";
import {
DATA_DIR,
loadSessionState,
saveSession,
listSavedSessions,
deleteSessionFile,
} from "./session-store";
import {
preparePlayerTurn,
processNpcTurn,
executePlayerAction,
} from "./turn-executor";
import { runAliasResolution, runHandoffResolution } from "./alias-handoff";
export class SimulationManager {
private sessions = new Map<string, SimSession>();
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
async create(
scenarioPath: string,
playEntityName?: string,
providerInstanceId?: string,
): Promise<SimSnapshot> {
// Resolve or validate the active generative provider upfront so we can
// return a clean error snapshot before touching the filesystem.
let activeInstance: ModelProviderInstance | null = providerInstanceId
? ProviderManager.list().find((p) => p.id === providerInstanceId) || null
: ProviderManager.getActive("generative");
if (!activeInstance) {
const envKey = process.env.GOOGLE_API_KEY;
if (envKey) {
activeInstance = ProviderManager.create(
"Default (Env)",
"google-genai",
envKey,
undefined,
"generative",
);
}
}
if (!activeInstance) {
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error:
"No active LLM Provider Instance found. Please configure a key in Settings first.",
};
}
const scenarioJson = JSON.parse(fs.readFileSync(scenarioPath, "utf-8"));
const id = `sim-${Date.now()}`;
fs.mkdirSync(DATA_DIR, { recursive: true });
const dbPath = path.join(DATA_DIR, `${id}.db`);
const db = new Database(dbPath);
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const loader = new ScenarioLoader(coreRepo, bufferRepo);
const worldInstanceId = id;
await loader.initializeWorld(scenarioJson, worldInstanceId);
const worldState = coreRepo.loadWorldState(worldInstanceId);
if (!worldState) {
db.close();
return {
id: "",
status: "error",
turn: 0,
maxTurns: 20,
scenarioName: "",
scenarioDescription: "",
entities: [],
log: [],
entityIndex: 0,
error: "Failed to load world state after initialization.",
};
}
// Build entity list
const rawEntities = Array.from(worldState.entities.values());
const entityInfos: EntityInfo[] = rawEntities.map((e) => ({
id: e.id,
name: (e.attributes.get("name")?.getValue() as string) || e.id,
isPlayer: false,
isAgent: e.isAgent,
}));
// Resolve player entity (exact match → name match → fuzzy)
let playerEntityId: string | undefined;
if (playEntityName) {
let matched = worldState.getEntity(playEntityName);
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
string | undefined;
if (nameAttr?.toLowerCase() === playEntityName.toLowerCase()) {
matched = ent;
break;
}
}
}
if (!matched) {
for (const ent of rawEntities) {
const nameAttr = ent.attributes.get("name")?.getValue() as
string | undefined;
if (
nameAttr?.toLowerCase().includes(playEntityName.toLowerCase()) ||
ent.id.toLowerCase().includes(playEntityName.toLowerCase())
) {
matched = ent;
break;
}
}
}
if (matched) {
playerEntityId = matched.id;
const info = entityInfos.find((e) => e.id === matched!.id);
if (info) info.isPlayer = true;
}
}
const mappings = ProviderManager.getMappings();
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { fallbackInstance: activeInstance });
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId,
scenarioName: scenarioJson.name,
scenarioDescription: scenarioJson.description || "",
turn: 1,
maxTurns: 20,
entities: entityInfos,
playerEntityId,
entityIndex: 0,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: [],
status: "running",
aliasDoneForTurn: false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
}
async load(id: string): Promise<SimSnapshot | null> {
const active = this.sessions.get(id);
if (active) return this.snapshot(active);
const dbPath = path.join(DATA_DIR, `${id}.db`);
if (!fs.existsSync(dbPath)) return null;
try {
const db = new Database(dbPath);
const state = loadSessionState(db, id);
if (!state) {
db.close();
return null;
}
const mappings = state.providerMappings || {};
const {
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
} = resolveProviders(mappings, { required: true });
const coreRepo = new SQLiteRepository(db);
const bufferRepo = new BufferRepository(db);
const ledgerRepo = new LedgerRepository(db);
const architect = new Architect(
{ validator: validatorProvider, timedelta: timedeltaProvider },
coreRepo,
);
const aliasGenerator = new AliasDeltaGenerator(actorProvider);
const session: SimSession = {
db,
dbPath,
coreRepo,
bufferRepo,
ledgerRepo,
worldInstanceId: id,
scenarioName: state.scenarioName,
scenarioDescription: state.scenarioDescription,
turn: state.turn,
maxTurns: state.maxTurns,
entities: state.entities || [],
playerEntityId: state.playerEntityId,
entityIndex: state.entityIndex,
actorProvider,
validatorProvider,
decoderProvider,
timedeltaProvider,
handoffProvider,
embeddingProvider,
architect,
aliasGenerator,
log: state.log || [],
status: state.status,
error: state.error,
waitingEntity: state.waitingEntity,
aliasDoneForTurn: state.aliasDoneForTurn || false,
providerMappings: mappings,
};
this.sessions.set(id, session);
return this.snapshot(session);
} catch (err) {
console.error(`Failed to load session ${id}:`, err);
return null;
}
}
close(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
}
deleteSession(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.db.close();
this.sessions.delete(id);
}
deleteSessionFile(id);
}
listSavedSessions(): SimSnapshot[] {
return listSavedSessions(this.sessions, (s) => this.snapshot(s));
}
getSnapshot(id: string): SimSnapshot | null {
const session = this.sessions.get(id);
return session ? this.snapshot(session) : null;
}
// ---------------------------------------------------------------------------
// Simulation stepping
// ---------------------------------------------------------------------------
async step(id: string): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "running") return this.snapshot(session);
try {
if (session.turn > session.maxTurns) {
session.status = "done";
saveSession(session);
return this.snapshot(session);
}
// Start of turn: alias + handoff resolution before any entity acts
if (!session.aliasDoneForTurn && session.entityIndex === 0) {
await runAliasResolution(session);
await runHandoffResolution(session);
session.aliasDoneForTurn = true;
saveSession(session);
return this.snapshot(session);
}
// End of turn: advance to next turn
if (session.entityIndex >= session.entities.length) {
session.turn++;
session.entityIndex = 0;
session.aliasDoneForTurn = false;
saveSession(session);
return this.snapshot(session);
}
const info = session.entities[session.entityIndex];
if (!info.isAgent) {
session.entityIndex++;
saveSession(session);
return this.snapshot(session);
}
if (info.isPlayer) {
await preparePlayerTurn(session, info);
saveSession(session);
return this.snapshot(session);
}
await processNpcTurn(session, info);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(session);
return this.snapshot(session);
}
async submitPlayerAction(
id: string,
prose: string,
): Promise<SimSnapshot | null> {
const session = this.sessions.get(id);
if (!session) return null;
if (session.status !== "waiting_player") return this.snapshot(session);
if (!session.waitingEntity) return this.snapshot(session);
const ctx = session.waitingEntity;
session.waitingEntity = undefined;
session.status = "running";
try {
await executePlayerAction(session, ctx, prose);
session.entityIndex++;
} catch (err) {
session.status = "error";
session.error = err instanceof Error ? err.message : String(err);
}
saveSession(session);
return this.snapshot(session);
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
async regenerateAllEmbeddings(newProviderInstanceId?: string): Promise<void> {
if (!fs.existsSync(DATA_DIR)) return;
const files = fs
.readdirSync(DATA_DIR)
.filter((f) => f.startsWith("sim-") && f.endsWith(".db"));
const list = ProviderManager.list();
let inst = newProviderInstanceId
? (list.find((p) => p.id === newProviderInstanceId) ?? null)
: null;
if (!inst || inst.type !== "embedding") {
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;
const embeddingProvider: IEmbeddingProvider =
providerName === "google-genai"
? new GeminiEmbeddingProvider(key, modelName)
: new MockEmbeddingProvider(modelName);
for (const file of files) {
const dbPath = path.join(DATA_DIR, file);
const fileId = file.replace(".db", "");
const activeSession = this.sessions.get(fileId);
const db = activeSession ? activeSession.db : new Database(dbPath);
try {
const rows = db
.prepare(`SELECT id, content FROM ledger_entries`)
.all() as { id: string; content: string }[];
for (const row of rows) {
const vector = await embeddingProvider.embed(row.content);
const buffer = Buffer.from(new Float32Array(vector).buffer);
db.prepare(
`UPDATE ledger_entries SET embedding = ? WHERE id = ?`,
).run(buffer, row.id);
}
} catch (err) {
console.error(`Failed to regenerate embeddings for ${file}:`, err);
} finally {
if (!activeSession) db.close();
}
}
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
private snapshot(session: SimSession): SimSnapshot {
return {
id: session.worldInstanceId,
status: session.status,
turn: session.turn,
maxTurns: session.maxTurns,
scenarioName: session.scenarioName,
scenarioDescription: session.scenarioDescription,
entities: session.entities,
log: session.log,
entityIndex: session.entityIndex,
waitingEntity: session.waitingEntity,
error: session.error,
};
}
}

View File

@@ -0,0 +1,277 @@
import {
ActorAgent,
ActorPromptBuilder,
buildBufferEntryForIntent,
} from "@omnia/actor";
import type { IActorProseGenerator } from "@omnia/actor";
import type { SimSession } from "./types";
import type {
EntityInfo,
IntentInfo,
LogEntry,
WaitingContext,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Prose generator that returns a fixed player-supplied string verbatim. */
class FixedProseGenerator implements IActorProseGenerator {
constructor(private prose: string) {}
async generate(
entityId: string,
systemPrompt: string,
userContext: string,
): Promise<string> {
void entityId;
void systemPrompt;
void userContext;
return this.prose;
}
}
/**
* Processes every intent produced by an actor turn:
* - Validates via Architect
* - Appends to actor's own buffer
* - Fan-outs to co-located observers for dialogue/action intents
*
* Extracted to eliminate verbatim duplication between NPC and player paths.
*/
async function processIntents(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
intents: any[],
actorEntityId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
entity: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
worldState: any,
session: SimSession,
): Promise<IntentInfo[]> {
const intentInfos: IntentInfo[] = [];
for (const intent of intents) {
const outcome = await session.architect.processIntent(worldState, intent);
const ts = worldState.clock.get().toISOString();
intentInfos.push({
type: intent.type,
description: intent.description,
selfDescription: intent.selfDescription,
modifiers: intent.modifiers || [],
targetIds: intent.targetIds,
isValid: outcome.isValid,
reason: outcome.reason,
minutesToAdvance: outcome.timeDelta?.minutesToAdvance,
});
const actorEntry = buildBufferEntryForIntent(intent, ts, entity.locationId);
if (intent.type === "action") {
actorEntry.outcome = { isValid: outcome.isValid, reason: outcome.reason };
}
session.bufferRepo.save(actorEntry);
// Fan-out observable events to co-located entities
if (
entity.locationId &&
(intent.type === "dialogue" || intent.type === "action")
) {
for (const [, other] of worldState.entities) {
if (
other.id !== actorEntityId &&
other.locationId === entity.locationId
) {
const observerEntry = buildBufferEntryForIntent(
intent,
ts,
entity.locationId,
);
if (intent.type === "action") {
observerEntry.outcome = {
isValid: outcome.isValid,
reason: outcome.reason,
};
}
session.bufferRepo.save({ ...observerEntry, ownerId: other.id });
}
}
}
}
return intentInfos;
}
// ---------------------------------------------------------------------------
// Exported turn functions
// ---------------------------------------------------------------------------
/**
* Builds the prompt for the player entity and sets the session to
* `waiting_player` so the next client call can supply the prose.
*/
export async function preparePlayerTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const promptBuilder = new ActorPromptBuilder(
session.bufferRepo,
session.ledgerRepo,
20,
);
const { systemPrompt, userContext } = promptBuilder.build(worldState, entity);
session.waitingEntity = {
entityId: info.id,
name: info.name,
systemPrompt,
userContext,
};
session.status = "waiting_player";
}
/**
* Runs an autonomous NPC turn: generates prose via ActorAgent, validates
* and persists all intents, and appends a LogEntry to the session.
*/
export async function processNpcTurn(
session: SimSession,
info: EntityInfo,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(info.id);
if (!entity) throw new Error(`Entity "${info.id}" not found`);
const actor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
);
const result = await actor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: info.id,
entityName: info.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
};
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;
}
entry.intents = await processIntents(
result.intents.intents,
info.id,
entity,
worldState,
session,
);
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}
/**
* Executes the player's turn using the prose they supplied.
* Uses a `FixedProseGenerator` so the ActorAgent bypasses its LLM call and
* returns the player's text directly.
*/
export async function executePlayerAction(
session: SimSession,
ctx: WaitingContext,
prose: string,
): Promise<void> {
const worldState = session.coreRepo.loadWorldState(session.worldInstanceId);
if (!worldState) throw new Error("World state lost");
const entity = worldState.getEntity(ctx.entityId);
if (!entity) throw new Error(`Player entity "${ctx.entityId}" not found`);
const playerActor = new ActorAgent(
{ actor: session.actorProvider, decoder: session.decoderProvider },
session.bufferRepo,
session.ledgerRepo,
20,
new FixedProseGenerator(prose),
);
const result = await playerActor.act(worldState, entity);
const entry: LogEntry = {
turn: session.turn,
entityId: ctx.entityId,
entityName: ctx.name,
narrativeProse: result.narrativeProse,
intents: [],
timestamp: worldState.clock.get().toISOString(),
rawPrompt: {
systemPrompt: ctx.systemPrompt,
userContext: ctx.userContext,
},
};
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,
};
entry.decoderUsage = call.usage;
}
entry.intents = await processIntents(
result.intents.intents,
ctx.entityId,
entity,
worldState,
session,
);
session.log.push(entry);
session.coreRepo.saveWorldState(worldState);
}

View File

@@ -0,0 +1,68 @@
import type Database from "better-sqlite3";
import type { SQLiteRepository } from "@omnia/core";
import type { BufferRepository, LedgerRepository } from "@omnia/memory";
import type { Architect, AliasDeltaGenerator } from "@omnia/architect";
import type { ILLMProvider, IEmbeddingProvider } from "@omnia/llm";
import type { EntityInfo, LogEntry, WaitingContext } from "../simulation-types";
export type {
EntityInfo,
IntentInfo,
LogEntry,
SimSnapshot,
WaitingContext,
} from "../simulation-types";
// ---------------------------------------------------------------------------
// Persisted state (written to sqlite gui_meta table as JSON)
// ---------------------------------------------------------------------------
export interface SavedState {
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
log: LogEntry[];
providerMappings: Record<string, string>;
}
// ---------------------------------------------------------------------------
// In-memory session (held in SimulationManager.sessions Map)
// ---------------------------------------------------------------------------
export interface SimSession {
db: Database.Database;
dbPath: string;
coreRepo: SQLiteRepository;
bufferRepo: BufferRepository;
ledgerRepo: LedgerRepository;
worldInstanceId: string;
scenarioName: string;
scenarioDescription: string;
turn: number;
maxTurns: number;
entities: EntityInfo[];
playerEntityId: string | undefined;
entityIndex: number;
actorProvider: ILLMProvider;
validatorProvider: ILLMProvider;
decoderProvider: ILLMProvider;
timedeltaProvider: ILLMProvider;
handoffProvider: ILLMProvider;
embeddingProvider: IEmbeddingProvider;
architect: Architect;
aliasGenerator: AliasDeltaGenerator;
log: LogEntry[];
status: "running" | "waiting_player" | "done" | "error";
error?: string;
waitingEntity?: WaitingContext;
aliasDoneForTurn: boolean;
providerMappings: Record<string, string>;
}

View File

@@ -47,7 +47,10 @@
"zod": "^4.4.3"
},
"dependencies": {
"@langchain/anthropic": "^0.3.11",
"@langchain/google-genai": "^2.2.0",
"@langchain/ollama": "^0.2.3",
"@langchain/openai": "^0.3.17",
"@langchain/openrouter": "^0.4.3",
"@types/node": "^20.19.43",
"dotenv": "^17.4.2"

459
packages/llm/README.md Normal file
View File

@@ -0,0 +1,459 @@
# @omnia/llm
LLM abstraction layer providing pluggable, database-backed provider instances for generative and embedding tasks.
## Architecture Overview
The system is built around three 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
```mermaid
graph TD
subgraph Interfaces
ILP["ILLMProvider"]
IEP["IEmbeddingProvider"]
MPI["ModelProviderInstance"]
end
subgraph Concrete Providers
GP["GeminiProvider"]
ORP["OpenRouterProvider"]
MP["MockLLMProvider"]
GEP["GeminiEmbeddingProvider"]
MEP["MockEmbeddingProvider"]
end
subgraph Storage
PM["ProviderManager"]
DB[("settings.db\nprovider_instances")]
DBMAP[("settings.db\nprovider_mappings")]
end
subgraph Resolution
PR["resolveProviders()"]
end
GP -->|implements| ILP
ORP -->|implements| ILP
MP -->|implements| ILP
GEP -->|implements| IEP
MEP -->|implements| IEP
PM -->|reads/writes| DB
PM -->|reads/writes| DBMAP
PM -->|returns| MPI
PR -->|queries| PM
PR -->|instantiates| GP
PR -->|instantiates| ORP
PR -->|instantiates| GEP
PR -->|fallback| MP
PR -->|fallback| MEP
```
## Core Interfaces
Defined in [`llm.ts`](src/llm.ts):
### `ILLMProvider`
The primary contract for generative (text-to-structured-data) providers.
| Member | Type | Description |
| ---------------------------------------- | ------------------------- | ---------------------------------------------------------- |
| `providerName` | `string` | Human-readable provider label |
| `maxContext` | `number?` | Maximum context window in tokens |
| `generateStructuredResponse<T>(request)` | `Promise<LLMResponse<T>>` | Sends a prompt + Zod schema → returns parsed, typed output |
| `lastCalls` | `LLMCallRecord[]?` | Audit trail of recent calls (prompts + usage) |
### `IEmbeddingProvider`
Contract for text-to-vector embedding providers.
| Member | Type | Description |
| -------------- | ------------------- | -------------------------------------------------- |
| `providerName` | `string` | Human-readable provider label |
| `embed(text)` | `Promise<number[]>` | Returns a dense vector embedding of the input text |
### `LLMRequest<T>`
Input to `generateStructuredResponse`:
```typescript
{
systemPrompt: string; // System-level instructions
userContext: string; // User/task-specific context
schema: T; // Zod schema — output is validated against this
temperature?: number; // Sampling temperature (optional)
}
```
### `LLMResponse<T>`
Output from `generateStructuredResponse`:
```typescript
{
success: boolean;
data?: T; // Parsed, schema-validated output
error?: string; // Error message on failure
usage?: {
inputTokens: number;
outputTokens: number;
totalTokens: number;
modelName?: string;
providerInstanceName?: string;
maxContext?: number;
};
}
```
### `ModelProviderInstance`
The persisted configuration record for a single provider instance:
```typescript
{
id: string; // Unique ID ("provider-<timestamp>")
name: string; // User-facing name ("Gemini (Env)")
providerName: string; // Provider type key ("google-genai" | "openrouter" | "mock")
apiKey: string; // API key
isActive: boolean; // Whether this is the active instance for its type
modelName?: string; // Specific model to use
type: "generative" | "embedding"; // Instance category
maxContext?: number; // Context window limit
}
```
### `ModelProviderMeta`
Static metadata for each available provider type (used by the UI's provider picker):
| Member | Type | Description |
| ----------------------- | -------- | ------------------------------------------------------------ |
| `id` | `string` | `"google-genai"` \| `"openrouter"` \| `"ollama"` \| `"mock"` |
| `displayName` | `string` | Human-readable name |
| `description` | `string` | Human-readable description |
| `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 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).
### Storage
The database is auto-created on first access. The table schema:
```sql
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
);
```
A second table stores per-task provider overrides:
```sql
CREATE TABLE IF NOT EXISTS provider_mappings (
task TEXT PRIMARY KEY,
providerInstanceId TEXT NOT NULL
);
```
### API
| Method | Signature | Description |
| ------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
| `list()` | `→ ModelProviderInstance[]` | Returns all saved instances |
| `create(name, providerName, apiKey, modelName?, type?, maxContext?)` | `→ ModelProviderInstance` | Creates a new instance. Auto-activates if it's the first of its type |
| `delete(id)` | `→ void` | Removes an instance. If it was active, auto-promotes the next instance of the same type |
| `setActive(id)` | `→ void` | Deactivates all instances of the same type, then activates the target |
| `update(id, name, providerName, apiKey?, modelName?, type?, maxContext?)` | `→ void` | Updates an existing instance. If `apiKey` is empty/omitted, the existing key is preserved |
| `getActive(type?)` | `→ ModelProviderInstance \| null` | Returns the currently active instance for the given type (`"generative"` by default) |
| `getMappings()` | `→ Record<string, string>` | Returns all task → providerInstanceId mappings |
| `setMapping(task, providerInstanceId)` | `→ void` | Sets or removes (if `providerInstanceId` is empty) a task-specific mapping |
### Active Instance Invariants
- **Only one active instance per type** — `setActive()` deactivates all sibling instances before activating the target.
- **Auto-promotion on delete** — if the deleted instance was active, the first remaining instance of the same type is promoted.
- **Auto-activation on create** — if no active instance exists for the type, the new instance is automatically activated.
### Environment Variable Bootstrap
On first database access (and if the `provider_instances` table is empty), the manager auto-seeds instances from environment variables:
```mermaid
flowchart TD
A["getSettingsDb() called"] --> B{"DB has 0 rows?"}
B -- No --> Z["Return DB"]
B -- Yes --> C{"GOOGLE_API_KEY set?"}
C -- Yes --> D["Insert 'Gemini (Env)'\ntype: generative, active: true"]
D --> E["Insert 'Gemini Embed (Env)'\ntype: embedding, active: true"]
E --> F{"OPENROUTER_API_KEY set?"}
C -- No --> F
F -- Yes --> G["Insert 'OpenRouter (Env)'\ntype: generative\nactive: only if no Google key"]
F -- No --> Z
G --> Z
```
This same bootstrap logic is **duplicated** inside `getActive()` as a safety net — if the DB is empty at query time, it re-attempts the same env-var seeding.
### Fallback Chain in `getActive()`
When no active row is found for the requested type:
```
1. DB query for isActive=1 AND type=<requested>
├── Found → return it
└── Not found
├── DB is empty → bootstrap from env vars → retry query
│ ├── Found → return it
│ └── Still empty → promote first row of same type
│ ├── Found → activate & return
│ └── None → return null
└── DB has rows but none active for this type
→ promote first row of same type (same as above)
2. On any DB error (catch block) → direct env var fallback
├── GOOGLE_API_KEY → synthetic "Gemini (Env Fallback)" instance
├── OPENROUTER_API_KEY → synthetic "OpenRouter (Env Fallback)" instance
└── Neither → return null
```
## Available Providers
### Google Gemini — `GeminiProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------------ |
| **File** | [`providers/google-genai.ts`](src/providers/google-genai.ts) |
| **Provider ID** | `google-genai` |
| **SDK** | `@langchain/google-genai` (`ChatGoogleGenerativeAI`) |
| **Default Model** | `gemini-2.5-flash` |
| **Default Embedding Model** | `gemini-embedding-001` |
| **Default Max Context** | `32768` |
| **Type** | Generative |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "google-genai"
3. GOOGLE_API_KEY env var → final fallback
4. None found → throw Error
```
Also exports `GeminiEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern but querying for the `"embedding"` type.
### Anthropic Claude — `AnthropicProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------ |
| **File** | [`providers/anthropic.ts`](src/providers/anthropic.ts) |
| **Provider ID** | `anthropic` |
| **SDK** | `@langchain/anthropic` (`ChatAnthropic`) |
| **Default Model** | `claude-3-5-sonnet-latest` |
| **Default Embedding Model** | _(none)_ |
| **Default Max Context** | `200000` |
| **Type** | Generative only (no embedding provider) |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "anthropic"
3. ANTHROPIC_API_KEY env var → final fallback
4. None found → throw Error
```
### OpenAI — `OpenAIProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------------ |
| **File** | [`providers/openai.ts`](src/providers/openai.ts) |
| **Provider ID** | `openai` |
| **SDK** | `@langchain/openai` (`ChatOpenAI`, `OpenAIEmbeddings`) |
| **Default Model** | `gpt-4o-mini` |
| **Default Embedding Model** | `text-embedding-3-small` |
| **Default Max Context** | `128000` |
| **Type** | Generative + Embedding |
**Key resolution** in the constructor follows this cascade:
```
1. Explicit apiKey argument → use it
2. ProviderManager.getActive() → if providerName matches "openai"
3. OPENAI_API_KEY env var → final fallback
4. None found → throw Error
```
Also exports `OpenAIEmbeddingProvider` (implements `IEmbeddingProvider`) using the same key resolution pattern against the `"embedding"` type instance. The default embedding model is `text-embedding-3-small`.
### OpenRouter — `OpenRouterProvider`
| Property | Value |
| --------------------------- | -------------------------------------------------------- |
| **File** | [`providers/openrouter.ts`](src/providers/openrouter.ts) |
| **Provider ID** | `openrouter` |
| **SDK** | `@langchain/openrouter` (`ChatOpenRouter`) |
| **Default Model** | `google/gemini-2.5-flash` |
| **Default Embedding Model** | `openai/text-embedding-3-small` |
| **Default Max Context** | `32768` |
| **Type** | Generative only (no embedding provider) |
Same three-step key resolution as Gemini (`explicit → ProviderManager → env var`), using `OPENROUTER_API_KEY`.
### Ollama — `OllamaProvider`
| Property | Value |
| --------------------------- | ------------------------------------------------ |
| **File** | [`providers/ollama.ts`](src/providers/ollama.ts) |
| **Provider ID** | `ollama` |
| **SDK** | `@langchain/ollama` (`ChatOllama`) |
| **Default Model** | `llama3.1` |
| **Default Embedding Model** | `nomic-embed-text` |
| **Default Max Context** | `32768` |
| **Type** | Generative + Embedding |
Ollama runs **locally** — no API key is required. The `endpointUrl` field in `ModelProviderInstance` stores the Ollama server base URL (default: `http://localhost:11434`).
**Key resolution** in the constructor:
```
1. Explicit baseUrl argument → use it
2. ProviderManager.getActive() → if providerName matches "ollama"
(endpointUrl field = base URL)
3. Default → http://localhost:11434
```
Also exports `OllamaEmbeddingProvider` (implements `IEmbeddingProvider`), which uses the same resolution pattern against the `"embedding"` type instance. The default embedding model is `nomic-embed-text`.
> [!TIP]
> To get started: `ollama pull llama3.1` and `ollama pull nomic-embed-text`. Then create a provider instance with `endpointUrl` = `http://localhost:11434`.
### Mock — `MockLLMProvider`
| Property | Value |
| --------------- | -------------------------------------------- |
| **File** | [`providers/mock.ts`](src/providers/mock.ts) |
| **Provider ID** | `mock` |
| **Type** | Generative + Embedding |
Stateless mock for testing and offline development:
- **Generative** (`MockLLMProvider`): Takes an array of canned responses at construction. Returns them in order, one per call. Returns `{ success: false, error: "Mock responses exhausted" }` when depleted.
- **Embedding** (`MockEmbeddingProvider`): Returns a deterministic 768-dimensional vector derived from the input text using `Math.sin`.
## Provider Resolution (Runtime)
The [`resolveProviders()`](../../../apps/gui/src/lib/simulation/provider-resolver.ts) function (in `apps/gui`) instantiates all providers needed for a simulation session. It resolves **six** provider slots:
| Slot | Type | Task Key |
| ------------------- | ---------- | ------------------ |
| `actorProvider` | Generative | `"actor-prose"` |
| `validatorProvider` | Generative | `"llm-validator"` |
| `decoderProvider` | Generative | `"intent-decoder"` |
| `timedeltaProvider` | Generative | `"timedelta"` |
| `handoffProvider` | Generative | `"handoff"` |
| `embeddingProvider` | Embedding | `"embeddings"` |
### Generative Resolution Order
For each generative slot (`resolveGenerative(task)`):
```
1. Task-specific mapping → mappings[task] → find instance by ID
2. Active generative instance → ProviderManager.getActive("generative")
3. Fallback instance → options.fallbackInstance (if provided)
4. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
5. No provider available → throw Error (if required) or MockLLMProvider
```
### Embedding Resolution Order
For the embedding slot (`resolveEmbedding()`):
```
1. Task-specific mapping → mappings["embeddings"] → find instance by ID
2. Active embedding instance → ProviderManager.getActive("embedding")
3. GOOGLE_API_KEY env var → auto-create via ProviderManager.create()
4. No provider available → throw Error (if required) or MockEmbeddingProvider
```
### Instance → Class Mapping
The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the final dispatch:
| `providerName` | Generative Class | Embedding Class |
| ----------------- | -------------------- | ------------------------- |
| `"google-genai"` | `GeminiProvider` | `GeminiEmbeddingProvider` |
| `"openai"` | `OpenAIProvider` | `OpenAIEmbeddingProvider` |
| `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ |
| `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` |
| `"anthropic"` | `AnthropicProvider` | _(falls through to mock)_ |
| _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` |
## Structured Output
All real providers use LangChain's `.withStructuredOutput(schema, { includeRaw: true })` pattern:
```typescript
const structuredModel = this.model.withStructuredOutput(request.schema, {
includeRaw: true,
});
const result = await structuredModel.invoke([
{ role: "system", content: request.systemPrompt },
{ role: "user", content: request.userContext },
]);
```
This sends the Zod schema to the model as a structured output constraint. The response includes both `parsed` (schema-validated data) and `raw` (full API response with usage metadata).
## Configuration
[`config.ts`](src/config.ts) parses environment variables using Zod:
| Variable | Required | Description |
| -------------------- | -------- | ------------------------ |
| `GOOGLE_API_KEY` | No | Google Gemini API key |
| `OPENAI_API_KEY` | No | OpenAI API key |
| `OPENROUTER_API_KEY` | No | OpenRouter API key |
| `ANTHROPIC_API_KEY` | No | Anthropic Claude API key |
Both are optional because providers can also be configured through the database via the GUI settings page.
## File Map
```
packages/llm/
├── src/
│ ├── index.ts # Re-exports everything
│ ├── llm.ts # Interfaces, types, AVAILABLE_PROVIDERS
│ ├── config.ts # Env var parsing (Zod)
│ ├── provider-manager.ts # ProviderManager (SQLite CRUD)
│ └── providers/
│ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider
│ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider
│ ├── openrouter.ts # OpenRouterProvider
│ ├── anthropic.ts # AnthropicProvider
│ ├── openai.ts # OpenAIProvider + OpenAIEmbeddingProvider
│ └── mock.ts # MockLLMProvider + MockEmbeddingProvider
├── tests/
│ ├── mock.test.ts
│ ├── openrouter.test.ts
│ └── provider-manager.test.ts
└── package.json
```

225
packages/llm/chat-openai.md Normal file
View File

@@ -0,0 +1,225 @@
> ## Documentation Index
>
> Fetch the complete documentation index at: https://docs.langchain.com/llms.txt
> Use this file to discover all available pages before exploring further.
# OpenAI integrations
> Integrate with OpenAI using LangChain JavaScript.
LangChain integrates with OpenAI and Azure OpenAI through the `@langchain/openai` package.
> [OpenAI](https://en.wikipedia.org/wiki/OpenAI) is American artificial intelligence (AI) research laboratory
> consisting of the non-profit `OpenAI Incorporated`
> and its for-profit subsidiary corporation `OpenAI Limited Partnership`.
> OpenAI conducts AI research with the declared intention of promoting and developing a friendly AI.
> OpenAI systems run on an `Azure`-based supercomputing platform from `Microsoft`.
> The [OpenAI API](https://platform.openai.com/docs/models) is powered by a diverse set of models with different capabilities and price points.
>
> [ChatGPT](https://chat.openai.com) is the Artificial Intelligence (AI) chatbot developed by `OpenAI`.
## Installation and setup
- Get an OpenAI api key and set it as an environment variable (`OPENAI_API_KEY`)
## Chat model
See a [usage example](/oss/javascript/integrations/chat/openai).
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
import { ChatOpenAI } from "@langchain/openai";
```
## LLM
See a [usage example](/oss/javascript/integrations/llms/openai).
<Tip>
See [this section for general instructions on installing LangChain packages](/oss/javascript/langchain/install).
</Tip>
```bash npm theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
npm install @langchain/openai @langchain/core
```
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
import { OpenAI } from "@langchain/openai";
```
## Text embedding model
See a [usage example](/oss/javascript/integrations/embeddings/openai)
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
import { OpenAIEmbeddings } from "@langchain/openai";
```
## Chain
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
import { OpenAIModerationChain } from "@langchain/classic/chains";
```
## Middleware
Middleware specifically designed for OpenAI models. Learn more about [middleware](/oss/javascript/langchain/middleware/overview).
| Middleware | Description |
| ----------------------------------------- | --------------------------------------------------------- |
| [Content moderation](#content-moderation) | Moderate agent traffic using OpenAI's moderation endpoint |
### Content moderation
Moderate agent traffic (user input, model output, and tool results) using OpenAI's moderation endpoint to detect and handle unsafe content. Content moderation is useful for the following:
- Applications requiring content safety and compliance
- Filtering harmful, hateful, or inappropriate content
- Customer-facing agents that need safety guardrails
- Meeting platform moderation requirements
<Info>
Learn more about [OpenAI's moderation models](https://platform.openai.com/docs/guides/moderation) and categories.
</Info>
**API reference:** [`openAIModerationMiddleware`](https://reference.langchain.com/javascript/langchain/index/openAIModerationMiddleware)
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
import { createAgent, openAIModerationMiddleware } from "langchain";
const agent = createAgent({
model: "openai:gpt-5.5",
tools: [searchTool, databaseTool],
middleware: [
openAIModerationMiddleware({
model: "openai:gpt-5.5",
moderationModel: "omni-moderation-latest",
checkInput: true,
checkOutput: true,
exitBehavior: "end",
}),
],
});
```
<Accordion title="Configuration options">
<ParamField body="model" type="string | BaseChatModel" required>
OpenAI model to use for moderation. Can be either a model name string (e.g., `"openai:gpt-5.5"`) or a `BaseChatModel` instance. The middleware will use this model's client to access the moderation endpoint.
</ParamField>
<ParamField body="moderationModel" type="ModerationModel" default="omni-moderation-latest">
OpenAI moderation model to use. Options: `'omni-moderation-latest'`, `'omni-moderation-2024-09-26'`, `'text-moderation-latest'`, `'text-moderation-stable'`
</ParamField>
<ParamField body="checkInput" type="boolean" default="true">
Whether to check user input messages before the model is called
</ParamField>
<ParamField body="checkOutput" type="boolean" default="true">
Whether to check model output messages after the model is called
</ParamField>
<ParamField body="checkToolResults" type="boolean" default="false">
Whether to check tool result messages before the model is called
</ParamField>
<ParamField body="exitBehavior" type="'error' | 'end' | 'replace'" default="'end'">
How to handle violations when content is flagged. Options:
* `'end'` - End agent execution immediately with a violation message
* `'error'` - Throw `OpenAIModerationError` exception
* `'replace'` - Replace the flagged content with the violation message and continue
</ParamField>
<ParamField body="violationMessage" type="string | undefined">
Custom template for violation messages. Supports template variables:
* `{categories}` - Comma-separated list of flagged categories
* `{category_scores}` - JSON string of category scores
* `{original_content}` - The original flagged content
Default: `"I'm sorry, but I can't comply with that request. It was flagged for {categories}."`
</ParamField>
</Accordion>
<Accordion title="Full example">
The middleware integrates OpenAI's moderation endpoint to check content at different stages:
**Moderation stages:**
- `checkInput` - User messages before model call
- `checkOutput` - AI messages after model call
- `checkToolResults` - Tool outputs before model call
**Exit behaviors:**
- `'end'` (default) - Stop execution with violation message
- `'error'` - Throw exception for application handling
- `'replace'` - Replace flagged content and continue
```typescript theme={"theme":{"light":"catppuccin-latte","dark":"catppuccin-mocha"}}
import { createAgent, openAIModerationMiddleware } from "langchain";
// Basic moderation
const agent = createAgent({
model: "openai:gpt-5.5",
tools: [searchTool, customerDataTool],
middleware: [
openAIModerationMiddleware({
model: "openai:gpt-5.5",
moderationModel: "omni-moderation-latest",
checkInput: true,
checkOutput: true,
}),
],
});
// Strict moderation with custom message
const agentStrict = createAgent({
model: "openai:gpt-5.5",
tools: [searchTool, customerDataTool],
middleware: [
openAIModerationMiddleware({
model: "openai:gpt-5.5",
moderationModel: "omni-moderation-latest",
checkInput: true,
checkOutput: true,
checkToolResults: true,
exitBehavior: "error",
violationMessage:
"Content policy violation detected: {categories}. " +
"Please rephrase your request.",
}),
],
});
// Moderation with replacement behavior
const agentReplace = createAgent({
model: "openai:gpt-5.5",
tools: [searchTool],
middleware: [
openAIModerationMiddleware({
model: "openai:gpt-5.5",
checkInput: true,
exitBehavior: "replace",
violationMessage: "[Content removed due to safety policies]",
}),
],
});
```
</Accordion>
---
<div className="source-links">
<Callout icon="terminal-2">
[Connect these docs](/use-these-docs) to Claude, VSCode, and more via MCP for real-time answers.
</Callout>
<Callout icon="edit">
[Edit this page on GitHub](https://github.com/langchain-ai/docs/edit/main/src/oss/javascript/integrations/providers/openai.mdx) or [file an issue](https://github.com/langchain-ai/docs/issues/new/choose).
</Callout>
</div>

View File

@@ -3,6 +3,8 @@ import { z } from "zod";
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(),
});
export const llmConfig = LLMConfigSchema.parse(process.env);

View File

@@ -2,5 +2,8 @@ export * from "./llm.js";
export * from "./config.js";
export * from "./providers/google-genai.js";
export * from "./providers/mock.js";
export * from "./providers/ollama.js";
export * from "./providers/openrouter.js";
export * from "./providers/anthropic.js";
export * from "./providers/openai.js";
export * from "./provider-manager.js";

View File

@@ -57,6 +57,7 @@ export interface ModelProviderInstance {
modelName?: string;
type: "generative" | "embedding";
maxContext?: number;
endpointUrl?: string;
}
export interface ModelProviderMeta {
@@ -75,6 +76,20 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
defaultModel: "gemini-2.5-flash",
defaultEmbeddingModel: "gemini-embedding-001",
},
{
id: "openai",
displayName: "OpenAI",
description: "Official OpenAI integration using @langchain/openai SDK",
defaultModel: "gpt-4o-mini",
defaultEmbeddingModel: "text-embedding-3-small",
},
{
id: "anthropic",
displayName: "Anthropic Claude",
description: "Official Claude integration using @langchain/anthropic SDK",
defaultModel: "claude-3-5-sonnet-latest",
defaultEmbeddingModel: "",
},
{
id: "openrouter",
displayName: "OpenRouter",
@@ -83,6 +98,14 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [
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",

View File

@@ -82,6 +82,14 @@ function getSettingsDb() {
// ignore
}
try {
db.prepare(
`ALTER TABLE provider_instances ADD COLUMN endpointUrl TEXT`,
).run();
} catch {
// ignore
}
// Auto-bootstrap environment variables if DB contains 0 instances
try {
if (!hasBootstrapped) {
@@ -91,7 +99,10 @@ function getSettingsDb() {
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
const anthropicKey = process.env.ANTHROPIC_API_KEY;
const openaiKey = process.env.OPENAI_API_KEY;
let hasInsertedGenerative = false;
let hasInsertedEmbedding = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
@@ -128,6 +139,74 @@ function getSettingsDb() {
"embedding",
0,
);
hasInsertedEmbedding = true;
}
if (anthropicKey && anthropicKey.trim()) {
const id = "provider-default-anthropic";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"Anthropic (Env)",
"anthropic",
anthropicKey.trim(),
isActive,
"claude-3-5-sonnet-latest",
"generative",
200000,
);
if (isActive === 1) {
hasInsertedGenerative = true;
}
}
if (openaiKey && openaiKey.trim()) {
const id = "provider-default-openai";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"OpenAI (Env)",
"openai",
openaiKey.trim(),
isActive,
"gpt-4o-mini",
"generative",
128000,
);
if (isActive === 1) {
hasInsertedGenerative = true;
}
const embedId = "provider-default-openai-embed";
const isEmbedActive = hasInsertedEmbedding ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
embedId,
"OpenAI Embed (Env)",
"openai",
openaiKey.trim(),
isEmbedActive,
"text-embedding-3-small",
"embedding",
0,
);
if (isEmbedActive === 1) {
hasInsertedEmbedding = true;
}
}
if (openRouterKey && openRouterKey.trim()) {
@@ -172,6 +251,7 @@ export class ProviderManager {
modelName?: string;
type: string;
maxContext?: number;
endpointUrl?: string;
}[];
return rows.map((r) => ({
id: r.id,
@@ -187,6 +267,7 @@ export class ProviderManager {
: r.type === "embedding"
? 0
: 32768,
endpointUrl: r.endpointUrl || undefined,
}));
} finally {
db.close();
@@ -200,6 +281,7 @@ export class ProviderManager {
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): ModelProviderInstance {
const db = getSettingsDb();
try {
@@ -220,8 +302,8 @@ export class ProviderManager {
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext, endpointUrl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
@@ -232,6 +314,7 @@ export class ProviderManager {
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
);
return {
@@ -243,6 +326,7 @@ export class ProviderManager {
modelName,
type,
maxContext: actualMaxContext,
endpointUrl,
};
} finally {
db.close();
@@ -299,6 +383,7 @@ export class ProviderManager {
modelName?: string,
type: "generative" | "embedding" = "generative",
maxContext?: number,
endpointUrl?: string,
): void {
const db = getSettingsDb();
try {
@@ -312,7 +397,7 @@ export class ProviderManager {
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?
SET name = ?, providerName = ?, apiKey = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
WHERE id = ?
`,
).run(
@@ -322,13 +407,14 @@ export class ProviderManager {
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
id,
);
} else {
db.prepare(
`
UPDATE provider_instances
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?
SET name = ?, providerName = ?, modelName = ?, type = ?, maxContext = ?, endpointUrl = ?
WHERE id = ?
`,
).run(
@@ -337,6 +423,7 @@ export class ProviderManager {
modelName || null,
type,
actualMaxContext,
endpointUrl || null,
id,
);
}
@@ -364,6 +451,7 @@ export class ProviderManager {
modelName?: string;
type: string;
maxContext?: number;
endpointUrl?: string;
}
| undefined;
@@ -374,7 +462,10 @@ export class ProviderManager {
if (totalCount.count === 0) {
const googleKey = process.env.GOOGLE_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
const anthropicKey = process.env.ANTHROPIC_API_KEY;
const openaiKey = process.env.OPENAI_API_KEY;
let hasInsertedGenerative = false;
let hasInsertedEmbedding = false;
if (googleKey && googleKey.trim()) {
const id = "provider-default-google";
@@ -411,6 +502,74 @@ export class ProviderManager {
"embedding",
0,
);
hasInsertedEmbedding = true;
}
if (anthropicKey && anthropicKey.trim()) {
const id = "provider-default-anthropic";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"Anthropic (Env)",
"anthropic",
anthropicKey.trim(),
isActive,
"claude-3-5-sonnet-latest",
"generative",
200000,
);
if (isActive === 1) {
hasInsertedGenerative = true;
}
}
if (openaiKey && openaiKey.trim()) {
const id = "provider-default-openai";
const isActive = hasInsertedGenerative ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
id,
"OpenAI (Env)",
"openai",
openaiKey.trim(),
isActive,
"gpt-4o-mini",
"generative",
128000,
);
if (isActive === 1) {
hasInsertedGenerative = true;
}
const embedId = "provider-default-openai-embed";
const isEmbedActive = hasInsertedEmbedding ? 0 : 1;
db.prepare(
`
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
embedId,
"OpenAI Embed (Env)",
"openai",
openaiKey.trim(),
isEmbedActive,
"text-embedding-3-small",
"embedding",
0,
);
if (isEmbedActive === 1) {
hasInsertedEmbedding = true;
}
}
if (openRouterKey && openRouterKey.trim()) {
@@ -447,6 +606,7 @@ export class ProviderManager {
modelName?: string;
type: string;
maxContext?: number;
endpointUrl?: string;
}
| undefined;
@@ -466,6 +626,7 @@ export class ProviderManager {
: retryRow.type === "embedding"
? 0
: 32768,
endpointUrl: retryRow.endpointUrl || undefined,
};
}
}
@@ -483,6 +644,7 @@ export class ProviderManager {
modelName?: string;
type: string;
maxContext?: number;
endpointUrl?: string;
}
| undefined;
if (firstRow) {
@@ -503,6 +665,7 @@ export class ProviderManager {
: firstRow.type === "embedding"
? 0
: 32768,
endpointUrl: firstRow.endpointUrl || undefined,
};
}
return null;
@@ -522,6 +685,7 @@ export class ProviderManager {
: row.type === "embedding"
? 0
: 32768,
endpointUrl: row.endpointUrl || undefined,
};
} catch {
const googleKey = process.env.GOOGLE_API_KEY;
@@ -538,6 +702,19 @@ export class ProviderManager {
maxContext: 0,
};
}
const openaiKey = process.env.OPENAI_API_KEY;
if (openaiKey && openaiKey.trim()) {
return {
id: "provider-default-env-embed-fallback",
name: "OpenAI Embed (Env Fallback)",
providerName: "openai",
apiKey: openaiKey.trim(),
isActive: true,
modelName: "text-embedding-3-small",
type: "embedding",
maxContext: 0,
};
}
return null;
}
@@ -554,6 +731,32 @@ export class ProviderManager {
maxContext: 32768,
};
}
const openaiKey = process.env.OPENAI_API_KEY;
if (openaiKey && openaiKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "OpenAI (Env Fallback)",
providerName: "openai",
apiKey: openaiKey.trim(),
isActive: true,
modelName: "gpt-4o-mini",
type: "generative",
maxContext: 128000,
};
}
const anthropicKey = process.env.ANTHROPIC_API_KEY;
if (anthropicKey && anthropicKey.trim()) {
return {
id: "provider-default-env-fallback",
name: "Anthropic (Env Fallback)",
providerName: "anthropic",
apiKey: anthropicKey.trim(),
isActive: true,
modelName: "claude-3-5-sonnet-latest",
type: "generative",
maxContext: 200000,
};
}
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (openRouterKey && openRouterKey.trim()) {
return {

View File

@@ -0,0 +1,114 @@
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";
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";
providerName = "Anthropic";
private model: ChatAnthropic;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
constructor(
apiKey?: string,
modelName?: string,
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";
}
}
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 };
}
}

View File

@@ -0,0 +1,162 @@
import { z } from "zod";
import { ChatOllama, OllamaEmbeddings } from "@langchain/ollama";
import {
ILLMProvider,
LLMRequest,
LLMResponse,
LLMCallRecord,
IEmbeddingProvider,
} from "../llm.js";
import { ProviderManager } from "../provider-manager.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";
providerName = "Ollama";
private model: ChatOllama;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
/**
* Creates an OllamaProvider.
*
* Resolution order for configuration:
* 1. Explicit constructor arguments
* 2. Active "generative" instance in ProviderManager whose providerName === "ollama"
* 3. Defaults (baseUrl: http://localhost:11434, model: llama3.1)
*
* No API key is required for Ollama. The `endpointUrl` in
* ModelProviderInstance stores the Ollama server base URL
* (e.g. "http://localhost:11434").
*/
constructor(
baseUrl?: string,
modelName?: string,
providerInstanceName?: string,
maxContext?: number,
) {
let url = baseUrl;
let model = modelName;
this.providerInstanceName = providerInstanceName;
this.maxContextUsed = maxContext;
if (!url || !model) {
const active = ProviderManager.getActive("generative");
if (active && active.providerName === OllamaProvider.providerId) {
if (!url) {
url = active.endpointUrl;
}
if (!model) {
model = active.modelName;
}
if (!this.providerInstanceName) {
this.providerInstanceName = active.name;
}
if (this.maxContextUsed === undefined) {
this.maxContextUsed = active.maxContext;
}
}
}
this.modelNameUsed = model || OllamaProvider.defaultModel;
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";
providerName = "Ollama";
private model: OllamaEmbeddings;
/**
* Creates an OllamaEmbeddingProvider.
*
* Resolution order:
* 1. Explicit constructor arguments
* 2. Active "embedding" instance in ProviderManager
* 3. Defaults (baseUrl: http://localhost:11434, model: nomic-embed-text)
*
* The `endpointUrl` field in ModelProviderInstance stores the base URL.
*/
constructor(baseUrl?: string, modelName?: string) {
let url = baseUrl;
let model = modelName;
if (!url || !model) {
const active = ProviderManager.getActive("embedding");
if (
active &&
active.providerName === OllamaEmbeddingProvider.providerId
) {
if (!url) {
url = active.endpointUrl;
}
if (!model) {
model = active.modelName;
}
}
}
this.model = new OllamaEmbeddings({
baseUrl: url || "http://localhost:11434",
model: model || "nomic-embed-text",
});
}
async embed(text: string): Promise<number[]> {
return this.model.embedQuery(text);
}
}

View File

@@ -0,0 +1,160 @@
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 { ProviderManager } from "../provider-manager.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";
providerName = "OpenAI";
private model: ChatOpenAI;
private modelNameUsed: string;
private providerInstanceName?: string;
private maxContextUsed?: number;
lastCalls: LLMCallRecord[] = [];
constructor(
apiKey?: string,
modelName?: string,
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";
}
}
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 };
}
}
export class OpenAIEmbeddingProvider implements IEmbeddingProvider {
static readonly providerId = "openai";
static readonly displayName = "OpenAI Embeddings";
providerName = "OpenAI";
private model: OpenAIEmbeddings;
constructor(apiKey?: string, modelName?: string) {
let key = apiKey;
let model = modelName;
if (!key) {
const active = ProviderManager.getActive("embedding");
if (
active &&
active.providerName === OpenAIEmbeddingProvider.providerId
) {
key = active.apiKey;
if (!model) {
model = active.modelName;
}
}
}
if (!key) {
key = llmConfig.OPENAI_API_KEY;
}
if (!key) {
throw new Error(
"OPENAI_API_KEY is required to initialize OpenAIEmbeddingProvider",
);
}
this.model = new OpenAIEmbeddings({
apiKey: key,
model: model || "text-embedding-3-small",
});
}
async embed(text: string): Promise<number[]> {
return this.model.embedQuery(text);
}
}

View File

@@ -259,11 +259,7 @@ ${candidatesList}
}
const result = response.data;
const db = (
this.bufferRepo as unknown as {
db: { transaction: (fn: () => void) => void };
}
).db;
const db = (this.bufferRepo as any).db;
const ledgerEntries: LedgerEntry[] = [];
for (const chunk of result.chunks) {

423
pnpm-lock.yaml generated
View File

@@ -260,12 +260,21 @@ settings:
importers:
.:
dependencies:
"@langchain/anthropic":
specifier: ^0.3.11
version: 0.3.34(zod@4.4.3)
"@langchain/google-genai":
specifier: ^2.2.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/ollama":
specifier: ^0.2.3
version: 0.2.4
"@langchain/openai":
specifier: ^0.3.17
version: 0.3.17(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)
version: 0.4.3(ws@8.21.0)(zod@4.4.3)
"@types/node":
specifier: ^20.19.43
version: 20.19.43
@@ -561,6 +570,18 @@ packages:
integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==,
}
"@anthropic-ai/sdk@0.65.0":
resolution:
{
integrity: sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw==,
}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
"@astrojs/compiler-binding-darwin-arm64@0.3.0":
resolution:
{
@@ -2049,6 +2070,15 @@ packages:
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
}
"@langchain/anthropic@0.3.34":
resolution:
{
integrity: sha512-8bOW1A2VHRCjbzdYElrjxutKNs9NSIxYRGtR+OJWVzluMqoKKh2NmmFrpPizEyqCUEG2tTq5xt6XA1lwfqMJRA==,
}
engines: { node: ">=18" }
peerDependencies:
"@langchain/core": ">=0.3.58 <0.4.0"
"@langchain/core@1.2.1":
resolution:
{
@@ -2065,6 +2095,24 @@ packages:
peerDependencies:
"@langchain/core": ^1.2.0
"@langchain/ollama@0.2.4":
resolution:
{
integrity: sha512-XThDrZurNPcUO6sasN13rkes1aGgu5gWAtDkkyIGT3ZeMOvrYgPKGft+bbhvsigTIH9C01TfPzrSp8LAmvHIjA==,
}
engines: { node: ">=18" }
peerDependencies:
"@langchain/core": ">=0.3.58 <0.4.0"
"@langchain/openai@0.3.17":
resolution:
{
integrity: sha512-uw4po32OKptVjq+CYHrumgbfh4NuD7LqyE+ZgqY9I/LrLc6bHLMc+sisHmI17vgek0K/yqtarI0alPJbzrwyag==,
}
engines: { node: ">=18" }
peerDependencies:
"@langchain/core": ">=0.3.29 <0.4.0"
"@langchain/openai@1.5.3":
resolution:
{
@@ -3858,6 +3906,18 @@ packages:
integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==,
}
"@types/node-fetch@2.6.13":
resolution:
{
integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==,
}
"@types/node@18.19.130":
resolution:
{
integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==,
}
"@types/node@20.19.43":
resolution:
{
@@ -4071,6 +4131,13 @@ packages:
integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==,
}
abort-controller@3.0.0:
resolution:
{
integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==,
}
engines: { node: ">=6.5" }
accepts@2.0.0:
resolution:
{
@@ -4094,6 +4161,13 @@ packages:
engines: { node: ">=0.4.0" }
hasBin: true
agentkeepalive@4.6.0:
resolution:
{
integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==,
}
engines: { node: ">= 8.0.0" }
ajv-formats@2.1.1:
resolution:
{
@@ -4250,6 +4324,12 @@ packages:
"@astrojs/markdown-remark":
optional: true
asynckit@0.4.0:
resolution:
{
integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==,
}
atomically@1.7.0:
resolution:
{
@@ -4555,6 +4635,13 @@ packages:
}
engines: { node: ">=12.5.0" }
combined-stream@1.0.8:
resolution:
{
integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==,
}
engines: { node: ">= 0.8" }
comma-separated-tokens@2.0.3:
resolution:
{
@@ -5137,6 +5224,13 @@ packages:
integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==,
}
delayed-stream@1.0.0:
resolution:
{
integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==,
}
engines: { node: ">=0.4.0" }
depd@2.0.0:
resolution:
{
@@ -5360,6 +5454,13 @@ packages:
}
engines: { node: ">= 0.4" }
es-set-tostringtag@2.1.0:
resolution:
{
integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==,
}
engines: { node: ">= 0.4" }
es-toolkit@1.49.0:
resolution:
{
@@ -5554,6 +5655,13 @@ packages:
}
engines: { node: ">= 0.6" }
event-target-shim@5.0.1:
resolution:
{
integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==,
}
engines: { node: ">=6" }
eventemitter3@4.0.7:
resolution:
{
@@ -5685,6 +5793,13 @@ packages:
integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==,
}
fast-xml-parser@4.5.7:
resolution:
{
integrity: sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==,
}
hasBin: true
fastq@1.20.1:
resolution:
{
@@ -5784,6 +5899,26 @@ packages:
}
engines: { node: ">=20" }
form-data-encoder@1.7.2:
resolution:
{
integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==,
}
form-data@4.0.6:
resolution:
{
integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==,
}
engines: { node: ">= 6" }
formdata-node@4.4.1:
resolution:
{
integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==,
}
engines: { node: ">= 12.20" }
forwarded@0.2.0:
resolution:
{
@@ -5965,6 +6100,13 @@ packages:
}
engines: { node: ">= 0.4" }
has-tostringtag@1.0.2:
resolution:
{
integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==,
}
engines: { node: ">= 0.4" }
hasown@2.0.4:
resolution:
{
@@ -6144,6 +6286,12 @@ packages:
}
engines: { node: ">=18.18.0" }
humanize-ms@1.2.1:
resolution:
{
integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==,
}
i18next@26.3.4:
resolution:
{
@@ -6504,6 +6652,13 @@ packages:
integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==,
}
json-schema-to-ts@3.1.1:
resolution:
{
integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==,
}
engines: { node: ">=16" }
json-schema-traverse@0.4.1:
resolution:
{
@@ -7222,6 +7377,13 @@ packages:
}
engines: { node: ">=8.6" }
mime-db@1.52.0:
resolution:
{
integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==,
}
engines: { node: ">= 0.6" }
mime-db@1.54.0:
resolution:
{
@@ -7229,6 +7391,13 @@ packages:
}
engines: { node: ">= 0.6" }
mime-types@2.1.35:
resolution:
{
integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==,
}
engines: { node: ">= 0.6" }
mime-types@3.0.2:
resolution:
{
@@ -7382,12 +7551,32 @@ packages:
}
engines: { node: ">=10" }
node-domexception@1.0.0:
resolution:
{
integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==,
}
engines: { node: ">=10.5.0" }
deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.7:
resolution:
{
integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==,
}
node-fetch@2.7.0:
resolution:
{
integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==,
}
engines: { node: 4.x || >=6.0.0 }
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-mock-http@1.0.4:
resolution:
{
@@ -7468,6 +7657,12 @@ packages:
integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==,
}
ollama@0.5.18:
resolution:
{
integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==,
}
on-finished@2.4.1:
resolution:
{
@@ -7521,6 +7716,21 @@ packages:
}
engines: { node: ">=12" }
openai@4.104.0:
resolution:
{
integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==,
}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openai@6.45.0:
resolution:
{
@@ -8624,6 +8834,12 @@ packages:
}
engines: { node: ">=0.10.0" }
strnum@1.1.2:
resolution:
{
integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==,
}
style-to-js@1.1.21:
resolution:
{
@@ -8782,6 +8998,12 @@ packages:
}
engines: { node: ">=0.6" }
tr46@0.0.3:
resolution:
{
integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==,
}
trim-lines@3.0.1:
resolution:
{
@@ -8794,6 +9016,12 @@ packages:
integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==,
}
ts-algebra@2.0.0:
resolution:
{
integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==,
}
ts-api-utils@2.5.0:
resolution:
{
@@ -8885,6 +9113,12 @@ packages:
integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==,
}
undici-types@5.26.5:
resolution:
{
integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==,
}
undici-types@6.21.0:
resolution:
{
@@ -9136,6 +9370,14 @@ packages:
integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==,
}
uuid@10.0.0:
resolution:
{
integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==,
}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@14.0.1:
resolution:
{
@@ -9282,6 +9524,31 @@ packages:
integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==,
}
web-streams-polyfill@4.0.0-beta.3:
resolution:
{
integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==,
}
engines: { node: ">= 14" }
webidl-conversions@3.0.1:
resolution:
{
integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==,
}
whatwg-fetch@3.6.20:
resolution:
{
integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==,
}
whatwg-url@5.0.0:
resolution:
{
integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==,
}
which@2.0.2:
resolution:
{
@@ -9455,6 +9722,12 @@ snapshots:
package-manager-detector: 1.7.0
tinyexec: 1.2.4
"@anthropic-ai/sdk@0.65.0(zod@4.4.3)":
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.4.3
"@astrojs/compiler-binding-darwin-arm64@0.3.0":
optional: true
@@ -10335,6 +10608,13 @@ snapshots:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.5
"@langchain/anthropic@0.3.34(zod@4.4.3)":
dependencies:
"@anthropic-ai/sdk": 0.65.0(zod@4.4.3)
fast-xml-parser: 4.5.7
transitivePeerDependencies:
- zod
"@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
@@ -10356,9 +10636,23 @@ snapshots:
"@google/generative-ai": 0.24.1
"@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)":
"@langchain/ollama@0.2.4":
dependencies:
ollama: 0.5.18
uuid: 10.0.0
"@langchain/openai@0.3.17(ws@8.21.0)":
dependencies:
js-tiktoken: 1.0.21
openai: 4.104.0(ws@8.21.0)(zod@3.25.76)
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
transitivePeerDependencies:
- encoding
- ws
"@langchain/openai@1.5.3(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
@@ -10368,10 +10662,9 @@ snapshots:
- "@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)":
"@langchain/openrouter@0.4.3(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)
"@langchain/openai": 1.5.3(ws@8.21.0)
eventsource-parser: 3.1.0
openai: 6.45.0(ws@8.21.0)(zod@4.4.3)
transitivePeerDependencies:
@@ -11622,6 +11915,15 @@ snapshots:
dependencies:
"@types/unist": 3.0.3
"@types/node-fetch@2.6.13":
dependencies:
"@types/node": 26.1.0
form-data: 4.0.6
"@types/node@18.19.130":
dependencies:
undici-types: 5.26.5
"@types/node@20.19.43":
dependencies:
undici-types: 6.21.0
@@ -11794,6 +12096,10 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -11805,6 +12111,10 @@ snapshots:
acorn@8.17.0: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-formats@2.1.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
@@ -11969,6 +12279,8 @@ snapshots:
- uploadthing
- yaml
asynckit@0.4.0: {}
atomically@1.7.0: {}
autoprefixer@10.5.2(postcss@8.5.16):
@@ -12128,6 +12440,10 @@ snapshots:
color-convert: 2.0.1
color-string: 1.9.1
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -12458,6 +12774,8 @@ snapshots:
dependencies:
robust-predicates: 3.0.3
delayed-stream@1.0.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -12558,6 +12876,13 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
es-toolkit@1.49.0: {}
esast-util-from-estree@2.0.0:
@@ -12720,6 +13045,8 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
@@ -12834,6 +13161,10 @@ snapshots:
dependencies:
fast-string-width: 3.0.2
fast-xml-parser@4.5.7:
dependencies:
strnum: 1.1.2
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -12893,6 +13224,21 @@ snapshots:
dependencies:
tiny-inflate: 1.0.3
form-data-encoder@1.7.2: {}
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
forwarded@0.2.0: {}
fraction.js@5.3.4: {}
@@ -12985,6 +13331,10 @@ snapshots:
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
@@ -13200,6 +13550,10 @@ snapshots:
human-signals@8.0.1: {}
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
i18next@26.3.4(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
@@ -13330,6 +13684,11 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-schema-to-ts@3.1.1:
dependencies:
"@babel/runtime": 7.29.7
ts-algebra: 2.0.0
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -13977,8 +14336,14 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
@@ -14059,8 +14424,14 @@ snapshots:
dependencies:
semver: 7.8.5
node-domexception@1.0.0: {}
node-fetch-native@1.6.7: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-mock-http@1.0.4: {}
node-releases@2.0.51: {}
@@ -14096,6 +14467,10 @@ snapshots:
ohash@2.0.11: {}
ollama@0.5.18:
dependencies:
whatwg-fetch: 3.6.20
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -14135,6 +14510,21 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@4.104.0(ws@8.21.0)(zod@3.25.76):
dependencies:
"@types/node": 18.19.130
"@types/node-fetch": 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
ws: 8.21.0
zod: 3.25.76
transitivePeerDependencies:
- encoding
openai@6.45.0(ws@8.21.0)(zod@4.4.3):
optionalDependencies:
ws: 8.21.0
@@ -15013,6 +15403,8 @@ snapshots:
strip-json-comments@2.0.1: {}
strnum@1.1.2: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -15092,10 +15484,14 @@ snapshots:
toidentifier@1.0.1: {}
tr46@0.0.3: {}
trim-lines@3.0.1: {}
trough@2.2.0: {}
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@@ -15148,6 +15544,8 @@ snapshots:
uncrypto@0.1.3: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici-types@7.18.2: {}
@@ -15272,6 +15670,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@14.0.1: {}
validate-npm-package-name@7.0.2: {}
@@ -15352,6 +15752,17 @@ snapshots:
web-namespaces@2.0.1: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-fetch@3.6.20: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0