From 3589adb91d4cdec357aa1edfec8d4112f82ecb36 Mon Sep 17 00:00:00 2001 From: Pratik Bodkhe Date: Sat, 6 Jun 2026 11:09:54 +0900 Subject: [PATCH] feat(ai): resilient shared OpenRouter client with retry and fallback Extract src/lib/ai/openrouter.ts as the single OpenRouter transport for enhance-notes, suggestions, and ask-series. Adds timeout, one retry, and a configurable model fallback (AI_MODEL_FALLBACK) so a slow or unavailable primary model no longer surfaces as a 502. Routes now record the model that actually answered and resolve the key via getOpenRouterApiKey. Wire the AI notes contract verifier and a new OpenRouter client test into CI; both were previously unenforced. --- .env.example | 2 + .github/workflows/ci.yml | 6 + package.json | 2 + scripts/generate-self-host-env.mjs | 1 + scripts/verify-ai-notes-contract.mjs | 48 ++++---- scripts/verify-openrouter-client.test.mjs | 107 ++++++++++++++++++ .../[meetingId]/enhance-notes/route.ts | 40 ++----- .../meetings/[meetingId]/suggestions/route.ts | 41 +------ src/app/api/series/[seriesId]/ask/route.ts | 41 +------ src/lib/ai/model.ts | 8 ++ src/lib/ai/openrouter.ts | 84 ++++++++++++++ 11 files changed, 255 insertions(+), 125 deletions(-) create mode 100644 scripts/verify-openrouter-client.test.mjs create mode 100644 src/lib/ai/openrouter.ts diff --git a/.env.example b/.env.example index 22d0efc..4e38a00 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,8 @@ POSTGRES_PORT=5432 OPENROUTER_API_KEY= AI_API_KEY= AI_MODEL=google/gemini-3.1-flash-lite +# Cheaper, widely available model the AI falls back to if AI_MODEL fails or is unavailable. +AI_MODEL_FALLBACK=google/gemini-3.1-flash-lite # --------------------------------------------------------------------------- # Optional: Auth Configuration diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f1fe63..2588b97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,12 @@ jobs: - name: Verify CI workflow contracts run: pnpm test:ci-workflows + - name: Verify AI contracts + run: pnpm test:ai-contracts + + - name: Verify OpenRouter client + run: pnpm test:ai-client + - name: Build (includes typecheck) run: pnpm build env: diff --git a/package.json b/package.json index 0cccdbc..d8762c9 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "test:runtime-config": "node --test scripts/verify-runtime-config.test.mjs", "test:query-contracts": "node scripts/verify-query-contracts.mjs", "test:ci-workflows": "node scripts/verify-ci-playwright-shards.mjs", + "test:ai-contracts": "node scripts/verify-ai-notes-contract.mjs", + "test:ai-client": "node --test scripts/verify-openrouter-client.test.mjs", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui", diff --git a/scripts/generate-self-host-env.mjs b/scripts/generate-self-host-env.mjs index 563b258..4dd5e18 100755 --- a/scripts/generate-self-host-env.mjs +++ b/scripts/generate-self-host-env.mjs @@ -79,6 +79,7 @@ EMAIL_FROM=noreply@localhost OPENROUTER_API_KEY= AI_API_KEY= AI_MODEL=claude-sonnet-4-6 +AI_MODEL_FALLBACK=google/gemini-3.1-flash-lite RESEND_API_KEY= ENABLE_GOOGLE_AUTH=false diff --git a/scripts/verify-ai-notes-contract.mjs b/scripts/verify-ai-notes-contract.mjs index f6b4301..3dca4e9 100644 --- a/scripts/verify-ai-notes-contract.mjs +++ b/scripts/verify-ai-notes-contract.mjs @@ -85,14 +85,30 @@ assert( const modelConfig = read("src/lib/ai/model.ts"); assert(modelConfig.includes('"google/gemini-3.1-flash-lite"'), "AI model default must be google/gemini-3.1-flash-lite"); assert(modelConfig.includes("AI_MODEL"), "AI model must be configurable via AI_MODEL"); +assert(modelConfig.includes("getAiModels"), "model.ts must expose getAiModels for resilient fallback"); +assert(modelConfig.includes("AI_MODEL_FALLBACK"), "model.ts must support a configurable fallback model"); + +// Transport contract lives in the shared client, not in each route. +const client = read("src/lib/ai/openrouter.ts"); +assert(client.includes("https://openrouter.ai/api/v1/chat/completions"), "Shared client must call OpenRouter chat completions"); +assert(client.includes('response_format: { type: "json_object" }'), "Shared client must request OpenRouter JSON mode"); +assert( + client.includes("OPENROUTER_API_KEY") && client.includes("AI_API_KEY"), + "Shared client must resolve OPENROUTER_API_KEY then AI_API_KEY" +); +assert(client.includes("getAiModels"), "Shared client must use getAiModels for primary+fallback resilience"); +assert(client.includes("AbortController"), "Shared client must time out provider calls"); + +function assertSharedClient(src, name) { + assert(!src.includes('"minimax/minimax-m3"'), `${name} route must not hardcode a model`); + assert(src.includes('from "@/lib/ai/openrouter"'), `${name} route must call the provider through the shared client`); + assert(src.includes("callOpenRouter"), `${name} route must use callOpenRouter`); + assert(src.includes("getOpenRouterApiKey"), `${name} route must resolve the key via getOpenRouterApiKey`); + assert(!/async function getOpenRouterData/.test(src), `${name} route must not re-implement the OpenRouter fetch`); +} const route = read("src/app/api/meetings/[meetingId]/enhance-notes/route.ts"); -assert(route.includes("getAiModel"), "Enhance route must resolve the model from config (getAiModel)"); -assert(!route.includes('"minimax/minimax-m3"'), "Enhance route must not hardcode a model"); -assert(route.includes("OPENROUTER_API_KEY"), "Enhance route must read OPENROUTER_API_KEY"); -assert(route.includes("AI_API_KEY"), "Enhance route must support AI_API_KEY fallback"); -assert(route.includes("https://openrouter.ai/api/v1/chat/completions"), "Enhance route must call OpenRouter chat completions"); -assert(route.includes("response_format: { type: \"json_object\" }"), "Enhance route must request OpenRouter JSON mode"); +assertSharedClient(route, "Enhance"); assert(route.includes("Return only the JSON object"), "Enhance prompt must forbid non-JSON wrapper text"); assert(route.includes("Do not invent owners, dates, or decisions"), "Enhance prompt must forbid invented accountability details"); assert(route.includes("ai_notes: parsed"), "Enhance route must return structured AI notes JSON"); @@ -102,18 +118,10 @@ assert( ); const suggestionsRoute = read("src/app/api/meetings/[meetingId]/suggestions/route.ts"); -assert(suggestionsRoute.includes("getAiModel"), "Suggestions route must resolve the model from config (getAiModel)"); -assert(!suggestionsRoute.includes('"minimax/minimax-m3"'), "Suggestions route must not hardcode a model"); -assert(suggestionsRoute.includes("OPENROUTER_API_KEY"), "Suggestions route must read OPENROUTER_API_KEY"); -assert(suggestionsRoute.includes("AI_API_KEY"), "Suggestions route must support AI_API_KEY fallback"); -assert(suggestionsRoute.includes("https://openrouter.ai/api/v1/chat/completions"), "Suggestions route must call OpenRouter chat completions"); -assert( - suggestionsRoute.includes('from "@/lib/ai/ask-series-answer"'), - "Suggestions route must reuse the shared fence-aware OpenRouter text helper" -); +assertSharedClient(suggestionsRoute, "Suggestions"); assert( - !/function getTextFromOpenRouter/.test(suggestionsRoute), - "Suggestions route must not redefine a non-fence-aware getTextFromOpenRouter; reuse the shared one so markdown-fenced provider JSON still parses" + suggestionsRoute.includes('from "@/lib/ai/ask-series-answer"') && !/function getTextFromOpenRouter/.test(suggestionsRoute), + "Suggestions route must reuse the shared fence-aware getTextFromOpenRouter so markdown-fenced provider JSON still parses" ); assert( suggestionsRoute.includes("Do not wrap it in markdown fences"), @@ -129,11 +137,7 @@ assert( ); const askSeriesRoute = read("src/app/api/series/[seriesId]/ask/route.ts"); -assert(askSeriesRoute.includes("getAiModel"), "Ask series route must resolve the model from config (getAiModel)"); -assert(!askSeriesRoute.includes('"minimax/minimax-m3"'), "Ask series route must not hardcode a model"); -assert(askSeriesRoute.includes("OPENROUTER_API_KEY"), "Ask series route must read OPENROUTER_API_KEY"); -assert(askSeriesRoute.includes("AI_API_KEY"), "Ask series route must support AI_API_KEY fallback"); -assert(askSeriesRoute.includes("https://openrouter.ai/api/v1/chat/completions"), "Ask series route must call OpenRouter chat completions"); +assertSharedClient(askSeriesRoute, "Ask series"); assert(askSeriesRoute.includes("The source context does not prove the answer."), "Ask series route must include unsupported-answer guard copy"); const askSeriesParserPath = "src/lib/ai/ask-series-answer.ts"; diff --git a/scripts/verify-openrouter-client.test.mjs b/scripts/verify-openrouter-client.test.mjs new file mode 100644 index 0000000..7064714 --- /dev/null +++ b/scripts/verify-openrouter-client.test.mjs @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; + +// Bundle the TypeScript client so we can exercise it from node:test (repo pattern). +const root = process.cwd(); +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minutia-openrouter-")); +const bundled = path.join(tempDir, "openrouter.mjs"); +await esbuild.build({ + entryPoints: ["src/lib/ai/openrouter.ts"], + outfile: bundled, + bundle: true, + platform: "node", + format: "esm", + logLevel: "silent", + absWorkingDir: root, +}); +const { callOpenRouter, getOpenRouterApiKey } = await import(pathToFileURL(bundled).href); + +const okBody = JSON.stringify({ + choices: [{ message: { content: '{"ok":true}' } }], +}); + +// Build a fake fetch that decides ok/fail per requested model, and records calls. +function fakeFetch(decide) { + const calls = []; + globalThis.fetch = async (_url, options) => { + const model = JSON.parse(options.body).model; + calls.push(model); + const outcome = decide(model, calls.length); + if (outcome === "network") throw new Error("network down"); + if (outcome === "bad") { + return { ok: false, status: 502, async json() { return {}; } }; + } + return { ok: true, status: 200, async json() { return JSON.parse(okBody); } }; + }; + return calls; +} + +const realFetch = globalThis.fetch; +test.afterEach(() => { globalThis.fetch = realFetch; }); + +test("getOpenRouterApiKey prefers OPENROUTER_API_KEY then AI_API_KEY", () => { + const prev = { o: process.env.OPENROUTER_API_KEY, a: process.env.AI_API_KEY }; + process.env.OPENROUTER_API_KEY = "primary"; + process.env.AI_API_KEY = "secondary"; + assert.equal(getOpenRouterApiKey(), "primary"); + delete process.env.OPENROUTER_API_KEY; + assert.equal(getOpenRouterApiKey(), "secondary"); + delete process.env.AI_API_KEY; + assert.equal(getOpenRouterApiKey(), null); + process.env.OPENROUTER_API_KEY = prev.o ?? ""; + process.env.AI_API_KEY = prev.a ?? ""; + if (!prev.o) delete process.env.OPENROUTER_API_KEY; + if (!prev.a) delete process.env.AI_API_KEY; +}); + +test("callOpenRouter returns data and the model that answered", async () => { + fakeFetch(() => "ok"); + const result = await callOpenRouter({ + apiKey: "k", system: "s", prompt: "p", models: ["strong"], retries: 0, + }); + assert.deepEqual(result.data, JSON.parse(okBody)); + assert.equal(result.model, "strong"); +}); + +test("callOpenRouter falls back to the next model when the primary fails", async () => { + const calls = fakeFetch((model) => (model === "strong" ? "bad" : "ok")); + const result = await callOpenRouter({ + apiKey: "k", system: "s", prompt: "p", models: ["strong", "cheap"], retries: 0, + }); + assert.equal(result.model, "cheap"); + assert.deepEqual(calls, ["strong", "cheap"]); +}); + +test("callOpenRouter retries the same model before giving up", async () => { + const calls = fakeFetch((model, n) => (n === 1 ? "network" : "ok")); + const result = await callOpenRouter({ + apiKey: "k", system: "s", prompt: "p", models: ["strong"], retries: 1, + }); + assert.equal(result.model, "strong"); + assert.equal(calls.length, 2); // failed once, retried, succeeded +}); + +test("callOpenRouter throws when every model and retry fails", async () => { + fakeFetch(() => "bad"); + await assert.rejects( + callOpenRouter({ apiKey: "k", system: "s", prompt: "p", models: ["a", "b"], retries: 1 }), + /Provider request failed/ + ); +}); + +test("callOpenRouter defaults to getAiModels() when no models passed", async () => { + const prev = { m: process.env.AI_MODEL, f: process.env.AI_MODEL_FALLBACK }; + process.env.AI_MODEL = "anthropic/claude-sonnet-4-6"; + process.env.AI_MODEL_FALLBACK = "google/gemini-3.1-flash-lite"; + const calls = fakeFetch((model) => (model.includes("claude") ? "bad" : "ok")); + const result = await callOpenRouter({ apiKey: "k", system: "s", prompt: "p", retries: 0 }); + assert.equal(result.model, "google/gemini-3.1-flash-lite"); + assert.deepEqual(calls, ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-flash-lite"]); + if (prev.m) process.env.AI_MODEL = prev.m; else delete process.env.AI_MODEL; + if (prev.f) process.env.AI_MODEL_FALLBACK = prev.f; else delete process.env.AI_MODEL_FALLBACK; +}); diff --git a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts index 9a971da..b7a3be7 100644 --- a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts +++ b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts @@ -2,11 +2,10 @@ import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { createClient } from "@/lib/supabase/server"; import { getTextFromOpenRouter } from "@/lib/ai/ask-series-answer"; -import { getAiModel } from "@/lib/ai/model"; +import { callOpenRouter, getOpenRouterApiKey } from "@/lib/ai/openrouter"; -const OPENROUTER_MODEL = getAiModel(); -const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const PROMPT_VERSION = "ai-notes-v1"; +const SYSTEM_PROMPT = "You are a precise meeting-notes editor. Return valid JSON only."; const requestSchema = z.object({ mode: z.enum(["preview"]).default("preview"), @@ -73,32 +72,6 @@ function buildPrompt(input: { ].join("\n"); } -async function getOpenRouterData(prompt: string, apiKey: string) { - const providerResponse = await fetch(OPENROUTER_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - "HTTP-Referer": process.env.SITE_URL ?? "https://example.com", - "X-OpenRouter-Title": "Minutia", - }, - body: JSON.stringify({ - model: OPENROUTER_MODEL, - messages: [ - { role: "system", content: "You are a precise meeting-notes editor. Return valid JSON only." }, - { role: "user", content: prompt }, - ], - response_format: { type: "json_object" }, - }), - }); - - if (!providerResponse.ok) { - throw new Error("Provider request failed"); - } - - return providerResponse.json(); -} - export async function POST( request: NextRequest, { params }: { params: Promise<{ meetingId: string }> } @@ -120,7 +93,7 @@ export async function POST( return NextResponse.json({ error: "Not authenticated", request_id: requestId }, { status: 401 }); } - const apiKey = process.env.OPENROUTER_API_KEY || process.env.AI_API_KEY; + const apiKey = getOpenRouterApiKey(); if (!apiKey) { return NextResponse.json( { error: "AI notes are not configured.", request_id: requestId }, @@ -157,8 +130,9 @@ export async function POST( }); let providerData: unknown; + let model: string; try { - providerData = await getOpenRouterData(prompt, apiKey); + ({ data: providerData, model } = await callOpenRouter({ apiKey, system: SYSTEM_PROMPT, prompt })); } catch { return NextResponse.json( { error: "AI provider request failed.", request_id: requestId }, @@ -185,7 +159,7 @@ export async function POST( raw_notes_markdown: rawNotes, ai_notes_markdown: aiNotes, ai_notes_generated_at: generatedAt, - ai_notes_model: OPENROUTER_MODEL, + ai_notes_model: model, ai_notes_prompt_version: PROMPT_VERSION, }) .eq("id", meetingId); @@ -200,7 +174,7 @@ export async function POST( return NextResponse.json({ ai_notes: parsed, ai_notes_markdown: aiNotes, - model: OPENROUTER_MODEL, + model, prompt_version: PROMPT_VERSION, generated_at: generatedAt, request_id: requestId, diff --git a/src/app/api/meetings/[meetingId]/suggestions/route.ts b/src/app/api/meetings/[meetingId]/suggestions/route.ts index 636ec8e..1212506 100644 --- a/src/app/api/meetings/[meetingId]/suggestions/route.ts +++ b/src/app/api/meetings/[meetingId]/suggestions/route.ts @@ -2,11 +2,10 @@ import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { createClient } from "@/lib/supabase/server"; import { getTextFromOpenRouter } from "@/lib/ai/ask-series-answer"; -import { getAiModel } from "@/lib/ai/model"; +import { callOpenRouter, getOpenRouterApiKey } from "@/lib/ai/openrouter"; -const OPENROUTER_MODEL = getAiModel(); -const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const PROMPT_VERSION = "ai-suggestions-v1"; +const SYSTEM_PROMPT = "You extract accountable meeting follow-ups. Return valid JSON only."; const requestSchema = z.object({ mode: z.enum(["generate"]).default("generate"), @@ -70,35 +69,6 @@ function buildPrompt(input: { ].join("\n"); } -async function getOpenRouterData(prompt: string, apiKey: string) { - const providerResponse = await fetch(OPENROUTER_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - "HTTP-Referer": process.env.SITE_URL ?? "https://example.com", - "X-OpenRouter-Title": "Minutia", - }, - body: JSON.stringify({ - model: OPENROUTER_MODEL, - messages: [ - { - role: "system", - content: "You extract accountable meeting follow-ups. Return valid JSON only.", - }, - { role: "user", content: prompt }, - ], - response_format: { type: "json_object" }, - }), - }); - - if (!providerResponse.ok) { - throw new Error("Provider request failed"); - } - - return providerResponse.json(); -} - export async function GET( _request: NextRequest, { params }: { params: Promise<{ meetingId: string }> } @@ -155,7 +125,7 @@ export async function POST( return NextResponse.json({ error: "Not authenticated", request_id: requestId }, { status: 401 }); } - const apiKey = process.env.OPENROUTER_API_KEY || process.env.AI_API_KEY; + const apiKey = getOpenRouterApiKey(); if (!apiKey) { return NextResponse.json( { error: "AI suggestions are not configured.", request_id: requestId }, @@ -190,8 +160,9 @@ export async function POST( }); let providerData: unknown; + let model: string; try { - providerData = await getOpenRouterData(prompt, apiKey); + ({ data: providerData, model } = await callOpenRouter({ apiKey, system: SYSTEM_PROMPT, prompt })); } catch { return NextResponse.json( { error: "AI provider request failed.", request_id: requestId }, @@ -235,7 +206,7 @@ export async function POST( due_date: suggestion.due_date, confidence: suggestion.confidence, source_excerpt: suggestion.source_excerpt, - ai_model: OPENROUTER_MODEL, + ai_model: model, ai_prompt_version: PROMPT_VERSION, })); diff --git a/src/app/api/series/[seriesId]/ask/route.ts b/src/app/api/series/[seriesId]/ask/route.ts index dbee7c0..3ee2005 100644 --- a/src/app/api/series/[seriesId]/ask/route.ts +++ b/src/app/api/series/[seriesId]/ask/route.ts @@ -2,11 +2,10 @@ import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { parseAskSeriesAnswer } from "@/lib/ai/ask-series-answer"; import { createClient } from "@/lib/supabase/server"; -import { getAiModel } from "@/lib/ai/model"; +import { callOpenRouter, getOpenRouterApiKey } from "@/lib/ai/openrouter"; -const OPENROUTER_MODEL = getAiModel(); -const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const PROMPT_VERSION = "ask-series-v1"; +const SYSTEM_PROMPT = "You answer from cited meeting memory only. Return valid JSON only."; const requestSchema = z.object({ question: z.string().trim().min(1).max(1000), @@ -65,35 +64,6 @@ function buildPrompt(input: { ].join("\n"); } -async function getOpenRouterData(prompt: string, apiKey: string) { - const providerResponse = await fetch(OPENROUTER_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - "HTTP-Referer": process.env.SITE_URL ?? "https://example.com", - "X-OpenRouter-Title": "Minutia", - }, - body: JSON.stringify({ - model: OPENROUTER_MODEL, - messages: [ - { - role: "system", - content: "You answer from cited meeting memory only. Return valid JSON only.", - }, - { role: "user", content: prompt }, - ], - response_format: { type: "json_object" }, - }), - }); - - if (!providerResponse.ok) { - throw new Error("Provider request failed"); - } - - return providerResponse.json(); -} - export async function POST( request: NextRequest, { params }: { params: Promise<{ seriesId: string }> } @@ -116,7 +86,7 @@ export async function POST( return NextResponse.json({ error: "Not authenticated", request_id: requestId }, { status: 401 }); } - const apiKey = process.env.OPENROUTER_API_KEY || process.env.AI_API_KEY; + const apiKey = getOpenRouterApiKey(); if (!apiKey) { return NextResponse.json( { error: "Ask this series is not configured.", request_id: requestId }, @@ -171,8 +141,9 @@ export async function POST( }); let providerData: unknown; + let model: string; try { - providerData = await getOpenRouterData(prompt, apiKey); + ({ data: providerData, model } = await callOpenRouter({ apiKey, system: SYSTEM_PROMPT, prompt })); } catch { return NextResponse.json( { error: "AI provider request failed.", request_id: requestId }, @@ -209,7 +180,7 @@ export async function POST( return NextResponse.json({ ...normalized, - model: OPENROUTER_MODEL, + model, prompt_version: PROMPT_VERSION, request_id: requestId, }); diff --git a/src/lib/ai/model.ts b/src/lib/ai/model.ts index ac38f56..4a51580 100644 --- a/src/lib/ai/model.ts +++ b/src/lib/ai/model.ts @@ -9,3 +9,11 @@ export function getAiModel() { DEFAULT_AI_MODEL ); } + +// Ordered model list for resilient calls: the configured model first, then a +// cheap, widely available fallback (AI_MODEL_FALLBACK, else the default). +export function getAiModels() { + const primary = getAiModel(); + const fallback = process.env.AI_MODEL_FALLBACK?.trim() || DEFAULT_AI_MODEL; + return primary === fallback ? [primary] : [primary, fallback]; +} diff --git a/src/lib/ai/openrouter.ts b/src/lib/ai/openrouter.ts new file mode 100644 index 0000000..f78f31d --- /dev/null +++ b/src/lib/ai/openrouter.ts @@ -0,0 +1,84 @@ +import { getAiModels } from "./model"; + +// Shared OpenRouter transport for every Minutia AI feature. Owns the wire +// format, key resolution, and resilience (timeout, retry, model fallback) so +// no route re-implements the call. +export const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_RETRIES = 1; + +export function getOpenRouterApiKey(): string | null { + return process.env.OPENROUTER_API_KEY || process.env.AI_API_KEY || null; +} + +export type OpenRouterResult = { data: unknown; model: string }; + +async function requestModel(input: { + model: string; + system: string; + prompt: string; + apiKey: string; + timeoutMs: number; +}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), input.timeoutMs); + try { + const response = await fetch(OPENROUTER_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${input.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": process.env.SITE_URL ?? "https://example.com", + "X-Title": "Minutia", + }, + body: JSON.stringify({ + model: input.model, + messages: [ + { role: "system", content: input.system }, + { role: "user", content: input.prompt }, + ], + response_format: { type: "json_object" }, + }), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Provider request failed: ${response.status}`); + } + return await response.json(); + } finally { + clearTimeout(timer); + } +} + +export async function callOpenRouter(input: { + apiKey: string; + system: string; + prompt: string; + models?: string[]; + timeoutMs?: number; + retries?: number; +}): Promise { + const models = (input.models ?? getAiModels()).filter(Boolean); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const retries = input.retries ?? DEFAULT_RETRIES; + + let lastError: unknown; + for (const model of models) { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const data = await requestModel({ + model, + system: input.system, + prompt: input.prompt, + apiKey: input.apiKey, + timeoutMs, + }); + return { data, model }; + } catch (error) { + lastError = error; + } + } + } + throw lastError instanceof Error ? lastError : new Error("Provider request failed"); +}