Source of truth for the Kiwano app's two remote data files — the provider catalog and the price table. Hand-edit here, CI validates + builds + publishes to Cloudflare R2 (with a manifest and a news feed beside them), and the app fetches them at runtime and treats them as authoritative: it ships no compiled copy of the catalog, so a machine that has never synced shows no providers rather than a stale list.
entries/
<id>/provider.json the stable half: one directory per model provider;
adding a provider = creating one new directory (id must
match the directory name). Identity, protocols, billing.
Its `currency` is the one the provider bills in
(default USD when omitted)
<id>/models.json the volatile half: every model this provider serves,
prices inline. Repricing, new models and retirements
touch only this file. Rows carry no currency: the
provider's applies
<id>/logo.<ext> provider logo (required; png|svg|jpg|jpeg|webp),
published to R2 under logos/<id>.<ext>
global.json exchange rates + the seeding version. Nothing else: a
price lives in the entry of the provider that serves it,
so a model has exactly one price
news/<id>.json model news — one file per notice, each naming a
(provider, model) pair; the file name is the id.
Published whole as dist/news.json
scripts/
generate.mjs validate + build dist/ artifacts (zero dependencies)
migrate-v2.mjs one-shot: the old provider.json + price.json layout ->
provider.json + models.json (already run)
test-validation.mjs failure-path tests for the validation rules
test-policy.mjs the same for the trust policy's edges
lib/fetch.mjs the plumbing the adapters share: fetch with a timeout,
drift, price parsing, value equality
lib/policy.mjs what a machine may change unattended, and what has to
wait for a person
sources/index.mjs every price source this repo can read, one adapter each —
the machine-readable answer to "where did this number
come from"
fetch-all.mjs read every source, judge the changes, write, prove the
write settled, and (with --commit) commit per entry
fetch-deepseek-pricing.mjs
fetch-kimi-pricing.mjs
fetch-openrouter-pricing.mjs
authoring aids: read a vendor's published price table and
print what it would change. Never run in the build — they
only save the author a transcription, and print a diff
rather than writing unless given --write
fetch-openrouter-rankings.mjs
the same, but it picks *which* rows OpenRouter's entry
carries: its own published token-usage ranking. The one
script that needs a key (the rankings dataset is
authenticated); it reads OPENROUTER_API_KEY, or --key-file
fetch-aliyun-models.mjs
reads an Alibaba Cloud platform's own model list rather
than a price (`/compatible-mode/v1/models` on the entry's
own host). Needs a key too, and takes it from
ALIYUN_API_KEY or --key-file
fetch-mimo-pricing.mjs
reads MiMo's pricing markdown. The docs index every page
as a `.md` under `/llms.txt`, so this parses markdown
rather than a rendered page — and it has to choose between
the yuan table and the dollar one
.github/workflows/
validate.yml PR gate: validation + dry-run build
publish.yml main push: build + upload to R2
The split is by how often each half changes: a vendor's endpoint, protocols and billing are near-static, while prices move constantly. Keeping them apart means a repricing PR touches no identity data, and vice versa.
A provider is one directory + one provider.json + one models.json + one
logo file. The directory name is the id, and the id field inside must equal
it exactly.
- Create
entries/<id>/provider.jsonfrom the first template below. - Add a logo file
entries/<id>/logo.<ext>(png|svg|jpg|jpeg|webp). It is required and published to R2 aslogos/<id>.<ext>— see Logo. - Add
entries/<id>/models.jsonlisting every model this provider serves, prices inline —[]when it serves none yet. It is required — see Models and pricing. - Add a row to
checklist.md—| [ ] | \` | | / | | checked — where the numbers came from |`. It stays unchecked until somebody has read the vendor's own page against the entry. - Decide whether it can be kept current by machine, which is a separate
question from whether it is correct. If the vendor publishes a price list a
script can read, add
scripts/sources/<vendor>.mjsand register it inindex.mjs— see Adding a source. If they do not, the entry is hand-maintained and the runner will say so on every run, which is the correct outcome rather than a gap. - Run
node scripts/fetch-all.mjs— with an adapter it reports what the entry would change, and printsno changeonce the entry agrees with its source. That is the same check the whole pipeline rests on, and it is the fastest way to find a typo in a transcription. - Open a PR —
validatechecks the schema + builds a dry run. - Merge to
main—publishuploadscatalog.json/models.json/news.json/manifest.jsonandlogos/to the R2 bucket root.
If the new entry prices anything, bump version in global.json in the same
change. It gates the published table, so an entry that arrives without it is an
entry the app never sees.
website is the vendor's own site and nothing else: the address a reader can
follow to see who they are dealing with. A referral link, an invite code or a
signup page with tracking parameters belongs in a separate optional field —
putting one here would mean the catalog's idea of "the vendor's site" depends on
who is reading it, and it would have to be swapped the day the deal changes.
Where a provider's API lives on a cloud platform rather than its own domain
(dashscope.aliyuncs.com, ark.*.volces.com, generativelanguage.googleapis.com),
name the vendor's site, not the platform's.
Eight required fields, and that is deliberate: anything the app can work out for
itself is not stored here. The letter-avatar glyph comes from name, the avatar
colour and the category label from the app's own palette and translations, and the
primary endpoint from the first entry of endpoints.
// entries/<id>/models.json — volatile: one record per model, prices inline
[
// Priced: `in` + `out` together mean "this model is priced".
{ "id": "example-1", "name": "Example One",
"in": "3", "out": "15", "cache_read": "0.30" },
// Unpriced: no `in`/`out`. It still shows in the app's model list.
{ "id": "claude-opus-5", "name": "Claude Opus 5",
"serves": { "anthropic": "anthropic/claude-opus-5" } },
// Served by every endpoint, under its own id — `serves` is omitted.
{ "id": "example-2", "name": "Example Two", "in": "1", "out": "5",
"flagship": true }
]Three things about models.json that are easy to get wrong:
idis the canonical pricing key, not necessarily the string the API accepts.dist/models.jsonis keyed by it, so the same model resold by several providers must use the same id — that is what makes price drift detectable.servescarries the upstream string, per protocol, when it differs fromid(OpenRouter wantsopenai/gpt-5.2where the canonical key isgpt-5.2). Omit it entirely to mean "every endpoint serves this, under its own id".cache_creationis optional and means0when absent — most providers charge nothing for cache writes. Write it only when it is non-zero.flagship: truemarks the one model whose price represents this provider on the Models list (at most one per provider, and it must be priced). It is a curation decision, not a derived value: the first pass flagged, for each of the 71 providers with priced models, the one with the highest output price (ties by input price, then id) — which lands on the model a vendor is known for in most cases. Worth a human look wherever a model that is a small or coding variant ends up representing a vendor: pricing a bigger sibling would say more about it. The rule is a starting point rather than a verdict —deepseekshows its flash model, chosen by hand over the pricier pro the rule picked. Its tie-break has a direction worth knowing: when a vendor prices generations alike, equal output and equal input fall through to the id, and the older model's id usually sorts first. Three vendors hit it on 2026-09-14 —grok-4.5overgrok-4.6,gemini-3.6-flashover-3.7and-3.8,doubao-seed-2.1-prooverdoubao-seed-evolving— and each was set by hand to the newer model.
Changing a flagship does not move the price table (version in global.json
gates that, and nothing about it changed), so it needs no version bump — the
catalog's own sha carries the change to clients.
desc says who the vendor is. It is not a place for capabilities, model
names, or numbers: the entry already carries the model list, the flagship, the
prices and the currency as data, so repeating any of them there gives one fact two
sources — and prose is the copy that goes stale, because it needs an edit every
time a model ships or a price moves. A line like "K3 flagship · 1M context" is
wrong the day K4 lands, while the flagship field already said it correctly.
Write something that survives their next release and reads as theirs and nobody else's. Compare:
| ✗ | OpenAI/Anthropic-compatible API · 1M context — true of half the aggregators here, and the context window is a spec that changes |
| ✓ | DeepSeek's own API, serving its open-weight models — you know which vendor it is, and it does not need editing when they ship |
| ✓ | Moonshot AI's Kimi assistant and API platform |
A vendor that sells both an API and a subscription usually sells them at
different addresses, and that is two entries: kimi is api.moonshot.cn and
kimi-for-coding is api.kimi.com/coding, so the billing mode is a property of
the address and there is nothing to reconcile.
both is for the case where there is only one address. Claude Pro and Max are
used against api.anthropic.com — the same base URL as the API — so the entry
describes one endpoint that accepts either a pay-as-you-go key or a subscription
credential, and which one applies is the reader's business, not the entry's.
It is not just a naming preference. A second entry on the same host would break
pricing for providers that are already installed: catalog_id_for
(../crates/core/src/vm.rs) links a local provider to a catalog entry by
endpoint and returns nothing when more than one entry matches, and an unlinked
provider is costed at another entry's rate. Two entries sharing a host is
therefore not a neutral choice — it makes every hand-added or pre-link Anthropic
provider ambiguous.
What both does not do is carry the subscription's price. The entry still
prices its models per token, because that is what the metered half charges, and
the monthly figure has no field yet.
An app that predates this value cannot write it: billing_to_db refuses a tag it
does not know rather than guessing, so an older build shows the raw tag on the
shelf and fails when the provider is added from it. The app has to resolve both
into one of the two modes with the user before saving, which is the right place
for that question anyway.
A subscription's usage is not part of a vendor's API surface in any standard way:
some expose a quota endpoint answerable with nothing but the API key the entry
already carries, and most expose nothing at all. Which of the two a vendor is
cannot be derived — billing: plan says how a provider charges, not what its
API can answer. In this catalog 18 entries bill as a plan and 4 can be queried.
So it is curated, one entry at a time:
"plan_query": { "template": "kimi" }The value names a template — a capability, not a provider: "the Kimi coding
plan usage API", "the Zhipu monitor API". Two entries can share one
(zhipu-glm and zhipu-glm-intl do, and the host is worked out from the entry's
own endpoints), and an entry may move vendor without the id moving. It is
deliberately not the entry's own id: keying the app's lookup on that would
put a list of catalog entries inside the app binary, and every newly queryable
provider would need an app release rather than a data change.
Only the template id. A few templates need extra credentials the user
supplies — an org id, an account access key. Those are the user's own and never
belong in a public repo, so the published projection writes the single template
key out rather than spreading the object: nothing else can ride along.
What it buys: the app offers per-plan quota limits — a percentage ceiling on the vendor's rolling 5-hour and weekly windows, enforced by routing around a provider that is over its own — only for entries that carry this field. Where it is absent those inputs are hidden rather than shown and left inert, since a limit the app cannot measure against is a number the user types for nothing.
Two authoring rules:
fieldsis not a key here. Extra credentials have no place in this repo.- An id the app does not know is warned about, not rejected. The app reports an unrecognised template as a readable error rather than crashing, and the list can only grow — blocking one is not this repo's job.
Each provider ships a logo file at entries/<id>/logo.<ext> — one of
png, svg, jpg, jpeg, or webp (case-insensitive base name, extension
decides the R2 content type). It is required: generate.mjs fails if a
provider has no logo file, or more than one.
On publish, the logo is uploaded to R2 at logos/<id>.<ext>, and the catalog
entry exposes a relative logo field:
{ "id": "example", "logo": "logos/example.png" }The app builds the full URL from its hub_url setting
(https://hub.kiwano.cc/logos/example.png). When the image can't load it falls
back to a letter avatar: the first letter of name, on a colour the app picks
from the same palette it uses for locally-added providers.
The app renders the logo as a plain <img> on both the light and the dark
theme, so artwork that is black on a transparent background simply disappears
in dark mode. Two cases account for nearly all of it:
- An SVG painted with
currentColor. Inline that resolves to the surrounding text colour, but a standalone file loaded through<img>has no CSS context and resolves to black. - An SVG whose elements carry no
fillat all, which also defaults to black.
The fix is a white rounded rectangle as the first child of <svg>, so
everything else paints on top of it:
<rect data-kw-bg="1" x="0" y="0" width="24" height="24" rx="5.28" fill="#FFFFFF"/>Size it from the viewBox, with rx at roughly 22% of the shorter side
(24 → 5.28, 512 → 112.64). The data-kw-bg marker is only there so the edit is
idempotent.
Check before adding one — two kinds of logo need nothing:
- those that already ship their own background (a full-canvas dark rect, so the white one would be covered and dead), and
- those whose mark is light or coloured rather than black, which are perfectly legible as-is.
The reliable test is to render the file on a #141414 page through <img> —
static analysis of fill attributes misjudges both of those cases (it counts a
background rect as artwork, and misses colours that come from <style> blocks
or gradients).
provider.json used to carry an optional icon — a legacy key into the app's
bundled icon registry (../app/src/components/icons/). It was a second icon
system running alongside the logo files and has been removed: the logo file
covers it. The registry's dark-theme variants are the one thing it carried that
a logo file does not — those belong inside the logo artwork, the same way the
white tiles were added for black marks.
entries/<id>/models.json is both the model list and the price list — one
record per model:
[
{
"id": "claude-opus-5", // canonical key; also what dist/models.json is keyed by
"name": "Claude Opus 5", // optional; required when priced (it becomes display_name)
"in": "5", // provider currency per million tokens, TEXT decimal
"out": "25",
"cache_read": "0.50", // optional, 0 when absent
"cache_creation": "6.25", // optional, 0 when absent (most providers charge nothing)
"flagship": true // optional: the model whose price represents this provider
}
]Presence of in/out is the price flag. A model without them is declared but
unpriced — the app lists it and shows no cost rather than a wrong one. [] is
valid and means the provider serves no models yet.
Some vendors charge less outside their business hours, and the listed rates are the peak ones:
{
"id": "deepseek-v4-pro", "name": "DeepSeek V4 Pro",
"in": "9.0", "out": "27.0", "cache_read": "0.30", // peak — the listed rates
"off_peak": { "in": "4.5", "out": "13.5", "cache_read": "0.15" },
"peak_hours": {
"tz_offset": 480, // minutes east of UTC — the VENDOR's billing clock
"windows": [
{ "days": ["mon","tue","wed","thu","fri"], "start": "09:00", "end": "12:00" },
{ "days": ["mon","tue","wed","thu","fri"], "start": "14:00", "end": "18:00" }
]
}
}Read it as: the row's own rates apply during peak_hours, off_peak outside
them. The two fields only appear together — a discount with no window is just a
different price — and only on a priced row.
tz_offsetis required, and it is the provider's clock, not the reader's. These windows are business hours somewhere; judging "is it peak now" against the user's own timezone would silently pick the wrong rate.- A window does not wrap midnight. 22:00–02:00 is two windows.
off_peakcarries its own rates rather than a discount factor, so a vendor who discounts input but not output is expressible. The factor happens to be exactly a half for DeepSeek — that is a fact about DeepSeek, not a rule.- Until a client reads
peak_hours, it charges the listed (peak) rate — the higher of the two, so an un-updated client overstates a night's cost rather than understating it.
Some vendors charge more once a request's input passes a size. MiniMax prices M3 in two bands, one exactly twice the other, and Alibaba prices most of its models this way:
{
"id": "minimax-m3", "name": "MiniMax M3",
"in": "2.10", "out": "8.40", "cache_read": "0.42", // ≤ 512k — the listed band
"long_context": {
"over": 512000, // input tokens, above which…
"in": "4.20", "out": "16.80", "cache_read": "0.84" // …these rates apply
}
}Read it as: the row's own rates apply up to over input tokens, the block's above
it. over is required and so are in and out; the block appears only on a
priced row, and long_context is the one place a rate is not the whole row.
The listed band is the cheaper one, and that is the opposite of the time-of-day rule above — deliberately. There, the vendor publishes a single rate and it is the peak one, so erring upward was free. Here the vendor publishes both bands with the cheap one first, so following the listing keeps the headline price right, and the cost is that a client which does not read the field bills a long request at the lower band — understating rather than overstating. That trade was made knowingly: the alternative puts a rate on the Models list that almost nobody pays.
Rows carry no currency: the provider's currency applies, and generate.mjs
stamps it into every published row of dist/models.json (so the app-side table
keeps its per-row currency). Listing one fails validation — two places to
disagree is exactly what this layout removes.
A price belongs to the provider entry that serves the model, and nowhere else. There is no vendor price table: a model the catalog does not carry is a model the app cannot price, and it records the request with no cost rather than a guessed one.
Aggregators resell the same vendor models, so one id appears in several
models.json files — and each is free to price it differently: a subsidy, a
markup, an off-peak rate. Those prices do not have to agree, and the validator's
objection to disagreement is a temporary limitation, not a rule.
The limitation: the app's price table is keyed by model alone, so it holds exactly
one price per model, and publishing two would leave the winner up to whichever row
the seeder wrote last. Until the app looks a price up by (provider, model) — which
needs the published rows to name the provider — a disagreement fails the build,
with a message saying exactly that.
generate.mjs writes the priced rows of every models.json into
dist/models.json, one row per id, sorted by id — the flat global model_id -> price table the app seeds from.
A notice that the app can surface as an in-app feed item: "provider X has model
Y". Every notice names both — provider_id and model_id — because the
pair is what makes it actionable: the app shows the model on that provider's
row and routes the call to action from that entry.
One file per notice, news/<id>.json. Two people adding news touch
different files, so they never conflict on a shared array — the same reason a
provider is a directory rather than a row.
// news/anthropic-claude-opus-5.json
{
// No "id" field: the file name IS the id, and it is what the app stores
// "dismissed" under. Repeating it here would be a second place to disagree —
// the same reason price rows carry no currency. Renaming the file re-shows
// the item to everyone who closed it, so treat the name as permanent.
"kind": "new_model", // new_model | free | discount | announce
"provider_id": "anthropic", // must be a directory in entries/
"model_id": "claude-opus-5", // must be a model that provider serves
"released": "2026-09-08", // YYYY-MM-DD, the day the model became usable
"title": "Claude Opus 5 is live on Anthropic", // ≤ 80 chars
"body": "…", // ≤ 300 chars
"badge": "NEW", // optional, ≤ 8 chars
"priority": 90, // optional integer; higher shows first
"expires_at": "2026-10-08", // optional; after `released`, not before
"url": "https://…" // optional; announcement page, not a signup
}The file name must be lowercase [a-z0-9.-], 3–64 chars, so it is a legal id
everywhere. An empty news/ is valid and means "no news". Non-JSON files are
reported and skipped; dotfiles (macOS .DS_Store) are skipped silently.
To retire a notice, delete its file. dist/news.json is rebuilt from what
is in news/, so deletion is the only pruning mechanism — see below for why
there is deliberately no age filter.
generate.mjs fails unless model_id is one the entry actually serves, matched
case-insensitively against the union of every model's id and every upstream
string in its serves. Either spelling works, because you may think of the model
by either name.
This check is what stops "OpenAI 的 claude-opus-5" from ever publishing. To
announce a model the entry does not carry yet, add it to models.json in the
same PR.
dist/news.json is written by priority descending, then released
descending, then id — so several live notices have a deterministic display
order, and the newest release wins a tie. Each published item carries its id
folded in from the file name, which the source file does not have.
A notice's age is not a build-time filter, and cannot be. publish.yml
runs on push to main only — there is no schedule. So "keep the last 3 days"
would mean "the last 3 days as of whenever someone last pushed": merge on the
1st and nothing rebuilds, so on the 5th clients still receive the 1st's news.
An item would disappear based on when the next commit landed, not on the
calendar.
It would also break the artifact's determinism. Age-filtered output makes the
same commit build differently tomorrow, so the manifest's news.sha256 would
move with nothing changed and every PR's --check would depend on the day.
Age is the client's call: the app knows what "today" is, and the build does
not. generate.mjs publishes everything in news/, and the app hides what
it has already shown or what expires_at has passed. Nothing consumes the feed
yet — that is a separate app-side change.
For the same reason the file carries no generated_at (unlike
dist/models.json): a date stamp would move the hash daily and make every
client re-download an unchanged feed. The manifest keeps the timestamp.
provider.json
name: non-empty stringwebsite: the vendor's own site, required, and validated as anhttp(s)URL. It is the plain address — not a referral or signup link: that is a different, optional field (see the note under Adding a provider)tag:official | third | aggregate | local | freerating: number0..5billing:plan | payg | unl | both— one entry per billing mode, withbothfor an address that serves two of them at once (see below)endpoints: non-empty array of{protocol, endpoint};protocolisanthropic | openai | geminiand may not repeat. The first is the primary protocol — it supplies the app'sendpoint/protocol/modelscurrency: anexchange_rateskey; omitted means USD. It is what the provider's prices and its spending limit are denominated in, so an unknown code fails validation rather than leaving the limit unmeasurabledesc: optional; one sentence introducing the vendor — see below for what belongs in itplan_query: optional{template}— the id of the template the app runs to read this plan's usage, and nothing else. An unknown key inside it fails; a template id the app does not know is warned about, not rejected, because the app answers one it does not recognise with a readable failure and the list can only grow. See Plan quotalogo: derived, not authored —entries/<id>/logo.<ext>must exist and is published aslogos/<id>.<ext>
models.json
- array of model records (may be
[]).idis required and unique within the file;nameis required whenever the model is priced (it becomesdisplay_nameindist/models.json, which the app requires) in/out: non-negative decimals, and they come together — their presence is the price flagcache_read/cache_creation: optional non-negative decimals,0when absentoff_peak/peak_hours: optional, and only together — the discounted rates and the hours the row's own rates apply. See Time-of-day pricinglong_context: optional{over, in, out}— the rates that apply aboveoverinput tokens, on a row whose own rates are the band below it. See Length-dependent pricingserves: optional{protocol: upstream string}; every protocol must exist inendpoints, the object must not be empty, and omitting it means every endpoint serves the model under its ownidflagship: optional boolean, at most one per provider, and that model must be priced- no
currencyon a record — the provider's applies. A model resold by several providers is priced by each of them independently, and they may disagree: a subsidy, a margin, a different billing clock. Every row indist/models.jsoncarries theprovider_idit came from, and the app looks a price up by (provider, model). Copies that agree still produce one row each, deliberately — the price a provider is billed at should be its own row, not a neighbour's that happens to match today. A disagreement is published and reported as a warning: app builds whose price table is still keyed by model alone fold the rows into one and keep whichever was seeded last, so it is a thing to know before publishing rather than a thing to hide
global.json
exchange_rates: units per 1 USD,USDpinned to1version: positive integer, must increase when pricing rows change (the app seeds version-gated and ignores older versions)- nothing else: a price belongs to the provider entry that serves the model, so
a
modelskey here fails validation
news/<id>.json
- one notice per file, the file name being the id (lowercase
[a-z0-9.-], 3–64 chars). Each requires akind, aprovider_idthat exists inentries/, amodel_idthat provider actually serves, a realreleaseddate, andtitle/bodywithin length;expires_atmust be afterreleasedandurlmust be http(s). Anidfield inside the file is rejected, and unknown keys are reported as warnings
Unknown keys anywhere are reported as warnings by the build — that is how a leftover field from an older schema gets caught before it silently does nothing.
Edit the model record in the entry of the provider that serves it —
entries/<id>/models.json. If the model is resold elsewhere, each copy is its
own price: change the ones whose price actually changed, and leave a reseller
that has not moved its own rate alone. The build reports any model priced
differently by different providers, which is now published rather than rejected.
Then bump version in global.json and, if the record introduced a new
currency, add it to exchange_rates. The version is the app's seed gate: an
unchanged version means the app keeps its existing table.
Several vendors publish a table a script can read, so scripts/fetch-deepseek-pricing.mjs,
scripts/fetch-kimi-pricing.mjs, scripts/fetch-mimo-pricing.mjs and
scripts/fetch-openrouter-pricing.mjs read theirs and print the difference — a
repricing becomes a review of a diff rather than a
transcription. All are authoring aids and nothing more: no cron runs them, and a
number they print is only as fresh as the last time somebody ran it.
DeepSeek's docs prerender the table as plain HTML, so a fetch is enough — but the
response carries a stray NUL byte, which is worth knowing because grep then
treats the file as binary and matches nothing at all. Kimi's docs go further and
serve every page as markdown (/docs/llms.txt is the index), with the price
table as a literal array inside a <DocTable> element; their script also reads
the model list, which is how two models we carried turned out to be retired.
OpenRouter is the easiest of the three and by far the largest: a plain JSON API
(/api/v1/models, no key) covering 430 text-out models, each with a name and
per-token USD prices. Two things about it are worth knowing before trusting a
diff. Prices are per token, so the script moves the decimal point six places
in the string — 0.00000003 * 1_000_000 is not 0.03 in binary floating
point, and a short fraction gains zeros rather than losing them (0.00001 is
10 per million, not 1). And a few models are priced -1: the vendor's way of
saying the price varies with whichever model it routes to. Those become rows with
no price, which is exactly what "declared, unpriced" already means here.
Note also which models it returns: filtering on modality === "text->text" looks
like the obvious reading and is wrong — it drops every model that accepts an
image, which is 272 of the 430, including the ones a reader actually picks. The
script filters on output modality instead.
fetch-openrouter-rankings.mjs is a different kind of aid: the two above diff
prices for the rows an entry already carries, and this one chooses which rows
those should be, from OpenRouter's own token-usage ranking
(/api/v1/datasets/rankings-daily). It is the only script here that needs a key.
Three things about that data are worth knowing before reading its output as fact.
It is one week, not a running total. openrouter.ai/rankings shows the latest
week, and its numbers are how to check the script: the bucket starting 2026-09-07
gives GPT-5.6 Luna 18.18T tokens, which is the 18.2T the page prints. Summing
several weeks instead puts a model that has since gone quiet near the top of a
list it is not on — stealth/ox-alpha had 27T across the window and zero in its
last two weeks.
Its ids are snapshots, and the models list dates them differently — the
ranking writes deepseek/deepseek-v4-flash-20260731 where the models list writes
deepseek/deepseek-v4-flash-0731, the same snapshot with the year dropped. That
translation is what keeps two ranked snapshots of one model apart, which matters:
the page ranks -20260731 (11.6T) and -20260423 (4.36T) as separate entries,
and so does the entry. Dropping the whole date instead collapses them into a row
that is neither.
A ranked model may have no record at all in the models list, and then there is no name and no price to write. The script lists those and writes the rest rather than dropping them quietly.
The dataset is CC BY 4.0: anything republished from it must carry "Source: OpenRouter (openrouter.ai/rankings), as of {as_of}" — which is why the entry's rows are not the only thing to read there.
The Alibaba Cloud entries needed a different question answered — not what a price
is, but which models are served at all. Their hosts answer
/compatible-mode/v1/models with the ids they will accept, so
fetch-aliyun-models.mjs reads that and diffs it against an entry. It decides
which entry from the key file's own base URL, because a key only answers for its
own plan. The payload carries bare ids and no modality, so anything whose name
looks like an image or an audio model is set aside and listed rather than dropped
quietly. And it refuses to write an entry whose rows carry prices: the list has
none, so replacing the file wholesale would delete them.
fetch-all.mjs runs every source above, plus the ones that only ever had a page
to read, and prints the whole catalogue's differences in one pass:
node scripts/fetch-all.mjs show the diff for every entry
node scripts/fetch-all.mjs --entry xai just one
node scripts/fetch-all.mjs --write apply
node scripts/fetch-all.mjs --write --commit apply and commit, one per entry
node scripts/fetch-all.mjs --force-write apply even if the policy says no
That last pair is the whole thing unattended: read, judge, write, prove, commit.
Nothing is pushed, and --commit refuses to run on a tree with changes it did not
make, so it cannot sweep up someone else's work.
The per-vendor scripts still exist and are still the best place to read about any one vendor. What the runner adds is the thing none of them could do alone: keep going when one vendor's docs restructure, and report what it could not read rather than leaving a stale number looking fresh.
Four things, and being able to read a page is only the first.
The source is readable. Fifteen of nineteen entries. The other four — the Kimi membership, the Qianwen token plan, the Tencent and Baidu plans — publish no per-token rate, and saying so out loud is the point: silence about an entry reads as coverage.
The source is reliable, which is not the same thing. OpenRouter taught this
one. GET /api/v1/models gives each model a single pricing object, but OpenRouter
routes a model to many upstreams with a price each — 18 of them for
deepseek/deepseek-v4.1-flash, spanning 0.15 to 0.375 — and the aggregate it
reports moves. Two values read from it in one session were not any endpoint's
price across 17 and 6 upstreams. That adapter now reads /models/{id}/endpoints
and takes the modal price: real, and a function of the endpoint set rather than
of which upstream a router felt like reporting. Ties break toward the model
owner's own endpoint, then the cheaper. Every other source here publishes a price
list, so this is the only one that needed the distinction.
Judgement is separated from fact. Every adapter declares owns — the price
fields it is the authority for — and the runner refuses a row carrying anything
else. flagship, serves, name and all of provider.json belong to the entry.
qianwen owning only in/out is the case worth knowing: its cache rates are
excepted from every percentage Alibaba publishes, and the contract is what stops a
later edit from quietly adding them back.
A run that finds nothing new writes nothing. This is checked, not assumed.
After writing, every entry is merged a second time against what the source said and
must come out clean — because a source reporting a value it will not report again
passes every other check while failing this one, which is exactly what OpenRouter
did. Nested shapes compare through sameValue, which ignores key order; comparing
them with JSON.stringify would call an unchanged long_context a change and
break the same property.
lib/policy.mjs states the rule the two original guards were special cases of: a
price is a fact, and a model arriving or leaving is a decision.
Price changes never stop a run. A vendor re-pricing something is the source speaking about the one thing it is the authority on, and there is no reason to make anyone read it. Membership is different — it changes what the catalogue claims a vendor sells, it is where a source silently changing shape shows up, and both times something went wrong in a single session it was here:
- an
intersectadapter that had not been taught to intersect offered to add 410 rows; - an aggregator about to be trusted with prices reported values no upstream charged.
So a run may create at most 10 models, delete at most 10, and churn at most 15 in
total; past that it refuses to write and says which models and why, unless
--force-write. test-policy.mjs asserts the edges, where an off-by-one is silent
data loss.
Version bumps are part of applying a change rather than a follow-up to remember: the published table is version-gated, so a price that moved without one is a price the app never sees.
An adapter is how an entry stops being hand-maintained. Add one when the vendor publishes a price list a script can read; not when they publish a number only a console shows, which is a fact about the product and not something to work around.
// scripts/sources/<vendor>.mjs
import { getText, drift, MEMBERSHIP } from "../lib/fetch.mjs";
export default {
ids: ["<entry-id>"], // one source may feed several entries
source: "<the URL this reads>", // printed on every run, and in commits
membership: MEMBERSHIP.FOLLOW, // see below
owns: ["in", "out", "cache_read"], // the fields this source is authoritative for
async read() {
const md = await getText("https://…");
// throw `drift(...)` when the page is not the shape you know — never return
// a partial or empty row set, which would read as "no change".
return { rows: { "<entry-id>": [ /* { id, name?, in, out, … } */ ] } };
},
};Then register it in scripts/sources/index.mjs and run
node scripts/fetch-all.mjs --entry <entry-id>.
membership is the decision to get right. follow means the source is a
complete statement of what the vendor sells, so a model it adds joins the entry
and one it drops leaves — right for a vendor pricing its own catalogue.
intersect means the source only re-prices what something else already chose —
right for an aggregator, whose catalogue is other people's models, and for entries
that are a deliberate selection from a longer list. Getting this wrong is what the
churn limit exists to catch.
Row ids are the entry's, not the vendor's. An adapter translates: MiniMax-M3
becomes minimax-m3. When the vendor's string must still reach the API, the
entry's serves map carries it, and the adapter joins on that.
The acceptance test is that a correct adapter has nothing to say. Run it against the committed entry: if the transcription was right, it reports no change. A diff there means the parser is wrong, not that a price moved — which is the opposite of what a diff usually means and takes getting used to.
Each source lives in scripts/sources/ as an adapter, and the list in
index.mjs is now the machine-readable answer to a question that used to have
five different answers: where did this number come from. An entry with no adapter
is not an oversight — it is a plan that publishes no per-token rate, or a console
behind a login — and the runner names those on every run.
Two rules the merge follows, both of which cost something to learn. The source
wins on what it can know: the prices, and for a follow source the list of models
itself. The entry wins on judgement the source cannot express — flagship and
serves are carried across untouched, because a scraper that re-derives them
would rewrite a human's decision as a guess. The other rule is that membership is
a separate question from price. A follow source is a complete statement of what
a vendor sells; an intersect source only prices what something else already
chose. OpenRouter is the second kind, and treating it as the first would replace
twenty curated rows with four hundred.
Two things worth knowing before the first run. Node's fetch ignores
https_proxy, which curl honours, so a source that works from the shell can
fail here — and turning the proxy on for everything is worse than leaving it off,
because most sources work direct and routing them through one breaks them.
Measured from this machine, exactly one source needs a proxy and the rest need
excluding from it. Which is which is a property of the network you are on rather
than of this repo, so run node scripts/probe-sources.mjs and use what it says
instead of trusting any list written down here.
And an entry's own docs can be read where its login page cannot: the
Volcengine Ark docs answer www.volcengine.com/api/doc/getDocDetail?DocumentID=…
with the whole document as JSON to anyone, while the console they are embedded in
refuses every path unauthenticated — including paths that do not exist, which is
why probing that host proves nothing about whether a page is public.
A doc response often carries its own content more than once, and the first copy is
not always the readable one. Ark returns the page as a Quill-style delta and as
markdown; Alibaba returns it as HTML escaped inside a window.__ICE_PAGE_PROPS__
JSON string. Both are readable, and the second is far less work.
Four entries have no adapter, and the runner names them on every run: the Kimi membership and the Qianwen token plan, which publish no per-token rate at all, and the Tencent and Baidu plans, whose model lists have no keyless endpoint. That is a fact about those products rather than a gap in the tooling, and saying it out loud is the point — silence about an entry reads as coverage.
dist/catalog.jsonis assembled from allentries/*/provider.json+models.json, sorted by id for deterministic output (the Models page sorts rows itself). Field order is canonical, not per-file.- Each published entry has twelve fields —
id,name,website,tag,rating,billing,currency,endpoints,logo,desc,plan_query,price_ref. Three of them are conditional:desc,plan_queryandprice_refappear only when the source carries them. Nothing the app can work out for itself is sent: no avatar glyph or colour, no category label, no primary endpoint beside the list it is the first entry of, no "added" flag.../crates/core/src/vm.rs::normalize_catalog_entryfills those on the way in, the same way it has always derivedadded. plan_querymust be declared on the app side to survive. The app re-serializes the parsed catalog before caching it, so a field the RustCatalogEntryVmdoes not name is dropped on the first sync and the frontend never sees it. Declaring it there is part of shipping this field, not an optional follow-up.price_refcarries the flagship's schedule too. When that model is priced by time of day, itsoff_peakandpeak_hoursare projected alongside the rates, so the Models page can say "these are the peak figures" — it reads the catalog and nothing else, and a tiered price it cannot see reads as a flat one.long_contexttravels the same way. The fields are the onesTime-of-day pricingandLength-dependent pricingdescribe, copied verbatim and only when published;models.jsonstays the place a client bills from.dist/models.jsonholds the priced models of everyentries/*/models.json, one row per model id, sorted. A model resold by several providers is written once — and only if every copy agrees. A row carries its currency, and itsoff_peak/peak_hours/long_contextwhen the provider has them, so the schedule and the band travel with the prices they modify.
validate.yml— runs on every PR:node scripts/generate.mjs --check(schema validation + dry-run build, no writes) andtest-policy.mjs.publish.yml— runs on push tomain(or manual dispatch): buildsdist/and uploads the JSON artifacts plusdist/logos/*to R2 withwrangler r2 object put --remote.sync.yml— runs daily at 06:43 UTC (or manual dispatch): runsfetch-all.mjs --write --commitand opens a PR with whatever moved.pages.yml— on push tomain(or manual dispatch): builds the web app inweb/and force-pushes the static bundle to thegh-pagesbranch.
Once the custom domain is set, the site is served from the root at
https://models.kiwano.cc; until then it is https://<owner>.github.io/ (see
below).
sync.yml is the one workflow here that touches the network, and it is kept out
sync.yml is the one workflow here that touches the network, and it is kept out
of the build path deliberately: validate still runs offline on a PR, and
publish still ships only what a person merged. It runs the same command a
person runs by hand, so the same guards apply — the trust policy refuses a run
that would change what a vendor is said to sell, the fixed-point check refuses a
source reporting a value it will not report again, and the commits come out one
per entry so each diff reads like one.
It opens a PR rather than merging itself, because this repo's premise is that
the data is hand-reviewed; an unattended commit that landed on its own would make
every checked <date> in the checklist mean nothing. One fixed branch
(automation/sync) means a quiet day updates the open PR instead of stacking a
new one, and a source that fails is reported in the step summary rather than
being allowed to read as a quiet day — which is the failure a silent run would
hide.
It runs on a US runner, which is the other reason it exists: platform.openai.com
and the Gemini pricing page answer unsupported_country_region_territory from
some networks and not others, and a run from elsewhere is the only way to find
out which side of that this repository is on.
A zero-heavy Vite + React SPA that renders the catalogue the way models.dev
renders its own: a providers grid (#/), a global sortable price table
(#/models), and per-provider details (#/provider/<id>) with expandable
long-context and peak/off-peak rows. Hash routing keeps deep links working on
GitHub Pages, which cannot rewrite arbitrary paths. Logos, catalog, models and
news JSON are staged from the data repo's dist/ into web/public/data/ by
web/scripts/stage-data.mjs before every dev or build run.
cd web && npm ci && npm run dev # local dev (requires ../dist built first)
node scripts/generate.mjs # if dist/ is missing (fresh clone)
The site is English-first, honours prefers-color-scheme with a manual
override, and converts CNY prices to USD at a toggle using the rates in
models.json itself. Deployment is pages.yml; it serves from the root (the
vite.config.ts base is "/"), so it belongs on a custom domain rather than
the github.io/models subpath — web/public/CNAME carries models.kiwano.cc
into every build so the domain survives the deploy's wholesale branch rewrite.
Setting the domain up, once:
- DNS for
kiwano.cc: add aCNAMErecordmodels → lightconsen.github.io. - Repository settings → Pages → Custom domain:
models.kiwano.cc→ Save (GitHub verifies the DNS, then issues the TLS certificate; first HTTPS can take a few minutes, HTTP works immediately). - The panel also has Source → Deploy from a branch →
gh-pages/ root — set once; every push tomainredeploys after that.
- Create the bucket (e.g.
kiwano-hub) and enable public access via a custom domain —hub.kiwano.ccis the defaulthub_urlbaked into the app (https://hub.kiwano.cc/catalog.json). - Add the three repo secrets:
CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN(R2 edit on this bucket),R2_BUCKET. - Run the
publishworkflow once (or push to main) and verify:curl https://hub.kiwano.cc/manifest.json
- Catalog: the app's
hub_urlsetting points at the public URL (.../catalog.json); payload shape is Hub protocol v0:{"total": N, "entries": [...]}. Sync is conditional — the manifest'scatalog.sha256skips the download when it matches the cached copy — and it is the app's only source: an install that has never synced shows an empty shelf with a "fetch it from the Hub" prompt, not a bundled list. - Pricing: the app fetches models.json from the Hub alongside the catalog,
gated by the manifest's
models.version+models.sha256, and seeds the rows into its local store (model_pricing.source = 'hub'). Note what that means for edits: the file is the whole table, not a patch — a model the catalog stops pricing simply disappears from it, and a client that keeps a local copy has to drop what is no longer there rather than merge into it. - Logos: each catalog entry's
logofield is a relative path resolved againsthub_url(e.g.https://hub.kiwano.cc/logos/<id>.png); the images are fetched from R2 at runtime, falling back to a letter avatar built fromnameand the app's own avatar palette. - Currency: a catalog entry's
currencyis what the app labels that provider's spending limit with, read-only. Cost is recorded in the currency the price row was written in, which is the provider's own — but a model the provider does not price itself is billed from the general row, which may be denominated in another, so the app converts each currency into the limit's before adding them, using theseexchange_rates. The dashboard converts too, rolling many providers into the user's display currency for comparison.