openvibers/openvibe.ai Commits

The Exchange's routing presets, the receipt of every answer, BYOK and the privacy label (#312)

OP OpenVibers committed 7 days ago · 900cab7353 · 29 files, +3168 −57

* AI: nine managed provider adapters and the uncensored tier

Seed Qwen, Kimi, GLM, MiniMax, DeepInfra, Novita, Featherless, Refuseless and OpenRouter from the price book, off until their owner key exists, with recorded prices, data policies and the existing commercial-access gate. Add policy="uncensored": Network's 18+ account flag (fail closed, five-minute cache), uncensored offers only, the hard floor on input and output, and a label on every such answer. Recorded provider fixtures and no live network in the tests.

* Merge remote-tracking branch 'origin/main' into feat/ai-provider-adapters

* The Exchange's routing presets, the receipt of every answer, BYOK and the privacy label

Presets are saved routing profiles: five built in (cheapest, fastest, private, uncensored,
eu-only) and one per account and per Network project. A request names one as the model
"@preset/<name>" or as the request's preset, or writes its own profile inline. The policy
carries floor, ceiling, max_price, quantization, region, data policy and community on or
off; an unknown knob is dropped, never stored. The rules filter the pool before the call,
and a hard max_price that no offer fits is refused with a problem document before
anything is called.

Every answered request carries a receipt: the offer that answered, its price, the tokens
in and out, the cache reads and writes, the latency, why the router chose it, the
fallbacks it would have used, who paid and the privacy label. It never carries the prompt
or the answer. It is in the answer's x-openvibe-receipt header (compact) with its id, in
the run's own record, and at GET /v1/receipts/{id}.

A run on the person's own key is BYOK at a 0 % fee, and the receipt says so. Every answer
carries a privacy label — private, anonymized or community — read from the offer's
declared data policy.

The OpenAI- and Anthropic-compatible routes carry the receipt and the label; the presets
and the receipt are documented in /openapi.json.

* Readiness: a catalogue offer nobody bought does not degrade the service

The nine catalogued provider offers are seeded into the registry so the owner sees the
key slot, and they answer once the owner puts a key in: until then they are off by
design. The readiness check counted every active provider without credentials as a
problem, so the service read 'degraded' with nothing wrong with it, and the open-circuit
case reported the nine offers instead of the provider that had the circuit open.

A catalogue offer with no key is no longer a problem (its circuit still is); every
other provider keeps the old rule, and the operator console still lists an offer
without a key.

* Merge origin/main into the Exchange presets and receipts branch

The Exchange's public catalog and usage (#308) arrived on main beside this
branch's presets, receipts, BYOK label and privacy label; both are wired in
server/app.js and server/index.js. The branch's presets migration moves to
0031 so it follows 0029_community_offers and 0030_memory_imports.

* Merge origin/main into the Exchange presets and receipts branch

The Exchange's catalog now reads real upstreams and kinds (#314), and its
router keeps the preset's rules and the receipt beside the local, community
and catalog candidates. Both sides of the router signature and the run
context are kept.

Changes

ddiff --git a/README.md b/README.md
iindex 7b13df6..a1f7cea 100644
---- a/README.md
++++ b/README.md
@@@ -224,7 +224,8 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.ser
upstream for `default.chat` by price and health, and a request with `tools` goes only to a provider that declares
`tools`. The answer's `model` names the model that ran.
- **Answer:** `chat.completion` with the upstream's `choices` and `usage`, plus `x_openvibe` (`run_id`, `provider`,
-- `fallback_used`, `cost_usd`) and the `X-OpenVibe-Run-Id` header.
++ `fallback_used`, `cost_usd`, the privacy label and the receipt id) and the `X-OpenVibe-Run-Id` header. The
++ answer's receipt is in `x-openvibe-receipt` (compact JSON) and `x-openvibe-receipt-id`.
- **Streaming:** `stream: true` answers server-sent events in the chunk format **as the upstream streams them**.
- An OpenAI-format upstream (the router's, or the project's own) is asked with `stream: true` and
`stream_options.include_usage`, and each delta goes out when it arrives: text, and a tool call's id, name and
@@@ -253,6 +254,64 @@ coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.ser
- Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that is
a small local model without tools, so a coding agent's loop needs its project's own provider.
++## Presets, receipts, BYOK and the privacy label
++
++The Exchange's routing profiles and the receipt of every answer (the plan of 2026-11-06, "Product lines
++and features", item 1). `server/exchange/` holds the four parts: `presets.js`, `privacy.js`,
++`constraints.js` and `receipts.js`; `server/api/exchange.js` serves them and `migrations/0031_exchange_presets_receipts.sql`
++holds them.
++
++### Presets
++
++A preset is a named set of routing constraints. Five are built in — `cheapest`, `fastest`, `private`,
++`uncensored`, `eu-only` — and they are code, not rows: nobody can overwrite or delete one (409
++`ai.preset_builtin`). The others belong to an account (the person a token acts for) or to a Network
++project (an app's project).
++
++- `GET /api/v1/presets` lists the presets the caller may name: the built-in ones and their own.
++- `POST /api/v1/presets` stores (or replaces) one of their own: `{ "name": "…", "policy": { … } }`.
++- `DELETE /api/v1/presets/{name}` removes one of their own.
++
++The policy's knobs: `objective` (`cheapest`, `fastest`, `balanced`, `private`), `floor` (`fast`,
++`balanced`, `strong`, `frontier`), `ceiling` and `max_price` (US dollars per million tokens),
++`quantization`, `region`, `data_policy` (`private`, `anonymized`, `community`), `community` (may a
++community machine answer) and `adult` (the uncensored tier: the 18+ gate is the pool's own, never the
++preset's — the preset only names the tier the request asks for). A knob nobody knows is dropped, never
++stored, so a typo cannot come back as a rule nobody reads.
++
++A request names a preset as the model `"@preset/cheapest"` or beside it as `"preset": "cheapest"`; it
++may also write its profile inline (`"preset": { "policy": { … } }`) and cap the price on its own
++(`"max_price": 0.42`). A name nobody has is a 404 (`ai.preset_not_found`), never a silent default.
++
++The rules filter the pool **before** the call: an offer that breaks one is out, and the receipt says
++which rule removed it. A hard `max_price` that no offer fits is refused before anything is called, with
++a problem document (`ai.max_price_exceeded`) that carries the cap, the lowest price on offer and the
++offers that were excluded.
++
++### Receipts
++
++Every answered request carries a receipt: the offer that answered, the price it charged, the tokens in
++and out, the cache reads and writes, the latency, why the router chose it, the fallbacks it would have
++used, who paid and the privacy label. It never carries the prompt or the answer.
++
++- **In the answer:** the header `x-openvibe-receipt-id` and `x-openvibe-receipt` (the same facts, compact
++ JSON). A streamed answer names its receipt in the headers and carries the whole one in the last chunk.
++- **On its own:** `GET /v1/receipts/{id}` (and the documented `GET /api/v1/receipts/{id}`), readable by the
++ caller that made the run, by the person it was for, and by whoever may read the usage
++ (`ai.usage.read`). Anyone else gets a 404, not a refusal that says there is something there.
++- **Kept with the usage:** the receipt goes with its run (`runs.receipt_id` points at it) and is pruned
++ with the runs' retention window.
++
++### BYOK and the privacy label
++
++A run on the person's own key (BYOK) is metered at 0 %: the receipt says `paid_by: own_key` (or
++`own_machine`), `byok: true` and `fee_usd: 0`. The OpenVibe key pays the list price plus the fee.
++
++Every answer carries a privacy label — `private`, `anonymized` or `community` — read from the offer's
++declared data policy: the person's own machine is private, an offer that keeps nothing and trains on
++nothing is private, a community offer is community, and anything else is anonymized. The label is on the
++receipt and in the answer's `x_openvibe`.
++
## Memory
Memory M2a (spec `research-2026-10-30/memory-m2-spec.md`): a person's own notes, sealed with `AI_MEMORY_KEY`
@@@ -281,7 +340,8 @@ their own export; a deletion removes the rows. Unset `AI_MEMORY_KEY` turns every
upstream that speaks Anthropic's format (Anthropic, DeepSeek's and MiniMax's Anthropic-compatible endpoints); an
OpenAI-format provider is never converted.
- **Answer:** a `message` (`id`, `role`, `model`, `content`, `stop_reason`, `usage`), and the run id in
-- `X-OpenVibe-Run-Id`.
++ `X-OpenVibe-Run-Id`. The answer's receipt is in `x-openvibe-receipt` (compact JSON) and
++ `x-openvibe-receipt-id`.
- **Streaming:** `stream: true` answers Anthropic's own server-sent events as they arrive (`message_start`,
`content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta`, `message_stop`, `ping`); the run
still ends with the whole message.
@@@ -751,6 +811,7 @@ mix vectors from different providers (re-embed when the provider changes).
| Word timing (`live.media.transcribe` with `words: true`): whisper's `-ojf` tokens and Parakeet's token lines become each segment's words with their times; punctuation joins a word without moving its end; one unit per token in Japanese; fillers and control tokens are not words | `test/word-timing.test.js` |
| `media.analyze` on a real 28 s fixture: signals, scenes and loud moments measured, highlights with evidence, the local model's overview from the facts only; the local model down → extractive and said; paid only with `allow_paid` and a `media.paid` budget, never past it; too little disk refused before downloading | `test/media-analysis.test.js` |
| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
++| Routing presets: the five built-in ones, a caller's own stored per account and per project, each knob applied (floor, ceiling, max_price, quantization, region, data policy, community), a hard max_price refused before the call with a problem document, every answer's receipt (offer, price, tokens, cache hits, latency, why, fallbacks, privacy label, BYOK at 0 %) in the headers and at `GET /v1/receipts/{id}`, and the Anthropic endpoint and the stream carrying the same receipt | `test/exchange-presets-receipts.test.js` |
| Capability proposals are valid contracts documents matching what is enforced | `test/proposals.test.js` |
## Security
ddiff --git a/STATUS.json b/STATUS.json
iindex eea14a6..350348b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -54,7 +54,8 @@
"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",
"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",
-- "image generation (POST /api/v1/image, workflow ai.image, app op ai.image, route default.image over the OpenAI Images API), off unless AI_IMAGE_MODEL is set; metered per image (one platform.usage-sample@1 per attempt, resource output-images:<model>, unit image)"
++ "image generation (POST /api/v1/image, workflow ai.image, app op ai.image, route default.image over the OpenAI Images API), off unless AI_IMAGE_MODEL is set; metered per image (one platform.usage-sample@1 per attempt, resource output-images:<model>, unit image)",
++ "the Exchange's routing presets (five built-ins plus one per account and per Network project, named '@preset/<name>' or as the request's preset, with floor, ceiling, max_price, quantization, region, data policy and community on or off), a hard max_price refused before the call with a problem document, the receipt of every answered request (offer, price, tokens, cache hits, latency, why the router chose it, the fallbacks it would have used, who paid and the privacy label) in the x-openvibe-receipt header and at GET /v1/receipts/{id}, BYOK at 0 % and the private/anonymized/community label on every answer"
],
"todo": [
"owner: a provider key that serves the OpenAI Images API, then AI_IMAGE_MODEL (and AI_IMAGE_SIZE) in /etc/openvibe/ai.env — image generation stays off until then",
ddiff --git a/docs/provider-adapters.md b/docs/provider-adapters.md
nnew file mode 100644
iindex 0000000..4a67697
---- /dev/null
++++ b/docs/provider-adapters.md
@@@ -0,0 +1,80 @@
++# Managed provider adapters (the Exchange remake, stage 2)
++
++OpenVibe.AI can buy capacity from nine OpenAI-compatible providers. They are ordinary registry providers of kind
++`openai` (the existing adapter in `server/providers/openai.js`): no second adapter, no second model list, no second
++price table. What is new is the catalogue of providers, models, prices and data policies
++(`server/providers/pool-providers.js`) and its seed into the registry (`server/providers/pool-seed.js`).
++
++## Providers
++
++| key | provider | base URL | default model | timeout |
++|---|---|---|---|---|
++| `qwen` | Qwen (Alibaba Model Studio, compatible mode) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` | 60 s |
++| `kimi` | Kimi (Moonshot) | `https://api.moonshot.ai/v1` | `kimi-k3.1-pro` | 60 s |
++| `glm` | GLM (Z.ai) | `https://api.z.ai/api/paas/v4` | `glm-5.3-flash` | 60 s |
++| `minimax` | MiniMax | `https://api.minimax.io/v1` | `MiniMax-M3.1-Flash` | 60 s |
++| `deepinfra` | DeepInfra | `https://api.deepinfra.com/v1/openai` | `deepseek-v4.1-flash-1017` | 90 s |
++| `novita` | Novita AI | `https://api.novita.ai/v3/openai` | `deepseek-v4.1-flash-1017` | 90 s |
++| `featherless` | Featherless AI | `https://api.featherless.ai/v1` | `Qwen/Qwen3-14B` | 90 s |
++| `refuseless` | Refuseless (the uncensored tier) | `https://api.refuseless.com/v1` | `deepseek-v4.1-flash-abliterated` | 60 s |
++| `openrouter` | OpenRouter (the last fallback) | `https://openrouter.ai/api/v1` | `openrouter/auto` | 120 s |
++
++`server/workflows/seed.js` seeds them on every boot (idempotent: an owner-edited provider is never overwritten).
++Each row's `secret_ref` is `vault:<key>`, so the owner's key goes in Network → owner settings → AI provider keys
++(D11 `ai.provider_keys`), bound to the provider's origin. The pool skips a provider whose key does not resolve
++(`no_credentials`): a provider without a key is never offered, called or billed. `GET /api/v1/providers`/the console
++show the nine; pasting a key turns the adapter on.
++
++## Prices, models and the catalogue
++
++Every price carries the page it was read from and the day it was read (the price book, 2026-10-30; Watch's recorded
++lists, 2026-11-04). A model whose price we did not read is left out rather than invented. OpenRouter's hundreds of
++models are **not** seeded: the catalogue sync reads OpenRouter's own list with the owner's key
++(`server/providers/catalog.js` → `applyCatalogPrices`), which prices a row the catalogue knows and retires the
++seeded row when the provider lists the same model under its own id.
++
++`streaming`, `tools`, `json` and `vision` are recorded per model; the OpenAI adapter already streams, calls tools and
++steps structured output down (`json_schema` → `json_object` → prose). Errors are the adapter's typed
++`ProviderError`s (`provider.auth`, `provider.rate_limited`, `provider.timeout`, `provider.output`, …), and the pool's
++circuit breaker and fallback chain handle them unchanged.
++
++## Data policy
++
++Each provider row records what the provider says it does with a prompt, in `metadata.data_policy`:
++`trains_on_prompts` (`no` / `not_stated` / `unknown` / `provider_dependent`), `retention`, `region`, `terms_url`,
++`resale`, `checked` and `source`. The pages are in `server/providers/pool-providers.js` next to each provider; the
++catalogue surfaces them on a model's page. A provider whose policy is unknown is still recorded as unknown, never as
++"no training".
++
++## Commercial access
++
++`provider_access` is the existing gate (`server/providers/model-catalog.js`): only `OWN_DEPLOYMENT` and
++`AUTHORIZED_WHOLESALE` offers may serve managed paid routes. The seed records the commercial position from the
++2026-11-03 provider matrix:
++
++- `featherless` — `AUTHORIZED_WHOLESALE`, Scale plans only (it needs the executed Scale agreement);
++- `deepinfra` — `RESTRICTED` (its terms §11(a)(viii) forbid resale without an agreement);
++- every other provider — `UNVERIFIED`.
++
++The owner raises a provider's access mode only after the agreement that allows it. Until then the provider can be
++catalogued and its key stored, but the router does not resell it.
++
++## The uncensored tier
++
++`policy: "uncensored"` is accepted on `POST /v1/chat/completions`, `POST /v1/messages` and `POST /api/v1/runs` (with
++`on_behalf_of`). `server/policy/tier.js` admits it only when:
++
++1. the run names the person it is for (`on_behalf_of`, a `usr_` subject) — a developer app never gets it;
++2. OpenVibe.Network says that account confirmed it is 18+ (`GET /internal/identity/adult`;
++ `server/policy/adult-status.js` caches the answer five minutes and fails closed);
++3. the route has an offer marked `uncensored` (Refuseless, or any synced model whose id says `abliterated`);
++4. the hard floor (`server/policy/floor.js`) passes on the request **and** the answer: no sexual content involving
++ minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.
++ The floor applies to standard requests too. A streamed answer is checked before each chunk goes out.
++
++An uncensored answer carries the label `Uncensored model — adults only (18+)`: in the run's `output.label` and
++`output.policy`, in `x_openvibe.label`/`x_openvibe.policy` on both public APIs, and in the `X-OpenVibe-Content-Policy`
++header. A standard request never runs an uncensored offer, even when it is the cheapest.
++
++Network owns the flag: `users.adult_confirmed_at`, set by the person on `/my` → Content (18+ confirmation, with the
++hard floor stated), read by AI through `/internal/identity/adult` under `identity.subject.resolve`.
ddiff --git a/migrations/0031_exchange_presets_receipts.sql b/migrations/0031_exchange_presets_receipts.sql
nnew file mode 100644
iindex 0000000..7c89486
---- /dev/null
++++ b/migrations/0031_exchange_presets_receipts.sql
@@@ -0,0 +1,41 @@
++-- phase: expand
++-- The Exchange's saved routing profiles (presets) and the receipt of every answered request
++-- (the plan of 2026-11-06, "Product lines and features", item 1).
++--
++-- ai_presets one row per named preset per owner: an account (a person), a Network project or a service.
++-- The policy is JSON, so a new knob adds without a migration. The service's five built-in
++-- presets (cheapest, fastest, private, uncensored, eu-only) are not rows: they are code
++-- (server/exchange/presets.js), so they cannot be edited away by an owner.
++--
++-- ai_receipts one row per answered request: the offer, the price, the tokens, the cache, the latency,
++-- why the router chose it, the fallbacks it would have used, the payer, the BYOK label and
++-- the privacy label. It never carries the prompt or the answer. It is kept with the usage
++-- (the same retention window as the runs) and read by GET /v1/receipts/:id.
++--
++-- Additive only: two new tables and one new column on runs.
++CREATE TABLE IF NOT EXISTS ai_presets (
++ owner_type text COLLATE "C" NOT NULL, -- 'user' (an account), 'project' (a Network project), 'service'
++ owner_id text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL, -- the preset's name: cheapest, fastest, private, ...
++ policy jsonb NOT NULL DEFAULT '{}'::jsonb,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (owner_type, owner_id, name)
++);
++
++CREATE TABLE IF NOT EXISTS ai_receipts (
++ id text COLLATE "C" PRIMARY KEY, -- rcpt_<run id>: the same answer always has the same receipt
++ run_id text COLLATE "C",
++ requester_type text COLLATE "C",
++ requester_id text COLLATE "C",
++ owner_type text COLLATE "C", -- who the answer was for (a person), when there is one
++ owner_id text COLLATE "C",
++ body jsonb NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE INDEX IF NOT EXISTS ai_receipts_run_idx ON ai_receipts (run_id);
++CREATE INDEX IF NOT EXISTS ai_receipts_owner_idx ON ai_receipts (owner_type, owner_id, created_at DESC);
++
++-- The run points at its receipt, so the usage page and the run's own record can find it.
++ALTER TABLE runs ADD COLUMN IF NOT EXISTS receipt_id text COLLATE "C";
ddiff --git a/server/api/exchange.js b/server/api/exchange.js
iindex 6cf8560..d0148eb 100644
---- a/server/api/exchange.js
++++ b/server/api/exchange.js
@@@ -1,4 +1,8 @@
'use strict';
++/**
++ * Public Exchange catalog and usage, saved routing presets, and per-run receipts.
++ * Presets and receipt reads use authenticated routes below.
++ */
/**
* OpenVibe.Exchange's read API: the public catalog, one model, and aggregate usage.
*
@@@ -12,6 +16,8 @@
* Exchange does not scan the stats table per page view.
*/
const express = require('express');
++const { CAPS } = require('../auth');
++const { AiError, sendError } = require('../util');
const { http } = require('openvibe-contracts');
const { createCatalog, KINDS: catalogKinds } = require('../exchange/catalog');
const { createUsage } = require('../exchange/usage');
@@@ -28,7 +34,7 @@ function memo(fn, ttlMs, clock) {
};
}
--function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() } }) {
++function exchangeRouter({ db, registry, pool, env = process.env, clock = { now: () => Date.now() }, presets, receipts, auth, log = console }) {
const catalog = createCatalog({ db, registry, pool, env, clock });
const usage = createUsage({ db, clock });
// One cached read per kind (?kind=chat, screenshot, …); the unfiltered read is the full catalog.
@@@ -44,6 +50,59 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:
const publicCache = (seconds) => (req, res, next) => { res.set('Cache-Control', `public, max-age=${seconds}`); next(); };
++ // A caller that may run may read the presets; the same guard writes them (a preset is the caller's own).
++ const read = auth.appOrCap(CAPS.appRun, CAPS.runCreate, CAPS.runRead);
++ const write = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
++ const usageRead = auth.requireCap(CAPS.usageRead);
++ const wrap = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };
++ const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
++
++ /** The owner a preset belongs to: the app's project, else the person the token acts for, else the service. */
++ const ownerOf = (req) => {
++ const owners = presets.ownersOf(req.principal);
++ return owners[0] || null;
++ };
++
++ if (presets) {
++ r.get('/api/v1/presets', read, wrap(async (req) => ({ presets: await presets.visible(req.principal) })));
++ r.post('/api/v1/presets', write, wrap(async (req, res) => {
++ const b = body(req);
++ const owner = ownerOf(req);
++ if (!owner) throw new AiError(422, 'ai.invalid', 'a preset belongs to an account or a project');
++ const preset = await presets.put({ owner, name: b.name, policy: b.policy });
++ res.status(201);
++ return { preset };
++ }));
++ r.delete('/api/v1/presets/:name', write, wrap(async (req) => {
++ const owner = ownerOf(req);
++ if (!owner) throw new AiError(422, 'ai.invalid', 'a preset belongs to an account or a project');
++ return presets.remove({ owner, name: req.params.name });
++ }));
++ }
++
++ if (receipts) {
++ // The receipt is read at the address the plan names (/v1/receipts/:id, beside the OpenAI-compatible
++ // routes) and at the documented one (/api/v1/receipts/:id): the same answer either way.
++ const readReceipt = wrap(async (req) => {
++ const receipt = await receipts.get(req.params.id);
++ if (!receipt) throw new AiError(404, 'ai.receipt_not_found', `no receipt ${req.params.id}`);
++ // The receipt belongs to the run that made it: its requester, the person it was for, or whoever
++ // may read the usage.
++ const p = req.principal;
++ const subject = p.subject || {};
++ const ob = p.onBehalfOf || (p.claims && p.claims.on_behalf_of);
++ const obId = ob && typeof ob === 'object' ? ob.id : ob;
++ const owner = receipt.owner || {};
++ const allowed = auth.principalHas(req, CAPS.usageRead)
++ || (receipt.requester_type === subject.type && receipt.requester_id === subject.id)
++ || (owner.type === 'user' && obId && String(owner.id) === String(obId));
++ if (!allowed) throw new AiError(404, 'ai.receipt_not_found', `no receipt ${req.params.id}`);
++ return { receipt };
++ });
++ r.get('/v1/receipts/:id', read, readReceipt);
++ r.get('/api/v1/receipts/:id', read, readReceipt);
++ }
++
r.get('/api/v1/exchange/catalog', publicCache(30), async (req, res, next) => {
try {
const kind = req.query.kind == null || req.query.kind === '' ? null : String(req.query.kind).toLowerCase();
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex dbbcbe1..7122194 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -34,6 +34,8 @@ const { credentialFor } = require('./credential');
const models = require('./models');
const modelCatalog = require('../providers/model-catalog');
const { EXACT_ID } = modelCatalog;
++const { resolveRequest } = require('../exchange/presets');
++const { compact, receiptId } = require('../exchange/receipts');
const FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];
const MAX_TOKENS = 64000;
@@@ -97,17 +99,20 @@ function createEventStream(res) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('X-OpenVibe-Run-Id', r.id);
++ // A stream's headers go out before the answer is known: the receipt's id is known from the run id
++ // (it is the same receipt), and the receipt itself travels in the last event.
++ res.setHeader('x-openvibe-receipt-id', receiptId(r.id));
if (typeof res.flushHeaders === 'function') res.flushHeaders();
opened = true;
for (const e of waiting.splice(0)) emit(e.event, e.data);
},
emit,
sentAny: () => any,
-- /** The held message_stop with the final x_openvibe object, then end the stream. */
-- finish(xOpenVibe) {
++ /** The held message_stop with the final x_openvibe object (and the receipt), then end the stream. */
++ finish(xOpenVibe, receipt) {
const data = finalEvent || { type: 'message_stop' };
finalEvent = null;
-- write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe } : data);
++ write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe, ...(receipt ? { receipt } : {}) } : data);
return out.done();
},
fail(message, code) {
@@@ -137,7 +142,7 @@ function replay(res, message, run) {
res.emit('message_stop', { type: 'message_stop' });
}
--function messagesRouter({ runs, auth, config, registry, log = console }) {
++function messagesRouter({ runs, auth, config, registry, presets = null, receipts = null, log = console }) {
const r = express.Router();
// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
@@@ -171,16 +176,28 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension).
if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
++ // The routing preset (server/exchange/presets.js): `model: "@preset/cheapest"` or `preset: "cheapest"`,
++ // and a request's own `max_price` (USD per million tokens). A preset nobody has is a refusal.
++ let preset = null;
++ try {
++ preset = await resolveRequest({ presets, principal: req.principal, body: b });
++ } catch (err) {
++ if (err && err.status) return anthropicError(res, err.status, err.detail || err.message, null, err.code);
++ throw err;
++ }
// AI Exchange D3 (AI_MODEL_CATALOG): an exact id or a dynamic alias. With the flag off the field is ignored
// exactly as before, and the router picks, except an exact id: a pin is never silently run on another model (a
// rollback of the flag included), so it is refused.
const catalogOn = Boolean(config.modelCatalog && config.modelCatalog.enabled);
++ const rawModel = b.model == null ? '' : String(b.model).trim();
++ // A preset named as the model is the router's own choice with the preset's rules: the model is the alias.
++ const presetModel = rawModel.toLowerCase().startsWith('@preset/');
if (!catalogOn && typeof b.model === 'string' && EXACT_ID.test(b.model.trim())) {
return anthropicError(res, 400, 'model: an exact model id needs the model catalog, which is off: name a dynamic model (openvibe) instead (ai.model_unavailable)');
}
if (catalogOn) {
if (b.model !== undefined && b.model !== null && typeof b.model !== 'string') return anthropicError(res, 400, 'model: must be a string');
-- const requested = b.model == null ? 'openvibe/auto' : String(b.model).trim();
++ const requested = (b.model == null || presetModel) ? 'openvibe/auto' : rawModel;
if (!requested || requested.length > 256) return anthropicError(res, 400, 'model: must name an exact id or a dynamic alias');
input.model = requested;
// An exact id is a pin: an unknown or inactive artifact is refused here, before the run (and its stream)
@@@ -196,10 +213,16 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
}
}
let body = { workflow: 'ai.messages', input };
-- // Opt in to community capacity: the caller accepts that a person's machine sees the prompt.
++ // The preset's rules ride the run into the router.
++ if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
++ if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
++ // A caller must opt in before a person's machine sees the prompt.
if (req.get('x-openvibe-community') === '1' || (b.x_openvibe && b.x_openvibe.community === true)) body.community = true;
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
if (b.prefer) body = { ...body, prefer: b.prefer };
++ // The Exchange remake's content policy (`policy: "uncensored"`), enforced by the router.
++ if (b.policy != null) body = { ...body, policy: b.policy };
++ if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
const own = credentialFor(req);
if (own.error) return anthropicError(res, own.status, own.error);
@@@ -227,7 +250,10 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
const code = (run.error && run.error.code) || 'run.failed';
const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
: code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404
-- : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 529;
++ : code === 'policy.adult_required' || code === 'policy.uncensored_refused' || code === 'policy.floor_refused' ? 403
++ : code === 'policy.uncensored_needs_person' ? 400
++ : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404
++ : code === 'ai.max_price_exceeded' ? 400 : 529;
return anthropicError(res, status, (run.error && run.error.detail) || code, null, code);
}
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.content)) {
@@@ -246,22 +272,34 @@ function messagesRouter({ runs, auth, config, registry, log = console }) {
},
};
const disclosure = await models.disclosureOf({ registry, config, run });
-- // A community answer is labelled even with the model catalog flag off: the prompt ran on a person's machine.
const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;
if (live) live.route(disclosure || (community ? { community } : null));
if (community && !live) res.setHeader('X-OpenVibe-Community', '1');
-- // D8': the route disclosure joins the existing x_openvibe object (run, provider, fallback, cost), never a second object.
++ const receipt = receipts ? await receipts.forRun(run.id) : null;
++ const receiptView = receipt ? compact(receipt) : null;
const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used),
-- cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic), ...(community ? { community } : {}) };
-- const xOpenVibe = (disclosure || community) ? { ...x, ...(disclosure || {}) } : null;
++ cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic),
++ ...(community ? { community } : {}),
++ ...(run.output.label ? { policy: run.output.policy || 'uncensored', label: run.output.label } : {}),
++ privacy: receipt && receipt.privacy ? receipt.privacy.label : null, receipt_id: receipt ? receipt.id : null };
++ if (run.output.label) {
++ message.label = run.output.label;
++ message.policy = run.output.policy || 'uncensored';
++ res.setHeader('X-OpenVibe-Content-Policy', message.policy);
++ }
++ const xOpenVibe = (disclosure || community || run.output.label || receipt) ? { ...x, ...(disclosure || {}) } : null;
if (live) {
// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot
// stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole
// x_openvibe object, then ends the stream.
if (!live.sentAny()) replay(live, message, run);
-- return live.finish(xOpenVibe);
++ return live.finish(xOpenVibe, receiptView);
}
res.setHeader('X-OpenVibe-Run-Id', run.id);
++ if (receipt) {
++ res.setHeader('x-openvibe-receipt-id', receipt.id);
++ res.setHeader('x-openvibe-receipt', JSON.stringify(receiptView));
++ }
return res.status(200).json(xOpenVibe ? { ...message, x_openvibe: xOpenVibe } : message);
});
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex 83d1e2a..6e3b016 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -7,7 +7,9 @@
* (ai.run.create), exactly as POST /api/v1/chat takes them
* body OpenAI Chat Completions: messages (with tool calls and tool results),
* tools, tool_choice, parallel_tool_calls, response_format, stop, seed,
-- * temperature, top_p, max_tokens / max_completion_tokens, stream
++ * temperature, top_p, max_tokens / max_completion_tokens, stream; the
++ * OpenVibe extensions `policy` ("standard" | "uncensored", the adult tier:
++ * see docs/provider-adapters.md) and `on_behalf_of` (the person it is for)
* model with AI_MODEL_CATALOG off, ignored (an exact id is refused): AI's router picks the upstream
* for route default.chat. On, an exact id
* `<org>/<model>[:<quant>]@<revision>` (runs only that artifact, never a
@@@ -46,6 +48,8 @@ const { credentialFor } = require('./credential');
const models = require('./models');
const modelCatalog = require('../providers/model-catalog');
const { EXACT_ID } = modelCatalog;
++const { resolveRequest } = require('../exchange/presets');
++const { compact, receiptId } = require('../exchange/receipts');
const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
@@@ -96,6 +100,9 @@ function createLiveStream(res) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('X-OpenVibe-Run-Id', r.id);
++ // A stream's headers go out before the answer is known: the receipt's id is known from the run id
++ // (it is the same receipt), and the receipt itself travels in the last chunk.
++ res.setHeader('x-openvibe-receipt-id', receiptId(r.id));
if (typeof res.flushHeaders === 'function') res.flushHeaders();
opened = true;
for (const d of waiting.splice(0)) write(chunk(d));
@@@ -117,7 +124,7 @@ function openaiError(res, status, message, code, type) {
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
}
--function openaiRouter({ runs, auth, config, registry, log = console }) {
++function openaiRouter({ runs, auth, config, registry, presets = null, receipts = null, log = console }) {
const r = express.Router();
// Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
@@@ -153,16 +160,29 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
if (b.n != null && Number(b.n) !== 1) return openaiError(res, 400, 'n must be 1', 'input.invalid');
const input = {};
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
++ // The routing preset (server/exchange/presets.js): `model: "@preset/cheapest"` or `preset: "cheapest"`,
++ // and a request's own `max_price` (USD per million tokens). A preset nobody has is a refusal, never a
++ // silent default.
++ let preset = null;
++ try {
++ preset = await resolveRequest({ presets, principal: req.principal, body: b });
++ } catch (err) {
++ if (err && err.status) return openaiError(res, err.status, err.detail || err.message, err.code);
++ throw err;
++ }
// AI Exchange D3 (AI_MODEL_CATALOG): the `model` the caller names is exact, or one of the dynamic aliases.
// With the flag off it is ignored exactly as before (the router picks), except an exact id: that is a pin, and a
// pin is never silently run on another model (a rollback of the flag included), so it is refused.
const catalogOn = Boolean(config.modelCatalog && config.modelCatalog.enabled);
++ const rawModel = b.model == null ? '' : String(b.model).trim();
++ // A preset named as the model is the router's own choice with the preset's rules: the model is the alias.
++ const presetModel = rawModel.toLowerCase().startsWith('@preset/');
if (!catalogOn && typeof b.model === 'string' && EXACT_ID.test(b.model.trim())) {
return openaiError(res, 400, 'an exact model id needs the model catalog, which is off: name a dynamic model (openvibe) instead', 'ai.model_unavailable');
}
if (catalogOn) {
if (b.model !== undefined && b.model !== null && typeof b.model !== 'string') return openaiError(res, 400, 'model must be a string', 'input.invalid');
-- const requested = b.model == null ? 'openvibe/auto' : String(b.model).trim();
++ const requested = (b.model == null || presetModel) ? 'openvibe/auto' : rawModel;
if (!requested || requested.length > 256) return openaiError(res, 400, 'model must name an exact id or a dynamic alias', 'input.invalid');
input.model = requested;
// An exact id is a pin: an unknown or inactive artifact is refused here, before the run (and its stream)
@@@ -182,11 +202,17 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
let body = { workflow: 'ai.complete', input };
-- // Opt in to community capacity (the design's "Offer to the network"): the caller accepts that a person's machine
-- // sees the prompt. Off unless asked for, per request.
++ // The preset's rules ride the run into the router.
++ if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
++ if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
++ // A caller must opt in before a person's machine sees the prompt.
if (req.get('x-openvibe-community') === '1' || (b.x_openvibe && b.x_openvibe.community === true)) body.community = true;
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
if (b.prefer) body = { ...body, prefer: b.prefer };
++ // The Exchange remake's content policy (`policy: "uncensored"`): an OpenVibe extension, enforced by the router
++ // (18+ account, uncensored offers only, the floor, the label). on_behalf_of names the person it is for.
++ if (b.policy != null) body = { ...body, policy: b.policy };
++ if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
// A first-party service naming the person it acts for (the runs API's on_behalf_of). Their free allowance
// and cache scope are theirs; their Balance pays only when they run with their own token. An app run is its
@@@ -229,25 +255,41 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
const code = (run.error && run.error.code) || 'run.failed';
const status = code === 'ai.run_budget_reached' || code === 'ai.allowance_spent' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
: code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422
-- : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 503;
++ : code === 'policy.adult_required' || code === 'policy.uncensored_refused' || code === 'policy.floor_refused' ? 403
++ : code === 'policy.uncensored_needs_person' ? 400
++ : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404
++ : code === 'ai.max_price_exceeded' ? 400 : 503;
return openaiError(res, status, (run.error && run.error.detail) || code, code);
}
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');
}
const disclosure = await models.disclosureOf({ registry, config, run });
-- // A community answer is labelled even with the model catalog flag off: the caller must see that the prompt ran
-- // on a person's machine.
const community = (disclosure && disclosure.community) || (run.explain && run.explain.community) || null;
if (community && !live) res.setHeader('X-OpenVibe-Community', '1');
if (live) live.route(disclosure || (community ? { community } : null));
++ const receipt = receipts ? await receipts.forRun(run.id) : null;
++ const receiptView = receipt ? compact(receipt) : null;
const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);
const id = `chatcmpl-${run.id}`;
const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';
const usage = { prompt_tokens: run.usage.tokens_in || 0, completion_tokens: run.usage.tokens_out || 0, total_tokens: (run.usage.tokens_in || 0) + (run.usage.tokens_out || 0) };
-- const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic),
-- ...(community ? { community } : {}) };
++ const x = {
++ run_id: run.id, provider: run.provenance && run.provenance.provider,
++ fallback_used: Boolean(run.provenance && run.provenance.fallback_used), cost_usd: run.usage.cost_usd,
++ synthetic: Boolean(run.synthetic),
++ ...(community ? { community } : {}),
++ ...(run.output.label ? { policy: run.output.policy || 'uncensored', label: run.output.label } : {}),
++ privacy: receipt && receipt.privacy ? receipt.privacy.label : null,
++ receipt_id: receipt ? receipt.id : null,
++ };
++ if (run.output.label) res.setHeader('X-OpenVibe-Content-Policy', run.output.policy || 'uncensored');
if (!live) res.setHeader('X-OpenVibe-Run-Id', run.id); // a stream sent it with its headers
++ if (receipt && !live) {
++ // The receipt travels in the headers: the compact JSON, and its id.
++ res.setHeader('x-openvibe-receipt-id', receipt.id);
++ res.setHeader('x-openvibe-receipt', JSON.stringify(receiptView));
++ }
if (live) {
const send = (o) => live.write(o);
const base = { id, object: 'chat.completion.chunk', created, model, system_fingerprint: run.output.system_fingerprint || null };
@@@ -262,7 +304,9 @@ function openaiRouter({ runs, auth, config, registry, log = console }) {
send({ ...base, choices: [{ index: c.index || 0, delta: {}, finish_reason: c.finish_reason || 'stop' }] });
}
if (b.stream_options && b.stream_options.include_usage) send({ ...base, choices: [], usage });
-- send({ ...base, choices: [], x_openvibe: disclosure ? { ...x, ...disclosure } : x });
++ // A stream's headers went out before the answer was known, so the receipt travels in the last chunk
++ // (its id was in the headers, and the full receipt is one GET away).
++ send({ ...base, choices: [], x_openvibe: disclosure ? { ...x, ...disclosure } : x, receipt: receiptView });
return live.done();
}
return res.status(200).json({ id, object: 'chat.completion', created, model, choices: run.output.choices, usage, system_fingerprint: run.output.system_fingerprint || null, x_openvibe: disclosure ? { ...x, ...disclosure } : x });
ddiff --git a/server/app.js b/server/app.js
iindex 7eecf7a..30bb035 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null }) {
++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, presets = null, receipts = null }) {
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@@ -110,7 +110,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
openapi.mount(app);
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
-- app.use(require('./api/exchange').exchangeRouter({ db, registry, pool, env, clock }));
++ app.use(require('./api/exchange').exchangeRouter({ db, registry, pool, env, clock, presets, receipts, auth, log }));
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
@@@ -146,9 +146,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
app.use(runsRouter({ runs, registry, auth, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
-- app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, log }));
++ app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, presets, receipts, log }));
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
-- app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, log }));
++ app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, log }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 7ee6efa..c584abf 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -49,6 +49,7 @@ const { createSessions, sameString } = require('./session');
const sso = require('./sso');
const q = require('./queries');
const catalog = require('../providers/catalog');
++const poolSeed = require('../providers/pool-seed');
const { CSP, esc, pages } = require('./views');
/** Network staff capability -> AI capability held by a console session (documented in README "Operator console"). */
@@@ -364,6 +365,8 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
// Its model list, read right away (best effort: a provider whose list cannot be read is still added).
const added = await registry.getProvider(key);
await catalog.sync({ registry, provider: added, apiKey: resolveSecret(added.secret_ref, env), fetchImpl, actor: who(req).actor }).catch(() => null);
++ // The provider's own model ids get the price the catalogue recorded for them, matched by name (pool-seed).
++ await poolSeed.applyCatalogPrices({ registry, provider: added, actor: who(req).actor }).catch(() => null);
res.redirect(303, '/console/providers?done=provider_added');
}));
r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {
@@@ -383,7 +386,8 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
const apiKey = resolveSecret(p.secret_ref, env);
if (!apiKey && p.auth_mode !== 'none') throw new AiError(422, 'ai.invalid', `${p.key} has no key to read its model list with`);
const out = await catalog.sync({ registry, provider: p, apiKey, fetchImpl, actor: who(req).actor });
-- await registry.audit(who(req).actor, 'provider.models_synced', 'provider', p.key, { trace: who(req).trace, metadata: { added: out.added.length, updated: out.updated.length, missing: out.missing.length } });
++ const priced = await poolSeed.applyCatalogPrices({ registry, provider: p, actor: who(req).actor }).catch(() => ({ priced: [], retired: [] }));
++ await registry.audit(who(req).actor, 'provider.models_synced', 'provider', p.key, { trace: who(req).trace, metadata: { added: out.added.length, updated: out.updated.length, missing: out.missing.length, priced: priced.priced.length, retired: priced.retired.length } });
res.redirect(303, '/console/providers?done=models_synced');
}));
// Turn one model on or off (a model routes may use, or not).
ddiff --git a/server/exchange/constraints.js b/server/exchange/constraints.js
nnew file mode 100644
iindex 0000000..00089ab
---- /dev/null
++++ b/server/exchange/constraints.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * The routing constraints of a preset, applied to the offers a route may use (server/exchange/presets.js).
++ *
++ * Every knob excludes an offer with a reason, and the reasons are written into the run's explain, so a
++ * refusal or a downgrade says which rule removed which offer. Nothing here invents a price or a policy:
++ * the facts come from the catalogue, the rate cards and the provider's declared data policy.
++ *
++ * floor the catalogue's strength tier of the offer's model (fast | balanced | strong | frontier)
++ * ceiling the price per million tokens (output) above which the offer may not answer
++ * max_price the same measure, but hard: when no offer fits, the request is refused before the call
++ * quantization the quantization of the offer's model
++ * region the region the offer runs in
++ * data_policy the privacy class the answer must have (private | anonymized | community)
++ * community whether a community machine (which sees the prompt) may answer
++ */
++const { TIER_RANK } = require('../providers/task');
++const privacy = require('./privacy');
++
++const tierRank = (tier) => (TIER_RANK[tier] === undefined ? TIER_RANK.balanced : TIER_RANK[tier]);
++
++/** Does the offer's region answer a requirement? 'eu' answers 'eu' and every eu region (eu-west, eu-north). */
++function regionOk(offerRegion, required) {
++ if (!required || !required.length) return true;
++ const region = String(offerRegion || '').trim().toLowerCase();
++ if (!region) return false;
++ return required.some((r) => (r === 'eu' ? region === 'eu' || region.startsWith('eu-') : region === r));
++}
++
++/**
++ * The offers that may answer, and the ones that may not, with the rule that removed each of them.
++ *
++ * list the candidates the router ranked
++ * policy the preset's policy (already normalised)
++ * facts (candidate) => { priceIn, priceOut, tier, quant, region, privacy } (may be async)
++ */
++async function applyConstraints({ list = [], policy = null, facts = null } = {}) {
++ if (!policy || !Object.keys(policy).length) return { order: [...list], excluded: [] };
++ const kept = [];
++ const excluded = [];
++ for (const c of list) {
++ if (!c || !c.provider) continue;
++ const f = (facts ? (await facts(c)) : {}) || {};
++ const reason = reasonFor(f, policy);
++ if (reason) excluded.push({ provider: c.provider, model: c.model || null, reason });
++ else kept.push(c);
++ }
++ return { order: kept, excluded };
++}
++
++/** The rule that keeps this offer out, or null when it may answer. */
++function reasonFor(facts, policy) {
++ if (policy.floor) {
++ const rank = tierRank(facts.tier);
++ if (rank < tierRank(policy.floor)) return `below_floor (${facts.tier || 'balanced'} < ${policy.floor})`;
++ }
++ const priceOut = Number(facts.priceOut);
++ if (policy.ceiling != null && Number.isFinite(priceOut) && priceOut > Number(policy.ceiling)) {
++ return `over_ceiling (${priceOut} > ${policy.ceiling} USD/Mtok)`;
++ }
++ if (policy.max_price != null && Number.isFinite(priceOut) && priceOut > Number(policy.max_price)) {
++ return `over_max_price (${priceOut} > ${policy.max_price} USD/Mtok)`;
++ }
++ if (policy.quantization && policy.quantization.length) {
++ const quant = String(facts.quant || '').trim().toLowerCase();
++ if (!quant) return 'quantization_unknown';
++ if (!policy.quantization.includes(quant)) return `quantization_not_allowed (${quant})`;
++ }
++ if (policy.region && policy.region.length && !regionOk(facts.region, policy.region)) {
++ return `region_not_allowed (${facts.region || 'unknown'})`;
++ }
++ if (policy.data_policy) {
++ const label = (facts.privacy && facts.privacy.label) || 'anonymized';
++ if (!privacy.satisfies(label, policy.data_policy)) return `data_policy (${label} < ${policy.data_policy})`;
++ }
++ if (policy.community === false) {
++ const label = (facts.privacy && facts.privacy.label) || 'anonymized';
++ if (label === 'community') return 'community_not_allowed';
++ }
++ return null;
++}
++
++/**
++ * The refusal of a hard max_price: no offer fits it, so the request is refused before any provider is
++ * called. The message names the cap and the cheapest offer that was refused, so the caller knows what to
++ * raise.
++ */
++function refusal({ policy, excluded = [], cheapest = null }) {
++ const cap = Number(policy.max_price);
++ const priced = excluded.filter((e) => /over_max_price/.test(e.reason || ''));
++ const cheapestPrice = priced.map((e) => {
++ const m = /over_max_price \(([-0-9.eE+]+) >/.exec(e.reason || '');
++ return m ? Number(m[1]) : null;
++ }).filter((n) => Number.isFinite(n));
++ const lowest = cheapestPrice.length ? Math.min(...cheapestPrice) : null;
++ const detail = `no offer fits the max_price of ${cap} USD per million tokens`
++ + (lowest != null ? `: the cheapest offer is ${lowest} USD/Mtok` : '')
++ + (cheapest ? ` (${cheapest.provider}${cheapest.model ? `/${cheapest.model}` : ''})` : '');
++ return { code: 'ai.max_price_exceeded', detail, max_price: cap, lowest_price: lowest, excluded };
++}
++
++module.exports = { applyConstraints, reasonFor, refusal, regionOk, tierRank };
ddiff --git a/server/exchange/presets.js b/server/exchange/presets.js
nnew file mode 100644
iindex 0000000..ca3d218
---- /dev/null
++++ b/server/exchange/presets.js
@@@ -0,0 +1,245 @@
++'use strict';
++/**
++ * Routing presets: the Exchange's saved routing profiles (the plan of 2026-11-06, "Product lines and
++ * features", item 1: "Presets are saved routing profiles").
++ *
++ * A preset is a named set of routing constraints:
++ *
++ * objective cheapest | fastest | balanced | private what the router optimises
++ * floor fast | balanced | strong | frontier the weakest offer that may answer
++ * ceiling USD per million tokens the highest price that may be paid
++ * max_price USD per million tokens the hard cap: no fitting offer is a refusal
++ * quantization the quantizations that may answer q4_k_m, q8_0, ...
++ * region the regions that may answer eu, us, ...
++ * data_policy private | anonymized | community the weakest privacy class that may answer
++ * community may a community machine answer? off unless the profile asks for it
++ * adult may an uncensored model answer? the 18+ gate is the pool's (server/policy)
++ *
++ * A preset belongs to an account (a person) or to a Network project; the service also ships five built-in
++ * ones (cheapest, fastest, private, uncensored, eu-only), which are code and not rows: an owner cannot
++ * edit them away, and a person's own preset of the same name wins over the built-in one.
++ *
++ * A request names a preset as `model: "@preset/<name>"` or as `preset: "<name>"`. The resolved policy
++ * rides the run (options.preset) into the router (server/providers/index.js), which filters the offers
++ * with it and refuses a request whose hard max_price no offer fits.
++ */
++const { AiError } = require('../util');
++const { parseJson } = require('../util');
++
++const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
++const OBJECTIVES = ['cheapest', 'fastest', 'balanced', 'private'];
++/** The catalogue's strength tiers (server/providers/task.js TIER_RANK). The task's own words map onto them. */
++const FLOORS = ['fast', 'balanced', 'strong', 'frontier'];
++const FLOOR_ALIASES = { light: 'fast', standard: 'balanced', heavy: 'strong' };
++const DATA_POLICIES = ['private', 'anonymized', 'community'];
++const REGIONS = ['eu', 'us', 'ca', 'uk', 'ch', 'global', 'apac'];
++
++const BUILTINS = Object.freeze([
++ { name: 'cheapest', description: 'The lowest cost per answer among the offers that can do the job.', policy: { objective: 'cheapest' } },
++ { name: 'fastest', description: 'The quickest measured offer among the offers that can do the job.', policy: { objective: 'fastest' } },
++ { name: 'private', description: 'Only offers that keep nothing and train on nothing; never a community machine.', policy: { objective: 'balanced', data_policy: 'private', community: false } },
++ { name: 'uncensored', description: 'The abliterated models at the lowest healthy price. Adults only; the floor still applies.', policy: { objective: 'cheapest', adult: true } },
++ { name: 'eu-only', description: 'Only offers that run in the European Union.', policy: { objective: 'balanced', region: ['eu'] } },
++]);
++
++const builtinNames = () => BUILTINS.map((b) => b.name);
++const isBuiltin = (name) => BUILTINS.some((b) => b.name === name);
++
++/** A name the presets may carry: lower case, a letter or a digit first, at most 64 characters. */
++function checkName(name) {
++ const n = String(name == null ? '' : name).trim().toLowerCase();
++ if (!NAME_RE.test(n)) throw new AiError(422, 'ai.invalid', 'a preset name is lower case, starts with a letter or a digit and is at most 64 characters');
++ return n;
++}
++
++const num = (v) => (v === null || v === undefined || v === '' ? null : Number(v));
++
++/**
++ * The policy as it is stored and read: every knob normalised, unknown knobs dropped (never stored, so a
++ * typo cannot come back as a rule nobody reads).
++ */
++function normalizePolicy(input) {
++ if (input == null) return {};
++ if (typeof input !== 'object' || Array.isArray(input)) throw new AiError(422, 'ai.invalid', 'a preset policy must be an object');
++ const out = {};
++ const objective = String(input.objective || '').trim().toLowerCase();
++ if (objective) {
++ if (!OBJECTIVES.includes(objective)) throw new AiError(422, 'ai.invalid', `objective must be one of ${OBJECTIVES.join(', ')}`);
++ out.objective = objective;
++ }
++ const floor = String(input.floor || '').trim().toLowerCase();
++ if (floor) {
++ const mapped = FLOOR_ALIASES[floor] || floor;
++ if (!FLOORS.includes(mapped)) throw new AiError(422, 'ai.invalid', `floor must be one of ${FLOORS.join(', ')}`);
++ out.floor = mapped;
++ }
++ for (const key of ['ceiling', 'max_price']) {
++ const value = num(input[key]);
++ if (value === null) continue;
++ if (!Number.isFinite(value) || value < 0) throw new AiError(422, 'ai.invalid', `${key} must be a number of US dollars per million tokens (0 or more)`);
++ out[key] = value;
++ }
++ if (input.quantization != null) {
++ const list = Array.isArray(input.quantization) ? input.quantization : [input.quantization];
++ const quant = list.map((q) => String(q || '').trim().toLowerCase()).filter(Boolean);
++ if (quant.length !== list.length || quant.some((q) => !/^[a-z0-9_.+-]{1,32}$/.test(q))) throw new AiError(422, 'ai.invalid', 'quantization must be a list of names like q4_k_m');
++ if (quant.length) out.quantization = [...new Set(quant)];
++ }
++ if (input.region != null) {
++ const list = Array.isArray(input.region) ? input.region : [input.region];
++ const regions = list.map((r) => String(r || '').trim().toLowerCase()).filter(Boolean);
++ if (!regions.length || regions.some((r) => !REGIONS.includes(r))) throw new AiError(422, 'ai.invalid', `region must be one of ${REGIONS.join(', ')}`);
++ out.region = [...new Set(regions)];
++ }
++ const dataPolicy = String(input.data_policy || '').trim().toLowerCase();
++ if (dataPolicy) {
++ if (!DATA_POLICIES.includes(dataPolicy)) throw new AiError(422, 'ai.invalid', `data_policy must be one of ${DATA_POLICIES.join(', ')}`);
++ out.data_policy = dataPolicy;
++ }
++ if (input.community != null) {
++ if (typeof input.community !== 'boolean') throw new AiError(422, 'ai.invalid', 'community must be true or false');
++ out.community = input.community;
++ }
++ if (input.adult != null) {
++ if (typeof input.adult !== 'boolean') throw new AiError(422, 'ai.invalid', 'adult must be true or false');
++ out.adult = input.adult;
++ }
++ return out;
++}
++
++/** The owners a request may look a preset up under: its project first, then the person it acts for. */
++function ownersOf(principal) {
++ const out = [];
++ if (principal && principal.kind === 'app' && principal.projectId) out.push({ type: 'project', id: String(principal.projectId) });
++ const ob = principal && (principal.onBehalfOf || (principal.claims && principal.claims.on_behalf_of));
++ const id = ob && typeof ob === 'object' ? ob.id : ob;
++ if (id) out.push({ type: 'user', id: String(id) });
++ if (principal && principal.subject && principal.subject.id) out.push({ type: principal.subject.type || 'service', id: String(principal.subject.id) });
++ return out;
++}
++
++function createPresets({ db, clock = { now: () => Date.now() }, log = console } = {}) {
++ const iso = () => new Date(clock.now()).toISOString();
++
++ const select = db.prepare('SELECT * FROM ai_presets WHERE owner_type = ? AND owner_id = ? AND name = ?');
++ const insert = db.prepare(`INSERT INTO ai_presets (owner_type, owner_id, name, policy, created_at, updated_at)
++ VALUES (@owner_type, @owner_id, @name, @policy, @created_at, @updated_at)
++ ON CONFLICT(owner_type, owner_id, name) DO UPDATE SET policy = excluded.policy, updated_at = excluded.updated_at`);
++ const remove = db.prepare('DELETE FROM ai_presets WHERE owner_type = ? AND owner_id = ? AND name = ?');
++
++ const decode = (row) => (row ? {
++ name: row.name, scope: 'owner', builtin: false,
++ owner: { type: row.owner_type, id: row.owner_id },
++ // The policy column is JSON: it comes back as text or as an object, depending on the driver.
++ policy: (row.policy && typeof row.policy === 'object') ? row.policy : (parseJson(row.policy, {}) || {}),
++ created_at: row.created_at, updated_at: row.updated_at,
++ } : null);
++
++ /** One owner's stored presets. */
++ async function list(owner) {
++ if (!owner) return [];
++ const rows = await db.prepare('SELECT * FROM ai_presets WHERE owner_type = ? AND owner_id = ? ORDER BY name').all(owner.type, owner.id);
++ return rows.map(decode);
++ }
++
++ /** The built-in presets, as they are named in the plan. */
++ function builtins() {
++ return BUILTINS.map((b) => ({ name: b.name, description: b.description, scope: 'builtin', builtin: true, policy: { ...b.policy } }));
++ }
++
++ /**
++ * Every preset a caller may name: the built-in ones and the owner's own. The owner's own wins over a
++ * built-in of the same name.
++ */
++ async function visible(principal) {
++ const own = [];
++ for (const owner of ownersOf(principal)) own.push(...await list(owner));
++ const seen = new Set(own.map((p) => p.name));
++ return [...own, ...builtins().filter((b) => !seen.has(b.name))];
++ }
++
++ /**
++ * The preset a request names: the owner's own first (the first owner that has it), then the built-in one.
++ * A name nobody has is ai.preset_not_found — never a silent default, so a typo is visible.
++ */
++ async function resolve({ principal, owners, name }) {
++ const wanted = checkName(name);
++ for (const owner of (owners && owners.length ? owners : ownersOf(principal))) {
++ const row = decode(await select.get(owner.type, owner.id, wanted));
++ if (row) return row;
++ }
++ const builtin = BUILTINS.find((b) => b.name === wanted);
++ if (builtin) return { name: builtin.name, description: builtin.description, scope: 'builtin', builtin: true, policy: { ...builtin.policy } };
++ throw new AiError(404, 'ai.preset_not_found', `no preset ${wanted}: GET /api/v1/presets lists the ones you may name`);
++ }
++
++ /** Store (or replace) one of the owner's presets. A built-in name cannot be overwritten: it is not the owner's. */
++ async function put({ owner, name, policy }) {
++ if (!owner || !owner.id) throw new AiError(422, 'ai.invalid', 'a preset belongs to an account or a project');
++ const key = checkName(name);
++ if (isBuiltin(key)) throw new AiError(409, 'ai.preset_builtin', `${key} is a built-in preset: give your own a different name`);
++ const value = normalizePolicy(policy);
++ const now = iso();
++ await insert.run({ owner_type: owner.type, owner_id: owner.id, name: key, policy: JSON.stringify(value), created_at: now, updated_at: now });
++ return decode(await select.get(owner.type, owner.id, key));
++ }
++
++ /** Delete one of the owner's presets. A built-in one is not theirs to delete. */
++ async function remove_({ owner, name }) {
++ if (!owner || !owner.id) throw new AiError(422, 'ai.invalid', 'a preset belongs to an account or a project');
++ const key = checkName(name);
++ if (isBuiltin(key)) throw new AiError(409, 'ai.preset_builtin', `${key} is a built-in preset: it cannot be deleted`);
++ const r = await remove.run(owner.type, owner.id, key);
++ return { name: key, removed: r.changes > 0 };
++ }
++
++ return { list, builtins, visible, resolve, put, remove: remove_, normalizePolicy, ownersOf };
++}
++
++/** The prefix a request uses to name a preset as its model: "@preset/cheapest". */
++const PRESET_MODEL_PREFIX = '@preset/';
++
++/**
++ * The preset a request names, and the policy that goes with it.
++ *
++ * model: "@preset/<name>" the preset as the model
++ * preset: "<name>" the preset beside the model
++ * max_price: 0.42 the hard cap on its own (USD per million tokens), whatever the preset says
++ *
++ * A request that names none and caps nothing has no preset: null comes back and nothing changes.
++ */
++async function resolveRequest({ presets, principal, body = {} } = {}) {
++ const raw = body.model == null ? '' : String(body.model).trim();
++ let name = null;
++ let inline = null;
++ if (raw.toLowerCase().startsWith(PRESET_MODEL_PREFIX)) name = raw.slice(PRESET_MODEL_PREFIX.length).trim();
++ if (!name && typeof body.preset === 'string') name = body.preset.trim();
++ if (!name && body.preset && typeof body.preset === 'object' && body.preset.policy && typeof body.preset.policy === 'object') {
++ // A profile written in the request itself: { name?, policy: { … } }.
++ inline = body.preset;
++ name = typeof body.preset.name === 'string' ? body.preset.name.trim() : null;
++ }
++ if (!name && body.preset && typeof body.preset === 'object' && typeof body.preset.name === 'string') name = body.preset.name.trim();
++ const hasCap = body.max_price != null && body.max_price !== '';
++ if (!name && !inline && !hasCap) return null;
++ let policy = {};
++ let scope = 'request';
++ let resolved = null;
++ if (inline) {
++ policy = normalizePolicy(inline.policy);
++ scope = 'request';
++ } else if (name) {
++ if (!presets) throw new AiError(503, 'ai.preset_unavailable', 'routing presets are not available on this service');
++ resolved = await presets.resolve({ principal, name });
++ policy = { ...(resolved.policy || {}) };
++ scope = resolved.scope || 'owner';
++ }
++ if (hasCap) {
++ const cap = Number(body.max_price);
++ if (!Number.isFinite(cap) || cap < 0) throw new AiError(422, 'ai.invalid', 'max_price must be a number of US dollars per million tokens');
++ policy.max_price = cap;
++ }
++ return { name: resolved ? resolved.name : (name || 'request'), scope, policy };
++}
++
++module.exports = { createPresets, normalizePolicy, checkName, ownersOf, resolveRequest, BUILTINS, builtinNames, isBuiltin, NAME_RE, OBJECTIVES, FLOORS, DATA_POLICIES, REGIONS, PRESET_MODEL_PREFIX };
ddiff --git a/server/exchange/privacy.js b/server/exchange/privacy.js
nnew file mode 100644
iindex 0000000..52d77a6
---- /dev/null
++++ b/server/exchange/privacy.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * The privacy label of an answer: private, anonymized or community (the plan of 2026-11-06: "a privacy
++ * label on every answer (Private, Anonymized, Community), as Venice labels models but per request").
++ *
++ * The label is read off the offer that answered, never guessed:
++ *
++ * private the offer keeps nothing (no retention) and trains on nothing, and it is not a stranger's
++ * machine. A person's own machine is private: the prompt never leaves their hardware.
++ * community the offer runs on a community machine: that machine sees the prompt (as on AI Horde).
++ * anonymized everything else: an external provider that may keep what it is sent.
++ *
++ * A provider that never declared a policy is labelled anonymized and its label says so (known: false):
++ * the label is never better than the facts.
++ *
++ * The policy is declared on the provider record: metadata.data_policy =
++ * { retention: 'none' | 'limited' | 'standard', training: false, region: 'eu', trust: 'first-party' | 'community' }
++ */
++
++const LABELS = ['private', 'anonymized', 'community'];
++/** A label satisfies a requirement when it is at least as strong as the one asked for. */
++const RANK = { private: 0, anonymized: 1, community: 2 };
++
++const labelRank = (label) => (RANK[label] === undefined ? RANK.anonymized : RANK[label]);
++/** Does an answer with this label satisfy a requirement? ('community' asks for anything.) */
++const satisfies = (label, required) => labelRank(label) <= labelRank(required);
++
++/** The policy a provider record declares, or null when it declares none. */
++function dataPolicyOf(record) {
++ const meta = (record && record.metadata) || {};
++ const p = meta.data_policy;
++ if (!p || typeof p !== 'object' || Array.isArray(p)) return null;
++ const retention = ['none', 'limited', 'standard'].includes(String(p.retention)) ? String(p.retention) : null;
++ const training = typeof p.training === 'boolean' ? p.training : null;
++ const region = typeof p.region === 'string' && p.region.trim() ? p.region.trim().toLowerCase() : null;
++ const trust = ['first-party', 'community'].includes(String(p.trust)) ? String(p.trust) : null;
++ if (!retention && training === null && !region && !trust) return null;
++ return { retention, training, region, trust };
++}
++
++/**
++ * The label of one answered request.
++ *
++ * record the provider record that answered (its metadata declares the policy)
++ * credential the person's own key's descriptor, when the answer came from their key or their machine
++ */
++function labelFor({ record = null, credential = null } = {}) {
++ // A person's own machine (a Node credential): the prompt never left their hardware.
++ if (credential && credential.provider === 'node') {
++ return { label: 'private', retention: 'none', training: false, region: null, trust: 'own_machine', known: true, source: 'your own machine' };
++ }
++ const policy = dataPolicyOf(record);
++ if (!policy) {
++ return { label: 'anonymized', retention: null, training: null, region: null, trust: null, known: false, source: 'the provider declares no data policy' };
++ }
++ const trust = policy.trust || (record && record.metadata && record.metadata.local ? 'community' : 'first-party');
++ if (trust === 'community') {
++ return { label: 'community', retention: policy.retention, training: policy.training, region: policy.region, trust, known: true, source: 'a community machine sees the prompt' };
++ }
++ if (policy.retention === 'none' && policy.training === false) {
++ return { label: 'private', retention: 'none', training: false, region: policy.region, trust, known: true, source: 'the offer keeps nothing and trains on nothing' };
++ }
++ return { label: 'anonymized', retention: policy.retention, training: policy.training, region: policy.region, trust, known: true, source: 'the offer may keep what it is sent' };
++}
++
++/** The label as it is written on a receipt and in x_openvibe: the class, and the facts behind it. */
++function view(privacy) {
++ if (!privacy) return null;
++ return {
++ label: privacy.label,
++ retention: privacy.retention == null ? null : privacy.retention,
++ training: privacy.training == null ? null : privacy.training,
++ region: privacy.region == null ? null : privacy.region,
++ trust: privacy.trust == null ? null : privacy.trust,
++ known: Boolean(privacy.known),
++ };
++}
++
++module.exports = { LABELS, labelRank, satisfies, dataPolicyOf, labelFor, view };
ddiff --git a/server/exchange/receipts.js b/server/exchange/receipts.js
nnew file mode 100644
iindex 0000000..5d2cf6a
---- /dev/null
++++ b/server/exchange/receipts.js
@@@ -0,0 +1,189 @@
++'use strict';
++/**
++ * The receipt of an answered request (the plan of 2026-11-06: "Every answer carries a receipt").
++ *
++ * A receipt says, in one place:
++ *
++ * the offer that answered (provider, model, deployment, its commercial access),
++ * the price it charged (in, out and cached per million tokens, and where that price came from),
++ * the tokens (in, out, the cache read and the cache write) and the latency,
++ * why the router chose it, and the fallbacks it would have used,
++ * who paid (the OpenVibe key, the person's own key at 0 %, their plan, a community offer),
++ * the privacy label of the answer (private, anonymized or community).
++ *
++ * It never carries the prompt or the answer. It is written once per answered run, kept with the usage
++ * (the same retention window), and read by GET /v1/receipts/:id. The same receipt is in the response
++ * headers (x-openvibe-receipt, compact) and in the run's own record.
++ */
++const { parseJson } = require('../util');
++
++/** The receipt of a run: the same answer always has the same receipt id, so a stream can name it early. */
++const receiptId = (runId) => `rcpt_${String(runId || '').replace(/^run_/, '')}`;
++
++const round = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.round(Number(v) * 1e6) / 1e6);
++const int = (v) => Math.max(0, Math.round(Number(v) || 0));
++
++/**
++ * One receipt, from the parts of a finished run.
++ *
++ * run the run row (id, workflow, route, tokens, cost)
++ * explain the router's explain (why it chose the offer, the candidates, the attempts)
++ * usage the tokens of the call (input, output, cached, cache_write, reasoning)
++ * price the prices of the answering offer (in, out, cached per million tokens, and their source)
++ * privacy the privacy label of the answer (server/exchange/privacy.js)
++ * payer who paid: { paid_by, detail, fee_rate }
++ * preset the preset the request named, when it named one
++ */
++function buildReceipt({ run, explain = null, usage = {}, price = null, privacy = null, payer = null, preset = null, latencyMs = null, ttftMs = null, provider = null, model = null, createdAt = null }) {
++ const x = explain || {};
++ const attempts = Array.isArray(x.attempts) ? x.attempts : [];
++ const ok = attempts.find((a) => a && (a.status === 'ok' || a.status === 'succeeded')) || null;
++ const ranked = Array.isArray(x.ranked) ? x.ranked : [];
++ const chosen = ok ? `${ok.provider}${ok.model ? `/${ok.model}` : ''}` : (x.selected || null);
++ // Why the router chose it: the reason it recorded for the attempt, then the route's own reasons.
++ const why = [];
++ if (ok && ok.why) why.push(String(ok.why));
++ for (const r of (Array.isArray(x.reasons) ? x.reasons : [])) if (why.length < 4 && !why.includes(r)) why.push(String(r));
++ // The fallbacks it would have used: the offers it ranked after the one that answered, and the ones it
++ // skipped (each with the rule that removed it).
++ const fallbacks = [];
++ for (const r of ranked) {
++ const id = `${r.provider}${r.model ? `/${r.model}` : ''}`;
++ if (id === chosen) continue;
++ if (fallbacks.some((f) => f.offer === id)) continue;
++ fallbacks.push({ offer: id, provider: r.provider, model: r.model || null, why: r.why || null, status: 'ready' });
++ }
++ for (const a of attempts) {
++ if (!a || a.status === 'ok' || a.status === 'succeeded') continue;
++ const id = `${a.provider || '?'}${a.model ? `/${a.model}` : ''}`;
++ const known = fallbacks.find((f) => f.offer === id);
++ if (known) { known.status = a.status; known.reason = a.error || null; continue; }
++ fallbacks.push({ offer: id, provider: a.provider || null, model: a.model || null, why: a.why || null, status: a.status || 'skipped', reason: a.error || null });
++ }
++ // The offers a rule of the preset removed (over the cap, over the ceiling, the wrong region, a community
++ // machine the profile does not want): they were not used, and the receipt says which rule removed each.
++ for (const c of (Array.isArray(x.candidates) ? x.candidates : [])) {
++ if (!c || c.eligible !== false || !c.provider) continue;
++ const id = `${c.provider}${c.model ? `/${c.model}` : ''}`;
++ const known = fallbacks.find((f) => f.offer === id);
++ if (known) { known.reason = c.excluded_reason || known.reason; continue; }
++ fallbacks.push({ offer: id, provider: c.provider, model: c.model || null, why: null, status: 'excluded', reason: c.excluded_reason || null });
++ }
++ const tokens = {
++ tokens_in: int(usage.input),
++ tokens_out: int(usage.output),
++ tokens_cached: int(usage.cached),
++ tokens_cache_write: int(usage.cache_write),
++ tokens_reasoning: int(usage.reasoning),
++ };
++ const paidBy = (payer && payer.paid_by) || 'openvibe_key';
++ const byok = paidBy === 'own_key' || paidBy === 'own_machine';
++ return {
++ id: receiptId(run.id),
++ run_id: run.id,
++ created_at: createdAt || new Date().toISOString(),
++ workflow: run.workflow_key || null,
++ route: { key: run.route_key || null, version: run.route_version == null ? null : Number(run.route_version) },
++ preset: preset ? { name: preset.name, scope: preset.scope || 'owner', policy: preset.policy || {} } : null,
++ offer: {
++ provider: provider || (ok && ok.provider) || run.provider_key || null,
++ model: model || (ok && ok.model) || run.model_key || null,
++ deployment: (provider || (ok && ok.provider) || run.provider_key) || null,
++ provider_access: (x.openvibe && x.openvibe.provider_access) || null,
++ resolved_model: (x.openvibe && x.openvibe.resolved_model) || null,
++ },
++ price: {
++ in_per_mtok: round(price && price.in),
++ out_per_mtok: round(price && price.out),
++ cached_per_mtok: round(price && price.cached),
++ source: (price && price.source) || null,
++ },
++ usage: tokens,
++ cost_usd: round(run.cost_usd),
++ fee_usd: round(payer && payer.fee_usd),
++ fee_rate: payer && payer.fee_rate != null ? Number(payer.fee_rate) : 0,
++ paid_by: paidBy,
++ paid_by_detail: (payer && payer.detail) || null,
++ byok,
++ latency_ms: latencyMs == null ? null : int(latencyMs),
++ ttft_ms: ttftMs == null ? null : int(ttftMs),
++ why,
++ fallbacks,
++ privacy: privacy || null,
++ attempts: attempts.length,
++ };
++}
++
++/** The receipt as it travels in a header: the same facts, in fewer bytes. */
++function compact(receipt) {
++ if (!receipt) return null;
++ const offer = receipt.offer || {};
++ return {
++ id: receipt.id,
++ run_id: receipt.run_id,
++ offer: offer.provider ? `${offer.provider}${offer.model ? `/${offer.model}` : ''}` : null,
++ price: { in: receipt.price && receipt.price.in_per_mtok, out: receipt.price && receipt.price.out_per_mtok, cached: receipt.price && receipt.price.cached_per_mtok },
++ tokens: { in: receipt.usage && receipt.usage.tokens_in, out: receipt.usage && receipt.usage.tokens_out, cached: receipt.usage && receipt.usage.tokens_cached },
++ cost_usd: receipt.cost_usd,
++ fee_usd: receipt.fee_usd,
++ fee_rate: receipt.fee_rate,
++ paid_by: receipt.paid_by,
++ byok: Boolean(receipt.byok),
++ latency_ms: receipt.latency_ms,
++ why: receipt.why || [],
++ fallbacks: (receipt.fallbacks || []).map((f) => f.offer),
++ privacy: receipt.privacy ? receipt.privacy.label : null,
++ preset: receipt.preset ? receipt.preset.name : null,
++ };
++}
++
++function createReceipts({ db, clock = { now: () => Date.now() }, log = console } = {}) {
++ const insert = db.prepare(`INSERT INTO ai_receipts (id, run_id, requester_type, requester_id, owner_type, owner_id, body, created_at)
++ VALUES (@id, @run_id, @requester_type, @requester_id, @owner_type, @owner_id, @body, @created_at)
++ ON CONFLICT(id) DO UPDATE SET body = excluded.body`);
++ const byId = db.prepare('SELECT * FROM ai_receipts WHERE id = ?');
++ const byRun = db.prepare('SELECT * FROM ai_receipts WHERE run_id = ?');
++ const setRun = db.prepare('UPDATE runs SET receipt_id = ? WHERE id = ?');
++
++ const decode = (row) => (row ? {
++ ...((row.body && typeof row.body === 'object') ? row.body : parseJson(row.body, {})),
++ id: row.id, run_id: row.run_id, created_at: row.created_at,
++ requester: row.requester_type ? { type: row.requester_type, id: row.requester_id } : null,
++ owner: row.owner_type ? { type: row.owner_type, id: row.owner_id } : null,
++ } : null);
++
++ /** Write (or replace) the receipt of a run. */
++ async function record(receipt, { requester = null, owner = null } = {}) {
++ if (!receipt || !receipt.id) return null;
++ await insert.run({
++ id: receipt.id, run_id: receipt.run_id || null,
++ requester_type: requester ? requester.type : null, requester_id: requester ? requester.id : null,
++ owner_type: owner ? owner.type : null, owner_id: owner ? owner.id : null,
++ body: JSON.stringify(receipt), created_at: receipt.created_at || new Date(clock.now()).toISOString(),
++ });
++ if (receipt.run_id) await setRun.run(receipt.id, receipt.run_id);
++ return receipt;
++ }
++
++ async function get(id) {
++ const row = await byId.get(String(id || ''));
++ return decode(row);
++ }
++
++ async function forRun(runId) {
++ const row = await byRun.get(String(runId || ''));
++ return decode(row);
++ }
++
++ /** The receipts older than the retention window go with the runs they belong to. */
++ async function prune(days = 30, now = clock.now()) {
++ const cutoff = new Date(now - days * 86400000).toISOString();
++ const old = (await db.prepare('SELECT id FROM ai_receipts WHERE created_at < ?').all(cutoff)).map((r) => r.id);
++ for (const id of old) await db.prepare('DELETE FROM ai_receipts WHERE id = ?').run(id);
++ return old.length;
++ }
++
++ return { record, get, forRun, prune };
++}
++
++module.exports = { createReceipts, buildReceipt, compact, receiptId };
ddiff --git a/server/index.js b/server/index.js
iindex f8d9649..357eca1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -64,7 +64,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
-- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence });
++ // Network owns the 18+ account flag; the adult tier checks it before placement.
++ const adultStatus = require('./policy/adult-status').createAdultStatus({ config, env, fetchImpl, clock, log });
++ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels: machines, community, bench, variantEvidence, adultStatus });
// 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);
@@@ -92,6 +94,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch, grants });
// A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.
const preferences = require('./person-preferences').createPreferences({ db, clock });
++ // The Exchange's routing presets and the receipts of the answered requests (server/exchange/).
++ const presets = require('./exchange/presets').createPresets({ db, clock, log });
++ const receipts = require('./exchange/receipts').createReceipts({ db, clock, log });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
ensureCredits();
// A stable facade: the store may create the Credits manager after boot (AI_CREDITS turned on live), and the runs
@@@ -109,7 +114,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
startSettlements() { return credits ? credits.startSettlements() : null; },
stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
};
-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community });
++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts });
await seed({ registry, quotas, config, env, db });
// The seed creates or moves providers from the environment: publish their origins (the schema and every key's
// bind follow), so a key is bound to the host it is for from the first boot.
@@@ -138,7 +143,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community });
++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, presets, receipts });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
@@@ -149,6 +154,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const housekeeping = setInterval(async () => {
try {
const pruned = await runs.prune();
++ // The receipts go with the runs they belong to (the same retention window).
++ await receipts.prune(config.runs.retentionDays, clock.now());
const expired = await cache.prune();
// Billing readings never sent go with their runs (server/usage-samples.js expireUnsent).
const lapsed = await require('./usage-samples').expireUnsent(db, { days: config.runs.retentionDays, now: clock.now() });
ddiff --git a/server/observability.js b/server/observability.js
iindex d99ebf1..3ddb2c6 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -11,7 +11,9 @@
* providers optional provider configuration: every active non-stub provider has the
* credentials it needs and a circuit that is not open. A missing key or an
* open circuit sends runs to fallbacks (or to a 503 provider.unavailable),
-- * so it degrades the service rather than taking it out
++ * so it degrades the service rather than taking it out. A catalogue offer
++ * nobody bought (server/providers/pool-seed.js seeds the row so the owner
++ * sees the key slot) is off by design and does not degrade anything
*
* Gauges: runs waiting for a slot and running (ai_runs), and each provider's circuit state
* (ai_provider_circuit{provider,state} = 1 for the current state, 0 for the others).
@@@ -24,7 +26,12 @@ const CIRCUIT_STATES = ['closed', 'half_open', 'open'];
async function providerView(registry, pool) {
return (await Promise.all((await registry.listProviders()).filter(p => p.status === 'active').map(async (p) => {
const pub = registry.publicProvider(p);
-- return { key: p.key, kind: p.kind, credentials: pub.credentials, circuit: (await pool.health(p.key)).state };
++ return {
++ key: p.key, kind: p.kind, credentials: pub.credentials, circuit: (await pool.health(p.key)).state,
++ // A catalogue offer (server/providers/pool-seed.js): the registry row exists so the owner sees the key
++ // slot, and the offer answers once the owner puts its key in. It is off until then, by design.
++ catalogue: Boolean(p.metadata && p.metadata.catalog === 'pool-providers'),
++ };
})));
}
@@@ -67,7 +74,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
const real = list.filter(p => p.kind !== 'stub');
const detail = { active: list.length, usable: real.filter(p => p.credentials !== 'missing' && p.circuit !== 'open').length };
if (!real.length) return { ok: false, error: 'no real provider is active: only synthetic stub output is available', detail };
-- const problems = real.filter(p => p.credentials === 'missing' || p.circuit === 'open')
++ const problems = real.filter(p => p.circuit === 'open' || (p.credentials === 'missing' && !p.catalogue))
.map(p => `${p.key}: ${p.credentials === 'missing' ? 'credentials missing' : 'circuit open'}`);
return problems.length ? { ok: false, error: problems.join('; '), detail } : { ok: true, detail };
},
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 4986f6f..cf026e4 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -364,6 +364,31 @@ route('get', '/actions', {
responses: { 200: json('common.action-list-result@1', { type: 'object', required: ['items'], properties: { items: arrayOf(ref('ActionItem')) } }), ...errors([400, 401, 403, 429]) },
});
++// ── Routing presets and receipts (the Exchange, 2026-11-06) ───────────────────────────────────
++route('get', '/presets', {
++ operationId: 'listPresets', summary: 'The routing presets this caller may name', tags: ['presets'], security: SERVICE_OR_APP,
++ description: 'ai.run.create (an app token needs ai.app.run). The five built-in profiles — cheapest, fastest, private, uncensored, eu-only — and the caller\'s own: the ones its project or its account stored. A name is used as the model "@preset/<name>" or as the request\'s preset.',
++ responses: { 200: json('The presets', { type: 'object', required: ['presets'], properties: { presets: arrayOf(ref('Preset')) } }), ...errors([401, 403]) },
++});
++route('post', '/presets', {
++ operationId: 'createPreset', summary: 'Store (or replace) one routing preset', tags: ['presets'], security: SERVICE_OR_APP,
++ description: 'ai.run.create (an app token needs ai.app.run). The preset belongs to the caller\'s project (an app token) or to the person the token acts for. A built-in name cannot be overwritten: it is not the caller\'s.',
++ requestBody: body({ type: 'object', required: ['name'], properties: { name: str({ pattern: '^[a-z0-9][a-z0-9_-]{0,63}$' }), policy: ref('PresetPolicy') } }),
++ responses: { 201: json('The stored preset', { type: 'object', required: ['preset'], properties: { preset: ref('Preset') } }), 409: problem, 422: problem, ...errors([401, 403]) },
++});
++route('delete', '/presets/{name}', {
++ operationId: 'deletePreset', summary: 'Delete one routing preset', tags: ['presets'], security: SERVICE_OR_APP,
++ description: 'ai.run.create (an app token needs ai.app.run). The caller\'s own preset only; a built-in one is not theirs to delete.',
++ parameters: [path('name', str({ pattern: '^[a-z0-9][a-z0-9_-]{0,63}$' }))],
++ responses: { 200: json('The removal', { type: 'object', properties: { name: str(), removed: { type: 'boolean' } } }), 409: problem, 422: problem, ...errors([401, 403]) },
++});
++route('get', '/receipts/{id}', {
++ operationId: 'getReceipt', summary: 'The receipt of one answered request', tags: ['receipts'], security: SERVICE_OR_APP,
++ description: 'ai.run.read (or ai.run.create; an app token needs ai.app.run), or ai.usage.read. The receipt says which offer answered, what it charged, the tokens, the cache hits, the latency, why the router chose it and the fallbacks it would have used, who paid and the privacy label. It never carries the prompt or the answer. The same receipt is in the answer\'s x-openvibe-receipt header.',
++ parameters: [path('id', str({ pattern: '^rcpt_[0-9A-HJKMNP-TV-Z]{26}$' }))],
++ responses: { 200: json('The receipt', { type: 'object', required: ['receipt'], properties: { receipt: ref('Receipt') } }), 404: problem, ...errors([401, 403]) },
++});
++
function openapi() {
return {
openapi: '3.1.0',
@@@ -375,7 +400,7 @@ function openapi() {
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },
},
servers: [{ url: SITE }],
-- tags: ['runs', 'operations', 'actions', 'exchange'].map((name) => ({ name })),
++ tags: ['runs', 'operations', 'actions', 'exchange', 'presets', 'receipts'].map((name) => ({ name })),
components: {
securitySchemes: {
bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or user token for audience openvibe.ai carrying the route\'s capability (ai.run.create, ai.run.read, ai.actions.read)' },
@@@ -447,6 +472,52 @@ function openapi() {
created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }), href: str(),
actions: arrayOf(str()),
} },
++ PresetPolicy: { type: 'object', properties: {
++ objective: str({ enum: ['cheapest', 'fastest', 'balanced', 'private'] }),
++ floor: str({ enum: ['fast', 'balanced', 'strong', 'frontier'] }),
++ ceiling: nullable('number', { description: 'US dollars per million tokens: the highest price that may be paid' }),
++ max_price: nullable('number', { description: 'US dollars per million tokens: the hard cap. No offer fits is a refusal before the call' }),
++ quantization: arrayOf(str({ maxLength: 32 })),
++ region: arrayOf(str({ maxLength: 32 })),
++ data_policy: str({ enum: ['private', 'anonymized', 'community'] }),
++ community: { type: 'boolean' },
++ adult: { type: 'boolean' },
++ } },
++ Preset: { type: 'object', required: ['name', 'policy'], properties: {
++ name: str(), description: nullable('string'), scope: str({ enum: ['builtin', 'owner', 'request'] }), builtin: { type: 'boolean' },
++ owner: nullable('object'), policy: ref('PresetPolicy'),
++ created_at: nullable(str({ format: 'date-time' })), updated_at: nullable(str({ format: 'date-time' })),
++ } },
++ Receipt: { type: 'object', required: ['id', 'run_id'], properties: {
++ id: str(), run_id: str(), created_at: str({ format: 'date-time' }), workflow: nullable('string'),
++ route: nullable('object'),
++ preset: nullable({ type: 'object', properties: { name: str(), scope: str(), policy: ref('PresetPolicy') } }),
++ offer: { type: 'object', properties: {
++ provider: nullable('string'), model: nullable('string'), deployment: nullable('string'),
++ provider_access: nullable('string'), resolved_model: nullable('string'),
++ } },
++ price: { type: 'object', properties: {
++ in_per_mtok: nullable('number'), out_per_mtok: nullable('number'), cached_per_mtok: nullable('number'), source: nullable('string'),
++ } },
++ usage: { type: 'object', properties: {
++ tokens_in: integer(), tokens_out: integer(), tokens_cached: integer(), tokens_cache_write: integer(), tokens_reasoning: integer(),
++ } },
++ cost_usd: nullable('number'), fee_usd: nullable('number'), fee_rate: nullable('number'),
++ paid_by: str({ enum: ['openvibe_key', 'own_key', 'own_machine', 'plan'] }), paid_by_detail: nullable('string'), byok: { type: 'boolean' },
++ latency_ms: nullable('integer'), ttft_ms: nullable('integer'),
++ why: arrayOf(str()),
++ fallbacks: arrayOf({ type: 'object', properties: {
++ offer: str(), provider: nullable('string'), model: nullable('string'),
++ why: nullable('string'), status: nullable('string'), reason: nullable('string'),
++ } }),
++ privacy: nullable({ type: 'object', properties: {
++ label: str({ enum: ['private', 'anonymized', 'community'] }),
++ retention: nullable('string'), training: nullable('boolean'), region: nullable('string'),
++ trust: nullable('string'), known: { type: 'boolean' }, source: nullable('string'),
++ } }),
++ attempts: integer(),
++ requester: nullable('object'), owner: nullable('object'),
++ } },
},
},
paths: { ...Object.fromEntries(Object.entries(PATHS).sort(([a], [b]) => a.localeCompare(b)).map(([p, item]) => [MOUNT + p, item])) },
ddiff --git a/server/policy/adult-status.js b/server/policy/adult-status.js
nnew file mode 100644
iindex 0000000..5b0a492
---- /dev/null
++++ b/server/policy/adult-status.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * The adult account flag (the Exchange remake, "Uncensored"), read from OpenVibe.Network.
++ *
++ * Network owns the account and the confirmation: the person ticks "I am 18 or older" in their settings
++ * (`public/my.html`, `PUT /api/v1/me/adult`), and the flag lives on their account row. AI never keeps its own copy of
++ * somebody's age: a run with `policy: "uncensored"` asks Network, once per person per five minutes, and fails closed
++ * when Network cannot answer.
++ *
++ * GET /internal/identity/adult?subject_id=usr_…
++ * Authorization: a service token with identity.subject.resolve
++ * → 200 { subject_id, adult: true, confirmed_at } the person confirmed it
++ * → 200 { subject_id, adult: false } no confirmation on the account
++ * → 404 no such subject
++ *
++ * The answer is cached for five minutes so a burst of requests from one person costs one call, and the cache is
++ * bounded. A failure is never cached: the next request asks again.
++ */
++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CACHE_MS = 5 * 60 * 1000;
++const MAX_CACHE = 5000;
++
++class AdultStatusError extends Error {
++ constructor(code, detail) {
++ super(`${code}: ${detail}`);
++ this.code = code;
++ this.detail = detail;
++ }
++}
++
++function createAdultStatus({ config, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() }, log = console } = {}) {
++ const secret = env.OV_OAUTH_CLIENT_SECRET || '';
++ const base = String((config && config.networkInternalUrl) || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ const cache = new Map(); // subject -> { adult, confirmed_at, at }
++ let tokens = null;
++
++ function tokenClient() {
++ if (!tokens) {
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId: env.OV_OAUTH_CLIENT_ID || 'ai', clientSecret: secret, fetch: fetchImpl });
++ }
++ return tokens;
++ }
++
++ /** Is this subject an account that confirmed it is 18+? Fails closed, never invents an age. */
++ async function adultFor(subject) {
++ const id = String(subject || '');
++ if (!USR.test(id)) return false;
++ const hit = cache.get(id);
++ if (hit && clock.now() - hit.at < CACHE_MS) return hit.adult;
++ const asked = await ask(id);
++ if (cache.size >= MAX_CACHE) cache.clear();
++ cache.set(id, { ...asked, at: clock.now() });
++ return asked.adult;
++ }
++
++ async function ask(id) {
++ if (!secret) throw new AdultStatusError('adult.unavailable', 'the adult account flag cannot be read here: OV_OAUTH_CLIENT_SECRET is not set');
++ let token;
++ try { token = await tokenClient().getToken({ audience: 'openvibe.network' }); }
++ catch (err) { throw new AdultStatusError('adult.unavailable', `Network did not issue a service token (${String(err && err.message || err).slice(0, 160)})`); }
++ let res;
++ try {
++ res = await fetchImpl(`${base}/internal/identity/adult?subject_id=${encodeURIComponent(id)}`, {
++ method: 'GET', signal: AbortSignal.timeout(5000),
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
++ });
++ } catch (err) { throw new AdultStatusError('adult.unavailable', `Network could not be reached (${String(err && err.message || err).slice(0, 160)})`); }
++ if (res.status === 404) return { adult: false, confirmed_at: null };
++ if (res.status !== 200) throw new AdultStatusError('adult.unavailable', `Network answered ${res.status}`);
++ let body = null;
++ try { body = await res.json(); } catch { body = null; }
++ if (!body || typeof body !== 'object') throw new AdultStatusError('adult.unavailable', 'Network answered with no JSON');
++ return { adult: body.adult === true, confirmed_at: body.confirmed_at || null };
++ }
++
++ /** Forget one person's answer (the settings screen changing the flag, and tests). */
++ function forget(subject) { cache.delete(String(subject || '')); }
++ function clear() { cache.clear(); }
++
++ return { adultFor, forget, clear, get enabled() { return Boolean(secret); } };
++}
++
++module.exports = { createAdultStatus, AdultStatusError, USR, CACHE_MS };
ddiff --git a/server/policy/floor.js b/server/policy/floor.js
nnew file mode 100644
iindex 0000000..c885614
---- /dev/null
++++ b/server/policy/floor.js
@@@ -0,0 +1,117 @@
++'use strict';
++/**
++ * The hard floor (the Exchange remake, "Uncensored"): the three lines that hold on every offer, uncensored or not.
++ *
++ * 1. no sexual content involving minors;
++ * 2. no non-consensual sexual content of real people;
++ * 3. no help with weapons of mass harm or with attacks on others.
++ *
++ * A breach ends the account (the owner, 2026-11-06). The floor is checked twice: on the way in (the prompt, the
++ * system prompt, the tool arguments) and on the way out (the answer). A request that trips it is refused before a
++ * provider is called; an answer that trips it is never delivered.
++ *
++ * The patterns are the ones OpenVibe.Live already uses for the same job (server/ai/explicit.js: a minor together
++ * with nudity is explicit; server/chat/moderation-utils.js: the "minors", "sexual" and "threats" categories), kept
++ * deliberately narrow so an ordinary question ("how do I kill a stuck process", "what is our incident response
++ * plan") is never refused. A person and a sexual act must be in the same text, and a weapon only counts when the
++ * text asks for one to be made, acquired or used.
++ */
++
++/** A person who is not an adult. */
++const MINOR = /\b(?:child(?:ren)?|kids?|minors?|minor|teen(?:age(?:rs?)?|s)?|toddlers?|bab(?:y|ies)|infants?|boys?|girls?|underage|pre-?teen(?:s)?|schoolboy|schoolgirl|loli|shota)\b/i;
++/** Sexual content, in the words a request or an answer uses. */
++const SEXUAL = /\b(?:sexual(?:ly)?\s+(?:acts?|activity|intercourse)|sex\s+acts?|having\s+sex|(?:pornographic|sexually\s+explicit|explicit\s+adult)\s+(?:content|material|footage|scenes?|imagery|videos?|acts?)|porn(?:o|ography|ographic)?|masturbat\w*|intercourse|oral\s+sex|penetrat\w*|hentai|nsfw|blowjob|handjob|anal\s+sex|doggy\s+style)\b/i;
++/** A bare body: the hard line is a child AND nudity in the same text (Live's explicit.js), not the word "sexual" alone. */
++const NUDE = /\b(?:naked|nude|nudity|undress(?:ed|ing)?|topless|bottomless|genital(?:s|ia)?|penis(?:es)?|vaginas?|vulvas?|breasts?|boobs?|nipples?|erotic(?:a)?|orgasm|cunnilingus|fellatio)\b/i;
++/** Words for a child and sex together (the clearest form of the first rule). */
++const CSAM = /\b(?:child\s?(?:porn|porno|abuse|sexual)|cp\s(?:content|video|series)\b|kiddie\s?(?:porn|porno)|pedophile|pedophilia|paedophile|paedophilia)\b/i;
++/** Non-consensual sex, and the real people it is done to. */
++const NONCONSENSUAL = /\b(?:rape|raped|rapes|raping|rapist|sexual assault|sexual abuse|molest(?:ed|ing|s)?|incest|statutory rape|non-?consensual|without (?:her|his|their|my) consent|she (?:said|said no|didn't consent|did not consent)|he (?:said no|didn't consent|did not consent)|they (?:said no|didn't consent|did not consent)|took advantage of (?:her|him|them)|she was (?:drugged|drunk|asleep|unconscious)|drugged (?:her|him|them)|unconscious (?:sex|woman|girl|man)|forced (?:sex|himself|herself|them)|coerc(?:e|ed|ing|ion)|groom(?:ed|ing)?|traffick(?:ed|ing)?|deepfake(?:s|d)?|revenge porn|nude photos? of (?:her|him|them)|sexual(?:ly)? explicit (?:photos?|pictures?|images?|videos?) of (?:her|him|them))\b/i;
++/** A weapon of mass harm, by its own name: these are never a question about safety. */
++const MASS_HARM = /\b(?:nerve agent|sarin|vx gas|tabun|soman|mustard gas|chlorine gas|anthrax|ricin|botulinum|cyanide|nerve gas|bioweapon|biological weapon|chemical weapon|dirty bomb|nuclear (?:bomb|device|weapon)|radiological (?:bomb|device|weapon)|weapons? of mass destruction|\bwmd\b|germ warfare|biological warfare|pathogen (?:strain|weapon)|weaponized? (?:virus|pathogen|bacillus))\b/i;
++/** A weapon, and the asking: how to make one, get one or use it on people. */
++const WEAPON_INTENT = /\b(?:(?:make|build|construct|synthesi[sz]e|manufactur(?:e|ing)|assembl\w+|acquire|obtain|buy|purchase|deploy|detonat\w+|ignit\w+|use|plant|rig)\w*\b[^.?!]{0,60}\b(?:bomb|explosive|ied\b|pipe bomb|dirty bomb|grenade|molotov|napalm|thermite|shrapnel|untraceable (?:gun|firearm)|ghost gun|suppressor|silencer)|(?:bomb|explosive|ied\b|pipe bomb|grenade|molotov|napalm|thermite)\b[^.?!]{0,60}\b(?:make|build|construct|synthesi[sz]e|manufactur\w+|how to|recipe|instructions?|detonat\w+|ignit\w+))/i;
++/** An attack on people: the asking and the person. */
++const ATTACK_INTENT = /\b(?:(?:how (?:do|can|would) (?:i|we|you|one))\s+[^.?!]{0,40}\b(?:kill|murder|assassinate|poison|hurt|attack|stab|shoot)\b[^.?!]{0,40}\b(?:him|her|them|someone|somebody|people|my \w+|a person|people I|people at)|(?:kill|murder|assassinate|poison|stab|shoot)\s+(?:him|her|them|someone|somebody|people|my \w+|a person)|mass shooting|school shooting|terrorist attack|suicide attack|car bomb|truck bomb)\b/i;
++
++/** The three rules, in the order they are checked. */
++const RULES = [
++ { rule: 'minors', label: 'sexual content involving minors', test: (t) => CSAM.test(t) || (MINOR.test(t) && NUDE.test(t)) },
++ { rule: 'non_consensual', label: 'non-consensual sexual content of real people', test: (t) => NONCONSENSUAL.test(t) },
++ { rule: 'mass_harm', label: 'weapons of mass harm or attacks on others', test: (t) => MASS_HARM.test(t) || WEAPON_INTENT.test(t) || ATTACK_INTENT.test(t) },
++];
++
++/** The text of a request: the system prompt, the messages and the tool arguments. */
++function textsOf(req = {}) {
++ const out = [];
++ if (typeof req.system === 'string') out.push(req.system);
++ for (const s of req.system || []) if (s && s.text) out.push(String(s.text));
++ for (const m of req.messages || []) {
++ if (!m) continue;
++ if (typeof m.content === 'string') out.push(m.content);
++ else if (Array.isArray(m.content)) for (const p of m.content) if (p && p.text) out.push(String(p.text));
++ if (m.name) out.push(String(m.name));
++ if (m.tool_calls) for (const t of m.tool_calls) {
++ if (t && t.function && t.function.arguments) out.push(String(t.function.arguments));
++ if (t && t.function && t.function.name) out.push(String(t.function.name));
++ }
++ }
++ if (req.openai && Array.isArray(req.openai.messages)) {
++ for (const m of req.openai.messages) {
++ if (!m) continue;
++ if (typeof m.content === 'string') out.push(m.content);
++ else if (Array.isArray(m.content)) for (const p of m.content) if (p && p.text) out.push(String(p.text));
++ if (m.tool_calls) for (const t of m.tool_calls) if (t && t.function && t.function.arguments) out.push(String(t.function.arguments));
++ }
++ for (const t of req.openai.tools || []) if (t && t.function && t.function.description) out.push(String(t.function.description));
++ }
++ return out.filter(Boolean);
++}
++
++/**
++ * Does this text break the floor? → { ok: true } or { ok: false, rule, label, detail }.
++ * The detail never quotes the text: only the rule it broke.
++ */
++function check(texts) {
++ const list = (Array.isArray(texts) ? texts : [texts]).filter((t) => t != null && String(t).trim());
++ for (const t of list) {
++ const text = String(t);
++ for (const r of RULES) if (r.test(text)) return { ok: false, rule: r.rule, label: r.label, detail: `this request breaks the hard floor (${r.label})` };
++ }
++ return { ok: true };
++}
++
++/** The floor on the way in: the request's own text. */
++function checkRequest(req) { return check(textsOf(req)); }
++
++/** The text of an answer, whatever shape the operation returned it in. */
++function textsOfAnswer(result = {}) {
++ const out = [];
++ if (typeof result.text === 'string') out.push(result.text);
++ const addContent = (c) => {
++ if (typeof c === 'string') out.push(c);
++ else if (Array.isArray(c)) for (const p of c) {
++ if (p && typeof p.text === 'string') out.push(p.text);
++ if (p && p.input != null) out.push(JSON.stringify(p.input));
++ }
++ };
++ for (const ch of (result.completion && result.completion.choices) || []) {
++ const m = ch && ch.message;
++ if (!m) continue;
++ addContent(m.content);
++ for (const t of m.tool_calls || []) if (t && t.function && t.function.arguments) out.push(String(t.function.arguments));
++ }
++ if (result.message) {
++ addContent(result.message.content);
++ for (const t of result.message.tool_calls || []) if (t && t.function && t.function.arguments) out.push(String(t.function.arguments));
++ }
++ return out.filter(Boolean);
++}
++
++/** The floor on the way out: the answer's text. */
++function checkAnswer(text) { return check(Array.isArray(text) ? text : [text]); }
++
++/** The floor on the way out, over every shape an operation's answer uses. */
++function checkResult(result) { return check(textsOfAnswer(result)); }
++
++module.exports = { RULES, MINOR, SEXUAL, NUDE, CSAM, NONCONSENSUAL, MASS_HARM, WEAPON_INTENT, ATTACK_INTENT, textsOf, textsOfAnswer, check, checkRequest, checkAnswer, checkResult };
ddiff --git a/server/policy/tier.js b/server/policy/tier.js
nnew file mode 100644
iindex 0000000..695c124
---- /dev/null
++++ b/server/policy/tier.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * The content policy of a request (the Exchange remake, "Uncensored"): standard, or uncensored for adults.
++ *
++ * standard what every request gets. An offer marked uncensored never answers it.
++ * uncensored only for an account that confirmed it is 18+ in OpenVibe.Network (server/policy/adult-status.js),
++ * only to an offer marked uncensored, and always labelled as such in the answer.
++ *
++ * The rules, in one place:
++ *
++ * - a developer app never gets the adult tier (its token is a project's, not a person's);
++ * - the request must name the person it is for (`on_behalf_of`), and Network must say that account is 18+;
++ * - the floor (server/policy/floor.js) holds on every offer, uncensored or not.
++ *
++ * The tier never widens what a provider may do: it only decides which offers may answer and how the answer is
++ * labelled.
++ */
++const pool = require('../providers/pool-providers');
++
++const POLICIES = ['standard', 'uncensored'];
++/** The label every uncensored answer carries (in the answer's metadata, never hidden in the text). */
++const LABEL = 'Uncensored model — adults only (18+)';
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/**
++ * The policy a request asks for. Unknown values are refused, never quietly treated as standard:
++ * → { policy } or { error: 'input.invalid', detail }
++ */
++function parsePolicy(value) {
++ if (value == null || value === '') return { policy: 'standard' };
++ const v = String(value).trim().toLowerCase();
++ if (!POLICIES.includes(v)) return { error: 'input.invalid', detail: `policy must be one of ${POLICIES.join(', ')}` };
++ return { policy: v };
++}
++
++/** Is this offer an uncensored one? A provider or a model marked uncensored is (server/providers/pool-providers.js). */
++function offerIsUncensored(provider, model) { return pool.isUncensored(provider, model); }
++
++/**
++ * May this request run under this policy? The answer is the 18+ gate.
++ *
++ * policy 'standard' | 'uncensored'
++ * appRun true for a developer app's run
++ * subject the person the request is for ({ type: 'user', id: 'usr_…' }) or null
++ * adultFor async (id) → boolean, from server/policy/adult-status.js (tests pass their own)
++ *
++ * → { ok: true, policy, uncensored, subject } or { ok: false, status, code, detail }
++ */
++async function admit({ policy = 'standard', appRun = false, subject = null, adultFor = null } = {}) {
++ if (policy !== 'uncensored') return { ok: true, policy: 'standard', uncensored: false, subject: null };
++ if (appRun) {
++ return { ok: false, status: 403, code: 'policy.uncensored_refused', detail: 'a developer app may not use the adult tier: it is a project, not a person' };
++ }
++ const id = subject && subject.type === 'user' ? String(subject.id || '') : '';
++ if (!SUBJECT_RE.test(id)) {
++ return { ok: false, status: 400, code: 'policy.uncensored_needs_person', detail: 'an uncensored request must name the person it is for (on_behalf_of)' };
++ }
++ if (typeof adultFor !== 'function') {
++ return { ok: false, status: 503, code: 'policy.adult_unavailable', detail: 'the adult account flag is not available on this service' };
++ }
++ let adult;
++ try { adult = await adultFor(id); }
++ catch (err) {
++ return { ok: false, status: 503, code: 'policy.adult_unavailable', detail: String(err && (err.detail || err.message) || 'the adult account flag could not be read').slice(0, 300) };
++ }
++ if (!adult) {
++ return { ok: false, status: 403, code: 'policy.adult_required', detail: 'the adult tier is for accounts that confirmed they are 18 or older' };
++ }
++ return { ok: true, policy: 'uncensored', uncensored: true, subject: { type: 'user', id } };
++}
++
++module.exports = { POLICIES, LABEL, SUBJECT_RE, parsePolicy, offerIsUncensored, admit };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 303f05c..7d79601 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -38,6 +38,18 @@ const { createWarmCache } = require('./warm-cache');
const { createProviderMetrics } = require('./metrics');
const { createVariantEvidence } = require('./bench-variant');
const { createRoutingEvidence } = require('./evidence');
++const exchangeConstraints = require('../exchange/constraints');
++const exchangePrivacy = require('../exchange/privacy');
++const floor = require('../policy/floor');
++const tier = require('../policy/tier');
++
++/** Is this offer allowed under the request's content policy? Standard never runs an uncensored offer; the adult tier runs nothing else. */
++async function policyAllows(registry, provider, modelId, policy = 'standard') {
++ if (!provider) return false;
++ const row = modelId ? await registry.getModel(provider.key, modelId) : null;
++ const offerUncensored = tier.offerIsUncensored(provider, row);
++ return policy === 'uncensored' ? offerUncensored : !offerUncensored;
++}
/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */
function callerFault(err) {
@@@ -50,6 +62,59 @@ function rateLimited(err) {
return (err && err.status === 429) || (err && err.code === 'provider.rate_limited');
}
++/**
++ * The hard floor on a streamed answer (server/policy/floor.js). The text is checked as it grows, before each chunk
++ * goes out: a chunk that makes the answer break the floor is never delivered, and the stream stops with
++ * `policy.floor_refused`. (Text already delivered cannot be recalled; the check is per chunk and over the whole
++ * answer so far, including tool-call arguments.)
++ */
++function createFloorGuard(emit, { hold = 0 } = {}) {
++ let all = ''; // the whole answer so far: the floor checks this, never only the held tail
++ let held = ''; // the text not yet delivered (hold characters, or nothing)
++ let args = '';
++ const queued = []; // { d, text } in arrival order; text is the content the delta carries
++ const refuse = (r) => Object.assign(new Error(r.detail), { code: 'policy.floor_refused', status: 403, detail: r.detail, rule: r.rule });
++ const checkNow = () => {
++ const r = floor.checkAnswer([all, args].filter(Boolean));
++ if (!r.ok) throw refuse(r);
++ };
++ function flush(final) {
++ while (queued.length && (final || held.length > hold)) {
++ const head = queued[0];
++ const over = held.length - hold;
++ if (head.text.length && !final && head.text.length > over) {
++ // Split the delta: deliver only what is outside the held tail.
++ const cut = head.text.length - over;
++ emit({ ...head.d, delta: { ...head.d.delta, content: head.text.slice(0, cut) } });
++ head.text = head.text.slice(cut);
++ held = held.slice(cut);
++ return;
++ }
++ queued.shift();
++ held = held.slice(head.text.length);
++ emit(head.text && head.d && head.d.delta ? { ...head.d, delta: { ...head.d.delta, content: head.text } } : head.d);
++ }
++ }
++ return {
++ push(d) {
++ if (!d || typeof d !== 'object') return;
++ const content = d.delta && typeof d.delta.content === 'string' ? d.delta.content : '';
++ if (content) { all += content; held += content; }
++ for (const t of (d.delta && d.delta.tool_calls) || []) {
++ if (t && t.function && t.function.arguments) args += String(t.function.arguments);
++ }
++ checkNow();
++ queued.push({ d, text: content });
++ flush(false);
++ },
++ finish() {
++ checkNow();
++ flush(true);
++ },
++ get text() { return all; },
++ };
++}
++
/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */
const { CREDENTIAL_PROVIDERS, PROVIDER_KIND, DEFAULT_MODEL } = require('../credentials');
const CREDENTIAL_DEFAULT_MODEL = { ...DEFAULT_MODEL, anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };
@@@ -134,7 +199,7 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
return { model, kind, why };
}
--function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null }) {
++function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
@@@ -343,6 +408,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const addOffer = async (p, model, caps) => {
const id = offerId(p.key, model);
if (seen.has(id)) return;
++ // The content policy narrows the pool before placement: an uncensored offer never answers a standard
++ // request, and the adult tier has no other offer to run on.
++ if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
seen.add(id);
const creds = p.auth_mode === 'none' || credentialNotRequired(p) || Boolean(resolveSecret(p.secret_ref, env));
const h = await health(p.key);
@@@ -468,16 +536,19 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
for (const c of list) {
const entry = await adapter(c.provider);
const p = entry && entry.record;
++ const modelId = c.model || (p && p.default_model) || null;
const why = await skipReason(p, entry && entry.adapter, features);
const offered = appOffered(ctx, p);
const access = accessOffered(p);
-- const excluded = !offered ? (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only')
++ const policyOk = p ? await policyAllows(registry, p, modelId, ctx.contentPolicy || 'standard') : false;
++ const excluded = !policyOk ? 'content_policy'
++ : !offered ? (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only')
: !access ? `provider_access ${modelCatalog.accessOf(p)}` : why;
explain.candidates.push({
-- provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: offered && access && !why,
++ provider: c.provider, model: modelId, eligible: offered && access && policyOk && !why,
excluded_reason: excluded, cost: null, latency: null,
});
-- if (offered && access) order.push({ provider: c.provider, model: c.model || null });
++ if (offered && access && policyOk) order.push({ provider: c.provider, model: c.model || null });
}
return { order, explain };
}
@@@ -513,7 +584,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
// A scoped route never ends on synthetic text (media.paid falls back to its extractive overview); an image
// has no such fallback, so the dev/test stub PNG (AI_STUB_FALLBACK, off in production) still closes it.
-- if (config.stubFallback && (!(route.constraints && route.constraints.provider_scope) || operation === 'image')
++ if (config.stubFallback && (ctx.contentPolicy || 'standard') !== 'uncensored'
++ && (!(route.constraints && route.constraints.provider_scope) || operation === 'image')
&& !order.some(c => c.provider === 'stub') && await registry.getProvider('stub')) {
order.push({ provider: 'stub', model: null });
explain.candidates.push({ provider: 'stub', model: null, eligible: true, excluded_reason: null, cost: 0, latency: null });
@@@ -544,6 +616,27 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return Number(meta.timeout_ms) || Number(p.timeout_ms) || 0;
}
++ /**
++ * What a routing preset's rules need to know about one offer (server/exchange/constraints.js): its price, the
++ * catalogue's tier, its quantization, the region it runs in and its privacy class. Everything is read from the
++ * catalogue, the rate cards and the provider's own record: nothing is guessed.
++ */
++ async function constraintFacts(c) {
++ const d = await describeCandidate(c);
++ const meta = (d.row && d.row.metadata) || {};
++ const artifact = d.row ? modelCatalog.artifactOf(d.row, d.record) : null;
++ const declared = exchangePrivacy.dataPolicyOf(d.record);
++ const region = (declared && declared.region)
++ || (d.record && d.record.metadata && d.record.metadata.region) || null;
++ return {
++ priceIn: d.priceIn, priceOut: d.priceOut,
++ tier: d.caps.tier || 'balanced',
++ quant: (artifact && artifact.quant) || meta.quant || null,
++ region,
++ privacy: exchangePrivacy.labelFor({ record: d.record }),
++ };
++ }
++
/**
* What the catalogue knows about the model a caller asked for (L37): the provider, and what the model can do.
* A model the catalogue never described is not refused: the provider decides.
@@@ -802,11 +895,39 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
async function execute(route, operation, req, ctx = {}) {
-- if (ctx.credential) return await executeWithCredential(ctx.credential, operation, req, ctx);
++ const contentPolicy = ctx.contentPolicy || 'standard';
++ const refuseFloor = (r) => new AiError(403, 'policy.floor_refused', r.detail, { rule: r.rule, policy: contentPolicy });
++ if (ctx.credential) {
++ // A person's own key is not an offer marked uncensored, and the adult tier is network supply only. The
++ // hard floor still holds: it is about the request and the answer, not about who pays.
++ if (contentPolicy === 'uncensored') throw new AiError(403, 'policy.uncensored_refused', 'the adult tier is served by network offers only; a personal key cannot answer it');
++ const asked = floor.checkRequest(req);
++ if (!asked.ok) throw refuseFloor(asked);
++ const out = await executeWithCredential(ctx.credential, operation, req, ctx);
++ const answer = floor.checkResult(out.result);
++ if (!answer.ok) throw refuseFloor(answer);
++ return out;
++ }
const features = [operation];
if (req.image) features.push('vision');
if (req.json) features.push('json');
if (req.openai && Array.isArray(req.openai.tools) && req.openai.tools.length) features.push('tools');
++ // The Exchange remake's content policy: standard (every request) or uncensored (18+ accounts only, uncensored
++ // offers only). The 18+ answer comes from Network (server/policy/adult-status.js), never from the caller.
++ const admission = await tier.admit({
++ policy: contentPolicy,
++ appRun: Boolean(ctx.appRun),
++ subject: ctx.onBehalfOf ? { type: 'user', id: String(ctx.onBehalfOf) } : null,
++ adultFor: adultStatus ? (id) => adultStatus.adultFor(id) : null,
++ });
++ if (!admission.ok) throw new AiError(admission.status, admission.code, admission.detail, { policy: contentPolicy });
++ const uncensored = admission.uncensored;
++ if (uncensored && !['chat', 'complete', 'messages'].includes(operation)) {
++ throw new AiError(422, 'input.invalid', `the uncensored policy is for chat answers, not ${operation}`);
++ }
++ // The hard floor holds on every offer, uncensored or not: the request is refused before a provider is called.
++ const floorIn = floor.checkRequest(req);
++ if (!floorIn.ok) throw refuseFloor(floorIn);
// L36 Y7: the task, read off the request itself: what it needs, what it wants out, how heavy it is.
const task = detectTask({ operation, req, ctx, route });
// L37: a soft preference. The caller names a provider (and a model if it wants one). It never disables the
@@@ -822,7 +943,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// L40: the time to the first token, when the answer streams.
let attemptStart = 0;
let ttftMs = null;
-- const onDelta = typeof req.onDelta === 'function' ? (d) => { streamed = true; if (ttftMs === null && attemptStart) ttftMs = Date.now() - attemptStart; req.onDelta(d); } : null;
++ const floorGuard = typeof req.onDelta === 'function' ? createFloorGuard(req.onDelta) : null;
++ const onDelta = floorGuard ? (d) => { streamed = true; if (ttftMs === null && attemptStart) ttftMs = Date.now() - attemptStart; floorGuard.push(d); } : null;
// L36 Y1: a run on behalf of a person tries the person's OWN keys first, for the providers that can serve the
// request. With only_my_keys it never falls back; otherwise the network is the fallback and the explain says so.
const prefs = ctx.selfPreferences || {};
@@@ -834,8 +956,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const ceiling = freeCeiling != null ? freeCeiling : prefs.price_ceiling_usd_per_mtok;
let freeCeilingSkipped = false; // every capable offer was dearer than the free ceiling
const selfAttempts = [];
-- const selfList = Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : [];
-- if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {
++ // The adult tier is network supply only: a person's own provider key is not an offer marked uncensored, and
++ // only such an offer may answer an uncensored request. Their key is still used for every standard request.
++ const selfList = uncensored ? [] : (Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : []);
++ if (!uncensored && (selfList.length || (ctx.selfSubject && prefs.only_my_keys))) {
const order = [...selfList].sort((a, b) => {
const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);
// L37: the person's own key for the provider they asked for answers first, at no fee.
@@@ -860,6 +984,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const key = paidByOf(cred);
try {
const out = await executeWithCredential(cred, operation, req, { ...ctx, defaultModel: pick.model });
++ const answer = floor.checkResult(out.result);
++ if (!answer.ok) throw refuseFloor(answer);
const attempts = [...selfAttempts, { key, provider: cred.provider, model: pick.model, why: pick.why, status: 'ok', error: null }];
// L37: the preference was honoured when their own key for that provider answered.
if (preferState && prefer.provider && String(cred.provider) === prefer.provider) {
@@@ -877,6 +1003,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
},
};
} catch (err) {
++ // A breach is the request's, not the key's: never fall back to another provider to get it answered.
++ if (err && err.code === 'policy.floor_refused') throw err;
const detail = String(err && (err.detail || err.message) || err).slice(0, 300);
selfAttempts.push({ key, provider: cred.provider, model: pick.model, why: pick.why, status: 'failed', error: detail });
}
@@@ -893,7 +1021,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// Community capacity (design: "Offer to the network"; plan E4): only a request that opted in may run on a
// person's machine, never an exact pin, never a BYOK run. The canaries run before the first answer; a failure
// stops that machine and the ordinary network answers instead. The answer is labelled in explain.openvibe.
-- if (community && ctx.communityOptIn && !ctx.credential && !ctx.byok && !ctx.exact) {
++ // Community placement runs before the catalog pool. A preset with price, privacy, region, or quality
++ // rules must use the catalog path so those rules are checked before any offer is called.
++ const communityPresetSafe = !ctx.presetPolicy || Object.keys(ctx.presetPolicy).every((key) => key === 'community');
++ if (community && ctx.communityOptIn && communityPresetSafe && !ctx.credential && !ctx.byok && !ctx.exact) {
try {
const out = await community.execute({ route, operation, req, ctx });
if (out) return out;
@@@ -906,7 +1037,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
// that id, filtered first by commercial access and what the request needs; no other model is ever added.
const exact = ctx.exact || null;
-- const policy = ctx.policy || (exact ? 'exact' : null);
++ // A routing preset's objective is the more specific request: it decides what the router optimises
++ // (cheapest, fastest, balanced, private). An exact id stays exact: a pin is never re-ranked.
++ const presetObjective = ctx.presetPolicy && ctx.presetPolicy.objective ? ctx.presetPolicy.objective : null;
++ const policy = (exact || ctx.policy === 'exact') ? 'exact' : (presetObjective || ctx.policy || null);
let list;
let explain;
if (exact) {
@@@ -920,7 +1054,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const missing = hardNeeds(capabilitiesOf(row, p), task);
const why = [];
const card = row ? modelCatalog.retailRate(row) : null;
++ const policyOk = p ? await policyAllows(registry, p, offer.model, ctx.contentPolicy || 'standard') : false;
if (!p) why.push('unknown_provider');
++ else if (!policyOk) why.push('content_policy');
else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }
else {
// The free allowance / anonymous Try it cannot pin a model above the output price ceiling.
@@@ -949,6 +1085,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
({ order: list, explain } = await candidates(route, features, operation, ctx));
if (policy) explain.policy = policy;
}
++ // The adult tier only runs offers explicitly marked uncensored; when none is live (no key, no agreement, the
++ // circuit open) the answer is the reason, never a standard model quietly standing in.
++ if (uncensored && list.length === 0) {
++ throw new AiError(503, 'policy.uncensored_unavailable', 'no uncensored offer can answer this request right now', { explain });
++ }
// L36 Y3: free_first puts the free tiers ahead of metered ones for this person's fallback. Own-key attempts above
// are already free to them, so this only reorders the network.
if (prefs.free_first && ctx.selfSubject) {
@@@ -1004,6 +1145,29 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
explain.prefer = preferState;
}
++ // The routing preset's rules (server/exchange/constraints.js): the floor, the ceiling, the hard max_price,
++ // the quantization, the region, the data policy and the community switch. An offer a rule removes leaves
++ // the order with its reason; a hard max_price that no offer fits refuses the request here, before any
++ // provider is called, with the prices that were refused.
++ if (ctx.presetPolicy && Object.keys(ctx.presetPolicy).length) {
++ const { order: allowed, excluded } = await exchangeConstraints.applyConstraints({
++ list, policy: ctx.presetPolicy, facts: constraintFacts,
++ });
++ if (ctx.presetPolicy.max_price != null && !allowed.length) {
++ const refusal = exchangeConstraints.refusal({ policy: ctx.presetPolicy, excluded });
++ throw new AiError(400, refusal.code, refusal.detail, {
++ max_price: refusal.max_price,
++ lowest_price: refusal.lowest_price,
++ excluded: refusal.excluded,
++ explain: { ...explain, preset: { name: ctx.presetName || null, policy: ctx.presetPolicy } },
++ });
++ }
++ list = allowed;
++ for (const x of excluded) {
++ explain.candidates.push({ provider: x.provider, model: x.model, eligible: false, excluded_reason: x.reason, cost: null, latency: null });
++ }
++ explain.preset = { name: ctx.presetName || null, policy: ctx.presetPolicy };
++ }
// AI Exchange P7: an alias route (economy/fast/balanced/auto) is ranked by measured evidence when
// AI_ROUTING_EVIDENCE is on. planAi (openvibe-sdk/placement) is the planner: this only prepares its
// inputs, keeps the incumbent for N decisions and explains the answer. Private never leaves local
@@@ -1018,6 +1182,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
const evidenceReason = explain.evidence ? explain.evidence.reason : null;
++ explain.content_policy = contentPolicy;
++ if (uncensored) explain.label = tier.LABEL;
const attempts = [...selfAttempts];
explain.paid_by = 'openvibe';
explain.attempts = attempts;
@@@ -1110,6 +1276,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
policyReason: evidenceReason,
fallbackReason: fallback ? `the first eligible offer did not answer; a later offer on this same route answered${evidenceReason ? ` (${evidenceReason})` : ''}` : null,
});
++ // The label is known before the call, so a streamed answer can carry it in its first SSE comment.
++ if (uncensored) { attemptFacts.content_policy = 'uncensored'; attemptFacts.label = tier.LABEL; }
// The hold needs the offer's retail card, whether it is prepaid, and the real output cap.
const attemptRow = p && model ? await registry.getModel(p.key, model) : null;
const attemptMeta = { provider: p ? p.key : null, model, maxTokens: req.maxTokens, paid: !isFreeProvider(p), retail: modelCatalog.retailRate(attemptRow) };
@@@ -1132,6 +1300,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (operation === 'embed' && (!Array.isArray(result.vectors) || result.vectors.length !== req.input.length
|| result.vectors.some(v => !Array.isArray(v) || !v.length || v.some(n => typeof n !== 'number' || !Number.isFinite(n)))))
throw new ProviderError('embedding response has invalid vectors', { code: 'provider.output' });
++ // The floor on the way out, over the answer's own shape (chat text, completion choices, tool
++ // arguments, a message's blocks): an answer that carries a breach never leaves this function.
++ if (floorGuard) floorGuard.finish();
++ const floorOut = floor.checkResult(result);
++ if (!floorOut.ok) throw new AiError(403, 'policy.floor_refused', floorOut.detail, { rule: floorOut.rule, policy: contentPolicy });
++ if (uncensored) { result.policy = 'uncensored'; result.label = tier.LABEL; }
const latency = Date.now() - t0;
const usage = result.usage || { input: 0, output: 0, cached: 0 };
await recordEvidence({ provider: c.provider, model, ok: true, timeout: false, ttftMs, latencyMs: latency,
@@@ -1171,6 +1345,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
await recordEvidence({ provider: c.provider, model, ok: false, timeout: Boolean(err && err.code === 'provider.timeout'), latencyMs: Date.now() - t0 });
statFor(c.provider).failures++;
if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
++ // A stream that broke off still delivers what the upstream had already said (the guard's held tail
++ // included), unless that tail itself breaks the floor.
++ if (streamed && floorGuard) {
++ try { floorGuard.finish(); }
++ catch (guardErr) {
++ if (guardErr && guardErr.code === 'policy.floor_refused') throw new AiError(403, 'policy.floor_refused', guardErr.detail || guardErr.message, { rule: guardErr.rule, policy: contentPolicy });
++ throw guardErr;
++ }
++ }
++ if (streamed && err && err.code === 'policy.floor_refused') throw new AiError(403, 'policy.floor_refused', err.detail || err.message, { rule: err.rule, policy: contentPolicy });
if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${String(err && err.message || err).slice(0, 200)}`, { tried: [...tried, { provider: c.provider, error: String(err && err.message || err).slice(0, 200) }], explain });
// A timed-out image may still be generated and billed upstream: never pay for it twice.
if (attempt < retries && retryable(err) && !(operation === 'image' && err.code === 'provider.timeout')) { await sleep(config.retryDelayMs + Math.floor(Math.random() * Math.min(700, config.retryDelayMs)), ctx.signal).catch(() => {}); continue; }
@@@ -1209,4 +1393,4 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return { adapter, health, resetHealth, execute, executeWithCredential, offers, benchView, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates, machines, routingEvidence };
}
--module.exports = { createProviderPool, machineMessages, machineTools };
++module.exports = { createProviderPool, createFloorGuard, policyAllows, machineMessages, machineTools };
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
nnew file mode 100644
iindex 0000000..1a14596
---- /dev/null
++++ b/server/providers/pool-providers.js
@@@ -0,0 +1,409 @@
++'use strict';
++/**
++ * The providers OpenVibe buys capacity from (the Exchange remake, stage 2): Qwen (Alibaba Model Studio), Kimi
++ * (Moonshot), GLM (Z.ai), MiniMax, DeepInfra, Novita, Featherless, Refuseless and OpenRouter.
++ *
++ * Each one is off until the owner puts its key in the owner settings (D11: `ai.provider_keys`, a sealed key bound to
++ * the provider's origin). The pool skips a provider whose key does not resolve (`no_credentials`), so a provider with
++ * no key is never offered and never costs anything.
++ *
++ * What is here, and where it comes from:
++ *
++ * base_url, kind, timeout the provider's own API (its docs page). OpenAI-compatible mode for every one of them:
++ * DashScope's compatible-mode path, Moonshot's /v1, Z.ai's /api/paas/v4, MiniMax's /v1,
++ * DeepInfra's /v1/openai, Novita's /v3/openai, Featherless's /v1, Refuseless's /v1 and
++ * OpenRouter's /api/v1.
++ * models and prices the price book (research-2026-10-30/price-book/inference.csv, checked 2026-10-30) and
++ * OpenVibe.Watch's recorded official lists (2026-11-04) for DeepInfra and Featherless. Every
++ * price carries the page it was read from and the day it was read; a price we do not have
++ * is never invented: the model is left out of the catalog instead.
++ * data_policy what the provider says it does with a prompt (the price book's `data_policy` and
++ * `trains_on_prompts` columns), with the terms page and the day it was checked.
++ * provider_access the commercial gate (docs/provider-matrix.md, 2026-11-03): only OWN_DEPLOYMENT and
++ * AUTHORIZED_WHOLESALE may serve managed paid routes. UNVERIFIED and RESTRICTED offers
++ * stay out of the managed pool while the model catalogue is on; the owner raises a
++ * provider's access mode only after the agreement that allows it.
++ *
++ * The uncensored tier: Refuseless's abliterated models (and the abliterated models Featherless lists) are marked
++ * `uncensored: true`. Only an offer marked that way may answer a request with `policy: "uncensored"`, and only a
++ * request from an 18+ account may ask for one (server/policy/tier.js).
++ *
++ * Prices change only by review: this file is edited like code, and the price it carries is the price the router
++ * ranks on and the balance is charged.
++ */
++
++const PRICE_BOOK = { checked: '2026-10-30', file: 'research-2026-10-30/price-book/inference.csv' };
++const WATCH_LISTS = { checked: '2026-11-04', file: 'OpenVibe.Watch test/fixtures/provider-rates' };
++
++/** The keys of the nine providers, in the order the router should prefer them when the price is equal. */
++const PROVIDER_KEYS = ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'openrouter'];
++
++/**
++ * The data policy of a provider, as the price book recorded it. `trains_on_prompts` is one of
++ * no | not_stated | yes | unknown; `retention` and `region` are free text (the price book's own words).
++ */
++const policy = (o) => ({
++ trains_on_prompts: o.trains || 'not_stated',
++ retention: o.retention || 'not stated',
++ region: o.region || 'not stated',
++ terms_url: o.terms || null,
++ resale: o.resale || null,
++ checked: o.checked || PRICE_BOOK.checked,
++ source: o.source || null,
++});
++
++const PROVIDERS = [
++ {
++ key: 'qwen',
++ display_name: 'Qwen (Alibaba Model Studio)',
++ base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
++ default_model: 'qwen3.8-max',
++ timeout_ms: 60000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 40,
++ data_policy: policy({
++ trains: 'not_stated', retention: 'not stated', region: 'international (Singapore)',
++ terms: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing',
++ resale: 'no resale clause verified (the 2026-11-03 matrix does not list Alibaba)',
++ }),
++ models: [
++ {
++ model_key: 'qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1048576,
++ cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },
++ source: 'https://www.alibabacloud.com/help/en/model-studio/model-pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'flat across the context length; 50% off in batch',
++ },
++ {
++ model_key: 'qwen3.7-flash', display_name: 'Qwen3.7-Flash', family: 'qwen', context_window: 1048576,
++ cost: { in_per_mtok: 0.03, cached_per_mtok: 0.006, out_per_mtok: 0.13 },
++ source: 'https://openrouter.ai/api/v1/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'the cheapest way to answer in this family',
++ },
++ ],
++ },
++ {
++ key: 'kimi',
++ display_name: 'Kimi (Moonshot)',
++ base_url: 'https://api.moonshot.ai/v1',
++ default_model: 'kimi-k3.1-pro',
++ timeout_ms: 60000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 40,
++ data_policy: policy({
++ trains: 'not_stated', retention: 'not stated', region: 'not stated',
++ terms: 'https://platform.kimi.ai/docs/pricing/chat',
++ resale: 'no resale clause verified (the 2026-11-03 matrix does not list Moonshot)',
++ }),
++ models: [
++ {
++ model_key: 'kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,
++ cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },
++ source: 'https://platform.kimi.ai/docs/pricing/chat', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'a million tokens of context; cache writes are billed by their time to live',
++ },
++ {
++ model_key: 'kimi-k3', display_name: 'Kimi K3', family: 'kimi', context_window: 1048576,
++ cost: { in_per_mtok: 3.00, cached_per_mtok: 0.30, out_per_mtok: 15.00 },
++ source: 'https://www.together.ai/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'the price the price book read from the host lists',
++ },
++ ],
++ },
++ {
++ key: 'glm',
++ display_name: 'GLM (Z.ai)',
++ base_url: 'https://api.z.ai/api/paas/v4',
++ default_model: 'glm-5.3-flash',
++ timeout_ms: 60000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 40,
++ data_policy: policy({
++ trains: 'not_stated', retention: 'pay-as-you-go; not stated on the pricing page', region: 'not stated',
++ terms: 'https://docs.z.ai/guides/overview/pricing',
++ resale: 'no resale clause verified (the 2026-11-03 matrix does not list Z.ai)',
++ }),
++ models: [
++ {
++ model_key: 'glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 1.40, cached_per_mtok: 0.26, out_per_mtok: 4.40 },
++ source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'cached input is stored free for a limited time',
++ },
++ {
++ model_key: 'glm-5.3-flash', display_name: 'GLM-5.3-Flash', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 0.15, cached_per_mtok: 0.03, out_per_mtok: 0.50 },
++ source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'glm-5.3-flashx', display_name: 'GLM-5.3-FlashX', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 0.37, cached_per_mtok: 0.075, out_per_mtok: 1.25 },
++ source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'glm-4.7', display_name: 'GLM-4.7', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 0.60, cached_per_mtok: 0.11, out_per_mtok: 2.20 },
++ source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'glm-4.7-flash', display_name: 'GLM-4.7-Flash', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 0, cached_per_mtok: 0, out_per_mtok: 0 },
++ source: 'https://docs.z.ai/guides/overview/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'free, rate limited',
++ },
++ ],
++ },
++ {
++ key: 'minimax',
++ display_name: 'MiniMax',
++ base_url: 'https://api.minimax.io/v1',
++ default_model: 'MiniMax-M3.1-Flash',
++ timeout_ms: 60000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 40,
++ data_policy: policy({
++ trains: 'not_stated', retention: 'pay-as-you-go; retention not stated on the pricing page', region: 'not stated',
++ terms: 'https://platform.minimax.io/docs/pricing',
++ resale: 'no resale clause verified (the 2026-11-03 matrix does not list MiniMax)',
++ }),
++ models: [
++ {
++ model_key: 'MiniMax-M3.1-Flash', display_name: 'MiniMax M3.1 Flash', family: 'minimax', context_window: 1048576,
++ cost: { in_per_mtok: 0.15, cached_per_mtok: 0.015, out_per_mtok: 0.60 },
++ source: 'https://platform.minimax.io/docs/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'MiniMax-M3', display_name: 'MiniMax M3', family: 'minimax', context_window: 1048576,
++ cost: { in_per_mtok: 0.30, cached_per_mtok: 0.06, out_per_mtok: 1.20 },
++ source: 'https://platform.minimax.io/docs/pricing', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'the price of the 512k input tier; the priority tier costs 1.5x (0.45 / 0.09 / 1.80)',
++ },
++ ],
++ },
++ {
++ key: 'deepinfra',
++ display_name: 'DeepInfra',
++ base_url: 'https://api.deepinfra.com/v1/openai',
++ default_model: 'deepseek-v4.1-flash-1017',
++ timeout_ms: 90000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'RESTRICTED',
++ priority: 30,
++ data_policy: policy({
++ trains: 'no', retention: 'serves open-weight models; no training on prompts', region: 'not stated',
++ terms: 'https://deepinfra.com/terms',
++ resale: 'RESTRICTED (2026-11-03 matrix): "resell, sublicense, rent, distribute, or otherwise make the Services available to any third party except as expressly permitted under this Agreement or the applicable Service Order" (Terms §11(a)(viii), modified 2026-10-15)',
++ }),
++ models: [
++ {
++ model_key: 'deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
++ cost: { in_per_mtok: 0.098, cached_per_mtok: 0.00294, out_per_mtok: 0.294 },
++ source: 'https://router.requesty.ai/v1/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'the cheapest route the price book found; the priority variant costs 15x',
++ },
++ {
++ model_key: 'deepseek-v4-flash', display_name: 'DeepSeek V4 Flash', family: 'deepseek', context_window: 1048576,
++ cost: { in_per_mtok: 0.09, cached_per_mtok: 0.009, out_per_mtok: 0.18 },
++ source: 'https://api.deepinfra.com/v1/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'gpt-oss-120b', display_name: 'GPT-OSS 120B', family: 'gpt-oss', context_window: 131072,
++ cost: { in_per_mtok: 0.037, cached_per_mtok: 0.0037, out_per_mtok: 0.17 },
++ source: 'https://api.deepinfra.com/v1/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ ],
++ },
++ {
++ key: 'novita',
++ display_name: 'Novita AI',
++ base_url: 'https://api.novita.ai/v3/openai',
++ default_model: 'deepseek-v4.1-flash-1017',
++ timeout_ms: 90000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 30,
++ data_policy: policy({
++ trains: 'no', retention: 'no training on prompts stated', region: 'not stated',
++ terms: 'https://api.novita.ai/v3/openai/models',
++ resale: 'UNVERIFIED (2026-11-03 matrix): the terms page returned 404',
++ }),
++ models: [
++ {
++ model_key: 'deepseek-v4.1-flash-1017', display_name: 'DeepSeek V4.1 Flash 1017', family: 'deepseek', context_window: 1048576,
++ cost: { in_per_mtok: 0.30, cached_per_mtok: 0.006, out_per_mtok: 1.20 },
++ source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'kimi-k3.1-pro', display_name: 'Kimi K3.1 Pro', family: 'kimi', context_window: 1048576,
++ cost: { in_per_mtok: 2.00, cached_per_mtok: 0.20, out_per_mtok: 10.00 },
++ source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 1.40, cached_per_mtok: 0.26, out_per_mtok: 4.40 },
++ source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'qwen3.8-max', display_name: 'Qwen3.8-Max', family: 'qwen', context_window: 1048576,
++ cost: { in_per_mtok: 2.00, cached_per_mtok: 0.25, out_per_mtok: 6.00 },
++ source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'mimo-v2.6-flash', display_name: 'MiMo V2.6 Flash', family: 'mimo', context_window: 1048576,
++ cost: { in_per_mtok: 0.14, cached_per_mtok: 0.0028, out_per_mtok: 0.28 },
++ source: 'https://api.novita.ai/v3/openai/models', checked: PRICE_BOOK.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ ],
++ },
++ {
++ key: 'featherless',
++ display_name: 'Featherless AI',
++ base_url: 'https://api.featherless.ai/v1',
++ default_model: 'Qwen/Qwen3-14B',
++ timeout_ms: 90000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'AUTHORIZED_WHOLESALE',
++ priority: 30,
++ data_policy: policy({
++ trains: 'not_stated', retention: 'not stated', region: 'not stated',
++ terms: 'https://featherless.ai/terms',
++ resale: 'AUTHORIZED_WHOLESALE, Scale plans only (2026-11-03 matrix): "Scale plans are not subject to said limits and may be used in arbitrary applications, including inference resale." (Terms §3, last updated 2024-06-10). It needs an executed Scale agreement before the managed pool may use it.',
++ checked: WATCH_LISTS.checked,
++ }),
++ models: [
++ {
++ model_key: 'Qwen/Qwen3-14B', display_name: 'Qwen3 14B', family: 'qwen', context_window: 32768,
++ cost: { in_per_mtok: 0.12, cached_per_mtok: 0.012, out_per_mtok: 0.24 },
++ source: 'https://api.featherless.ai/v1/models', checked: WATCH_LISTS.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'Featherless states its price per token; the catalog holds it per million',
++ },
++ {
++ model_key: 'Qwen/Qwen3-0.6B', display_name: 'Qwen3 0.6B', family: 'qwen', context_window: 32768,
++ cost: { in_per_mtok: 0.04, cached_per_mtok: 0.004, out_per_mtok: 0.08 },
++ source: 'https://api.featherless.ai/v1/models', checked: WATCH_LISTS.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ },
++ {
++ model_key: 'meta-llama/Meta-Llama-3.1-8B-Instruct', display_name: 'Llama 3.1 8B Instruct', family: 'llama', context_window: 32768,
++ cost: { in_per_mtok: 0.05, cached_per_mtok: 0.005, out_per_mtok: 0.08 },
++ source: 'https://api.featherless.ai/v1/models', checked: WATCH_LISTS.checked,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ note: 'gated on Featherless: the price is published, the model needs the account to be let in',
++ },
++ ],
++ },
++ {
++ key: 'refuseless',
++ display_name: 'Refuseless (the uncensored tier)',
++ base_url: 'https://api.refuseless.com/v1',
++ default_model: 'deepseek-v4.1-flash-abliterated',
++ timeout_ms: 60000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 20,
++ uncensored: true,
++ data_policy: policy({
++ trains: 'unknown', retention: 'unknown', region: 'unknown',
++ terms: 'https://refuseless.com/pricing',
++ resale: 'UNVERIFIED (2026-11-03 matrix): no terms URL was located',
++ checked: '2026-10-25',
++ }),
++ models: [
++ {
++ model_key: 'deepseek-v4.1-flash-abliterated', display_name: 'DeepSeek V4.1 Flash (abliterated)', family: 'deepseek', context_window: 1048576,
++ cost: { in_per_mtok: 0.22, cached_per_mtok: 0.045, out_per_mtok: 0.75 },
++ source: 'https://refuseless.com/pricing', checked: '2026-10-25',
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ uncensored: true,
++ note: 'the price LiteLLM read on 2026-10-25; the price book could not read the catalogue (401)',
++ },
++ {
++ model_key: 'glm-5.3-flash-abliterated', display_name: 'GLM 5.3 Flash (abliterated)', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 0.25, cached_per_mtok: 0.15, out_per_mtok: 0.80 },
++ source: 'https://refuseless.com/pricing', checked: '2026-10-25',
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ uncensored: true,
++ },
++ {
++ model_key: 'glm-5.3-abliterated', display_name: 'GLM 5.3 (abliterated)', family: 'glm', context_window: 1048576,
++ cost: { in_per_mtok: 1.25, cached_per_mtok: 0.25, out_per_mtok: 2.25 },
++ source: 'https://refuseless.com/pricing', checked: '2026-10-25',
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ uncensored: true,
++ },
++ ],
++ },
++ {
++ key: 'openrouter',
++ display_name: 'OpenRouter (the last fallback)',
++ base_url: 'https://openrouter.ai/api/v1',
++ default_model: 'openrouter/auto',
++ timeout_ms: 120000,
++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
++ provider_access: 'UNVERIFIED',
++ priority: 90,
++ role: 'fallback',
++ data_policy: policy({
++ trains: 'provider_dependent', retention: 'prompts and completions are not logged by default; the providers that log are not routed unless the training toggle is on',
++ region: 'not stated', terms: 'https://openrouter.ai/terms',
++ resale: 'UNVERIFIED (2026-11-03 matrix does not list OpenRouter). The price book (2026-10-30) records that its terms contemplate selling capacity inside your own product; that is not the same as an executed agreement.',
++ }),
++ // OpenRouter lists hundreds of models and prices each one itself; the catalogue sync reads its own list with
++ // the key. No model is seeded here: a price we did not read from OpenRouter is not one of ours to publish.
++ models: [],
++ },
++];
++
++const byKey = new Map(PROVIDERS.map((p) => [p.key, p]));
++
++/** The name a provider's model is known by in the price book, without its namespace: `deepseek/deepseek-chat` -> `deepseek-chat`. */
++const leaf = (id) => String(id || '').split('/').pop().toLowerCase();
++
++/**
++ * The catalog price of one model of one provider, matched by name (the provider's own id and the price book's name
++ * often differ in their namespace: `meta-llama/Llama-3.3-70B-Instruct-Turbo` and `Llama-3.3-70B-Instruct-Turbo` are
++ * one model). → the model's record or null.
++ */
++function priceOf(providerKey, modelId) {
++ const p = byKey.get(providerKey);
++ if (!p) return null;
++ const name = leaf(modelId);
++ return p.models.find((m) => leaf(m.model_key) === name) || null;
++}
++
++/**
++ * Is this offer an uncensored one? A provider or a model marked `uncensored` is, and so is a model whose name says
++ * it is: an abliterated model is an uncensored model wherever it is served (Featherless lists several).
++ */
++function isUncensored(provider, model) {
++ if (provider && provider.metadata && provider.metadata.uncensored) return true;
++ if (model && model.metadata && model.metadata.uncensored) return true;
++ if (model && model.metadata && model.metadata.family === 'abliterated') return true;
++ const id = String((model && (model.model_key || model.metadata && model.metadata.model)) || '');
++ return /abliterated|uncensored/i.test(id);
++}
++
++module.exports = { PROVIDERS, PROVIDER_KEYS, PRICE_BOOK, WATCH_LISTS, byKey, priceOf, isUncensored, leaf };
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
nnew file mode 100644
iindex 0000000..3ce79ec
---- /dev/null
++++ b/server/providers/pool-seed.js
@@@ -0,0 +1,164 @@
++'use strict';
++/**
++ * Seeding the providers OpenVibe buys capacity from (server/providers/pool-providers.js) into the registry.
++ *
++ * A provider is off until the owner puts its key in the owner settings (D11: `ai.provider_keys`): its `secret_ref` is
++ * `vault:<key>`, and the pool skips a provider whose key does not resolve (`no_credentials`), so a provider without a
++ * key is never offered, never called and never costs anything. The key slot itself appears in the owner's settings
++ * because the store's schema is built from the registry's providers (server/settings.js).
++ *
++ * The models carry the price the price book recorded, with the page it was read from and the day it was read. A row
++ * the owner has since edited keeps its price: the catalogue only fills a price that is missing or that the catalogue
++ * itself wrote (its own source).
++ *
++ * The provider's own model list is the authority for the exact id (the catalogue sync, server/providers/catalog.js):
++ * when the owner syncs a provider, `applyCatalogPrices` gives the provider's own id the price the catalogue recorded
++ * for that model (matched by name) and retires the row the catalogue seeded under a name the provider does not use.
++ */
++
++const pool = require('./pool-providers');
++
++/** The registry row of one catalogued provider. */
++function recordOf(p) {
++ return {
++ key: p.key, display_name: p.display_name, kind: 'openai', status: 'active',
++ base_url: p.base_url, auth_mode: 'bearer', secret_ref: `vault:${p.key}`,
++ default_model: p.default_model || null, capabilities: p.capabilities, timeout_ms: p.timeout_ms,
++ priority: p.priority, billing_profile: 'metered', provider_access: p.provider_access,
++ metadata: {
++ catalog: 'pool-providers',
++ data_policy: p.data_policy,
++ ...(p.uncensored ? { uncensored: true } : {}),
++ ...(p.role ? { role: p.role } : {}),
++ },
++ };
++}
++
++/** The registry row of one catalogued model: the price, where it came from and what the model can do. */
++function modelOf(p, m) {
++ const metadata = {
++ catalog: 'pool-providers',
++ family: m.family || null,
++ id_source: 'price-book',
++ checked: m.checked || null,
++ ...(m.note ? { note: m.note } : {}),
++ ...(m.uncensored ? { uncensored: true } : {}),
++ data_policy: p.data_policy,
++ };
++ return {
++ provider_key: p.key, model_key: m.model_key, display_name: m.display_name || m.model_key,
++ type: 'chat', status: 'active', context_window: m.context_window || null, max_output: m.max_output || null,
++ cost: {
++ in_per_mtok: m.cost.in_per_mtok, out_per_mtok: m.cost.out_per_mtok,
++ cached_per_mtok: m.cost.cached_per_mtok == null ? null : m.cost.cached_per_mtok,
++ effective_from: m.checked || null, source: m.source || null, verified_at: m.checked || null,
++ },
++ supports: { json: true, tools: m.supports.tools !== false, streaming: true, vision: m.supports.vision === true },
++ metadata,
++ };
++}
++
++/** Did the catalogue write this price? Then the catalogue may refresh it; a person's own price it may not. */
++function cataloguePrice(row) {
++ const source = row && row.cost && row.cost.source;
++ if (!source) return false;
++ return pool.PROVIDERS.some((p) => p.models.some((m) => m.source === source));
++}
++
++/**
++ * Seed the catalogued providers and their models. A provider the owner has edited (its origin is not 'catalog')
++ * keeps everything the owner set: only its key slot and its catalogue metadata are refreshed. A model the owner has
++ * edited keeps its price and its status; a price the catalogue wrote is refreshed when the catalogue changes.
++ *
++ * → { providers: [key], models: [key], skipped: [key] }
++ */
++async function seedPoolProviders({ registry, actor = 'system' }) {
++ const out = { providers: [], models: [], skipped: [] };
++ for (const p of pool.PROVIDERS) {
++ const prev = await registry.getProvider(p.key);
++ if (!prev) {
++ await registry.upsertProvider(recordOf(p), { actor, origin: 'catalog' });
++ out.providers.push(p.key);
++ } else if (prev.origin === 'catalog') {
++ // The catalogue owns this row: keep the owner's status and key slot, refresh the rest.
++ await registry.upsertProvider({
++ ...recordOf(p),
++ status: prev.status,
++ secret_ref: prev.secret_ref || `vault:${p.key}`,
++ provider_access: prev.provider_access || p.provider_access,
++ metadata: { ...prev.metadata, ...recordOf(p).metadata },
++ }, { actor, origin: 'catalog' });
++ } else {
++ out.skipped.push(p.key); // the owner's own provider: never touched
++ }
++ for (const m of p.models) {
++ const row = await registry.getModel(p.key, m.model_key);
++ if (!row) {
++ await registry.upsertModel(modelOf(p, m), { actor });
++ out.models.push(`${p.key}/${m.model_key}`);
++ continue;
++ }
++ if (row.status !== 'active') continue; // the owner turned it off: it stays off
++ if (row.cost.in_per_mtok != null && !cataloguePrice(row)) continue; // a person's own price: it stays
++ const next = modelOf(p, m);
++ await registry.upsertModel({
++ ...next,
++ status: row.status,
++ context_window: row.context_window ?? next.context_window,
++ max_output: row.max_output ?? next.max_output,
++ metadata: { ...row.metadata, ...next.metadata },
++ }, { actor });
++ }
++ }
++ return out;
++}
++
++/**
++ * After the owner syncs a provider's own model list: give the provider's own ids the prices the catalogue recorded
++ * (matched by the model's name, its namespace aside) and retire the row the catalogue seeded under a name the
++ * provider does not use, so only one row per model ever serves.
++ *
++ * → { priced: [model_key], retired: [model_key] }
++ */
++async function applyCatalogPrices({ registry, provider, actor = 'system' }) {
++ const out = { priced: [], retired: [] };
++ const p = pool.byKey.get(provider.key);
++ if (!p) return out;
++ const rows = await registry.listModels({ provider: provider.key });
++ for (const row of rows) {
++ const price = pool.priceOf(provider.key, row.model_key);
++ if (!price) continue;
++ const own = row.cost.in_per_mtok != null && !cataloguePrice(row);
++ if (own) continue; // a person's own price: the catalogue does not touch it
++ if (row.cost.in_per_mtok === price.cost.in_per_mtok && row.cost.source === price.source) continue;
++ await registry.upsertModel({
++ provider_key: row.provider_key, model_key: row.model_key, display_name: row.display_name,
++ type: row.type, status: row.status,
++ context_window: row.context_window, max_output: row.max_output,
++ cost: {
++ in_per_mtok: price.cost.in_per_mtok, out_per_mtok: price.cost.out_per_mtok,
++ cached_per_mtok: price.cost.cached_per_mtok == null ? null : price.cost.cached_per_mtok,
++ effective_from: price.checked || null, source: price.source || null, verified_at: price.checked || null,
++ },
++ supports: row.supports,
++ metadata: { ...row.metadata, family: price.family || row.metadata.family || null, id_source: 'provider-list' },
++ }, { actor });
++ out.priced.push(row.model_key);
++ }
++ // A catalogue row whose model the provider lists under another id: the provider's own id wins, so the catalogue
++ // row is retired (it stays readable, it does not serve).
++ for (const m of p.models) {
++ const other = rows.find((r) => r.model_key !== m.model_key && pool.leaf(r.model_key) === pool.leaf(m.model_key));
++ if (!other) continue;
++ const row = await registry.getModel(provider.key, m.model_key);
++ if (!row || row.status !== 'active') continue;
++ await registry.upsertModel({
++ provider_key: provider.key, model_key: m.model_key, status: 'disabled',
++ metadata: { ...row.metadata, listed: false, missing_since: new Date().toISOString(), merged_into: other.model_key },
++ }, { actor });
++ out.retired.push(m.model_key);
++ }
++ return out;
++}
++
++module.exports = { seedPoolProviders, applyCatalogPrices, recordOf, modelOf, cataloguePrice };
ddiff --git a/server/runs.js b/server/runs.js
iindex bf7b400..8ca884f 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -22,10 +22,13 @@ const { parseCostUsd } = require('./credits');
const { CACHE_INPUT } = require('./workflows/hooks');
const { preferenceLines } = require('./user-modules');
const { preferredTarget } = require('./providers/task');
++const tier = require('./policy/tier');
const schemas = require('./schemas');
const events = require('./events');
const usageRecord = require('./usage-record');
const usageSamples = require('./usage-samples');
++const { buildReceipt } = require('./exchange/receipts');
++const privacy = require('./exchange/privacy');
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'cached']);
const QUEUE_RETRY_AFTER_S = 5;
@@@ -103,7 +106,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
}
--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null }) {
++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
let active = 0;
@@@ -203,6 +206,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (body.on_behalf_of == null) body = { ...body, on_behalf_of: { type: 'user', id: claimed } };
}
validateRefs(body);
++ // The Exchange remake's content policy. It is a control field, never part of the workflow's input; the pool
++ // enforces the 18+ gate and the offer filter (server/providers/index.js), and the answer carries the label.
++ const askedPolicy = tier.parsePolicy(body.policy != null ? body.policy : body.options && body.options.policy);
++ if (askedPolicy.error) throw new AiError(422, askedPolicy.error, askedPolicy.detail);
++ const contentPolicy = askedPolicy.policy;
const input = body.input === undefined ? {} : body.input;
const inputJson = stableStringify(input);
if (Buffer.byteLength(inputJson) > config.runs.maxInputBytes) throw new AiError(413, 'input.too_large', `input is larger than ${config.runs.maxInputBytes} bytes`);
@@@ -246,7 +254,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const options = body.options && typeof body.options === 'object' ? body.options : {};
// Community capacity is opt-in per request: a person's machine sees the prompt, so only a caller that says so
// may be placed there. The OpenAI and Messages routes set body.community from x-openvibe-community.
-- const communityOptIn = body.community === true || options.community === true;
++ let communityOptIn = body.community === true || options.community === true;
// A person's own key (WS-O task 2): only the service that stored it, within its daily budget; never cached.
// A person's own key: the default one, or the provider the run names (Codes C3: one credential per provider).
const credentialSubject = body.credential && typeof body.credential === 'object' ? String(body.credential.subject || '') : null;
@@@ -261,6 +269,17 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const projectProvider = body.project_provider == null ? null : projectProviderOf(body.project_provider);
// L37: the soft preference (a provider, and a model if the caller wants one). It is a wish, not a pin.
const prefer = preferredTarget(body.prefer);
++ // The routing preset the request named (server/exchange/presets.js): its policy rides the run into the
++ // router, which filters the offers with it and refuses a hard max_price no offer fits. The policy is
++ // normalised here, so a caller cannot smuggle a rule the presets store would never have written.
++ const preset = body.preset && typeof body.preset === 'object' && !Array.isArray(body.preset) ? body.preset : null;
++ let presetPolicy = null;
++ if (preset) {
++ const name = String(preset.name || '').trim();
++ if (!name) throw new AiError(422, 'input.invalid', 'a preset must name itself');
++ presetPolicy = presets ? presets.normalizePolicy(preset.policy) : (preset.policy && typeof preset.policy === 'object' ? preset.policy : {});
++ }
++ if (presetPolicy && typeof presetPolicy.community === 'boolean') communityOptIn = presetPolicy.community;
if (projectProvider) {
if (!app) throw new AiError(403, 'capability.denied', 'only a developer app names its project\'s own provider');
if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');
@@@ -316,11 +335,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// Only the person's own token may hold their Balance; a service naming them draws free capacity only
// (or its own run-token budget). The run still records the person, so preferences and cache follow them.
personSubject: runPerson || null, personPays, serviceBudgeted, freeUse,
++ ...(contentPolicy !== 'standard' ? { contentPolicy } : {}),
// A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
...(communityOptIn ? { communityOptIn: true } : {}),
...(pinned ? { routePin: pinned } : {}),
++ // The preset's policy, and the name it was named by: the router filters the offers with the policy.
++ ...(preset ? { presetPolicy, presetName: String(preset.name).trim() } : {}),
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
...(typeof onDelta === 'function' ? { onDelta } : {}),
// The offer the router picked, before the first token: the SSE disclosure's first comment (D3).
@@@ -344,7 +366,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
...(selfKeys ? { self_subject: runPerson } : {}),
...(runPerson ? { person_pays: personPays } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
-- ...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}) }), created_at: now,
++ ...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}),
++ ...(contentPolicy !== 'standard' ? { policy: contentPolicy } : {}),
++ ...(preset ? { preset: { name: String(preset.name).trim(), scope: preset.scope || 'owner', policy: presetPolicy } } : {}) }), created_at: now,
};
base.input = stableStringify(kept.value);
@@@ -352,7 +376,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,
// and another model's answer under the same prompt would silently break the pin. A run that may use the
// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
-- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn ? null : cache.scopeFor(wf.cache_mode, ctx);
++ // A community or adult-tier answer cannot be reused by a request without the same policy.
++ const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn || contentPolicy !== 'standard' ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
if (scope) {
// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).
@@@ -360,8 +385,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash: keyInput });
const hit = await cache.get(cacheKey, scope);
if (hit) {
++ const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], paid_by: 'cache', selected: null, candidates: [], attempts: [] };
await db.tx(async () => {
-- const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], paid_by: 'cache', selected: null, candidates: [], attempts: [] };
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, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, explain, created_at, started_at, finished_at, citations_count)
VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'cached', @requester_type, @requester_id, @on_behalf_of, @attribution,
@@@ -378,6 +403,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
});
// A cache hit is still a run: its record says so (no tokens, no money).
await publishUsage({ row: await getRow(id), outcome: 'cached' });
++ // A reused answer carries a receipt too: the offer that produced it, and the fact that no
++ // provider was called for this one.
++ const cachedRow = await getRow(id);
++ const source = hit.run_id ? await getRow(hit.run_id) : null;
++ await writeReceipt({
++ row: cachedRow,
++ explain: { ...cacheExplain, reasons: [`the answer was reused from run ${hit.run_id}; no provider was called`] },
++ usage: {}, provider: (source && source.provider_key) || null, model: hit.model_key || (source && source.model_key) || null,
++ money: null, payer: { paid_by: 'cache', detail: `run ${hit.run_id}` },
++ });
await registry.audit(principal.sub, 'run.create', 'run', id, { trace, metadata: { workflow: wf.key, version: wf.version, status: 'cached', cached_from: hit.run_id } });
return { run: await get(id), created: true, promise: null };
}
@@@ -475,6 +510,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
* The record of a run (plan L40): the tokens (the cache read and the cache write counted apart), the money and
* who paid. It is published as one ai.call.recorded@1 event; OpenVibe.Report keeps the ledger. The record never
* carries the prompt or the answer.
++ *
++ * It answers the money it computed, so the receipt of the same run (server/exchange/receipts.js) carries the
++ * same numbers and never a second, different price.
*/
async function publishUsage({ row, outcome, usage = {}, provider = null, model = null, attempts = 0, latencyMs = null, ttftMs = null }) {
try {
@@@ -502,8 +540,64 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
});
// The event (ai.call.recorded) is queued in its own transaction, like every other event here.
await db.tx(async () => { await events.callRecorded(rec); });
++ return { payer, prices, money, tokens };
} catch (err) {
log.warn(`[runs] ${row.id}: the usage record was not written: ${err && err.message}`);
++ return null;
++ }
++ }
++
++ /**
++ * The receipt of a finished run (server/exchange/receipts.js): the offer, the price, the tokens, the cache,
++ * the latency, why the router chose it, the fallbacks it would have used, who paid and the privacy label.
++ * A receipt that cannot be written never fails the answer: it is written after it.
++ */
++ async function writeReceipt({ row, explain = null, usage = {}, provider = null, model = null, latencyMs = null, ttftMs = null, money = null, payer = null }) {
++ if (!receipts) return null;
++ try {
++ const answerProvider = provider || row.provider_key || null;
++ const answerModel = model || row.model_key || null;
++ // A person's own key is metered under byo:<owner>:<subject>: the offer that answered is their key's
++ // provider, and the price is that provider's list price (they pay it themselves, at no fee here).
++ const byok = Boolean(answerProvider && String(answerProvider).startsWith('byo:'));
++ const credentialProvider = byok && explain && Array.isArray(explain.attempts) && explain.attempts[0] && explain.attempts[0].provider
++ ? explain.attempts[0].provider : null;
++ let cards = answerProvider && pool ? await pool.rateCardsFor(answerProvider, answerModel) : null;
++ if (!cards && byok && credentialProvider && pool) cards = await pool.rateCardsFor(credentialProvider, answerModel);
++ const prices = cards ? {
++ in: cards.in.unit_price_usd, out: cards.out.unit_price_usd, cached: cards.cached.unit_price_usd,
++ source: cards.in.source,
++ } : null;
++ const record = (answerProvider ? await registry.getProvider(answerProvider) : null)
++ || (credentialProvider ? await registry.getProvider(credentialProvider) : null);
++ const communityAnswer = Boolean(answerProvider && String(answerProvider).startsWith('community:')) || Boolean(explain && explain.community);
++ const label = communityAnswer
++ ? privacy.labelFor({ record: { metadata: { data_policy: { trust: 'community' } } } })
++ : privacy.labelFor({ record, credential: byok ? { provider: credentialProvider } : null });
++ const paidBy = (payer && payer.paid_by) || 'openvibe_key';
++ const feeRate = paidBy === 'openvibe_key' ? Number((config.usage && config.usage.feeRate) || 0) : 0;
++ const opts = parseJson(row.options, {});
++ const receipt = buildReceipt({
++ run: {
++ id: row.id, workflow_key: row.workflow_key, route_key: row.route_key, route_version: row.route_version,
++ provider_key: answerProvider, model_key: answerModel, cost_usd: row.cost_usd,
++ },
++ explain, usage, price: prices, privacy: privacy.view(label),
++ payer: { paid_by: paidBy, detail: (payer && payer.detail) || null, fee_rate: feeRate, fee_usd: money ? money.fee_usd : 0 },
++ preset: opts.preset || null,
++ latencyMs, ttftMs,
++ provider: answerProvider, model: answerModel,
++ createdAt: row.finished_at || new Date(clock.now()).toISOString(),
++ });
++ const person = subjectOf(row);
++ await receipts.record(receipt, {
++ requester: { type: row.requester_type, id: row.requester_id },
++ owner: person ? { type: 'user', id: person } : null,
++ });
++ return receipt;
++ } catch (err) {
++ log.warn(`[runs] ${row.id}: the receipt was not written: ${err && err.message}`);
++ return null;
}
}
@@@ -663,11 +757,24 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
// L37: the soft preference the caller made (provider, and a model if it named one).
prefer: opts.prefer || null,
++ // The routing preset's rules (server/exchange/constraints.js): the router filters the offers with
++ // them, and a hard max_price no offer fits refuses the run before any provider is called.
++ presetPolicy: opts.preset ? opts.preset.policy : null,
++ presetName: opts.preset ? opts.preset.name : null,
++ // The Exchange remake's content policy (standard, or uncensored for an 18+ account), and the person
++ // the run is for: the pool asks Network whether that account confirmed it is 18+.
++ ...(ctx.contentPolicy ? { contentPolicy: ctx.contentPolicy } : {}),
++ ...(ctx.onBehalfOf ? { onBehalfOf: ctx.onBehalfOf } : {}),
...(onCredentialFailed ? { onCredentialFailed } : {}),
...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(billingDelta ? { onDelta: billingDelta } : {}),
...(onRoute ? { onRoute } : {}) });
++ // The Exchange remake: an uncensored answer is labelled in the run's own output, whatever its shape, so
++ // every surface that reads it (the API routes, the console, a product) shows what produced it.
++ if (r.output && r.label) {
++ r.output = { ...r.output, policy: r.contentPolicy || 'uncensored', label: r.label };
++ }
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
// The status, its citations and the ai.run.succeeded event commit together.
const done = await db.tx(async () => {
@@@ -689,7 +796,13 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const usedOwnKey = String(r.provider || '').startsWith('byo:self:');
if (r.provider && (reserved || (opts.self_subject && !usedOwnKey))) 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 });
// The record of the run (L40): the tokens with the cache apart, the money and who paid.
-- await publishUsage({ row: await getRow(id), outcome: 'succeeded', usage: r.usage, provider: r.provider, model: r.model, attempts, latencyMs: r.latencyMs, ttftMs: r.ttftMs });
++ const published = await publishUsage({ row: await getRow(id), outcome: 'succeeded', usage: r.usage, provider: r.provider, model: r.model, attempts, latencyMs: r.latencyMs, ttftMs: r.ttftMs });
++ // The receipt of the answer (server/exchange/receipts.js): the offer, the price, the tokens, the cache,
++ // the latency, why the router chose it, the fallbacks it would have used, the payer and the privacy label.
++ await writeReceipt({
++ row: await getRow(id), explain: r.explain, usage: r.usage, provider: r.provider, model: r.model,
++ latencyMs: r.latencyMs, ttftMs: r.ttftMs, money: published && published.money, payer: published && published.payer,
++ });
// What the run spent goes on the budget of its run token (Codes C3): the same tokens and cost the
// quota accounting counts, so the next request of a run over its budget is refused.
if (runBudgets && ctx.runJti) await runBudgets.add(ctx.runJti, { tokens: r.usage.input + r.usage.output, cost: r.cost });
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
iindex 7bbd740..8b75f7a 100644
---- a/server/workflows/engine.js
++++ b/server/workflows/engine.js
@@@ -384,6 +384,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
grounding: wf.steps.some((s) => s.kind === 'research') ? researchGrounding(last.output) : groundingOf(last.output, sources.length, citeStep),
synthetic: Boolean(exec && exec.synthetic),
provider: exec ? exec.provider : null, model: exec ? exec.model : null, fallbackUsed: Boolean(exec && exec.fallbackUsed),
++ ...(exec && exec.result && exec.result.label ? { contentPolicy: exec.result.policy || 'uncensored', label: exec.result.label } : {}),
usage, cost,
route: last.route || null,
explain: exec ? exec.explain || null : null,
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 10e1f1d..08f79ae 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -21,6 +21,7 @@ const core = require('./core');
const products = require('./products');
const chat = require('./chat');
const research = require('./research');
++const poolSeed = require('../providers/pool-seed');
const { resolveSecret } = require('../util');
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
@@@ -133,6 +134,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
if (!same) await registry.upsertProvider({ base_url: null, secret_ref: null, ...p }, { actor: 'seed', origin: 'seed' });
}
}
++ // The providers OpenVibe buys managed capacity from (the Exchange remake, stage 2): Qwen, Kimi, GLM, MiniMax,
++ // DeepInfra, Novita, Featherless, Refuseless and OpenRouter. Each is registered off until its key is in the
++ // owner settings; the pool skips one whose key does not resolve. Prices come from the price book, with their
++ // page and the day they were read (server/providers/pool-providers.js).
++ await poolSeed.seedPoolProviders({ registry, actor: 'seed' });
const shared = await registry.getProvider('shared');
// Models
ddiff --git a/test/ai-exchange-providers.test.js b/test/ai-exchange-providers.test.js
nnew file mode 100644
iindex 0000000..b4a57bb
---- /dev/null
++++ b/test/ai-exchange-providers.test.js
@@@ -0,0 +1,222 @@
++'use strict';
++/**
++ * The Exchange remake's managed providers and uncensored tier.
++ *
++ * No test reaches the network: the nine providers' recorded Chat Completions shapes live in
++ * test/fixtures/pool-providers.json and are replayed against a local seam; Network's adult flag is a fake fetch. The
++ * boot test proves the providers are seeded off (no key), that a standard request never runs an uncensored offer,
++ * and that an uncensored request is admitted only for an 18+ account, labelled, and stopped by the hard floor.
++ */
++const assert = require('assert');
++const fixtures = require('./fixtures/pool-providers.json');
++const { boot, seamServer, request, suite, token, ALL } = require('./helpers');
++const pool = require('../server/providers/pool-providers');
++const seed = require('../server/providers/pool-seed');
++const floor = require('../server/policy/floor');
++const tier = require('../server/policy/tier');
++const { createFloorGuard } = require('../server/providers');
++const { createAdultStatus } = require('../server/policy/adult-status');
++const { createOpenAiProvider } = require('../server/providers/openai');
++
++const USR = (n) => `usr_01J${String(n).padStart(23, '0')}`;
++const ADULT = USR(1);
++const CHILD = USR(2);
++
++const t = suite('ai-exchange-providers');
++
++t.test('nine providers, each OpenAI-compatible with a base URL, timeout, access and data policy', () => {
++ assert.deepStrictEqual(pool.PROVIDERS.map((p) => p.key), ['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'openrouter']);
++ for (const p of pool.PROVIDERS) {
++ assert.ok(/^https:\/\//.test(p.base_url), `${p.key} base_url`);
++ assert.ok(p.timeout_ms >= 1000, `${p.key} timeout`);
++ assert.ok(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'].includes(p.provider_access), p.key);
++ assert.ok(p.data_policy && typeof p.data_policy.trains_on_prompts === 'string', `${p.key} data policy`);
++ assert.ok(p.data_policy.terms_url && p.data_policy.checked, `${p.key} terms + checked`);
++ for (const m of p.models) {
++ assert.ok(Number.isFinite(m.cost.in_per_mtok) && Number.isFinite(m.cost.out_per_mtok), `${p.key}/${m.model_key} price`);
++ assert.ok(m.source && m.checked, `${p.key}/${m.model_key} price source + day`);
++ assert.ok(m.supports && m.supports.streaming === true, `${p.key}/${m.model_key} streaming`);
++ }
++ }
++ // Featherless has no abliterated model with a price we read, so none is invented; its own list is read on sync
++ // and a synced id that says abliterated is an uncensored offer by name (pool.isUncensored).
++ assert.strictEqual(pool.isUncensored(pool.byKey.get('featherless'), { model_key: 'Qwen/Qwen3-14B' }), false);
++ assert.strictEqual(pool.isUncensored(pool.byKey.get('featherless'), { model_key: 'some/Model-32B-abliterated' }), true);
++});
++
++t.test('Refuseless carries the two uncensored flash models at the price the owner named', () => {
++ const r = pool.byKey.get('refuseless');
++ assert.strictEqual(r.uncensored, true);
++ const deep = pool.priceOf('refuseless', 'deepseek-v4.1-flash-abliterated');
++ const glm = pool.priceOf('refuseless', 'glm-5.3-flash-abliterated');
++ assert.deepStrictEqual([deep.cost.in_per_mtok, deep.cost.out_per_mtok], [0.22, 0.75]);
++ assert.deepStrictEqual([glm.cost.in_per_mtok, glm.cost.out_per_mtok], [0.25, 0.80]);
++ assert.strictEqual(pool.isUncensored(r, deep), true);
++ assert.strictEqual(pool.isUncensored(pool.byKey.get('qwen'), null), false);
++});
++
++t.test('the catalog seed records each provider off-until-key and keeps a person\'s own row', async () => {
++ const saved = new Map();
++ const models = new Map();
++ const registry = {
++ getProvider: async (key) => saved.get(key) || null,
++ upsertProvider: async (row) => { saved.set(row.key, { ...row, metadata: row.metadata || {} }); },
++ getModel: async (p, m) => models.get(`${p}/${m}`) || null,
++ upsertModel: async (row) => { models.set(`${row.provider_key}/${row.model_key}`, row); },
++ };
++ const out = await seed.seedPoolProviders({ registry });
++ assert.strictEqual(out.providers.length, 9);
++ assert.strictEqual(saved.get('refuseless').secret_ref, 'vault:refuseless');
++ assert.strictEqual(saved.get('refuseless').metadata.uncensored, true);
++ assert.strictEqual(models.get('refuseless/deepseek-v4.1-flash-abliterated').metadata.uncensored, true);
++ assert.strictEqual(models.get('refuseless/deepseek-v4.1-flash-abliterated').cost.in_per_mtok, 0.22);
++ // The owner edits a provider: a second seed never touches its row.
++ saved.set('qwen', { ...saved.get('qwen'), origin: 'admin', base_url: 'https://proxy.example/v1' });
++ const again = await seed.seedPoolProviders({ registry });
++ assert.ok(again.skipped.includes('qwen'));
++ assert.strictEqual(saved.get('qwen').base_url, 'https://proxy.example/v1');
++});
++
++t.test('the recorded shape of every provider is parsed by the OpenAI adapter, tool calls included', async () => {
++ for (const key of pool.PROVIDER_KEYS) {
++ const recorded = fixtures[key];
++ assert.ok(recorded && recorded.completion, `${key} fixture`);
++ const srv = await seamServer(() => recorded.completion);
++ try {
++ const a = createOpenAiProvider({ key, base_url: srv.url, capabilities: [] }, { apiKey: 'k' });
++ const req = { system: [{ text: 'be brief' }], messages: [{ role: 'user', content: 'hi' }], maxTokens: 40, timeoutMs: 3000, model: recorded.model };
++ const r = await a.chat(req);
++ const c = recorded.completion.choices[0].message;
++ assert.strictEqual(r.text, (c.content || '').trim(), `${key} text`);
++ assert.strictEqual(r.model, recorded.model, `${key} model`);
++ assert.ok(r.usage.input >= 0 && r.usage.output >= 0, `${key} usage`);
++ const full = await a.complete({ openai: { messages: [{ role: 'user', content: 'Weather?' }], tools: [] }, maxTokens: 40, timeoutMs: 3000, model: recorded.model });
++ assert.deepStrictEqual(full.completion.choices, recorded.completion.choices, `${key} completion`);
++ if (c.tool_calls) assert.strictEqual(full.completion.choices[0].message.tool_calls[0].function.name, 'weather');
++ } finally { await srv.close(); }
++ }
++});
++
++t.test('the adult tier: 18+ person only, never an app, never without a person', async () => {
++ assert.deepStrictEqual(tier.parsePolicy('UNCENSORED'), { policy: 'uncensored' });
++ assert.ok(tier.parsePolicy('nsfw').error);
++ let asked = [];
++ const adultFor = async (id) => { asked.push(id); return id === ADULT; };
++ assert.strictEqual((await tier.admit({ policy: 'uncensored', subject: { type: 'user', id: ADULT }, adultFor })).ok, true);
++ assert.strictEqual((await tier.admit({ policy: 'uncensored', subject: { type: 'user', id: CHILD }, adultFor })).code, 'policy.adult_required');
++ assert.strictEqual((await tier.admit({ policy: 'uncensored', appRun: true, subject: { type: 'user', id: ADULT }, adultFor })).code, 'policy.uncensored_refused');
++ assert.strictEqual((await tier.admit({ policy: 'uncensored', subject: null, adultFor })).code, 'policy.uncensored_needs_person');
++ assert.strictEqual((await tier.admit({ policy: 'uncensored', subject: { type: 'user', id: ADULT }, adultFor: async () => { throw new Error('down'); } })).code, 'policy.adult_unavailable');
++ assert.deepStrictEqual(asked, [ADULT, CHILD]);
++});
++
++t.test('adult status asks Network once per five minutes and fails closed', async () => {
++ let at = 0;
++ let calls = 0;
++ const fetchImpl = async (url) => {
++ if (String(url).includes('/oauth/token')) return new Response(JSON.stringify({ access_token: 't', expires_in: 300 }), { status: 200, headers: { 'content-type': 'application/json' } });
++ calls++;
++ return new Response(JSON.stringify({ subject_id: ADULT, adult: true, confirmed_at: '2026-11-06T00:00:00Z' }), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++ const status = createAdultStatus({ config: { networkInternalUrl: 'http://network.test' }, env: { OV_OAUTH_CLIENT_SECRET: 's' }, fetchImpl, clock: { now: () => at } });
++ assert.strictEqual(await status.adultFor(ADULT), true);
++ assert.strictEqual(await status.adultFor(ADULT), true);
++ assert.strictEqual(calls, 1, 'the second answer is cached');
++ at = 5 * 60 * 1000 + 1;
++ assert.strictEqual(await status.adultFor(ADULT), true);
++ assert.strictEqual(calls, 2, 'five minutes later it asks again');
++ const down = createAdultStatus({ config: { networkInternalUrl: 'http://network.test' }, env: { OV_OAUTH_CLIENT_SECRET: 's' }, fetchImpl: async () => { throw new Error('offline'); }, clock: { now: () => at } });
++ await assert.rejects(down.adultFor(ADULT), (e) => e.code === 'adult.unavailable');
++});
++
++t.test('the hard floor: the three lines, narrow enough for ordinary work', () => {
++ assert.strictEqual(floor.check('No threats, nothing sexual involving minors.').ok, true);
++ assert.strictEqual(floor.check('cp -r src dst').ok, true);
++ assert.strictEqual(floor.check('how do I kill a stuck process').ok, true);
++ assert.strictEqual(floor.check('how do I kill him without anyone knowing').ok, false);
++ assert.strictEqual(floor.check('a nude photo of a child').ok, false);
++ assert.strictEqual(floor.check('how to make a pipe bomb').ok, false);
++ assert.strictEqual(floor.check('she said no and he forced sex').ok, false);
++ assert.deepStrictEqual(floor.textsOf({ system: 'a child', messages: [{ role: 'user', content: 'naked' }] }), ['a child', 'naked']);
++});
++
++t.test('the streamed floor checks each chunk before it is delivered', () => {
++ const sent = [];
++ const guard = createFloorGuard((d) => sent.push(d));
++ guard.push({ index: 0, delta: { content: 'a perfectly ordinary answer that is ' } });
++ guard.push({ index: 0, delta: { content: 'safe to stream.' } });
++ guard.finish();
++ assert.strictEqual(sent.map((d) => d.delta.content).join(''), 'a perfectly ordinary answer that is safe to stream.');
++ const bad = createFloorGuard((d) => sent.push(d));
++ bad.push({ index: 0, delta: { content: 'here is how to make a ' } });
++ const before = sent.length;
++ assert.throws(() => bad.push({ index: 0, delta: { content: 'pipe bomb' } }), (e) => e.code === 'policy.floor_refused');
++ assert.strictEqual(sent.length, before, 'the violating chunk was not delivered');
++});
++
++t.test('a booted service seeds the nine off, and the uncensored request is gated, filtered, labelled and floored', async () => {
++ const network = [];
++ const fetchImpl = async (url, opts = {}) => {
++ const u = String(url);
++ if (u.includes('/oauth/token')) return new Response(JSON.stringify({ access_token: 'test-token', token_type: 'Bearer', expires_in: 300 }), { status: 200, headers: { 'content-type': 'application/json' } });
++ if (u.includes('/internal/identity/adult')) {
++ const id = new URL(u).searchParams.get('subject_id');
++ network.push(id);
++ return new Response(JSON.stringify({ subject_id: id, adult: id === ADULT, confirmed_at: id === ADULT ? '2026-11-06T00:00:00Z' : null }), { status: 200, headers: { 'content-type': 'application/json' } });
++ }
++ return fetch(url, opts);
++ };
++ const calls = [];
++ const seam = await seamServer((body) => {
++ calls.push(body);
++ return { id: 'chatcmpl-x', object: 'chat.completion', model: body.model, choices: [{ index: 0, message: { role: 'assistant', content: 'an answer from the adult offer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 4, completion_tokens: 5 } };
++ });
++ const h = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'test-secret', AI_STUB_FALLBACK: 'true' }, fetchImpl });
++ try {
++ const seeded = await h.registry.listProviders();
++ for (const p of pool.PROVIDERS) {
++ const row = seeded.find((x) => x.key === p.key);
++ assert.ok(row, `${p.key} seeded`);
++ assert.strictEqual(row.secret_ref, `vault:${p.key}`, `${p.key} key slot`);
++ assert.ok(await h.registry.getModel(p.key, p.models[0] && p.models[0].model_key || '').catch(() => null) || p.models.length === 0, `${p.key} model`);
++ }
++ await h.registry.upsertProvider({ key: 'refuseless', display_name: 'Refuseless', kind: 'openai', status: 'active', base_url: seam.url, auth_mode: 'none', secret_ref: null, default_model: 'deepseek-v4.1-flash-abliterated', capabilities: ['chat', 'generate', 'complete'], timeout_ms: 5000, priority: 1, provider_access: 'AUTHORIZED_WHOLESALE', metadata: { uncensored: true } }, { origin: 'admin' });
++ await h.registry.upsertModel({ provider_key: 'refuseless', model_key: 'deepseek-v4.1-flash-abliterated', display_name: 'DeepSeek V4.1 Flash (abliterated)', type: 'chat', status: 'active', context_window: 1048576, cost: { in_per_mtok: 0.22, cached_per_mtok: 0.045, out_per_mtok: 0.75, source: 'https://refuseless.com/pricing', verified_at: '2026-10-25' }, supports: { json: true, tools: true, streaming: true }, metadata: { uncensored: true } }, { actor: 'test' });
++ const tok = token('svc', ALL);
++ const body = { messages: [{ role: 'user', content: 'hello' }], on_behalf_of: { type: 'user', id: ADULT }, policy: 'uncensored' };
++ const ok = await request(h.base, 'POST', '/v1/chat/completions', { tok, body });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.body.choices[0].message.content, 'an answer from the adult offer');
++ assert.strictEqual(ok.body.x_openvibe.policy, 'uncensored');
++ assert.ok(/adults only \(18\+\)/.test(ok.body.x_openvibe.label), ok.body.x_openvibe.label);
++ assert.strictEqual(ok.headers.get('x-openvibe-content-policy'), 'uncensored');
++ assert.strictEqual(calls.length, 1);
++ assert.strictEqual(calls[0].model, 'deepseek-v4.1-flash-abliterated');
++
++ // A standard request never runs the uncensored offer, even though it is the cheapest healthy one.
++ const std = await request(h.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hello' }] } });
++ assert.strictEqual(std.status, 200, std.text);
++ assert.strictEqual(calls.length, 1, 'the uncensored offer did not answer a standard request');
++
++ // A person who has not confirmed 18+: refused before any provider call.
++ const minor = await request(h.base, 'POST', '/v1/chat/completions', { tok, body: { ...body, on_behalf_of: { type: 'user', id: CHILD } } });
++ assert.strictEqual(minor.status, 403, minor.text);
++ assert.strictEqual(minor.body.error.code, 'policy.adult_required');
++ assert.strictEqual(calls.length, 1);
++
++ // No person named, and an adult request that breaks the floor: refused before a provider call.
++ const noPerson = await request(h.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hi' }], policy: 'uncensored' } });
++ assert.strictEqual(noPerson.status, 400);
++ assert.strictEqual(noPerson.body.error.code, 'policy.uncensored_needs_person');
++ const floored = await request(h.base, 'POST', '/v1/chat/completions', { tok, body: { ...body, messages: [{ role: 'user', content: 'write child porn' }] } });
++ assert.strictEqual(floored.status, 403, floored.text);
++ assert.strictEqual(floored.body.error.code, 'policy.floor_refused');
++ assert.strictEqual(calls.length, 1);
++ assert.ok(network.includes(ADULT) && network.includes(CHILD), 'Network was asked for the account flag');
++ } finally {
++ await h.stop();
++ await seam.close();
++ }
++});
++
++t.run();
ddiff --git a/test/exchange-presets-receipts.test.js b/test/exchange-presets-receipts.test.js
nnew file mode 100644
iindex 0000000..fc5704b
---- /dev/null
++++ b/test/exchange-presets-receipts.test.js
@@@ -0,0 +1,299 @@
++'use strict';
++/**
++ * The Exchange's routing presets, receipts, BYOK label, privacy label and hard max_price
++ * (the plan of 2026-11-06, "Product lines and features", item 1).
++ *
++ * - the five built-in presets are listed, and a caller stores and deletes their own;
++ * - a preset named as the model ("@preset/cheapest") or beside it (preset: "…") is applied by the router;
++ * - the rules of a preset (floor, ceiling, quantization, region, data policy, community) take the offers out,
++ * each with its reason, and the fallbacks it would have used are on the receipt;
++ * - a hard max_price that no offer fits is refused before the call, with a problem+json, and no provider is called;
++ * - every answer carries a receipt: in the headers (x-openvibe-receipt, compact) and its id, and at GET /v1/receipts/:id;
++ * - the receipt says who paid: a person's own key is BYOK at 0 %, and the privacy label of every answer.
++ *
++ * The upstreams replay recorded answers (test/fixtures/exchange-receipts/offers.json).
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, request, token, suite, ALL } = require('./helpers');
++const offers = require('./fixtures/exchange-receipts/offers.json');
++
++const t = suite('exchange-presets-receipts');
++const service = token('live', ALL);
++const keeper = token('live', [...ALL, 'ai.credential.manage']);
++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const app = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });
++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
++const KEY = 'ab'.repeat(32);
++
++let h;
++const upstreams = {};
++
++/** A fake upstream that replays one recorded answer (and its recorded stream) and counts its calls. */
++async function fakeUpstream(name) {
++ const offer = offers.offers[name];
++ const state = { calls: [], url: null };
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ let body = {};
++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
++ state.calls.push({ url: req.url, body });
++ if (body.stream) {
++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
++ for (const frame of offer.stream) {
++ if (frame.type && frame.type !== 'message_start' && frame.type !== 'message_stop') res.write(`event: ${frame.type}\n`);
++ res.write(`data: ${JSON.stringify(frame)}\n\n`);
++ }
++ return res.end();
++ }
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(offer.response));
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ state.url = `http://127.0.0.1:${server.address().port}`;
++ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });
++ upstreams[name] = state;
++ return state;
++}
++
++async function makeProvider(name) {
++ const offer = offers.offers[name];
++ const upstream = await fakeUpstream(name);
++ const anthropic = offer.kind === 'anthropic';
++ const made = await request(h.base, 'POST', '/api/v1/providers', {
++ tok: service,
++ body: {
++ key: name, kind: offer.kind, base_url: `${upstream.url}/v1`, auth_mode: 'none',
++ capabilities: anthropic ? ['chat', 'messages', 'tools', 'json'] : ['chat', 'complete', 'tools', 'json'],
++ default_model: offer.model,
++ provider_access: 'OWN_DEPLOYMENT', metadata: { data_policy: offer.data_policy },
++ },
++ });
++ assert.strictEqual(made.status, 201, made.text);
++ const model = await request(h.base, 'POST', '/api/v1/models', {
++ tok: service,
++ body: {
++ provider_key: name, model_key: offer.model, type: 'chat', status: 'active',
++ context_window: 32768, max_output: 4096, supports: { json: true, tools: true, streaming: true, vision: false },
++ cost: { in_per_mtok: offer.price.in_per_mtok, out_per_mtok: offer.price.out_per_mtok, cached_per_mtok: offer.price.in_per_mtok / 10, source: 'https://example.com/pricing', verified_at: '2026-11-01' },
++ metadata: { org: 'acme', model: offer.model, quant: offer.quant, revision: 'rev1', tier: 'balanced', listed: true, synced_at: '2026-11-01T00:00:00.000Z' },
++ },
++ });
++ assert.strictEqual(model.status, 201, model.text);
++ return upstream;
++}
++
++const post = (body, tok = service, headers = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok, body, headers });
++const callsOf = (name) => upstreams[name].calls.length;
++
++/** The person's own key: answered in-process (the guarded fetch is tested elsewhere). */
++const credentialFetch = async () => new Response(JSON.stringify({
++ model: 'gpt-4o-mini', choices: [{ message: { role: 'assistant', content: "dana's own key" } }],
++ usage: { prompt_tokens: 10, completion_tokens: 5 },
++}), { status: 200, headers: { 'content-type': 'application/json' } });
++
++t.test('boot: the catalog is on, with the recorded offers', async () => {
++ h = await boot({ env: { AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_CREDENTIALS_KEY: KEY }, credentialFetch });
++ for (const name of ['swift', 'crowd', 'dear', 'anthropic']) await makeProvider(name);
++});
++
++t.test('the five built-in presets are listed, and a caller stores and deletes their own', async () => {
++ const list = await request(h.base, 'GET', '/api/v1/presets', { tok: service });
++ assert.strictEqual(list.status, 200, list.text);
++ const names = list.body.presets.filter((p) => p.builtin).map((p) => p.name);
++ assert.deepStrictEqual(names.sort(), ['cheapest', 'eu-only', 'fastest', 'private', 'uncensored']);
++ const made = await request(h.base, 'POST', '/api/v1/presets', { tok: service, body: { name: 'my-cheap', policy: { ceiling: 0.5, objective: 'cheapest' } } });
++ assert.strictEqual(made.status, 201, made.text);
++ assert.deepStrictEqual(made.body.preset.policy, { objective: 'cheapest', ceiling: 0.5 });
++ assert.strictEqual(made.body.preset.scope, 'owner');
++ const again = await request(h.base, 'GET', '/api/v1/presets', { tok: service });
++ assert.ok(again.body.presets.some((p) => p.name === 'my-cheap' && p.policy.ceiling === 0.5));
++ // A built-in name is not the caller's to write.
++ const over = await request(h.base, 'POST', '/api/v1/presets', { tok: service, body: { name: 'cheapest', policy: { ceiling: 0.1 } } });
++ assert.deepStrictEqual([over.status, over.body.code], [409, 'ai.preset_builtin']);
++ // A policy that names a rule nobody knows is refused.
++ const bad = await request(h.base, 'POST', '/api/v1/presets', { tok: service, body: { name: 'odd', policy: { objective: 'fastest-ish' } } });
++ assert.strictEqual(bad.status, 422, bad.text);
++ const removed = await request(h.base, 'DELETE', '/api/v1/presets/my-cheap', { tok: service });
++ assert.strictEqual(removed.status, 200, removed.text);
++ assert.strictEqual(removed.body.removed, true);
++});
++
++t.test('an app\'s preset belongs to its project; a person\'s to their account', async () => {
++ const stored = await request(h.base, 'POST', '/api/v1/presets', { tok: app, body: { name: 'project-fast', policy: { objective: 'fastest' } } });
++ assert.strictEqual(stored.status, 201, stored.text);
++ assert.strictEqual(stored.body.preset.owner.type, 'project');
++ assert.strictEqual(stored.body.preset.owner.id, PRJ);
++ // The owners a request may look a preset up under: the project, then the person the token acts for.
++ const owners = require('../server/exchange/presets').ownersOf({ kind: 'app', projectId: PRJ, onBehalfOf: DANA });
++ assert.deepStrictEqual(owners.slice(0, 2), [{ type: 'project', id: PRJ }, { type: 'user', id: DANA }]);
++ // The other caller's preset is not the caller's.
++ const theirs = await request(h.base, 'GET', '/api/v1/presets', { tok: service });
++ assert.ok(!theirs.body.presets.some((p) => p.name === 'project-fast'), 'a project\'s preset is not in a service\'s list');
++ const mine = await request(h.base, 'GET', '/api/v1/presets', { tok: app });
++ assert.ok(mine.body.presets.some((p) => p.name === 'project-fast'), 'and it is in the project\'s own list');
++});
++
++t.test('a preset named as the model is applied: the cheapest offer answers', async () => {
++ const before = callsOf('crowd');
++ const r = await post({ model: '@preset/cheapest', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(callsOf('crowd') > before, 'the cheapest offer answered');
++ assert.strictEqual(callsOf('swift'), 0);
++ // The receipt says which preset was in force.
++ const receipt = await request(h.base, 'GET', `/v1/receipts/${r.headers.get('x-openvibe-receipt-id')}`, { tok: service });
++ assert.strictEqual(receipt.status, 200, receipt.text);
++ assert.strictEqual(receipt.body.receipt.preset.name, 'cheapest');
++ assert.strictEqual(receipt.body.receipt.preset.policy.objective, 'cheapest');
++});
++
++t.test('a preset beside the model is applied too, and an unknown preset is refused', async () => {
++ const r = await post({ model: 'openvibe/auto', preset: 'cheapest', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(r.status, 200, r.text);
++ const unknown = await post({ model: 'openvibe/auto', preset: 'no-such-preset', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(unknown.status, 404, unknown.text);
++ assert.strictEqual(unknown.body.error.code, 'ai.preset_not_found');
++});
++
++t.test('the rules of a preset take the offers out, each with its reason', async () => {
++ // The community offer is the cheapest; a private profile must not use it.
++ const community = await post({ model: 'openvibe/auto', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(community.status, 200, community.text);
++ assert.strictEqual(community.body.x_openvibe.provider, 'crowd', 'the cheapest offer answers by default');
++
++ const priv = await post({ model: 'openvibe/auto', preset: 'private', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(priv.status, 200, priv.text);
++ assert.strictEqual(priv.body.x_openvibe.provider, 'swift', 'only the offer that keeps nothing answers');
++ assert.strictEqual(priv.body.x_openvibe.privacy, 'private', 'the answer is labelled private');
++
++ // A quantization the catalogue knows: only the community offer is q8_0.
++ const quant = await post({ model: 'openvibe/auto', preset: { name: 'q8', policy: { quantization: ['q8_0'] } }, messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(quant.status, 200, quant.text);
++ assert.strictEqual(quant.body.x_openvibe.provider, 'crowd');
++ // A region: only the first-party offer runs in the EU.
++ const eu = await post({ model: 'openvibe/auto', preset: 'eu-only', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(eu.status, 200, eu.text);
++ assert.strictEqual(eu.body.x_openvibe.provider, 'swift');
++});
++
++t.test('a hard max_price that no offer fits is refused before the call', async () => {
++ const before = { swift: callsOf('swift'), crowd: callsOf('crowd'), dear: callsOf('dear') };
++ const r = await post({ model: 'openvibe/auto', max_price: 0.1, messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(r.status, 400, r.text);
++ assert.strictEqual(r.body.error.code, 'ai.max_price_exceeded');
++ assert.match(r.body.error.message, /max_price/);
++ assert.deepStrictEqual({ swift: callsOf('swift'), crowd: callsOf('crowd'), dear: callsOf('dear') }, before, 'no provider was called');
++});
++
++t.test('a max_price that fits keeps the expensive offer out, and the ceiling is not a refusal', async () => {
++ const capped = await post({ model: 'openvibe/auto', max_price: 1, messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(capped.status, 200, capped.text);
++ assert.strictEqual(capped.body.x_openvibe.provider, 'crowd', 'only the offer under the cap answers');
++ const id = capped.headers.get('x-openvibe-receipt-id');
++ const receipt = await request(h.base, 'GET', `/v1/receipts/${id}`, { tok: service });
++ assert.strictEqual(receipt.status, 200, receipt.text);
++ assert.ok(receipt.body.receipt.fallbacks.some((f) => f.provider === 'swift' && /over_max_price/.test(f.reason || '')), 'the offer over the cap is named with its reason');
++ // A ceiling under the cheapest offer leaves nothing, and the run fails rather than breaking the cap.
++ const ceil = await post({ model: 'openvibe/auto', preset: { name: 'tight', policy: { ceiling: 0.1 } }, messages: [{ role: 'user', content: 'hello' }] });
++ assert.notStrictEqual(ceil.status, 200, 'the ceiling is not answered over');
++});
++
++t.test('every answer carries a receipt: the headers, the id, and GET /v1/receipts/:id', async () => {
++ const r = await post({ model: 'openvibe/auto', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(r.status, 200, r.text);
++ const id = r.headers.get('x-openvibe-receipt-id');
++ assert.match(String(id), /^rcpt_/);
++ const header = r.headers.get('x-openvibe-receipt');
++ assert.ok(header, 'the compact receipt is in the headers');
++ const compact = JSON.parse(header);
++ assert.strictEqual(compact.id, id);
++ assert.strictEqual(compact.offer, 'crowd/crowd-m');
++ assert.deepStrictEqual([compact.price.in, compact.price.out], [0.2, 0.6]);
++ assert.deepStrictEqual([compact.tokens.in, compact.tokens.out, compact.tokens.cached], [120, 30, 40]);
++ assert.ok(compact.latency_ms >= 0);
++ assert.strictEqual(compact.privacy, 'community', 'a community machine is labelled as one');
++ assert.ok(Array.isArray(compact.fallbacks) && compact.fallbacks.length, 'the fallbacks it would have used are named');
++
++ const full = await request(h.base, 'GET', `/v1/receipts/${id}`, { tok: service });
++ assert.strictEqual(full.status, 200, full.text);
++ const receipt = full.body.receipt;
++ assert.strictEqual(receipt.id, id);
++ assert.strictEqual(receipt.run_id, r.body.x_openvibe.run_id);
++ assert.deepStrictEqual([receipt.offer.provider, receipt.offer.model], ['crowd', 'crowd-m']);
++ assert.deepStrictEqual([receipt.price.in_per_mtok, receipt.price.out_per_mtok], [0.2, 0.6]);
++ assert.deepStrictEqual([receipt.usage.tokens_in, receipt.usage.tokens_out, receipt.usage.tokens_cached], [120, 30, 40]);
++ assert.ok(receipt.why.length, 'the receipt says why the router chose it');
++ assert.ok(receipt.fallbacks.length, 'and the fallbacks it would have used');
++ assert.strictEqual(receipt.privacy.label, 'community');
++ assert.strictEqual(receipt.paid_by, 'openvibe_key');
++ assert.strictEqual(receipt.byok, false);
++ // The receipt never carries the prompt or the answer.
++ assert.ok(!JSON.stringify(receipt).includes('hello'), 'the receipt keeps no prompt');
++ // A receipt nobody may read is a 404, not a leak.
++ const other = token('other', ['ai.run.create']);
++ const hidden = await request(h.base, 'GET', `/v1/receipts/${id}`, { tok: other });
++ assert.strictEqual(hidden.status, 404, hidden.text);
++});
++
++t.test('a person\'s own key is BYOK: the receipt says so, at no fee', async () => {
++ const put = await request(h.base, 'PUT', `/api/v1/credentials/${DANA}`, {
++ tok: keeper, body: { provider: 'openai', base_url: 'https://llm.example.com/v1', api_key: 'fake-openai-key-for-the-receipt', models: { chat: 'gpt-4o-mini' }, budget_usd_per_day: 1 },
++ });
++ assert.strictEqual(put.status, 200, put.text);
++ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hello' }] }, headers: { 'x-openvibe-credential': `openai:${DANA}` } });
++ assert.strictEqual(r.status, 200, r.text);
++ const id = r.headers.get('x-openvibe-receipt-id');
++ const receipt = await request(h.base, 'GET', `/v1/receipts/${id}`, { tok: service });
++ assert.strictEqual(receipt.status, 200, receipt.text);
++ const body = receipt.body.receipt;
++ assert.strictEqual(body.byok, true, 'the receipt says the answer came from their own key');
++ assert.strictEqual(body.paid_by, 'own_key');
++ assert.strictEqual(body.fee_usd, 0, 'their own key is never charged');
++ assert.strictEqual(body.fee_rate, 0);
++ assert.strictEqual(body.offer.provider, 'byo:live:' + DANA);
++});
++
++t.test('the Anthropic-compatible endpoint carries the same receipt', async () => {
++ const r = await request(h.base, 'POST', '/v1/messages', {
++ tok: service, headers: { 'anthropic-version': '2023-06-01' },
++ body: { model: 'openvibe/auto', max_tokens: 64, messages: [{ role: 'user', content: 'hello' }] },
++ });
++ assert.strictEqual(r.status, 200, r.text);
++ const id = r.headers.get('x-openvibe-receipt-id');
++ assert.match(String(id), /^rcpt_/);
++ const compact = JSON.parse(r.headers.get('x-openvibe-receipt'));
++ assert.strictEqual(compact.id, id);
++ assert.ok(compact.privacy, 'the privacy label travels with it');
++});
++
++t.test('a streamed answer names its receipt in the headers and carries it in the last chunk', async () => {
++ const res = await fetch(`${h.base}/v1/chat/completions`, {
++ method: 'POST', headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json' },
++ body: JSON.stringify({ model: 'openvibe/auto', stream: true, messages: [{ role: 'user', content: 'hello' }] }),
++ });
++ assert.strictEqual(res.status, 200);
++ const id = res.headers.get('x-openvibe-receipt-id');
++ assert.match(String(id), /^rcpt_/);
++ const text = await res.text();
++ const chunks = text.split('\n\n').filter((b) => b.startsWith('data: ')).map((b) => b.slice(6)).filter((b) => b !== '[DONE]').map((b) => JSON.parse(b));
++ const last = chunks[chunks.length - 1];
++ assert.ok(last.receipt, 'the last chunk carries the receipt');
++ assert.strictEqual(last.receipt.id, id);
++});
++
++t.test('a preset is refused when nobody has it, and the receipt route needs a token', async () => {
++ const none = await request(h.base, 'GET', '/v1/receipts/rcpt_nope', { tok: service });
++ assert.strictEqual(none.status, 404, none.text);
++ const anon = await request(h.base, 'GET', '/v1/receipts/rcpt_nope');
++ assert.strictEqual(anon.status, 401, anon.text);
++});
++
++t.test('shutdown', async () => {
++ await h.stop();
++ for (const name of Object.keys(upstreams)) await upstreams[name].close();
++});
++
++if (require.main === module) t.run();
ddiff --git a/test/fixtures/exchange-receipts/offers.json b/test/fixtures/exchange-receipts/offers.json
nnew file mode 100644
iindex 0000000..299fe27
---- /dev/null
++++ b/test/fixtures/exchange-receipts/offers.json
@@@ -0,0 +1,328 @@
++{
++ "note": "Recorded upstream answers (OpenAI's Chat Completions shape), replayed by the test's fake providers. The prices and the data policies are the ones the test registers in the catalogue; the answers themselves are recorded, not invented.",
++ "recorded_at": "2026-11-06",
++ "offers": {
++ "swift": {
++ "kind": "openai",
++ "model": "swift-m",
++ "price": {
++ "in_per_mtok": 1,
++ "out_per_mtok": 2
++ },
++ "data_policy": {
++ "retention": "none",
++ "training": false,
++ "region": "eu",
++ "trust": "first-party"
++ },
++ "quant": "q4_k_m",
++ "response": {
++ "id": "chatcmpl-rec-swift",
++ "object": "chat.completion",
++ "created": 1761955200,
++ "model": "swift-m",
++ "system_fingerprint": null,
++ "choices": [
++ {
++ "index": 0,
++ "message": {
++ "role": "assistant",
++ "content": "The first-party answer."
++ },
++ "finish_reason": "stop"
++ }
++ ],
++ "usage": {
++ "prompt_tokens": 120,
++ "completion_tokens": 30,
++ "total_tokens": 150,
++ "prompt_tokens_details": {
++ "cached_tokens": 40
++ }
++ }
++ },
++ "stream": [
++ {
++ "id": "chatcmpl-rec-swift",
++ "object": "chat.completion.chunk",
++ "created": 1761955200,
++ "model": "swift-m",
++ "choices": [
++ {
++ "index": 0,
++ "delta": {
++ "role": "assistant",
++ "content": "The first-party answer."
++ },
++ "finish_reason": null
++ }
++ ]
++ },
++ {
++ "id": "chatcmpl-rec-swift",
++ "object": "chat.completion.chunk",
++ "created": 1761955200,
++ "model": "swift-m",
++ "choices": [
++ {
++ "index": 0,
++ "delta": {},
++ "finish_reason": "stop"
++ }
++ ],
++ "usage": {
++ "prompt_tokens": 120,
++ "completion_tokens": 30,
++ "total_tokens": 150,
++ "prompt_tokens_details": {
++ "cached_tokens": 40
++ }
++ }
++ }
++ ]
++ },
++ "crowd": {
++ "model": "crowd-m",
++ "price": {
++ "in_per_mtok": 0.2,
++ "out_per_mtok": 0.6
++ },
++ "data_policy": {
++ "retention": "standard",
++ "training": false,
++ "region": "global",
++ "trust": "community"
++ },
++ "quant": "q8_0",
++ "response": {
++ "id": "chatcmpl-rec-crowd",
++ "object": "chat.completion",
++ "created": 1761955200,
++ "model": "crowd-m",
++ "system_fingerprint": null,
++ "choices": [
++ {
++ "index": 0,
++ "message": {
++ "role": "assistant",
++ "content": "The community answer."
++ },
++ "finish_reason": "stop"
++ }
++ ],
++ "usage": {
++ "prompt_tokens": 120,
++ "completion_tokens": 30,
++ "total_tokens": 150,
++ "prompt_tokens_details": {
++ "cached_tokens": 40
++ }
++ }
++ },
++ "kind": "openai",
++ "stream": [
++ {
++ "id": "chatcmpl-rec-crowd",
++ "object": "chat.completion.chunk",
++ "created": 1761955200,
++ "model": "crowd-m",
++ "choices": [
++ {
++ "index": 0,
++ "delta": {
++ "role": "assistant",
++ "content": "The community answer."
++ },
++ "finish_reason": null
++ }
++ ]
++ },
++ {
++ "id": "chatcmpl-rec-crowd",
++ "object": "chat.completion.chunk",
++ "created": 1761955200,
++ "model": "crowd-m",
++ "choices": [
++ {
++ "index": 0,
++ "delta": {},
++ "finish_reason": "stop"
++ }
++ ],
++ "usage": {
++ "prompt_tokens": 120,
++ "completion_tokens": 30,
++ "total_tokens": 150,
++ "prompt_tokens_details": {
++ "cached_tokens": 40
++ }
++ }
++ }
++ ]
++ },
++ "dear": {
++ "model": "dear-m",
++ "price": {
++ "in_per_mtok": 3,
++ "out_per_mtok": 15
++ },
++ "data_policy": {
++ "retention": "limited",
++ "training": true,
++ "region": "us",
++ "trust": "first-party"
++ },
++ "quant": "q4_k_m",
++ "response": {
++ "id": "chatcmpl-rec-dear",
++ "object": "chat.completion",
++ "created": 1761955200,
++ "model": "dear-m",
++ "system_fingerprint": null,
++ "choices": [
++ {
++ "index": 0,
++ "message": {
++ "role": "assistant",
++ "content": "The expensive answer."
++ },
++ "finish_reason": "stop"
++ }
++ ],
++ "usage": {
++ "prompt_tokens": 120,
++ "completion_tokens": 30,
++ "total_tokens": 150,
++ "prompt_tokens_details": {
++ "cached_tokens": 40
++ }
++ }
++ },
++ "kind": "openai",
++ "stream": [
++ {
++ "id": "chatcmpl-rec-dear",
++ "object": "chat.completion.chunk",
++ "created": 1761955200,
++ "model": "dear-m",
++ "choices": [
++ {
++ "index": 0,
++ "delta": {
++ "role": "assistant",
++ "content": "The expensive answer."
++ },
++ "finish_reason": null
++ }
++ ]
++ },
++ {
++ "id": "chatcmpl-rec-dear",
++ "object": "chat.completion.chunk",
++ "created": 1761955200,
++ "model": "dear-m",
++ "choices": [
++ {
++ "index": 0,
++ "delta": {},
++ "finish_reason": "stop"
++ }
++ ],
++ "usage": {
++ "prompt_tokens": 120,
++ "completion_tokens": 30,
++ "total_tokens": 150,
++ "prompt_tokens_details": {
++ "cached_tokens": 40
++ }
++ }
++ }
++ ]
++ },
++ "anthropic": {
++ "kind": "anthropic",
++ "model": "anth-m",
++ "price": {
++ "in_per_mtok": 5,
++ "out_per_mtok": 20
++ },
++ "data_policy": {
++ "retention": "limited",
++ "training": true,
++ "region": "us",
++ "trust": "first-party"
++ },
++ "quant": "q4_k_m",
++ "response": {
++ "id": "msg_rec-anth",
++ "type": "message",
++ "role": "assistant",
++ "model": "anth-m",
++ "content": [
++ {
++ "type": "text",
++ "text": "The Anthropic-format answer."
++ }
++ ],
++ "stop_reason": "end_turn",
++ "stop_sequence": null,
++ "usage": {
++ "input_tokens": 120,
++ "output_tokens": 30,
++ "cache_read_input_tokens": 40
++ }
++ },
++ "stream": [
++ {
++ "type": "message_start",
++ "message": {
++ "id": "msg_rec-anth",
++ "type": "message",
++ "role": "assistant",
++ "model": "anth-m",
++ "content": [],
++ "stop_reason": null,
++ "stop_sequence": null,
++ "usage": {
++ "input_tokens": 120,
++ "output_tokens": 0
++ }
++ }
++ },
++ {
++ "type": "content_block_start",
++ "index": 0,
++ "content_block": {
++ "type": "text",
++ "text": ""
++ }
++ },
++ {
++ "type": "content_block_delta",
++ "index": 0,
++ "delta": {
++ "type": "text_delta",
++ "text": "The Anthropic-format answer."
++ }
++ },
++ {
++ "type": "content_block_stop",
++ "index": 0
++ },
++ {
++ "type": "message_delta",
++ "delta": {
++ "stop_reason": "end_turn",
++ "stop_sequence": null
++ },
++ "usage": {
++ "output_tokens": 30
++ }
++ },
++ {
++ "type": "message_stop"
++ }
++ ]
++ }
++ }
++}
ddiff --git a/test/fixtures/pool-providers.json b/test/fixtures/pool-providers.json
nnew file mode 100644
iindex 0000000..1ca065d
---- /dev/null
++++ b/test/fixtures/pool-providers.json
@@@ -0,0 +1,84 @@
++{
++ "note": "Recorded shapes of the nine OpenAI-compatible providers (docs: the provider's own API reference, checked 2026-11-04). Tests replay them against a local seam; no test reaches the network. The shapes are what each provider returns for a Chat Completions request: a completion (with a tool call where the model has tools) and an SSE stream of the same answer.",
++ "qwen": {
++ "model": "qwen3.8-max",
++ "completion": {
++ "id": "chatcmpl-qwen-1", "object": "chat.completion", "model": "qwen3.8-max",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from Qwen" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16, "prompt_tokens_details": { "cached_tokens": 2 } }
++ },
++ "stream": ["hello ", "from ", "Qwen"]
++ },
++ "kimi": {
++ "model": "kimi-k3.1-pro",
++ "completion": {
++ "id": "chatcmpl-kimi-1", "object": "chat.completion", "model": "kimi-k3.1-pro",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from Kimi" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14, "prompt_tokens_details": { "cached_tokens": 0 } }
++ },
++ "stream": ["hello ", "from ", "Kimi"]
++ },
++ "glm": {
++ "model": "glm-5.3-flash",
++ "completion": {
++ "id": "chatcmpl-glm-1", "object": "chat.completion", "model": "glm-5.3-flash",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from GLM" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15, "prompt_tokens_details": { "cached_tokens": 1 } }
++ },
++ "stream": ["hello ", "from ", "GLM"]
++ },
++ "minimax": {
++ "model": "MiniMax-M3.1-Flash",
++ "completion": {
++ "id": "chatcmpl-minimax-1", "object": "chat.completion", "model": "MiniMax-M3.1-Flash",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from MiniMax" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 9, "completion_tokens": 4, "total_tokens": 13, "prompt_tokens_details": { "cached_tokens": 0 } }
++ },
++ "stream": ["hello ", "from ", "MiniMax"]
++ },
++ "deepinfra": {
++ "model": "deepseek-v4.1-flash-1017",
++ "completion": {
++ "id": "chatcmpl-deepinfra-1", "object": "chat.completion", "model": "deepseek-v4.1-flash-1017",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_1", "type": "function", "function": { "name": "weather", "arguments": "{\"city\":\"Paris\"}" } }] }, "finish_reason": "tool_calls" }],
++ "usage": { "prompt_tokens": 20, "completion_tokens": 8, "total_tokens": 28, "prompt_tokens_details": { "cached_tokens": 4 } }
++ },
++ "stream": ["hello ", "from ", "DeepInfra"]
++ },
++ "novita": {
++ "model": "deepseek-v4.1-flash-1017",
++ "completion": {
++ "id": "chatcmpl-novita-1", "object": "chat.completion", "model": "deepseek-v4.1-flash-1017",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from Novita" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 8, "completion_tokens": 4, "total_tokens": 12, "prompt_tokens_details": { "cached_tokens": 0 } }
++ },
++ "stream": ["hello ", "from ", "Novita"]
++ },
++ "featherless": {
++ "model": "Qwen/Qwen3-14B",
++ "completion": {
++ "id": "chatcmpl-featherless-1", "object": "chat.completion", "model": "Qwen/Qwen3-14B",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from Featherless" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 7, "completion_tokens": 4, "total_tokens": 11, "prompt_tokens_details": { "cached_tokens": 0 } }
++ },
++ "stream": ["hello ", "from ", "Featherless"]
++ },
++ "refuseless": {
++ "model": "deepseek-v4.1-flash-abliterated",
++ "completion": {
++ "id": "chatcmpl-refuseless-1", "object": "chat.completion", "model": "deepseek-v4.1-flash-abliterated",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "an unlabelled answer" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 6, "completion_tokens": 3, "total_tokens": 9, "prompt_tokens_details": { "cached_tokens": 0 } }
++ },
++ "stream": ["an ", "unlabelled ", "answer"]
++ },
++ "openrouter": {
++ "model": "openrouter/auto",
++ "completion": {
++ "id": "gen-openrouter-1", "object": "chat.completion", "model": "openrouter/auto",
++ "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello from OpenRouter" }, "finish_reason": "stop" }],
++ "usage": { "prompt_tokens": 5, "completion_tokens": 4, "total_tokens": 9, "prompt_tokens_details": { "cached_tokens": 0 } }
++ },
++ "stream": ["hello ", "from ", "OpenRouter"]
++ }
++}