Files
zeekay 715912e9bf The model pages build from a committed catalogue, so a blip at api.hanzo.ai no longer fails a deploy and a price change alone rebuilds no page
scripts/sync-models.mjs runs in prebuild and saves what the pages read of
/v1/models and /v1/pricing as lib/data/served.json: names, words, windows,
specs, features, tiers and links, no rate and no time. An unreachable API, an
error, a body that is not the document, or a list under half the snapshot's
keeps the committed file and the build goes on; every cloud restart during a
build used to fail the deploy. Rates are read in the browser as before, so a
catalogue that changed only its prices writes the same file and the pages come
out byte for byte (two builds of one tree: no model page differs on data).

lib/api/build.ts and the HANZO_BUILD stamp that keyed its fetch cache go.
2026-10-18 16:48:30 -07:00

59 lines
2.9 KiB
JavaScript

#!/usr/bin/env node
/**
* The catalogue the model pages are built from, as api.hanzo.ai serves it.
*
* node scripts/sync-models.mjs [api base URL]
*
* Saves what lib/models/served.ts reads as lib/data/served.json: each row of
* GET /v1/models (its id, name, words, family, window and outputs, in the
* gateway's order) and each of our own models on GET /v1/pricing (its name,
* words, window, specs, features, tier and links). No rate is kept, and no
* time: a page reads its price when it opens, so a catalogue that changed only
* its prices writes the same file, the build renders the same pages, and the
* publish uploads none of them.
*
* Part of `prebuild`. The committed file is what the build renders when the API
* cannot answer, so a blip at api.hanzo.ai no longer fails a deploy: an
* unreachable API, an error, a body that is not the document, or a model list
* under half the snapshot's (a degraded read, not a retirement) keeps the file
* byte for byte, and the build goes on.
*/
import { readFileSync, writeFileSync } from 'node:fs'
const FILE = new URL('../lib/data/served.json', import.meta.url)
const API = (process.argv[2] ?? 'https://api.hanzo.ai').replace(/\/$/, '')
const SERVED = ['id', 'name', 'description', 'owned_by', 'family', 'context_window', 'outputs']
const OWN = ['name', 'fullName', 'description', 'features', 'tier', 'context', 'specs', 'links']
const keep = (why) => {
console.log(`sync-models: kept the committed catalogue (${why})`)
process.exit(0)
}
const read = async (path) => {
const res = await fetch(`${API}${path}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(30_000) })
if (!res.ok) keep(`${path}: ${res.status}`)
return res.json()
}
const pick = (row, keys) => Object.fromEntries(keys.filter((k) => row[k] != null).map((k) => [k, row[k]]))
try {
const [models, pricing] = await Promise.all([read('/v1/models'), read('/v1/pricing')])
const served = Array.isArray(models?.data) ? models.data : []
const own = Array.isArray(pricing?.hanzoModels) ? pricing.hanzoModels : []
if (!served.length || !served.every((r) => typeof r?.id === 'string')) keep('/v1/models: not the model list')
if (!own.length || !own.every((r) => typeof r?.name === 'string')) keep('/v1/pricing: not the price list')
const held = JSON.parse(readFileSync(FILE, 'utf8'))
if (served.length < held.served.length / 2) keep(`/v1/models lists ${served.length} models where the snapshot holds ${held.served.length}`)
const text = `${JSON.stringify({ served: served.map((r) => pick(r, SERVED)), own: own.map((r) => pick(r, OWN)) }, null, 1)}\n`
if (text === readFileSync(FILE, 'utf8')) {
console.log(`sync-models: ${served.length} models, unchanged`)
} else {
writeFileSync(FILE, text)
console.log(`sync-models: ${served.length} models (the snapshot held ${held.served.length}), written`)
}
} catch (e) {
keep(e instanceof Error ? e.message : String(e))
}