openvibers/openvibe.ai Commits

Claim the provider free allowance per subject before pricing usage

OP OpenVibers committed 4 Oct 2026 · dde3bec6b2 · 9 files, +415 −8

Asked: T6 step 2 — OpenVibe.AI: bounded provider free allowance (§2.1.8). Money path. ONE pull request from current origin/main (git fetch first; the local checkout may be stale, read with `git show origin/main:<path>`). The full design is the groundwork dossier /home/workstation/openvibe/agents/ds/prep/T…
Harness job ai-t6-step-2-openvibe-ai-172201 (plan T6).

Changes

ddiff --git a/README.md b/README.md
iindex 6a37874..ad18708 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
- **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` |
ddiff --git a/docs/cutover-pr-openvibe-ai-15.md b/docs/cutover-pr-openvibe-ai-15.md
nnew file mode 100644
iindex 0000000..c666dd4
---- /dev/null
++++ b/docs/cutover-pr-openvibe-ai-15.md
@@@ -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.
ddiff --git a/migrations/0006_free_allowance.sql b/migrations/0006_free_allowance.sql
nnew file mode 100644
iindex 0000000..7481594
---- /dev/null
++++ b/migrations/0006_free_allowance.sql
@@@ -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);
ddiff --git a/server/free-allowance.js b/server/free-allowance.js
nnew file mode 100644
iindex 0000000..14a120e
---- /dev/null
++++ b/server/free-allowance.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 09fe90a..fe45308 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/server/quota.js b/server/quota.js
iindex 27ac894..ed2ccf2 100644
---- a/server/quota.js
++++ b/server/quota.js
@@@ -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 };
ddiff --git a/server/usage-samples.js b/server/usage-samples.js
iindex 4f014c2..60ad08a 100644
---- a/server/usage-samples.js
++++ b/server/usage-samples.js
@@@ -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;
}
ddiff --git a/test/free-allowance.test.js b/test/free-allowance.test.js
nnew file mode 100644
iindex 0000000..9a80b42
---- /dev/null
++++ b/test/free-allowance.test.js
@@@ -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();
ddiff --git a/test/usage-sample.test.js b/test/usage-sample.test.js
iindex 0878023..8c21513 100644
---- a/test/usage-sample.test.js
++++ b/test/usage-sample.test.js
@@@ -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();