openvibers/openvibe.ai Commits

Price routing on platform rate cards and provider states per period (#7)

OP OpenVibers committed 2 Oct 2026 · 8903236992 · 11 files, +474 −57

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.AI/pull/7

Changes

ddiff --git a/.env.example b/.env.example
iindex e028ddc..59e8330 100644
---- a/.env.example
++++ b/.env.example
@@@ -63,6 +63,8 @@ AI_MODEL=
#AI_EMBEDDING_MODEL=text-embedding-3-small
#AI_PROVIDER_TIMEOUT_MS=30000
# USD per million tokens, longest model-prefix match: {"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005}}
++# Each entry may also carry its platform.rate-card@1 terms: source (URI), verified_at, effective_from (YYYY-MM-DD),
++# free_allowance (tokens per metric) and reset_period (day|month|none). Unset = unknown (1970-01-01, urn:openvibe:ai:unverified:*).
#AI_PRICING_JSON=
#AI_INPUT_COST_PER_MTOK=3
#AI_OUTPUT_COST_PER_MTOK=15
ddiff --git a/README.md b/README.md
iindex c47ff29..75296dd 100644
---- a/README.md
++++ b/README.md
@@@ -43,7 +43,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)
- provider APIs configured by the operator (OpenAI-compatible, Anthropic, the HTTP seam), whisper.cpp
and the local model server `openvibe-llm.service` on this host
--- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.26.0 (service tokens, events outbox),
++- `openvibe-contracts` v0.84.0, `openvibe-sdk` v0.26.0 (service tokens, events outbox),
`openvibe-shared` v2.3.1, pinned by release tarball
## Capabilities
ddiff --git a/STATUS.json b/STATUS.json
iindex bfdb538..f00fb53 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"port": 4700,
"domain": "ai.openvibe.network still serves the OpenVibe.Sites placeholder; the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700, loopback only, used by OpenVibe.Live and OpenVibe.Network",
-- "contracts": "openvibe-contracts v0.76.0",
++ "contracts": "openvibe-contracts v0.84.0",
"sdk": "openvibe-sdk v0.26.0",
"capabilities": [
"ai.run.create",
ddiff --git a/migrations/0004_rate_card_provenance.sql b/migrations/0004_rate_card_provenance.sql
nnew file mode 100644
iindex 0000000..656464a
---- /dev/null
++++ b/migrations/0004_rate_card_provenance.sql
@@@ -0,0 +1,15 @@
++-- phase: expand
++-- T6 J4: a model's prices become platform.rate-card@1 objects (server/providers/rate-cards.js), one per
++-- metric (input, cached input, output tokens per million). These columns hold what the contract needs beyond
++-- the price itself. Additive only: every column is nullable or defaulted and no existing row is rewritten.
++-- A NULL date or source means "not recorded": the card then says so (effective_from/verified_at 1970-01-01,
++-- source urn:openvibe:ai:unverified:models). Rate cards change only by review.
++-- The index serves the router's per-provider usage query for platform.provider-state@1 (this period's usage).
++
++ALTER TABLE models ADD COLUMN effective_from text COLLATE "C"; -- YYYY-MM-DD the price took effect, or NULL (unknown)
++ALTER TABLE models ADD COLUMN source text COLLATE "C"; -- URI of the provider's price page it was checked against, or NULL (unknown)
++ALTER TABLE models ADD COLUMN verified_at text COLLATE "C"; -- YYYY-MM-DD someone checked it against that source, or NULL (never)
++ALTER TABLE models ADD COLUMN free_allowance double precision NOT NULL DEFAULT 0; -- free tokens per reset period, for each of the model's token metrics
++ALTER TABLE models ADD COLUMN reset_period text COLLATE "C" NOT NULL DEFAULT 'month'; -- day | month | none: when the free allowance resets
++
++CREATE INDEX idx_usage_daily_provider_day ON usage_daily(provider_key, day);
ddiff --git a/package-lock.json b/package-lock.json
iindex 10af43f..6e0b4ae 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"ajv": "^8.17.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0"
@@@ -1448,9 +1448,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.76.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
-- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
++ "version": "0.84.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
++ "integrity": "sha512-wnmQbzffd2xXSGk3Zr1JxZYIL4oSlnJTo8lGpUApBPoDKeQ3U4VCLJgt3cZpbtkbl5NH1qNjBDXkwx+uASwoyA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex d4a7951..0a109df 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"ajv": "^8.17.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.84.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0"
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 5b0bd45..0aa19b8 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -28,6 +28,7 @@ const { createOpenAiProvider } = require('./openai');
const { createAnthropicProvider } = require('./anthropic');
const { createHttpSeamProvider } = require('./http');
const { createWhisperProvider } = require('./whisper');
++const rates = require('./rate-cards');
/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */
function callerFault(err) {
@@@ -94,33 +95,37 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
async function resetHealth(key) { await db.prepare('DELETE FROM provider_health WHERE provider_key = ?').run(key); }
-- // ── Pricing (Live's rules: model row -> AI_PRICING_JSON longest prefix -> flat rates) ──
-- async function priceFor(providerKey, model) {
++ // ── Pricing: platform.rate-card@1 per metric (server/providers/rate-cards.js; models row -> AI_PRICING_JSON -> flat) ──
++ /** The { in, cached, out } rate cards for a provider + model, or null for a provider that is never billed (stub, whisper). */
++ async function rateCardsFor(providerKey, model) {
const p = await registry.getProvider(providerKey);
-- if (!p || p.kind === 'stub' || p.kind === 'whisper') return { in: 0, out: 0, cached: 0 };
++ if (!p || p.kind === 'stub' || p.kind === 'whisper') return null;
const row = model ? await registry.getModel(providerKey, model) : null;
-- if (row && row.cost && Number.isFinite(row.cost.in_per_mtok) && row.cost.in_per_mtok !== null) {
-- const inRate = Number(row.cost.in_per_mtok);
-- return { in: inRate, out: Number(row.cost.out_per_mtok) || 0, cached: row.cost.cached_per_mtok != null ? Number(row.cost.cached_per_mtok) : inRate * 0.1 };
-- }
-- const table = config.pricing.table || {};
-- const m = String(model || '').toLowerCase();
-- let best = null; let bestLen = -1;
-- for (const [k, v] of Object.entries(table)) {
-- const key = String(k).toLowerCase();
-- if (key === 'default') continue;
-- if (m.startsWith(key) && key.length > bestLen && v && typeof v === 'object') { best = v; bestLen = key.length; }
-- }
-- if (!best && table.default && typeof table.default === 'object') best = table.default;
-- const inRate = best && Number.isFinite(Number(best.in)) ? Number(best.in) : config.pricing.inputPerMtok;
-- const outRate = best && Number.isFinite(Number(best.out)) ? Number(best.out) : config.pricing.outputPerMtok;
-- const cachedRate = best && Number.isFinite(Number(best.cached)) ? Number(best.cached) : inRate * 0.1;
-- return { in: inRate, out: outRate, cached: cachedRate };
++ return rates.buildCards({ providerKey, model: model || null, row, pricing: config.pricing });
++ }
++ async function priceFor(providerKey, model) {
++ const cards = await rateCardsFor(providerKey, model);
++ return cards ? rates.pricesOf(cards) : { in: 0, out: 0, cached: 0 };
}
async function costOf(providerKey, model, usage) {
-- const pr = await priceFor(providerKey, model);
-- const input = Math.max(0, (usage.input || 0) - (usage.cached || 0));
-- return (input / 1e6) * pr.in + ((usage.cached || 0) / 1e6) * pr.cached + ((usage.output || 0) / 1e6) * pr.out;
++ const cards = await rateCardsFor(providerKey, model);
++ return cards ? rates.costOfUsage(cards, usage) : 0;
++ }
++
++ // ── Provider state: platform.provider-state@1 per provider, from usage_daily and the breaker ──
++ const getPeriodUsage = db.prepare(`SELECT day, model_key, SUM(tokens_in)::bigint AS tokens_in, SUM(tokens_out)::bigint AS tokens_out, SUM(tokens_cached)::bigint AS tokens_cached
++ FROM usage_daily WHERE provider_key = ? AND day >= ? GROUP BY day, model_key`);
++ /** One state per provider over the cards the plan uses (cards: { provider: [card, ...] }); each card counts its own period. */
++ async function providerStates(cardsByProvider) {
++ const now = clock.now();
++ const out = [];
++ for (const [key, cards] of Object.entries(cardsByProvider)) {
++ const usageRows = (await getPeriodUsage.all(key, rates.usageSince(cards, now)))
++ .map(r => ({ day: r.day, model_key: r.model_key, tokens_in: Number(r.tokens_in), tokens_out: Number(r.tokens_out), tokens_cached: Number(r.tokens_cached) }));
++ const p = await registry.getProvider(key);
++ out.push(rates.providerState({ provider: key, cards, usageRows, breaker: (await health(key)).state, reserve: p && p.metadata ? p.metadata.reserve : null, now }));
++ }
++ return out;
}
// ── Routing ────────────────────────────────────────────
@@@ -155,6 +160,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const stats = await statsByProvider();
const offers = [];
const rateCards = [];
++ const cardsByProvider = {};
const seen = new Set();
const addOffer = async (p, model, caps) => {
const id = offerId(p.key, model);
@@@ -166,15 +172,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const errorRate = st.requests ? st.errors / st.requests : 0;
const status = p.status !== 'active' || !creds ? 'down' : h.state === 'open' ? 'down' : (h.state === 'half_open' || errorRate > 0.5) ? 'degraded' : 'up';
const free = isFreeProvider(p);
++ // Placement prices an offer on one card: input tokens, the part of a call known before it runs.
++ // The cached-input and output cards ride along (costOf bills all three; the state counts each metric).
++ const cards = free ? null : await rateCardsFor(p.key, model);
offers.push({
offer_id: id, kind: 'provider', provider: p.key, region: 'global',
trust: (p.metadata && p.metadata.local) || p.kind === 'stub' ? 'community' : 'first-party',
capabilities: caps, latency_ms: st.p95 ? { p95: st.p95 } : {}, health: { status },
-- pricing: free ? { model: 'prepaid' } : { model: 'metered', rate_card: id },
++ pricing: cards ? { model: 'metered', rate_card: cards.in.id } : { model: 'prepaid' },
});
-- if (!free) {
-- const price = await priceFor(p.key, model);
-- rateCards.push({ id, provider: p.key, metric: 'tokens', unit_size: 1e6, unit_price_usd: (price.in + price.out) / 2, free_allowance: 0, reset_period: 'month' });
++ if (cards) {
++ rateCards.push(cards.in, cards.cached, cards.out);
++ (cardsByProvider[p.key] || (cardsByProvider[p.key] = [])).push(cards.in, cards.cached, cards.out);
}
};
for (const p of await registry.listProviders()) {
@@@ -201,8 +210,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
};
const state = await getRouteState.get(route.key);
const current = state && state.current_provider ? offerId(state.current_provider, state.current_model) : null;
++ const states = await providerStates(cardsByProvider);
const result = placement.plan(requirements, offers, {
-- rateCards, now: clock.now(), current, minGain: config.placement.minGain,
++ rateCards, states, now: clock.now(), current, minGain: config.placement.minGain,
weights: { cost: config.placement.costWeight, latency: config.placement.latencyWeight },
});
const byId = new Map(offers.map(o => [o.offer_id, o]));
@@@ -222,7 +232,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return ea - eb || (a.score ?? Infinity) - (b.score ?? Infinity);
});
for (const x of rest) order.push(x.id);
-- return { order: order.map(splitOfferId), explain, placement: result };
++ return { order: order.map(splitOfferId), explain, placement: result, rateCards, states };
}
/** A pinned route: [primary, ...fallbacks], exactly as before. */
@@@ -239,14 +249,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
async function candidates(route, features, operation) {
-- const { order, explain, placement: result = null } = route.capability && operation
++ const { order, explain, placement: result = null, ...rest } = route.capability && operation
? await poolCandidates(route, features, operation)
: await pinnedCandidates(route, features);
if (config.stubFallback && !order.some(c => c.provider === 'stub') && await registry.getProvider('stub')) {
order.push({ provider: 'stub', model: null });
explain.candidates.push({ provider: 'stub', model: null, eligible: true, excluded_reason: null, cost: 0, latency: null });
}
-- return { order, explain, placement: result }; // placement: the platform.placement-result@1 explain is built from (pool routes)
++ return { order, explain, placement: result, rateCards: rest.rateCards || [], states: rest.states || [] }; // placement: the platform.placement-result@1 explain is built from (pool routes)
}
async function skipReason(p, a, features) {
@@@ -286,17 +296,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
try {
const result = await a.chat({ ...req, model, signal: ctx.signal, timeoutMs });
const usage = result.usage || { input: 0, output: 0, cached: 0 };
-- const listPrice = (() => {
-- const table = config.pricing.table || {};
-- const m = String(result.model || model).toLowerCase();
-- let best = null; let len = -1;
-- for (const [k, v] of Object.entries(table)) { const key = String(k).toLowerCase(); if (key !== 'default' && m.startsWith(key) && key.length > len && v && typeof v === 'object') { best = v; len = key.length; } }
-- const b = best || table.default || {};
-- const inRate = Number(b.in) || 0;
-- return { in: inRate, out: Number(b.out) || 0, cached: b.cached != null ? Number(b.cached) : inRate * 0.1 };
-- })();
-- const input = Math.max(0, (usage.input || 0) - (usage.cached || 0));
-- const cost = (input / 1e6) * listPrice.in + ((usage.cached || 0) / 1e6) * listPrice.cached + ((usage.output || 0) / 1e6) * listPrice.out;
++ const cost = rates.costOfUsage(rates.buildCards({ providerKey: cred.providerKey, model: result.model || model, pricing: config.pricing, flat: false }), usage);
const latency = Date.now() - t0;
if (ctx.logRequest) {
ctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,
@@@ -389,7 +389,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });
}
-- return { adapter, health, resetHealth, execute, executeWithCredential, priceFor, costOf, stats, statFor, candidates };
++ return { adapter, health, resetHealth, execute, executeWithCredential, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates };
}
module.exports = { createProviderPool };
ddiff --git a/server/providers/rate-cards.js b/server/providers/rate-cards.js
nnew file mode 100644
iindex 0000000..7f08950
---- /dev/null
++++ b/server/providers/rate-cards.js
@@@ -0,0 +1,204 @@
++'use strict';
++/**
++ * Rate cards (platform.rate-card@1) and provider states (platform.provider-state@1) for the router.
++ *
++ * Every price AI charges or ranks on is read from one of these cards, so priceFor/costOf and the cards
++ * handed to openvibe-sdk/placement can never disagree. The numbers come from AI's existing price
++ * sources, in Live's order, per metric:
++ *
++ * models row cost_in/out/cached_per_mtok (+ its provenance columns, migrations/0004)
++ * pricing AI_PRICING_JSON, longest model prefix, else its `default` entry (+ optional provenance keys)
++ * flat AI_INPUT_COST_PER_MTOK / AI_OUTPUT_COST_PER_MTOK
++ * derived cached input when nothing sets it: 10 % of the input price (the existing rule)
++ *
++ * Input, cached input and output are separate cards (metric `<kind>-tokens:<model>`, unit_size 1e6, the
++ * price per million tokens exactly as configured — never rounded or changed). Rate cards change only by
++ * review: this module never makes up a price, an allowance or a provenance.
++ *
++ * Unknown provenance stays visibly unknown, the same way everywhere: `effective_from` and `verified_at`
++ * are UNKNOWN_DATE (the epoch, 1970-01-01) and `source` is `urn:openvibe:ai:unverified:<origin>`, naming
++ * where AI took the number from. `free_allowance` is 0 and `reset_period` 'month' unless configured.
++ */
++
++const UNKNOWN_DATE = '1970-01-01';
++const unverified = (origin) => `urn:openvibe:ai:unverified:${origin}`;
++const KINDS = { in: 'input', cached: 'cached-input', out: 'output' };
++const RESET_PERIODS = ['day', 'month', 'none'];
++const DATE = /^\d{4}-\d{2}-\d{2}$/;
++const URI = /^[a-z][a-z0-9+.-]*:[^\s]+$/i;
++
++const metricFor = (kind, model) => (model ? `${KINDS[kind]}-tokens:${model}` : `${KINDS[kind]}-tokens`);
++const finite = (v) => v !== null && v !== undefined && v !== '' && Number.isFinite(Number(v));
++const isDate = (v) => typeof v === 'string' && DATE.test(v) && !Number.isNaN(Date.parse(`${v}T00:00:00Z`));
++
++/** Configured provenance (a models row or an AI_PRICING_JSON entry), each field only when it is valid. */
++function provenance(src = {}) {
++ const p = {};
++ if (isDate(src.effective_from)) p.effective_from = src.effective_from;
++ if (isDate(src.effective_until)) p.effective_until = src.effective_until;
++ if (typeof src.source === 'string' && URI.test(src.source)) p.source = src.source;
++ if (isDate(src.verified_at)) p.verified_at = src.verified_at;
++ if (finite(src.free_allowance) && Number(src.free_allowance) >= 0) p.free_allowance = Number(src.free_allowance);
++ if (RESET_PERIODS.includes(src.reset_period)) p.reset_period = src.reset_period;
++ return p;
++}
++
++/** Longest-prefix AI_PRICING_JSON entry for a model, else its `default` entry, else null. */
++function tableEntry(table, model) {
++ const m = String(model || '').toLowerCase();
++ let best = null; let bestLen = -1;
++ for (const [k, v] of Object.entries(table || {})) {
++ const key = String(k).toLowerCase();
++ if (key === 'default') continue;
++ if (m.startsWith(key) && key.length > bestLen && v && typeof v === 'object') { best = v; bestLen = key.length; }
++ }
++ if (!best && table && table.default && typeof table.default === 'object') best = table.default;
++ return best;
++}
++
++/** One platform.rate-card@1: exactly the schema's fields, unknown provenance per the convention above. */
++function card(providerKey, model, kind, price, origin, prov = {}) {
++ const c = {
++ id: `${model ? `${providerKey}:${model}` : providerKey}/${KINDS[kind]}-tokens`,
++ provider: providerKey,
++ metric: metricFor(kind, model),
++ unit_size: 1e6,
++ unit_price_usd: price,
++ free_allowance: prov.free_allowance ?? 0,
++ reset_period: prov.reset_period || 'month',
++ effective_from: prov.effective_from || UNKNOWN_DATE,
++ source: prov.source || unverified(origin),
++ verified_at: prov.verified_at || UNKNOWN_DATE,
++ };
++ if (prov.effective_until) c.effective_until = prov.effective_until;
++ return c;
++}
++
++/** A price taken from elsewhere keeps the entry's allowance terms but not its source or dates. */
++const allowanceOnly = (prov) => ({ free_allowance: prov.free_allowance, reset_period: prov.reset_period });
++
++/**
++ * The cards for one provider + model: { in, cached, out } from the models row, else AI_PRICING_JSON, else
++ * the flat rates (cached: else 10 % of input). `flat: false` is the person's-own-key path, which never
++ * used the models row or the flat rates: AI_PRICING_JSON only, else 0.
++ */
++function buildCards({ providerKey, model, row = null, pricing = {}, flat = true }) {
++ const make = (kind, price, origin, prov) => card(providerKey, model, kind, price, origin, prov);
++ if (flat && row && row.cost && Number.isFinite(row.cost.in_per_mtok)) {
++ const prov = provenance(row.cost);
++ const inRate = row.cost.in_per_mtok;
++ return {
++ in: make('in', inRate, 'models', prov),
++ cached: row.cost.cached_per_mtok != null ? make('cached', Number(row.cost.cached_per_mtok), 'models', prov)
++ : make('cached', inRate * 0.1, 'derived', allowanceOnly(prov)),
++ out: make('out', Number(row.cost.out_per_mtok) || 0, 'models', prov),
++ };
++ }
++ const best = tableEntry(pricing.table, model);
++ const prov = provenance(best || {});
++ if (!flat) {
++ // A person's own key: AI_PRICING_JSON only, as before (an unset price is 0, never the flat rates).
++ const b = best || {};
++ const inRate = Number(b.in) || 0;
++ return {
++ in: make('in', inRate, best && b.in != null ? 'pricing' : 'unpriced', prov),
++ cached: b.cached != null ? make('cached', Number(b.cached), 'pricing', prov) : make('cached', inRate * 0.1, 'derived', allowanceOnly(prov)),
++ out: make('out', Number(b.out) || 0, best && b.out != null ? 'pricing' : 'unpriced', prov),
++ };
++ }
++ const has = (field) => Boolean(best) && best[field] != null && best[field] !== '' && Number.isFinite(Number(best[field]));
++ const inRate = has('in') ? Number(best.in) : pricing.inputPerMtok;
++ return {
++ in: has('in') ? make('in', inRate, 'pricing', prov) : make('in', inRate, 'flat', allowanceOnly(prov)),
++ cached: has('cached') ? make('cached', Number(best.cached), 'pricing', prov) : make('cached', inRate * 0.1, 'derived', allowanceOnly(prov)),
++ out: has('out') ? make('out', Number(best.out), 'pricing', prov) : make('out', pricing.outputPerMtok, 'flat', allowanceOnly(prov)),
++ };
++}
++
++/** Per-million-token prices read off the cards. */
++const pricesOf = (cards) => ({ in: cards.in.unit_price_usd, out: cards.out.unit_price_usd, cached: cards.cached.unit_price_usd });
++
++/** USD for one call's usage, priced on its cards (input excludes the cached part, as providers bill). */
++function costOfUsage(cards, usage = {}) {
++ const cached = usage.cached || 0;
++ const input = Math.max(0, (usage.input || 0) - cached);
++ return (input / cards.in.unit_size) * cards.in.unit_price_usd + (cached / cards.cached.unit_size) * cards.cached.unit_price_usd
++ + ((usage.output || 0) / cards.out.unit_size) * cards.out.unit_price_usd;
++}
++
++/** How far back a never-resetting ('none') card counts usage: usage_daily keeps every day, so the window is bounded. */
++const NONE_LOOKBACK_DAYS = 366;
++
++/**
++ * The billing period that holds `now` for a reset period, UTC. 'none' never resets: from the card's
++ * `effective_from` (`since`, when known) up to now, but never further back than NONE_LOOKBACK_DAYS.
++ * An `effective_from` later than the start of today (a card not yet in effect) counts from the
++ * start of today, matching usageSince's day bound.
++ */
++function periodOf(resetPeriod, now, since) {
++ const d = new Date(now);
++ if (resetPeriod === 'day') {
++ const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
++ return { start, end: start + 86400000 };
++ }
++ if (resetPeriod === 'none') {
++ const today = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
++ const floor = today - NONE_LOOKBACK_DAYS * 86400000;
++ const from = since ? Date.parse(since) : NaN;
++ return { start: Number.isFinite(from) && from > floor ? Math.min(from, today) : floor, end: now };
++ }
++ return { start: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1), end: Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) };
++}
++const cardPeriod = (card, now) => periodOf(card.reset_period, now, card.effective_from);
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++
++/** The first usage_daily day any of these cards counts: the query bound for one provider's state. */
++function usageSince(cards, now) {
++ return dayOf(Math.min(...cards.map(c => cardPeriod(c, now).start)));
++}
++
++const BREAKER_HEALTH = { closed: 'up', half_open: 'degraded', open: 'down' };
++
++/**
++ * One platform.provider-state@1 for a provider. Each metric counts and forecasts over its own card's period
++ * (a provider's models may reset differently), from usage_daily rows {day, model_key, tokens_in, tokens_out,
++ * tokens_cached}; a row without `day` is taken as inside every period. The state's own period_start/period_end
++ * is the shortest reset period among its cards (day, else month, else none). The reserve only when the
++ * provider's config sets one, and the breaker as health.
++ */
++function providerState({ provider, cards, usageRows = [], breaker = 'closed', reserve = null, now }) {
++ const periods = {};
++ const used = {};
++ for (const c of cards) { periods[c.metric] = cardPeriod(c, now); used[c.metric] = 0; }
++ const firstDay = Object.fromEntries(Object.entries(periods).map(([m, p]) => [m, dayOf(p.start)]));
++ for (const r of usageRows) {
++ const model = r.model_key || null;
++ const add = (kind, n) => {
++ const m = metricFor(kind, model);
++ if (m in used && !(r.day && String(r.day) < firstDay[m])) used[m] += Math.max(0, Number(n) || 0);
++ };
++ add('in', (Number(r.tokens_in) || 0) - (Number(r.tokens_cached) || 0));
++ add('cached', r.tokens_cached);
++ add('out', r.tokens_out);
++ }
++ const forecast = Object.fromEntries(Object.entries(used).map(([m, n]) => {
++ const { start, end } = periods[m];
++ const elapsed = Math.max(1, now - start);
++ return [m, n * (Math.max(elapsed, end - start) / elapsed)];
++ }));
++ const shortest = RESET_PERIODS.find(rp => cards.some(c => c.reset_period === rp));
++ const { start, end } = shortest ? cardPeriod(cards.find(c => c.reset_period === shortest), now) : periodOf('month', now);
++ const state = {
++ provider, period_start: new Date(start).toISOString(), period_end: new Date(Math.max(end, start)).toISOString(),
++ usage: used, forecast,
++ };
++ const res = reserve && typeof reserve === 'object'
++ ? Object.fromEntries(Object.entries(reserve).filter(([, v]) => finite(v) && Number(v) >= 0 && Number(v) <= 1).map(([m, v]) => [m, Number(v)]))
++ : {};
++ if (Object.keys(res).length) state.reserve = res;
++ state.health = BREAKER_HEALTH[breaker] || 'up';
++ state.updated_at = new Date(now).toISOString();
++ return state;
++}
++
++module.exports = { UNKNOWN_DATE, unverified, metricFor, provenance, tableEntry, buildCards, pricesOf, costOfUsage, periodOf, usageSince, providerState, NONE_LOOKBACK_DAYS, BREAKER_HEALTH };
ddiff --git a/server/registry.js b/server/registry.js
iindex c6f400d..6ffd83b 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -11,6 +11,7 @@
*/
const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = require('./util');
const schemas = require('./schemas');
++const { provenance } = require('./providers/rate-cards');
const PROVIDER_KINDS = ['stub', 'openai', 'anthropic', 'http', 'whisper'];
const FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe'];
@@@ -100,7 +101,10 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
return {
provider_key: r.provider_key, model_key: r.model_key, display_name: r.display_name, type: r.type, status: r.status,
context_window: r.context_window, max_output: r.max_output,
-- cost: { in_per_mtok: r.cost_in_per_mtok, out_per_mtok: r.cost_out_per_mtok, cached_per_mtok: r.cost_cached_per_mtok },
++ // The price and its platform.rate-card@1 terms (migrations/0004); a NULL date or source is unknown, never filled in.
++ cost: { in_per_mtok: r.cost_in_per_mtok, out_per_mtok: r.cost_out_per_mtok, cached_per_mtok: r.cost_cached_per_mtok,
++ free_allowance: r.free_allowance == null ? 0 : Number(r.free_allowance), reset_period: r.reset_period || 'month',
++ effective_from: r.effective_from ?? null, source: r.source ?? null, verified_at: r.verified_at ?? null },
supports: { json: !!r.supports_json, tools: !!r.supports_tools, streaming: !!r.supports_streaming, vision: !!r.supports_vision },
metadata: parseJson(r.metadata, {}), created_at: r.created_at, updated_at: r.updated_at,
};
@@@ -118,20 +122,29 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
if (!['chat', 'vision', 'embedding', 'stt'].includes(type)) throw new AiError(422, 'ai.invalid', 'model type must be chat, vision, embedding or stt');
const status = input.status || (prev && prev.status) || 'active';
if (!['active', 'disabled'].includes(status)) throw new AiError(422, 'ai.invalid', 'model status must be active or disabled');
++ // A new cost replaces the old one whole: new prices never inherit the old ones' source or verification date.
const cost = input.cost || (prev && prev.cost) || {};
++ const terms = provenance(cost);
++ for (const f of ['effective_from', 'source', 'verified_at', 'free_allowance', 'reset_period']) {
++ if (cost[f] != null && !(f in terms)) throw new AiError(422, 'ai.invalid', `cost.${f} is not a valid platform.rate-card@1 ${f}`);
++ }
const sup = input.supports || (prev && prev.supports) || {};
const t = now();
await db.prepare(`INSERT INTO models (provider_key, model_key, display_name, type, status, context_window, max_output, cost_in_per_mtok, cost_out_per_mtok, cost_cached_per_mtok,
++ free_allowance, reset_period, effective_from, source, verified_at,
supports_json, supports_tools, supports_streaming, supports_vision, metadata, created_at, updated_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider_key, model_key) DO UPDATE SET display_name = excluded.display_name, type = excluded.type, status = excluded.status,
context_window = excluded.context_window, max_output = excluded.max_output, cost_in_per_mtok = excluded.cost_in_per_mtok,
-- cost_out_per_mtok = excluded.cost_out_per_mtok, cost_cached_per_mtok = excluded.cost_cached_per_mtok, supports_json = excluded.supports_json,
++ cost_out_per_mtok = excluded.cost_out_per_mtok, cost_cached_per_mtok = excluded.cost_cached_per_mtok,
++ free_allowance = excluded.free_allowance, reset_period = excluded.reset_period, effective_from = excluded.effective_from,
++ source = excluded.source, verified_at = excluded.verified_at, supports_json = excluded.supports_json,
supports_tools = excluded.supports_tools, supports_streaming = excluded.supports_streaming, supports_vision = excluded.supports_vision,
metadata = excluded.metadata, updated_at = excluded.updated_at`)
.run(input.provider_key, input.model_key, input.display_name || (prev && prev.display_name) || input.model_key, type, status,
input.context_window ?? (prev && prev.context_window) ?? null, input.max_output ?? (prev && prev.max_output) ?? null,
cost.in_per_mtok ?? null, cost.out_per_mtok ?? null, cost.cached_per_mtok ?? null,
++ terms.free_allowance ?? 0, terms.reset_period || 'month', terms.effective_from || null, terms.source || null, terms.verified_at || null,
sup.json === false ? 0 : 1, sup.tools ? 1 : 0, sup.streaming ? 1 : 0, sup.vision ? 1 : 0,
JSON.stringify(input.metadata || (prev && prev.metadata) || {}), t, t);
await audit(actor, prev ? 'model.update' : 'model.create', 'model', `${input.provider_key}/${input.model_key}`, { trace, metadata: { status, type } });
ddiff --git a/test/provider-router.test.js b/test/provider-router.test.js
iindex 2018a9f..21ddfe2 100644
---- a/test/provider-router.test.js
++++ b/test/provider-router.test.js
@@@ -97,10 +97,8 @@ t.test('two providers on one capability: the cheaper is chosen; a circuit-open o
// explain is AI's projection of the placement result; that result is a platform.placement-result@1.
const placed = await h.pool.candidates(r, ['classify'], 'classify');
-- let v = null;
-- try { v = contracts.validate('platform.placement-result@1', placed.placement); } catch (e) { if (!/unknown contract/.test(e.message)) throw e; }
-- if (v) assert.ok(v.valid, `platform.placement-result@1: ${JSON.stringify(v.errors)}`);
-- else console.log(`placement-result: skipped (openvibe-contracts ${require('openvibe-contracts/package.json').version} has no platform.placement-result@1; it lands in 0.77.0)`);
++ const v = contracts.validate('platform.placement-result@1', placed.placement);
++ assert.ok(v.valid, `platform.placement-result@1: ${JSON.stringify(v.errors)}`);
assert.strictEqual(placed.placement.selected, 'cheap:cheap-m');
assert.strictEqual(placed.explain.selected, placed.placement.selected, 'explain.selected is the placement\'s');
assert.deepStrictEqual(placed.explain.candidates.map((c) => (c.model ? `${c.provider}:${c.model}` : c.provider)), placed.placement.candidates.map((c) => c.id), 'one explain candidate per placement candidate');
ddiff --git a/test/rate-card.test.js b/test/rate-card.test.js
nnew file mode 100644
iindex 0000000..8af7597
---- /dev/null
++++ b/test/rate-card.test.js
@@@ -0,0 +1,185 @@
++'use strict';
++// T6 J4: AI's prices are platform.rate-card@1 objects and each provider's period is a platform.provider-state@1,
++// validated against openvibe-contracts (no skip: a missing contract fails). One card per metric (input, cached
++// input, output) carries the configured number exactly; unknown provenance stays visibly unknown. The states
++// reach openvibe-sdk/placement, so a free allowance left wins and an open breaker (health down) never does.
++const assert = require('assert');
++const { validate } = require('openvibe-contracts');
++const rates = require('../server/providers/rate-cards');
++const { boot, request, token, suite, ALL } = require('./helpers');
++
++const t = suite('rate-card');
++const tok = token('live', ALL);
++let h;
++
++function valid(name, obj) {
++ const v = validate(name, obj);
++ assert.ok(v.valid, `${name} ${JSON.stringify(obj)}: ${JSON.stringify(v.errors)}`);
++}
++const UNKNOWN = { effective_from: '1970-01-01', verified_at: '1970-01-01' };
++
++t.test('cards from every price source validate, keep each number and mark unknown provenance', () => {
++ const pricing = {
++ table: {
++ 'gpt-4o': { in: 2.5, out: 10, cached: 1.25, source: 'https://openai.com/api/pricing/', verified_at: '2026-09-30', effective_from: '2026-09-01' },
++ default: { in: 0.7 },
++ },
++ inputPerMtok: 3, outputPerMtok: 15,
++ };
++ const fromTable = rates.buildCards({ providerKey: 'shared', model: 'gpt-4o-mini', pricing });
++ const fromDefault = rates.buildCards({ providerKey: 'shared', model: 'other', pricing });
++ const flat = rates.buildCards({ providerKey: 'shared', model: 'other', pricing: { table: {}, inputPerMtok: 3, outputPerMtok: 15 } });
++ const fromRow = rates.buildCards({ providerKey: 'p', model: 'm', row: { cost: { in_per_mtok: 1.1, out_per_mtok: 4.4, cached_per_mtok: null, free_allowance: 0, reset_period: 'month', source: null, verified_at: null, effective_from: null } }, pricing });
++ const own = rates.buildCards({ providerKey: 'byo:live:1', model: 'x', pricing: { table: {} }, flat: false });
++ for (const set of [fromTable, fromDefault, flat, fromRow, own]) for (const c of [set.in, set.cached, set.out]) valid('platform.rate-card@1', c);
++
++ // Separate metrics, the configured numbers untouched (no (in+out)/2 any more).
++ assert.deepStrictEqual(rates.pricesOf(fromTable), { in: 2.5, out: 10, cached: 1.25 });
++ assert.deepStrictEqual([fromTable.in.metric, fromTable.cached.metric, fromTable.out.metric], ['input-tokens:gpt-4o-mini', 'cached-input-tokens:gpt-4o-mini', 'output-tokens:gpt-4o-mini']);
++ assert.deepStrictEqual({ ...fromTable.in, id: undefined }, { id: undefined, provider: 'shared', metric: 'input-tokens:gpt-4o-mini', unit_size: 1e6, unit_price_usd: 2.5, free_allowance: 0, reset_period: 'month', effective_from: '2026-09-01', source: 'https://openai.com/api/pricing/', verified_at: '2026-09-30' });
++ // The default entry has no output price: that card is the flat rate, and says so.
++ assert.deepStrictEqual(rates.pricesOf(fromDefault), { in: 0.7, out: 15, cached: 0.7 * 0.1 });
++ assert.strictEqual(fromDefault.out.source, 'urn:openvibe:ai:unverified:flat');
++ assert.strictEqual(fromDefault.in.source, 'urn:openvibe:ai:unverified:pricing');
++ assert.strictEqual(fromDefault.cached.source, 'urn:openvibe:ai:unverified:derived');
++ assert.deepStrictEqual(rates.pricesOf(flat), { in: 3, out: 15, cached: 3 * 0.1 });
++ assert.deepStrictEqual(rates.pricesOf(fromRow), { in: 1.1, out: 4.4, cached: 1.1 * 0.1 });
++ for (const c of [flat.in, flat.out, fromRow.in, fromRow.out, fromDefault.in]) assert.deepStrictEqual({ effective_from: c.effective_from, verified_at: c.verified_at }, UNKNOWN);
++ assert.strictEqual(fromRow.in.source, 'urn:openvibe:ai:unverified:models');
++ assert.deepStrictEqual(rates.pricesOf(own), { in: 0, out: 0, cached: 0 });
++ assert.strictEqual(own.in.source, 'urn:openvibe:ai:unverified:unpriced');
++
++ // costOf bills the three metrics on their own cards.
++ assert.strictEqual(rates.costOfUsage(fromTable, { input: 1e6, cached: 4e5, output: 2e6 }), 0.6 * 2.5 + 0.4 * 1.25 + 2 * 10);
++});
++
++t.test('provider states validate: usage per metric, linear forecast, reserve only when configured, breaker as health', () => {
++ const cards = Object.values(rates.buildCards({ providerKey: 'p', model: 'm', pricing: { table: {}, inputPerMtok: 3, outputPerMtok: 15 } }));
++ const now = Date.UTC(2026, 9, 11, 0, 0, 0); // 10 of October's 31 days have passed
++ const usageRows = [{ model_key: 'm', tokens_in: 1000, tokens_out: 300, tokens_cached: 200 }, { model_key: 'other', tokens_in: 5, tokens_out: 5, tokens_cached: 0 }];
++ const s = rates.providerState({ provider: 'p', cards, usageRows, breaker: 'closed', now });
++ valid('platform.provider-state@1', s);
++ assert.deepStrictEqual(s.usage, { 'input-tokens:m': 800, 'cached-input-tokens:m': 200, 'output-tokens:m': 300 });
++ assert.strictEqual(s.period_start, '2026-10-01T00:00:00.000Z');
++ assert.strictEqual(s.period_end, '2026-11-01T00:00:00.000Z');
++ assert.strictEqual(s.forecast['input-tokens:m'], 800 * 3.1);
++ assert.strictEqual(s.health, 'up');
++ assert.ok(!('reserve' in s), 'no reserve unless configured');
++ const half = rates.providerState({ provider: 'p', cards, breaker: 'half_open', reserve: { 'input-tokens:m': 0.25, bad: 2 }, now });
++ valid('platform.provider-state@1', half);
++ assert.strictEqual(half.health, 'degraded');
++ assert.deepStrictEqual(half.reserve, { 'input-tokens:m': 0.25 });
++ const open = rates.providerState({ provider: 'p', cards, breaker: 'open', now });
++ valid('platform.provider-state@1', open);
++ assert.strictEqual(open.health, 'down');
++
++ // A provider whose models reset differently: each metric counts and forecasts over its own card's period.
++ const rowCard = (model, reset_period, effective_from = null) => rates.buildCards({ providerKey: 'p', model, pricing: { table: {} },
++ row: { cost: { in_per_mtok: 1, out_per_mtok: 1, cached_per_mtok: null, free_allowance: 0, reset_period, source: null, verified_at: null, effective_from } } });
++ const mixed = [rowCard('d', 'day'), rowCard('mo', 'month'), rowCard('n', 'none', '2026-10-05')].flatMap(Object.values);
++ const days = ['2026-10-11', '2026-10-06', '2026-10-02', '2026-09-30'];
++ const rows = days.flatMap((day) => ['d', 'mo', 'n'].map((model_key) => ({ day, model_key, tokens_in: 10, tokens_out: 0, tokens_cached: 0 })));
++ const ms = rates.providerState({ provider: 'p', cards: mixed, usageRows: rows, now: now + 6 * 3600000 });
++ valid('platform.provider-state@1', ms);
++ assert.deepStrictEqual([ms.usage['input-tokens:d'], ms.usage['input-tokens:mo'], ms.usage['input-tokens:n']], [10, 30, 20]);
++ assert.strictEqual(ms.forecast['input-tokens:d'], 10 * 4, 'a day card forecasts to the end of its day');
++ assert.strictEqual(ms.forecast['input-tokens:n'], 20, 'a card that never resets forecasts what it has used');
++ assert.deepStrictEqual([ms.period_start, ms.period_end], ['2026-10-11T00:00:00.000Z', '2026-10-12T00:00:00.000Z'], 'the state window is the shortest reset period');
++ // 'none' is bounded: from effective_from when known, never more than NONE_LOOKBACK_DAYS back.
++ const n = rowCard('n', 'none', '2026-10-05');
++ assert.strictEqual(rates.usageSince([n.in, n.out], now), '2026-10-05');
++ const cap = new Date(now - rates.NONE_LOOKBACK_DAYS * 86400000).toISOString().slice(0, 10);
++ assert.strictEqual(rates.usageSince([n.cached], now), cap, 'the derived cached card has no effective_from of its own');
++ assert.strictEqual(rates.usageSince(Object.values(rowCard('n', 'none')), now), cap);
++});
++
++async function provider(key, capability = 'classify') {
++ const r = await request(h.base, 'POST', '/api/v1/providers', { tok, body: { key, kind: 'http', auth_mode: 'none', timeout_ms: 3000, base_url: 'http://127.0.0.1:1/x', capabilities: [capability], default_model: `${key}-m` } });
++ assert.strictEqual(r.status, 201, r.text);
++}
++async function model(key, cost) {
++ const r = await request(h.base, 'POST', '/api/v1/models', { tok, body: { provider_key: key, model_key: `${key}-m`, type: 'chat', cost } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.body.model;
++}
++
++t.test('the router plans on full rate cards and provider states: a free allowance left wins, then runs out', async () => {
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
++ await provider('allow');
++ await provider('plain');
++ const m = await model('allow', { in_per_mtok: 2, out_per_mtok: 8, free_allowance: 1e6, reset_period: 'month', source: 'https://example.com/pricing', verified_at: '2026-09-30' });
++ assert.deepStrictEqual(m.cost, { in_per_mtok: 2, out_per_mtok: 8, cached_per_mtok: null, free_allowance: 1e6, reset_period: 'month', effective_from: null, source: 'https://example.com/pricing', verified_at: '2026-09-30' });
++ await model('plain', { in_per_mtok: 2, out_per_mtok: 8 });
++ const bad = await request(h.base, 'POST', '/api/v1/models', { tok, body: { provider_key: 'plain', model_key: 'plain-m', cost: { in_per_mtok: 2, verified_at: 'yesterday' } } });
++ assert.strictEqual(bad.status, 422, 'a provenance field that is not the contract\'s shape is refused');
++ const route = (await request(h.base, 'POST', '/api/v1/routes/test.rate-cards/versions', { tok, body: { capability: 'classify', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } })).body.route;
++
++ const c1 = await h.pool.candidates(route, ['classify'], 'classify');
++ for (const c of c1.rateCards) valid('platform.rate-card@1', c);
++ for (const s of c1.states) valid('platform.provider-state@1', s);
++ for (const key of ['allow', 'plain']) assert.ok(c1.states.some((s) => s.provider === key), `a state for ${key}`);
++ valid('platform.placement-result@1', c1.placement);
++ assert.strictEqual(c1.placement.selected, 'allow:allow-m', 'equal prices: the free allowance left wins');
++ assert.strictEqual(c1.placement.candidates.find((c) => c.id === 'allow:allow-m').estimated_cost_usd, 0);
++ assert.ok(c1.placement.reasons.some((r) => /free allowance/.test(r)));
++ // priceFor/costOf read the same cards placement saw.
++ const allowIn = c1.rateCards.find((c) => c.id === 'allow:allow-m/input-tokens');
++ assert.deepStrictEqual(await h.pool.priceFor('allow', 'allow-m'), { in: allowIn.unit_price_usd, out: 8, cached: 2 * 0.1 });
++ assert.strictEqual(allowIn.free_allowance, 1e6);
++ assert.strictEqual(await h.pool.costOf('allow', 'allow-m', { input: 1e6, output: 1e6, cached: 0 }), 10);
++
++ // This period's usage already spends the allowance: the forecast says it runs out, so it is not free any more.
++ await h.db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)
++ VALUES (?, 'service:live', '', 'test', 'allow', 'allow-m', 1, 2000000, 0, 0, 0)`).run(new Date().toISOString().slice(0, 10));
++ const c2 = await h.pool.candidates(route, ['classify'], 'classify');
++ const st = c2.states.find((s) => s.provider === 'allow');
++ valid('platform.provider-state@1', st);
++ assert.strictEqual(st.usage['input-tokens:allow-m'], 2000000);
++ assert.ok(c2.placement.candidates.find((c) => c.id === 'allow:allow-m').estimated_cost_usd > 0, 'an allowance that is spent is not free');
++});
++
++t.test('an open breaker is health down in the state and the provider is not selected', async () => {
++ await provider('cheapest', 'enrich');
++ await model('cheapest', { in_per_mtok: 0.1, out_per_mtok: 0.1 });
++ await provider('dearer', 'enrich');
++ await model('dearer', { in_per_mtok: 5, out_per_mtok: 5 });
++ const route = (await request(h.base, 'POST', '/api/v1/routes/test.rate-cards-breaker/versions', { tok, body: { capability: 'enrich', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } })).body.route;
++ assert.strictEqual((await h.pool.candidates(route, ['enrich'], 'enrich')).placement.selected, 'cheapest:cheapest-m');
++ await h.db.prepare(`INSERT INTO provider_health (provider_key, state, consecutive_failures, opened_at, last_error, last_success_at, last_failure_at)
++ VALUES ('cheapest', 'open', 5, ?, 'boom', NULL, ?)`).run(Date.now(), Date.now());
++ const c = await h.pool.candidates(route, ['enrich'], 'enrich');
++ const st = c.states.find((s) => s.provider === 'cheapest');
++ valid('platform.provider-state@1', st);
++ assert.strictEqual(st.health, 'down');
++ assert.notStrictEqual(c.placement.selected, 'cheapest:cheapest-m');
++ assert.match(c.placement.candidates.find((x) => x.id === 'cheapest:cheapest-m').excluded_because, /health down/);
++ await h.stop();
++});
++
++t.test('a null price cell in AI_PRICING_JSON falls back, it is not a price of 0', () => {
++ const pricing = { table: { n: { in: 2, cached: null, out: 8 } }, inputPerMtok: 3, outputPerMtok: 15 };
++ const cards = rates.buildCards({ providerKey: 'p', model: 'n', pricing });
++ assert.deepStrictEqual(rates.pricesOf(cards), { in: 2, out: 8, cached: 2 * 0.1 });
++ assert.strictEqual(cards.cached.source, 'urn:openvibe:ai:unverified:derived');
++});
++
++t.test('a table entry with null or empty prices uses the flat rates', () => {
++ const pricing = { table: { n: { in: null, out: '' } }, inputPerMtok: 3, outputPerMtok: 15 };
++ const cards = rates.buildCards({ providerKey: 'p', model: 'n', pricing });
++ assert.deepStrictEqual(rates.pricesOf(cards), { in: 3, out: 15, cached: 3 * 0.1 });
++ assert.strictEqual(cards.in.source, 'urn:openvibe:ai:unverified:flat');
++ assert.strictEqual(cards.out.source, 'urn:openvibe:ai:unverified:flat');
++});
++
++t.test('a none card not yet effective counts from the start of today, so its start is a whole day', () => {
++ const now = Date.parse('2026-10-02T15:00:00Z');
++ assert.deepStrictEqual(rates.periodOf('none', now, '2026-12-01'), { start: Date.parse('2026-10-02T00:00:00Z'), end: now });
++ const base = rates.buildCards({ providerKey: 'p', model: 'n', pricing: { table: {}, inputPerMtok: 3, outputPerMtok: 15 } });
++ const card = { ...base.in, reset_period: 'none', effective_from: '2026-12-01' };
++ assert.strictEqual(rates.usageSince([card], now), '2026-10-02');
++ const state = rates.providerState({ provider: 'p', cards: [card], usageRows: [], now });
++ assert.strictEqual(state.period_start, '2026-10-02T00:00:00.000Z');
++ assert.ok(Date.parse(state.period_start) < Date.parse(state.period_end), 'the start is strictly before the end');
++});
++
++t.run();