openvibers/openvibe.ai Commits

The living price book: a weekly job that reads each provider's own price listing (#331)

OP OpenVibers committed 7 days ago · d6d1e3abc5 · 34 files, +5725 −8

* The living price book: a weekly job that reads each provider's own price listing

Plan E1 (2026-10-30 §8): the price the router charges comes from each provider's own
listing, and nothing refreshed it. A price that moved on the provider's side kept being
charged at the number the catalogue wrote on the day it was written.

server/pricebook/ is the job: it reads every catalogued provider's own model and price
listing (DeepSeek, Alibaba Model Studio/Qwen, Kimi, Z.ai/GLM, MiniMax, DeepInfra,
Novita, Featherless, Refuseless, Abliteration.ai) through the egress guard, with
OpenRouter's public model list as a cross-reference only. Each listing has its own
parser, because each provider publishes its prices in its own shape.

A price never changes silently. Every change lands in the price_changes log with the old
price, the new price, the listing and the day it was read. A change of more than 20%
waits for the owner on the operator console; a smaller one is applied to the model row at
once, and the catalogue's own seed leaves a price the book owns alone. A listing that
cannot be read leaves every price alone, and a price a person typed by hand is never
touched.

The catalog says when a price was checked, and GET /api/v1/exchange/price-changes is the
changelog Exchange renders. The job is off under a drill and off in test mode: the tests
replay the recorded listings through the reader's transport seam, so CI never touches the
network.

* The price book is due-based: the last run is a row, not a timer

A timer in the process is reset by every deploy, and AI is deployed several times a
day, so the book would never run on its own.

The last finished run is now a row (price_book_state, migration 0037), written at the
end of every run whether or not a price changed. The service ticks once an hour and
reads the listings only when the interval has passed since then; the first tick after
a boot is a few minutes in, so the boot path stays free and a restart does not restart
the week. AI runs several workers, so a run claims the row before it starts: only one
of them reads the listings at a time, and the claim expires on its own if a worker
dies. The console's "Read the listings now" still forces a run and writes the same
time.

0036 is untouched; the state is a new expand-only migration.

Tests: a restart before the week does not run the book, a restart after it runs it
once, two instances at the same moment never both read the listings, and the drill and
test-mode switches still keep it off.

Changes

ddiff --git a/README.md b/README.md
iindex a916ae1..5082fa0 100644
---- a/README.md
++++ b/README.md
@@@ -654,6 +654,48 @@ the service boots without Valkey; with it on, production refuses to boot without
| `AI_GOVERN_TIMEOUT_MS` | `1000` | longest wait for Valkey before it counts as unreachable |
| `VALKEY_URL`, `VALKEY_PREFIX` | empty | the shared Valkey (ADR-035) the counters live in |
++## The living price book
++
++The price the router charges and the Exchange shows is read from the provider that sells it, and from nowhere
++else. A price nobody publishes stays unknown: it is never guessed, never averaged, never taken from an
++aggregator.
++
++Once a week (plan E1, 2026-10-30 §8) a job reads each catalogued provider's own model and price listing —
++DeepSeek's price page, Alibaba Model Studio's price table, Kimi's pricing page, Z.ai's and MiniMax's price
++pages, DeepInfra's, Novita's, Featherless', Refuseless' and Abliteration.ai's listings — through the same egress
++guard the rest of the platform uses: https only, a public host, never an IP literal, a timeout and a size cap.
++OpenRouter's public model list is read as well, but only as a second opinion; no price ever comes from it.
++
++A price never changes silently. Every change lands in the `price_changes` log with the old price, the new
++price, the listing it was read from and the day it was read. A change of **more than 20%** waits there for the
++owner, who approves or rejects it on the operator console (`/console/pricebook`, `ai.provider.manage`); a
++smaller one the job applies at once, and the model row carries the new price with the day it was checked. A
++listing that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price a
++person typed by hand is never touched.
++
++The book is **due-based**, not timer-based: AI is deployed several times a day, so a timer in the process
++would be reset every time and the week would never come round. The last finished run is a row in the database
++(`price_book_state`, migration 0037) and the service asks once an hour whether the interval has passed since
++then; the first ask is a few minutes after a boot, so the boot path stays free. AI runs several workers, so a
++run is claimed before it starts: only one of them reads the listings at a time, and the claim expires on its own
++if a worker dies.
++
++The catalog says when a price was checked (`price_checked_at`, `price_checked`), and
++`GET /api/v1/exchange/price-changes` is the changelog Exchange renders: what a price was, what it became, the
++listing it was read from and the day it was read.
++
++| Setting | Default | Meaning |
++|---|---|---|
++| `AI_PRICE_BOOK` | on (off in test mode, off under `AI_DRILL=1`) | the weekly job |
++| `AI_PRICE_BOOK_INTERVAL_MS` | 604800000 (a week) | how long between two runs |
++| `AI_PRICE_BOOK_TICK_MS`, `AI_PRICE_BOOK_FIRST_TICK_MS` | 3600000, 300000 | how often the service asks whether the book is due, and how long after a boot the first ask is |
++| `AI_PRICE_BOOK_CLAIM_TTL_MS` | 900000 | how long a claimed run holds the claim |
++| `AI_PRICE_BOOK_THRESHOLD_PCT` | 20 | a change of more than this waits for the owner |
++| `AI_PRICE_BOOK_TIMEOUT_MS`, `AI_PRICE_BOOK_MAX_BYTES` | 20000, 8 MiB | the guard's timeout and size cap |
++
++The tests never touch the network: the listings are recorded (`test/fixtures/pricebook/`, written by
++`scripts/record-price-book.js`) and replayed through the reader's transport seam.
++
## Workflows
- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page` (both on route `wiki.write`, see [MegaSource](#megasource-research)), `blog.draft_post`,
@@@ -849,6 +891,7 @@ mix vectors from different providers (re-embed when the provider changes).
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
| Routing presets: the five built-in ones, a caller's own stored per account and per project, each knob applied (floor, ceiling, max_price, quantization, region, data policy, community), a hard max_price refused before the call with a problem document, every answer's receipt (offer, price, tokens, cache hits, latency, why, fallbacks, privacy label, BYOK at 0 %) in the headers and at `GET /v1/receipts/{id}`, and the Anthropic endpoint and the stream carrying the same receipt | `test/exchange-presets-receipts.test.js` |
| Capability proposals are valid contracts documents matching what is enforced | `test/proposals.test.js` |
++| The living price book: every listing read in its own shape, the guard refusing an IP literal, a change of 20% or less applied at once, one of more than 20% waiting for the owner and applied only on approval, the change log, the catalog's "price checked" date, the public changelog, the job off under a drill, the book due-based (a restart before the week does not run it, a restart after it does) and two instances at the same moment never both running | `test/price-book.test.js` |
## Security
ddiff --git a/migrations/0036_price_changes.sql b/migrations/0036_price_changes.sql
nnew file mode 100644
iindex 0000000..af0983e
---- /dev/null
++++ b/migrations/0036_price_changes.sql
@@@ -0,0 +1,29 @@
++-- phase: expand
++-- The living price book (plan E1, 2026-10-30 §8): the log of every price change the weekly job found.
++--
++-- A price is only ever read from the provider that sells it, and a live price never changes silently. The job
++-- reads each catalogued provider's own listing, compares it with the price the router charges, and writes what it
++-- found here: the old price, the new price, the listing the price was read from and the day it was read. A change
++-- of more than 20% waits as 'pending' until the owner approves it on the operator console; a smaller one is
++-- applied to the model row at once and lands here as 'applied'.
++--
++-- This table is the log, not a second list of prices: the live price stays in the models row the router reads
++-- (server/registry.js), and this table says how it got there and who said so.
++CREATE TABLE IF NOT EXISTS price_changes (
++ id text COLLATE "C" PRIMARY KEY, -- pc_<ULID>
++ provider_key text COLLATE "C" NOT NULL,
++ model_key text COLLATE "C" NOT NULL,
++ field text COLLATE "C" NOT NULL CHECK (field IN ('in_per_mtok', 'cached_per_mtok', 'out_per_mtok')),
++ old_price numeric(20, 6), -- null when the model had no price yet
++ new_price numeric(20, 6) NOT NULL,
++ change_pct numeric(14, 6), -- the signed relative change, null when there was no price
++ source text COLLATE "C" NOT NULL, -- the provider's own listing the price was read from
++ checked_at date NOT NULL, -- the day the price was read
++ status text COLLATE "C" NOT NULL CHECK (status IN ('applied', 'pending', 'approved', 'rejected', 'superseded')),
++ created_at timestamptz NOT NULL,
++ decided_at timestamptz,
++ decided_by text COLLATE "C",
++ note text COLLATE "C"
++);
++CREATE INDEX IF NOT EXISTS price_changes_model_idx ON price_changes (provider_key, model_key, created_at DESC);
++CREATE INDEX IF NOT EXISTS price_changes_status_idx ON price_changes (status, created_at DESC);
ddiff --git a/migrations/0037_price_book_state.sql b/migrations/0037_price_book_state.sql
nnew file mode 100644
iindex 0000000..979283d
---- /dev/null
++++ b/migrations/0037_price_book_state.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- The living price book's own state: when it last ran, and which instance is running it now.
++--
++-- The weekly interval has to survive a restart. OpenVibe.AI is deployed several times a day, so a timer that
++-- lives in the process would be reset every time and the price book would never run on its own. The last run
++-- is therefore a row in the database, not a moment in memory, and the due check reads it.
++--
++-- The row carries the claim as well: AI runs several workers, and only one of them may read the providers'
++-- listings at a time. A worker claims the row before it starts and releases it when it ends; the claim expires
++-- on its own, so a worker that dies mid-run does not block the next one for ever.
++CREATE TABLE IF NOT EXISTS price_book_state (
++ id text COLLATE "C" PRIMARY KEY CHECK (id = 'pricebook'), -- one row
++ last_run_at timestamptz, -- when the last run finished (null before the first one)
++ claimed_until timestamptz, -- while this is in the future, an instance holds the run
++ claimed_by text COLLATE "C", -- the instance that holds it
++ updated_at timestamptz NOT NULL
++);
++INSERT INTO price_book_state (id, last_run_at, claimed_until, claimed_by, updated_at)
++VALUES ('pricebook', NULL, NULL, NULL, now()) ON CONFLICT (id) DO NOTHING;
ddiff --git a/scripts/record-price-book.js b/scripts/record-price-book.js
nnew file mode 100644
iindex 0000000..586e03e
---- /dev/null
++++ b/scripts/record-price-book.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * Record the providers' own price listings as fixtures (test/fixtures/pricebook/).
++ *
++ * The price book reads the open internet once a week in production and never in the tests: the tests replay the
++ * listings recorded here. This script is how a fixture is refreshed — it reads each listing through the same
++ * egress guard the job uses, keeps the rows the catalogue cares about (the listing of a provider with a thousand
++ * models is a megabyte of fixture nobody reads) and writes the result.
++ *
++ * node scripts/record-price-book.js [outdir]
++ *
++ * The recorded date is in the file name of the manifest (test/fixtures/pricebook/manifest.json), so a test can say
++ * when the listing was read.
++ */
++const fs = require('fs');
++const path = require('path');
++const pool = require('../server/providers/pool-providers');
++const { SOURCES } = require('../server/pricebook/sources');
++const { readListing } = require('../server/pricebook/read');
++
++const OUT = path.join(__dirname, '..', 'test', 'fixtures', 'pricebook');
++
++/** The models of a provider the catalogue knows, so the fixture keeps the rows that matter. */
++function wantedFor(provider) {
++ if (provider === 'deepseek') return ['deepseek-flash', 'deepseek-v4-pro'];
++ const p = pool.byKey.get(provider);
++ return p ? p.models.map((m) => m.model_key) : [];
++}
++
++const leaf = (id) => String(id || '').split('/').pop().toLowerCase();
++
++/** The JSON listings: only the models the catalogue knows. */
++function trimJson(body, wanted) {
++ const json = JSON.parse(body);
++ const leaves = new Set(wanted.map(leaf));
++ const keep = (json.data || []).filter((m) => leaves.has(leaf(m.id)) || wanted.some((w) => String(m.id || '').toLowerCase() === w.toLowerCase()));
++ return JSON.stringify({ ...json, data: keep }, null, 2);
++}
++
++/** Alibaba's page: the table rows of the models the catalogue knows. */
++function trimQwen(body, wanted) {
++ const rows = [...body.matchAll(/<tr[^>]*>[\s\S]*?<\/tr>/gi)].map((m) => m[0]);
++ const keep = rows.filter((row) => wanted.some((w) => row.includes(w)));
++ return keep.join('\n');
++}
++
++/** Kimi's page: the table payloads (the columns and the rows) that name a model the catalogue knows. */
++function trimKimi(body, wanted) {
++ const end = (text, start) => {
++ let depth = 0;
++ for (let i = start; i < text.length; i++) {
++ if (text[i] === '[') depth++;
++ else if (text[i] === ']') { depth--; if (depth === 0) return i; }
++ }
++ return -1;
++ };
++ const out = [];
++ let at = 0;
++ while ((at = body.indexOf('columns:[', at)) !== -1) {
++ const colsStart = body.indexOf('[', at);
++ const colsEnd = end(body, colsStart);
++ const rowsAt = body.indexOf('rows:[', colsEnd);
++ if (rowsAt === -1) break;
++ const rowsStart = body.indexOf('[', rowsAt);
++ const rowsEnd = end(body, rowsStart);
++ const columns = body.slice(at, colsEnd + 1);
++ const rows = body.slice(rowsAt, rowsEnd + 1);
++ if (wanted.some((w) => rows.includes(w))) out.push(columns + '\n' + rows);
++ at = rowsEnd;
++ }
++ return out.join('\n\n');
++}
++
++async function recordFixtures() {
++ fs.mkdirSync(OUT, { recursive: true });
++ const manifest = { recorded_at: new Date().toISOString().slice(0, 10), sources: [] };
++ for (const source of SOURCES) {
++ const res = await readListing(source.url);
++ if (!res.ok) {
++ console.error(`${source.key}: ${res.error}`);
++ manifest.sources.push({ key: source.key, url: source.url, error: res.error });
++ continue;
++ }
++ // The cross-reference is compared with every provider's models, so it keeps all of them.
++ const wanted = source.role === 'cross-reference'
++ ? pool.PROVIDERS.flatMap((p) => p.models.map((m) => m.model_key))
++ : wantedFor(source.provider);
++ let body = res.body;
++ let file = source.key;
++ if (source.kind === 'json') { body = trimJson(body, wanted); file += '.json'; }
++ else if (source.key === 'qwen') { body = trimQwen(body, wanted); file += '.html'; }
++ else if (source.key === 'kimi') { body = trimKimi(body, wanted); file += '.html'; }
++ else file += '.md';
++ fs.writeFileSync(path.join(OUT, file), body);
++ console.log(`${source.key}: ${body.length} bytes -> ${file}`);
++ manifest.sources.push({ key: source.key, url: source.url, file, bytes: body.length, listed: Object.keys(source.parse(source.kind === 'json' ? JSON.parse(body) : body)).length });
++ }
++ fs.writeFileSync(path.join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
++}
++
++module.exports = { wantedFor, trimJson, trimQwen, trimKimi, recordFixtures };
++
++if (require.main === module) recordFixtures().catch((err) => { console.error(err.message); process.exitCode = 1; });
ddiff --git a/server/api/exchange.js b/server/api/exchange.js
iindex 77d73b2..8da548a 100644
---- a/server/api/exchange.js
++++ b/server/api/exchange.js
@@@ -7,6 +7,7 @@
* OpenVibe.Exchange's read API: the public catalog, one model, and aggregate usage.
*
* GET /api/v1/exchange/catalog every model, its offers, prices, speed and uptime
++ * GET /api/v1/exchange/price-changes the price changelog: what a price was and what it became
* GET /api/v1/exchange/models/:slug one model ("deepseek/deepseek-chat", or org + model)
* GET /api/v1/exchange/usage?days=30 requests, tokens and spend by day, model and product
*
@@@ -37,6 +38,8 @@ function memo(fn, ttlMs, clock) {
function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console }) {
const catalog = createCatalog({ db, registry, pool, env, clock, config });
const usage = createUsage({ db, clock });
++ // The price book's log: what a price was, what it became and when it was read.
++ const priceStore = require('../pricebook/store').createPriceStore({ db, clock });
// One cached read per kind (?kind=chat, screenshot, …); the unfiltered read is the full catalog.
const hits = new Map();
const allFor = (kind) => {
@@@ -175,6 +178,33 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:
} catch (err) { return next(err); }
});
++ // The changelog of the price book (plan E1): what a price was, what it became and the provider's own
++ // listing it was read from. Only the changes that are live are here: a change waiting for the owner is not
++ // a price yet, and a guess never reaches a catalog.
++ r.get('/api/v1/exchange/price-changes', publicCache(60), async (req, res, next) => {
++ try {
++ const limit = Math.max(1, Math.min(200, Number.parseInt(req.query.limit, 10) || 50));
++ const provider = req.query.provider ? String(req.query.provider) : null;
++ const rows = await priceStore.live({ limit, provider });
++ return res.json({
++ generated_at: new Date(clock.now()).toISOString(),
++ count: rows.length,
++ changes: rows.map((c) => ({
++ id: c.id,
++ provider: c.provider_key,
++ model: c.model_key,
++ field: c.field,
++ old_price: c.old_price == null ? null : Number(c.old_price),
++ new_price: Number(c.new_price),
++ change_pct: c.change_pct == null ? null : Number(c.change_pct),
++ source: c.source,
++ checked_at: String(c.checked_at).slice(0, 10),
++ changed_at: c.created_at,
++ })),
++ });
++ } catch (err) { return next(err); }
++ });
++
r.get('/api/v1/exchange/usage', publicCache(60), async (req, res, next) => {
try {
const days = Math.max(1, Math.min(366, Number.parseInt(req.query.days, 10) || 30));
ddiff --git a/server/app.js b/server/app.js
iindex 7557b9a..73aad19 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null }) {
++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@@ -177,7 +177,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
}
-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse }));
++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook }));
app.use(runPageRouter({ runs, personSessions, log }));
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
ddiff --git a/server/config.js b/server/config.js
iindex 6a810da..ed1e664 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -357,6 +357,27 @@ function load(env = process.env) {
providerRetries: Math.max(0, int(env.AI_PROVIDER_RETRIES, 1)),
retryDelayMs: Math.max(0, int(env.AI_PROVIDER_RETRY_DELAY_MS, 800)),
++ // ── The living price book (server/pricebook/) ──
++ // The weekly job that reads every catalogued provider's own price listing and keeps the price the router
++ // charges in step with it (plan E1). A change of more than the threshold waits for the owner on the
++ // operator console; a smaller one is applied at once. Off under a restore drill (AI_DRILL=1) and off in
++ // test mode, where the recorded listings are replayed instead.
++ priceBook: {
++ enabled: bool(env.AI_PRICE_BOOK, !bool(env.AI_DRILL, false) && nodeEnv !== 'test'),
++ // How long between two runs. The service is deployed several times a day, so the book is due-based:
++ // the last finished run is a row in the database and the tick only reads the listings when the
++ // interval has passed since then.
++ intervalMs: Math.max(3600000, int(env.AI_PRICE_BOOK_INTERVAL_MS, 7 * 24 * 60 * 60 * 1000)),
++ // How often the service asks whether the book is due, and how long after a boot the first ask is.
++ tickMs: Math.max(60000, int(env.AI_PRICE_BOOK_TICK_MS, 60 * 60 * 1000)),
++ firstTickMs: Math.max(1000, int(env.AI_PRICE_BOOK_FIRST_TICK_MS, 5 * 60 * 1000)),
++ // How long a claimed run holds the claim, so a worker that dies does not block the next one.
++ claimTtlMs: Math.max(60000, int(env.AI_PRICE_BOOK_CLAIM_TTL_MS, 15 * 60 * 1000)),
++ timeoutMs: Math.max(1000, int(env.AI_PRICE_BOOK_TIMEOUT_MS, 20000)),
++ maxBytes: Math.max(1024, int(env.AI_PRICE_BOOK_MAX_BYTES, 8 * 1024 * 1024)),
++ thresholdPct: Number(env.AI_PRICE_BOOK_THRESHOLD_PCT || 20) / 100,
++ },
++
// ── Hourly provider balance probe (server/providers/balance.js) ──
// Each active provider with a balance API (DeepSeek today) is asked GET <origin>/user/balance and the reading
// is exposed as ai_provider_balance_usd{provider} on GET /metrics. Off under a restore drill (AI_DRILL=1).
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 86460d6..6133739 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -19,6 +19,9 @@
* Billing readings by delivery state, usage-samples deliveryReport)
* GET /console/cache; POST /console/cache/purge (writes: ai.provider.manage; ops.purgeCache)
* GET /console/audit[?kind&action&actor&target_type&target_id&before]
++ * GET /console/pricebook the living price book: the prices that wait for the owner
++ * POST /console/pricebook/changes/:id approve or reject a waiting price change (ai.provider.manage)
++ * POST /console/pricebook/run read the providers' listings now (ai.provider.manage)
* GET /console/exchange AI Exchange state (AI_CREDITS or AI_ROUTING_EVIDENCE)
* POST /console/exchange/credits/:id/resolve mark a review settle row resolved, never retried
*
@@@ -102,6 +105,9 @@ const NOTICES = {
cache_purged: 'Cache purged.',
run_cancelled: 'Run cancelled.',
settle_resolved: 'Settle queue row marked resolved. Nothing was retried.',
++ price_approved: 'The price change was approved: the price the router charges is the new one.',
++ price_rejected: 'The price change was rejected: the price stands.',
++ pricebook_ran: 'The provider listings were read.',
};
const DEF_KINDS = {
routes: { one: 'route', list: 'listRoutes', get: 'getRoute' },
@@@ -110,7 +116,7 @@ const DEF_KINDS = {
};
const DAY_MS = 86400000;
--function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null }) {
++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null, priceBook = null }) {
const cc = config.console;
const creditsOn = Boolean(config.credits && config.credits.enabled);
const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);
@@@ -556,6 +562,37 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
res.redirect(303, '/console/gpus?done=gpu_requested');
}));
++ // ── The living price book: the prices the router charges, checked against each provider's own listing ──
++ // The owner decides the changes the weekly job may not make on its own (a change of more than 20%).
++ r.get('/console/pricebook', read, wrap(async (req, res) => {
++ await send(res, 200, pages.pricebook({
++ ...common(req), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req),
++ report: priceBook ? priceBook.last : null,
++ state: priceBook ? await priceBook.state.read() : null,
++ pending: priceBook ? await priceBook.pending(50) : [],
++ recent: priceBook ? await priceBook.changes({ status: 'applied', limit: 50 }) : [],
++ }));
++ }));
++ r.post('/console/pricebook/changes/:id', needs(CAPS.providerManage), wrap(async (req, res) => {
++ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');
++ const id = String(req.params.id || '').slice(0, 60);
++ const decision = String((req.body || {}).decision || '') === 'approve' ? 'approve' : 'reject';
++ const out = await priceBook.decide(id, { decision, actor: req.staff.subject });
++ if (!out.ok) throw new AiError(409, 'ai.conflict', out.error);
++ await audit(req.staff.subject, decision === 'approve' ? 'price_change.approved' : 'price_change.rejected', 'price_change', id, { decision }, req);
++ return res.redirect(303, '/console/pricebook?done=' + (decision === 'approve' ? 'price_approved' : 'price_rejected'));
++ }));
++ r.post('/console/pricebook/run', needs(CAPS.providerManage), wrap(async (req, res) => {
++ if (!priceBook) throw new AiError(409, 'ai.conflict', 'the price book is off on this host');
++ const report = await priceBook.runOnce({ force: true });
++ await audit(req.staff.subject, 'pricebook.run', 'pricebook', 'run', {
++ sources: report.sources ? report.sources.length : 0,
++ applied: (report.applied || []).length,
++ pending: (report.pending || []).length,
++ }, req);
++ return res.redirect(303, '/console/pricebook?done=pricebook_ran');
++ }));
++
// ── AI Exchange (P7): Credits settle queue and routing evidence ──
// Read-only except "mark resolved", which acknowledges one review row (a 409/422 needs a person) and
// deliberately does not retry it. Both sections are absent unless their flag is on.
ddiff --git a/server/console/views.js b/server/console/views.js
iindex da769e5..338d791 100644
---- a/server/console/views.js
++++ b/server/console/views.js
@@@ -123,6 +123,7 @@ const NAV = [
['/console/templates', 'Templates', 'templates'], ['/console/workflows', 'Workflows', 'workflows'], ['/console/runs', 'Runs', 'runs'],
['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/abuse', 'Abuse', 'abuse'], ['/console/audit', 'Audit', 'audit'],
['/console/gpus', 'GPUs', 'gpus'],
++ ['/console/pricebook', 'Prices', 'pricebook'],
];
// The console's pages are noindex; the one public page drawn with this layout (/stats) passes robots, a description and
@@@ -697,6 +698,56 @@ ${table('Audit rows, newest first', ['#', 'At', 'Actor', 'Action', 'Target', 'De
});
}
++/**
++ * The price book: what the weekly job found, what waits for the owner and what it changed.
++ *
++ * A price never changes silently. A change of more than 20% waits here until the owner approves it; a smaller
++ * one was applied by the job and is in the log. The listing the price was read from is on every row, so the
++ * owner can go and read it before saying yes.
++ */
++function priceChangeRow(c, { csrf, canManage, actions = false } = {}) {
++ const pct = c.change_pct == null ? null : Number(c.change_pct) * 100;
++ return html`<tr>
++ <td><code>${c.provider_key}</code> / <code>${c.model_key}</code></td>
++ <td>${c.field.replace(/_per_mtok$/, '')}</td>
++ <td>${c.old_price == null ? html`<span class="muted">no price</span>` : usd(Number(c.old_price))}</td>
++ <td>${usd(Number(c.new_price))}</td>
++ <td>${pct == null ? html`<span class="muted">new price</span>` : (pct >= 0 ? '+' : '') + pct.toFixed(1) + ' %'}</td>
++ <td>${c.checked_at}</td>
++ <td><span class="muted">${clip(c.source, 70)}</span></td>
++ ${actions ? html`<td>${canManage ? html`<form class="inline" method="post" action="/console/pricebook/changes/${enc(c.id)}">${csrfField(csrf)}
++ <input type="hidden" name="decision" value="approve"><button type="submit">Approve</button></form>
++ <form class="inline" method="post" action="/console/pricebook/changes/${enc(c.id)}">${csrfField(csrf)}
++ <input type="hidden" name="decision" value="reject"><button type="submit">Reject</button></form>` : html`<span class="muted">needs ai.provider.manage</span>`}</td>` : ''}
++</tr>`;
++}
++
++function pricebook({ staff, csrf, report, state, pending, recent, canManage, notice, error }) {
++ const st = report || {};
++ const unreadable = st.unreadable || [];
++ const cross = st.cross_reference || null;
++ const row = state || {};
++ return layout({
++ title: 'Price book', section: 'pricebook', staff, csrf, notice, error, body: html`<h1>Price book</h1>
++<p class="muted">Every catalogued provider's own price listing, read once a week. A price comes from the provider that sells it and never from a guess; a listing that cannot be read leaves every price alone. A change of more than 20% waits here for the owner; a smaller one the job applies at once.</p>
++<div class="grid">
++ <div class="card"><div class="k">Last run</div><div class="v">${row.last_run_at ? String(row.last_run_at).slice(0, 10) : (st.checked_at || 'never')}</div><div class="muted">${row.last_run_at ? 'finished ' + when(row.last_run_at) : 'the job has not run yet'}</div></div>
++ <div class="card"><div class="k">Waiting for the owner</div><div class="v">${n((pending || []).length)}</div><div class="muted">a change of more than 20%</div></div>
++ <div class="card"><div class="k">Changed by the job</div><div class="v">${n((st.applied || []).length)}</div><div class="muted">a change of 20% or less</div></div>
++ <div class="card"><div class="k">Listings unreadable</div><div class="v">${n(unreadable.length)}</div><div class="muted">their prices stand</div></div>
++</div>
++${canManage ? html`<form class="inline" method="post" action="/console/pricebook/run">${csrfField(csrf)}<button type="submit">Read the prices now</button></form>` : html`<p class="muted">Reading the prices on demand needs <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}
++${row.claimed_by ? html`<p class="muted">Another instance is running the book right now (${row.claimed_by}).</p>` : ''}
++${unreadable.length ? html`${table('Listings that could not be read', ['Listing', 'Why'], unreadable.map((u) => html`<tr><td><code>${u.source}</code></td><td>${u.error}</td></tr>`), 'every listing was read.')}` : ''}
++${cross ? html`${table('OpenRouter as a second opinion', ['Models compared', 'The same price', 'A different price'], [html`<tr><td>${n(cross.models)}</td><td>${n(cross.agree)}</td><td>${n(cross.differ)}</td></tr>`], 'nothing was compared.')}` : ''}
++<h2>Waiting for the owner</h2>
++${table('Pending price changes', ['Model', 'Price', 'Was', 'Now', 'Change', 'Checked', 'Read from', 'Decision'], (pending || []).map((c) => priceChangeRow(c, { csrf, canManage, actions: true })), 'no price change is waiting.')}
++<h2>What the job changed</h2>
++${table('Applied price changes', ['Model', 'Price', 'Was', 'Now', 'Change', 'Checked', 'Read from'], (recent || []).map((c) => priceChangeRow(c, { csrf, canManage })), 'the job has not changed a price yet.')}
++`,
++ });
++}
++
// ── Person: one run (GET /runs/:id, signed in) ───────────────
/**
* A person's receipt for one run: status, workflow, model, cost, times, and the output or the error.
@@@ -755,4 +806,4 @@ function allowance(cards) {
/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */
const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };
--module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, abuse: abusePage, audit, exchange, gpus } };
++module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, abuse: abusePage, audit, exchange, gpus, pricebook } };
ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex ddddc56..adfeb4a 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -107,6 +107,15 @@ function modalitiesOf(row) {
const usd6 = (n) => (n == null || !Number.isFinite(Number(n)) ? (n ?? null) : Math.round(Number(n) * 1e6) / 1e6);
++/** The later of two days ("YYYY-MM-DD"), or the one there is. */
++const laterOf = (a, b) => {
++ const x = a ? String(a).slice(0, 10) : null;
++ const y = b ? String(b).slice(0, 10) : null;
++ if (!x) return y;
++ if (!y) return x;
++ return x > y ? x : y;
++};
++
function createCatalog({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, config = null }) {
// The free-tier admission rules are the router's (server/free-tier.js): the catalog marks an offer with what
// the free allowance and the anonymous Try it may actually use. config.freeUse is fixed at boot, like the
@@@ -114,6 +123,20 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
const freeUse = config && config.freeUse ? config.freeUse : undefined;
const seen = observed.createObserved({ db, clock });
++ /**
++ * The price book's log: the day each model's price was last read and changed. The catalog reads it, so the
++ * date it shows is the date the book recorded, not a number written into the code. One read for the whole
++ * catalog; a service whose database has no log yet simply has nothing to add.
++ */
++ async function loggedChecks() {
++ const rows = await db.prepare(`SELECT DISTINCT ON (provider_key, model_key) provider_key, model_key, checked_at
++ FROM price_changes WHERE status IN ('applied', 'approved') ORDER BY provider_key, model_key, created_at DESC`)
++ .all().catch(() => []);
++ const map = new Map();
++ for (const row of rows || []) map.set(`${row.provider_key}/${row.model_key}`, String(row.checked_at || '').slice(0, 10));
++ return map;
++ }
++
/**
* Every live offer, grouped by the model it serves.
* days: the window the measured speed, uptime and request counts cover.
@@@ -137,6 +160,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
};
const upstreamByKey = new Map();
for (const p of providers) upstreamByKey.set(p.key, upstream.upstreamOf(p));
++ // What the price book recorded: the day a price was read and changed (server/pricebook/).
++ const logged = await loggedChecks();
const groups = new Map();
const drafts = [];
@@@ -161,6 +186,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
if (!slug) continue;
const cards = await pool.rateCardsFor(provider.key, row.model_key);
const price = cards ? rates.pricesOf(cards) : null;
++ // The day this price was checked: what the price book logged, or the day the rate card carries.
++ const checkedAt = laterOf(logged.get(`${row.provider_key}/${row.model_key}`), cards ? cards.out.verified_at : null);
const offer = {
provider: up.key,
provider_name: up.name,
@@@ -181,7 +208,10 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
price: price ? { in: usd6(price.in), out: usd6(price.out), cached: usd6(price.cached), per: '1M tokens', currency: 'USD' } : null,
// Where the number came from and when someone checked it; null when nobody recorded it.
price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,
-- price_checked_at: cards && cards.out.verified_at && cards.out.verified_at !== rates.UNKNOWN_DATE ? cards.out.verified_at : null,
++ // When the price was checked: the day the price book recorded, or the day the card carries.
++ price_checked_at: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? checkedAt : null,
++ // The same day in words: what the catalog shows under the price is when somebody read it.
++ price_checked: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? `price checked ${checkedAt}` : null,
data_policy: policyOf(provider),
// The privacy label of this offer, from the same facts the router uses (server/exchange/privacy.js):
// Private (zero retention, no training, or the host's own deployment), Anonymized, Community.
ddiff --git a/server/index.js b/server/index.js
iindex 4afc273..8f2437a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -150,6 +150,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// The store's own maintenance (Memory M2c/M3): owner by owner, with a per-run cap, and the expired import
// drafts. It is off in test mode and off while a provider is in a drill (server/memory/jobs.js).
const memoryJobs = require('./memory/jobs').createMemoryJobs({ memory, memoryImport, config, clock, log, registry });
++ // The living price book (plan E1): the weekly job that reads every catalogued provider's own price listing and
++ // keeps the price the router charges in step with it. A change of more than 20% waits for the owner on the
++ // operator console. Off in test mode (the tests replay the recorded listings) and off under a drill.
++ const priceBook = require('./pricebook').createPriceBook({ db, registry, config, clock, log });
// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).
const accountData = accountDataLib.create({ db, log, memory });
const accountSend = env.OV_OAUTH_CLIENT_SECRET ? createNetworkSender({
@@@ -157,7 +161,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, presets, receipts, balance, gpus, abuse });
++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, presets, receipts, balance, gpus, abuse, priceBook });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
@@@ -196,6 +200,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (listen) userModules.start();
if (listen) balance.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
++ // The price book reads the providers' listings once a week; it never starts in test mode or under a drill.
++ if (listen && config.priceBook && config.priceBook.enabled) priceBook.start();
// Durable Credit settles: the same jobs rule as Memory (never in test mode, never under a provider drill; the
// queue's runOnce checks the drill itself).
if (listen && credits && config.nodeEnv !== 'test') credits.startSettlements();
@@@ -231,6 +237,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (accountSubscription) accountSubscription.stop();
userModules.stop();
memoryJobs.stop();
++ priceBook.stop();
if (credits) credits.stopSettlements();
if (jwks) jwks.stop();
if (server) {
@@@ -246,7 +253,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
}
-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, endpoints, gpus, abuse, close };
++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, priceBook, credits: creditsRef, settings, vault, community, endpoints, gpus, abuse, close };
}
if (require.main === module) {
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 3613d9a..37b6d2f 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -360,6 +360,20 @@ route('get', '/exchange/usage', {
} }) },
});
++route('get', '/exchange/price-changes', {
++ operationId: 'exchangePriceChanges', summary: 'What a price was and what it became', tags: ['exchange'], security: [],
++ description: "Public. The price changelog: every change the weekly price book made, with the old price, the new price, the provider's own listing the price was read from and the day it was read. A change that waits for the owner's approval is not a price yet and is not here.",
++ parameters: [query('limit', integer({ minimum: 1, maximum: 200 }), 'Default 50'), query('provider', str(), 'Only this provider')],
++ responses: { 200: json('The changes', { type: 'object', properties: {
++ generated_at: str({ format: 'date-time' }), count: integer(),
++ changes: arrayOf({ type: 'object', properties: {
++ id: str(), provider: str(), model: str(), field: str(),
++ old_price: nullable('number'), new_price: { type: 'number' }, change_pct: nullable('number'),
++ source: str(), checked_at: str({ format: 'date' }), changed_at: str({ format: 'date-time' }),
++ } }),
++ } }) },
++});
++
// ── The seller API: a standalone OpenAI-compatible endpoint (the design's SDK gap) ───────────
const sellerEndpointOffer = {
type: 'object', required: ['id', 'offer_id', 'base_url', 'models', 'hours', 'concurrency', 'price', 'audience'],
ddiff --git a/server/pricebook/index.js b/server/pricebook/index.js
nnew file mode 100644
iindex 0000000..580ee0f
---- /dev/null
++++ b/server/pricebook/index.js
@@@ -0,0 +1,14 @@
++'use strict';
++/**
++ * The living price book (plan E1, 2026-10-30 §8): the weekly job that reads every catalogued provider's own
++ * price listing, the change log it writes and the listings it reads.
++ *
++ * The job is created once, in the service boot (server/index.js), and handed to the operator console so the owner
++ * can approve the changes the job may not make on its own.
++ */
++const { createPriceBook } = require('./job');
++const { createPriceStore } = require('./store');
++const { SOURCES } = require('./sources');
++const { readListing } = require('./read');
++
++module.exports = { createPriceBook, createPriceStore, SOURCES, readListing };
ddiff --git a/server/pricebook/job.js b/server/pricebook/job.js
nnew file mode 100644
iindex 0000000..bd598ee
---- /dev/null
++++ b/server/pricebook/job.js
@@@ -0,0 +1,387 @@
++'use strict';
++/**
++ * The living price book: a weekly job that reads every catalogued provider's own price listing and keeps the
++ * price the router charges in step with it.
++ *
++ * The rules it lives by:
++ *
++ * - A price comes from the provider that sells it, never from a guess and never from an aggregator.
++ * - A price nobody publishes stays unknown. A listing that cannot be read leaves every price alone.
++ * - A live price never changes silently. Every change lands in the price_changes log with the old price, the
++ * new price, the listing it was read from and the day it was read.
++ * - A change of more than the threshold (20%) waits for the owner on the operator console. A smaller one is
++ * applied at once.
++ *
++ * The job is off under drill mode and off in the tests, and the tests replay the recorded listings through the
++ * transport seam, so CI never touches the network.
++ *
++ * It is due-based rather than timer-based: the last finished run is a row in the database (server/pricebook/
++ * state.js), because AI is deployed several times a day and a timer in the process would be reset every time.
++ * The service ticks once an hour and reads the listings only when the interval has passed; the first tick after a
++ * boot is a few minutes in, so the boot path stays free. Several workers run this service, so a run is claimed
++ * before it starts: only one of them reads the listings at a time.
++ */
++const os = require('os');
++const pool = require('../providers/pool-providers');
++const { SOURCES } = require('./sources');
++const { readListing } = require('./read');
++const { createPriceStore, FIELDS, FIELD_NAMES } = require('./store');
++const { createPriceState } = require('./state');
++
++/** A price that differs by less than this is the same price: the providers publish floats (0.205999992). */
++const EPSILON = 1e-6;
++const DEFAULT_THRESHOLD = 0.2;
++const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
++const HOUR_MS = 60 * 60 * 1000;
++
++/** The sources the catalogue itself names, so a row the catalogue wrote is recognisable. */
++function catalogueSources() {
++ const set = new Set();
++ for (const p of pool.PROVIDERS || []) for (const m of p.models || []) if (m.source) set.add(m.source);
++ return set;
++}
++
++/**
++ * May the price book price this row? A row a person priced by hand keeps its price, and so does a row the price
++ * book already owns.
++ */
++function eligible(row, sources = catalogueSources()) {
++ const meta = row.metadata || {};
++ if (meta.price_book) return true;
++ if (meta.id_source === 'provider-page' || meta.id_source === 'provider-list') return true;
++ return Boolean(row.cost && row.cost.source && sources.has(row.cost.source));
++}
++
++/** The price a listing publishes for a model: the model's own id first, its leaf name second. */
++function findPrice(prices, modelKey) {
++ const exact = prices[String(modelKey || '').trim().toLowerCase()];
++ if (exact) return { price: exact, via: 'id' };
++ const wanted = String(modelKey || '').split('/').pop().trim().toLowerCase();
++ for (const [id, price] of Object.entries(prices)) {
++ if (id.split('/').pop().trim().toLowerCase() === wanted) return { price, via: 'leaf' };
++ }
++ return null;
++}
++
++const numeric = (v) => {
++ if (v === null || v === undefined || v === '') return null;
++ const n = Number(v);
++ return Number.isFinite(n) ? n : null;
++};
++
++function createPriceBook({ db, registry, config = {}, clock = { now: () => Date.now() }, log = console, transport = null, lookup = null, sources = SOURCES, threshold = null }) {
++ const settings = {
++ threshold: DEFAULT_THRESHOLD,
++ timeoutMs: 20000,
++ maxBytes: 8 * 1024 * 1024,
++ intervalMs: WEEK_MS,
++ tickMs: HOUR_MS,
++ firstTickMs: 5 * 60 * 1000,
++ claimTtlMs: 15 * 60 * 1000,
++ ...(config.priceBook || {}),
++ };
++ const limit = threshold === null || threshold === undefined ? Number(settings.threshold) : Number(threshold);
++ const store = createPriceStore({ db, clock });
++ // When the book last ran and which instance is running it now (server/pricebook/state.js).
++ const state = createPriceState({ db, clock });
++ // Who this instance is: the claim says which one holds the run.
++ const who = `${os.hostname()}:${process.pid}`;
++ // In test mode the book reads nothing from the internet: a test that wants a listing replays a recorded one
++ // through the transport seam, and the service's own instance refuses to open a socket at all.
++ const reader = transport || (config.nodeEnv === 'test'
++ ? async () => ({ ok: false, error: 'the price book does not read the network in test mode' })
++ : null);
++ let running = false;
++ let timer = null;
++ let first = null;
++ let last = null;
++
++ const iso = () => new Date(clock.now()).toISOString();
++ const day = () => iso().slice(0, 10);
++
++ /** A provider in a drill must not move a price: the run waits for the drill to end. */
++ async function drillActive() {
++ if (!registry) return false;
++ const providers = await registry.listProviders();
++ return providers.some((p) => p && p.metadata && (p.metadata.fail || p.metadata.drill));
++ }
++
++ /** The model's row with a new price (or the same one with a new checked date). */
++ async function writeRow({ row, cost, metadata, actor = 'pricebook' }) {
++ return registry.upsertModel({
++ provider_key: row.provider_key,
++ model_key: row.model_key,
++ display_name: row.display_name,
++ type: row.type,
++ status: row.status,
++ context_window: row.context_window,
++ max_output: row.max_output,
++ cost,
++ supports: row.supports,
++ metadata,
++ }, { actor });
++ }
++
++ /** The cost of a row with the price book's numbers in it. */
++ function costWith(row, { changes = [], source, checkedAt, moved = false }) {
++ const cost = { ...(row.cost || {}) };
++ for (const change of changes) cost[change.field] = change.next;
++ cost.source = source;
++ cost.verified_at = checkedAt;
++ if (moved) cost.effective_from = checkedAt;
++ if (cost.cache_write_per_mtok === undefined) delete cost.cache_write_per_mtok;
++ return cost;
++ }
++
++ /** The metadata that says the price book owns this price, so the catalogue does not write over it. */
++ function metadataWith(row, { source, checkedAt }) {
++ return { ...(row.metadata || {}), price_book: { checked_at: checkedAt, source } };
++ }
++
++ /**
++ * Compare one provider's listing with the prices that are live, and record what changed.
++ */
++ async function priceProvider(source, prices, report, checkedAt) {
++ const rows = await registry.listModels({ provider: source.provider });
++ const catalogue = catalogueSources();
++ for (const row of rows) {
++ if (!eligible(row, catalogue)) { report.owned_by_hand.push(row.model_key); continue; }
++ const found = findPrice(prices, row.model_key);
++ if (!found) { report.not_listed.push(row.model_key); continue; }
++ report.models++;
++ const changes = [];
++ for (const field of FIELDS) {
++ const next = found.price[FIELD_NAMES[field]];
++ if (next === null || next === undefined) continue; // the listing publishes no such price
++ const old = numeric(row.cost ? row.cost[field] : null);
++ if (old !== null && Math.abs(old - next) <= EPSILON) { report.unchanged++; continue; }
++ const pct = old === null || old === 0 ? null : (next - old) / old;
++ changes.push({ field, old, next, pct });
++ }
++ if (!changes.length) {
++ // The price stands: only the day it was checked moves, so the catalog can say when it was read.
++ if (row.cost && row.cost.verified_at !== checkedAt) {
++ await writeRow({
++ row,
++ cost: costWith(row, { source: source.url, checkedAt }),
++ metadata: metadataWith(row, { source: source.url, checkedAt }),
++ });
++ report.checked.push(row.model_key);
++ }
++ continue;
++ }
++ const small = changes.filter((c) => c.pct !== null && Math.abs(c.pct) <= limit);
++ const big = changes.filter((c) => !(c.pct !== null && Math.abs(c.pct) <= limit));
++ if (small.length) {
++ await writeRow({
++ row,
++ cost: costWith(row, { changes: small, source: source.url, checkedAt, moved: true }),
++ metadata: metadataWith(row, { source: source.url, checkedAt }),
++ });
++ for (const change of small) {
++ await store.record({
++ provider_key: row.provider_key,
++ model_key: row.model_key,
++ field: change.field,
++ old_price: change.old,
++ new_price: change.next,
++ change_pct: change.pct,
++ source: source.url,
++ checked_at: checkedAt,
++ status: 'applied',
++ note: 'applied by the weekly job',
++ });
++ report.applied.push({ model: row.model_key, field: change.field, old: change.old, next: change.next });
++ }
++ }
++ for (const change of big) {
++ await store.supersede({ provider_key: row.provider_key, model_key: row.model_key, field: change.field });
++ const id = await store.record({
++ provider_key: row.provider_key,
++ model_key: row.model_key,
++ field: change.field,
++ old_price: change.old,
++ new_price: change.next,
++ change_pct: change.pct,
++ source: source.url,
++ checked_at: checkedAt,
++ status: 'pending',
++ note: 'over ' + Math.round(limit * 100) + '%: waiting for the owner',
++ });
++ report.pending.push({ id, model: row.model_key, field: change.field, old: change.old, next: change.next, pct: change.pct });
++ }
++ }
++ }
++
++ /**
++ * The cross-reference: what OpenRouter says about the same models next to what the provider says. It is a
++ * second opinion and nothing more: no price comes from it.
++ */
++ async function crossCheck(reference, readings, report) {
++ const out = { models: 0, agree: 0, differ: 0, notes: [] };
++ for (const [provider, prices] of Object.entries(readings)) {
++ const rows = await registry.listModels({ provider });
++ for (const row of rows) {
++ const own = findPrice(prices, row.model_key);
++ if (!own) continue;
++ const other = findPrice(reference, row.model_key);
++ if (!other) continue;
++ out.models++;
++ const a = numeric(own.price.in);
++ const b = numeric(other.price.in);
++ if (a === null || b === null) continue;
++ if (Math.abs(a - b) <= EPSILON) out.agree++;
++ else {
++ out.differ++;
++ if (out.notes.length < 20) out.notes.push({ model: row.model_key, provider_price: a, openrouter_price: b });
++ }
++ }
++ }
++ report.cross_reference = out;
++ }
++
++ /**
++ * Is the book due? A run that finished less than an interval ago is not: the service may be deployed several
++ * times a day, and the week must not restart with every restart.
++ */
++ async function due() {
++ const last = await state.lastRunAt();
++ if (last === null) return true; // the book has never run: it is due at once
++ return clock.now() - last >= Number(settings.intervalMs);
++ }
++
++ /** One run of the book: read every listing, compare, record. */
++ async function runOnce({ force = false } = {}) {
++ if (running) return { skipped: 'running' };
++ if (!force && settings.enabled === false) return { skipped: 'disabled' };
++ running = true;
++ const checkedAt = day();
++ const report = {
++ checked_at: checkedAt,
++ started_at: iso(),
++ sources: [],
++ models: 0,
++ applied: [],
++ pending: [],
++ unchanged: 0,
++ checked: [],
++ not_listed: [],
++ owned_by_hand: [],
++ unreadable: [],
++ cross_reference: null,
++ };
++ const readings = {};
++ try {
++ if (await drillActive()) return { skipped: 'drill' };
++ // Only one worker reads the listings at a time (the service runs several).
++ if (!await state.claim({ ttlMs: Number(settings.claimTtlMs), who })) return { skipped: 'claimed' };
++ try {
++ for (const source of sources) {
++ const res = await readListing(source.url, {
++ transport: reader,
++ lookup,
++ timeoutMs: Number(settings.timeoutMs),
++ maxBytes: Number(settings.maxBytes),
++ });
++ if (!res.ok) {
++ report.unreadable.push({ source: source.key, url: source.url, error: res.error });
++ continue;
++ }
++ let prices;
++ try {
++ prices = source.parse(source.kind === 'json' ? JSON.parse(res.body) : res.body);
++ } catch {
++ report.unreadable.push({ source: source.key, url: source.url, error: 'the listing could not be read' });
++ continue;
++ }
++ report.sources.push({ source: source.key, url: source.url, role: source.role, listed: Object.keys(prices).length });
++ if (source.role === 'cross-reference') { await crossCheck(prices, readings, report); continue; }
++ readings[source.provider] = prices;
++ await priceProvider(source, prices, report, checkedAt);
++ }
++ } finally {
++ // The claim goes back whatever happened, so the next worker may run.
++ await state.release({ who });
++ }
++ // The run is over: the next one is due when the interval has passed (whether or not a price moved).
++ await state.markRun();
++ } catch (err) {
++ report.error = err && err.code ? err.code : 'error';
++ log.warn('[pricebook] the run stopped early: ' + report.error);
++ } finally {
++ running = false;
++ }
++ last = report;
++ log.log(`[pricebook] checked ${report.sources.length} listings, ${report.models} models, ${report.applied.length} applied, ${report.pending.length} waiting for the owner`);
++ return report;
++ }
++
++ /** The pending changes, and the ones that are live. */
++ const pending = (limit) => store.pending(limit);
++ const live = (limit) => store.live(limit);
++ const changes = (opts) => store.list(opts);
++
++ /**
++ * The owner's decision on a waiting change. Approving it writes the price into the model row; rejecting it
++ * leaves the live price alone and the log keeps the story.
++ */
++ async function decide(id, { decision, actor, note = null }) {
++ const row = await store.get(id);
++ if (!row) return { ok: false, error: 'no such change' };
++ if (row.status !== 'pending') return { ok: false, error: 'that change was already decided', change: row };
++ if (decision === 'approve') {
++ const model = await registry.getModel(row.provider_key, row.model_key);
++ if (!model) return { ok: false, error: 'the model is gone', change: row };
++ const changesForRow = [{
++ field: row.field,
++ old: numeric(row.old_price),
++ next: numeric(row.new_price),
++ pct: numeric(row.change_pct),
++ }];
++ const day = row.checked_at instanceof Date ? row.checked_at.toISOString().slice(0, 10) : String(row.checked_at || '').slice(0, 10);
++ await writeRow({
++ row: model,
++ cost: costWith(model, { changes: changesForRow, source: row.source, checkedAt: day, moved: true }),
++ metadata: metadataWith(model, { source: row.source, checkedAt: day }),
++ });
++ const decided = await store.decide(id, { status: 'approved', actor, note });
++ return { ok: true, change: decided };
++ }
++ const decided = await store.decide(id, { status: 'rejected', actor, note });
++ return { ok: true, change: decided };
++ }
++
++ /**
++ * One tick: run the book when it is due. The due check reads the last finished run from the database, so a
++ * restart in the middle of the week does not run it again and a restart after it does.
++ */
++ async function tick() {
++ if (running) return { skipped: 'running' };
++ if (settings.enabled === false) return { skipped: 'disabled' };
++ if (!await due()) return { skipped: 'not due' };
++ return await runOnce();
++ }
++
++ /**
++ * The service ticks once an hour and reads the listings only when the interval has passed. The first tick
++ * after a boot is a few minutes in, so the boot path stays free and a restart does not restart the week.
++ */
++ function start() {
++ if (timer) return timer;
++ const every = Math.max(60000, Number(settings.tickMs) || HOUR_MS);
++ timer = setInterval(() => { tick().catch(() => {}); }, every);
++ timer.unref?.();
++ first = setTimeout(() => { tick().catch(() => {}); }, Math.max(1000, Number(settings.firstTickMs) || 5 * 60 * 1000));
++ first.unref?.();
++ return timer;
++ }
++
++ function stop() {
++ if (timer) { clearInterval(timer); timer = null; }
++ if (first) { clearTimeout(first); first = null; }
++ }
++
++ return { runOnce, tick, due, start, stop, decide, pending, live, changes, store, state, settings, threshold: limit, get last() { return last; } };
++}
++
++module.exports = { createPriceBook, eligible, findPrice, catalogueSources, EPSILON, DEFAULT_THRESHOLD, WEEK_MS };
ddiff --git a/server/pricebook/parsers.js b/server/pricebook/parsers.js
nnew file mode 100644
iindex 0000000..214d7d3
---- /dev/null
++++ b/server/pricebook/parsers.js
@@@ -0,0 +1,400 @@
++'use strict';
++/**
++ * The parsers that read a provider's own price listing and turn it into prices per million tokens.
++ *
++ * Every provider publishes its prices in its own shape: a JSON model list (DeepInfra, Novita, Featherless,
++ * OpenRouter), a markdown table (Z.ai, Abliteration.ai, MiniMax, DeepSeek), an HTML table (Alibaba's Model
++ * Studio) or a table the page builds in the browser (Moonshot's Kimi). One parser per shape, one rule for all of
++ * them: a price nobody publishes is null, never a guess.
++ *
++ * The result is a map from a model's leaf name (its id without the namespace, in lower case) to the three prices
++ * the catalogue holds: in, cached (the cache read) and out. A field the listing does not publish stays null, and
++ * the price book leaves the live price of that field alone.
++ *
++ * These parsers read bytes, never a network: the weekly job hands them what the egress guard fetched, and the
++ * tests replay the recorded listings (test/fixtures/pricebook/).
++ */
++
++/** A model's name without its namespace: "deepseek/deepseek-chat" -> "deepseek-chat". */
++const leaf = (id) => String(id === null || id === undefined ? '' : id).split('/').pop().trim().toLowerCase();
++
++/** A number, or null when the cell holds no number at all. */
++function num(v) {
++ if (v === null || v === undefined || v === '') return null;
++ const n = Number(v);
++ return Number.isFinite(n) ? n : null;
++}
++
++/**
++ * A money cell: "$0.030", "\$1.4", "1,200" -> the number. A struck-through price is the old one, so it is
++ * dropped first ("~~$0.60~~ $0.30" is $0.30). A cell that names no price is null, never a guess.
++ */
++function money(cell) {
++ if (cell === null || cell === undefined) return null;
++ const text = String(cell).replace(/~~[^~]*~~/g, ' ').replace(/\\?\$/g, ' ').replace(/,/g, '');
++ const m = text.match(/-?\d+(?:\.\d+)?/);
++ return m ? num(m[0]) : null;
++}
++
++/** A price cell: a money cell, or 0 when the provider says the model is free. */
++function price(cell) {
++ const m = money(cell);
++ if (m !== null) return m;
++ const t = String(cell === null || cell === undefined ? '' : cell).trim().toLowerCase();
++ if (t === 'free' || t === 'free of charge') return 0;
++ return null;
++}
++
++/** The leading identifier of a cell: "**MiniMax-M3** <= 512k input tokens" -> "MiniMax-M3". */
++function leadingId(cell) {
++ const t = String(cell === null || cell === undefined ? '' : cell).replace(/\*\*/g, '');
++ const m = t.match(/[A-Za-z0-9][A-Za-z0-9._-]*/);
++ return m ? m[0].toLowerCase() : null;
++}
++
++/** The trailing identifier of a cell: "**Abliterated Model**abliterated-model" -> "abliterated-model". */
++function trailingId(cell) {
++ const t = String(cell === null || cell === undefined ? '' : cell).replace(/\*\*/g, ' ');
++ const token = t.trim().split(/\s+/).pop() || '';
++ const id = token.replace(/^[^A-Za-z0-9._-]+/, '');
++ return /^[a-z0-9][a-z0-9._-]*$/i.test(id) ? id.toLowerCase() : null;
++}
++
++/** The header of a table, normalized: bold and dollars dropped, spaces collapsed, lower case. */
++const normHead = (h) => String(h === null || h === undefined ? '' : h).replace(/\*\*/g, ' ').replace(/\?\$/g, ' ')
++ .replace(/\s+/g, ' ').trim().toLowerCase();
++
++/**
++ * The markdown tables of a page. A table is a run of lines that start with a pipe; the quoted rows of an
++ * accordion ("> | Model | ...") are tables of their own, so the quote marker is dropped first.
++ * -> [{ header: [cell], rows: [[cell]] }]
++ */
++function markdownTables(text) {
++ const tables = [];
++ let cur = null;
++ for (const raw of String(text || '').split(/\r?\n/)) {
++ const line = raw.replace(/^\s*>\s?/, '').trim();
++ if (!line.startsWith('|')) { if (cur) { tables.push(cur); cur = null; } continue; }
++ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
++ if (!cur) { cur = { header: cells, rows: [] }; continue; }
++ if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // the separator row under the header
++ cur.rows.push(cells);
++ }
++ if (cur) tables.push(cur);
++ return tables;
++}
++
++/** The column of a table whose header starts with one of these words (the first that matches). */
++function columnOf(header, prefixes) {
++ const head = header.map(normHead);
++ for (const p of prefixes) {
++ const i = head.findIndex((h) => h === p || h.startsWith(p));
++ if (i >= 0) return i;
++ }
++ return -1;
++}
++
++/** The HTML text of a cell: tags dropped, entities decoded, spaces collapsed. */
++function htmlText(fragment) {
++ return String(fragment || '')
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
++ .replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ')
++ .replace(/\s+/g, ' ')
++ .trim();
++}
++
++/** The index of the matching bracket, ignoring the brackets inside a backticked string. */
++function matchBracket(text, start) {
++ let depth = 0;
++ let quoted = false;
++ for (let i = start; i < text.length; i++) {
++ const ch = text[i];
++ if (quoted) { if (ch === '\\') i++; else if (ch === '`') quoted = false; continue; }
++ if (ch === '`') { quoted = true; continue; }
++ if (ch === '[') depth++;
++ else if (ch === ']') { depth--; if (depth === 0) return i; }
++ }
++ return -1;
++}
++
++/** The rows of a "rows:[[...]]" payload: each row is the text between the outer brackets. */
++function payloadRows(text, start) {
++ const end = matchBracket(text, start);
++ if (end < 0) return [];
++ const body = text.slice(start + 1, end); // without the outer '[' and ']'
++ const rows = [];
++ let depth = 0;
++ let quoted = false;
++ let from = 0;
++ for (let i = 0; i < body.length; i++) {
++ const ch = body[i];
++ if (quoted) { if (ch === '\\') i++; else if (ch === '`') quoted = false; continue; }
++ if (ch === '`') { quoted = true; continue; }
++ if (ch === '[') { if (depth === 0) from = i + 1; depth++; continue; }
++ if (ch === ']') { depth--; if (depth === 0) { rows.push(body.slice(from, i)); from = i + 1; } continue; }
++ }
++ return rows;
++}
++
++/** The backticked strings of a payload row: the href, the model's name and the prices. */
++const backticked = (text) => [...String(text || '').matchAll(/`([^`]*)`/g)].map((m) => m[1]);
++
++// ── The parsers ───────────────────────────────────────────────
++
++/**
++ * Z.ai's pricing page (markdown): Model | Input | Cached Input | Cached Input Storage | Output. The model id
++ * is the name in the first column.
++ */
++function glm(text) {
++ const out = {};
++ for (const t of markdownTables(text)) {
++ const iModel = columnOf(t.header, ['model']);
++ const iIn = columnOf(t.header, ['input']);
++ const iOut = columnOf(t.header, ['output']);
++ const iCached = columnOf(t.header, ['cached input']);
++ if (iModel < 0 || iIn < 0 || iOut < 0) continue;
++ for (const row of t.rows) {
++ const id = leadingId(row[iModel]);
++ if (!id || out[id]) continue;
++ const entry = { in: price(row[iIn]), cached: iCached >= 0 ? price(row[iCached]) : null, out: price(row[iOut]) };
++ if (entry.in === null && entry.out === null) continue;
++ out[id] = entry;
++ }
++ }
++ return out;
++}
++
++/**
++ * Abliteration.ai's pricing page (markdown): Model | Input / 1M | Cached input / 1M | Output / 1M. The first
++ * column holds the display name and the model's id side by side, so the id is the trailing word of the cell.
++ */
++function abliteration(text) {
++ const out = {};
++ for (const t of markdownTables(text)) {
++ const iModel = columnOf(t.header, ['model']);
++ const iIn = columnOf(t.header, ['input']);
++ const iOut = columnOf(t.header, ['output']);
++ const iCached = columnOf(t.header, ['cached input']);
++ if (iModel < 0 || iIn < 0 || iOut < 0) continue;
++ for (const row of t.rows) {
++ const id = trailingId(row[iModel]);
++ if (!id || out[id]) continue;
++ const entry = { in: price(row[iIn]), cached: iCached >= 0 ? price(row[iCached]) : null, out: price(row[iOut]) };
++ if (entry.in === null && entry.out === null) continue;
++ out[id] = entry;
++ }
++ }
++ return out;
++}
++
++/**
++ * MiniMax's pricing page (markdown): Model | Input | Output | Prompt caching Read, per million tokens. The
++ * page lists a model once per token tier and once per service tier; the first row wins, so the standard tier up
++ * to 512K is the one the catalogue holds. A price the page struck through is the old one.
++ */
++function minimax(text) {
++ const out = {};
++ for (const t of markdownTables(text)) {
++ const iModel = columnOf(t.header, ['model']);
++ const iIn = columnOf(t.header, ['input']);
++ const iOut = columnOf(t.header, ['output']);
++ const iCached = columnOf(t.header, ['prompt caching read', 'cached']);
++ if (iModel < 0 || iIn < 0 || iOut < 0) continue;
++ for (const row of t.rows) {
++ const id = leadingId(row[iModel]);
++ if (!id || out[id]) continue;
++ const entry = { in: price(row[iIn]), cached: iCached >= 0 ? price(row[iCached]) : null, out: price(row[iOut]) };
++ if (entry.in === null && entry.out === null) continue;
++ out[id] = entry;
++ }
++ }
++ return out;
++}
++
++/**
++ * DeepSeek's pricing page (markdown, transposed): the models are the columns and the prices the rows, with an
++ * OFF-PEAK and a PEAK rate for each. The card carries the peak rate (the page halves it off-peak).
++ */
++function deepseek(text) {
++ const out = {};
++ for (const t of markdownTables(text)) {
++ const columns = [];
++ t.header.forEach((cell, index) => {
++ const id = String(cell || '').replace(/\(\d+\)\s*$/, '').trim().toLowerCase();
++ if (!id || id === 'model' || id.startsWith('pricing')) return;
++ columns.push({ index, id });
++ });
++ if (!columns.length) continue;
++ for (const row of t.rows) {
++ const metric = String(row[1] || '').toUpperCase();
++ const band = String(row[2] || '').toUpperCase();
++ if (band !== 'PEAK') continue; // the catalogue holds the peak rate
++ let field = null;
++ if (metric.includes('CACHE HIT')) field = 'cached';
++ else if (metric.includes('CACHE MISS')) field = 'in';
++ else if (metric.includes('OUTPUT TOKENS')) field = 'out';
++ if (!field) continue;
++ for (const c of columns) {
++ const value = price(row[c.index]);
++ if (value === null) continue;
++ if (!out[c.id]) out[c.id] = { in: null, cached: null, out: null };
++ if (out[c.id][field] === null) out[c.id][field] = value;
++ }
++ }
++ }
++ return out;
++}
++
++/**
++ * Alibaba Model Studio's pricing page (HTML): a table per deployment, with the model id, the deployment scope,
++ * the token tier and the input and output price. The catalogue holds the international deployment, so a row
++ * without that scope is another deployment and is left alone.
++ */
++function qwen(html) {
++ const out = {};
++ for (const tr of String(html || '').matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
++ const cells = [...tr[1].matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi)].map((m) => htmlText(m[1]));
++ if (cells.length < 4) continue;
++ if (!cells.some((c) => c.toLowerCase() === 'international')) continue;
++ const id = leadingId(cells[0]);
++ if (!id || out[id]) continue;
++ const prices = cells.filter((c) => /^\$[\d.,]+$/.test(c)).map((c) => price(c));
++ if (prices.length < 2) continue;
++ out[id] = { in: prices[0], cached: null, out: prices[1] };
++ }
++ return out;
++}
++
++/**
++ * Moonshot's Kimi pricing page: the table is built in the browser, so the prices live in the page's own
++ * payload next to its column titles (Model | Input | Output | Cached Input | 5m writes | 1h writes).
++ */
++function kimi(html) {
++ const text = String(html || '');
++ const out = {};
++ let at = 0;
++ while ((at = text.indexOf('columns:[', at)) !== -1) {
++ const colsEnd = matchBracket(text, text.indexOf('[', at));
++ if (colsEnd < 0) break;
++ const titles = [...text.slice(at, colsEnd).matchAll(/title:(?:`([^`]*)`|[\s\S]*?children:`([^`]*)`)/g)]
++ .map((m) => (m[1] || m[2] || '').trim().toLowerCase());
++ const rowsAt = text.indexOf('rows:[', colsEnd);
++ if (rowsAt === -1) break;
++ for (const row of payloadRows(text, rowsAt + 'rows:'.length)) {
++ const parts = backticked(row);
++ if (parts.length < 3) continue;
++ const id = leaf(parts[1]);
++ if (!id || out[id]) continue;
++ const entry = { in: null, cached: null, out: null };
++ titles.forEach((title, index) => {
++ if (index === 0) return; // the model column
++ const value = price(parts[index + 1]);
++ if (value === null) return;
++ if (title === 'input') entry.in = value;
++ else if (title === 'output') entry.out = value;
++ else if (title.startsWith('cached input')) entry.cached = value;
++ });
++ if (entry.in === null && entry.out === null) continue;
++ out[id] = entry;
++ }
++ at = rowsAt + 5;
++ }
++ return out;
++}
++
++/** DeepInfra's own model list: metadata.pricing per million tokens. */
++function deepinfra(json) {
++ const out = {};
++ for (const m of (json && json.data) || []) {
++ const p = (m.metadata && m.metadata.pricing) || null;
++ if (!p) continue;
++ const id = String(m.id || '').trim().toLowerCase();
++ if (!id) continue;
++ out[id] = { in: num(p.input_tokens), cached: num(p.cache_read_tokens), out: num(p.output_tokens) };
++ }
++ return out;
++}
++
++/** Novita's own model list: pricing.<metric>.price_per_m_decimal per million tokens. */
++function novita(json) {
++ const out = {};
++ for (const m of (json && json.data) || []) {
++ const p = (m && m.pricing) || null;
++ if (!p) continue;
++ const id = String(m.id || '').trim().toLowerCase();
++ if (!id) continue;
++ const pick = (key) => {
++ const e = p[key];
++ if (!e) return null;
++ return num(e.price_per_m_decimal !== undefined ? e.price_per_m_decimal : e.price_per_m);
++ };
++ out[id] = { in: pick('prompt'), cached: pick('input_cache_read'), out: pick('completion') };
++ }
++ return out;
++}
++
++/** Featherless's own model list: pricing.input and pricing.output per million tokens. */
++function featherless(json) {
++ const out = {};
++ for (const m of (json && json.data) || []) {
++ const p = (m && m.pricing) || null;
++ if (!p) continue;
++ const id = String(m.id || '').trim().toLowerCase();
++ if (!id) continue;
++ let input = num(p.input);
++ let output = num(p.output);
++ if (input === null && p.prompt !== undefined) { const n = num(p.prompt); input = n === null ? null : n * 1e6; }
++ if (output === null && p.completion !== undefined) { const n = num(p.completion); output = n === null ? null : n * 1e6; }
++ out[id] = { in: input, cached: null, out: output };
++ }
++ return out;
++}
++
++/** OpenRouter's public model list: the prices are per token, so they are carried over per million tokens. */
++function openrouter(json) {
++ const out = {};
++ for (const m of (json && json.data) || []) {
++ const p = (m && m.pricing) || null;
++ if (!p) continue;
++ const id = String(m.id || '').trim().toLowerCase();
++ if (!id) continue;
++ const per = (v) => { const n = num(v); return n === null ? null : n * 1e6; };
++ out[id] = { in: per(p.prompt), cached: null, out: per(p.completion) };
++ }
++ return out;
++}
++
++/**
++ * Refuseless publishes its model list but no price at all (its pricing page answers a bot check). The parser
++ * reads the list and finds no price, so the price book leaves every price of that provider alone.
++ */
++function refuseless(text) {
++ const out = {};
++ const body = String(text || '');
++ // The page shows the API's own answer (a JSON block); the ids are what it names.
++ const block = body.match(/```json[\s\S]*?```/);
++ if (block) {
++ try {
++ const json = JSON.parse(block[0].replace(/^```json/, '').replace(/```$/, '').trim());
++ for (const m of json.data || []) {
++ const id = leaf(m.id);
++ if (!id) continue;
++ if (!out[id]) out[id] = { in: null, cached: null, out: null };
++ }
++ } catch { /* the page's answer is not JSON: the ids are read below */ }
++ }
++ if (!Object.keys(out).length) {
++ for (const m of body.matchAll(/"id":\s*"([^"]+)"/g)) {
++ const id = leaf(m[1]);
++ if (!id) continue;
++ if (!out[id]) out[id] = { in: null, cached: null, out: null };
++ }
++ }
++ return out;
++}
++
++module.exports = {
++ leaf, num, money, price, markdownTables, htmlText, columnOf, normHead, leadingId, trailingId,
++ glm, abliteration, minimax, deepseek, qwen, kimi, deepinfra, novita, featherless, openrouter, refuseless,
++};
ddiff --git a/server/pricebook/read.js b/server/pricebook/read.js
nnew file mode 100644
iindex 0000000..797132f
---- /dev/null
++++ b/server/pricebook/read.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * Reading a provider's own price listing, through the egress guard.
++ *
++ * The price book reads the open internet, so it reads it the way the rest of the platform reads it: https only,
++ * the ports a public site serves, a host that resolves to a public address and nothing else, a timeout, a size
++ * cap and redirects followed by hand so every hop is judged on its own.
++ *
++ * An IP literal is refused before the socket is opened. Node never calls the lookup hook for an IP literal, so
++ * the literal itself is the check (the same lesson as Actor #297): https://127.0.0.1/ must not be readable
++ * because the guard never gets a chance to look it up.
++ *
++ * The transport is a seam: the tests replay the recorded listings through it and never open a socket.
++ */
++const https = require('https');
++const net = require('net');
++const egress = require('openvibe-shared/egress');
++
++const PORTS = [443, 8443];
++const MAX_REDIRECTS = 2;
++
++const refuse = (message) => {
++ const err = new Error(message);
++ err.code = 'EGRESS_DENIED';
++ return err;
++};
++
++/**
++ * Is this a URL the price book may read? An https page on a public host, no credentials in the URL.
++ * → the parsed URL, or an error that says why not.
++ */
++function judge(url) {
++ let parsed;
++ try { parsed = new URL(String(url)); } catch { throw refuse('that is not a URL'); }
++ if (parsed.protocol !== 'https:') throw refuse('only an https listing is read');
++ if (parsed.username || parsed.password) throw refuse('a URL with credentials is never read');
++ const host = egress.normalizeHost(parsed.hostname);
++ // Node never calls the lookup hook for an IP literal, so the literal is the check (Actor #297).
++ if (net.isIP(host)) throw refuse('an IP-literal host is never read');
++ if (egress.isInternalName(host)) throw refuse('that is not a public host name');
++ if (parsed.port && !PORTS.includes(Number(parsed.port))) throw refuse('that port is not read');
++ return parsed;
++}
++
++/** The one request: https, a guarded lookup, a timeout and a cap on the bytes. */
++function defaultTransport({ method, url, headers, lookup, timeoutMs, maxBytes }) {
++ return new Promise((resolve, reject) => {
++ const req = https.request(url, { method, headers, lookup, timeout: timeoutMs }, (res) => {
++ const chunks = [];
++ let size = 0;
++ res.on('data', (chunk) => {
++ size += chunk.length;
++ if (size > maxBytes) { req.destroy(new Error('the listing is larger than the cap')); return; }
++ chunks.push(chunk);
++ });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
++ res.on('error', reject);
++ });
++ req.on('timeout', () => req.destroy(new Error('the listing took too long')));
++ req.on('error', reject);
++ req.end();
++ });
++}
++
++/**
++ * Read a listing, following the redirects by hand so each hop is judged on its own.
++ * → { ok, status, body, final_url, error }
++ */
++async function readListing(url, { transport = defaultTransport, lookup = egress.createSafeLookup(), timeoutMs = 20000, maxBytes = 8 * 1024 * 1024 } = {}) {
++ let current = String(url);
++ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
++ let parsed;
++ try { parsed = judge(current); } catch (err) { return { ok: false, error: err.message, url: current }; }
++ let res;
++ try {
++ res = await transport({ method: 'GET', url: parsed.toString(), headers: { accept: '*/*' }, lookup, timeoutMs, maxBytes });
++ } catch (err) {
++ return { ok: false, error: err.message, url: current };
++ }
++ const status = Number(res.status || 0);
++ if (status === 307 || status === 308) {
++ const location = res.headers && res.headers.location;
++ if (!location) return { ok: false, error: 'the listing redirects without saying where', url: current };
++ current = new URL(location, parsed).toString();
++ continue; // the next hop is judged on its own
++ }
++ if (status < 200 || status >= 300) return { ok: false, error: 'the listing answered ' + status, url: current };
++ return { ok: true, status, body: res.body, final_url: current };
++ }
++ return { ok: false, error: 'the listing redirected too often', url: current };
++}
++
++module.exports = { readListing, judge, defaultTransport, PORTS, MAX_REDIRECTS };
ddiff --git a/server/pricebook/sources.js b/server/pricebook/sources.js
nnew file mode 100644
iindex 0000000..3e6f9b3
---- /dev/null
++++ b/server/pricebook/sources.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * The price listings the price book reads: each catalogued provider's own model and price list.
++ *
++ * A price is only ever read from the provider that sells it. OpenRouter's public model list is in here as a
++ * cross-reference: it is a second opinion about what a model costs, never the source of a price, so nothing the
++ * job learns from it can change a price.
++ *
++ * The URLs are the ones the catalogue already names in each model's source (server/providers/pool-providers.js),
++ * so the price a card shows and the price the book read point at the same page. Two of them answer in a slightly
++ * different shape than the catalogue's URL suggests (MiniMax redirects to its pricing overview, Alibaba's page
++ * is the HTML table behind the markdown URL), and the reader follows the redirect by hand.
++ */
++const parsers = require('./parsers');
++
++/** Every provider's own listing, in the order the job reads them. */
++const SOURCES = [
++ {
++ key: 'deepseek',
++ provider: 'deepseek',
++ role: 'own',
++ url: 'https://api-docs.deepseek.com/quick_start/pricing.md',
++ kind: 'text',
++ parse: parsers.deepseek,
++ },
++ {
++ key: 'qwen',
++ provider: 'qwen',
++ role: 'own',
++ url: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing',
++ kind: 'text',
++ parse: parsers.qwen,
++ },
++ {
++ key: 'kimi',
++ provider: 'kimi',
++ role: 'own',
++ url: 'https://platform.kimi.ai/docs/pricing/chat',
++ kind: 'text',
++ parse: parsers.kimi,
++ },
++ {
++ key: 'glm',
++ provider: 'glm',
++ role: 'own',
++ url: 'https://docs.z.ai/guides/overview/pricing.md',
++ kind: 'text',
++ parse: parsers.glm,
++ },
++ {
++ key: 'minimax',
++ provider: 'minimax',
++ role: 'own',
++ url: 'https://platform.minimax.io/docs/pricing.md',
++ kind: 'text',
++ parse: parsers.minimax,
++ },
++ {
++ key: 'deepinfra',
++ provider: 'deepinfra',
++ role: 'own',
++ url: 'https://api.deepinfra.com/v1/models',
++ kind: 'json',
++ parse: parsers.deepinfra,
++ },
++ {
++ key: 'novita',
++ provider: 'novita',
++ role: 'own',
++ url: 'https://api.novita.ai/v3/openai/models',
++ kind: 'json',
++ parse: parsers.novita,
++ },
++ {
++ key: 'featherless',
++ provider: 'featherless',
++ role: 'own',
++ url: 'https://api.featherless.ai/v1/models',
++ kind: 'json',
++ parse: parsers.featherless,
++ },
++ {
++ key: 'refuseless',
++ provider: 'refuseless',
++ role: 'own',
++ url: 'https://docs.refuseless.com/api/models.md',
++ kind: 'text',
++ parse: parsers.refuseless,
++ },
++ {
++ key: 'abliteration',
++ provider: 'abliteration',
++ role: 'own',
++ url: 'https://docs.abliteration.ai/pricing.md',
++ kind: 'text',
++ parse: parsers.abliteration,
++ },
++ {
++ key: 'openrouter',
++ provider: 'openrouter',
++ role: 'cross-reference',
++ url: 'https://openrouter.ai/api/v1/models',
++ kind: 'json',
++ parse: parsers.openrouter,
++ },
++];
++
++/** The listings that may change a price (the cross-reference is not one of them). */
++const ownSources = () => SOURCES.filter((s) => s.role === 'own');
++
++/** The one listing that only says what the others say. */
++const crossReference = () => SOURCES.find((s) => s.role === 'cross-reference') || null;
++
++module.exports = { SOURCES, ownSources, crossReference };
ddiff --git a/server/pricebook/state.js b/server/pricebook/state.js
nnew file mode 100644
iindex 0000000..c2c4440
---- /dev/null
++++ b/server/pricebook/state.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * The price book's own state: when it last ran, and which instance is running it now.
++ *
++ * The book is due-based, not timer-based. A timer that lives in the process is reset by every deploy, and AI is
++ * deployed several times a day, so the week would never come round; the last run is therefore a row in the
++ * database and the due check reads it.
++ *
++ * The row also carries the claim. AI runs several workers and only one of them may read the providers' listings
++ * at a time, so a worker claims the row before it starts and releases it when it ends. The claim expires on its
++ * own, so a worker that dies mid-run does not block the next one for ever.
++ */
++const STATE_ID = 'pricebook';
++
++function createPriceState({ db, clock = { now: () => Date.now() } }) {
++ const iso = () => new Date(clock.now()).toISOString();
++
++ /** When the last run finished, or null when the book has never run. */
++ async function lastRunAt() {
++ const row = await db.prepare('SELECT last_run_at FROM price_book_state WHERE id = ?').get(STATE_ID).catch(() => null);
++ if (!row || !row.last_run_at) return null;
++ const at = new Date(row.last_run_at).getTime();
++ return Number.isFinite(at) ? at : null;
++ }
++
++ /** The row as it is, for the operator page (the times as ISO strings, not Date objects). */
++ async function read() {
++ const row = await db.prepare('SELECT * FROM price_book_state WHERE id = ?').get(STATE_ID).catch(() => null);
++ if (!row) return null;
++ const isoOrNull = (v) => (v ? new Date(v).toISOString() : null);
++ return { ...row, last_run_at: isoOrNull(row.last_run_at), claimed_until: isoOrNull(row.claimed_until) };
++ }
++
++ /**
++ * Claim the run: one instance at a time. The claim is taken by the database, so two workers that tick at the
++ * same moment cannot both hold it.
++ * → true when this instance holds the run.
++ */
++ async function claim({ ttlMs, who }) {
++ const until = new Date(clock.now() + Math.max(1000, Number(ttlMs) || 0)).toISOString();
++ const at = iso();
++ const row = await db.prepare(`INSERT INTO price_book_state (id, last_run_at, claimed_until, claimed_by, updated_at)
++ VALUES (?, NULL, ?, ?, ?)
++ ON CONFLICT (id) DO UPDATE SET claimed_until = EXCLUDED.claimed_until, claimed_by = EXCLUDED.claimed_by, updated_at = EXCLUDED.updated_at
++ WHERE price_book_state.claimed_until IS NULL OR price_book_state.claimed_until <= ?::timestamptz
++ RETURNING claimed_by`)
++ .get(STATE_ID, until, who, at, at).catch(() => null);
++ return Boolean(row && row.claimed_by);
++ }
++
++ /** Give the claim back, so the next worker may run. */
++ async function release({ who }) {
++ await db.prepare('UPDATE price_book_state SET claimed_until = NULL, claimed_by = NULL, updated_at = ? WHERE id = ? AND claimed_by = ?')
++ .run(iso(), STATE_ID, who).catch(() => null);
++ }
++
++ /** The run is over: the next one is due when the interval has passed. */
++ async function markRun() {
++ const at = iso();
++ await db.prepare(`INSERT INTO price_book_state (id, last_run_at, claimed_until, claimed_by, updated_at)
++ VALUES (?, ?, NULL, NULL, ?)
++ ON CONFLICT (id) DO UPDATE SET last_run_at = EXCLUDED.last_run_at, updated_at = EXCLUDED.updated_at`)
++ .run(STATE_ID, at, at).catch(() => null);
++ }
++
++ return { lastRunAt, read, claim, release, markRun, STATE_ID };
++}
++
++module.exports = { createPriceState, STATE_ID };
ddiff --git a/server/pricebook/store.js b/server/pricebook/store.js
nnew file mode 100644
iindex 0000000..9295371
---- /dev/null
++++ b/server/pricebook/store.js
@@@ -0,0 +1,86 @@
++'use strict';
++/**
++ * The price change log: what a price was, what it became, the provider's own listing it was read from and the
++ * day it was read.
++ *
++ * This is a log, not a second list of prices: the live price stays in the model row the router reads, and this
++ * table says how it got there. A change over the threshold waits here as pending until the owner approves it on
++ * the operator console; a smaller one is applied at once and lands here as applied.
++ */
++const { ids } = require('openvibe-contracts');
++
++const FIELDS = ['in_per_mtok', 'cached_per_mtok', 'out_per_mtok'];
++
++/** The field of a model row the log talks about, and the parser's name for it. */
++const FIELD_NAMES = { in_per_mtok: 'in', cached_per_mtok: 'cached', out_per_mtok: 'out' };
++
++function createPriceStore({ db, clock = { now: () => Date.now() } }) {
++ const iso = () => new Date(clock.now()).toISOString();
++
++ /** Write one change into the log. */
++ async function record({ provider_key, model_key, field, old_price = null, new_price, change_pct = null, source, checked_at, status, note = null, actor = null }) {
++ const id = 'pc_' + ids.ulid();
++ await db.prepare(`INSERT INTO price_changes
++ (id, provider_key, model_key, field, old_price, new_price, change_pct, source, checked_at, status, created_at, decided_at, decided_by, note)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(id, provider_key, model_key, field, old_price === null || old_price === undefined ? null : old_price,
++ new_price, change_pct === null || change_pct === undefined ? null : change_pct,
++ source, checked_at, status, iso(), null, actor, note);
++ return id;
++ }
++
++ /** The changes of the log, newest first. */
++ async function list({ status = null, provider = null, model = null, limit = 100 } = {}) {
++ const where = [];
++ const args = [];
++ if (status) { where.push('status = ?'); args.push(status); }
++ if (provider) { where.push('provider_key = ?'); args.push(provider); }
++ if (model) { where.push('model_key = ?'); args.push(model); }
++ const sql = 'SELECT * FROM price_changes' + (where.length ? ' WHERE ' + where.join(' AND ') : '') + ' ORDER BY created_at DESC LIMIT ?';
++ const rows = await db.prepare(sql).all(...args, limit);
++ return rows || [];
++ }
++
++ /** One change by its id. */
++ async function get(id) {
++ return db.prepare('SELECT * FROM price_changes WHERE id = ?').get(id);
++ }
++
++ /** The changes that wait for the owner's approval. */
++ async function pending(limit = 100) {
++ return list({ status: 'pending', limit });
++ }
++
++ /** The changes the catalog and the Exchange changelog show: the ones that are live. */
++ async function live({ limit = 100, provider = null } = {}) {
++ const where = `status IN ('applied', 'approved')`;
++ const args = [];
++ if (provider) { args.push(provider); }
++ const sql = `SELECT * FROM price_changes WHERE ${where}` + (provider ? ' AND provider_key = ?' : '') + ' ORDER BY created_at DESC LIMIT ?';
++ const rows = await db.prepare(sql).all(...args, limit);
++ return rows || [];
++ }
++
++ /**
++ * Decide a pending change. Approving it hands the price to the job, which writes it into the model row;
++ * rejecting it leaves the live price alone and the log keeps the story.
++ */
++ async function decide(id, { status, actor, note = null }) {
++ const row = await get(id);
++ if (!row) return null;
++ if (row.status !== 'pending') return row;
++ await db.prepare('UPDATE price_changes SET status = ?, decided_at = ?, decided_by = ?, note = COALESCE(?, note) WHERE id = ?')
++ .run(status, iso(), actor, note, id);
++ return get(id);
++ }
++
++ /** Mark the pending changes of one model and field as superseded: a newer reading replaced them. */
++ async function supersede({ provider_key, model_key, field, except = null }) {
++ await db.prepare(`UPDATE price_changes SET status = 'superseded', decided_at = ? WHERE status = 'pending' AND provider_key = ? AND model_key = ? AND field = ? AND id <> ?`)
++ .run(iso(), provider_key, model_key, field, except || '');
++ }
++
++ return { record, list, get, pending, live, decide, supersede, FIELDS, FIELD_NAMES };
++}
++
++module.exports = { createPriceStore, FIELDS, FIELD_NAMES };
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex d82bf86..0f1b060 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -108,6 +108,7 @@ async function seedPoolProviders({ registry, actor = 'system' }) {
continue;
}
if (row.status !== 'active') continue; // the owner turned it off: it stays off
++ if (row.metadata && row.metadata.price_book) continue; // the price book owns this price: it refreshes it
if (row.cost.in_per_mtok != null && !cataloguePrice(row)) continue; // a person's own price: it stays
const next = modelOf(p, m);
await registry.upsertModel({
@@@ -137,6 +138,7 @@ async function applyCatalogPrices({ registry, provider, actor = 'system' }) {
for (const row of rows) {
const price = pool.priceOf(provider.key, row.model_key);
if (!price) continue;
++ if (row.metadata && row.metadata.price_book) continue; // the price book owns this price
const own = row.cost.in_per_mtok != null && !cataloguePrice(row);
if (own) continue; // a person's own price: the catalogue does not touch it
if (row.cost.in_per_mtok === price.cost.in_per_mtok && row.cost.source === price.source) continue;
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex bb768e0..51e9053 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -150,7 +150,9 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
if (deepseek && deepseek.status === 'active') {
for (const m of DEEPSEEK_MODELS) {
const prev = await registry.getModel('deepseek', m.model_key);
-- if (!prev || (prev.metadata && prev.metadata.configured_by === 'DEEPSEEK_API_KEY')) {
++ // Once the price book has checked a price it owns it: the seed does not write over it.
++ const owned = prev && prev.metadata && prev.metadata.price_book;
++ if (!prev || (!owned && prev.metadata && prev.metadata.configured_by === 'DEEPSEEK_API_KEY')) {
await registry.upsertModel({ provider_key: 'deepseek', ...m, status: prev ? prev.status : 'active' }, { actor: 'seed' });
}
}
ddiff --git a/test/fixtures/pricebook/abliteration.md b/test/fixtures/pricebook/abliteration.md
nnew file mode 100644
iindex 0000000..fc29320
---- /dev/null
++++ b/test/fixtures/pricebook/abliteration.md
@@@ -0,0 +1,39 @@
++> ## Documentation Index
++> Fetch the complete documentation index at: https://docs.abliteration.ai/llms.txt
++> Use this file to discover all available pages before exploring further.
++
++# Pricing
++
++> Per-token API pricing for Abliteration models. Input and output have model-specific rates; cached input (prompt-cache reads) is billed at 10% of input.
++
++## Per-token rates
++
++Rates are per 1M tokens in USD.
++
++| Model | Input / 1M | Cached input / 1M | Output / 1M |
++| - | - | - | - |
++| **Abliterated Model**abliterated-model | \$1.00 | \$0.10 | \$3.00 |
++| **Abliterated Large Flash**abliterated-model-large-flash | \$1.00 | \$0.10 | \$2.00 |
++| **Abliterated Large v2**abliterated-model-large-v2 | \$3.00 | \$0.30 | \$5.00 |
++| **Abliterated Large**!abliterated-model-large | \$3.00 | \$0.30 | \$5.00 |
++
++**Deprecated**
++
++See [models](https://docs.abliteration.ai/models.md) for context windows and capabilities.
++
++## Prompt caching
++
++When a request reuses a prefix that's already cached, those **cache-read** input tokens are billed at **10% of the model's standard input rate**. **Cache creation** (processing tokens and making them eligible for possible later reuse) is charged at the standard input rate — there's no cache-write premium, and a cache write does not guarantee a later cache read. Output tokens are always billed at the standard rate.
++
++Caching is automatic; cached and uncached input tokens are reported separately in the response `usage`. Cache hits are best-effort, and cached-token counts are measured per request rather than accumulated across a conversation. Use the reported cache-read tokens to determine whether the discount applied. See [prompt caching](https://docs.abliteration.ai/capabilities/prompt-caching.md).
++
++## Billing and balance
++
++Usage is billed in USD against a single prepaid balance that works across every model. You pay the per-token rates above; cache-read input tokens are billed at the discounted rate, so they draw down your balance proportionally less. Image inputs on `abliterated-model` and `abliterated-model-large-flash` are metered as tokens on the same per-token scale.
++
++## Subscriptions and credit packs
++
++Monthly subscriptions and prepaid credit packs are available, along with custom volume and enterprise pricing. See the [pricing page](https://abliteration.ai/pricing) for current plans and to buy credits.
++
++
++This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/deepinfra.json b/test/fixtures/pricebook/deepinfra.json
nnew file mode 100644
iindex 0000000..ae1c81c
---- /dev/null
++++ b/test/fixtures/pricebook/deepinfra.json
@@@ -0,0 +1,167 @@
++{
++ "object": "list",
++ "data": [
++ {
++ "id": "deepseek-ai/DeepSeek-V4-Flash",
++ "object": "model",
++ "created": 0,
++ "owned_by": "deepinfra",
++ "root": "deepseek-ai/DeepSeek-V4-Flash",
++ "parent": null,
++ "metadata": {
++ "description": "DeepSeek V4 Flash is an efficiency-focused MoE model with 284B total parameters (13B active) and a 1M-token context window. It's tuned for fast inference and high-throughput use cases while still holding up on reasoning and coding tasks.",
++ "context_length": 1048576,
++ "max_tokens": 1048576,
++ "pricing": {
++ "input_tokens": 0.09,
++ "output_tokens": 0.18,
++ "cache_read_tokens": 0.018
++ },
++ "discount": null,
++ "tags": [
++ "chat",
++ "prompt_cache",
++ "reasoning"
++ ],
++ "default_width": null,
++ "default_height": null,
++ "default_iterations": null
++ }
++ },
++ {
++ "id": "zai-org/GLM-5.3",
++ "object": "model",
++ "created": 0,
++ "owned_by": "deepinfra",
++ "root": "zai-org/GLM-5.3",
++ "parent": null,
++ "metadata": {
++ "description": "GLM-5.3 is a large-scale reasoning model from Z.ai, built for complex software engineering and long-horizon agent tasks. It supports text input and output with a 1M-token context window, and improves on GLM-5.2 in coding and in the balance between performance and token efficiency.",
++ "context_length": 1048576,
++ "max_tokens": 1048576,
++ "pricing": {
++ "input_tokens": 0.9,
++ "output_tokens": 4,
++ "cache_read_tokens": 0.199999998
++ },
++ "discount": 0.375,
++ "tags": [
++ "chat",
++ "prompt_cache",
++ "reasoning"
++ ],
++ "default_width": null,
++ "default_height": null,
++ "default_iterations": null
++ }
++ },
++ {
++ "id": "Qwen/Qwen3-14B",
++ "object": "model",
++ "created": 0,
++ "owned_by": "deepinfra",
++ "root": "Qwen/Qwen3-14B",
++ "parent": null,
++ "metadata": {
++ "description": "Qwen3 is the latest generation of large language models in Qwen series, offering a comprehensive suite of dense and mixture-of-experts (MoE) models. Built upon extensive training, Qwen3 delivers groundbreaking advancements in reasoning, instruction-following, agent capabilities, and multilingual support.",
++ "context_length": 40960,
++ "max_tokens": 40960,
++ "pricing": {
++ "input_tokens": 0.12000000000000001,
++ "output_tokens": 0.24000000000000002
++ },
++ "discount": null,
++ "tags": [
++ "chat",
++ "reasoning_effort",
++ "reasoning"
++ ],
++ "default_width": null,
++ "default_height": null,
++ "default_iterations": null
++ }
++ },
++ {
++ "id": "openai/gpt-oss-120b",
++ "object": "model",
++ "created": 0,
++ "owned_by": "deepinfra",
++ "root": "openai/gpt-oss-120b",
++ "parent": null,
++ "metadata": {
++ "description": "gpt-oss-120b is an open-weight, 117B-parameter Mixture-of-Experts (MoE) language model from OpenAI designed for high-reasoning, agentic, and general-purpose production use cases. The model supports configurable reasoning depth, full chain-of-thought access, and native tool use, including function calling, browsing, and structured output generation.",
++ "context_length": 131072,
++ "max_tokens": 131072,
++ "pricing": {
++ "input_tokens": 0.037,
++ "output_tokens": 0.16999999999999998
++ },
++ "discount": null,
++ "tags": [
++ "chat",
++ "reasoning_effort",
++ "reasoning"
++ ],
++ "default_width": null,
++ "default_height": null,
++ "default_iterations": null
++ }
++ },
++ {
++ "id": "Qwen/Qwen3.8-Max",
++ "object": "model",
++ "created": 0,
++ "owned_by": "deepinfra",
++ "root": "Qwen/Qwen3.8-Max",
++ "parent": null,
++ "metadata": {
++ "description": "Qwen's latest 2.4-trillion-parameter MoE flagship model delivering a comprehensive leap in coding and professional work.",
++ "context_length": 256000,
++ "max_tokens": 256000,
++ "pricing": {
++ "input_tokens": 1.6500000000000001,
++ "output_tokens": 4.9510000000000005,
++ "cache_read_tokens": 0.20599999200000002
++ },
++ "discount": null,
++ "tags": [
++ "chat",
++ "prompt_cache",
++ "reasoning_effort"
++ ],
++ "default_width": null,
++ "default_height": null,
++ "default_iterations": null
++ }
++ },
++ {
++ "id": "deepseek-ai/DeepSeek-V4.1-Flash-1017",
++ "object": "model",
++ "created": 0,
++ "owned_by": "deepinfra",
++ "root": "deepseek-ai/DeepSeek-V4.1-Flash-1017",
++ "parent": null,
++ "metadata": {
++ "description": "DeepSeek-V4.1-Flash-1017 is a minor update to DeepSeek-V4.1-Flash, delivering substantial improvements across reasoning, coding, agentic tasks, and visual benchmarks. Alongside these gains, the model reduces task execution time and output length, enabling users to complete tasks faster and at lower cost and advancing the performance–cost Pareto frontier across multiple agent harnesses.",
++ "context_length": 1048576,
++ "max_tokens": 1048576,
++ "pricing": {
++ "input_tokens": 0.2,
++ "output_tokens": 0.6,
++ "cache_read_tokens": 0.006
++ },
++ "discount": 0.51,
++ "tags": [
++ "chat",
++ "vlm",
++ "vision",
++ "prompt_cache",
++ "reasoning"
++ ],
++ "default_width": null,
++ "default_height": null,
++ "default_iterations": null
++ }
++ }
++ ]
++}
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/deepseek.md b/test/fixtures/pricebook/deepseek.md
nnew file mode 100644
iindex 0000000..72cc769
---- /dev/null
++++ b/test/fixtures/pricebook/deepseek.md
@@@ -0,0 +1,58 @@
++# Models & Pricing
++
++Model prices are listed in units of per 1M tokens. A token, the smallest unit of text that the model recognizes, can be a word, a number, or even a punctuation mark. We bill model usage based on the total number of input and output tokens by the model.
++
++***
++
++## Model Details
++
++| MODEL | MODEL | MODEL | deepseek-flash(1) | deepseek-v4-pro |
++| --------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
++| BASE URL (OpenAI Format) | BASE URL (OpenAI Format) | BASE URL (OpenAI Format) | <https://api.deepseek.com> | <https://api.deepseek.com> |
++| BASE URL (Anthropic Format) | BASE URL (Anthropic Format) | BASE URL (Anthropic Format) | <https://api.deepseek.com/anthropic> | <https://api.deepseek.com/anthropic> |
++| MODEL VERSION | MODEL VERSION | MODEL VERSION | DeepSeek-V4.1-Flash-1017 | DeepSeek-V4-Pro-0813 |
++| THINKING MODE | THINKING MODE | THINKING MODE | Supports both non-thinking and thinking (default) modes / See [Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode.md) for how to switch | Supports both non-thinking and thinking (default) modes / See [Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode.md) for how to switch |
++| CONTEXT LENGTH | CONTEXT LENGTH | CONTEXT LENGTH | 1M | 1M |
++| MAX OUTPUT | MAX OUTPUT | MAX OUTPUT | MAXIMUM: 384K | MAXIMUM: 384K |
++| FEATURES | [Json Output](https://api-docs.deepseek.com/guides/json_mode.md) | [Json Output](https://api-docs.deepseek.com/guides/json_mode.md) | ✓ | ✓ |
++| FEATURES | [Tool Calls](https://api-docs.deepseek.com/guides/tool_calls.md) | [Tool Calls](https://api-docs.deepseek.com/guides/tool_calls.md) | ✓ | ✓ |
++| FEATURES | [Responses API](https://api-docs.deepseek.com/guides/responses_api.md) | [Responses API](https://api-docs.deepseek.com/guides/responses_api.md) | ✓ | ✓ |
++| FEATURES | [Anthropic API](https://api-docs.deepseek.com/guides/anthropic_api.md) | [Anthropic API](https://api-docs.deepseek.com/guides/anthropic_api.md) | ✓ | ✓ |
++| FEATURES | [Chat Prefix Completion(Beta)](https://api-docs.deepseek.com/guides/chat_prefix_completion.md) | [Chat Prefix Completion(Beta)](https://api-docs.deepseek.com/guides/chat_prefix_completion.md) | ✓ | ✓ |
++| FEATURES | [FIM Completion(Beta)](https://api-docs.deepseek.com/guides/fim_completion.md) | [FIM Completion(Beta)](https://api-docs.deepseek.com/guides/fim_completion.md) | Non-thinking mode only | Non-thinking mode only |
++| FEATURES | [Vision](https://api-docs.deepseek.com/guides/vision.md) | [Vision](https://api-docs.deepseek.com/guides/vision.md) | ✓ | Not supported |
++| PRICING(2) | 1M INPUT TOKENS / (CACHE HIT) | OFF-PEAK | $0.003 | $0.022 |
++| PRICING(2) | 1M INPUT TOKENS / (CACHE HIT) | PEAK | $0.006 | $0.044 |
++| PRICING(2) | 1M INPUT TOKENS / (CACHE MISS) | OFF-PEAK | $0.15 | $0.66 |
++| PRICING(2) | 1M INPUT TOKENS / (CACHE MISS) | PEAK | $0.3 | $1.32 |
++| PRICING(2) | 1M OUTPUT TOKENS | OFF-PEAK | $0.6 | $1.98 |
++| PRICING(2) | 1M OUTPUT TOKENS | PEAK | $1.2 | $3.96 |
++| Concurrency Limit(3) | Concurrency Limit(3) | Concurrency Limit(3) | 2500 | 500 |
++
++(1) Use `deepseek-flash` as the model name. The legacy names `deepseek-v4-flash` and `deepseek-v4-flash-vision-exp` are still accepted, but the corresponding models have been retired, their requests are served by the DeepSeek-V4.1-Flash-1017 model and billed at the Flash price.
++
++(2) Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full.
++
++(3) For more details on concurrency limits, please refer to [Rate Limit & Isolation](https://api-docs.deepseek.com/quick_start/rate_limit.md).
++
++***
++
++## Search & Fetch Pricing
++
++Search and Fetch are standalone APIs billed per request.
++
++| API | Unit | Price |
++| ------ | ----------------------------- | ----- |
++| Search | Per 1,000 successful requests | $0.75 |
++| Fetch | Per 1,000 successful requests | $0.75 |
++
++See the [Search & Fetch guide](https://api-docs.deepseek.com/guides/search.md) for API usage and complete billing rules.
++
++***
++
++## Deduction Rules
++
++For model usage, the expense = number of tokens × price. Search and Fetch are charged per successful request at the rates above.
++The corresponding fees will be directly deducted from your topped-up balance or granted balance, with a preference for using the granted balance first when both balances are available.
++
++Product prices may vary and DeepSeek reserves the right to adjust them. We recommend topping up based on your actual usage and regularly checking this page for the most recent pricing information.
ddiff --git a/test/fixtures/pricebook/featherless.json b/test/fixtures/pricebook/featherless.json
nnew file mode 100644
iindex 0000000..d13d8ea
---- /dev/null
++++ b/test/fixtures/pricebook/featherless.json
@@@ -0,0 +1,404 @@
++{
++ "data": [
++ {
++ "id": "zetasepic/Qwen2.5-72B-Instruct-abliterated",
++ "is_gated": false,
++ "created": 1728604928,
++ "model_class": "qwen25-72b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 4,
++ "pricing": {
++ "prompt": "0.00000148",
++ "completion": "0.0000016",
++ "image": "0",
++ "request": "0",
++ "input": 1.48,
++ "output": 1.6
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "huihui-ai/Qwen2.5-72B-Instruct-abliterated",
++ "is_gated": false,
++ "created": 1733530235,
++ "model_class": "qwen25-72b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 4,
++ "pricing": {
++ "prompt": "0.00000148",
++ "completion": "0.0000016",
++ "image": "0",
++ "request": "0",
++ "input": 1.48,
++ "output": 1.6
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "Qwen/Qwen3-0.6B",
++ "is_gated": false,
++ "created": 1751086102,
++ "model_class": "qwen3-0b6",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000004",
++ "completion": "0.00000008",
++ "image": "0",
++ "request": "0",
++ "input": 0.04,
++ "output": 0.08
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "mlabonne/Meta-Llama-3.1-8B-Instruct-abliterated",
++ "is_gated": false,
++ "created": 1722075281,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000005",
++ "completion": "0.00000008",
++ "image": "0",
++ "request": "0",
++ "input": 0.05,
++ "output": 0.08
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "NousResearch/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1722624089,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "unsloth/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1722624089,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "SillyTilly/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1722624098,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "Crystalcareai/Meta-llama-3.1-8b-instruct",
++ "is_gated": false,
++ "created": 1722624104,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "meta-llama/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": true,
++ "created": 1726585673,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000005",
++ "completion": "0.00000008",
++ "image": "0",
++ "request": "0",
++ "input": 0.05,
++ "output": 0.08
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "aifeifei798/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1727078881,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "tanliboy/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1727554503,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "Qwen/Qwen3-14B",
++ "is_gated": false,
++ "created": 1747094414,
++ "model_class": "qwen3-14b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000012",
++ "completion": "0.00000024",
++ "image": "0",
++ "request": "0",
++ "input": 0.12,
++ "output": 0.24
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "unsloth/Qwen3-14B",
++ "is_gated": false,
++ "created": 1767854980,
++ "model_class": "qwen3-14b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000048",
++ "completion": "0.00000096",
++ "image": "0",
++ "request": "0",
++ "input": 0.48,
++ "output": 0.96
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "willcb/Qwen3-14B",
++ "is_gated": false,
++ "created": 1767858092,
++ "model_class": "qwen3-14b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000048",
++ "completion": "0.00000096",
++ "image": "0",
++ "request": "0",
++ "input": 0.48,
++ "output": 0.96
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "Greytechai/Qwen3-14B",
++ "is_gated": false,
++ "created": 1773790311,
++ "model_class": "qwen3-14b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000048",
++ "completion": "0.00000096",
++ "image": "0",
++ "request": "0",
++ "input": 0.48,
++ "output": 0.96
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "mombip/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1751687717,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "zgrgr/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1726175967,
++ "model_class": "llama3-8b",
++ "owned_by": "Feather",
++ "context_length": 8192,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000037",
++ "completion": "0.00000038",
++ "image": "0",
++ "request": "0",
++ "input": 0.37,
++ "output": 0.38
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "Cirrascale/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1751692639,
++ "model_class": "llama31-8b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.0000002",
++ "completion": "0.00000032",
++ "image": "0",
++ "request": "0",
++ "input": 0.2,
++ "output": 0.32
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "camenduru/Meta-Llama-3.1-8B-Instruct",
++ "is_gated": false,
++ "created": 1767880391,
++ "model_class": "llama3-8b",
++ "owned_by": "Feather",
++ "context_length": 8192,
++ "concurrency_cost": 1,
++ "pricing": {
++ "prompt": "0.00000037",
++ "completion": "0.00000038",
++ "image": "0",
++ "request": "0",
++ "input": 0.37,
++ "output": 0.38
++ },
++ "features": {
++ "tool_use": true
++ }
++ },
++ {
++ "id": "indigoskyai/Qwen2.5-72B-Instruct-abliterated",
++ "is_gated": false,
++ "created": 1783098151,
++ "model_class": "qwen2-72b",
++ "owned_by": "Feather",
++ "context_length": 32768,
++ "concurrency_cost": 4,
++ "pricing": {
++ "prompt": "0.000012",
++ "completion": "0.00002",
++ "image": "0",
++ "request": "0",
++ "input": 12,
++ "output": 20
++ },
++ "features": {
++ "tool_use": true
++ }
++ }
++ ]
++}
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/glm.md b/test/fixtures/pricebook/glm.md
nnew file mode 100644
iindex 0000000..625039b
---- /dev/null
++++ b/test/fixtures/pricebook/glm.md
@@@ -0,0 +1,108 @@
++> ## Documentation Index
++> Fetch the complete documentation index at: https://docs.z.ai/llms.txt
++> Use this file to discover all available pages before exploring further.
++
++# Pricing
++
++> This page provides pricing information for Z.AI’s models and tools. All prices are in USD.
++
++## Models
++
++### Latest Models
++
++Prices per 1M tokens.
++
++| Model | Input | Cached Input | Cached Input Storage | Output |
++| :- | :- | :- | :- | :- |
++| GLM-5.3 | \$1.4 | \$0.26 | Limited-time Free | \$4.4 |
++| GLM-5.3-Flash | \$0.15 | \$0.03 | Limited-time Free | \$0.50 |
++| GLM-5.3-FlashX | \$0.37 | \$0.075 | Limited-time Free | \$1.25 |
++
++### Text Models
++
++Prices per 1M tokens.
++
++| Model | Input | Cached Input | Cached Input Storage | Output |
++| :- | :- | :- | :- | :- |
++| GLM-5.3-FlashX-Legal | \$0.37 | \$0.075 | Limited-time Free | \$1.25 |
++| GLM-5.2 | \$1.4 | \$0.26 | Limited-time Free | \$4.4 |
++| GLM-5.1 | \$1.4 | \$0.26 | Limited-time Free | \$4.4 |
++| GLM-5 | \$1 | \$0.2 | Limited-time Free | \$3.2 |
++| GLM-4.7 | \$0.6 | \$0.11 | Limited-time Free | \$2.2 |
++| GLM-4.6 | \$0.6 | \$0.11 | Limited-time Free | \$2.2 |
++
++### Built-in Tools
++
++| Tool | Cost |
++| :- | :- |
++| Web Search | \$0.01 / use |
++
++### Image Generation Models
++
++Prices per image.
++
++| Model | Price |
++| :- | :- |
++| GLM-Image | \$0.015 |
++
++### Agents
++
++| Agent | Price |
++| :- | :- |
++| GLM Slide/Poster Agent(beta) | \$0.7 / MTok |
++| General-Purpose Translation | \$3 / MTok |
++| Popular Special Effects Video Templates | \$0.2 / video |
++
++> [accordion] Other Pricing
++>
++> ### Text Models
++>
++> Prices per 1M tokens.
++>
++> | Model | Input | Cached Input | Cached Input Storage | Output |
++> | :- | :- | :- | :- | :- |
++> | GLM-4.7-FlashX | \$0.07 | \$0.01 | Limited-time Free | \$0.4 |
++> | GLM-4.5 | \$0.6 | \$0.11 | Limited-time Free | \$2.2 |
++> | GLM-4.5-X | \$2.2 | \$0.45 | Limited-time Free | \$8.9 |
++> | GLM-4.5-Air | \$0.2 | \$0.03 | Limited-time Free | \$1.1 |
++> | GLM-4.5-AirX | \$1.1 | \$0.22 | Limited-time Free | \$4.5 |
++> | GLM-4-32B-0414-128K | \$0.1 | - | - | \$0.1 |
++> | GLM-4.7-Flash | Free | Free | Free | Free |
++> | GLM-4.5-Flash | Free | Free | Free | Free |
++>
++> ### Vision Models
++>
++> Prices per 1M tokens.
++>
++> | Model | Input | Cached Input | Cached Input Storage | Output |
++> | :- | :- | :- | :- | :- |
++> | GLM-4.6V | \$0.3 | \$0.05 | Limited-time Free | \$0.9 |
++> | GLM-OCR | \$0.03 | \\ | \\ | \$0.03 |
++> | GLM-4.6V-FlashX | \$0.04 | \$0.004 | Limited-time Free | \$0.4 |
++> | GLM-4.5V | \$0.6 | \$0.11 | Limited-time Free | \$1.8 |
++> | GLM-4.6V-Flash | Free | Free | Free | Free |
++>
++> ### Image Generation Models
++>
++> Prices per image.
++>
++> | Model | Price |
++> | :- | :- |
++> | CogView-4 | \$0.01 |
++>
++> ### Video Generation Models
++>
++> Prices per video.
++>
++> | Model | Price |
++> | :- | :- |
++> | CogVideoX-3 | \$0.2 |
++>
++> ### Audio Models
++>
++> | Model | Price |
++> | :- | :- |
++> | GLM-ASR-2512 | \$0.03 / MTok (equivalent to approximately \$0.0024/minute) |
++
++
++This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/kimi.html b/test/fixtures/pricebook/kimi.html
nnew file mode 100644
iindex 0000000..764d9a1
---- /dev/null
++++ b/test/fixtures/pricebook/kimi.html
@@@ -0,0 +1,2 @@
++columns:[{title:`Model`,width:`25%`},{title:`Input`,width:`15%`},{title:`Output`,width:`15%`},{title:_jsx(Tooltip,{tip:`Cached input is billed at this rate; each hit automatically refreshes the cache entry's TTL (time-to-live).`,children:`Cached Input`}),width:`15%`},{title:_jsx(Tooltip,{tip:`Writing a prompt prefix into the context cache with a 5-minute TTL (time-to-live); this is the default tier when no TTL is specified.`,children:`5m writes`}),width:`15%`},{title:_jsx(Tooltip,{tip:`Writing a prompt prefix into the context cache with a 1-hour TTL.`,children:`1h writes`}),width:`15%`}]
++rows:[[_jsx(_components.a,{href:`/guide/kimi-k3-1-pro-quickstart`,children:`kimi-k3.1-pro`}),`$2.00 / MTok`,`$10.00 / MTok`,`$0.20 / MTok`,`$2.00 / MTok`,`$4.00 / MTok`],[_jsx(_components.a,{href:`/guide/kimi-k3-1-pro-quickstart`,children:`kimi-k3.1-pro-highspeed`}),`$4.00 / MTok`,`$20.00 / MTok`,`$0.20 / MTok`,`$4.00 / MTok`,`$8.00 / MTok`],[_jsx(_components.a,{href:`/guide/kimi-k3-quickstart`,children:`kimi-k3`}),`$3.00 / MTok`,`$15.00 / MTok`,`$0.30 / MTok`,`$3.00 / MTok`,`$6.00 / MTok`]]
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/manifest.json b/test/fixtures/pricebook/manifest.json
nnew file mode 100644
iindex 0000000..394e179
---- /dev/null
++++ b/test/fixtures/pricebook/manifest.json
@@@ -0,0 +1,82 @@
++{
++ "recorded_at": "2026-11-06",
++ "sources": [
++ {
++ "key": "deepseek",
++ "url": "https://api-docs.deepseek.com/quick_start/pricing.md",
++ "file": "deepseek.md",
++ "bytes": 13624,
++ "listed": 2
++ },
++ {
++ "key": "qwen",
++ "url": "https://www.alibabacloud.com/help/en/model-studio/model-pricing",
++ "file": "qwen.html",
++ "bytes": 15056,
++ "listed": 5
++ },
++ {
++ "key": "kimi",
++ "url": "https://platform.kimi.ai/docs/pricing/chat",
++ "file": "kimi.html",
++ "bytes": 1091,
++ "listed": 3
++ },
++ {
++ "key": "glm",
++ "url": "https://docs.z.ai/guides/overview/pricing.md",
++ "file": "glm.md",
++ "bytes": 3016,
++ "listed": 22
++ },
++ {
++ "key": "minimax",
++ "url": "https://platform.minimax.io/docs/pricing.md",
++ "file": "minimax.md",
++ "bytes": 21388,
++ "listed": 4
++ },
++ {
++ "key": "deepinfra",
++ "url": "https://api.deepinfra.com/v1/models",
++ "file": "deepinfra.json",
++ "bytes": 5748,
++ "listed": 6
++ },
++ {
++ "key": "novita",
++ "url": "https://api.novita.ai/v3/openai/models",
++ "file": "novita.json",
++ "bytes": 9504,
++ "listed": 5
++ },
++ {
++ "key": "featherless",
++ "url": "https://api.featherless.ai/v1/models",
++ "file": "featherless.json",
++ "bytes": 9593,
++ "listed": 20
++ },
++ {
++ "key": "refuseless",
++ "url": "https://docs.refuseless.com/api/models.md",
++ "file": "refuseless.md",
++ "bytes": 3988,
++ "listed": 9
++ },
++ {
++ "key": "abliteration",
++ "url": "https://docs.abliteration.ai/pricing.md",
++ "file": "abliteration.md",
++ "bytes": 2465,
++ "listed": 4
++ },
++ {
++ "key": "openrouter",
++ "url": "https://openrouter.ai/api/v1/models",
++ "file": "openrouter.json",
++ "bytes": 52870,
++ "listed": 14
++ }
++ ]
++}
ddiff --git a/test/fixtures/pricebook/minimax.md b/test/fixtures/pricebook/minimax.md
nnew file mode 100644
iindex 0000000..68a8ed5
---- /dev/null
++++ b/test/fixtures/pricebook/minimax.md
@@@ -0,0 +1,511 @@
++> ## Documentation Index
++> Fetch the complete documentation index at: https://platform.minimax.io/docs/llms.txt
++> Use this file to discover all available pages before exploring further.
++
++# Pricing Overview
++
++[Pricing Overview](#overview)
++
++Model Pricing
++
++[LLM](#models/text)
++[Video](#models/video)
++[Audio](#models/speech)
++[Image](#models/image)
++
++Tool Pricing
++
++[MCP](#tools/mcp)
++[Server Tools](#tools/tools)
++
++Packages
++
++[Audio](#packages/speech)
++[Video](#packages/video)
++
++Billing Guides
++
++[Payment Methods](#guides/compare)
++[Pay-as-you-go Billing](#guides/billing)
++[Package Usage Rules](#guides/packages)
++[FAQ](#guides/faq)
++
++> [accordion] Pricing navigation
++>
++> [Pricing Overview](#overview)
++>
++> Model Pricing
++>
++> [LLM](#models/text)
++> [Video](#models/video)
++> [Audio](#models/speech)
++> [Image](#models/image)
++>
++> Tool Pricing
++>
++> [MCP](#tools/mcp)
++> [Server Tools](#tools/tools)
++>
++> Packages
++>
++> [Audio](#packages/speech)
++> [Video](#packages/video)
++>
++> Billing Guides
++>
++> [Payment Methods](#guides/compare)
++> [Pay-as-you-go Billing](#guides/billing)
++> [Package Usage Rules](#guides/packages)
++> [FAQ](#guides/faq)
++
++Explore MiniMax model and tool pricing, choose an Audio or Video Package, or learn about billing.
++
++## LLM
++
++[Recharge Now](https://platform.minimax.io/user-center/payment/balance)
++
++| **Model** | **Input** \$ / M tokens | **Output** \$ / M tokens | **Prompt caching Read** \$ / M tokens |
++| :- | :-: | :-: | :-: |
++| **MiniMax-M3.1-Flash** | \$0.15 | \$0.60 | \$0.015 |
++
++> [tab] Standard
++>
++> | **Model** | **Input** \$ / M tokens | **Output** \$ / M tokens | **Prompt caching Read** \$ / M tokens |
++> | :- | :-: | :-: | :-: |
++> | **MiniMax-M3** ≤ 512k input tokens Permanent 50% off | ~~\$0.60~~ \$0.30 | ~~\$2.40~~ \$1.20 | ~~\$0.12~~ \$0.06 |
++> | **MiniMax-M3** > 512k input tokens\* Permanent 50% off | ~~\$1.20~~ \$0.60 | ~~\$4.80~~ \$2.40 | ~~\$0.24~~ \$0.12 |
++
++> [tab] Priority\*
++>
++> | **Model** | **Input** \$ / M tokens | **Output** \$ / M tokens | **Prompt caching Read** \$ / M tokens |
++> | :- | :-: | :-: | :-: |
++> | **MiniMax-M3** ≤ 512k input tokens Permanent 50% off | ~~\$0.90~~ \$0.45 | ~~\$3.60~~ \$1.80 | ~~\$0.18~~ \$0.09 |
++> | **MiniMax-M3** > 512k input tokens Permanent 50% off | ~~\$1.80~~ \$0.90 | ~~\$7.20~~ \$3.60 | ~~\$0.36~~ \$0.18 |
++>
++> \* Priority provides priority admission for faster response times and improved request reliability. Set `service_tier` to `priority` to enable it. Pricing is 1.5x standard.
++
++> [!NOTE]
++>
++> Note:
++>
++> 1. The billing item is token count; the token-to-character ratio varies slightly depending on the usage scenario, subject to actual consumption
++> 2. Token to English word ratio (estimate): approximately 750 English words consume 1000 tokens
++
++## Video
++
++[Recharge Now](https://platform.minimax.io/user-center/payment/balance)
++
++### H3 Series Flagship Models
++
++<table>
++ <thead>
++ <tr>
++ <th>Model</th>
++ <th>Output Resolution</th>
++ <th>Output Video\
++ per second</th>
++ <th>Input Video\
++ per second</th>
++ <th>Input Image\
++ per image\
++ beyond free quota</th>
++ </tr>
++ </thead>
++
++ <tbody>
++ <tr>
++ <td rowSpan="2">**MiniMax-H3**\
++ Quality first, for high-quality video creation.</td>
++ <td>2K</td>
++ <td>\$0.13</td>
++ <td>\$0.13</td>
++ <td>\$0.04</td>
++ </tr>
++
++ <tr>
++ <td>768P</td>
++ <td>\$0.08</td>
++ <td>\$0.08</td>
++ <td>\$0.04</td>
++ </tr>
++
++ <tr>
++ <td rowSpan="2">**MiniMax-H3-Highspeed**\
++ Balances quality and speed, for everyday content creation.</td>
++ <td>768P</td>
++ <td>\$0.06</td>
++ <td>\$0.06</td>
++ <td>\$0.03</td>
++ </tr>
++
++ <tr>
++ <td>480P</td>
++ <td>\$0.04</td>
++ <td>\$0.04</td>
++ <td>\$0.03</td>
++ </tr>
++
++ <tr>
++ <td rowSpan="2">**MiniMax-H3-Lite**\
++ Lower cost, for creative exploration and batch generation.</td>
++ <td>768P</td>
++ <td>\$0.04</td>
++ <td>\$0.04</td>
++ <td>\$0.02</td>
++ </tr>
++
++ <tr>
++ <td>480P</td>
++ <td>\$0.025</td>
++ <td>\$0.025</td>
++ <td>\$0.02</td>
++ </tr>
++ </tbody>
++</table>
++
++* **Image input**: The first **5 images** per task are free; additional images are billed per image. The unit price does not vary with image or output video resolution.
++* **Video input**: Billed by input duration at the output resolution of the selected model.
++* **Task types**: For the same model and resolution, all task types share the same unit price. Audio input is free.
++
++> [accordion] View flagship model task types
++>
++> <table>
++> <thead>
++> <tr>
++> <th>Model</th>
++> <th>Text-to-video t2v</th>
++> <th>Image-to-video i2v</th>
++> <th>First & last frame fl2v</th>
++> <th>Reference ref</th>
++> </tr>
++> </thead>
++>
++> <tbody>
++> <tr>
++> <td>MiniMax-H3</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Image, video, audio</td>
++> </tr>
++>
++> <tr>
++> <td>MiniMax-H3-Highspeed</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Image, video, audio</td>
++> </tr>
++>
++> <tr>
++> <td>MiniMax-H3-Lite</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Image, video, audio</td>
++> </tr>
++> </tbody>
++> </table>
++
++### H3 Series Extended Models
++
++<table>
++ <thead>
++ <tr>
++ <th>Model</th>
++ <th>Output Resolution</th>
++ <th>Output Video\
++ per second</th>
++ <th>Input Video\
++ per second</th>
++ <th>Input Image\
++ per image\
++ beyond free quota</th>
++ </tr>
++ </thead>
++
++ <tbody>
++ <tr>
++ <td rowSpan="2">**MiniMax-H3-Max**\
++ Supports image, video, and audio references, for multi-asset video creation.</td>
++ <td>768P</td>
++ <td>\$0.08</td>
++ <td>\$0.143</td>
++ <td>\$0.074</td>
++ </tr>
++
++ <tr>
++ <td>480P</td>
++ <td>\$0.05</td>
++ <td>\$0.0553</td>
++ <td>\$0.074</td>
++ </tr>
++ </tbody>
++</table>
++
++* **MiniMax-H3-Max**: The first **2 images** per task are free; additional images are billed per image. Input video is billed by duration at the output resolution. Audio input is free.
++
++> [accordion] View extended model task types
++>
++> <table>
++> <thead>
++> <tr>
++> <th>Model</th>
++> <th>Text-to-video t2v</th>
++> <th>Image-to-video i2v</th>
++> <th>First & last frame fl2v</th>
++> <th>Reference ref</th>
++> </tr>
++> </thead>
++>
++> <tbody>
++> <tr>
++> <td>MiniMax-H3-Max</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Supported</td>
++> <td>Image, video, audio</td>
++> </tr>
++> </tbody>
++> </table>
++
++### Video Regeneration
++
++Regenerate a 768P video produced by MiniMax-H3 into 2K.
++
++| **Model / API** | **Spec** | **Output Video** per second | **Input Video** per second | **Input Image** per image beyond free quota |
++| :- | :- | -: | -: | -: |
++| MiniMax-H3-Regeneration | 768P → 2K | \$0.05 | \$0.05 | \$0.025 |
++
++* Input materials from the original task are billed again: the first **5 images** are free and additional images are billed per image; video is billed by the original input duration; audio is free.
++* The regeneration fee is charged separately from the original task fee and includes the regenerated video output fee and input material fees.
++
++### Context-IR
++
++| **Model / API** | **Input Price** per M tokens | **Output Price** per M tokens |
++| :- | -: | -: |
++| MiniMax-H3-Context-IR | \$0.90 | \$3.60 |
++
++> [accordion] Legacy Models
++>
++> | Model | Price |
++> | :- | :- |
++> | MiniMax-Hailuo-2.3-Fast | \$0.19 per 768P, 6s video |
++> | MiniMax-Hailuo-2.3-Fast | \$0.32 per 768P, 10s video |
++> | MiniMax-Hailuo-2.3-Fast | \$0.33 per 1080P, 6s video |
++> | MiniMax-Hailuo-2.3 | \$0.28 per 768P, 6s video |
++> | MiniMax-Hailuo-2.3 | \$0.56 per 768P, 10s video |
++> | MiniMax-Hailuo-2.3 | \$0.49 per 1080P, 6s video |
++> | MiniMax-Hailuo-02 | \$0.28 per 768P, 6s video |
++> | MiniMax-Hailuo-02 | \$0.56 per 768P, 10s video |
++> | MiniMax-Hailuo-02 | \$0.49 per 1080P, 6s video |
++> | MiniMax-Hailuo-02 | \$0.10 per 512P, 6s video |
++> | MiniMax-Hailuo-02 | \$0.15 per 512P, 10s video |
++
++## Audio
++
++[Recharge Now](https://platform.minimax.io/user-center/payment/balance)
++
++**Automatic Speech Recognition (ASR)**
++
++| **API** | **Endpoint** | **Description** | **Price** |
++| :- | :- | :- | :- |
++| Speech Recognition | Speech-to-Text | Audio-to-text transcription with streaming, speaker diarization, and subtitle (srt/vtt) export. | \$0.38 / hour |
++
++**Text-to-Speech (TTS)**
++
++| **API** | **Model** | **Description** | **Price** |
++| :- | :- | :- | :- |
++| **T2A** (Sync) | speech-2.8-hd | Real-time synthesis with volume/pitch/speed/mixing controls and bitrate/sample-rate options. Best for short-form conversational audio. | \$100 / M characters |
++| **T2A** (Sync) | speech-2.8-turbo | Same capabilities as hd, optimized for speed and cost. | \$60 / M characters |
++| **T2A Async** | speech-2.8-hd | Asynchronous long-form synthesis, up to 1M characters per request, retrieved asynchronously. | \$100 / M characters |
++| **T2A Async** | speech-2.8-turbo | Same capabilities as hd, optimized for speed and cost. | \$60 / M characters |
++
++**Voice Management**
++
++| **API** | **Description** | **Price** | **Notes** |
++| :- | :- | :- | :- |
++| **Voice Design** | Generate a voice (voice\_id) from a natural-language description; usable in both sync and async T2A endpoints. | \$3 per voice | Charged when the designed voice is first used for synthesis, not at generation time. In-API preview synthesis is billed at \$60 / M characters. |
++| **Rapid Voice Cloning** | LLM-powered voice cloning that produces a high-fidelity replica in seconds, without long high-quality reference audio. | \$1.5 per voice | Charged when the cloned voice is first used for synthesis, not at cloning time. Preview characters are billed at the selected preview model's rate. |
++
++> [accordion] Legacy Models
++>
++> | API | Model | Price |
++> | :- | :- | :- |
++> | **T2A** | speech-2.6-turbo / speech-02-turbo | \$60/M characters |
++> | **T2A** | speech-2.6-hd / speech-02-hd | \$100/M characters |
++
++## Image
++
++[Recharge Now](https://platform.minimax.io/user-center/payment/balance)
++
++| Model | Price |
++| :- | :- |
++| image-01 | \$0.0035 per image |
++
++## MCP
++
++[Recharge Now](https://platform.minimax.io/user-center/payment/balance)
++
++| Model | Input Price |
++| :- | :- |
++| **API-vlm** | \$0.01 / request |
++
++When API-vlm is called through M Plan, usage deducts from the included M Plan quota according to its pay-as-you-go price. If the included quota is exhausted and purchased Credits are available, additional usage can be automatically covered by purchased Credits.
++
++## Server Tools&#x20;
++
++[Recharge Now](https://platform.minimax.io/user-center/payment/balance)
++
++| Server Tool | Description | Price |
++| :- | :- | :- |
++| **web\_search** | Web search; the model runs the search on the server and answers based on the results. See [Server Tools](https://platform.minimax.io/docs/guides/server-tools.md). | \$0.01 / request |
++
++## Audio
++
++[Purchase Now](https://platform.minimax.io/console/resource-packs/buy/audio)
++
++| Category | Starter | Standard | Pro | Scale | Business | Customer Pricing |
++| :- | :- | :- | :- | :- | :- | :- |
++| **Monthly Subscription** | \$5 | \$30 | \$99 | \$249 | \$999 | / |
++| **3-month Subscription** (10% off) | \$13.5 | \$81 | \$267 | \$672 | \$2,697 | / |
++| **Yearly Subscription** (20% off) | \$48 | \$288 | \$950 | \$2,390 | \$9,590 | / |
++| **Audio points per month** | 100,000 | 300,000 | 1,100,000 | 3,300,000 | 20,000,000 | As many audio points as you need |
++| **Functions** | • Voice slots: 10 • RPM: 10 • Faster speed of generating speech • Supports T2A v2, T2A large v2 API • Supports all speech models | • Voice slots: 100 • RPM: 50 • Faster speed of generating speech • Supports T2A v2, T2A large v2 API • Supports all speech models | • Voice slots: 250 • RPM: 200 • Faster speed of generating speech • Supports T2A v2, T2A large v2 API • Supports all speech models | • Voice slots: 500 • RPM: 500 • Faster speed of generating speech • Supports T2A v2, T2A large v2 API • Supports all speech models | • Voice slots: 800 • RPM: 800 • Faster speed of generating speech • Supports T2A v2, T2A large v2 API • Supports all speech models | • Unlimited RPM/TPM • Priority access to model updates • More voices and more cloning options • Exclusive security and stability guarantees |
++
++## Video
++
++[Purchase Now](https://platform.minimax.io/console/resource-packs/buy/video)
++
++Video packages support Hailuo video models. MiniMax H3 is not supported yet
++
++Need H3? Use the&#x20;
++[pay-as-you-go API](#models/video), or contact sales for a custom plan.
++
++| Package Category | Standard (SAVE 5%) | Pro (SAVE 10%) | Scale (SAVE 15%) | Business (SAVE 20%) | Custom Pricing |
++| :- | :- | :- | :- | :- | :- |
++| **Discounted Price** | \$1,000 | \$2,500 | \$4,500 | \$6,000 | - |
++| **Valid** | 1 month | 1 month | 1 month | 1 month | - |
++| **Total video points** | 3,760 video points | 9,920 video points | 18,900 video points | 26,780 video points | As many video points as you need |
++| **Functions** | • Supports Video Generation API • RPM: 20 | • Supports Video Generation API • RPM: 30 | • Supports Video Generation API • RPM: 40 | • Supports Video Generation API • RPM: 50 | • Unlimited RPM/TPM • Priority access to model updates • Exclusive security and stability guarantees |
++
++Tips
++
++1. The deduction times for generating videos differ depending on the model:
++ * For model \[MiniMax-Hailuo-2.3-Fast], generating a single 768p,6s video will deduct 0.7 video points
++ * For model \[MiniMax-Hailuo-2.3-Fast], generating a single 768p,10s video will deduct 1.1 video points
++ * For model \[MiniMax-Hailuo-2.3-Fast], generating a single 1080p,6s video will deduct 1.3 video points
++ * For model \[MiniMax-Hailuo-2.3] \[MiniMax-Hailuo-02], generating a single 768p,6s video will deduct 1 video point
++ * For model \[MiniMax-Hailuo-2.3] \[MiniMax-Hailuo-02], generating a single 768p,10s video will deduct 2 video points
++ * For model \[MiniMax-Hailuo-2.3] \[MiniMax-Hailuo-02], generating a single 1080p,6s video will deduct 2 video points
++ * For model \[MiniMax-Hailuo-02], generating a single 512p,6s video will deduct 0.3 video points
++ * For model \[MiniMax-Hailuo-02], generating a single 512p,10s video will deduct 0.5 video points
++
++2. Video generation failures or videos that trigger security review will not result in a deduction.
++
++3. Resource pack remaining quantity is not inherited; it will automatically reset to zero when expired.
++
++## Payment Methods
++
++MiniMax offers pay-as-you-go billing, Audio and Video Packages. Choose a billing option based on the models you use, how much you expect to use them, and for how long.
++
++### Compare Billing Options
++
++| Feature | Pay-as-you-go | Packages |
++| - | - | - |
++| Use cases | Use any MiniMax model or tool and pay for what you use, with the flexibility to support a wide range of business needs and API workloads. | For speech or video workloads where you expect to make good use of the included points. |
++| How you pay | Add funds to your account and pay for what you use | Purchase an Audio or Video Package |
++| Coverage | All MiniMax models and tools | All speech models; Hailuo video models |
++| Billing units | Varies by model, configuration, and type of usage | Audio points per month; video points |
++| Get started | [Recharge Now](https://platform.minimax.io/user-center/payment/balance) | [View Audio](#packages/speech) · [View Video](#packages/video) |
++
++### Before You Buy
++
++Check the supported models, included points, and how long the package lasts. Audio packages support all speech models. Video Packages support Hailuo Video models; MiniMax H3 is not yet supported.
++
++[View Audio](#packages/speech) · [View Video](#packages/video) · [View Usage Rules](#guides/packages)
++
++## Pay-as-you-go Billing
++
++With pay-as-you-go billing, you use standard Open Platform API keys and pay from your account balance based on usage. All prices are in USD.
++
++### How Billing Works
++
++* **Payment:** API usage charges are deducted from your account balance.
++* **Billing units:** Depending on the service, charges are based on tokens, characters, duration, images, voices, or requests. See the relevant pricing table for the exact unit.
++* **Rates:** Rates vary by model, API, and configuration. For example, LLM input length and service tier, or video output resolution, can affect pricing.
++
++### How Charges Are Calculated
++
++> [!NOTE]
++>
++> For each billing item, multiply actual usage by the applicable unit price. First, convert usage to the unit listed in the pricing table. For example, if a price is listed per million tokens, divide the token count by 1,000,000 before multiplying by the price. If a request includes multiple billing items, add their costs together.
++
++See Model Pricing and Tool Pricing for individual rates. Audio and Video Packages are also available for supported models. To compare these options, see [Payment Methods](#guides/compare).
++
++## Package Usage Rules
++
++Audio and Video Packages include points for use with supported models and APIs. Before purchasing, check which models are supported, how many points are included, and how long the package lasts.
++
++### Coverage
++
++| Feature | Audio | Video |
++| - | - | - |
++| Supported models | All speech models | Hailuo video models; MiniMax H3 is not supported yet |
++| Supported APIs | T2A v2 and T2A large v2 | Video Generation API |
++| Included points | Audio points per month | Video points |
++| Package duration | 1 month, 3 months (10% off), or 1 year (20% off) | 1 month for all four standard packages |
++| Plan details | [Audio](#packages/speech) | [Video](#packages/video) |
++
++### How Points Are Used
++
++* **Audio points:** Starter, Standard, Pro, Scale, and Business each include a monthly allowance of audio points. The allowance varies by plan. See the [Audio package pricing table](#packages/speech) for included points, voice slots, and rate limits.
++* **Video points:** Points used depend on the model, resolution, and video duration. One point does not equal one video. See the [video point deduction rules](#packages/video/package-rules).
++* **Failed video tasks:** No points are deducted when video generation fails or a video triggers a security review.
++
++### Expiration and Usage Limits
++
++Unused video points expire with the package and do not roll over. Your video point balance resets to zero when the package expires. For Audio packages, see the pricing table for available terms, monthly audio points, and plan features. Rate limits and other included features are listed in the relevant pricing tables.
++
++## FAQ
++
++Find answers to common questions about pay-as-you-go billing, Audio and Video Packages. See the relevant pricing tables for exact prices and included features.
++
++### Billing and Usage
++
++> [accordion] What’s the difference between the Standard and Priority service tiers for MiniMax-M3?
++>
++> Priority gives your requests priority access for faster responses and improved reliability. It costs 1.5 times the Standard rate. To enable it, set `service_tier` to `priority`. See the LLM pricing tables for the rates that apply to each input length.
++
++> [accordion] Does video output pricing include charges for input media?
++>
++> H3 video output and input media are billed separately. Images beyond the free allowance incur an additional charge. Input video is billed based on its duration and the output resolution. If you regenerate a video, the input media from the original task is billed again. See the video pricing tables for the applicable rates.
++
++> [accordion] How do I add funds and check usage and charges?
++>
++> Visit the [Recharge page](https://platform.minimax.io/user-center/payment/balance) to add funds to your account for pay-as-you-go API usage. View usage and charges on the [API Usage page](https://platform.minimax.io/console/consumption-detail).
++
++> [accordion] Which models can I use with packages?
++>
++> You can use Audio Packages with all speech models via the T2A v2 and T2A large v2 APIs. Video Packages support Hailuo video models, but do not currently support MiniMax H3. Before you buy, check the supported models, included points, and plan duration. For details, see [Package Usage Rules](#guides/packages).
++
++> [accordion] What happens to unused video points when my package expires?
++>
++> Any unused video points expire when your package ends, and your video point balance resets to zero. Each of the four standard video packages is valid for one month.
++
++> [accordion] About Music Models
++>
++> As of August 20, 2026, the music generation and lyrics generation APIs are no longer available to new users. Existing paying users can continue using these APIs.
++>
++> To generate music, visit [**MiniMax Audio**](https://www.minimax.io/audio) or try the open-source **MiniMax Music 3** model on [**Hugging Face**](https://huggingface.co/MiniMaxAI/MiniMax-Music3) or [**ModelScope**](https://modelscope.cn/models/MiniMax/MiniMax-Music3).
++>
++> The following rates apply to existing paying users.
++>
++> | **Model** | **Price** |
++> | :- | :-: |
++> | Music-3.0 | \$0.15 per track (up to 5 minutes) |
++> | Music-2.6 | \$0.15 per track (up to 5 minutes) |
++> | Lyrics Generation | \$0.01 per song |
++> | Music-2.5+ | \$0.15 per track (up to 5 minutes) |
++> | Music-2.5 | \$0.15 per track (up to 5 minutes) |
++> | Music-2.0 | \$0.03 per track (up to 5 minutes) |
ddiff --git a/test/fixtures/pricebook/novita.json b/test/fixtures/pricebook/novita.json
nnew file mode 100644
iindex 0000000..310ded7
---- /dev/null
++++ b/test/fixtures/pricebook/novita.json
@@@ -0,0 +1,315 @@
++{
++ "data": [
++ {
++ "created": 1792375957,
++ "id": "deepseek/deepseek-v4.1-flash-1017",
++ "object": "model",
++ "owned_by": "novita",
++ "permission": null,
++ "root": "",
++ "parent": "",
++ "input_token_price_per_m": 3000,
++ "output_token_price_per_m": 12000,
++ "pricing": {
++ "prompt": {
++ "origin_price_per_m": 3000,
++ "price_per_m": 3000,
++ "origin_price_per_m_decimal": "0.3",
++ "price_per_m_decimal": "0.3"
++ },
++ "completion": {
++ "origin_price_per_m": 12000,
++ "price_per_m": 12000,
++ "origin_price_per_m_decimal": "1.2",
++ "price_per_m_decimal": "1.2"
++ },
++ "input_cache_read": {
++ "origin_price_per_m": 60,
++ "price_per_m": 60,
++ "origin_price_per_m_decimal": "0.006",
++ "price_per_m_decimal": "0.006"
++ }
++ },
++ "is_tiered_billing": false,
++ "title": "deepseek/deepseek-v4.1-flash-1017",
++ "description": "",
++ "tags": [],
++ "context_size": 1048576,
++ "status": 1,
++ "display_name": "DeepSeek V4.1 Flash 1017",
++ "model_type": "chat",
++ "max_output_tokens": 393216,
++ "features": [
++ "serverless",
++ "function-calling",
++ "structured-outputs",
++ "reasoning"
++ ],
++ "endpoints": [
++ "chat/completions",
++ "anthropic",
++ "responses"
++ ],
++ "input_modalities": [
++ "text",
++ "image"
++ ],
++ "output_modalities": [
++ "text"
++ ]
++ },
++ {
++ "created": 1792803011,
++ "id": "moonshotai/kimi-k3.1-pro",
++ "object": "model",
++ "owned_by": "novita",
++ "permission": null,
++ "root": "",
++ "parent": "",
++ "input_token_price_per_m": 20000,
++ "output_token_price_per_m": 100000,
++ "pricing": {
++ "prompt": {
++ "origin_price_per_m": 20000,
++ "price_per_m": 20000,
++ "origin_price_per_m_decimal": "2",
++ "price_per_m_decimal": "2"
++ },
++ "completion": {
++ "origin_price_per_m": 100000,
++ "price_per_m": 100000,
++ "origin_price_per_m_decimal": "10",
++ "price_per_m_decimal": "10"
++ },
++ "input_cache_read": {
++ "origin_price_per_m": 2000,
++ "price_per_m": 2000,
++ "origin_price_per_m_decimal": "0.2",
++ "price_per_m_decimal": "0.2"
++ },
++ "input_cache_write": {
++ "origin_price_per_m": 20000,
++ "price_per_m": 20000,
++ "origin_price_per_m_decimal": "2",
++ "price_per_m_decimal": "2"
++ },
++ "input_cache_write_1h": {
++ "origin_price_per_m": 40000,
++ "price_per_m": 40000,
++ "origin_price_per_m_decimal": "4",
++ "price_per_m_decimal": "4"
++ }
++ },
++ "is_tiered_billing": false,
++ "title": "moonshotai/kimi-k3.1-pro",
++ "description": "Kimi K3.1 Pro is Moonshot AI's flagship open-weight multimodal reasoning model and a post-trained successor to Kimi K3, with a 1M-token context window. It is built for long-horizon coding, 3D creation, financial research, and end-to-end agentic knowledge work, with native image and video understanding.",
++ "tags": [],
++ "context_size": 1048576,
++ "status": 1,
++ "display_name": "Kimi K3.1 Pro",
++ "model_type": "chat",
++ "max_output_tokens": 1048576,
++ "features": [
++ "serverless",
++ "function-calling",
++ "structured-outputs",
++ "reasoning"
++ ],
++ "endpoints": [
++ "chat/completions",
++ "anthropic",
++ "responses"
++ ],
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ]
++ },
++ {
++ "created": 1790230295,
++ "id": "xiaomimimo/mimo-v2.6-flash",
++ "object": "model",
++ "owned_by": "novita",
++ "permission": null,
++ "root": "",
++ "parent": "",
++ "input_token_price_per_m": 1400,
++ "output_token_price_per_m": 2800,
++ "pricing": {
++ "prompt": {
++ "origin_price_per_m": 1400,
++ "price_per_m": 1400,
++ "origin_price_per_m_decimal": "0.14",
++ "price_per_m_decimal": "0.14"
++ },
++ "completion": {
++ "origin_price_per_m": 2800,
++ "price_per_m": 2800,
++ "origin_price_per_m_decimal": "0.28",
++ "price_per_m_decimal": "0.28"
++ },
++ "input_cache_read": {
++ "origin_price_per_m": 28,
++ "price_per_m": 28,
++ "origin_price_per_m_decimal": "0.0028",
++ "price_per_m_decimal": "0.0028"
++ }
++ },
++ "is_tiered_billing": false,
++ "title": "xiaomimimo/mimo-v2.6-flash",
++ "description": "MiMo V2.6 Flash is Xiaomi's cost-efficient open-source reasoning model for high-frequency calls and large-scale professional workflows. It combines native text, image, video, and audio understanding with a 1M-token context window and up to 128K output tokens, while supporting controllable deep thinking, tool calling, JSON mode, streaming, and prompt caching.",
++ "tags": [],
++ "context_size": 1048576,
++ "status": 1,
++ "display_name": "MiMo V2.6 Flash",
++ "model_type": "chat",
++ "max_output_tokens": 131072,
++ "features": [
++ "serverless",
++ "function-calling",
++ "structured-outputs",
++ "reasoning"
++ ],
++ "endpoints": [
++ "chat/completions",
++ "anthropic",
++ "responses"
++ ],
++ "input_modalities": [
++ "text",
++ "image",
++ "video",
++ "audio"
++ ],
++ "output_modalities": [
++ "text"
++ ]
++ },
++ {
++ "created": 1787105676,
++ "id": "zai-org/glm-5.3",
++ "object": "model",
++ "owned_by": "novita",
++ "permission": null,
++ "root": "",
++ "parent": "",
++ "input_token_price_per_m": 14000,
++ "output_token_price_per_m": 44000,
++ "pricing": {
++ "prompt": {
++ "origin_price_per_m": 14000,
++ "price_per_m": 14000,
++ "origin_price_per_m_decimal": "1.4",
++ "price_per_m_decimal": "1.4"
++ },
++ "completion": {
++ "origin_price_per_m": 44000,
++ "price_per_m": 44000,
++ "origin_price_per_m_decimal": "4.4",
++ "price_per_m_decimal": "4.4"
++ },
++ "input_cache_read": {
++ "origin_price_per_m": 2600,
++ "price_per_m": 2600,
++ "origin_price_per_m_decimal": "0.26",
++ "price_per_m_decimal": "0.26"
++ }
++ },
++ "is_tiered_billing": false,
++ "title": "zai-org/glm-5.3",
++ "description": "GLM-5.3 is a large-scale reasoning model from Z.ai, built for complex software engineering and long-horizon agent tasks. It supports text input and output with a 1M-token context window, and improves on GLM-5.2 in coding and in the balance between performance and token efficiency.\nReasoning is always on and cannot be disabled. Reasoning efforts low, high, and max are supported; max is the default.",
++ "tags": [],
++ "context_size": 1048576,
++ "status": 1,
++ "display_name": "GLM 5.3",
++ "model_type": "chat",
++ "max_output_tokens": 131072,
++ "features": [
++ "function-calling",
++ "structured-outputs",
++ "reasoning",
++ "serverless"
++ ],
++ "endpoints": [
++ "chat/completions",
++ "anthropic",
++ "responses"
++ ],
++ "input_modalities": [
++ "text"
++ ],
++ "output_modalities": [
++ "text"
++ ]
++ },
++ {
++ "created": 1785494178,
++ "id": "qwen/qwen3.8-max",
++ "object": "model",
++ "owned_by": "novita",
++ "permission": null,
++ "root": "",
++ "parent": "",
++ "input_token_price_per_m": 20000,
++ "output_token_price_per_m": 60000,
++ "pricing": {
++ "prompt": {
++ "origin_price_per_m": 20000,
++ "price_per_m": 20000,
++ "origin_price_per_m_decimal": "2",
++ "price_per_m_decimal": "2"
++ },
++ "completion": {
++ "origin_price_per_m": 60000,
++ "price_per_m": 60000,
++ "origin_price_per_m_decimal": "6",
++ "price_per_m_decimal": "6"
++ },
++ "input_cache_read": {
++ "origin_price_per_m": 2500,
++ "price_per_m": 2500,
++ "origin_price_per_m_decimal": "0.25",
++ "price_per_m_decimal": "0.25"
++ },
++ "input_cache_write": {
++ "origin_price_per_m": 25000,
++ "price_per_m": 25000,
++ "origin_price_per_m_decimal": "2.5",
++ "price_per_m_decimal": "2.5"
++ }
++ },
++ "is_tiered_billing": false,
++ "title": "qwen/qwen3.8-max",
++ "description": " A 2.4T-parameter MoE flagship for coding and knowledge work. Programs autonomously for days to deliver complete projects end to end, with native visual understanding across planning, execution, and verification, plus deep semantic analysis of ultra-long documents and video.",
++ "tags": [],
++ "context_size": 1000000,
++ "status": 1,
++ "display_name": "Qwen3.8 Max",
++ "model_type": "chat",
++ "max_output_tokens": 131072,
++ "features": [
++ "serverless",
++ "reasoning",
++ "structured-outputs",
++ "function-calling"
++ ],
++ "endpoints": [
++ "chat/completions",
++ "anthropic",
++ "responses"
++ ],
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ]
++ }
++ ]
++}
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/openrouter.json b/test/fixtures/pricebook/openrouter.json
nnew file mode 100644
iindex 0000000..c5cec35
---- /dev/null
++++ b/test/fixtures/pricebook/openrouter.json
@@@ -0,0 +1,2005 @@
++{
++ "data": [
++ {
++ "id": "moonshotai/kimi-k3.1-pro",
++ "canonical_slug": "moonshotai/kimi-k3.1-pro-20261023",
++ "hugging_face_id": "moonshotai/Kimi-K3.1-Pro",
++ "name": "MoonshotAI: Kimi K3.1 Pro",
++ "created": 1792778188,
++ "description": "Kimi K3.1 Pro is Moonshot AI's flagship open-weight multimodal reasoning model and a post-trained successor to Kimi K3, with a 1M-token context window. It is built for long-horizon coding, 3D...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text+image+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.000002",
++ "completion": "0.00001",
++ "input_cache_read": "0.0000002"
++ },
++ "top_provider": {
++ "context_length": 1048576,
++ "max_completion_tokens": 943718,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "top_p": 0.95
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/moonshotai/kimi-k3.1-pro-20261023/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "agents",
++ "category": "androidnative",
++ "elo": 1171,
++ "win_rate": 43.7,
++ "rank": 22
++ },
++ {
++ "arena": "agents",
++ "category": "codetovideo",
++ "elo": 1263,
++ "win_rate": 59.8,
++ "rank": 4
++ },
++ {
++ "arena": "agents",
++ "category": "fullstack",
++ "elo": 1323,
++ "win_rate": 61.8,
++ "rank": 1
++ },
++ {
++ "arena": "agents",
++ "category": "mobileapps",
++ "elo": 1277,
++ "win_rate": 59.8,
++ "rank": 3
++ },
++ {
++ "arena": "agents",
++ "category": "pdf",
++ "elo": 1249,
++ "win_rate": 58.2,
++ "rank": 8
++ },
++ {
++ "arena": "agents",
++ "category": "python-pptxslides",
++ "elo": 1205,
++ "win_rate": 58.5,
++ "rank": 10
++ },
++ {
++ "arena": "agents",
++ "category": "webapps",
++ "elo": 1300,
++ "win_rate": 58.6,
++ "rank": 3
++ },
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1445,
++ "win_rate": 71.7,
++ "rank": 2
++ },
++ {
++ "arena": "models",
++ "category": "asciiart",
++ "elo": 1302,
++ "win_rate": 59.5,
++ "rank": 5
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1354,
++ "win_rate": 61,
++ "rank": 4
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1306,
++ "win_rate": 56.5,
++ "rank": 12
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1409,
++ "win_rate": 66.8,
++ "rank": 4
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1327,
++ "win_rate": 62.2,
++ "rank": 2
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1395,
++ "win_rate": 66,
++ "rank": 1
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1324,
++ "win_rate": 57.9,
++ "rank": 5
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": 46.5,
++ "coding_index": null,
++ "agentic_index": null
++ }
++ },
++ "reasoning": {
++ "mandatory": false,
++ "default_enabled": true,
++ "supported_efforts": [
++ "max",
++ "high",
++ "low",
++ "none"
++ ],
++ "default_effort": "max"
++ }
++ },
++ {
++ "id": "minimax/minimax-m3.1-flash",
++ "canonical_slug": "minimax/minimax-m3.1-flash-20261019",
++ "hugging_face_id": "MiniMaxAI/MiniMax-M3.1-Flash",
++ "name": "MiniMax: MiniMax M3.1 Flash",
++ "created": 1792379569,
++ "description": "MiniMax-M3.1-Flash is a multimodal model from MiniMax. Compared with M3, it delivers significant improvements across multiple benchmarks, particularly in complex agentic tasks, coding, and cybersecurity. It is built for complex,...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text+image+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.000000075",
++ "completion": "0.0000003",
++ "input_cache_read": "0.0000000075",
++ "overrides": [
++ {
++ "utc_start": 1500,
++ "utc_end": 100,
++ "prompt": "0.000000075",
++ "completion": "0.0000003",
++ "input_cache_read": "0.0000000075"
++ },
++ {
++ "utc_start": 100,
++ "utc_end": 1500,
++ "prompt": "0.00000015",
++ "completion": "0.0000006",
++ "input_cache_read": "0.000000015"
++ }
++ ]
++ },
++ "top_provider": {
++ "context_length": 1048576,
++ "max_completion_tokens": 524288,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {},
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/minimax/minimax-m3.1-flash-20261019/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [],
++ "artificial_analysis": {
++ "intelligence_index": 29.1,
++ "coding_index": null,
++ "agentic_index": null
++ }
++ },
++ "reasoning": {
++ "mandatory": true,
++ "supported_efforts": [
++ "max",
++ "xhigh",
++ "high",
++ "medium",
++ "low"
++ ],
++ "default_effort": "max"
++ }
++ },
++ {
++ "id": "deepseek/deepseek-v4.1-flash-1017",
++ "canonical_slug": "deepseek/deepseek-v4.1-flash-20261017",
++ "hugging_face_id": "deepseek-ai/DeepSeek-V4.1-Flash-1017",
++ "name": "DeepSeek: DeepSeek V4.1 Flash 1017",
++ "created": 1792225624,
++ "description": "DeepSeek V4.1 Flash 1017 is the October 17, 2026 snapshot of DeepSeek V4.1 Flash, served by DeepSeek under the `deepseek-flash` API model name. V4.1 Flash is a sparse mixture-of-experts model...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text+image->text",
++ "input_modalities": [
++ "text",
++ "image"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "DeepSeek",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.00000015",
++ "completion": "0.0000006",
++ "input_cache_read": "0.000000003",
++ "overrides": [
++ {
++ "utc_days": [
++ "saturday",
++ "sunday"
++ ],
++ "prompt": "0.00000015",
++ "completion": "0.0000006",
++ "input_cache_read": "0.000000003"
++ },
++ {
++ "utc_days": [
++ "monday",
++ "tuesday",
++ "wednesday",
++ "thursday",
++ "friday"
++ ],
++ "utc_start": 0,
++ "utc_end": 100,
++ "prompt": "0.00000015",
++ "completion": "0.0000006",
++ "input_cache_read": "0.000000003"
++ },
++ {
++ "utc_days": [
++ "monday",
++ "tuesday",
++ "wednesday",
++ "thursday",
++ "friday"
++ ],
++ "utc_start": 100,
++ "utc_end": 400,
++ "prompt": "0.0000003",
++ "completion": "0.0000012",
++ "input_cache_read": "0.000000006"
++ },
++ {
++ "utc_days": [
++ "monday",
++ "tuesday",
++ "wednesday",
++ "thursday",
++ "friday"
++ ],
++ "utc_start": 400,
++ "utc_end": 600,
++ "prompt": "0.00000015",
++ "completion": "0.0000006",
++ "input_cache_read": "0.000000003"
++ },
++ {
++ "utc_days": [
++ "monday",
++ "tuesday",
++ "wednesday",
++ "thursday",
++ "friday"
++ ],
++ "utc_start": 600,
++ "utc_end": 1000,
++ "prompt": "0.0000003",
++ "completion": "0.0000012",
++ "input_cache_read": "0.000000006"
++ },
++ {
++ "utc_days": [
++ "monday",
++ "tuesday",
++ "wednesday",
++ "thursday",
++ "friday"
++ ],
++ "utc_start": 1000,
++ "utc_end": 0,
++ "prompt": "0.00000015",
++ "completion": "0.0000006",
++ "input_cache_read": "0.000000003"
++ }
++ ]
++ },
++ "top_provider": {
++ "context_length": 1048576,
++ "max_completion_tokens": 393216,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_completion_tokens",
++ "max_tokens",
++ "min_p",
++ "parallel_tool_calls",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {},
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/deepseek/deepseek-v4.1-flash-20261017/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "agents",
++ "category": "spreadsheet",
++ "elo": 1282,
++ "win_rate": 51.2,
++ "rank": 7
++ },
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1356,
++ "win_rate": 47.3,
++ "rank": 10
++ },
++ {
++ "arena": "models",
++ "category": "asciiart",
++ "elo": 1227,
++ "win_rate": 48.3,
++ "rank": 21
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1289,
++ "win_rate": 48.5,
++ "rank": 19
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1267,
++ "win_rate": 50.4,
++ "rank": 27
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1260,
++ "win_rate": 43.7,
++ "rank": 32
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1252,
++ "win_rate": 52.4,
++ "rank": 16
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1303,
++ "win_rate": 50.7,
++ "rank": 16
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1295,
++ "win_rate": 51.7,
++ "rank": 17
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": 28.6,
++ "coding_index": null,
++ "agentic_index": null
++ }
++ },
++ "reasoning": {
++ "mandatory": false,
++ "default_enabled": true,
++ "supported_efforts": [
++ "max",
++ "high",
++ "low",
++ "none"
++ ],
++ "default_effort": "high"
++ }
++ },
++ {
++ "id": "xiaomi/mimo-v2.6-flash",
++ "canonical_slug": "xiaomi/mimo-v2.6-flash-20260921",
++ "hugging_face_id": "XiaomiMiMo/MiMo-V2.6-Flash-RL",
++ "name": "Xiaomi: MiMo-V2.6-Flash",
++ "created": 1790021264,
++ "description": "MiMo-V2.6-Flash is an open-source foundation model developed by Xiaomi. Built on a Mixture-of-Experts architecture with 309B total parameters and 15B activated per token, it employs a hybrid attention mechanism for...",
++ "context_length": 1050000,
++ "architecture": {
++ "modality": "text+image+audio+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video",
++ "audio"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.00000014",
++ "completion": "0.00000028",
++ "input_cache_read": "0.0000000028"
++ },
++ "top_provider": {
++ "context_length": 1048576,
++ "max_completion_tokens": 131072,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "parallel_tool_calls",
++ "presence_penalty",
++ "reasoning",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": 1,
++ "top_p": 0.95,
++ "frequency_penalty": 0
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/xiaomi/mimo-v2.6-flash-20260921/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [],
++ "artificial_analysis": {
++ "intelligence_index": 26.7,
++ "coding_index": null,
++ "agentic_index": null
++ }
++ },
++ "reasoning": {
++ "mandatory": false
++ }
++ },
++ {
++ "id": "z-ai/glm-5.3-flashx",
++ "canonical_slug": "z-ai/glm-5.3-flashx-20260918",
++ "hugging_face_id": "",
++ "name": "Z.ai: GLM 5.3 FlashX",
++ "created": 1789744020,
++ "description": "GLM-5.3-FlashX is the high-speed variant of Z.ai's GLM-5.3-Flash, a native multimodal model delivering inference speeds of up to 200 tokens/s. Built on the same hybrid sparse and linear attention architecture...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text+image+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.00000037",
++ "completion": "0.00000125",
++ "input_cache_read": "0.000000075"
++ },
++ "top_provider": {
++ "context_length": 1048576,
++ "max_completion_tokens": 131072,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "include_reasoning",
++ "max_tokens",
++ "reasoning",
++ "reasoning_effort",
++ "response_format",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": 1,
++ "top_p": 0.95
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/z-ai/glm-5.3-flashx-20260918/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "reasoning": {
++ "mandatory": true,
++ "default_enabled": true,
++ "supported_efforts": [
++ "max",
++ "high",
++ "low"
++ ],
++ "default_effort": "max"
++ }
++ },
++ {
++ "id": "qwen/qwen3.8-flash",
++ "canonical_slug": "qwen/qwen3.8-flash-20260826",
++ "hugging_face_id": "Qwen/Qwen3.8-Flash-Next",
++ "name": "Qwen: Qwen3.8 Flash",
++ "created": 1787773060,
++ "description": "Qwen3.8 Flash is a multimodal reasoning model from Alibaba. It is suited for coding assistance, agentic workflows, visual understanding, document and codebase analysis, desktop interaction, chart analysis, and long-video analysis.",
++ "context_length": 1000000,
++ "architecture": {
++ "modality": "text+image+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Qwen",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.00000015",
++ "completion": "0.00000047",
++ "input_cache_read": "0.000000016"
++ },
++ "top_provider": {
++ "context_length": 1000000,
++ "max_completion_tokens": 131072,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logprobs",
++ "max_tokens",
++ "presence_penalty",
++ "reasoning",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {},
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/qwen/qwen3.8-flash-20260826/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "reasoning": {
++ "mandatory": false,
++ "default_enabled": true,
++ "supports_max_tokens": true
++ }
++ },
++ {
++ "id": "z-ai/glm-5.3-flash",
++ "canonical_slug": "z-ai/glm-5.3-flash-20260826",
++ "hugging_face_id": "zai-org/GLM-5.3-Flash",
++ "name": "Z.ai: GLM 5.3 Flash",
++ "created": 1787752741,
++ "description": "GLM-5.3-Flash is a native multimodal model from Z.ai. It is suited for efficient coding and long-horizon agent tasks. Its hybrid sparse and linear attention architecture maintains accurate long-context behavior while...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text+image+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.00000015",
++ "completion": "0.0000005",
++ "input_cache_read": "0.00000003"
++ },
++ "top_provider": {
++ "context_length": 1048575,
++ "max_completion_tokens": 943717,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "parallel_tool_calls",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": 1,
++ "top_p": 0.95
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/z-ai/glm-5.3-flash-20260826/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1310,
++ "win_rate": 56,
++ "rank": 18
++ },
++ {
++ "arena": "models",
++ "category": "asciiart",
++ "elo": 1273,
++ "win_rate": 54.7,
++ "rank": 12
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1277,
++ "win_rate": 49.1,
++ "rank": 25
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1261,
++ "win_rate": 50.3,
++ "rank": 32
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1273,
++ "win_rate": 46.8,
++ "rank": 24
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1275,
++ "win_rate": 54.5,
++ "rank": 14
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1303,
++ "win_rate": 52.5,
++ "rank": 17
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1269,
++ "win_rate": 48.4,
++ "rank": 29
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": 27.8,
++ "coding_index": 71.5,
++ "agentic_index": 50.9
++ }
++ },
++ "reasoning": {
++ "mandatory": true,
++ "default_enabled": true,
++ "supported_efforts": [
++ "max",
++ "high",
++ "low"
++ ],
++ "default_effort": "max"
++ }
++ },
++ {
++ "id": "z-ai/glm-5.3",
++ "canonical_slug": "z-ai/glm-5.3-20260816",
++ "hugging_face_id": "zai-org/GLM-5.3",
++ "name": "Z.ai: GLM 5.3",
++ "created": 1787086655,
++ "description": "GLM-5.3 is a large-scale reasoning model from Z.ai, built for complex software engineering and long-horizon agent tasks. It supports text input and output with a 1M-token context window, and improves...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text->text",
++ "input_modalities": [
++ "text"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.0000000699",
++ "completion": "0.0000014",
++ "input_cache_read": "0.0000000689"
++ },
++ "top_provider": {
++ "context_length": 1048576,
++ "max_completion_tokens": 943718,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "parallel_tool_calls",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": 1,
++ "top_p": 0.95
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/z-ai/glm-5.3-20260816/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1318,
++ "win_rate": 58,
++ "rank": 16
++ },
++ {
++ "arena": "models",
++ "category": "asciiart",
++ "elo": 1208,
++ "win_rate": 47.6,
++ "rank": 23
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1294,
++ "win_rate": 53.1,
++ "rank": 17
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1259,
++ "win_rate": 51.5,
++ "rank": 33
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1299,
++ "win_rate": 51.3,
++ "rank": 18
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1261,
++ "win_rate": 52.6,
++ "rank": 15
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1293,
++ "win_rate": 53.5,
++ "rank": 21
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1288,
++ "win_rate": 53.3,
++ "rank": 21
++ },
++ {
++ "arena": "agents",
++ "category": "htmlslides",
++ "elo": 1189,
++ "win_rate": 39.1,
++ "rank": 2
++ },
++ {
++ "arena": "agents",
++ "category": "mobileapps",
++ "elo": 1190,
++ "win_rate": 52.2,
++ "rank": 22
++ },
++ {
++ "arena": "agents",
++ "category": "python-pptxslides",
++ "elo": 1214,
++ "win_rate": 51.2,
++ "rank": 9
++ },
++ {
++ "arena": "agents",
++ "category": "webapps",
++ "elo": 1273,
++ "win_rate": 54.2,
++ "rank": 9
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": 31.7,
++ "coding_index": 74.8,
++ "agentic_index": 53.1
++ }
++ },
++ "reasoning": {
++ "mandatory": true,
++ "default_enabled": true,
++ "supported_efforts": [
++ "max",
++ "high",
++ "low"
++ ],
++ "default_effort": "max"
++ }
++ },
++ {
++ "id": "moonshotai/kimi-k3",
++ "canonical_slug": "moonshotai/kimi-k3-20260715",
++ "hugging_face_id": "moonshotai/Kimi-K3",
++ "name": "MoonshotAI: Kimi K3",
++ "created": 1784215858,
++ "description": "Kimi K3 is a 2.8T parameter open-weight multimodal reasoning model from Moonshot AI. It is suited for complex coding, knowledge work, and long-horizon agentic workflows, and is particularly strong at...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text+image+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.000000192",
++ "completion": "0.000016",
++ "input_cache_read": "0.000000182"
++ },
++ "top_provider": {
++ "context_length": 1048576,
++ "max_completion_tokens": 943718,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_completion_tokens",
++ "max_tokens",
++ "min_p",
++ "parallel_tool_calls",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": null,
++ "top_p": 0.95,
++ "top_k": null,
++ "frequency_penalty": null,
++ "presence_penalty": null,
++ "repetition_penalty": null
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/moonshotai/kimi-k3-20260715/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "agents",
++ "category": "agenticgamedev",
++ "elo": 1202,
++ "win_rate": 53.8,
++ "rank": 4
++ },
++ {
++ "arena": "agents",
++ "category": "androidnative",
++ "elo": 1255,
++ "win_rate": 56.7,
++ "rank": 6
++ },
++ {
++ "arena": "agents",
++ "category": "blender3d",
++ "elo": 1168,
++ "win_rate": 41.8,
++ "rank": 8
++ },
++ {
++ "arena": "agents",
++ "category": "codetovideo",
++ "elo": 1271,
++ "win_rate": 60.9,
++ "rank": 3
++ },
++ {
++ "arena": "agents",
++ "category": "fullstack",
++ "elo": 1307,
++ "win_rate": 61.5,
++ "rank": 3
++ },
++ {
++ "arena": "agents",
++ "category": "godotgamedev",
++ "elo": 1224,
++ "win_rate": 48.4,
++ "rank": 14
++ },
++ {
++ "arena": "agents",
++ "category": "htmlslides",
++ "elo": 1153,
++ "win_rate": 55.6,
++ "rank": 5
++ },
++ {
++ "arena": "agents",
++ "category": "mobileapps",
++ "elo": 1263,
++ "win_rate": 57,
++ "rank": 5
++ },
++ {
++ "arena": "agents",
++ "category": "pdf",
++ "elo": 1301,
++ "win_rate": 66.3,
++ "rank": 2
++ },
++ {
++ "arena": "agents",
++ "category": "python-pptxslides",
++ "elo": 1219,
++ "win_rate": 54.8,
++ "rank": 6
++ },
++ {
++ "arena": "agents",
++ "category": "spreadsheet",
++ "elo": 1105,
++ "win_rate": 31.1,
++ "rank": 20
++ },
++ {
++ "arena": "agents",
++ "category": "webapps",
++ "elo": 1281,
++ "win_rate": 58.8,
++ "rank": 7
++ },
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1377,
++ "win_rate": 64.1,
++ "rank": 8
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1351,
++ "win_rate": 60,
++ "rank": 5
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1350,
++ "win_rate": 63.1,
++ "rank": 4
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1351,
++ "win_rate": 54.6,
++ "rank": 8
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1305,
++ "win_rate": 60,
++ "rank": 7
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1339,
++ "win_rate": 60.1,
++ "rank": 8
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1333,
++ "win_rate": 58.6,
++ "rank": 3
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": 17.4,
++ "coding_index": 72,
++ "agentic_index": 29.3
++ }
++ },
++ "reasoning": {
++ "mandatory": false,
++ "default_enabled": true,
++ "supported_efforts": [
++ "max",
++ "high",
++ "low",
++ "none"
++ ],
++ "default_effort": "max"
++ }
++ },
++ {
++ "id": "minimax/minimax-m3",
++ "canonical_slug": "minimax/minimax-m3-20260531",
++ "hugging_face_id": "MiniMaxAI/Minimax-M3",
++ "name": "MiniMax: MiniMax M3",
++ "created": 1780245374,
++ "description": "MiniMax-M3 is a multimodal foundation model from MiniMax. It supports text, image, and video inputs with text output, a 1M-token context window, and is suited for long-horizon agentic work, coding,...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text+image+video->text",
++ "input_modalities": [
++ "text",
++ "image",
++ "video"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.0000003",
++ "completion": "0.0000012",
++ "input_cache_read": "0.00000006"
++ },
++ "top_provider": {
++ "context_length": 524288,
++ "max_completion_tokens": 512000,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "presence_penalty",
++ "reasoning",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": 1,
++ "top_p": 0.95,
++ "top_k": null,
++ "frequency_penalty": null,
++ "presence_penalty": null,
++ "repetition_penalty": null
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/minimax/minimax-m3-20260531/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "agents",
++ "category": "agenticgamedev",
++ "elo": 1114,
++ "win_rate": 44.9,
++ "rank": 18
++ },
++ {
++ "arena": "agents",
++ "category": "androidnative",
++ "elo": 1158,
++ "win_rate": 44.5,
++ "rank": 25
++ },
++ {
++ "arena": "agents",
++ "category": "blender3d",
++ "elo": 1092,
++ "win_rate": 43.3,
++ "rank": 15
++ },
++ {
++ "arena": "agents",
++ "category": "codetovideo",
++ "elo": 1230,
++ "win_rate": 57.3,
++ "rank": 6
++ },
++ {
++ "arena": "agents",
++ "category": "fullstack",
++ "elo": 1191,
++ "win_rate": 47.2,
++ "rank": 19
++ },
++ {
++ "arena": "agents",
++ "category": "htmlslides",
++ "elo": 1076,
++ "win_rate": 46.7,
++ "rank": 14
++ },
++ {
++ "arena": "agents",
++ "category": "mobileapps",
++ "elo": 1173,
++ "win_rate": 46.4,
++ "rank": 27
++ },
++ {
++ "arena": "agents",
++ "category": "pdf",
++ "elo": 1280,
++ "win_rate": 62,
++ "rank": 4
++ },
++ {
++ "arena": "agents",
++ "category": "python-pptxslides",
++ "elo": 1163,
++ "win_rate": 47.5,
++ "rank": 17
++ },
++ {
++ "arena": "agents",
++ "category": "spreadsheet",
++ "elo": 1115,
++ "win_rate": 49.2,
++ "rank": 19
++ },
++ {
++ "arena": "agents",
++ "category": "webapps",
++ "elo": 1193,
++ "win_rate": 47.5,
++ "rank": 22
++ },
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1202,
++ "win_rate": 50,
++ "rank": 50
++ },
++ {
++ "arena": "models",
++ "category": "asciiart",
++ "elo": 1154,
++ "win_rate": 45.9,
++ "rank": 43
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1243,
++ "win_rate": 50.5,
++ "rank": 44
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1234,
++ "win_rate": 51.1,
++ "rank": 45
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1208,
++ "win_rate": 45.4,
++ "rank": 51
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1158,
++ "win_rate": 44.7,
++ "rank": 46
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1241,
++ "win_rate": 50.5,
++ "rank": 45
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1254,
++ "win_rate": 51.5,
++ "rank": 42
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": 18.9,
++ "coding_index": 58.6,
++ "agentic_index": 29.5
++ }
++ },
++ "reasoning": {
++ "mandatory": false
++ }
++ },
++ {
++ "id": "deepseek/deepseek-v4-flash",
++ "canonical_slug": "deepseek/deepseek-v4-flash-20260423",
++ "hugging_face_id": "deepseek-ai/DeepSeek-V4-Flash",
++ "name": "DeepSeek: DeepSeek V4 Flash 0423",
++ "created": 1777000666,
++ "description": "DeepSeek V4 Flash is an efficiency-optimized Mixture-of-Experts model from DeepSeek with 284B total parameters and 13B activated parameters, supporting a 1M-token context window. It is designed for fast inference and...",
++ "context_length": 1048576,
++ "architecture": {
++ "modality": "text->text",
++ "input_modalities": [
++ "text"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "DeepSeek",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.000000091",
++ "completion": "0.000000182",
++ "input_cache_read": "0.0000000182"
++ },
++ "top_provider": {
++ "context_length": 1048575,
++ "max_completion_tokens": 943717,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_completion_tokens",
++ "max_tokens",
++ "min_p",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_a",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {},
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/deepseek/deepseek-v4-flash-20260423/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1182,
++ "win_rate": 49.3,
++ "rank": 58
++ },
++ {
++ "arena": "models",
++ "category": "asciiart",
++ "elo": 1107,
++ "win_rate": 42.7,
++ "rank": 61
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1202,
++ "win_rate": 48.9,
++ "rank": 63
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1127,
++ "win_rate": 40.7,
++ "rank": 94
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1194,
++ "win_rate": 50.2,
++ "rank": 59
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1152,
++ "win_rate": 48.4,
++ "rank": 47
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1159,
++ "win_rate": 44.7,
++ "rank": 77
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1206,
++ "win_rate": 49.1,
++ "rank": 64
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": null,
++ "coding_index": 56.2,
++ "agentic_index": 22.2
++ }
++ },
++ "reasoning": {
++ "mandatory": false,
++ "supported_efforts": [
++ "xhigh",
++ "high",
++ "none"
++ ],
++ "default_effort": "high"
++ }
++ },
++ {
++ "id": "z-ai/glm-4.7-flash",
++ "canonical_slug": "z-ai/glm-4.7-flash-20260119",
++ "hugging_face_id": "zai-org/GLM-4.7-Flash",
++ "name": "Z.ai: GLM 4.7 Flash",
++ "created": 1768833913,
++ "description": "As a 30B-class SOTA model, GLM-4.7-Flash offers a new option that balances performance and efficiency. It is further optimized for agentic coding use cases, strengthening coding capabilities, long-horizon task planning,...",
++ "context_length": 200000,
++ "architecture": {
++ "modality": "text->text",
++ "input_modalities": [
++ "text"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Other",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.0000000605",
++ "completion": "0.0000004"
++ },
++ "top_provider": {
++ "context_length": 131072,
++ "max_completion_tokens": 117964,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "presence_penalty",
++ "reasoning",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": 1,
++ "top_p": 0.95,
++ "frequency_penalty": null
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": null,
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/z-ai/glm-4.7-flash-20260119/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 1114,
++ "win_rate": 51.2,
++ "rank": 87
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 1174,
++ "win_rate": 53.1,
++ "rank": 71
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 1123,
++ "win_rate": 45.3,
++ "rank": 98
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 1132,
++ "win_rate": 49.7,
++ "rank": 84
++ },
++ {
++ "arena": "models",
++ "category": "svg",
++ "elo": 1034,
++ "win_rate": 44.2,
++ "rank": 82
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 1202,
++ "win_rate": 57.6,
++ "rank": 61
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 1192,
++ "win_rate": 54,
++ "rank": 70
++ }
++ ]
++ },
++ "reasoning": {
++ "mandatory": false,
++ "default_enabled": true
++ }
++ },
++ {
++ "id": "openai/gpt-oss-120b",
++ "canonical_slug": "openai/gpt-oss-120b",
++ "hugging_face_id": "openai/gpt-oss-120b",
++ "name": "OpenAI: gpt-oss-120b",
++ "created": 1754414231,
++ "description": "gpt-oss-120b is an open-weight, 117B-parameter Mixture-of-Experts (MoE) language model from OpenAI designed for high-reasoning, agentic, and general-purpose production use cases. It activates 5.1B parameters per forward pass and is optimized...",
++ "context_length": 131072,
++ "architecture": {
++ "modality": "text->text",
++ "input_modalities": [
++ "text"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "GPT",
++ "instruct_type": null
++ },
++ "pricing": {
++ "prompt": "0.000000012",
++ "completion": "0.000000045",
++ "input_cache_read": "0.000000011"
++ },
++ "top_provider": {
++ "context_length": 131072,
++ "max_completion_tokens": 65536,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "presence_penalty",
++ "reasoning",
++ "reasoning_effort",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_a",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {
++ "temperature": null,
++ "top_p": null,
++ "frequency_penalty": null
++ },
++ "supported_voices": null,
++ "knowledge_cutoff": "2024-06-30",
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/openai/gpt-oss-120b/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [
++ {
++ "arena": "models",
++ "category": "3d",
++ "elo": 895,
++ "win_rate": 29.4,
++ "rank": 124
++ },
++ {
++ "arena": "models",
++ "category": "codecategories",
++ "elo": 959,
++ "win_rate": 33.4,
++ "rank": 135
++ },
++ {
++ "arena": "models",
++ "category": "dataviz",
++ "elo": 987,
++ "win_rate": 43.6,
++ "rank": 120
++ },
++ {
++ "arena": "models",
++ "category": "gamedev",
++ "elo": 989,
++ "win_rate": 40.5,
++ "rank": 124
++ },
++ {
++ "arena": "models",
++ "category": "uicomponent",
++ "elo": 921,
++ "win_rate": 35.7,
++ "rank": 129
++ },
++ {
++ "arena": "models",
++ "category": "website",
++ "elo": 966,
++ "win_rate": 32.5,
++ "rank": 138
++ }
++ ],
++ "artificial_analysis": {
++ "intelligence_index": null,
++ "coding_index": 30.4,
++ "agentic_index": 3.7
++ }
++ },
++ "reasoning": {
++ "mandatory": true,
++ "supported_efforts": [
++ "high",
++ "medium",
++ "low"
++ ],
++ "default_effort": "medium"
++ }
++ },
++ {
++ "id": "qwen/qwen3-14b",
++ "canonical_slug": "qwen/qwen3-14b-04-28",
++ "hugging_face_id": "Qwen/Qwen3-14B",
++ "name": "Qwen: Qwen3 14B",
++ "created": 1745876478,
++ "description": "Qwen3-14B is a dense 14.8B parameter causal language model from the Qwen3 series, designed for both complex reasoning and efficient dialogue. It supports seamless switching between a \"thinking\" mode for...",
++ "context_length": 40960,
++ "architecture": {
++ "modality": "text->text",
++ "input_modalities": [
++ "text"
++ ],
++ "output_modalities": [
++ "text"
++ ],
++ "tokenizer": "Qwen3",
++ "instruct_type": "qwen3"
++ },
++ "pricing": {
++ "prompt": "0.00000012",
++ "completion": "0.00000024"
++ },
++ "top_provider": {
++ "context_length": 40960,
++ "max_completion_tokens": 16384,
++ "is_moderated": false
++ },
++ "per_request_limits": null,
++ "supported_parameters": [
++ "frequency_penalty",
++ "include_reasoning",
++ "logit_bias",
++ "logprobs",
++ "max_tokens",
++ "min_p",
++ "presence_penalty",
++ "reasoning",
++ "repetition_penalty",
++ "response_format",
++ "seed",
++ "stop",
++ "structured_outputs",
++ "temperature",
++ "tool_choice",
++ "tools",
++ "top_k",
++ "top_logprobs",
++ "top_p"
++ ],
++ "default_parameters": {},
++ "supported_voices": null,
++ "knowledge_cutoff": "2025-03-31",
++ "expiration_date": null,
++ "links": {
++ "details": "/api/v1/models/qwen/qwen3-14b-04-28/endpoints"
++ },
++ "endpoints": [
++ "/api/v1/chat/completions",
++ "/api/v1/completions",
++ "/api/v1/messages",
++ "/api/v1/responses"
++ ],
++ "benchmarks": {
++ "design_arena": [],
++ "artificial_analysis": {
++ "intelligence_index": null,
++ "coding_index": 13.8,
++ "agentic_index": 0.9
++ }
++ },
++ "reasoning": {
++ "mandatory": false
++ }
++ }
++ ],
++ "total_count": 458,
++ "links": {
++ "next": null
++ }
++}
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/qwen.html b/test/fixtures/pricebook/qwen.html
nnew file mode 100644
iindex 0000000..9f7027b
---- /dev/null
++++ b/test/fixtures/pricebook/qwen.html
@@@ -0,0 +1,115 @@
++<tr><td style="vertical-align:top"><p>qwen3.8-max</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>International</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$2</p></td><td style="vertical-align:top"><p>$6</p></td><td style="vertical-align:top"><p>1 million tokens</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max-0902</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>International</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$2</p></td><td style="vertical-align:top"><p>$6</p></td><td style="vertical-align:top"><p>1 million tokens</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max</p><blockquote>
++<p>50% <a href="/help/en/model-studio/batch-interfaces-compatible-with-openai">batch inference</a> discount</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max-0902</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max-0902</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max-0902</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>US</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$2</p></td><td style="vertical-align:top"><p>$6</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max-0902</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-max-0902</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>Non-Thinking and Thinking modes</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$1.65</p></td><td style="vertical-align:top"><p>$4.951</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>International</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.15</p></td><td style="vertical-align:top"><p>$0.47</p></td><td style="vertical-align:top"><p>1 million tokens</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash</p><blockquote>
++<p>Currently equivalent to qwen3.7-flash-2026-07-15</p>
++</blockquote><blockquote>
++<p>50% <a href="/help/en/model-studio/batch-interfaces-compatible-with-openai">batch inference</a> discount</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td rowspan="3" style="vertical-align:top"><p>International</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.030</p></td><td style="vertical-align:top"><p>$0.130</p></td><td rowspan="3" style="vertical-align:top"><p>1 million tokens</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash-2026-07-15</p></td><td rowspan="3" style="vertical-align:top"><p>International</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.030</p></td><td style="vertical-align:top"><p>$0.130</p></td><td rowspan="3" style="vertical-align:top"><p>No free quota</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p>50% <a href="/help/en/model-studio/batch-interfaces-compatible-with-openai">batch inference</a> discount</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.113</p></td><td style="vertical-align:top"><p>$0.382</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash</p><blockquote>
++<p>Currently equivalent to qwen3.7-flash-2026-07-15</p>
++</blockquote><blockquote>
++<p>50% <a href="/help/en/model-studio/batch-interfaces-compatible-with-openai">batch inference</a> discount</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash-2026-07-15</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Hong Kong</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.15</p></td><td style="vertical-align:top"><p>$0.47</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.113</p></td><td style="vertical-align:top"><p>$0.382</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash</p><blockquote>
++<p>Currently equivalent to qwen3.7-flash-2026-07-15</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash-2026-07-15</p></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>EU</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.15</p></td><td style="vertical-align:top"><p>$0.47</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.113</p></td><td style="vertical-align:top"><p>$0.382</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash</p><blockquote>
++<p>Currently equivalent to qwen3.7-flash-2026-07-15</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash-2026-07-15</p></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.113</p></td><td style="vertical-align:top"><p>$0.382</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>US</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.15</p></td><td style="vertical-align:top"><p>$0.47</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash</p><blockquote>
++<p>Currently equivalent to qwen3.7-flash-2026-07-15</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash</p><blockquote>
++<p>Currently equivalent to qwen3.7-flash-2026-07-15</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td rowspan="3" style="vertical-align:top"><p>US</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.030</p></td><td style="vertical-align:top"><p>$0.130</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash-2026-07-15</p></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td style="vertical-align:top"><p>qwen3.8-flash</p><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤1M</p></td><td style="vertical-align:top"><p>$0.113</p></td><td style="vertical-align:top"><p>$0.382</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash</p><blockquote>
++<p>Currently equivalent to qwen3.7-flash-2026-07-15</p>
++</blockquote><blockquote>
++<p><a href="/help/en/model-studio/context-cache">context caching discount</a></p>
++</blockquote></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
++<tr><td rowspan="3" style="vertical-align:top"><p>qwen3.7-flash-2026-07-15</p></td><td rowspan="3" style="vertical-align:top"><p>Global</p></td><td style="vertical-align:top"><p>0&lt;Token≤32K</p></td><td style="vertical-align:top"><p>$0.028</p></td><td style="vertical-align:top"><p>$0.110</p></td></tr>
\\ No newline at end of file
ddiff --git a/test/fixtures/pricebook/refuseless.md b/test/fixtures/pricebook/refuseless.md
nnew file mode 100644
iindex 0000000..d8d1de6
---- /dev/null
++++ b/test/fixtures/pricebook/refuseless.md
@@@ -0,0 +1,90 @@
++> ## Documentation Index
++> Fetch the complete documentation index at: https://docs.refuseless.com/llms.txt
++> Use this file to discover all available pages before exploring further.
++
++# Models API
++
++> GET /v1/models and GET /v1/models/{model}.
++
++# Models API
++
++## List models
++
++```
++GET https://api.refuseless.com/v1/models
++```
++
++The list is public. Without a key it returns the public lineup. With a key it
++also includes any private models granted to that key's account.
++
++```json theme={null}
++{
++ "object": "list",
++ "data": [
++ { "id": "glm-5.3-abliterated", "object": "model", "created": 1700000000, "owned_by": "refuseless", "abliterated": true, "context_length": 1048576, "reasoning_efforts": ["low", "high", "max"], "reasoning_default": "low" },
++ { "id": "glm-5.3-flash-abliterated", "object": "model", "created": 1700000000, "owned_by": "refuseless", "abliterated": true, "context_length": 1048576, "reasoning_efforts": ["low", "high", "max"], "reasoning_default": "low" },
++ { "id": "deepseek-v4.1-flash-abliterated", "object": "model", "created": 1700000000, "owned_by": "refuseless", "abliterated": true, "context_length": 1048576, "reasoning_efforts": ["off", "low", "high", "max"], "reasoning_default": "low" },
++ { "id": "deepseek-v4.1-flash-fast", "object": "model", "created": 1700000000, "owned_by": "refuseless", "abliterated": false, "context_length": 1048576, "reasoning_efforts": ["off", "low", "high", "max"], "reasoning_default": "low" },
++ { "id": "minimax-m3.1-flash", "object": "model", "created": 1700000000, "owned_by": "refuseless", "abliterated": false, "context_length": 1048576, "reasoning_efforts": ["low", "high", "max"], "reasoning_default": "low" },
++ { "id": "deepseek-v4.1-flash-abliterated-free", "object": "model", "created": 1700000000, "owned_by": "refuseless", "abliterated": true, "context_length": 524288, "reasoning_efforts": ["off", "low", "high", "max"], "reasoning_default": "low" }
++ ]
++}
++```
++
++### Fields
++
++| Field | Type | Notes |
++| - | - | - |
++| `id` | string | The id to send as `model`. |
++| `object` | string | Always `"model"`. |
++| `created` | integer | Unix timestamp. |
++| `owned_by` | string | Always `"refuseless"`. |
++| `abliterated` | boolean | Always present. See below. |
++| `vision` | boolean | `true` when the model accepts image input. Sending an image to a model with `false` returns `400`. Omitted only by the local fallback list. |
++| `context_length` | integer | Context window in tokens. Omitted when the catalog has no value. |
++| `reasoning_efforts` | string\[] | Reasoning levels the model accepts, from `off`, `low`, `high`, `max`. Omitted when none are set. |
++| `reasoning_default` | string | The level used when the request does not set one. Omitted when none is set. |
++
++`context_length` uses the same field name as OpenRouter.
++
++`abliterated` is `true` when refusal behavior has been removed from the
++weights and `false` for standard weights (for example,
++`deepseek-v4.1-flash-fast` and `minimax-m3.1-flash`). Filter on this field
++rather than on the `-abliterated` suffix: ids are catalog names and may not
++follow that pattern.
++
++Sending a reasoning level that is not in the model's `reasoning_efforts`
++returns `400`. See [Reasoning](https://docs.refuseless.com/guides/reasoning.md).
++
++A live empty catalog is the catalog. The local fallback only applies when
++the billing service is unreachable.
++
++## Retrieve a model
++
++```
++GET https://api.refuseless.com/v1/models/{model}
++```
++
++```json theme={null}
++{
++ "id": "deepseek-v4.1-flash-abliterated",
++ "object": "model",
++ "created": 1700000000,
++ "owned_by": "refuseless",
++ "abliterated": true,
++ "context_length": 1048576,
++ "reasoning_efforts": ["off", "low", "high", "max"],
++ "reasoning_default": "low"
++}
++```
++
++Unknown id → `404`:
++
++```json theme={null}
++{
++ "error": { "code": "model_not_found", "message": "Unknown model '…'.", "type": "api_error" }
++}
++```
++
++
++This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.
\\ No newline at end of file
ddiff --git a/test/price-book.test.js b/test/price-book.test.js
nnew file mode 100644
iindex 0000000..405d31b
---- /dev/null
++++ b/test/price-book.test.js
@@@ -0,0 +1,270 @@
++'use strict';
++/**
++ * The living price book: the parsers, the egress guard, the weekly job and the owner's approval.
++ *
++ * No test reaches the internet: the providers' own listings are recorded (test/fixtures/pricebook/, written by
++ * scripts/record-price-book.js) and replayed through the transport seam. Two of them are played back with one
++ * price moved, so the two paths a price change can take are both seen: a small change the job applies at once and
++ * a big one that waits for the owner.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, request, suite, silent } = require('./helpers');
++const parsers = require('../server/pricebook/parsers');
++const { readListing } = require('../server/pricebook/read');
++const { createPriceBook } = require('../server/pricebook/job');
++const { SOURCES } = require('../server/pricebook/sources');
++
++const FIXTURES = path.join(__dirname, 'fixtures', 'pricebook');
++const FILE = {
++ deepseek: 'deepseek.md', qwen: 'qwen.html', kimi: 'kimi.html', glm: 'glm.md', minimax: 'minimax.md',
++ deepinfra: 'deepinfra.json', novita: 'novita.json', featherless: 'featherless.json',
++ refuseless: 'refuseless.md', abliteration: 'abliteration.md', openrouter: 'openrouter.json',
++};
++const read = (name) => fs.readFileSync(path.join(FIXTURES, name), 'utf8');
++const json = (name) => JSON.parse(read(name));
++
++/** The recorded listings, as the job's transport sees them. */
++const BY_URL = new Map(SOURCES.map((s) => [s.url, FILE[s.key]]));
++async function recorded({ url }) {
++ const file = BY_URL.get(url);
++ if (!file) return { status: 404, headers: {}, body: '' };
++ return { status: 200, headers: {}, body: read(file) };
++}
++
++/** The same listings with one price moved: GLM's input price jumps 43%, Qwen's input price rises 7%. */
++async function moved({ url }) {
++ const res = await recorded({ url });
++ if (res.status !== 200) return res;
++ let body = res.body;
++ if (url.includes('docs.z.ai')) body = body.replace(/(\| GLM-5\.3 \| )\\\$1\.4/, (m, head) => head + '\\$2.0');
++ if (url.includes('model-studio')) body = body.replace(/(qwen3\.8-flash<\/p>[\s\S]*?)\$0\.15/, (m, head) => head + '$0.16');
++ return { status: 200, headers: {}, body };
++}
++
++const t = suite('price-book');
++let h;
++const CLOCK = { now: () => Date.parse('2026-11-13T09:00:00Z') };
++const DAY = '2026-11-13';
++
++t.test('every provider listing is read in its own shape', async () => {
++ assert.deepStrictEqual(parsers.glm(read('glm.md'))['glm-5.3'], { in: 1.4, cached: 0.26, out: 4.4 });
++ assert.deepStrictEqual(parsers.glm(read('glm.md'))['glm-4.7-flash'], { in: 0, cached: 0, out: 0 });
++ assert.deepStrictEqual(parsers.abliteration(read('abliteration.md'))['abliterated-model'], { in: 1, cached: 0.1, out: 3 });
++ assert.deepStrictEqual(parsers.minimax(read('minimax.md'))['minimax-m3'], { in: 0.3, cached: 0.06, out: 1.2 });
++ assert.deepStrictEqual(parsers.deepseek(read('deepseek.md'))['deepseek-v4-pro'], { in: 1.32, cached: 0.044, out: 3.96 });
++ assert.deepStrictEqual(parsers.qwen(read('qwen.html'))['qwen3.8-max'], { in: 2, cached: null, out: 6 });
++ assert.deepStrictEqual(parsers.kimi(read('kimi.html'))['kimi-k3.1-pro'], { in: 2, cached: 0.2, out: 10 });
++ assert.deepStrictEqual(parsers.deepinfra(json('deepinfra.json'))['deepseek-ai/deepseek-v4.1-flash-1017'], { in: 0.2, cached: 0.006, out: 0.6 });
++ assert.deepStrictEqual(parsers.novita(json('novita.json'))['qwen/qwen3.8-max'], { in: 2, cached: 0.25, out: 6 });
++ assert.deepStrictEqual(parsers.featherless(json('featherless.json'))['qwen/qwen3-14b'], { in: 0.12, cached: null, out: 0.24 });
++ // A provider that publishes no price at all: the price stays unknown, it is never guessed.
++ assert.deepStrictEqual(parsers.refuseless(read('refuseless.md'))['deepseek-v4.1-flash-abliterated'], { in: null, cached: null, out: null });
++});
++
++t.test('the recorder keeps the rows the catalogue knows, and reads nothing of its own', async () => {
++ const recorder = require('../scripts/record-price-book');
++ assert.ok(recorder.wantedFor('qwen').includes('qwen3.8-max'));
++ const trimmed = JSON.parse(recorder.trimJson(read('deepinfra.json'), ['deepseek-ai/DeepSeek-V4.1-Flash-1017']));
++ assert.strictEqual(trimmed.data.length, 1);
++ assert.strictEqual(trimmed.data[0].id, 'deepseek-ai/DeepSeek-V4.1-Flash-1017');
++ assert.ok(recorder.trimQwen(read('qwen.html'), ['qwen3.8-max']).includes('qwen3.8-max'));
++ assert.ok(recorder.trimKimi(read('kimi.html'), ['kimi-k3.1-pro']).includes('kimi-k3.1-pro'));
++ // The module the service loads is the one the operator console reads.
++ assert.strictEqual(typeof require('../server/pricebook').createPriceBook, 'function');
++ assert.strictEqual(typeof require('../server/api/exchange').exchangeRouter, 'function');
++});
++
++t.test('the guard refuses an IP literal, plain http and an internal name', async () => {
++ const transport = async () => ({ status: 200, headers: {}, body: 'never read' });
++ assert.strictEqual((await readListing('https://example.com/models', { transport })).ok, true);
++ // A redirect is followed by hand, so every hop is judged on its own (MiniMax's price page moves).
++ const redirecting = async ({ url }) => (url.endsWith('/docs/pricing/overview.md')
++ ? { status: 200, headers: {}, body: 'ok' }
++ : { status: 307, headers: { location: 'https://platform.minimax.io/docs/pricing/overview.md' }, body: '' });
++ const hop = await readListing('https://platform.minimax.io/docs/pricing.md', { transport: redirecting });
++ assert.strictEqual(hop.ok, true);
++ assert.strictEqual(hop.final_url, 'https://platform.minimax.io/docs/pricing/overview.md');
++ for (const url of ['https://127.0.0.1/models', 'https://169.254.169.254/latest', 'https://10.0.0.5/m', 'http://example.com/m', 'https://localhost/m', 'https://model.internal/m']) {
++ const res = await readListing(url, { transport });
++ assert.strictEqual(res.ok, false, url + ' must not be read');
++ }
++});
++
++t.test('boot', async () => {
++ h = await boot({ env: { DEEPSEEK_KEY: 'test-deepseek-key', DEEPSEEK_API_KEY: 'test-deepseek-key', DEEPINFRA_KEY: 'test-deepinfra-key' } });
++});
++
++t.test('the job applies a small change and waits for the owner on a big one', async () => {
++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent, transport: moved });
++ const report = await book.runOnce();
++ // Every listing was read; none of them failed.
++ assert.strictEqual(report.unreadable.length, 0, JSON.stringify(report.unreadable));
++ assert.strictEqual(report.sources.length, SOURCES.length);
++ // The small change (Qwen's input price, +7%) was applied at once.
++ const applied = report.applied.find((a) => a.model === 'qwen3.8-flash' && a.field === 'in_per_mtok');
++ assert.ok(applied, 'the small change was applied');
++ assert.strictEqual(applied.next, 0.16);
++ const qwen = await h.registry.getModel('qwen', 'qwen3.8-flash');
++ assert.strictEqual(Number(qwen.cost.in_per_mtok), 0.16);
++ assert.strictEqual(qwen.cost.verified_at, DAY);
++ // The big change (GLM's input price, +43%) waits: the live price is untouched.
++ const waiting = report.pending.find((p) => p.model === 'glm-5.3' && p.field === 'in_per_mtok');
++ assert.ok(waiting, 'the big change waits for the owner');
++ const glm = await h.registry.getModel('glm', 'glm-5.3');
++ assert.strictEqual(Number(glm.cost.in_per_mtok), 1.4);
++ // Every change is in the log with the old price, the new price, the listing and the day.
++ const changes = await h.db.prepare('SELECT * FROM price_changes ORDER BY created_at').all();
++ assert.ok(changes.length >= 2);
++ const logged = changes.find((c) => c.model_key === 'qwen3.8-flash');
++ assert.strictEqual(logged.status, 'applied');
++ assert.strictEqual(Number(logged.old_price), 0.15);
++ assert.strictEqual(Number(logged.new_price), 0.16);
++ assert.strictEqual(String(logged.checked_at).slice(0, 10), DAY);
++ assert.strictEqual(logged.source, 'https://www.alibabacloud.com/help/en/model-studio/model-pricing');
++ const pending = changes.find((c) => c.model_key === 'glm-5.3');
++ assert.strictEqual(pending.status, 'pending');
++});
++
++t.test('the owner approves a waiting change and the price the router charges follows', async () => {
++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent, transport: moved });
++ const pending = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending'").all();
++ assert.strictEqual(pending.length, 1);
++ const out = await book.decide(pending[0].id, { decision: 'approve', actor: 'usr_owner' });
++ assert.strictEqual(out.ok, true);
++ const glm = await h.registry.getModel('glm', 'glm-5.3');
++ assert.strictEqual(Number(glm.cost.in_per_mtok), 2);
++ assert.strictEqual(glm.cost.verified_at, DAY);
++ const row = await h.db.prepare('SELECT * FROM price_changes WHERE id = ?').get(pending[0].id);
++ assert.strictEqual(row.status, 'approved');
++ assert.strictEqual(row.decided_by, 'usr_owner');
++ // A change that was already decided is not decided twice.
++ const again = await book.decide(pending[0].id, { decision: 'approve', actor: 'usr_owner' });
++ assert.strictEqual(again.ok, false);
++});
++
++t.test('the public catalog says when the price was checked', async () => {
++ // The catalog lists an offer only when the provider's key is there, as production has it.
++ await h.registry.upsertProvider({
++ key: 'deepinfra', display_name: 'DeepInfra', kind: 'openai', auth_mode: 'bearer', secret_ref: 'env:DEEPINFRA_KEY',
++ base_url: 'https://api.deepinfra.test', capabilities: ['chat'], provider_access: 'AUTHORIZED_WHOLESALE',
++ });
++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: CLOCK, log: silent, transport: recorded });
++ await book.runOnce();
++ const res = await request(h.base, 'GET', '/api/v1/exchange/catalog');
++ assert.strictEqual(res.status, 200);
++ const offer = res.body.models.flatMap((m) => m.offers).find((o) => o.provider === 'deepinfra' && o.model === 'deepseek-ai/DeepSeek-V4.1-Flash-1017');
++ assert.ok(offer, 'the deepinfra offer is listed');
++ assert.strictEqual(offer.price_checked_at, DAY);
++ assert.strictEqual(offer.price_checked, 'price checked ' + DAY);
++});
++
++t.test('the Exchange changelog serves the changes that are live', async () => {
++ const res = await request(h.base, 'GET', '/api/v1/exchange/price-changes');
++ assert.strictEqual(res.status, 200);
++ assert.ok(res.body.count >= 1);
++ const change = res.body.changes.find((c) => c.model === 'qwen3.8-flash');
++ assert.ok(change, 'the applied change is in the changelog');
++ assert.strictEqual(change.old_price, 0.15);
++ assert.strictEqual(change.new_price, 0.16);
++ assert.strictEqual(change.checked_at, DAY);
++ assert.ok(change.source.startsWith('https://'));
++ // A change that waits for the owner is not a price yet, so it is not in the changelog.
++ assert.ok(!res.body.changes.some((c) => c.model === 'glm-5.3' && c.status === 'pending'));
++});
++
++t.test('the job is off under a drill and off in test mode', async () => {
++ const book = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: false }, nodeEnv: 'test' }, clock: CLOCK, log: silent, transport: recorded });
++ assert.deepStrictEqual(await book.runOnce(), { skipped: 'disabled' });
++ assert.deepStrictEqual(await book.tick(), { skipped: 'disabled' });
++ // A clock past the interval, so the book is due: the drill is what stops it.
++ const later = { now: () => Date.parse('2026-12-20T09:00:00Z') };
++ const drilled = createPriceBook({ db: h.db, registry: h.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' }, clock: later, log: silent, transport: recorded });
++ await h.registry.upsertProvider({ key: 'qwen', display_name: 'Qwen', kind: 'openai', auth_mode: 'bearer', secret_ref: 'vault:qwen', metadata: { drill: true } });
++ assert.deepStrictEqual(await drilled.runOnce(), { skipped: 'drill' });
++ assert.deepStrictEqual(await drilled.tick(), { skipped: 'drill' });
++});
++
++t.test('the operator page shows the change that waits for the owner', async () => {
++ const { pages } = require('../server/console/views');
++ const pending = await h.db.prepare("SELECT * FROM price_changes WHERE status = 'pending'").all();
++ assert.ok(pending.length >= 1);
++ const html = pages.pricebook({
++ staff: { subject: 'usr_owner', username: 'owner', role: 'owner', caps: ['ai.provider.manage'] },
++ csrf: 'csrf-token', canManage: true,
++ report: { checked_at: DAY, sources: [{ source: 'glm' }], applied: [], pending: pending, unreadable: [] },
++ state: { last_run_at: '2026-11-13T09:00:00.000Z' },
++ pending, recent: [],
++ });
++ assert.match(html, /Price book/);
++ assert.match(html, /Waiting for the owner/);
++ assert.ok(html.includes(pending[0].model_key), 'the waiting change is on the page');
++ assert.match(html, /Approve/);
++ assert.ok(!/<script/i.test(html), 'no script on the operator page');
++});
++
++t.test('the book is due-based: a restart before the week does not run it, a restart after it does', async () => {
++ // Its own database, so the last run of the other tests is not this one's history.
++ const own = await boot({ env: { DEEPSEEK_KEY: 'test-deepseek-key', DEEPSEEK_API_KEY: 'test-deepseek-key' } });
++ try {
++ const at = Date.parse('2026-11-20T09:00:00Z');
++ const book = (now) => createPriceBook({
++ db: own.db, registry: own.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' },
++ clock: { now: () => now }, log: silent, transport: recorded,
++ });
++
++ // The first boot has no row: the book has never run, so it is due at once.
++ const first = await book(at).tick();
++ assert.ok(first && !first.skipped, 'the first run happens');
++ assert.ok(first.sources.length > 0, 'the listings were read');
++
++ // A restart a minute later: the week has not passed, so nothing runs.
++ assert.deepStrictEqual(await book(at + 60 * 1000).tick(), { skipped: 'not due' });
++
++ // A restart after the interval: the book runs once.
++ const later = book(at + 8 * 24 * 60 * 60 * 1000);
++ const again = await later.tick();
++ assert.ok(again && !again.skipped, 'the week came round');
++ assert.deepStrictEqual(await later.tick(), { skipped: 'not due' });
++
++ // The row says when the last run finished, and nobody holds the claim.
++ const row = await own.db.prepare("SELECT * FROM price_book_state WHERE id = 'pricebook'").get();
++ assert.ok(row.last_run_at, 'the last run is remembered in the database');
++ assert.strictEqual(row.claimed_until, null);
++ assert.strictEqual(row.claimed_by, null);
++ } finally {
++ await own.stop();
++ }
++});
++
++t.test('two instances at the same moment: only one reads the listings', async () => {
++ const own = await boot({ env: { DEEPSEEK_KEY: 'test-deepseek-key', DEEPSEEK_API_KEY: 'test-deepseek-key' } });
++ try {
++ const at = Date.parse('2026-11-27T09:00:00Z');
++ // A listing that takes a moment, so the first instance still holds the claim when the second asks.
++ const slow = async ({ url }) => { await new Promise((r) => setTimeout(r, 120)); return recorded({ url }); };
++ const instance = () => createPriceBook({
++ db: own.db, registry: own.registry, config: { priceBook: { enabled: true }, nodeEnv: 'test' },
++ clock: { now: () => at }, log: silent, transport: slow,
++ });
++ const [one, two] = await Promise.all([instance().tick(), instance().tick()]);
++ const ran = [one, two].filter((r) => r && !r.skipped);
++ const skipped = [one, two].filter((r) => r && r.skipped);
++ assert.strictEqual(ran.length, 1, 'one instance read the listings');
++ assert.strictEqual(skipped.length, 1);
++ assert.strictEqual(skipped[0].skipped, 'claimed');
++ // The claim was given back when the run ended.
++ const row = await own.db.prepare("SELECT * FROM price_book_state WHERE id = 'pricebook'").get();
++ assert.strictEqual(row.claimed_until, null);
++ assert.strictEqual(row.claimed_by, null);
++ } finally {
++ await own.stop();
++ }
++});
++
++t.test('stop', async () => {
++ await h.stop();
++});
++
++t.run();