diff --git a/bun.lock b/bun.lock index 57d0877f3..135b4cb17 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,10 @@ "packages/web": { "name": "@models.dev/web", "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", + "minisearch": "^7.2.0", "models.dev": "workspace:*", }, "devDependencies": { @@ -56,9 +59,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 +85,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=="], @@ -198,6 +205,8 @@ "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], + "models.dev": ["models.dev@workspace:packages/core"], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -322,8 +331,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 +338,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/index.html b/packages/web/index.html index 2ff27c7ee..110f79691 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -29,6 +29,7 @@ + diff --git a/packages/web/package.json b/packages/web/package.json index 852f51abe..a463f252c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,7 +6,10 @@ "build": "./script/build.ts" }, "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", + "minisearch": "^7.2.0", "models.dev": "workspace:*" }, "devDependencies": { diff --git a/packages/web/script/build.ts b/packages/web/script/build.ts index daad95cc7..4f0140d4f 100755 --- a/packages/web/script/build.ts +++ b/packages/web/script/build.ts @@ -43,6 +43,10 @@ for (const entry of entries) { let html = await Bun.file("./dist/index.html").text(); html = html.replace("", Rendered); +html = html.replace( + '', + '' +); await Bun.write("./dist/index.html", html); await Bun.write("./dist/api.json", JSON.stringify(Providers)); diff --git a/packages/web/src/columns.test.ts b/packages/web/src/columns.test.ts new file mode 100644 index 000000000..b7225d982 --- /dev/null +++ b/packages/web/src/columns.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { + ALL_COLUMN_IDS, + DEFAULT_COLUMN_IDS, + parseUrlState, +} from "./url-state.js"; + +describe("DEFAULT_COLUMN_IDS", () => { + it("has exactly 9 items", () => { + expect(DEFAULT_COLUMN_IDS).toHaveLength(9); + }); + + it("contains the expected default columns", () => { + expect(DEFAULT_COLUMN_IDS).toContain("provider"); + expect(DEFAULT_COLUMN_IDS).toContain("model"); + expect(DEFAULT_COLUMN_IDS).toContain("family"); + expect(DEFAULT_COLUMN_IDS).toContain("model-id"); + expect(DEFAULT_COLUMN_IDS).toContain("tool-call"); + expect(DEFAULT_COLUMN_IDS).toContain("reasoning"); + expect(DEFAULT_COLUMN_IDS).toContain("input-cost"); + expect(DEFAULT_COLUMN_IDS).toContain("output-cost"); + expect(DEFAULT_COLUMN_IDS).toContain("context-limit"); + }); + + it("does NOT contain non-default columns", () => { + expect(DEFAULT_COLUMN_IDS).not.toContain("provider-id"); + expect(DEFAULT_COLUMN_IDS).not.toContain("cache-read-cost"); + expect(DEFAULT_COLUMN_IDS).not.toContain("audio-input-cost"); + expect(DEFAULT_COLUMN_IDS).not.toContain("audio-output-cost"); + expect(DEFAULT_COLUMN_IDS).not.toContain("input-limit"); + expect(DEFAULT_COLUMN_IDS).not.toContain("output-limit"); + expect(DEFAULT_COLUMN_IDS).not.toContain("knowledge"); + expect(DEFAULT_COLUMN_IDS).not.toContain("release-date"); + expect(DEFAULT_COLUMN_IDS).not.toContain("last-updated"); + }); + + it("all entries are valid column IDs (present in ALL_COLUMN_IDS)", () => { + const allIds = [...ALL_COLUMN_IDS] as string[]; + for (const id of DEFAULT_COLUMN_IDS) { + expect(allIds).toContain(id); + } + }); +}); + +describe("column visibility via parseUrlState", () => { + it("cols=provider,model returns exactly those 2 columns", () => { + const state = parseUrlState(new URLSearchParams("cols=provider,model")); + expect(state.cols).toEqual(["provider", "model"]); + expect(state.cols).toHaveLength(2); + }); + + it("invalid column IDs in cols param are filtered out", () => { + const state = parseUrlState( + new URLSearchParams("cols=provider,not-a-real-column,model"), + ); + expect(state.cols).toEqual(["provider", "model"]); + }); + + it("cols param with all invalid IDs falls back to defaults", () => { + const state = parseUrlState( + new URLSearchParams("cols=fake-col,another-fake"), + ); + expect(state.cols).toEqual([...DEFAULT_COLUMN_IDS]); + }); + + it("no cols param returns DEFAULT_COLUMN_IDS", () => { + const state = parseUrlState(new URLSearchParams("")); + expect(state.cols).toEqual([...DEFAULT_COLUMN_IDS]); + }); +}); 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/debounce.test.ts b/packages/web/src/debounce.test.ts new file mode 100644 index 000000000..ab0c5d463 --- /dev/null +++ b/packages/web/src/debounce.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, mock } from "bun:test"; +import { debounce } from "./search.js"; + +describe("debounce", () => { + it("calls the function after the specified delay", async () => { + const fn = mock(() => {}); + const debounced = debounce(fn, 50); + + debounced(); + expect(fn).not.toHaveBeenCalled(); // not yet + + await Bun.sleep(80); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("rapid calls only trigger the function once (last call wins)", async () => { + const fn = mock((_val: string) => {}); + const debounced = debounce(fn, 50); + + debounced("a"); + debounced("b"); + debounced("c"); + + await Bun.sleep(80); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("c"); + }); + + it("cancel() prevents the pending execution", async () => { + const fn = mock(() => {}); + const debounced = debounce(fn, 50); + + debounced(); + debounced.cancel(); + + await Bun.sleep(80); + expect(fn).not.toHaveBeenCalled(); + }); + + it("works correctly with different delay values", async () => { + const fast = mock(() => {}); + const slow = mock(() => {}); + + const debouncedFast = debounce(fast, 30); + const debouncedSlow = debounce(slow, 120); + + debouncedFast(); + debouncedSlow(); + + await Bun.sleep(60); + // fast should have fired, slow should not yet + expect(fast).toHaveBeenCalledTimes(1); + expect(slow).not.toHaveBeenCalled(); + + await Bun.sleep(100); + // now slow should have fired too + expect(slow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58a4a5c4e..bbfaf15fe 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -8,40 +8,46 @@ :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; } } +@media (max-width: 45rem) { + :root { + --header-height: 92px; + } +} + 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); @@ -71,6 +77,7 @@ a { header { top: 0; display: flex; + flex-wrap: nowrap; gap: 0.5rem; justify-content: space-between; align-items: center; @@ -81,7 +88,7 @@ header { width: 100%; z-index: 10; - &>div { + & > div { display: flex; align-items: center; @@ -143,6 +150,11 @@ header { min-width: 12.5rem; } + .columns-container { + position: relative; + flex: 0 0 auto; + } + input { width: 100%; font-size: 0.8125rem; @@ -168,7 +180,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 { @@ -184,41 +197,156 @@ header { border-radius: 0.25rem; } - @media (max-width: 32rem) { + @media (max-width: 45rem) { div.left { - p, span.slash { display: none; } } - } - @media (max-width: 45rem) { + flex-wrap: wrap; + align-content: center; + gap: 0.75rem; + row-gap: 0.5rem; + padding: 0.5rem 0.75rem; + + div.left { + order: 0; + } + div.right { + display: contents; .github, + .columns-container, + button#help { + order: 1; + flex: 0 0 auto; + } + .search-container { - display: none; + order: 2; + flex: 1 0 100%; + min-width: 0; } } } } +/* ─── 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); + -webkit-overflow-scrolling: touch; + transform: translateZ(0); +} + +#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; @@ -228,8 +356,7 @@ table thead th { text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-secondary); - backdrop-filter: blur(6px); - background-color: var(--color-alpha-background); + background-color: var(--color-background); z-index: 10; } @@ -264,6 +391,8 @@ td { text-align: left; border-bottom: 1px solid var(--color-border); white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; } tbody { @@ -271,44 +400,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,40 +457,42 @@ 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 { display: flex; align-items: center; - justify-content: space-between; gap: 0.375rem; + min-width: 0; } - .model-id-text {} + .model-id-text { + flex: 0 0 auto; + } .copy-button { flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; background: none; border: none; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; - color: var(--color-text-tertiary); - opacity: 0; - transition: opacity 0.2s ease, color 0.2s ease; - } - - .model-id-cell:hover .copy-button { + color: var(--color-text-secondary); opacity: 1; + transition: + opacity 0.2s ease, + color 0.2s ease; } .model-id-cell .copy-button svg { @@ -433,12 +573,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 +685,4 @@ dialog { } } } - -} \ No newline at end of file +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 81afb4342..11c1c598f 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,240 +1,744 @@ +import { + createTable, + getCoreRowModel, + 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 { buildSearchIndex, searchRows, debounce } from "./search"; +import { + ALL_COLUMN_IDS, + DEFAULT_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 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); -} - -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); - } - } - const newPath = params.toString() - ? `${window.location.pathname}?${params.toString()}` - : window.location.pathname; - window.history.pushState({}, "", newPath); +const modalClose = document.getElementById("close") as HTMLButtonElement; +const helpButton = document.getElementById("help") as HTMLButtonElement; + +// ─── State ──────────────────────────────────────────────────────────────────── +let allRows: Row[] = []; +let rows: Row[] = []; +let sorting: SortingState = []; +let globalFilter = ""; +let columnVisibility: VisibilityState = {}; +let computedColumnSizes: Partial> = {}; + +function textWidthPx(length: number, mono = false): number { + return length * (mono ? 8 : 7); } -function getColumnNameForURL(headerEl: Element): string { - const text = headerEl.textContent?.trim().toLowerCase() || ""; - return text.replace(/↑|↓/g, "").trim().split(/\s+/).slice(0, 2).join("-"); +function maxLength(items: T[], getLength: (item: T) => number): number { + let max = 0; + for (const item of items) max = Math.max(max, getLength(item)); + return max; } -function getColumnIndexByUrlName(name: string): number { - const headers = document.querySelectorAll("th.sortable"); - return Array.from(headers).findIndex( - (header) => getColumnNameForURL(header) === name - ); +function formatCost(value: number | undefined): string { + return value === undefined ? "-" : `$${value.toFixed(2)}`; } -///////////////////////// -// Handle "How to use" -///////////////////////// -let y = 0; +function formatNumber(value: number | undefined): string { + return value == null ? "-" : value.toLocaleString(); +} -help.addEventListener("click", () => { - y = window.scrollY; - document.body.style.position = "fixed"; - document.body.style.top = `-${y}px`; - modal.showModal(); -}); +function computeColumnSizes(data: Row[]): Partial> { + const tdHorizontalPaddingPx = 24; + const sortIndicatorPx = 18; + const extraSafetyPx = 16; + const iconSizePx = 16; + const iconGapPx = 6; + const copyButtonWidthPx = 30; + const modalityIconWidthPx = 20; + const modalityIconGapPx = 4; + const widths: Partial> = {}; + + for (const col of columnDefs) { + const id = String(col.id ?? ""); + if (!id) continue; + + const meta = col.meta as ColumnMeta | undefined; + const baseSize = col.size ?? 0; + const headerLabel = meta?.headerLabel ?? id; + const headerSubLabel = meta?.headerSubLabel ?? ""; + const headerTextWidth = + Math.max( + textWidthPx(headerLabel.length), + textWidthPx(headerSubLabel.length), + ) + + tdHorizontalPaddingPx + + sortIndicatorPx + + extraSafetyPx; + + let cellWidth = baseSize; + switch (id) { + case "provider": + cellWidth = + textWidthPx(maxLength(data, (row) => row.providerName.length)) + + iconSizePx + + iconGapPx + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "model": + cellWidth = + textWidthPx(maxLength(data, (row) => row.name.length)) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "family": + cellWidth = + textWidthPx(maxLength(data, (row) => (row.family ?? "-").length)) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "provider-id": + cellWidth = + textWidthPx(maxLength(data, (row) => row.providerId.length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "model-id": + cellWidth = + textWidthPx(maxLength(data, (row) => row.modelId.length), true) + + copyButtonWidthPx + + iconGapPx + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "input-modalities": + cellWidth = + maxLength(data, (row) => { + const count = row.modalities.input.length; + return count * modalityIconWidthPx + Math.max(0, count - 1) * modalityIconGapPx; + }) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "output-modalities": + cellWidth = + maxLength(data, (row) => { + const count = row.modalities.output.length; + return count * modalityIconWidthPx + Math.max(0, count - 1) * modalityIconGapPx; + }) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "input-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.input).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "output-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.output).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "reasoning-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.reasoning).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "cache-read-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.cache_read).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "cache-write-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.cache_write).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "audio-input-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.input_audio).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "audio-output-cost": + cellWidth = + textWidthPx(maxLength(data, (row) => formatCost(row.cost?.output_audio).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "context-limit": + cellWidth = + textWidthPx(maxLength(data, (row) => formatNumber(row.limit.context).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "input-limit": + cellWidth = + textWidthPx(maxLength(data, (row) => formatNumber(row.limit.input).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "output-limit": + cellWidth = + textWidthPx(maxLength(data, (row) => formatNumber(row.limit.output).length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "knowledge": + cellWidth = + textWidthPx( + maxLength(data, (row) => (row.knowledge ? row.knowledge.substring(0, 7) : "-").length), + true, + ) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "release-date": + cellWidth = + textWidthPx(maxLength(data, (row) => (row.release_date ?? "-").length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + case "last-updated": + cellWidth = + textWidthPx(maxLength(data, (row) => (row.last_updated ?? "-").length), true) + + tdHorizontalPaddingPx + + extraSafetyPx; + break; + default: + if (meta?.dataType === "boolean") { + cellWidth = textWidthPx(3) + tdHorizontalPaddingPx + extraSafetyPx; + } else if (meta?.dataType === "text") { + cellWidth = + textWidthPx( + maxLength(data, (row) => { + if (id === "weights") return (row.open_weights ? "Open" : "Closed").length; + return 1; + }), + ) + + tdHorizontalPaddingPx + + extraSafetyPx; + } + break; + } -function closeDialog() { - modal.close(); - document.body.style.position = ""; - document.body.style.top = ""; - window.scrollTo(0, y); + widths[id] = Math.max(baseSize, headerTextWidth, cellWidth); + } + + return widths; } -modalClose.addEventListener("click", closeDialog); -modal.addEventListener("cancel", closeDialog); -modal.addEventListener("click", (e) => { - if (e.target === modal) closeDialog(); +function getColumnSize(columnId: string, fallbackSize: number): number { + return Math.max(fallbackSize, computedColumnSizes[columnId] ?? fallbackSize); +} + +// ─── Tanstack Table ─────────────────────────────────────────────────────────── +const table = createTable({ + data: rows, + columns: columnDefs, + state: { sorting, columnVisibility }, + onStateChange: () => {}, + renderFallbackValue: null, + onSortingChange: (updater) => { + sorting = typeof updater === "function" ? updater(sorting) : updater; + afterStateChange(); + }, + onColumnVisibilityChange: (updater) => { + columnVisibility = + typeof updater === "function" ? updater(columnVisibility) : updater; + afterStateChange(); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), }); -//////////////////// -// 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, +// 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() { + const filtered = globalFilter + ? searchRows(globalFilter, allRows) ?? allRows + : allRows; + rows = filtered; + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { ...prev.state, sorting, columnVisibility }, + })); + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ + ...virtualizer.options, + count: rowCount, }); + virtualizer._willUpdate(); + virtualizer.scrollToOffset(0); + renderHead(); + renderRows(); + updateUrl(); + updateColumnPickerCheckboxes(); + + // Persist column visibility changes to localStorage + const visibleCols = table + .getAllColumns() + .filter((c) => c.getIsVisible()) + .map((c) => c.id); + saveColsToStorage(visibleCols); +} - // 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); - } +// ─── 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(); + +/** Compute proportional flex-grow so text-heavy columns absorb more extra space. */ +export function flexGrow(dataType: string | undefined, baseSize: number): number { + switch (dataType) { + case "text": + // Text columns grow proportionally to their base size + // e.g. Model (200) → grow 3, Provider (150) → grow 2, Family (120) → grow 2 + return Math.max(1, Math.round(baseSize / 80)); + case "cost": + case "number": + // Numbers/costs get minimal growth — they're fairly fixed width + return 1; + case "boolean": + case "modalities": + // Booleans and modality icons don't need extra space at all + return 0; + default: + return 1; + } +} - return direction === "asc" ? comparison : -comparison; - }); - rows.forEach((row) => tbody.appendChild(row)); +// ─── 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 = getColumnSize(header.column.id, header.column.getSize()); + const isSorted = header.column.getIsSorted(); + + const th = document.createElement("th"); + th.className = "sortable"; + const grow = flexGrow(meta?.dataType, colSize); + th.style.cssText = `width: ${colSize}px; flex: ${grow} 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); + } + } - // update sort indicators - const headers = document.querySelectorAll("th.sortable"); - headers.forEach((header, i) => { - const indicator = header.querySelector(".sort-indicator")!; + tableHead.append(tr); +} - if (i === column) { - indicator.textContent = direction === "asc" ? "↑" : "↓"; - } else { - indicator.textContent = ""; +// ─── 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 = getColumnSize(cell.column.id, cell.column.getSize()); + const meta = cell.column.columnDef.meta as ColumnMeta | undefined; + const grow = flexGrow(meta?.dataType, colSize); + td.style.cssText = `width: ${colSize}px; flex: ${grow} 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) : "-"; + } + + // Add tooltip for text cells that might overflow + if (meta?.dataType === "text" && td.textContent) { + td.title = td.textContent; + } + + tr.append(td); } - }); + + tableBody.append(tr); + } } -function getCellValue( - cell: HTMLTableCellElement, - type: string -): string | number | undefined { - if (type === "modalities") - return cell.querySelectorAll(".modality-icon").length; +// ─── 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); + saveColsToStorage([...ALL_COLUMN_IDS]); + }); + 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); + } + + columnsPicker.append(groupEl); + } +} - const text = cell.textContent?.trim() || ""; - if (text === "-") return; - if (type === "number") return parseFloat(text.replace(/[$,]/g, "")) || 0; - return text; +function updateColumnPickerCheckboxes() { + columnsPicker + .querySelectorAll("[data-col-id]") + .forEach((cb) => { + const col = table.getColumn(cb.dataset.colId ?? ""); + if (col) cb.checked = col.getIsVisible(); + }); } -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); - }); +columnsToggle.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !columnsPicker.hidden; + columnsPicker.hidden = isOpen; + if (!isOpen) buildColumnPicker(); +}); + +document.addEventListener("click", (e) => { + if ( + !columnsPicker.hidden && + !columnsPicker.contains(e.target as Node) && + e.target !== columnsToggle + ) { + columnsPicker.hidden = true; + } +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !columnsPicker.hidden) { + columnsPicker.hidden = true; + } }); -/////////////////// -// 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"; +// ─── localStorage persistence ───────────────────────────────────────────────── +const LS_KEY = "models.dev:cols"; + +function saveColsToStorage(cols: string[]): void { + try { + localStorage.setItem(LS_KEY, cols.join(",")); + } catch { + // localStorage unavailable (e.g. private browsing with strict settings) + } +} + +function loadColsFromStorage(): string[] | null { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + const cols = raw + .split(",") + .filter((c) => (ALL_COLUMN_IDS as readonly string[]).includes(c)); + return cols.length > 0 ? cols : null; + } catch { + return null; + } +} + +// ─── 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, }); - updateQueryParams({ search: value || null }); + const newPath = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.pushState({}, "", newPath); } -search.addEventListener("input", () => { - filterTable(search.value); -}); +function applyUrlState() { + const urlParams = new URLSearchParams(window.location.search); + const state = parseUrlState(urlParams); + + globalFilter = state.search; + searchInput.value = state.search; + sorting = state.sort + ? [{ id: state.sort, desc: state.order === "desc" }] + : []; + + // Priority: URL cols= param > localStorage > DEFAULT_COLUMN_IDS + let activeCols: string[]; + if (urlParams.has("cols")) { + // Explicit URL param — use it (already parsed and validated in state.cols) + activeCols = state.cols; + } else { + // No URL param — check localStorage, fall back to defaults + activeCols = loadColsFromStorage() ?? [...DEFAULT_COLUMN_IDS]; + } -document.addEventListener("keydown", (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - search.focus(); + const newVisibility: VisibilityState = {}; + for (const id of ALL_COLUMN_IDS) { + newVisibility[id] = activeCols.includes(id); } + columnVisibility = newVisibility; + + const filtered = globalFilter + ? searchRows(globalFilter, allRows) ?? allRows + : allRows; + rows = filtered; + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { ...prev.state, sorting, columnVisibility }, + })); + + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ ...virtualizer.options, count: rowCount }); + virtualizer._willUpdate(); + virtualizer.scrollToOffset(0); + + renderHead(); + renderRows(); + buildColumnPicker(); +} + +// ─── Search ─────────────────────────────────────────────────────────────────── +const debouncedSearch = debounce((value: string) => { + globalFilter = value; + afterStateChange(); +}, 150); + +searchInput.addEventListener("input", () => { + debouncedSearch(searchInput.value); }); -search.addEventListener("keydown", (e) => { +searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") { - search.value = ""; - search.dispatchEvent(new Event("input")); + debouncedSearch.cancel(); + searchInput.value = ""; + globalFilter = ""; + afterStateChange(); } }); -/////////////////////////////////// -// Handle Copy model ID function -/////////////////////////////////// -(window as any).copyModelId = async ( - button: HTMLButtonElement, - modelId: string -) => { - try { - if (navigator.clipboard) { - await navigator.clipboard.writeText(modelId); +document.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + searchInput.focus(); + } +}); - // Switch to check icon - const copyIcon = button.querySelector(".copy-icon") as HTMLElement; - const checkIcon = button.querySelector(".check-icon") as HTMLElement; +// ─── Help modal ─────────────────────────────────────────────────────────────── +let savedScrollY = 0; - copyIcon.style.display = "none"; - checkIcon.style.display = "block"; +helpButton.addEventListener("click", () => { + savedScrollY = window.scrollY; + document.body.style.position = "fixed"; + document.body.style.top = `-${savedScrollY}px`; + modal.showModal(); +}); - // Switch back after 1 second - setTimeout(() => { - copyIcon.style.display = "block"; - checkIcon.style.display = "none"; - }, 1000); - } - } catch (err) { - console.error("Failed to copy text: ", err); - } -}; +function closeDialog() { + modal.close(); + document.body.style.position = ""; + document.body.style.top = ""; + window.scrollTo(0, savedScrollY); +} -/////////////////////////////////// -// Initialize State from URL -/////////////////////////////////// -function initializeFromURL() { - const params = getQueryParams(); +modalClose.addEventListener("click", closeDialog); +modal.addEventListener("cancel", closeDialog); +modal.addEventListener("click", (e) => { + if (e.target === modal) closeDialog(); +}); - (() => { - const searchQuery = params.get("search"); - if (!searchQuery) return; - search.value = searchQuery; - filterTable(searchQuery); - })(); +// ─── Init ───────────────────────────────────────────────────────────────────── +function init() { + const dataEl = document.getElementById("model-data"); + const api = JSON.parse(dataEl!.textContent!); + allRows = flattenProviders(api as any); - (() => { - const columnName = params.get("sort"); - if (!columnName) return; + // Default sort: provider name, then model name + allRows.sort((a, b) => { + const p = a.providerName.localeCompare(b.providerName); + return p !== 0 ? p : a.name.localeCompare(b.name); + }); - const columnIndex = getColumnIndexByUrlName(columnName); - if (columnIndex === -1) return; + computedColumnSizes = computeColumnSizes(allRows); - const direction = (params.get("order") as "asc" | "desc") || "asc"; - sortTable(columnIndex, direction); - })(); + buildSearchIndex(allRows); + rows = allRows; + table.setOptions((prev) => ({ ...prev, data: rows })); + applyUrlState(); } -document.addEventListener("DOMContentLoaded", initializeFromURL); -window.addEventListener("popstate", initializeFromURL); +window.addEventListener("popstate", applyUrlState); +document.addEventListener("DOMContentLoaded", () => { + init(); +}); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index a1bdfcc2f..66c139b69 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}
+
+ + + +
+ +

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/search.test.ts b/packages/web/src/search.test.ts new file mode 100644 index 000000000..3b12ea3ff --- /dev/null +++ b/packages/web/src/search.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, beforeAll } from "bun:test"; +import { SEARCH_FIELD_NAMES, buildSearchIndex, searchRows } from "./search.js"; +import type { Row } from "./data.js"; + +// --------------------------------------------------------------------------- +// Mock rows +// --------------------------------------------------------------------------- + +const anthropicSonnet: Row = { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + family: "claude-sonnet", + reasoning: false, + tool_call: true, + attachment: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 3.0, output: 15.0 }, + limit: { context: 200_000, output: 8_192 }, + release_date: "2024-10-22", + last_updated: "2024-10-22", +}; + +const anthropicHaiku: Row = { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + family: "claude-haiku", + reasoning: false, + tool_call: true, + attachment: 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", +}; + +const openaiGpt4o: Row = { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-4o", + name: "GPT-4o", + family: "gpt-4o", + reasoning: false, + tool_call: true, + attachment: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 2.5, output: 10.0 }, + limit: { context: 128_000, output: 16_384 }, + release_date: "2024-05-13", + last_updated: "2024-05-13", +}; + +const googleGemini: Row = { + providerId: "google", + providerName: "Google", + modelId: "gemini-1.5-pro", + name: "Gemini 1.5 Pro", + family: "gemini-1.5", + reasoning: false, + tool_call: true, + attachment: true, + open_weights: false, + modalities: { input: ["text", "image", "audio"], output: ["text"] }, + cost: { input: 1.25, output: 5.0 }, + limit: { context: 1_000_000, output: 8_192 }, + release_date: "2024-02-15", + last_updated: "2024-02-15", +}; + +const MOCK_ROWS: Row[] = [anthropicSonnet, anthropicHaiku, openaiGpt4o, googleGemini]; + +// --------------------------------------------------------------------------- +// SEARCH_FIELD_NAMES +// --------------------------------------------------------------------------- + +describe("SEARCH_FIELD_NAMES", () => { + it("contains exactly the expected text fields", () => { + expect(SEARCH_FIELD_NAMES).toEqual([ + "providerName", + "name", + "modelId", + "providerId", + "family", + ]); + }); + + it("does NOT contain cost fields", () => { + expect(SEARCH_FIELD_NAMES).not.toContain("cost"); + expect(SEARCH_FIELD_NAMES).not.toContain("input"); + expect(SEARCH_FIELD_NAMES).not.toContain("output"); + }); + + it("does NOT contain boolean capability fields", () => { + expect(SEARCH_FIELD_NAMES).not.toContain("reasoning"); + expect(SEARCH_FIELD_NAMES).not.toContain("tool_call"); + expect(SEARCH_FIELD_NAMES).not.toContain("attachment"); + expect(SEARCH_FIELD_NAMES).not.toContain("open_weights"); + }); + + it("does NOT contain limit fields", () => { + expect(SEARCH_FIELD_NAMES).not.toContain("limit"); + expect(SEARCH_FIELD_NAMES).not.toContain("context"); + }); +}); + +// --------------------------------------------------------------------------- +// searchRows +// --------------------------------------------------------------------------- + +describe("searchRows", () => { + beforeAll(() => { + buildSearchIndex(MOCK_ROWS); + }); + + it("returns null for empty query (show all)", () => { + expect(searchRows("", MOCK_ROWS)).toBeNull(); + }); + + it("returns null for whitespace-only query", () => { + expect(searchRows(" ", MOCK_ROWS)).toBeNull(); + }); + + it("exact match: 'claude' finds Claude models", () => { + const results = searchRows("claude", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.every((r) => r.name.toLowerCase().includes("claude"))).toBe(true); + }); + + it("fuzzy match: 'claud' (typo) still finds Claude models", () => { + const results = searchRows("claud", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.some((r) => r.name.toLowerCase().includes("claude"))).toBe(true); + }); + + it("prefix match: 'gem' finds Gemini models", () => { + const results = searchRows("gem", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.some((r) => r.name.toLowerCase().includes("gemini"))).toBe(true); + }); + + it("provider search: 'anthropic' finds Anthropic models", () => { + const results = searchRows("anthropic", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.every((r) => r.providerId === "anthropic")).toBe(true); + }); + + it("family search: 'gpt' finds GPT family models", () => { + const results = searchRows("gpt", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!.length).toBeGreaterThanOrEqual(1); + expect(results!.some((r) => r.family?.includes("gpt"))).toBe(true); + }); + + it("cost value NOT matched: '$15.00' returns empty array (cost not indexed)", () => { + // Dollar-sign prefix can't appear in any indexed text field (providerName, name, modelId, providerId, family) + const results = searchRows("$15.00", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!).toHaveLength(0); + }); + + it("boolean value NOT matched: 'true' returns empty array", () => { + // 'true' does not appear in any indexed text field + const results = searchRows("true", MOCK_ROWS); + expect(results).not.toBeNull(); + expect(results!).toHaveLength(0); + }); + + it("comma-separated OR: 'claude, gpt' finds both Claude and GPT models", () => { + const results = searchRows("claude, gpt", MOCK_ROWS); + expect(results).not.toBeNull(); + const names = results!.map((r) => r.name); + expect(names.some((n) => n.toLowerCase().includes("claude"))).toBe(true); + expect(names.some((n) => n.toLowerCase().includes("gpt"))).toBe(true); + }); + + it("comma-separated OR: 'anthropic, google' finds models from both providers", () => { + const results = searchRows("anthropic, google", MOCK_ROWS); + expect(results).not.toBeNull(); + const providerIds = results!.map((r) => r.providerId); + expect(providerIds).toContain("anthropic"); + expect(providerIds).toContain("google"); + }); +}); diff --git a/packages/web/src/search.ts b/packages/web/src/search.ts new file mode 100644 index 000000000..0e558bada --- /dev/null +++ b/packages/web/src/search.ts @@ -0,0 +1,71 @@ +import MiniSearch from 'minisearch'; +import type { Row } from './data.js'; + +// Fields to index — deliberately excludes cost values, booleans, limits, dates +const SEARCH_FIELDS = ['providerName', 'name', 'modelId', 'providerId', 'family'] as const; + +export const SEARCH_FIELD_NAMES = [...SEARCH_FIELDS]; // exported for testing + +let searchIndex: MiniSearch | null = null; + +export function buildSearchIndex(rows: Row[]): void { + // Create MiniSearch instance + // IMPORTANT: modelId is NOT unique across providers (same model appears under multiple providers) + // Use array index as the unique ID field + searchIndex = new MiniSearch({ + fields: [...SEARCH_FIELDS], + storeFields: [], + idField: '_searchId', + searchOptions: { + fuzzy: 0.2, // typo tolerance + prefix: true, // prefix matching ("claud" matches "claude") + boost: { name: 2, providerName: 1.5 }, // relevance weighting + }, + }); + + // Add rows with composite IDs (array index) + const docs = rows.map((row, i) => ({ + _searchId: i, + providerName: row.providerName, + name: row.name, + modelId: row.modelId, + providerId: row.providerId, + family: row.family ?? '', + })); + searchIndex.addAll(docs); +} + +export function searchRows(query: string, rows: Row[]): Row[] | null { + if (!searchIndex || !query.trim()) return null; // null = show all (no filter) + + // Support comma-separated OR terms + const terms = query.split(',').map(t => t.trim()).filter(Boolean); + if (terms.length === 0) return null; + + // Union of results for each term (OR logic) + const matchedIndices = new Set(); + for (const term of terms) { + const results = searchIndex.search(term, { fuzzy: 0.2, prefix: true }); + for (const r of results) { + matchedIndices.add(r.id as number); + } + } + + return Array.from(matchedIndices).map(i => rows[i]); +} + +export function debounce void>( + fn: T, + delay: number, +): T & { cancel(): void } { + let timer: ReturnType | null = null; + const debounced = (...args: any[]) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; + debounced.cancel = () => { + if (timer) clearTimeout(timer); + timer = null; + }; + return debounced as T & { cancel(): void }; +} diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 5302f92d5..36db4f944 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); @@ -70,6 +74,10 @@ const server = Bun.serve({ let html = await result.then((r) => r.text()); html = html.replace("", Rendered); + html = html.replace( + '', + '' + ); return new Response(html, { headers: { "Content-Type": "text/html", diff --git a/packages/web/src/url-state.test.ts b/packages/web/src/url-state.test.ts new file mode 100644 index 000000000..ddbbb8917 --- /dev/null +++ b/packages/web/src/url-state.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import { + ALL_COLUMN_IDS, + DEFAULT_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([...DEFAULT_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"]); + }); + + it("explicit cols= in URL overrides defaults", () => { + const state = parseUrlState( + new URLSearchParams("cols=provider,model,reasoning-cost,weights"), + ); + expect(state.cols).toEqual([ + "provider", + "model", + "reasoning-cost", + "weights", + ]); + // Should NOT equal the defaults + expect(state.cols).not.toEqual(DEFAULT_COLUMN_IDS); + }); +}); + +describe("serializeUrlState", () => { + it("omits cols when matching defaults", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...DEFAULT_COLUMN_IDS], + }); + expect(params.get("cols")).toBeNull(); + }); + + it("includes cols when all columns visible (not default)", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("cols")).toBe(ALL_COLUMN_IDS.join(",")); + }); + + 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: [...DEFAULT_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..3480ce495 --- /dev/null +++ b/packages/web/src/url-state.ts @@ -0,0 +1,78 @@ +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 const DEFAULT_COLUMN_IDS: string[] = [ + "provider", + "model", + "family", + "model-id", + "tool-call", + "reasoning", + "input-cost", + "output-cost", + "context-limit", +]; + +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)) + : [...DEFAULT_COLUMN_IDS]; + + return { + search: params.get("search") ?? "", + sort: params.get("sort"), + order: params.get("order") === "desc" ? "desc" : "asc", + cols: cols.length > 0 ? cols : [...DEFAULT_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 isDefault = + state.cols.length === DEFAULT_COLUMN_IDS.length && + DEFAULT_COLUMN_IDS.every((id) => state.cols.includes(id)); + if (!isDefault) params.set("cols", state.cols.join(",")); + return params; +}