From 2b56c01e4ca6ade8774c712ed7e69332b7fdc3b1 Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 16 Jul 2026 18:25:07 +0530 Subject: [PATCH] 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