From 3775f9ac097a26a713e6d57b7e6ded4829a8f922 Mon Sep 17 00:00:00 2001 From: ngoiyaeric <1.15367894e+08+ngoiyaeric@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:38:47 +0000 Subject: [PATCH] Add OpenRouter inference provider --- .env.local.example | 13 ++++++++ README.md | 15 ++++++++- app/actions.tsx | 5 ++- lib/agents/tools/document-retrieve.tsx | 4 +-- lib/agents/tools/geospatial.tsx | 6 +++- lib/utils/index.ts | 23 ++++++++++++++ lib/utils/openrouter.ts | 44 ++++++++++++++++++++++++++ tests-unit/openrouter.test.ts | 40 +++++++++++++++++++++++ 8 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 lib/utils/openrouter.ts create mode 100644 tests-unit/openrouter.test.ts diff --git a/.env.local.example b/.env.local.example index 38277361..217622d5 100644 --- a/.env.local.example +++ b/.env.local.example @@ -17,6 +17,19 @@ NEXT_PUBLIC_COMPOSIO_USER_ID=user@example.com # NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_public_token_here # AI Provider API Keys +# OpenRouter (set INFERENCE_PROVIDER=openrouter to route inference through OpenRouter) +INFERENCE_PROVIDER=openrouter +OPENROUTER_API_KEY=your_openrouter_api_key +# Must be an OpenRouter model slug. The default is openai/gpt-4o. +OPENROUTER_MODEL=openai/gpt-4o +# Optional separate model for image inputs; defaults to OPENROUTER_MODEL. +OPENROUTER_VISION_MODEL=openai/gpt-4o +# Keep ada-002 to remain compatible with existing document vectors (1536 dimensions). +OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-ada-002 +# Optional OpenRouter attribution headers (site URL falls back to NEXT_PUBLIC_APP_URL). +OPENROUTER_SITE_URL= +OPENROUTER_APP_NAME=QCX + # Gemini 3.1 Pro (Google Generative AI) GEMINI_3_PRO_API_KEY=your_gemini_3_pro_api_key_here diff --git a/README.md b/README.md index 72b927c7..acce4897 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ### 1. Planetary Copilot (Generative AI/UI) - **Vercel AI SDK Core**: Stream-updates multi-turn conversational agents that yield complex Generative UI elements directly in the chat panel. -- **Unified Provider Routing**: Seamlessly shifts requests between xAI (Grok-3-mini, Grok-2-vision), Google Gemini (Gemini 3.1 Pro), AWS Bedrock, and OpenAI, depending on key configuration and task requirements. +- **Unified Provider Routing**: Routes AI requests through xAI, Google Gemini, AWS Bedrock, OpenAI, or OpenRouter. OpenRouter can be selected as a unified OpenAI-compatible inference provider while preserving streaming, tool calling, structured outputs, image inputs, and document embeddings when the configured models support them. - **Vision-Enabled Tasks**: When handling image or visual payloads, QCX automatically routes tasks to vision-enabled models (such as `grok-vision-beta` or `grok-2-vision-1212`) as standard text-only model names do not support visual inputs. - **Interactive Geospatial Tools**: Integrates specialized GIS tools directly into the conversational interface, allowing the agent to pan the map, query coordinates, and inspect locations dynamically. @@ -171,6 +171,19 @@ NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN="pk.your_mapbox_token_here" # ========================================== # 6. GENERATIVE AI PROVIDERS (Model Keys) # ========================================== +# OpenRouter (select as the inference provider) +INFERENCE_PROVIDER="openrouter" +OPENROUTER_API_KEY="sk-or-v1-your-openrouter-key" +# OpenRouter model slugs. Defaults to openai/gpt-4o when unset. +OPENROUTER_MODEL="openai/gpt-4o" +# Optional image-input model; defaults to OPENROUTER_MODEL. +OPENROUTER_VISION_MODEL="openai/gpt-4o" +# Keep this at ada-002 for existing document vectors; changing models requires re-embedding stored chunks. +OPENROUTER_EMBEDDING_MODEL="openai/text-embedding-ada-002" +# Optional OpenRouter attribution headers; site URL falls back to NEXT_PUBLIC_APP_URL. +OPENROUTER_SITE_URL="https://your-qcx-site.example" +OPENROUTER_APP_NAME="QCX" + # xAI API Key (Grok models) - PLATFORM PRIMARY CHOICE XAI_API_KEY="xai-your-api-key" diff --git a/app/actions.tsx b/app/actions.tsx index 4bdcfc84..b8db8b1f 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -7,8 +7,7 @@ import { getMutableAIState } from 'ai/rsc' import { CoreMessage, ToolResultPart, TextPart, ImagePart, embedMany } from 'ai' -import { openai } from '@ai-sdk/openai' -import { nanoid } from '@/lib/utils' +import { getEmbeddingModel, nanoid } from '@/lib/utils' import type { FeatureCollection } from 'geojson' import { Spinner } from '@/components/ui/spinner' import { Section } from '@/components/section' @@ -459,7 +458,7 @@ async function submit(formData?: FormData, skip?: boolean) { if (cappedChunks.length > 0) { const { embeddings } = await embedMany({ - model: openai.embedding('text-embedding-ada-002'), + model: getEmbeddingModel(), values: cappedChunks, }) diff --git a/lib/agents/tools/document-retrieve.tsx b/lib/agents/tools/document-retrieve.tsx index ba3788f1..8b1639b9 100644 --- a/lib/agents/tools/document-retrieve.tsx +++ b/lib/agents/tools/document-retrieve.tsx @@ -7,8 +7,8 @@ import RetrieveSection from '@/components/retrieve-section' import { db } from '@/lib/db' import { sql } from 'drizzle-orm' import { embed } from 'ai' -import { openai } from '@ai-sdk/openai' import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user' +import { getEmbeddingModel } from '@/lib/utils' interface DocumentChunkResult { id: string @@ -36,7 +36,7 @@ export const documentRetrieveTool = ({ uiStream, fullResponse }: ToolProps) => ( // 2. Embed query const { embedding } = await embed({ - model: openai.embedding('text-embedding-ada-002'), + model: getEmbeddingModel(), value: query, }) diff --git a/lib/agents/tools/geospatial.tsx b/lib/agents/tools/geospatial.tsx index 863f59b5..99e8a93d 100644 --- a/lib/agents/tools/geospatial.tsx +++ b/lib/agents/tools/geospatial.tsx @@ -249,7 +249,11 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g const selectedModel = await getSelectedModel(); - if (selectedModel?.toLowerCase().includes('gemini') && mapProvider === 'google') { + if ( + process.env.INFERENCE_PROVIDER?.trim().toLowerCase() !== 'openrouter' && + selectedModel?.toLowerCase().includes('gemini') && + mapProvider === 'google' + ) { let feedbackMessage = `Processing geospatial query with Gemini 3.1 Pro...`; uiFeedbackStream.update(feedbackMessage); diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 5b4f03e1..413709a1 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -6,6 +6,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock' import { createXai } from '@ai-sdk/xai'; import { v4 as uuidv4 } from 'uuid'; +import { createOpenRouterEmbeddingModel, createOpenRouterModel } from './openrouter'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -23,6 +24,15 @@ export { generateUUID as nanoid }; export async function getModel(requireVision: boolean = false) { const selectedModel = await getSelectedModel(); + const inferenceProvider = process.env.INFERENCE_PROVIDER?.trim().toLowerCase() || 'auto'; + + if (inferenceProvider === 'openrouter') { + return createOpenRouterModel(requireVision); + } + + if (inferenceProvider !== 'auto') { + throw new Error(`Unsupported INFERENCE_PROVIDER "${inferenceProvider}". Supported values: auto, openrouter.`); + } const xaiApiKey = process.env.XAI_API_KEY; const gemini3ProApiKey = process.env.GEMINI_3_PRO_API_KEY; @@ -138,6 +148,19 @@ export async function getModel(requireVision: boolean = false) { return openai('gpt-4o'); } +export function getEmbeddingModel() { + const inferenceProvider = process.env.INFERENCE_PROVIDER?.trim().toLowerCase() || 'auto'; + if (inferenceProvider === 'openrouter') { + return createOpenRouterEmbeddingModel(); + } + if (inferenceProvider !== 'auto') { + throw new Error(`Unsupported INFERENCE_PROVIDER "${inferenceProvider}". Supported values: auto, openrouter.`); + } + + const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }); + return openai.embedding('text-embedding-ada-002'); +} + /** * Normalizes and sanitizes message content to plain text. */ diff --git a/lib/utils/openrouter.ts b/lib/utils/openrouter.ts new file mode 100644 index 00000000..099e12e8 --- /dev/null +++ b/lib/utils/openrouter.ts @@ -0,0 +1,44 @@ +import { createOpenAI } from '@ai-sdk/openai'; + +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o'; +// Keep this at ada-002 so new document/query vectors remain in the same vector space as existing rows. +const DEFAULT_OPENROUTER_EMBEDDING_MODEL = 'openai/text-embedding-ada-002'; + +function createOpenRouterProvider() { + const apiKey = process.env.OPENROUTER_API_KEY?.trim(); + if (!apiKey) { + throw new Error('INFERENCE_PROVIDER is "openrouter" but OPENROUTER_API_KEY is not configured.'); + } + + const headers: Record = { + 'X-Title': process.env.OPENROUTER_APP_NAME?.trim() || 'QCX', + }; + const siteUrl = process.env.OPENROUTER_SITE_URL?.trim() || process.env.NEXT_PUBLIC_APP_URL?.trim(); + if (siteUrl) headers['HTTP-Referer'] = siteUrl; + + return createOpenAI({ + apiKey, + baseURL: OPENROUTER_BASE_URL, + headers, + }); +} + +/** + * Creates an OpenRouter model using the OpenAI-compatible Chat Completions API. + * The default model supports the text, vision, tool-call, and structured-output + * paths used by QCX. Deployments may choose a different model through env vars. + */ +export function createOpenRouterModel(requireVision = false) { + const textModel = process.env.OPENROUTER_MODEL?.trim() || DEFAULT_OPENROUTER_MODEL; + const modelId = requireVision + ? process.env.OPENROUTER_VISION_MODEL?.trim() || textModel + : textModel; + // OpenRouter exposes Chat Completions compatibility, not OpenAI Responses. + return createOpenRouterProvider().chat(modelId); +} + +export function createOpenRouterEmbeddingModel() { + const modelId = process.env.OPENROUTER_EMBEDDING_MODEL?.trim() || DEFAULT_OPENROUTER_EMBEDDING_MODEL; + return createOpenRouterProvider().embedding(modelId); +} diff --git a/tests-unit/openrouter.test.ts b/tests-unit/openrouter.test.ts new file mode 100644 index 00000000..4d74ab5e --- /dev/null +++ b/tests-unit/openrouter.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { createOpenRouterEmbeddingModel, createOpenRouterModel } from '../lib/utils/openrouter'; + +const envKeys = [ + 'OPENROUTER_API_KEY', + 'OPENROUTER_MODEL', + 'OPENROUTER_VISION_MODEL', + 'OPENROUTER_EMBEDDING_MODEL', + 'OPENROUTER_SITE_URL', + 'OPENROUTER_APP_NAME', +] as const; +const previousValues = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + +try { + for (const key of envKeys) delete process.env[key]; + assert.throws(() => createOpenRouterModel(), /OPENROUTER_API_KEY is not configured/); + + process.env.OPENROUTER_API_KEY = 'test-key'; + const defaultModel = createOpenRouterModel(); + assert.equal(defaultModel.modelId, 'openai/gpt-4o'); + + process.env.OPENROUTER_MODEL = 'google/gemini-2.5-pro'; + const textModel = createOpenRouterModel(); + assert.equal(textModel.modelId, 'google/gemini-2.5-pro'); + + process.env.OPENROUTER_VISION_MODEL = 'openai/gpt-4o'; + const visionModel = createOpenRouterModel(true); + assert.equal(visionModel.modelId, 'openai/gpt-4o'); + + const embeddingModel = createOpenRouterEmbeddingModel(); + assert.equal(embeddingModel.modelId, 'openai/text-embedding-ada-002'); +} finally { + for (const key of envKeys) { + const value = previousValues[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +console.log('OpenRouter provider tests passed.');