TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import type { ArenaResponse, ArenaSession, ArenaVote, PolyModel, ProviderId } from "@/lib/client/types";2import { isFastModel } from "@/components/chat/model-badges";3import { computeWinner, normalizeOrder, type WinnerResult } from "@/lib/arena/scoring";45/** Wire shapes: the API serialises `createdAt` as an ISO string. */6export type ArenaResponseDto = Omit<ArenaResponse, "createdAt"> & { createdAt: string };7export type ArenaVoteDto = Omit<ArenaVote, "createdAt"> & { createdAt: string };8export type ArenaSessionDto = Omit<ArenaSession, "createdAt"> & { createdAt: string; responses: ArenaResponseDto[]; votes: ArenaVoteDto[] };910export type ColumnStatus = "idle" | "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped";1112export interface ColumnState {13 modelKey: string;14 responseId: string | null;15 status: ColumnStatus;16 text: string;17 reasoning: string;18 citations: { url?: string; title?: string; snippet?: string }[];19 serverTools: { name: string; status: string }[];20 response: ArenaResponseDto | null;21 error: { code: string; message: string } | null;22 startedAt: number;23 firstTokenAt?: number;24}2526export const MAX_MODELS = 4;2728/** Legacy rating keys (still written for backward compatibility — see `ratingKeyFor`). */29export const RATINGS: { key: string; label: string; short: string }[] = [30 { key: "best", label: "Best overall", short: "Best" },31 { key: "bestSpeed", label: "Fastest (subjective)", short: "Fastest" },32 { key: "bestReasoning", label: "Best reasoning", short: "Reasoning" },33 { key: "bestCoding", label: "Best coding", short: "Coding" },34 { key: "bestWriting", label: "Best writing", short: "Writing" },35];3637export function emptyColumn(modelKey: string): ColumnState {38 return { modelKey, responseId: null, status: "idle", text: "", reasoning: "", citations: [], serverTools: [], response: null, error: null, startedAt: Date.now() };39}4041/** Build a column from a persisted response (history “Open”). */42export function columnFromResponse(r: ArenaResponseDto): ColumnState {43 const status: ColumnStatus = r.status === "complete" ? "done" : r.status === "error" ? "error" : "stopped";44 return { modelKey: r.modelKey, responseId: r.id, status, text: r.content ?? "", reasoning: r.reasoning ?? "", citations: [], serverTools: [], response: r, error: r.error ?? null, startedAt: new Date(r.createdAt).getTime() };45}4647export interface ColumnMetrics {48 ttftMs: number | null;49 latencyMs: number | null;50 inputTokens: number | null;51 outputTokens: number | null;52 reasoningTokens: number | null;53 cachedTokens: number | null;54 tokensPerSecond: number | null;55 costUsd: number | null;56}5758export function metricsOf(r: ArenaResponseDto | null): ColumnMetrics | null {59 if (!r) return null;60 const u = (r.usage ?? null) as { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } | null;61 const out = u?.outputTokens ?? null;62 const gen = r.latencyMs !== null && r.latencyMs !== undefined ? Math.max(1, r.latencyMs - (r.ttftMs ?? 0)) : null;63 return {64 ttftMs: r.ttftMs ?? null,65 latencyMs: r.latencyMs ?? null,66 inputTokens: u?.inputTokens ?? null,67 outputTokens: out,68 reasoningTokens: u?.reasoningTokens ?? null,69 cachedTokens: u?.cachedInputTokens ?? null,70 tokensPerSecond: out && gen ? Math.round((out / gen) * 1000) : null,71 costUsd: r.costUsd ?? null,72 };73}7475export function isFinal(s: ColumnStatus): boolean {76 return s === "done" || s === "error" || s === "stopped";77}7879/** Winners (fastest TTFT, cheapest) once every column has settled. Requires at least two comparable columns. */80export function computeWinners(columns: ColumnState[]): { fastest: string | null; cheapest: string | null } {81 if (!columns.length || !columns.every((c) => isFinal(c.status))) return { fastest: null, cheapest: null };82 const done = columns.filter((c) => c.status === "done" && c.response);83 const pick = (get: (c: ColumnState) => number | null | undefined) => {84 const cands = done.map((c) => ({ key: c.modelKey, v: get(c) })).filter((x): x is { key: string; v: number } => typeof x.v === "number" && Number.isFinite(x.v));85 if (cands.length < 2) return null;86 cands.sort((a, b) => a.v - b.v);87 return cands[0].v === cands[1].v ? null : cands[0].key;88 };89 return { fastest: pick((c) => c.response?.ttftMs), cheapest: pick((c) => c.response?.costUsd) };90}9192// ---------------------------------------------------------------------------93// Session helpers (history, exports, deep links)94// ---------------------------------------------------------------------------95export function sessionBlind(s: Pick<ArenaSessionDto, "settings">): boolean {96 return (s.settings as { blind?: unknown } | null)?.blind === true;97}9899/** Display permutation for Blind Arena (identity when absent/invalid). */100export function sessionOrder(s: Pick<ArenaSessionDto, "settings" | "modelKeys">): number[] {101 return normalizeOrder((s.settings as { blindOrder?: unknown } | null)?.blindOrder, s.modelKeys.length);102}103104export function sessionAttachmentCount(s: Pick<ArenaSessionDto, "settings">): number {105 const ids = (s.settings as { attachmentIds?: unknown } | null)?.attachmentIds;106 return Array.isArray(ids) ? ids.length : 0;107}108109export function sessionWinner(s: Pick<ArenaSessionDto, "responses" | "votes">): WinnerResult | null {110 const votes = s.votes ?? [];111 if (votes.length) return computeWinner(s.responses, votes);112 // Legacy sessions: fall back to the exclusive "best" rating.113 const best = s.responses.find((r) => r.ratings?.best);114 return best ? { modelKey: best.modelKey, responseId: best.id, criteriaWon: ["best"], votes: 1, tieBreak: null, deltas: { costUsd: null, ttftMs: null, latencyMs: null, outputTokens: null, others: 0 } } : null;115}116117export function sessionCost(s: Pick<ArenaSessionDto, "responses">): number {118 return s.responses.reduce((acc, r) => acc + (r.costUsd ?? 0), 0);119}120121// ---------------------------------------------------------------------------122// Presets123// ---------------------------------------------------------------------------124125function weight(m: PolyModel): number {126 return Number((m.metadata as { sortWeight?: unknown } | undefined)?.sortWeight ?? 0);127}128129/** One model per connected provider (ordered by sortWeight), then fill up to MAX_MODELS with the remaining candidates. */130function pickDiverse(candidates: PolyModel[], connected: Set<ProviderId>): string[] {131 const usable = candidates.filter((m) => connected.has(m.provider) && m.status !== "deprecated").sort((a, b) => weight(b) - weight(a));132 const perProvider = new Map<ProviderId, PolyModel>();133 for (const m of usable) if (!perProvider.has(m.provider)) perProvider.set(m.provider, m);134 const picked = [...perProvider.values()].slice(0, MAX_MODELS);135 for (const m of usable) {136 if (picked.length >= MAX_MODELS) break;137 if (!picked.includes(m)) picked.push(m);138 }139 return picked.map((m) => m.key);140}141142export type PresetId = "flagships" | "fast" | "reasoning";143144export function presetModels(id: PresetId, models: PolyModel[], connected: Set<ProviderId>): string[] {145 switch (id) {146 case "flagships":147 return pickDiverse(models, connected).slice(0, Math.min(MAX_MODELS, connected.size || MAX_MODELS));148 case "fast":149 return pickDiverse(models.filter(isFastModel), connected);150 case "reasoning":151 return pickDiverse(models.filter((m) => m.capabilities.reasoning), connected);152 }153}154155// ---------------------------------------------------------------------------156// Shared settings: a synthetic model whose sheet is the union of the selection157// ---------------------------------------------------------------------------158159export function buildSharedModel(models: PolyModel[]): PolyModel | undefined {160 if (!models.length) return undefined;161 const caps = { ...models[0].capabilities };162 const params: PolyModel["parameters"] = {};163 const levels: string[] = [];164 let budget: { min: number; max: number } | undefined;165 let temp: { min: number; max: number } | undefined;166 let maxOut = 0;167 for (const m of models) {168 for (const k of Object.keys(caps) as (keyof PolyModel["capabilities"])[]) caps[k] = caps[k] || m.capabilities[k];169 const p = m.parameters;170 for (const k of ["temperature", "topP", "topK", "maxTokens", "reasoningEffort", "thinkingBudget", "stop", "seed", "frequencyPenalty", "presencePenalty", "verbosity"] as const) if (p[k]) params[k] = true;171 for (const l of p.reasoningEffortLevels ?? []) if (!levels.includes(l)) levels.push(l);172 if (p.thinkingBudgetRange) budget = budget ? { min: Math.min(budget.min, p.thinkingBudgetRange.min), max: Math.max(budget.max, p.thinkingBudgetRange.max) } : { ...p.thinkingBudgetRange };173 if (p.temperatureRange) temp = temp ? { min: Math.min(temp.min, p.temperatureRange.min), max: Math.max(temp.max, p.temperatureRange.max) } : { ...p.temperatureRange };174 maxOut = Math.max(maxOut, m.limits?.maxOutputTokens ?? 0);175 }176 if (levels.length) params.reasoningEffortLevels = levels;177 if (budget) params.thinkingBudgetRange = budget;178 if (temp) params.temperatureRange = temp;179 // Built-in PolyLLM tools are not run in the Arena (the server strips `tools`), so hide those controls.180 caps.tools = false;181 return {182 key: "arena/shared",183 id: "shared",184 provider: models[0].provider,185 displayName: models.length === 1 ? models[0].displayName : `${models.length} models (shared)`,186 capabilities: caps,187 parameters: params,188 limits: maxOut ? { maxOutputTokens: maxOut } : undefined,189 status: "active",190 pricing: null,191 metadata: models.length === 1 ? { ...models[0].metadata } : {},192 };193}194195/** Composer stand-in: attachments are accepted when at least one model can read them; PDFs need `files` everywhere. */196export function buildComposerModel(models: PolyModel[], count: number): PolyModel | undefined {197 if (!models.length) return undefined;198 const base = buildSharedModel(models)!;199 return {200 ...base,201 displayName: models.length === 1 ? models[0].displayName : "Some selected models",202 capabilities: { ...base.capabilities, vision: models.some((m) => m.capabilities.vision), files: models.every((m) => m.capabilities.files) },203 metadata: { ...base.metadata, arenaCount: count },204 };205}206