diff --git a/bun.lock b/bun.lock index 57d0877f3..0eb3e8e28 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,8 @@ "packages/web": { "name": "@models.dev/web", "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", "models.dev": "workspace:*", }, @@ -56,9 +58,13 @@ "@models.dev/web": ["@models.dev/web@workspace:packages/web"], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="], + "@tsconfig/bun": ["@tsconfig/bun@1.0.8", "", {}, "sha512-JlJaRaS4hBTypxtFe8WhnwV8blf0R+3yehLk8XuyxUYNx6VXsKCjACSCvOYEFUiqlhlBWxtYCn/zRlOb8BzBQg=="], - "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], @@ -78,7 +84,7 @@ "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], - "bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -322,8 +328,6 @@ "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "models.dev/@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], - "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], @@ -331,11 +335,5 @@ "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - - "models.dev/@types/bun/bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], - - "models.dev/@types/bun/bun-types/@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], - - "models.dev/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], } } diff --git a/packages/web/package.json b/packages/web/package.json index 852f51abe..691046d69 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,6 +6,8 @@ "build": "./script/build.ts" }, "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", "models.dev": "workspace:*" }, diff --git a/packages/web/src/columns.ts b/packages/web/src/columns.ts new file mode 100644 index 000000000..9b56a214b --- /dev/null +++ b/packages/web/src/columns.ts @@ -0,0 +1,496 @@ +import type { ColumnDef } from "@tanstack/table-core"; +import type { Row } from "./data"; + +export type ColumnMeta = { + dataType: "text" | "number" | "boolean" | "modalities" | "cost"; + headerLabel: string; + headerSubLabel?: string; +}; + +// ── Cell helpers (safe DOM construction, no innerHTML) ──────────────────────── + +function makeBooleanCell(value: boolean | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value === undefined ? "-" : value ? "Yes" : "No"; + return span; +} + +function makeCostCell(value: number | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value === undefined ? "-" : `$${value.toFixed(2)}`; + return span; +} + +// SVG paths for each modality — built via createElementNS so no innerHTML needed +type SvgPathSpec = { + tag: "polyline" | "line" | "rect" | "circle" | "path" | "polygon"; + attrs: Record; +}; + +const MODALITY_PATHS: Record = { + text: [ + { tag: "polyline", attrs: { points: "4,7 4,4 20,4 20,7" } }, + { tag: "line", attrs: { x1: "9", y1: "20", x2: "15", y2: "20" } }, + { tag: "line", attrs: { x1: "12", y1: "4", x2: "12", y2: "20" } }, + ], + image: [ + { + tag: "rect", + attrs: { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }, + }, + { tag: "circle", attrs: { cx: "9", cy: "9", r: "2" } }, + { + tag: "path", + attrs: { d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" }, + }, + ], + audio: [ + { + tag: "polygon", + attrs: { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5" }, + }, + { + tag: "path", + attrs: { + d: "m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07", + }, + }, + ], + video: [ + { tag: "path", attrs: { d: "m22 8-6 4 6 4V8Z" } }, + { + tag: "rect", + attrs: { width: "14", height: "12", x: "2", y: "6", rx: "2", ry: "2" }, + }, + ], + pdf: [ + { + tag: "path", + attrs: { + d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + }, + { tag: "polyline", attrs: { points: "14,2 14,8 20,8" } }, + { tag: "line", attrs: { x1: "16", y1: "13", x2: "8", y2: "13" } }, + { tag: "line", attrs: { x1: "16", y1: "17", x2: "8", y2: "17" } }, + ], +}; + +function buildModalityIcon(modality: string): SVGSVGElement | null { + const paths = MODALITY_PATHS[modality]; + if (!paths) return null; + + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("width", "16"); + svg.setAttribute("height", "16"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + + for (const { tag, attrs } of paths) { + const el = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + svg.append(el); + } + return svg; +} + +function makeModalitiesCell(modalities: string[]): HTMLElement { + const div = document.createElement("div"); + div.className = "modalities"; + for (const m of modalities) { + const svg = buildModalityIcon(m); + if (!svg) continue; + const span = document.createElement("span"); + span.className = "modality-icon"; + span.setAttribute("data-tooltip", m.charAt(0).toUpperCase() + m.slice(1)); + span.append(svg); + div.append(span); + } + return div; +} + +function makeCopyIcon(): SVGSVGElement { + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.classList.add("copy-icon"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + const rect = document.createElementNS(NS, "rect"); + rect.setAttribute("width", "14"); + rect.setAttribute("height", "14"); + rect.setAttribute("x", "8"); + rect.setAttribute("y", "8"); + rect.setAttribute("rx", "2"); + rect.setAttribute("ry", "2"); + const path = document.createElementNS(NS, "path"); + path.setAttribute( + "d", + "m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + ); + svg.append(rect, path); + return svg; +} + +function makeCheckIcon(): SVGSVGElement { + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.classList.add("check-icon"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.style.display = "none"; + const polyline = document.createElementNS(NS, "polyline"); + polyline.setAttribute("points", "20,6 9,17 4,12"); + svg.append(polyline); + return svg; +} + +function makeModelIdCell(modelId: string): HTMLElement { + const div = document.createElement("div"); + div.className = "model-id-cell"; + + const span = document.createElement("span"); + span.className = "model-id-text"; + span.textContent = modelId; + + const button = document.createElement("button"); + button.className = "copy-button"; + const copyIcon = makeCopyIcon(); + const checkIcon = makeCheckIcon(); + button.append(copyIcon, checkIcon); + + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(modelId); + copyIcon.style.display = "none"; + checkIcon.style.display = "block"; + setTimeout(() => { + copyIcon.style.display = "block"; + checkIcon.style.display = "none"; + }, 1000); + } catch { + // clipboard unavailable + } + }); + + div.append(span, button); + return div; +} + +function makeProviderCell( + providerId: string, + providerName: string, +): HTMLElement { + const div = document.createElement("div"); + div.className = "provider-cell"; + + const img = document.createElement("img"); + img.src = `/logos/${providerId}.svg`; + img.alt = providerName; + img.width = 16; + img.height = 16; + img.className = "provider-logo"; + + const span = document.createElement("span"); + span.textContent = providerName; + + div.append(img, span); + return div; +} + +function makeNumberCell(value: number | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value == null ? "-" : value.toLocaleString(); + return span; +} + +// ── Column definitions ──────────────────────────────────────────────────────── + +export const columnDefs: ColumnDef[] = [ + { + id: "provider", + header: "Provider", + accessorFn: (row) => row.providerName, + size: 150, + meta: { dataType: "text", headerLabel: "Provider" } satisfies ColumnMeta, + cell: (info) => + makeProviderCell(info.row.original.providerId, info.getValue()), + }, + { + id: "model", + header: "Model", + accessorFn: (row) => row.name, + size: 200, + meta: { dataType: "text", headerLabel: "Model" } satisfies ColumnMeta, + }, + { + id: "family", + header: "Family", + accessorFn: (row) => row.family ?? "-", + size: 120, + meta: { dataType: "text", headerLabel: "Family" } satisfies ColumnMeta, + }, + { + id: "provider-id", + header: "Provider ID", + accessorFn: (row) => row.providerId, + size: 120, + meta: { dataType: "text", headerLabel: "Provider ID" } satisfies ColumnMeta, + }, + { + id: "model-id", + header: "Model ID", + accessorFn: (row) => row.modelId, + size: 220, + meta: { dataType: "text", headerLabel: "Model ID" } satisfies ColumnMeta, + cell: (info) => makeModelIdCell(info.getValue()), + }, + { + id: "tool-call", + header: "Tool Call", + accessorFn: (row) => row.tool_call, + size: 90, + sortingFn: "basic", + meta: { + dataType: "boolean", + headerLabel: "Tool Call", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "reasoning", + header: "Reasoning", + accessorFn: (row) => row.reasoning, + size: 90, + sortingFn: "basic", + meta: { + dataType: "boolean", + headerLabel: "Reasoning", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "input-modalities", + header: "Input", + accessorFn: (row) => row.modalities.input.length, + size: 100, + meta: { + dataType: "modalities", + headerLabel: "Input", + } satisfies ColumnMeta, + cell: (info) => makeModalitiesCell(info.row.original.modalities.input), + }, + { + id: "output-modalities", + header: "Output", + accessorFn: (row) => row.modalities.output.length, + size: 100, + meta: { + dataType: "modalities", + headerLabel: "Output", + } satisfies ColumnMeta, + cell: (info) => makeModalitiesCell(info.row.original.modalities.output), + }, + { + id: "input-cost", + header: "Input Cost", + accessorFn: (row) => row.cost?.input, + size: 110, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Input Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "output-cost", + header: "Output Cost", + accessorFn: (row) => row.cost?.output, + size: 115, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Output Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "reasoning-cost", + header: "Reasoning Cost", + accessorFn: (row) => row.cost?.reasoning, + size: 130, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Reasoning Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "cache-read-cost", + header: "Cache Read Cost", + accessorFn: (row) => row.cost?.cache_read, + size: 130, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Cache Read Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "cache-write-cost", + header: "Cache Write Cost", + accessorFn: (row) => row.cost?.cache_write, + size: 135, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Cache Write Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "audio-input-cost", + header: "Audio Input Cost", + accessorFn: (row) => row.cost?.input_audio, + size: 135, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Audio Input Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "audio-output-cost", + header: "Audio Output Cost", + accessorFn: (row) => row.cost?.output_audio, + size: 140, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Audio Output Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "context-limit", + header: "Context Limit", + accessorFn: (row) => row.limit.context, + size: 115, + meta: { + dataType: "number", + headerLabel: "Context Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "input-limit", + header: "Input Limit", + accessorFn: (row) => row.limit.input, + size: 100, + sortUndefined: "last", + meta: { + dataType: "number", + headerLabel: "Input Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "output-limit", + header: "Output Limit", + accessorFn: (row) => row.limit.output, + size: 110, + meta: { + dataType: "number", + headerLabel: "Output Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "structured-output", + header: "Structured Output", + accessorFn: (row) => row.structured_output, + size: 145, + meta: { + dataType: "boolean", + headerLabel: "Structured Output", + } satisfies ColumnMeta, + cell: (info) => { + const v = info.getValue(); + const span = document.createElement("span"); + span.textContent = v === undefined ? "-" : v ? "Yes" : "No"; + return span; + }, + }, + { + id: "temperature", + header: "Temperature", + accessorFn: (row) => row.temperature, + size: 110, + meta: { + dataType: "boolean", + headerLabel: "Temperature", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "weights", + header: "Weights", + accessorFn: (row) => (row.open_weights ? "Open" : "Closed"), + size: 90, + meta: { dataType: "text", headerLabel: "Weights" } satisfies ColumnMeta, + }, + { + id: "knowledge", + header: "Knowledge", + accessorFn: (row) => (row.knowledge ? row.knowledge.substring(0, 7) : "-"), + size: 105, + meta: { dataType: "text", headerLabel: "Knowledge" } satisfies ColumnMeta, + }, + { + id: "release-date", + header: "Release Date", + accessorFn: (row) => row.release_date ?? "-", + size: 115, + meta: { + dataType: "text", + headerLabel: "Release Date", + } satisfies ColumnMeta, + }, + { + id: "last-updated", + header: "Last Updated", + accessorFn: (row) => row.last_updated ?? "-", + size: 115, + meta: { + dataType: "text", + headerLabel: "Last Updated", + } satisfies ColumnMeta, + }, +]; diff --git a/packages/web/src/data.test.ts b/packages/web/src/data.test.ts new file mode 100644 index 000000000..dec870a75 --- /dev/null +++ b/packages/web/src/data.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import { flattenProviders } from "./data"; + +const mockApi = { + anthropic: { + name: "Anthropic", + models: { + "claude-3-5-haiku-20241022": { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + family: "claude-haiku", + reasoning: false, + tool_call: true, + attachment: true, + temperature: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 0.8, output: 4.0 }, + limit: { context: 200_000, output: 8_192 }, + release_date: "2024-11-04", + last_updated: "2024-11-04", + }, + }, + }, +}; + +describe("flattenProviders", () => { + it("creates one row per model", () => { + const rows = flattenProviders(mockApi as any); + expect(rows).toHaveLength(1); + }); + + it("adds providerId and providerName to each row", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.providerId).toBe("anthropic"); + expect(row.providerName).toBe("Anthropic"); + }); + + it("adds modelId to each row", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.modelId).toBe("claude-3-5-haiku-20241022"); + }); + + it("preserves model fields", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.name).toBe("Claude 3.5 Haiku"); + expect(row.cost?.input).toBe(0.8); + expect(row.limit.context).toBe(200_000); + }); + + it("filters out alpha models", () => { + const apiWithAlpha = { + ...mockApi, + test: { + name: "Test", + models: { + "alpha-model": { + ...mockApi.anthropic.models["claude-3-5-haiku-20241022"], + status: "alpha", + }, + }, + }, + }; + const rows = flattenProviders(apiWithAlpha as any); + expect(rows).toHaveLength(1); // alpha filtered out + }); +}); diff --git a/packages/web/src/data.ts b/packages/web/src/data.ts new file mode 100644 index 000000000..f21822aa0 --- /dev/null +++ b/packages/web/src/data.ts @@ -0,0 +1,64 @@ +export type Row = { + providerId: string; + providerName: string; + modelId: string; + name: string; + family?: string; + reasoning: boolean; + tool_call: boolean; + attachment: boolean; + temperature?: boolean; + structured_output?: boolean; + open_weights: boolean; + modalities: { input: string[]; output: string[] }; + cost?: { + input?: number; + output?: number; + reasoning?: number; + cache_read?: number; + cache_write?: number; + input_audio?: number; + output_audio?: number; + }; + limit: { context: number; input?: number; output: number }; + knowledge?: string; + release_date?: string; + last_updated?: string; +}; + +type ApiJson = Record< + string, + { + name: string; + models: Record; + } +>; + +export function flattenProviders(api: ApiJson): Row[] { + const rows: Row[] = []; + for (const [providerId, provider] of Object.entries(api)) { + for (const [modelId, model] of Object.entries(provider.models)) { + if (model.status === "alpha") continue; + rows.push({ + providerId, + providerName: provider.name, + modelId, + name: model.name, + family: model.family, + reasoning: model.reasoning, + tool_call: model.tool_call, + attachment: model.attachment, + temperature: model.temperature, + structured_output: model.structured_output, + open_weights: model.open_weights, + modalities: model.modalities, + cost: model.cost, + limit: model.limit, + knowledge: model.knowledge, + release_date: model.release_date, + last_updated: model.last_updated, + }); + } + } + return rows; +} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58a4a5c4e..e87f6afaa 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -8,40 +8,40 @@ :root { --icon-opacity: 0.85; --header-height: 56px; - --font-mono: 'IBM Plex Mono', monospace; + --font-mono: "IBM Plex Mono", monospace; } :root { - --color-brand: #FD9527; - --color-background: #FFF; - --color-border: #DDD; - --color-surface: #EEE; + --color-brand: #fd9527; + --color-background: #fff; + --color-border: #ddd; + --color-surface: #eee; --color-alpha-background: rgba(255, 255, 255, 0.75); --color-text: #333; - --color-text-invert: #FFF; + --color-text-invert: #fff; --color-text-secondary: #666; --color-text-tertiary: #999; } @media (prefers-color-scheme: dark) { :root { - --color-brand: #FD9527; - --color-background: #1E1E1E; + --color-brand: #fd9527; + --color-background: #1e1e1e; --color-border: #333; --color-surface: #111; --color-alpha-background: rgba(30, 30, 30, 0.75); - --color-text: #FFF; + --color-text: #fff; --color-text-invert: #333; - --color-text-secondary: #AAA; + --color-text-secondary: #aaa; --color-text-tertiary: #666; } } html, body { - font-family: 'Rubik', sans-serif; + font-family: "Rubik", sans-serif; line-height: 1.6; color: var(--color-text); background-color: var(--color-background); @@ -81,7 +81,7 @@ header { width: 100%; z-index: 10; - &>div { + & > div { display: flex; align-items: center; @@ -143,6 +143,11 @@ header { min-width: 12.5rem; } + .columns-container { + position: relative; + flex: 0 0 auto; + } + input { width: 100%; font-size: 0.8125rem; @@ -168,7 +173,8 @@ header { font-size: 0.75rem; color: var(--color-text-tertiary); pointer-events: none; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } button { @@ -186,7 +192,6 @@ header { @media (max-width: 32rem) { div.left { - p, span.slash { display: none; @@ -196,7 +201,6 @@ header { @media (max-width: 45rem) { div.right { - .github, .search-container { display: none; @@ -205,20 +209,117 @@ header { } } +/* ─── Column picker dropdown ────────────────────────────────────────────────── */ +#columns-picker { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + box-shadow: + 0 4px 8px rgba(0, 0, 0, 0.08), + 0 8px 24px rgba(0, 0, 0, 0.1); + padding: 0.375rem; + z-index: 10; + min-width: 11rem; + max-height: 32rem; + overflow-y: auto; +} + +#table-head { + z-index: 1; + position: relative; +} + +.picker-actions { + padding: 0.25rem 0.5rem 0.5rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 0.375rem; + + button { + background: none; + color: var(--color-brand); + border: none; + font-size: 0.75rem; + cursor: pointer; + padding: 0; + height: auto; + line-height: 1; + + &:hover { + text-decoration: underline; + } + } +} + +.picker-group { + margin-bottom: 0.375rem; + + &:last-child { + margin-bottom: 0; + } +} + +.picker-group-label { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-tertiary); + padding: 0.25rem 0.5rem; + font-weight: 500; +} + +.picker-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.8125rem; + + &:hover { + background-color: var(--color-surface); + } + + input[type="checkbox"] { + cursor: pointer; + accent-color: var(--color-brand); + width: 1rem; + height: 1rem; + } +} + +/* ─── Virtual scroll container ──────────────────────────────────────────────── */ +#table-scroll-container { + height: calc(100svh - var(--header-height)); + overflow-y: auto; + overflow-x: auto; + margin-top: var(--header-height); +} + +#table-loading { + padding: 2rem; + text-align: center; + color: var(--color-text-tertiary); + font-size: 0.875rem; +} + table { border-collapse: separate; border-spacing: 0; font-size: 0.875rem; width: 100%; - margin-top: var(--header-height); } thead, -tbody {} +tbody { +} table thead th { position: sticky; - top: var(--header-height); + top: 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); font-size: 0.75rem; @@ -271,44 +372,53 @@ tbody { color: var(--color-text-tertiary); } - td:nth-child(1) { + /* Provider column — bold name */ + td[data-column-id="provider"] { font-weight: 500; + color: var(--color-text); } - td:nth-child(1), - td:nth-child(2), - td:nth-child(5), - td:nth-child(6), - td:nth-child(9), - td:nth-child(10), - td:nth-child(11), - td:nth-child(12), - td:nth-child(13), - td:nth-child(14), - td:nth-child(15), - td:nth-child(16) { + /* Main identity columns — full text color */ + td[data-column-id="model"], + td[data-column-id="family"], + td[data-column-id="weights"] { color: var(--color-text); } - td:nth-child(5), - td:nth-child(6), - td:nth-child(18) { + /* ID columns — mono, uppercase */ + td[data-column-id="provider-id"], + td[data-column-id="model-id"] { font-size: 0.8125rem; font-family: var(--font-mono); - text-transform: uppercase; + color: var(--color-text); } - td:nth-child(3), - td:nth-child(4), - td:nth-child(9), - td:nth-child(10), - td:nth-child(11), - td:nth-child(12), - td:nth-child(13), - td:nth-child(14), - td:nth-child(15), - td:nth-child(16), - td:nth-child(17) { + /* Cost columns — mono */ + td[data-column-id="input-cost"], + td[data-column-id="output-cost"], + td[data-column-id="reasoning-cost"], + td[data-column-id="cache-read-cost"], + td[data-column-id="cache-write-cost"], + td[data-column-id="audio-input-cost"], + td[data-column-id="audio-output-cost"] { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--color-text); + } + + /* Limit columns — mono */ + td[data-column-id="context-limit"], + td[data-column-id="input-limit"], + td[data-column-id="output-limit"] { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--color-text); + } + + /* Date/metadata columns — mono */ + td[data-column-id="knowledge"], + td[data-column-id="release-date"], + td[data-column-id="last-updated"] { font-size: 0.8125rem; font-family: var(--font-mono); } @@ -319,15 +429,12 @@ tbody { gap: 0.375rem; } - .provider-cell span:first-child { - flex: 0 0 auto; - } - - .provider-cell svg { + .provider-logo { display: block; + flex: 0 0 auto; width: 1rem; height: 1rem; - color: var(--color-text-secondary); + object-fit: contain; } .model-id-cell { @@ -337,7 +444,8 @@ tbody { gap: 0.375rem; } - .model-id-text {} + .model-id-text { + } .copy-button { flex: 0 0 auto; @@ -348,7 +456,9 @@ tbody { border-radius: 0.25rem; color: var(--color-text-tertiary); opacity: 0; - transition: opacity 0.2s ease, color 0.2s ease; + transition: + opacity 0.2s ease, + color 0.2s ease; } .model-id-cell:hover .copy-button { @@ -433,12 +543,12 @@ dialog { max-width: 40rem; max-height: calc(100svh - 2rem); box-shadow: - 0 2px 4px rgba(0, 0, 0, .05), - 0 4px 8px rgba(0, 0, 0, .05), - 0 8px 16px rgba(0, 0, 0, .07), - 0 16px 32px rgba(0, 0, 0, .07), - 0 32px 64px rgba(0, 0, 0, .07), - 0 48px 96px rgba(0, 0, 0, .07); + 0 2px 4px rgba(0, 0, 0, 0.05), + 0 4px 8px rgba(0, 0, 0, 0.05), + 0 8px 16px rgba(0, 0, 0, 0.07), + 0 16px 32px rgba(0, 0, 0, 0.07), + 0 32px 64px rgba(0, 0, 0, 0.07), + 0 48px 96px rgba(0, 0, 0, 0.07); flex-direction: column; overflow: hidden; @@ -545,5 +655,4 @@ dialog { } } } - -} \ No newline at end of file +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 81afb4342..9b2161dfb 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,240 +1,463 @@ +import { + createTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type SortingState, + type VisibilityState, +} from "@tanstack/table-core"; +import { + elementScroll, + measureElement, + observeElementOffset, + observeElementRect, + Virtualizer, +} from "@tanstack/virtual-core"; +import { type ColumnMeta, columnDefs } from "./columns"; +import { flattenProviders, type Row } from "./data"; +import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; + +// ─── DOM refs ────────────────────────────────────────────────────────────────── +const scrollContainer = document.getElementById( + "table-scroll-container", +) as HTMLDivElement; +const tableHead = document.getElementById( + "table-head", +) as HTMLTableSectionElement; +const tableBody = document.getElementById( + "table-body", +) as HTMLTableSectionElement; +const tableLoading = document.getElementById("table-loading") as HTMLDivElement; +const searchInput = document.getElementById("search") as HTMLInputElement; +const columnsToggle = document.getElementById( + "columns-toggle", +) as HTMLButtonElement; +const columnsPicker = document.getElementById( + "columns-picker", +) as HTMLDivElement; const modal = document.getElementById("modal") as HTMLDialogElement; -const modalClose = document.getElementById("close")!; -const help = document.getElementById("help")!; -const search = document.getElementById("search")! as HTMLInputElement; - -///////////////////////// -// URL State Management -///////////////////////// -function getQueryParams() { - return new URLSearchParams(window.location.search); +const modalClose = document.getElementById("close") as HTMLButtonElement; +const helpButton = document.getElementById("help") as HTMLButtonElement; + +// ─── State ──────────────────────────────────────────────────────────────────── +let rows: Row[] = []; +let sorting: SortingState = []; +let globalFilter = ""; +let columnVisibility: VisibilityState = {}; + +// ─── Tanstack Table ─────────────────────────────────────────────────────────── +const table = createTable({ + data: rows, + columns: columnDefs, + state: { sorting, globalFilter, columnVisibility }, + onStateChange: () => {}, + renderFallbackValue: null, + onSortingChange: (updater) => { + sorting = typeof updater === "function" ? updater(sorting) : updater; + afterStateChange(); + }, + onGlobalFilterChange: (updater) => { + globalFilter = + typeof updater === "function" ? updater(globalFilter) : updater; + afterStateChange(); + }, + onColumnVisibilityChange: (updater) => { + columnVisibility = + typeof updater === "function" ? updater(columnVisibility) : updater; + afterStateChange(); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + globalFilterFn: (row, _columnId, filterValue: string) => { + const terms = filterValue + .toLowerCase() + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + if (terms.length === 0) return true; + const orig = row.original as Record; + const text = Object.values(orig) + .map((v) => + typeof v === "string" || typeof v === "number" ? String(v) : "", + ) + .join(" ") + .toLowerCase(); + return terms.some((term) => text.includes(term)); + }, +}); + +// Prime state with all feature-provided defaults (e.g. columnPinning: {left:[],right:[]}) +// so that getHeaderGroups() never reads undefined.left +table.setOptions((prev) => ({ + ...prev, + state: { ...table.initialState, ...prev.state }, +})); + +function afterStateChange() { + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { ...prev.state, sorting, globalFilter, columnVisibility }, + })); + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ + ...virtualizer.options, + count: rowCount, + }); + virtualizer._willUpdate(); + virtualizer.scrollToOffset(0); + renderHead(); + renderRows(); + updateUrl(); + updateColumnPickerCheckboxes(); } -function updateQueryParams(updates: Record) { - const params = getQueryParams(); - for (const [key, value] of Object.entries(updates)) { - if (value) { - params.set(key, value); - } else { - params.delete(key); +// ─── Tanstack Virtual ───────────────────────────────────────────────────────── +const virtualizer = new Virtualizer({ + count: 0, + getScrollElement: () => scrollContainer, + estimateSize: () => 45, + overscan: 5, + scrollToFn: elementScroll, + observeElementRect, + observeElementOffset, + measureElement, + onChange: () => renderRows(), +}); +// In vanilla JS, _willUpdate() must be called manually to start observing +// the scroll element (frameworks call it automatically via lifecycle hooks). +virtualizer._willUpdate(); + +// ─── Render: thead ──────────────────────────────────────────────────────────── +function renderHead() { + tableHead.textContent = ""; + const tr = document.createElement("tr"); + tr.style.cssText = "display: flex; width: 100%;"; + + for (const headerGroup of table.getHeaderGroups()) { + for (const header of headerGroup.headers) { + if (!header.column.getIsVisible()) continue; + + const meta = header.column.columnDef.meta as ColumnMeta | undefined; + const colSize = header.column.getSize(); + const isSorted = header.column.getIsSorted(); + + const th = document.createElement("th"); + th.className = "sortable"; + th.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + th.setAttribute("data-column-id", header.column.id); + + if (meta?.headerSubLabel) { + const container = document.createElement("div"); + container.className = "header-container"; + + const textSpan = document.createElement("span"); + textSpan.className = "header-text"; + textSpan.textContent = meta.headerLabel; + + const descSpan = document.createElement("span"); + descSpan.className = "desc"; + descSpan.textContent = meta.headerSubLabel; + textSpan.append(document.createElement("br"), descSpan); + + const sortSpan = document.createElement("span"); + sortSpan.className = "sort-indicator"; + sortSpan.textContent = + isSorted === "asc" ? "↑" : isSorted === "desc" ? "↓" : ""; + + container.append(textSpan, sortSpan); + th.append(container); + } else { + const label = meta?.headerLabel ?? header.column.id; + const sortIndicator = + isSorted === "asc" ? " ↑" : isSorted === "desc" ? " ↓" : ""; + th.textContent = label + sortIndicator; + } + + th.addEventListener("click", () => { + header.column.toggleSorting(header.column.getIsSorted() === "asc"); + }); + + tr.append(th); } } - const newPath = params.toString() - ? `${window.location.pathname}?${params.toString()}` - : window.location.pathname; - window.history.pushState({}, "", newPath); -} -function getColumnNameForURL(headerEl: Element): string { - const text = headerEl.textContent?.trim().toLowerCase() || ""; - return text.replace(/↑|↓/g, "").trim().split(/\s+/).slice(0, 2).join("-"); + tableHead.append(tr); } -function getColumnIndexByUrlName(name: string): number { - const headers = document.querySelectorAll("th.sortable"); - return Array.from(headers).findIndex( - (header) => getColumnNameForURL(header) === name - ); +// ─── Render: tbody (virtual) ────────────────────────────────────────────────── +function renderRows() { + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const tableRows = table.getRowModel().rows; + + tableBody.style.cssText = `height: ${totalSize}px; position: relative;`; + tableBody.textContent = ""; + + for (const virtualRow of virtualItems) { + const row = tableRows[virtualRow.index]; + if (!row) continue; + + const tr = document.createElement("tr"); + tr.style.cssText = [ + "position: absolute", + "top: 0", + `transform: translateY(${virtualRow.start}px)`, + "display: flex", + "width: 100%", + ].join("; "); + tr.dataset.index = String(virtualRow.index); + + for (const cell of row.getVisibleCells()) { + const td = document.createElement("td"); + const colSize = cell.column.getSize(); + td.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + td.dataset.columnId = cell.column.id; + + const colDef = cell.column.columnDef; + if (typeof colDef.cell === "function") { + const rendered = colDef.cell(cell.getContext()); + if (rendered instanceof HTMLElement) { + td.append(rendered); + } else if (rendered != null) { + td.textContent = String(rendered); + } + } else { + const v = cell.getValue(); + td.textContent = v != null ? String(v) : "-"; + } + + tr.append(td); + } + + tableBody.append(tr); + } } -///////////////////////// -// Handle "How to use" -///////////////////////// -let y = 0; +// ─── Column picker ──────────────────────────────────────────────────────────── +const COLUMN_GROUPS: { label: string; ids: string[] }[] = [ + { + label: "Identity", + ids: ["provider", "model", "family", "provider-id", "model-id"], + }, + { + label: "Capabilities", + ids: [ + "tool-call", + "reasoning", + "structured-output", + "temperature", + "weights", + ], + }, + { label: "Modalities", ids: ["input-modalities", "output-modalities"] }, + { + label: "Cost", + ids: [ + "input-cost", + "output-cost", + "reasoning-cost", + "cache-read-cost", + "cache-write-cost", + "audio-input-cost", + "audio-output-cost", + ], + }, + { label: "Limits", ids: ["context-limit", "input-limit", "output-limit"] }, + { label: "Metadata", ids: ["knowledge", "release-date", "last-updated"] }, +]; + +function buildColumnPicker() { + columnsPicker.textContent = ""; + + const actions = document.createElement("div"); + actions.className = "picker-actions"; + const showAll = document.createElement("button"); + showAll.textContent = "Show all"; + showAll.addEventListener("click", () => { + table.toggleAllColumnsVisible(true); + }); + actions.append(showAll); + columnsPicker.append(actions); + + for (const group of COLUMN_GROUPS) { + const groupEl = document.createElement("div"); + groupEl.className = "picker-group"; + + const groupLabel = document.createElement("div"); + groupLabel.className = "picker-group-label"; + groupLabel.textContent = group.label; + groupEl.append(groupLabel); + + for (const colId of group.ids) { + const col = table.getColumn(colId); + if (!col) continue; + const meta = col.columnDef.meta as ColumnMeta | undefined; + + const label = document.createElement("label"); + label.className = "picker-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = col.getIsVisible(); + checkbox.dataset.colId = colId; + checkbox.addEventListener("change", () => { + col.toggleVisibility(checkbox.checked); + }); + + const labelText = document.createTextNode(meta?.headerLabel ?? colId); + label.append(checkbox, labelText); + groupEl.append(label); + } -help.addEventListener("click", () => { - y = window.scrollY; - document.body.style.position = "fixed"; - document.body.style.top = `-${y}px`; - modal.showModal(); -}); + columnsPicker.append(groupEl); + } +} -function closeDialog() { - modal.close(); - document.body.style.position = ""; - document.body.style.top = ""; - window.scrollTo(0, y); +function updateColumnPickerCheckboxes() { + columnsPicker + .querySelectorAll("[data-col-id]") + .forEach((cb) => { + const col = table.getColumn(cb.dataset.colId ?? ""); + if (col) cb.checked = col.getIsVisible(); + }); } -modalClose.addEventListener("click", closeDialog); -modal.addEventListener("cancel", closeDialog); -modal.addEventListener("click", (e) => { - if (e.target === modal) closeDialog(); +columnsToggle.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !columnsPicker.hidden; + columnsPicker.hidden = isOpen; + if (!isOpen) buildColumnPicker(); }); -//////////////////// -// Handle Sorting -//////////////////// -let currentSort = { column: -1, direction: "asc" }; - -function sortTable(column: number, direction: "asc" | "desc") { - const header = document.querySelectorAll("th.sortable")[column]; - const columnType = header.getAttribute("data-type"); - if (!columnType) return; - - // update state - currentSort = { column, direction }; - updateQueryParams({ - sort: getColumnNameForURL(header), - order: direction, - }); - - // sort rows - const tbody = document.querySelector("table tbody")!; - const rows = Array.from( - tbody.querySelectorAll("tr") - ) as HTMLTableRowElement[]; - rows.sort((a, b) => { - const aValue = getCellValue(a.cells[column], columnType); - const bValue = getCellValue(b.cells[column], columnType); - - // Handle undefined values - always sort to bottom - if (aValue === undefined && bValue === undefined) return 0; - if (aValue === undefined) return 1; - if (bValue === undefined) return -1; - - let comparison = 0; - if (columnType === "number" || columnType === "modalities") { - comparison = (aValue as number) - (bValue as number); - } else if (columnType === "boolean") { - comparison = (aValue as string).localeCompare(bValue as string); - } else { - comparison = (aValue as string).localeCompare(bValue as string); - } - - return direction === "asc" ? comparison : -comparison; - }); - rows.forEach((row) => tbody.appendChild(row)); +document.addEventListener("click", (e) => { + if ( + !columnsPicker.hidden && + !columnsPicker.contains(e.target as Node) && + e.target !== columnsToggle + ) { + columnsPicker.hidden = true; + } +}); - // update sort indicators - const headers = document.querySelectorAll("th.sortable"); - headers.forEach((header, i) => { - const indicator = header.querySelector(".sort-indicator")!; +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !columnsPicker.hidden) { + columnsPicker.hidden = true; + } +}); - if (i === column) { - indicator.textContent = direction === "asc" ? "↑" : "↓"; - } else { - indicator.textContent = ""; - } +// ─── URL state ──────────────────────────────────────────────────────────────── +function updateUrl() { + const visibleCols = table + .getAllColumns() + .filter((c) => c.getIsVisible()) + .map((c) => c.id); + + const params = serializeUrlState({ + search: globalFilter, + sort: sorting[0]?.id ?? null, + order: sorting[0]?.desc ? "desc" : "asc", + cols: visibleCols, }); -} -function getCellValue( - cell: HTMLTableCellElement, - type: string -): string | number | undefined { - if (type === "modalities") - return cell.querySelectorAll(".modality-icon").length; - - const text = cell.textContent?.trim() || ""; - if (text === "-") return; - if (type === "number") return parseFloat(text.replace(/[$,]/g, "")) || 0; - return text; + const newPath = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.pushState({}, "", newPath); } -document.querySelectorAll("th.sortable").forEach((header) => { - header.addEventListener("click", () => { - const column = Array.from(header.parentElement!.children).indexOf(header); - const direction = - currentSort.column === column && currentSort.direction === "asc" - ? "desc" - : "asc"; - sortTable(column, direction); - }); -}); +function applyUrlState() { + const state = parseUrlState(new URLSearchParams(window.location.search)); -/////////////////// -// Handle Search -/////////////////// -function filterTable(value: string) { - const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== ""); - const rows = document.querySelectorAll( - "table tbody tr" - ) as NodeListOf; - - rows.forEach((row) => { - const cellTexts = Array.from(row.cells).map((cell) => - cell.textContent!.toLowerCase() - ); - const isVisible = lowerCaseValues.length === 0 || - lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue))); - row.style.display = isVisible ? "" : "none"; - }); + globalFilter = state.search; + searchInput.value = state.search; + sorting = state.sort + ? [{ id: state.sort, desc: state.order === "desc" }] + : []; - updateQueryParams({ search: value || null }); + const newVisibility: VisibilityState = {}; + for (const id of ALL_COLUMN_IDS) { + newVisibility[id] = state.cols.includes(id); + } + columnVisibility = newVisibility; + + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { ...prev.state, sorting, globalFilter, columnVisibility }, + })); + + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ ...virtualizer.options, count: rowCount }); + virtualizer._willUpdate(); + virtualizer.scrollToOffset(0); + + renderHead(); + renderRows(); + buildColumnPicker(); } -search.addEventListener("input", () => { - filterTable(search.value); +// ─── Search ─────────────────────────────────────────────────────────────────── +searchInput.addEventListener("input", () => { + table.setGlobalFilter(searchInput.value); +}); + +searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + searchInput.value = ""; + table.setGlobalFilter(""); + } }); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); - search.focus(); + searchInput.focus(); } }); -search.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - search.value = ""; - search.dispatchEvent(new Event("input")); - } +// ─── Help modal ─────────────────────────────────────────────────────────────── +let savedScrollY = 0; + +helpButton.addEventListener("click", () => { + savedScrollY = window.scrollY; + document.body.style.position = "fixed"; + document.body.style.top = `-${savedScrollY}px`; + modal.showModal(); }); -/////////////////////////////////// -// Handle Copy model ID function -/////////////////////////////////// -(window as any).copyModelId = async ( - button: HTMLButtonElement, - modelId: string -) => { - try { - if (navigator.clipboard) { - await navigator.clipboard.writeText(modelId); - - // Switch to check icon - const copyIcon = button.querySelector(".copy-icon") as HTMLElement; - const checkIcon = button.querySelector(".check-icon") as HTMLElement; - - copyIcon.style.display = "none"; - checkIcon.style.display = "block"; - - // Switch back after 1 second - setTimeout(() => { - copyIcon.style.display = "block"; - checkIcon.style.display = "none"; - }, 1000); - } - } catch (err) { - console.error("Failed to copy text: ", err); - } -}; - -/////////////////////////////////// -// Initialize State from URL -/////////////////////////////////// -function initializeFromURL() { - const params = getQueryParams(); - - (() => { - const searchQuery = params.get("search"); - if (!searchQuery) return; - search.value = searchQuery; - filterTable(searchQuery); - })(); - - (() => { - const columnName = params.get("sort"); - if (!columnName) return; - - const columnIndex = getColumnIndexByUrlName(columnName); - if (columnIndex === -1) return; - - const direction = (params.get("order") as "asc" | "desc") || "asc"; - sortTable(columnIndex, direction); - })(); +function closeDialog() { + modal.close(); + document.body.style.position = ""; + document.body.style.top = ""; + window.scrollTo(0, savedScrollY); } -document.addEventListener("DOMContentLoaded", initializeFromURL); -window.addEventListener("popstate", initializeFromURL); +modalClose.addEventListener("click", closeDialog); +modal.addEventListener("cancel", closeDialog); +modal.addEventListener("click", (e) => { + if (e.target === modal) closeDialog(); +}); + +// ─── Init ───────────────────────────────────────────────────────────────────── +async function init() { + const res = await fetch("/api.json"); + const api: Record = await res.json(); + rows = flattenProviders(api as any); + + // Default sort: provider name, then model name + rows.sort((a, b) => { + const p = a.providerName.localeCompare(b.providerName); + return p !== 0 ? p : a.name.localeCompare(b.name); + }); + + table.setOptions((prev) => ({ ...prev, data: rows })); + tableLoading.remove(); + applyUrlState(); +} + +window.addEventListener("popstate", applyUrlState); +document.addEventListener("DOMContentLoaded", () => { + init().catch(console.error); +}); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index a1bdfcc2f..3650f3307 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -1,14 +1,14 @@ /** @jsx jsx */ /** @jsxImportSource hono/jsx */ -import { generate } from "models.dev"; +import { existsSync } from "fs"; import { Fragment } from "hono/jsx"; import { renderToString } from "hono/jsx/dom/server"; -import { existsSync } from "fs"; +import { generate } from "models.dev"; import path from "path"; export const Providers = await generate( - path.join(import.meta.dir, "..", "..", "..", "providers") + path.join(import.meta.dir, "..", "..", "..", "providers"), ); // Function to load SVG content @@ -20,7 +20,7 @@ const loadProviderSvg = async (providerId: string): Promise => { "..", "providers", providerId, - "logo.svg" + "logo.svg", ); const defaultLogoPath = path.join( @@ -29,7 +29,7 @@ const loadProviderSvg = async (providerId: string): Promise => { "..", "..", "providers", - "logo.svg" + "logo.svg", ); try { @@ -210,256 +210,28 @@ export const Rendered = renderToString( ⌘K - +
+ + +
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {Object.entries(Providers) - .sort(([, providerA], [, providerB]) => - providerA.name.localeCompare(providerB.name) - ) - .flatMap(([providerId, provider]) => - Object.entries(provider.models) - .filter(([, model]) => model.status !== "alpha") - .sort(([, modelA], [, modelB]) => - modelA.name.localeCompare(modelB.name) - ) - .map(([modelId, model]) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - )) - )} - -
- Provider - - Model - - Family - - Provider ID - - Model ID - - Tool Call - - Reasoning - - Input - - Output - -
- - Input Cost -
- per 1M tokens -
- -
-
-
- - Output Cost -
- per 1M tokens -
- -
-
-
- - Reasoning Cost -
- per 1M tokens -
- -
-
-
- - Cache Read Cost -
- per 1M tokens -
- -
-
-
- - Cache Write Cost -
- per 1M tokens -
- -
-
-
- - Audio Input Cost -
- per 1M tokens -
- -
-
-
- - Audio Output Cost -
- per 1M tokens -
- -
-
- Context Limit - - Input Limit - - Output Limit - - Structured Output - - Temperature - - Weights - - Knowledge - - Release Date - - Last Updated -
-
- {renderProviderLogo(providerId)} - {provider.name} -
-
{model.name}{model.family ?? "-"}{providerId} -
- {modelId} - -
-
{model.tool_call ? "Yes" : "No"}{model.reasoning ? "Yes" : "No"} -
- {model.modalities.input.map((modality) => - getModalityIcon(modality) - )} -
-
-
- {model.modalities.output.map((modality) => - getModalityIcon(modality) - )} -
-
{renderCost(model.cost?.input)}{renderCost(model.cost?.output)}{renderCost(model.cost?.reasoning)}{renderCost(model.cost?.cache_read)}{renderCost(model.cost?.cache_write)}{renderCost(model.cost?.input_audio)}{renderCost(model.cost?.output_audio)}{model.limit.context.toLocaleString()}{model.limit.input?.toLocaleString() ?? "-"}{model.limit.output.toLocaleString()} - {model.structured_output === undefined - ? "-" - : model.structured_output - ? "Yes" - : "No"} - {model.temperature ? "Yes" : "No"}{model.open_weights ? "Open" : "Closed"} - {model.knowledge ? model.knowledge.substring(0, 7) : "-"} - {model.release_date}{model.last_updated}
+
+ + + +
+
Loading models…
+

How to use

-

- If we don't have a provider's logo, a default logo is served instead. + If we don't have a provider's logo, a default logo is served + instead.

Contribute

@@ -570,5 +343,5 @@ export const Rendered = renderToString(

- + , ); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 5302f92d5..5bb81787b 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1,14 +1,18 @@ -import Index from "../index.html"; -import { Rendered } from "./render"; import path from "path"; +import Index from "../index.html"; +import { Providers, Rendered } from "./render"; Bun.serve({ port: 16_000, routes: { "/": Index, + "/api.json": () => + new Response(JSON.stringify(Providers), { + headers: { "Content-Type": "application/json" }, + }), "/assets/*": (req) => { const file = Bun.file( - path.join(import.meta.dir, new URL(req.url).pathname) + path.join(import.meta.dir, new URL(req.url).pathname), ); return new Response(file); }, @@ -22,7 +26,7 @@ Bun.serve({ "..", "providers", provider, - "logo.svg" + "logo.svg", ); const defaultLogoPath = path.join( import.meta.dir, @@ -30,7 +34,7 @@ Bun.serve({ "..", "..", "providers", - "logo.svg" + "logo.svg", ); let file = Bun.file(logoPath); diff --git a/packages/web/src/url-state.test.ts b/packages/web/src/url-state.test.ts new file mode 100644 index 000000000..40d373038 --- /dev/null +++ b/packages/web/src/url-state.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; + +describe("parseUrlState", () => { + it("returns empty defaults when no params", () => { + const state = parseUrlState(new URLSearchParams("")); + expect(state.search).toBe(""); + expect(state.sort).toBeNull(); + expect(state.order).toBe("asc"); + expect(state.cols).toEqual([...ALL_COLUMN_IDS]); + }); + + it("parses search param", () => { + const state = parseUrlState(new URLSearchParams("search=gpt")); + expect(state.search).toBe("gpt"); + }); + + it("parses sort and order", () => { + const state = parseUrlState( + new URLSearchParams("sort=input-cost&order=desc"), + ); + expect(state.sort).toBe("input-cost"); + expect(state.order).toBe("desc"); + }); + + it("parses cols param as array", () => { + const state = parseUrlState( + new URLSearchParams("cols=provider,model,input-cost"), + ); + expect(state.cols).toEqual(["provider", "model", "input-cost"]); + }); +}); + +describe("serializeUrlState", () => { + it("omits cols when all visible", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("cols")).toBeNull(); + }); + + it("includes cols when not all visible", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: ["provider", "model"], + }); + expect(params.get("cols")).toBe("provider,model"); + }); + + it("omits search when empty", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("search")).toBeNull(); + }); +}); diff --git a/packages/web/src/url-state.ts b/packages/web/src/url-state.ts new file mode 100644 index 000000000..ba0de1e22 --- /dev/null +++ b/packages/web/src/url-state.ts @@ -0,0 +1,66 @@ +export const ALL_COLUMN_IDS = [ + "provider", + "model", + "family", + "provider-id", + "model-id", + "tool-call", + "reasoning", + "input-modalities", + "output-modalities", + "input-cost", + "output-cost", + "reasoning-cost", + "cache-read-cost", + "cache-write-cost", + "audio-input-cost", + "audio-output-cost", + "context-limit", + "input-limit", + "output-limit", + "structured-output", + "temperature", + "weights", + "knowledge", + "release-date", + "last-updated", +] as const; + +export type ColumnId = (typeof ALL_COLUMN_IDS)[number]; + +export type UrlState = { + search: string; + sort: string | null; + order: "asc" | "desc"; + cols: string[]; +}; + +export function parseUrlState(params: URLSearchParams): UrlState { + const colsParam = params.get("cols"); + const cols = colsParam + ? colsParam + .split(",") + .filter((c) => (ALL_COLUMN_IDS as readonly string[]).includes(c)) + : [...ALL_COLUMN_IDS]; + + return { + search: params.get("search") ?? "", + sort: params.get("sort"), + order: params.get("order") === "desc" ? "desc" : "asc", + cols: cols.length > 0 ? cols : [...ALL_COLUMN_IDS], + }; +} + +export function serializeUrlState(state: UrlState): URLSearchParams { + const params = new URLSearchParams(); + if (state.search) params.set("search", state.search); + if (state.sort) { + params.set("sort", state.sort); + if (state.order !== "asc") params.set("order", state.order); + } + const allVisible = + state.cols.length === ALL_COLUMN_IDS.length && + ALL_COLUMN_IDS.every((id) => state.cols.includes(id)); + if (!allVisible) params.set("cols", state.cols.join(",")); + return params; +}