From 9356e1f7d018231aefebcfe10bb54ce4d22a55d6 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 13:10:59 +0530 Subject: [PATCH 1/8] feat(llm): Added ollama provider --- apps/gui/src/app/actions.ts | 4 + .../config/ProviderInstancesConfig.tsx | 69 +++++--- .../src/lib/simulation/provider-resolver.ts | 11 ++ package.json | 1 + packages/llm/src/index.ts | 1 + packages/llm/src/llm.ts | 9 + packages/llm/src/provider-manager.ts | 30 +++- packages/llm/src/providers/ollama.ts | 162 ++++++++++++++++++ packages/memory/src/handoff.ts | 6 +- pnpm-lock.yaml | 55 +++++- 10 files changed, 311 insertions(+), 37 deletions(-) create mode 100644 packages/llm/src/providers/ollama.ts diff --git a/apps/gui/src/app/actions.ts b/apps/gui/src/app/actions.ts index eefc5c1..23d8152 100644 --- a/apps/gui/src/app/actions.ts +++ b/apps/gui/src/app/actions.ts @@ -254,6 +254,7 @@ export async function createProviderInstance( modelName?: string, type: "generative" | "embedding" = "generative", maxContext?: number, + endpointUrl?: string, ): Promise { 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 { ProviderManager.update( id, @@ -290,6 +293,7 @@ export async function updateProviderInstance( modelName, type, maxContext, + endpointUrl, ); } diff --git a/apps/gui/src/components/config/ProviderInstancesConfig.tsx b/apps/gui/src/components/config/ProviderInstancesConfig.tsx index 4b9da0e..192016b 100644 --- a/apps/gui/src/components/config/ProviderInstancesConfig.tsx +++ b/apps/gui/src/components/config/ProviderInstancesConfig.tsx @@ -64,6 +64,7 @@ export function ProviderInstancesConfig({ "generative", ); const [editMaxContext, setEditMaxContext] = useState(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({ - Generative (Chat / Text Completion) + Generative (Text Generation) - Embedding (Vector generation) + Embedding (Vector Embeddings) @@ -398,21 +408,36 @@ export function ProviderInstancesConfig({ )} -
- - setEditKey(e.target.value)} - placeholder={ - selectedInstanceId === "new" - ? "AIzaSy..." - : "•••••••• (unchanged)" - } - required={selectedInstanceId === "new"} - /> -
+ {editProvider !== "ollama" && ( +
+ + setEditKey(e.target.value)} + placeholder={ + selectedInstanceId === "new" + ? "AIzaSy..." + : "•••••••• (unchanged)" + } + required={selectedInstanceId === "new"} + /> +
+ )} + + {editProvider === "ollama" && ( +
+ + setEditEndpointUrl(e.target.value)} + placeholder="e.g. http://localhost:11434" + required + /> +
+ )}
diff --git a/apps/gui/src/lib/simulation/provider-resolver.ts b/apps/gui/src/lib/simulation/provider-resolver.ts index 325d33a..b7fcc57 100644 --- a/apps/gui/src/lib/simulation/provider-resolver.ts +++ b/apps/gui/src/lib/simulation/provider-resolver.ts @@ -1,6 +1,8 @@ import { GeminiProvider, MockLLMProvider, + OllamaProvider, + OllamaEmbeddingProvider, ProviderManager, OpenRouterProvider, GeminiEmbeddingProvider, @@ -58,6 +60,13 @@ function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider { inst.name, inst.maxContext, ); + } else if (inst.providerName === "ollama") { + return new OllamaProvider( + inst.endpointUrl, + inst.modelName, + inst.name, + inst.maxContext, + ); } return new MockLLMProvider([]); } @@ -67,6 +76,8 @@ function buildEmbeddingProvider( ): 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); } return new MockEmbeddingProvider(inst.modelName); } diff --git a/package.json b/package.json index 9d8ab7a..7d83620 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@langchain/google-genai": "^2.2.0", + "@langchain/ollama": "^0.2.3", "@langchain/openrouter": "^0.4.3", "@types/node": "^20.19.43", "dotenv": "^17.4.2" diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 6817141..a7c747b 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -2,5 +2,6 @@ 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 "./provider-manager.js"; diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index b8635d2..ad8a7aa 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -57,6 +57,7 @@ export interface ModelProviderInstance { modelName?: string; type: "generative" | "embedding"; maxContext?: number; + endpointUrl?: string; } export interface ModelProviderMeta { @@ -83,6 +84,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", diff --git a/packages/llm/src/provider-manager.ts b/packages/llm/src/provider-manager.ts index 522c5bf..4401990 100644 --- a/packages/llm/src/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -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) { @@ -172,6 +180,7 @@ export class ProviderManager { modelName?: string; type: string; maxContext?: number; + endpointUrl?: string; }[]; return rows.map((r) => ({ id: r.id, @@ -187,6 +196,7 @@ export class ProviderManager { : r.type === "embedding" ? 0 : 32768, + endpointUrl: r.endpointUrl || undefined, })); } finally { db.close(); @@ -200,6 +210,7 @@ export class ProviderManager { modelName?: string, type: "generative" | "embedding" = "generative", maxContext?: number, + endpointUrl?: string, ): ModelProviderInstance { const db = getSettingsDb(); try { @@ -220,8 +231,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 +243,7 @@ export class ProviderManager { modelName || null, type, actualMaxContext, + endpointUrl || null, ); return { @@ -243,6 +255,7 @@ export class ProviderManager { modelName, type, maxContext: actualMaxContext, + endpointUrl, }; } finally { db.close(); @@ -299,6 +312,7 @@ export class ProviderManager { modelName?: string, type: "generative" | "embedding" = "generative", maxContext?: number, + endpointUrl?: string, ): void { const db = getSettingsDb(); try { @@ -312,7 +326,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 +336,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 +352,7 @@ export class ProviderManager { modelName || null, type, actualMaxContext, + endpointUrl || null, id, ); } @@ -364,6 +380,7 @@ export class ProviderManager { modelName?: string; type: string; maxContext?: number; + endpointUrl?: string; } | undefined; @@ -447,6 +464,7 @@ export class ProviderManager { modelName?: string; type: string; maxContext?: number; + endpointUrl?: string; } | undefined; @@ -466,6 +484,7 @@ export class ProviderManager { : retryRow.type === "embedding" ? 0 : 32768, + endpointUrl: retryRow.endpointUrl || undefined, }; } } @@ -483,6 +502,7 @@ export class ProviderManager { modelName?: string; type: string; maxContext?: number; + endpointUrl?: string; } | undefined; if (firstRow) { @@ -503,6 +523,7 @@ export class ProviderManager { : firstRow.type === "embedding" ? 0 : 32768, + endpointUrl: firstRow.endpointUrl || undefined, }; } return null; @@ -522,6 +543,7 @@ export class ProviderManager { : row.type === "embedding" ? 0 : 32768, + endpointUrl: row.endpointUrl || undefined, }; } catch { const googleKey = process.env.GOOGLE_API_KEY; diff --git a/packages/llm/src/providers/ollama.ts b/packages/llm/src/providers/ollama.ts new file mode 100644 index 0000000..9db58e8 --- /dev/null +++ b/packages/llm/src/providers/ollama.ts @@ -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( + request: LLMRequest, + ): Promise>> { + const structuredModel = this.model.withStructuredOutput(request.schema, { + includeRaw: true, + }); + const result = (await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ])) as unknown as { + parsed?: z.infer; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = { + 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 { + return this.model.embedQuery(text); + } +} diff --git a/packages/memory/src/handoff.ts b/packages/memory/src/handoff.ts index 733ff26..4e61028 100644 --- a/packages/memory/src/handoff.ts +++ b/packages/memory/src/handoff.ts @@ -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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 058feeb..4d9b0b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,9 +263,12 @@ importers: "@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/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 @@ -2065,6 +2068,15 @@ 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@1.5.3": resolution: { @@ -7468,6 +7480,12 @@ packages: integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==, } + ollama@0.5.18: + resolution: + { + integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==, + } + on-finished@2.4.1: resolution: { @@ -9136,6 +9154,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 +9308,12 @@ packages: integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, } + whatwg-fetch@3.6.20: + resolution: + { + integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==, + } + which@2.0.2: resolution: { @@ -10356,9 +10388,13 @@ 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@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 +10404,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: @@ -14096,6 +14131,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 @@ -15272,6 +15311,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + uuid@14.0.1: {} validate-npm-package-name@7.0.2: {} @@ -15352,6 +15393,8 @@ snapshots: web-namespaces@2.0.1: {} + whatwg-fetch@3.6.20: {} + which@2.0.2: dependencies: isexe: 2.0.0 From d6ab076b09761bb31642064dbcb19c986cc9f2ed Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 13:11:23 +0530 Subject: [PATCH 2/8] docs(llm): Added reamde for @omnia/llm --- packages/llm/README.md | 409 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 packages/llm/README.md diff --git a/packages/llm/README.md b/packages/llm/README.md new file mode 100644 index 0000000..249379f --- /dev/null +++ b/packages/llm/README.md @@ -0,0 +1,409 @@ +# @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(request)` | `Promise>` | 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` | Returns a dense vector embedding of the input text | + +### `LLMRequest` + +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` + +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-") + 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` | 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= + ├── 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. + +### 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 `apiKey` field in `ModelProviderInstance` is repurposed to store 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" + (apiKey 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 `apiKey` = `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` | +| `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ | +| `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` | +| _(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 | +| `OPENROUTER_API_KEY` | No | OpenRouter 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 +│ └── mock.ts # MockLLMProvider + MockEmbeddingProvider +├── tests/ +│ ├── mock.test.ts +│ ├── openrouter.test.ts +│ └── provider-manager.test.ts +└── package.json +``` From 622fdfe2f16c97b855a3d8e30d0666459eb5e35d Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 13:21:00 +0530 Subject: [PATCH 3/8] feat(llm): Added Anthropic Claude provider --- .../src/lib/simulation/provider-resolver.ts | 8 ++ package.json | 1 + packages/llm/README.md | 38 ++++-- packages/llm/src/config.ts | 1 + packages/llm/src/index.ts | 1 + packages/llm/src/llm.ts | 7 ++ packages/llm/src/provider-manager.ts | 61 ++++++++++ packages/llm/src/providers/anthropic.ts | 114 ++++++++++++++++++ pnpm-lock.yaml | 76 ++++++++++++ 9 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 packages/llm/src/providers/anthropic.ts diff --git a/apps/gui/src/lib/simulation/provider-resolver.ts b/apps/gui/src/lib/simulation/provider-resolver.ts index b7fcc57..bdb22fb 100644 --- a/apps/gui/src/lib/simulation/provider-resolver.ts +++ b/apps/gui/src/lib/simulation/provider-resolver.ts @@ -5,6 +5,7 @@ import { OllamaEmbeddingProvider, ProviderManager, OpenRouterProvider, + AnthropicProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, } from "@omnia/llm"; @@ -67,6 +68,13 @@ function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider { inst.name, inst.maxContext, ); + } else if (inst.providerName === "anthropic") { + return new AnthropicProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); } return new MockLLMProvider([]); } diff --git a/package.json b/package.json index 7d83620..f4d1a3d 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "zod": "^4.4.3" }, "dependencies": { + "@langchain/anthropic": "^0.3.11", "@langchain/google-genai": "^2.2.0", "@langchain/ollama": "^0.2.3", "@langchain/openrouter": "^0.4.3", diff --git a/packages/llm/README.md b/packages/llm/README.md index 249379f..4f0fea8 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -258,6 +258,27 @@ When no active row is found for the requested type: 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 +``` + ### OpenRouter — `OpenRouterProvider` | Property | Value | @@ -284,21 +305,21 @@ Same three-step key resolution as Gemini (`explicit → ProviderManager → env | **Default Max Context** | `32768` | | **Type** | Generative + Embedding | -Ollama runs **locally** — no API key is required. The `apiKey` field in `ModelProviderInstance` is repurposed to store the Ollama server base URL (default: `http://localhost:11434`). +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" - (apiKey field = base URL) + (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 `apiKey` = `http://localhost:11434`. +> 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` @@ -358,6 +379,7 @@ The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the fi | `"google-genai"` | `GeminiProvider` | `GeminiEmbeddingProvider` | | `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ | | `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` | +| `"anthropic"` | `AnthropicProvider` | _(falls through to mock)_ | | _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` | ## Structured Output @@ -380,10 +402,11 @@ This sends the Zod schema to the model as a structured output constraint. The re [`config.ts`](src/config.ts) parses environment variables using Zod: -| Variable | Required | Description | -| -------------------- | -------- | --------------------- | -| `GOOGLE_API_KEY` | No | Google Gemini API key | -| `OPENROUTER_API_KEY` | No | OpenRouter API key | +| Variable | Required | Description | +| -------------------- | -------- | ------------------------ | +| `GOOGLE_API_KEY` | No | Google Gemini 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. @@ -400,6 +423,7 @@ packages/llm/ │ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider │ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider │ ├── openrouter.ts # OpenRouterProvider +│ ├── anthropic.ts # AnthropicProvider │ └── mock.ts # MockLLMProvider + MockEmbeddingProvider ├── tests/ │ ├── mock.test.ts diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts index 0605902..dad9d7b 100644 --- a/packages/llm/src/config.ts +++ b/packages/llm/src/config.ts @@ -3,6 +3,7 @@ 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(), }); export const llmConfig = LLMConfigSchema.parse(process.env); diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index a7c747b..bb0aee7 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -4,4 +4,5 @@ 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 "./provider-manager.js"; diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index ad8a7aa..ea850c9 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -76,6 +76,13 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [ defaultModel: "gemini-2.5-flash", defaultEmbeddingModel: "gemini-embedding-001", }, + { + id: "anthropic", + displayName: "Anthropic Claude", + description: "Official Claude integration using @langchain/anthropic SDK", + defaultModel: "claude-3-5-sonnet-latest", + defaultEmbeddingModel: "", + }, { id: "openrouter", displayName: "OpenRouter", diff --git a/packages/llm/src/provider-manager.ts b/packages/llm/src/provider-manager.ts index 4401990..4fc572d 100644 --- a/packages/llm/src/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -99,6 +99,7 @@ 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; let hasInsertedGenerative = false; if (googleKey && googleKey.trim()) { @@ -138,6 +139,29 @@ function getSettingsDb() { ); } + 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 (openRouterKey && openRouterKey.trim()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -391,6 +415,7 @@ 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; let hasInsertedGenerative = false; if (googleKey && googleKey.trim()) { @@ -430,6 +455,29 @@ export class ProviderManager { ); } + 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 (openRouterKey && openRouterKey.trim()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -576,6 +624,19 @@ export class ProviderManager { maxContext: 32768, }; } + 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 { diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts new file mode 100644 index 0000000..03d7ebd --- /dev/null +++ b/packages/llm/src/providers/anthropic.ts @@ -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( + request: LLMRequest, + ): Promise>> { + const structuredModel = this.model.withStructuredOutput(request.schema, { + includeRaw: true, + }); + const result = (await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ])) as unknown as { + parsed?: z.infer; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = { + 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 }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d9b0b3..63145f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ 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)) @@ -564,6 +567,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: { @@ -2052,6 +2067,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: { @@ -5697,6 +5721,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: { @@ -6516,6 +6547,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: { @@ -8642,6 +8680,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: { @@ -8812,6 +8856,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: { @@ -9487,6 +9537,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 @@ -10367,6 +10423,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 @@ -12869,6 +12932,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 @@ -13365,6 +13432,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: {} @@ -15052,6 +15124,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 @@ -15135,6 +15209,8 @@ snapshots: trough@2.2.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 From 13155cba23924447b236eeb66dbfdd5b7a65d00e Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 13:29:21 +0530 Subject: [PATCH 4/8] feat(llm): Added OpenAI provider --- .../src/lib/simulation/provider-resolver.ts | 11 + package.json | 1 + packages/llm/README.md | 26 ++ packages/llm/chat-openai.md | 225 ++++++++++++++ packages/llm/src/config.ts | 1 + packages/llm/src/index.ts | 1 + packages/llm/src/llm.ts | 7 + packages/llm/src/provider-manager.ts | 120 +++++++ packages/llm/src/providers/openai.ts | 160 ++++++++++ pnpm-lock.yaml | 292 ++++++++++++++++++ 10 files changed, 844 insertions(+) create mode 100644 packages/llm/chat-openai.md create mode 100644 packages/llm/src/providers/openai.ts diff --git a/apps/gui/src/lib/simulation/provider-resolver.ts b/apps/gui/src/lib/simulation/provider-resolver.ts index bdb22fb..47a2993 100644 --- a/apps/gui/src/lib/simulation/provider-resolver.ts +++ b/apps/gui/src/lib/simulation/provider-resolver.ts @@ -6,6 +6,8 @@ import { ProviderManager, OpenRouterProvider, AnthropicProvider, + OpenAIProvider, + OpenAIEmbeddingProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, } from "@omnia/llm"; @@ -75,6 +77,13 @@ function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider { inst.name, inst.maxContext, ); + } else if (inst.providerName === "openai") { + return new OpenAIProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); } return new MockLLMProvider([]); } @@ -86,6 +95,8 @@ function buildEmbeddingProvider( 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); } diff --git a/package.json b/package.json index f4d1a3d..9989e42 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@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" diff --git a/packages/llm/README.md b/packages/llm/README.md index 4f0fea8..5d71278 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -279,6 +279,29 @@ Also exports `GeminiEmbeddingProvider` (implements `IEmbeddingProvider`) using t 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 | @@ -377,6 +400,7 @@ The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the fi | `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)_ | @@ -405,6 +429,7 @@ This sends the Zod schema to the model as a structured output constraint. The re | 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 | @@ -424,6 +449,7 @@ packages/llm/ │ ├── ollama.ts # OllamaProvider + OllamaEmbeddingProvider │ ├── openrouter.ts # OpenRouterProvider │ ├── anthropic.ts # AnthropicProvider +│ ├── openai.ts # OpenAIProvider + OpenAIEmbeddingProvider │ └── mock.ts # MockLLMProvider + MockEmbeddingProvider ├── tests/ │ ├── mock.test.ts diff --git a/packages/llm/chat-openai.md b/packages/llm/chat-openai.md new file mode 100644 index 0000000..ac1c5ba --- /dev/null +++ b/packages/llm/chat-openai.md @@ -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). + + + See [this section for general instructions on installing LangChain packages](/oss/javascript/langchain/install). + + +```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 + + + Learn more about [OpenAI's moderation models](https://platform.openai.com/docs/guides/moderation) and categories. + + +**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", + }), + ], +}); +``` + + + + 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. + + + + OpenAI moderation model to use. Options: `'omni-moderation-latest'`, `'omni-moderation-2024-09-26'`, `'text-moderation-latest'`, `'text-moderation-stable'` + + + + Whether to check user input messages before the model is called + + + + Whether to check model output messages after the model is called + + + + Whether to check tool result messages before the model is called + + + + 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 + + + + + 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}."` + + + + + + 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]", + }), + ], +}); +``` + + + +--- + +
+ + [Connect these docs](/use-these-docs) to Claude, VSCode, and more via MCP for real-time answers. + + + + [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). + +
diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts index dad9d7b..f3b3b7f 100644 --- a/packages/llm/src/config.ts +++ b/packages/llm/src/config.ts @@ -4,6 +4,7 @@ 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); diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index bb0aee7..b6452b0 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -5,4 +5,5 @@ 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"; diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index ea850c9..98f7614 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -76,6 +76,13 @@ 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", diff --git a/packages/llm/src/provider-manager.ts b/packages/llm/src/provider-manager.ts index 4fc572d..e8b5693 100644 --- a/packages/llm/src/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -100,7 +100,9 @@ function getSettingsDb() { 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"; @@ -137,6 +139,7 @@ function getSettingsDb() { "embedding", 0, ); + hasInsertedEmbedding = true; } if (anthropicKey && anthropicKey.trim()) { @@ -162,6 +165,50 @@ function getSettingsDb() { } } + 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()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -416,7 +463,9 @@ export class ProviderManager { 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"; @@ -453,6 +502,7 @@ export class ProviderManager { "embedding", 0, ); + hasInsertedEmbedding = true; } if (anthropicKey && anthropicKey.trim()) { @@ -478,6 +528,50 @@ export class ProviderManager { } } + 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()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -608,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; } @@ -624,6 +731,19 @@ 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 { diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts new file mode 100644 index 0000000..0565504 --- /dev/null +++ b/packages/llm/src/providers/openai.ts @@ -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( + request: LLMRequest, + ): Promise>> { + const structuredModel = this.model.withStructuredOutput(request.schema, { + includeRaw: true, + }); + const result = (await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ])) as unknown as { + parsed?: z.infer; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = { + 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 { + return this.model.embedQuery(text); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63145f6..13ed722 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: "@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(ws@8.21.0)(zod@4.4.3) @@ -2101,6 +2104,15 @@ packages: 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: { @@ -3894,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: { @@ -4107,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: { @@ -4130,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: { @@ -4286,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: { @@ -4591,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: { @@ -5173,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: { @@ -5396,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: { @@ -5590,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: { @@ -5827,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: { @@ -6008,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: { @@ -6187,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: { @@ -7272,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: { @@ -7279,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: { @@ -7432,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: { @@ -7577,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: { @@ -8844,6 +8998,12 @@ packages: } engines: { node: ">=0.6" } + tr46@0.0.3: + resolution: + { + integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, + } + trim-lines@3.0.1: resolution: { @@ -8953,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: { @@ -9358,12 +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: { @@ -10456,6 +10641,16 @@ snapshots: 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: js-tiktoken: 1.0.21 @@ -11720,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 @@ -11892,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 @@ -11903,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 @@ -12067,6 +12279,8 @@ snapshots: - uploadthing - yaml + asynckit@0.4.0: {} + atomically@1.7.0: {} autoprefixer@10.5.2(postcss@8.5.16): @@ -12226,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: {} @@ -12556,6 +12774,8 @@ snapshots: dependencies: robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -12656,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: @@ -12818,6 +13045,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} eventemitter3@5.0.4: {} @@ -12995,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: {} @@ -13087,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 @@ -13302,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 @@ -14084,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 @@ -14166,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: {} @@ -14246,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 @@ -15205,6 +15484,8 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -15263,6 +15544,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici-types@7.18.2: {} @@ -15469,8 +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 From 2b56c01e4ca6ade8774c712ed7e69332b7fdc3b1 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 18:25:07 +0530 Subject: [PATCH 5/8] feat(llm): Added Groq and Deepseek providers --- .../src/lib/simulation/provider-resolver.ts | 16 +++ package.json | 2 + packages/llm/README.md | 48 +++++++ packages/llm/src/config.ts | 2 + packages/llm/src/index.ts | 2 + packages/llm/src/llm.ts | 14 ++ packages/llm/src/provider-manager.ts | 122 ++++++++++++++++ packages/llm/src/providers/deepseek.ts | 114 +++++++++++++++ packages/llm/src/providers/groq.ts | 112 +++++++++++++++ packages/llm/tests/deepseek.test.ts | 98 +++++++++++++ packages/llm/tests/groq.test.ts | 98 +++++++++++++ packages/llm/tests/openai.test.ts | 135 ++++++++++++++++++ pnpm-lock.yaml | 66 +++++++++ 13 files changed, 829 insertions(+) create mode 100644 packages/llm/src/providers/deepseek.ts create mode 100644 packages/llm/src/providers/groq.ts create mode 100644 packages/llm/tests/deepseek.test.ts create mode 100644 packages/llm/tests/groq.test.ts create mode 100644 packages/llm/tests/openai.test.ts diff --git a/apps/gui/src/lib/simulation/provider-resolver.ts b/apps/gui/src/lib/simulation/provider-resolver.ts index 47a2993..f7c37cd 100644 --- a/apps/gui/src/lib/simulation/provider-resolver.ts +++ b/apps/gui/src/lib/simulation/provider-resolver.ts @@ -8,6 +8,8 @@ import { AnthropicProvider, OpenAIProvider, OpenAIEmbeddingProvider, + GroqProvider, + DeepSeekProvider, GeminiEmbeddingProvider, MockEmbeddingProvider, } from "@omnia/llm"; @@ -84,6 +86,20 @@ function buildLLMProvider(inst: ModelProviderInstance): ILLMProvider { inst.name, inst.maxContext, ); + } else if (inst.providerName === "groq") { + return new GroqProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); + } else if (inst.providerName === "deepseek") { + return new DeepSeekProvider( + inst.apiKey, + inst.modelName, + inst.name, + inst.maxContext, + ); } return new MockLLMProvider([]); } diff --git a/package.json b/package.json index 9989e42..62750c1 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,9 @@ }, "dependencies": { "@langchain/anthropic": "^0.3.11", + "@langchain/deepseek": "^1.1.5", "@langchain/google-genai": "^2.2.0", + "@langchain/groq": "^1.3.1", "@langchain/ollama": "^0.2.3", "@langchain/openai": "^0.3.17", "@langchain/openrouter": "^0.4.3", diff --git a/packages/llm/README.md b/packages/llm/README.md index 5d71278..ff40f18 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -279,6 +279,48 @@ Also exports `GeminiEmbeddingProvider` (implements `IEmbeddingProvider`) using t 4. None found → throw Error ``` +### Groq — `GroqProvider` + +| Property | Value | +| --------------------------- | -------------------------------------------- | +| **File** | [`providers/groq.ts`](src/providers/groq.ts) | +| **Provider ID** | `groq` | +| **SDK** | `@langchain/groq` (`ChatGroq`) | +| **Default Model** | `llama-3.3-70b-versatile` | +| **Default Embedding Model** | _(none)_ | +| **Default Max Context** | `8192` | +| **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 "groq" +3. GROQ_API_KEY env var → final fallback +4. None found → throw Error +``` + +### DeepSeek — `DeepSeekProvider` + +| Property | Value | +| --------------------------- | ---------------------------------------------------- | +| **File** | [`providers/deepseek.ts`](src/providers/deepseek.ts) | +| **Provider ID** | `deepseek` | +| **SDK** | `@langchain/deepseek` (`ChatDeepSeek`) | +| **Default Model** | `deepseek-chat` | +| **Default Embedding Model** | _(none)_ | +| **Default Max Context** | `64000` | +| **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 "deepseek" +3. DEEPSEEK_API_KEY env var → final fallback +4. None found → throw Error +``` + ### OpenAI — `OpenAIProvider` | Property | Value | @@ -404,6 +446,8 @@ The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the fi | `"openrouter"` | `OpenRouterProvider` | _(falls through to mock)_ | | `"ollama"` | `OllamaProvider` | `OllamaEmbeddingProvider` | | `"anthropic"` | `AnthropicProvider` | _(falls through to mock)_ | +| `"groq"` | `GroqProvider` | _(falls through to mock)_ | +| `"deepseek"` | `DeepSeekProvider` | _(falls through to mock)_ | | _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` | ## Structured Output @@ -432,6 +476,8 @@ This sends the Zod schema to the model as a structured output constraint. The re | `OPENAI_API_KEY` | No | OpenAI API key | | `OPENROUTER_API_KEY` | No | OpenRouter API key | | `ANTHROPIC_API_KEY` | No | Anthropic Claude API key | +| `GROQ_API_KEY` | No | Groq API key | +| `DEEPSEEK_API_KEY` | No | DeepSeek API key | Both are optional because providers can also be configured through the database via the GUI settings page. @@ -450,6 +496,8 @@ packages/llm/ │ ├── openrouter.ts # OpenRouterProvider │ ├── anthropic.ts # AnthropicProvider │ ├── openai.ts # OpenAIProvider + OpenAIEmbeddingProvider +│ ├── groq.ts # GroqProvider +│ ├── deepseek.ts # DeepSeekProvider │ └── mock.ts # MockLLMProvider + MockEmbeddingProvider ├── tests/ │ ├── mock.test.ts diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts index f3b3b7f..881ecc2 100644 --- a/packages/llm/src/config.ts +++ b/packages/llm/src/config.ts @@ -5,6 +5,8 @@ const LLMConfigSchema = z.object({ OPENROUTER_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(), + GROQ_API_KEY: z.string().optional(), + DEEPSEEK_API_KEY: z.string().optional(), }); export const llmConfig = LLMConfigSchema.parse(process.env); diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index b6452b0..0bd1970 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -6,4 +6,6 @@ export * from "./providers/ollama.js"; export * from "./providers/openrouter.js"; export * from "./providers/anthropic.js"; export * from "./providers/openai.js"; +export * from "./providers/groq.js"; +export * from "./providers/deepseek.js"; export * from "./provider-manager.js"; diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index 98f7614..69a7c7c 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -90,6 +90,20 @@ export const AVAILABLE_PROVIDERS: ModelProviderMeta[] = [ defaultModel: "claude-3-5-sonnet-latest", defaultEmbeddingModel: "", }, + { + id: "groq", + displayName: "Groq", + description: "Official Groq integration using @langchain/groq SDK", + defaultModel: "llama-3.3-70b-versatile", + defaultEmbeddingModel: "", + }, + { + id: "deepseek", + displayName: "DeepSeek", + description: "Official DeepSeek integration using @langchain/deepseek SDK", + defaultModel: "deepseek-chat", + defaultEmbeddingModel: "", + }, { id: "openrouter", displayName: "OpenRouter", diff --git a/packages/llm/src/provider-manager.ts b/packages/llm/src/provider-manager.ts index e8b5693..c6fe5f0 100644 --- a/packages/llm/src/provider-manager.ts +++ b/packages/llm/src/provider-manager.ts @@ -101,6 +101,8 @@ function getSettingsDb() { const openRouterKey = process.env.OPENROUTER_API_KEY; const anthropicKey = process.env.ANTHROPIC_API_KEY; const openaiKey = process.env.OPENAI_API_KEY; + const groqKey = process.env.GROQ_API_KEY; + const deepseekKey = process.env.DEEPSEEK_API_KEY; let hasInsertedGenerative = false; let hasInsertedEmbedding = false; @@ -209,6 +211,52 @@ function getSettingsDb() { } } + if (groqKey && groqKey.trim()) { + const id = "provider-default-groq"; + const isActive = hasInsertedGenerative ? 0 : 1; + db.prepare( + ` + INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + id, + "Groq (Env)", + "groq", + groqKey.trim(), + isActive, + "llama-3.3-70b-versatile", + "generative", + 8192, + ); + if (isActive === 1) { + hasInsertedGenerative = true; + } + } + + if (deepseekKey && deepseekKey.trim()) { + const id = "provider-default-deepseek"; + const isActive = hasInsertedGenerative ? 0 : 1; + db.prepare( + ` + INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + id, + "DeepSeek (Env)", + "deepseek", + deepseekKey.trim(), + isActive, + "deepseek-chat", + "generative", + 64000, + ); + if (isActive === 1) { + hasInsertedGenerative = true; + } + } + if (openRouterKey && openRouterKey.trim()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -464,6 +512,8 @@ export class ProviderManager { const openRouterKey = process.env.OPENROUTER_API_KEY; const anthropicKey = process.env.ANTHROPIC_API_KEY; const openaiKey = process.env.OPENAI_API_KEY; + const groqKey = process.env.GROQ_API_KEY; + const deepseekKey = process.env.DEEPSEEK_API_KEY; let hasInsertedGenerative = false; let hasInsertedEmbedding = false; @@ -572,6 +622,52 @@ export class ProviderManager { } } + if (groqKey && groqKey.trim()) { + const id = "provider-default-groq"; + const isActive = hasInsertedGenerative ? 0 : 1; + db.prepare( + ` + INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + id, + "Groq (Env)", + "groq", + groqKey.trim(), + isActive, + "llama-3.3-70b-versatile", + "generative", + 8192, + ); + if (isActive === 1) { + hasInsertedGenerative = true; + } + } + + if (deepseekKey && deepseekKey.trim()) { + const id = "provider-default-deepseek"; + const isActive = hasInsertedGenerative ? 0 : 1; + db.prepare( + ` + INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName, type, maxContext) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + id, + "DeepSeek (Env)", + "deepseek", + deepseekKey.trim(), + isActive, + "deepseek-chat", + "generative", + 64000, + ); + if (isActive === 1) { + hasInsertedGenerative = true; + } + } + if (openRouterKey && openRouterKey.trim()) { const id = "provider-default-openrouter"; const isActive = hasInsertedGenerative ? 0 : 1; @@ -757,6 +853,32 @@ export class ProviderManager { maxContext: 200000, }; } + const groqKey = process.env.GROQ_API_KEY; + if (groqKey && groqKey.trim()) { + return { + id: "provider-default-env-fallback", + name: "Groq (Env Fallback)", + providerName: "groq", + apiKey: groqKey.trim(), + isActive: true, + modelName: "llama-3.3-70b-versatile", + type: "generative", + maxContext: 8192, + }; + } + const deepseekKey = process.env.DEEPSEEK_API_KEY; + if (deepseekKey && deepseekKey.trim()) { + return { + id: "provider-default-env-fallback", + name: "DeepSeek (Env Fallback)", + providerName: "deepseek", + apiKey: deepseekKey.trim(), + isActive: true, + modelName: "deepseek-chat", + type: "generative", + maxContext: 64000, + }; + } const openRouterKey = process.env.OPENROUTER_API_KEY; if (openRouterKey && openRouterKey.trim()) { return { diff --git a/packages/llm/src/providers/deepseek.ts b/packages/llm/src/providers/deepseek.ts new file mode 100644 index 0000000..868d884 --- /dev/null +++ b/packages/llm/src/providers/deepseek.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { ChatDeepSeek } from "@langchain/deepseek"; +import { + ILLMProvider, + LLMRequest, + LLMResponse, + LLMCallRecord, +} from "../llm.js"; +import { llmConfig } from "../config.js"; +import { ProviderManager } from "../provider-manager.js"; + +export class DeepSeekProvider implements ILLMProvider { + static readonly providerId = "deepseek"; + static readonly displayName = "DeepSeek"; + static readonly description = + "Official DeepSeek integration using @langchain/deepseek SDK"; + static readonly defaultModel = "deepseek-chat"; + + providerName = "DeepSeek"; + private model: ChatDeepSeek; + 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 === DeepSeekProvider.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.DEEPSEEK_API_KEY; + if (!this.providerInstanceName && key) { + this.providerInstanceName = "Environment Variable"; + } + } + + if (!key) { + throw new Error( + "DEEPSEEK_API_KEY is required to initialize DeepSeekProvider", + ); + } + + this.modelNameUsed = model || DeepSeekProvider.defaultModel; + this.model = new ChatDeepSeek({ + apiKey: key, + model: this.modelNameUsed, + }); + } + + async generateStructuredResponse( + request: LLMRequest, + ): Promise>> { + const structuredModel = this.model.withStructuredOutput(request.schema, { + includeRaw: true, + }); + const result = (await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ])) as unknown as { + parsed?: z.infer; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = { + 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 : 64000, + }; + + this.lastCalls.push({ + systemPrompt: request.systemPrompt, + userContext: request.userContext, + usage, + }); + + return { success: true, data: parsed, usage }; + } +} diff --git a/packages/llm/src/providers/groq.ts b/packages/llm/src/providers/groq.ts new file mode 100644 index 0000000..20df4ba --- /dev/null +++ b/packages/llm/src/providers/groq.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import { ChatGroq } from "@langchain/groq"; +import { + ILLMProvider, + LLMRequest, + LLMResponse, + LLMCallRecord, +} from "../llm.js"; +import { llmConfig } from "../config.js"; +import { ProviderManager } from "../provider-manager.js"; + +export class GroqProvider implements ILLMProvider { + static readonly providerId = "groq"; + static readonly displayName = "Groq"; + static readonly description = + "Official Groq integration using @langchain/groq SDK"; + static readonly defaultModel = "llama-3.3-70b-versatile"; + + providerName = "Groq"; + private model: ChatGroq; + 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 === GroqProvider.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.GROQ_API_KEY; + if (!this.providerInstanceName && key) { + this.providerInstanceName = "Environment Variable"; + } + } + + if (!key) { + throw new Error("GROQ_API_KEY is required to initialize GroqProvider"); + } + + this.modelNameUsed = model || GroqProvider.defaultModel; + this.model = new ChatGroq({ + apiKey: key, + model: this.modelNameUsed, + }); + } + + async generateStructuredResponse( + request: LLMRequest, + ): Promise>> { + const structuredModel = this.model.withStructuredOutput(request.schema, { + includeRaw: true, + }); + const result = (await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ])) as unknown as { + parsed?: z.infer; + raw?: { + usage_metadata?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + }; + }; + }; + + const parsed = result?.parsed; + const raw = result?.raw; + + const usage = { + 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 : 8192, + }; + + this.lastCalls.push({ + systemPrompt: request.systemPrompt, + userContext: request.userContext, + usage, + }); + + return { success: true, data: parsed, usage }; + } +} diff --git a/packages/llm/tests/deepseek.test.ts b/packages/llm/tests/deepseek.test.ts new file mode 100644 index 0000000..4a4d06e --- /dev/null +++ b/packages/llm/tests/deepseek.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, vi } from "vitest"; +import { z } from "zod"; +import { DeepSeekProvider } from "../src/providers/deepseek.js"; +import { llmConfig } from "../src/config.js"; + +// Mock the ChatDeepSeek class +vi.mock("@langchain/deepseek", () => { + return { + ChatDeepSeek: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + withStructuredOutput = vi.fn().mockImplementation(() => { + return { + invoke: vi.fn().mockImplementation(async () => { + return { + parsed: { + name: "mocked response", + success: true, + }, + raw: { + usage_metadata: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }, + }; + }), + }; + }); + }, + }; +}); + +describe("DeepSeekProvider Unit Tests (Tier 1)", () => { + test("initializes successfully with a provided apiKey", () => { + const provider = new DeepSeekProvider("dummy-key"); + expect(provider.providerName).toBe("DeepSeek"); + }); + + test("initializes successfully with apiKey from config", () => { + const originalKey = llmConfig.DEEPSEEK_API_KEY; + llmConfig.DEEPSEEK_API_KEY = "env-dummy-key"; + + try { + const provider = new DeepSeekProvider(); + expect(provider.providerName).toBe("DeepSeek"); + } finally { + llmConfig.DEEPSEEK_API_KEY = originalKey; + } + }); + + test("throws error if no API key is provided or in config", () => { + const originalKey = llmConfig.DEEPSEEK_API_KEY; + llmConfig.DEEPSEEK_API_KEY = undefined; + + try { + expect(() => new DeepSeekProvider()).toThrow( + "DEEPSEEK_API_KEY is required to initialize DeepSeekProvider", + ); + } finally { + llmConfig.DEEPSEEK_API_KEY = originalKey; + } + }); + + test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => { + const provider = new DeepSeekProvider("dummy-key"); + const TestSchema = z.object({ + name: z.string(), + success: z.boolean(), + }); + + const response = await provider.generateStructuredResponse({ + systemPrompt: "system prompt", + userContext: "user context", + schema: TestSchema, + }); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ + name: "mocked response", + success: true, + }); + + expect(response.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + modelName: "deepseek-chat", + providerInstanceName: "Default", + maxContext: 64000, + }); + + expect(provider.lastCalls.length).toBe(1); + }); +}); diff --git a/packages/llm/tests/groq.test.ts b/packages/llm/tests/groq.test.ts new file mode 100644 index 0000000..0587a3c --- /dev/null +++ b/packages/llm/tests/groq.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, vi } from "vitest"; +import { z } from "zod"; +import { GroqProvider } from "../src/providers/groq.js"; +import { llmConfig } from "../src/config.js"; + +// Mock the ChatGroq class +vi.mock("@langchain/groq", () => { + return { + ChatGroq: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + withStructuredOutput = vi.fn().mockImplementation(() => { + return { + invoke: vi.fn().mockImplementation(async () => { + return { + parsed: { + name: "mocked response", + success: true, + }, + raw: { + usage_metadata: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }, + }; + }), + }; + }); + }, + }; +}); + +describe("GroqProvider Unit Tests (Tier 1)", () => { + test("initializes successfully with a provided apiKey", () => { + const provider = new GroqProvider("dummy-key"); + expect(provider.providerName).toBe("Groq"); + }); + + test("initializes successfully with apiKey from config", () => { + const originalKey = llmConfig.GROQ_API_KEY; + llmConfig.GROQ_API_KEY = "env-dummy-key"; + + try { + const provider = new GroqProvider(); + expect(provider.providerName).toBe("Groq"); + } finally { + llmConfig.GROQ_API_KEY = originalKey; + } + }); + + test("throws error if no API key is provided or in config", () => { + const originalKey = llmConfig.GROQ_API_KEY; + llmConfig.GROQ_API_KEY = undefined; + + try { + expect(() => new GroqProvider()).toThrow( + "GROQ_API_KEY is required to initialize GroqProvider", + ); + } finally { + llmConfig.GROQ_API_KEY = originalKey; + } + }); + + test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => { + const provider = new GroqProvider("dummy-key"); + const TestSchema = z.object({ + name: z.string(), + success: z.boolean(), + }); + + const response = await provider.generateStructuredResponse({ + systemPrompt: "system prompt", + userContext: "user context", + schema: TestSchema, + }); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ + name: "mocked response", + success: true, + }); + + expect(response.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + modelName: "llama-3.3-70b-versatile", + providerInstanceName: "Default", + maxContext: 8192, + }); + + expect(provider.lastCalls.length).toBe(1); + }); +}); diff --git a/packages/llm/tests/openai.test.ts b/packages/llm/tests/openai.test.ts new file mode 100644 index 0000000..efaaca8 --- /dev/null +++ b/packages/llm/tests/openai.test.ts @@ -0,0 +1,135 @@ +import { describe, test, expect, vi } from "vitest"; +import { z } from "zod"; +import { + OpenAIProvider, + OpenAIEmbeddingProvider, +} from "../src/providers/openai.js"; +import { llmConfig } from "../src/config.js"; + +// Mock the ChatOpenAI and OpenAIEmbeddings classes +vi.mock("@langchain/openai", () => { + return { + ChatOpenAI: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + withStructuredOutput = vi.fn().mockImplementation(() => { + return { + invoke: vi.fn().mockImplementation(async () => { + return { + parsed: { + name: "mocked response", + success: true, + }, + raw: { + usage_metadata: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }, + }, + }; + }), + }; + }); + }, + OpenAIEmbeddings: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + embedQuery = vi.fn().mockImplementation(async (text: string) => { + return [0.1, 0.2, 0.3]; + }); + }, + }; +}); + +describe("OpenAIProvider Unit Tests (Tier 1)", () => { + test("initializes successfully with a provided apiKey", () => { + const provider = new OpenAIProvider("dummy-key"); + expect(provider.providerName).toBe("OpenAI"); + }); + + test("initializes successfully with apiKey from config", () => { + const originalKey = llmConfig.OPENAI_API_KEY; + llmConfig.OPENAI_API_KEY = "env-dummy-key"; + + try { + const provider = new OpenAIProvider(); + expect(provider.providerName).toBe("OpenAI"); + } finally { + llmConfig.OPENAI_API_KEY = originalKey; + } + }); + + test("throws error if no API key is provided or in config", () => { + const originalKey = llmConfig.OPENAI_API_KEY; + llmConfig.OPENAI_API_KEY = undefined; + + try { + expect(() => new OpenAIProvider()).toThrow( + "OPENAI_API_KEY is required to initialize OpenAIProvider", + ); + } finally { + llmConfig.OPENAI_API_KEY = originalKey; + } + }); + + test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => { + const provider = new OpenAIProvider("dummy-key"); + const TestSchema = z.object({ + name: z.string(), + success: z.boolean(), + }); + + const response = await provider.generateStructuredResponse({ + systemPrompt: "system prompt", + userContext: "user context", + schema: TestSchema, + }); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ + name: "mocked response", + success: true, + }); + + expect(response.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + modelName: "gpt-4o-mini", + providerInstanceName: "Default", + maxContext: 128000, + }); + + expect(provider.lastCalls.length).toBe(1); + }); +}); + +describe("OpenAIEmbeddingProvider Unit Tests (Tier 1)", () => { + test("initializes successfully with a provided apiKey", () => { + const provider = new OpenAIEmbeddingProvider("dummy-key"); + expect(provider.providerName).toBe("OpenAI"); + }); + + test("initializes successfully with apiKey from config", () => { + const originalKey = llmConfig.OPENAI_API_KEY; + llmConfig.OPENAI_API_KEY = "env-dummy-key"; + + try { + const provider = new OpenAIEmbeddingProvider(); + expect(provider.providerName).toBe("OpenAI"); + } finally { + llmConfig.OPENAI_API_KEY = originalKey; + } + }); + + test("embed returns dummy array successfully", async () => { + const provider = new OpenAIEmbeddingProvider("dummy-key"); + const result = await provider.embed("hello"); + expect(result).toEqual([0.1, 0.2, 0.3]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13ed722..17b33d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,9 +263,15 @@ importers: "@langchain/anthropic": specifier: ^0.3.11 version: 0.3.34(zod@4.4.3) + "@langchain/deepseek": + specifier: ^1.1.5 + version: 1.1.5(ws@8.21.0) "@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/groq": + specifier: ^1.3.1 + version: 1.3.1 "@langchain/ollama": specifier: ^0.2.3 version: 0.2.4 @@ -2086,6 +2092,15 @@ packages: } engines: { node: ">=20" } + "@langchain/deepseek@1.1.5": + resolution: + { + integrity: sha512-5IRoEUaHAgIF8TyIncNVhhjavCqsjWTjakWsnus1yJN2X3W15Bw8Qmf+vJzCnFo7yndICsGdOGfHJoIN5xNxoQ==, + } + engines: { node: ">=20" } + peerDependencies: + "@langchain/core": ^1.0.0 + "@langchain/google-genai@2.2.0": resolution: { @@ -2095,6 +2110,15 @@ packages: peerDependencies: "@langchain/core": ^1.2.0 + "@langchain/groq@1.3.1": + resolution: + { + integrity: sha512-ImxfGBis4FEHpZdeT6dot6V6l09uBLYm/1BHQ6x3XEQmJFF7aKwbOeSOV5h/h5IeRx+2gaInR+LfyYoqT8satQ==, + } + engines: { node: ">=20" } + peerDependencies: + "@langchain/core": ^1.1.30 + "@langchain/ollama@0.2.4": resolution: { @@ -2122,6 +2146,15 @@ packages: peerDependencies: "@langchain/core": ^1.2.1 + "@langchain/openai@1.5.5": + resolution: + { + integrity: sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==, + } + engines: { node: ">=20" } + peerDependencies: + "@langchain/core": ^1.2.2 + "@langchain/openrouter@0.4.3": resolution: { @@ -6081,6 +6114,13 @@ packages: integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, } + groq-sdk@1.3.0: + resolution: + { + integrity: sha512-mvgUIpAxlk/VxWIoliHx4R+Ha78Bd/g0t24OFjCXtdLbNiY1rW4h9AcznKBwFho7K/Nq732ZSjxMYkNb1xeCFg==, + } + hasBin: true + h3@1.15.11: resolution: { @@ -10631,11 +10671,24 @@ snapshots: - openai - ws + "@langchain/deepseek@1.1.5(ws@8.21.0)": + dependencies: + "@langchain/openai": 1.5.5(ws@8.21.0) + transitivePeerDependencies: + - "@aws-sdk/credential-provider-node" + - "@smithy/hash-node" + - "@smithy/signature-v4" + - ws + "@langchain/google-genai@2.2.0(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))": dependencies: "@google/generative-ai": 0.24.1 "@langchain/core": 1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + "@langchain/groq@1.3.1": + dependencies: + groq-sdk: 1.3.0 + "@langchain/ollama@0.2.4": dependencies: ollama: 0.5.18 @@ -10662,6 +10715,17 @@ snapshots: - "@smithy/signature-v4" - ws + "@langchain/openai@1.5.5(ws@8.21.0)": + dependencies: + js-tiktoken: 1.0.21 + openai: 6.45.0(ws@8.21.0)(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - "@aws-sdk/credential-provider-node" + - "@smithy/hash-node" + - "@smithy/signature-v4" + - ws + "@langchain/openrouter@0.4.3(ws@8.21.0)(zod@4.4.3)": dependencies: "@langchain/openai": 1.5.3(ws@8.21.0) @@ -13315,6 +13379,8 @@ snapshots: graceful-fs@4.2.11: {} + groq-sdk@1.3.0: {} + h3@1.15.11: dependencies: cookie-es: 1.2.3 From 8ad94d3fc2a564b89bb4c539d7963ec35b39098f Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 19:16:55 +0530 Subject: [PATCH 6/8] feat(llm): Dynamically fetch available models from providers --- apps/gui/src/app/actions.ts | 31 ++ .../config/ProviderInstancesConfig.tsx | 172 +++++++++- apps/gui/src/components/ui/combobox.tsx | 300 ++++++++++++++++++ apps/gui/src/components/ui/input-group.tsx | 172 ++++++++++ packages/llm/README.md | 34 ++ packages/llm/src/index.ts | 1 + packages/llm/src/model-lister.ts | 261 +++++++++++++++ packages/llm/tests/model-lister.test.ts | 120 +++++++ 8 files changed, 1083 insertions(+), 8 deletions(-) create mode 100644 apps/gui/src/components/ui/combobox.tsx create mode 100644 apps/gui/src/components/ui/input-group.tsx create mode 100644 packages/llm/src/model-lister.ts create mode 100644 packages/llm/tests/model-lister.test.ts diff --git a/apps/gui/src/app/actions.ts b/apps/gui/src/app/actions.ts index 23d8152..b5e726c 100644 --- a/apps/gui/src/app/actions.ts +++ b/apps/gui/src/app/actions.ts @@ -9,6 +9,8 @@ import { ModelProviderInstance, AVAILABLE_PROVIDERS, ModelProviderMeta, + ModelLister, + ModelInfo, } from "@omnia/llm"; function resolveScenarioPath(relative: string): string { @@ -317,3 +319,32 @@ export async function regenerateEmbeddings( ): Promise { await simulationManager.regenerateAllEmbeddings(newProviderInstanceId); } + +/** + * Fetch available models for a provider given its credentials. + * Used when creating a new instance (before it's saved to the DB). + */ +export async function fetchAvailableModels( + providerName: string, + apiKey: string, + endpointUrl?: string, +): Promise { + return ModelLister.listModels(providerName, apiKey, endpointUrl); +} + +/** + * Fetch available models for an existing saved provider instance. + * The API key is retrieved from the DB server-side — never sent to the client. + */ +export async function fetchAvailableModelsForInstance( + instanceId: string, +): Promise { + const instances = ProviderManager.list(); + const inst = instances.find((i) => i.id === instanceId); + if (!inst) return []; + return ModelLister.listModels( + inst.providerName, + inst.apiKey, + inst.endpointUrl, + ); +} diff --git a/apps/gui/src/components/config/ProviderInstancesConfig.tsx b/apps/gui/src/components/config/ProviderInstancesConfig.tsx index 192016b..c70a98a 100644 --- a/apps/gui/src/components/config/ProviderInstancesConfig.tsx +++ b/apps/gui/src/components/config/ProviderInstancesConfig.tsx @@ -1,14 +1,20 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createProviderInstance, updateProviderInstance, setActiveProviderInstance, regenerateEmbeddings, deleteProviderInstance, + fetchAvailableModels, + fetchAvailableModelsForInstance, } from "@/app/actions"; -import type { ModelProviderInstance, ModelProviderMeta } from "@omnia/llm"; +import type { + ModelInfo, + ModelProviderInstance, + ModelProviderMeta, +} from "@omnia/llm"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -22,6 +28,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; import { Card, CardHeader, @@ -38,6 +52,7 @@ import { } from "@/components/ui/item"; import { Empty, EmptyTitle, EmptyDescription } from "@/components/ui/empty"; import { cn } from "@/lib/utils"; +import { RefreshCwIcon } from "lucide-react"; interface ProviderInstancesConfigProps { instances: ModelProviderInstance[]; @@ -68,6 +83,61 @@ export function ProviderInstancesConfig({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + // Model listing state + const [availableModels, setAvailableModels] = useState([]); + const [modelsLoading, setModelsLoading] = useState(false); + const debounceTimer = useRef | null>(null); + + // Fetch models for an existing saved instance + const fetchModelsForExistingInstance = async (instanceId: string) => { + setModelsLoading(true); + setAvailableModels([]); + try { + const models = await fetchAvailableModelsForInstance(instanceId); + setAvailableModels(models); + } catch { + setAvailableModels([]); + } finally { + setModelsLoading(false); + } + }; + + // Fetch models for a new instance (using live key/endpoint from form) + const fetchModelsForNewInstance = ( + provider: string, + apiKey: string, + endpointUrl: string, + ) => { + if (debounceTimer.current) clearTimeout(debounceTimer.current); + + const isOllama = provider === "ollama"; + const hasCredentials = isOllama + ? endpointUrl.trim().length > 0 + : apiKey.trim().length > 8; // don't spam API with partial keys + + if (!hasCredentials) { + setAvailableModels([]); + return; + } + + debounceTimer.current = setTimeout(async () => { + setModelsLoading(true); + setAvailableModels([]); + try { + const models = await fetchAvailableModels( + provider, + isOllama ? "none" : apiKey, + isOllama ? endpointUrl : undefined, + ); + setAvailableModels(models); + } catch { + setAvailableModels([]); + } finally { + setModelsLoading(false); + } + }, 600); + }; + useEffect(() => { if (selectedInstanceId === null) { setEditName(""); @@ -78,6 +148,7 @@ export function ProviderInstancesConfig({ setEditType("generative"); setEditMaxContext(32768); setEditEndpointUrl(""); + setAvailableModels([]); } else if (selectedInstanceId === "new") { setEditName(""); const defaultProvider = "google-genai"; @@ -89,6 +160,7 @@ export function ProviderInstancesConfig({ setEditIsActive(false); setEditMaxContext(32768); setEditEndpointUrl(""); + setAvailableModels([]); } else { const inst = instances.find((i) => i.id === selectedInstanceId); if (inst) { @@ -113,10 +185,21 @@ export function ProviderInstancesConfig({ : 32768, ); setEditEndpointUrl(inst.endpointUrl || ""); + setAvailableModels([]); + // Auto-fetch models for existing instances + fetchModelsForExistingInstance(selectedInstanceId); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedInstanceId, instances, availableProviders]); + // Re-fetch models when provider/key/endpoint changes on new instance form + useEffect(() => { + if (selectedInstanceId !== "new") return; + fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editProvider, editKey, editEndpointUrl, selectedInstanceId]); + const handleProviderChange = (providerId: string | null) => { if (!providerId) return; setEditProvider(providerId); @@ -126,6 +209,7 @@ export function ProviderInstancesConfig({ ? pMeta?.defaultEmbeddingModel || "" : pMeta?.defaultModel || "", ); + setAvailableModels([]); }; const handleTypeChange = (type: "generative" | "embedding") => { @@ -138,6 +222,14 @@ export function ProviderInstancesConfig({ ); }; + const handleRefreshModels = () => { + if (selectedInstanceId && selectedInstanceId !== "new") { + fetchModelsForExistingInstance(selectedInstanceId); + } else { + fetchModelsForNewInstance(editProvider, editKey, editEndpointUrl); + } + }; + const handleSave = async (e: React.FormEvent) => { e.preventDefault(); if (!editName.trim()) { @@ -440,13 +532,77 @@ export function ProviderInstancesConfig({ )}
- - + + +
+ setEditModel(e.target.value)} - placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro" - /> + onValueChange={(v) => { + if (v) setEditModel(v); + }} + items={availableModels.map((m) => m.id)} + > + 0 + ? "Search or select a model…" + : "e.g. gemini-2.5-flash" + } + disabled={modelsLoading} + showClear={false} + value={editModel} + onChange={(e) => + setEditModel((e.target as HTMLInputElement).value) + } + className="w-full" + /> + + + {modelsLoading + ? "Fetching models…" + : "No models found. Type a custom model name above."} + + + {(modelId: string) => { + const model = availableModels.find( + (m) => m.id === modelId, + ); + return ( + + {modelId} + {model?.ownedBy && ( + + {model.ownedBy} + + )} + + ); + }} + + +
{editType === "generative" && ( diff --git a/apps/gui/src/components/ui/combobox.tsx b/apps/gui/src/components/ui/combobox.tsx new file mode 100644 index 0000000..52d5399 --- /dev/null +++ b/apps/gui/src/components/ui/combobox.tsx @@ -0,0 +1,300 @@ +"use client"; + +import * as React from "react"; +import { Combobox as ComboboxPrimitive } from "@base-ui/react"; +import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; + +const Combobox = ComboboxPrimitive.Root; + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return ; +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ); +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ); +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean; + showClear?: boolean; +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + } + data-slot="input-group-button" + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" + disabled={disabled} + /> + )} + {showClear && } + + {children} + + ); +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ); +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ); +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ); +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ); +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ); +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + + ); +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & + ComboboxPrimitive.Chips.Props) { + return ( + + ); +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean; +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ); +} + +function ComboboxChipsInput({ + className, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + + ); +} + +function useComboboxAnchor() { + return React.useRef(null); +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +}; diff --git a/apps/gui/src/components/ui/input-group.tsx b/apps/gui/src/components/ui/input-group.tsx new file mode 100644 index 0000000..d005a09 --- /dev/null +++ b/apps/gui/src/components/ui/input-group.tsx @@ -0,0 +1,172 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className, + )} + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", + "inline-end": + "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": + "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", + }, + }, +); + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }} + {...props} + /> + ); +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: "", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + }, +); + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + render, + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset"; + render?: React.ReactElement; + }) { + if (render) { + return React.cloneElement(render, { + className: cn( + inputGroupButtonVariants({ size }), + (render.props as any)?.className, + className, + ), + type, + ...props, + } as any); + } + + return ( +