TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import { computeWinner, criterionById, type ResponseLike, type VoteLike } from "./scoring";23/**4 * Arena exports (Markdown / JSON) and the public share snapshot. Pure functions over plain data so the5 * API route, the share page and the unit tests share one implementation.6 */7export interface ExportModelInfo {8 displayName: string;9 provider: string;10}1112export interface ArenaExportResponse extends ResponseLike {13 provider: string;14 content: string;15 reasoning: string | null;16 error: { code: string; message: string } | null;17 ratings: Record<string, boolean>;18 createdAt: string;19}2021export interface ArenaExportVote extends VoteLike {22 category: string | null;23 createdAt: string;24}2526export interface ArenaExportInput {27 session: {28 id: string;29 prompt: string;30 systemPrompt: string | null;31 modelKeys: string[];32 settings: Record<string, unknown>;33 createdAt: string;34 };35 responses: ArenaExportResponse[];36 votes: ArenaExportVote[];37 models: Record<string, ExportModelInfo>;38}3940/** Generation parameters only (the session settings also carry attachment ids and Arena flags). */41export function generationParameters(settings: Record<string, unknown>): Record<string, unknown> {42 const { attachmentIds: _a, blind: _b, blindOrder: _o, tools: _t, ...rest } = settings ?? {};43 void _a;44 void _b;45 void _o;46 void _t;47 return rest;48}4950export function attachmentCount(settings: Record<string, unknown>): number {51 const ids = (settings as { attachmentIds?: unknown })?.attachmentIds;52 return Array.isArray(ids) ? ids.length : 0;53}5455export function isBlind(settings: Record<string, unknown>): boolean {56 return (settings as { blind?: unknown })?.blind === true;57}5859export function modelName(key: string, models: Record<string, ExportModelInfo>): string {60 return models[key]?.displayName ?? key.split("/").slice(1).join("/") ?? key;61}6263function tokensPerSecond(r: ResponseLike): number | null {64 const out = r.usage?.outputTokens;65 if (!out || r.latencyMs === null) return null;66 const gen = Math.max(1, r.latencyMs - (r.ttftMs ?? 0));67 return Math.round((out / gen) * 1000);68}6970function fmtMs(ms: number | null | undefined): string {71 if (ms === null || ms === undefined) return "—";72 return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;73}7475function fmtUsd(v: number | null | undefined): string {76 if (v === null || v === undefined) return "—";77 return v < 0.01 ? `$${v.toFixed(v < 0.001 ? 5 : 4)}` : `$${v.toFixed(2)}`;78}7980function fmtInt(v: number | null | undefined): string {81 return typeof v === "number" ? new Intl.NumberFormat("en-US").format(v) : "—";82}8384export function arenaExportFilename(prompt: string, ext: "md" | "json"): string {85 const slug =86 prompt87 .normalize("NFD")88 .replace(/[̀-ͯ]/g, "")89 .toLowerCase()90 .replace(/[^a-z0-9]+/g, "-")91 .replace(/^-+|-+$/g, "")92 .slice(0, 48) || "arena";93 return `arena-${slug}.${ext}`;94}9596export function buildArenaJson(input: ArenaExportInput): string {97 const { session, responses, votes, models } = input;98 const winner = computeWinner(responses, votes);99 return JSON.stringify(100 {101 exportedAt: new Date().toISOString(),102 app: "PolyLLM",103 kind: "arena-session",104 version: 1,105 session: {106 id: session.id,107 createdAt: session.createdAt,108 prompt: session.prompt,109 systemPrompt: session.systemPrompt,110 blind: isBlind(session.settings),111 attachmentCount: attachmentCount(session.settings),112 parameters: generationParameters(session.settings),113 },114 models: session.modelKeys.map((key) => ({ key, provider: models[key]?.provider ?? key.split("/")[0], displayName: modelName(key, models) })),115 responses: responses.map((r) => ({116 id: r.id,117 modelKey: r.modelKey,118 provider: r.provider,119 displayName: modelName(r.modelKey, models),120 status: r.status,121 content: r.content,122 reasoning: r.reasoning,123 error: r.error,124 metrics: { ttftMs: r.ttftMs, latencyMs: r.latencyMs, tokensPerSecond: tokensPerSecond(r), usage: r.usage, costUsd: r.costUsd },125 ratings: r.ratings,126 criteriaWon: votes.filter((v) => v.responseId === r.id).map((v) => v.criterion),127 createdAt: r.createdAt,128 })),129 votes: votes.map((v) => ({ criterion: v.criterion, label: criterionById(v.criterion).label, responseId: v.responseId, modelKey: v.modelKey, category: v.category, createdAt: v.createdAt })),130 winner: winner ? { modelKey: winner.modelKey, displayName: modelName(winner.modelKey, models), criteriaWon: winner.criteriaWon, tieBreak: winner.tieBreak, deltas: winner.deltas } : null,131 },132 null,133 2,134 );135}136137export function buildArenaMarkdown(input: ArenaExportInput): string {138 const { session, responses, votes, models } = input;139 const winner = computeWinner(responses, votes);140 const params = generationParameters(session.settings);141 const lines: string[] = [];142 lines.push(`# Arena comparison — ${session.modelKeys.length} model${session.modelKeys.length === 1 ? "" : "s"}`, "");143 lines.push(`_Exported from PolyLLM on ${new Date().toISOString().slice(0, 10)} · session created ${session.createdAt.slice(0, 16).replace("T", " ")} UTC_`, "");144 if (isBlind(session.settings)) lines.push("> Blind Arena: model identities were hidden until the vote.", "");145 lines.push("## Prompt", "", session.prompt, "");146 if (session.systemPrompt) lines.push("**System prompt**", "", "> " + session.systemPrompt.replace(/\n/g, "\n> "), "");147 const n = attachmentCount(session.settings);148 if (n) lines.push(`_${n} attachment${n === 1 ? "" : "s"} (not included in the export)_`, "");149150 lines.push("## Models & parameters", "");151 lines.push("| Model | Provider |", "| --- | --- |");152 for (const key of session.modelKeys) lines.push(`| ${modelName(key, models)} | ${models[key]?.provider ?? key.split("/")[0]} |`);153 lines.push("");154 const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== null);155 if (entries.length) {156 lines.push("Shared generation settings (filtered per model — unsupported parameters are not sent):", "");157 for (const [k, v] of entries) lines.push(`- \`${k}\`: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`);158 lines.push("");159 } else lines.push("Default generation settings.", "");160161 lines.push("## Metrics", "");162 lines.push("| Model | Status | TTFT | Total | Tokens in → out | tok/s | Est. cost |", "| --- | --- | --- | --- | --- | --- | --- |");163 for (const r of responses) {164 const tps = tokensPerSecond(r);165 lines.push(`| ${modelName(r.modelKey, models)} | ${r.status} | ${fmtMs(r.ttftMs)} | ${fmtMs(r.latencyMs)} | ${fmtInt(r.usage?.inputTokens)} → ${fmtInt(r.usage?.outputTokens)} | ${tps ?? "—"} | ${fmtUsd(r.costUsd)} |`);166 }167 lines.push("");168169 lines.push("## Votes", "");170 if (!votes.length) lines.push("_No votes recorded._", "");171 else {172 for (const v of votes) lines.push(`- **${criterionById(v.criterion).label}** → ${modelName(v.modelKey, models)}`);173 lines.push("");174 if (winner) {175 lines.push(`**Arena Winner: ${modelName(winner.modelKey, models)}** — won ${winner.criteriaWon.length} criteri${winner.criteriaWon.length === 1 ? "on" : "a"}${winner.tieBreak === "fastest" ? " (tie broken by speed)" : ""}.`, "");176 }177 }178 const legacy = responses.filter((r) => Object.values(r.ratings ?? {}).some(Boolean));179 if (legacy.length) {180 lines.push("Legacy ratings:", "");181 for (const r of legacy) lines.push(`- ${modelName(r.modelKey, models)}: ${Object.entries(r.ratings).filter(([, on]) => on).map(([k]) => k).join(", ")}`);182 lines.push("");183 }184185 lines.push("## Responses", "");186 for (const r of responses) {187 lines.push(`### ${modelName(r.modelKey, models)} (${r.provider})`, "");188 if (r.reasoning) lines.push("<details><summary>Reasoning</summary>", "", r.reasoning, "", "</details>", "");189 if (r.error) lines.push(`> **Error ${r.error.code}:** ${r.error.message}`, "");190 lines.push(r.content || (r.status === "stopped" ? "_Stopped before any output._" : "_Empty response._"), "");191 }192 return lines.join("\n");193}194195// ---------------------------------------------------------------------------196// Public share snapshot197// ---------------------------------------------------------------------------198export interface ArenaShareSnapshot {199 version: 1;200 prompt: string;201 systemPrompt: string | null;202 blind: boolean;203 attachmentCount: number;204 parameters: Record<string, unknown>;205 createdAt: string;206 models: { key: string; provider: string; displayName: string }[];207 responses: {208 id: string;209 modelKey: string;210 provider: string;211 displayName: string;212 status: string;213 content: string;214 reasoning: string | null;215 error: { code: string; message: string } | null;216 ttftMs: number | null;217 latencyMs: number | null;218 costUsd: number | null;219 usage: { inputTokens?: number; outputTokens?: number; reasoningTokens?: number } | null;220 criteriaWon: string[];221 }[];222 votes: { criterion: string; label: string; modelKey: string; responseId: string }[];223 winner: { modelKey: string; responseId: string; criteriaWon: string[]; tieBreak: "fastest" | "order" | null; deltas: { costUsd: number | null; ttftMs: number | null; latencyMs: number | null; outputTokens: number | null; others: number } } | null;224}225226export function buildArenaShareSnapshot(input: ArenaExportInput): ArenaShareSnapshot {227 const { session, responses, votes, models } = input;228 const winner = computeWinner(responses, votes);229 return {230 version: 1,231 prompt: session.prompt,232 systemPrompt: session.systemPrompt,233 blind: isBlind(session.settings),234 attachmentCount: attachmentCount(session.settings),235 parameters: generationParameters(session.settings),236 createdAt: session.createdAt,237 models: session.modelKeys.map((key) => ({ key, provider: models[key]?.provider ?? key.split("/")[0], displayName: modelName(key, models) })),238 responses: responses.map((r) => ({239 id: r.id,240 modelKey: r.modelKey,241 provider: r.provider,242 displayName: modelName(r.modelKey, models),243 status: r.status,244 content: r.content,245 reasoning: r.reasoning,246 error: r.error,247 ttftMs: r.ttftMs,248 latencyMs: r.latencyMs,249 costUsd: r.costUsd,250 usage: r.usage ? { inputTokens: r.usage.inputTokens, outputTokens: r.usage.outputTokens, reasoningTokens: (r.usage as { reasoningTokens?: number }).reasoningTokens } : null,251 criteriaWon: votes.filter((v) => v.responseId === r.id).map((v) => v.criterion),252 })),253 votes: votes.map((v) => ({ criterion: v.criterion, label: criterionById(v.criterion).label, modelKey: v.modelKey, responseId: v.responseId })),254 winner: winner ? { modelKey: winner.modelKey, responseId: winner.responseId, criteriaWon: winner.criteriaWon, tieBreak: winner.tieBreak, deltas: winner.deltas } : null,255 };256}257