Commit
database, auth and api routes
commit
00e6446
30 changed files with +3111 and −0
Jump to a changed file
- prisma/schema.prisma +207 −0
- prisma/seed.ts +69 −0
- src/app/api/auth/[...nextauth]/route.ts +3 −0
- src/app/api/debates/[id]/export/route.ts +30 −0
- src/app/api/debates/[id]/route.ts +36 −0
- src/app/api/debates/[id]/share/route.ts +22 −0
- src/app/api/debates/run/route.ts +48 −0
- src/app/api/keys/route.ts +80 −0
- src/app/api/models/route.ts +21 −0
- src/app/api/presets/[id]/route.ts +15 −0
- src/app/api/presets/route.ts +36 −0
- src/auth.ts +45 −0
- src/db/client.ts +17 −0
- src/db/repositories.ts +590 −0
- src/lib/byok.ts +87 −0
- src/lib/crypto.test.ts +55 −0
- src/lib/crypto.ts +67 −0
- src/lib/debate-runner.ts +109 −0
- src/lib/debate-view.ts +274 −0
- src/lib/demo-fixtures.data.ts +338 −0
- src/lib/demo-fixtures.ts +286 −0
- src/lib/env.ts +91 −0
- src/lib/export-markdown.ts +96 −0
- src/lib/model-cache.ts +59 −0
- src/lib/model-visuals.ts +42 −0
- src/lib/openrouter.ts +252 −0
- src/lib/rate-limit.ts +42 −0
- src/lib/sse.ts +34 −0
- src/lib/utils.ts +51 −0
- src/types/next-auth.d.ts +9 −0
added prisma/schema.prisma +207 −0
| @@ -0,0 +1,207 @@ | ||
| 1 | +// Roundtable data model. | |
| 2 | +// | |
| 3 | +// Debates are persisted incrementally — each StageResult row is written the | |
| 4 | +// moment that stage completes, not at the end — so a client that disconnects | |
| 5 | +// mid-debate can reconnect and replay current state, and history/export read | |
| 6 | +// from the same rows. | |
| 7 | + | |
| 8 | +generator client { | |
| 9 | + provider = "prisma-client-js" | |
| 10 | + // "native" for local dev; the debian target matches the node:20-slim runtime. | |
| 11 | + binaryTargets = ["native", "debian-openssl-3.0.x"] | |
| 12 | +} | |
| 13 | + | |
| 14 | +datasource db { | |
| 15 | + provider = "postgresql" | |
| 16 | + url = env("DATABASE_URL") | |
| 17 | +} | |
| 18 | + | |
| 19 | +// --- Auth.js (NextAuth) models ------------------------------------------- | |
| 20 | + | |
| 21 | +model User { | |
| 22 | + id String @id @default(cuid()) | |
| 23 | + name String? | |
| 24 | + email String? @unique | |
| 25 | + emailVerified DateTime? | |
| 26 | + image String? | |
| 27 | + createdAt DateTime @default(now()) | |
| 28 | + | |
| 29 | + accounts Account[] | |
| 30 | + sessions Session[] | |
| 31 | + apiKey ApiKey? | |
| 32 | + debates Debate[] | |
| 33 | + presets Preset[] | |
| 34 | +} | |
| 35 | + | |
| 36 | +model Account { | |
| 37 | + id String @id @default(cuid()) | |
| 38 | + userId String | |
| 39 | + type String | |
| 40 | + provider String | |
| 41 | + providerAccountId String | |
| 42 | + refresh_token String? @db.Text | |
| 43 | + access_token String? @db.Text | |
| 44 | + expires_at Int? | |
| 45 | + token_type String? | |
| 46 | + scope String? | |
| 47 | + id_token String? @db.Text | |
| 48 | + session_state String? | |
| 49 | + | |
| 50 | + user User @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| 51 | + | |
| 52 | + @@unique([provider, providerAccountId]) | |
| 53 | +} | |
| 54 | + | |
| 55 | +model Session { | |
| 56 | + id String @id @default(cuid()) | |
| 57 | + sessionToken String @unique | |
| 58 | + userId String | |
| 59 | + expires DateTime | |
| 60 | + user User @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| 61 | +} | |
| 62 | + | |
| 63 | +model VerificationToken { | |
| 64 | + identifier String | |
| 65 | + token String @unique | |
| 66 | + expires DateTime | |
| 67 | + | |
| 68 | + @@unique([identifier, token]) | |
| 69 | +} | |
| 70 | + | |
| 71 | +// --- BYOK: per-user encrypted OpenRouter key ------------------------------ | |
| 72 | + | |
| 73 | +model ApiKey { | |
| 74 | + id String @id @default(cuid()) | |
| 75 | + userId String @unique | |
| 76 | + user User @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| 77 | + // AES-256-GCM payload (v1.iv.tag.ciphertext). Never returned to the client. | |
| 78 | + encrypted String @db.Text | |
| 79 | + keyMask String | |
| 80 | + label String? | |
| 81 | + createdAt DateTime @default(now()) | |
| 82 | + updatedAt DateTime @updatedAt | |
| 83 | +} | |
| 84 | + | |
| 85 | +// --- Debates -------------------------------------------------------------- | |
| 86 | + | |
| 87 | +enum DebateStatus { | |
| 88 | + pending | |
| 89 | + running | |
| 90 | + completed | |
| 91 | + failed | |
| 92 | + aborted | |
| 93 | +} | |
| 94 | + | |
| 95 | +enum StageType { | |
| 96 | + answer | |
| 97 | + critique | |
| 98 | + revision | |
| 99 | + convergence | |
| 100 | + synthesis | |
| 101 | +} | |
| 102 | + | |
| 103 | +model Debate { | |
| 104 | + id String @id @default(cuid()) | |
| 105 | + userId String? | |
| 106 | + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| 107 | + | |
| 108 | + question String @db.Text | |
| 109 | + status DebateStatus @default(pending) | |
| 110 | + config Json | |
| 111 | + chairmanModel String | |
| 112 | + convergenceModel String | |
| 113 | + maxRounds Int | |
| 114 | + convergenceThreshold Int | |
| 115 | + temperature Float | |
| 116 | + promptVersion String | |
| 117 | + | |
| 118 | + totalCostUsd Float @default(0) | |
| 119 | + promptTokens Int @default(0) | |
| 120 | + completionTokens Int @default(0) | |
| 121 | + roundsCompleted Int @default(0) | |
| 122 | + durationMs Int @default(0) | |
| 123 | + error String? @db.Text | |
| 124 | + | |
| 125 | + // Demo fixtures are seeded and publicly replayable with no auth. | |
| 126 | + isDemo Boolean @default(false) | |
| 127 | + // Unlisted share link token for the public deliberation report. | |
| 128 | + shareToken String? @unique | |
| 129 | + | |
| 130 | + createdAt DateTime @default(now()) | |
| 131 | + updatedAt DateTime @updatedAt | |
| 132 | + | |
| 133 | + participants Participant[] | |
| 134 | + stageResults StageResult[] | |
| 135 | + synthesis SynthesisResult? | |
| 136 | + | |
| 137 | + @@index([userId, createdAt]) | |
| 138 | + @@index([isDemo]) | |
| 139 | +} | |
| 140 | + | |
| 141 | +model Participant { | |
| 142 | + id String @id @default(cuid()) | |
| 143 | + debateId String | |
| 144 | + debate Debate @relation(fields: [debateId], references: [id], onDelete: Cascade) | |
| 145 | + localId String // p0, p1, … (stable within a debate) | |
| 146 | + model String | |
| 147 | + displayName String | |
| 148 | + orderIndex Int | |
| 149 | + | |
| 150 | + @@unique([debateId, localId]) | |
| 151 | +} | |
| 152 | + | |
| 153 | +model StageResult { | |
| 154 | + id String @id @default(cuid()) | |
| 155 | + debateId String | |
| 156 | + debate Debate @relation(fields: [debateId], references: [id], onDelete: Cascade) | |
| 157 | + round Int | |
| 158 | + stage StageType | |
| 159 | + participantLocalId String? // null for convergence (assessor) and dropped-model failures keyed separately | |
| 160 | + model String | |
| 161 | + // Primary text: the answer / revised answer. Empty for critique/convergence. | |
| 162 | + content String @db.Text | |
| 163 | + // Structured payload: critique reviews, revision changelog, convergence score/disagreements. | |
| 164 | + data Json? | |
| 165 | + promptTokens Int @default(0) | |
| 166 | + completionTokens Int @default(0) | |
| 167 | + costUsd Float @default(0) | |
| 168 | + latencyMs Int @default(0) | |
| 169 | + error String? @db.Text | |
| 170 | + createdAt DateTime @default(now()) | |
| 171 | + | |
| 172 | + @@index([debateId, round, stage]) | |
| 173 | +} | |
| 174 | + | |
| 175 | +model SynthesisResult { | |
| 176 | + id String @id @default(cuid()) | |
| 177 | + debateId String @unique | |
| 178 | + debate Debate @relation(fields: [debateId], references: [id], onDelete: Cascade) | |
| 179 | + model String | |
| 180 | + finalAnswer String @db.Text | |
| 181 | + // Dissent report: array of { topic, positions: [{ participantLocalId, model, position }] } | |
| 182 | + dissent Json | |
| 183 | + promptTokens Int @default(0) | |
| 184 | + completionTokens Int @default(0) | |
| 185 | + costUsd Float @default(0) | |
| 186 | + latencyMs Int @default(0) | |
| 187 | + createdAt DateTime @default(now()) | |
| 188 | +} | |
| 189 | + | |
| 190 | +// --- Saved councils ------------------------------------------------------- | |
| 191 | + | |
| 192 | +model Preset { | |
| 193 | + id String @id @default(cuid()) | |
| 194 | + userId String | |
| 195 | + user User @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| 196 | + name String | |
| 197 | + models String[] | |
| 198 | + chairmanModel String | |
| 199 | + convergenceModel String? | |
| 200 | + maxRounds Int @default(3) | |
| 201 | + convergenceThreshold Int @default(85) | |
| 202 | + temperature Float @default(0.7) | |
| 203 | + createdAt DateTime @default(now()) | |
| 204 | + updatedAt DateTime @updatedAt | |
| 205 | + | |
| 206 | + @@unique([userId, name]) | |
| 207 | +} |
added prisma/seed.ts +69 −0
| @@ -0,0 +1,69 @@ | ||
| 1 | +/* eslint-disable no-console */ | |
| 2 | +/** | |
| 3 | + * Seed the public demo debates from hand-authored, realistic fixtures. | |
| 4 | + * | |
| 5 | + * The fixtures are written by hand (see src/lib/demo-fixtures*) so each model | |
| 6 | + * gives a genuinely different answer and the synthesis is a real verdict. They | |
| 7 | + * are persisted through the *real* write path (createDebate -> persistEvent -> | |
| 8 | + * finalizeDebate), so the seeded rows are identical to what a live debate | |
| 9 | + * produces and the demo replays through the exact same UI. Idempotent: existing | |
| 10 | + * demo debates are cleared first. Requires DATABASE_URL (loaded from .env below). | |
| 11 | + */ | |
| 12 | +import { readFileSync } from 'node:fs'; | |
| 13 | +import { resolve } from 'node:path'; | |
| 14 | + | |
| 15 | +// Next loads .env automatically; a standalone tsx run does not. | |
| 16 | +try { | |
| 17 | + const file = readFileSync(resolve(process.cwd(), '.env'), 'utf8'); | |
| 18 | + for (const raw of file.split('\n')) { | |
| 19 | + const line = raw.trim(); | |
| 20 | + if (!line || line.startsWith('#')) continue; | |
| 21 | + const eq = line.indexOf('='); | |
| 22 | + if (eq === -1) continue; | |
| 23 | + const key = line.slice(0, eq).trim(); | |
| 24 | + let val = line.slice(eq + 1).trim(); | |
| 25 | + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { | |
| 26 | + val = val.slice(1, -1); | |
| 27 | + } | |
| 28 | + if (!(key in process.env)) process.env[key] = val; | |
| 29 | + } | |
| 30 | +} catch { | |
| 31 | + /* rely on ambient environment */ | |
| 32 | +} | |
| 33 | + | |
| 34 | +async function main() { | |
| 35 | + const { buildDebateResult, resultToStageEvents, FIXTURES } = await import('@/lib/demo-fixtures'); | |
| 36 | + const { PROMPT_VERSION } = await import('@/core/prompts'); | |
| 37 | + const { prisma } = await import('@/db/client'); | |
| 38 | + const repo = await import('@/db/repositories'); | |
| 39 | + | |
| 40 | + console.log('Clearing existing demo debates...'); | |
| 41 | + await prisma.debate.deleteMany({ where: { isDemo: true } }); | |
| 42 | + | |
| 43 | + for (const spec of FIXTURES) { | |
| 44 | + const result = buildDebateResult(spec); | |
| 45 | + const debateId = await repo.createDebate({ | |
| 46 | + userId: null, | |
| 47 | + config: result.config, | |
| 48 | + participants: result.participants, | |
| 49 | + promptVersion: PROMPT_VERSION, | |
| 50 | + isDemo: true, | |
| 51 | + }); | |
| 52 | + for (const event of resultToStageEvents(result)) { | |
| 53 | + await repo.persistEvent(debateId, event); | |
| 54 | + } | |
| 55 | + await repo.finalizeDebate(debateId, result); | |
| 56 | + await repo.ensureShareToken(debateId); | |
| 57 | + console.log( | |
| 58 | + ` ok ${spec.question.slice(0, 58)}... ${result.rounds.length} rounds, $${result.totals.costUsd.toFixed(4)}`, | |
| 59 | + ); | |
| 60 | + } | |
| 61 | + | |
| 62 | + await prisma.$disconnect(); | |
| 63 | + console.log(`\nSeeded ${FIXTURES.length} demo debates.`); | |
| 64 | +} | |
| 65 | + | |
| 66 | +main().catch(async (err) => { | |
| 67 | + console.error('Seed failed:', err); | |
| 68 | + process.exit(1); | |
| 69 | +}); |
added src/app/api/auth/[...nextauth]/route.ts +3 −0
| @@ -0,0 +1,3 @@ | ||
| 1 | +import { handlers } from '@/auth'; | |
| 2 | + | |
| 3 | +export const { GET, POST } = handlers; |
added src/app/api/debates/[id]/export/route.ts +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { currentUserId } from '@/auth'; | |
| 3 | +import { getDebateOwnership, loadDebateResult } from '@/db/repositories'; | |
| 4 | +import { debateToMarkdown } from '@/lib/export-markdown'; | |
| 5 | + | |
| 6 | +export const runtime = 'nodejs'; | |
| 7 | + | |
| 8 | +type Params = { params: Promise<{ id: string }> }; | |
| 9 | + | |
| 10 | +/** GET ?format=md - download a Markdown deliberation report. */ | |
| 11 | +export async function GET(_request: Request, { params }: Params) { | |
| 12 | + const { id } = await params; | |
| 13 | + const ownership = await getDebateOwnership(id); | |
| 14 | + if (!ownership) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 15 | + | |
| 16 | + const userId = await currentUserId(); | |
| 17 | + const canRead = ownership.isDemo || ownership.userId === null || ownership.userId === userId; | |
| 18 | + if (!canRead) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); | |
| 19 | + | |
| 20 | + const result = await loadDebateResult(id); | |
| 21 | + if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 22 | + | |
| 23 | + const markdown = debateToMarkdown(result); | |
| 24 | + return new Response(markdown, { | |
| 25 | + headers: { | |
| 26 | + 'Content-Type': 'text/markdown; charset=utf-8', | |
| 27 | + 'Content-Disposition': `attachment; filename="roundtable-${id}.md"`, | |
| 28 | + }, | |
| 29 | + }); | |
| 30 | +} |
added src/app/api/debates/[id]/route.ts +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { currentUserId } from '@/auth'; | |
| 3 | +import { deleteDebate, getDebateOwnership, loadDebateResult } from '@/db/repositories'; | |
| 4 | + | |
| 5 | +export const runtime = 'nodejs'; | |
| 6 | + | |
| 7 | +type Params = { params: Promise<{ id: string }> }; | |
| 8 | + | |
| 9 | +/** GET a full debate snapshot for replay/history. */ | |
| 10 | +export async function GET(_request: Request, { params }: Params) { | |
| 11 | + const { id } = await params; | |
| 12 | + const ownership = await getDebateOwnership(id); | |
| 13 | + if (!ownership) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 14 | + | |
| 15 | + const userId = await currentUserId(); | |
| 16 | + // Readable if: a demo, an unlisted anon debate (no owner), or you own it. | |
| 17 | + const canRead = ownership.isDemo || ownership.userId === null || ownership.userId === userId; | |
| 18 | + if (!canRead) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); | |
| 19 | + | |
| 20 | + const result = await loadDebateResult(id); | |
| 21 | + if (!result) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 22 | + return NextResponse.json(result); | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** DELETE a debate you own. */ | |
| 26 | +export async function DELETE(_request: Request, { params }: Params) { | |
| 27 | + const { id } = await params; | |
| 28 | + const ownership = await getDebateOwnership(id); | |
| 29 | + if (!ownership) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 30 | + const userId = await currentUserId(); | |
| 31 | + if (!userId || ownership.userId !== userId) { | |
| 32 | + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); | |
| 33 | + } | |
| 34 | + await deleteDebate(id); | |
| 35 | + return NextResponse.json({ ok: true }); | |
| 36 | +} |
added src/app/api/debates/[id]/share/route.ts +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { currentUserId } from '@/auth'; | |
| 3 | +import { ensureShareToken, getDebateOwnership } from '@/db/repositories'; | |
| 4 | + | |
| 5 | +export const runtime = 'nodejs'; | |
| 6 | + | |
| 7 | +type Params = { params: Promise<{ id: string }> }; | |
| 8 | + | |
| 9 | +/** POST: mint (or return the existing) unlisted share token for a debate. */ | |
| 10 | +export async function POST(request: Request, { params }: Params) { | |
| 11 | + const { id } = await params; | |
| 12 | + const ownership = await getDebateOwnership(id); | |
| 13 | + if (!ownership) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 14 | + | |
| 15 | + const userId = await currentUserId(); | |
| 16 | + const canShare = ownership.userId === null || ownership.userId === userId; | |
| 17 | + if (!canShare) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); | |
| 18 | + | |
| 19 | + const token = await ensureShareToken(id); | |
| 20 | + const origin = new URL(request.url).origin; | |
| 21 | + return NextResponse.json({ token, url: `${origin}/r/${token}` }); | |
| 22 | +} |
added src/app/api/debates/run/route.ts +48 −0
| @@ -0,0 +1,48 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { currentUserId } from '@/auth'; | |
| 3 | +import { resolveDebateConfig } from '@/core/config'; | |
| 4 | +import { debateConfigInputSchema } from '@/core/schemas'; | |
| 5 | +import { KeyResolutionError, startDebateStream } from '@/lib/debate-runner'; | |
| 6 | +import { env } from '@/lib/env'; | |
| 7 | +import { rateLimit } from '@/lib/rate-limit'; | |
| 8 | + | |
| 9 | +export const runtime = 'nodejs'; | |
| 10 | +export const dynamic = 'force-dynamic'; | |
| 11 | +// Debate streams run for minutes; on a self-hosted node server there is no cap, | |
| 12 | +// but declare a generous ceiling for platforms that honor it. | |
| 13 | +export const maxDuration = 800; | |
| 14 | + | |
| 15 | +function clientIp(request: Request): string { | |
| 16 | + const fwd = request.headers.get('x-forwarded-for'); | |
| 17 | + return fwd?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || 'unknown'; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export async function POST(request: Request) { | |
| 21 | + const body = await request.json().catch(() => null); | |
| 22 | + const parsed = debateConfigInputSchema.safeParse(body); | |
| 23 | + if (!parsed.success) { | |
| 24 | + return NextResponse.json({ error: 'Invalid debate configuration', issues: parsed.error.flatten() }, { status: 400 }); | |
| 25 | + } | |
| 26 | + const config = resolveDebateConfig(parsed.data); | |
| 27 | + const userId = await currentUserId(); | |
| 28 | + | |
| 29 | + const rlKey = userId ? `user:${userId}` : `ip:${clientIp(request)}`; | |
| 30 | + const rl = rateLimit(`debate:${rlKey}`, env.RATE_LIMIT_DEBATES_PER_HOUR, 60 * 60 * 1000); | |
| 31 | + if (!rl.allowed) { | |
| 32 | + return NextResponse.json( | |
| 33 | + { error: `Rate limit reached (${rl.limit}/hour). Try again in ${Math.ceil(rl.resetMs / 60000)} min.` }, | |
| 34 | + { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.resetMs / 1000)) } }, | |
| 35 | + ); | |
| 36 | + } | |
| 37 | + | |
| 38 | + try { | |
| 39 | + const { response } = await startDebateStream({ config, userId }); | |
| 40 | + return response; | |
| 41 | + } catch (err) { | |
| 42 | + if (err instanceof KeyResolutionError) { | |
| 43 | + return NextResponse.json({ error: err.message, code: 'NO_KEY' }, { status: 401 }); | |
| 44 | + } | |
| 45 | + console.error('debate start failed', err); | |
| 46 | + return NextResponse.json({ error: 'Failed to start debate' }, { status: 500 }); | |
| 47 | + } | |
| 48 | +} |
added src/app/api/keys/route.ts +80 −0
| @@ -0,0 +1,80 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { currentUserId } from '@/auth'; | |
| 4 | +import { | |
| 5 | + clearSessionKeyCookie, | |
| 6 | + hasAnyKey, | |
| 7 | + setSessionKeyCookie, | |
| 8 | +} from '@/lib/byok'; | |
| 9 | +import { maskKey } from '@/lib/crypto'; | |
| 10 | +import { env } from '@/lib/env'; | |
| 11 | +import { validateOpenRouterKey } from '@/lib/openrouter'; | |
| 12 | +import { deleteSavedKey, getSavedKeyInfo, saveEncryptedKey } from '@/db/repositories'; | |
| 13 | +import { encryptSecret } from '@/lib/crypto'; | |
| 14 | + | |
| 15 | +export const runtime = 'nodejs'; | |
| 16 | + | |
| 17 | +/** GET: current key status (never returns the key itself). */ | |
| 18 | +export async function GET() { | |
| 19 | + const userId = await currentUserId(); | |
| 20 | + const saved = userId ? await getSavedKeyInfo(userId) : null; | |
| 21 | + const status = await hasAnyKey(); | |
| 22 | + return NextResponse.json({ | |
| 23 | + mockMode: env.MOCK_LLM, | |
| 24 | + hasKey: status.has, | |
| 25 | + source: status.source, | |
| 26 | + saved, | |
| 27 | + authenticated: Boolean(userId), | |
| 28 | + }); | |
| 29 | +} | |
| 30 | + | |
| 31 | +const postSchema = z.object({ | |
| 32 | + apiKey: z.string().min(8).max(512), | |
| 33 | + mode: z.enum(['save', 'session']), | |
| 34 | + label: z.string().max(80).optional(), | |
| 35 | +}); | |
| 36 | + | |
| 37 | +/** POST: validate a key against OpenRouter and store it (encrypted). */ | |
| 38 | +export async function POST(request: Request) { | |
| 39 | + const body = await request.json().catch(() => null); | |
| 40 | + const parsed = postSchema.safeParse(body); | |
| 41 | + if (!parsed.success) { | |
| 42 | + return NextResponse.json({ error: 'Invalid request', issues: parsed.error.flatten() }, { status: 400 }); | |
| 43 | + } | |
| 44 | + const { apiKey, mode, label } = parsed.data; | |
| 45 | + | |
| 46 | + const validation = await validateOpenRouterKey(apiKey); | |
| 47 | + if (!validation.valid) { | |
| 48 | + return NextResponse.json({ error: validation.error ?? 'Key rejected by OpenRouter' }, { status: 400 }); | |
| 49 | + } | |
| 50 | + | |
| 51 | + if (mode === 'save') { | |
| 52 | + const userId = await currentUserId(); | |
| 53 | + if (!userId) { | |
| 54 | + return NextResponse.json({ error: 'Sign in to save a key. Use session mode otherwise.' }, { status: 401 }); | |
| 55 | + } | |
| 56 | + await saveEncryptedKey(userId, encryptSecret(apiKey), maskKey(apiKey), label ?? validation.label ?? null); | |
| 57 | + } else { | |
| 58 | + await setSessionKeyCookie(apiKey); | |
| 59 | + } | |
| 60 | + | |
| 61 | + return NextResponse.json({ | |
| 62 | + ok: true, | |
| 63 | + source: mode, | |
| 64 | + keyMask: maskKey(apiKey), | |
| 65 | + credits: { | |
| 66 | + usage: validation.usage ?? null, | |
| 67 | + limit: validation.limit ?? null, | |
| 68 | + remaining: validation.limitRemaining ?? null, | |
| 69 | + isFreeTier: validation.isFreeTier ?? null, | |
| 70 | + }, | |
| 71 | + }); | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** DELETE: remove the saved key and/or clear the session cookie. */ | |
| 75 | +export async function DELETE() { | |
| 76 | + const userId = await currentUserId(); | |
| 77 | + if (userId) await deleteSavedKey(userId); | |
| 78 | + await clearSessionKeyCookie(); | |
| 79 | + return NextResponse.json({ ok: true }); | |
| 80 | +} |
added src/app/api/models/route.ts +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { getModels } from '@/lib/model-cache'; | |
| 3 | + | |
| 4 | +export const runtime = 'nodejs'; | |
| 5 | +// Must be dynamic: the catalog depends on runtime env (MOCK_LLM) and must not | |
| 6 | +// be frozen into the build. Freshness is handled by the in-process 10-min cache | |
| 7 | +// in model-cache.ts, not by Next's static cache. | |
| 8 | +export const dynamic = 'force-dynamic'; | |
| 9 | + | |
| 10 | +export async function GET() { | |
| 11 | + const models = await getModels(); | |
| 12 | + return NextResponse.json({ | |
| 13 | + models: models.map((m) => ({ | |
| 14 | + id: m.id, | |
| 15 | + name: m.name, | |
| 16 | + contextLength: m.context_length ?? null, | |
| 17 | + promptPrice: Number(m.pricing?.prompt ?? '0'), | |
| 18 | + completionPrice: Number(m.pricing?.completion ?? '0'), | |
| 19 | + })), | |
| 20 | + }); | |
| 21 | +} |
added src/app/api/presets/[id]/route.ts +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { currentUserId } from '@/auth'; | |
| 3 | +import { deletePreset } from '@/db/repositories'; | |
| 4 | + | |
| 5 | +export const runtime = 'nodejs'; | |
| 6 | + | |
| 7 | +type Params = { params: Promise<{ id: string }> }; | |
| 8 | + | |
| 9 | +export async function DELETE(_request: Request, { params }: Params) { | |
| 10 | + const userId = await currentUserId(); | |
| 11 | + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); | |
| 12 | + const { id } = await params; | |
| 13 | + await deletePreset(userId, id); | |
| 14 | + return NextResponse.json({ ok: true }); | |
| 15 | +} |
added src/app/api/presets/route.ts +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { currentUserId } from '@/auth'; | |
| 4 | +import { modelSlugSchema } from '@/core/schemas'; | |
| 5 | +import { listPresets, upsertPreset } from '@/db/repositories'; | |
| 6 | + | |
| 7 | +export const runtime = 'nodejs'; | |
| 8 | + | |
| 9 | +export async function GET() { | |
| 10 | + const userId = await currentUserId(); | |
| 11 | + if (!userId) return NextResponse.json({ presets: [] }); | |
| 12 | + return NextResponse.json({ presets: await listPresets(userId) }); | |
| 13 | +} | |
| 14 | + | |
| 15 | +const presetSchema = z.object({ | |
| 16 | + name: z.string().min(1).max(80), | |
| 17 | + models: z.array(modelSlugSchema).min(3).max(6), | |
| 18 | + chairmanModel: modelSlugSchema, | |
| 19 | + convergenceModel: modelSlugSchema.nullable().optional(), | |
| 20 | + maxRounds: z.number().int().min(1).max(5).optional(), | |
| 21 | + convergenceThreshold: z.number().int().min(0).max(100).optional(), | |
| 22 | + temperature: z.number().min(0).max(2).optional(), | |
| 23 | +}); | |
| 24 | + | |
| 25 | +export async function POST(request: Request) { | |
| 26 | + const userId = await currentUserId(); | |
| 27 | + if (!userId) return NextResponse.json({ error: 'Sign in to save presets' }, { status: 401 }); | |
| 28 | + | |
| 29 | + const body = await request.json().catch(() => null); | |
| 30 | + const parsed = presetSchema.safeParse(body); | |
| 31 | + if (!parsed.success) { | |
| 32 | + return NextResponse.json({ error: 'Invalid preset', issues: parsed.error.flatten() }, { status: 400 }); | |
| 33 | + } | |
| 34 | + const preset = await upsertPreset(userId, parsed.data); | |
| 35 | + return NextResponse.json({ preset }); | |
| 36 | +} |
added src/auth.ts +45 −0
| @@ -0,0 +1,45 @@ | ||
| 1 | +/** | |
| 2 | + * Auth.js (NextAuth v5) configuration - GitHub only. | |
| 3 | + * | |
| 4 | + * This is a personal/portfolio tool, not a SaaS, so auth is intentionally | |
| 5 | + * minimal: one OAuth provider, database sessions via the Prisma adapter, and a | |
| 6 | + * graceful no-op when GitHub credentials aren't configured (the public demo | |
| 7 | + * runs entirely unauthenticated). | |
| 8 | + */ | |
| 9 | +import { PrismaAdapter } from '@auth/prisma-adapter'; | |
| 10 | +import NextAuth from 'next-auth'; | |
| 11 | +import GitHub from 'next-auth/providers/github'; | |
| 12 | +import { prisma } from '@/db/client'; | |
| 13 | +import { env, isGithubAuthConfigured } from '@/lib/env'; | |
| 14 | + | |
| 15 | +export const { handlers, auth, signIn, signOut } = NextAuth({ | |
| 16 | + adapter: PrismaAdapter(prisma), | |
| 17 | + session: { strategy: 'database' }, | |
| 18 | + trustHost: env.AUTH_TRUST_HOST, | |
| 19 | + // Fall back to the (always-present) encryption key so demo deployments don't | |
| 20 | + // need a separate AUTH_SECRET just to render pages without noisy warnings. | |
| 21 | + secret: env.AUTH_SECRET ?? env.ENCRYPTION_KEY, | |
| 22 | + providers: isGithubAuthConfigured | |
| 23 | + ? [ | |
| 24 | + GitHub({ | |
| 25 | + clientId: env.AUTH_GITHUB_ID!, | |
| 26 | + clientSecret: env.AUTH_GITHUB_SECRET!, | |
| 27 | + }), | |
| 28 | + ] | |
| 29 | + : [], | |
| 30 | + pages: { | |
| 31 | + signIn: '/signin', | |
| 32 | + }, | |
| 33 | + callbacks: { | |
| 34 | + session({ session, user }) { | |
| 35 | + if (session.user) session.user.id = user.id; | |
| 36 | + return session; | |
| 37 | + }, | |
| 38 | + }, | |
| 39 | +}); | |
| 40 | + | |
| 41 | +/** Convenience: the current user id, or null. */ | |
| 42 | +export async function currentUserId(): Promise<string | null> { | |
| 43 | + const session = await auth(); | |
| 44 | + return session?.user?.id ?? null; | |
| 45 | +} |
added src/db/client.ts +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +/** | |
| 2 | + * Prisma client singleton. | |
| 3 | + * | |
| 4 | + * Next.js hot-reload in dev would otherwise open a new pool on every reload and | |
| 5 | + * exhaust Postgres connections; caching on `globalThis` avoids that. | |
| 6 | + */ | |
| 7 | +import { PrismaClient } from '@prisma/client'; | |
| 8 | + | |
| 9 | +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; | |
| 10 | + | |
| 11 | +export const prisma = | |
| 12 | + globalForPrisma.prisma ?? | |
| 13 | + new PrismaClient({ | |
| 14 | + log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], | |
| 15 | + }); | |
| 16 | + | |
| 17 | +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; |
added src/db/repositories.ts +590 −0
| @@ -0,0 +1,590 @@ | ||
| 1 | +/** | |
| 2 | + * Data access for debates, keys, presets, and spend. | |
| 3 | + * | |
| 4 | + * The write path is event-driven: `persistEvent` is called for each completed | |
| 5 | + * stage as the orchestrator emits it, so state survives a client disconnect and | |
| 6 | + * can be replayed. `loadDebateResult` reconstructs the exact `DebateResult` | |
| 7 | + * shape the UI renders - the same shape the live event reducer builds - so live | |
| 8 | + * and replay share every component. | |
| 9 | + */ | |
| 10 | +import type { Prisma, DebateStatus as DbStatus } from '@prisma/client'; | |
| 11 | +import type { DebateEvent } from '@/core/events'; | |
| 12 | +import type { | |
| 13 | + AnswerRecord, | |
| 14 | + ConvergenceRecord, | |
| 15 | + CritiqueRecord, | |
| 16 | + DebateConfig, | |
| 17 | + DebateResult, | |
| 18 | + DebateStatus, | |
| 19 | + Disagreement, | |
| 20 | + FailureRecord, | |
| 21 | + Participant, | |
| 22 | + PeerReview, | |
| 23 | + RevisionChangelog, | |
| 24 | + RevisionRecord, | |
| 25 | + RoundRecord, | |
| 26 | + SynthesisRecord, | |
| 27 | +} from '@/core/types'; | |
| 28 | +import { prisma } from './client'; | |
| 29 | + | |
| 30 | +// --------------------------------------------------------------------------- | |
| 31 | +// Creation + lifecycle | |
| 32 | +// --------------------------------------------------------------------------- | |
| 33 | + | |
| 34 | +export interface CreateDebateArgs { | |
| 35 | + userId: string | null; | |
| 36 | + config: DebateConfig; | |
| 37 | + participants: Participant[]; | |
| 38 | + promptVersion: string; | |
| 39 | + isDemo?: boolean; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export async function createDebate(args: CreateDebateArgs): Promise<string> { | |
| 43 | + const debate = await prisma.debate.create({ | |
| 44 | + data: { | |
| 45 | + userId: args.userId, | |
| 46 | + question: args.config.question, | |
| 47 | + status: 'running', | |
| 48 | + config: args.config as unknown as Prisma.InputJsonValue, | |
| 49 | + chairmanModel: args.config.chairmanModel, | |
| 50 | + convergenceModel: args.config.convergenceModel, | |
| 51 | + maxRounds: args.config.maxRounds, | |
| 52 | + convergenceThreshold: args.config.convergenceThreshold, | |
| 53 | + temperature: args.config.temperature, | |
| 54 | + promptVersion: args.promptVersion, | |
| 55 | + isDemo: args.isDemo ?? false, | |
| 56 | + participants: { | |
| 57 | + create: args.participants.map((p, i) => ({ | |
| 58 | + localId: p.id, | |
| 59 | + model: p.model, | |
| 60 | + displayName: p.displayName, | |
| 61 | + orderIndex: i, | |
| 62 | + })), | |
| 63 | + }, | |
| 64 | + }, | |
| 65 | + select: { id: true }, | |
| 66 | + }); | |
| 67 | + return debate.id; | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** Persist the durable events. Transient events (tokens, cost) are ignored. */ | |
| 71 | +export async function persistEvent(debateId: string, event: DebateEvent): Promise<void> { | |
| 72 | + switch (event.type) { | |
| 73 | + case 'answer_completed': { | |
| 74 | + const r = event.record; | |
| 75 | + await prisma.stageResult.create({ | |
| 76 | + data: { | |
| 77 | + debateId, | |
| 78 | + round: r.round, | |
| 79 | + stage: 'answer', | |
| 80 | + participantLocalId: r.participantId, | |
| 81 | + model: r.model, | |
| 82 | + content: r.content, | |
| 83 | + promptTokens: r.usage.promptTokens, | |
| 84 | + completionTokens: r.usage.completionTokens, | |
| 85 | + costUsd: r.usage.costUsd, | |
| 86 | + latencyMs: r.latencyMs, | |
| 87 | + }, | |
| 88 | + }); | |
| 89 | + return; | |
| 90 | + } | |
| 91 | + case 'critique_completed': { | |
| 92 | + const r = event.record; | |
| 93 | + await prisma.stageResult.create({ | |
| 94 | + data: { | |
| 95 | + debateId, | |
| 96 | + round: r.round, | |
| 97 | + stage: 'critique', | |
| 98 | + participantLocalId: r.reviewerParticipantId, | |
| 99 | + model: r.reviewerModel, | |
| 100 | + content: '', | |
| 101 | + data: { reviews: r.reviews } as unknown as Prisma.InputJsonValue, | |
| 102 | + promptTokens: r.usage.promptTokens, | |
| 103 | + completionTokens: r.usage.completionTokens, | |
| 104 | + costUsd: r.usage.costUsd, | |
| 105 | + latencyMs: r.latencyMs, | |
| 106 | + }, | |
| 107 | + }); | |
| 108 | + return; | |
| 109 | + } | |
| 110 | + case 'revision_completed': { | |
| 111 | + const r = event.record; | |
| 112 | + await prisma.stageResult.create({ | |
| 113 | + data: { | |
| 114 | + debateId, | |
| 115 | + round: r.round, | |
| 116 | + stage: 'revision', | |
| 117 | + participantLocalId: r.participantId, | |
| 118 | + model: r.model, | |
| 119 | + content: r.content, | |
| 120 | + data: { changelog: r.changelog } as unknown as Prisma.InputJsonValue, | |
| 121 | + promptTokens: r.usage.promptTokens, | |
| 122 | + completionTokens: r.usage.completionTokens, | |
| 123 | + costUsd: r.usage.costUsd, | |
| 124 | + latencyMs: r.latencyMs, | |
| 125 | + }, | |
| 126 | + }); | |
| 127 | + return; | |
| 128 | + } | |
| 129 | + case 'convergence_result': { | |
| 130 | + const r = event.record; | |
| 131 | + await prisma.stageResult.create({ | |
| 132 | + data: { | |
| 133 | + debateId, | |
| 134 | + round: r.round, | |
| 135 | + stage: 'convergence', | |
| 136 | + model: r.model, | |
| 137 | + content: '', | |
| 138 | + data: { | |
| 139 | + score: r.score, | |
| 140 | + converged: r.converged, | |
| 141 | + disagreements: r.disagreements, | |
| 142 | + } as unknown as Prisma.InputJsonValue, | |
| 143 | + promptTokens: r.usage.promptTokens, | |
| 144 | + completionTokens: r.usage.completionTokens, | |
| 145 | + costUsd: r.usage.costUsd, | |
| 146 | + latencyMs: r.latencyMs, | |
| 147 | + }, | |
| 148 | + }); | |
| 149 | + return; | |
| 150 | + } | |
| 151 | + case 'model_failed': { | |
| 152 | + await prisma.stageResult.create({ | |
| 153 | + data: { | |
| 154 | + debateId, | |
| 155 | + round: event.round, | |
| 156 | + stage: event.stage, | |
| 157 | + participantLocalId: event.participantId, | |
| 158 | + model: event.model, | |
| 159 | + content: '', | |
| 160 | + error: event.error, | |
| 161 | + }, | |
| 162 | + }); | |
| 163 | + return; | |
| 164 | + } | |
| 165 | + case 'synthesis_completed': { | |
| 166 | + const r = event.record; | |
| 167 | + await prisma.synthesisResult.upsert({ | |
| 168 | + where: { debateId }, | |
| 169 | + create: { | |
| 170 | + debateId, | |
| 171 | + model: r.model, | |
| 172 | + finalAnswer: r.finalAnswer, | |
| 173 | + dissent: r.dissent as unknown as Prisma.InputJsonValue, | |
| 174 | + promptTokens: r.usage.promptTokens, | |
| 175 | + completionTokens: r.usage.completionTokens, | |
| 176 | + costUsd: r.usage.costUsd, | |
| 177 | + latencyMs: r.latencyMs, | |
| 178 | + }, | |
| 179 | + update: { | |
| 180 | + finalAnswer: r.finalAnswer, | |
| 181 | + dissent: r.dissent as unknown as Prisma.InputJsonValue, | |
| 182 | + }, | |
| 183 | + }); | |
| 184 | + return; | |
| 185 | + } | |
| 186 | + default: | |
| 187 | + return; // transient - nothing to persist | |
| 188 | + } | |
| 189 | +} | |
| 190 | + | |
| 191 | +export async function finalizeDebate(debateId: string, result: DebateResult): Promise<void> { | |
| 192 | + await prisma.debate.update({ | |
| 193 | + where: { id: debateId }, | |
| 194 | + data: { | |
| 195 | + status: result.status as DbStatus, | |
| 196 | + totalCostUsd: result.totals.costUsd, | |
| 197 | + promptTokens: result.totals.promptTokens, | |
| 198 | + completionTokens: result.totals.completionTokens, | |
| 199 | + roundsCompleted: result.totals.rounds, | |
| 200 | + durationMs: result.totals.durationMs, | |
| 201 | + error: result.error ?? null, | |
| 202 | + }, | |
| 203 | + }); | |
| 204 | +} | |
| 205 | + | |
| 206 | +// --------------------------------------------------------------------------- | |
| 207 | +// Reconstruction (replay) | |
| 208 | +// --------------------------------------------------------------------------- | |
| 209 | + | |
| 210 | +const debateInclude = { | |
| 211 | + participants: { orderBy: { orderIndex: 'asc' } }, | |
| 212 | + stageResults: { orderBy: { createdAt: 'asc' } }, | |
| 213 | + synthesis: true, | |
| 214 | +} satisfies Prisma.DebateInclude; | |
| 215 | + | |
| 216 | +type DebateWithRelations = Prisma.DebateGetPayload<{ include: typeof debateInclude }>; | |
| 217 | + | |
| 218 | +export async function loadDebateResult(debateId: string): Promise<DebateResult | null> { | |
| 219 | + const debate = await prisma.debate.findUnique({ where: { id: debateId }, include: debateInclude }); | |
| 220 | + return debate ? toResult(debate) : null; | |
| 221 | +} | |
| 222 | + | |
| 223 | +export async function loadDebateByShareToken(token: string): Promise<DebateResult | null> { | |
| 224 | + const debate = await prisma.debate.findUnique({ where: { shareToken: token }, include: debateInclude }); | |
| 225 | + return debate ? toResult(debate) : null; | |
| 226 | +} | |
| 227 | + | |
| 228 | +function toResult(debate: DebateWithRelations): DebateResult { | |
| 229 | + const participants: Participant[] = debate.participants.map((p) => ({ | |
| 230 | + id: p.localId, | |
| 231 | + model: p.model, | |
| 232 | + displayName: p.displayName, | |
| 233 | + })); | |
| 234 | + | |
| 235 | + const answersRows = debate.stageResults.filter((s) => s.stage === 'answer' && !s.error); | |
| 236 | + const initialAnswers: AnswerRecord[] = answersRows | |
| 237 | + .filter((s) => s.round === 0) | |
| 238 | + .map((s) => stageToAnswer(s)); | |
| 239 | + | |
| 240 | + const roundNumbers = [...new Set(debate.stageResults.filter((s) => s.round >= 1).map((s) => s.round))].sort( | |
| 241 | + (a, b) => a - b, | |
| 242 | + ); | |
| 243 | + | |
| 244 | + const rounds: RoundRecord[] = roundNumbers.map((round) => { | |
| 245 | + const inRound = debate.stageResults.filter((s) => s.round === round && !s.error); | |
| 246 | + const critiques: CritiqueRecord[] = inRound | |
| 247 | + .filter((s) => s.stage === 'critique') | |
| 248 | + .map((s) => ({ | |
| 249 | + round, | |
| 250 | + reviewerParticipantId: s.participantLocalId ?? '', | |
| 251 | + reviewerModel: s.model, | |
| 252 | + reviews: readJson<{ reviews: PeerReview[] }>(s.data)?.reviews ?? [], | |
| 253 | + usage: usageFromRow(s), | |
| 254 | + latencyMs: s.latencyMs, | |
| 255 | + })); | |
| 256 | + const revisions: RevisionRecord[] = inRound | |
| 257 | + .filter((s) => s.stage === 'revision') | |
| 258 | + .map((s) => ({ | |
| 259 | + round, | |
| 260 | + participantId: s.participantLocalId ?? '', | |
| 261 | + model: s.model, | |
| 262 | + content: s.content, | |
| 263 | + changelog: readJson<{ changelog: RevisionChangelog }>(s.data)?.changelog ?? { | |
| 264 | + changed: false, | |
| 265 | + summary: '', | |
| 266 | + bullets: [], | |
| 267 | + }, | |
| 268 | + usage: usageFromRow(s), | |
| 269 | + latencyMs: s.latencyMs, | |
| 270 | + })); | |
| 271 | + const convRow = inRound.find((s) => s.stage === 'convergence'); | |
| 272 | + const convData = convRow | |
| 273 | + ? readJson<{ score: number; converged: boolean; disagreements: Disagreement[] }>(convRow.data) | |
| 274 | + : null; | |
| 275 | + const convergence: ConvergenceRecord | null = convRow | |
| 276 | + ? { | |
| 277 | + round, | |
| 278 | + model: convRow.model, | |
| 279 | + score: convData?.score ?? 0, | |
| 280 | + converged: convData?.converged ?? false, | |
| 281 | + disagreements: convData?.disagreements ?? [], | |
| 282 | + usage: usageFromRow(convRow), | |
| 283 | + latencyMs: convRow.latencyMs, | |
| 284 | + } | |
| 285 | + : null; | |
| 286 | + return { round, critiques, revisions, convergence }; | |
| 287 | + }); | |
| 288 | + | |
| 289 | + // Current answer per participant = highest-round answer/revision row. | |
| 290 | + const currentByParticipant = new Map<string, AnswerRecord>(); | |
| 291 | + for (const s of answersRows) upsertLatest(currentByParticipant, stageToAnswer(s)); | |
| 292 | + for (const round of rounds) { | |
| 293 | + for (const rev of round.revisions) { | |
| 294 | + upsertLatest(currentByParticipant, { | |
| 295 | + participantId: rev.participantId, | |
| 296 | + model: rev.model, | |
| 297 | + round: rev.round, | |
| 298 | + content: rev.content, | |
| 299 | + usage: rev.usage, | |
| 300 | + latencyMs: rev.latencyMs, | |
| 301 | + }); | |
| 302 | + } | |
| 303 | + } | |
| 304 | + | |
| 305 | + const failures: FailureRecord[] = debate.stageResults | |
| 306 | + .filter((s) => s.error) | |
| 307 | + .map((s) => ({ | |
| 308 | + round: s.round, | |
| 309 | + stage: s.stage, | |
| 310 | + participantId: s.participantLocalId ?? '', | |
| 311 | + model: s.model, | |
| 312 | + error: s.error ?? '', | |
| 313 | + droppedFromDebate: s.stage === 'answer' && s.round === 0, | |
| 314 | + })); | |
| 315 | + const droppedIds = new Set(failures.filter((f) => f.droppedFromDebate).map((f) => f.participantId)); | |
| 316 | + | |
| 317 | + const finalAnswers = [...currentByParticipant.values()].filter((a) => !droppedIds.has(a.participantId)); | |
| 318 | + | |
| 319 | + const synthesis: SynthesisRecord | null = debate.synthesis | |
| 320 | + ? { | |
| 321 | + model: debate.synthesis.model, | |
| 322 | + finalAnswer: debate.synthesis.finalAnswer, | |
| 323 | + dissent: | |
| 324 | + readJson<SynthesisRecord['dissent']>(debate.synthesis.dissent) ?? [], | |
| 325 | + usage: { | |
| 326 | + promptTokens: debate.synthesis.promptTokens, | |
| 327 | + completionTokens: debate.synthesis.completionTokens, | |
| 328 | + totalTokens: debate.synthesis.promptTokens + debate.synthesis.completionTokens, | |
| 329 | + costUsd: debate.synthesis.costUsd, | |
| 330 | + }, | |
| 331 | + latencyMs: debate.synthesis.latencyMs, | |
| 332 | + } | |
| 333 | + : null; | |
| 334 | + | |
| 335 | + const costByModel: Record<string, number> = {}; | |
| 336 | + for (const s of debate.stageResults) { | |
| 337 | + costByModel[s.model] = (costByModel[s.model] ?? 0) + s.costUsd; | |
| 338 | + } | |
| 339 | + | |
| 340 | + return { | |
| 341 | + debateId: debate.id, | |
| 342 | + config: debate.config as unknown as DebateConfig, | |
| 343 | + participants, | |
| 344 | + status: debate.status as DebateStatus, | |
| 345 | + initialAnswers, | |
| 346 | + rounds, | |
| 347 | + synthesis, | |
| 348 | + failures, | |
| 349 | + finalAnswers, | |
| 350 | + totals: { | |
| 351 | + costUsd: debate.totalCostUsd, | |
| 352 | + promptTokens: debate.promptTokens, | |
| 353 | + completionTokens: debate.completionTokens, | |
| 354 | + rounds: debate.roundsCompleted, | |
| 355 | + durationMs: debate.durationMs, | |
| 356 | + costByModel, | |
| 357 | + }, | |
| 358 | + ...(debate.error ? { error: debate.error } : {}), | |
| 359 | + }; | |
| 360 | +} | |
| 361 | + | |
| 362 | +// --------------------------------------------------------------------------- | |
| 363 | +// History / queries | |
| 364 | +// --------------------------------------------------------------------------- | |
| 365 | + | |
| 366 | +export interface DebateSummary { | |
| 367 | + id: string; | |
| 368 | + question: string; | |
| 369 | + status: DebateStatus; | |
| 370 | + models: string[]; | |
| 371 | + chairmanModel: string; | |
| 372 | + totalCostUsd: number; | |
| 373 | + roundsCompleted: number; | |
| 374 | + createdAt: string; | |
| 375 | + isDemo: boolean; | |
| 376 | + shareToken: string | null; | |
| 377 | +} | |
| 378 | + | |
| 379 | +export async function listDebates( | |
| 380 | + userId: string, | |
| 381 | + opts: { search?: string; take?: number; skip?: number } = {}, | |
| 382 | +): Promise<DebateSummary[]> { | |
| 383 | + const debates = await prisma.debate.findMany({ | |
| 384 | + where: { | |
| 385 | + userId, | |
| 386 | + ...(opts.search ? { question: { contains: opts.search, mode: 'insensitive' } } : {}), | |
| 387 | + }, | |
| 388 | + orderBy: { createdAt: 'desc' }, | |
| 389 | + take: opts.take ?? 30, | |
| 390 | + skip: opts.skip ?? 0, | |
| 391 | + include: { participants: { orderBy: { orderIndex: 'asc' }, select: { model: true } } }, | |
| 392 | + }); | |
| 393 | + return debates.map((d) => summarize(d, d.participants.map((p) => p.model))); | |
| 394 | +} | |
| 395 | + | |
| 396 | +export async function listDemoDebates(): Promise<DebateSummary[]> { | |
| 397 | + const debates = await prisma.debate.findMany({ | |
| 398 | + where: { isDemo: true }, | |
| 399 | + orderBy: { createdAt: 'asc' }, | |
| 400 | + include: { participants: { orderBy: { orderIndex: 'asc' }, select: { model: true } } }, | |
| 401 | + }); | |
| 402 | + return debates.map((d) => summarize(d, d.participants.map((p) => p.model))); | |
| 403 | +} | |
| 404 | + | |
| 405 | +function summarize( | |
| 406 | + d: { id: string; question: string; status: DbStatus; chairmanModel: string; totalCostUsd: number; roundsCompleted: number; createdAt: Date; isDemo: boolean; shareToken: string | null }, | |
| 407 | + models: string[], | |
| 408 | +): DebateSummary { | |
| 409 | + return { | |
| 410 | + id: d.id, | |
| 411 | + question: d.question, | |
| 412 | + status: d.status as DebateStatus, | |
| 413 | + models, | |
| 414 | + chairmanModel: d.chairmanModel, | |
| 415 | + totalCostUsd: d.totalCostUsd, | |
| 416 | + roundsCompleted: d.roundsCompleted, | |
| 417 | + createdAt: d.createdAt.toISOString(), | |
| 418 | + isDemo: d.isDemo, | |
| 419 | + shareToken: d.shareToken, | |
| 420 | + }; | |
| 421 | +} | |
| 422 | + | |
| 423 | +export interface DebateOwnership { | |
| 424 | + userId: string | null; | |
| 425 | + isDemo: boolean; | |
| 426 | +} | |
| 427 | + | |
| 428 | +export async function getDebateOwnership(debateId: string): Promise<DebateOwnership | null> { | |
| 429 | + const d = await prisma.debate.findUnique({ where: { id: debateId }, select: { userId: true, isDemo: true } }); | |
| 430 | + return d ? { userId: d.userId, isDemo: d.isDemo } : null; | |
| 431 | +} | |
| 432 | + | |
| 433 | +export async function deleteDebate(debateId: string): Promise<void> { | |
| 434 | + await prisma.debate.delete({ where: { id: debateId } }); | |
| 435 | +} | |
| 436 | + | |
| 437 | +export async function monthlySpendUsd(userId: string): Promise<number> { | |
| 438 | + const start = new Date(); | |
| 439 | + start.setUTCDate(1); | |
| 440 | + start.setUTCHours(0, 0, 0, 0); | |
| 441 | + const agg = await prisma.debate.aggregate({ | |
| 442 | + where: { userId, createdAt: { gte: start } }, | |
| 443 | + _sum: { totalCostUsd: true }, | |
| 444 | + }); | |
| 445 | + return agg._sum.totalCostUsd ?? 0; | |
| 446 | +} | |
| 447 | + | |
| 448 | +export async function countRecentDebates(userId: string, sinceMs: number): Promise<number> { | |
| 449 | + return prisma.debate.count({ | |
| 450 | + where: { userId, createdAt: { gte: new Date(Date.now() - sinceMs) } }, | |
| 451 | + }); | |
| 452 | +} | |
| 453 | + | |
| 454 | +// --------------------------------------------------------------------------- | |
| 455 | +// Sharing | |
| 456 | +// --------------------------------------------------------------------------- | |
| 457 | + | |
| 458 | +export async function ensureShareToken(debateId: string): Promise<string> { | |
| 459 | + const existing = await prisma.debate.findUnique({ where: { id: debateId }, select: { shareToken: true } }); | |
| 460 | + if (existing?.shareToken) return existing.shareToken; | |
| 461 | + const token = randomToken(); | |
| 462 | + await prisma.debate.update({ where: { id: debateId }, data: { shareToken: token } }); | |
| 463 | + return token; | |
| 464 | +} | |
| 465 | + | |
| 466 | +// --------------------------------------------------------------------------- | |
| 467 | +// helpers | |
| 468 | +// --------------------------------------------------------------------------- | |
| 469 | + | |
| 470 | +function stageToAnswer(s: { participantLocalId: string | null; model: string; round: number; content: string; promptTokens: number; completionTokens: number; costUsd: number; latencyMs: number }): AnswerRecord { | |
| 471 | + return { | |
| 472 | + participantId: s.participantLocalId ?? '', | |
| 473 | + model: s.model, | |
| 474 | + round: s.round, | |
| 475 | + content: s.content, | |
| 476 | + usage: usageFromRow(s), | |
| 477 | + latencyMs: s.latencyMs, | |
| 478 | + }; | |
| 479 | +} | |
| 480 | + | |
| 481 | +function usageFromRow(s: { promptTokens: number; completionTokens: number; costUsd: number }) { | |
| 482 | + return { | |
| 483 | + promptTokens: s.promptTokens, | |
| 484 | + completionTokens: s.completionTokens, | |
| 485 | + totalTokens: s.promptTokens + s.completionTokens, | |
| 486 | + costUsd: s.costUsd, | |
| 487 | + }; | |
| 488 | +} | |
| 489 | + | |
| 490 | +function upsertLatest(map: Map<string, AnswerRecord>, rec: AnswerRecord): void { | |
| 491 | + const prev = map.get(rec.participantId); | |
| 492 | + if (!prev || rec.round >= prev.round) map.set(rec.participantId, rec); | |
| 493 | +} | |
| 494 | + | |
| 495 | +function readJson<T>(value: Prisma.JsonValue | null): T | null { | |
| 496 | + if (value === null || value === undefined) return null; | |
| 497 | + return value as unknown as T; | |
| 498 | +} | |
| 499 | + | |
| 500 | +function randomToken(): string { | |
| 501 | + // URL-safe unlisted token. | |
| 502 | + const bytes = new Uint8Array(18); | |
| 503 | + globalThis.crypto.getRandomValues(bytes); | |
| 504 | + return Buffer.from(bytes).toString('base64url'); | |
| 505 | +} | |
| 506 | + | |
| 507 | +// --------------------------------------------------------------------------- | |
| 508 | +// BYOK saved keys | |
| 509 | +// --------------------------------------------------------------------------- | |
| 510 | + | |
| 511 | +export interface SavedKeyInfo { | |
| 512 | + keyMask: string; | |
| 513 | + label: string | null; | |
| 514 | + updatedAt: string; | |
| 515 | +} | |
| 516 | + | |
| 517 | +/** Returns display metadata only - never the encrypted payload. */ | |
| 518 | +export async function getSavedKeyInfo(userId: string): Promise<SavedKeyInfo | null> { | |
| 519 | + const row = await prisma.apiKey.findUnique({ where: { userId }, select: { keyMask: true, label: true, updatedAt: true } }); | |
| 520 | + return row ? { keyMask: row.keyMask, label: row.label, updatedAt: row.updatedAt.toISOString() } : null; | |
| 521 | +} | |
| 522 | + | |
| 523 | +/** Returns the encrypted payload for server-side decryption at debate time. */ | |
| 524 | +export async function getEncryptedKey(userId: string): Promise<string | null> { | |
| 525 | + const row = await prisma.apiKey.findUnique({ where: { userId }, select: { encrypted: true } }); | |
| 526 | + return row?.encrypted ?? null; | |
| 527 | +} | |
| 528 | + | |
| 529 | +export async function saveEncryptedKey( | |
| 530 | + userId: string, | |
| 531 | + encrypted: string, | |
| 532 | + keyMask: string, | |
| 533 | + label: string | null, | |
| 534 | +): Promise<void> { | |
| 535 | + await prisma.apiKey.upsert({ | |
| 536 | + where: { userId }, | |
| 537 | + create: { userId, encrypted, keyMask, label }, | |
| 538 | + update: { encrypted, keyMask, label }, | |
| 539 | + }); | |
| 540 | +} | |
| 541 | + | |
| 542 | +export async function deleteSavedKey(userId: string): Promise<void> { | |
| 543 | + await prisma.apiKey.deleteMany({ where: { userId } }); | |
| 544 | +} | |
| 545 | + | |
| 546 | +// --------------------------------------------------------------------------- | |
| 547 | +// Presets ("my default council") | |
| 548 | +// --------------------------------------------------------------------------- | |
| 549 | + | |
| 550 | +export interface PresetInput { | |
| 551 | + name: string; | |
| 552 | + models: string[]; | |
| 553 | + chairmanModel: string; | |
| 554 | + convergenceModel?: string | null; | |
| 555 | + maxRounds?: number; | |
| 556 | + convergenceThreshold?: number; | |
| 557 | + temperature?: number; | |
| 558 | +} | |
| 559 | + | |
| 560 | +export async function listPresets(userId: string) { | |
| 561 | + return prisma.preset.findMany({ where: { userId }, orderBy: { updatedAt: 'desc' } }); | |
| 562 | +} | |
| 563 | + | |
| 564 | +export async function upsertPreset(userId: string, input: PresetInput) { | |
| 565 | + return prisma.preset.upsert({ | |
| 566 | + where: { userId_name: { userId, name: input.name } }, | |
| 567 | + create: { | |
| 568 | + userId, | |
| 569 | + name: input.name, | |
| 570 | + models: input.models, | |
| 571 | + chairmanModel: input.chairmanModel, | |
| 572 | + convergenceModel: input.convergenceModel ?? null, | |
| 573 | + maxRounds: input.maxRounds ?? 3, | |
| 574 | + convergenceThreshold: input.convergenceThreshold ?? 85, | |
| 575 | + temperature: input.temperature ?? 0.7, | |
| 576 | + }, | |
| 577 | + update: { | |
| 578 | + models: input.models, | |
| 579 | + chairmanModel: input.chairmanModel, | |
| 580 | + convergenceModel: input.convergenceModel ?? null, | |
| 581 | + maxRounds: input.maxRounds ?? 3, | |
| 582 | + convergenceThreshold: input.convergenceThreshold ?? 85, | |
| 583 | + temperature: input.temperature ?? 0.7, | |
| 584 | + }, | |
| 585 | + }); | |
| 586 | +} | |
| 587 | + | |
| 588 | +export async function deletePreset(userId: string, id: string): Promise<void> { | |
| 589 | + await prisma.preset.deleteMany({ where: { id, userId } }); | |
| 590 | +} |
added src/lib/byok.ts +87 −0
| @@ -0,0 +1,87 @@ | ||
| 1 | +/** | |
| 2 | + * BYOK key resolution - there is no server-paid key. | |
| 3 | + * | |
| 4 | + * A request's OpenRouter key comes from one of two places, in order: | |
| 5 | + * 1. the authenticated user's saved key (encrypted at rest, AES-256-GCM), or | |
| 6 | + * 2. a session-only key the visitor supplied, held encrypted in an HttpOnly | |
| 7 | + * cookie (never persisted server-side). | |
| 8 | + * | |
| 9 | + * In `MOCK_LLM` mode no key is required - the mock client serves everything. | |
| 10 | + */ | |
| 11 | +import { cookies } from 'next/headers'; | |
| 12 | +import { currentUserId } from '@/auth'; | |
| 13 | +import { getEncryptedKey } from '@/db/repositories'; | |
| 14 | +import { decryptSecret, encryptSecret } from './crypto'; | |
| 15 | +import { env } from './env'; | |
| 16 | + | |
| 17 | +export const SESSION_KEY_COOKIE = 'rt_session_key'; | |
| 18 | + | |
| 19 | +const COOKIE_OPTS = { | |
| 20 | + httpOnly: true, | |
| 21 | + secure: env.NODE_ENV === 'production', | |
| 22 | + sameSite: 'lax' as const, | |
| 23 | + path: '/', | |
| 24 | + maxAge: 60 * 60 * 12, // 12 hours | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export type KeySource = 'mock' | 'saved' | 'session'; | |
| 28 | + | |
| 29 | +export interface ResolvedKey { | |
| 30 | + ok: true; | |
| 31 | + apiKey: string; | |
| 32 | + source: KeySource; | |
| 33 | +} | |
| 34 | +export interface KeyMissing { | |
| 35 | + ok: false; | |
| 36 | + reason: string; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** Resolve the OpenRouter key for the current request. Never returns it to the client. */ | |
| 40 | +export async function resolveApiKey(): Promise<ResolvedKey | KeyMissing> { | |
| 41 | + if (env.MOCK_LLM) return { ok: true, apiKey: 'mock', source: 'mock' }; | |
| 42 | + | |
| 43 | + const userId = await currentUserId(); | |
| 44 | + if (userId) { | |
| 45 | + const encrypted = await getEncryptedKey(userId); | |
| 46 | + if (encrypted) { | |
| 47 | + try { | |
| 48 | + return { ok: true, apiKey: decryptSecret(encrypted), source: 'saved' }; | |
| 49 | + } catch { | |
| 50 | + return { ok: false, reason: 'Saved key could not be decrypted; please re-enter it.' }; | |
| 51 | + } | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + const jar = await cookies(); | |
| 56 | + const sessionEnc = jar.get(SESSION_KEY_COOKIE)?.value; | |
| 57 | + if (sessionEnc) { | |
| 58 | + try { | |
| 59 | + return { ok: true, apiKey: decryptSecret(sessionEnc), source: 'session' }; | |
| 60 | + } catch { | |
| 61 | + return { ok: false, reason: 'Session key is invalid; please re-enter it.' }; | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + return { ok: false, reason: 'No OpenRouter API key found. Add one to start a debate.' }; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** Store a session-only key (encrypted) in an HttpOnly cookie. */ | |
| 69 | +export async function setSessionKeyCookie(apiKey: string): Promise<void> { | |
| 70 | + const jar = await cookies(); | |
| 71 | + jar.set(SESSION_KEY_COOKIE, encryptSecret(apiKey), COOKIE_OPTS); | |
| 72 | +} | |
| 73 | + | |
| 74 | +export async function clearSessionKeyCookie(): Promise<void> { | |
| 75 | + const jar = await cookies(); | |
| 76 | + jar.delete(SESSION_KEY_COOKIE); | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** Whether the current request has *some* usable key (without decrypting for use). */ | |
| 80 | +export async function hasAnyKey(): Promise<{ has: boolean; source: KeySource | null }> { | |
| 81 | + if (env.MOCK_LLM) return { has: true, source: 'mock' }; | |
| 82 | + const userId = await currentUserId(); | |
| 83 | + if (userId && (await getEncryptedKey(userId))) return { has: true, source: 'saved' }; | |
| 84 | + const jar = await cookies(); | |
| 85 | + if (jar.get(SESSION_KEY_COOKIE)?.value) return { has: true, source: 'session' }; | |
| 86 | + return { has: false, source: null }; | |
| 87 | +} |
added src/lib/crypto.test.ts +55 −0
| @@ -0,0 +1,55 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { decryptSecret, encryptSecret, maskKey, safeEqual } from './crypto'; | |
| 3 | + | |
| 4 | +describe('encryptSecret / decryptSecret', () => { | |
| 5 | + it('round-trips a secret', () => { | |
| 6 | + const secret = 'sk-or-v1-abcdef0123456789'; | |
| 7 | + const encrypted = encryptSecret(secret); | |
| 8 | + expect(decryptSecret(encrypted)).toBe(secret); | |
| 9 | + }); | |
| 10 | + | |
| 11 | + it('produces a versioned, dot-delimited ciphertext that hides the plaintext', () => { | |
| 12 | + const encrypted = encryptSecret('super-secret-key'); | |
| 13 | + expect(encrypted.startsWith('v1.')).toBe(true); | |
| 14 | + expect(encrypted.split('.')).toHaveLength(4); | |
| 15 | + expect(encrypted).not.toContain('super-secret-key'); | |
| 16 | + }); | |
| 17 | + | |
| 18 | + it('uses a fresh IV so identical plaintext encrypts differently', () => { | |
| 19 | + const a = encryptSecret('same'); | |
| 20 | + const b = encryptSecret('same'); | |
| 21 | + expect(a).not.toBe(b); | |
| 22 | + expect(decryptSecret(a)).toBe('same'); | |
| 23 | + expect(decryptSecret(b)).toBe('same'); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it('rejects tampered ciphertext (GCM auth tag)', () => { | |
| 27 | + const encrypted = encryptSecret('integrity-matters'); | |
| 28 | + const parts = encrypted.split('.'); | |
| 29 | + // Flip a character in the ciphertext segment. | |
| 30 | + const tampered = [parts[0], parts[1], parts[2], `${parts[3]!.slice(0, -1)}X`].join('.'); | |
| 31 | + expect(() => decryptSecret(tampered)).toThrow(); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it('rejects a malformed payload', () => { | |
| 35 | + expect(() => decryptSecret('not-a-valid-payload')).toThrow(); | |
| 36 | + expect(() => decryptSecret('v2.a.b.c')).toThrow(); | |
| 37 | + }); | |
| 38 | +}); | |
| 39 | + | |
| 40 | +describe('maskKey', () => { | |
| 41 | + it('shows a prefix and suffix only', () => { | |
| 42 | + expect(maskKey('sk-or-v1-abcdef0123456789xyz')).toBe('sk-or-...9xyz'); | |
| 43 | + }); | |
| 44 | + it('fully masks short values', () => { | |
| 45 | + expect(maskKey('short')).toBe('••••'); | |
| 46 | + }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +describe('safeEqual', () => { | |
| 50 | + it('is true for equal strings and false otherwise', () => { | |
| 51 | + expect(safeEqual('token', 'token')).toBe(true); | |
| 52 | + expect(safeEqual('token', 'tokes')).toBe(false); | |
| 53 | + expect(safeEqual('token', 'tokenn')).toBe(false); | |
| 54 | + }); | |
| 55 | +}); |
added src/lib/crypto.ts +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +/** | |
| 2 | + * AES-256-GCM encryption for user-saved OpenRouter API keys. | |
| 3 | + * | |
| 4 | + * Keys are stored encrypted at rest with a server-side master key from | |
| 5 | + * `ENCRYPTION_KEY`. GCM gives us confidentiality + integrity (the auth tag | |
| 6 | + * detects tampering). Each ciphertext carries a fresh random 96-bit IV, so | |
| 7 | + * encrypting the same key twice yields different ciphertexts. | |
| 8 | + * | |
| 9 | + * Wire format (all base64url, dot-separated, versioned): | |
| 10 | + * v1.<iv>.<authTag>.<ciphertext> | |
| 11 | + */ | |
| 12 | +import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto'; | |
| 13 | +import { env } from './env'; | |
| 14 | + | |
| 15 | +const ALGORITHM = 'aes-256-gcm'; | |
| 16 | +const IV_BYTES = 12; | |
| 17 | +const VERSION = 'v1'; | |
| 18 | + | |
| 19 | +function masterKey(): Buffer { | |
| 20 | + const raw = env.ENCRYPTION_KEY; | |
| 21 | + const key = /^[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64'); | |
| 22 | + if (key.length !== 32) { | |
| 23 | + throw new Error('ENCRYPTION_KEY must decode to 32 bytes for AES-256-GCM.'); | |
| 24 | + } | |
| 25 | + return key; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export function encryptSecret(plaintext: string): string { | |
| 29 | + const iv = randomBytes(IV_BYTES); | |
| 30 | + const cipher = createCipheriv(ALGORITHM, masterKey(), iv); | |
| 31 | + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); | |
| 32 | + const authTag = cipher.getAuthTag(); | |
| 33 | + return [VERSION, b64url(iv), b64url(authTag), b64url(ciphertext)].join('.'); | |
| 34 | +} | |
| 35 | + | |
| 36 | +export function decryptSecret(payload: string): string { | |
| 37 | + const parts = payload.split('.'); | |
| 38 | + if (parts.length !== 4 || parts[0] !== VERSION) { | |
| 39 | + throw new Error('Malformed or unsupported ciphertext.'); | |
| 40 | + } | |
| 41 | + const [, ivB64, tagB64, ctB64] = parts; | |
| 42 | + const decipher = createDecipheriv(ALGORITHM, masterKey(), fromB64url(ivB64!)); | |
| 43 | + decipher.setAuthTag(fromB64url(tagB64!)); | |
| 44 | + const plaintext = Buffer.concat([decipher.update(fromB64url(ctB64!)), decipher.final()]); | |
| 45 | + return plaintext.toString('utf8'); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Constant-time equality for comparing secrets/tokens without leaking length. */ | |
| 49 | +export function safeEqual(a: string, b: string): boolean { | |
| 50 | + const ba = Buffer.from(a); | |
| 51 | + const bb = Buffer.from(b); | |
| 52 | + if (ba.length !== bb.length) return false; | |
| 53 | + return timingSafeEqual(ba, bb); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** A short, non-reversible fingerprint for display ("sk-or-...a1b2"). */ | |
| 57 | +export function maskKey(key: string): string { | |
| 58 | + if (key.length <= 8) return '••••'; | |
| 59 | + return `${key.slice(0, 6)}...${key.slice(-4)}`; | |
| 60 | +} | |
| 61 | + | |
| 62 | +function b64url(buf: Buffer): string { | |
| 63 | + return buf.toString('base64url'); | |
| 64 | +} | |
| 65 | +function fromB64url(s: string): Buffer { | |
| 66 | + return Buffer.from(s, 'base64url'); | |
| 67 | +} |
added src/lib/debate-runner.ts +109 −0
| @@ -0,0 +1,109 @@ | ||
| 1 | +/** | |
| 2 | + * The thin adapter between an HTTP request and the framework-agnostic | |
| 3 | + * orchestrator: it selects the LLM client (mock vs OpenRouter), creates the | |
| 4 | + * debate row, and streams `DebateEvent`s over SSE while persisting each durable | |
| 5 | + * stage. | |
| 6 | + * | |
| 7 | + * Crucially, the orchestrator is NOT tied to the client connection - if the | |
| 8 | + * browser disconnects, `runDebate` keeps going server-side and keeps writing | |
| 9 | + * StageResults, so a reconnect can replay current state (graceful resume). | |
| 10 | + */ | |
| 11 | +import { participantsFor } from '@/core/config'; | |
| 12 | +import type { DebateEvent } from '@/core/events'; | |
| 13 | +import { MockLlmClient } from '@/core/mock-client'; | |
| 14 | +import { runDebate, type Logger } from '@/core/orchestrator'; | |
| 15 | +import { PROMPT_VERSION } from '@/core/prompts'; | |
| 16 | +import type { DebateConfig } from '@/core/types'; | |
| 17 | +import { createDebate, finalizeDebate, persistEvent } from '@/db/repositories'; | |
| 18 | +import { resolveApiKey } from './byok'; | |
| 19 | +import { env } from './env'; | |
| 20 | +import { createOpenRouterClient } from './openrouter'; | |
| 21 | +import { getPricing } from './model-cache'; | |
| 22 | +import { encodeEvent, encodeHeartbeat, encodeNamed, SSE_HEADERS } from './sse'; | |
| 23 | + | |
| 24 | +export class KeyResolutionError extends Error {} | |
| 25 | + | |
| 26 | +const serverLogger: Logger = { | |
| 27 | + info: (m, meta) => console.info(`[debate] ${m}`, meta ?? ''), | |
| 28 | + warn: (m, meta) => console.warn(`[debate] ${m}`, meta ?? ''), | |
| 29 | + error: (m, meta) => console.error(`[debate] ${m}`, meta ?? ''), | |
| 30 | +}; | |
| 31 | + | |
| 32 | +export interface StartDebateArgs { | |
| 33 | + config: DebateConfig; | |
| 34 | + userId: string | null; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export async function startDebateStream( | |
| 38 | + args: StartDebateArgs, | |
| 39 | +): Promise<{ response: Response; debateId: string }> { | |
| 40 | + const key = await resolveApiKey(); | |
| 41 | + if (!key.ok) throw new KeyResolutionError(key.reason); | |
| 42 | + | |
| 43 | + const participants = participantsFor(args.config.models); | |
| 44 | + const debateId = await createDebate({ | |
| 45 | + userId: args.userId, | |
| 46 | + config: args.config, | |
| 47 | + participants, | |
| 48 | + promptVersion: PROMPT_VERSION, | |
| 49 | + }); | |
| 50 | + | |
| 51 | + const llm = env.MOCK_LLM | |
| 52 | + ? new MockLlmClient() | |
| 53 | + : createOpenRouterClient({ apiKey: key.apiKey, pricing: await getPricing() }); | |
| 54 | + | |
| 55 | + let closed = false; | |
| 56 | + let heartbeat: ReturnType<typeof setInterval> | undefined; | |
| 57 | + | |
| 58 | + const stream = new ReadableStream<Uint8Array>({ | |
| 59 | + start(controller) { | |
| 60 | + const safeEnqueue = (chunk: Uint8Array) => { | |
| 61 | + if (closed) return; | |
| 62 | + try { | |
| 63 | + controller.enqueue(chunk); | |
| 64 | + } catch { | |
| 65 | + closed = true; | |
| 66 | + } | |
| 67 | + }; | |
| 68 | + | |
| 69 | + safeEnqueue(encodeNamed('ready', { debateId })); | |
| 70 | + heartbeat = setInterval(() => safeEnqueue(encodeHeartbeat()), 15_000); | |
| 71 | + | |
| 72 | + const emit = async (event: DebateEvent) => { | |
| 73 | + safeEnqueue(encodeEvent(event)); | |
| 74 | + try { | |
| 75 | + await persistEvent(debateId, event); | |
| 76 | + } catch (err) { | |
| 77 | + serverLogger.error('persist_failed', { type: event.type, err: String(err) }); | |
| 78 | + } | |
| 79 | + }; | |
| 80 | + | |
| 81 | + void (async () => { | |
| 82 | + try { | |
| 83 | + const result = await runDebate(args.config, { llm, emit, logger: serverLogger }, { debateId }); | |
| 84 | + await finalizeDebate(debateId, result); | |
| 85 | + } catch (err) { | |
| 86 | + serverLogger.error('run_failed', { debateId, err: String(err) }); | |
| 87 | + safeEnqueue(encodeNamed('error', { message: err instanceof Error ? err.message : 'Debate failed' })); | |
| 88 | + } finally { | |
| 89 | + if (heartbeat) clearInterval(heartbeat); | |
| 90 | + if (!closed) { | |
| 91 | + try { | |
| 92 | + controller.close(); | |
| 93 | + } catch { | |
| 94 | + /* already closed */ | |
| 95 | + } | |
| 96 | + } | |
| 97 | + } | |
| 98 | + })(); | |
| 99 | + }, | |
| 100 | + cancel() { | |
| 101 | + // Client went away. Stop writing to the socket but let the debate finish | |
| 102 | + // and persist server-side - do NOT abort the run. | |
| 103 | + closed = true; | |
| 104 | + if (heartbeat) clearInterval(heartbeat); | |
| 105 | + }, | |
| 106 | + }); | |
| 107 | + | |
| 108 | + return { response: new Response(stream, { headers: SSE_HEADERS }), debateId }; | |
| 109 | +} |
added src/lib/debate-view.ts +274 −0
| @@ -0,0 +1,274 @@ | ||
| 1 | +/** | |
| 2 | + * The client-side debate view-model and its reducer. | |
| 3 | + * | |
| 4 | + * `applyEvent` folds the `DebateEvent` stream into a `DebateView` - the single | |
| 5 | + * shape every debate component renders. Because live streaming and recorded | |
| 6 | + * playback both feed the SAME reducer, and `fromResult` lifts a persisted | |
| 7 | + * `DebateResult` into the same shape, one set of components serves live debates, | |
| 8 | + * history replay, the demo, and the public share page. | |
| 9 | + */ | |
| 10 | +import type { DebateEvent } from '@/core/events'; | |
| 11 | +import { chairmanSharesProvider } from '@/core/models'; | |
| 12 | +import type { | |
| 13 | + AnswerRecord, | |
| 14 | + ConvergenceRecord, | |
| 15 | + CritiqueRecord, | |
| 16 | + DebateConfig, | |
| 17 | + DebateResult, | |
| 18 | + DebateStatus, | |
| 19 | + FailureRecord, | |
| 20 | + Participant, | |
| 21 | + RevisionRecord, | |
| 22 | + RoundRecord, | |
| 23 | + StageType, | |
| 24 | + SynthesisRecord, | |
| 25 | +} from '@/core/types'; | |
| 26 | + | |
| 27 | +export interface DebateView { | |
| 28 | + debateId: string; | |
| 29 | + status: DebateStatus; | |
| 30 | + question: string; | |
| 31 | + config: DebateConfig | null; | |
| 32 | + participants: Participant[]; | |
| 33 | + chairmanModel: string; | |
| 34 | + convergenceModel: string; | |
| 35 | + chairmanProviderConflict: boolean; | |
| 36 | + initialAnswers: Record<string, AnswerRecord>; | |
| 37 | + rounds: RoundRecord[]; | |
| 38 | + synthesis: SynthesisRecord | null; | |
| 39 | + failures: FailureRecord[]; | |
| 40 | + totals: { | |
| 41 | + costUsd: number; | |
| 42 | + promptTokens: number; | |
| 43 | + completionTokens: number; | |
| 44 | + costByModel: Record<string, number>; | |
| 45 | + }; | |
| 46 | + /** Live-only: participantId -> answer text streaming in right now. */ | |
| 47 | + streaming: Record<string, string>; | |
| 48 | + activeStage: { round: number; stage: StageType } | null; | |
| 49 | + /** participantId -> the stage it is currently working on. */ | |
| 50 | + working: Record<string, StageType>; | |
| 51 | + droppedParticipants: string[]; | |
| 52 | + error?: string; | |
| 53 | +} | |
| 54 | + | |
| 55 | +export function initialDebateView(question = ''): DebateView { | |
| 56 | + return { | |
| 57 | + debateId: '', | |
| 58 | + status: 'pending', | |
| 59 | + question, | |
| 60 | + config: null, | |
| 61 | + participants: [], | |
| 62 | + chairmanModel: '', | |
| 63 | + convergenceModel: '', | |
| 64 | + chairmanProviderConflict: false, | |
| 65 | + initialAnswers: {}, | |
| 66 | + rounds: [], | |
| 67 | + synthesis: null, | |
| 68 | + failures: [], | |
| 69 | + totals: { costUsd: 0, promptTokens: 0, completionTokens: 0, costByModel: {} }, | |
| 70 | + streaming: {}, | |
| 71 | + activeStage: null, | |
| 72 | + working: {}, | |
| 73 | + droppedParticipants: [], | |
| 74 | + }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function ensureRound(rounds: RoundRecord[], round: number): RoundRecord[] { | |
| 78 | + if (rounds.some((r) => r.round === round)) return rounds; | |
| 79 | + return [...rounds, { round, critiques: [], revisions: [], convergence: null }].sort((a, b) => a.round - b.round); | |
| 80 | +} | |
| 81 | + | |
| 82 | +function updateRound(rounds: RoundRecord[], round: number, fn: (r: RoundRecord) => RoundRecord): RoundRecord[] { | |
| 83 | + return ensureRound(rounds, round).map((r) => (r.round === round ? fn(r) : r)); | |
| 84 | +} | |
| 85 | + | |
| 86 | +export function applyEvent(prev: DebateView, event: DebateEvent): DebateView { | |
| 87 | + switch (event.type) { | |
| 88 | + case 'debate_started': | |
| 89 | + return { | |
| 90 | + ...prev, | |
| 91 | + debateId: event.debateId, | |
| 92 | + status: 'running', | |
| 93 | + config: event.config, | |
| 94 | + question: event.config.question, | |
| 95 | + participants: event.participants, | |
| 96 | + chairmanModel: event.chairmanModel, | |
| 97 | + convergenceModel: event.config.convergenceModel, | |
| 98 | + chairmanProviderConflict: event.chairmanProviderConflict, | |
| 99 | + }; | |
| 100 | + | |
| 101 | + case 'stage_started': { | |
| 102 | + const working = { ...prev.working }; | |
| 103 | + const streaming = { ...prev.streaming }; | |
| 104 | + if (event.participantId) { | |
| 105 | + working[event.participantId] = event.stage; | |
| 106 | + if (event.stage === 'answer' && streaming[event.participantId] === undefined) { | |
| 107 | + streaming[event.participantId] = ''; | |
| 108 | + } | |
| 109 | + } | |
| 110 | + return { ...prev, activeStage: { round: event.round, stage: event.stage }, working, streaming }; | |
| 111 | + } | |
| 112 | + | |
| 113 | + case 'token_delta': { | |
| 114 | + const current = prev.streaming[event.participantId] ?? ''; | |
| 115 | + return { ...prev, streaming: { ...prev.streaming, [event.participantId]: current + event.delta } }; | |
| 116 | + } | |
| 117 | + | |
| 118 | + case 'answer_completed': { | |
| 119 | + const working = { ...prev.working }; | |
| 120 | + delete working[event.record.participantId]; | |
| 121 | + const streaming = { ...prev.streaming }; | |
| 122 | + delete streaming[event.record.participantId]; | |
| 123 | + return { | |
| 124 | + ...prev, | |
| 125 | + initialAnswers: { ...prev.initialAnswers, [event.record.participantId]: event.record }, | |
| 126 | + working, | |
| 127 | + streaming, | |
| 128 | + }; | |
| 129 | + } | |
| 130 | + | |
| 131 | + case 'critique_completed': { | |
| 132 | + const working = { ...prev.working }; | |
| 133 | + delete working[event.record.reviewerParticipantId]; | |
| 134 | + return { | |
| 135 | + ...prev, | |
| 136 | + rounds: updateRound(prev.rounds, event.round, (r) => ({ | |
| 137 | + ...r, | |
| 138 | + critiques: [...r.critiques.filter((c) => c.reviewerParticipantId !== event.record.reviewerParticipantId), event.record], | |
| 139 | + })), | |
| 140 | + working, | |
| 141 | + }; | |
| 142 | + } | |
| 143 | + | |
| 144 | + case 'revision_completed': { | |
| 145 | + const working = { ...prev.working }; | |
| 146 | + delete working[event.record.participantId]; | |
| 147 | + return { | |
| 148 | + ...prev, | |
| 149 | + rounds: updateRound(prev.rounds, event.round, (r) => ({ | |
| 150 | + ...r, | |
| 151 | + revisions: [...r.revisions.filter((x) => x.participantId !== event.record.participantId), event.record], | |
| 152 | + })), | |
| 153 | + working, | |
| 154 | + }; | |
| 155 | + } | |
| 156 | + | |
| 157 | + case 'convergence_result': | |
| 158 | + return { | |
| 159 | + ...prev, | |
| 160 | + rounds: updateRound(prev.rounds, event.round, (r) => ({ ...r, convergence: event.record })), | |
| 161 | + }; | |
| 162 | + | |
| 163 | + case 'model_failed': { | |
| 164 | + const working = { ...prev.working }; | |
| 165 | + delete working[event.participantId]; | |
| 166 | + const streaming = { ...prev.streaming }; | |
| 167 | + delete streaming[event.participantId]; | |
| 168 | + return { | |
| 169 | + ...prev, | |
| 170 | + working, | |
| 171 | + streaming, | |
| 172 | + failures: [ | |
| 173 | + ...prev.failures, | |
| 174 | + { | |
| 175 | + round: event.round, | |
| 176 | + stage: event.stage, | |
| 177 | + participantId: event.participantId, | |
| 178 | + model: event.model, | |
| 179 | + error: event.error, | |
| 180 | + droppedFromDebate: event.droppedFromDebate, | |
| 181 | + }, | |
| 182 | + ], | |
| 183 | + droppedParticipants: event.droppedFromDebate | |
| 184 | + ? [...new Set([...prev.droppedParticipants, event.participantId])] | |
| 185 | + : prev.droppedParticipants, | |
| 186 | + }; | |
| 187 | + } | |
| 188 | + | |
| 189 | + case 'cost_update': | |
| 190 | + return { | |
| 191 | + ...prev, | |
| 192 | + totals: { | |
| 193 | + costUsd: event.totalCostUsd, | |
| 194 | + promptTokens: event.promptTokens, | |
| 195 | + completionTokens: event.completionTokens, | |
| 196 | + costByModel: event.costByModel, | |
| 197 | + }, | |
| 198 | + }; | |
| 199 | + | |
| 200 | + case 'synthesis_completed': | |
| 201 | + return { ...prev, synthesis: event.record, activeStage: null }; | |
| 202 | + | |
| 203 | + case 'debate_completed': | |
| 204 | + return { | |
| 205 | + ...prev, | |
| 206 | + status: event.status, | |
| 207 | + activeStage: null, | |
| 208 | + working: {}, | |
| 209 | + totals: { ...prev.totals, costUsd: event.totalCostUsd }, | |
| 210 | + }; | |
| 211 | + | |
| 212 | + case 'debate_failed': | |
| 213 | + return { ...prev, status: 'failed', error: event.error, activeStage: null, working: {} }; | |
| 214 | + | |
| 215 | + default: | |
| 216 | + return prev; | |
| 217 | + } | |
| 218 | +} | |
| 219 | + | |
| 220 | +/** Lift a persisted result into a view for replay (no live streaming state). */ | |
| 221 | +export function fromResult(result: DebateResult): DebateView { | |
| 222 | + const initialAnswers: Record<string, AnswerRecord> = {}; | |
| 223 | + for (const a of result.initialAnswers) initialAnswers[a.participantId] = a; | |
| 224 | + return { | |
| 225 | + debateId: result.debateId, | |
| 226 | + status: result.status, | |
| 227 | + question: result.config.question, | |
| 228 | + config: result.config, | |
| 229 | + participants: result.participants, | |
| 230 | + chairmanModel: result.config.chairmanModel, | |
| 231 | + convergenceModel: result.config.convergenceModel, | |
| 232 | + chairmanProviderConflict: chairmanSharesProvider(result.config.chairmanModel, result.config.models), | |
| 233 | + initialAnswers, | |
| 234 | + rounds: result.rounds, | |
| 235 | + synthesis: result.synthesis, | |
| 236 | + failures: result.failures, | |
| 237 | + totals: { | |
| 238 | + costUsd: result.totals.costUsd, | |
| 239 | + promptTokens: result.totals.promptTokens, | |
| 240 | + completionTokens: result.totals.completionTokens, | |
| 241 | + costByModel: result.totals.costByModel, | |
| 242 | + }, | |
| 243 | + streaming: {}, | |
| 244 | + activeStage: null, | |
| 245 | + working: {}, | |
| 246 | + droppedParticipants: result.failures.filter((f) => f.droppedFromDebate).map((f) => f.participantId), | |
| 247 | + ...(result.error ? { error: result.error } : {}), | |
| 248 | + }; | |
| 249 | +} | |
| 250 | + | |
| 251 | +/** Current answer text per participant (latest revision, else initial). */ | |
| 252 | +export function currentAnswers(view: DebateView): Record<string, AnswerRecord> { | |
| 253 | + const out: Record<string, AnswerRecord> = { ...view.initialAnswers }; | |
| 254 | + for (const round of view.rounds) { | |
| 255 | + for (const rev of round.revisions) { | |
| 256 | + out[rev.participantId] = { | |
| 257 | + participantId: rev.participantId, | |
| 258 | + model: rev.model, | |
| 259 | + round: rev.round, | |
| 260 | + content: rev.content, | |
| 261 | + usage: rev.usage, | |
| 262 | + latencyMs: rev.latencyMs, | |
| 263 | + }; | |
| 264 | + } | |
| 265 | + } | |
| 266 | + return out; | |
| 267 | +} | |
| 268 | + | |
| 269 | +/** All critique records across rounds, useful for the aggregate matrix. */ | |
| 270 | +export function allCritiques(view: DebateView): CritiqueRecord[] { | |
| 271 | + return view.rounds.flatMap((r) => r.critiques); | |
| 272 | +} | |
| 273 | + | |
| 274 | +export type { RevisionRecord, ConvergenceRecord, SynthesisRecord, FailureRecord }; |
added src/lib/demo-fixtures.data.ts +338 −0
| @@ -0,0 +1,338 @@ | ||
| 1 | +/** | |
| 2 | + * The hand-written content for the seeded demo debates. Kept apart from the | |
| 3 | + * expansion logic in demo-fixtures.ts so the prose is easy to edit. No em-dashes | |
| 4 | + * or curly quotes here on purpose - plain ASCII throughout. | |
| 5 | + */ | |
| 6 | +import type { FixtureSpec } from './demo-fixtures'; | |
| 7 | + | |
| 8 | +const monolith: FixtureSpec = { | |
| 9 | + question: | |
| 10 | + 'Our 4-engineer startup is building a new B2B SaaS. Should we start with a modular monolith or microservices?', | |
| 11 | + models: ['openai/gpt-4o', 'anthropic/claude-3.5-sonnet', 'google/gemini-pro-1.5'], | |
| 12 | + chairmanModel: 'x-ai/grok-2-1212', | |
| 13 | + convergenceModel: 'google/gemini-2.0-flash-001', | |
| 14 | + convergenceThreshold: 85, | |
| 15 | + answers: [ | |
| 16 | + // GPT-4o - pragmatic monolith | |
| 17 | + `Start with a modular monolith. With four engineers your scarcest resource is engineering attention, and microservices tax it heavily: network calls, distributed transactions, versioned APIs, and a dozen deploy pipelines before you have product-market fit. Build one deployable application, but enforce clean module boundaries - separate packages for billing, auth, and tenanting, with no cross-module database access. That keeps the door open to extracting a service later, the day a specific module actually needs independent scaling or a different runtime. Ship features now; buy the distributed-systems complexity only when a real bottleneck forces you to.`, | |
| 18 | + // Claude 3.5 Sonnet - nuanced, boundaries are the deliverable | |
| 19 | + `A modular monolith is the right default, but the value is in the discipline, not the deployment unit. The failure mode for a small team is not "monolith vs microservices", it is a big ball of mud either way. Define your bounded contexts up front - billing, identity, and the core product domain - give each an explicit interface, and forbid reaching across contexts except through those interfaces. Microservices force those boundaries with the network, at the price of operational overhead you cannot afford yet. So get the boundaries for free in a monolith, through module structure and code review. Revisit only when an organizational reason (a team that owns a context) or a scaling reason (a component with wildly different load) makes a separate deployable worth its cost.`, | |
| 20 | + // Gemini Pro 1.5 - argues for a couple of early services | |
| 21 | + `It depends on your deployment and scaling story more than on team size. If parts of your product have very different scaling or compliance profiles - a webhook ingester hammered by traffic, or a data-residency-sensitive component - a couple of well-chosen services from day one can be cheaper than retrofitting them later. I would avoid full microservices, but consider a "macroservice" split: the main app plus one or two services drawn along the sharpest scaling or isolation seams. Independent deploys also shrink blast radius, which matters when four people push to production daily. The key is to keep the count tiny and the boundaries obvious.`, | |
| 22 | + ], | |
| 23 | + rounds: [ | |
| 24 | + { | |
| 25 | + critiques: [ | |
| 26 | + { | |
| 27 | + by: 0, | |
| 28 | + on: [ | |
| 29 | + { | |
| 30 | + p: 1, | |
| 31 | + score: 8, | |
| 32 | + strengths: ['Correctly identifies the ball-of-mud as the real risk', 'Frames boundaries as the actual deliverable'], | |
| 33 | + weaknesses: ['Does not give concrete triggers for when to extract a service'], | |
| 34 | + note: 'Strong on principle, light on the operational specifics a founder needs.', | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + p: 2, | |
| 38 | + score: 6, | |
| 39 | + strengths: ['Fair point that scaling and compliance seams can justify an early split'], | |
| 40 | + weaknesses: [ | |
| 41 | + 'Underweights the fixed cost of even one extra service for a team of four', | |
| 42 | + 'Blast radius is better handled by tests and feature flags than by network boundaries at this stage', | |
| 43 | + ], | |
| 44 | + note: 'Reasonable exceptions, but the default it implies is too eager to split.', | |
| 45 | + }, | |
| 46 | + ], | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + by: 1, | |
| 50 | + on: [ | |
| 51 | + { | |
| 52 | + p: 0, | |
| 53 | + score: 8, | |
| 54 | + strengths: ['Concrete rule: no cross-module database access', 'Names the real cost drivers of microservices'], | |
| 55 | + weaknesses: ['Slightly understates how easily clean modules rot without enforcement'], | |
| 56 | + note: 'Actionable and correct. I would pair it with automated boundary checks.', | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + p: 2, | |
| 60 | + score: 6, | |
| 61 | + strengths: ['Independent deploys and isolation are genuine benefits'], | |
| 62 | + weaknesses: [ | |
| 63 | + 'Treats a couple of services as low-cost, which it is not at this size', | |
| 64 | + 'Compliance and residency rarely bite a pre-PMF startup', | |
| 65 | + ], | |
| 66 | + note: 'The exceptions are valid but rare enough not to be the default.', | |
| 67 | + }, | |
| 68 | + ], | |
| 69 | + }, | |
| 70 | + { | |
| 71 | + by: 2, | |
| 72 | + on: [ | |
| 73 | + { | |
| 74 | + p: 0, | |
| 75 | + score: 7, | |
| 76 | + strengths: ['Pragmatic and shippable', 'The extract-later path is sound'], | |
| 77 | + weaknesses: ['Assumes the future extraction will actually happen; teams often never pay down that debt'], | |
| 78 | + note: 'Good default, but extract later is easier said than done.', | |
| 79 | + }, | |
| 80 | + { | |
| 81 | + p: 1, | |
| 82 | + score: 7, | |
| 83 | + strengths: ['Bounded-contexts framing is exactly right'], | |
| 84 | + weaknesses: ['Does not address cases where scaling profiles genuinely differ from day one'], | |
| 85 | + note: 'Principled, but a bit absolutist about avoiding all early services.', | |
| 86 | + }, | |
| 87 | + ], | |
| 88 | + }, | |
| 89 | + ], | |
| 90 | + revisions: [ | |
| 91 | + { | |
| 92 | + p: 0, | |
| 93 | + changed: true, | |
| 94 | + content: `Start with a modular monolith with boundaries you actually enforce. One deployable application; each context (billing, identity, the core domain) lives in its own module with a public interface and its own database schema. Make the boundaries real, not aspirational: add an architecture test to CI that fails the build on cross-context imports or shared tables. Extract a context into its own service only when you hit a concrete trigger - it needs to scale independently, it needs a different runtime or language, it falls under a separate compliance boundary, or a dedicated team will own it. Until one of those is true, one deployable wins. This gives you most of what microservices promise without the operational tax a four-person team cannot afford before product-market fit.`, | |
| 95 | + summary: 'Added enforcement (CI architecture tests, per-context schemas) and explicit extraction triggers, answering the "clean modules rot" and "when to split" critiques.', | |
| 96 | + bullets: ['Automated architecture tests in CI', 'A schema per bounded context', 'Named, concrete triggers for extraction'], | |
| 97 | + }, | |
| 98 | + { | |
| 99 | + p: 1, | |
| 100 | + changed: true, | |
| 101 | + content: `A modular monolith is the right default, and here is the concrete version. One deployable, with bounded contexts as modules - billing, identity, core domain - each exposing an explicit interface and owning its own tables, enforced both in code review and by an automated boundary check. Extract a context into a service when there is an organizational reason (a team will own it) or a technical one (its load or runtime diverges sharply from the rest). Structuring it this way keeps the boundaries honest now, and makes the eventual extraction mechanical rather than a rewrite.`, | |
| 102 | + summary: 'Made the principle concrete with explicit extraction triggers and named enforcement, addressing the "too abstract" critique. Converged with the enforced-boundaries view.', | |
| 103 | + bullets: ['Added explicit extraction triggers', 'Named the enforcement mechanisms'], | |
| 104 | + }, | |
| 105 | + { | |
| 106 | + p: 2, | |
| 107 | + changed: true, | |
| 108 | + content: `I will narrow my position. For a four-person, pre-PMF team, start with a modular monolith and enforced boundaries; the fixed cost of even one extra service usually is not worth it, and blast radius is better handled with tests and feature flags at this stage. I would keep one exception: if a component has a genuinely different and punishing scaling profile on day one - a high-volume ingester - or a hard data-residency requirement, splitting just that one component out can be cheaper than retrofitting it later. Otherwise: monolith now, extract on a concrete trigger.`, | |
| 109 | + summary: 'Conceded that a couple of early services is too eager as a default for this team size. Kept a single narrow exception for a day-one extreme-scaling or residency seam.', | |
| 110 | + bullets: ['Dropped the macroservice-by-default stance', 'Kept one narrow early-split exception'], | |
| 111 | + }, | |
| 112 | + ], | |
| 113 | + convergence: { | |
| 114 | + score: 74, | |
| 115 | + disagreements: [ | |
| 116 | + { | |
| 117 | + topic: 'Day-one exceptions', | |
| 118 | + summary: 'Whether any single component justifies its own service before product-market fit.', | |
| 119 | + positions: [ | |
| 120 | + { p: 2, stance: 'One narrow exception (extreme scaling or data residency) can justify an early split.' }, | |
| 121 | + { p: 0, stance: 'Even those are better served by a well-isolated module now, extracted on a real trigger.' }, | |
| 122 | + ], | |
| 123 | + }, | |
| 124 | + ], | |
| 125 | + }, | |
| 126 | + }, | |
| 127 | + { | |
| 128 | + critiques: [ | |
| 129 | + { | |
| 130 | + by: 0, | |
| 131 | + on: [ | |
| 132 | + { p: 1, score: 9, strengths: ['Now fully concrete: triggers plus enforcement'], weaknesses: ['Could name the specific architecture-test tooling'], note: 'Essentially aligned. Excellent.' }, | |
| 133 | + { p: 2, score: 8, strengths: ['Good concession; the narrow exception is defensible'], weaknesses: ['The exception risks becoming a slippery slope without a hard bar'], note: 'Agree, provided the exception stays truly exceptional.' }, | |
| 134 | + ], | |
| 135 | + }, | |
| 136 | + { | |
| 137 | + by: 1, | |
| 138 | + on: [ | |
| 139 | + { p: 0, score: 9, strengths: ['CI boundary checks and per-context schemas make it real'], weaknesses: ['Nothing material'], note: 'The strongest, most actionable version.' }, | |
| 140 | + { p: 2, score: 8, strengths: ['Reasonable, well-narrowed position'], weaknesses: ['Would require the day-one split to clear a very high bar'], note: 'Close to consensus.' }, | |
| 141 | + ], | |
| 142 | + }, | |
| 143 | + { | |
| 144 | + by: 2, | |
| 145 | + on: [ | |
| 146 | + { p: 0, score: 9, strengths: ['Triggers plus enforcement answer my worry that extraction never happens'], weaknesses: ['Still assumes the team keeps the CI checks green'], note: 'I am largely convinced.' }, | |
| 147 | + { p: 1, score: 8, strengths: ['Bounded contexts plus triggers'], weaknesses: ['Slightly less concrete than the tooling in the other answer'], note: 'Aligned.' }, | |
| 148 | + ], | |
| 149 | + }, | |
| 150 | + ], | |
| 151 | + revisions: [ | |
| 152 | + { | |
| 153 | + p: 0, | |
| 154 | + changed: false, | |
| 155 | + content: `Start with a modular monolith with boundaries you actually enforce. One deployable application; each context (billing, identity, the core domain) lives in its own module with a public interface and its own database schema. Make the boundaries real, not aspirational: add an architecture test to CI that fails the build on cross-context imports or shared tables. Extract a context into its own service only when you hit a concrete trigger - it needs to scale independently, it needs a different runtime or language, it falls under a separate compliance boundary, or a dedicated team will own it. Until one of those is true, one deployable wins. This gives you most of what microservices promise without the operational tax a four-person team cannot afford before product-market fit.`, | |
| 156 | + summary: 'No substantive change; the position held up under critique. Implementation note: enforce boundaries with a tool like import-linter (Python) or ArchUnit (JVM).', | |
| 157 | + bullets: [], | |
| 158 | + }, | |
| 159 | + { | |
| 160 | + p: 1, | |
| 161 | + changed: false, | |
| 162 | + content: `A modular monolith is the right default, and here is the concrete version. One deployable, with bounded contexts as modules - billing, identity, core domain - each exposing an explicit interface and owning its own tables, enforced both in code review and by an automated boundary check. Extract a context into a service when there is an organizational reason (a team will own it) or a technical one (its load or runtime diverges sharply from the rest). Structuring it this way keeps the boundaries honest now, and makes the eventual extraction mechanical rather than a rewrite.`, | |
| 163 | + summary: 'Defending the revised position; it converged with the council and needs no further change.', | |
| 164 | + bullets: [], | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + p: 2, | |
| 168 | + changed: true, | |
| 169 | + content: `I will narrow my position. For a four-person, pre-PMF team, start with a modular monolith and enforced boundaries; the fixed cost of even one extra service usually is not worth it, and blast radius is better handled with tests and feature flags at this stage. For the day-one exception, set a hard bar: only pre-split a component if you can point to a specific, quantified scaling or legal requirement that a module provably cannot satisfy - otherwise it stays in the monolith. So: monolith now, one deployable, extract on a concrete trigger.`, | |
| 170 | + summary: 'Added a hard, quantified bar for the early-split exception so it cannot become a slippery slope, addressing the remaining critique.', | |
| 171 | + bullets: ['Required a specific, quantified justification for any day-one split'], | |
| 172 | + }, | |
| 173 | + ], | |
| 174 | + convergence: { | |
| 175 | + score: 91, | |
| 176 | + disagreements: [], | |
| 177 | + }, | |
| 178 | + }, | |
| 179 | + ], | |
| 180 | + finalAnswer: `Start with a modular monolith - one deployable application - but treat its internal boundaries as first-class. Split the codebase into bounded contexts (billing, identity, and your core product domain), give each an explicit public interface and its own database schema, and enforce those boundaries automatically with an architecture test in CI that fails on cross-context imports or shared tables. This buys you almost everything microservices promise - clear ownership, replaceable parts, contained blast radius - without the operational tax a four-person team cannot afford before product-market fit: multiple deploy pipelines, network failure modes, distributed transactions, and versioned inter-service APIs. | |
| 181 | + | |
| 182 | +Extract a context into its own service only when you hit a concrete, nameable trigger: it needs to scale independently, it requires a different runtime or language, it falls under a separate compliance or data-residency boundary, or a dedicated team will own it. Because the boundaries were enforced from the start, that extraction is mechanical rather than a rewrite. In short: get the discipline of microservices now, effectively for free, through module structure, and pay for their operations only when a real constraint - not a hypothetical one - demands it.`, | |
| 183 | + dissent: [ | |
| 184 | + { | |
| 185 | + topic: 'Splitting a service on day one', | |
| 186 | + positions: [ | |
| 187 | + { p: 2, text: 'Would allow one narrow exception: a component with an extreme, quantified scaling or data-residency requirement can justify its own service from the start.' }, | |
| 188 | + { p: 0, text: 'Even those cases are better served by a well-isolated module initially, extracted the moment the requirement is proven, avoiding any premature distribution.' }, | |
| 189 | + ], | |
| 190 | + }, | |
| 191 | + ], | |
| 192 | +}; | |
| 193 | + | |
| 194 | +const restVsGraphql: FixtureSpec = { | |
| 195 | + question: | |
| 196 | + 'For a new internal service API used by our web and mobile apps, should we go with REST or GraphQL?', | |
| 197 | + models: ['openai/gpt-4o', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'], | |
| 198 | + chairmanModel: 'google/gemini-pro-1.5', | |
| 199 | + convergenceModel: 'google/gemini-2.0-flash-001', | |
| 200 | + convergenceThreshold: 85, | |
| 201 | + answers: [ | |
| 202 | + // GPT-4o - REST default | |
| 203 | + `Default to REST. For an internal API with two first-party clients, REST over HTTP with clear resource nouns is boring in the best way: cacheable with standard HTTP semantics, trivial to debug with curl, and supported by every proxy and tool you already run. GraphQL earns its keep when you have many heterogeneous clients that each need different slices of data and you want to avoid endpoint sprawl - that is not you yet. Start REST; if the mobile team starts complaining about over-fetching or too many round-trips, add a GraphQL or backend-for-frontend layer for that client specifically.`, | |
| 204 | + // Claude 3.5 Sonnet - depends on data + clients | |
| 205 | + `The honest answer is that it depends on your data and your clients, but for two first-party apps I would lean REST with a caveat. If your screens map cleanly to resources, REST is simpler to build, cache, and secure. GraphQL's real win is letting a client select exactly the fields it needs across a graph of related entities in a single request - valuable if your mobile app is on flaky networks and your data is highly relational. Its costs are real too: you own query-complexity limiting, caching gets harder, and authorization moves into resolvers. For most internal APIs, REST plus a few purpose-built aggregate endpoints gets you most of GraphQL's benefit at a fraction of the cost.`, | |
| 206 | + // DeepSeek - GraphQL for velocity | |
| 207 | + `I would choose GraphQL. With a web and a mobile client evolving in parallel, a single typed schema becomes a shared contract: clients fetch exactly the fields they need, the backend stops shipping a bespoke endpoint for every screen, and the type system plus introspection give you strong tooling and code generation. Over-fetching and under-fetching both disappear. Yes, you take on caching and query-cost management, but those are well-trodden problems with mature libraries. The developer velocity from not negotiating a new endpoint for every UI change is worth the price.`, | |
| 208 | + ], | |
| 209 | + rounds: [ | |
| 210 | + { | |
| 211 | + critiques: [ | |
| 212 | + { | |
| 213 | + by: 0, | |
| 214 | + on: [ | |
| 215 | + { p: 1, score: 8, strengths: ['REST plus aggregate endpoints captures most of the upside cheaply'], weaknesses: ['Does not say when the relational or flaky-network case actually tips to GraphQL'], note: 'Balanced and practical.' }, | |
| 216 | + { p: 2, score: 6, strengths: ['Right that a shared typed schema speeds client work'], weaknesses: ['Understates caching and authorization cost for a small team', 'Calling those solved problems hand-waves a real operational burden'], note: 'Overweights velocity, underweights ops.' }, | |
| 217 | + ], | |
| 218 | + }, | |
| 219 | + { | |
| 220 | + by: 1, | |
| 221 | + on: [ | |
| 222 | + { p: 0, score: 8, strengths: ['Correct default, with a good tie-break: add a BFF per client if over-fetching bites'], weaknesses: ['Could acknowledge relational data as a genuine GraphQL trigger'], note: 'Solid, if slightly REST-absolutist.' }, | |
| 223 | + { p: 2, score: 6, strengths: ['The velocity argument is real when UIs change fast'], weaknesses: ['Caching and authorization costs are dismissed too quickly for a small team'], note: 'Good upside case, weak on the downside.' }, | |
| 224 | + ], | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + by: 2, | |
| 228 | + on: [ | |
| 229 | + { p: 0, score: 7, strengths: ['Pragmatic and low-risk'], weaknesses: ['Add GraphQL later underestimates the migration cost once REST endpoints proliferate'], note: 'Safe, but may defer a decision that only gets costlier.' }, | |
| 230 | + { p: 1, score: 8, strengths: ['Names the exact conditions where GraphQL wins'], weaknesses: ['The aggregate-endpoints path can sprawl too'], note: 'Closest to how I would actually decide.' }, | |
| 231 | + ], | |
| 232 | + }, | |
| 233 | + ], | |
| 234 | + revisions: [ | |
| 235 | + { | |
| 236 | + p: 0, | |
| 237 | + changed: true, | |
| 238 | + content: `Default to REST for two first-party clients whose screens map cleanly to resources - it is cheaper to build, cache, secure, and debug. But check two things now, because they are the triggers that tip the balance toward GraphQL: (1) highly relational data where clients routinely need nested, varying slices, and (2) fast-diverging client needs that would otherwise breed a new endpoint per screen. If neither holds, REST plus a handful of aggregate or backend-for-frontend endpoints is the cheaper path. If both hold strongly, start with GraphQL to avoid a costly migration later.`, | |
| 239 | + summary: 'Added explicit triggers that would justify GraphQL up front, answering the "when does it tip" critique.', | |
| 240 | + bullets: ['Named the relational-data and diverging-clients triggers'], | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + p: 1, | |
| 244 | + changed: false, | |
| 245 | + content: `The honest answer is that it depends on your data and your clients, but for two first-party apps I would lean REST with a caveat. If your screens map cleanly to resources, REST is simpler to build, cache, and secure. GraphQL's real win is letting a client select exactly the fields it needs across a graph of related entities in a single request - valuable if your mobile app is on flaky networks and your data is highly relational. Its costs are real too: you own query-complexity limiting, caching gets harder, and authorization moves into resolvers. For most internal APIs, REST plus a few purpose-built aggregate endpoints gets you most of GraphQL's benefit at a fraction of the cost - just put light governance on those endpoints so they do not sprawl.`, | |
| 246 | + summary: 'Position held; it already scoped the trade-off. Added that aggregate endpoints need governance to avoid their own sprawl.', | |
| 247 | + bullets: [], | |
| 248 | + }, | |
| 249 | + { | |
| 250 | + p: 2, | |
| 251 | + changed: true, | |
| 252 | + content: `I will qualify my answer. GraphQL is the better fit when data is relational and client needs diverge quickly - common, but not universal. For a small team whose screens map cleanly to resources, REST is genuinely cheaper to operate, and GraphQL's caching and authorization costs are real, not trivial. So: choose GraphQL if you expect many evolving views over connected data; otherwise start REST and add a GraphQL or BFF layer for the client that needs it. The decision should follow the data shape and the rate of UI change, not fashion.`, | |
| 253 | + summary: 'Conceded that GraphQL\'s operational costs are real, and made the choice conditional on data shape and UI churn rather than a blanket recommendation.', | |
| 254 | + bullets: ['Made GraphQL conditional on relational data and fast-changing UIs', 'Acknowledged the caching and authorization costs'], | |
| 255 | + }, | |
| 256 | + ], | |
| 257 | + convergence: { | |
| 258 | + score: 79, | |
| 259 | + disagreements: [ | |
| 260 | + { | |
| 261 | + topic: 'The default choice', | |
| 262 | + summary: 'Whether to start REST-by-default or GraphQL-by-default for two first-party clients.', | |
| 263 | + positions: [ | |
| 264 | + { p: 0, stance: 'REST by default; adopt GraphQL only on clear triggers.' }, | |
| 265 | + { p: 2, stance: 'GraphQL by default when UIs change fast over connected data.' }, | |
| 266 | + ], | |
| 267 | + }, | |
| 268 | + ], | |
| 269 | + }, | |
| 270 | + }, | |
| 271 | + { | |
| 272 | + critiques: [ | |
| 273 | + { | |
| 274 | + by: 0, | |
| 275 | + on: [ | |
| 276 | + { p: 1, score: 9, strengths: ['Clear decision criteria, no dogma'], weaknesses: ['Nothing material'], note: 'The best framing of the three.' }, | |
| 277 | + { p: 2, score: 8, strengths: ['Good concession; now criteria-driven'], weaknesses: ['Still leans GraphQL in genuinely ambiguous cases'], note: 'Nearly aligned.' }, | |
| 278 | + ], | |
| 279 | + }, | |
| 280 | + { | |
| 281 | + by: 1, | |
| 282 | + on: [ | |
| 283 | + { p: 0, score: 9, strengths: ['The triggers make the default actionable'], weaknesses: ['Nothing material'], note: 'Strong.' }, | |
| 284 | + { p: 2, score: 8, strengths: ['The conditional recommendation is right'], weaknesses: ['Would set a higher bar before starting with GraphQL'], note: 'Close.' }, | |
| 285 | + ], | |
| 286 | + }, | |
| 287 | + { | |
| 288 | + by: 2, | |
| 289 | + on: [ | |
| 290 | + { p: 0, score: 8, strengths: ['Low-risk and clear'], weaknesses: ['Migration cost if the triggers appear later is still a real risk'], note: 'Fair.' }, | |
| 291 | + { p: 1, score: 9, strengths: ['Exactly the decision procedure I would use now'], weaknesses: ['Aggregate-endpoint governance is the one open detail'], note: 'Agreed.' }, | |
| 292 | + ], | |
| 293 | + }, | |
| 294 | + ], | |
| 295 | + revisions: [ | |
| 296 | + { | |
| 297 | + p: 0, | |
| 298 | + changed: false, | |
| 299 | + content: `Default to REST for two first-party clients whose screens map cleanly to resources - it is cheaper to build, cache, secure, and debug. But check two things now, because they are the triggers that tip the balance toward GraphQL: (1) highly relational data where clients routinely need nested, varying slices, and (2) fast-diverging client needs that would otherwise breed a new endpoint per screen. If neither holds, REST plus a handful of aggregate or backend-for-frontend endpoints is the cheaper path. If both hold strongly, start with GraphQL to avoid a costly migration later.`, | |
| 300 | + summary: 'No change; the criteria held up under critique.', | |
| 301 | + bullets: [], | |
| 302 | + }, | |
| 303 | + { | |
| 304 | + p: 1, | |
| 305 | + changed: false, | |
| 306 | + content: `The honest answer is that it depends on your data and your clients, but for two first-party apps I would lean REST with a caveat. If your screens map cleanly to resources, REST is simpler to build, cache, and secure. GraphQL's real win is letting a client select exactly the fields it needs across a graph of related entities in a single request - valuable if your mobile app is on flaky networks and your data is highly relational. Its costs are real too: you own query-complexity limiting, caching gets harder, and authorization moves into resolvers. For most internal APIs, REST plus a few purpose-built aggregate endpoints gets you most of GraphQL's benefit at a fraction of the cost - just put light governance on those endpoints so they do not sprawl.`, | |
| 307 | + summary: 'Defending the position; the council converged on a criteria-based choice.', | |
| 308 | + bullets: [], | |
| 309 | + }, | |
| 310 | + { | |
| 311 | + p: 2, | |
| 312 | + changed: false, | |
| 313 | + content: `I will qualify my answer. GraphQL is the better fit when data is relational and client needs diverge quickly - common, but not universal. For a small team whose screens map cleanly to resources, REST is genuinely cheaper to operate, and GraphQL's caching and authorization costs are real, not trivial. So: choose GraphQL if you expect many evolving views over connected data; otherwise start REST and add a GraphQL or BFF layer for the client that needs it. The decision should follow the data shape and the rate of UI change, not fashion.`, | |
| 314 | + summary: 'No further change; my qualified, data-shape-driven position matches the emerging consensus.', | |
| 315 | + bullets: [], | |
| 316 | + }, | |
| 317 | + ], | |
| 318 | + convergence: { | |
| 319 | + score: 90, | |
| 320 | + disagreements: [], | |
| 321 | + }, | |
| 322 | + }, | |
| 323 | + ], | |
| 324 | + finalAnswer: `Default to REST, and reach for GraphQL only on specific triggers. For an internal API serving two first-party clients whose screens map cleanly to resources, REST over HTTP is cheaper to build, cache, secure, and debug - you inherit standard HTTP semantics and every tool in your stack already speaks it. Cover the common over-fetching complaints with a small number of purpose-built aggregate or backend-for-frontend endpoints, and put light governance on them so they do not sprawl. | |
| 325 | + | |
| 326 | +Choose GraphQL up front instead when two conditions hold together: your data is highly relational and clients routinely need nested, varying slices of it, and your web and mobile UIs change fast enough that REST would breed a new endpoint per screen. In that world a single typed schema pays for its operational costs - which are real: you must own query-complexity limiting, caching, and resolver-level authorization. Let the data shape and the rate of UI change decide, not fashion. The two clients you have today do not force GraphQL, but a growing, connected data model and fast-diverging clients would.`, | |
| 327 | + dissent: [ | |
| 328 | + { | |
| 329 | + topic: 'Where the default should sit', | |
| 330 | + positions: [ | |
| 331 | + { p: 2, text: 'Would set GraphQL as the default whenever UIs are expected to change quickly over connected data, accepting the operational cost for the velocity.' }, | |
| 332 | + { p: 0, text: 'Keeps REST as the default until the relational-data and diverging-client triggers are clearly met, to avoid paying GraphQL\'s operational tax prematurely.' }, | |
| 333 | + ], | |
| 334 | + }, | |
| 335 | + ], | |
| 336 | +}; | |
| 337 | + | |
| 338 | +export const FIXTURES: FixtureSpec[] = [monolith, restVsGraphql]; |
added src/lib/demo-fixtures.ts +286 −0
| @@ -0,0 +1,286 @@ | ||
| 1 | +/** | |
| 2 | + * Hand-authored, realistic demo debates. | |
| 3 | + * | |
| 4 | + * The deterministic mock client is great for tests but produces near-identical | |
| 5 | + * text for every model, which reads fake in the showcase. These fixtures are | |
| 6 | + * written by hand so each model has a genuinely different answer, the critiques | |
| 7 | + * are specific with varied scores, the revisions actually change, and the | |
| 8 | + * chairman delivers a real synthesized verdict plus an honest dissent. | |
| 9 | + * | |
| 10 | + * `buildDebateResult` expands a compact spec into the exact `DebateResult` shape | |
| 11 | + * a live debate produces, and `resultToStageEvents` turns it into the same | |
| 12 | + * events the persistence layer stores, so the seed writes byte-identical rows | |
| 13 | + * and the demo replays through the real UI. | |
| 14 | + */ | |
| 15 | +import type { DebateEvent } from '@/core/events'; | |
| 16 | +import { | |
| 17 | + displayNameForModel, | |
| 18 | + emptyUsage, | |
| 19 | + type AnswerRecord, | |
| 20 | + type ConvergenceRecord, | |
| 21 | + type CritiqueRecord, | |
| 22 | + type DebateConfig, | |
| 23 | + type DebateResult, | |
| 24 | + type Participant, | |
| 25 | + type PeerReview, | |
| 26 | + type RevisionRecord, | |
| 27 | + type RoundRecord, | |
| 28 | + type SynthesisRecord, | |
| 29 | + type Usage, | |
| 30 | +} from '@/core/types'; | |
| 31 | +import { sumUsage } from '@/core/usage'; | |
| 32 | + | |
| 33 | +// --- authoring format ------------------------------------------------------ | |
| 34 | + | |
| 35 | +interface FixtureReview { | |
| 36 | + p: number; // target participant index | |
| 37 | + score: number; | |
| 38 | + strengths: string[]; | |
| 39 | + weaknesses: string[]; | |
| 40 | + note: string; | |
| 41 | +} | |
| 42 | +interface FixtureCritique { | |
| 43 | + by: number; // reviewer participant index | |
| 44 | + on: FixtureReview[]; | |
| 45 | +} | |
| 46 | +interface FixtureRevision { | |
| 47 | + p: number; | |
| 48 | + content: string; | |
| 49 | + changed: boolean; | |
| 50 | + summary: string; | |
| 51 | + bullets?: string[]; | |
| 52 | +} | |
| 53 | +interface FixtureConvergence { | |
| 54 | + score: number; | |
| 55 | + disagreements: { topic: string; summary: string; positions: { p: number; stance: string }[] }[]; | |
| 56 | +} | |
| 57 | +interface FixtureRound { | |
| 58 | + critiques: FixtureCritique[]; | |
| 59 | + revisions: FixtureRevision[]; | |
| 60 | + convergence: FixtureConvergence; | |
| 61 | +} | |
| 62 | +export interface FixtureSpec { | |
| 63 | + question: string; | |
| 64 | + models: string[]; | |
| 65 | + chairmanModel: string; | |
| 66 | + convergenceModel: string; | |
| 67 | + convergenceThreshold?: number; | |
| 68 | + answers: string[]; | |
| 69 | + rounds: FixtureRound[]; | |
| 70 | + finalAnswer: string; | |
| 71 | + dissent: { topic: string; positions: { p: number; text: string }[] }[]; | |
| 72 | +} | |
| 73 | + | |
| 74 | +// --- cost model ------------------------------------------------------------ | |
| 75 | + | |
| 76 | +const PRICE: Record<string, { p: number; c: number }> = { | |
| 77 | + 'openai/gpt-4o': { p: 2.5e-6, c: 1e-5 }, | |
| 78 | + 'openai/gpt-4o-mini': { p: 1.5e-7, c: 6e-7 }, | |
| 79 | + 'anthropic/claude-3.5-sonnet': { p: 3e-6, c: 1.5e-5 }, | |
| 80 | + 'google/gemini-pro-1.5': { p: 1.25e-6, c: 5e-6 }, | |
| 81 | + 'google/gemini-2.0-flash-001': { p: 1e-7, c: 4e-7 }, | |
| 82 | + 'x-ai/grok-2-1212': { p: 2e-6, c: 1e-5 }, | |
| 83 | + 'deepseek/deepseek-chat': { p: 1.4e-7, c: 2.8e-7 }, | |
| 84 | +}; | |
| 85 | + | |
| 86 | +function usageFor(model: string, promptChars: number, completionChars: number): Usage { | |
| 87 | + const promptTokens = Math.max(60, Math.round(promptChars / 4)); | |
| 88 | + const completionTokens = Math.max(1, Math.round(completionChars / 4)); | |
| 89 | + const price = PRICE[model] ?? { p: 1e-6, c: 3e-6 }; | |
| 90 | + return { | |
| 91 | + promptTokens, | |
| 92 | + completionTokens, | |
| 93 | + totalTokens: promptTokens + completionTokens, | |
| 94 | + costUsd: promptTokens * price.p + completionTokens * price.c, | |
| 95 | + }; | |
| 96 | +} | |
| 97 | + | |
| 98 | +const SYS_PAD = 520; | |
| 99 | +const reviewChars = (r: FixtureReview) => | |
| 100 | + r.strengths.join(' ').length + r.weaknesses.join(' ').length + r.note.length + 24; | |
| 101 | + | |
| 102 | +// --- expansion ------------------------------------------------------------- | |
| 103 | + | |
| 104 | +export function buildDebateResult(spec: FixtureSpec): DebateResult { | |
| 105 | + const models = spec.models; | |
| 106 | + const threshold = spec.convergenceThreshold ?? 85; | |
| 107 | + const participants: Participant[] = models.map((m, i) => ({ | |
| 108 | + id: `p${i}`, | |
| 109 | + model: m, | |
| 110 | + displayName: displayNameForModel(m), | |
| 111 | + })); | |
| 112 | + const config: DebateConfig = { | |
| 113 | + question: spec.question, | |
| 114 | + models, | |
| 115 | + chairmanModel: spec.chairmanModel, | |
| 116 | + convergenceModel: spec.convergenceModel, | |
| 117 | + maxRounds: Math.max(3, spec.rounds.length), | |
| 118 | + convergenceThreshold: threshold, | |
| 119 | + temperature: 0.7, | |
| 120 | + perModelTimeoutMs: 90_000, | |
| 121 | + }; | |
| 122 | + | |
| 123 | + const initialAnswers: AnswerRecord[] = spec.answers.map((content, i) => ({ | |
| 124 | + participantId: `p${i}`, | |
| 125 | + model: models[i]!, | |
| 126 | + round: 0, | |
| 127 | + content, | |
| 128 | + usage: usageFor(models[i]!, spec.question.length + SYS_PAD, content.length), | |
| 129 | + latencyMs: 1400 + i * 450, | |
| 130 | + })); | |
| 131 | + | |
| 132 | + const current = [...spec.answers]; // current answer per participant, for prompt sizing | |
| 133 | + | |
| 134 | + const rounds: RoundRecord[] = spec.rounds.map((r, ri) => { | |
| 135 | + const round = ri + 1; | |
| 136 | + | |
| 137 | + const critiques: CritiqueRecord[] = r.critiques.map((c) => { | |
| 138 | + const peersLen = c.on.reduce((s, rv) => s + (current[rv.p]?.length ?? 0), 0); | |
| 139 | + const compLen = c.on.reduce((s, rv) => s + reviewChars(rv), 24); | |
| 140 | + const reviews: PeerReview[] = c.on.map((rv, j) => ({ | |
| 141 | + label: String.fromCharCode(65 + j), | |
| 142 | + targetParticipantId: `p${rv.p}`, | |
| 143 | + weaknesses: rv.weaknesses, | |
| 144 | + strengths: rv.strengths, | |
| 145 | + score: rv.score, | |
| 146 | + justification: rv.note, | |
| 147 | + })); | |
| 148 | + return { | |
| 149 | + round, | |
| 150 | + reviewerParticipantId: `p${c.by}`, | |
| 151 | + reviewerModel: models[c.by]!, | |
| 152 | + reviews, | |
| 153 | + usage: usageFor(models[c.by]!, spec.question.length + peersLen + SYS_PAD, compLen), | |
| 154 | + latencyMs: 1800 + c.by * 320, | |
| 155 | + }; | |
| 156 | + }); | |
| 157 | + | |
| 158 | + const revisions: RevisionRecord[] = r.revisions.map((rev) => { | |
| 159 | + const incomingLen = critiques | |
| 160 | + .flatMap((cr) => cr.reviews.filter((x) => x.targetParticipantId === `p${rev.p}`)) | |
| 161 | + .reduce((t, x) => t + x.justification.length + x.strengths.join(' ').length + x.weaknesses.join(' ').length, 0); | |
| 162 | + const rec: RevisionRecord = { | |
| 163 | + round, | |
| 164 | + participantId: `p${rev.p}`, | |
| 165 | + model: models[rev.p]!, | |
| 166 | + content: rev.content, | |
| 167 | + changelog: { changed: rev.changed, summary: rev.summary, bullets: rev.bullets ?? [] }, | |
| 168 | + usage: usageFor( | |
| 169 | + models[rev.p]!, | |
| 170 | + spec.question.length + (current[rev.p]?.length ?? 0) + incomingLen + SYS_PAD, | |
| 171 | + rev.content.length + rev.summary.length, | |
| 172 | + ), | |
| 173 | + latencyMs: 1900 + rev.p * 420, | |
| 174 | + }; | |
| 175 | + current[rev.p] = rev.content; | |
| 176 | + return rec; | |
| 177 | + }); | |
| 178 | + | |
| 179 | + const answersLen = current.reduce((s, c) => s + c.length, 0); | |
| 180 | + const convLen = r.convergence.disagreements.reduce( | |
| 181 | + (s, d) => s + d.summary.length + d.topic.length + d.positions.reduce((t, p) => t + p.stance.length, 0), | |
| 182 | + 80, | |
| 183 | + ); | |
| 184 | + const convergence: ConvergenceRecord = { | |
| 185 | + round, | |
| 186 | + model: spec.convergenceModel, | |
| 187 | + score: r.convergence.score, | |
| 188 | + converged: r.convergence.score >= threshold, | |
| 189 | + disagreements: r.convergence.disagreements.map((d) => ({ | |
| 190 | + topic: d.topic, | |
| 191 | + summary: d.summary, | |
| 192 | + positions: d.positions.map((p) => ({ | |
| 193 | + label: String.fromCharCode(65 + p.p), | |
| 194 | + participantId: `p${p.p}`, | |
| 195 | + stance: p.stance, | |
| 196 | + })), | |
| 197 | + })), | |
| 198 | + usage: usageFor(spec.convergenceModel, answersLen + 300, convLen), | |
| 199 | + latencyMs: 820 + ri * 60, | |
| 200 | + }; | |
| 201 | + | |
| 202 | + return { round, critiques, revisions, convergence }; | |
| 203 | + }); | |
| 204 | + | |
| 205 | + const dissentLen = spec.dissent.reduce((s, d) => s + d.positions.reduce((t, p) => t + p.text.length, 0), 0); | |
| 206 | + const synthesis: SynthesisRecord = { | |
| 207 | + model: spec.chairmanModel, | |
| 208 | + finalAnswer: spec.finalAnswer, | |
| 209 | + dissent: spec.dissent.map((d) => ({ | |
| 210 | + topic: d.topic, | |
| 211 | + positions: d.positions.map((p) => ({ participantId: `p${p.p}`, model: models[p.p]!, position: p.text })), | |
| 212 | + })), | |
| 213 | + usage: usageFor( | |
| 214 | + spec.chairmanModel, | |
| 215 | + current.reduce((s, c) => s + c.length, 0) + 400, | |
| 216 | + spec.finalAnswer.length + dissentLen, | |
| 217 | + ), | |
| 218 | + latencyMs: 3200, | |
| 219 | + }; | |
| 220 | + | |
| 221 | + const finalAnswers: AnswerRecord[] = participants.map((p, i) => ({ | |
| 222 | + participantId: p.id, | |
| 223 | + model: p.model, | |
| 224 | + round: rounds.length, | |
| 225 | + content: current[i]!, | |
| 226 | + usage: emptyUsage(), | |
| 227 | + latencyMs: 0, | |
| 228 | + })); | |
| 229 | + | |
| 230 | + const allUsage: Usage[] = [ | |
| 231 | + ...initialAnswers.map((a) => a.usage), | |
| 232 | + ...rounds.flatMap((r) => [ | |
| 233 | + ...r.critiques.map((c) => c.usage), | |
| 234 | + ...r.revisions.map((x) => x.usage), | |
| 235 | + ...(r.convergence ? [r.convergence.usage] : []), | |
| 236 | + ]), | |
| 237 | + synthesis.usage, | |
| 238 | + ]; | |
| 239 | + const totals = sumUsage(allUsage); | |
| 240 | + const costByModel: Record<string, number> = {}; | |
| 241 | + const add = (m: string, u: Usage) => { | |
| 242 | + costByModel[m] = (costByModel[m] ?? 0) + u.costUsd; | |
| 243 | + }; | |
| 244 | + initialAnswers.forEach((a) => add(a.model, a.usage)); | |
| 245 | + rounds.forEach((r) => { | |
| 246 | + r.critiques.forEach((c) => add(c.reviewerModel, c.usage)); | |
| 247 | + r.revisions.forEach((x) => add(x.model, x.usage)); | |
| 248 | + if (r.convergence) add(r.convergence.model, r.convergence.usage); | |
| 249 | + }); | |
| 250 | + add(synthesis.model, synthesis.usage); | |
| 251 | + | |
| 252 | + return { | |
| 253 | + debateId: '', | |
| 254 | + config, | |
| 255 | + participants, | |
| 256 | + status: 'completed', | |
| 257 | + initialAnswers, | |
| 258 | + rounds, | |
| 259 | + synthesis, | |
| 260 | + failures: [], | |
| 261 | + finalAnswers, | |
| 262 | + totals: { | |
| 263 | + costUsd: totals.costUsd, | |
| 264 | + promptTokens: totals.promptTokens, | |
| 265 | + completionTokens: totals.completionTokens, | |
| 266 | + rounds: rounds.length, | |
| 267 | + durationMs: 42_000 + rounds.length * 16_000 + models.length * 2_500, | |
| 268 | + costByModel, | |
| 269 | + }, | |
| 270 | + }; | |
| 271 | +} | |
| 272 | + | |
| 273 | +/** The durable events, in order, that reproduce this result via persistEvent. */ | |
| 274 | +export function resultToStageEvents(result: DebateResult): DebateEvent[] { | |
| 275 | + const events: DebateEvent[] = []; | |
| 276 | + for (const a of result.initialAnswers) events.push({ type: 'answer_completed', round: 0, record: a }); | |
| 277 | + for (const round of result.rounds) { | |
| 278 | + for (const c of round.critiques) events.push({ type: 'critique_completed', round: round.round, record: c }); | |
| 279 | + for (const rev of round.revisions) events.push({ type: 'revision_completed', round: round.round, record: rev }); | |
| 280 | + if (round.convergence) events.push({ type: 'convergence_result', round: round.round, record: round.convergence }); | |
| 281 | + } | |
| 282 | + if (result.synthesis) events.push({ type: 'synthesis_completed', record: result.synthesis }); | |
| 283 | + return events; | |
| 284 | +} | |
| 285 | + | |
| 286 | +export { FIXTURES } from './demo-fixtures.data'; |
added src/lib/env.ts +91 −0
| @@ -0,0 +1,91 @@ | ||
| 1 | +/** | |
| 2 | + * Boot-time environment validation. | |
| 3 | + * | |
| 4 | + * Everything the server reads from `process.env` passes through this Zod schema | |
| 5 | + * exactly once. A misconfigured deployment fails loudly at startup instead of | |
| 6 | + * mysteriously at request time. Set `SKIP_ENV_VALIDATION=1` for `next build` | |
| 7 | + * (no runtime secrets needed to compile) - CI does this. | |
| 8 | + * | |
| 9 | + * This module is server-only; importing it from a client component is a bug and | |
| 10 | + * would leak nothing useful anyway (Next strips it), but keep it server-side. | |
| 11 | + */ | |
| 12 | +import { z } from 'zod'; | |
| 13 | + | |
| 14 | +const booleanish = z | |
| 15 | + .enum(['0', '1', 'true', 'false']) | |
| 16 | + .transform((v) => v === '1' || v === 'true'); | |
| 17 | + | |
| 18 | +const envSchema = z.object({ | |
| 19 | + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), | |
| 20 | + | |
| 21 | + // Persistence | |
| 22 | + DATABASE_URL: z.string().min(1), | |
| 23 | + | |
| 24 | + // BYOK encryption - the one secret required even for public demo mode. | |
| 25 | + ENCRYPTION_KEY: z | |
| 26 | + .string() | |
| 27 | + .min(1) | |
| 28 | + .refine((v) => decodeKeyLength(v) === 32, { | |
| 29 | + message: 'ENCRYPTION_KEY must decode to exactly 32 bytes (base64 or hex). Try: openssl rand -base64 32', | |
| 30 | + }), | |
| 31 | + | |
| 32 | + // Auth (optional - public demo works without login) | |
| 33 | + AUTH_SECRET: z.string().min(1).optional(), | |
| 34 | + AUTH_URL: z.string().url().optional(), | |
| 35 | + AUTH_TRUST_HOST: booleanish.default('true'), | |
| 36 | + AUTH_GITHUB_ID: z.string().optional(), | |
| 37 | + AUTH_GITHUB_SECRET: z.string().optional(), | |
| 38 | + | |
| 39 | + // OpenRouter gateway | |
| 40 | + OPENROUTER_BASE_URL: z.string().url().default('https://openrouter.ai/api/v1'), | |
| 41 | + OPENROUTER_APP_URL: z.string().url().default('http://localhost:3000'), | |
| 42 | + OPENROUTER_APP_TITLE: z.string().default('Roundtable'), | |
| 43 | + | |
| 44 | + // Behavior | |
| 45 | + MOCK_LLM: booleanish.default('0'), | |
| 46 | + RATE_LIMIT_DEBATES_PER_HOUR: z.coerce.number().int().positive().default(20), | |
| 47 | +}); | |
| 48 | + | |
| 49 | +export type Env = z.infer<typeof envSchema>; | |
| 50 | + | |
| 51 | +function decodeKeyLength(value: string): number { | |
| 52 | + try { | |
| 53 | + if (/^[0-9a-fA-F]{64}$/.test(value)) return Buffer.from(value, 'hex').length; | |
| 54 | + return Buffer.from(value, 'base64').length; | |
| 55 | + } catch { | |
| 56 | + return -1; | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +function loadEnv(): Env { | |
| 61 | + if (process.env.SKIP_ENV_VALIDATION === '1') { | |
| 62 | + // Build-time / typecheck: trust the shape, fill defaults where trivial. | |
| 63 | + return { | |
| 64 | + NODE_ENV: (process.env.NODE_ENV as Env['NODE_ENV']) ?? 'development', | |
| 65 | + DATABASE_URL: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/placeholder', | |
| 66 | + ENCRYPTION_KEY: process.env.ENCRYPTION_KEY ?? Buffer.alloc(32).toString('base64'), | |
| 67 | + AUTH_SECRET: process.env.AUTH_SECRET, | |
| 68 | + AUTH_URL: process.env.AUTH_URL, | |
| 69 | + AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST !== 'false', | |
| 70 | + AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID, | |
| 71 | + AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET, | |
| 72 | + OPENROUTER_BASE_URL: process.env.OPENROUTER_BASE_URL ?? 'https://openrouter.ai/api/v1', | |
| 73 | + OPENROUTER_APP_URL: process.env.OPENROUTER_APP_URL ?? 'http://localhost:3000', | |
| 74 | + OPENROUTER_APP_TITLE: process.env.OPENROUTER_APP_TITLE ?? 'Roundtable', | |
| 75 | + MOCK_LLM: process.env.MOCK_LLM === '1' || process.env.MOCK_LLM === 'true', | |
| 76 | + RATE_LIMIT_DEBATES_PER_HOUR: Number(process.env.RATE_LIMIT_DEBATES_PER_HOUR ?? 20), | |
| 77 | + }; | |
| 78 | + } | |
| 79 | + | |
| 80 | + const parsed = envSchema.safeParse(process.env); | |
| 81 | + if (!parsed.success) { | |
| 82 | + const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n'); | |
| 83 | + throw new Error(`Invalid environment configuration:\n${issues}`); | |
| 84 | + } | |
| 85 | + return parsed.data; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export const env: Env = loadEnv(); | |
| 89 | + | |
| 90 | +/** GitHub auth is only usable when both client id and secret are present. */ | |
| 91 | +export const isGithubAuthConfigured = Boolean(env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET && env.AUTH_SECRET); |
added src/lib/export-markdown.ts +96 −0
| @@ -0,0 +1,96 @@ | ||
| 1 | +/** | |
| 2 | + * Render a `DebateResult` as a clean, self-contained Markdown deliberation | |
| 3 | + * report - the downloadable counterpart to the shareable web page. | |
| 4 | + */ | |
| 5 | +import { buildScoreMatrix } from '@/core/scoring'; | |
| 6 | +import type { DebateResult, Participant } from '@/core/types'; | |
| 7 | + | |
| 8 | +export function debateToMarkdown(result: DebateResult): string { | |
| 9 | + const nameOf = nameLookup(result.participants); | |
| 10 | + const lines: string[] = []; | |
| 11 | + const push = (s = '') => lines.push(s); | |
| 12 | + | |
| 13 | + push(`# Roundtable Deliberation Report`); | |
| 14 | + push(); | |
| 15 | + push(`## Question`); | |
| 16 | + push(result.config.question); | |
| 17 | + push(); | |
| 18 | + | |
| 19 | + push(`## Council`); | |
| 20 | + for (const p of result.participants) push(`- **${p.displayName}** \`${p.model}\``); | |
| 21 | + push(`- **Chairman:** \`${result.config.chairmanModel}\``); | |
| 22 | + push(`- **Convergence assessor:** \`${result.config.convergenceModel}\``); | |
| 23 | + push(); | |
| 24 | + push( | |
| 25 | + `_Rounds: ${result.totals.rounds} · Cost: $${result.totals.costUsd.toFixed(4)} · ` + | |
| 26 | + `Tokens: ${result.totals.promptTokens + result.totals.completionTokens} · Status: ${result.status}_`, | |
| 27 | + ); | |
| 28 | + push(); | |
| 29 | + | |
| 30 | + if (result.synthesis) { | |
| 31 | + push(`## Final Answer`); | |
| 32 | + push(`_Synthesized by \`${result.synthesis.model}\`._`); | |
| 33 | + push(); | |
| 34 | + push(result.synthesis.finalAnswer); | |
| 35 | + push(); | |
| 36 | + if (result.synthesis.dissent.length) { | |
| 37 | + push(`### Dissent Report`); | |
| 38 | + for (const d of result.synthesis.dissent) { | |
| 39 | + push(`- **${d.topic}**`); | |
| 40 | + for (const pos of d.positions) push(` - ${nameOf(pos.participantId)}: ${pos.position}`); | |
| 41 | + } | |
| 42 | + push(); | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + push(`## Round 0 - Independent Answers`); | |
| 47 | + for (const a of result.initialAnswers) { | |
| 48 | + push(`### ${nameOf(a.participantId)}`); | |
| 49 | + push(a.content); | |
| 50 | + push(); | |
| 51 | + } | |
| 52 | + | |
| 53 | + for (const round of result.rounds) { | |
| 54 | + push(`## Round ${round.round}`); | |
| 55 | + if (round.convergence) { | |
| 56 | + push(`_Convergence score: ${round.convergence.score}/100 ` + `(${round.convergence.converged ? 'converged' : 'not converged'})._`); | |
| 57 | + push(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + if (round.critiques.length) { | |
| 61 | + push(`### Critique scores`); | |
| 62 | + const matrix = buildScoreMatrix(result.participants, round.critiques); | |
| 63 | + const header = ['reviewer ↓ / target →', ...matrix.order.map(nameOf)]; | |
| 64 | + push(`| ${header.join(' | ')} |`); | |
| 65 | + push(`| ${header.map(() => '---').join(' | ')} |`); | |
| 66 | + for (const reviewer of matrix.order) { | |
| 67 | + const cells = matrix.order.map((target) => { | |
| 68 | + const v = matrix.cells[reviewer]![target]; | |
| 69 | + return v === null ? '-' : String(v); | |
| 70 | + }); | |
| 71 | + push(`| ${nameOf(reviewer)} | ${cells.join(' | ')} |`); | |
| 72 | + } | |
| 73 | + push(); | |
| 74 | + } | |
| 75 | + | |
| 76 | + if (round.revisions.length) { | |
| 77 | + push(`### Revisions`); | |
| 78 | + for (const rev of round.revisions) { | |
| 79 | + push(`#### ${nameOf(rev.participantId)}`); | |
| 80 | + push(`> ${rev.changelog.changed ? 'Revised' : 'Defended original'}: ${rev.changelog.summary}`); | |
| 81 | + push(); | |
| 82 | + push(rev.content); | |
| 83 | + push(); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + push(`---`); | |
| 89 | + push(`_Generated by Roundtable._`); | |
| 90 | + return lines.join('\n'); | |
| 91 | +} | |
| 92 | + | |
| 93 | +function nameLookup(participants: Participant[]): (id: string) => string { | |
| 94 | + const map = new Map(participants.map((p) => [p.id, p.displayName])); | |
| 95 | + return (id: string) => map.get(id) ?? id; | |
| 96 | +} |
added src/lib/model-cache.ts +59 −0
| @@ -0,0 +1,59 @@ | ||
| 1 | +/** | |
| 2 | + * In-memory, TTL-cached OpenRouter model catalog. | |
| 3 | + * | |
| 4 | + * The `/models` endpoint is public (no key needed) and changes slowly, so we | |
| 5 | + * cache it process-wide for 10 minutes. Pricing for cost attribution is derived | |
| 6 | + * from the same catalog. In `MOCK_LLM` mode we serve a small static catalog so | |
| 7 | + * the picker and demo work fully offline. | |
| 8 | + */ | |
| 9 | +import { env } from './env'; | |
| 10 | +import { | |
| 11 | + fetchOpenRouterModels, | |
| 12 | + pricingFromModels, | |
| 13 | + type OpenRouterModel, | |
| 14 | + type PricingMap, | |
| 15 | +} from './openrouter'; | |
| 16 | + | |
| 17 | +const TTL_MS = 10 * 60 * 1000; | |
| 18 | +let cache: { at: number; models: OpenRouterModel[] } | null = null; | |
| 19 | + | |
| 20 | +const MOCK_CATALOG: OpenRouterModel[] = [ | |
| 21 | + mock('openai/gpt-4o', 'GPT-4o', 2.5e-6, 1e-5, 128000), | |
| 22 | + mock('openai/gpt-4o-mini', 'GPT-4o Mini', 1.5e-7, 6e-7, 128000), | |
| 23 | + mock('anthropic/claude-3.5-sonnet', 'Claude 3.5 Sonnet', 3e-6, 1.5e-5, 200000), | |
| 24 | + mock('anthropic/claude-3.5-haiku', 'Claude 3.5 Haiku', 8e-7, 4e-6, 200000), | |
| 25 | + mock('google/gemini-2.0-flash-001', 'Gemini 2.0 Flash', 1e-7, 4e-7, 1000000), | |
| 26 | + mock('google/gemini-pro-1.5', 'Gemini Pro 1.5', 1.25e-6, 5e-6, 2000000), | |
| 27 | + mock('meta-llama/llama-3.3-70b-instruct', 'Llama 3.3 70B', 1.2e-7, 3e-7, 131072), | |
| 28 | + mock('mistralai/mistral-large', 'Mistral Large', 2e-6, 6e-6, 128000), | |
| 29 | + mock('x-ai/grok-2-1212', 'Grok 2', 2e-6, 1e-5, 131072), | |
| 30 | + mock('deepseek/deepseek-chat', 'DeepSeek Chat', 1.4e-7, 2.8e-7, 64000), | |
| 31 | +]; | |
| 32 | + | |
| 33 | +function mock(id: string, name: string, prompt: number, completion: number, ctx: number): OpenRouterModel { | |
| 34 | + return { | |
| 35 | + id, | |
| 36 | + name, | |
| 37 | + description: `${name} (mock catalog entry)`, | |
| 38 | + context_length: ctx, | |
| 39 | + pricing: { prompt: String(prompt), completion: String(completion) }, | |
| 40 | + }; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export async function getModels(): Promise<OpenRouterModel[]> { | |
| 44 | + if (env.MOCK_LLM) return MOCK_CATALOG; | |
| 45 | + if (cache && Date.now() - cache.at < TTL_MS) return cache.models; | |
| 46 | + try { | |
| 47 | + const models = await fetchOpenRouterModels(); | |
| 48 | + cache = { at: Date.now(), models }; | |
| 49 | + return models; | |
| 50 | + } catch { | |
| 51 | + // Fall back to the last good cache, else the mock catalog, so the picker | |
| 52 | + // never hard-fails. | |
| 53 | + return cache?.models ?? MOCK_CATALOG; | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +export async function getPricing(): Promise<PricingMap> { | |
| 58 | + return pricingFromModels(await getModels()); | |
| 59 | +} |
added src/lib/model-visuals.ts +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +/** | |
| 2 | + * Stable per-participant visual identity (color + short tag) so the same model | |
| 3 | + * reads the same across panels, the critique matrix, and the cost chart. | |
| 4 | + */ | |
| 5 | +const HUES = [243, 158, 32, 199, 340, 262]; | |
| 6 | + | |
| 7 | +export function participantColor(index: number): string { | |
| 8 | + const hue = HUES[index % HUES.length]!; | |
| 9 | + return `hsl(${hue} 70% 55%)`; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function participantColorSoft(index: number): string { | |
| 13 | + const hue = HUES[index % HUES.length]!; | |
| 14 | + return `hsl(${hue} 70% 55% / 0.14)`; | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** "A", "B", "C" ... the de-anonymized identity tag shown next to a model. */ | |
| 18 | +export function participantTag(index: number): string { | |
| 19 | + return String.fromCharCode(65 + index); | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function stageColorVar(stage: string): string { | |
| 23 | + switch (stage) { | |
| 24 | + case 'answer': | |
| 25 | + return 'var(--stage-answer)'; | |
| 26 | + case 'critique': | |
| 27 | + return 'var(--stage-critique)'; | |
| 28 | + case 'revision': | |
| 29 | + return 'var(--stage-revision)'; | |
| 30 | + case 'synthesis': | |
| 31 | + return 'var(--stage-synthesis)'; | |
| 32 | + default: | |
| 33 | + return 'var(--primary)'; | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Blue→green scale for a 1-10 critique score. */ | |
| 38 | +export function scoreColor(score: number): string { | |
| 39 | + const t = Math.max(0, Math.min(1, (score - 1) / 9)); | |
| 40 | + const hue = 8 + t * 140; // red-ish (low) → green (high) | |
| 41 | + return `hsl(${hue} 65% 45%)`; | |
| 42 | +} |
added src/lib/openrouter.ts +252 −0
| @@ -0,0 +1,252 @@ | ||
| 1 | +/** | |
| 2 | + * OpenRouter-backed implementation of the core `LlmClient`, plus the REST | |
| 3 | + * helpers the UI needs (model catalog, key validation, credit balance). | |
| 4 | + * | |
| 5 | + * OpenRouter is OpenAI-compatible, so we drive it through the Vercel AI SDK's | |
| 6 | + * OpenAI provider pointed at the OpenRouter base URL. This is the ONLY place in | |
| 7 | + * the server that talks to a model provider; the orchestrator stays oblivious. | |
| 8 | + * | |
| 9 | + * Cost is computed locally from the model catalog's per-token pricing so we can | |
| 10 | + * attribute spend per model per stage without a second API round-trip. | |
| 11 | + */ | |
| 12 | +import { createOpenAI } from '@ai-sdk/openai'; | |
| 13 | +import { generateText, streamText } from 'ai'; | |
| 14 | +import { | |
| 15 | + LlmError, | |
| 16 | + type LlmClient, | |
| 17 | + type LlmMessage, | |
| 18 | + type LlmRequest, | |
| 19 | + type LlmResult, | |
| 20 | + type LlmStreamHandle, | |
| 21 | +} from '@/core/llm-client'; | |
| 22 | +import type { Usage } from '@/core/types'; | |
| 23 | +import { env } from './env'; | |
| 24 | + | |
| 25 | +// --------------------------------------------------------------------------- | |
| 26 | +// Catalog + pricing | |
| 27 | +// --------------------------------------------------------------------------- | |
| 28 | + | |
| 29 | +export interface OpenRouterModel { | |
| 30 | + id: string; | |
| 31 | + name: string; | |
| 32 | + description?: string; | |
| 33 | + context_length?: number; | |
| 34 | + pricing: { prompt: string; completion: string; request?: string; image?: string }; | |
| 35 | + top_provider?: { max_completion_tokens?: number | null }; | |
| 36 | + architecture?: { modality?: string; input_modalities?: string[] }; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export interface ModelPricing { | |
| 40 | + /** USD per prompt token. */ | |
| 41 | + prompt: number; | |
| 42 | + /** USD per completion token. */ | |
| 43 | + completion: number; | |
| 44 | +} | |
| 45 | +export type PricingMap = Map<string, ModelPricing>; | |
| 46 | + | |
| 47 | +function authHeaders(apiKey?: string): Record<string, string> { | |
| 48 | + return { | |
| 49 | + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), | |
| 50 | + 'HTTP-Referer': env.OPENROUTER_APP_URL, | |
| 51 | + 'X-Title': env.OPENROUTER_APP_TITLE, | |
| 52 | + 'Content-Type': 'application/json', | |
| 53 | + }; | |
| 54 | +} | |
| 55 | + | |
| 56 | +async function fetchWithRetry(url: string, init: RequestInit, attempts = 3): Promise<Response> { | |
| 57 | + let lastErr: unknown; | |
| 58 | + for (let i = 0; i < attempts; i++) { | |
| 59 | + try { | |
| 60 | + const res = await fetch(url, init); | |
| 61 | + if (res.status === 429 || res.status >= 500) { | |
| 62 | + if (i < attempts - 1) { | |
| 63 | + await sleep(250 * 2 ** i); | |
| 64 | + continue; | |
| 65 | + } | |
| 66 | + } | |
| 67 | + return res; | |
| 68 | + } catch (err) { | |
| 69 | + lastErr = err; | |
| 70 | + if (i < attempts - 1) await sleep(250 * 2 ** i); | |
| 71 | + } | |
| 72 | + } | |
| 73 | + throw new LlmError(`Network error contacting OpenRouter: ${String(lastErr)}`, { retryable: true, cause: lastErr }); | |
| 74 | +} | |
| 75 | + | |
| 76 | +export async function fetchOpenRouterModels(apiKey?: string): Promise<OpenRouterModel[]> { | |
| 77 | + const res = await fetchWithRetry(`${env.OPENROUTER_BASE_URL}/models`, { | |
| 78 | + method: 'GET', | |
| 79 | + headers: authHeaders(apiKey), | |
| 80 | + }); | |
| 81 | + if (!res.ok) { | |
| 82 | + throw new LlmError(`Failed to fetch model catalog (${res.status})`, { | |
| 83 | + status: res.status, | |
| 84 | + retryable: res.status >= 500, | |
| 85 | + }); | |
| 86 | + } | |
| 87 | + const json = (await res.json()) as { data: OpenRouterModel[] }; | |
| 88 | + return json.data ?? []; | |
| 89 | +} | |
| 90 | + | |
| 91 | +export function pricingFromModels(models: OpenRouterModel[]): PricingMap { | |
| 92 | + const map: PricingMap = new Map(); | |
| 93 | + for (const m of models) { | |
| 94 | + const prompt = Number(m.pricing?.prompt ?? '0'); | |
| 95 | + const completion = Number(m.pricing?.completion ?? '0'); | |
| 96 | + map.set(m.id, { | |
| 97 | + prompt: Number.isFinite(prompt) ? prompt : 0, | |
| 98 | + completion: Number.isFinite(completion) ? completion : 0, | |
| 99 | + }); | |
| 100 | + } | |
| 101 | + return map; | |
| 102 | +} | |
| 103 | + | |
| 104 | +// --------------------------------------------------------------------------- | |
| 105 | +// Key validation / credits | |
| 106 | +// --------------------------------------------------------------------------- | |
| 107 | + | |
| 108 | +export interface KeyValidation { | |
| 109 | + valid: boolean; | |
| 110 | + label?: string; | |
| 111 | + usage?: number; | |
| 112 | + limit?: number | null; | |
| 113 | + limitRemaining?: number | null; | |
| 114 | + isFreeTier?: boolean; | |
| 115 | + error?: string; | |
| 116 | +} | |
| 117 | + | |
| 118 | +export async function validateOpenRouterKey(apiKey: string): Promise<KeyValidation> { | |
| 119 | + try { | |
| 120 | + const res = await fetchWithRetry(`${env.OPENROUTER_BASE_URL}/key`, { | |
| 121 | + method: 'GET', | |
| 122 | + headers: authHeaders(apiKey), | |
| 123 | + }); | |
| 124 | + if (res.status === 401 || res.status === 403) return { valid: false, error: 'Invalid API key' }; | |
| 125 | + if (!res.ok) return { valid: false, error: `OpenRouter returned ${res.status}` }; | |
| 126 | + const json = (await res.json()) as { | |
| 127 | + data?: { label?: string; usage?: number; limit?: number | null; is_free_tier?: boolean }; | |
| 128 | + }; | |
| 129 | + const d = json.data ?? {}; | |
| 130 | + const limit = d.limit ?? null; | |
| 131 | + const usage = d.usage ?? 0; | |
| 132 | + return { | |
| 133 | + valid: true, | |
| 134 | + label: d.label, | |
| 135 | + usage, | |
| 136 | + limit, | |
| 137 | + limitRemaining: limit === null ? null : Math.max(0, limit - usage), | |
| 138 | + isFreeTier: d.is_free_tier, | |
| 139 | + }; | |
| 140 | + } catch (err) { | |
| 141 | + return { valid: false, error: err instanceof Error ? err.message : 'Network error' }; | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 145 | +// --------------------------------------------------------------------------- | |
| 146 | +// LlmClient implementation | |
| 147 | +// --------------------------------------------------------------------------- | |
| 148 | + | |
| 149 | +function toCoreMessages(messages: LlmMessage[]) { | |
| 150 | + return messages.map((m) => ({ role: m.role, content: m.content }) as const); | |
| 151 | +} | |
| 152 | + | |
| 153 | +function buildUsage(model: string, raw: { promptTokens?: number; completionTokens?: number; totalTokens?: number } | undefined, pricing: PricingMap): Usage { | |
| 154 | + const promptTokens = raw?.promptTokens ?? 0; | |
| 155 | + const completionTokens = raw?.completionTokens ?? 0; | |
| 156 | + const totalTokens = raw?.totalTokens ?? promptTokens + completionTokens; | |
| 157 | + const price = pricing.get(model); | |
| 158 | + const costUsd = price ? promptTokens * price.prompt + completionTokens * price.completion : 0; | |
| 159 | + return { promptTokens, completionTokens, totalTokens, costUsd }; | |
| 160 | +} | |
| 161 | + | |
| 162 | +function toLlmError(err: unknown): LlmError { | |
| 163 | + if (err instanceof LlmError) return err; | |
| 164 | + const anyErr = err as { statusCode?: number; status?: number; name?: string; message?: string } | undefined; | |
| 165 | + const status = anyErr?.statusCode ?? anyErr?.status; | |
| 166 | + const retryable = status === 429 || (status !== undefined && status >= 500); | |
| 167 | + return new LlmError(anyErr?.message ?? String(err), { status, retryable, cause: err }); | |
| 168 | +} | |
| 169 | + | |
| 170 | +export interface OpenRouterClientOptions { | |
| 171 | + apiKey: string; | |
| 172 | + pricing?: PricingMap; | |
| 173 | + maxRetries?: number; | |
| 174 | +} | |
| 175 | + | |
| 176 | +export function createOpenRouterClient(opts: OpenRouterClientOptions): LlmClient { | |
| 177 | + const provider = createOpenAI({ | |
| 178 | + baseURL: env.OPENROUTER_BASE_URL, | |
| 179 | + apiKey: opts.apiKey, | |
| 180 | + name: 'openrouter', | |
| 181 | + headers: { | |
| 182 | + 'HTTP-Referer': env.OPENROUTER_APP_URL, | |
| 183 | + 'X-Title': env.OPENROUTER_APP_TITLE, | |
| 184 | + }, | |
| 185 | + }); | |
| 186 | + const pricing = opts.pricing ?? new Map(); | |
| 187 | + const maxRetries = opts.maxRetries ?? 4; | |
| 188 | + | |
| 189 | + return { | |
| 190 | + async complete(req: LlmRequest): Promise<LlmResult> { | |
| 191 | + const t0 = Date.now(); | |
| 192 | + try { | |
| 193 | + const res = await generateText({ | |
| 194 | + model: provider.chat(req.model), | |
| 195 | + messages: toCoreMessages(req.messages), | |
| 196 | + temperature: req.temperature, | |
| 197 | + maxTokens: req.maxTokens, | |
| 198 | + abortSignal: req.signal, | |
| 199 | + maxRetries, | |
| 200 | + }); | |
| 201 | + return { | |
| 202 | + text: res.text, | |
| 203 | + usage: buildUsage(req.model, res.usage, pricing), | |
| 204 | + model: req.model, | |
| 205 | + latencyMs: Date.now() - t0, | |
| 206 | + }; | |
| 207 | + } catch (err) { | |
| 208 | + throw toLlmError(err); | |
| 209 | + } | |
| 210 | + }, | |
| 211 | + | |
| 212 | + async streamComplete(req: LlmRequest): Promise<LlmStreamHandle> { | |
| 213 | + const t0 = Date.now(); | |
| 214 | + const result = streamText({ | |
| 215 | + model: provider.chat(req.model), | |
| 216 | + messages: toCoreMessages(req.messages), | |
| 217 | + temperature: req.temperature, | |
| 218 | + maxTokens: req.maxTokens, | |
| 219 | + abortSignal: req.signal, | |
| 220 | + maxRetries, | |
| 221 | + }); | |
| 222 | + | |
| 223 | + async function* stream(): AsyncGenerator<string> { | |
| 224 | + try { | |
| 225 | + for await (const delta of result.textStream) yield delta; | |
| 226 | + } catch (err) { | |
| 227 | + throw toLlmError(err); | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + const final: Promise<LlmResult> = (async () => { | |
| 232 | + try { | |
| 233 | + const [text, usage] = await Promise.all([result.text, result.usage]); | |
| 234 | + return { | |
| 235 | + text, | |
| 236 | + usage: buildUsage(req.model, usage, pricing), | |
| 237 | + model: req.model, | |
| 238 | + latencyMs: Date.now() - t0, | |
| 239 | + }; | |
| 240 | + } catch (err) { | |
| 241 | + throw toLlmError(err); | |
| 242 | + } | |
| 243 | + })(); | |
| 244 | + | |
| 245 | + return { stream: stream(), result: final }; | |
| 246 | + }, | |
| 247 | + }; | |
| 248 | +} | |
| 249 | + | |
| 250 | +function sleep(ms: number): Promise<void> { | |
| 251 | + return new Promise((resolve) => setTimeout(resolve, ms)); | |
| 252 | +} |
added src/lib/rate-limit.ts +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +/** | |
| 2 | + * In-memory sliding-window rate limiter for the debate-start endpoint. | |
| 3 | + * | |
| 4 | + * This protects the *server* (each debate spawns many concurrent model calls | |
| 5 | + * and DB writes); inference itself is paid by each user's own key. A process- | |
| 6 | + * local map is sufficient for a single-VPS deployment; swap for Redis if the | |
| 7 | + * app is ever horizontally scaled. | |
| 8 | + */ | |
| 9 | +const hits = new Map<string, number[]>(); | |
| 10 | + | |
| 11 | +export interface RateLimitResult { | |
| 12 | + allowed: boolean; | |
| 13 | + remaining: number; | |
| 14 | + limit: number; | |
| 15 | + resetMs: number; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { | |
| 19 | + const now = Date.now(); | |
| 20 | + const windowStart = now - windowMs; | |
| 21 | + const timestamps = (hits.get(key) ?? []).filter((t) => t > windowStart); | |
| 22 | + | |
| 23 | + if (timestamps.length >= limit) { | |
| 24 | + const oldest = timestamps[0]!; | |
| 25 | + hits.set(key, timestamps); | |
| 26 | + return { allowed: false, remaining: 0, limit, resetMs: oldest + windowMs - now }; | |
| 27 | + } | |
| 28 | + | |
| 29 | + timestamps.push(now); | |
| 30 | + hits.set(key, timestamps); | |
| 31 | + return { allowed: true, remaining: limit - timestamps.length, limit, resetMs: windowMs }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** Periodically drop empty buckets so the map can't grow unbounded. */ | |
| 35 | +export function pruneRateLimiter(windowMs: number): void { | |
| 36 | + const cutoff = Date.now() - windowMs; | |
| 37 | + for (const [key, ts] of hits) { | |
| 38 | + const kept = ts.filter((t) => t > cutoff); | |
| 39 | + if (kept.length === 0) hits.delete(key); | |
| 40 | + else hits.set(key, kept); | |
| 41 | + } | |
| 42 | +} |
added src/lib/sse.ts +34 −0
| @@ -0,0 +1,34 @@ | ||
| 1 | +/** | |
| 2 | + * Server-Sent Events plumbing for the debate stream. | |
| 3 | + * | |
| 4 | + * The orchestrator emits a `DebateEvent` union; we serialize each as a named SSE | |
| 5 | + * message so the browser's EventSource-style reader can dispatch on `event:`. | |
| 6 | + * Long-lived (minutes) - heartbeat comments keep intermediaries from closing the | |
| 7 | + * idle connection between slow model calls. | |
| 8 | + */ | |
| 9 | +import type { DebateEvent } from '@/core/events'; | |
| 10 | + | |
| 11 | +export const SSE_HEADERS: Record<string, string> = { | |
| 12 | + 'Content-Type': 'text/event-stream; charset=utf-8', | |
| 13 | + 'Cache-Control': 'no-cache, no-transform', | |
| 14 | + Connection: 'keep-alive', | |
| 15 | + // Disable proxy buffering (nginx) so events flush immediately. | |
| 16 | + 'X-Accel-Buffering': 'no', | |
| 17 | +}; | |
| 18 | + | |
| 19 | +const encoder = new TextEncoder(); | |
| 20 | + | |
| 21 | +/** Encode a debate event as an SSE `event:`/`data:` frame. */ | |
| 22 | +export function encodeEvent(event: DebateEvent): Uint8Array { | |
| 23 | + return encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** A generic named SSE frame (used for `ready`, `error`, `ping`). */ | |
| 27 | +export function encodeNamed(name: string, data: unknown): Uint8Array { | |
| 28 | + return encoder.encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`); | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** Heartbeat comment - ignored by clients, keeps the socket warm. */ | |
| 32 | +export function encodeHeartbeat(): Uint8Array { | |
| 33 | + return encoder.encode(`: ping ${Date.now()}\n\n`); | |
| 34 | +} |
added src/lib/utils.ts +51 −0
| @@ -0,0 +1,51 @@ | ||
| 1 | +import { clsx, type ClassValue } from 'clsx'; | |
| 2 | +import { twMerge } from 'tailwind-merge'; | |
| 3 | + | |
| 4 | +/** shadcn/ui class combiner: clsx semantics + Tailwind conflict resolution. */ | |
| 5 | +export function cn(...inputs: ClassValue[]): string { | |
| 6 | + return twMerge(clsx(inputs)); | |
| 7 | +} | |
| 8 | + | |
| 9 | +/** Format a USD cost with adaptive precision (fractions of a cent stay legible). */ | |
| 10 | +export function formatUsd(amount: number): string { | |
| 11 | + if (amount === 0) return '$0.00'; | |
| 12 | + if (amount < 0.01) return `$${amount.toFixed(4)}`; | |
| 13 | + if (amount < 1) return `$${amount.toFixed(3)}`; | |
| 14 | + return `$${amount.toFixed(2)}`; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function formatTokens(n: number): string { | |
| 18 | + if (n < 1000) return String(n); | |
| 19 | + if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`; | |
| 20 | + return `${(n / 1_000_000).toFixed(1)}M`; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function formatLatency(ms: number): string { | |
| 24 | + if (ms < 1000) return `${Math.round(ms)}ms`; | |
| 25 | + return `${(ms / 1000).toFixed(1)}s`; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export function formatDuration(ms: number): string { | |
| 29 | + const s = Math.round(ms / 1000); | |
| 30 | + if (s < 60) return `${s}s`; | |
| 31 | + const m = Math.floor(s / 60); | |
| 32 | + return `${m}m ${s % 60}s`; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function formatRelativeTime(date: Date | string): string { | |
| 36 | + const d = typeof date === 'string' ? new Date(date) : date; | |
| 37 | + const diff = Date.now() - d.getTime(); | |
| 38 | + const mins = Math.floor(diff / 60_000); | |
| 39 | + if (mins < 1) return 'just now'; | |
| 40 | + if (mins < 60) return `${mins}m ago`; | |
| 41 | + const hours = Math.floor(mins / 60); | |
| 42 | + if (hours < 24) return `${hours}h ago`; | |
| 43 | + const days = Math.floor(hours / 24); | |
| 44 | + if (days < 30) return `${days}d ago`; | |
| 45 | + return d.toLocaleDateString(); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** Truncate to a max length with an ellipsis. */ | |
| 49 | +export function truncate(s: string, max: number): string { | |
| 50 | + return s.length <= max ? s : `${s.slice(0, max - 1)}...`; | |
| 51 | +} |
added src/types/next-auth.d.ts +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +import type { DefaultSession } from 'next-auth'; | |
| 2 | + | |
| 3 | +declare module 'next-auth' { | |
| 4 | + interface Session { | |
| 5 | + user: { | |
| 6 | + id: string; | |
| 7 | + } & DefaultSession['user']; | |
| 8 | + } | |
| 9 | +} |