openvibers/openvibe.ai Commits
Price routing on platform rate cards and provider states per period (#7)
Changes
@@@ -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@@@ -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.cppand 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@@@ -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",nnew file mode 100644@@@ -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);@@@ -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",@@@ -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"@@@ -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.noconst 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.noconst 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.noreturn 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.notry {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.nothrow 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 };nnew file mode 100644@@@ -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 };@@@ -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.ereturn {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.eif (!['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 } });@@@ -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');nnew file mode 100644@@@ -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();
@@ -63,6 +63,8 @@ AI_MODEL=
@@ -63,6 +63,8 @@ AI_MODEL=
#AI_EMBEDDING_MODEL=text-embedding-3-small
#AI_EMBEDDING_MODEL=text-embedding-3-small
#AI_PROVIDER_TIMEOUT_MS=30000
#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}}
# 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_PRICING_JSON=
#AI_INPUT_COST_PER_MTOK=3
#AI_INPUT_COST_PER_MTOK=3
#AI_OUTPUT_COST_PER_MTOK=15
#AI_OUTPUT_COST_PER_MTOK=15
@@ -43,7 +43,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -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)
- 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
- 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
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
`openvibe-shared` v2.3.1, pinned by release tarball
## Capabilities
## Capabilities
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"runtime": "node 22, express, postgresql",
"port": 4700,
"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",
"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",
"sdk": "openvibe-sdk v0.26.0",
"capabilities": [
"capabilities": [
"ai.run.create",
"ai.run.create",
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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);
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1448,9 +1448,9 @@
@@ -1448,9 +1448,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -28,6 +28,7 @@ const { createOpenAiProvider } = require('./openai');
@@ -28,6 +28,7 @@ const { createOpenAiProvider } = require('./openai');
const { createAnthropicProvider } = require('./anthropic');
const { createAnthropicProvider } = require('./anthropic');
const { createHttpSeamProvider } = require('./http');
const { createHttpSeamProvider } = require('./http');
const { createWhisperProvider } = require('./whisper');
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. */
/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */
function callerFault(err) {
function callerFault(err) {
@@ -94,33 +95,37 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -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); }
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);
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;
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) {
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 ────────────────────────────────────────────
// ── Routing ────────────────────────────────────────────
@@ -155,6 +160,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -155,6 +160,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const stats = await statsByProvider();
const stats = await statsByProvider();
const offers = [];
const offers = [];
const rateCards = [];
const rateCards = [];
+ const cardsByProvider = {};
const seen = new Set();
const seen = new Set();
const addOffer = async (p, model, caps) => {
const addOffer = async (p, model, caps) => {
const id = offerId(p.key, model);
const id = offerId(p.key, model);
@@ -166,15 +172,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -166,15 +172,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const errorRate = st.requests ? st.errors / st.requests : 0;
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 status = p.status !== 'active' || !creds ? 'down' : h.state === 'open' ? 'down' : (h.state === 'half_open' || errorRate > 0.5) ? 'degraded' : 'up';
const free = isFreeProvider(p);
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({
offers.push({
offer_id: id, kind: 'provider', provider: p.key, region: 'global',
offer_id: id, kind: 'provider', provider: p.key, region: 'global',
trust: (p.metadata && p.metadata.local) || p.kind === 'stub' ? 'community' : 'first-party',
trust: (p.metadata && p.metadata.local) || p.kind === 'stub' ? 'community' : 'first-party',
capabilities: caps, latency_ms: st.p95 ? { p95: st.p95 } : {}, health: { status },
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()) {
for (const p of await registry.listProviders()) {
@@ -201,8 +210,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -201,8 +210,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
};
};
const state = await getRouteState.get(route.key);
const state = await getRouteState.get(route.key);
const current = state && state.current_provider ? offerId(state.current_provider, state.current_model) : null;
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, {
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 },
weights: { cost: config.placement.costWeight, latency: config.placement.latencyWeight },
});
});
const byId = new Map(offers.map(o => [o.offer_id, o]));
const byId = new Map(offers.map(o => [o.offer_id, o]));
@@ -222,7 +232,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -222,7 +232,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return ea - eb || (a.score ?? Infinity) - (b.score ?? Infinity);
return ea - eb || (a.score ?? Infinity) - (b.score ?? Infinity);
});
});
for (const x of rest) order.push(x.id);
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. */
/** A pinned route: [primary, ...fallbacks], exactly as before. */
@@ -239,14 +249,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -239,14 +249,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
async function candidates(route, features, operation) {
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 poolCandidates(route, features, operation)
: await pinnedCandidates(route, features);
: await pinnedCandidates(route, features);
if (config.stubFallback && !order.some(c => c.provider === 'stub') && await registry.getProvider('stub')) {
if (config.stubFallback && !order.some(c => c.provider === 'stub') && await registry.getProvider('stub')) {
order.push({ provider: 'stub', model: null });
order.push({ provider: 'stub', model: null });
explain.candidates.push({ provider: 'stub', model: null, eligible: true, excluded_reason: null, cost: 0, latency: 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) {
async function skipReason(p, a, features) {
@@ -286,17 +296,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -286,17 +296,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
try {
try {
const result = await a.chat({ ...req, model, signal: ctx.signal, timeoutMs });
const result = await a.chat({ ...req, model, signal: ctx.signal, timeoutMs });
const usage = result.usage || { input: 0, output: 0, cached: 0 };
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;
const latency = Date.now() - t0;
if (ctx.logRequest) {
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,
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
@@ -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 });
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 };
module.exports = { createProviderPool };
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -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 };
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
*/
*/
const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = require('./util');
const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = require('./util');
const schemas = require('./schemas');
const schemas = require('./schemas');
+const { provenance } = require('./providers/rate-cards');
const PROVIDER_KINDS = ['stub', 'openai', 'anthropic', 'http', 'whisper'];
const PROVIDER_KINDS = ['stub', 'openai', 'anthropic', 'http', 'whisper'];
const FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe'];
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
@@ -100,7 +101,10 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
return {
return {
provider_key: r.provider_key, model_key: r.model_key, display_name: r.display_name, type: r.type, status: r.status,
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,
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 },
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,
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
@@ -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');
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';
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');
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 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 sup = input.supports || (prev && prev.supports) || {};
const t = now();
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,
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)
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,
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,
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,
supports_tools = excluded.supports_tools, supports_streaming = excluded.supports_streaming, supports_vision = excluded.supports_vision,
metadata = excluded.metadata, updated_at = excluded.updated_at`)
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,
.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,
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,
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,
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);
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 } });
await audit(actor, prev ? 'model.update' : 'model.create', 'model', `${input.provider_key}/${input.model_key}`, { trace, metadata: { status, type } });
@@ -97,10 +97,8 @@ t.test('two providers on one capability: the cheaper is chosen; a circuit-open o
@@ -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.
// 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');
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.placement.selected, 'cheap:cheap-m');
assert.strictEqual(placed.explain.selected, placed.placement.selected, 'explain.selected is the placement\'s');
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');
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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -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();