import type { ArenaResponse, ArenaSession, ArenaVote, PolyModel, ProviderId } from "@/lib/client/types"; import { isFastModel } from "@/components/chat/model-badges"; import { computeWinner, normalizeOrder, type WinnerResult } from "@/lib/arena/scoring"; /** Wire shapes: the API serialises `createdAt` as an ISO string. */ export type ArenaResponseDto = Omit & { createdAt: string }; export type ArenaVoteDto = Omit & { createdAt: string }; export type ArenaSessionDto = Omit & { createdAt: string; responses: ArenaResponseDto[]; votes: ArenaVoteDto[] }; export type ColumnStatus = "idle" | "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped"; export interface ColumnState { modelKey: string; responseId: string | null; status: ColumnStatus; text: string; reasoning: string; citations: { url?: string; title?: string; snippet?: string }[]; serverTools: { name: string; status: string }[]; response: ArenaResponseDto | null; error: { code: string; message: string } | null; startedAt: number; firstTokenAt?: number; } export const MAX_MODELS = 4; /** Legacy rating keys (still written for backward compatibility — see `ratingKeyFor`). */ export const RATINGS: { key: string; label: string; short: string }[] = [ { key: "best", label: "Best overall", short: "Best" }, { key: "bestSpeed", label: "Fastest (subjective)", short: "Fastest" }, { key: "bestReasoning", label: "Best reasoning", short: "Reasoning" }, { key: "bestCoding", label: "Best coding", short: "Coding" }, { key: "bestWriting", label: "Best writing", short: "Writing" }, ]; export function emptyColumn(modelKey: string): ColumnState { return { modelKey, responseId: null, status: "idle", text: "", reasoning: "", citations: [], serverTools: [], response: null, error: null, startedAt: Date.now() }; } /** Build a column from a persisted response (history “Open”). */ export function columnFromResponse(r: ArenaResponseDto): ColumnState { const status: ColumnStatus = r.status === "complete" ? "done" : r.status === "error" ? "error" : "stopped"; 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() }; } export interface ColumnMetrics { ttftMs: number | null; latencyMs: number | null; inputTokens: number | null; outputTokens: number | null; reasoningTokens: number | null; cachedTokens: number | null; tokensPerSecond: number | null; costUsd: number | null; } export function metricsOf(r: ArenaResponseDto | null): ColumnMetrics | null { if (!r) return null; const u = (r.usage ?? null) as { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } | null; const out = u?.outputTokens ?? null; const gen = r.latencyMs !== null && r.latencyMs !== undefined ? Math.max(1, r.latencyMs - (r.ttftMs ?? 0)) : null; return { ttftMs: r.ttftMs ?? null, latencyMs: r.latencyMs ?? null, inputTokens: u?.inputTokens ?? null, outputTokens: out, reasoningTokens: u?.reasoningTokens ?? null, cachedTokens: u?.cachedInputTokens ?? null, tokensPerSecond: out && gen ? Math.round((out / gen) * 1000) : null, costUsd: r.costUsd ?? null, }; } export function isFinal(s: ColumnStatus): boolean { return s === "done" || s === "error" || s === "stopped"; } /** Winners (fastest TTFT, cheapest) once every column has settled. Requires at least two comparable columns. */ export function computeWinners(columns: ColumnState[]): { fastest: string | null; cheapest: string | null } { if (!columns.length || !columns.every((c) => isFinal(c.status))) return { fastest: null, cheapest: null }; const done = columns.filter((c) => c.status === "done" && c.response); const pick = (get: (c: ColumnState) => number | null | undefined) => { 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)); if (cands.length < 2) return null; cands.sort((a, b) => a.v - b.v); return cands[0].v === cands[1].v ? null : cands[0].key; }; return { fastest: pick((c) => c.response?.ttftMs), cheapest: pick((c) => c.response?.costUsd) }; } // --------------------------------------------------------------------------- // Session helpers (history, exports, deep links) // --------------------------------------------------------------------------- export function sessionBlind(s: Pick): boolean { return (s.settings as { blind?: unknown } | null)?.blind === true; } /** Display permutation for Blind Arena (identity when absent/invalid). */ export function sessionOrder(s: Pick): number[] { return normalizeOrder((s.settings as { blindOrder?: unknown } | null)?.blindOrder, s.modelKeys.length); } export function sessionAttachmentCount(s: Pick): number { const ids = (s.settings as { attachmentIds?: unknown } | null)?.attachmentIds; return Array.isArray(ids) ? ids.length : 0; } export function sessionWinner(s: Pick): WinnerResult | null { const votes = s.votes ?? []; if (votes.length) return computeWinner(s.responses, votes); // Legacy sessions: fall back to the exclusive "best" rating. const best = s.responses.find((r) => r.ratings?.best); 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; } export function sessionCost(s: Pick): number { return s.responses.reduce((acc, r) => acc + (r.costUsd ?? 0), 0); } // --------------------------------------------------------------------------- // Presets // --------------------------------------------------------------------------- function weight(m: PolyModel): number { return Number((m.metadata as { sortWeight?: unknown } | undefined)?.sortWeight ?? 0); } /** One model per connected provider (ordered by sortWeight), then fill up to MAX_MODELS with the remaining candidates. */ function pickDiverse(candidates: PolyModel[], connected: Set): string[] { const usable = candidates.filter((m) => connected.has(m.provider) && m.status !== "deprecated").sort((a, b) => weight(b) - weight(a)); const perProvider = new Map(); for (const m of usable) if (!perProvider.has(m.provider)) perProvider.set(m.provider, m); const picked = [...perProvider.values()].slice(0, MAX_MODELS); for (const m of usable) { if (picked.length >= MAX_MODELS) break; if (!picked.includes(m)) picked.push(m); } return picked.map((m) => m.key); } export type PresetId = "flagships" | "fast" | "reasoning"; export function presetModels(id: PresetId, models: PolyModel[], connected: Set): string[] { switch (id) { case "flagships": return pickDiverse(models, connected).slice(0, Math.min(MAX_MODELS, connected.size || MAX_MODELS)); case "fast": return pickDiverse(models.filter(isFastModel), connected); case "reasoning": return pickDiverse(models.filter((m) => m.capabilities.reasoning), connected); } } // --------------------------------------------------------------------------- // Shared settings: a synthetic model whose sheet is the union of the selection // --------------------------------------------------------------------------- export function buildSharedModel(models: PolyModel[]): PolyModel | undefined { if (!models.length) return undefined; const caps = { ...models[0].capabilities }; const params: PolyModel["parameters"] = {}; const levels: string[] = []; let budget: { min: number; max: number } | undefined; let temp: { min: number; max: number } | undefined; let maxOut = 0; for (const m of models) { for (const k of Object.keys(caps) as (keyof PolyModel["capabilities"])[]) caps[k] = caps[k] || m.capabilities[k]; const p = m.parameters; for (const k of ["temperature", "topP", "topK", "maxTokens", "reasoningEffort", "thinkingBudget", "stop", "seed", "frequencyPenalty", "presencePenalty", "verbosity"] as const) if (p[k]) params[k] = true; for (const l of p.reasoningEffortLevels ?? []) if (!levels.includes(l)) levels.push(l); if (p.thinkingBudgetRange) budget = budget ? { min: Math.min(budget.min, p.thinkingBudgetRange.min), max: Math.max(budget.max, p.thinkingBudgetRange.max) } : { ...p.thinkingBudgetRange }; if (p.temperatureRange) temp = temp ? { min: Math.min(temp.min, p.temperatureRange.min), max: Math.max(temp.max, p.temperatureRange.max) } : { ...p.temperatureRange }; maxOut = Math.max(maxOut, m.limits?.maxOutputTokens ?? 0); } if (levels.length) params.reasoningEffortLevels = levels; if (budget) params.thinkingBudgetRange = budget; if (temp) params.temperatureRange = temp; // Built-in PolyLLM tools are not run in the Arena (the server strips `tools`), so hide those controls. caps.tools = false; return { key: "arena/shared", id: "shared", provider: models[0].provider, displayName: models.length === 1 ? models[0].displayName : `${models.length} models (shared)`, capabilities: caps, parameters: params, limits: maxOut ? { maxOutputTokens: maxOut } : undefined, status: "active", pricing: null, metadata: models.length === 1 ? { ...models[0].metadata } : {}, }; } /** Composer stand-in: attachments are accepted when at least one model can read them; PDFs need `files` everywhere. */ export function buildComposerModel(models: PolyModel[], count: number): PolyModel | undefined { if (!models.length) return undefined; const base = buildSharedModel(models)!; return { ...base, displayName: models.length === 1 ? models[0].displayName : "Some selected models", capabilities: { ...base.capabilities, vision: models.some((m) => m.capabilities.vision), files: models.every((m) => m.capabilities.files) }, metadata: { ...base.metadata, arenaCount: count }, }; }