Compare commits

...

2 Commits

Author SHA1 Message Date
Luke Parker 589619e2aa fix(web): restore provider logo tint for external SVGs
Normalize built and served logo SVGs so currentColor resolves to the themed gray used before provider logos moved to external images.
2026-04-20 18:05:41 +10:00
Luke Parker 041b6a3aef feat(web): load model rows from /api.json
Shrink the initial HTML payload by keeping the table shell server-rendered and filling rows client-side. This preserves the current UI while removing the giant SSR table from the document.
2026-04-20 17:53:36 +10:00
6 changed files with 421 additions and 357 deletions
+9 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { Rendered, Providers } from "../src/render";
import { normalizeLogoSvg } from "../src/logo.js";
import fs from "fs/promises";
import path from "path";
import { $ } from "bun";
@@ -23,7 +24,10 @@ await fs.mkdir("./dist/logos", { recursive: true });
const defaultLogoPath = "../../providers/logo.svg";
const defaultLogo = Bun.file(defaultLogoPath);
if (await defaultLogo.exists()) {
await Bun.write("./dist/logos/default.svg", defaultLogo);
await Bun.write(
"./dist/logos/default.svg",
normalizeLogoSvg(await defaultLogo.text())
);
}
// Then copy provider-specific logos
@@ -36,7 +40,10 @@ for (const entry of entries) {
const logoFile = Bun.file(logoPath);
if (await logoFile.exists()) {
await Bun.write(`./dist/logos/${provider}.svg`, logoFile);
await Bun.write(
`./dist/logos/${provider}.svg`,
normalizeLogoSvg(await logoFile.text())
);
}
}
}
+14 -6
View File
@@ -319,15 +319,12 @@ tbody {
gap: 0.375rem;
}
.provider-cell span:first-child {
.provider-logo {
flex: 0 0 auto;
}
.provider-cell svg {
display: block;
width: 1rem;
height: 1rem;
color: var(--color-text-secondary);
object-fit: contain;
}
.model-id-cell {
@@ -416,6 +413,17 @@ tbody {
.modality-icon:hover::after {
opacity: 1;
}
tr.loading-row td,
tr.error-row td {
padding: 1rem 0.75rem;
text-align: center;
font-family: inherit;
font-size: 0.875rem;
font-weight: 400;
text-transform: none;
color: var(--color-text-secondary);
}
}
dialog::backdrop {
@@ -546,4 +554,4 @@ dialog {
}
}
}
}
+367 -65
View File
@@ -1,7 +1,248 @@
interface ApiCost {
input?: number;
output?: number;
reasoning?: number;
cache_read?: number;
cache_write?: number;
input_audio?: number;
output_audio?: number;
}
interface ApiLimit {
context: number;
input?: number;
output: number;
}
interface ApiModel {
name: string;
family?: string;
status?: string;
tool_call: boolean;
reasoning: boolean;
modalities: {
input: string[];
output: string[];
};
cost?: ApiCost;
limit: ApiLimit;
structured_output?: boolean;
temperature: boolean;
open_weights: boolean;
knowledge?: string;
release_date: string;
last_updated: string;
}
interface ApiProvider {
name: string;
models: Record<string, ApiModel>;
}
type ApiResponse = Record<string, ApiProvider>;
const COLUMN_COUNT = 25;
const modal = document.getElementById("modal") as HTMLDialogElement;
const modalClose = document.getElementById("close")!;
const help = document.getElementById("help")!;
const search = document.getElementById("search")! as HTMLInputElement;
const tableBody = document.getElementById("table-body")! as HTMLTableSectionElement;
const copyIcon = `
<svg
class="copy-icon"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
`;
const checkIcon = `
<svg
class="check-icon"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="display: none;"
>
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
`;
const modalityIcons: Record<string, { label: string; svg: string }> = {
text: {
label: "Text",
svg: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4,7 4,4 20,4 20,7"></polyline>
<line x1="9" y1="20" x2="15" y2="20"></line>
<line x1="12" y1="4" x2="12" y2="20"></line>
</svg>
`,
},
image: {
label: "Image",
svg: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
<circle cx="9" cy="9" r="2"></circle>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
</svg>
`,
},
audio: {
label: "Audio",
svg: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
`,
},
video: {
label: "Video",
svg: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m22 8-6 4 6 4V8Z"></path>
<rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect>
</svg>
`,
},
pdf: {
label: "PDF",
svg: `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10,9 9,9 8,9"></polyline>
</svg>
`,
},
};
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function renderCost(cost?: number) {
return cost === undefined ? "-" : `$${cost.toFixed(2)}`;
}
function renderModalityIcon(modality: string) {
const icon = modalityIcons[modality];
if (!icon) return "";
return `<span class="modality-icon" data-tooltip="${icon.label}">${icon.svg}</span>`;
}
function renderModalities(modalities: string[]) {
return `<div class="modalities">${modalities.map(renderModalityIcon).join("")}</div>`;
}
function renderProviderLogo(providerId: string) {
return `<img class="provider-logo" src="/logos/${encodeURIComponent(providerId)}.svg" alt="" width="16" height="16" loading="lazy" decoding="async" />`;
}
function renderRow(
providerId: string,
providerName: string,
modelId: string,
model: ApiModel
) {
const safeProviderId = escapeHtml(providerId);
const safeProviderName = escapeHtml(providerName);
const safeModelId = escapeHtml(modelId);
const safeModelName = escapeHtml(model.name);
const safeFamily = escapeHtml(model.family ?? "-");
const safeKnowledge = escapeHtml(model.knowledge?.substring(0, 7) ?? "-");
const safeReleaseDate = escapeHtml(model.release_date);
const safeLastUpdated = escapeHtml(model.last_updated);
return `
<tr data-model-row="true">
<td>
<div class="provider-cell">
${renderProviderLogo(providerId)}
<span>${safeProviderName}</span>
</div>
</td>
<td>${safeModelName}</td>
<td>${safeFamily}</td>
<td>${safeProviderId}</td>
<td>
<div class="model-id-cell">
<span class="model-id-text">${safeModelId}</span>
<button class="copy-button" type="button" data-model-id="${safeModelId}" aria-label="Copy model ID">
${copyIcon}
${checkIcon}
</button>
</div>
</td>
<td>${model.tool_call ? "Yes" : "No"}</td>
<td>${model.reasoning ? "Yes" : "No"}</td>
<td>${renderModalities(model.modalities.input)}</td>
<td>${renderModalities(model.modalities.output)}</td>
<td>${renderCost(model.cost?.input)}</td>
<td>${renderCost(model.cost?.output)}</td>
<td>${renderCost(model.cost?.reasoning)}</td>
<td>${renderCost(model.cost?.cache_read)}</td>
<td>${renderCost(model.cost?.cache_write)}</td>
<td>${renderCost(model.cost?.input_audio)}</td>
<td>${renderCost(model.cost?.output_audio)}</td>
<td>${model.limit.context.toLocaleString()}</td>
<td>${model.limit.input?.toLocaleString() ?? "-"}</td>
<td>${model.limit.output.toLocaleString()}</td>
<td>${model.structured_output === undefined ? "-" : model.structured_output ? "Yes" : "No"}</td>
<td>${model.temperature ? "Yes" : "No"}</td>
<td>${model.open_weights ? "Open" : "Closed"}</td>
<td>${safeKnowledge}</td>
<td>${safeReleaseDate}</td>
<td>${safeLastUpdated}</td>
</tr>
`;
}
function renderTableRows(providers: ApiResponse) {
return 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]) =>
renderRow(providerId, provider.name, modelId, model)
)
)
.join("");
}
function setStatusRow(message: string, className = "loading-row") {
tableBody.innerHTML = `<tr class="${className}"><td colspan="${COLUMN_COUNT}">${escapeHtml(message)}</td></tr>`;
}
/////////////////////////
// URL State Management
@@ -10,7 +251,10 @@ function getQueryParams() {
return new URLSearchParams(window.location.search);
}
function updateQueryParams(updates: Record<string, string | null>) {
function updateQueryParams(
updates: Record<string, string | null>,
historyMode: "push" | "replace" = "push"
) {
const params = getQueryParams();
for (const [key, value] of Object.entries(updates)) {
if (value) {
@@ -19,9 +263,18 @@ function updateQueryParams(updates: Record<string, string | null>) {
params.delete(key);
}
}
const newPath = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
if (newPath === `${window.location.pathname}${window.location.search}`) return;
if (historyMode === "replace") {
window.history.replaceState({}, "", newPath);
return;
}
window.history.pushState({}, "", newPath);
}
@@ -65,66 +318,71 @@ modal.addEventListener("click", (e) => {
////////////////////
// Handle Sorting
////////////////////
let currentSort = { column: -1, direction: "asc" };
let currentSort = { column: -1, direction: "asc" as "asc" | "desc" };
function sortTable(column: number, direction: "asc" | "desc") {
function updateSortIndicators(column: number, direction: "asc" | "desc") {
const headers = document.querySelectorAll("th.sortable");
headers.forEach((header, i) => {
const indicator = header.querySelector(".sort-indicator")!;
indicator.textContent = i === column ? (direction === "asc" ? "↑" : "↓") : "";
});
}
function clearSortIndicators() {
updateSortIndicators(-1, "asc");
}
function sortTable(
column: number,
direction: "asc" | "desc",
syncUrl = true
) {
const header = document.querySelectorAll("th.sortable")[column];
const columnType = header.getAttribute("data-type");
const columnType = header?.getAttribute("data-type");
if (!columnType) return;
// update state
currentSort = { column, direction };
updateQueryParams({
sort: getColumnNameForURL(header),
order: direction,
});
if (syncUrl) {
updateQueryParams(
{
sort: getColumnNameForURL(header),
order: direction,
},
"push"
);
}
// sort rows
const tbody = document.querySelector("table tbody")!;
const rows = Array.from(
tbody.querySelectorAll("tr")
tableBody.querySelectorAll('tr[data-model-row="true"]')
) 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);
}
const comparison =
columnType === "number" || columnType === "modalities"
? (aValue as number) - (bValue as number)
: (aValue as string).localeCompare(bValue as string);
return direction === "asc" ? comparison : -comparison;
});
rows.forEach((row) => tbody.appendChild(row));
// update sort indicators
const headers = document.querySelectorAll("th.sortable");
headers.forEach((header, i) => {
const indicator = header.querySelector(".sort-indicator")!;
if (i === column) {
indicator.textContent = direction === "asc" ? "↑" : "↓";
} else {
indicator.textContent = "";
}
});
rows.forEach((row) => tableBody.appendChild(row));
updateSortIndicators(column, direction);
}
function getCellValue(
cell: HTMLTableCellElement,
type: string
): string | number | undefined {
if (type === "modalities")
if (type === "modalities") {
return cell.querySelectorAll(".modality-icon").length;
}
const text = cell.textContent?.trim() || "";
if (text === "-") return;
@@ -146,22 +404,31 @@ document.querySelectorAll("th.sortable").forEach((header) => {
///////////////////
// Handle Search
///////////////////
function filterTable(value: string) {
const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== "");
const rows = document.querySelectorAll(
"table tbody tr"
function filterTable(value: string, syncUrl = true) {
const lowerCaseValues = value
.toLowerCase()
.split(",")
.map((part) => part.trim())
.filter(Boolean);
const rows = tableBody.querySelectorAll(
'tr[data-model-row="true"]'
) as NodeListOf<HTMLTableRowElement>;
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)));
const isVisible =
lowerCaseValues.length === 0 ||
lowerCaseValues.some((lowerCaseValue) =>
cellTexts.some((text) => text.includes(lowerCaseValue))
);
row.style.display = isVisible ? "" : "none";
});
updateQueryParams({ search: value || null });
if (syncUrl) {
updateQueryParams({ search: value || null }, "replace");
}
}
search.addEventListener("input", () => {
@@ -185,22 +452,22 @@ search.addEventListener("keydown", (e) => {
///////////////////////////////////
// Handle Copy model ID function
///////////////////////////////////
(window as any).copyModelId = async (
button: HTMLButtonElement,
modelId: string
) => {
tableBody.addEventListener("click", async (event) => {
const target = event.target as HTMLElement;
const button = target.closest(".copy-button") as HTMLButtonElement | null;
const modelId = button?.dataset.modelId;
if (!button || !modelId) return;
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";
@@ -209,32 +476,67 @@ search.addEventListener("keydown", (e) => {
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
});
///////////////////////////////////
// Initialize State from URL
///////////////////////////////////
let tableLoaded = false;
function initializeFromURL() {
if (!tableLoaded) return;
const params = getQueryParams();
const searchQuery = params.get("search") ?? "";
search.value = searchQuery;
filterTable(searchQuery, false);
(() => {
const searchQuery = params.get("search");
if (!searchQuery) return;
search.value = searchQuery;
filterTable(searchQuery);
})();
const columnName = params.get("sort");
if (!columnName) {
currentSort = { column: -1, direction: "asc" };
clearSortIndicators();
return;
}
(() => {
const columnName = params.get("sort");
if (!columnName) return;
const columnIndex = getColumnIndexByUrlName(columnName);
if (columnIndex === -1) return;
const columnIndex = getColumnIndexByUrlName(columnName);
if (columnIndex === -1) return;
const direction = (params.get("order") as "asc" | "desc") || "asc";
sortTable(columnIndex, direction);
})();
const direction = (params.get("order") as "asc" | "desc") || "asc";
sortTable(columnIndex, direction, false);
}
document.addEventListener("DOMContentLoaded", initializeFromURL);
window.addEventListener("popstate", initializeFromURL);
async function loadTable() {
try {
const response = await fetch("/api.json");
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status}`);
}
const providers = (await response.json()) as ApiResponse;
const rows = renderTableRows(providers);
tableBody.innerHTML = rows || "";
if (!rows) {
setStatusRow("No models found.");
return;
}
tableLoaded = true;
initializeFromURL();
window.addEventListener("popstate", initializeFromURL);
} catch (error) {
console.error("Failed to load model data:", error);
setStatusRow("Failed to load models.", "loading-row error-row");
}
}
function initializeApp() {
search.value = getQueryParams().get("search") ?? "";
void loadTable();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeApp);
} else {
initializeApp();
}
+16
View File
@@ -0,0 +1,16 @@
const LOGO_THEME_MARKER = "models-dev-logo-theme";
const LOGO_THEME_STYLE = `<style id="${LOGO_THEME_MARKER}">:root{color:#666}@media (prefers-color-scheme: dark){:root{color:#AAA}}</style>`;
export function normalizeLogoSvg(svgText: string) {
if (svgText.includes(LOGO_THEME_MARKER)) {
return svgText;
}
return svgText.replace(/<svg\b[^>]*>/i, (svgTag) => {
const themedTag = svgTag.includes("fill=")
? svgTag
: svgTag.replace("<svg", '<svg fill="currentColor"');
return `${themedTag}${LOGO_THEME_STYLE}`;
});
}
+5 -282
View File
@@ -1,184 +1,15 @@
/** @jsx jsx */
/** @jsxImportSource hono/jsx */
import { generate } from "models.dev";
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")
);
// Function to load SVG content
const loadProviderSvg = async (providerId: string): Promise<string | null> => {
const providerLogoPath = path.join(
import.meta.dir,
"..",
"..",
"..",
"providers",
providerId,
"logo.svg"
);
const defaultLogoPath = path.join(
import.meta.dir,
"..",
"..",
"..",
"providers",
"logo.svg"
);
try {
// Try provider-specific logo first
if (existsSync(providerLogoPath)) {
const file = Bun.file(providerLogoPath);
return await file.text();
}
//
// Fall back to default logo
if (existsSync(defaultLogoPath)) {
const file = Bun.file(defaultLogoPath);
return await file.text();
}
return null;
} catch (error) {
console.warn(`Failed to load logo for provider ${providerId}:`, error);
return null;
}
};
// Create a cache of loaded SVGs at build time
const providerLogos = new Map<string, string>();
// Pre-load all provider logos
for (const [providerId] of Object.entries(Providers)) {
const svgContent = await loadProviderSvg(providerId);
if (svgContent) {
providerLogos.set(providerId, svgContent);
}
}
function renderProviderLogo(providerId: string) {
const svgContent = providerLogos.get(providerId) || "";
return <span dangerouslySetInnerHTML={{ __html: svgContent }} />;
}
const getModalityIcon = (modality: string) => {
switch (modality) {
case "text":
return (
<span class="modality-icon" data-tooltip="Text">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4,7 4,4 20,4 20,7"></polyline>
<line x1="9" y1="20" x2="15" y2="20"></line>
<line x1="12" y1="4" x2="12" y2="20"></line>
</svg>
</span>
);
case "image":
return (
<span class="modality-icon" data-tooltip="Image">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
<circle cx="9" cy="9" r="2"></circle>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
</svg>
</span>
);
case "audio":
return (
<span class="modality-icon" data-tooltip="Audio">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</span>
);
case "video":
return (
<span class="modality-icon" data-tooltip="Video">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m22 8-6 4 6 4V8Z"></path>
<rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect>
</svg>
</span>
);
case "pdf":
return (
<span class="modality-icon" data-tooltip="PDF">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10,9 9,9 8,9"></polyline>
</svg>
</span>
);
default:
return null;
}
};
const renderCost = (cost?: number) => {
return cost === undefined ? "-" : `$${cost.toFixed(2)}`;
};
export const Rendered = renderToString(
<Fragment>
<header>
@@ -342,118 +173,10 @@ export const Rendered = renderToString(
</th>
</tr>
</thead>
<tbody>
{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]) => (
<tr key={`${providerId}-${modelId}`}>
<td>
<div class="provider-cell">
{renderProviderLogo(providerId)}
<span>{provider.name}</span>
</div>
</td>
<td>{model.name}</td>
<td>{model.family ?? "-"}</td>
<td>{providerId}</td>
<td>
<div class="model-id-cell">
<span class="model-id-text">{modelId}</span>
<button
class="copy-button"
onclick={`copyModelId(this, '${modelId}')`}
>
<svg
class="copy-icon"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
width="14"
height="14"
x="8"
y="8"
rx="2"
ry="2"
/>
<path d="m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
<svg
class="check-icon"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="display: none;"
>
<polyline points="20,6 9,17 4,12" />
</svg>
</button>
</div>
</td>
<td>{model.tool_call ? "Yes" : "No"}</td>
<td>{model.reasoning ? "Yes" : "No"}</td>
<td>
<div class="modalities">
{model.modalities.input.map((modality) =>
getModalityIcon(modality)
)}
</div>
</td>
<td>
<div class="modalities">
{model.modalities.output.map((modality) =>
getModalityIcon(modality)
)}
</div>
</td>
<td>{renderCost(model.cost?.input)}</td>
<td>{renderCost(model.cost?.output)}</td>
<td>{renderCost(model.cost?.reasoning)}</td>
<td>{renderCost(model.cost?.cache_read)}</td>
<td>{renderCost(model.cost?.cache_write)}</td>
<td>{renderCost(model.cost?.input_audio)}</td>
<td>{renderCost(model.cost?.output_audio)}</td>
<td>{model.limit.context.toLocaleString()}</td>
<td>{model.limit.input?.toLocaleString() ?? "-"}</td>
<td>{model.limit.output.toLocaleString()}</td>
<td>
{model.structured_output === undefined
? "-"
: model.structured_output
? "Yes"
: "No"}
</td>
<td>{model.temperature ? "Yes" : "No"}</td>
<td>{model.open_weights ? "Open" : "Closed"}</td>
<td>
{model.knowledge ? model.knowledge.substring(0, 7) : "-"}
</td>
<td>{model.release_date}</td>
<td>{model.last_updated}</td>
</tr>
))
)}
<tbody id="table-body">
<tr class="loading-row">
<td colspan={25}>Loading models...</td>
</tr>
</tbody>
</table>
<dialog id="modal">
+10 -2
View File
@@ -1,11 +1,19 @@
import Index from "../index.html";
import { Rendered } from "./render";
import { normalizeLogoSvg } from "./logo.js";
import { Providers, Rendered } from "./render";
import path from "path";
Bun.serve({
port: 16_000,
routes: {
"/": Index,
"/api.json": () => {
return Response.json(Providers, {
headers: {
"Cache-Control": "public, max-age=3600",
},
});
},
"/assets/*": (req) => {
const file = Bun.file(
path.join(import.meta.dir, new URL(req.url).pathname)
@@ -38,7 +46,7 @@ Bun.serve({
file = Bun.file(defaultLogoPath);
}
return new Response(file, {
return new Response(normalizeLogoSvg(await file.text()), {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=3600",