From 4339a8b4b5de81c159fc4467b2e635bf9a83642e Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Thu, 9 Jul 2026 11:13:02 +0530 Subject: [PATCH] feat: Added openrouter provider --- package.json | 1 + packages/llm/src/config.ts | 1 + packages/llm/src/index.ts | 1 + packages/llm/src/providers/openrouter.ts | 31 +++++++++ packages/llm/tests/openrouter.test.ts | 81 ++++++++++++++++++++++ pnpm-lock.yaml | 86 ++++++++++++++++++++++-- 6 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 packages/llm/src/providers/openrouter.ts create mode 100644 packages/llm/tests/openrouter.test.ts diff --git a/package.json b/package.json index 00896ae..c3ccf5b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@langchain/google-genai": "^2.2.0", + "@langchain/openrouter": "^0.4.3", "@types/node": "^20.19.43", "dotenv": "^17.4.2" } diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts index 7d500f5..0605902 100644 --- a/packages/llm/src/config.ts +++ b/packages/llm/src/config.ts @@ -2,6 +2,7 @@ import { z } from "zod"; const LLMConfigSchema = z.object({ GOOGLE_API_KEY: z.string().optional(), + OPENROUTER_API_KEY: z.string().optional(), }); export const llmConfig = LLMConfigSchema.parse(process.env); diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index a9795b4..f8df5f7 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -2,3 +2,4 @@ export * from "./llm.js"; export * from "./config.js"; export * from "./providers/google-genai.js"; export * from "./providers/mock.js"; +export * from "./providers/openrouter.js"; diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts new file mode 100644 index 0000000..c115994 --- /dev/null +++ b/packages/llm/src/providers/openrouter.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { ChatOpenRouter } from "@langchain/openrouter"; +import { ILLMProvider, LLMRequest, LLMResponse } from "../llm.js"; +import { llmConfig } from "../config.js"; + +export class OpenRouterProvider implements ILLMProvider { + providerName = "OpenRouter"; + private model: ChatOpenRouter; + + constructor(apiKey?: string, modelName: string = "google/gemini-2.5-flash") { + const key = apiKey || llmConfig.OPENROUTER_API_KEY; + if (!key) { + throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider"); + } + this.model = new ChatOpenRouter({ + apiKey: key, + model: modelName, + }); + } + + async generateStructuredResponse( + request: LLMRequest, + ): Promise>> { + const structuredModel = this.model.withStructuredOutput(request.schema); + const result = await structuredModel.invoke([ + { role: "system", content: request.systemPrompt }, + { role: "user", content: request.userContext }, + ]); + return { success: true, data: result as z.infer }; + } +} diff --git a/packages/llm/tests/openrouter.test.ts b/packages/llm/tests/openrouter.test.ts new file mode 100644 index 0000000..e87ca71 --- /dev/null +++ b/packages/llm/tests/openrouter.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect, vi } from "vitest"; +import { z } from "zod"; +import { OpenRouterProvider } from "../src/providers/openrouter.js"; +import { llmConfig } from "../src/config.js"; + +// Mock the ChatOpenRouter class +vi.mock("@langchain/openrouter", () => { + return { + ChatOpenRouter: class { + config: unknown; + constructor(config: unknown) { + this.config = config; + } + withStructuredOutput = vi.fn().mockImplementation(() => { + return { + invoke: vi.fn().mockImplementation(async () => { + // Return a mock output that matches a sample schema + return { + name: "mocked response", + success: true, + }; + }), + }; + }); + }, + }; +}); + +describe("OpenRouterProvider Unit Tests (Tier 1)", () => { + test("initializes successfully with a provided apiKey", () => { + const provider = new OpenRouterProvider("dummy-key"); + expect(provider.providerName).toBe("OpenRouter"); + }); + + test("initializes successfully with apiKey from config", () => { + // Save current config + const originalKey = llmConfig.OPENROUTER_API_KEY; + llmConfig.OPENROUTER_API_KEY = "env-dummy-key"; + + try { + const provider = new OpenRouterProvider(); + expect(provider.providerName).toBe("OpenRouter"); + } finally { + llmConfig.OPENROUTER_API_KEY = originalKey; + } + }); + + test("throws error if no API key is provided or in config", () => { + // Save current config + const originalKey = llmConfig.OPENROUTER_API_KEY; + llmConfig.OPENROUTER_API_KEY = undefined; + + try { + expect(() => new OpenRouterProvider()).toThrow( + "OPENROUTER_API_KEY is required to initialize OpenRouterProvider" + ); + } finally { + llmConfig.OPENROUTER_API_KEY = originalKey; + } + }); + + test("generateStructuredResponse invokes the model with structured output", async () => { + const provider = new OpenRouterProvider("dummy-key"); + const TestSchema = z.object({ + name: z.string(), + success: z.boolean(), + }); + + const response = await provider.generateStructuredResponse({ + systemPrompt: "system prompt", + userContext: "user context", + schema: TestSchema, + }); + + expect(response.success).toBe(true); + expect(response.data).toEqual({ + name: "mocked response", + success: true, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07c5a1b..3ef0412 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,7 +209,10 @@ importers: dependencies: '@langchain/google-genai': specifier: ^2.2.0 - version: 2.2.0(@langchain/core@1.2.1) + version: 2.2.0(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3))) + '@langchain/openrouter': + specifier: ^0.4.3 + version: 0.4.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(zod@4.4.3) '@types/node': specifier: ^20.19.43 version: 20.19.43 @@ -376,7 +379,7 @@ importers: dependencies: '@langchain/google-genai': specifier: ^2.2.0 - version: 2.2.0(@langchain/core@1.2.1) + version: 2.2.0(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3))) '@types/node': specifier: ^26.1.0 version: 26.1.0 @@ -1259,6 +1262,18 @@ packages: peerDependencies: '@langchain/core': ^1.2.0 + '@langchain/openai@1.5.3': + resolution: {integrity: sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.2.1 + + '@langchain/openrouter@0.4.3': + resolution: {integrity: sha512-LUul0i3VAlQBkhOg0EH1N20DUOxHGAZ1gqQtX1IRO/jYbDL3HZ7uHUupjMnerDaULiyAJZT3DxDw6Cr1QMT4ng==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.0.0 + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -2584,6 +2599,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -3561,6 +3580,26 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + openai@6.45.0: + resolution: {integrity: sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5205,12 +5244,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@langchain/core@1.2.1': + '@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3))': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.7.15 + langsmith: 0.7.15(openai@6.45.0(zod@4.4.3)) mustache: 4.2.0 p-queue: 6.6.2 zod: 4.4.3 @@ -5221,10 +5260,35 @@ snapshots: - openai - ws - '@langchain/google-genai@2.2.0(@langchain/core@1.2.1)': + '@langchain/google-genai@2.2.0(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))': dependencies: '@google/generative-ai': 0.24.1 - '@langchain/core': 1.2.1 + '@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3)) + + '@langchain/openai@1.5.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))': + dependencies: + '@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3)) + js-tiktoken: 1.0.21 + openai: 6.45.0(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - ws + + '@langchain/openrouter@0.4.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3)))(zod@4.4.3)': + dependencies: + '@langchain/core': 1.2.1(openai@6.45.0(zod@4.4.3)) + '@langchain/openai': 1.5.3(@langchain/core@1.2.1(openai@6.45.0(zod@4.4.3))) + eventsource-parser: 3.1.0 + openai: 6.45.0(zod@4.4.3) + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - ws + - zod '@mdx-js/mdx@3.1.1': dependencies: @@ -6854,6 +6918,8 @@ snapshots: eventemitter3@5.0.4: {} + eventsource-parser@3.1.0: {} + expand-template@2.0.3: {} expect-type@1.4.0: {} @@ -7474,9 +7540,11 @@ snapshots: klona@2.0.6: {} - langsmith@0.7.15: + langsmith@0.7.15(openai@6.45.0(zod@4.4.3)): dependencies: p-queue: 6.6.2 + optionalDependencies: + openai: 6.45.0(zod@4.4.3) language-subtag-registry@0.3.23: {} @@ -8185,6 +8253,10 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + openai@6.45.0(zod@4.4.3): + optionalDependencies: + zod: 4.4.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4