Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pages/models.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const base = import.meta.env.BASE_URL;
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 relative">
<p class="text-xs font-semibold tracking-[0.2em] uppercase mb-3 text-blue-200">Model API</p>
<h1 class="font-display text-4xl sm:text-5xl font-bold tracking-tight">Model Registry</h1>
<p class="mt-4 max-w-2xl text-lg text-blue-100">Open-weight models served with zero data retention. Compare context limits and token prices, then copy the exact API ID.</p>
<p class="mt-4 max-w-2xl text-lg text-blue-100">Open-weight models served with zero data retention. Compare capabilities, context limits, and token prices, then copy the exact API ID.</p>
<div class="flex flex-wrap gap-x-6 gap-y-2 mt-6 text-sm font-semibold">
<a class="underline underline-offset-4 decoration-blue-300 hover:decoration-white" href={`${base}models/guide/`}>How to read the registry</a>
<a class="underline underline-offset-4 decoration-blue-300 hover:decoration-white" href={`${base}docs/api-keys/`}>Model API documentation</a>
Expand Down
15 changes: 13 additions & 2 deletions src/scripts/model-availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type Offering = z.infer<typeof offeringSchema>;
const capabilityNames = new Map([
['text-generation', 'Text generation'],
['automatic-speech-recognition', 'Speech recognition'],
['text-to-speech', 'Speech generation'],
['text-to-image', 'Image generation'],
['vision', 'Image input'],
['function-calling', 'Function calling'],
['structured-output', 'Structured output'],
Expand Down Expand Up @@ -91,6 +93,8 @@ const iconPaths = new Map([
['structured-output', 'M8 3H5v6l-2 3 2 3v6h3 M16 3h3v6l2 3-2 3v6h-3 M10 9h4 M10 15h4'],
['text-generation', 'M4 4h16v12H8l-4 4V4Z M8 8h8 M8 12h5'],
['automatic-speech-recognition', 'M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0V5Z M5 10v2a7 7 0 0 0 14 0v-2 M12 19v3 M8 22h8'],
['text-to-speech', 'M11 5 6 9H2v6h4l5 4V5Z M15.5 8.5a5 5 0 0 1 0 7 M19 5a10 10 0 0 1 0 14'],
['text-to-image', 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M11 9a2 2 0 1 1-4 0 2 2 0 0 1 4 0 M21 15l-5-5L5 21'],
['long-context', 'M6 3h9l4 4v14H6V3Z M14 3v5h5 M9 12h7 M9 16h7'],
['open-weights', 'M7 11V7a5 5 0 0 1 9-3 M5 11h14v10H5V11Z M12 15v3'],
]);
Expand Down Expand Up @@ -206,17 +210,24 @@ function apiIdentity(offering: Offering): HTMLElement {
return api;
}

// Speech and image models are priced per audio minute or image tile, not per
// token, and have no text context window to report.
const nonTokenCapabilities = new Set(['automatic-speech-recognition', 'text-to-speech', 'text-to-image']);

function offeringRow(offering: Offering): HTMLElement {
const row = element('div', '', 'model-facts');
const specs = figure('Context limit', offering.context_length === null ? 'Not published' : `${integers.format(offering.context_length)} tokens`);
const nonToken = offering.capabilities.some(capability => nonTokenCapabilities.has(capability));
const specs = offering.context_length === null && nonToken
? element('div', '', 'spec')
: figure('Context limit', offering.context_length === null ? 'Not published' : `${integers.format(offering.context_length)} tokens`);
const capabilities = element('div', '', 'capabilities');
for (const capability of offering.capabilities) capabilities.append(badge(capabilityNames.get(capability) ?? capability, capability));
if (offering.capabilities.length === 0) capabilities.append(element('span', 'Capabilities not published', 'spec-value'));
if (offering.context_length !== null && offering.context_length >= 100_000) capabilities.append(badge('Long context', 'long-context'));

const prices = element('div', '', 'offering-prices');
if (offering.pricing === undefined) {
prices.append(figure('USD / million tokens', 'Token prices not published'));
prices.append(nonToken ? figure('Pricing', 'Not priced per token') : figure('USD / million tokens', 'Token prices not published'));
} else {
const { input, output, basis } = offering.pricing;
const prefix = basis === 'starting_at' ? 'from ' : '';
Expand Down
5 changes: 5 additions & 0 deletions tests/models_page_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def pricing(input_price: float, output_price: float, basis: str = "standard") ->
offering("same-name-a", name="Identical name"),
offering("same-name-b", name="Identical name"),
offering("image-route", name="Image generator", capabilities=["text-to-image"], pricing=pricing(0.00000001, 0.1)),
offering("flux-1-schnell", "workers-ai", name="FLUX.1 schnell", capabilities=["text-to-image"], context_length=None),
]}
MODEL_COUNT = len(CATALOG["data"])

Expand Down Expand Up @@ -89,6 +90,10 @@ def test_canonical_models_and_copy(page: Page) -> None:
expect(model_card(page, "deepseek-v3.2-speciale")).to_contain_text("Input from $0.04")
expect(model_card(page, "image-route")).to_contain_text("$0.00000001")
expect(model_card(page, "new-release-2099")).to_contain_text("Token prices not published")
flux = model_card(page, "flux-1-schnell")
expect(flux).to_contain_text("Image generation")
expect(flux).to_contain_text("Not priced per token")
expect(flux).not_to_contain_text("Context limit")
summary = model_card(page, "gpt-oss-120b").locator(".model-summary")
expect(summary).to_contain_text("116.8B")
summary.get_by_text("Details and sources", exact=True).press("Enter")
Expand Down
Loading