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.0 KB · 206 lines typescript
Raw Blame History
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