openvibers/openvibe.services Commits

Integrate: AI Exchange UX (model explorer, AI settings, Balance page and money guides) (#214)

OP OpenVibers committed 8 days ago · 08a69e3ed7 · 36 files, +2550 −62

* Model explorer (AI Exchange P8): /models, a model's page, the comparison, behind a flag

Every model OpenVibe AI can run, by exact build (org/model[:quant]@revision) and variant
(original, abliterated, fine-tuned, merged, LoRA), filtered by what it can do and who
supplies it (own deployment, licensed partner, your key, pending agreement), priced for
a real workload. A model's page has its lineage, the four token-class prices, a
calculator exact to the nano-dollar (each class rounded up once per request, the same
arithmetic as Billing's subledger), its quality (never borrowed from the base model) and
how to call it with curl, the OpenAI and the Anthropic SDKs. Compare shows 2-4 side by
side with the cost of each workload.

Behind SERVICES_MODEL_EXPLORER (off) and noindex: the data is the preview fixture in the
ai.model@1 shape, labelled example prices, until OpenVibe AI's catalog is live
(SERVICES_MODEL_CATALOG_SOURCE=ai). The layout takes a page's own stylesheet.

* The preview catalog lives in server/fixtures (server/data is ignored: runtime data)

* AI playground (AI Exchange P8): one chat reply as the app, behind SERVICES_AI_PLAYGROUND

A third playground kind: the app's own ai.inference.create token (D8: the public
capability developer apps call AI with; ai.run.create stays first-party) calls OpenVibe
AI's /v1/chat/completions. The reply, the model that really answered and its route
disclosure (the openvibe object) and the tokens are shown once; the run log keeps the
outcome and ids, never the prompt or the reply. Models come from AI's catalog when it is
the source, else the dynamic name that runs today. Migration 0007 widens
playground_runs.kind (expand). The success-path test runs once Contracts with D8 is
pinned; until then it says so.

* openvibe-contracts v0.284.0 (was v0.222.0): ai.inference.create exists, so the AI playground's success path runs

The grant goes through Services' own grant flow; the Network mock accepts sandbox tokens
for openvibe.ai (AI Exchange D10: Network's DEV_SANDBOX_AUDIENCES at launch). The model
catalog's read of OpenVibe AI's /v1/models is on the SSRF ratchet's reviewed list (a
configured URL, never a caller's). ov test --all: 52 pass; docs.test is red on main too.

* /docs/ai (AI Exchange P8): the developer guide, behind the explorer's flag

Start in three steps (project, sandbox app with ai.inference.create, key, first call)
with curl and the OpenAI and Anthropic SDKs; exact ids against the dynamic names; the
openvibe object every answer carries; prices to the nano-dollar with a worked example,
Credits holds and max_cost_usd; streaming and aborts; errors; BYOK, private and sandbox.
noindex and labelled preview.

* A project's Credits page (AI Exchange D9): balance, held, available, spend per day, the requests

Read from OpenVibe Billing's GET /api/me/credits with the person's own token (Billing is
the authority; Services stores nothing): three tiles, 30 days of spend as bars, recent
requests with the model, the token counts and the exact charge, BYOK and shortfall marked.
'Not open yet' while Billing's ledger is off; the top-up button waits for the launch.
Behind SERVICES_MODEL_EXPLORER.

* Start with OpenVibe AI (/projects/:id/ai): the steps to a first answer, each read live

A sandbox app, the ai.inference.create permission (with the button that asks for it), its
key, a first call in the AI playground, and Credits: done ones ticked, the next one marked
with its action. Read from Network, the playground runs and Billing; nothing stored.
Behind SERVICES_MODEL_EXPLORER. Checked at 390 and 1280 px, no overflow.

* The model explorer reads ai.model@1 exactly as Contracts v0.284.0 defines it

The preview catalog is now valid ai.model@1 (the test validates every entry): display_name,
context, max_output, capabilities and supports as booleans, retail_rate's flat nano-USD
fields, availability available|limited|unavailable|deprecated, and the Exchange's extras
(who supplies it, lineage, hardware, quality) in metadata. normalize() reads that shape
(and the older names), so AI's real /v1/models drops in with no change to the pages.

* D8' and D3': apps call AI with the existing ai.app.run, and the route is disclosed in x_openvibe

AI already has both: ai.app.run is the public capability developer apps hold (ADR-014; AI
takes it on /v1/chat/completions and /v1/messages), and every answer carries x_openvibe
(run_id, provider, fallback_used, cost_usd). The playground, the onboarding page, the guide
and the code samples use them, instead of the duplicate ai.inference.create and a second
'openvibe' object.

* AI playground: read the disclosure from x_openvibe only (D3')

* Contract docs: one page per major version

Since openvibe-contracts v0.241.0 the catalog lists some ids at two majors (ai.credential-put@1 and @2, and six
others). The docs looked a contract up by id alone, so the older entry's page showed the newer schema and the
docs test failed once this branch moved to v0.284.0. Now the latest major is /docs/contracts/<id> and an older
one /docs/contracts/<id>@<major>, with its own schema, a link to the current major, and in the sitemap.

* Credits page: sandbox credit shown apart from the balance (D10)

Billing's statement now reports a person's sandbox credit separately (it pays only for sandbox apps' test
requests). The page shows it as one quiet line under the tiles, never added to the balance.

* D11 owner settings: services.ai (shared 4.110.0, contracts 0.288.0)

* D12: a project's Balance page and the Vibes/Balance docs

Renames the project page to /projects/:id/balance (the old /credits 301s), changes the public
copy to Balance, Add money and Get Vibes, and adds /docs/vibes and /docs/balance. The docs cover
Vibes Pay with the SDK and plain HTTP, the webhook signature and its 300 s window, sandbox,
refunds, payouts, limits, metered Balance holds and transfers. No public Credits text remains.

* Merge #212 D11 owner settings: services.ai

* Merge #213 D12: Balance page and the Vibes/Balance docs

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex aa3e3f9..dd081bd 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,14 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
## Unreleased
++- **Money words (D12): Balance, Vibes and Vibes Pay.** The project page from D9 is now the
++ project's **Balance** at `/projects/:project/balance` (the old `/credits` address 301s to it), with **Add money**,
++ a sandbox Balance shown apart, and the old public word gone from every page. New developer guides: `/docs/vibes` (what Vibes are;
++ Vibes Pay step by step with `openvibe-sdk/vibes` and plain HTTP; the `X-OpenVibe-Signature-V2` webhook and its
++ ±300 s window; sandbox, refunds, payouts, `billing.vibes` limits) and `/docs/balance` (how metered usage holds and
++ settles against a project's Balance, adding money, moving money to a project with `/api/me/balance/transfers`).
++ The AI guide says Balance, and the docs index links both guides. `server/http/money-docs.js`,
++ `server/domain/balance.js`, `test/balance-page.test.js`, `test/money-docs.test.js`.
- **Mod the network** (`/mod`, `server/http/mod.js`; the owner 2026-10-25: "openvibe.services as the place to mod the network or build your own sites with the SDK and APIs"). Every way to extend OpenVibe today, each with its link: your own site with the shared home-page kit, chat bots, alerts and work into a team room (Chat room hooks), watches that act, agents, Bot drivers and screen shares, game worlds, events, and changing OpenVibe itself on Codes. Packaged mods say plainly that staff install them until ADR-013's signing and review decisions are made. In the navigation, the sitemap, llms.txt and the home's quick start. `test/mod-page.test.js`.
- Control operations (ADR-048, plan T13 step 10): `POST /api/v1/resources/control` behind `services.resource.control` (contracts v0.154.0, was v0.150.0).
- It takes a `common.resource-control-request@1` and answers the owning authority's `common.resource-control-result@1`. The request is forwarded through `openvibe-sdk/resources` `createResourceClient`.
ddiff --git a/README.md b/README.md
iindex 79f3537..86da493 100644
---- a/README.md
++++ b/README.md
@@@ -285,7 +285,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.222.0
++- openvibe-contracts: v0.288.0
- openvibe-sdk: v0.82.0
--- openvibe-shared: v4.109.2
++- openvibe-shared: v4.110.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c7033d1..4168263 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,13 +7,13 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
"port": 4930,
"domain": "openvibe.services",
-- "contracts": "openvibe-contracts v0.222.0",
++ "contracts": "openvibe-contracts v0.288.0",
"sdk": "openvibe-sdk v0.82.0",
-- "shared": "openvibe-shared v4.109.2",
++ "shared": "openvibe-shared v4.110.0",
"packages": [
-- "openvibe-contracts v0.222.0",
++ "openvibe-contracts v0.288.0",
"openvibe-sdk v0.82.0",
-- "openvibe-shared v4.109.2"
++ "openvibe-shared v4.110.0"
],
"capabilities": [
"services.resource.read",
@@@ -46,6 +46,7 @@
"per-actor limits (openvibe-sdk/limits)",
"Services' own manifests and releases (services.manifest, services.release) in the merged index, read in process, with OVRN reads",
"crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt) and the home page's JSON-LD",
++ "the D12 money guides (/docs/vibes and /docs/balance) and a project's Balance page (the D9 page renamed, /credits 301s to /balance)",
"the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.110.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority",
"one adapter per authority (server/authorities/adapter.js): GET /api/v1/resources and GET /api/v1/resources/:ovrn over that authority's loopback origin with a Network client-credentials token for its audience, scoped to its resource.read capability and cached (one mint per authority, refreshed 60 s before expiry; server/authorities/tokens.js)",
"the merged resource index (server/resources.js, GET /api/v1/resources?project=&kind=&service=&cursor=&limit=): openvibe-sdk/resources' createResourceIndex fans out and merges every authority's pages (per-authority cursor walk, concurrency 4, per-authority timeout), Services adds a deterministic (service, ovrn, id) total order and an opaque keyset cursor, and filters project/kind/service locally so a scoped read never shows a row outside its scope",
@@@ -64,5 +65,5 @@
"Governance material (code of conduct, contribution guide, contributor ladder, moderation policy) is written as drafts pending owner review: not in effect, not indexed",
"plan T13 step 4 done in OpenVibe.Contracts v0.110.0 (services.resource.read is registered, active and first-party), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"
],
-- "updated": "2026-11-03"
++ "updated": "2026-11-04"
}
ddiff --git a/migrations/0007_playground_ai.sql b/migrations/0007_playground_ai.sql
nnew file mode 100644
iindex 0000000..c108ef5
---- /dev/null
++++ b/migrations/0007_playground_ai.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- The AI playground (AI Exchange P8): a run of kind 'ai' (one chat reply as the app, ai.inference.create). The set of
++-- kinds only widens: code before this migration never writes 'ai', so it is N-1 safe. The run keeps the outcome and
++-- ids only; the prompt and the reply are never stored.
++ALTER TABLE playground_runs DROP CONSTRAINT IF EXISTS playground_runs_kind_check;
++ALTER TABLE playground_runs ADD CONSTRAINT playground_runs_kind_check CHECK (kind IN ('events', 'media', 'ai'));
ddiff --git a/migrations/0008_config.sql b/migrations/0008_config.sql
nnew file mode 100644
iindex 0000000..49d9764
---- /dev/null
++++ b/migrations/0008_config.sql
@@@ -0,0 +1,28 @@
++-- phase: expand
++-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.
++CREATE TABLE IF NOT EXISTS config_snapshots (
++ namespace text COLLATE "C" NOT NULL,
++ revision bigint NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ previous_revision bigint,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
++ classification_json text COLLATE "C" NOT NULL,
++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ activated_at text COLLATE "C",
++ activated_by text COLLATE "C",
++ reason text COLLATE "C",
++ error text COLLATE "C",
++ copied_from bigint,
++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
++ PRIMARY KEY (namespace, revision)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
++-- The fingerprint key of each namespace. Never served, never logged.
++CREATE TABLE IF NOT EXISTS config_keys (
++ namespace text COLLATE "C" PRIMARY KEY,
++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
++ created_at text COLLATE "C" NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex f9f4157..6113597 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,9 +16,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1165,9 +1165,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.222.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
++ "version": "0.288.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1209,9 +1209,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.109.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
++ "version": "4.110.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 013ec2c..6741bdf 100644
---- a/package.json
++++ b/package.json
@@@ -27,9 +27,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
},
"devDependencies": {
ddiff --git a/public/css/models.css b/public/css/models.css
nnew file mode 100644
iindex 0000000..183289a
---- /dev/null
++++ b/public/css/models.css
@@@ -0,0 +1,207 @@
++/* /models (AI Exchange P8): the model explorer, a model's page, the comparison. Theme tokens from the Frame; works
++ without JavaScript; a table on a computer, cards on a phone. Loaded on these pages only. */
++.mx { --mx-line: var(--border, rgba(150, 170, 200, .18)); --mx-card: var(--bg-card, #131c2e); --mx-soft: var(--text-secondary, #96a7c2);
++ --mx-muted: var(--text-muted, #7d8ba3); --mx-accent: var(--accent, #3b82f6); --mx-ok: var(--success, #22c55e); --mx-warn: var(--warning, #f59e0b);
++ --mx-bad: var(--danger, #ef4444); --mx-abl: #c084fc; --mx-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; display: grid; gap: 18px; min-width: 0; }
++.mx * { min-width: 0; }
++.mx-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
++
++/* Head */
++.mx-hero { display: grid; gap: 8px; padding: 8px 0 4px; }
++.mx-hero h1 { margin: 0; font-size: clamp(1.7rem, 4.2vw, 2.6rem); line-height: 1.08; letter-spacing: -.02em; text-wrap: balance; }
++.mx-hero.is-small h1 { font-size: clamp(1.5rem, 3.4vw, 2.1rem); }
++.mx-eye { margin: 0; color: var(--mx-accent); font-size: 12px; font-weight: 750; letter-spacing: .12em; text-transform: uppercase; }
++.mx-lede { margin: 0; max-width: 66ch; color: var(--mx-soft); font-size: 1.02rem; line-height: 1.55; }
++.mx-note { margin: 4px 0 0; padding: 10px 14px; border-radius: 12px; border: 1px dashed color-mix(in srgb, var(--mx-warn) 55%, transparent); background: color-mix(in srgb, var(--mx-warn) 9%, transparent); font-size: 14px; }
++.mx-note.is-bad { border-color: color-mix(in srgb, var(--mx-bad) 55%, transparent); background: color-mix(in srgb, var(--mx-bad) 9%, transparent); }
++.mx-idline { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 0; }
++
++/* Ids, badges, capability chips */
++.mx-id { font: 12.5px/1.4 var(--mx-mono); color: var(--mx-soft); overflow-wrap: anywhere; }
++.mx-id.is-big { font-size: 14px; color: var(--text-primary, #e6edf7); padding: 4px 8px; border-radius: 8px; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
++.mx-tags { display: inline-flex; flex-wrap: wrap; gap: 6px; margin: 0; }
++.mx-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 1px 9px; border-radius: 999px; font-size: 12px; font-weight: 650; line-height: 1.2; white-space: nowrap;
++ color: var(--mx-soft); background: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); }
++.mx-badge.is-ok { color: var(--success-text, var(--mx-ok)); background: color-mix(in srgb, var(--mx-ok) 15%, transparent); }
++.mx-badge.is-new { color: var(--accent-light, var(--mx-accent)); background: color-mix(in srgb, var(--mx-accent) 15%, transparent); }
++.mx-badge.is-warn { color: var(--warning-text, var(--mx-warn)); background: color-mix(in srgb, var(--mx-warn) 16%, transparent); }
++.mx-badge.is-bad { color: var(--danger-text, var(--mx-bad)); background: color-mix(in srgb, var(--mx-bad) 15%, transparent); }
++.mx-badge.is-abl { color: var(--mx-abl); background: color-mix(in srgb, var(--mx-abl) 16%, transparent); }
++.mx-badge.is-tuned { color: #2dd4bf; background: color-mix(in srgb, #2dd4bf 15%, transparent); }
++.mx-caps { display: inline-flex; flex-wrap: wrap; gap: 5px; }
++.mx-cap { padding: 2px 8px; border-radius: 6px; font-size: 12px; border: 1px solid var(--mx-line); color: var(--mx-soft); white-space: nowrap; }
++.mx-caps.is-big .mx-cap { padding: 6px 12px; font-size: 14px; border-radius: 9px; }
++.mx-cap.is-on { color: var(--text-primary, #e6edf7); border-color: color-mix(in srgb, var(--mx-ok) 45%, var(--mx-line)); }
++.mx-cap.is-off { opacity: .55; }
++
++/* The dynamic names */
++.mx-sec-head { display: grid; gap: 4px; margin-bottom: 10px; }
++.mx-sec-head h2 { margin: 0; font-size: 1.15rem; }
++.mx-sec-head p { margin: 0; color: var(--mx-soft); font-size: 14px; }
++.mx-alias-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 180px), 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; }
++.mx-alias { display: grid; gap: 4px; height: 100%; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--mx-line); background: var(--mx-card); color: inherit; text-decoration: none; transition: border-color .15s ease, transform .15s ease; }
++.mx-alias:hover { border-color: color-mix(in srgb, var(--mx-accent) 55%, var(--mx-line)); transform: translateY(-1px); }
++.mx-alias code { font: 12px var(--mx-mono); color: var(--mx-accent); }
++.mx-alias span { color: var(--mx-soft); font-size: 13px; line-height: 1.4; }
++
++/* Filters */
++.mx-bar { display: grid; grid-template-columns: minmax(180px, 1.4fr) repeat(3, minmax(0, 1fr)); gap: 10px 12px; align-items: end; padding: 14px; border-radius: 16px; border: 1px solid var(--mx-line); background: var(--mx-card); }
++.mx-field { display: grid; gap: 5px; font-size: 12.5px; font-weight: 650; color: var(--mx-soft); }
++.mx-field select, .mx-search input { width: 100%; min-height: 42px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--mx-line); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; font-size: 15px; }
++.mx-search { display: grid; align-self: end; }
++.mx-needs { grid-column: 1 / -2; display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; padding: 0; border: 0; border-radius: 0; }
++.mx-needs legend { float: left; margin: 0 6px 0 0; padding: 0; font-size: 12.5px; font-weight: 650; color: var(--mx-soft); }
++.mx-chip { position: relative; display: inline-flex; }
++.mx-chip input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
++.mx-chip span { display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--mx-line); font-size: 14px; font-weight: 600; color: var(--mx-soft); }
++.mx-chip input:checked + span { color: var(--text-primary, #fff); border-color: var(--mx-accent); background: color-mix(in srgb, var(--mx-accent) 16%, transparent); }
++.mx-chip input:focus-visible + span { outline: 3px solid var(--mx-accent); outline-offset: 2px; }
++.mx-apply { justify-self: end; min-height: 42px; padding: 0 18px; border: 0; border-radius: 10px; background: var(--mx-accent); color: var(--on-accent, #fff); font: inherit; font-weight: 700; cursor: pointer; }
++.mx-count { margin: 0; color: var(--mx-soft); font-size: 14px; }
++.mx-empty { padding: 28px 16px; text-align: center; color: var(--mx-soft); border: 1px dashed var(--mx-line); border-radius: 14px; }
++
++/* The list */
++.mx-table-wrap { overflow-x: auto; border-radius: 16px; border: 1px solid var(--mx-line); background: var(--mx-card); }
++.mx-table { width: 100%; border-collapse: collapse; font-size: 14px; }
++.mx-table th, .mx-table td { padding: 12px 14px; border-bottom: 1px solid var(--mx-line); text-align: left; vertical-align: top; }
++.mx-table thead th { position: sticky; top: 0; font-size: 11.5px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; color: var(--mx-muted); background: var(--mx-card); white-space: nowrap; }
++.mx-table tbody tr:last-child td { border-bottom: 0; }
++.mx-table tbody tr:hover { background: color-mix(in srgb, var(--mx-accent) 5%, transparent); }
++.mx-num { text-align: right !important; font-variant-numeric: tabular-nums; white-space: nowrap; }
++.mx-strong { font-weight: 750; color: var(--text-primary, #fff); }
++.mx-c-model { min-width: 250px; }
++.mx-model-cell { display: grid; gap: 4px; }
++.mx-c-can { min-width: 150px; }
++.mx-name { font-weight: 700; font-size: 15px; color: var(--text-primary, #fff); text-decoration: none; }
++.mx-name:hover { color: var(--mx-accent); }
++.mx-pick { width: 1%; }
++.mx-check { display: inline-grid; place-items: center; width: 40px; height: 40px; margin: -10px; cursor: pointer; }
++.mx-check input { width: 18px; height: 18px; }
++.mx-compare-bar { position: sticky; bottom: max(10px, env(safe-area-inset-bottom)); display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 10px 10px 16px; border-radius: 14px; border: 1px solid var(--mx-line); background: color-mix(in srgb, var(--mx-card) 92%, transparent); backdrop-filter: blur(8px); box-shadow: 0 10px 30px -14px rgb(0 0 0 / .6); font-size: 14px; color: var(--mx-soft); }
++.mx-compare-bar.is-ready { border-color: var(--mx-accent); color: var(--text-primary, #fff); }
++
++/* A model's page */
++.mx-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
++.mx-card { padding: 18px 20px; border-radius: 16px; border: 1px solid var(--mx-line); background: var(--mx-card); }
++.mx-card h2 { margin: 0 0 12px; font-size: 1.1rem; }
++.mx-price { grid-row: span 2; }
++.mx-rates { width: 100%; border-collapse: collapse; margin-bottom: 14px; }
++.mx-rates th, .mx-rates td { padding: 8px 0; border-bottom: 1px solid var(--mx-line); font-size: 14px; }
++.mx-rates th { text-align: left; font-weight: 600; color: var(--mx-soft); }
++.mx-rates small { color: var(--mx-muted); font-weight: 400; }
++.mx-calc { display: grid; gap: 10px; padding: 14px; border-radius: 12px; background: color-mix(in srgb, var(--mx-accent) 6%, transparent); border: 1px solid color-mix(in srgb, var(--mx-accent) 25%, transparent); }
++.mx-calc-title { margin: 0; font-weight: 700; }
++.mx-calc-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 10px; }
++.mx-calc-grid label { display: grid; gap: 4px; font-size: 12.5px; color: var(--mx-soft); font-weight: 600; }
++.mx-calc-grid input { width: 100%; min-height: 40px; padding: 6px 10px; border-radius: 9px; border: 1px solid var(--mx-line); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; font-size: 15px; font-variant-numeric: tabular-nums; }
++.mx-calc-out { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
++.mx-calc-out span { display: grid; gap: 2px; padding: 10px; border-radius: 10px; background: var(--mx-card); font-size: 12.5px; color: var(--mx-soft); }
++.mx-calc-out b { font-size: 16px; color: var(--text-primary, #fff); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
++.mx-calc .mx-apply { justify-self: start; }
++.mx-hint { margin: 10px 0 0; color: var(--mx-muted); font-size: 13px; line-height: 1.45; }
++.mx-facts { display: grid; gap: 0; margin: 0; }
++.mx-fact { display: grid; grid-template-columns: 9.5em minmax(0, 1fr); gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--mx-line); font-size: 14px; }
++.mx-fact dt { color: var(--mx-soft); }
++.mx-fact dd { margin: 0; overflow-wrap: anywhere; }
++.mx-hash { font: 12px var(--mx-mono); }
++.mx-score b { font-size: 1.6rem; }
++.mx-use { display: grid; gap: 12px; }
++.mx-snippet { border-radius: 12px; border: 1px solid var(--mx-line); overflow: hidden; }
++.mx-snippet-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px 6px 14px; font-size: 12.5px; font-weight: 650; color: var(--mx-soft); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); border-bottom: 1px solid var(--mx-line); }
++.mx-snippet pre { margin: 0; padding: 12px 14px; overflow-x: auto; font: 13px/1.55 var(--mx-mono); background: var(--bg-primary, #0a0f1c); }
++.mx-copy { min-height: 30px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--mx-line); background: transparent; color: inherit; font: inherit; font-size: 12.5px; font-weight: 650; cursor: pointer; }
++.mx-copy:hover { border-color: var(--mx-accent); color: var(--mx-accent); }
++.mx-more { display: grid; gap: 12px; }
++.mx-more > summary { cursor: pointer; color: var(--accent-light, var(--mx-accent)); font-weight: 650; min-height: 32px; display: flex; align-items: center; }
++.mx-more[open] > summary { margin-bottom: 12px; }
++.mx-more .mx-snippet + .mx-snippet { margin-top: 12px; }
++
++/* The comparison */
++.mx-bars { display: grid; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--mx-line); }
++.mx-bars:last-child { border-bottom: 0; }
++.mx-bars h3 { margin: 0 0 2px; font-size: 14px; }
++.mx-bars h3 small { color: var(--mx-muted); font-weight: 400; margin-left: 6px; }
++.mx-bar-row { display: grid; grid-template-columns: minmax(0, 11em) minmax(0, 1fr) auto; gap: 10px; align-items: center; font-size: 13.5px; }
++.mx-bar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--mx-soft); }
++.mx-bar-track { height: 10px; border-radius: 99px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); overflow: hidden; }
++.mx-bar-fill { display: block; height: 100%; border-radius: 99px; background: color-mix(in srgb, var(--mx-accent) 70%, transparent); }
++.mx-bar-row.is-best .mx-bar-fill { background: var(--mx-ok); }
++.mx-bar-row.is-best .mx-bar-name { color: var(--text-primary, #fff); font-weight: 650; }
++.mx-bar-val { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 650; }
++.mx-bar-val small { color: var(--mx-muted); font-weight: 400; }
++.mx-vs th[scope="row"] { color: var(--mx-soft); font-weight: 600; white-space: nowrap; }
++
++@media (max-width: 900px) {
++ .mx-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ .mx-search { grid-column: 1 / -1; }
++ .mx-needs { grid-column: 1 / -1; }
++ .mx-apply { justify-self: stretch; }
++ .mx-grid { grid-template-columns: minmax(0, 1fr); }
++ .mx-price { grid-row: auto; }
++}
++@media (max-width: 720px) {
++ /* The list as cards: each row a card, each cell a labelled line. */
++ .mx-table-wrap:not(.is-vs) { border: 0; background: none; overflow: visible; }
++ .mx-table:not(.mx-vs), .mx-table:not(.mx-vs) tbody, .mx-table:not(.mx-vs) tr, .mx-table:not(.mx-vs) td { display: block; width: 100%; }
++ .mx-table:not(.mx-vs) thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
++ .mx-table:not(.mx-vs) tr { position: relative; margin-bottom: 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--mx-line); background: var(--mx-card); }
++ .mx-table:not(.mx-vs) td { display: grid; grid-template-columns: 8.5em minmax(0, 1fr); gap: 8px; padding: 6px 0; border: 0; text-align: left !important; }
++ .mx-table:not(.mx-vs) td::before { content: attr(data-label); color: var(--mx-muted); font-size: 12.5px; font-weight: 600; }
++ .mx-table:not(.mx-vs) td.mx-c-model { display: block; padding: 0 44px 8px 0; min-width: 0; }
++ .mx-table:not(.mx-vs) td.mx-c-can { min-width: 0; }
++ .mx-table:not(.mx-vs) td.mx-c-model::before { display: none; }
++ .mx-table:not(.mx-vs) td.mx-pick { position: absolute; top: 12px; right: 12px; width: auto; padding: 0; }
++ .mx-table:not(.mx-vs) td.mx-pick::before { display: none; }
++ .mx-bar { grid-template-columns: minmax(0, 1fr); }
++ .mx-calc-grid, .mx-calc-out { grid-template-columns: minmax(0, 1fr); }
++ .mx-fact { grid-template-columns: minmax(0, 1fr); gap: 2px; }
++ .mx-bar-row { grid-template-columns: minmax(0, 1fr) auto; }
++ .mx-bar-track { grid-column: 1 / -1; grid-row: 2; }
++ .mx-card { padding: 16px; }
++ /* The dynamic names as a row to swipe through; the compare bar at the end of the list (the site's own bar is at the
++ bottom of a phone's screen). */
++ .mx-alias-list { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: min(78%, 260px); overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 4px; scrollbar-width: none; }
++ .mx-alias-list::-webkit-scrollbar { display: none; }
++ .mx-alias-list > li { scroll-snap-align: start; }
++ .mx-compare-bar { position: static; }
++}
++@media (prefers-reduced-motion: reduce) { .mx-alias { transition: none; } .mx-alias:hover { transform: none; } }
++/* The guide (/docs/ai) */
++.mx-doc .mx-card p { line-height: 1.6; max-width: 72ch; }
++.mx-doc .mx-card h2 { font-size: 1.25rem; }
++.mx-doc .mx-card { scroll-margin-top: 72px; display: grid; gap: 12px; }
++.mx-jump { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 14px; }
++.mx-jump a { color: var(--mx-soft); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--mx-accent) 40%, transparent); }
++.mx-steps { margin: 0; padding-left: 20px; display: grid; gap: 8px; line-height: 1.55; }
++.mx-list { margin: 0; padding-left: 18px; display: grid; gap: 10px; line-height: 1.55; }
++/* A project's Balance */
++.cr-tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
++.cr-tile { display: grid; gap: 4px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--mx-line); background: var(--mx-card); }
++.cr-tile span { color: var(--mx-soft); font-size: 13px; font-weight: 650; }
++.cr-tile b { font-size: clamp(1.3rem, 3vw, 1.8rem); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
++.cr-tile small { color: var(--mx-muted); font-size: 12.5px; }
++.cr-sandbox { margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; color: var(--mx-soft); font-size: 14px; }
++.cr-sandbox b { color: var(--text-primary, #e8eef8); font-variant-numeric: tabular-nums; }
++.cr-total { color: var(--mx-soft); font-weight: 600; font-size: .85em; margin-left: 8px; }
++.cr-chart { display: grid; grid-template-columns: repeat(30, minmax(0, 1fr)); align-items: end; gap: 3px; height: 140px; padding: 8px 0; border-bottom: 1px solid var(--mx-line); }
++.cr-bar { display: flex; align-items: flex-end; height: 100%; }
++.cr-bar > span { display: block; width: 100%; border-radius: 4px 4px 0 0; background: color-mix(in srgb, var(--mx-accent) 75%, transparent); }
++.cr-topup { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 6px 0 0; }
++.cr-topup .mx-apply:disabled { opacity: .5; cursor: not-allowed; }
++@media (max-width: 720px) { .cr-tiles { grid-template-columns: minmax(0, 1fr); } .cr-chart { gap: 2px; height: 110px; } }
++/* Start with OpenVibe AI (/projects/:id/ai) */
++.ob-bar { height: 8px; max-width: 420px; border-radius: 99px; background: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); overflow: hidden; }
++.ob-bar > span { display: block; height: 100%; border-radius: 99px; background: var(--mx-accent); }
++.ob-steps { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
++.ob-step { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; align-items: start; }
++.ob-step.is-next { border-color: var(--mx-accent); box-shadow: 0 0 0 1px var(--mx-accent); }
++.ob-n { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); color: var(--mx-soft); font-weight: 750; }
++.ob-step.is-done .ob-n { background: color-mix(in srgb, var(--mx-ok) 18%, transparent); color: var(--mx-ok); }
++.ob-step.is-next .ob-n { background: var(--mx-accent); color: var(--on-accent, #fff); }
++.ob-body h2 { margin: 0 0 4px; font-size: 1.05rem; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++.ob-body p { margin: 0; color: var(--mx-soft); line-height: 1.55; }
++.ob-action { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
++.ob-action form { margin: 0; }
++.ob-action .mx-copy { display: inline-flex; align-items: center; text-decoration: none; min-height: 40px; }
++@media (max-width: 640px) { .ob-step { grid-template-columns: 32px minmax(0, 1fr); gap: 10px; } .ob-n { width: 32px; height: 32px; } }
++a.mx-apply { display: inline-flex; align-items: center; text-decoration: none; }
ddiff --git a/public/js/models.js b/public/js/models.js
nnew file mode 100644
iindex 0000000..9fa7ead
---- /dev/null
++++ b/public/js/models.js
@@@ -0,0 +1,76 @@
++// /models (AI Exchange P8): the filters apply as they change, the compare bar counts what is picked, the price
++// calculator works as you type (the same nano-dollar math as the server: each token class rounded up once), and
++// the code samples can be copied. Every part works without this file.
++(function () {
++ 'use strict';
++ var NANO = 1000000000n;
++
++ // Filters: a change applies at once; typing in the search waits for a pause.
++ var filters = document.querySelector('[data-mx-filters]');
++ if (filters) {
++ var timer = 0;
++ filters.addEventListener('change', function (e) { if (e.target.name !== 'q') filters.requestSubmit ? filters.requestSubmit() : filters.submit(); });
++ var q = filters.querySelector('input[name="q"]');
++ if (q) q.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { filters.requestSubmit ? filters.requestSubmit() : filters.submit(); }, 450); });
++ }
++
++ // Compare: two to four.
++ var picks = Array.prototype.slice.call(document.querySelectorAll('[data-mx-pick]'));
++ var said = document.querySelector('[data-mx-picked]');
++ function count() {
++ var chosen = picks.filter(function (p) { return p.checked; });
++ picks.forEach(function (p) { p.disabled = !p.checked && chosen.length >= 4; });
++ if (!said) return;
++ var bar = said.closest('.mx-compare-bar');
++ said.textContent = chosen.length === 0 ? 'Pick two to four models to compare them.' : chosen.length === 1 ? '1 picked: pick one more.' : chosen.length + ' picked.';
++ if (bar) bar.classList.toggle('is-ready', chosen.length >= 2);
++ }
++ picks.forEach(function (p) { p.addEventListener('change', count); });
++ count();
++
++ // The calculator.
++ function usd(n) {
++ var whole = n / NANO;
++ var frac = (n % NANO).toString().padStart(9, '0');
++ var digits;
++ if (whole >= 1n) digits = 2;
++ else { var lead = frac.search(/[1-9]/); digits = lead < 0 ? 2 : Math.min(9, Math.max(2, lead + 3)); }
++ var shown = frac.slice(0, digits).replace(/0+$/, '');
++ if (whole >= 1n || shown.length < 2) shown = frac.slice(0, 2);
++ return '$' + whole.toLocaleString('en-US') + '.' + shown;
++ }
++ var calc = document.querySelector('[data-mx-calc]');
++ if (calc && typeof BigInt === 'function') {
++ var rates = {};
++ try { rates = JSON.parse(calc.getAttribute('data-rates') || '{}'); } catch (e) { rates = {}; }
++ var big = function (v) { return /^[0-9]{1,19}$/.test(String(v)) ? BigInt(String(v)) : 0n; };
++ var work = function () {
++ var total = big(rates.per_request);
++ ['input', 'cached_read', 'cache_write', 'output'].forEach(function (c) {
++ var field = calc.querySelector('input[name="' + c + '"]');
++ var t = field ? BigInt(Math.max(0, Math.min(10000000, Math.floor(Number(field.value) || 0)))) : 0n;
++ if (t > 0n) total += (t * big(rates[c]) + 999999n) / 1000000n;
++ });
++ calc.querySelector('[data-mx-one]').textContent = usd(total);
++ calc.querySelector('[data-mx-k]').textContent = usd(total * 1000n);
++ calc.querySelector('[data-mx-m]').textContent = usd(total * 1000000n);
++ };
++ calc.addEventListener('input', work);
++ var go = calc.querySelector('[data-mx-calc-submit]');
++ if (go) go.hidden = true;
++ }
++
++ // Copy.
++ document.querySelectorAll('[data-mx-copy]').forEach(function (btn) {
++ var target = document.querySelector(btn.getAttribute('data-mx-copy'));
++ if (!target || !navigator.clipboard) return;
++ var label = btn.textContent;
++ btn.hidden = false;
++ btn.addEventListener('click', function () {
++ navigator.clipboard.writeText(target.textContent || '').then(function () {
++ btn.textContent = 'Copied';
++ setTimeout(function () { btn.textContent = label; }, 1600);
++ }, function () {});
++ });
++ });
++})();
ddiff --git a/server/ai-config.js b/server/ai-config.js
nnew file mode 100644
iindex 0000000..e38fbf3
---- /dev/null
++++ b/server/ai-config.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * The `services.ai` configuration namespace (D11, owner settings): the three AI Exchange switches the owner
++ * changes from openvibe.network's AI tab. The env variables stay the pins (`x-env`): while one is set it wins,
++ * is read-only in the summary and a write answers 409 config.pinned_in_env. Nothing changes until the owner
++ * sets a value in the store.
++ *
++ * `/internal/config` is openvibe-shared/config's adminRoutes, guarded by services.config.manage (Network's
++ * service token, the only holder). actor is the owner Network names in X-OV-Subject; a secret or an x-danger
++ * write needs X-OV-Fresh: 1. Every activation writes one audit line with the actor and the keys, never a value.
++ */
++const express = require('express');
++const shared = require('openvibe-shared/config');
++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
++
++const SERVICE = 'services';
++const NAMESPACE = 'services.ai';
++const CAPABILITY = 'services.config.manage';
++const GROUPS = 'AI Exchange';
++
++const SCHEMA = {
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'OpenVibe.AI in the developer portal',
++ description: 'The AI Exchange surfaces OpenVibe.Services offers: the model explorer and docs, the AI playground, and where the model catalog is read from.',
++ type: 'object',
++ additionalProperties: false,
++ required: ['model_explorer', 'ai_playground', 'model_catalog_source'],
++ properties: {
++ model_explorer: {
++ type: 'boolean', title: 'Model explorer and AI docs', 'x-group': GROUPS, 'x-env': 'SERVICES_MODEL_EXPLORER',
++ description: 'Serve /models, /models/:id, /models/compare and /docs/ai. Off until the owner approves the public catalog and its prices.',
++ },
++ ai_playground: {
++ type: 'boolean', title: 'AI playground', 'x-group': GROUPS, 'x-env': 'SERVICES_AI_PLAYGROUND',
++ description: 'Offer the AI playground in a sandbox app: one non-streamed call as the app, never stored.',
++ },
++ model_catalog_source: {
++ type: 'string', enum: ['preview', 'ai'], default: 'preview', title: 'Model catalog source', 'x-group': GROUPS, 'x-env': 'SERVICES_MODEL_CATALOG_SOURCE',
++ description: 'preview reads the checked-in example catalog; ai reads OpenVibe.AI\'s live catalog and says when it cannot.',
++ },
++ },
++};
++const DEFAULTS = Object.freeze({ model_explorer: false, ai_playground: false, model_catalog_source: 'preview' });
++
++function bearer(req) {
++ const h = String((req.headers && req.headers.authorization) || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++}
++
++/** serviceAuth verify with the service's own key store; the capability is contracts' decision. */
++function capabilityGuard({ keys, config, log }) {
++ return async function guard(req, res, next) {
++ try {
++ const token = bearer(req);
++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a Network service token is required', ctx: req.ov });
++ const publicKey = typeof keys.pemForToken === 'function' ? await keys.pemForToken(token) : keys.get();
++ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded yet', ctx: req.ov });
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
++ if (!r.ok) return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason || 'the token does not verify', ctx: req.ov });
++ const sub = String((r.claims && r.claims.sub) || '');
++ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Services configuration`, ctx: req.ov });
++ const decision = capabilities.check(r.claims, CAPABILITY);
++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${CAPABILITY} not granted`, ctx: req.ov });
++ req.configClaims = r.claims;
++ return next();
++ } catch (err) {
++ log.error(`[config] services.ai guard failed: ${(err && err.message) || err}`);
++ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });
++ }
++ };
++}
++
++function actor(req) {
++ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();
++ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;
++}
++
++function requireFresh(req) {
++ return String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
++}
++
++async function create({ db, keys, config, env = process.env, log = console }) {
++ const store = await shared.createConfigStore({
++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env, log,
++ onChange: ({ namespace, keys: changed, actor: who, revision }) => {
++ const by = who && who.id ? `${who.type}:${who.id}` : 'unknown';
++ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${by}: ${changed.join(', ')}`);
++ },
++ });
++ if (env.OV_CONFIG_KEKS) {
++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
++ }
++ const router = shared.adminRoutes([store], {
++ basePath: '/internal/config', requireAdmin: capabilityGuard({ keys, config, log }), actor, requireFresh, log,
++ });
++ return {
++ store, router,
++ get: (key) => store.get(key),
++ mount(app) {
++ // The admin routes read the body themselves (adminRoutes' readBody), so no body parser is mounted here.
++ app.use('/internal/config', express.json({ limit: '128kb' }));
++ router.mount(app);
++ },
++ };
++}
++
++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
ddiff --git a/server/app.js b/server/app.js
iindex 2fd1b1c..fc5a69f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -57,6 +57,7 @@ const { createApi } = require('./http/api');
const openapi = require('./http/openapi');
const { createServicesReadiness, registerServicesGauges } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
++const createAiConfig = require('./ai-config').create;
const { createIndexNow } = require('openvibe-shared/indexnow');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
@@@ -112,7 +113,12 @@ async function createApp(opts = {}) {
const confirmations = opts.confirmations || createConfirmations(config, { tokens, fetchImpl: fetchImpl || globalThis.fetch });
const control = createControl({ store, index, confirmations, log });
-- const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, indexnow, authorities, tokens, index, apiAuth, control, log };
++ // D11 owner settings (services.ai): the AI Exchange switches read through the store, env pins winning.
++ const aiConfig = await createAiConfig({ db: store.db, keys, config, env: opts.env || process.env, log });
++ Object.defineProperty(config, 'modelExplorer', { enumerable: true, configurable: true, get: () => aiConfig.get('model_explorer') });
++ Object.defineProperty(config, 'modelCatalogSource', { enumerable: true, configurable: true, get: () => aiConfig.get('model_catalog_source') });
++ if (config.playground) Object.defineProperty(config.playground, 'ai', { enumerable: true, configurable: true, get: () => aiConfig.get('ai_playground') });
++ const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, archiver, limits, indexnow, authorities, tokens, index, apiAuth, control, log, aiConfig };
const app = express();
app.disable('x-powered-by');
@@@ -159,6 +165,9 @@ async function createApp(opts = {}) {
}));
app.use(cookieParser());
++ // ── D11 owner settings: /internal/config (loopback only; services.config.manage, Network's token) ──
++ aiConfig.mount(app);
++
// ── OpenVibe.Events → Services (loopback only: nginx answers 404 for /internal/) ──
// network.account.export_requested and network.account.deleted (ADR-033), answered by openvibe-sdk/account-data's
// consumer over domain/account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a
@@@ -260,6 +269,10 @@ async function createApp(opts = {}) {
app.use('/projects', rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false, skip: (req) => req.method === 'GET' }), createPortalRoutes(ctx));
app.use('/releases', createReleaseActionRoutes(ctx));
app.use(createToolRoutes(ctx));
++ // The model explorer (AI Exchange P8, /models): behind SERVICES_MODEL_EXPLORER, off until the catalog is live.
++ app.use(require('./http/models').createModelRoutes(ctx));
++ app.use(require('./http/ai-docs').createAiDocsRoutes(ctx));
++ app.use(require('./http/money-docs').createMoneyDocsRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try the <a href="/docs">docs</a> or <a href="/projects">your projects</a>.</p>` }));
ddiff --git a/server/config.js b/server/config.js
iindex 538302c..fb8b092 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -104,6 +104,8 @@ function loadConfig(env = process.env) {
playground: {
eventsUrl: trim(env.SERVICES_PLAYGROUND_EVENTS_URL || originOf('events', 'https://openvibe.events')),
mediaUrl: trim(env.SERVICES_PLAYGROUND_MEDIA_URL || originOf('media', 'https://openvibe.media')),
++ aiUrl: trim(env.SERVICES_PLAYGROUND_AI_URL || originOf('ai', 'https://ai.openvibe.services')),
++ ai: env.SERVICES_AI_PLAYGROUND === '1',
maxUploadBytes: int(env.SERVICES_PLAYGROUND_MAX_UPLOAD_BYTES, 1024 * 1024),
runsPerHour: int(env.SERVICES_PLAYGROUND_RUNS_PER_HOUR, 60),
},
ddiff --git a/server/docs/generate.js b/server/docs/generate.js
iindex 44c00b7..fd4b39a 100644
---- a/server/docs/generate.js
++++ b/server/docs/generate.js
@@@ -128,15 +128,25 @@ function sdkModules() {
function generate({ now = () => Date.now() } = {}) {
const deprecations = new Map((contractsRegistry.deprecations || []).map((d) => [d.id, d]));
const byDollarId = new Map();
++ // A contract may have several majors in the catalog (ai.credential-put@1 and @2): each gets its own page. The
++ // latest major is /docs/contracts/<id>; an older one is /docs/contracts/<id>@<major>. The fixtures that ship
++ // with the package are the latest major's, so only its page shows them.
++ const majorOf = (entry) => Number(String(entry.version).split('.')[0]);
++ const latestMajor = new Map();
++ for (const entry of contracts.catalog) latestMajor.set(entry.id, Math.max(latestMajor.get(entry.id) || 0, majorOf(entry)));
const contractList = contracts.catalog.map((entry) => {
-- const schema = contracts.schema(entry.id);
-- byDollarId.set(schema.$id, entry.id);
-- const fixtures = {
++ const major = majorOf(entry);
++ const latest = major === latestMajor.get(entry.id);
++ const schema = contracts.schema(`${entry.id}@${major}`);
++ const ref = latest ? entry.id : `${entry.id}@${major}`;
++ byDollarId.set(schema.$id, ref);
++ const fixtures = latest ? {
valid: readJsonDir(path.join(CONTRACTS_ROOT, 'fixtures', entry.id, 'valid')),
invalid: readJsonDir(path.join(CONTRACTS_ROOT, 'fixtures', entry.id, 'invalid')),
-- };
++ } : { valid: [], invalid: [] };
return {
-- ...entry, $id: schema.$id, title: schema.title || entry.id, description: schema.description || '', schema,
++ ...entry, major, latest, ref, href: `/docs/contracts/${ref}`, latestMajor: latestMajor.get(entry.id),
++ $id: schema.$id, title: schema.title || entry.id, description: schema.description || '', schema,
fields: fieldRows(schema, schema.$id), fixtures, deprecation: deprecations.get(entry.id) || null,
};
});
@@@ -175,7 +185,8 @@ function generate({ now = () => Date.now() } = {}) {
sdkLicense: sdkPkg.license,
sdkContractsRange: (() => { try { return require('openvibe-sdk/core').CONTRACTS_RANGE; } catch { return null; } })(),
contracts: contractList,
-- contract: (id) => contractList.find((c) => c.id === id) || null,
++ // The latest major of an id, or the named major (`contract('ai.credential-put', 1)`).
++ contract: (id, major) => contractList.find((c) => c.id === id && (major == null ? c.latest : c.major === Number(major))) || null,
capabilities,
capability: (id) => capabilities.find((c) => c.id === id) || null,
services: contracts.services.manifests,
ddiff --git a/server/domain/balance.js b/server/domain/balance.js
nnew file mode 100644
iindex 0000000..88e77a5
---- /dev/null
++++ b/server/domain/balance.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * A project's Balance (D12, the D9 page renamed) as Services shows it: OpenVibe.Billing is the authority (its
++ * project statement, the person's own token); Services only reads and draws. Nothing is stored here.
++ *
++ * createBalanceReader({ fetchImpl })
++ * statement(token, projectId) → { project_id, balance_nano_usd, held_nano_usd, available_nano_usd, charges[] }
++ * daily(charges, days, now) → [{ day, nano }] (the last `days` days, oldest first; BigInt nano-USD)
++ *
++ * Billing's project statement wire path is /api/me/credits?project_id; D12 keeps it as the alias of the project
++ * Balance read, so Services never needs to hold or recompute a balance of its own.
++ */
++
++const BILLING = () => String(process.env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600').replace(/\/+$/, '');
++
++function createBalanceReader({ fetchImpl = globalThis.fetch } = {}) {
++ async function statement(token, projectId) {
++ const res = await fetchImpl(`${BILLING()}/api/me/credits?project_id=${encodeURIComponent(projectId)}`, {
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(5000),
++ });
++ const doc = await res.json().catch(() => null);
++ if (!res.ok || !doc) {
++ const err = new Error((doc && doc.detail) || `OpenVibe Billing answered ${res.status}`);
++ err.status = res.status; err.code = (doc && doc.code) || 'services.billing_unavailable';
++ throw err;
++ }
++ return doc;
++ }
++ return { statement };
++}
++
++/** Spend per UTC day for the last `days` days, oldest first. */
++function daily(charges, days = 30, now = Date.now()) {
++ const out = [];
++ const start = new Date(now);
++ start.setUTCHours(0, 0, 0, 0);
++ for (let i = days - 1; i >= 0; i--) out.push({ day: new Date(start.getTime() - i * 86400000).toISOString().slice(0, 10), nano: 0n });
++ const by = new Map(out.map((d) => [d.day, d]));
++ for (const c of charges || []) {
++ const d = by.get(String(c.at || '').slice(0, 10));
++ if (d && /^[0-9]{1,19}$/.test(String(c.charge_nano_usd))) d.nano += BigInt(c.charge_nano_usd);
++ }
++ return out;
++}
++
++module.exports = { createBalanceReader, daily };
ddiff --git a/server/domain/model-catalog.js b/server/domain/model-catalog.js
nnew file mode 100644
iindex 0000000..55db665
---- /dev/null
++++ b/server/domain/model-catalog.js
@@@ -0,0 +1,201 @@
++'use strict';
++
++/**
++ * The model catalog the developer portal shows (AI Exchange P8): every model OpenVibe AI can run, its exact identity
++ * (org/model[:quant]@revision), its variant (original, abliterated, fine-tuned, merged, LoRA), what it can do, its retail
++ * price, and who supplies it. OpenVibe.AI is the authority (GET /v1/models, ai.model@1); Services only shows it.
++ *
++ * createModelCatalog({ config, fetchImpl, now })
++ * list() → { source: 'ai' | 'preview', at, models: [ai.model@1 …], aliases: […], problem }
++ * get(id) → one model or alias, or null
++ * cost(model, usage) → BigInt nano-USD for one request (AI Exchange D1: once per component, ceil)
++ * usd(nano, { per }) → "$0.000123" — exact to the nano-dollar, never a float
++ * WORKLOADS, filterModels(models, query), sortModels(models, by, workload)
++ *
++ * Source: AI's catalog when SERVICES_MODEL_CATALOG_SOURCE=ai (cached a minute, a failure is said and the last
++ * reading kept); otherwise the preview fixture (server/fixtures/ai-models.preview.json), whose prices are examples and
++ * every page says so. The pages are behind SERVICES_MODEL_EXPLORER (off) until AI's catalog and Billing's
++ * inference prices are live: no public price promise before the owner approves one.
++ */
++
++const fs = require('fs');
++const path = require('path');
++
++const PREVIEW = path.join(__dirname, '..', 'fixtures', 'ai-models.preview.json');
++const NANO = 1_000_000_000n;
++const COMPONENTS = ['input', 'cached_read', 'cache_write', 'output'];
++const VARIANTS = {
++ original: 'Original', abliterated: 'Abliterated', finetuned: 'Fine-tuned', merged: 'Merged', lora: 'LoRA',
++};
++const ACCESS = {
++ OWN_DEPLOYMENT: ['OpenVibe-run', 'ok'],
++ AUTHORIZED_WHOLESALE: ['Licensed partner', 'ok'],
++ CUSTOMER_BYOK: ['Your own key', 'new'],
++ RESTRICTED: ['Restricted', 'bad'],
++ UNVERIFIED: ['Pending agreement', 'off'],
++};
++const AVAILABILITY = {
++ available: ['Available', 'ok'], planned: ['Planned', 'off'], pending_agreement: ['Pending agreement', 'off'],
++ byok_only: ['With your own key', 'new'], degraded: ['Degraded', 'warn'],
++ limited: ['Limited', 'warn'], unavailable: ['Not available yet', 'off'], deprecated: ['Deprecated', 'off'],
++};
++
++/** Workloads a price is shown for: tokens of each class in one request. */
++const WORKLOADS = {
++ chat: { label: 'Chat reply', desc: '1,200 tokens in, 400 out', usage: { input: 1200, cached_read: 0, cache_write: 0, output: 400 } },
++ coding: { label: 'Coding step', desc: '6,000 in, 20,000 cached, 1,200 out', usage: { input: 6000, cached_read: 20000, cache_write: 0, output: 1200 } },
++ agent: { label: 'Agent turn', desc: '3,000 in, 15,000 cached, 2,000 cache write, 800 out', usage: { input: 3000, cached_read: 15000, cache_write: 2000, output: 800 } },
++ document: { label: 'Long document', desc: '30,000 in, 600 out', usage: { input: 30000, cached_read: 0, cache_write: 0, output: 600 } },
++};
++
++const big = (v) => {
++ if (typeof v === 'bigint') return v;
++ const s = String(v == null ? '0' : v).trim();
++ return /^[0-9]{1,19}$/.test(s) ? BigInt(s) : 0n;
++};
++const ceilDiv = (a, b) => (a + b - 1n) / b;
++
++/** One request's retail charge in nano-USD: each token class rounded up once, then the per-request fee. */
++function cost(model, usage = {}) {
++ const r = (model && model.retail) || {};
++ const per = r.nano_usd_per_m || {};
++ let total = big(r.per_request_nano_usd);
++ for (const c of COMPONENTS) {
++ const tokens = BigInt(Math.max(0, Math.floor(Number(usage[c]) || 0)));
++ if (tokens === 0n) continue;
++ total += ceilDiv(tokens * big(per[c]), 1_000_000n);
++ }
++ return total;
++}
++
++/**
++ * Nano-USD as dollars, exactly: "$12.50", "$0.0042", "$0.000123". Whole cents above a dollar; below, enough decimals
++ * to show three significant digits, never more than nine (one nano-dollar).
++ */
++function usd(nano, { per = null } = {}) {
++ let n = big(nano);
++ if (per) n *= BigInt(per);
++ const whole = n / NANO;
++ const frac = (n % NANO).toString().padStart(9, '0');
++ let digits;
++ if (whole >= 1n) digits = 2;
++ else {
++ const lead = frac.search(/[1-9]/);
++ digits = lead < 0 ? 2 : Math.min(9, Math.max(2, lead + 3));
++ }
++ let shown = frac.slice(0, digits).replace(/0+$/, '');
++ if (whole >= 1n || shown.length < 2) shown = frac.slice(0, 2);
++ return `$${whole.toLocaleString('en-US')}.${shown}`;
++}
++
++/** A per-million rate as dollars per million tokens. */
++const perMillion = (nanoPerM) => usd(nanoPerM);
++
++/**
++ * One entry of the catalog in the shape the pages use, from ai.model@1 as OpenVibe.AI serves it (openvibe-contracts
++ * v0.284.0): capabilities and supports are objects of booleans, the price is retail_rate's flat nano-USD fields,
++ * availability is available | limited | unavailable | deprecated, and what the AI Exchange adds beyond the schema
++ * (who supplies it, lineage, hardware, quality) rides in metadata. The older field names are read too.
++ */
++function normalize(m) {
++ if (!m || typeof m !== 'object' || !m.id) return null;
++ const md = (m.metadata && typeof m.metadata === 'object') ? m.metadata : {};
++ const dynamic = m.dynamic === true || (/^openvibe(\/|$)/.test(m.id) && !m.id.includes('@'));
++ const capObj = (!Array.isArray(m.capabilities) && m.capabilities) || null;
++ const sup = m.supports || {};
++ const caps = Array.isArray(m.capabilities) ? m.capabilities
++ : Object.keys(CAPABILITY_KEYS).filter((k) => (capObj && capObj[k]) || sup[CAPABILITY_KEYS[k]]);
++ const rr = m.retail_rate || null;
++ const retail = m.retail || (rr ? {
++ version: rr.version || null,
++ nano_usd_per_m: { input: rr.input_nano_usd_per_m, cached_read: rr.cached_read_nano_usd_per_m, cache_write: rr.cache_write_nano_usd_per_m, output: rr.output_nano_usd_per_m },
++ per_request_nano_usd: rr.per_request_nano_usd || md.per_request_nano_usd || '0',
++ } : null);
++ const note = md.availability_note;
++ const availability = AVAILABILITY[note] ? note : AVAILABILITY[m.availability] ? m.availability : 'planned';
++ const input = Array.isArray(m.modalities) ? (md.input_modalities || m.modalities) : ((m.modalities || {}).input || ['text']);
++ return {
++ ...m,
++ dynamic,
++ name: m.name || m.display_name || m.id,
++ org: m.org || md.org || m.provider_key || String(m.id).split('/')[0],
++ variant: dynamic ? null : (VARIANTS[m.variant] ? m.variant : 'original'),
++ capabilities: caps,
++ retail,
++ context_tokens: m.context_tokens || m.context || m.context_window || null,
++ max_output_tokens: m.max_output_tokens || m.max_output || null,
++ modalities: { input, output: ['text'] },
++ base_model: m.base_model !== undefined ? m.base_model : (md.base_model || null),
++ modification: m.modification || md.modification || null,
++ hardware: m.hardware || md.hardware || null,
++ quality: m.quality || md.quality || { score: null, samples: 0 },
++ access: (Array.isArray(m.access) ? m.access : Array.isArray(md.access) ? md.access : []).filter((a) => ACCESS[a]),
++ availability,
++ sha256: m.sha256 || null,
++ };
++}
++const CAPABILITY_KEYS = { tools: 'tools', json: 'json', vision: 'vision', stream: 'streaming' };
++
++function filterModels(models, q = {}) {
++ const text = String(q.q || '').trim().toLowerCase();
++ const caps = [].concat(q.cap || []).filter(Boolean);
++ return models.filter((m) => {
++ if (text && !`${m.id} ${m.name} ${m.family || ''} ${m.org || ''}`.toLowerCase().includes(text)) return false;
++ if (q.variant && q.variant !== 'all' && m.variant !== q.variant) return false;
++ if (caps.some((c) => !m.capabilities.includes(c))) return false;
++ if (q.access === 'managed' && !m.access.some((a) => a === 'OWN_DEPLOYMENT' || a === 'AUTHORIZED_WHOLESALE')) return false;
++ if (q.access === 'byok' && !m.access.includes('CUSTOMER_BYOK')) return false;
++ if (q.context && Number(m.context_tokens || 0) < Number(q.context)) return false;
++ return true;
++ });
++}
++
++function sortModels(models, by = 'price', workload = 'chat') {
++ const usage = (WORKLOADS[workload] || WORKLOADS.chat).usage;
++ const list = [...models];
++ if (by === 'context') list.sort((a, b) => Number(b.context_tokens || 0) - Number(a.context_tokens || 0) || a.name.localeCompare(b.name));
++ else if (by === 'name') list.sort((a, b) => a.name.localeCompare(b.name));
++ else list.sort((a, b) => { const d = cost(a, usage) - cost(b, usage); return d < 0n ? -1 : d > 0n ? 1 : a.name.localeCompare(b.name); });
++ return list;
++}
++
++function createModelCatalog({ config = {}, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
++ const sourceOf = () => String(config.modelCatalogSource || 'preview');
++ const aiUrl = String(process.env.OV_AI_PUBLIC_URL || config.aiUrl || 'https://ai.openvibe.services').replace(/\/+$/, '');
++ let cache = { at: 0, value: null };
++
++ function preview() {
++ const doc = JSON.parse(fs.readFileSync(PREVIEW, 'utf8'));
++ return { source: 'preview', at: doc.as_of || null, models: (doc.models || []).map(normalize).filter(Boolean), aliases: (doc.aliases || []).map(normalize).filter(Boolean), problem: null };
++ }
++
++ async function fromAi() {
++ const res = await fetchImpl(`${aiUrl}/v1/models`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
++ if (!res.ok) throw new Error(`OpenVibe AI answered ${res.status}`);
++ const doc = await res.json();
++ const all = (doc.data || doc.models || []).map(normalize).filter(Boolean);
++ return { source: 'ai', at: new Date(now()).toISOString(), models: all.filter((m) => !m.dynamic), aliases: all.filter((m) => m.dynamic), problem: null };
++ }
++
++ async function list() {
++ if (sourceOf() !== 'ai') return preview();
++ if (cache.value && now() - cache.at < 60_000) return cache.value;
++ try {
++ cache = { at: now(), value: await fromAi() };
++ } catch (e) {
++ log.warn && log.warn(`[Services] the model catalog could not be read: ${String(e && e.message).slice(0, 160)}`);
++ const kept = cache.value || { source: 'ai', at: null, models: [], aliases: [] };
++ return { ...kept, problem: { code: 'services.ai_catalog_unavailable', detail: 'OpenVibe AI did not answer; the last reading is shown.' } };
++ }
++ return cache.value;
++ }
++
++ async function get(id) {
++ const all = await list();
++ return [...all.models, ...all.aliases].find((m) => m.id === id) || null;
++ }
++
++ return { list, get, get source() { return sourceOf(); } };
++}
++
++module.exports = { createModelCatalog, cost, usd, perMillion, filterModels, sortModels, normalize, WORKLOADS, VARIANTS, ACCESS, AVAILABILITY, COMPONENTS };
ddiff --git a/server/domain/playground.js b/server/domain/playground.js
iindex 6e90717..8cb5f5c 100644
---- a/server/domain/playground.js
++++ b/server/domain/playground.js
@@@ -8,6 +8,9 @@
* source app-<app ulid>)
* media upload one small file (sandbox) needs media.object.upload (audience openvibe.media,
* namespace = project id)
++ * ai one chat reply (AI Exchange P8) needs ai.app.run (audience openvibe.ai; POST /v1/chat/completions,
++ * not streamed; behind SERVICES_AI_PLAYGROUND). The prompt
++ * and the reply are shown once and never stored.
*
* The order is fixed and every step can refuse:
* 1. app the app (as Network reports it to the signed-in member) is active and is a SANDBOX app
@@@ -36,7 +39,21 @@ const KINDS = {
// `needs`: the capability the APP must hold (owned by Events / Media; Services guards nothing with it).
events: { needs: 'events.app.publish', audience: 'openvibe.events', label: 'Events: publish a test event' },
media: { needs: 'media.object.upload', audience: 'openvibe.media', label: 'Media: upload a file to the sandbox' },
++ // ai.app.run: the public capability developer apps already call AI with (ADR-014; AI Exchange D8').
++ ai: { needs: 'ai.app.run', audience: 'openvibe.ai', label: 'AI: one chat reply' },
};
++const MODEL_ID = /^[A-Za-z0-9._:/@-]{1,160}$/;
++
++/** The AI playground's input: a model (an exact id or a dynamic name), a prompt, a reply cap. → { body } | { error } */
++function aiInput(input) {
++ const model = String(input.model || 'openvibe').trim();
++ if (!MODEL_ID.test(model)) return { error: 'the model is an exact id (org/model[:quant]@revision) or a dynamic name (openvibe/auto …)' };
++ const prompt = String(input.prompt || '').trim();
++ if (!prompt) return { error: 'write a prompt' };
++ if (prompt.length > 4000) return { error: 'the prompt is at most 4,000 characters here' };
++ const max = Math.max(1, Math.min(1024, Math.floor(Number(input.max_tokens) || 256)));
++ return { body: { model, messages: [{ role: 'user', content: prompt }], max_tokens: max, stream: false } };
++}
function scrub(text, secrets) {
let s = String(text || '').slice(0, 500);
@@@ -84,6 +101,7 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
function precheck(app, kind) {
const k = KINDS[kind];
if (!k) return { ok: false, stage: 'input', code: 'playground.unknown', detail: 'no such playground' };
++ if (kind === 'ai' && !config.playground.ai) return { ok: false, stage: 'input', code: 'playground.unknown', detail: 'the AI playground is not open yet' };
if (app.revoked_at) return { ok: false, stage: 'app', code: 'playground.app_revoked', detail: 'this app is revoked in Network' };
if (app.environment !== 'sandbox') return { ok: false, stage: 'app', code: 'playground.production_app', detail: 'playgrounds run only sandbox apps; create a sandbox app in this project to try things out' };
const grants = Array.isArray(app.grants) ? app.grants : [];
@@@ -173,7 +191,12 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
if (!pre.ok) return { ...await finish({ outcome: 'refused', stage: pre.stage, code: pre.code, detail: pre.detail }), missing: pre.missing, grantable: pre.grantable };
let envelope = null;
-- if (kind === 'events') {
++ let aiBody = null;
++ if (kind === 'ai') {
++ const a = aiInput(input);
++ if (a.error) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: a.error });
++ aiBody = a.body;
++ } else if (kind === 'events') {
const e = eventInput({ ...input, appId: app.id, projectId: app.project_id });
if (e.error) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });
envelope = e.envelope;
@@@ -189,6 +212,30 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
const tok = await appToken(app, k, credential);
if (!tok.ok) return await finish({ outcome: 'refused', stage: 'token', code: tok.code, detail: tok.detail, http_status: tok.http_status });
++ if (kind === 'ai') {
++ // The prompt and the reply are returned to this page once; the run log keeps only the outcome and ids.
++ secrets.push(aiBody.messages[0].content);
++ let res; let doc = null;
++ try {
++ res = await (fetchImpl || globalThis.fetch)(`${config.playground.aiUrl}/v1/chat/completions`, {
++ method: 'POST', headers: { Authorization: `Bearer ${tok.token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify(aiBody), signal: AbortSignal.timeout(60_000),
++ });
++ doc = await res.json().catch(() => null);
++ } catch (err) {
++ return await finish({ outcome: 'failed', stage: 'call', code: 'playground.ai_unreachable', detail: 'OpenVibe AI did not answer' });
++ }
++ if (!res.ok || !doc) {
++ const code = (doc && (doc.code || (doc.error && (doc.error.code || doc.error.type)))) || 'playground.ai_error';
++ const detail = (doc && (doc.detail || (doc.error && doc.error.message))) || `OpenVibe AI answered ${res.status}`;
++ return await finish({ outcome: 'failed', stage: 'call', http_status: res.status, code: String(code).slice(0, 80), detail: `OpenVibe AI answered ${res.status}: ${String(detail).slice(0, 300)}` });
++ }
++ const choice = Array.isArray(doc.choices) && doc.choices[0] ? doc.choices[0] : {};
++ const reply = String((choice.message && choice.message.content) || '').slice(0, 8000);
++ const out = await finish({ outcome: 'ok', stage: 'done', http_status: res.status, detail: `answered by ${String(doc.model || aiBody.model).slice(0, 160)}`, ref: doc.id ? String(doc.id).slice(0, 120) : null });
++ return { ...out, result: { model: doc.model || aiBody.model, reply, finish_reason: choice.finish_reason || null, usage: doc.usage || null, openvibe: doc.x_openvibe || null } };
++ }
++
const client = sdkClient(tok.token);
try {
if (kind === 'events') {
@@@ -214,4 +261,4 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
return { run, precheck, runsFor, runsForProject, deleteRunsForProject, grantAdvice, KINDS, projectKey, appSource };
}
--module.exports = { createPlayground, KINDS, grantAdvice, scrub, projectKey, appSource };
++module.exports = { createPlayground, KINDS, grantAdvice, scrub, projectKey, appSource, aiInput };
ddiff --git a/server/fixtures/ai-models.preview.json b/server/fixtures/ai-models.preview.json
nnew file mode 100644
iindex 0000000..7787e91
---- /dev/null
++++ b/server/fixtures/ai-models.preview.json
@@@ -0,0 +1,515 @@
++{
++ "note": "PREVIEW FIXTURE for the developer portal (AI Exchange P8). Every model validates as ai.model@1 (openvibe-contracts v0.284.0; test/models.test.js checks it). Prices are EXAMPLES, not offers; revisions are 'preview' and sha256 is null until OpenVibe.AI publishes verified artifacts. Never shown publicly: the pages are behind SERVICES_MODEL_EXPLORER and say 'example prices'.",
++ "as_of": "2026-11-03",
++ "aliases": [
++ {
++ "id": "openvibe/auto",
++ "object": "model",
++ "dynamic": true,
++ "name": "Auto",
++ "policy": "balanced",
++ "description": "Picks among the models you allow, per request, and tells you which one answered and why.",
++ "capabilities": [
++ "tools",
++ "json",
++ "stream"
++ ]
++ },
++ {
++ "id": "openvibe/economy",
++ "object": "model",
++ "dynamic": true,
++ "name": "Economy",
++ "policy": "cheapest",
++ "description": "The lowest cost per successful answer that still meets the quality floor.",
++ "capabilities": [
++ "json",
++ "stream"
++ ]
++ },
++ {
++ "id": "openvibe/balanced",
++ "object": "model",
++ "dynamic": true,
++ "name": "Balanced",
++ "policy": "balanced",
++ "description": "Cost, success rate and speed weighed together.",
++ "capabilities": [
++ "tools",
++ "json",
++ "stream"
++ ]
++ },
++ {
++ "id": "openvibe/fast",
++ "object": "model",
++ "dynamic": true,
++ "name": "Fast",
++ "policy": "fastest",
++ "description": "The quickest first token under your cost cap.",
++ "capabilities": [
++ "json",
++ "stream"
++ ]
++ },
++ {
++ "id": "openvibe/private",
++ "object": "model",
++ "dynamic": true,
++ "name": "Private",
++ "policy": "private",
++ "description": "Never leaves OpenVibe-run machines or your own.",
++ "capabilities": [
++ "json",
++ "stream"
++ ]
++ }
++ ],
++ "models": [
++ {
++ "id": "qwen/qwen3-4b-abliterated:q4_k_m@preview",
++ "provider_key": "qwen",
++ "model_key": "qwen3-4b-abliterated:q4_k_m",
++ "display_name": "Qwen3 4B Abliterated",
++ "type": "chat",
++ "status": "active",
++ "cost": {
++ "in_per_mtok": null,
++ "out_per_mtok": null,
++ "cached_per_mtok": null
++ },
++ "supports": {
++ "json": true,
++ "tools": true,
++ "streaming": true,
++ "vision": false
++ },
++ "family": "qwen3",
++ "variant": "abliterated",
++ "quant": "q4_k_m",
++ "revision": "preview",
++ "license": "apache-2.0",
++ "context": 32768,
++ "max_output": 8192,
++ "capabilities": {
++ "tools": true,
++ "json": true,
++ "vision": false,
++ "stream": true
++ },
++ "modalities": [
++ "text"
++ ],
++ "retail_rate": {
++ "version": "example-1",
++ "input_nano_usd_per_m": "20000000",
++ "cached_read_nano_usd_per_m": "5000000",
++ "cache_write_nano_usd_per_m": "20000000",
++ "output_nano_usd_per_m": "60000000"
++ },
++ "availability": "unavailable",
++ "metadata": {
++ "org": "qwen",
++ "base_model": "qwen/qwen3-4b",
++ "modification": {
++ "method": "abliteration",
++ "note": "Refusal direction removed from the weights. Not a guarantee of quality or of no refusals."
++ },
++ "access": [
++ "OWN_DEPLOYMENT"
++ ],
++ "availability_note": "planned",
++ "hardware": "One 8-12 GB GPU",
++ "quality": {
++ "bench_url": null,
++ "score": null,
++ "samples": 0
++ },
++ "input_modalities": [
++ "text"
++ ],
++ "per_request_nano_usd": "0"
++ }
++ },
++ {
++ "id": "qwen/qwen3-4b:q4_k_m@preview",
++ "provider_key": "qwen",
++ "model_key": "qwen3-4b:q4_k_m",
++ "display_name": "Qwen3 4B",
++ "type": "chat",
++ "status": "active",
++ "cost": {
++ "in_per_mtok": null,
++ "out_per_mtok": null,
++ "cached_per_mtok": null
++ },
++ "supports": {
++ "json": true,
++ "tools": true,
++ "streaming": true,
++ "vision": false
++ },
++ "family": "qwen3",
++ "variant": "original",
++ "quant": "q4_k_m",
++ "revision": "preview",
++ "license": "apache-2.0",
++ "context": 32768,
++ "max_output": 8192,
++ "capabilities": {
++ "tools": true,
++ "json": true,
++ "vision": false,
++ "stream": true
++ },
++ "modalities": [
++ "text"
++ ],
++ "retail_rate": {
++ "version": "example-1",
++ "input_nano_usd_per_m": "20000000",
++ "cached_read_nano_usd_per_m": "5000000",
++ "cache_write_nano_usd_per_m": "20000000",
++ "output_nano_usd_per_m": "60000000"
++ },
++ "availability": "unavailable",
++ "metadata": {
++ "org": "qwen",
++ "access": [
++ "OWN_DEPLOYMENT"
++ ],
++ "availability_note": "planned",
++ "hardware": "One 8-12 GB GPU",
++ "quality": {
++ "bench_url": null,
++ "score": null,
++ "samples": 0
++ },
++ "input_modalities": [
++ "text"
++ ],
++ "per_request_nano_usd": "0"
++ }
++ },
++ {
++ "id": "qwen/qwen3-8b-abliterated:q4_k_m@preview",
++ "provider_key": "qwen",
++ "model_key": "qwen3-8b-abliterated:q4_k_m",
++ "display_name": "Qwen3 8B Abliterated",
++ "type": "chat",
++ "status": "active",
++ "cost": {
++ "in_per_mtok": null,
++ "out_per_mtok": null,
++ "cached_per_mtok": null
++ },
++ "supports": {
++ "json": true,
++ "tools": true,
++ "streaming": true,
++ "vision": false
++ },
++ "family": "qwen3",
++ "variant": "abliterated",
++ "quant": "q4_k_m",
++ "revision": "preview",
++ "license": "apache-2.0",
++ "context": 32768,
++ "max_output": 8192,
++ "capabilities": {
++ "tools": true,
++ "json": true,
++ "vision": false,
++ "stream": true
++ },
++ "modalities": [
++ "text"
++ ],
++ "retail_rate": {
++ "version": "example-1",
++ "input_nano_usd_per_m": "40000000",
++ "cached_read_nano_usd_per_m": "10000000",
++ "cache_write_nano_usd_per_m": "40000000",
++ "output_nano_usd_per_m": "120000000"
++ },
++ "availability": "unavailable",
++ "metadata": {
++ "org": "qwen",
++ "base_model": "qwen/qwen3-8b",
++ "modification": {
++ "method": "abliteration",
++ "note": "Refusal direction removed from the weights. Benchmarked separately from the original."
++ },
++ "access": [
++ "OWN_DEPLOYMENT"
++ ],
++ "availability_note": "planned",
++ "hardware": "One 12-16 GB GPU",
++ "quality": {
++ "bench_url": null,
++ "score": null,
++ "samples": 0
++ },
++ "input_modalities": [
++ "text"
++ ],
++ "per_request_nano_usd": "0"
++ }
++ },
++ {
++ "id": "google/gemma-3-27b-abliterated:q4_k_m@preview",
++ "provider_key": "google",
++ "model_key": "gemma-3-27b-abliterated:q4_k_m",
++ "display_name": "Gemma 3 27B Abliterated",
++ "type": "vision",
++ "status": "active",
++ "cost": {
++ "in_per_mtok": null,
++ "out_per_mtok": null,
++ "cached_per_mtok": null
++ },
++ "supports": {
++ "json": true,
++ "tools": false,
++ "streaming": true,
++ "vision": true
++ },
++ "family": "gemma3",
++ "variant": "abliterated",
++ "quant": "q4_k_m",
++ "revision": "preview",
++ "license": "gemma",
++ "context": 32768,
++ "max_output": 8192,
++ "capabilities": {
++ "tools": false,
++ "json": true,
++ "vision": true,
++ "stream": true
++ },
++ "modalities": [
++ "image",
++ "text"
++ ],
++ "retail_rate": {
++ "version": "example-1",
++ "input_nano_usd_per_m": "90000000",
++ "cached_read_nano_usd_per_m": "25000000",
++ "cache_write_nano_usd_per_m": "90000000",
++ "output_nano_usd_per_m": "250000000"
++ },
++ "availability": "unavailable",
++ "metadata": {
++ "org": "google",
++ "base_model": "google/gemma-3-27b-it",
++ "modification": {
++ "method": "abliteration",
++ "note": "Community abliteration of the instruction-tuned model; Gemma terms apply."
++ },
++ "access": [
++ "OWN_DEPLOYMENT"
++ ],
++ "availability_note": "planned",
++ "hardware": "One 24 GB GPU",
++ "quality": {
++ "bench_url": null,
++ "score": null,
++ "samples": 0
++ },
++ "input_modalities": [
++ "text",
++ "image"
++ ],
++ "per_request_nano_usd": "0"
++ }
++ },
++ {
++ "id": "deepseek/deepseek-v3.2@preview",
++ "provider_key": "deepseek",
++ "model_key": "deepseek-v3.2",
++ "display_name": "DeepSeek V3.2",
++ "type": "chat",
++ "status": "active",
++ "cost": {
++ "in_per_mtok": null,
++ "out_per_mtok": null,
++ "cached_per_mtok": null
++ },
++ "supports": {
++ "json": true,
++ "tools": true,
++ "streaming": true,
++ "vision": false
++ },
++ "family": "deepseek-v3",
++ "variant": "original",
++ "quant": "fp8",
++ "revision": "preview",
++ "license": "mit",
++ "context": 131072,
++ "max_output": 8192,
++ "capabilities": {
++ "tools": true,
++ "json": true,
++ "vision": false,
++ "stream": true
++ },
++ "modalities": [
++ "text"
++ ],
++ "retail_rate": {
++ "version": "example-1",
++ "input_nano_usd_per_m": "300000000",
++ "cached_read_nano_usd_per_m": "30000000",
++ "cache_write_nano_usd_per_m": "300000000",
++ "output_nano_usd_per_m": "450000000"
++ },
++ "availability": "limited",
++ "metadata": {
++ "org": "deepseek",
++ "access": [
++ "CUSTOMER_BYOK",
++ "UNVERIFIED"
++ ],
++ "availability_note": "byok_only",
++ "hardware": "Large MoE: through a partner",
++ "quality": {
++ "bench_url": null,
++ "score": null,
++ "samples": 0
++ },
++ "input_modalities": [
++ "text"
++ ],
++ "per_request_nano_usd": "0"
++ }
++ },
++ {
++ "id": "zai/glm-4.6@preview",
++ "provider_key": "zai",
++ "model_key": "glm-4.6",
++ "display_name": "GLM 4.6",
++ "type": "chat",
++ "status": "active",
++ "cost": {
++ "in_per_mtok": null,
++ "out_per_mtok": null,
++ "cached_per_mtok": null
++ },
++ "supports": {
++ "json": true,
++ "tools": true,
++ "streaming": true,
++ "vision": false
++ },
++ "family": "glm",
++ "variant": "original",
++ "quant": "fp8",
++ "revision": "preview",
++ "license": "mit",
++ "context": 131072,
++ "max_output": 16384,
++ "capabilities": {
++ "tools": true,
++ "json": true,
++ "vision": false,
++ "stream": true
++ },
++ "modalities": [
++ "text"
++ ],
++ "retail_rate": {
++ "version": "example-1",
++ "input_nano_usd_per_m": "450000000",
++ "cached_read_nano_usd_per_m": "110000000",
++ "cache_write_nano_usd_per_m": "450000000",
++ "output_nano_usd_per_m": "1600000000"
++ },
++ "availability": "unavailable",
++ "metadata": {
++ "org": "zai",
++ "access": [
++ "CUSTOMER_BYOK",
++ "UNVERIFIED"
++ ],
++ "availability_note": "pending_agreement",
++ "hardware": "Large MoE: through a partner",
++ "quality": {
++ "bench_url": null,
++ "score": null,
++ "samples": 0
++ },
++ "input_modalities": [
++ "text"
++ ],
++ "per_request_nano_usd": "0"
++ }
++ },
++ {
++ "id": "mistral/devstral-small-2505:q4_k_m@preview",
++ "provider_key": "mistral",
++ "model_key": "devstral-small-2505:q4_k_m",
++ "display_name": "Devstral Small",
++ "type": "chat",
++ "status": "active",
++ "cost": {
++ "in_per_mtok": null,
++ "out_per_mtok": null,
++ "cached_per_mtok": null
++ },
++ "supports": {
++ "json": true,
++ "tools": true,
++ "streaming": true,
++ "vision": false
++ },
++ "family": "devstral",
++ "variant": "finetuned",
++ "quant": "q4_k_m",
++ "revision": "preview",
++ "license": "apache-2.0",
++ "context": 131072,
++ "max_output": 8192,
++ "capabilities": {
++ "tools": true,
++ "json": true,
++ "vision": false,
++ "stream": true
++ },
++ "modalities": [
++ "text"
++ ],
++ "retail_rate": {
++ "version": "example-1",
++ "input_nano_usd_per_m": "70000000",
++ "cached_read_nano_usd_per_m": "20000000",
++ "cache_write_nano_usd_per_m": "70000000",
++ "output_nano_usd_per_m": "200000000"
++ },
++ "availability": "unavailable",
++ "metadata": {
++ "org": "mistral",
++ "base_model": "mistral/mistral-small-3.1",
++ "modification": {
++ "method": "fine-tune",
++ "note": "Tuned by Mistral for coding agents."
++ },
++ "access": [
++ "OWN_DEPLOYMENT",
++ "CUSTOMER_BYOK"
++ ],
++ "availability_note": "planned",
++ "hardware": "One 24 GB GPU",
++ "quality": {
++ "bench_url": null,
++ "score": null,
++ "samples": 0
++ },
++ "input_modalities": [
++ "text"
++ ],
++ "per_request_nano_usd": "0"
++ }
++ }
++ ]
++}
\\ No newline at end of file
ddiff --git a/server/http/ai-docs.js b/server/http/ai-docs.js
nnew file mode 100644
iindex 0000000..89f2286
---- /dev/null
++++ b/server/http/ai-docs.js
@@@ -0,0 +1,116 @@
++'use strict';
++
++/**
++ * GET /docs/ai (AI Exchange P8): the developer guide to OpenVibe AI's API, as the decisions of the AI Exchange define it
++ * (CHECKPOINT D1-D3, D8'-D10): one base URL for the OpenAI and Anthropic SDKs, exact model ids and dynamic names, the
++ * `x_openvibe` object every answer carries, prices to the nano-dollar with holds, streaming, errors, BYOK, privacy and
++ * sandbox. Behind SERVICES_MODEL_EXPLORER with the model pages (no public promise before the owner approves); noindex.
++ */
++
++const { asyncRouter } = require('./router');
++const { html } = require('../render/html');
++const { send } = require('../render/layout');
++const { usd, cost } = require('../domain/model-catalog');
++
++const AI = 'https://ai.openvibe.services';
++
++function createAiDocsRoutes(appCtx) {
++ const { config } = appCtx;
++ const r = asyncRouter();
++
++ r.get('/docs/ai', (req, res, next) => {
++ if (!config.modelExplorer) return next();
++ const example = { retail: { nano_usd_per_m: { input: '40000000', cached_read: '10000000', cache_write: '40000000', output: '120000000' }, per_request_nano_usd: '0' } };
++ const ex = { input: 1200, cached_read: 8000, cache_write: 0, output: 400 };
++ const exCost = cost(example, ex);
++ const block = (id, code) => html`<div class="mx-snippet"><div class="mx-snippet-head"><span>${id}</span><button type="button" class="mx-copy" data-mx-copy="#doc-${id.replace(/[^a-z]+/gi, '-').toLowerCase()}" hidden>Copy</button></div><pre id="doc-${id.replace(/[^a-z]+/gi, '-').toLowerCase()}" tabindex="0"><code>${code}</code></pre></div>`;
++ const row = (cells) => html`<tr>${cells.map((c, i) => (i ? html`<td>${c}</td>` : html`<th scope="row">${c}</th>`))}</tr>`;
++ const body = html`<div class="mx mx-doc">
++<header class="mx-hero is-small">
++ <p class="mx-eye">Docs · OpenVibe AI</p>
++ <h1>One API for every model, priced to the nano-dollar</h1>
++ <p class="mx-lede">Use the OpenAI or the Anthropic SDK you already have. Name an exact model build, or let OpenVibe pick within your rules. Every answer says which model ran, where, at what price.</p>
++ <p class="mx-note" role="note"><b>Preview.</b> This describes the API as the AI Exchange opens it; until then the catalog and the prices are examples.</p>
++ <nav class="mx-jump" aria-label="On this page"><a href="#start">Start</a><a href="#models">Models</a><a href="#answer">What every answer says</a><a href="#prices">Prices and caps</a><a href="#stream">Streaming</a><a href="#errors">Errors</a><a href="#keys">Your keys, privacy, sandbox</a></nav>
++</header>
++
++<section class="mx-card" id="start" aria-labelledby="start-h">
++ <h2 id="start-h">Start in three steps</h2>
++ <ol class="mx-steps">
++ <li><b>A project and an app.</b> Create a project on <a href="/projects">Projects</a> and a sandbox app in it; ask for <code>ai.app.run</code> on the app (sandbox apps get it at once).</li>
++ <li><b>Its key.</b> The app's credential is shown once. Keep it in your environment as <code>OPENVIBE_API_KEY</code>; OpenVibe stores only its hash.</li>
++ <li><b>The first call.</b> The base URL is <code>${AI}/v1</code> for the OpenAI SDK and <code>${AI}</code> for the Anthropic one.</li>
++ </ol>
++ ${block('curl', `curl ${AI}/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model": "openvibe/auto", "messages": [{"role": "user", "content": "Hello"}]}'`)}
++ <details class="mx-more"><summary>OpenAI and Anthropic SDKs (2)</summary>
++ ${block('OpenAI SDK · JavaScript', `import OpenAI from 'openai';\n\nconst ai = new OpenAI({ baseURL: '${AI}/v1', apiKey: process.env.OPENVIBE_API_KEY });\nconst reply = await ai.chat.completions.create({ model: 'openvibe/auto', messages: [{ role: 'user', content: 'Hello' }] });\nconsole.log(reply.choices[0].message.content, reply.x_openvibe);`)}
++ ${block('Anthropic SDK · Python', `import anthropic, os\n\nai = anthropic.Anthropic(base_url="${AI}", api_key=os.environ["OPENVIBE_API_KEY"])\nreply = ai.messages.create(model="openvibe/auto", max_tokens=1024, messages=[{"role": "user", "content": "Hello"}])\nprint(reply.content[0].text)`)}
++ </details>
++</section>
++
++<section class="mx-card" id="models" aria-labelledby="models-h">
++ <h2 id="models-h">Choosing a model</h2>
++ <p><b>An exact id</b> names one build: <code>org/model[:quant]@revision</code>, for example <code>qwen/qwen3-8b-abliterated:q4_k_m@3f2a1c9</code>. It runs that artifact or fails with an error; it is never swapped for another quantization, variant or model. <a href="/models">The model list</a> has every id.</p>
++ <p><b>A dynamic name</b> picks per request among the models you allow, after your hard rules (exact pins, privacy class, the capabilities the request needs, your price cap):</p>
++ <div class="mx-table-wrap is-vs"><table class="mx-table mx-vs"><thead><tr><th scope="col">Name</th><th scope="col">Picks</th></tr></thead><tbody>
++ ${row([html`<code>openvibe/auto</code>`, 'Balanced, and the default for the old name openvibe'])}
++ ${row([html`<code>openvibe/economy</code>`, 'The lowest cost per successful answer above the quality floor'])}
++ ${row([html`<code>openvibe/balanced</code>`, 'Cost, success rate and speed weighed together'])}
++ ${row([html`<code>openvibe/fast</code>`, 'The quickest first token under your cost cap'])}
++ ${row([html`<code>openvibe/private</code>`, 'Only OpenVibe-run machines or your own; never an outside provider'])}
++ </tbody></table></div>
++ <p class="mx-hint">Abliterated, fine-tuned and quantized variants are measured on their own; a variant never borrows its base model's score. A change to the weights is no promise of quality or of no refusals.</p>
++</section>
++
++<section class="mx-card" id="answer" aria-labelledby="answer-h">
++ <h2 id="answer-h">What every answer says</h2>
++ <p>Each response carries an <code>x_openvibe</code> object (in a stream: just before <code>[DONE]</code>), beside the <code>run_id</code>, <code>provider</code>, <code>fallback_used</code> and <code>cost_usd</code> it already has:</p>
++ <div class="mx-table-wrap is-vs"><table class="mx-table mx-vs"><tbody>
++ ${row([html`<code>resolved_model</code>`, 'The exact id that answered'])}
++ ${row([html`<code>revision</code>`, 'Its build'])}
++ ${row([html`<code>provider_access</code>`, html`Who ran it: <code>OWN_DEPLOYMENT</code> (OpenVibe), <code>AUTHORIZED_WHOLESALE</code> (a licensed partner), <code>CUSTOMER_BYOK</code> (your key)`])}
++ ${row([html`<code>rate_version</code>`, 'The price version the charge used, so any bill can be reproduced'])}
++ ${row([html`<code>policy</code>`, 'The dynamic name\'s policy, when you used one'])}
++ ${row([html`<code>fallback_reason</code>`, 'Why another model answered, when one did (a dynamic name only)'])}
++ </tbody></table></div>
++</section>
++
++<section class="mx-card" id="prices" aria-labelledby="prices-h">
++ <h2 id="prices-h">Prices and caps</h2>
++ <p>A model has four prices per million tokens: input, cached input, cache write and output, and sometimes a per-request fee. One request costs each class's tokens times its price, <b>rounded up once per class</b>, to the nano-dollar ($0.000000001). Nothing is rounded per token or to a cent.</p>
++ <p>For example, at $0.04 / $0.01 / $0.04 / $0.12 per million, a request of 1,200 input, 8,000 cached and 400 output tokens costs <b>${usd(exCost)}</b>; a thousand of them, ${usd(exCost, { per: 1000 })}.</p>
++ <p><b>Balance</b> pays for it: your project's prepaid dollars, one for one with USD. Before a request runs, the most it can cost is held from your balance; when it ends, only what was used is charged and the rest is released at once. Set <code>max_cost_usd</code> on a request to cap it; a request that would exceed your balance or cap is refused before it runs (402).</p>
++</section>
++
++<section class="mx-card" id="stream" aria-labelledby="stream-h">
++ <h2 id="stream-h">Streaming</h2>
++ <p>Set <code>"stream": true</code>: Server-Sent Events, as the OpenAI and Anthropic SDKs expect. Stop reading, or abort, and you pay for the tokens produced until then, never the cap. A provider that fails after the first token ends the stream with an error; two models' answers are never spliced into one.</p>
++</section>
++
++<section class="mx-card" id="errors" aria-labelledby="errors-h">
++ <h2 id="errors-h">Errors</h2>
++ <div class="mx-table-wrap is-vs"><table class="mx-table mx-vs"><thead><tr><th scope="col">Status</th><th scope="col">Code</th><th scope="col">Means</th></tr></thead><tbody>
++ ${row(['404', html`<code>ai.model_not_found</code>`, 'No such model id'])}
++ ${row(['400', html`<code>ai.model_unavailable</code>`, 'The exact build cannot run now; nothing else was tried'])}
++ ${row(['402', html`<code>billing.insufficient_funds</code>`, 'Your Balance (or your cap) does not cover the request'])}
++ ${row(['409', html`<code>billing.hold_key_reused</code>`, 'An idempotency key was reused with a different request'])}
++ ${row(['429', html`<code>rate_limited</code>`, 'Too many requests for your project now; retry after the header says'])}
++ </tbody></table></div>
++</section>
++
++<section class="mx-card" id="keys" aria-labelledby="keys-h">
++ <h2 id="keys-h">Your own keys, privacy, sandbox</h2>
++ <ul class="mx-list">
++ <li><b>Your own provider keys (BYOK).</b> Add them on Your AI; requests they serve are billed by that provider, and OpenVibe charges nothing for them.</li>
++ <li><b>Private.</b> <code>openvibe/private</code>, or a private class in your project, keeps a request on OpenVibe-run machines or your own. No prompt or answer is kept in logs, caches or analytics.</li>
++ <li><b>Sandbox apps</b> run on sandbox Balance only: test freely, nothing is charged and no paid provider is used.</li>
++ </ul>
++</section>
++</div>`;
++ send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'OpenVibe AI API', description: 'The OpenVibe AI API: exact models, dynamic names, prices to the nano-dollar.', crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'AI' }], scripts: ['js/models.js'], css: ['css/models.css'], body });
++ });
++
++ return r;
++}
++
++module.exports = { createAiDocsRoutes };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 4647ef3..6b375cf 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -51,6 +51,8 @@ const PAGE_TEXT = {
'/docs/events': ['Event types', 'Every event type a service manifest declares it produces or consumes.'],
'/docs/services': ['Service registry', 'The Network\'s registry as it answers, with health (never invented).'],
'/docs/billing': ['Billing policy', 'The billing policy as OpenVibe.Billing\'s /policy.json states it.'],
++ '/docs/vibes': ['Vibes and Vibes Pay', 'What Vibes are and how an app sells something with Vibes Pay: the SDK, plain HTTP, webhooks, sandbox, refunds, payouts and limits.'],
++ '/docs/balance': ['Balance', 'How metered usage bills a project\'s Balance, adding money and moving money to a project.'],
'/docs/limits': ['Limits and tiers', 'What a project may do in sandbox and production, from the services that enforce it.'],
'/docs/tools': ['Tools API', 'The OpenVibe.Tools registry, callable from code.'],
'/docs/export': ['Project export', 'What a project export holds: the metadata JSON and the full archive.'],
@@@ -107,6 +109,8 @@ function publicPages({ docs }) {
{ path: '/docs/events', changefreq: 'weekly', priority: 0.6 },
{ path: '/docs/services', changefreq: 'weekly', priority: 0.6 },
{ path: '/docs/billing', changefreq: 'monthly', priority: 0.5 },
++ { path: '/docs/vibes', changefreq: 'monthly', priority: 0.6 },
++ { path: '/docs/balance', changefreq: 'monthly', priority: 0.6 },
{ path: '/docs/limits', changefreq: 'monthly', priority: 0.5 },
{ path: '/docs/tools', changefreq: 'monthly', priority: 0.5 },
{ path: '/docs/export', changefreq: 'monthly', priority: 0.5 },
@@@ -121,7 +125,7 @@ function publicPages({ docs }) {
{ path: '/policy/licensing', changefreq: 'monthly', priority: 0.4 },
{ path: '/policy/transparency', changefreq: 'monthly', priority: 0.4 },
];
-- for (const c of docs.contracts) pages.push({ path: `/docs/contracts/${c.id}`, changefreq: 'monthly', priority: 0.4 });
++ for (const c of docs.contracts) pages.push({ path: c.href, changefreq: 'monthly', priority: c.latest ? 0.4 : 0.2 });
for (const c of docs.capabilities) pages.push({ path: `/docs/capabilities/${c.id}`, changefreq: 'monthly', priority: 0.4 });
for (const x of contracts.openapi.index()) pages.push({ path: `/docs/api/${x.service}`, changefreq: 'monthly', priority: 0.4 });
for (const m of docs.sdk) pages.push({ path: `/docs/sdk/${m.slug}`, changefreq: 'monthly', priority: 0.3 });
@@@ -139,8 +143,8 @@ function describePage(pathname, { docs, apiIndex = [] }) {
if (PAGE_TEXT[pathname]) return { title: PAGE_TEXT[pathname][0], text: PAGE_TEXT[pathname][1] };
let m;
if ((m = /^\/docs\/contracts\/([^/]+)$/.exec(pathname))) {
-- const c = docs.contracts.find((x) => x.id === m[1]);
-- if (c) return { title: c.title, text: seo.clip(c.description, 200) };
++ const c = docs.contracts.find((x) => x.ref === m[1]);
++ if (c) return { title: c.latest ? c.title : `${c.title} (${c.id}@${c.major})`, text: seo.clip(c.description, 200) };
}
if ((m = /^\/docs\/capabilities\/([^/]+)$/.exec(pathname))) {
const c = docs.capabilities.find((x) => x.id === m[1]);
ddiff --git a/server/http/docs.js b/server/http/docs.js
iindex 2341529..4c28077 100644
---- a/server/http/docs.js
++++ b/server/http/docs.js
@@@ -15,6 +15,7 @@
* /docs/events event types from the service manifests
* /docs/services the Network's registry with health as reported (never invented)
* /docs/billing the billing policy as OpenVibe.Billing's /policy.json states it (never restated)
++ * /docs/vibes, /docs/balance the D12 money guides (http/money-docs.js)
* /docs/limits limits and tiers: each service's /limits.json as it answers (never restated)
* /docs/export the project export: the metadata JSON and the full archive's layout
* /docs/sdk[/:module] SDK reference from its .d.ts files
@@@ -48,7 +49,8 @@ function createDocsViews(ctx) {
if (s.$ref) {
const key = String(s.$ref).replace('#/components/schemas/', '');
const id = key.replace(/\.v\d+$/, '');
-- return docs.contract(id) ? html`<a href="/docs/contracts/${id}">${id}@${key.split('.v').pop()}</a>` : code(key);
++ const c = docs.contract(id, key.split('.v').pop()) || docs.contract(id);
++ return c ? html`<a href="${c.href}">${id}@${key.split('.v').pop()}</a>` : code(key);
}
if (s.anyOf) return html`one of ${s.anyOf.map((x, i) => html`${i ? ', ' : ''}${schemaLink(x)}`)}`;
if (s.contentEncoding === 'binary') return 'bytes';
@@@ -63,7 +65,7 @@ function createDocsViews(ctx) {
description: `OpenVibe platform reference generated from openvibe-contracts v${docs.contractsVersion} and openvibe-sdk v${docs.sdkVersion}.`,
crumbs: [{ label: 'Docs' }],
body: html`<h1>Platform reference</h1>${versions()}
--<p>Nothing on these pages is written by hand: each one is rendered from the published packages above when Services starts, so it always matches what the platform runs against. If something is missing here, it is not part of the public surface yet (<a href="/policy/compatibility">why</a>).</p>
++<p>Most of these pages are generated: each one is rendered from the published packages above when Services starts, so it always matches what the platform runs against. The two money guides (<a href="/docs/vibes">Vibes</a> and <a href="/docs/balance">Balance</a>) are written from the platform's money decisions. If something is missing here, it is not part of the public surface yet (<a href="/policy/compatibility">why</a>).</p>
<ul class="cards">
<li><a href="/docs/contracts"><strong>Contracts</strong></a><span>${docs.contracts.length} JSON Schemas with fields, examples and versions</span></li>
<li><a href="/docs/api"><strong>API explorer</strong></a><span>${apiIndex.reduce((n, s) => n + s.operations, 0)} routes across ${apiIndex.length} services, with their capabilities and schemas (OpenAPI 3.1)</span></li>
@@@ -75,6 +77,8 @@ function createDocsViews(ctx) {
<li><a href="https://openvibe.codes/harnesses"><strong>Coding harnesses</strong></a><span>the coding agents OpenVibe.Codes routes a task to, with their capabilities, prices and limits</span></li>
<li><a href="/docs/billing"><strong>Billing policy</strong></a><span>prices, the creator split, fees, holds and cashouts, live from OpenVibe.Billing</span></li>
<li><a href="/docs/limits"><strong>Limits and tiers</strong></a><span>what a project may do in sandbox and production, live from the services that enforce it</span></li>
++<li><a href="/docs/vibes"><strong>Vibes</strong></a><span>the social currency, and selling something with Vibes Pay: SDK, HTTP, webhooks, sandbox, refunds, payouts</span></li>
++<li><a href="/docs/balance"><strong>Balance</strong></a><span>how metered usage bills a project's Balance, adding money and moving money to a project</span></li>
<li><a href="/docs/export"><strong>Project export</strong></a><span>take a project with you: its configuration, modules, objects and events as one download</span></li>
<li><a href="/docs/sdk"><strong>SDK</strong></a><span>${docs.sdk.length} modules of openvibe-sdk from their type definitions</span></li>
<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
@@@ -170,39 +174,43 @@ function createDocsRoutes(ctx) {
body: html`<h1>Contracts</h1>${versions()}
<p>A contract id is permanent. Minor versions only add optional fields; a breaking change is a new major with a deprecation window (<a href="/policy/compatibility">policy</a>).</p>
${table(['Contract', 'Version', 'Owner', 'Visibility', 'Status', 'Compatibility'], docs.contracts.map((c) => [
-- html`<a href="/docs/contracts/${c.id}">${c.id}</a>${c.deprecation ? html` ${badge('deprecated', 'warn')}` : ''}`,
++ html`<a href="${c.href}">${c.latest ? c.id : `${c.id}@${c.major}`}</a>${c.deprecation ? html` ${badge('deprecated', 'warn')}` : ''}`,
c.version, c.owner, c.visibility, c.status, c.compatibility,
]))}`,
});
});
++ // `<id>` is the latest major, `<id>@<major>` a named one.
++ const named = (raw) => {
++ const m = /^(.+)@(\d+)$/.exec(String(raw));
++ return m ? { id: m[1], major: m[2], c: docs.contract(m[1], m[2]) } : { id: String(raw), major: null, c: docs.contract(String(raw)) };
++ };
++
r.get('/contracts/:id.json', (req, res, next) => {
-- const c = docs.contract(req.params.id);
++ const { c } = named(req.params.id);
if (!c) return next();
res.set('Cache-Control', PUBLIC_CACHE).type('application/schema+json').send(JSON.stringify(c.schema, null, 2));
});
r.get('/contracts/:id', (req, res, next) => {
-- // A contract named the way the platform writes it, `<name>@<major>` (actor.task-webhook@1): its page, when that
-- // major is the one documented here.
-- const versioned = /^(.+)@(\d+)$/.exec(String(req.params.id));
-- if (versioned) {
-- const named = docs.contract(versioned[1]);
-- if (named && named.version.split('.')[0] === versioned[2]) return res.redirect(301, `/docs/contracts/${encodeURIComponent(named.id)}`);
-- return next();
-- }
-- const c = docs.contract(req.params.id);
++ // A contract named the way the platform writes it, `<name>@<major>` (actor.task-webhook@1): the latest major
++ // redirects to the plain page; an older major still in the catalog has its own page; any other is 404.
++ const asked = named(req.params.id);
++ const c = asked.c;
if (!c) return next();
-- const major = c.version.split('.')[0];
++ if (asked.major != null && c.latest) return res.redirect(301, `/docs/contracts/${encodeURIComponent(c.id)}`);
++ const major = c.major;
++ const current = c.latest ? null : docs.contract(c.id);
const fixture = (f, ok) => html`<details${ok ? raw(' open') : ''}><summary>${ok ? 'Valid' : 'Rejected'}: ${f.name}</summary><pre tabindex="0"><code>${JSON.stringify(f.body, null, 2)}</code></pre></details>`;
page(req, res, {
title: `${c.id}@${major}`,
description: c.description.slice(0, 200),
-- crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Contracts', href: '/docs/contracts' }, { label: c.id }],
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Contracts', href: '/docs/contracts' }, { label: c.latest ? c.id : `${c.id}@${major}` }],
body: html`<h1>${c.title} <small><code>${c.id}@${major}</code></small></h1>${versions()}
++${current ? html`<div class="notice">An older major, still in the catalog. The current one is <a href="${current.href}"><code>${c.id}@${current.major}</code></a>.</div>` : ''}
<dl class="facts"><dt>Version</dt><dd>${c.version}</dd><dt>Owner</dt><dd>${c.owner}</dd><dt>Visibility</dt><dd>${c.visibility}</dd><dt>Status</dt><dd>${c.status}</dd>
<dt>Compatibility</dt><dd>${c.compatibility}</dd>${c.adr ? html`<dt>Decision</dt><dd><a href="/docs/adr/${c.adr}">${c.adr}</a></dd>` : ''}
--<dt>Schema</dt><dd><a href="/docs/contracts/${c.id}.json"><code>${c.$id}</code></a></dd></dl>
++<dt>Schema</dt><dd><a href="${c.href}.json"><code>${c.$id}</code></a></dd></dl>
${c.deprecation ? html`<div class="notice warn">Deprecated since ${c.deprecation.since}; use ${c.deprecation.replacement}. Removed after ${c.deprecation.removeAfter}. ${c.deprecation.reason || ''}</div>` : ''}
<p>${c.description}</p>
<h2>Fields</h2>
@@@ -216,7 +224,7 @@ ${table(['Field', 'Type', 'Required', 'Description', 'Constraints'], c.fields.ma
<h2>Examples</h2>
<p class="muted">From the contract's own test fixtures: valid ones validate, rejected ones must fail.</p>
${c.fixtures.valid.map((f) => fixture(f, true))}${c.fixtures.invalid.map((f) => fixture(f, false))}
--${!c.fixtures.valid.length && !c.fixtures.invalid.length ? html`<p class="muted">No fixtures ship with this contract.</p>` : ''}
++${!c.fixtures.valid.length && !c.fixtures.invalid.length ? html`<p class="muted">${c.latest ? 'No fixtures ship with this contract.' : 'The fixtures that ship with the package are the current major\'s.'}</p>` : ''}
<h2>Validate</h2>
<pre tabindex="0"><code>const contracts = require('openvibe-contracts');
contracts.validate('${c.id}@${major}', value); // { valid, errors: [{ path, message }] }</code></pre>`,
@@@ -258,7 +266,7 @@ ${c.grantable ? html`<div class="notice ok">Grantable to apps: request it for an
<dt>Status</dt><dd>${c.status}</dd><dt>Visibility</dt><dd>${c.visibility}</dd>
<dt>Resource constraints</dt><dd>${c.resourceConstraints.join(', ') || 'none'}</dd><dt>Quota class</dt><dd>${c.quotaClass}</dd>
<dt>Permissions</dt><dd>${c.permissions.join(', ') || '—'}</dd>
--${c.inputSchema ? html`<dt>Input</dt><dd>${docs.contract(c.inputSchema.split('@')[0]) ? html`<a href="/docs/contracts/${c.inputSchema.split('@')[0]}">${c.inputSchema}</a>` : c.inputSchema}</dd>` : ''}
++${c.inputSchema ? html`<dt>Input</dt><dd>${(() => { const [sid, sm] = c.inputSchema.split('@'); const s = docs.contract(sid, sm) || docs.contract(sid); return s ? html`<a href="${s.href}">${c.inputSchema}</a>` : c.inputSchema; })()}</dd>` : ''}
${c.outputSchema ? html`<dt>Output</dt><dd>${c.outputSchema}</dd>` : ''}
<dt>Events</dt><dd>${c.events.length ? c.events.join(', ') : '—'}</dd>
<dt>Implemented by</dt><dd>${(c.implementedBy || []).length ? html`<ul class="plain">${c.implementedBy.map((x) => html`<li><code>${x}</code></li>`)}</ul>` : '—'}</dd></dl>
ddiff --git a/server/http/models.js b/server/http/models.js
nnew file mode 100644
iindex 0000000..f6725e6
---- /dev/null
++++ b/server/http/models.js
@@@ -0,0 +1,220 @@
++'use strict';
++
++/**
++ * The model explorer (AI Exchange P8): the developer portal's pages for OpenVibe AI's models.
++ *
++ * GET /models every model: search, variant, what it can do, who supplies it; priced for a workload
++ * GET /models/compare?ids=… two to four side by side, with the cost of each workload
++ * GET /models/:id one model: exact identity, lineage, prices with a calculator, quality, how to call it
++ *
++ * Behind SERVICES_MODEL_EXPLORER (off): no price is promised in public before the owner approves the catalog. The
++ * pages work without JavaScript (forms and links); js/models.js makes the filters and the calculator live. Data is
++ * domain/model-catalog.js (OpenVibe AI's catalog, or the preview fixture, which every page then labels).
++ */
++
++const { asyncRouter } = require('./router');
++const { html, raw, esc } = require('../render/html');
++const { send } = require('../render/layout');
++const catalogLib = require('../domain/model-catalog');
++
++const { cost, usd, perMillion, filterModels, sortModels, WORKLOADS, VARIANTS, ACCESS, AVAILABILITY, COMPONENTS } = catalogLib;
++const CAPS = { tools: 'Tools', json: 'JSON', vision: 'Vision', stream: 'Streaming' };
++const COMPONENT_NAMES = { input: 'Input', cached_read: 'Cached input', cache_write: 'Cache write', output: 'Output' };
++const AI_URL = 'https://ai.openvibe.services';
++
++const enabled = (config) => !!config.modelExplorer;
++const badge = (text, tone) => html`<span class="mx-badge is-${tone || 'plain'}">${text}</span>`;
++const variantBadge = (m) => (m.variant ? badge(VARIANTS[m.variant], m.variant === 'abliterated' ? 'abl' : m.variant === 'original' ? 'plain' : 'tuned') : '');
++const accessBadges = (m) => m.access.map((a) => badge(ACCESS[a][0], ACCESS[a][1]));
++const href = (id) => `/models/${encodeURIComponent(id)}`;
++const n = (x) => Number(x || 0).toLocaleString('en-US');
++const ctx = (t) => (t >= 1000 ? `${Math.round(t / 1024)}K` : String(t || '—'));
++
++function createModelRoutes(appCtx) {
++ const { config } = appCtx;
++ const catalog = appCtx.modelCatalog || catalogLib.createModelCatalog({ config, log: appCtx.log || console });
++ const r = asyncRouter();
++
++ const page = (req, res, o) => send(res, o.status || 200, {
++ viewer: req.viewer, config, path: req.originalUrl, index: false, scripts: ['js/models.js'], css: ['css/models.css'], ...o,
++ });
++ const previewNote = (data) => (data.source === 'preview'
++ ? html`<p class="mx-note" role="note"><b>Preview.</b> These are example entries and example prices, to shape this page. The live catalog comes from OpenVibe AI.</p>`
++ : data.problem ? html`<p class="mx-note is-bad" role="status">${data.problem.detail}</p>` : '');
++
++ r.get('/models', async (req, res, next) => {
++ if (!enabled(config)) return next();
++ const data = await catalog.list();
++ const q = req.query || {};
++ const workload = WORKLOADS[q.workload] ? q.workload : 'chat';
++ const sort = ['price', 'context', 'name'].includes(q.sort) ? q.sort : 'price';
++ const caps = [].concat(q.cap || []).filter((c) => CAPS[c]);
++ const shown = sortModels(filterModels(data.models, { ...q, cap: caps }), sort, workload);
++ const counts = Object.fromEntries(Object.keys(VARIANTS).map((v) => [v, data.models.filter((m) => m.variant === v).length]));
++ const usage = WORKLOADS[workload].usage;
++ const option = (value, label, selected, extra = '') => raw(`<option value="${esc(value)}"${selected ? ' selected' : ''}${extra}>${esc(label)}</option>`);
++
++ const aliases = data.aliases.length ? html`<section class="mx-aliases" aria-labelledby="mx-aliases-h">
++<div class="mx-sec-head"><h2 id="mx-aliases-h">Or let OpenVibe pick</h2><p>A dynamic name picks a model per request, within your rules, and every answer says which model ran and why.</p></div>
++<ul class="mx-alias-list">${data.aliases.map((a) => html`<li><a class="mx-alias" href="${href(a.id)}"><code>${a.id}</code><b>${a.name}</b><span>${a.description || ''}</span></a></li>`)}</ul></section>` : '';
++
++ const rows = shown.map((m) => {
++ const per = m.retail && m.retail.nano_usd_per_m ? m.retail.nano_usd_per_m : {};
++ const [avail, availTone] = AVAILABILITY[m.availability];
++ return html`<tr>
++<td class="mx-c-model" data-label="Model"><div class="mx-model-cell"><a class="mx-name" href="${href(m.id)}">${m.name}</a><code class="mx-id">${m.id}</code><span class="mx-tags">${variantBadge(m)}${m.quant ? badge(m.quant.toUpperCase(), 'plain') : ''}${badge(avail, availTone)}</span></div></td>
++<td data-label="Context">${ctx(m.context_tokens)}</td>
++<td class="mx-c-can" data-label="Can"><span class="mx-caps">${m.capabilities.filter((c) => CAPS[c] && c !== 'stream').map((c) => html`<span class="mx-cap">${CAPS[c]}</span>`)}</span></td>
++<td class="mx-num" data-label="Input / 1M">${perMillion(per.input)}</td>
++<td class="mx-num" data-label="Output / 1M">${perMillion(per.output)}</td>
++<td class="mx-num mx-strong" data-label="${WORKLOADS[workload].label}">${usd(cost(m, usage))}</td>
++<td data-label="Supplied by"><span class="mx-tags">${accessBadges(m)}</span></td>
++<td class="mx-pick" data-label="Compare"><label class="mx-check"><input type="checkbox" name="ids" value="${m.id}" form="mx-compare" data-mx-pick><span class="mx-sr">Compare ${m.name}</span></label></td>
++</tr>`;
++ });
++
++ const body = html`<div class="mx">
++<header class="mx-hero">
++ <p class="mx-eye">OpenVibe AI · Models</p>
++ <h1>Every model, its exact build, its real price</h1>
++ <p class="mx-lede">Original and abliterated variants, each pinned to a revision and benchmarked on its own. Prices to the nano-dollar, for the work you actually send.</p>
++ ${previewNote(data)}
++</header>
++${aliases}
++<form class="mx-bar" method="get" action="/models" data-mx-filters role="search" aria-label="Filter models">
++ <label class="mx-search"><span class="mx-sr">Search</span><input type="search" name="q" value="${q.q || ''}" placeholder="Search models" autocomplete="off" spellcheck="false"></label>
++ <label class="mx-field">Variant <select name="variant" data-ov-title="Variant">${option('all', `All (${data.models.length})`, !q.variant || q.variant === 'all')}${Object.entries(VARIANTS).filter(([v]) => counts[v]).map(([v, label]) => option(v, `${label} (${counts[v]})`, q.variant === v))}</select></label>
++ <label class="mx-field">Priced for <select name="workload" data-ov-title="Price for">${Object.entries(WORKLOADS).map(([k, w]) => option(k, w.label, workload === k, ` data-desc="${esc(w.desc)}"`))}</select></label>
++ <label class="mx-field">Sort <select name="sort" data-ov-title="Sort by">${option('price', 'Cheapest first', sort === 'price')}${option('context', 'Longest context', sort === 'context')}${option('name', 'Name', sort === 'name')}</select></label>
++ <fieldset class="mx-needs"><legend>Needs</legend>${Object.entries(CAPS).filter(([c]) => c !== 'stream').map(([c, label]) => html`<label class="mx-chip"><input type="checkbox" name="cap" value="${c}"${caps.includes(c) ? raw(' checked') : ''}><span>${label}</span></label>`)}</fieldset>
++ <button class="mx-apply" type="submit">Show</button>
++</form>
++<p class="mx-count" aria-live="polite"><b>${shown.length}</b> of ${data.models.length} models · priced for a ${WORKLOADS[workload].label.toLowerCase()} (${WORKLOADS[workload].desc})</p>
++<form id="mx-compare" method="get" action="/models/compare"></form>
++${shown.length ? html`<div class="mx-table-wrap"><table class="mx-table">
++<thead><tr><th scope="col">Model</th><th scope="col">Context</th><th scope="col">Can</th><th scope="col" class="mx-num">Input / 1M</th><th scope="col" class="mx-num">Output / 1M</th><th scope="col" class="mx-num">${WORKLOADS[workload].label}</th><th scope="col">Supplied by</th><th scope="col"><span class="mx-sr">Compare</span></th></tr></thead>
++<tbody>${rows}</tbody></table></div>` : html`<p class="mx-empty">No model matches. <a href="/models">Show every model</a>.</p>`}
++<div class="mx-compare-bar"><span data-mx-picked>Pick two to four models to compare them.</span><button class="mx-apply" type="submit" form="mx-compare">Compare</button></div>
++</div>`;
++ page(req, res, { title: 'Models', description: 'OpenVibe AI models: exact builds, variants and prices.', crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Models' }], body });
++ });
++
++ r.get('/models/compare', async (req, res, next) => {
++ if (!enabled(config)) return next();
++ const data = await catalog.list();
++ const ids = [].concat(req.query.ids || []).map(String).slice(0, 4);
++ const picked = ids.map((id) => data.models.find((m) => m.id === id)).filter(Boolean);
++ if (picked.length < 2) {
++ return page(req, res, { status: 200, title: 'Compare models', crumbs: [{ label: 'Models', href: '/models' }, { label: 'Compare' }], body: html`<div class="mx"><h1>Compare models</h1><p class="mx-empty">Pick two to four models on <a href="/models">the model list</a>, then press Compare.</p></div>` });
++ }
++ const line = (label, cell) => html`<tr><th scope="row">${label}</th>${picked.map((m) => html`<td>${cell(m)}</td>`)}</tr>`;
++ const bars = Object.entries(WORKLOADS).map(([k, w]) => {
++ const costs = picked.map((m) => cost(m, w.usage));
++ const max = costs.reduce((a, b) => (b > a ? b : a), 1n);
++ const min = costs.reduce((a, b) => (b < a ? b : a), costs[0]);
++ return html`<div class="mx-bars"><h3>${w.label} <small>${w.desc}</small></h3>${picked.map((m, i) => html`<div class="mx-bar-row${costs[i] === min ? ' is-best' : ''}"><span class="mx-bar-name">${m.name}</span><span class="mx-bar-track"><span class="mx-bar-fill" style="width:${Math.max(2, Number((costs[i] * 1000n) / max) / 10)}%"></span></span><span class="mx-bar-val">${usd(costs[i])}<small> · ${usd(costs[i], { per: 1000 })} per 1,000</small></span></div>`)}</div>`;
++ });
++ const body = html`<div class="mx">
++<header class="mx-hero is-small"><p class="mx-eye">Compare</p><h1>${picked.map((m) => m.name).join(' · ')}</h1>${previewNote(data)}</header>
++<section class="mx-card"><h2>What each costs</h2>${bars}</section>
++<section class="mx-card"><h2>Side by side</h2><div class="mx-table-wrap is-vs"><table class="mx-table mx-vs">
++<thead><tr><th scope="col"><span class="mx-sr">Attribute</span></th>${picked.map((m) => html`<th scope="col"><a href="${href(m.id)}">${m.name}</a></th>`)}</tr></thead>
++<tbody>
++${line('Exact id', (m) => html`<code class="mx-id">${m.id}</code>`)}
++${line('Variant', (m) => variantBadge(m))}
++${line('Base model', (m) => m.base_model || '—')}
++${line('Quantization', (m) => (m.quant || '—').toUpperCase())}
++${line('Context', (m) => `${n(m.context_tokens)} tokens`)}
++${line('Can', (m) => html`<span class="mx-caps">${m.capabilities.filter((c) => CAPS[c]).map((c) => html`<span class="mx-cap">${CAPS[c]}</span>`)}</span>`)}
++${COMPONENTS.map((c) => line(`${COMPONENT_NAMES[c]} / 1M`, (m) => perMillion(((m.retail || {}).nano_usd_per_m || {})[c])))}
++${line('Supplied by', (m) => html`<span class="mx-tags">${accessBadges(m)}</span>`)}
++${line('Runs on', (m) => m.hardware || '—')}
++${line('License', (m) => m.license || '—')}
++${line('Quality', (m) => (m.quality && m.quality.score != null ? `${m.quality.score} (${n(m.quality.samples)} samples)` : 'Not benchmarked yet'))}
++</tbody></table></div></section></div>`;
++ page(req, res, { title: 'Compare models', crumbs: [{ label: 'Models', href: '/models' }, { label: 'Compare' }], body });
++ });
++
++ r.get('/models/:id', async (req, res, next) => {
++ if (!enabled(config)) return next();
++ const data = await catalog.list();
++ const m = await catalog.get(String(req.params.id));
++ if (!m) return next();
++ const q = req.query || {};
++ const usage = Object.fromEntries(COMPONENTS.map((c) => [c, Math.max(0, Math.min(10_000_000, Math.floor(Number(q[c]) || (c === 'input' ? 1200 : c === 'output' ? 400 : 0))))]));
++ const crumbs = [{ label: 'Models', href: '/models' }, { label: m.name }];
++ const snippetCurl = `curl ${AI_URL}/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model": "${m.id}", "messages": [{"role": "user", "content": "Hello"}]}'`;
++ const snippetJs = `import OpenAI from 'openai';\n\nconst ai = new OpenAI({ baseURL: '${AI_URL}/v1', apiKey: process.env.OPENVIBE_API_KEY });\nconst reply = await ai.chat.completions.create({\n model: '${m.id}',\n messages: [{ role: 'user', content: 'Hello' }],\n});\nconsole.log(reply.choices[0].message.content, reply.x_openvibe);`;
++ const snippetPy = `from openai import OpenAI\nimport os\n\nai = OpenAI(base_url="${AI_URL}/v1", api_key=os.environ["OPENVIBE_API_KEY"])\nreply = ai.chat.completions.create(\n model="${m.id}",\n messages=[{"role": "user", "content": "Hello"}],\n)\nprint(reply.choices[0].message.content)`;
++ const snippetAnthropic = `import Anthropic from '@anthropic-ai/sdk';\n\nconst ai = new Anthropic({ baseURL: '${AI_URL}', apiKey: process.env.OPENVIBE_API_KEY });\nconst reply = await ai.messages.create({\n model: '${m.id}',\n max_tokens: 1024,\n messages: [{ role: 'user', content: 'Hello' }],\n});`;
++ const snippet = (id, label, code) => html`<div class="mx-snippet"><div class="mx-snippet-head"><span>${label}</span><button type="button" class="mx-copy" data-mx-copy="#${id}" hidden>Copy</button></div><pre id="${id}" tabindex="0"><code>${code}</code></pre></div>`;
++
++ if (m.dynamic) {
++ const body = html`<div class="mx"><header class="mx-hero is-small"><p class="mx-eye">Dynamic model</p><h1>${m.name}</h1><p class="mx-lede">${m.description || ''}</p><p><code class="mx-id is-big">${m.id}</code></p>${previewNote(data)}</header>
++<section class="mx-card"><h2>How it picks</h2><p>Policy <b>${m.policy}</b>. Only models you allow are considered: your exact pins, your privacy class, the capabilities the request needs and your price cap come first, then the policy chooses among what is left. Every answer carries an <code>x_openvibe</code> object naming the model, revision and supplier that answered, the price version, and why.</p></section>
++<section class="mx-card"><h2>Use it</h2>${snippet('mx-s-curl', 'curl', snippetCurl)}${snippet('mx-s-js', 'OpenAI SDK · JavaScript', snippetJs)}</section></div>`;
++ return page(req, res, { title: m.name, crumbs, body });
++ }
++
++ const per = (m.retail && m.retail.nano_usd_per_m) || {};
++ const one = cost(m, usage);
++ const [avail, availTone] = AVAILABILITY[m.availability];
++ const fact = (label, value) => html`<div class="mx-fact"><dt>${label}</dt><dd>${value}</dd></div>`;
++ const body = html`<div class="mx">
++<header class="mx-hero is-small">
++ <p class="mx-eye">${m.org || ''} · ${m.family || ''}</p>
++ <h1>${m.name}</h1>
++ <p class="mx-idline"><code class="mx-id is-big" id="mx-the-id">${m.id}</code><button type="button" class="mx-copy" data-mx-copy="#mx-the-id" hidden>Copy id</button></p>
++ <p class="mx-tags">${variantBadge(m)}${m.quant ? badge(m.quant.toUpperCase(), 'plain') : ''}${badge(avail, availTone)}${accessBadges(m)}</p>
++ ${m.modification && m.modification.note ? html`<p class="mx-lede">${m.modification.note}</p>` : ''}
++ ${previewNote(data)}
++</header>
++<div class="mx-grid">
++<section class="mx-card mx-price" aria-labelledby="mx-price-h">
++ <h2 id="mx-price-h">Price</h2>
++ <table class="mx-rates"><tbody>${COMPONENTS.map((c) => html`<tr><th scope="row">${COMPONENT_NAMES[c]}</th><td class="mx-num">${perMillion(per[c])} <small>per 1M tokens</small></td></tr>`)}${m.retail && m.retail.per_request_nano_usd && m.retail.per_request_nano_usd !== '0' ? html`<tr><th scope="row">Per request</th><td class="mx-num">${usd(m.retail.per_request_nano_usd)}</td></tr>` : ''}</tbody></table>
++ <form class="mx-calc" method="get" action="${href(m.id)}" data-mx-calc data-rates="${JSON.stringify({ ...per, per_request: (m.retail && m.retail.per_request_nano_usd) || '0' })}">
++ <p class="mx-calc-title">Your request</p>
++ <div class="mx-calc-grid">${COMPONENTS.map((c) => html`<label>${COMPONENT_NAMES[c]}<input type="number" inputmode="numeric" min="0" max="10000000" step="1" name="${c}" value="${usage[c]}"></label>`)}</div>
++ <output class="mx-calc-out" data-mx-out><span><b data-mx-one>${usd(one)}</b> per request</span><span><b data-mx-k>${usd(one, { per: 1000 })}</b> per 1,000</span><span><b data-mx-m>${usd(one, { per: 1000000 })}</b> per million</span></output>
++ <button class="mx-apply" type="submit" data-mx-calc-submit>Work it out</button>
++ <p class="mx-hint">Each token class is rounded up once per request, to the nano-dollar. Billing charges what was used, never the cap you set.</p>
++ </form>
++</section>
++<section class="mx-card" aria-labelledby="mx-id-h">
++ <h2 id="mx-id-h">Exactly this build</h2>
++ <dl class="mx-facts">
++ ${fact('Base model', m.base_model || 'This is the original')}
++ ${fact('Changed by', m.modification ? m.modification.method : 'Unchanged')}
++ ${fact('Revision', html`<code>${m.revision}</code>`)}
++ ${fact('Weights SHA-256', m.sha256 ? html`<code class="mx-hash">${m.sha256}</code>` : 'Published when it is deployed')}
++ ${fact('License', m.license || '—')}
++ ${fact('Context', `${n(m.context_tokens)} tokens`)}
++ ${fact('Longest answer', `${n(m.max_output_tokens)} tokens`)}
++ ${fact('Runs on', m.hardware || '—')}
++ </dl>
++ <p class="mx-hint">An exact id runs this build or fails; it is never swapped for another quantization or variant.</p>
++</section>
++<section class="mx-card" aria-labelledby="mx-can-h">
++ <h2 id="mx-can-h">What it can do</h2>
++ <p class="mx-caps is-big">${Object.entries(CAPS).map(([c, label]) => html`<span class="mx-cap${m.capabilities.includes(c) ? ' is-on' : ' is-off'}">${m.capabilities.includes(c) ? '✓' : '—'} ${label}</span>`)}</p>
++ <p class="mx-hint">Reads ${(((m.modalities || {}).input) || ['text']).join(', ')}; writes ${(((m.modalities || {}).output) || ['text']).join(', ')}.</p>
++</section>
++<section class="mx-card" aria-labelledby="mx-q-h">
++ <h2 id="mx-q-h">Quality</h2>
++ ${m.quality && m.quality.score != null ? html`<p class="mx-score"><b>${m.quality.score}</b> on OpenVibe Bench, from ${n(m.quality.samples)} samples.</p>` : html`<p>Not benchmarked yet. It is measured on its own on <a href="https://bench.openvibe.services">OpenVibe Bench</a> before it serves paid traffic; a variant never borrows its base model's score.</p>`}
++</section>
++</div>
++<section class="mx-card mx-use" aria-labelledby="mx-use-h">
++ <h2 id="mx-use-h">Call it</h2>
++ ${snippet('mx-s-curl', 'curl', snippetCurl)}
++ <details class="mx-more"><summary>More ways (3)</summary>${snippet('mx-s-js', 'OpenAI SDK · JavaScript', snippetJs)}${snippet('mx-s-py', 'OpenAI SDK · Python', snippetPy)}${snippet('mx-s-an', 'Anthropic SDK · JavaScript', snippetAnthropic)}</details>
++</section>
++</div>`;
++ page(req, res, { title: m.name, description: `${m.name}: ${m.id}`, crumbs, body });
++ });
++
++ return r;
++}
++
++module.exports = { createModelRoutes };
ddiff --git a/server/http/money-docs.js b/server/http/money-docs.js
nnew file mode 100644
iindex 0000000..cc6b9f7
---- /dev/null
++++ b/server/http/money-docs.js
@@@ -0,0 +1,270 @@
++'use strict';
++
++/**
++ * GET /docs/vibes and /docs/balance (D12, MONEY.md): the developer guides to Vibes Pay and to a project's Balance.
++ *
++ * They are hand-written guides, not generated from a package: the platform's money decisions are the source, and
++ * every number is quoted from Billing's own surfaces. A "Preview" note says plainly that the paid surfaces open when
++ * the owner enables Billing's billing.vibes / billing.credits namespaces; the sandbox works without real money.
++ *
++ * /docs/vibes what Vibes are, Vibes Pay step by step (SDK and plain HTTP), webhooks and their signature,
++ * sandbox, refunds, payouts, limits
++ * /docs/balance how metered usage bills a project's Balance, adding money, moving money to a project
++ */
++
++const { asyncRouter } = require('./router');
++const { html } = require('../render/html');
++const { send } = require('../render/layout');
++
++const BILLING = 'https://billing.openvibe.network';
++const PAY = 'https://openvibe.network/pay';
++
++function block(id, code) {
++ const slug = id.replace(/[^a-z]+/gi, '-').toLowerCase();
++ return html`<div class="mx-snippet"><div class="mx-snippet-head"><span>${id}</span><button type="button" class="mx-copy" data-mx-copy="#doc-${slug}" hidden>Copy</button></div><pre id="doc-${slug}" tabindex="0"><code>${code}</code></pre></div>`;
++}
++
++const row = (cells) => html`<tr>${cells.map((c, i) => (i ? html`<td>${c}</td>` : html`<th scope="row">${c}</th>`))}</tr>`;
++
++function preview() {
++ return html`<p class="mx-note" role="note"><b>Preview.</b> These surfaces open when OpenVibe enables Billing's
++money settings. The sandbox works now and never touches real money.</p>`;
++}
++
++function createMoneyDocsRoutes(appCtx) {
++ const { config } = appCtx;
++ const r = asyncRouter();
++
++ r.get('/docs/vibes', (req, res) => {
++ const body = html`<div class="mx mx-doc">
++<header class="mx-hero is-small">
++ <p class="mx-eye">Docs · Vibes</p>
++ <h1>Vibes, and Vibes Pay for your app</h1>
++ <p class="mx-lede">Vibes are OpenVibe's social currency: whole numbers, bought with money, spent on tips, cheers,
++ paid messages, consumer tools and in-app items. They are never earned, and they are not cash.</p>
++ ${preview()}
++ <nav class="mx-jump" aria-label="On this page"><a href="#what">What Vibes are</a><a href="#steps">Pay step by step</a><a href="#sdk">Server SDK</a><a href="#http">Plain HTTP</a><a href="#webhooks">Webhooks</a><a href="#sandbox">Sandbox</a><a href="#refunds">Refunds</a><a href="#payouts">Payouts</a><a href="#limits">Limits</a></nav>
++</header>
++
++<section class="mx-card" id="what" aria-labelledby="what-h">
++ <h2 id="what-h">What Vibes are</h2>
++ <p>One Vibes price is a <b>whole number</b>, set by the app or the product: 50 Vibes, 1,250 Vibes. A person gets
++ Vibes with <b>Get Vibes</b> (a pack with a card, or from their Balance) and spends them. Vibes are never
++ <b>earned</b>: OpenCoins are the reward currency. Vibes cannot be moved back into Balance; a creator or app that
++ receives Vibes cashes them out under the payout rules below.</p>
++ <p>Use Vibes when the price is social and simple — a tip, a cheer, a paid message, an image or another consumer
++ tool, an item in your app. Use <b>Balance</b> (dollars, exact to the nano-dollar) for metered and professional
++ usage: see <a href="/docs/balance">the Balance guide</a>. A product never asks a person to choose between the two
++ for the same thing.</p>
++</section>
++
++<section class="mx-card" id="steps" aria-labelledby="steps-h">
++ <h2 id="steps-h">Vibes Pay step by step</h2>
++ <ol class="mx-steps">
++ <li><b>Your server creates a charge</b> with your app token (capability <code>billing.vibes.charge</code>):
++ <code>amount</code> in whole Vibes, a <code>description</code>, your <code>reference</code> (the order id, and the
++ idempotency key) and a <code>return_url</code>. Billing answers a <code>pay_url</code> and a charge id.</li>
++ <li><b>The person pays on OpenVibe's sheet</b> at <code>pay_url</code>. It shows your app and the item, the
++ price in Vibes and their Vibes, with one button — “Pay 50 Vibes”, or “Get Vibes” when they are short. It never
++ shows a card form: the Vibes are already bought.</li>
++ <li><b>Billing moves the Vibes</b>, atomically and once, from the person to your project's Vibes wallet. The
++ charge becomes <code>paid</code>.</li>
++ <li><b>Your server learns it</b> from a signed webhook, and can always ask
++ <code>GET /api/v1/vibes/charges/:id</code>. Fulfil only after one of those says <code>paid</code>; never trust the
++ page's word alone.</li>
++ <li><b>Refund, pay out and watch the limits</b> as described below.</li>
++ </ol>
++ <p>In the browser, <code>openvibe-shared/vibes-pay.js</code> opens <code>pay_url</code> and resolves
++ <code>{ status }</code> from a <code>postMessage</code> whose origin is <code>https://openvibe.network</code>:</p>
++ ${block('Browser · vibes-pay.js', `<script src="https://openvibe.network/shared/vibes-pay.js" defer></script>\n\n<button data-ov-vibes-pay="${PAY}/vch_…">Pay with Vibes</button>\n\n<script>\n const { status } = await OpenVibe.vibes.pay('${PAY}/vch_…');\n if (status === 'paid') showThanks();\n</script>`)}
++ <p class="mx-hint">The button binds itself in the Vibes accent and both themes. The page result is a hint to
++ refresh; the server's webhook or charge read is the confirmation.</p>
++</section>
++
++<section class="mx-card" id="sdk" aria-labelledby="sdk-h">
++ <h2 id="sdk-h">Server SDK</h2>
++ <p><code>openvibe-sdk/vibes</code> is in openvibe-sdk v0.84.0 and later:</p>
++ ${block('Node · openvibe-sdk/vibes', `const { createVibesClient } = require('openvibe-sdk/vibes');\n\nconst vibes = createVibesClient({\n appToken: process.env.OPENVIBE_APP_TOKEN, // capability billing.vibes.charge\n billingUrl: '${BILLING}',\n});\n\nconst charge = await vibes.charges.create({\n amount: 50, // whole Vibes, never a float\n description: 'Sticker pack: Neon cats',\n reference: 'order_123', // your order id; also the Idempotency-Key\n return_url: 'https://myapp.example/thanks',\n});\n// { id: 'vch_…', pay_url: '${PAY}/vch_…', status: 'pending', expires_at: '…' }\n\nconst current = await vibes.charges.get(charge.id); // { status: 'paid', … }\nawait vibes.charges.refund(charge.id, { reason: 'requested by the buyer' });`)}
++ <p>The app token stays on your server. A token that expires can be supplied as a
++ <code>tokenProvider</code> (a function, or an SDK token client with <code>getToken()</code>/<code>invalidate()</code>);
++ a 401 invalidates it and retries once with the same <code>Idempotency-Key</code>. A float, zero or a negative
++ amount throws before anything is sent. Billing's problem+json becomes a <code>VibesError</code> with
++ <code>status</code>, <code>code</code>, <code>body</code> and <code>retryable</code>.</p>
++</section>
++
++<section class="mx-card" id="http" aria-labelledby="http-h">
++ <h2 id="http-h">Plain HTTP</h2>
++ <p>Everything the SDK does is these three requests. The app token carries <code>billing.vibes.charge</code>; the
++ person's token is not used by your server.</p>
++ ${block('Create a charge', `POST ${BILLING}/api/v1/vibes/charges\nAuthorization: Bearer $OPENVIBE_APP_TOKEN\nContent-Type: application/json\nIdempotency-Key: order_123\n\n{\n "amount": 50,\n "description": "Sticker pack: Neon cats",\n "reference": "order_123",\n "return_url": "https://myapp.example/thanks"\n}\n\n→ 201\n{ "id": "vch_…", "pay_url": "${PAY}/vch_…", "status": "pending", "expires_at": "…" }`)}
++ ${block('Read a charge', `GET ${BILLING}/api/v1/vibes/charges/vch_…\nAuthorization: Bearer $OPENVIBE_APP_TOKEN\n\n→ 200 { "id": "vch_…", "amount": 50, "status": "paid", "reference": "order_123", … }`)}
++ ${block('Refund a charge', `POST ${BILLING}/api/v1/vibes/charges/vch_…/refund\nAuthorization: Bearer $OPENVIBE_APP_TOKEN\nContent-Type: application/json\n\n{ "reason": "requested by the buyer" }`)}
++ <p>The <code>reference</code> is the idempotency key. The same reference with the same request is the same charge;
++ changing the request under it is refused, never charged twice. A create whose answer was lost can be retried
++ safely.</p>
++</section>
++
++<section class="mx-card" id="webhooks" aria-labelledby="webhooks-h">
++ <h2 id="webhooks-h">Webhooks and their signature</h2>
++ <p>Register an HTTPS endpoint for your app. Billing sends a signed delivery on <code>paid</code> and
++ <code>refunded</code> (event types <code>billing.vibes.charge.paid</code> and
++ <code>billing.vibes.charge.refunded</code>), retried with backoff until your server answers 2xx.</p>
++ <p>Every delivery carries <code>X-OpenVibe-Signature-V2</code>:</p>
++ <pre tabindex="0"><code>t=&lt;unix seconds&gt;,v2=&lt;hex HMAC-SHA256 of "&lt;t&gt;.&lt;raw body&gt;"&gt;</code></pre>
++ <p>Verify the <b>raw bytes</b> you received, not a re-serialised JSON object, with the app's webhook secret. Check
++ the signature in constant time, reject a timestamp more than 300 seconds from your clock, and return 2xx quickly.
++ The legacy <code>X-OpenVibe-Signature</code> (<code>sha256=&lt;hex&gt;</code> of the body alone) is still accepted
++ by the SDK for receivers that have not moved; it carries no time and can be replayed, so use v2.</p>
++ ${block('Node · verify the raw body', `const express = require('express');\nconst { verifyWebhook } = require('openvibe-sdk/vibes');\nconst app = express();\n\napp.post('/webhooks/vibes', express.raw({ type: 'application/json' }), (req, res) => {\n const ok = verifyWebhook(\n req.body, // a Buffer: the raw body\n req.get('X-OpenVibe-Signature-V2'),\n process.env.OPENVIBE_VIBES_WEBHOOK_SECRET,\n );\n if (!ok) return res.sendStatus(401);\n\n const event = JSON.parse(req.body.toString('utf8'));\n // Mark the order paid by event.data.reference (or the charge id); the same delivery may arrive twice.\n markPaid(event);\n res.sendStatus(204);\n});`)}
++ <p>The same check is <code>vibes.webhooks.verify(rawBody, signatureHeader, secret)</code> on the SDK client — pass
++ the raw bytes, never a re-serialised object. Delivery is at least once: key your work on the charge id (or your
++ reference) and do nothing when it is already paid. If a delivery never arrives, poll <code>GET /api/v1/vibes/charges/:id</code>; that read is the same
++ authority as the webhook.</p>
++</section>
++
++<section class="mx-card" id="sandbox" aria-labelledby="sandbox-h">
++ <h2 id="sandbox-h">Sandbox</h2>
++ <p>A sandbox app creates sandbox charges, paid with sandbox Vibes: test grants worth nothing. They never touch a
++ person's real Vibes or a project's real Vibes wallet, and a sandbox app never charges a production account. Use the
++ sandbox to exercise the whole flow — create, pay sheet, webhook, refund — before your app is reviewed for
++ production.</p>
++</section>
++
++<section class="mx-card" id="refunds" aria-labelledby="refunds-h">
++ <h2 id="refunds-h">Refunds</h2>
++ <p><code>POST /api/v1/vibes/charges/:id/refund</code> returns a paid charge's Vibes to the person, from your
++ project's Vibes wallet. The charge becomes <code>refunded</code> and the app gets the refunded webhook. A charge
++ can be refunded once; a repeat is answered from the charge's status rather than moving the Vibes again. Only the
++ app that created the charge may refund it.</p>
++</section>
++
++<section class="mx-card" id="payouts" aria-labelledby="payouts-h">
++ <h2 id="payouts-h">Payouts</h2>
++ <p>The Vibes your app receives sit in your project's Vibes wallet. The project's owner cashes them out under the
++ platform's payout rules — the same rules a creator's payable balance uses: a minimum, a hold, and a payout
++ provider. They are never converted into Balance. The current thresholds and hold are on
++ <a href="/docs/billing">the Billing policy page</a>, which renders Billing's own policy, not a copy.</p>
++</section>
++
++<section class="mx-card" id="limits" aria-labelledby="limits-h">
++ <h2 id="limits-h">Limits and errors</h2>
++ <p>The owner sets Vibes Pay's limits in OpenVibe's AI settings, namespace <code>billing.vibes</code>: the largest
++ single charge, a person's daily Vibes spend in apps, and whether Vibes Pay is on. Sandbox is separate.</p>
++ <div class="mx-table-wrap is-vs"><table class="mx-table mx-vs"><thead><tr><th scope="col">Status</th><th scope="col">Means</th></tr></thead><tbody>
++ ${row(['401', 'No app token, or it expired. Mint one with billing.vibes.charge.'])}
++ ${row(['402', 'The person is short of Vibes; show Get Vibes on the sheet and let them top up.'])}
++ ${row(['403', 'Vibes Pay is off for apps, or this app does not hold the capability.'])}
++ ${row(['404', 'No such charge for this app.'])}
++ ${row(['409', 'The reference was reused with a different request; never retry it as a new charge.'])}
++ ${row(['429', 'Too many requests; retry after the header says.'])}
++ </tbody></table></div>
++</section>
++</div>`;
++ send(res, 200, {
++ viewer: req.viewer, config, path: req.originalUrl, index: true,
++ title: 'Vibes and Vibes Pay', description: 'What Vibes are and how to sell something with Vibes Pay: the SDK, plain HTTP, webhooks, sandbox, refunds, payouts and limits.',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Vibes' }], css: ['css/models.css'], scripts: ['js/models.js'], body,
++ });
++ });
++
++ r.get('/docs/balance', (req, res) => {
++ const body = html`<div class="mx mx-doc">
++<header class="mx-hero is-small">
++ <p class="mx-eye">Docs · Balance</p>
++ <h1>Balance: dollars, exact to the nano-dollar</h1>
++ <p class="mx-lede">Balance is prepaid US dollars, one for one: $1 of Balance is $1. It pays for metered and
++ professional usage — the AI API, the Exchange, compute, developer tools and team plans. Vibes are for tips and
++ consumer products; <a href="/docs/vibes">the Vibes guide</a> is separate.</p>
++ ${preview()}
++ <nav class="mx-jump" aria-label="On this page"><a href="#who">Who holds one</a><a href="#usage">How usage bills</a><a href="#read">Read it</a><a href="#add">Add money</a><a href="#move">Move money</a><a href="#sandbox">Sandbox</a><a href="#words">The words</a></nav>
++</header>
++
++<section class="mx-card" id="who" aria-labelledby="who-h">
++ <h2 id="who-h">Who holds one</h2>
++ <ul class="mx-list">
++ <li><b>A person.</b> Your own playground runs and your own API calls with your personal token use your personal
++ Balance.</li>
++ <li><b>A project.</b> Usage by a project's apps and keys uses <b>the project's Balance</b>: the developer pays for
++ the app's usage. A project's admins add money to it directly, or move money in from their personal Balance.</li>
++ </ul>
++ <p>In Billing's ledger a person's Balance is a USD account owned by <code>user:&lt;usr_id&gt;</code>; a project's
++ is owned by <code>project:&lt;prj_id&gt;</code>. A project's page on OpenVibe.Services shows its Balance at
++ <code>/projects/&lt;prj_id&gt;/balance</code>.</p>
++</section>
++
++<section class="mx-card" id="usage" aria-labelledby="usage-h">
++ <h2 id="usage-h">How usage bills a project's Balance</h2>
++ <p>A request is priced before it starts. The most it can cost is <b>held</b> from the project's available
++ Balance; when it ends, only what was used is <b>charged</b> and the rest is released at once. A request that would
++ exceed the balance, or its own cap, is refused before it runs (<code>402 billing.insufficient_funds</code>). If a
++ provider fails mid-stream, only the tokens produced until then are charged; the cap is never the charge.</p>
++ <p>The arithmetic is integer <b>nano-USD</b> ($0.000000001), never a float. A model prices four token classes per
++ million tokens — input, cached read, cache write and output — plus an optional per-request fee. Each class is
++ rounded up once per request, then added:</p>
++ <pre tabindex="0"><code>charge = ceil(input_tokens × rate_in / 1e6)
++ + ceil(cached_tokens × rate_cached / 1e6)
++ + ceil(cache_write × rate_cache / 1e6)
++ + ceil(output_tokens × rate_out / 1e6)
++ + per_request_nano_usd</code></pre>
++ <p>For example, 1,200 input at $0.04/M, 8,000 cached at $0.01/M and 400 output at $0.12/M costs
++ <b>$0.0001136</b> — the exact integer Billing stores. A statement shows every charge with the key or app that
++ made it. Set <code>max_cost_usd</code> on a request to cap it.</p>
++</section>
++
++<section class="mx-card" id="read" aria-labelledby="read-h">
++ <h2 id="read-h">Read it</h2>
++ <p>With the person's Network user token, Billing answers their Balance and the projects they administer:</p>
++ ${block('GET /api/me/balance', `GET ${BILLING}/api/me/balance\nAuthorization: Bearer <the person's token>\n\n→ 200 {\n "personal": { "balance_nano_usd": "12400000000", "held_nano_usd": "0", "available_nano_usd": "12400000000" },\n "projects": [ { "owner": { "type": "project", "id": "prj_…" }, "balance_nano_usd": "…", … } ]\n}`)}
++ <p>A project's statement (its Balance, held and available, and its recent charges) is the same read scoped to the
++ project, with <code>project_id</code>; only an admin or owner of the project may see it. Everything is a decimal
++ string of integer nano-USD: parse it as a BigInt, never as a float.</p>
++</section>
++
++<section class="mx-card" id="add" aria-labelledby="add-h">
++ <h2 id="add-h">Add money</h2>
++ <p><b>Add money</b> in <a href="https://openvibe.network/wallet">the wallet</a> opens a top-up through any of the
++ platform's payment providers: PowerChat, Stripe, PayPal, CCBill or NOWPayments. The provider handles the card or
++ the wallet; OpenVibe never stores a card number. A project's admins add money to the project directly, or move
++ their own Balance in. A top-up is not enabled until the owner turns on the platform's top-up switch.</p>
++</section>
++
++<section class="mx-card" id="move" aria-labelledby="move-h">
++ <h2 id="move-h">Move money to a project</h2>
++ <p>Your Balance → a project's Balance is a transfer. It is atomic and idempotent, and it can never spend money a
++ running request is holding. Use <b>Move to a project</b> in the wallet, or the API:</p>
++ ${block('POST /api/me/balance/transfers', `POST ${BILLING}/api/me/balance/transfers\nAuthorization: Bearer <the person's token>\nContent-Type: application/json\n\n{\n "to_project_id": "prj_…",\n "amount_nano_usd": "5000000000",\n "idempotency_key": "move-2026-11-04-1"\n}\n\n→ 201 { "transfer": { … }, "from": { … }, "to": { … } }`)}
++ <p><code>amount_nano_usd</code> is a positive integer string: 5,000,000,000 is $5.00. The same idempotency key
++ with the same request replays the first answer; the same key with a different request is refused. Moving money
++ the other way — a project's Balance to a person — is not a top-up or a withdrawal; it is a refund or a payout under
++ Billing's rules.</p>
++</section>
++
++<section class="mx-card" id="sandbox" aria-labelledby="sandbox-h">
++ <h2 id="sandbox-h">Sandbox</h2>
++ <p>A sandbox app spends only its project's <b>sandbox Balance</b>, never the paid one. Test freely: no card, no
++ real money, and nothing a production request could spend. The project page shows the sandbox Balance apart from
++ the real Balance.</p>
++</section>
++
++<section class="mx-card" id="words" aria-labelledby="words-h">
++ <h2 id="words-h">The words</h2>
++ <ul class="mx-list">
++ <li><b>Balance</b> is dollars, added with <i>Add money</i> and used by metered and professional products.</li>
++ <li><b>Vibes</b> are the social currency, bought with <i>Get Vibes</i> and spent; never earned.</li>
++ <li><b>OpenCoins</b> are the reward currency, earned by watching and chatting.</li>
++ </ul>
++ <p>A product never asks a person to choose between Balance and Vibes for the same thing. Use Balance for exact,
++ metered usage; use <a href="/docs/vibes">Vibes</a> for tips, cheers and consumer items.</p>
++</section>
++</div>`;
++ send(res, 200, {
++ viewer: req.viewer, config, path: req.originalUrl, index: true,
++ title: 'Balance', description: 'How a project\'s Balance pays for metered usage, adding money and moving money to a project.',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Balance' }], css: ['css/models.css'], scripts: ['js/models.js'], body,
++ });
++ });
++
++ return r;
++}
++
++module.exports = { createMoneyDocsRoutes };
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 4baaac8..46e74de 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -88,6 +88,7 @@ const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
function createPortalRoutes(ctx) {
const { config, docs, network, sso, releases, trust, playground, archiver, limits, actorLimits, log } = ctx;
++ const modelCatalog = ctx.modelCatalog || require('../domain/model-catalog').createModelCatalog({ config, log });
const r = asyncRouter();
const B = (name) => actorLimits.budget(name);
const form = express.urlencoded({ extended: false, limit: '96kb' });
@@@ -877,6 +878,14 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
const c = csrf(req);
const events = playground.precheck(app, 'events');
const media = playground.precheck(app, 'media');
++ // The AI playground (AI Exchange P8, SERVICES_AI_PLAYGROUND): models from OpenVibe AI's catalog when it is the
++ // source; until then only the dynamic name that runs today.
++ const ai = config.playground.ai ? playground.precheck(app, 'ai') : null;
++ let aiModels = [{ id: 'openvibe', name: 'Auto (OpenVibe picks)' }];
++ if (ai && modelCatalog.source === 'ai') {
++ const cat = await modelCatalog.list().catch(() => ({ models: [], aliases: [] }));
++ aiModels = [...cat.aliases, ...cat.models.filter((m) => m.availability === 'available')].map((m) => ({ id: m.id, name: m.name, variant: m.variant }));
++ }
const credFields = html`<fieldset><legend>The app's credential (used for this request only, never stored)</legend>
<label><input type="radio" name="credential_type" value="client_secret" checked> client secret — Services asks Network for a 5-minute token with it</label>
<label><input type="radio" name="credential_type" value="access_token"> access token you minted for this app</label>
@@@ -901,6 +910,13 @@ ${credFields}<button type="submit">Publish test event</button></form>` : ''}
<p class="muted small">Needs <code>media.object.upload</code> (audience <code>openvibe.media</code>) for the project's namespace <code>${project.id}</code>. Endpoint: <code>${config.playground.mediaUrl}</code>. At most ${config.playground.maxUploadBytes} bytes.</p>
${pre(media)}${media.ok ? html`<form method="post" action="${base}/playground/media" enctype="multipart/form-data" class="stack">${csrfField(c)}
<label>File <input type="file" name="file" required></label>${credFields}<button type="submit">Upload</button></form>` : ''}
++${ai ? html`<h2>${playground.KINDS.ai.label}</h2>
++<p class="muted small">Needs <code>ai.app.run</code> (audience <code>openvibe.ai</code>). One reply, not streamed. The prompt and the answer are shown here once and never stored; the run log keeps the outcome only. Endpoint: <code>${config.playground.aiUrl}/v1/chat/completions</code>.</p>
++${pre(ai)}${ai.ok ? html`<form method="post" action="${base}/playground/ai" class="stack">${csrfField(c)}
++<label>Model <select name="model" data-ov-title="Model">${aiModels.map((m) => html`<option value="${m.id}"${values.model === m.id ? raw(' selected') : ''}${m.variant ? raw(` data-desc="${m.variant}"`) : ''}>${m.name}</option>`)}</select></label>
++<label>Prompt <textarea name="prompt" rows="4" maxlength="4000" required>${values.prompt || 'Say hello in five words.'}</textarea></label>
++<label>Longest reply (tokens) <input name="max_tokens" type="number" min="1" max="1024" value="${values.max_tokens || 256}"></label>
++${credFields}<button type="submit">Ask</button></form>` : ''}` : ''}
<h2>Recent runs</h2>
${table(['When', 'Playground', 'Credential', 'Outcome', 'Stage', 'HTTP', 'Code', 'Detail', 'Result'], runs.map((x) => [
time(x.at), x.kind, x.credential, badge(x.outcome, x.outcome === 'ok' ? 'ok' : (x.outcome === 'failed' ? 'bad' : 'warn')), x.stage, x.http_status || '', x.code ? code(x.code) : '', x.detail, x.ref ? code(x.ref) : '',
@@@ -911,11 +927,102 @@ ${table(['When', 'Playground', 'Credential', 'Outcome', 'Stage', 'HTTP', 'Code',
function runResult(r) {
return html`<div class="result ${r.outcome === 'ok' ? 'ok' : 'bad'}" role="status"><p><strong>${r.outcome === 'ok' ? 'Done.' : (r.outcome === 'refused' ? 'Refused.' : 'Failed.')}</strong> ${r.detail}</p>
<p class="muted small">stage ${r.stage}${r.http_status ? ` · HTTP ${r.http_status}` : ''}${r.code ? html` · <code>${r.code}</code>` : ''}${r.runId ? html` · run <code>${r.runId}</code>` : ''}</p>
--${r.result ? html`<pre tabindex="0"><code>${JSON.stringify(r.result, null, 2)}</code></pre>` : ''}</div>`;
++${r.result && r.kind === 'ai' ? aiResult(r.result) : r.result ? html`<pre tabindex="0"><code>${JSON.stringify(r.result, null, 2)}</code></pre>` : ''}</div>`;
++ }
++
++ /** An AI reply: the answer, which model really answered and why (the x_openvibe disclosure), and the tokens. */
++ function aiResult(x) {
++ const o = x.openvibe || {};
++ const u = x.usage || {};
++ return html`<div class="ai-reply"><p class="muted small">Answered by <code>${o.resolved_model || x.model}</code>${o.revision ? html` @ <code>${o.revision}</code>` : ''}${o.provider_access ? html` · ${o.provider_access}` : ''}${o.policy ? html` · policy ${o.policy}` : ''}${o.fallback_reason ? html` · ${o.fallback_reason}` : ''}</p>
++<blockquote class="ai-answer">${x.reply || '(an empty reply)'}</blockquote>
++<p class="muted small">${u.prompt_tokens != null ? `${u.prompt_tokens} tokens in · ${u.completion_tokens} out` : 'No usage reported'}${x.finish_reason ? ` · ended: ${x.finish_reason}` : ''}${o.rate_version ? html` · price version <code>${o.rate_version}</code>` : ''}</p></div>`;
}
const statusFor = (out) => (out.outcome === 'ok' ? 200 : (out.stage === 'grant' || out.stage === 'app' ? 403 : (out.stage === 'input' ? 422 : (out.stage === 'rate' ? 429 : (out.stage === 'token' ? (out.http_status && out.http_status >= 400 ? out.http_status : 401) : 502)))));
++ // ── Balance (D12, the D9 page renamed): the project's prepaid dollars and what they paid for; Billing is the authority ──
++ const balanceReader = ctx.balanceReader || require('../domain/balance').createBalanceReader({});
++ // The old address keeps working: /credits was the page's name before D12.
++ r.get('/:project/credits', idParams, (req, res) => res.redirect(301, `/projects/${encodeURIComponent(req.params.project)}/balance`));
++ r.get('/:project/balance', idParams, async (req, res, next) => {
++ if (!config.modelExplorer) return next();
++ const project = await net(req, res, (t) => network.projects.get(t, req.params.project));
++ if (!project.ok) return problemPage(req, res, project.problem, { back: '/projects' });
++ const { usd } = require('../domain/model-catalog');
++ const { daily } = require('../domain/balance');
++ let st = null; let problem = null;
++ try { st = await sso.asViewer(req, res, (t) => balanceReader.statement(t, req.params.project)); } catch (e) {
++ problem = { status: e.status || 502, code: e.code || 'services.billing_unavailable', detail: e.status === 404 ? 'Balance is not open yet.' : String(e.message || 'Billing did not answer').slice(0, 200) };
++ }
++ const base = `/projects/${req.params.project}`;
++ const tile = (label, nano, note) => html`<div class="cr-tile"><span>${label}</span><b>${usd(nano)}</b>${note ? html`<small>${note}</small>` : ''}</div>`;
++ let bodyMain;
++ if (!st) {
++ bodyMain = html`<div class="mx-note${problem && problem.status === 404 ? '' : ' is-bad'}" role="status">${problem.detail}</div>`;
++ } else {
++ const days = daily(st.charges, 30);
++ const max = days.reduce((m, d) => (d.nano > m ? d.nano : m), 1n);
++ const total = days.reduce((m, d) => m + d.nano, 0n);
++ const n = (x) => Number(x || 0).toLocaleString('en-US');
++ // Sandbox Balance (D10) is its own account: it pays only for sandbox apps' test requests, never a real one.
++ const box = st.sandbox && st.sandbox.available_nano_usd != null ? st.sandbox : null;
++ bodyMain = html`<div class="cr-tiles">${tile('Balance', st.balance_nano_usd)}${tile('Held now', st.held_nano_usd, 'by requests still running')}${tile('Available', st.available_nano_usd)}</div>
++${box ? html`<p class="cr-sandbox"><span class="mx-badge">Sandbox</span> <span><b>${usd(box.available_nano_usd)}</b> of test Balance for your sandbox apps. It is not part of your balance and never pays for a real request.</span></p>` : ''}
++<section class="mx-card"><h2>Spent in the last 30 days <small class="cr-total">${usd(total)}</small></h2>
++<div class="cr-chart" role="img" aria-label="${`Spend per day for 30 days, ${usd(total)} in all`}">${days.map((d) => html`<span class="cr-bar" title="${`${d.day}: ${usd(d.nano)}`}"><span style="height:${d.nano === 0n ? 0 : Math.max(3, Number((d.nano * 100n) / max))}%"></span></span>`)}</div>
++<p class="mx-hint">${days[0].day} to ${days[days.length - 1].day}, UTC.</p></section>
++<section class="mx-card"><h2>Recent requests</h2>
++${st.charges.length ? html`<div class="mx-table-wrap"><table class="mx-table"><thead><tr><th scope="col">When</th><th scope="col">Model</th><th scope="col" class="mx-num">Tokens in · cached · out</th><th scope="col" class="mx-num">Charge</th></tr></thead><tbody>
++${st.charges.map((c) => { const u = c.usage || {}; return html`<tr><td data-label="When">${String(c.at || '').replace('T', ' ').slice(0, 16)} UTC</td><td data-label="Model"><code class="mx-id">${c.model || c.service}</code>${c.byok ? html` <span class="mx-badge is-new">Your key</span>` : ''}${c.shortfall_nano_usd && c.shortfall_nano_usd !== '0' ? html` <span class="mx-badge is-warn">Short ${usd(c.shortfall_nano_usd)}</span>` : ''}</td><td class="mx-num" data-label="Tokens">${n(u.input_tokens)} · ${n(u.cached_read_tokens)} · ${n(u.output_tokens)}</td><td class="mx-num mx-strong" data-label="Charge">${usd(c.charge_nano_usd)}</td></tr>`; })}
++</tbody></table></div>` : html`<p class="mx-empty">No requests yet. <a href="/docs/ai#start">Make the first one</a>.</p>`}</section>`;
++ }
++ page(req, res, {
++ title: `Balance · ${project.data.name}`, css: ['css/models.css'], scripts: ['js/models.js'],
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.data.name, href: base }, { label: 'Balance' }],
++ body: html`<div class="mx"><header class="mx-hero is-small"><p class="mx-eye">Balance</p><h1>${project.data.name}</h1><p class="mx-lede">The prepaid dollars this project's AI requests spend, exact to the nano-dollar. Each request holds at most its cap, then is charged only what it used.</p>
++<p class="cr-topup"><button type="button" class="mx-apply" disabled>Add money</button> <span class="mx-hint">Adding money opens when Balance launches.</span></p></header>${bodyMain}</div>`,
++ });
++ });
++
++ // ── Start with OpenVibe AI (AI Exchange P8): the steps to a first call, each read live, nothing stored ──
++ r.get('/:project/ai', idParams, async (req, res, next) => {
++ if (!config.modelExplorer) return next();
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const base = `/projects/${project.id}`;
++ const got = await net(req, res, (t) => network.projects.apps(t, project.id));
++ const list = got.ok ? (Array.isArray(got.data) ? got.data : (got.data && got.data.apps) || []) : [];
++ const sandbox = list.filter((a) => a.environment === 'sandbox' && !a.revoked_at);
++ const app = sandbox.find((a) => (a.grants || []).includes('ai.app.run')) || sandbox[0] || null;
++ const granted = !!(app && (app.grants || []).includes('ai.app.run'));
++ const runs = app ? await playground.runsFor(app.id).catch(() => []) : [];
++ const called = runs.some((x) => x.kind === 'ai' && x.outcome === 'ok');
++ let balance = null;
++ try { balance = await sso.asViewer(req, res, (t) => balanceReader.statement(t, project.id)); } catch { balance = null; }
++ const { usd } = require('../domain/model-catalog');
++ const funded = !!(balance && balance.balance_nano_usd && balance.balance_nano_usd !== '0');
++ const c = csrf(req);
++ const steps = [
++ { done: !!app, title: 'A sandbox app', text: app ? html`<b>${app.name}</b> runs in the sandbox: it can try everything and spend nothing real.` : html`An app is what calls the API. Make a sandbox one first; production comes when you are ready.`, action: app ? '' : html`<a class="mx-apply" href="${base}#apps">Make a sandbox app</a>` },
++ { done: granted, title: 'Permission to call AI', text: html`The app needs <code>ai.app.run</code>. Sandbox apps get it at once.`, action: app && !granted ? html`<form method="post" action="${base}/apps/${app.id}/grants">${csrfField(c)}<input type="hidden" name="capability" value="ai.app.run"><button class="mx-apply" type="submit">Ask for it</button></form>` : '' },
++ { done: !!app, title: 'Its key', text: html`The app's secret was shown once when it was made; OpenVibe keeps only its hash. Lost it? Rotate it on the app's page.`, action: app ? html`<a class="mx-copy" href="${base}/apps/${app.id}">The app's page</a>` : '' },
++ { done: called, title: 'A first call', text: html`Ask anything in the playground, as the app: you see the answer, the model that gave it, and what it cost.`, action: app && granted ? html`<a class="mx-apply" href="${base}/apps/${app.id}/playground#ai">Open the playground</a>` : '' },
++ { done: funded, title: 'Balance', text: balance ? html`Balance <b>${usd(balance.balance_nano_usd)}</b>. Paid models spend Balance; your own keys and the sandbox never do.` : html`Balance is not open yet: the sandbox and your own keys work without them.`, action: html`<a class="mx-copy" href="${base}/balance">See Balance</a>` },
++ ];
++ const nextStep = steps.findIndex((x) => !x.done);
++ const done = steps.filter((x) => x.done).length;
++ page(req, res, {
++ title: `Start with AI · ${project.name}`, css: ['css/models.css'], scripts: ['js/models.js'],
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: base }, { label: 'AI' }],
++ body: html`<div class="mx"><header class="mx-hero is-small"><p class="mx-eye">Start with OpenVibe AI</p><h1>From nothing to a first answer</h1>
++<p class="mx-lede">${done} of ${steps.length} done. Each step checks itself; come back any time.</p>
++<div class="ob-bar" role="progressbar" aria-label="Steps done" aria-valuemin="0" aria-valuemax="${steps.length}" aria-valuenow="${done}"><span style="width:${Math.round((done / steps.length) * 100)}%"></span></div></header>
++<ol class="ob-steps">${steps.map((x, i) => html`<li class="mx-card ob-step${x.done ? ' is-done' : ''}${i === nextStep ? ' is-next' : ''}"><span class="ob-n" aria-hidden="true">${x.done ? '✓' : i + 1}</span><div class="ob-body"><h2>${x.title}${x.done ? html` <span class="mx-badge is-ok">Done</span>` : i === nextStep ? html` <span class="mx-badge is-new">Next</span>` : ''}</h2><p>${x.text}</p>${x.action ? html`<div class="ob-action">${x.action}</div>` : ''}</div></li>`)}</ol>
++<section class="mx-card"><h2>Then, from your code</h2><p>The base URL is <code>https://ai.openvibe.services/v1</code> for the OpenAI SDK. <a href="/docs/ai">The guide</a> has every SDK, the model names and the prices; <a href="/models">the models</a> are all here.</p></section></div>`,
++ });
++ });
++
r.get('/:project/apps/:app/playground', idParams, async (req, res) => await playgroundPage(req, res));
r.post('/:project/apps/:app/playground/events', idParams, B('services.playground.run'), form, guard, async (req, res) => {
@@@ -931,6 +1038,18 @@ ${r.result ? html`<pre tabindex="0"><code>${JSON.stringify(r.result, null, 2)}</
return await playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
});
++ r.post('/:project/apps/:app/playground/ai', idParams, B('services.playground.run'), form, guard, async (req, res) => {
++ const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
++ if (!got.ok) return problemPage(req, res, got.problem, { back: appBase(req) });
++ const b = req.body || {};
++ const out = await playground.run({
++ actor: `user:${req.viewer.subject}`, app: got.data, kind: 'ai',
++ credential: { type: b.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: typeof b.credential === 'string' ? b.credential.trim() : '' },
++ input: { model: b.model, prompt: b.prompt, max_tokens: b.max_tokens },
++ });
++ return await playgroundPage(req, res, { result: { ...out, kind: 'ai' }, values: { model: b.model, prompt: b.prompt, max_tokens: b.max_tokens }, status: statusFor(out) });
++ });
++
r.post('/:project/apps/:app/playground/media', idParams, B('services.playground.run'), async (req, res) => {
if (!sameOrigin(config, req)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
let parsed;
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex af1a1da..f9e496f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -51,7 +51,8 @@ function setRelease(id) { if (id) RELEASE = String(id); }
/**
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
-- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names],
++ * css [this site's own stylesheets for one page, e.g. 'css/models.css']
*/
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
@@@ -99,6 +100,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'services' })}
<link rel="stylesheet" href="${asset('css/services.css')}">
++${(o.css || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${render(scripts)}
<meta name="ov-boost" content="services@${esc(RELEASE)}">
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex e3eb967..1bc2ea0 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -41,7 +41,7 @@ async function startNetworkStub() {
tables: TABLES,
taskKeyed: ["manifests","releases"],
-- exempt: {account_data_events: 'the ledger of requests itself: which export and deletion Services applied'},
++ exempt: {account_data_events: 'the ledger of requests itself: which export and deletion Services applied', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data' },
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
ddiff --git a/test/balance-page.test.js b/test/balance-page.test.js
nnew file mode 100644
iindex 0000000..3aba7ee
---- /dev/null
++++ b/test/balance-page.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * A project's Balance page (D12, the D9 page renamed): the balance, held and available from Billing's project
++ * statement with the person's own token, spend per day, recent requests with exact charges; "not open yet" when
++ * Billing's ledger is off; the old /credits address redirects; nothing without the explorer's flag.
++ */
++const assert = require('assert');
++const http = require('http');
++const { daily } = require('../server/domain/balance');
++
++(async () => {
++ const asked = [];
++ let mode = 'open';
++ const billing = http.createServer((req, res) => {
++ asked.push({ url: req.url, auth: req.headers.authorization || '' });
++ if (mode === 'off') { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ code: 'billing.not_enabled', detail: 'Balance is not open yet' })); }
++ const today = new Date().toISOString();
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({
++ project_id: 'x', balance_nano_usd: '4999896', held_nano_usd: '20000', available_nano_usd: '4979896',
++ sandbox: { balance_nano_usd: '1000000', held_nano_usd: '0', available_nano_usd: '1000000' },
++ charges: [
++ { at: today, settlement_id: 'stl_1', service: 'ai', model: 'qwen/qwen3-4b:q4_k_m@3f2a1c9', charge_nano_usd: '104', shortfall_nano_usd: '0', byok: false, usage: { input_tokens: 1000, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 1000 } },
++ { at: today, settlement_id: 'stl_2', service: 'ai', model: 'deepseek/deepseek-v3.2@preview', charge_nano_usd: '0', shortfall_nano_usd: '0', byok: true, usage: { input_tokens: 50, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 9 } },
++ ],
++ }));
++ });
++ await new Promise((r) => billing.listen(0, '127.0.0.1', r));
++ process.env.OV_BILLING_INTERNAL_URL = `http://127.0.0.1:${billing.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++
++ await check('spend per day: the last 30 days, exact', async () => {
++ const now = Date.UTC(2026, 10, 3, 12);
++ const d = daily([{ at: '2026-11-03T01:00:00Z', charge_nano_usd: '104' }, { at: '2026-11-03T02:00:00Z', charge_nano_usd: '6' }, { at: '2026-10-01T00:00:00Z', charge_nano_usd: '9' }], 30, now);
++ assert.strictEqual(d.length, 30);
++ assert.strictEqual(d[29].day, '2026-11-03');
++ assert.strictEqual(d[29].nano, 110n);
++ assert.strictEqual(d.reduce((m, x) => m + x.nano, 0n), 110n, 'a day outside the window is not counted');
++ });
++
++ const t = await boot();
++ const owner = t.network.addUser('nia');
++ const projectId = await t.project(owner, 'Spend');
++ try {
++ await check('off by default', async () => {
++ assert.strictEqual((await t.get(`/projects/${projectId}/balance`, { as: owner })).status, 404);
++ });
++ await t.ctx.aiConfig.store.apply({ model_explorer: true }, { actor: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, reason: 'test: open the explorer' });
++ await check('the page: balance, held, available, the requests, with the person\'s own token', async () => {
++ const r = await t.get(`/projects/${projectId}/balance`, { as: owner });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(asked.at(-1).url, new RegExp(`^/api/me/credits\\?project_id=${projectId}$`), 'Billing\'s project statement (the D12 wire alias)');
++ assert.match(asked.at(-1).auth, /^Bearer ey/, 'the person\'s own token');
++ assert.match(r.text, /<span>Balance<\/span><b>\$0\.00499<\/b>/);
++ assert.match(r.text, /class="cr-sandbox"><span class="mx-badge">Sandbox<\/span> <span><b>\$0\.001<\/b> of test Balance/, 'sandbox Balance is shown apart, never in the balance');
++ assert.match(r.text, /<span>Held now<\/span><b>\$0\.00002<\/b>/);
++ assert.match(r.text, /<code class="mx-id">qwen\/qwen3-4b:q4_k_m@3f2a1c9<\/code>/);
++ assert.match(r.text, /\$0\.000000104/);
++ assert.match(r.text, /Your key/, 'a BYOK request says so');
++ assert.match(r.text, /Adding money opens when Balance launches/);
++ assert.ok(!/Credits/i.test(r.text), 'the public word is Balance');
++ });
++ await check('start with AI: each step read live, the next one marked, its action offered', async () => {
++ const page = async () => (await t.get(`/projects/${projectId}/ai`, { as: owner })).text;
++ let h = await page();
++ assert.match(h, /0 of 5 done|1 of 5 done/);
++ assert.match(h, /class="mx-card ob-step is-next"><span class="ob-n" aria-hidden="true">1<\/span>/, 'first: a sandbox app');
++ const app = await t.app(owner, projectId);
++ h = await page();
++ assert.match(h, /<span class="ob-n" aria-hidden="true">2<\/span>[\s\S]*?Next/, 'then: the permission');
++ assert.match(h, new RegExp(`action="/projects/${projectId}/apps/${app.id}/grants"`), 'with the button that asks for it');
++ await t.grant(owner, projectId, app.id, 'ai.app.run');
++ h = await page();
++ assert.match(h, new RegExp(`href="/projects/${projectId}/apps/${app.id}/playground#ai"`), 'then: the playground');
++ assert.match(h, /Balance <b>\$0\.00499<\/b>/, 'and the Balance, read from Billing');
++ assert.match(h, new RegExp(`href="/projects/${projectId}/balance"`), 'the step links to the Balance page, not the old address');
++ });
++
++ await check('Billing\'s ledger off: "not open yet", not an error page', async () => {
++ mode = 'off';
++ const r = await t.get(`/projects/${projectId}/balance`, { as: owner });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Balance is not open yet\./);
++ });
++ await check('the old /credits address redirects to /balance', async () => {
++ const r = await t.get(`/projects/${projectId}/credits`, { as: owner });
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), `/projects/${projectId}/balance`);
++ });
++ } finally {
++ await t.close();
++ billing.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 204ffd9..b709e22 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -15,6 +15,7 @@ const { RESOURCE_READ } = require('../server/registry/resource-index');
const { check, done } = require('./helpers/boot');
const CONTROL = 'services.resource.control';
++const CONFIG = 'services.config.manage';
(async () => {
const manifest = contracts.services.get('services');
@@@ -35,7 +36,7 @@ const CONTROL = 'services.resource.control';
// Contracts 0.164 also lists three planned capabilities (recipe run, usage and cost reads): declared, not served
// or guarded yet, so they stay planned here.
const PLANNED = ['services.recipe.run', 'services.usage.read', 'services.cost.read'];
-- assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ, RESOURCE_READ, CONTROL, ...PLANNED].sort());
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ, RESOURCE_READ, CONTROL, CONFIG, ...PLANNED].sort());
for (const id of PLANNED) assert.strictEqual(contracts.capabilities.get(id).status, 'planned', `${id} is not guarded by this code`);
for (const id of [MANAGE, READ]) {
const c = contracts.capabilities.get(id);
@@@ -43,6 +44,13 @@ const CONTROL = 'services.resource.control';
assert.strictEqual(c.owner, 'services');
assert.strictEqual(c.status, 'active');
}
++ // services.config.manage (D11) guards /internal/config; it is internal, so only Network's token may hold it.
++ const configCap = contracts.capabilities.get(CONFIG);
++ assert.ok(configCap, CONFIG);
++ assert.strictEqual(configCap.owner, 'services');
++ assert.strictEqual(configCap.status, 'active');
++ assert.strictEqual(configCap.visibility, 'internal');
++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server', 'ai-config.js'), 'utf8'), /services\.config\.manage/);
const resource = contracts.capabilities.get(RESOURCE_READ);
assert.ok(resource, RESOURCE_READ);
assert.strictEqual(resource.owner, 'services');
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex 41131c5..9a3deab 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -56,18 +56,29 @@ const sdkPkg = require('openvibe-sdk/package.json');
await check('one page per contract in the catalog, with its version, fields and fixtures', async () => {
const index = (await t.get('/docs/contracts')).text;
++ // Every major of an id in the catalog has a page: the latest at /docs/contracts/<id>, an older one at <id>@<major>.
++ const majorOf = (c) => Number(c.version.split('.')[0]);
++ const latest = new Map();
++ for (const c of contracts.catalog) latest.set(c.id, Math.max(latest.get(c.id) || 0, majorOf(c)));
for (const c of contracts.catalog) {
-- assert.ok(index.includes(`/docs/contracts/${c.id}"`), `${c.id} listed`);
-- const r = await t.get(`/docs/contracts/${c.id}`);
-- assert.strictEqual(r.status, 200, c.id);
-- assert.ok(r.text.includes(`<dd>${c.version}</dd>`), `${c.id} version`);
-- const schema = contracts.schema(c.id);
-- for (const field of Object.keys(schema.properties || {})) assert.ok(r.text.includes(`<code>${field}</code>`), `${c.id} field ${field}`);
++ const isLatest = majorOf(c) === latest.get(c.id);
++ const ref = isLatest ? c.id : `${c.id}@${majorOf(c)}`;
++ assert.ok(index.includes(`/docs/contracts/${ref}"`), `${ref} listed`);
++ const r = await t.get(`/docs/contracts/${ref}`);
++ assert.strictEqual(r.status, 200, ref);
++ assert.ok(r.text.includes(`<dd>${c.version}</dd>`), `${ref} version`);
++ const schema = contracts.schema(`${c.id}@${majorOf(c)}`);
++ for (const field of Object.keys(schema.properties || {})) assert.ok(r.text.includes(`<code>${field}</code>`), `${ref} field ${field}`);
const fixtureDir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', c.id, 'valid');
-- for (const f of fs.existsSync(fixtureDir) ? fs.readdirSync(fixtureDir) : []) assert.ok(r.text.includes(f.replace(/\.json$/, '')), `${c.id} fixture ${f}`);
-- const raw = await t.get(`/docs/contracts/${c.id}.json`);
++ if (isLatest) for (const f of fs.existsSync(fixtureDir) ? fs.readdirSync(fixtureDir) : []) assert.ok(r.text.includes(f.replace(/\.json$/, '')), `${ref} fixture ${f}`);
++ const raw = await t.get(`/docs/contracts/${ref}.json`);
assert.deepStrictEqual(JSON.parse(raw.text), schema);
}
++ const older = contracts.catalog.find((c) => majorOf(c) < latest.get(c.id));
++ if (older) {
++ const r = await t.get(`/docs/contracts/${older.id}@${majorOf(older)}`);
++ assert.ok(r.text.includes(`/docs/contracts/${older.id}"`), 'an older major links the current one');
++ }
assert.strictEqual((await t.get('/docs/contracts/no.such-contract')).status, 404);
// The way the platform writes a contract, name@major, opens its page; another major does not.
const one = contracts.catalog[0];
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 74b7dd3..89a17af 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -55,7 +55,7 @@ async function boot(opts = {}) {
const authorities = opts.registry || registryOf(stubs);
const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });
const { createStore } = require('../../server/db');
-- const built = await createApp({ config, store: createStore(store.db), authorities, log, actorLimits: false, ...(opts.appOpts || {}) });
++ const built = await createApp({ config, store: createStore(store.db), authorities, log, actorLimits: false, env, ...(opts.appOpts || {}) });
const app = built.app;
Object.assign(app.locals, built.ctx);
await built.ctx.keys.ensure();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 5f8b0ed..e0963be 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow, accountSend: opts.accountSend, authorities: opts.authorities });
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow, accountSend: opts.accountSend, authorities: opts.authorities, env });
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
ddiff --git a/test/internal-config.test.js b/test/internal-config.test.js
nnew file mode 100644
iindex 0000000..8aeeb49
---- /dev/null
++++ b/test/internal-config.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * D11 owner settings: the services.ai namespace and /internal/config.
++ *
++ * - services.config.manage is required; a service token without it is 403;
++ * - an env pin wins (read-only in the summary, a write is 409 config.pinned_in_env);
++ * - an owner's change applies live (the page reads the store, not process.env);
++ * - the boot reseal is optional: with no KEK the store still works and reports sealed: false;
++ * - a secret-class value never leaves a summary, history or the audit log.
++ *
++ * node test/internal-config.test.js
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++const CAP = ['services.config.manage'];
++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const SECRET = 'sk_services_d11_toppsecret_123456';
++const call = (t, method, p, o = {}) => t.call(method, p, {
++ cap: CAP, sub: 'svc:network', headers: { 'x-ov-subject': OWNER }, ...o,
++});
++
++(async () => {
++ const t = await boot({ env: { SERVICES_MODEL_EXPLORER: '1' } });
++ try {
++ await check('the summary: the namespace, its schema, the env pin, sealed:false without a KEK', async () => {
++ const r = await call(t, 'GET', '/internal/config/services.ai');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json.namespace, 'services.ai');
++ assert.strictEqual(r.json.sealed, false);
++ assert.strictEqual(r.json.values.model_explorer, true, 'the env pin is the effective value');
++ assert.deepStrictEqual(r.json.pins.model_explorer, { env: 'SERVICES_MODEL_EXPLORER', pinned: true });
++ assert.strictEqual(r.json.schema.properties.model_catalog_source.enum.join(','), 'preview,ai');
++ assert.ok(!r.text.includes(SECRET));
++ });
++
++ await check('a service token without services.config.manage is refused', async () => {
++ const r = await call(t, 'GET', '/internal/config/services.ai', { cap: ['services.resource.read'] });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json.code, 'capability.denied');
++ });
++
++ await check('an env-pinned key is read-only', async () => {
++ const r = await call(t, 'POST', '/internal/config/services.ai', { body: { values: { model_explorer: false }, reason: 'off' } });
++ assert.strictEqual(r.status, 409, r.text.slice(0, 300));
++ assert.strictEqual(r.json.code, 'config.pinned_in_env');
++ });
++
++ await check('an owner change applies live, and the audit line names the actor and the key, never a value', async () => {
++ const r = await call(t, 'POST', '/internal/config/services.ai', { body: { values: { ai_playground: true }, reason: 'open the playground' } });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json.revision, 2);
++ assert.strictEqual(t.config.playground.ai, true, 'the running process reads the new value');
++ assert.ok(t.logs.join('\n').includes('by user:' + OWNER), 'the audit line names the actor');
++ assert.ok(!t.logs.join('\n').includes(SECRET));
++ });
++
++ await check('no KEK still works, and reports sealed:false', async () => {
++ const r = await call(t, 'GET', '/internal/config/services.ai');
++ assert.strictEqual(r.json.sealed, false);
++ assert.ok(r.json.last_known_good, 'a revision is still recorded');
++ });
++
++ await check('with a KEK the store is sealed, and boot reseals without changing a value', async () => {
++ const sealed = await boot({ env: { OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });
++ try {
++ const r = await call(sealed, 'GET', '/internal/config/services.ai');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json.sealed, true);
++ assert.strictEqual(r.json.kek, 'k1');
++ assert.strictEqual(r.json.values.model_catalog_source, 'preview');
++ } finally { await sealed.close(); }
++ });
++
++ await check('a secret is write-only and never appears in a summary, a snapshot, the history or the audit log', async () => {
++ const shared = require('openvibe-shared/config');
++ const extra = await shared.createConfigStore({
++ db: t.db, service: 'services', namespace: 'services.test_secrets',
++ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },
++ classify: () => 'secret', log: { info: () => {}, warn: () => {}, error: () => {} },
++ });
++ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });
++ assert.strictEqual(marker.redacted, true);
++ assert.ok(!JSON.stringify(marker).includes(SECRET));
++ const summary = await extra.summary();
++ assert.strictEqual(summary.values.api_key.redacted, true);
++ assert.ok(!JSON.stringify(summary).includes(SECRET));
++ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));
++ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/models.test.js b/test/models.test.js
nnew file mode 100644
iindex 0000000..7654bfe
---- /dev/null
++++ b/test/models.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * The model explorer (AI Exchange P8): the nano-dollar arithmetic (CHECKPOINT D1), the catalog's filters and order,
++ * and the pages (/models, /models/compare, /models/:id) behind SERVICES_MODEL_EXPLORER, on the preview fixture.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const lib = require('../server/domain/model-catalog');
++
++(async () => {
++ await check('the arithmetic: once per token class, rounded up, in nano-dollars', async () => {
++ const m = { retail: { nano_usd_per_m: { input: '20000000', cached_read: '5000000', cache_write: '20000000', output: '60000000' }, per_request_nano_usd: '0' } };
++ // 1,200 input at $0.02/M = 24,000 nano; 400 output at $0.06/M = 24,000 nano.
++ assert.strictEqual(lib.cost(m, { input: 1200, output: 400 }), 48000n);
++ // One token at $0.02/M is 20 nano-dollars: exact, nothing rounded.
++ assert.strictEqual(lib.cost(m, { input: 1 }), 20n);
++ assert.strictEqual(lib.cost(m, { input: 1, cached_read: 1, cache_write: 1, output: 1 }), 105n, 'four classes added exactly');
++ // 1,000 tokens at 333 nano per million is 0.333 nano: rounded up once, to 1 nano, not to a cent and not per row.
++ const tiny = { retail: { nano_usd_per_m: { input: '333', cached_read: '333', cache_write: '333', output: '333' } } };
++ assert.strictEqual(lib.cost(tiny, { input: 1000 }), 1n);
++ assert.strictEqual(lib.cost(tiny, { input: 1000, cached_read: 1000, cache_write: 1000, output: 1000 }), 4n, 'each class rounded once');
++ assert.strictEqual(lib.cost(tiny, { input: 4000 }), 2n, 'one class, one rounding (1.332 → 2), never per token');
++ assert.strictEqual(lib.cost({ retail: { nano_usd_per_m: {}, per_request_nano_usd: '1500' } }, {}), 1500n);
++ assert.strictEqual(lib.usd(48000n), '$0.000048');
++ assert.strictEqual(lib.usd(4200000n), '$0.0042');
++ assert.strictEqual(lib.usd(20000000n), '$0.02');
++ assert.strictEqual(lib.usd(12500000000n), '$12.50');
++ assert.strictEqual(lib.usd(0n), '$0.00');
++ assert.strictEqual(lib.usd(1n), '$0.000000001');
++ assert.strictEqual(lib.usd(48000n, { per: 1000000 }), '$48.00');
++ assert.strictEqual(lib.usd('not money'), '$0.00', 'a malformed amount is nothing, never NaN');
++ });
++
++ await check('the catalog: filters and order', async () => {
++ const cat = lib.createModelCatalog({ config: {} });
++ const { models, aliases, source } = await cat.list();
++ assert.strictEqual(source, 'preview');
++ assert.ok(models.length >= 5 && aliases.every((a) => a.dynamic && a.id.startsWith('openvibe/')));
++ assert.ok(models.every((m) => /^[a-z0-9-]+\/[a-z0-9.-]+(:[a-z0-9_]+)?@[a-z0-9]+$/.test(m.id)), 'every id is exact (D3)');
++ const contracts = require('openvibe-contracts');
++ const raw = require('../server/fixtures/ai-models.preview.json').models;
++ for (const m of raw) { const v = contracts.validate('ai.model@1', m); assert.ok(v.valid, `${m.id}: ${JSON.stringify(v.errors).slice(0, 300)}`); }
++ assert.ok(models.every((m) => m.retail && m.retail.nano_usd_per_m && m.capabilities.length), 'normalized from the contract shape');
++ assert.ok(lib.filterModels(models, { variant: 'abliterated' }).every((m) => m.variant === 'abliterated'));
++ assert.ok(lib.filterModels(models, { cap: ['vision'] }).every((m) => m.capabilities.includes('vision')));
++ assert.ok(lib.filterModels(models, { access: 'managed' }).every((m) => m.access.some((a) => a === 'OWN_DEPLOYMENT' || a === 'AUTHORIZED_WHOLESALE')));
++ const sorted = lib.sortModels(models, 'price', 'chat');
++ for (let i = 1; i < sorted.length; i++) assert.ok(lib.cost(sorted[i - 1], lib.WORKLOADS.chat.usage) <= lib.cost(sorted[i], lib.WORKLOADS.chat.usage));
++ });
++
++ const t = await boot();
++ try {
++ await check('off by default: no page, no price promised', async () => {
++ assert.strictEqual((await t.get('/models')).status, 404);
++ });
++
++ // The owner changes services.ai from the AI tab: the running process reads the store, live.
++ await t.ctx.aiConfig.store.apply({ model_explorer: true }, { actor: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, reason: 'test: open the explorer' });
++ await check('the list: every model, a preview label, the aliases, filters that work without script', async () => {
++ const res = await t.get('/models');
++ assert.strictEqual(res.status, 200, res.text.slice(0, 300));
++ assert.match(res.text, /<b>Preview\.<\/b>/, 'example prices are said to be examples');
++ assert.match(res.text, /noindex/, 'never indexed');
++ assert.match(res.text, /<code>openvibe\/auto<\/code>/);
++ assert.match(res.text, /css\/models\.css/);
++ assert.match(res.text, /js\/models\.js/);
++ const ab = await t.get('/models?variant=abliterated&cap=tools');
++ assert.match(ab.text, /Qwen3 4B Abliterated/);
++ assert.doesNotMatch(ab.text, />Gemma 3 27B Abliterated</, 'no tools: filtered out');
++ assert.doesNotMatch(ab.text, />Qwen3 4B<\/a>/, 'the original is not an abliterated variant');
++ });
++
++ await check('a model: exact id, price, the calculator from the query, how to call it', async () => {
++ const id = 'qwen/qwen3-4b-abliterated:q4_k_m@preview';
++ const res = await t.get(`/models/${encodeURIComponent(id)}?input=1200&output=400`);
++ assert.strictEqual(res.status, 200, res.text.slice(0, 300));
++ assert.match(res.text, /<code class="mx-id is-big" id="mx-the-id">qwen\/qwen3-4b-abliterated:q4_k_m@preview<\/code>/);
++ assert.match(res.text, /<b data-mx-one>\$0\.000048<\/b> per request/);
++ assert.match(res.text, /never swapped for another quantization or variant/);
++ assert.match(res.text, /Not benchmarked yet/);
++ assert.match(res.text, /&quot;model&quot;: &quot;qwen\/qwen3-4b-abliterated:q4_k_m@preview&quot;/, 'the curl sample names the exact id');
++ assert.strictEqual((await t.get('/models/nope%2Fnothing%40x')).status, 404);
++ const alias = await t.get(`/models/${encodeURIComponent('openvibe/economy')}`);
++ assert.match(alias.text, /Policy <b>cheapest<\/b>/);
++ });
++
++ await check('the guide: start, ids against names, the disclosure, prices worked out exactly, errors', async () => {
++ const res = await t.get('/docs/ai');
++ assert.strictEqual(res.status, 200);
++ assert.match(res.text, /noindex/);
++ assert.match(res.text, /never swapped for another quantization/);
++ assert.match(res.text, /<code>openvibe\/private<\/code>/);
++ assert.match(res.text, /<code>resolved_model<\/code>/);
++ // 1,200 x $0.04/M + 8,000 x $0.01/M + 400 x $0.12/M = 48,000 + 80,000 + 48,000 nano = $0.000176.
++ assert.match(res.text, /costs <b>\$0\.000176<\/b>/);
++ assert.match(res.text, /<code>ai\.model_unavailable<\/code>/);
++ await t.ctx.aiConfig.store.apply({ model_explorer: false }, { actor: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, reason: 'test: close it' });
++ assert.strictEqual((await t.get('/docs/ai')).status, 404, 'off: no guide either');
++ await t.ctx.aiConfig.store.apply({ model_explorer: true }, { actor: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, reason: 'test: open it again' });
++ });
++
++ await check('compare: two to four, the cheapest marked per workload', async () => {
++ const a = 'qwen/qwen3-4b-abliterated:q4_k_m@preview';
++ const b = 'google/gemma-3-27b-abliterated:q4_k_m@preview';
++ const res = await t.get(`/models/compare?ids=${encodeURIComponent(a)}&ids=${encodeURIComponent(b)}`);
++ assert.strictEqual(res.status, 200);
++ assert.match(res.text, /class="mx-bar-row is-best"><span class="mx-bar-name">Qwen3 4B Abliterated/);
++ assert.match(res.text, /Not benchmarked yet/);
++ const one = await t.get(`/models/compare?ids=${encodeURIComponent(a)}`);
++ assert.match(one.text, /Pick two to four models/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/money-docs.test.js b/test/money-docs.test.js
nnew file mode 100644
iindex 0000000..f55394a
---- /dev/null
++++ b/test/money-docs.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * /docs/vibes and /docs/balance (D12): the two money guides are public, crawlable and use the owner's words —
++ * Vibes, Balance, Add money, Get Vibes — never "Credits" or "earn Vibes". They link from /docs and name the SDK's
++ * Vibes Pay calls, the webhook signature and the Balance transfer. No flag: these are documentation, not a feature.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the docs index links both guides', async () => {
++ const r = await t.get('/docs');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /href="\/docs\/vibes"/);
++ assert.match(r.text, /href="\/docs\/balance"/);
++ });
++
++ await check('/docs/vibes: what Vibes are, Vibes Pay, the SDK, plain HTTP, webhooks, sandbox, refunds, payouts, limits', async () => {
++ const r = await t.get('/docs/vibes');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.match(r.text, /<title>Vibes and Vibes Pay · OpenVibe\.Services<\/title>/);
++ for (const text of ['What Vibes are', 'Vibes Pay step by step', 'Server SDK', 'Plain HTTP', 'Webhooks and their signature',
++ 'Sandbox', 'Refunds', 'Payouts', 'Limits and errors', 'openvibe-sdk/vibes', 'charges.create', 'webhooks.verify',
++ 'X-OpenVibe-Signature-V2', 'billing.vibes.charge.paid', 'Get Vibes', 'whole number']) {
++ assert.ok(r.text.includes(text), `vibes guide says ${text}`);
++ }
++ assert.match(r.text, /amount: 50/);
++ assert.match(r.text, /idempotency/i);
++ assert.match(r.text, /300 seconds/, 'the replay window is stated');
++ assert.ok(!/Credits/i.test(r.text), 'never Credits');
++ assert.ok(!/\bearn(?:ing|ed)? Vibes\b/i.test(r.text), 'Vibes are bought and spent, never earned');
++ });
++
++ await check('/docs/balance: metered usage, Add money, moving money to a project, exact nano-USD', async () => {
++ const r = await t.get('/docs/balance');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.match(r.text, /<title>Balance · OpenVibe\.Services<\/title>/);
++ for (const text of ['How usage bills a project\'s Balance', 'Add money', 'Move money to a project', 'Move to a project',
++ '/api/me/balance', '/api/me/balance/transfers', 'nano-dollar', 'project:&lt;prj_id&gt;', 'Sandbox']) {
++ assert.ok(r.text.includes(text), `balance guide says ${text}`);
++ }
++ assert.match(r.text, /amount_nano_usd/);
++ assert.match(r.text, /502|5000000000/, 'an exact nano-USD example');
++ assert.ok(!/Credits/i.test(r.text), 'never Credits');
++ });
++
++ await check('both guides are crawlable (not noindex)', async () => {
++ for (const p of ['/docs/vibes', '/docs/balance']) {
++ const r = await t.get(p);
++ assert.ok(!/<meta name="robots" content="noindex/i.test(r.text), `${p} is indexable`);
++ assert.match(r.text, /<link rel="canonical"/);
++ }
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex b2edf3d..d350308 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -44,7 +44,8 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();
const base = `http://127.0.0.1:${port}`;
try {
let up = false;
-- for (let i = 0; i < 150 && !up; i++) {
++ // 45 s, not 15: the shared host's PGlite boot (now including the D11 config tables) can take that long under load.
++ for (let i = 0; i < 450 && !up; i++) {
up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
if (!up) await new Promise((r) => setTimeout(r, 100));
}
ddiff --git a/test/playground-ai.test.js b/test/playground-ai.test.js
nnew file mode 100644
iindex 0000000..349a0d5
---- /dev/null
++++ b/test/playground-ai.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * The AI playground (AI Exchange P8): one chat reply as the APP, with its own ai.app.run token (AI Exchange D8'), behind
++ * SERVICES_AI_PLAYGROUND. Refused without the grant (nothing requested); with it, the app token goes to OpenVibe AI,
++ * the reply and the openvibe disclosure are shown once, and the run log keeps neither the prompt nor the reply.
++ */
++const assert = require('assert');
++const http = require('http');
++
++(async () => {
++ // A fake OpenVibe AI: answers one chat completion with a disclosure, and remembers what it was asked.
++ const asked = [];
++ const ai = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ asked.push({ path: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({
++ id: 'chatcmpl_test1', model: 'qwen/qwen3-4b:q4_k_m@3f2a1c9',
++ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello from a tiny model here.' }, finish_reason: 'stop' }],
++ usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19 },
++ x_openvibe: { run_id: 'run_test1', provider: 'own', fallback_used: false, cost_usd: 0, resolved_model: 'qwen/qwen3-4b:q4_k_m@3f2a1c9', revision: '3f2a1c9', provider_access: 'OWN_DEPLOYMENT', policy: 'balanced', rate_version: 'r-7', fallback_reason: null },
++ }));
++ });
++ });
++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
++ process.env.SERVICES_PLAYGROUND_AI_URL = `http://127.0.0.1:${ai.address().port}`;
++ const { boot, check, done } = require('./helpers/boot');
++ const { aiInput } = require('../server/domain/playground');
++
++ await check('the input: an exact id or a dynamic name, a prompt, a capped reply', async () => {
++ assert.deepStrictEqual(aiInput({ model: 'openvibe/auto', prompt: 'hi', max_tokens: 99999 }).body.max_tokens, 1024);
++ assert.match(aiInput({ model: 'bad model!', prompt: 'hi' }).error, /exact id/);
++ assert.match(aiInput({ model: 'openvibe', prompt: '' }).error, /write a prompt/);
++ assert.match(aiInput({ model: 'openvibe', prompt: 'x'.repeat(4001) }).error, /4,000/);
++ });
++
++ // AI Exchange D10: openvibe.ai accepts sandbox app tokens (Network's DEV_SANDBOX_AUDIENCES at launch; AI spends only
++ // sandbox credit on them). The Network mock is started with it.
++ const t = await boot({ network: { sandboxAudiences: ['openvibe.events', 'openvibe.media', 'openvibe.ai'] } });
++ // The test boot builds its config from a fixed environment: the AI playground's switch and endpoint are set on it.
++ await t.ctx.aiConfig.store.apply({ ai_playground: true }, { actor: { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, reason: 'test: open the playground' });
++ t.config.playground.aiUrl = process.env.SERVICES_PLAYGROUND_AI_URL;
++ const owner = t.network.addUser('ana');
++ const projectId = await t.project(owner, 'Ask');
++ const app = await t.app(owner, projectId);
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const ask = (fields) => t.get(`${base}/playground/ai`, { as: owner, form: { model: 'openvibe', prompt: 'Say hello in five words.', max_tokens: '64', ...fields } });
++ try {
++ await check('without ai.app.run: refused at the grant step, OpenVibe AI never called', async () => {
++ const r = await ask({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 403, String((r.text.match(/<div class="(?:result|notice)[^>]*>[\s\S]{0,300}/) || [r.status])[0]));
++ assert.match(r.text, /does not hold ai\.app\.run/);
++ assert.strictEqual(asked.length, 0);
++ const page = await t.get(`${base}/playground`, { as: owner });
++ assert.match(page.text, /AI: one chat reply/);
++ assert.ok(!page.text.includes(`action="${base}/playground/ai"`), 'no form without the grant');
++ });
++
++ { // the success path: ai.app.run is the existing public capability apps call AI with (D8')
++ await check('with the grant: the app token goes to OpenVibe AI; the reply and what answered are shown', async () => {
++ await t.grant(owner, projectId, app.id, 'ai.app.run'); // through Services' own grant flow (D8, Contracts v0.284.0)
++ const r = await ask({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 200, String((r.text.match(/<div class="result[^>]*>[\s\S]{0,400}/) || [r.status])[0]));
++ assert.strictEqual(asked.length, 1);
++ assert.strictEqual(asked[0].path, '/v1/chat/completions');
++ assert.match(asked[0].auth, /^Bearer ey/, 'the app\'s own token, never a secret of Services');
++ assert.ok(!asked[0].auth.includes(app.secret), 'the client secret never leaves for AI');
++ assert.deepStrictEqual(asked[0].body, { model: 'openvibe', messages: [{ role: 'user', content: 'Say hello in five words.' }], max_tokens: 64, stream: false });
++ assert.match(r.text, /<blockquote class="ai-answer">Hello from a tiny model here\.<\/blockquote>/);
++ assert.match(r.text, /Answered by <code>qwen\/qwen3-4b:q4_k_m@3f2a1c9<\/code>/);
++ assert.match(r.text, /OWN_DEPLOYMENT/);
++ assert.match(r.text, /12 tokens in · 7 out/);
++ });
++
++ await check('the run log keeps neither the prompt nor the reply', async () => {
++ const page = await t.get(`${base}/playground`, { as: owner });
++ const log = page.text.slice(page.text.indexOf('Recent runs'));
++ assert.match(log, /answered by qwen\/qwen3-4b:q4_k_m@3f2a1c9/);
++ assert.ok(!log.includes('Say hello in five words'), 'no prompt in the log');
++ assert.ok(!log.includes('Hello from a tiny model'), 'no reply in the log');
++ const dump = await t.dbDump();
++ assert.ok(!dump.includes('Say hello in five words') && !dump.includes('Hello from a tiny model'), 'nor anywhere in the database');
++ });
++ }
++ } finally {
++ await t.close();
++ ai.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex fbfe18a..6fad93d 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -58,6 +58,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/network.js': 'Network\'s developer projects API (configured OV_NETWORK_URL), with the caller\'s own token',
'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',
++ 'server/domain/balance.js': 'OpenVibe Billing\'s GET /api/me/balance (configured OV_BILLING_INTERNAL_URL), with the person\'s own token: never a caller-chosen URL',
++ 'server/domain/model-catalog.js': 'OpenVibe AI\'s GET /v1/models (configured OV_AI_PUBLIC_URL, read only, no credential): never a caller-chosen URL',
'server/domain/project-archive.js': 'no request: a shell snippet in the archive\'s README text',
'server/http/docs.js': 'Network changelog, Tools catalog and Billing policy (configured)',
'server/http/network-mcp.js': 'the Network frame (configured OV_NETWORK_URL), then only the https origins it lists: each one\'s /.well-known/agent.json and /openapi.json (on its own origin), and GETs of the paths those documents name (each {param} one segment); never a caller-chosen host, no credential, redirects not followed (AGT-2)',