openvibers/openvibe.ai Commits
Claim the provider free allowance per subject before pricing usage
Changes
@@@ -221,6 +221,16 @@ a second upstream exists, an unavailable sole provider answers `503 provider.unauses separate local and paid pools so paid work still requires its explicit budget. The syntheticstub can join general text routes as a last resort outside production only (`AI_STUB_FALLBACK`).++Free allowance (§2.1.8): a rate card's `free_allowance` (tokens per metric, from a models row or++`AI_PRICING_JSON`) is free provider capacity for **each subject** (the run's attribution, else its++actor, else its requester) per provider, metric and `reset_period` (`day` and `month` reset at the UTC++boundary; `none` never resets for the card's terms). An accounted run claims it first, atomically, in++the same transaction as its usage (`free_allowance_usage`, `server/free-allowance.js`); the free tokens++are the reading's `free_allowance_used` on its `platform.usage-sample@1`, and its `cost_estimate` leaves++their price out. It is pricing, never admission: quotas, request counting and the run's own cost are++unchanged, and no call is ever refused because the allowance is used up. Every card defaults to 0, so++nothing is free and the field is absent until an operator configures an allowance.++## Workflows- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `blog.draft_post`,@@@ -279,6 +289,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart| Edits are new versions; runs pin versions; lifecycle; admin versions survive reseeding | `test/versioning.test.js` || A degraded primary falls back and records it; breaker; skips; timeouts; explicit `provider.unavailable` | `test/fallback.test.js` || Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |++| Free allowance: free up to the card's allowance then fully priced, reset per day/month (never for `none`), per subject, never past the cap under concurrent claims, absent at 0 | `test/free-allowance.test.js` || The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` || Queue caps: one caller cannot fill the run queue (per-caller and global 429 `queue.full` + Retry-After) | `test/queue.test.js` || Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |nnew file mode 100644@@@ -0,0 +1,108 @@++# Cutover — provider free allowance (`migrations/0006_free_allowance.sql`, T6 step 2)++++The change: an accounted run first claims its subject's provider free allowance (§2.1.8: the rate++card's `free_allowance` per provider, metric and `reset_period`) in the transaction that accounts++it. The free tokens are reported as `free_allowance_used` on the run's `platform.usage-sample@1`++and their price is left out of its `cost_estimate`. Pricing only: quotas, request counting and the++run's own cost are unchanged, and no call is refused because of it. Details in++`server/free-allowance.js`.++++## What the migration changes++++`migrations/0006_free_allowance.sql` is an **expand** migration, additive and transactional:++++- a new table `free_allowance_usage (subject, provider, metric, period_start, period_end, free_used,++ updated_at)`, primary key `(subject, provider, metric, period_start)`, one row per subject,++ provider, metric and reset period;++- one index, `idx_free_allowance_period (period_end)`, for the current periods.++++No existing table or row is touched, no data moves. The previous release (N-1) does not know the++table, so a rollback to it needs no data change. The only writer is the AI service itself: there is++no import, no dual-write window and no backfill. Every rate card defaults to `free_allowance` 0, so++until an operator configures an allowance the table stays empty and readings are unchanged.++++## Order++++1. **Preconditions (pre-merge).** CI green on the branch head; the PR body carries the `cutover`++ manifest; the rehearsal below recorded green. Billing does not have to change:++ `free_allowance_used` is an optional field of `platform.usage-sample@1` it already accepts.++++2. **Backup (before anything touches the schema).**++ `ov access run openvibe-ovh db-backup ai` — ovhost takes a backup of AI's declared database and++ logs it. Keep the backup id/artifact for the deployment until the verify step is signed off.++ By hand (owner role, same result): `pg_dump --format=custom --no-owner "$DATABASE_DIRECT_URL" >++ ai-<utc-timestamp>.dump`, then `pg_restore --list` on it to prove it is complete. This is the++ only backup the cutover needs; the migration itself loses nothing.++++3. **Rehearse on a copy.** Restore the backup into a scratch database (`pg_restore` into++ `ai-rehearsal`, or copy `/…/data/pglite`), start the release against it as the owner++ (`DATABASE_URL`/`DATABASE_DIRECT_URL` pointed at the copy, `node server/index.js`), and run the++ verify checks below against the copy. The migration is applied by the service at boot++ (`server/db.js` `openDb()`), as the owner on the direct connection, in a transaction, serialised++ by the SDK's advisory lock, and recorded in `ov_migrations` with a checksum. Whoever runs this++ writes the marker `ds/deploy/rehearsals/ai-free-allowance-0006.json` as `{"ok": true}` — the++ author never writes it.++++4. **Apply in production.** Merged work deploys through its pipeline (the recipe runs with every++ guard). The migration runs on the next service start, in the same boot path as the rehearsal.++ No manual `psql` step.++++5. **Verify** (see below). Sign the deploy off only when the schema checks pass and one run on++ default cards still produces a reading without `free_allowance_used`.++++## How the result is verified++++Schema, as the owner on the direct connection:++++```sql++SELECT id, name, phase, applied_at FROM ov_migrations WHERE id = '0006'; -- one row, phase expand++\d free_allowance_usage -- table, primary key + the index++SELECT count(*) FROM free_allowance_usage; -- 0 while every card's allowance is 0++```++++Service and follow-through:++++- `ov access run openvibe-ovh health ai` → ready (`GET /api/ready`).++- `ov access run openvibe-ovh journal openvibe-ai.service 200` → no migration error.++- One accounted run on default cards: its reading has no `free_allowance_used` and the same++ `cost_estimate` as before —++ `SELECT envelope FROM usage_sample_outbox ORDER BY id DESC LIMIT 1;`.++- Once an operator sets an allowance (a models row's `cost.free_allowance`/`reset_period`, or++ `AI_PRICING_JSON`), one run against it: a row per metric appears with `free_used` never above the++ card's `free_allowance`, and the run's reading carries `free_allowance_used` equal to the free++ tokens.++++ ```sql++ SELECT subject, provider, metric, period_start, period_end, free_used FROM free_allowance_usage ORDER BY updated_at DESC LIMIT 5;++ ```++++## Way back++++- **Roll back the release (the normal way).** The migration is expand and additive and N-1 ignores++ the table, so `ov access run openvibe-ovh rollback ai` (owner) is enough; leave the table in++ place, it is harmless. N-1 prices every token again; readings it writes carry no++ `free_allowance_used`.++- **Stop granting free tokens without changing code or schema:** set the cards' `free_allowance`++ back to 0 (the models row or `AI_PRICING_JSON`) and restart — nothing more is claimed; the rows++ already written stay as the record of what was free.++- **Only if the table itself must go** (not required for a rollback), with the owner's approval and++ the backup in hand, after the previous release is running:++++ ```sql++ DROP TABLE IF EXISTS free_allowance_usage;++ DELETE FROM ov_migrations WHERE id = '0006';++ ```++++ The only loss is the record of free tokens used in the current periods (a later release would++ grant them again); `usage_daily`, the run ledger and the readings are untouched.++- **Never edit `0006_free_allowance.sql` in place:** `ov_migrations` records its checksum and the++ runner refuses a changed file. Undo with new SQL (or a later migration), not by rewriting it.++++## Notes++++- Not a `migrate`/`contract` pair: nothing depends on the new table outside this service and++ nothing needs the N-1 window, so no follow-up migration is scheduled.++- `deploy/` and `.env.example` need no change: no new env; `AI_PRICING_JSON` already documents++ `free_allowance` and `reset_period`.++- T6 step 8 (`openvibe-sdk/govern` tiers) moves this counter's store; its semantics and the++ reading's field stay.nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- T6 step 2 / §2.1.8: the provider free allowance a subject has used, per provider, metric and reset period++-- (server/free-allowance.js). account() claims from it in the transaction that accounts a run; the free share is++-- reported as free_allowance_used on the run's platform.usage-sample@1 and never priced on its cost_estimate.++-- Pricing only, never admission: no request is refused because of this table. Additive only: a new table.++CREATE TABLE IF NOT EXISTS free_allowance_usage (++ subject text COLLATE "C" NOT NULL, -- attributionKey | actorKey | requesterType:requesterId++ provider text COLLATE "C" NOT NULL,++ metric text COLLATE "C" NOT NULL, -- the card's metric: input-tokens[:model] | cached-input-tokens[:model] | output-tokens[:model]++ period_start bigint NOT NULL, -- epoch ms: the card's reset period start++ period_end bigint NOT NULL, -- epoch ms: its end (reset_period 'none': the card's effective_until, else never)++ free_used bigint NOT NULL DEFAULT 0, -- tokens, never more than the card's free_allowance++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (subject, provider, metric, period_start)++);++CREATE INDEX IF NOT EXISTS idx_free_allowance_period ON free_allowance_usage (period_end);nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++/**++ * §2.1.8 bounded free allowance (T6 step 2). Each platform.rate-card@1's free_allowance/reset_period++ * (server/providers/rate-cards.js) is provider capacity a subject uses free before its spend is priced. The++ * counter is per subject + provider + metric + reset period (free_allowance_usage, migrations/0006). account()++ * claims from it inside its own transaction; the free tokens are reported as free_allowance_used on the run's++ * platform.usage-sample@1 and their list price is left out of its cost_estimate.++ *++ * Pricing only, never admission: reserve() and the request counters are untouched and no call is refused here.++ * A claim is atomic: the period's row is created if missing, then locked (SELECT … FOR UPDATE) before the free++ * share is computed, so concurrent claims for one subject, provider, metric and period never exceed the card's++ * free_allowance in total.++ *++ * const free = createFreeAllowance(db, { cardsFor: pool.rateCardsFor });++ * const { tokens, usd } = await free.claim(subject, provider, model, { in, cached, out }); // inside db.tx++ */++const rates = require('./providers/rate-cards');++++const KINDS = ['in', 'cached', 'out'];++const NEVER = 8.64e15; // the last instant a Date can hold: the end of a 'none' period with no effective_until++++/** Who an allowance belongs to: the attribution, else the actor, else the requester; null → nothing is free. */++function subjectOf(ctx = {}) {++ if (ctx.attributionKey) return String(ctx.attributionKey);++ if (ctx.actorKey) return String(ctx.actorKey);++ return ctx.requesterType && ctx.requesterId ? `${ctx.requesterType}:${ctx.requesterId}` : null;++}++++/**++ * The card's allowance period holding `now`: periodOf's UTC day or month. 'none' never resets, so it is one++ * period for the card's terms, from effective_from (unknown: the epoch) to effective_until (else never), not++ * periodOf's rolling usage lookback, whose start moves every day.++ */++function periodFor(card, now) {++ const p = rates.periodOf(card.reset_period, now, card.effective_from);++ if (card.reset_period !== 'none') return p;++ const from = Date.parse(card.effective_from);++ const until = card.effective_until ? Date.parse(card.effective_until) : NaN;++ return { start: Number.isFinite(from) ? from : 0, end: Number.isFinite(until) ? until : NEVER };++}++++function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() } }) {++ const open = db.prepare(`INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at)++ VALUES (?, ?, ?, ?, ?, 0, ?) ON CONFLICT (subject, provider, metric, period_start) DO NOTHING`);++ const lock = db.prepare('SELECT free_used FROM free_allowance_usage WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ? FOR UPDATE');++ const take = db.prepare('UPDATE free_allowance_usage SET free_used = free_used + ?, updated_at = ? WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ?');++++ /**++ * Claim this attempt's free tokens: usage { in (excluding cached), cached, out } on `provider`/`model`.++ * Returns { tokens, usd }: the free tokens and their list price on the same cards. A provider with no++ * cards (never billed) or a card with free_allowance 0 claims nothing and writes nothing.++ */++ async function claim(subject, provider, model, usage = {}, now = clock.now()) {++ const out = { tokens: 0, usd: 0 };++ if (!subject || !provider) return out;++ const cards = await cardsFor(provider, model || null);++ if (!cards) return out;++ const at = new Date(now).toISOString();++ await db.tx(async () => {++ for (const kind of KINDS) { // one fixed order, so two claims lock rows in the same order++ const c = cards[kind];++ const cap = Math.floor(Number(c && c.free_allowance) || 0);++ const q = Math.max(0, Math.floor(Number(usage[kind]) || 0));++ if (cap <= 0 || q <= 0) continue;++ const p = periodFor(c, now);++ await open.run(subject, provider, c.metric, p.start, p.end, at);++ const row = await lock.get(subject, provider, c.metric, p.start);++ const free = Math.min(q, Math.max(0, cap - Number(row.free_used)));++ if (!free) continue;++ await take.run(free, at, subject, provider, c.metric, p.start);++ out.tokens += free;++ out.usd += (free / c.unit_size) * c.unit_price_usd;++ }++ });++ return out;++ }++++ /**++ * Free used and remaining in the current periods, for the staff console (never a public page: rows are per++ * subject). Rows: { subject, provider, metric, period_start, period_end, free_used, free_allowance, remaining }.++ */++ async function current({ now = clock.now(), limit = 200 } = {}) {++ const rows = await db.prepare(`SELECT subject, provider, metric, period_start, period_end, free_used FROM free_allowance_usage++ WHERE period_start <= ? AND period_end > ? ORDER BY provider, metric, subject LIMIT ?`).all(now, now, limit);++ const byKind = { 'input-tokens': 'in', 'cached-input-tokens': 'cached', 'output-tokens': 'out' };++ const out = [];++ for (const r of rows) {++ const i = r.metric.indexOf(':');++ const cards = await cardsFor(r.provider, i < 0 ? null : r.metric.slice(i + 1));++ const c = cards && cards[byKind[i < 0 ? r.metric : r.metric.slice(0, i)]];++ const cap = c ? Number(c.free_allowance) || 0 : 0;++ out.push({ ...r, period_start: Number(r.period_start), period_end: Number(r.period_end), free_used: Number(r.free_used), free_allowance: cap, remaining: Math.max(0, cap - Number(r.free_used)) });++ }++ return out;++ }++++ return { claim, current };++}++++module.exports = { createFreeAllowance, subjectOf, periodFor };@@@ -15,6 +15,7 @@ const { createFetcher } = require('./fetcher');const { createEngine } = require('./workflows/engine');const { createCache } = require('./cache');const { createQuotas } = require('./quota');++const { createFreeAllowance } = require('./free-allowance');const { createRuns } = require('./runs');const { seed } = require('./workflows/seed');const { createAuth, createNetworkKeys } = require('./auth');@@@ -29,9 +30,11 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.const db = givenDb || await openDb(config, { log });const registry = createRegistry(db, { clock, env });-- const quotas = createQuotas(db, { clock, registry });-- const cache = createCache(db, { clock });const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch });++ // §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js).++ const freeAllowance = createFreeAllowance(db, { cardsFor: pool.rateCardsFor, clock });++ const quotas = createQuotas(db, { clock, registry, freeAllowance });++ const cache = createCache(db, { clock });const fetcher = createFetcher(config);const engine = createEngine({ registry, pool, fetcher, quotas, config, log });// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.@@@ -15,6 +15,7 @@*/const { AiError, iso } = require('./util');const usageSamples = require('./usage-samples');++const { subjectOf } = require('./free-allowance');const WINDOWS = { minute: 60, hour: 3600, day: 86400 };@@@ -47,7 +48,7 @@ function estimatePercentile(hist, q) {return LATENCY_BUCKETS_MS[LATENCY_BUCKETS_MS.length - 1];}--function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {}) {++function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAllowance = null } = {}) {const nowSec = () => Math.floor(clock.now() / 1000);const windowStart = (w, t = nowSec()) => Math.floor(t / WINDOWS[w]) * WINDOWS[w];@@@ -172,7 +173,9 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})* provider rollup and per-route placement state. `attempts` is the run's finished attempts (the same* entries logged to `requests`); `writeUsage` is false when a run spent nothing (stats are still kept).* `runId`/`traceId` name the run a reading is written for (server/usage-samples.js): with a runId the-- * run's platform.usage-sample@1 reading commits with its usage.++ * run's platform.usage-sample@1 reading commits with its usage. Before it, the run's tokens claim the subject's++ * provider free allowance (server/free-allowance.js, §2.1.8): the free share is the reading's free_allowance_used++ * and its list price is left out of the reading's cost_estimate. Pricing only: the counters above are unchanged.*/async function account(ctx, reserved, { provider, model, tokensIn = 0, tokensOut = 0, tokensCached = 0, cost = 0, attempts = null, writeUsage = true, runId = null, traceId = null }) {const tokens = tokensIn + tokensOut;@@@ -181,7 +184,10 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})for (const r of reserved || []) await bump.run(r.scope_type, r.scope_id, r.window, r.ws, r.workflow_prefix, 0, tokens, cost);if (writeUsage) await insDaily.run(day, `${ctx.requesterType}:${ctx.requesterId}`, ctx.attributionKey || '', ctx.workflowKey, provider || '', model || '', tokensIn, tokensOut, tokensCached, cost);// The run's platform.usage-sample@1 reading for Billing commits with its usage (server/usage-samples.js).-- if (writeUsage && runId) await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, provider, tokensIn, tokensOut, cost, at: clock.now(), traceId });++ if (writeUsage && runId) {++ const free = freeAllowance ? await freeAllowance.claim(subjectOf(ctx), provider, model, { in: Math.max(0, tokensIn - tokensCached), cached: tokensCached, out: tokensOut }, clock.now()) : { tokens: 0, usd: 0 };++ await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, provider, tokensIn, tokensOut, cost: Math.max(0, cost - free.usd), freeAllowanceUsed: free.tokens, at: clock.now(), traceId });++ }await recordStats(day, attempts);});}@@@ -259,7 +265,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})return { max_cost_usd: Math.min(...rows.map((r) => r.max_cost_usd)), spent, window: 'day' };}-- return { reserve, account, statsFor, list, upsert, usage, counters, paidBudget, WINDOWS };++ return { reserve, account, statsFor, list, upsert, usage, counters, paidBudget, freeAllowance, WINDOWS };}module.exports = { createQuotas, WINDOWS };@@@ -11,7 +11,8 @@* never carries the input, the output or a prompt. Readings are queued once init() ran, unless* USAGE_SAMPLES=off. The relay runs while OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET are set.* Runs on a person's own key, and runs that called no provider and were not a cache hit, are not-- * accounted and have no reading.++ * accounted and have no reading. free_allowance_used (§2.1.8) is the share the provider free allowance covered; the++ * cost_estimate the caller passes already leaves that share out.*/const { createPgOutbox } = require('openvibe-sdk/events');const { createServiceTokenClient } = require('openvibe-sdk/auth');@@@ -19,12 +20,15 @@ const contracts = require('openvibe-contracts');const TABLE = 'usage_sample_outbox'; const METRIC = 'tokens'; const SCHEMA = 'platform.usage-sample@1';const REFUSED = new Set([400, 409, 413, 422]); const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;let relay = null; let on = false; let pruneTimer = null; let lastError = null; let queued = 0;--function sampleOf({ runId, workflowKey, requester, provider = null, tokensIn = 0, tokensOut = 0, cost = 0, at, traceId = null }) {++function sampleOf({ runId, workflowKey, requester, provider = null, tokensIn = 0, tokensOut = 0, cost = 0, freeAllowanceUsed = 0, at, traceId = null }) {const key = `ai:${runId}:${METRIC}`;const s = { id: key, idempotency_key: key, service: 'ai', subject: requester, resource: runId, operation: workflowKey,quantity: Math.max(0, (Number(tokensIn) || 0) + (Number(tokensOut) || 0)), unit: 'tokens', at: new Date(at).toISOString(),cost_estimate: Math.max(0, Number(cost) || 0), source: 'ai.runs' };if (provider && provider !== 'mixed') s.provider = provider;++ // §2.1.8: the tokens the provider free allowance covered (server/free-allowance.js); absent when nothing was free.++ const free = Math.min(s.quantity, Math.max(0, Math.floor(Number(freeAllowanceUsed) || 0)));++ if (free > 0) s.free_allowance_used = free;if (traceId) s.trace_id = String(traceId);return s;}nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++// §2.1.8 bounded free allowance (server/free-allowance.js): a subject's tokens use the provider's free_allowance++// first, per metric and reset period; the free share is reported as free_allowance_used on the run's++// platform.usage-sample@1 and is never priced on its cost_estimate. Pricing only: no call is refused for it.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');++const { testDb } = require('./db');++const rates = require('../server/providers/rate-cards');++const { createFreeAllowance, subjectOf } = require('../server/free-allowance');++++const t = suite('free-allowance');++const live = token('live', ALL);++const usageSamples = require('../server/usage-samples');++let tdb;++let h;++let seam;++++const DAY = 86400000;++const at = (y, m, d, hh = 12) => Date.UTC(y, m - 1, d, hh);++// One provider's cards from an AI_PRICING_JSON default entry: in 1, cached 0.5, out 2 USD per million tokens.++const cardsWith = (terms) => async (provider, model) => rates.buildCards({ providerKey: provider, model, pricing: { table: { default: { in: 1, cached: 0.5, out: 2, ...terms } }, inputPerMtok: 0, outputPerMtok: 0 } });++const usedRows = async () => (await tdb.db.prepare('SELECT subject, provider, metric, period_start, period_end, free_used FROM free_allowance_usage ORDER BY subject, metric, period_start').all())++ .map((r) => ({ ...r, period_start: Number(r.period_start), period_end: Number(r.period_end), free_used: Number(r.free_used) }));++const clear = async () => { await tdb.db.prepare('DELETE FROM free_allowance_usage').run(); };++++t.test('a migrated database', async () => { tdb = await testDb(); });++++t.test('the subject is the attribution, else the actor, else the requester; none → nothing free', async () => {++ assert.strictEqual(subjectOf({ attributionKey: 'live:user:42', actorKey: 'user:usr_1', requesterType: 'service', requesterId: 'live' }), 'live:user:42');++ assert.strictEqual(subjectOf({ actorKey: 'user:usr_1', requesterType: 'service', requesterId: 'live' }), 'user:usr_1');++ assert.strictEqual(subjectOf({ requesterType: 'service', requesterId: 'live' }), 'service:live');++ assert.strictEqual(subjectOf({}), null);++ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });++ assert.deepStrictEqual(await fa.claim(null, 'p', 'm', { in: 50 }, at(2026, 10, 4)), { tokens: 0, usd: 0 });++ assert.deepStrictEqual(await usedRows(), []);++});++++t.test('free up to the allowance per metric, then fully priced; the real period is stored', async () => {++ await clear();++ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });++ const now = at(2026, 10, 4);++ let c = await fa.claim('s1', 'p', 'm', { in: 60, out: 30 }, now);++ assert.strictEqual(c.tokens, 90);++ assert.ok(Math.abs(c.usd - (60 * 1 + 30 * 2) / 1e6) < 1e-12, `usd ${c.usd}`);++ c = await fa.claim('s1', 'p', 'm', { in: 60, out: 30 }, now + 1000);++ assert.strictEqual(c.tokens, 40 + 30, 'input: the 40 left; output: 30 of the 70 left');++ c = await fa.claim('s1', 'p', 'm', { in: 60, cached: 10, out: 100 }, now + 2000);++ assert.strictEqual(c.tokens, 10 + 40, 'input exhausted; cached has its own 100; output the 40 left');++ c = await fa.claim('s1', 'p', 'm', { in: 60, out: 60 }, now + 3000);++ assert.deepStrictEqual(c, { tokens: 0, usd: 0 }, 'fully priced once exhausted');++ const rows = await usedRows();++ assert.deepStrictEqual(rows.map((r) => [r.metric, r.free_used]), [['cached-input-tokens:m', 10], ['input-tokens:m', 100], ['output-tokens:m', 100]]);++ for (const r of rows) assert.deepStrictEqual([r.period_start, r.period_end], [Date.UTC(2026, 9, 4), Date.UTC(2026, 9, 5)]);++});++++t.test('the counter resets at the period boundary: day and month; none never resets', async () => {++ await clear();++ const day = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });++ assert.strictEqual((await day.claim('s1', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 4, 23, 59, 59))).tokens, 100);++ assert.strictEqual((await day.claim('s1', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 4, 23, 59, 59, 999))).tokens, 0);++ assert.strictEqual((await day.claim('s1', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 5))).tokens, 100, 'a new UTC day');++++ const month = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'month' }) });++ assert.strictEqual((await month.claim('s2', 'p', 'm', { in: 500 }, at(2026, 10, 1))).tokens, 100);++ assert.strictEqual((await month.claim('s2', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 31, 23, 59))).tokens, 0, 'same month');++ assert.strictEqual((await month.claim('s2', 'p', 'm', { in: 500 }, Date.UTC(2026, 10, 1))).tokens, 100, 'a new UTC month');++ const m = (await usedRows()).filter((r) => r.subject === 's2').map((r) => [r.period_start, r.period_end]);++ assert.deepStrictEqual(m, [[Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1)], [Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1)]]);++++ const none = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'none' }) });++ assert.strictEqual((await none.claim('s3', 'p', 'm', { in: 500 }, at(2026, 10, 4))).tokens, 100);++ assert.strictEqual((await none.claim('s3', 'p', 'm', { in: 500 }, at(2026, 10, 5))).tokens, 0, 'not the next day');++ assert.strictEqual((await none.claim('s3', 'p', 'm', { in: 500 }, at(2026, 10, 4) + 800 * DAY)).tokens, 0, 'not past the usage lookback either');++ const terms = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'none', effective_from: '2027-01-01' }) });++ assert.strictEqual((await terms.claim('s3', 'p', 'm', { in: 500 }, at(2027, 1, 2))).tokens, 100, 'a card with new terms is a new allowance');++});++++t.test('free_allowance 0 (the default) claims nothing and writes nothing', async () => {++ await clear();++ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({}) });++ assert.deepStrictEqual(await fa.claim('s1', 'p', 'm', { in: 100, cached: 10, out: 100 }, at(2026, 10, 4)), { tokens: 0, usd: 0 });++ const unbilled = createFreeAllowance(tdb.db, { cardsFor: async () => null });++ assert.deepStrictEqual(await unbilled.claim('s1', 'p', 'm', { in: 100 }, at(2026, 10, 4)), { tokens: 0, usd: 0 });++ assert.deepStrictEqual(await usedRows(), []);++});++++t.test('two subjects never share an allowance', async () => {++ await clear();++ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'month' }) });++ const now = at(2026, 10, 4);++ assert.strictEqual((await fa.claim('live:user:1', 'p', 'm', { out: 150 }, now)).tokens, 100);++ assert.strictEqual((await fa.claim('live:user:2', 'p', 'm', { out: 150 }, now)).tokens, 100);++ assert.strictEqual((await fa.claim('live:user:1', 'p', 'm', { out: 150 }, now)).tokens, 0);++ assert.strictEqual((await fa.claim('live:user:1', 'other', 'm', { out: 150 }, now)).tokens, 100, 'nor two providers');++});++++t.test('concurrent claims never exceed the cap in total', async () => {++ await clear();++ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'month' }) });++ const now = at(2026, 10, 4);++ // Each claim in its own transaction (as account() makes it), all at once: on PostgreSQL they race for the row.++ const got = await Promise.all(Array.from({ length: 12 }, () => tdb.db.tx(async () => (await fa.claim('s1', 'p', 'm', { in: 30, out: 30 }, now)).tokens)));++ assert.strictEqual(got.reduce((a, b) => a + b, 0), 200, `claims ${got.join(',')}`);++ assert.deepStrictEqual((await usedRows()).map((r) => r.free_used), [100, 100]);++ await tdb.close();++});++++// End to end: a run's reading carries free_allowance_used, and its cost_estimate leaves the free share out.++const env = { AI_STUB_FALLBACK: 'false' };++const readings = async () => (await h.db.prepare('SELECT envelope FROM usage_sample_outbox ORDER BY id').all())++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));++const gen = async () => await request(h.base, 'POST', '/api/v1/generate', { tok: live, body: { prompt: `p${Math.random()}`, options: { cache: false } } });++++t.test('boot with a seam provider whose model has a free allowance of 100 tokens a day', async () => {++ seam = await seamServer(() => ({ text: 'ok', usage: { input: 1000, output: 1000 } }));++ h = await boot({ env });++ await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['generate', 'chat'] } });++ let r = await request(h.base, 'POST', '/api/v1/models', { tok: live, body: { provider_key: 'seam', model_key: 'm1', cost: { in_per_mtok: 1000, out_per_mtok: 1000, free_allowance: 100, reset_period: 'day' } } });++ assert.ok(r.status < 300, r.text);++ r = await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam', model: 'm1' }, fallbacks: [] } });++ assert.strictEqual(r.status, 201, r.text);++});++++t.test('the first run is free up to the allowance per metric, the next one is fully priced', async () => {++ assert.strictEqual((await gen()).status, 201);++ assert.strictEqual((await gen()).status, 201);++ const [a, b] = await readings();++ for (const s of [a, b]) assert.ok(contracts.validate('platform.usage-sample@1', s).valid, JSON.stringify(s));++ assert.strictEqual(a.quantity, 2000);++ assert.strictEqual(a.free_allowance_used, 200, '100 input + 100 output tokens free');++ assert.ok(Math.abs(a.cost_estimate - 1.8) < 1e-9, `the free share is not priced: ${a.cost_estimate}`);++ assert.ok(!('free_allowance_used' in b), 'nothing free: the field is absent');++ assert.ok(Math.abs(b.cost_estimate - 2) < 1e-9, `fully priced: ${b.cost_estimate}`);++ const rows = await h.quotas.freeAllowance.current();++ assert.deepStrictEqual(rows.map((r) => [r.subject, r.provider, r.metric, r.free_used, r.free_allowance, r.remaining]),++ [['service:live', 'seam', 'input-tokens:m1', 100, 100, 0], ['service:live', 'seam', 'output-tokens:m1', 100, 100, 0]]);++ // Pricing, never admission: the run cost and the request counters are what they always were.++ const run = await h.db.prepare('SELECT cost_usd FROM runs ORDER BY created_at DESC LIMIT 1').get();++ assert.ok(Math.abs(Number(run.cost_usd) - 2) < 1e-9);++});++++t.test('shutdown', async () => { usageSamples._reset(); await h.stop(); await seam.close(); });++++t.run();@@@ -127,6 +127,20 @@ t.test('the outbox outlives a restart', async () => {assert.strictEqual(b.got.filter((s) => s.idempotency_key === key).length, 1, 'sent exactly once');});++t.test('free_allowance_used: the free share when some was free, never more than the quantity, absent at 0', () => {++ const base = { runId: 'run_x', workflowKey: 'ai.generate', requester: 'service:live', provider: 'seam', tokensIn: 300, tokensOut: 100, cost: 0.1, at: Date.UTC(2026, 9, 4) };++ const some = usageSamples.sampleOf({ ...base, freeAllowanceUsed: 150 });++ assert.strictEqual(some.free_allowance_used, 150);++ assert.ok(contracts.validate('platform.usage-sample@1', some).valid);++ assert.strictEqual(usageSamples.sampleOf({ ...base, freeAllowanceUsed: 9999 }).free_allowance_used, 400);++ assert.ok(!('free_allowance_used' in usageSamples.sampleOf({ ...base, freeAllowanceUsed: 0 })));++ assert.ok(!('free_allowance_used' in usageSamples.sampleOf(base)));++});++++t.test('runs on default cards (free_allowance 0) carry no free_allowance_used', async () => {++ for (const r of await readings()) assert.ok(!('free_allowance_used' in r.envelope), JSON.stringify(r.envelope));++});++t.test('shutdown', async () => { usageSamples._reset(); await h.stop(); await seam.close(); });t.run();
@@ -221,6 +221,16 @@ a second upstream exists, an unavailable sole provider answers `503 provider.una
@@ -221,6 +221,16 @@ a second upstream exists, an unavailable sole provider answers `503 provider.una
uses separate local and paid pools so paid work still requires its explicit budget. The synthetic
uses separate local and paid pools so paid work still requires its explicit budget. The synthetic
stub can join general text routes as a last resort outside production only (`AI_STUB_FALLBACK`).
stub can join general text routes as a last resort outside production only (`AI_STUB_FALLBACK`).
+Free allowance (§2.1.8): a rate card's `free_allowance` (tokens per metric, from a models row or
+`AI_PRICING_JSON`) is free provider capacity for **each subject** (the run's attribution, else its
+actor, else its requester) per provider, metric and `reset_period` (`day` and `month` reset at the UTC
+boundary; `none` never resets for the card's terms). An accounted run claims it first, atomically, in
+the same transaction as its usage (`free_allowance_usage`, `server/free-allowance.js`); the free tokens
+are the reading's `free_allowance_used` on its `platform.usage-sample@1`, and its `cost_estimate` leaves
+their price out. It is pricing, never admission: quotas, request counting and the run's own cost are
+unchanged, and no call is ever refused because the allowance is used up. Every card defaults to 0, so
+nothing is free and the field is absent until an operator configures an allowance.
+
## Workflows
## Workflows
- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `blog.draft_post`,
- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `blog.draft_post`,
@@ -279,6 +289,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart
@@ -279,6 +289,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart
| Edits are new versions; runs pin versions; lifecycle; admin versions survive reseeding | `test/versioning.test.js` |
| Edits are new versions; runs pin versions; lifecycle; admin versions survive reseeding | `test/versioning.test.js` |
| A degraded primary falls back and records it; breaker; skips; timeouts; explicit `provider.unavailable` | `test/fallback.test.js` |
| A degraded primary falls back and records it; breaker; skips; timeouts; explicit `provider.unavailable` | `test/fallback.test.js` |
| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
+| Free allowance: free up to the card's allowance then fully priced, reset per day/month (never for `none`), per subject, never past the cap under concurrent claims, absent at 0 | `test/free-allowance.test.js` |
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| Queue caps: one caller cannot fill the run queue (per-caller and global 429 `queue.full` + Retry-After) | `test/queue.test.js` |
| Queue caps: one caller cannot fill the run queue (per-caller and global 429 `queue.full` + Retry-After) | `test/queue.test.js` |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` |
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+# Cutover — provider free allowance (`migrations/0006_free_allowance.sql`, T6 step 2)
+
+The change: an accounted run first claims its subject's provider free allowance (§2.1.8: the rate
+card's `free_allowance` per provider, metric and `reset_period`) in the transaction that accounts
+it. The free tokens are reported as `free_allowance_used` on the run's `platform.usage-sample@1`
+and their price is left out of its `cost_estimate`. Pricing only: quotas, request counting and the
+run's own cost are unchanged, and no call is refused because of it. Details in
+`server/free-allowance.js`.
+
+## What the migration changes
+
+`migrations/0006_free_allowance.sql` is an **expand** migration, additive and transactional:
+
+- a new table `free_allowance_usage (subject, provider, metric, period_start, period_end, free_used,
+ updated_at)`, primary key `(subject, provider, metric, period_start)`, one row per subject,
+ provider, metric and reset period;
+- one index, `idx_free_allowance_period (period_end)`, for the current periods.
+
+No existing table or row is touched, no data moves. The previous release (N-1) does not know the
+table, so a rollback to it needs no data change. The only writer is the AI service itself: there is
+no import, no dual-write window and no backfill. Every rate card defaults to `free_allowance` 0, so
+until an operator configures an allowance the table stays empty and readings are unchanged.
+
+## Order
+
+1. **Preconditions (pre-merge).** CI green on the branch head; the PR body carries the `cutover`
+ manifest; the rehearsal below recorded green. Billing does not have to change:
+ `free_allowance_used` is an optional field of `platform.usage-sample@1` it already accepts.
+
+2. **Backup (before anything touches the schema).**
+ `ov access run openvibe-ovh db-backup ai` — ovhost takes a backup of AI's declared database and
+ logs it. Keep the backup id/artifact for the deployment until the verify step is signed off.
+ By hand (owner role, same result): `pg_dump --format=custom --no-owner "$DATABASE_DIRECT_URL" >
+ ai-<utc-timestamp>.dump`, then `pg_restore --list` on it to prove it is complete. This is the
+ only backup the cutover needs; the migration itself loses nothing.
+
+3. **Rehearse on a copy.** Restore the backup into a scratch database (`pg_restore` into
+ `ai-rehearsal`, or copy `/…/data/pglite`), start the release against it as the owner
+ (`DATABASE_URL`/`DATABASE_DIRECT_URL` pointed at the copy, `node server/index.js`), and run the
+ verify checks below against the copy. The migration is applied by the service at boot
+ (`server/db.js` `openDb()`), as the owner on the direct connection, in a transaction, serialised
+ by the SDK's advisory lock, and recorded in `ov_migrations` with a checksum. Whoever runs this
+ writes the marker `ds/deploy/rehearsals/ai-free-allowance-0006.json` as `{"ok": true}` — the
+ author never writes it.
+
+4. **Apply in production.** Merged work deploys through its pipeline (the recipe runs with every
+ guard). The migration runs on the next service start, in the same boot path as the rehearsal.
+ No manual `psql` step.
+
+5. **Verify** (see below). Sign the deploy off only when the schema checks pass and one run on
+ default cards still produces a reading without `free_allowance_used`.
+
+## How the result is verified
+
+Schema, as the owner on the direct connection:
+
+```sql
+SELECT id, name, phase, applied_at FROM ov_migrations WHERE id = '0006'; -- one row, phase expand
+\d free_allowance_usage -- table, primary key + the index
+SELECT count(*) FROM free_allowance_usage; -- 0 while every card's allowance is 0
+```
+
+Service and follow-through:
+
+- `ov access run openvibe-ovh health ai` → ready (`GET /api/ready`).
+- `ov access run openvibe-ovh journal openvibe-ai.service 200` → no migration error.
+- One accounted run on default cards: its reading has no `free_allowance_used` and the same
+ `cost_estimate` as before —
+ `SELECT envelope FROM usage_sample_outbox ORDER BY id DESC LIMIT 1;`.
+- Once an operator sets an allowance (a models row's `cost.free_allowance`/`reset_period`, or
+ `AI_PRICING_JSON`), one run against it: a row per metric appears with `free_used` never above the
+ card's `free_allowance`, and the run's reading carries `free_allowance_used` equal to the free
+ tokens.
+
+ ```sql
+ SELECT subject, provider, metric, period_start, period_end, free_used FROM free_allowance_usage ORDER BY updated_at DESC LIMIT 5;
+ ```
+
+## Way back
+
+- **Roll back the release (the normal way).** The migration is expand and additive and N-1 ignores
+ the table, so `ov access run openvibe-ovh rollback ai` (owner) is enough; leave the table in
+ place, it is harmless. N-1 prices every token again; readings it writes carry no
+ `free_allowance_used`.
+- **Stop granting free tokens without changing code or schema:** set the cards' `free_allowance`
+ back to 0 (the models row or `AI_PRICING_JSON`) and restart — nothing more is claimed; the rows
+ already written stay as the record of what was free.
+- **Only if the table itself must go** (not required for a rollback), with the owner's approval and
+ the backup in hand, after the previous release is running:
+
+ ```sql
+ DROP TABLE IF EXISTS free_allowance_usage;
+ DELETE FROM ov_migrations WHERE id = '0006';
+ ```
+
+ The only loss is the record of free tokens used in the current periods (a later release would
+ grant them again); `usage_daily`, the run ledger and the readings are untouched.
+- **Never edit `0006_free_allowance.sql` in place:** `ov_migrations` records its checksum and the
+ runner refuses a changed file. Undo with new SQL (or a later migration), not by rewriting it.
+
+## Notes
+
+- Not a `migrate`/`contract` pair: nothing depends on the new table outside this service and
+ nothing needs the N-1 window, so no follow-up migration is scheduled.
+- `deploy/` and `.env.example` need no change: no new env; `AI_PRICING_JSON` already documents
+ `free_allowance` and `reset_period`.
+- T6 step 8 (`openvibe-sdk/govern` tiers) moves this counter's store; its semantics and the
+ reading's field stay.
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- T6 step 2 / §2.1.8: the provider free allowance a subject has used, per provider, metric and reset period
+-- (server/free-allowance.js). account() claims from it in the transaction that accounts a run; the free share is
+-- reported as free_allowance_used on the run's platform.usage-sample@1 and never priced on its cost_estimate.
+-- Pricing only, never admission: no request is refused because of this table. Additive only: a new table.
+CREATE TABLE IF NOT EXISTS free_allowance_usage (
+ subject text COLLATE "C" NOT NULL, -- attributionKey | actorKey | requesterType:requesterId
+ provider text COLLATE "C" NOT NULL,
+ metric text COLLATE "C" NOT NULL, -- the card's metric: input-tokens[:model] | cached-input-tokens[:model] | output-tokens[:model]
+ period_start bigint NOT NULL, -- epoch ms: the card's reset period start
+ period_end bigint NOT NULL, -- epoch ms: its end (reset_period 'none': the card's effective_until, else never)
+ free_used bigint NOT NULL DEFAULT 0, -- tokens, never more than the card's free_allowance
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (subject, provider, metric, period_start)
+);
+CREATE INDEX IF NOT EXISTS idx_free_allowance_period ON free_allowance_usage (period_end);
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+/**
+ * §2.1.8 bounded free allowance (T6 step 2). Each platform.rate-card@1's free_allowance/reset_period
+ * (server/providers/rate-cards.js) is provider capacity a subject uses free before its spend is priced. The
+ * counter is per subject + provider + metric + reset period (free_allowance_usage, migrations/0006). account()
+ * claims from it inside its own transaction; the free tokens are reported as free_allowance_used on the run's
+ * platform.usage-sample@1 and their list price is left out of its cost_estimate.
+ *
+ * Pricing only, never admission: reserve() and the request counters are untouched and no call is refused here.
+ * A claim is atomic: the period's row is created if missing, then locked (SELECT … FOR UPDATE) before the free
+ * share is computed, so concurrent claims for one subject, provider, metric and period never exceed the card's
+ * free_allowance in total.
+ *
+ * const free = createFreeAllowance(db, { cardsFor: pool.rateCardsFor });
+ * const { tokens, usd } = await free.claim(subject, provider, model, { in, cached, out }); // inside db.tx
+ */
+const rates = require('./providers/rate-cards');
+
+const KINDS = ['in', 'cached', 'out'];
+const NEVER = 8.64e15; // the last instant a Date can hold: the end of a 'none' period with no effective_until
+
+/** Who an allowance belongs to: the attribution, else the actor, else the requester; null → nothing is free. */
+function subjectOf(ctx = {}) {
+ if (ctx.attributionKey) return String(ctx.attributionKey);
+ if (ctx.actorKey) return String(ctx.actorKey);
+ return ctx.requesterType && ctx.requesterId ? `${ctx.requesterType}:${ctx.requesterId}` : null;
+}
+
+/**
+ * The card's allowance period holding `now`: periodOf's UTC day or month. 'none' never resets, so it is one
+ * period for the card's terms, from effective_from (unknown: the epoch) to effective_until (else never), not
+ * periodOf's rolling usage lookback, whose start moves every day.
+ */
+function periodFor(card, now) {
+ const p = rates.periodOf(card.reset_period, now, card.effective_from);
+ if (card.reset_period !== 'none') return p;
+ const from = Date.parse(card.effective_from);
+ const until = card.effective_until ? Date.parse(card.effective_until) : NaN;
+ return { start: Number.isFinite(from) ? from : 0, end: Number.isFinite(until) ? until : NEVER };
+}
+
+function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() } }) {
+ const open = db.prepare(`INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at)
+ VALUES (?, ?, ?, ?, ?, 0, ?) ON CONFLICT (subject, provider, metric, period_start) DO NOTHING`);
+ const lock = db.prepare('SELECT free_used FROM free_allowance_usage WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ? FOR UPDATE');
+ const take = db.prepare('UPDATE free_allowance_usage SET free_used = free_used + ?, updated_at = ? WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ?');
+
+ /**
+ * Claim this attempt's free tokens: usage { in (excluding cached), cached, out } on `provider`/`model`.
+ * Returns { tokens, usd }: the free tokens and their list price on the same cards. A provider with no
+ * cards (never billed) or a card with free_allowance 0 claims nothing and writes nothing.
+ */
+ async function claim(subject, provider, model, usage = {}, now = clock.now()) {
+ const out = { tokens: 0, usd: 0 };
+ if (!subject || !provider) return out;
+ const cards = await cardsFor(provider, model || null);
+ if (!cards) return out;
+ const at = new Date(now).toISOString();
+ await db.tx(async () => {
+ for (const kind of KINDS) { // one fixed order, so two claims lock rows in the same order
+ const c = cards[kind];
+ const cap = Math.floor(Number(c && c.free_allowance) || 0);
+ const q = Math.max(0, Math.floor(Number(usage[kind]) || 0));
+ if (cap <= 0 || q <= 0) continue;
+ const p = periodFor(c, now);
+ await open.run(subject, provider, c.metric, p.start, p.end, at);
+ const row = await lock.get(subject, provider, c.metric, p.start);
+ const free = Math.min(q, Math.max(0, cap - Number(row.free_used)));
+ if (!free) continue;
+ await take.run(free, at, subject, provider, c.metric, p.start);
+ out.tokens += free;
+ out.usd += (free / c.unit_size) * c.unit_price_usd;
+ }
+ });
+ return out;
+ }
+
+ /**
+ * Free used and remaining in the current periods, for the staff console (never a public page: rows are per
+ * subject). Rows: { subject, provider, metric, period_start, period_end, free_used, free_allowance, remaining }.
+ */
+ async function current({ now = clock.now(), limit = 200 } = {}) {
+ const rows = await db.prepare(`SELECT subject, provider, metric, period_start, period_end, free_used FROM free_allowance_usage
+ WHERE period_start <= ? AND period_end > ? ORDER BY provider, metric, subject LIMIT ?`).all(now, now, limit);
+ const byKind = { 'input-tokens': 'in', 'cached-input-tokens': 'cached', 'output-tokens': 'out' };
+ const out = [];
+ for (const r of rows) {
+ const i = r.metric.indexOf(':');
+ const cards = await cardsFor(r.provider, i < 0 ? null : r.metric.slice(i + 1));
+ const c = cards && cards[byKind[i < 0 ? r.metric : r.metric.slice(0, i)]];
+ const cap = c ? Number(c.free_allowance) || 0 : 0;
+ out.push({ ...r, period_start: Number(r.period_start), period_end: Number(r.period_end), free_used: Number(r.free_used), free_allowance: cap, remaining: Math.max(0, cap - Number(r.free_used)) });
+ }
+ return out;
+ }
+
+ return { claim, current };
+}
+
+module.exports = { createFreeAllowance, subjectOf, periodFor };
@@ -15,6 +15,7 @@ const { createFetcher } = require('./fetcher');
@@ -15,6 +15,7 @@ const { createFetcher } = require('./fetcher');
const { createEngine } = require('./workflows/engine');
const { createEngine } = require('./workflows/engine');
const { createCache } = require('./cache');
const { createCache } = require('./cache');
const { createQuotas } = require('./quota');
const { createQuotas } = require('./quota');
+const { createFreeAllowance } = require('./free-allowance');
const { createRuns } = require('./runs');
const { createRuns } = require('./runs');
const { seed } = require('./workflows/seed');
const { seed } = require('./workflows/seed');
const { createAuth, createNetworkKeys } = require('./auth');
const { createAuth, createNetworkKeys } = require('./auth');
@@ -29,9 +30,11 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -29,9 +30,11 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const db = givenDb || await openDb(config, { log });
const registry = createRegistry(db, { clock, env });
const registry = createRegistry(db, { clock, env });
- const quotas = createQuotas(db, { clock, registry });
- const cache = createCache(db, { clock });
const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch });
const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch });
+ // §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js).
+ const freeAllowance = createFreeAllowance(db, { cardsFor: pool.rateCardsFor, clock });
+ const quotas = createQuotas(db, { clock, registry, freeAllowance });
+ const cache = createCache(db, { clock });
const fetcher = createFetcher(config);
const fetcher = createFetcher(config);
const engine = createEngine({ registry, pool, fetcher, quotas, config, log });
const engine = createEngine({ registry, pool, fetcher, quotas, config, log });
// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.
// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
*/
*/
const { AiError, iso } = require('./util');
const { AiError, iso } = require('./util');
const usageSamples = require('./usage-samples');
const usageSamples = require('./usage-samples');
+const { subjectOf } = require('./free-allowance');
const WINDOWS = { minute: 60, hour: 3600, day: 86400 };
const WINDOWS = { minute: 60, hour: 3600, day: 86400 };
@@ -47,7 +48,7 @@ function estimatePercentile(hist, q) {
@@ -47,7 +48,7 @@ function estimatePercentile(hist, q) {
return LATENCY_BUCKETS_MS[LATENCY_BUCKETS_MS.length - 1];
return LATENCY_BUCKETS_MS[LATENCY_BUCKETS_MS.length - 1];
}
}
-function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {}) {
+function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAllowance = null } = {}) {
const nowSec = () => Math.floor(clock.now() / 1000);
const nowSec = () => Math.floor(clock.now() / 1000);
const windowStart = (w, t = nowSec()) => Math.floor(t / WINDOWS[w]) * WINDOWS[w];
const windowStart = (w, t = nowSec()) => Math.floor(t / WINDOWS[w]) * WINDOWS[w];
@@ -172,7 +173,9 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})
@@ -172,7 +173,9 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})
* provider rollup and per-route placement state. `attempts` is the run's finished attempts (the same
* provider rollup and per-route placement state. `attempts` is the run's finished attempts (the same
* entries logged to `requests`); `writeUsage` is false when a run spent nothing (stats are still kept).
* entries logged to `requests`); `writeUsage` is false when a run spent nothing (stats are still kept).
* `runId`/`traceId` name the run a reading is written for (server/usage-samples.js): with a runId the
* `runId`/`traceId` name the run a reading is written for (server/usage-samples.js): with a runId the
- * run's platform.usage-sample@1 reading commits with its usage.
+ * run's platform.usage-sample@1 reading commits with its usage. Before it, the run's tokens claim the subject's
+ * provider free allowance (server/free-allowance.js, §2.1.8): the free share is the reading's free_allowance_used
+ * and its list price is left out of the reading's cost_estimate. Pricing only: the counters above are unchanged.
*/
*/
async function account(ctx, reserved, { provider, model, tokensIn = 0, tokensOut = 0, tokensCached = 0, cost = 0, attempts = null, writeUsage = true, runId = null, traceId = null }) {
async function account(ctx, reserved, { provider, model, tokensIn = 0, tokensOut = 0, tokensCached = 0, cost = 0, attempts = null, writeUsage = true, runId = null, traceId = null }) {
const tokens = tokensIn + tokensOut;
const tokens = tokensIn + tokensOut;
@@ -181,7 +184,10 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})
@@ -181,7 +184,10 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})
for (const r of reserved || []) await bump.run(r.scope_type, r.scope_id, r.window, r.ws, r.workflow_prefix, 0, tokens, cost);
for (const r of reserved || []) await bump.run(r.scope_type, r.scope_id, r.window, r.ws, r.workflow_prefix, 0, tokens, cost);
if (writeUsage) await insDaily.run(day, `${ctx.requesterType}:${ctx.requesterId}`, ctx.attributionKey || '', ctx.workflowKey, provider || '', model || '', tokensIn, tokensOut, tokensCached, cost);
if (writeUsage) await insDaily.run(day, `${ctx.requesterType}:${ctx.requesterId}`, ctx.attributionKey || '', ctx.workflowKey, provider || '', model || '', tokensIn, tokensOut, tokensCached, cost);
// The run's platform.usage-sample@1 reading for Billing commits with its usage (server/usage-samples.js).
// The run's platform.usage-sample@1 reading for Billing commits with its usage (server/usage-samples.js).
- if (writeUsage && runId) await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, provider, tokensIn, tokensOut, cost, at: clock.now(), traceId });
+ if (writeUsage && runId) {
+ const free = freeAllowance ? await freeAllowance.claim(subjectOf(ctx), provider, model, { in: Math.max(0, tokensIn - tokensCached), cached: tokensCached, out: tokensOut }, clock.now()) : { tokens: 0, usd: 0 };
+ await usageSamples.record(db, { runId, workflowKey: ctx.workflowKey, requester: `${ctx.requesterType}:${ctx.requesterId}`, provider, tokensIn, tokensOut, cost: Math.max(0, cost - free.usd), freeAllowanceUsed: free.tokens, at: clock.now(), traceId });
+ }
await recordStats(day, attempts);
await recordStats(day, attempts);
});
});
}
}
@@ -259,7 +265,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})
@@ -259,7 +265,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {})
return { max_cost_usd: Math.min(...rows.map((r) => r.max_cost_usd)), spent, window: 'day' };
return { max_cost_usd: Math.min(...rows.map((r) => r.max_cost_usd)), spent, window: 'day' };
}
}
- return { reserve, account, statsFor, list, upsert, usage, counters, paidBudget, WINDOWS };
+ return { reserve, account, statsFor, list, upsert, usage, counters, paidBudget, freeAllowance, WINDOWS };
}
}
module.exports = { createQuotas, WINDOWS };
module.exports = { createQuotas, WINDOWS };
@@ -11,7 +11,8 @@
@@ -11,7 +11,8 @@
* never carries the input, the output or a prompt. Readings are queued once init() ran, unless
* never carries the input, the output or a prompt. Readings are queued once init() ran, unless
* USAGE_SAMPLES=off. The relay runs while OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET are set.
* USAGE_SAMPLES=off. The relay runs while OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET are set.
* Runs on a person's own key, and runs that called no provider and were not a cache hit, are not
* Runs on a person's own key, and runs that called no provider and were not a cache hit, are not
- * accounted and have no reading.
+ * accounted and have no reading. free_allowance_used (§2.1.8) is the share the provider free allowance covered; the
+ * cost_estimate the caller passes already leaves that share out.
*/
*/
const { createPgOutbox } = require('openvibe-sdk/events');
const { createPgOutbox } = require('openvibe-sdk/events');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
@@ -19,12 +20,15 @@ const contracts = require('openvibe-contracts');
@@ -19,12 +20,15 @@ const contracts = require('openvibe-contracts');
const TABLE = 'usage_sample_outbox'; const METRIC = 'tokens'; const SCHEMA = 'platform.usage-sample@1';
const TABLE = 'usage_sample_outbox'; const METRIC = 'tokens'; const SCHEMA = 'platform.usage-sample@1';
const REFUSED = new Set([400, 409, 413, 422]); const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;
const REFUSED = new Set([400, 409, 413, 422]); const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;
let relay = null; let on = false; let pruneTimer = null; let lastError = null; let queued = 0;
let relay = null; let on = false; let pruneTimer = null; let lastError = null; let queued = 0;
-function sampleOf({ runId, workflowKey, requester, provider = null, tokensIn = 0, tokensOut = 0, cost = 0, at, traceId = null }) {
+function sampleOf({ runId, workflowKey, requester, provider = null, tokensIn = 0, tokensOut = 0, cost = 0, freeAllowanceUsed = 0, at, traceId = null }) {
const key = `ai:${runId}:${METRIC}`;
const key = `ai:${runId}:${METRIC}`;
const s = { id: key, idempotency_key: key, service: 'ai', subject: requester, resource: runId, operation: workflowKey,
const s = { id: key, idempotency_key: key, service: 'ai', subject: requester, resource: runId, operation: workflowKey,
quantity: Math.max(0, (Number(tokensIn) || 0) + (Number(tokensOut) || 0)), unit: 'tokens', at: new Date(at).toISOString(),
quantity: Math.max(0, (Number(tokensIn) || 0) + (Number(tokensOut) || 0)), unit: 'tokens', at: new Date(at).toISOString(),
cost_estimate: Math.max(0, Number(cost) || 0), source: 'ai.runs' };
cost_estimate: Math.max(0, Number(cost) || 0), source: 'ai.runs' };
if (provider && provider !== 'mixed') s.provider = provider;
if (provider && provider !== 'mixed') s.provider = provider;
+ // §2.1.8: the tokens the provider free allowance covered (server/free-allowance.js); absent when nothing was free.
+ const free = Math.min(s.quantity, Math.max(0, Math.floor(Number(freeAllowanceUsed) || 0)));
+ if (free > 0) s.free_allowance_used = free;
if (traceId) s.trace_id = String(traceId);
if (traceId) s.trace_id = String(traceId);
return s;
return s;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+// §2.1.8 bounded free allowance (server/free-allowance.js): a subject's tokens use the provider's free_allowance
+// first, per metric and reset period; the free share is reported as free_allowance_used on the run's
+// platform.usage-sample@1 and is never priced on its cost_estimate. Pricing only: no call is refused for it.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
+const { testDb } = require('./db');
+const rates = require('../server/providers/rate-cards');
+const { createFreeAllowance, subjectOf } = require('../server/free-allowance');
+
+const t = suite('free-allowance');
+const live = token('live', ALL);
+const usageSamples = require('../server/usage-samples');
+let tdb;
+let h;
+let seam;
+
+const DAY = 86400000;
+const at = (y, m, d, hh = 12) => Date.UTC(y, m - 1, d, hh);
+// One provider's cards from an AI_PRICING_JSON default entry: in 1, cached 0.5, out 2 USD per million tokens.
+const cardsWith = (terms) => async (provider, model) => rates.buildCards({ providerKey: provider, model, pricing: { table: { default: { in: 1, cached: 0.5, out: 2, ...terms } }, inputPerMtok: 0, outputPerMtok: 0 } });
+const usedRows = async () => (await tdb.db.prepare('SELECT subject, provider, metric, period_start, period_end, free_used FROM free_allowance_usage ORDER BY subject, metric, period_start').all())
+ .map((r) => ({ ...r, period_start: Number(r.period_start), period_end: Number(r.period_end), free_used: Number(r.free_used) }));
+const clear = async () => { await tdb.db.prepare('DELETE FROM free_allowance_usage').run(); };
+
+t.test('a migrated database', async () => { tdb = await testDb(); });
+
+t.test('the subject is the attribution, else the actor, else the requester; none → nothing free', async () => {
+ assert.strictEqual(subjectOf({ attributionKey: 'live:user:42', actorKey: 'user:usr_1', requesterType: 'service', requesterId: 'live' }), 'live:user:42');
+ assert.strictEqual(subjectOf({ actorKey: 'user:usr_1', requesterType: 'service', requesterId: 'live' }), 'user:usr_1');
+ assert.strictEqual(subjectOf({ requesterType: 'service', requesterId: 'live' }), 'service:live');
+ assert.strictEqual(subjectOf({}), null);
+ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });
+ assert.deepStrictEqual(await fa.claim(null, 'p', 'm', { in: 50 }, at(2026, 10, 4)), { tokens: 0, usd: 0 });
+ assert.deepStrictEqual(await usedRows(), []);
+});
+
+t.test('free up to the allowance per metric, then fully priced; the real period is stored', async () => {
+ await clear();
+ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });
+ const now = at(2026, 10, 4);
+ let c = await fa.claim('s1', 'p', 'm', { in: 60, out: 30 }, now);
+ assert.strictEqual(c.tokens, 90);
+ assert.ok(Math.abs(c.usd - (60 * 1 + 30 * 2) / 1e6) < 1e-12, `usd ${c.usd}`);
+ c = await fa.claim('s1', 'p', 'm', { in: 60, out: 30 }, now + 1000);
+ assert.strictEqual(c.tokens, 40 + 30, 'input: the 40 left; output: 30 of the 70 left');
+ c = await fa.claim('s1', 'p', 'm', { in: 60, cached: 10, out: 100 }, now + 2000);
+ assert.strictEqual(c.tokens, 10 + 40, 'input exhausted; cached has its own 100; output the 40 left');
+ c = await fa.claim('s1', 'p', 'm', { in: 60, out: 60 }, now + 3000);
+ assert.deepStrictEqual(c, { tokens: 0, usd: 0 }, 'fully priced once exhausted');
+ const rows = await usedRows();
+ assert.deepStrictEqual(rows.map((r) => [r.metric, r.free_used]), [['cached-input-tokens:m', 10], ['input-tokens:m', 100], ['output-tokens:m', 100]]);
+ for (const r of rows) assert.deepStrictEqual([r.period_start, r.period_end], [Date.UTC(2026, 9, 4), Date.UTC(2026, 9, 5)]);
+});
+
+t.test('the counter resets at the period boundary: day and month; none never resets', async () => {
+ await clear();
+ const day = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }) });
+ assert.strictEqual((await day.claim('s1', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 4, 23, 59, 59))).tokens, 100);
+ assert.strictEqual((await day.claim('s1', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 4, 23, 59, 59, 999))).tokens, 0);
+ assert.strictEqual((await day.claim('s1', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 5))).tokens, 100, 'a new UTC day');
+
+ const month = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'month' }) });
+ assert.strictEqual((await month.claim('s2', 'p', 'm', { in: 500 }, at(2026, 10, 1))).tokens, 100);
+ assert.strictEqual((await month.claim('s2', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 31, 23, 59))).tokens, 0, 'same month');
+ assert.strictEqual((await month.claim('s2', 'p', 'm', { in: 500 }, Date.UTC(2026, 10, 1))).tokens, 100, 'a new UTC month');
+ const m = (await usedRows()).filter((r) => r.subject === 's2').map((r) => [r.period_start, r.period_end]);
+ assert.deepStrictEqual(m, [[Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1)], [Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1)]]);
+
+ const none = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'none' }) });
+ assert.strictEqual((await none.claim('s3', 'p', 'm', { in: 500 }, at(2026, 10, 4))).tokens, 100);
+ assert.strictEqual((await none.claim('s3', 'p', 'm', { in: 500 }, at(2026, 10, 5))).tokens, 0, 'not the next day');
+ assert.strictEqual((await none.claim('s3', 'p', 'm', { in: 500 }, at(2026, 10, 4) + 800 * DAY)).tokens, 0, 'not past the usage lookback either');
+ const terms = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'none', effective_from: '2027-01-01' }) });
+ assert.strictEqual((await terms.claim('s3', 'p', 'm', { in: 500 }, at(2027, 1, 2))).tokens, 100, 'a card with new terms is a new allowance');
+});
+
+t.test('free_allowance 0 (the default) claims nothing and writes nothing', async () => {
+ await clear();
+ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({}) });
+ assert.deepStrictEqual(await fa.claim('s1', 'p', 'm', { in: 100, cached: 10, out: 100 }, at(2026, 10, 4)), { tokens: 0, usd: 0 });
+ const unbilled = createFreeAllowance(tdb.db, { cardsFor: async () => null });
+ assert.deepStrictEqual(await unbilled.claim('s1', 'p', 'm', { in: 100 }, at(2026, 10, 4)), { tokens: 0, usd: 0 });
+ assert.deepStrictEqual(await usedRows(), []);
+});
+
+t.test('two subjects never share an allowance', async () => {
+ await clear();
+ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'month' }) });
+ const now = at(2026, 10, 4);
+ assert.strictEqual((await fa.claim('live:user:1', 'p', 'm', { out: 150 }, now)).tokens, 100);
+ assert.strictEqual((await fa.claim('live:user:2', 'p', 'm', { out: 150 }, now)).tokens, 100);
+ assert.strictEqual((await fa.claim('live:user:1', 'p', 'm', { out: 150 }, now)).tokens, 0);
+ assert.strictEqual((await fa.claim('live:user:1', 'other', 'm', { out: 150 }, now)).tokens, 100, 'nor two providers');
+});
+
+t.test('concurrent claims never exceed the cap in total', async () => {
+ await clear();
+ const fa = createFreeAllowance(tdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'month' }) });
+ const now = at(2026, 10, 4);
+ // Each claim in its own transaction (as account() makes it), all at once: on PostgreSQL they race for the row.
+ const got = await Promise.all(Array.from({ length: 12 }, () => tdb.db.tx(async () => (await fa.claim('s1', 'p', 'm', { in: 30, out: 30 }, now)).tokens)));
+ assert.strictEqual(got.reduce((a, b) => a + b, 0), 200, `claims ${got.join(',')}`);
+ assert.deepStrictEqual((await usedRows()).map((r) => r.free_used), [100, 100]);
+ await tdb.close();
+});
+
+// End to end: a run's reading carries free_allowance_used, and its cost_estimate leaves the free share out.
+const env = { AI_STUB_FALLBACK: 'false' };
+const readings = async () => (await h.db.prepare('SELECT envelope FROM usage_sample_outbox ORDER BY id').all())
+ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
+const gen = async () => await request(h.base, 'POST', '/api/v1/generate', { tok: live, body: { prompt: `p${Math.random()}`, options: { cache: false } } });
+
+t.test('boot with a seam provider whose model has a free allowance of 100 tokens a day', async () => {
+ seam = await seamServer(() => ({ text: 'ok', usage: { input: 1000, output: 1000 } }));
+ h = await boot({ env });
+ await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['generate', 'chat'] } });
+ let r = await request(h.base, 'POST', '/api/v1/models', { tok: live, body: { provider_key: 'seam', model_key: 'm1', cost: { in_per_mtok: 1000, out_per_mtok: 1000, free_allowance: 100, reset_period: 'day' } } });
+ assert.ok(r.status < 300, r.text);
+ r = await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam', model: 'm1' }, fallbacks: [] } });
+ assert.strictEqual(r.status, 201, r.text);
+});
+
+t.test('the first run is free up to the allowance per metric, the next one is fully priced', async () => {
+ assert.strictEqual((await gen()).status, 201);
+ assert.strictEqual((await gen()).status, 201);
+ const [a, b] = await readings();
+ for (const s of [a, b]) assert.ok(contracts.validate('platform.usage-sample@1', s).valid, JSON.stringify(s));
+ assert.strictEqual(a.quantity, 2000);
+ assert.strictEqual(a.free_allowance_used, 200, '100 input + 100 output tokens free');
+ assert.ok(Math.abs(a.cost_estimate - 1.8) < 1e-9, `the free share is not priced: ${a.cost_estimate}`);
+ assert.ok(!('free_allowance_used' in b), 'nothing free: the field is absent');
+ assert.ok(Math.abs(b.cost_estimate - 2) < 1e-9, `fully priced: ${b.cost_estimate}`);
+ const rows = await h.quotas.freeAllowance.current();
+ assert.deepStrictEqual(rows.map((r) => [r.subject, r.provider, r.metric, r.free_used, r.free_allowance, r.remaining]),
+ [['service:live', 'seam', 'input-tokens:m1', 100, 100, 0], ['service:live', 'seam', 'output-tokens:m1', 100, 100, 0]]);
+ // Pricing, never admission: the run cost and the request counters are what they always were.
+ const run = await h.db.prepare('SELECT cost_usd FROM runs ORDER BY created_at DESC LIMIT 1').get();
+ assert.ok(Math.abs(Number(run.cost_usd) - 2) < 1e-9);
+});
+
+t.test('shutdown', async () => { usageSamples._reset(); await h.stop(); await seam.close(); });
+
+t.run();
@@ -127,6 +127,20 @@ t.test('the outbox outlives a restart', async () => {
@@ -127,6 +127,20 @@ t.test('the outbox outlives a restart', async () => {
assert.strictEqual(b.got.filter((s) => s.idempotency_key === key).length, 1, 'sent exactly once');
assert.strictEqual(b.got.filter((s) => s.idempotency_key === key).length, 1, 'sent exactly once');
});
});
+t.test('free_allowance_used: the free share when some was free, never more than the quantity, absent at 0', () => {
+ const base = { runId: 'run_x', workflowKey: 'ai.generate', requester: 'service:live', provider: 'seam', tokensIn: 300, tokensOut: 100, cost: 0.1, at: Date.UTC(2026, 9, 4) };
+ const some = usageSamples.sampleOf({ ...base, freeAllowanceUsed: 150 });
+ assert.strictEqual(some.free_allowance_used, 150);
+ assert.ok(contracts.validate('platform.usage-sample@1', some).valid);
+ assert.strictEqual(usageSamples.sampleOf({ ...base, freeAllowanceUsed: 9999 }).free_allowance_used, 400);
+ assert.ok(!('free_allowance_used' in usageSamples.sampleOf({ ...base, freeAllowanceUsed: 0 })));
+ assert.ok(!('free_allowance_used' in usageSamples.sampleOf(base)));
+});
+
+t.test('runs on default cards (free_allowance 0) carry no free_allowance_used', async () => {
+ for (const r of await readings()) assert.ok(!('free_allowance_used' in r.envelope), JSON.stringify(r.envelope));
+});
+
t.test('shutdown', async () => { usageSamples._reset(); await h.stop(); await seam.close(); });
t.test('shutdown', async () => { usageSamples._reset(); await h.stop(); await seam.close(); });
t.run();
t.run();