openvibers/openvibe.ai Commits

Hold govern tier budgets in Valkey and claim free allowance there

OP OpenVibers committed 4 Oct 2026 · e7880cae84 · 13 files, +647 −28

Asked: T6 step 8 — OpenVibe.AI: adopt openvibe-sdk/govern tiers (Valkey-backed) for the SaaS tier and per-subject budgets (dossier /home/workstation/openvibe/agents/ds/prep/T6.md row 8, decisions D5, brief §4). Money and concurrency path. ONE pull request from current origin/main (git fetch first; the loc…
Harness job ai-t6-step-8-openvibe-ai-183655 (plan T6).

Changes

ddiff --git a/.env.example b/.env.example
iindex 5e8dbaf..3207045 100644
---- a/.env.example
++++ b/.env.example
@@@ -55,6 +55,16 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#USAGE_SAMPLES=on
#USAGE_SAMPLES_INTERVAL_MS=2000
++# ── Tier budgets (server/govern.js, T6 step 8): openvibe-sdk/govern in Valkey (VALKEY_URL above), off by default ──
++# Units ai-token and ai-usd per subject and per project; tiers free, paid, staff. See README "Tier budgets".
++#AI_GOVERN_TIERS=0
++#AI_GOVERN_POLICY_JSON=
++#AI_GOVERN_TIER_MAP=
++#AI_GOVERN_DEFAULT_TIER=free
++#AI_GOVERN_HOLD_TOKENS=4000
++#AI_GOVERN_HOLD_USD=0.02
++#AI_GOVERN_TIMEOUT_MS=1000
++
# ── A person's own provider keys (server/credentials.js, WS-O task 2) ──
# 64 hex characters (openssl rand -hex 32). Keys stored through PUT /api/v1/credentials/:subject are AES-256-GCM
# encrypted with it; unset turns the credentials routes and credentialed runs off (503). Changing it makes every
ddiff --git a/README.md b/README.md
iindex 6661bce..268588a 100644
---- a/README.md
++++ b/README.md
@@@ -122,7 +122,8 @@ circuit) degrades it. `/metrics` (openvibe-shared/metrics) carries golden signal
Errors are `application/problem+json` (openvibe-contracts `http.problem`): `input.invalid` (with the
schema errors), `workflow.not_found`, `idempotency.conflict`, `quota.exceeded` (429 +
--`Retry-After`), `queue.full` (429 + `Retry-After`: the run would wait behind `AI_MAX_QUEUED_RUNS`
++`Retry-After`), `govern.exceeded` (429 + `Retry-After`: a tier budget) and `govern.unavailable` (503: Valkey
++unreachable for a paid or staff run), `queue.full` (429 + `Retry-After`: the run would wait behind `AI_MAX_QUEUED_RUNS`
runs, or `AI_MAX_QUEUED_RUNS_PER_CALLER` of this caller's), `capability.denied`, `capability.namespace_denied`, `token.*`. A run that cannot
produce a real answer ends `failed` with an explicit code — `provider.unavailable`,
`route.unavailable`, `fetch.refused`, `source.unavailable`, `input.insufficient`, `output.empty`,
@@@ -235,6 +236,35 @@ their price out. It is pricing, never admission: quotas, request counting and th
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.
++Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers
++`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider
++(and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two
++budgets: its **subject** (attribution, else actor, else requester) inside its **project** (the calling
++product, e.g. `service:live`), and the **project** as a whole. Each hold is one atomic step, so two runs
++never both take the last unit; a hold that does not fit refuses the run with `govern.exceeded` (429 +
++`Retry-After`). When the run is accounted the hold becomes its real tokens and its priced cost (the free
++allowance's share left out, so a fully free run spends no `ai-usd`); a run that spent nothing gives it back.
++Like the quotas, a run that spends more than its hold bounds the next run, not itself. A subject's tier is
++its own `AI_GOVERN_TIER_MAP` entry, else its project's, else `AI_GOVERN_DEFAULT_TIER`. With the tiers on,
++the free allowance's counter lives in the same Valkey (same numbers, one atomic claim per metric);
++`free_allowance_usage` keeps its total for the console and is the floor a lost key restarts from. Each
++`platform.usage-sample@1` comes from the `onUsage` record of an in-process govern meter, with the same
++idempotency key (`ai:<run>:tokens`), whether the tiers are on or off. **Valkey unreachable:** a run with
++any budget on `paid` or `staff` is refused with `govern.unavailable` (503, fail closed); an all-`free` run
++is admitted on the PostgreSQL quotas alone (today's behaviour) and its tokens are priced in full (nothing is
++free while the counter cannot be read). Reads never touch the tiers. With the flag off nothing here runs and
++the service boots without Valkey; with it on, production refuses to boot without `VALKEY_URL`.
++
++| Setting | Default | Meaning |
++|---|---|---|
++| `AI_GOVERN_TIERS` | `0` | `1` turns the tier budgets on |
++| `AI_GOVERN_POLICY_JSON` | free: 200,000 `ai-token`/day and 2,000,000/month, $0.50 `ai-usd`/day and $5/month; paid: 5,000,000 `ai-token`/day, $50/day; staff: unlimited | govern policy `{ unit: { tier: { minute?, hour?, day?, month? } } }` for `ai-token` and `ai-usd`; a missing tier or window is unlimited, 0 refuses |
++| `AI_GOVERN_TIER_MAP` | empty | `subject=tier` pairs, comma separated (`service:live=paid,user:usr_1=staff`) |
++| `AI_GOVERN_DEFAULT_TIER` | `free` | the tier of a subject or project not in the map |
++| `AI_GOVERN_HOLD_TOKENS`, `AI_GOVERN_HOLD_USD` | `4000`, `0.02` | the estimate a run holds before its provider call |
++| `AI_GOVERN_TIMEOUT_MS` | `1000` | longest wait for Valkey before it counts as unreachable |
++| `VALKEY_URL`, `VALKEY_PREFIX` | empty | the shared Valkey (ADR-035) the counters live in |
++
## Workflows
- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `blog.draft_post`,
@@@ -294,6 +324,7 @@ file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart
| 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` |
++| Tier budgets: enforced per subject and per project and reset per window, two concurrent runs never both take the last unit, the free allowance's numbers unchanged on the Valkey store, one reading per accounted run from govern's `onUsage`, Valkey unreachable (paid 503, free admitted), off by default | `test/govern-tiers.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/STATUS.json b/STATUS.json
iindex a733255..53fcf20 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -43,6 +43,7 @@
"ai.preferences and ai.usage_summary user modules",
"server-rendered operator console for Network staff",
"Live import",
-- "platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console"
++ "platform.usage-sample@1 readings to Billing with a per-subject provider free allowance; each run's reading state in explain and the free allowance on the staff console",
++ "tier budgets (free, paid, staff) per subject and per project on openvibe-sdk/govern in Valkey, behind AI_GOVERN_TIERS (off by default); readings from govern's onUsage"
]
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 3dc9d93..c19262d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,6 +12,7 @@
"ajv": "^8.17.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
++ "iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.86.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
@@@ -405,6 +406,12 @@
"url": "https://opencollective.com/libvips"
}
},
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -618,6 +625,15 @@
"optional": true,
"peer": true
},
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@@ -736,6 +752,15 @@
"node": ">=4.0.0"
}
},
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@@ -1160,6 +1185,49 @@
"optional": true,
"peer": true
},
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@@ -1231,12 +1299,24 @@
"safe-buffer": "^5.0.1"
}
},
++ "node_modules/lodash.defaults": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
++ "license": "MIT"
++ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@@ -1778,6 +1858,27 @@
"node": ">= 6"
}
},
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@@ -2056,6 +2157,12 @@
"node": ">= 10.x"
}
},
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
ddiff --git a/package.json b/package.json
iindex c7e264c..2035993 100644
---- a/package.json
++++ b/package.json
@@@ -24,6 +24,7 @@
"ajv": "^8.17.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
++ "iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.86.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
ddiff --git a/server/config.js b/server/config.js
iindex e150603..d33e832 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -12,6 +12,8 @@
* NAME through secretRef(); nothing here logs or returns a value.
*/
++const { loadGovern } = require('./govern');
++
const int = (v, d) => {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : d;
@@@ -205,6 +207,9 @@ function load(env = process.env) {
audience: 'openvibe.billing',
intervalMs: Math.max(100, int(env.USAGE_SAMPLES_INTERVAL_MS, 2000)),
},
++
++ // ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
++ govern: loadGovern(env, { bool, int, num: float }),
};
}
ddiff --git a/server/free-allowance.js b/server/free-allowance.js
iindex 14a120e..08d3986 100644
---- a/server/free-allowance.js
++++ b/server/free-allowance.js
@@@ -11,6 +11,10 @@
* share is computed, so concurrent claims for one subject, provider, metric and period never exceed the card's
* free_allowance in total.
*
++ * With AI_GOVERN_TIERS on, `store` (server/govern.js freeStore) holds the counter in Valkey instead, claimed in one
++ * atomic step; free_allowance_usage then keeps the store's total (never lower), which is the store's floor when
++ * a key was lost and what the console reads. The numbers are the same either way.
++ *
* const free = createFreeAllowance(db, { cardsFor: pool.rateCardsFor });
* const { tokens, usd } = await free.claim(subject, provider, model, { in, cached, out }); // inside db.tx
*/
@@@ -39,11 +43,23 @@ function periodFor(card, now) {
return { start: Number.isFinite(from) ? from : 0, end: Number.isFinite(until) ? until : NEVER };
}
--function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() } }) {
++function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() }, store = null }) {
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 = ?');
++ const floor = db.prepare('SELECT free_used FROM free_allowance_usage WHERE subject = ? AND provider = ? AND metric = ? AND period_start = ?');
++ const mirror = db.prepare(`INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (subject, provider, metric, period_start)
++ DO UPDATE SET free_used = GREATEST(free_allowance_usage.free_used, excluded.free_used), updated_at = excluded.updated_at`);
++
++ /** The free share of q on the store: one atomic claim, then its total is kept in free_allowance_usage. */
++ async function claimOnStore(subject, provider, c, p, q, cap, at, now) {
++ const row = await floor.get(subject, provider, c.metric, p.start);
++ const r = await store.claim(`${subject}|${provider}|${c.metric}|${p.start}`, q, cap, row ? Number(row.free_used) : 0, p.end >= NEVER ? 0 : p.end + 86400000, now);
++ if (r.used != null) await mirror.run(subject, provider, c.metric, p.start, p.end, r.used, at);
++ return r.free;
++ }
/**
* Claim this attempt's free tokens: usage { in (excluding cached), cached, out } on `provider`/`model`.
@@@ -63,6 +79,12 @@ function createFreeAllowance(db, { cardsFor, clock = { now: () => Date.now() } }
const q = Math.max(0, Math.floor(Number(usage[kind]) || 0));
if (cap <= 0 || q <= 0) continue;
const p = periodFor(c, now);
++ if (store) {
++ const free = await claimOnStore(subject, provider, c, p, q, cap, at, now);
++ out.tokens += free;
++ out.usd += (free / c.unit_size) * c.unit_price_usd;
++ continue;
++ }
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)));
ddiff --git a/server/govern.js b/server/govern.js
nnew file mode 100644
iindex 0000000..78ee1a3
---- /dev/null
++++ b/server/govern.js
@@@ -0,0 +1,200 @@
++'use strict';
++/**
++ * T6 step 8 (decision D5): the SaaS tier budgets on openvibe-sdk/govern, its counters in Valkey (ADR-035).
++ * Behind AI_GOVERN_TIERS (default off: none of this runs and Valkey is not needed).
++ *
++ * Admission. Before a run touches a provider, admit() holds an estimate (config.govern.hold) of the units
++ * ai-token and ai-usd against two budgets: the subject's (the attribution, else the actor, else the requester)
++ * inside its project (the calling product, `service:live`), and the project's as a whole. Each budget's tier
++ * (free, paid, staff) comes from config.govern.tiers, else config.govern.defaultTier; the windows per unit and
++ * tier are config.govern.policy (a missing tier or window is unlimited, 0 refuses). A hold that would pass a
++ * limit refuses the run with 429 govern.exceeded and retry_after_seconds; govern's reserve is one atomic step
++ * per budget, so two runs can never both take the last unit. settle() commits the real tokens and the priced
++ * cost (the free-allowance share left out) when the run is accounted; release() gives the hold back when the
++ * run spent nothing. quota.js reserve() and its request counters are unchanged and run after admit().
++ *
++ * Valkey unreachable: a run any of whose budgets is not on the free tier is refused (503 govern.unavailable,
++ * fail closed); an all-free run is admitted on the PostgreSQL quotas alone, as with the flag off.
++ *
++ * Pricing. freeStore() is the step-2 provider free allowance (server/free-allowance.js) on the same Valkey:
++ * one counter per subject, provider, metric and the card's own period (UTC day, UTC month, or the card's terms
++ * for 'none'), claimed atomically (min(wanted, cap − used)), so the numbers are step 2's. PostgreSQL's
++ * free_allowance_usage stays the record (the console reads it, a lost Valkey key restarts from it).
++ *
++ * Readings. meter() is an in-process governor whose onUsage builds each platform.usage-sample@1
++ * (server/usage-samples.js): never refuses, never needs Valkey.
++ */
++const { createGovernor } = require('openvibe-sdk/govern');
++const { createValkey } = require('openvibe-sdk/valkey');
++const { AiError } = require('./util');
++const { subjectOf } = require('./free-allowance');
++
++const UNITS = ['ai-token', 'ai-usd'];
++const TIERS = ['free', 'paid', 'staff'];
++/** The default policy (AI_GOVERN_POLICY_JSON replaces it): staff is unlimited. */
++const DEFAULT_POLICY = {
++ 'ai-token': { free: { day: 200000, month: 2000000 }, paid: { day: 5000000 }, staff: {} },
++ 'ai-usd': { free: { day: 0.5, month: 5 }, paid: { day: 50 }, staff: {} },
++};
++
++const timeout = (p, ms) => {
++ let timer;
++ return Promise.race([p, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`no answer in ${ms} ms`)), ms); if (timer.unref) timer.unref(); })]).finally(() => clearTimeout(timer));
++};
++
++const CLAIM = `
++-- KEYS[1]: one free-allowance counter. ARGV: wanted, cap, floor (PostgreSQL's free_used), expire-at ms (0: never).
++-- Claims min(wanted, cap - used) and returns { claimed, used after }.
++local used = math.max(tonumber(redis.call('GET', KEYS[1]) or '0'), tonumber(ARGV[3]))
++local free = math.min(tonumber(ARGV[1]), math.max(0, tonumber(ARGV[2]) - used))
++used = used + free
++redis.call('SET', KEYS[1], tostring(used))
++if tonumber(ARGV[4]) > 0 then redis.call('PEXPIREAT', KEYS[1], tonumber(ARGV[4])) end
++return { free, used }`;
++
++/** The free-allowance counters: on Valkey (one Lua call each), else in this process (tests, one process). */
++function freeCounters(valkey) {
++ if (!valkey) {
++ const m = new Map();
++ return { async claim(key, wanted, cap, floor, expireAt, t) {
++ for (const [k, v] of m) if (v.until && v.until <= t) m.delete(k);
++ const c = m.get(key) || { used: 0, until: expireAt };
++ c.used = Math.max(c.used, floor);
++ const free = Math.min(wanted, Math.max(0, cap - c.used));
++ c.used += free; m.set(key, c);
++ return { free, used: c.used };
++ } };
++ }
++ const c = valkey.client;
++ if (typeof c.ovAiFreeClaim !== 'function') c.defineCommand('ovAiFreeClaim', { numberOfKeys: 1, lua: CLAIM });
++ return { async claim(key, wanted, cap, floor, expireAt) {
++ const r = await c.ovAiFreeClaim(valkey.key('gov', 'free', key), String(wanted), String(cap), String(floor), String(expireAt));
++ return { free: Number(r[0]), used: Number(r[1]) };
++ } };
++}
++
++function createTiers({ config, valkey = null, clock = { now: () => Date.now() }, log = console } = {}) {
++ const g = config.govern;
++ if (!g.enabled) return { enabled: false, admit: async () => null, settle: async () => {}, release: async () => {}, freeStore: null, close: async () => {} };
++ const owned = !valkey && Boolean(g.valkeyUrl);
++ if (owned) valkey = createValkey({ url: g.valkeyUrl, prefix: g.valkeyPrefix, log });
++ if (!valkey && config.isProduction) throw new Error('AI_GOVERN_TIERS=1 needs VALKEY_URL: the tier budgets are counted in Valkey');
++ if (!valkey) log.warn('[govern] no VALKEY_URL: tier budgets are counted in this process only');
++ const gov = createGovernor({ policy: g.policy, valkey, now: () => clock.now() });
++ const free = freeCounters(valkey);
++ const tierOf = (...keys) => { for (const k of keys) if (k && g.tiers[k]) return g.tiers[k]; return g.defaultTier; };
++ let lastError = null;
++ const warn = (msg) => { if (msg !== lastError) log.warn(`[govern] ${msg}`); lastError = msg; };
++
++ /**
++ * Hold the run's estimate on every budget, or refuse it. Returns the hold ({ ids: { unit: [id] } }) to settle
++ * or release, or null when an all-free run was admitted without Valkey.
++ */
++ async function admit(ctx, runId) {
++ const project = `${ctx.requesterType}:${ctx.requesterId}`;
++ const subject = subjectOf(ctx) || project;
++ const budgets = [{ scope: 'subject', subject, project, tier: tierOf(subject, project) }, { scope: 'project', subject: '*', project, tier: tierOf(project) }];
++ const hold = { ids: { 'ai-token': [], 'ai-usd': [] }, budgets };
++ try {
++ for (const b of budgets) {
++ for (const unit of UNITS) {
++ const r = await timeout(gov.reserve({ subject: b.subject, project: b.project, tier: b.tier, unit, amount: g.hold[unit], key: `ai:${runId}:hold` }), g.timeoutMs);
++ if (!r.ok) {
++ await release(hold);
++ throw new AiError(429, 'govern.exceeded', `the ${b.tier} tier allows ${r.limit} ${unit} per ${r.window} for this ${b.scope}`, {
++ retry_after_seconds: r.retryAfterS, govern: { scope: b.scope, tier: b.tier, unit, window: r.window, limit: r.limit, used: r.used } });
++ }
++ hold.ids[unit].push(r.id);
++ }
++ }
++ } catch (err) {
++ if (err instanceof AiError) throw err;
++ warn(`Valkey unreachable: ${err.message}`);
++ await release(hold);
++ if (budgets.some((b) => b.tier !== 'free')) throw new AiError(503, 'govern.unavailable', 'the tier budgets cannot be checked right now; retry shortly', { retry_after_seconds: 5 });
++ return null;
++ }
++ lastError = null;
++ return hold;
++ }
++
++ /** The run is accounted: commit its real tokens and priced cost on every budget it held. */
++ async function settle(hold, { tokens = 0, usd = 0 } = {}) {
++ if (!hold) return;
++ const actual = { 'ai-token': Math.max(0, Number(tokens) || 0), 'ai-usd': Math.max(0, Number(usd) || 0) };
++ for (const unit of UNITS) {
++ for (const id of hold.ids[unit]) {
++ try { await timeout(gov.commit(id, actual[unit]), g.timeoutMs); } catch (err) { warn(`commit failed (the hold stays counted): ${err.message}`); }
++ }
++ }
++ hold.ids = { 'ai-token': [], 'ai-usd': [] };
++ }
++
++ /** The run spent nothing: every hold goes back. */
++ async function release(hold) {
++ if (!hold) return;
++ for (const unit of UNITS) {
++ for (const id of hold.ids[unit]) {
++ try { await timeout(gov.release(id), g.timeoutMs); } catch (err) { warn(`release failed (the hold stays counted until it expires): ${err.message}`); }
++ }
++ }
++ hold.ids = { 'ai-token': [], 'ai-usd': [] };
++ }
++
++ /** The free-allowance store for createFreeAllowance({ store }): Valkey unreachable → nothing is free (priced fully). */
++ const freeStore = {
++ async claim(key, wanted, cap, floor, expireAt, now) {
++ try { return await timeout(free.claim(key, wanted, cap, floor, expireAt, now), g.timeoutMs); } catch (err) { warn(`free allowance unreadable, priced in full: ${err.message}`); return { free: 0, used: null }; }
++ },
++ };
++
++ async function close() { if (owned) await valkey.close().catch(() => {}); }
++
++ return { enabled: true, admit, settle, release, freeStore, usage: (o) => gov.usage(o), policy: g.policy, close };
++}
++
++/**
++ * The reading meter: an in-process governor (no policy, so nothing is ever refused) whose onUsage is where a
++ * platform.usage-sample@1 comes from. reading(fields) reserves the run's tokens under its idempotency key and
++ * returns the onUsage record, or null when that key is already being read (a replay).
++ */
++function createMeter() {
++ const seen = new Map();
++ const gov = createGovernor({ onUsage: (e) => { if (seen.has(e.idempotency_key)) seen.set(e.idempotency_key, e); } });
++ return async function reading({ subject, quantity, key }) {
++ if (seen.has(key)) return null;
++ seen.set(key, null);
++ try {
++ const r = await gov.reserve({ subject, tier: 'meter', unit: 'ai-token', amount: quantity, key });
++ if (r.ok) await gov.commit(r.id, quantity);
++ return seen.get(key);
++ } finally { seen.delete(key); }
++ };
++}
++
++/** config.govern from the environment (server/config.js). */
++function loadGovern(env, { bool, int, num }) {
++ let policy = DEFAULT_POLICY;
++ if (env.AI_GOVERN_POLICY_JSON) {
++ try { policy = JSON.parse(env.AI_GOVERN_POLICY_JSON); } catch { throw new Error('AI_GOVERN_POLICY_JSON is not valid JSON'); }
++ for (const u of Object.keys(policy || {})) if (!UNITS.includes(u)) throw new Error(`AI_GOVERN_POLICY_JSON: unit ${u} is not one of ${UNITS.join(', ')}`);
++ }
++ const tiers = {};
++ for (const pair of String(env.AI_GOVERN_TIER_MAP || '').split(',').map((s) => s.trim()).filter(Boolean)) {
++ const i = pair.lastIndexOf('=');
++ const who = pair.slice(0, i).trim(); const tier = pair.slice(i + 1).trim();
++ if (i <= 0 || !TIERS.includes(tier)) throw new Error(`AI_GOVERN_TIER_MAP: "${pair}" is not subject=${TIERS.join('|')}`);
++ tiers[who] = tier;
++ }
++ const defaultTier = env.AI_GOVERN_DEFAULT_TIER || 'free';
++ if (!TIERS.includes(defaultTier)) throw new Error(`AI_GOVERN_DEFAULT_TIER must be one of ${TIERS.join(', ')}`);
++ return {
++ enabled: bool(env.AI_GOVERN_TIERS, false),
++ policy, tiers, defaultTier,
++ hold: { 'ai-token': Math.max(0, int(env.AI_GOVERN_HOLD_TOKENS, 4000)), 'ai-usd': Math.max(0, num(env.AI_GOVERN_HOLD_USD, 0.02)) },
++ timeoutMs: Math.max(50, int(env.AI_GOVERN_TIMEOUT_MS, 1000)),
++ valkeyUrl: env.VALKEY_URL || '', valkeyPrefix: env.VALKEY_PREFIX || '',
++ };
++}
++
++module.exports = { createTiers, createMeter, loadGovern, freeCounters, DEFAULT_POLICY, UNITS, TIERS };
ddiff --git a/server/index.js b/server/index.js
iindex fe45308..6a3dd3b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -16,6 +16,7 @@ const { createEngine } = require('./workflows/engine');
const { createCache } = require('./cache');
const { createQuotas } = require('./quota');
const { createFreeAllowance } = require('./free-allowance');
++const { createTiers } = require('./govern');
const { createRuns } = require('./runs');
const { seed } = require('./workflows/seed');
const { createAuth, createNetworkKeys } = require('./auth');
@@@ -24,16 +25,19 @@ const schemas = require('./schemas');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null } = {}) {
++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null } = {}) {
config = config || load(env);
schemas.configure(config.schemaCache);
// 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 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 });
++ // T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
++ const tiers = createTiers({ config, valkey, clock, log });
++ // §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);
++ // with the tiers on, its counter is in their Valkey store.
++ const freeAllowance = createFreeAllowance(db, { cardsFor: pool.rateCardsFor, clock, store: tiers.freeStore });
++ const quotas = createQuotas(db, { clock, registry, freeAllowance, tiers });
const cache = createCache(db, { clock });
const fetcher = createFetcher(config);
const engine = createEngine({ registry, pool, fetcher, quotas, config, log });
@@@ -45,7 +49,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });
// A person's own provider keys (WS-O task 2): stored by the service holding their consent, used by their runs only.
const credentials = require('./credentials').createCredentials({ db, config, clock });
-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials });
++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers });
await seed({ registry, quotas, config, env, db });
const interrupted = await runs.recoverInterrupted();
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
@@@ -100,10 +104,11 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
await require('./usage-samples').stop(); // the readings the runs queued get one last send; unsent rows wait for the next start
const w = await pool.adapter('whisper');
if (w && w.adapter.killActive) w.adapter.killActive();
++ await tiers.close();
if (!givenDb) await db.close();
}
-- return { config, db, registry, pool, quotas, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, close };
++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, close };
}
if (require.main === module) {
ddiff --git a/server/quota.js b/server/quota.js
iindex ed2ccf2..982515a 100644
---- a/server/quota.js
++++ b/server/quota.js
@@@ -48,7 +48,7 @@ function estimatePercentile(hist, q) {
return LATENCY_BUCKETS_MS[LATENCY_BUCKETS_MS.length - 1];
}
--function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAllowance = null } = {}) {
++function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAllowance = null, tiers = null } = {}) {
const nowSec = () => Math.floor(clock.now() / 1000);
const windowStart = (w, t = nowSec()) => Math.floor(t / WINDOWS[w]) * WINDOWS[w];
@@@ -176,20 +176,25 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAll
* 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.
++ * `hold` is the run's tier hold (server/govern.js): once the accounting committed, it is settled with the
++ * run's tokens and its priced cost (the free share left out), so a fully free run spends no ai-usd.
*/
-- 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, hold = null }) {
const tokens = tokensIn + tokensOut;
const day = iso(clock.now()).slice(0, 10);
++ let freeUsd = 0;
await db.tx(async () => {
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) {
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 };
++ freeUsd = free.usd;
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);
});
++ if (tiers && hold) await tiers.settle(hold, { tokens: (Number(tokensIn) || 0) + (Number(tokensOut) || 0), usd: Math.max(0, (Number(cost) || 0) - freeUsd) });
}
/**
ddiff --git a/server/runs.js b/server/runs.js
iindex 1fd90e6..71955ad 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -59,7 +59,7 @@ function retainedInput(input, wf, debugRaw) {
return { value, full: !redacted };
}
--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null }) {
++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
let active = 0;
@@@ -228,22 +228,30 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const caller = `${requester.type}:${requester.id}`;
admit(caller);
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
-- const reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);
++ // The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
++ const hold = credentialSubject !== null || !tiers ? null : await tiers.admit(ctx, id);
++ let reserved;
++ try {
++ reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);
++ } catch (err) { if (hold) await tiers.release(hold); throw err; }
-- await db.tx(async () => {
-- await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
-- source_service, target, target_key, input, input_hash, cache_key, retry_of, idempotency_key, trace_id, request_id, options, created_at)
-- VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'queued', @requester_type, @requester_id, @on_behalf_of, @attribution,
-- @source_service, @target, @target_key, @input, @input_hash, @cache_key, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @created_at)`)
-- .run({ ...base, cache_key: cacheKey });
-- await events.runChanged(await getRow(id));
-- });
++ try {
++ await db.tx(async () => {
++ await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
++ source_service, target, target_key, input, input_hash, cache_key, retry_of, idempotency_key, trace_id, request_id, options, created_at)
++ VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'queued', @requester_type, @requester_id, @on_behalf_of, @attribution,
++ @source_service, @target, @target_key, @input, @input_hash, @cache_key, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @created_at)`)
++ .run({ ...base, cache_key: cacheKey });
++ await events.runChanged(await getRow(id));
++ });
++ } catch (err) { if (hold) await tiers.release(hold); throw err; }
await registry.audit(principal.sub, retryOf ? 'run.retry' : 'run.create', 'run', id, { trace, metadata: { workflow: wf.key, version: wf.version, target: ctx.targetKey, retry_of: retryOf } });
const controller = new AbortController();
let release;
const gate = new Promise((r) => { release = r; });
-- const promise = gate.then(async () => await execute(id, { controller, reserved, scope, ctx, input })).catch((err) => { log.error(`[runs] ${id}: ${err.stack || err}`); });
++ // Whatever the run's end, a tier hold it did not settle (accounted) goes back.
++ const promise = gate.then(async () => { try { await execute(id, { controller, reserved, scope, ctx, input, hold }); } finally { if (hold) await tiers.release(hold); } }).catch((err) => { log.error(`[runs] ${id}: ${err.stack || err}`); });
inflight.set(id, { controller, promise, release, caller });
queue.push(id);
pump();
@@@ -271,7 +279,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
-- async function execute(id, { controller, reserved, scope, ctx, input }) {
++ async function execute(id, { controller, reserved, scope, ctx, input, hold = null }) {
const row = await getRow(id);
if (!row || row.status !== 'queued') { inflight.delete(id); return; }
await db.prepare("UPDATE runs SET status = 'running', started_at = ? WHERE id = ? AND status = 'queued'").run(iso(clock.now()), id);
@@@ -315,7 +323,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
return d;
});
if (!done.changes) return; // cancelled while finishing
-- if (r.provider && reserved) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id });
++ if (r.provider && reserved) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });
if (r.fallbackUsed) await registry.audit('system', 'run.fallback', 'run', id, { trace: row.trace_id, metadata: { workflow: row.workflow_key, provider: r.provider, route: r.route && r.route.key } });
// Output shaped by someone's preferences, or kept for nobody (history off), is never cached for reuse.
if (scope && row.cache_key && !r.synthetic && wf.cache_mode !== 'none' && preferences.history !== false && !preferenceLines(preferences)) {
@@@ -338,7 +346,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// Account the spent tokens/cost as before, but always roll up the finished attempts (the failed
// ones included) into provider_stats_daily/placement_state: a run that answered nothing still
// tells the router a provider was unhealthy.
-- if (reserved && logged.some((e) => e.status !== 'skipped')) await quotas.account(ctx, reserved, { provider: 'mixed', model: '', tokensIn: spent.ti, tokensOut: spent.tout, cost: spent.c, attempts: logged, writeUsage: Boolean(spent.ti || spent.tout), runId: id, traceId: row.trace_id });
++ if (reserved && logged.some((e) => e.status !== 'skipped')) await quotas.account(ctx, reserved, { provider: 'mixed', model: '', tokensIn: spent.ti, tokensOut: spent.tout, cost: spent.c, attempts: logged, writeUsage: Boolean(spent.ti || spent.tout), runId: id, traceId: row.trace_id, hold });
} finally {
inflight.delete(id);
}
ddiff --git a/server/usage-samples.js b/server/usage-samples.js
iindex 47a4b85..c46f421 100644
---- a/server/usage-samples.js
++++ b/server/usage-samples.js
@@@ -12,15 +12,19 @@
* 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. 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.
++ * cost_estimate the caller passes already leaves that share out. Each reading comes from the onUsage record of an
++ * openvibe-sdk/govern meter (server/govern.js createMeter) reserving the run's tokens under the reading's
++ * idempotency key, so a key read twice at once yields one reading.
*/
const { createPgOutbox } = require('openvibe-sdk/events');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const contracts = require('openvibe-contracts');
++const { createMeter } = require('./govern');
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;
const keyOf = (runId) => `ai:${runId}:${METRIC}`;
++const meter = createMeter();
function sampleOf({ runId, workflowKey, requester, provider = null, tokensIn = 0, tokensOut = 0, cost = 0, freeAllowanceUsed = 0, at, traceId = null }) {
const key = keyOf(runId);
const s = { id: key, idempotency_key: key, service: 'ai', subject: requester, resource: runId, operation: workflowKey,
@@@ -33,10 +37,16 @@ function sampleOf({ runId, workflowKey, requester, provider = null, tokensIn = 0
if (traceId) s.trace_id = String(traceId);
return s;
}
++/** The reading: govern's onUsage record (id, subject, quantity) on the run's own fields. */
++function sampleFromUsage(e, fields) {
++ return { ...sampleOf(fields), id: e.idempotency_key, idempotency_key: e.idempotency_key, subject: e.subject, quantity: e.amount };
++}
/** MUST be awaited inside the transaction that accounts the run; a second call for the same run changes nothing. */
async function record(db, fields) {
if (!on) return null;
-- const s = sampleOf(fields);
++ const e = await meter({ subject: fields.requester, quantity: Math.max(0, (Number(fields.tokensIn) || 0) + (Number(fields.tokensOut) || 0)), key: keyOf(fields.runId) });
++ if (!e) return null; // this run's reading is being written by another call right now
++ const s = sampleFromUsage(e, fields);
const v = contracts.validate(SCHEMA, s);
if (!v.valid) throw new Error(`usage sample for ${fields.runId} is not a valid ${SCHEMA}: ${JSON.stringify(v.errors)}`);
const ins = await db.prepare(`INSERT INTO ${TABLE} (event_id, envelope, created_at) VALUES (?, ?, ?) ON CONFLICT (event_id) DO NOTHING`).run(s.id, JSON.stringify(s), Date.now());
@@@ -103,4 +113,4 @@ async function stop({ drainMs = 3000 } = {}) {
if (r) { let timer; await Promise.race([r.flush().catch(() => {}), new Promise((res) => { timer = setTimeout(res, drainMs); if (timer.unref) timer.unref(); })]); clearTimeout(timer); await r.stop(); }
}
function _reset() { if (pruneTimer) clearInterval(pruneTimer); pruneTimer = null; if (relay) relay.stop(); relay = null; on = false; queued = 0; lastError = null; }
--module.exports = { init, record, sampleOf, keyOf, readingsFor, deliveryReport, status, stop, _reset, TABLE };
++module.exports = { init, record, sampleOf, sampleFromUsage, keyOf, readingsFor, deliveryReport, status, stop, _reset, TABLE };
ddiff --git a/test/govern-tiers.test.js b/test/govern-tiers.test.js
nnew file mode 100644
iindex 0000000..64cd391
---- /dev/null
++++ b/test/govern-tiers.test.js
@@@ -0,0 +1,214 @@
++'use strict';
++// T6 step 8 (server/govern.js): tier budgets on openvibe-sdk/govern (ai-token, ai-usd; per subject and per
++// project), the step-2 free allowance on the same store with the same numbers, the Billing reading from govern's
++// onUsage, Valkey unreachable (paid fails closed, free keeps the quotas alone) and the flag off by default.
++const assert = require('assert');
++const { boot, request, token, suite, seamServer, silent, ALL } = require('./helpers');
++const { testDb } = require('./db');
++const { load } = require('../server/config');
++const rates = require('../server/providers/rate-cards');
++const { createFreeAllowance } = require('../server/free-allowance');
++const { createTiers, createMeter } = require('../server/govern');
++const usageSamples = require('../server/usage-samples');
++
++const t = suite('govern-tiers');
++const live = token('live', ALL);
++const DAY = 86400000;
++const at = (y, m, d, hh = 12) => Date.UTC(y, m - 1, d, hh);
++const svc = (extra = {}) => ({ requesterType: 'service', requesterId: 'live', workflowKey: 'core.generate', ...extra });
++const tiersWith = (env, clock) => createTiers({ config: load({ NODE_ENV: 'test', AI_GOVERN_TIERS: '1', ...env }), clock, log: silent });
++const policy = (p) => JSON.stringify(p);
++let tdb; let h; let seam;
++
++t.test('flag off (the default): no tiers, no Valkey, admit holds nothing', async () => {
++ const config = load({ NODE_ENV: 'production' });
++ assert.strictEqual(config.govern.enabled, false);
++ const tiers = createTiers({ config, log: silent });
++ assert.strictEqual(tiers.enabled, false);
++ assert.strictEqual(tiers.freeStore, null, 'the free allowance stays on PostgreSQL');
++ assert.strictEqual(await tiers.admit(svc(), 'run_1'), null);
++ assert.throws(() => createTiers({ config: load({ NODE_ENV: 'production', AI_GOVERN_TIERS: '1' }), log: silent }), /needs VALKEY_URL/, 'on in production without Valkey: no boot');
++ assert.throws(() => load({ AI_GOVERN_TIER_MAP: 'service:live=gold' }), /AI_GOVERN_TIER_MAP/);
++});
++
++t.test('a tier budget is enforced and resets per window', async () => {
++ let now = at(2026, 10, 4);
++ const tiers = tiersWith({ AI_GOVERN_POLICY_JSON: policy({ 'ai-token': { free: { minute: 100 }, staff: {} } }), AI_GOVERN_HOLD_TOKENS: '60', AI_GOVERN_HOLD_USD: '0', AI_GOVERN_TIER_MAP: 'user:usr_staff=staff,service:console=staff' }, { now: () => now });
++ const a = await tiers.admit(svc(), 'run_a');
++ await tiers.settle(a, { tokens: 60, usd: 0 });
++ await assert.rejects(tiers.admit(svc(), 'run_b'), (e) => {
++ assert.strictEqual(e.status, 429);
++ assert.strictEqual(e.code, 'govern.exceeded');
++ assert.deepStrictEqual([e.extra.govern.tier, e.extra.govern.unit, e.extra.govern.window, e.extra.govern.limit, e.extra.govern.used], ['free', 'ai-token', 'minute', 100, 60]);
++ assert.ok(e.extra.retry_after_seconds >= 1 && e.extra.retry_after_seconds <= 60);
++ return true;
++ });
++ // Staff (here a staff subject in a staff project) is unlimited.
++ for (const id of ['run_s1', 'run_s2', 'run_s3']) await tiers.settle(await tiers.admit(svc({ requesterId: 'console', actorKey: 'user:usr_staff' }), id), { tokens: 60 });
++ now += 60000;
++ const d = await tiers.admit(svc(), 'run_d');
++ assert.ok(d && d.ids['ai-token'].length === 2, 'a new minute: admitted again');
++ await tiers.release(d);
++});
++
++t.test('per subject and per project: the project budget counts every subject of the project', async () => {
++ const tiers = tiersWith({ AI_GOVERN_POLICY_JSON: policy({ 'ai-usd': { free: { day: 1 }, paid: { day: 0.1 } } }), AI_GOVERN_HOLD_TOKENS: '0', AI_GOVERN_HOLD_USD: '0.06', AI_GOVERN_TIER_MAP: 'service:live=paid' }, { now: () => at(2026, 10, 4) });
++ // A subject of a paid project is on the paid tier too, unless mapped: 0.1 a day for the subject and for the project.
++ await tiers.settle(await tiers.admit(svc({ attributionKey: 'live:user:1' }), 'run_p1'), { usd: 0.05 });
++ // user:2's own budget has room for the 0.06 hold; the project's (0.05 spent) does not.
++ await assert.rejects(tiers.admit(svc({ attributionKey: 'live:user:2' }), 'run_p2'), (e) => e.code === 'govern.exceeded' && e.extra.govern.scope === 'project' && e.extra.govern.tier === 'paid');
++ await tiers.release(await tiers.admit(svc({ attributionKey: 'live:user:2', requesterId: 'blog' }), 'run_p3')); // another project
++});
++
++t.test('two concurrent runs cannot both spend the last unit', async () => {
++ const tiers = tiersWith({ AI_GOVERN_POLICY_JSON: policy({ 'ai-token': { free: { day: 100 } } }), AI_GOVERN_HOLD_TOKENS: '50', AI_GOVERN_HOLD_USD: '0' }, { now: () => at(2026, 10, 4) });
++ await tiers.settle(await tiers.admit(svc(), 'run_first'), { tokens: 50 });
++ const got = await Promise.allSettled([tiers.admit(svc(), 'run_x'), tiers.admit(svc(), 'run_y')]);
++ assert.deepStrictEqual(got.map((g) => g.status).sort(), ['fulfilled', 'rejected']);
++ assert.strictEqual(got.find((g) => g.status === 'rejected').reason.code, 'govern.exceeded');
++ // A released hold gives the unit back.
++ await tiers.release(got.find((g) => g.status === 'fulfilled').value);
++ await tiers.release(await tiers.admit(svc(), 'run_z'));
++});
++
++t.test('Valkey unreachable: a paid run is refused (503), an all-free run is admitted on the quotas alone, nothing is free', async () => {
++ const down = { on() {}, defineCommand(name) { this[name] = () => Promise.reject(new Error('connect ECONNREFUSED 127.0.0.1:6379')); }, async quit() {}, disconnect() {} };
++ const valkey = require('openvibe-sdk/valkey').createValkey({ client: down, log: silent });
++ const config = load({ NODE_ENV: 'production', AI_GOVERN_TIERS: '1', AI_GOVERN_TIER_MAP: 'live:user:9=paid' });
++ const tiers = createTiers({ config, valkey, log: silent });
++ await assert.rejects(tiers.admit(svc({ attributionKey: 'live:user:9' }), 'run_paid'), (e) => e.status === 503 && e.code === 'govern.unavailable');
++ assert.strictEqual(await tiers.admit(svc({ attributionKey: 'live:user:1' }), 'run_free'), null);
++ assert.deepStrictEqual(await tiers.freeStore.claim('k', 50, 100, 0, 0, Date.now()), { free: 0, used: null });
++});
++
++// ── The step-2 free-allowance numbers, on the govern store (test/free-allowance.test.js, same assertions) ──
++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 onStore = (terms) => createFreeAllowance(tdb.db, { cardsFor: cardsWith(terms), store: tiersWith({}).freeStore });
++
++t.test('a migrated database', async () => { tdb = await testDb(); });
++
++t.test('on the store: free up to the allowance per metric, then fully priced; the period is kept', async () => {
++ const fa = onStore({ 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}`);
++ assert.strictEqual((await fa.claim('s1', 'p', 'm', { in: 60, out: 30 }, now + 1000)).tokens, 40 + 30);
++ assert.strictEqual((await fa.claim('s1', 'p', 'm', { in: 60, cached: 10, out: 100 }, now + 2000)).tokens, 10 + 40);
++ assert.deepStrictEqual(await fa.claim('s1', 'p', 'm', { in: 60, out: 60 }, now + 3000), { tokens: 0, usd: 0 });
++ 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('on the store: day and month reset at their boundary, none never resets', async () => {
++ await tdb.db.prepare('DELETE FROM free_allowance_usage').run();
++ const day = onStore({ 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);
++ const month = onStore({ 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);
++ assert.strictEqual((await month.claim('s2', 'p', 'm', { in: 500 }, Date.UTC(2026, 10, 1))).tokens, 100);
++ const none = onStore({ 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);
++ assert.strictEqual((await none.claim('s3', 'p', 'm', { in: 500 }, at(2026, 10, 4) + 800 * DAY)).tokens, 0);
++ const terms = onStore({ 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);
++});
++
++t.test('on the store: a lost counter restarts from PostgreSQL, never from zero', async () => {
++ const fresh = onStore({ free_allowance: 100, reset_period: 'month' }); // a new store: as if Valkey lost its keys
++ assert.strictEqual((await fresh.claim('s2', 'p', 'm', { in: 500 }, Date.UTC(2026, 9, 20))).tokens, 0);
++});
++
++t.test('on the store: concurrent claims never exceed the cap in total', async () => {
++ await tdb.db.prepare('DELETE FROM free_allowance_usage').run();
++ const fa = onStore({ free_allowance: 100, reset_period: 'month' });
++ const got = await Promise.all(Array.from({ length: 12 }, () => tdb.db.tx(async () => (await fa.claim('s1', 'p', 'm', { in: 30, out: 30 }, at(2026, 10, 4))).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();
++});
++
++t.test('the meter: one onUsage record per idempotency key at once', async () => {
++ const reading = createMeter();
++ const got = await Promise.all([reading({ subject: 'service:live', quantity: 5, key: 'ai:run_m:tokens' }), reading({ subject: 'service:live', quantity: 5, key: 'ai:run_m:tokens' })]);
++ assert.strictEqual(got.filter(Boolean).length, 1);
++ assert.deepStrictEqual([got.find(Boolean).idempotency_key, got.find(Boolean).amount, got.find(Boolean).subject], ['ai:run_m:tokens', 5, 'service:live']);
++});
++
++// On the containers' Valkey (OV_TEST_VALKEY_URL, set by openvibe-sdk/scripts/test-services.sh): govern's Lua and the
++// free-allowance claim, atomic across concurrent callers. Without it (a plain `ov test`) this passes as skipped.
++t.test('on Valkey: the tier budget and the free allowance hold under concurrent callers', async () => {
++ if (!process.env.OV_TEST_VALKEY_URL) { console.log(' (skipped: no OV_TEST_VALKEY_URL)'); return; }
++ const valkey = require('openvibe-sdk/valkey').createValkey({ url: process.env.OV_TEST_VALKEY_URL, prefix: `ov:ai-test:${Math.random().toString(36).slice(2)}:`, log: silent });
++ const vdb = await testDb();
++ try {
++ const config = load({ NODE_ENV: 'test', AI_GOVERN_TIERS: '1', AI_GOVERN_POLICY_JSON: policy({ 'ai-token': { free: { day: 100 } } }), AI_GOVERN_HOLD_TOKENS: '50', AI_GOVERN_HOLD_USD: '0' });
++ const tiers = createTiers({ config, valkey, log: silent });
++ await tiers.settle(await tiers.admit(svc(), 'run_v1'), { tokens: 50 });
++ const got = await Promise.allSettled([tiers.admit(svc(), 'run_v2'), tiers.admit(svc(), 'run_v3')]);
++ assert.deepStrictEqual(got.map((g) => g.status).sort(), ['fulfilled', 'rejected']);
++ await tiers.release(got.find((g) => g.status === 'fulfilled').value);
++ const fa = createFreeAllowance(vdb.db, { cardsFor: cardsWith({ free_allowance: 100, reset_period: 'day' }), store: tiers.freeStore });
++ const now = Date.now();
++ const claimed = await Promise.all(Array.from({ length: 12 }, () => vdb.db.tx(async () => (await fa.claim('s1', 'p', 'm', { in: 30, out: 30 }, now)).tokens)));
++ assert.strictEqual(claimed.reduce((x, y) => x + y, 0), 200, `claims ${claimed.join(',')}`);
++ const rows = await vdb.db.prepare('SELECT metric, free_used, period_start FROM free_allowance_usage ORDER BY metric').all();
++ assert.deepStrictEqual(rows.map((r) => [r.metric, Number(r.free_used)]), [['input-tokens:m', 100], ['output-tokens:m', 100]]);
++ const ttl = await valkey.client.pttl(valkey.key('gov', 'free', `s1|p|input-tokens:m|${Number(rows[0].period_start)}`));
++ assert.ok(ttl > 86400000 && ttl <= 2 * 86400000, `the counter expires a day after its period: ${ttl} ms`);
++ } finally { await vdb.close(); await valkey.close(); }
++});
++
++// ── End to end with the flag on (no VALKEY_URL in tests: the store is in this process) ──
++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 AI_GOVERN_TIERS=1 and a seam model with a free allowance of 100 tokens a day', async () => {
++ seam = await seamServer(() => ({ text: 'ok', usage: { input: 1000, output: 1000 } }));
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_GOVERN_TIERS: '1', AI_GOVERN_POLICY_JSON: policy({ 'ai-token': { free: { day: 5000 } } }), AI_GOVERN_HOLD_TOKENS: '1000' } });
++ assert.strictEqual(h.tiers.enabled, true);
++ 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 readings carry step 2\'s numbers and come one per accounted run', async () => {
++ assert.strictEqual((await gen()).status, 201);
++ assert.strictEqual((await gen()).status, 201);
++ const runs = await h.db.prepare('SELECT id FROM runs ORDER BY created_at, id').all();
++ const [a, b] = await readings();
++ assert.deepStrictEqual([a, b].map((s) => s.idempotency_key), runs.map((r) => `ai:${r.id}:tokens`), 'one reading per run, the same key as before');
++ assert.strictEqual(a.free_allowance_used, 200);
++ assert.ok(Math.abs(a.cost_estimate - 1.8) < 1e-9, `${a.cost_estimate}`);
++ assert.ok(!('free_allowance_used' in b));
++ assert.ok(Math.abs(b.cost_estimate - 2) < 1e-9, `${b.cost_estimate}`);
++ const rows = await h.quotas.freeAllowance.current();
++ assert.deepStrictEqual(rows.map((r) => [r.metric, r.free_used, r.remaining]), [['input-tokens:m1', 100, 0], ['output-tokens:m1', 100, 0]]);
++});
++
++t.test('a run whose hold no longer fits is refused before the provider; reserve() did not count it', async () => {
++ const counted = async () => Number((await h.db.prepare("SELECT requests FROM usage_counters WHERE scope_type = 'service' AND scope_id = 'live' AND \"window\" = 'day' AND workflow_prefix = ''").get()).requests);
++ const before = await counted();
++ const r = await gen(); // 4000 used + a 1000 hold = 5000: admitted; it spends 2000, so the next hold does not fit
++ assert.strictEqual(r.status, 201, r.text);
++ const calls = seam.calls;
++ const no = await gen();
++ assert.deepStrictEqual([no.status, no.body.code], [429, 'govern.exceeded'], no.text);
++ assert.strictEqual(seam.calls, calls, 'no provider call');
++ assert.strictEqual(await counted(), before + 1, 'the refused run is not counted');
++ assert.strictEqual((await readings()).length, 3);
++});
++
++t.test('shutdown', async () => { usageSamples._reset(); await h.stop(); await seam.close(); });
++
++t.run();