diff --git a/bin/catalog-import-utils.node.test.ts b/bin/catalog-import-utils.node.test.ts new file mode 100644 index 00000000000..811ab468c78 --- /dev/null +++ b/bin/catalog-import-utils.node.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + hasMoreCatalogModels, + mapConcurrentOrdered, +} from "./catalog-import-utils"; + +describe("catalog import utilities", () => { + it("stops pagination after all API rows are consumed", () => { + expect(hasMoreCatalogModels(100, 101, 100)).toBe(true); + expect(hasMoreCatalogModels(101, 101, 1)).toBe(false); + expect(hasMoreCatalogModels(100, 101, 0)).toBe(false); + }); + + it("keeps workers busy and returns results in input order", async () => { + let active = 0; + let maxActive = 0; + const completed: number[] = []; + const results = await mapConcurrentOrdered( + [30, 5, 10, 1], + 2, + async (delay, index) => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, delay)); + active--; + return index; + }, + (count) => completed.push(count), + ); + + expect(results).toEqual([0, 1, 2, 3]); + expect(maxActive).toBe(2); + expect(completed).toEqual([1, 2, 3, 4]); + }); + + it("stops dispatching and drains active work after a failure", async () => { + const started: number[] = []; + const finished: number[] = []; + let releaseActive!: () => void; + const active = new Promise((resolve) => { + releaseActive = resolve; + }); + const operation = mapConcurrentOrdered([0, 1, 2], 2, async (_, index) => { + started.push(index); + if (index === 0) throw new Error("failed"); + await active; + finished.push(index); + return index; + }); + let settled = false; + void operation.then( + () => (settled = true), + () => (settled = true), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(started).toEqual([0, 1]); + expect(settled).toBe(false); + releaseActive(); + await expect(operation).rejects.toThrow("failed"); + expect(started).toEqual([0, 1]); + expect(finished).toEqual([1]); + }); +}); diff --git a/bin/catalog-import-utils.ts b/bin/catalog-import-utils.ts new file mode 100644 index 00000000000..06c02873b99 --- /dev/null +++ b/bin/catalog-import-utils.ts @@ -0,0 +1,41 @@ +export function hasMoreCatalogModels( + modelsSeen: number, + totalCount: number, + pageSize: number, +): boolean { + return pageSize > 0 && modelsSeen < totalCount; +} + +export async function mapConcurrentOrdered( + values: readonly T[], + concurrency: number, + mapper: (value: T, index: number) => Promise, + onComplete?: (completed: number) => void, +): Promise { + if (!Number.isInteger(concurrency) || concurrency < 1) + throw new RangeError("concurrency must be a positive integer"); + const results = new Array(values.length); + let nextIndex = 0; + let completed = 0; + let failed = false; + let firstError: unknown; + + async function run(): Promise { + while (!failed && nextIndex < values.length) { + const index = nextIndex++; + try { + results[index] = await mapper(values[index], index); + onComplete?.(++completed); + } catch (error) { + if (!failed) firstError = error; + failed = true; + } + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, run), + ); + if (failed) throw firstError; + return results; +} diff --git a/bin/fetch-catalog-models.ts b/bin/fetch-catalog-models.ts index 13b016d8d48..83b9e997eb0 100644 --- a/bin/fetch-catalog-models.ts +++ b/bin/fetch-catalog-models.ts @@ -25,6 +25,10 @@ import fs from "node:fs"; import path from "node:path"; +import { + hasMoreCatalogModels, + mapConcurrentOrdered, +} from "./catalog-import-utils"; interface CatalogModel { model_id: string; @@ -80,8 +84,6 @@ interface CatalogModel { private?: boolean; created_at?: string; updated_at?: string; - // Returned by the catalog API but not consumed by the docs site. - // Stripped before writing to disk. pricing?: Record; } @@ -188,6 +190,7 @@ async function fetchModelList( const modelIds: string[] = []; let page = 1; let hasMore = true; + let modelsSeen = 0; console.log("Fetching model list from Unified Catalog API..."); console.log(` Base URL: ${API_BASE_URL}`); @@ -225,13 +228,14 @@ async function fetchModelList( } const { count, total_count } = data.result_info!; + modelsSeen += data.result.length; const privateNote = skippedPrivate > 0 ? ` (${skippedPrivate} private skipped)` : ""; console.log( - ` Page ${page}: ${count} models (${modelIds.length}/${total_count})${privateNote}`, + ` Page ${page}: ${count} models (${modelsSeen}/${total_count})${privateNote}`, ); - hasMore = modelIds.length < total_count; + hasMore = hasMoreCatalogModels(modelsSeen, total_count, data.result.length); page++; } @@ -289,26 +293,27 @@ async function fetchFromApi(): Promise { ); // Pass 2: fetch full details for each model - const models: CatalogModel[] = []; - const failed: string[] = []; - - for (let i = 0; i < modelIds.length; i += CONCURRENCY) { - const batch = modelIds.slice(i, i + CONCURRENCY); - const results = await Promise.all( - batch.map((id) => fetchModelDetail(ACCOUNT_ID, API_TOKEN, id)), - ); - - for (let j = 0; j < results.length; j++) { - const result = results[j]; - if (result) { - models.push(result); - } else { - failed.push(batch[j]); + const results = await mapConcurrentOrdered( + modelIds, + CONCURRENCY, + async (modelId) => { + try { + return await fetchModelDetail(ACCOUNT_ID, API_TOKEN, modelId); + } catch (error) { + console.error(` Failed to fetch ${modelId}:`, error); + return null; } - } + }, + (fetched) => + process.stdout.write(`\r ${fetched}/${modelIds.length} models fetched`), + ); - const fetched = Math.min(i + CONCURRENCY, modelIds.length); - process.stdout.write(`\r ${fetched}/${modelIds.length} models fetched`); + const models: CatalogModel[] = []; + const failed: string[] = []; + for (let index = 0; index < results.length; index++) { + const result = results[index]; + if (result) models.push(result); + else failed.push(modelIds[index]); } console.log(); @@ -433,10 +438,6 @@ function writeModels(models: CatalogModel[]): void { model.name = model.name.trim(); model.description = model.description.trim(); - // Drop the `pricing` field — it's returned by the catalog API but is - // not consumed by the docs site and isn't declared in the schema. - delete model.pricing; - // Strip credentials from any pre-signed URLs in the response. const redacted = redactCredentialUrls(model); diff --git a/src/components/models/ModelBadges.astro b/src/components/models/ModelBadges.astro index b0d15192dc3..c9032252d47 100644 --- a/src/components/models/ModelBadges.astro +++ b/src/components/models/ModelBadges.astro @@ -55,7 +55,7 @@ for (const { property_id, value } of model.propertiesList) { const badges: BadgeDef[] = [providerBadge, ...propertyBadges]; --- -
    +
      { badges.map((badge) => (
    • diff --git a/src/components/models/ModelCard.astro b/src/components/models/ModelCard.astro index 028eeafea50..48852147649 100644 --- a/src/components/models/ModelCard.astro +++ b/src/components/models/ModelCard.astro @@ -15,6 +15,8 @@ import ModelBadges from "./ModelBadges.astro"; import { authorData } from "~/components/models/data"; import { facetValues, + formatCompactTokens, + formatModelPricing, pinnedModelNames, type ModelCardData, } from "~/util/models"; @@ -43,12 +45,20 @@ const pinnedIndex = pinnedModelNames.indexOf(model.name); const isPinned = pinnedIndex >= 0; // Author logo; initial-letter tile when the author id is unmapped. const authorLogo = authorData[model.author]?.logo; +const contextWindow = formatCompactTokens(model.properties.context_window); +const maxOutputTokens = formatCompactTokens(model.properties.max_output_tokens); +const pricing = formatModelPricing(model.pricing); +const decisionFacts = [ + contextWindow ? `Context: ${contextWindow}` : null, + maxOutputTokens ? `Maximum output: ${maxOutputTokens}` : null, + pricing.length > 0 ? "Pricing listed" : null, +].filter((value): value is string => value !== null); ---
      { isPinned && ( @@ -103,9 +127,9 @@ const authorLogo = authorData[model.author]?.logo; ) } -
      +

      {model.shortName}

      @@ -123,20 +147,48 @@ const authorLogo = authorData[model.author]?.logo;
      - {model.authorName} - + + {model.authorName} + + {model.task}

      {model.description}

      +
      + { + decisionFacts.length > 0 && ( +
        + {decisionFacts.map((fact) => ( +
      • {fact}
      • + ))} +
      + ) + } + +
      diff --git a/src/components/models/ModelCatalog.astro b/src/components/models/ModelCatalog.astro index 8d08920d01a..64fa1222c5a 100644 --- a/src/components/models/ModelCatalog.astro +++ b/src/components/models/ModelCatalog.astro @@ -1,23 +1,9 @@ --- -/** - * ModelCatalog — filterable AI model catalog, rendered inside the docs layout. - * - * The toolbar (search + Task Types / Capabilities / Providers? / Authors - * multi-selects + sort) and the "We found N models" count/clear live in small - * React islands (FilterDropdownWrapper / SortSelectWrapper) built on base-ui, - * styled with Nimbus design tokens. The islands fire CustomEvents; a vanilla - * JS ` diff --git a/src/components/models/ModelFeatures.astro b/src/components/models/ModelFeatures.astro index 36bbc6831c5..2b782c84f67 100644 --- a/src/components/models/ModelFeatures.astro +++ b/src/components/models/ModelFeatures.astro @@ -6,7 +6,7 @@ * beta → Batch → request formats → partner → realtime → unit price → * dashboard pricing link. */ -import type { ModelView } from "~/util/models"; +import { modelCurrencyFormatter, type ModelView } from "~/util/models"; interface Props { model: ModelView; @@ -15,11 +15,6 @@ interface Props { const { model } = Astro.props; const nf = new Intl.NumberFormat("en-US"); -const currencyFormatter = new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 10, -}); // Flatten the property list into a lookup (values are strings). const properties: Record = {}; @@ -194,7 +189,9 @@ const price = Array.isArray(properties.price) Unit Pricing {price - .map((p) => `${currencyFormatter.format(p.price)} ${p.unit}`) + .map( + (p) => `${modelCurrencyFormatter.format(p.price)} ${p.unit}`, + ) .join(", ")} diff --git a/src/content.config.ts b/src/content.config.ts index 28cb62de1a6..4cc46382d4f 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -417,6 +417,7 @@ export const collections = { // Capabilities context_length: z.number().nullable(), max_output_tokens: z.number().nullable(), + pricing: z.record(z.string(), z.unknown()).default({}), supports_async: z.boolean(), // Zero Data Retention (optional — older API rows omit it). diff --git a/src/util/models/index.ts b/src/util/models/index.ts index 0e445d37f56..fc0c9cb4c71 100644 --- a/src/util/models/index.ts +++ b/src/util/models/index.ts @@ -3,6 +3,7 @@ */ export * from "./model-types"; export * from "./model-helpers"; +export * from "./model-format"; export * from "./model-properties"; export * from "./model-schema"; export * from "./model-resolver"; diff --git a/src/util/models/model-format.node.test.ts b/src/util/models/model-format.node.test.ts new file mode 100644 index 00000000000..b5c9a294b09 --- /dev/null +++ b/src/util/models/model-format.node.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { formatCompactTokens, formatModelPricing } from "./model-format"; + +describe("model display formatting", () => { + it("formats token counts compactly", () => { + expect(formatCompactTokens(200_000)).toBe("200K tokens"); + expect(formatCompactTokens(null)).toBeNull(); + }); + + it("formats supported pricing values and omits nested metadata", () => { + expect( + formatModelPricing({ + "Input tokens (per 1M)": 1.25, + "per M output tokens": 2.5, + "per M cached input tokens": 0.25, + "cached output tokens (per 1m)": 0.5, + plan: "included", + metadata: { currency: "USD" }, + }), + ).toEqual([ + "Input (per 1M tokens): $1.25", + "Output (per 1M tokens): $2.50", + "Cached input (per 1M tokens): $0.25", + "Cached output (per 1M tokens): $0.50", + "plan: included", + ]); + }); + + it("preserves non-token billing units", () => { + expect( + formatModelPricing({ + "per input 512x512 tile": 0.000059, + "per step": 0.01, + "per image MP": 0.02, + "per audio minute": 0.03, + "per request": 0.04, + }), + ).toEqual([ + "per input 512x512 tile: $0.000059", + "per step: $0.01", + "per image MP: $0.02", + "per audio minute: $0.03", + "per request: $0.04", + ]); + }); +}); diff --git a/src/util/models/model-format.ts b/src/util/models/model-format.ts new file mode 100644 index 00000000000..00be450ee08 --- /dev/null +++ b/src/util/models/model-format.ts @@ -0,0 +1,48 @@ +const compactNumber = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +export const modelCurrencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 10, +}); + +export function formatCompactTokens(value: unknown): string | null { + const count = Number(value); + return Number.isFinite(count) && count > 0 + ? `${compactNumber.format(count)} tokens` + : null; +} + +export function formatModelPricing( + pricing: Record | undefined, +): string[] { + return Object.entries(pricing ?? {}).flatMap(([label, value]) => { + const displayLabel = formatPricingLabel(label); + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return [`${displayLabel}: ${modelCurrencyFormatter.format(value)}`]; + } + if (typeof value === "string" && value.trim()) { + return [`${displayLabel}: ${value.trim()}`]; + } + return []; + }); +} + +function formatPricingLabel(label: string): string { + const normalized = label.replaceAll("_", " ").replace(/\s+/g, " ").trim(); + const lower = normalized.toLowerCase(); + const tokenPrice = + /^per (?:1)?m (cached )?(input|output) tokens$/.exec(lower) ?? + /^(cached )?(input|output) tokens \(per (?:1)?m\)$/.exec(lower); + if (tokenPrice) { + const direction = tokenPrice[2] === "input" ? "Input" : "Output"; + const displayDirection = tokenPrice[1] + ? `Cached ${direction.toLowerCase()}` + : direction; + return `${displayDirection} (per 1M tokens)`; + } + return normalized; +} diff --git a/src/util/models/model-resolver.ts b/src/util/models/model-resolver.ts index 1388d9aaa3e..6facfd59a9a 100644 --- a/src/util/models/model-resolver.ts +++ b/src/util/models/model-resolver.ts @@ -29,6 +29,27 @@ const isTrue = (v: unknown): boolean => v === true || v === "true"; const authorDisplayName = (author: string): string => authorData[author]?.name ?? author; +function legacyPricing(value: unknown): Record { + if (!Array.isArray(value)) return {}; + return Object.fromEntries( + value.flatMap((entry) => { + if ( + typeof entry !== "object" || + entry === null || + !("unit" in entry) || + !("price" in entry) || + typeof entry.unit !== "string" || + typeof entry.price !== "number" || + !Number.isFinite(entry.price) || + entry.price < 0 + ) { + return []; + } + return [[entry.unit, entry.price]]; + }), + ); +} + function buildView(args: { id: string; name: string; @@ -39,6 +60,7 @@ function buildView(args: { hosting: "hosted" | "proxied"; task: string; description: string; + tags: string[]; properties: Record; propertiesList: { property_id: string; value: unknown }[]; schema: { input: Record; output: Record }; @@ -46,6 +68,7 @@ function buildView(args: { zdrComment?: string | null; modelId?: string; requestFormats?: string[] | null; + pricing?: Record; examples?: ModelExample[]; banner?: ModelBanner | null; digest?: number | string; @@ -68,6 +91,7 @@ function buildView(args: { source: args.source, task: args.task, description: args.description, + tags: args.tags, capabilities, beta: isTrue(args.properties.beta), createdAt: args.createdAt, @@ -75,6 +99,7 @@ function buildView(args: { propertiesList: args.propertiesList, modelId: args.modelId, requestFormats: args.requestFormats ?? null, + pricing: args.pricing, examples: args.examples, banner: args.banner ?? null, schema: args.schema, @@ -131,6 +156,7 @@ export function catalogToResolved(entry: CatalogEntry): ModelView { hosting: "proxied", task: model.task, description: model.description, + tags: Array.isArray(model.tags) ? model.tags : [], properties, propertiesList, schema, @@ -139,6 +165,7 @@ export function catalogToResolved(entry: CatalogEntry): ModelView { zdrComment: model.zdr_comment ?? null, modelId: model.model_id, requestFormats: (model.request_formats as string[] | undefined) ?? null, + pricing: model.pricing, examples: (model.examples as ModelExample[] | undefined) ?? [], banner: (model.banner as ModelBanner | null | undefined) ?? null, digest: entry.digest, @@ -168,6 +195,8 @@ export function legacyToResolved(entry: LegacyEntry): ModelView { hosting: "hosted", task: d.task.name, description: d.description, + tags: Array.isArray(d.tags) ? d.tags : [], + pricing: legacyPricing(properties.price), properties, propertiesList, schema: { diff --git a/src/util/models/model-types.ts b/src/util/models/model-types.ts index 1345748403e..a4392f20434 100644 --- a/src/util/models/model-types.ts +++ b/src/util/models/model-types.ts @@ -59,6 +59,7 @@ export interface ModelView { source: number; task: string; description: string; + tags: string[]; /** Derived capability labels (filter facet + badges). */ capabilities: string[]; beta: boolean; @@ -71,6 +72,7 @@ export interface ModelView { modelId?: string; /** Accepted request formats (e.g. `["responses","chat-completions"]`). Catalog-only. */ requestFormats?: string[] | null; + pricing?: Record; /** Usage (first) + Examples list (rest). Catalog-only. */ examples?: ModelExample[]; /** In-page notice. Catalog-only. */