Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions scripts/generate-self-host-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 26 additions & 22 deletions scripts/verify-ai-notes-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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"),
Expand All @@ -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";
Expand Down
107 changes: 107 additions & 0 deletions scripts/verify-openrouter-client.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
40 changes: 7 additions & 33 deletions src/app/api/meetings/[meetingId]/enhance-notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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 }> }
Expand All @@ -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 },
Expand Down Expand Up @@ -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 },
Expand All @@ -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);
Expand All @@ -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,
Expand Down
41 changes: 6 additions & 35 deletions src/app/api/meetings/[meetingId]/suggestions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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 }> }
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
}));

Expand Down
Loading
Loading