From 6f3d9e2e1a193029399bda1c3b13beef4f6728fe Mon Sep 17 00:00:00 2001 From: bangla24bdrang-lab Date: Thu, 27 Aug 2026 06:01:30 +0000 Subject: [PATCH] feat(ai-provider): add OrcaRouter as a first-class AI provider --- README.md | 12 +++++-- __tests__/ai-provider.test.ts | 68 +++++++++++++++++++++++++++++++++++ lib/ai-provider.ts | 23 +++++++++--- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2f06a5b0..192d42d9 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ ADANOS_API_KEY=your_adanos_api_key # ADANOS_API_BASE_URL=https://api.adanos.org # AI Provider (optional, default: "gemini") -# Supported: "gemini", "minimax", "siray" +# Supported: "gemini", "minimax", "siray", "orcarouter" # AI_PROVIDER=gemini # Gemini @@ -267,6 +267,10 @@ GEMINI_API_KEY=your_gemini_api_key # Get your key at https://platform.minimaxi.com # MINIMAX_API_KEY=your_minimax_api_key +# OrcaRouter (optional, used when AI_PROVIDER=orcarouter) +# Get your key at https://www.orcarouter.ai +# ORCAROUTER_API_KEY=your_orcarouter_api_key + # Inngest Signing Key (required for Vercel deployment) # Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys INNGEST_SIGNING_KEY=your_inngest_signing_key @@ -298,7 +302,7 @@ ADANOS_API_KEY=your_adanos_api_key # ADANOS_API_BASE_URL=https://api.adanos.org # AI Provider (optional, default: "gemini") -# Supported: "gemini", "minimax", "siray" +# Supported: "gemini", "minimax", "siray", "orcarouter" # AI_PROVIDER=gemini # Gemini @@ -308,6 +312,10 @@ GEMINI_API_KEY=your_gemini_api_key # Get your key at https://platform.minimaxi.com # MINIMAX_API_KEY=your_minimax_api_key +# OrcaRouter (optional, used when AI_PROVIDER=orcarouter) +# Get your key at https://www.orcarouter.ai +# ORCAROUTER_API_KEY=your_orcarouter_api_key + # Inngest Signing Key (required for Vercel deployment) # Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys INNGEST_SIGNING_KEY=your_inngest_signing_key diff --git a/__tests__/ai-provider.test.ts b/__tests__/ai-provider.test.ts index 4e3a483a..d6846bcf 100644 --- a/__tests__/ai-provider.test.ts +++ b/__tests__/ai-provider.test.ts @@ -54,6 +54,27 @@ describe("getProviderConfig", () => { expect(config.apiKey).toBe("siray-key"); }); + it("returns orcarouter config when provider is orcarouter", () => { + process.env.ORCAROUTER_API_KEY = "orca-key"; + const config = getProviderConfig("orcarouter"); + expect(config.name).toBe("orcarouter"); + expect(config.baseUrl).toBe("https://api.orcarouter.ai/v1"); + expect(config.model).toBe("orcarouter/auto"); + expect(config.apiKey).toBe("orca-key"); + }); + + it("respects ORCAROUTER_MODEL env var", () => { + process.env.ORCAROUTER_MODEL = "openai/gpt-5.5"; + const config = getProviderConfig("orcarouter"); + expect(config.model).toBe("openai/gpt-5.5"); + }); + + it("respects ORCAROUTER_BASE_URL env var", () => { + process.env.ORCAROUTER_BASE_URL = "https://custom.orcarouter.example/v1"; + const config = getProviderConfig("orcarouter"); + expect(config.baseUrl).toBe("https://custom.orcarouter.example/v1"); + }); + it("reads AI_PROVIDER from env when no argument is given", () => { process.env.AI_PROVIDER = "minimax"; process.env.MINIMAX_API_KEY = "k"; @@ -131,6 +152,13 @@ describe("callAIProvider", () => { ); }); + it("throws when ORCAROUTER_API_KEY is missing for orcarouter provider", async () => { + delete process.env.ORCAROUTER_API_KEY; + await expect(callAIProvider("hello", "orcarouter")).rejects.toThrow( + "ORCAROUTER_API_KEY is not set" + ); + }); + it("calls Gemini API with correct format", async () => { process.env.GEMINI_API_KEY = "test-gemini-key"; @@ -201,6 +229,46 @@ describe("callAIProvider", () => { expect(options.headers["Authorization"]).toBe("Bearer test-siray-key"); }); + it("calls OrcaRouter API with OpenAI-compatible format", async () => { + process.env.ORCAROUTER_API_KEY = "test-orca-key"; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Hello from OrcaRouter" } }], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const result = await callAIProvider("test prompt", "orcarouter"); + expect(result).toBe("Hello from OrcaRouter"); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://api.orcarouter.ai/v1/chat/completions"); + expect(options.headers["Authorization"]).toBe("Bearer test-orca-key"); + const body = JSON.parse(options.body); + expect(body.model).toBe("orcarouter/auto"); + expect(body.messages[0].content).toBe("test prompt"); + expect(body.temperature).toBe(0.7); + }); + + it("throws on empty OrcaRouter response", async () => { + process.env.ORCAROUTER_API_KEY = "k"; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ choices: [{ message: {} }] }), + }) + ); + + await expect(callAIProvider("hello", "orcarouter")).rejects.toThrow( + "orcarouter returned empty response" + ); + }); + it("throws on API error response", async () => { process.env.GEMINI_API_KEY = "k"; diff --git a/lib/ai-provider.ts b/lib/ai-provider.ts index 3cf14721..fd0da087 100644 --- a/lib/ai-provider.ts +++ b/lib/ai-provider.ts @@ -2,14 +2,15 @@ * AI Provider abstraction for OpenStock. * * Supports multiple LLM backends via the AI_PROVIDER environment variable: - * - "gemini" (default) – Google Gemini REST API - * - "minimax" – MiniMax (OpenAI-compatible) - * - "siray" – Siray.ai (OpenAI-compatible) + * - "gemini" (default) – Google Gemini REST API + * - "minimax" – MiniMax (OpenAI-compatible) + * - "siray" – Siray.ai (OpenAI-compatible) + * - "orcarouter" – OrcaRouter (OpenAI-compatible AI gateway) * * Each provider returns a plain-text string from the model. */ -export type AIProviderName = "gemini" | "minimax" | "siray"; +export type AIProviderName = "gemini" | "minimax" | "siray" | "orcarouter"; export interface AIProviderConfig { name: AIProviderName; @@ -49,6 +50,18 @@ export function getProviderConfig( model: "siray-1.0-ultra", }; + case "orcarouter": + return { + name: "orcarouter", + apiKey: process.env.ORCAROUTER_API_KEY || "", + baseUrl: + process.env.ORCAROUTER_BASE_URL || "https://api.orcarouter.ai/v1", + // Defaults to the OrcaRouter auto router, which picks the best model + // for each request. Set ORCAROUTER_MODEL to select a specific model + // (e.g. "openai/gpt-5.5", "anthropic/claude-opus-4.8"). + model: process.env.ORCAROUTER_MODEL || "orcarouter/auto", + }; + case "gemini": default: return { @@ -159,7 +172,7 @@ export async function callAIProvider( if (config.name === "gemini") { return callGemini(prompt, config); } - // MiniMax and Siray both use OpenAI-compatible endpoints + // MiniMax, Siray and OrcaRouter all use OpenAI-compatible endpoints return callOpenAICompatible(prompt, config); }