SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
10.8 KB · 257 lines typescript
Raw Blame History
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