openvibers/openvibe.sdk Commits

0.83.0: the AI Exchange face of the one planner (planAi) and the nano-USD money math (#115)

OP OpenVibers committed 9 days ago · e153b1bcde · 17 files, +1301 −16

planAi() is the same ONE planner (decision D5) for the AI Exchange: the hard gates first (the exact artifact, the commercial right, the privacy class and the trust tier, the capabilities, the context bound, the health, the cost ceiling), then the expected cost per success. It is a new entry point, not a second router: plan() still answers platform.placement-result@1 and is untouched. It is off by default.

Money is integer nano-USD (decision D1), never a float of dollars: ceil(tokens * rate_nano_per_m / 1e6) charged once per token class plus the per-request fee, and the cost per success rounded up to a whole nano-USD. openvibe-sdk/cost gains nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano and formatNanoUsd; the rates travel in the flat ai.model@1 retail_rate shape or the nested settlement shape.

Sparse quality evidence is marked, not trusted: it carries no confidence and is pulled back to the prior, so a thin measurement can never dominate the ranking. The answer explains itself with explain.rejected[{ id, reason }] and explain.objective, and every reason is stripped of keys, subject ids, hosts, IPs, URLs and paths.

Hysteresis and affinity stay the ones plan() has.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b070116..242d9ce 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,31 @@
# Changelog
++## 0.83.0 (2026-11-03)
++
++- **`openvibe-sdk/placement`: `planAi()`, the AI Exchange face of the ONE planner** (plan-state 2026-10-22 §E,
++ G-P1/P7; decisions D1 and D5). Not a second router: `plan()` still answers `platform.placement-result@1` and is
++ untouched. **Off by default** — without `enabled: true` the planner answers nothing and no behaviour changes.
++ - **The hard gates, before any scoring.** Each one is a reason, never a weight: the exact artifact (an exact id
++ is never substituted), the commercial right (a paid managed route needs `OWN_DEPLOYMENT` or
++ `AUTHORIZED_WHOLESALE`), the privacy class and the trust tier, the capabilities (`tools`, `json`, `vision`,
++ `stream`), the context bound, the health, and the cost ceiling. A candidate with no rate is never assumed
++ free: it is rejected with `no rate: the cost is unknown`.
++ - **The objective is the cost of one success.** `cost_per_success = expected_cost / max(p_success, floor)`, so a
++ candidate that is cheaper per token but fails more often loses to one that is dearer per token and succeeds
++ more. `p_success` comes from optional quality evidence with a confidence: **sparse evidence is marked, not
++ trusted** (no confidence, or fewer than 20 samples → `sparse_evidence: true`, no confidence, and the probability
++ is pulled back to the prior), so a thin measurement can never dominate the ranking.
++ - **Money is integer nano-USD** (decision D1), never a float of dollars: `ceil(tokens * rate_nano_per_m / 1e6)`
++ charged once per token class (input, cached read, cache write, output) plus the per-request fee, and the cost
++ per success rounded up to a whole nano-USD. `openvibe-sdk/cost` gains `nanoUsd`, `chargeTokens`,
++ `expectedCostNano`, `costPerSuccessNano` and `formatNanoUsd`; the rates travel in the flat `ai.model@1`
++ `retail_rate` shape or the nested settlement shape.
++ - **The answer explains itself.** `explain.rejected` is `[{ id, reason }]` (as `ai.route-explain@1` names it) and
++ `explain.objective` says what was optimised for. Every reason is stripped of keys, subject ids, hosts, IPs,
++ URLs and paths: no secret and no internal host ever reaches the answer.
++ - Hysteresis and affinity stay the ones `plan()` has: `current` is kept unless another candidate is clearly
++ better (`minGain`), and `affinity` keeps a workload on the deployment it is warm on.
++
## 0.82.0 (2026-11-02)
- **`openvibe-sdk/events` outbox: `pending()` and `rejected()` keep their count** (`countsCacheMs`, 30 s when
ddiff --git a/README.md b/README.md
iindex 65d6596..f1ba4ae 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.82.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.83.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.82.0 is tagged:
++After v0.83.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -190,7 +190,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) |
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
--| `openvibe-sdk/placement` | server | `plan(requirements, offers, {rateCards, states, now, current, minGain, weights, success})` → `platform.placement-result@1`; `place(requirements, offers, {…plan, person, step, floor, priors, outcomes, rules, names})` → `{result, line, ranked, rejected, fallback}`: the person's rules and ownership, the quality bar, cost per success and one sentence saying why; `optionKey`, `learn`, `successOf`, `escalate`, `lineFor`; `TRUST_CLASSES`, `TRUST_ORDER`, `DEFAULT_TRUST` ([details](docs/placement.md)) |
++| `openvibe-sdk/placement` | server | `plan(requirements, offers, {rateCards, states, now, current, minGain, weights, success})` → `platform.placement-result@1`; `place(requirements, offers, {…plan, person, step, floor, priors, outcomes, rules, names})` → `{result, line, ranked, rejected, fallback}`: the person's rules and ownership, the quality bar, cost per success and one sentence saying why; `optionKey`, `learn`, `successOf`, `escalate`, `lineFor`; `TRUST_CLASSES`, `TRUST_ORDER`, `DEFAULT_TRUST`; `planAi(requirements, deployments, {enabled, usage, evidence, rates, current})` → `{chosen, rejected, objective, estimate, explain}`: the AI Exchange face of the same planner (hard gates, the cost per success in nano-USD, sparse evidence marked), off by default ([details](docs/placement.md)) |
| `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token |
| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
@@@ -220,7 +220,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})`, `startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?})` → `{run(), stop()}` (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) |
--| `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card |
++| `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card; the AI Exchange money math in integer nano-USD with `nanoUsd()`, `chargeTokens()`, `expectedCostNano()`, `costPerSuccessNano()`, `formatNanoUsd()` |
| `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes |
| `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` |
| `openvibe-sdk/delivery` | server | `deliveryOffer()` and `validateDeliveryOffer()` for `platform.delivery-offer@1` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 913ff20..5a3eded 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.82.0",
++ "version": "0.83.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -34,6 +34,7 @@
"account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
"resources (openvibe-sdk/resources): the ADR-048 resource index fan-out and the idempotent control client",
"placement v1 (openvibe-sdk/placement): place() with the person's rules and ownership, the quality bar, cost per success, learn/successOf, escalate and the one-line reason; plan() takes the optional success map and never defaults to user-owned",
++ "the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to"
],
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 2889efc..0cb8caa 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.82.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.83.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -47,7 +47,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.82.0';
++const SDK_VERSION = '0.83.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex f012296..d8d8a0f 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -458,9 +458,26 @@
"ulid": "function"
},
"openvibe-sdk/cost": {
++ "NANO_USD": "bigint",
++ "RATE_FIELDS": {
++ "object": [
++ "cache_write_tokens",
++ "cached_read_tokens",
++ "input_tokens",
++ "output_tokens"
++ ]
++ },
++ "TOKENS_PER_MILLION": "bigint",
++ "TOKEN_CLASSES": "array",
++ "chargeTokens": "function",
++ "costPerSuccessNano": "function",
"costSnapshot": "function",
"estimate": "function",
++ "expectedCostNano": "function",
++ "formatNanoUsd": "function",
++ "nanoUsd": "function",
"rateCard": "function",
++ "rateFor": "function",
"validateCostSnapshot": "function",
"validateRateCard": "function"
},
@@@ -619,9 +636,16 @@
"createOpenReClient": "function"
},
"openvibe-sdk/placement": {
++ "DEFAULT_P_FLOOR": "number",
"DEFAULT_TRUST": "array",
++ "MODEL_CAPABILITIES": "array",
++ "PAID_PROVIDER_ACCESS": "object",
++ "PRIVACY_CLASSES": "array",
++ "PROVIDER_ACCESS": "array",
++ "SPARSE_SAMPLE_COUNT": "number",
"TRUST_CLASSES": "array",
"TRUST_ORDER": "array",
++ "aiExcluded": "function",
"canonical": "function",
"createPlanHolder": "function",
"escalate": "function",
@@@ -635,8 +659,11 @@
"pickTwo": "function",
"place": "function",
"plan": "function",
++ "planAi": "function",
"priceOf": "function",
++ "qualityOf": "function",
"rendezvous": "function",
++ "safeReason": "function",
"signPlan": "function",
"successOf": "function",
"verifyPlan": "function"
ddiff --git a/docs/placement.md b/docs/placement.md
iindex 9236c3d..1a8d5ec 100644
---- a/docs/placement.md
++++ b/docs/placement.md
@@@ -157,3 +157,103 @@ its files) makes it say "your files". With neither it says only "your own machin
`lineFor(facts)` is exported for tests and for a caller that already has the two factors; `place()` and `escalate()`
build the facts from the result.
++
++## The AI Exchange face: `planAi()`
++
++`planAi()` is the same ONE planner for the AI Exchange (plan-state 2026-10-22 §E, G-P1/P7; decisions D1 and D5).
++It is a new entry point, not a second router: `plan()` still answers `platform.placement-result@1` and is untouched.
++
++**It is off by default.** A caller must pass `enabled: true`; without it the planner answers nothing and no
++behaviour changes until the owner turns it on.
++
++```js
++const { planAi } = require('openvibe-sdk/placement');
++
++const r = planAi(
++ { objective: 'cheapest', paid_managed: true,
++ exact_artifact: 'qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9',
++ capabilities: ['tools', 'json'], privacy_class: 'zero_retention',
++ max_cost_nano_usd: '50000' },
++ deployments, // ai.inference-deployment@1 + its ai.model@1 entry
++ {
++ enabled: true,
++ now: Date.parse('2026-11-03T12:00:00Z'),
++ usage: { input_tokens: 800, output_tokens: 400 },
++ rates: { 'dep-qwen-9b-us-east-001': { input_nano_usd_per_m: '3000', output_nano_usd_per_m: '15000', per_request_nano_usd: '0' } },
++ evidence: { 'dep-qwen-9b-us-east-001': { p_success: 0.95, confidence: 0.9, samples: 120 } },
++ current: 'dep-qwen-9b-us-east-001',
++ },
++);
++
++r.chosen; // 'dep-qwen-9b-us-east-001'
++r.rejected; // [{ id, reason }] — every candidate that was left out, and why
++r.objective; // 'cheapest'
++r.estimate; // { charge_nano_usd: '9', input_tokens: 800, output_tokens: 400, … }
++r.explain; // { objective, rejected, reasons, candidates, decided_at }
++```
++
++### The hard gates, before any scoring
++
++Each gate is a reason, never a weight: a candidate that misses one is excluded and never chosen, however cheap it
++is.
++
++| Gate | Requirement | Reason it gives |
++|---|---|---|
++| the exact artifact | `exact_artifact` | `exact model requested (org/model@rev)` — an exact id is never substituted (decision D3) |
++| the commercial right | `paid_managed` | `commercial right RESTRICTED may not serve a paid route` — a paid managed route needs `OWN_DEPLOYMENT` or `AUTHORIZED_WHOLESALE` (decision D3) |
++| the privacy class | `privacy_class` | `privacy class standard is not within zero_retention` |
++| the trust tier | `trust` | `trust tier approved_wholesale is not allowed` |
++| the capabilities | `capabilities` | `lacks tools` — for `tools`, `json`, `vision`, `stream` |
++| the context bound | `context_tokens` | `context 4096 tokens is under 32768` |
++| the health | — | `status offline`, `not healthy` |
++| the cost ceiling | `max_cost_nano_usd` | `expected cost 9 nano-USD is over the 8 ceiling` |
++
++A candidate with **no rate** is never assumed free: it is rejected with `no rate: the cost is unknown`.
++
++### The objective: the cost of one success
++
++Money is integer nano-USD (decision D1): one nano-USD is 1e-9 USD, and every amount is a `BigInt`. The expected
++cost of a request is the four token classes, each charged **once** at its own rate, plus the per-request fee:
++
++```
++charge(tokens, rate_nano_per_m) = ceil(tokens * rate_nano_per_m / 1e6) // rounded up, never down
++expected_cost = input + cached_read + cache_write + output + per_request
++cost_per_success = expected_cost / max(p_success, floor) // rounded up to a whole nano-USD
++```
++
++The rates travel in either shape: the flat `ai.model@1` `retail_rate`
++(`input_nano_usd_per_m`, …) or the nested settlement shape (`nano_usd_per_m: { input, cached_read, … }`).
++
++`p_success` comes from optional quality evidence: `{ p_success, confidence, samples }`. **Sparse evidence is marked,
++not trusted**: an evidence set with no confidence, or fewer than 20 samples, is marked `sparse_evidence: true`,
++carries no confidence, and is pulled back to the prior — so it can never win on quality alone, only on price.
++A candidate with no chance of success is rejected with `no chance of success`.
++
++The planner therefore prefers the **cheaper per success** over the cheaper per token: a candidate that costs 9
++nano-USD and succeeds 60% of the time (15 per success) loses to one that costs 13 and succeeds 95% (14 per success).
++
++### The objectives
++
++`objective` says what the planner weighed (PLAN §E's named policies):
++
++| Objective | What it weighs |
++|---|---|
++| `cheapest` (the default) | the expected cost per success, alone |
++| `balanced` | the cost per success and the latency |
++| `fastest` | the latency, mostly |
++| `best` | the measured quality, mostly |
++| `exact` | the pinned artifact: the exact gate has already left one candidate |
++| `private` | the trust class: the privacy gate has already left the rest |
++
++`explain.objective` always says which one was used.
++
++### Hysteresis and affinity
++
++The same two rules `plan()` has. `current` (the deployment the workload runs on now) is kept unless another
++candidate is clearly better (`minGain`, default 0.15), and `affinity` (a map of deployment id to a stickiness
++between 0 and 1) keeps a workload on the deployment it is warm on. Both say so in `explain.reasons`.
++
++### The explanation never leaks
++
++Every reason is passed through `safeReason()`: no key, no subject id, no host, no IP, no URL, no path ever reaches
++the answer. `explain.rejected` is `[{ id, reason }]`, exactly as `ai.route-explain@1` names it.
ddiff --git a/esm/cost.mjs b/esm/cost.mjs
iindex 155c077..402da73 100644
---- a/esm/cost.mjs
++++ b/esm/cost.mjs
@@@ -2,6 +2,8 @@
import cjs from '../src/cost.js';
export const {
-- rateCard, costSnapshot, estimate, validateRateCard, validateCostSnapshot,
++ rateCard, costSnapshot, estimate, validateRateCard, validateCostSnapshot, nanoUsd,
++ chargeTokens, expectedCostNano, costPerSuccessNano, formatNanoUsd, rateFor, NANO_USD,
++ TOKENS_PER_MILLION, TOKEN_CLASSES, RATE_FIELDS,
} = cjs;
export default cjs;
ddiff --git a/esm/placement.mjs b/esm/placement.mjs
iindex b9716d4..475e3c6 100644
---- a/esm/placement.mjs
++++ b/esm/placement.mjs
@@@ -5,6 +5,8 @@ export const {
plan, excluded, marginalCost, priceOf, forecast, ownedLoadCost,
rendezvous, pickTwo, signPlan, verifyPlan, createPlanHolder, canonical,
place, escalate, optionKey, learn, successOf, lineFor,
-- TRUST_CLASSES, TRUST_ORDER, DEFAULT_TRUST,
++ planAi, aiExcluded, qualityOf, safeReason, TRUST_CLASSES, TRUST_ORDER,
++ DEFAULT_TRUST, PAID_PROVIDER_ACCESS, PROVIDER_ACCESS, PRIVACY_CLASSES, MODEL_CAPABILITIES, DEFAULT_P_FLOOR,
++ SPARSE_SAMPLE_COUNT,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 3a18d6b..e1c8804 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.82.0",
++ "version": "0.83.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.82.0",
++ "version": "0.83.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex f76fb26..9b2190b 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.82.0",
++ "version": "0.83.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 8328ad1..ce13d23 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.82.0';
++const SDK_VERSION = '0.83.0';
module.exports = {
createClient,
ddiff --git a/src/cost.js b/src/cost.js
iindex c38cd4a..97cb96e 100644
---- a/src/cost.js
++++ b/src/cost.js
@@@ -8,7 +8,131 @@
* const card = rateCard({ id, provider, metric, unit_size, unit_price_usd, free_allowance, reset_period,
* effective_from, source, verified_at });
* estimate(card, 2_500_000, 'ops'); // { quantity, unit, billable, cost_usd }
++ *
++ * It also carries the AI Exchange money math (decision D1): integer nano-USD (1e-9 USD) with BigInt arithmetic,
++ * never floats. Rates are integer nano-USD per million tokens; the charge of a component is
++ * `ceil(tokens * rate_nano_per_m / 1e6)`, computed once per component per request, plus a per-request fee.
++ *
++ * const { nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano } = require('openvibe-sdk/cost');
++ * const charge = expectedCostNano({ input_tokens: 800, output_tokens: 400 }, {
++ * input_nano_usd_per_m: '3000', cached_read_nano_usd_per_m: '300',
++ * cache_write_nano_usd_per_m: '3000', output_nano_usd_per_m: '15000', per_request_nano_usd: '0',
++ * }); // 3n (input) + 6n (output) = 9n
++ */
++
++// ── Money: integer nano-USD (decision D1) ────────────────────────────────────
++
++/** One nano-USD: 1e-9 USD. Every money amount in the AI Exchange is an integer count of these. */
++const NANO_USD = 1000000000n;
++
++/** Tokens per million: the denominator of every per-million-token rate. */
++const TOKENS_PER_MILLION = 1000000n;
++
++/**
++ * A money amount as a BigInt count of nano-USD. A string is the wire form (ai.model@1 stores money as a
++ * string); a number is accepted only when it is a whole number of nano-USD (never a float of dollars: a
++ * fractional dollar is a bug, not a rounding opportunity). null/undefined/'' is zero.
*/
++function nanoUsd(value) {
++ if (value == null || value === '') return 0n;
++ if (typeof value === 'bigint') return value;
++ if (typeof value === 'number') {
++ if (!Number.isFinite(value) || !Number.isInteger(value)) throw new TypeError(`nanoUsd: ${value} is not a whole number of nano-USD`);
++ return BigInt(value);
++ }
++ const s = String(value).trim();
++ if (!/^\d+$/.test(s)) throw new TypeError(`nanoUsd: "${s}" is not an integer amount of nano-USD`);
++ return BigInt(s);
++}
++
++/**
++ * The charge of `tokens` at `rateNanoPerM` nano-USD per million tokens, rounded UP (never down): the customer is
++ * never under-charged for a request, and the platform never eats a fraction of a nano-USD silently.
++ * `ceil(tokens * rate / 1e6)`.
++ */
++function chargeTokens(tokens, rateNanoPerM) {
++ const t = BigInt(Math.max(0, Math.floor(Number(tokens) || 0)));
++ const rate = nanoUsd(rateNanoPerM);
++ if (!t || !rate) return 0n;
++ return (t * rate + TOKENS_PER_MILLION - 1n) / TOKENS_PER_MILLION;
++}
++
++/** The four token classes, in the order the settlement uses: input, cached read, cache write, output. */
++const TOKEN_CLASSES = ['input_tokens', 'cached_read_tokens', 'cache_write_tokens', 'output_tokens'];
++
++/** The rate field each token class is priced by. */
++const RATE_FIELDS = {
++ input_tokens: 'input_nano_usd_per_m',
++ cached_read_tokens: 'cached_read_nano_usd_per_m',
++ cache_write_tokens: 'cache_write_nano_usd_per_m',
++ output_tokens: 'output_nano_usd_per_m',
++};
++
++/**
++ * The rate of one token class, from either shape a rate travels in:
++ * - the flat ai.model@1 retail_rate: `input_nano_usd_per_m`, `cached_read_nano_usd_per_m`, …;
++ * - the nested settlement rate (billing.inference-settlement@1): `nano_usd_per_m: { input, cached_read, … }`.
++ * A missing rate is zero, never a guess.
++ */
++function rateFor(rates, tokenClass) {
++ const r = rates || {};
++ const flat = r[RATE_FIELDS[tokenClass]];
++ if (flat != null) return flat;
++ const perM = r.nano_usd_per_m || r.rates || null;
++ if (!perM) return 0n;
++ const key = {
++ input_tokens: 'input', cached_read_tokens: 'cached_read', cache_write_tokens: 'cache_write', output_tokens: 'output',
++ }[tokenClass];
++ return perM[key] != null ? perM[key] : 0n;
++}
++
++/**
++ * The expected cost of one request, in nano-USD: each token class charged once at its own rate (ceil), plus the
++ * per-request fee. `usage` counts tokens; `rates` is an ai.model@1 retail_rate (or a supply rate card) with the
++ * four nano-USD-per-million rates and the per-request fee.
++ *
++ * expectedCostNano({ input_tokens: 800, output_tokens: 400 }, {
++ * input_nano_usd_per_m: '3000', cached_read_nano_usd_per_m: '300',
++ * cache_write_nano_usd_per_m: '3000', output_nano_usd_per_m: '15000', per_request_nano_usd: '0',
++ * }); // 3n (input: 800 * 3000 / 1e6 = 2.4, up) + 6n (output: 400 * 15000 / 1e6 = 6) = 9n
++ */
++function expectedCostNano(usage, rates) {
++ const u = usage || {};
++ let total = 0n;
++ for (const cls of TOKEN_CLASSES) {
++ const tokens = u[cls] != null ? u[cls] : (cls === 'input_tokens' ? u.input : 0);
++ total += chargeTokens(tokens, rateFor(rates, cls));
++ }
++ const perRequest = (rates && rates.per_request_nano_usd != null) ? rates.per_request_nano_usd : 0;
++ return total + nanoUsd(perRequest);
++}
++
++/**
++ * The cost of one SUCCESS, in nano-USD: `expected / max(p_success, floor)`.
++ * `pSuccess` is the chance the request succeeds there (0..1); `floor` (default 0.1) keeps a tiny probability from
++ * dividing the cost into infinity. A probability at or below zero has no cost per success: it is not a candidate
++ * the planner may choose (the caller excludes it before scoring).
++ *
++ * The division is integer: the divisor is fixed-point at 1e-9 (nine decimals of a probability), and the result is
++ * rounded UP to a whole nano-USD, so a candidate is never flattered by a fraction of a nano-USD.
++ */
++function costPerSuccessNano(expectedNano, pSuccess, floor = 0.1) {
++ const cost = typeof expectedNano === 'bigint' ? expectedNano : nanoUsd(expectedNano);
++ const p = Number(pSuccess);
++ const f = Number(floor);
++ const eff = Number.isFinite(p) && p > 0 ? p : 0;
++ const fl = Number.isFinite(f) && f > 0 ? f : 0;
++ if (!eff) return null; // no chance of success: no cost per success
++ const divisor = Math.max(eff, fl);
++ const scaled = BigInt(Math.round(divisor * 1000000000)); // the divisor at 1e-9
++ if (!scaled) return null;
++ return (cost * 1000000000n + scaled - 1n) / scaled; // ceil
++}
++
++/** A money amount as the wire string: a decimal count of nano-USD, never a float. */
++function formatNanoUsd(nano) {
++ return (typeof nano === 'bigint' ? nano : nanoUsd(nano)).toString();
++}
/** The fields platform.rate-card@1 requires or allows, in the schema's order; null/undefined are stripped. */
function rateCard(fields) {
@@@ -79,4 +203,8 @@ function validateCostSnapshot(record) {
return { ok: Boolean(r.valid), errors: r.errors || [] };
}
--module.exports = { rateCard, costSnapshot, estimate, validateRateCard, validateCostSnapshot };
++module.exports = {
++ rateCard, costSnapshot, estimate, validateRateCard, validateCostSnapshot,
++ nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano, formatNanoUsd, rateFor,
++ NANO_USD, TOKENS_PER_MILLION, TOKEN_CLASSES, RATE_FIELDS,
++};
ddiff --git a/src/placement.js b/src/placement.js
iindex a52d51d..abe84c9 100644
---- a/src/placement.js
++++ b/src/placement.js
@@@ -24,6 +24,14 @@
* binding resource is busy, then rises so work spills to free or paid capacity before the node saturates;
* - hysteresis: a candidate replaces the current placement only when it is clearly better (`minGain`), or when
* the current one is no longer eligible (failover is immediate).
++ *
++ * const { planAi } = require('openvibe-sdk/placement');
++ * const r = planAi(requirements, deployments, { enabled: true, usage, evidence, rates });
++ * // → { chosen, rejected: [{ id, reason }], objective, estimate, explain }
++ *
++ * planAi() is the AI Exchange face of this ONE planner (plan-state 2026-10-22 §E, G-P1/P7; decisions D1 and D5):
++ * the same hard-constraints-first rule, priced in integer nano-USD and ranked by the expected cost per SUCCESS.
++ * It is OFF by default: without `enabled: true` it answers nothing and changes no behaviour.
*/
const crypto = require('crypto');
@@@ -573,6 +581,417 @@ function escalate(previous, failure, requirements, offers, ctx = {}) {
return { ...next, line, escalations: attempts + 1, outcomes: outcomes || [] };
}
++// ── The AI Exchange planner (plan-state 2026-10-22 §E, G-P1/P7; decision D1 and D5) ────────────────────────────
++//
++// planAi() is the ONE planner's AI Exchange face: the same hard-constraints-first rule, but priced in integer
++// nano-USD and ranked by the cost of one SUCCESS rather than by the cost of one attempt. It is a new entry point,
++// not a second router: plan() still answers platform.placement-result@1 and is untouched.
++//
++// It is OFF by default. A caller must pass `enabled: true` (or set the feature flag) to get an answer; without
++// it the planner refuses with the reason, so nothing changes behaviour until the owner turns it on.
++//
++// The rules:
++// - hard gates first, each one a reason, never a weight: the exact artifact, the commercial right
++// (provider_access), the privacy/trust class, the capabilities (tools, json, vision, stream, context), the
++// context bound, the health, and the cost ceiling;
++// - then the objective: the expected cost per SUCCESS, `expected_cost / max(p_success, floor)`, where p_success
++// comes from optional quality evidence and its confidence. Sparse evidence is MARKED (sparse_evidence), not
++// trusted: a candidate whose evidence is thin keeps a neutral probability and may not win on quality alone;
++// - money is integer nano-USD (D1): the four token classes and the per-request fee, charged once each, ceil.
++//
++// The answer explains itself: `explain.rejected` lists every candidate that was left out and why, and
++// `explain.objective` says what the planner optimised for. No secret, no key, no host, no endpoint ever appears
++// in a reason.
++
++const { nanoUsd, expectedCostNano, costPerSuccessNano, formatNanoUsd } = require('./cost');
++
++/** The commercial rights a paid managed route may use (decision D3). */
++const PAID_PROVIDER_ACCESS = new Set(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE']);
++
++/** The provider_access values of the AI Exchange (ai.inference-deployment@1). */
++const PROVIDER_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
++
++/** The privacy classes a deployment may carry, from the strictest to the loosest. */
++const PRIVACY_CLASSES = ['zero_retention', 'no_training', 'standard', 'unknown'];
++
++/** The default probability floor: a candidate never divides its cost by less than this. */
++const DEFAULT_P_FLOOR = 0.1;
++
++/** The evidence below this many observations is sparse: it is marked, never trusted. */
++const SPARSE_SAMPLE_COUNT = 20;
++
++/** The capabilities a model may declare (ai.model@1): tools, json, vision, stream. */
++const MODEL_CAPABILITIES = ['tools', 'json', 'vision', 'stream'];
++
++/**
++ * A reason a candidate was left out, with everything secret stripped: no key, no host, no endpoint, no path.
++ * The candidate's own id is never in its own reason (the answer already says which candidate it was).
++ */
++function safeReason(text) {
++ let s = String(text == null ? '' : text);
++ s = s.replace(/\b[a-z]{2,8}_[0-9A-HJKMNP-TV-Z]{26}\b/gi, 'a key'); // usr_…, prj_…, sec_…
++ s = s.replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{8,}\b/g, 'a key'); // provider keys
++ s = s.replace(/\bhttps?:\/\/[^\s,;)]+/gi, 'an endpoint'); // urls
++ s = s.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, 'a host'); // ipv4
++ s = s.replace(/\b[a-zA-Z0-9-]+\.(?:internal|local|lan)\b/g, 'a host'); // internal host names
++ s = s.replace(/(?:^|\s)\/(?:[^/\s]+\/)+[^\s]*/g, ' a path'); // paths
++ s = s.replace(/\s+/g, ' ').trim();
++ return s || 'not eligible';
++}
++
++/** The candidate's id: the deployment id, else the model id, else the offer id. */
++function candidateId(c) {
++ return String((c && (c.id || c.deployment_id || c.offer_id || c.model_id)) || '');
++}
++
++/** The model the candidate runs: ai.model@1 id, else the artifact it loads, else the candidate's own id. */
++function candidateModel(c) {
++ return String((c && (c.model_id || c.model || c.artifact_id)) || candidateId(c));
++}
++
++/** The commercial right of the candidate, as ai.inference-deployment@1 names it. */
++function candidateAccess(c) {
++ return String((c && (c.provider_access || c.commercial_rights)) || '').toUpperCase();
++}
++
++/** The privacy class of the candidate: an explicit privacy_policy, else its trust tier. */
++function candidatePrivacy(c) {
++ const p = c && c.privacy_policy;
++ if (p) return String(p);
++ const tier = c && c.trust_tier;
++ if (tier === 'first_party' || tier === 'owner_node') return 'zero_retention';
++ if (tier === 'approved_wholesale') return 'standard';
++ return 'unknown';
++}
++
++/** The capabilities the candidate declares: ai.model@1's four, read from the model or the candidate itself. */
++function candidateCapabilities(c) {
++ const src = (c && (c.capabilities || (c.model && c.model.capabilities) || (c.model_info && c.model_info.capabilities))) || {};
++ const out = new Set();
++ for (const k of MODEL_CAPABILITIES) if (src[k]) out.add(k);
++ return out;
++}
++
++/** The context bound of the candidate, in tokens: the deployment's limit, else the model's context. */
++function candidateContext(c) {
++ const limits = c && c.limits;
++ if (limits && limits.context_tokens != null) return Number(limits.context_tokens);
++ const m = c && (c.model || c.model_info);
++ if (m && m.context != null) return Number(m.context);
++ if (m && m.context_window != null) return Number(m.context_window);
++ return null;
++}
++
++/** The quality evidence for a candidate: { p_success, confidence, samples, source }. */
++function evidenceOf(evidence, id) {
++ if (!evidence) return null;
++ if (evidence instanceof Map) return evidence.get(id) || null;
++ return evidence[id] || null;
++}
++
++/**
++ * The chance a request succeeds on a candidate, from optional quality evidence, and how much that evidence may
++ * be trusted. Sparse evidence (few observations, or no confidence given) is MARKED, not trusted: the probability
++ * is pulled toward the neutral prior and the candidate may not win on quality alone.
++ *
++ * The evidence is `{ p_success, confidence, samples }`:
++ * - `p_success` in (0, 1]: the measured chance the request succeeds there;
++ * - `confidence` in (0, 1]: how much the measurement may be trusted (0.5 = a prior, 1 = a full measurement);
++ * - `samples`: how many observations the measurement rests on.
++ */
++function qualityOf(ev, { prior = 0.9, floor = SPARSE_SAMPLE_COUNT } = {}) {
++ if (!ev) return { p_success: prior, confidence: 0, samples: 0, sparse: true, source: 'prior' };
++ const p = Number(ev.p_success);
++ const conf = Number(ev.confidence);
++ const samples = Number(ev.samples || 0);
++ const sparse = !Number.isFinite(conf) || conf <= 0 || samples < floor;
++ if (!Number.isFinite(p) || p <= 0) return { p_success: 0, confidence: 0, samples, sparse: true, source: ev.source || 'evidence' };
++ // A trusted measurement is used as it is; a sparse one is pulled toward the prior, so it cannot dominate.
++ const trust = sparse ? 0 : Math.min(1, Math.max(0, conf));
++ const blended = trust > 0 ? trust * p + (1 - trust) * prior : prior;
++ return {
++ p_success: Math.max(0, Math.min(1, blended)),
++ confidence: sparse ? 0 : trust,
++ samples,
++ sparse,
++ source: ev.source || (sparse ? 'sparse_evidence' : 'evidence'),
++ };
++}
++
++/**
++ * The hard gates of the AI Exchange. Each returns a reason when the candidate may not run the workload, or null.
++ * The order is the order of the reasons an operator reads: what it runs, who may serve it, what may see the
++ * request, what it can do, what it costs.
++ */
++function aiExcluded(req, c, ctx) {
++ const id = candidateId(c);
++
++ // 1. The exact artifact: a pinned model id is never substituted (decision D3).
++ const exact = req.exact_artifact || req.artifact_id || req.exact_model;
++ if (exact) {
++ const model = candidateModel(c);
++ const artifact = String(c.artifact_id || '');
++ if (model !== exact && artifact !== exact) return `exact model requested (${exact})`;
++ }
++
++ // 2. The commercial right: a paid managed route needs OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE (decision D3).
++ const access = candidateAccess(c);
++ const paid = c.paid_managed === true || req.paid_managed === true;
++ if (paid) {
++ if (!access) return 'no recorded commercial right';
++ if (!PAID_PROVIDER_ACCESS.has(access)) return `commercial right ${access} may not serve a paid route`;
++ } else if (access && !PAID_PROVIDER_ACCESS.has(access) && req.allow_restricted === true) {
++ return `commercial right ${access} is restricted`;
++ }
++
++ // 3. The privacy / trust class: the request may not reach a looser class than the workload allows.
++ if (req.privacy_class) {
++ const allowed = PRIVACY_CLASSES.filter((p) => PRIVACY_CLASSES.indexOf(p) <= PRIVACY_CLASSES.indexOf(req.privacy_class));
++ const have = candidatePrivacy(c);
++ if (!allowed.includes(have)) return `privacy class ${have} is not within ${req.privacy_class}`;
++ }
++ if (req.trust && req.trust.length) {
++ const tier = c.trust_tier;
++ if (tier && !req.trust.includes(tier)) return `trust tier ${tier} is not allowed`;
++ }
++
++ // 4. The capabilities: tools, json, vision, stream.
++ const wanted = req.capabilities || [];
++ const have = candidateCapabilities(c);
++ for (const k of wanted) {
++ if (!MODEL_CAPABILITIES.includes(k)) continue; // a non-model capability is the caller's own gate
++ if (!have.has(k)) return `lacks ${k}`;
++ }
++
++ // 5. The context bound.
++ if (req.context_tokens != null) {
++ const ctxTokens = candidateContext(c);
++ if (ctxTokens != null && ctxTokens < req.context_tokens) return `context ${ctxTokens} tokens is under ${req.context_tokens}`;
++ }
++
++ // 6. The health of the deployment.
++ const status = c.status || (c.health && c.health.healthy === false ? 'offline' : null);
++ if (status && !['ready', 'warming', 'degraded'].includes(status)) return `status ${status}`;
++ if (c.health && c.health.healthy === false) return 'not healthy';
++
++ return null;
++}
++
++/**
++ * The cost ceiling: the expected cost of the request on this candidate, in nano-USD, against the ceiling.
++ * A candidate without a rate is never assumed free: it is rejected, not priced at zero.
++ */
++function aiCostExcluded(req, c, ctx) {
++ const ceiling = req.max_cost_nano_usd != null ? req.max_cost_nano_usd : (req.max_cost_usd != null ? null : null);
++ if (ceiling == null && req.max_cost_usd == null) return null;
++ const usage = ctx.usage || {};
++ const rates = ctx.ratesFor(c);
++ if (!rates) return 'no rate: the cost is unknown';
++ const cost = expectedCostNano(usage, rates);
++ const limit = req.max_cost_nano_usd != null ? nanoUsd(req.max_cost_nano_usd) : BigInt(Math.ceil(req.max_cost_usd * 1e9));
++ if (cost > limit) return `expected cost ${formatNanoUsd(cost)} nano-USD is over the ${formatNanoUsd(limit)} ceiling`;
++ return null;
++}
++
++/**
++ * planAi(requirements, candidates, opts) → the AI Exchange answer.
++ *
++ * const r = planAi(
++ * { exact_artifact: 'qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9', capabilities: ['tools'], paid_managed: true,
++ * max_cost_nano_usd: '50000', objective: 'cheapest' },
++ * candidates, // ai.inference-deployment@1 + its ai.model@1 entry
++ * { enabled: true, usage: { input_tokens: 800, output_tokens: 400 },
++ * evidence: { 'dep-qwen-9b-us-east-001': { p_success: 0.95, confidence: 0.9, samples: 120 } },
++ * rates: { 'dep-qwen-9b-us-east-001': { input_nano_usd_per_m: '3000', ... } },
++ * current: 'dep-qwen-9b-us-east-001', now: Date.now() },
++ * );
++ * // → { chosen, rejected: [{ id, reason }], objective, estimate, explain: { rejected, objective, ... } }
++ *
++ * `opts.enabled` is the flag: it is off by default, and without it the planner answers
++ * `{ chosen: null, rejected: [], objective, disabled: true, explain: { objective, disabled: true } }`.
++ *
++ * Hysteresis and affinity are the ones plan() has: `current` (the deployment the workload runs on now) is kept
++ * unless another candidate is clearly better (`minGain`, default 0.15), and `affinity` (a map of deployment id to a
++ * stickiness bonus, 0..1) keeps a workload on the deployment it is warm on.
++ */
++function planAi(req, candidates, opts = {}) {
++ const {
++ enabled = false, now = Date.now(), current = null, minGain = 0.15,
++ usage = {}, evidence = null, rates = null, pFloor = DEFAULT_P_FLOOR,
++ affinity = null, affinityGain = 0.05, prior = 0.9, sparseSampleFloor = SPARSE_SAMPLE_COUNT,
++ } = opts;
++ const objective = req.objective || 'cheapest';
++ const list = Array.isArray(candidates) ? candidates : [];
++
++ if (!enabled) {
++ return {
++ chosen: null,
++ rejected: [],
++ objective,
++ disabled: true,
++ explain: { objective, disabled: true, rejected: [], candidates: [], decided_at: new Date(now).toISOString() },
++ };
++ }
++
++ const ctx = {
++ now, usage,
++ ratesFor: (c) => {
++ const id = candidateId(c);
++ if (rates instanceof Map) return rates.get(id) || null;
++ if (rates && typeof rates === 'object') return rates[id] || null;
++ return c.retail_rate || c.rate || null;
++ },
++ };
++
++ const rejected = [];
++ const seen = new Set();
++ const reject = (id, reason) => {
++ if (seen.has(id)) return;
++ seen.add(id);
++ rejected.push({ id, reason: safeReason(reason) });
++ };
++
++ const scored = [];
++ for (const c of list) {
++ const id = candidateId(c);
++ if (!id) continue;
++ const why = aiExcluded(req, c, ctx) || aiCostExcluded(req, c, ctx);
++ if (why) { reject(id, why); continue; }
++ const ratesForThis = ctx.ratesFor(c);
++ if (!ratesForThis) { reject(id, 'no rate: the cost is unknown'); continue; }
++ const expected = expectedCostNano(usage, ratesForThis);
++ const ev = evidenceOf(evidence, id);
++ const q = qualityOf(ev, { prior, floor: sparseSampleFloor });
++ if (q.p_success <= 0) { reject(id, 'no chance of success'); continue; }
++ const cps = costPerSuccessNano(expected, q.p_success, pFloor);
++ if (cps == null) { reject(id, 'no chance of success'); continue; }
++ scored.push({
++ id,
++ model: candidateModel(c),
++ expected_cost_nano_usd: expected,
++ p_success: q.p_success,
++ confidence: q.confidence,
++ samples: q.samples,
++ sparse_evidence: q.sparse,
++ evidence_source: q.source,
++ cost_per_success_nano_usd: cps,
++ latency_ms: c.latency_ms != null ? Number(c.latency_ms) : null,
++ });
++ }
++
++ if (!scored.length) {
++ return {
++ chosen: null, rejected, objective,
++ explain: { objective, rejected, candidates: [], decided_at: new Date(now).toISOString(), no_eligible_candidate: true },
++ };
++ }
++
++ // Rank by the cost of one success. A candidate whose evidence is sparse may not win on a thin measurement:
++ // it is compared as if its probability were the prior, so a sparse candidate only wins on price.
++ //
++ // The objective says how the cost per success is weighed against the other things the caller cares about
++ // (PLAN §E): `cheapest` is the cost per success alone; `fastest` prefers the latency; `best` prefers the
++ // measured quality; `balanced` weighs cost and latency; `exact` is a pinned artifact (the exact gate already
++ // left one candidate); `private` is a trust constraint (the privacy gate already left the rest).
++ const priorCost = (s) => costPerSuccessNano(s.expected_cost_nano_usd, prior, pFloor);
++ const costOf = (s) => (s.sparse_evidence ? priorCost(s) : s.cost_per_success_nano_usd);
++ const qualityOf_ = (s) => (s.sparse_evidence ? prior : s.p_success);
++ const latencyOf_ = (s) => (s.latency_ms == null ? null : Number(s.latency_ms));
++
++ // The scales the objective weighs against each other: the cost per success, the latency and the quality.
++ const lats = scored.map(latencyOf_).filter((x) => x != null);
++ const maxLat = lats.length ? lats.reduce((m, l) => (l > m ? l : m), 0) : 0;
++
++ const weights = {
++ cheapest: { cost: 1, latency: 0, quality: 0 },
++ balanced: { cost: 0.7, latency: 0.3, quality: 0 },
++ fastest: { cost: 0.2, latency: 0.8, quality: 0 },
++ best: { cost: 0.2, latency: 0, quality: 0.8 },
++ exact: { cost: 1, latency: 0, quality: 0 },
++ private: { cost: 1, latency: 0, quality: 0 },
++ }[objective] || { cost: 1, latency: 0, quality: 0 };
++
++ const ranked = scored.slice().sort((a, b) => {
++ const ca = costOf(a); const cb = costOf(b);
++ if (ca !== cb && (weights.cost > 0)) {
++ // The cost per success decides unless another objective outweighs it.
++ const latA = latencyOf_(a); const latB = latencyOf_(b);
++ const latGap = (latA != null && latB != null && maxLat > 0) ? (latA - latB) / maxLat : 0;
++ const qGap = qualityOf_(a) - qualityOf_(b);
++ const advantage = weights.latency * latGap - weights.quality * qGap;
++ if (advantage > 0.05) return 1;
++ if (advantage < -0.05) return -1;
++ return ca < cb ? -1 : 1;
++ }
++ const latA = latencyOf_(a); const latB = latencyOf_(b);
++ if (latA != null && latB != null && latA !== latB) return latA - latB;
++ return a.id < b.id ? -1 : 1;
++ });
++
++ let best = ranked[0];
++ const reasons = [];
++ // Affinity: a workload stays on the deployment it is warm on. It is a tie-breaker, never a gate.
++ if (affinity) {
++ const bonus = (id) => Number((affinity instanceof Map ? affinity.get(id) : affinity[id]) || 0);
++ const warm = ranked.find((s) => bonus(s.id) > 0);
++ if (warm && warm !== best) {
++ const cb = costOf(best); const cw = costOf(warm);
++ const gap = cb === 0n ? 0 : Number((cb - cw) * 1000n / (cb || 1n)) / 1000;
++ if (gap <= Number(affinityGain)) { best = warm; reasons.push(`stays on ${warm.id}: it is warm (affinity)`); }
++ }
++ }
++ // Hysteresis: the current placement is kept unless another one is clearly better.
++ const cur = current && scored.find((s) => s.id === current);
++ if (cur && cur !== best) {
++ const cb = costOf(best); const cc = costOf(cur);
++ const gain = cc > 0n ? Number((cc - cb) * 1000n / cc) / 1000 : 0;
++ if (gain < minGain) { best = cur; reasons.push(`stays on ${cur.id}: the best alternative is only ${(gain * 100).toFixed(1)}% better`); }
++ } else if (current && !cur) {
++ reasons.push(`${current} is no longer eligible: moved`);
++ }
++ if (best.sparse_evidence) reasons.push(`${best.id} has sparse quality evidence: it is priced as if it were at the prior`);
++ reasons.push(`objective ${objective}: the expected cost per success`);
++
++ const chosen = scored.find((s) => s.id === best.id);
++ for (const s of scored) {
++ if (s.id === chosen.id) continue;
++ if (!seen.has(s.id)) reject(s.id, 'a higher expected cost per success');
++ }
++
++ const estimate = {
++ charge_nano_usd: formatNanoUsd(chosen.expected_cost_nano_usd),
++ input_tokens: Math.max(0, Math.floor(Number(usage.input_tokens) || 0)),
++ cached_read_tokens: Math.max(0, Math.floor(Number(usage.cached_read_tokens) || 0)),
++ cache_write_tokens: Math.max(0, Math.floor(Number(usage.cache_write_tokens) || 0)),
++ output_tokens: Math.max(0, Math.floor(Number(usage.output_tokens) || 0)),
++ };
++
++ return {
++ chosen: chosen.id,
++ rejected,
++ objective,
++ estimate,
++ explain: {
++ objective,
++ rejected,
++ reasons,
++ candidates: scored.map((s) => ({
++ id: s.id,
++ model: s.model,
++ expected_cost_nano_usd: formatNanoUsd(s.expected_cost_nano_usd),
++ cost_per_success_nano_usd: formatNanoUsd(s.cost_per_success_nano_usd),
++ p_success: Number(s.p_success.toFixed(6)),
++ confidence: Number(s.confidence.toFixed(6)),
++ samples: s.samples,
++ sparse_evidence: s.sparse_evidence,
++ evidence_source: s.evidence_source,
++ selected: s.id === chosen.id,
++ })),
++ decided_at: new Date(now).toISOString(),
++ },
++ };
++}
++
// ── Stable assignment and local balancing ────────────────────────────────────
/** Weighted rendezvous (highest random weight) hashing: the target for `key`; adding or removing one moves little. */
@@@ -658,5 +1077,8 @@ module.exports = {
plan, excluded, marginalCost, priceOf, forecast, ownedLoadCost, rendezvous, pickTwo,
signPlan, verifyPlan, createPlanHolder, canonical,
place, escalate, optionKey, learn, successOf, lineFor,
++ planAi, aiExcluded, qualityOf, safeReason,
TRUST_CLASSES, TRUST_ORDER, DEFAULT_TRUST,
++ PAID_PROVIDER_ACCESS, PROVIDER_ACCESS, PRIVACY_CLASSES, MODEL_CAPABILITIES,
++ DEFAULT_P_FLOOR, SPARSE_SAMPLE_COUNT,
};
ddiff --git a/test/cost.test.js b/test/cost.test.js
iindex d3a84f2..9b1454e 100644
---- a/test/cost.test.js
++++ b/test/cost.test.js
@@@ -4,7 +4,8 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { run } = require('./helpers');
--const { rateCard, costSnapshot, estimate, validateRateCard, validateCostSnapshot } = require('../src/cost');
++const { rateCard, costSnapshot, estimate, validateRateCard, validateCostSnapshot,
++ nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano, formatNanoUsd } = require('../src/cost');
const FIXTURES_RC = path.join(__dirname, '..', 'node_modules', 'openvibe-contracts', 'fixtures', 'platform.rate-card');
const FIXTURES_CS = path.join(__dirname, '..', 'node_modules', 'openvibe-contracts', 'fixtures', 'platform.cost-snapshot');
@@@ -75,4 +76,66 @@ run([
// null unit is carried through as null
assert.equal(estimate(card, 100).unit, null);
}],
++
++ ['nano-USD money: an integer count of 1e-9 USD, never a float of dollars', async () => {
++ assert.equal(nanoUsd('3000'), 3000n);
++ assert.equal(nanoUsd(0), 0n);
++ assert.equal(nanoUsd(null), 0n);
++ assert.equal(nanoUsd(undefined), 0n);
++ assert.equal(nanoUsd(''), 0n);
++ assert.equal(nanoUsd(3000n), 3000n);
++ assert.throws(() => nanoUsd(1.5), TypeError, 'a fractional nano-USD is a bug, not a rounding opportunity');
++ assert.throws(() => nanoUsd('0.5'), TypeError);
++ assert.throws(() => nanoUsd('12abc'), TypeError);
++ assert.equal(formatNanoUsd(8400n), '8400');
++ assert.equal(formatNanoUsd('8400'), '8400');
++ }],
++
++ ['nano-USD money: the charge of a component is ceil(tokens * rate / 1e6)', async () => {
++ // 800 input tokens at 3000 nano-USD per million: 800 * 3000 = 2_400_000; / 1e6 = 2.4; ceil = 3.
++ assert.equal(chargeTokens(800, '3000'), 3n);
++ // 400 output tokens at 15000 nano-USD per million: 400 * 15000 = 6_000_000; / 1e6 = 6; ceil = 6.
++ assert.equal(chargeTokens(400, '15000'), 6n);
++ // A rate that divides exactly is not rounded up.
++ assert.equal(chargeTokens(1e6, '3000'), 3000n);
++ // One token at 3000 per million is 0.003 nano-USD: the whole nano-USD is charged.
++ assert.equal(chargeTokens(1, '3000'), 1n);
++ // Nothing to charge for nothing.
++ assert.equal(chargeTokens(0, '3000'), 0n);
++ assert.equal(chargeTokens(1000, '0'), 0n);
++ }],
++
++ ['nano-USD money: the expected cost adds the four token classes and the per-request fee', async () => {
++ const rates = {
++ input_nano_usd_per_m: '3000', cached_read_nano_usd_per_m: '300',
++ cache_write_nano_usd_per_m: '3000', output_nano_usd_per_m: '15000', per_request_nano_usd: '0',
++ };
++ // input 800 @ 3000/M = 3; output 400 @ 15000/M = 6; the rest are 0; fee 0 => 9.
++ assert.equal(expectedCostNano({ input_tokens: 800, output_tokens: 400 }, rates), 9n);
++ // The per-request fee is added once.
++ assert.equal(expectedCostNano({ input_tokens: 800, output_tokens: 400 }, { ...rates, per_request_nano_usd: '7' }), 16n);
++ // All four classes at once:
++ // input 1000 @ 3000/M = 3; cached read 2000 @ 300/M = 0.6 -> 1; cache write 500 @ 3000/M = 1.5 -> 2;
++ // output 1000 @ 15000/M = 15; fee 3 => 24.
++ assert.equal(expectedCostNano(
++ { input_tokens: 1000, cached_read_tokens: 2000, cache_write_tokens: 500, output_tokens: 1000 },
++ { ...rates, per_request_nano_usd: '3' },
++ ), 24n);
++ // The nested settlement shape prices the same way.
++ assert.equal(expectedCostNano({ input_tokens: 800, output_tokens: 400 }, {
++ nano_usd_per_m: { input: '3000', cached_read: '300', cache_write: '3000', output: '15000' },
++ per_request_nano_usd: '0',
++ }), 9n);
++ }],
++
++ ['nano-USD money: the cost per success is the expected cost over max(p_success, floor)', async () => {
++ // 9 nano-USD at p = 0.9: 9 / 0.9 = 10.
++ assert.equal(costPerSuccessNano(9n, 0.9, 0.1), 10n);
++ // The floor holds it up: p = 0.01 with a floor of 0.1 gives 9 / 0.1 = 90.
++ assert.equal(costPerSuccessNano(9n, 0.01, 0.1), 90n);
++ // No chance of success has no cost per success.
++ assert.equal(costPerSuccessNano(9n, 0, 0.1), null);
++ // A probability above one is clamped to the floor's job: the divisor never goes below the floor.
++ assert.equal(costPerSuccessNano(9n, 1, 0.1), 9n);
++ }],
]);
ddiff --git a/test/placement-ai.test.js b/test/placement-ai.test.js
nnew file mode 100644
iindex 0000000..0e520ec
---- /dev/null
++++ b/test/placement-ai.test.js
@@@ -0,0 +1,421 @@
++'use strict';
++/**
++ * openvibe-sdk/placement, the AI Exchange face (plan-state 2026-10-22 §E, G-P1/P7; decisions D1 and D5):
++ * hard gates before scoring, the expected cost per success, sparse evidence marked and not trusted, the
++ * nano-USD math by hand, and an explanation that never carries a secret or a host.
++ *
++ * Every number below is hand-computed in its comment.
++ */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const { planAi, aiExcluded, safeReason, PROVIDER_ACCESS, PAID_PROVIDER_ACCESS } = require('../src/placement');
++const { nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano, formatNanoUsd } = require('../src/cost');
++
++// The contracts of the AI Exchange (ai.route-explain@1) may not be in the pinned openvibe-contracts yet; the
++// shape check then skips and says so.
++let contracts = null;
++try { contracts = require('openvibe-contracts'); } catch { /* not installed */ }
++let hasRouteExplain = false;
++if (contracts) {
++ try { contracts.validate('ai.route-explain@1', { chosen: 'x', rejected: [], objective: 'cheapest', estimate: { charge_nano_usd: '0' } }); hasRouteExplain = true; } catch { /* the contract is not in this pin yet */ }
++}
++
++const T = Date.parse('2026-11-03T12:00:00Z');
++
++// The usage of one request: 800 uncached input tokens, 400 output tokens.
++const USAGE = { input_tokens: 800, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 400 };
++
++// The rates of one model (ai.model@1 retail_rate): 3000 nano-USD per million input tokens, 15000 per million
++// output tokens, no per-request fee.
++const RATES = {
++ input_nano_usd_per_m: '3000',
++ cached_read_nano_usd_per_m: '300',
++ cache_write_nano_usd_per_m: '3000',
++ output_nano_usd_per_m: '15000',
++ per_request_nano_usd: '0',
++};
++
++// A deployment (ai.inference-deployment@1) with its model (ai.model@1).
++const deployment = (id, extra = {}) => ({
++ id,
++ artifact_id: 'qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9',
++ region: 'us-east',
++ trust_tier: 'first_party',
++ operator: 'openvibe',
++ status: 'ready',
++ limits: { context_tokens: 32768 },
++ provider_access: 'OWN_DEPLOYMENT',
++ commercial_rights: 'OWN_DEPLOYMENT',
++ privacy_policy: 'zero_retention',
++ health: { healthy: true },
++ updated_at: '2026-11-03T00:00:00.000Z',
++ capabilities: { tools: true, json: true, vision: false, stream: true },
++ ...extra,
++});
++
++// The requirements of one request.
++const req = (o = {}) => ({ objective: 'cheapest', paid_managed: true, ...o });
++
++const opts = (o = {}) => ({ enabled: true, now: T, usage: USAGE, rates: {}, ...o });
++
++// The rates of a set of deployments.
++const ratesFor = (map) => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, v]));
++
++run([
++ ['the planner is off by default and says so', async () => {
++ const r = planAi(req(), [deployment('dep-a')], opts({ enabled: false }));
++ assert.equal(r.disabled, true);
++ assert.equal(r.chosen, null);
++ assert.equal(r.explain.disabled, true);
++ assert.equal(r.explain.objective, 'cheapest');
++ }],
++
++ ['nano-USD: the charge of a component is ceil(tokens * rate / 1e6), by hand', async () => {
++ // 800 input tokens at 3000 nano-USD per million: 800 * 3000 = 2_400_000; / 1e6 = 2.4; ceil = 3.
++ assert.equal(chargeTokens(800, '3000'), 3n);
++ // 400 output tokens at 15000 nano-USD per million: 400 * 15000 = 6_000_000; / 1e6 = 6; ceil = 6.
++ assert.equal(chargeTokens(400, '15000'), 6n);
++ // A partial token still costs the whole nano-USD: 1 token at 3000 per million = 0.003; ceil = 1.
++ assert.equal(chargeTokens(1, '3000'), 1n);
++ // Zero tokens cost nothing.
++ assert.equal(chargeTokens(0, '3000'), 0n);
++ // A rate of zero costs nothing.
++ assert.equal(chargeTokens(1e9, '0'), 0n);
++ // Money is an integer count of nano-USD, never a float of dollars.
++ assert.equal(nanoUsd('3000'), 3000n);
++ assert.equal(nanoUsd(0), 0n);
++ assert.equal(nanoUsd(null), 0n);
++ assert.throws(() => nanoUsd(1.5), TypeError, 'a fractional nano-USD is a bug, not a rounding opportunity');
++ assert.throws(() => nanoUsd('0.5'), TypeError);
++ assert.equal(formatNanoUsd(8400n), '8400');
++ }],
++
++ ['nano-USD: the expected cost adds the four token classes and the per-request fee', async () => {
++ // input 800 @ 3000/M = 3; output 400 @ 15000/M = 6; cached read 0; cache write 0; fee 0 => 9 nano-USD.
++ const cost = expectedCostNano(USAGE, RATES);
++ assert.equal(cost, 9n);
++ // The cached read is cheaper: 1000 cached @ 300/M = 1000*300/1e6 = 0.3; ceil = 1.
++ assert.equal(chargeTokens(1000, '300'), 1n);
++ // A per-request fee is added once, not per component.
++ const withFee = expectedCostNano(USAGE, { ...RATES, per_request_nano_usd: '7' });
++ assert.equal(withFee, 16n);
++ // The nested settlement rate shape (nano_usd_per_m) prices the same way.
++ const nested = expectedCostNano(USAGE, {
++ nano_usd_per_m: { input: '3000', cached_read: '300', cache_write: '3000', output: '15000' },
++ per_request_nano_usd: '0',
++ });
++ assert.equal(nested, 9n);
++ // Four token classes at once, each charged once:
++ // input 1000 @ 3000/M = 3; cached read 2000 @ 300/M = 600000/1e6 = 0.6 -> 1;
++ // cache write 500 @ 3000/M = 1.5 -> 2; output 1000 @ 15000/M = 15; fee 3 => 3+1+2+15+3 = 24.
++ const four = expectedCostNano(
++ { input_tokens: 1000, cached_read_tokens: 2000, cache_write_tokens: 500, output_tokens: 1000 },
++ { ...RATES, per_request_nano_usd: '3' },
++ );
++ assert.equal(four, 24n);
++ }],
++
++ ['nano-USD: the cost per success is the expected cost over max(p_success, floor)', async () => {
++ // 9 nano-USD at p = 0.9: 9 / 0.9 = 10 nano-USD.
++ assert.equal(costPerSuccessNano(9n, 0.9, 0.1), 10n);
++ // The floor holds it up: p = 0.01 with a floor of 0.1 gives 9 / 0.1 = 90.
++ assert.equal(costPerSuccessNano(9n, 0.01, 0.1), 90n);
++ // No chance of success has no cost per success.
++ assert.equal(costPerSuccessNano(9n, 0, 0.1), null);
++ }],
++
++ ['gate: the exact artifact is never substituted', async () => {
++ const wanted = 'qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9';
++ const other = deployment('dep-b', { artifact_id: 'meta-llama/llama_3.1_8b_instruct:fp16@bca2580' });
++ const r = planAi(req({ exact_artifact: wanted }), [deployment('dep-a'), other], opts({
++ rates: { 'dep-a': RATES, 'dep-b': RATES },
++ }));
++ assert.equal(r.chosen, 'dep-a');
++ const rejected = r.explain.rejected.find((x) => x.id === 'dep-b');
++ assert.ok(rejected, 'the other artifact is rejected');
++ assert.match(rejected.reason, /exact model requested/);
++ // The gate is a hard one: nothing is eligible when no deployment carries the artifact.
++ const none = planAi(req({ exact_artifact: 'org/nope@abc' }), [deployment('dep-a')], opts({ rates: { 'dep-a': RATES } }));
++ assert.equal(none.chosen, null);
++ assert.equal(none.explain.rejected[0].reason.includes('exact model requested'), true);
++ }],
++
++ ['gate: a paid managed route needs OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE', async () => {
++ for (const access of ['CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED']) {
++ const d = deployment('dep-x', { provider_access: access, commercial_rights: access });
++ const r = planAi(req(), [d], opts({ rates: { 'dep-x': RATES } }));
++ assert.equal(r.chosen, null, `${access} may not serve a paid route`);
++ assert.match(r.explain.rejected[0].reason, /commercial right/);
++ }
++ // The two rights that may.
++ for (const access of PAID_PROVIDER_ACCESS) {
++ const d = deployment('dep-x', { provider_access: access, commercial_rights: access });
++ const r = planAi(req(), [d], opts({ rates: { 'dep-x': RATES } }));
++ assert.equal(r.chosen, 'dep-x', `${access} may serve a paid route`);
++ }
++ // A route that is not paid managed accepts a restricted right.
++ const free = planAi(req({ paid_managed: false }), [deployment('dep-x', { provider_access: 'RESTRICTED' })],
++ opts({ rates: { 'dep-x': RATES } }));
++ assert.equal(free.chosen, 'dep-x');
++ // The full set of rights is the one the contract names.
++ assert.deepEqual([...PROVIDER_ACCESS].sort(), ['AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'OWN_DEPLOYMENT', 'RESTRICTED', 'UNVERIFIED']);
++ }],
++
++ ['gate: the privacy class and the trust tier keep a request where it belongs', async () => {
++ // A deployment that is not zero-retention may not run a zero-retention workload.
++ const loose = deployment('dep-loose', { privacy_policy: 'standard' });
++ const r = planAi(req({ privacy_class: 'zero_retention' }), [loose], opts({ rates: { 'dep-loose': RATES } }));
++ assert.equal(r.chosen, null);
++ assert.match(r.explain.rejected[0].reason, /privacy class/);
++ // A zero-retention deployment may.
++ const tight = deployment('dep-tight');
++ const ok = planAi(req({ privacy_class: 'zero_retention' }), [tight], opts({ rates: { 'dep-tight': RATES } }));
++ assert.equal(ok.chosen, 'dep-tight');
++ // The trust tier is a gate too.
++ const partner = deployment('dep-p', { trust_tier: 'approved_wholesale' });
++ const trust = planAi(req({ trust: ['first_party'] }), [partner], opts({ rates: { 'dep-p': RATES } }));
++ assert.equal(trust.chosen, null);
++ assert.match(trust.explain.rejected[0].reason, /trust tier/);
++ }],
++
++ ['gate: a capability the model lacks keeps it out', async () => {
++ for (const cap of ['tools', 'json', 'vision', 'stream']) {
++ const blind = deployment('dep-blind', { capabilities: { tools: false, json: false, vision: false, stream: false } });
++ const r = planAi(req({ capabilities: [cap] }), [blind], opts({ rates: { 'dep-blind': RATES } }));
++ assert.equal(r.chosen, null, `${cap} is required`);
++ assert.match(r.explain.rejected[0].reason, new RegExp(`lacks ${cap}`));
++ }
++ // A deployment that has them all passes every gate.
++ const full = deployment('dep-full', { capabilities: { tools: true, json: true, vision: true, stream: true } });
++ const ok = planAi(req({ capabilities: ['tools', 'json', 'vision', 'stream'] }), [full], opts({ rates: { 'dep-full': RATES } }));
++ assert.equal(ok.chosen, 'dep-full');
++ }],
++
++ ['gate: the context bound and the health keep a candidate out', async () => {
++ const small = deployment('dep-small', { limits: { context_tokens: 4096 } });
++ const r = planAi(req({ context_tokens: 32768 }), [small], opts({ rates: { 'dep-small': RATES } }));
++ assert.equal(r.chosen, null);
++ assert.match(r.explain.rejected[0].reason, /context/);
++ // A deployment that is not ready is out.
++ const down = deployment('dep-down', { status: 'offline' });
++ const h = planAi(req(), [down], opts({ rates: { 'dep-down': RATES } }));
++ assert.equal(h.chosen, null);
++ assert.match(h.explain.rejected[0].reason, /status offline/);
++ }],
++
++ ['gate: the cost ceiling is in nano-USD and an unpriced candidate is never free', async () => {
++ // The expected cost of the request is 9 nano-USD.
++ const r = planAi(req({ max_cost_nano_usd: '8' }), [deployment('dep-a')], opts({ rates: { 'dep-a': RATES } }));
++ assert.equal(r.chosen, null);
++ assert.match(r.explain.rejected[0].reason, /over the 8 ceiling/);
++ // A ceiling of 9 lets it through.
++ const ok = planAi(req({ max_cost_nano_usd: '9' }), [deployment('dep-a')], opts({ rates: { 'dep-a': RATES } }));
++ assert.equal(ok.chosen, 'dep-a');
++ // A candidate with no rate is not free: it is rejected.
++ const noRate = planAi(req(), [deployment('dep-a')], opts({ rates: {} }));
++ assert.equal(noRate.chosen, null);
++ assert.match(noRate.explain.rejected[0].reason, /no rate/);
++ }],
++
++ ['the objective prefers the cheaper per success over the cheaper per token', async () => {
++ // dep-cheap costs 9 nano-USD for the request and succeeds 60% of the time: 9 / 0.6 = 15 per success.
++ // dep-good costs 12 nano-USD and succeeds 95% of the time: 12 / 0.95 = 12.63 per success.
++ // dep-cheap is cheaper per token (9 < 12) but dearer per success (15 > 12.63): the planner takes dep-good.
++ const cheap = deployment('dep-cheap');
++ const good = deployment('dep-good');
++ const r = planAi(req(), [cheap, good], opts({
++ rates: { 'dep-cheap': RATES, 'dep-good': { ...RATES, output_nano_usd_per_m: '24000' } },
++ evidence: {
++ 'dep-cheap': { p_success: 0.6, confidence: 1, samples: 500 },
++ 'dep-good': { p_success: 0.95, confidence: 1, samples: 500 },
++ },
++ }));
++ assert.equal(r.chosen, 'dep-good', 'the cheaper per success wins, not the cheaper per token');
++ const byId = Object.fromEntries(r.explain.candidates.map((c) => [c.id, c]));
++ assert.equal(byId['dep-cheap'].expected_cost_nano_usd, '9');
++ // dep-good: output 400 @ 24000/M = 400*24000/1e6 = 9.6 -> 10; input 3; fee 0 => 13.
++ assert.equal(byId['dep-good'].expected_cost_nano_usd, '13');
++ // 13 / 0.95 = 13.68 -> ceil = 14; 9 / 0.6 = 15.
++ assert.equal(byId['dep-good'].cost_per_success_nano_usd, '14');
++ assert.equal(byId['dep-cheap'].cost_per_success_nano_usd, '15');
++ // The one that lost is listed with its reason.
++ assert.ok(r.explain.rejected.find((x) => x.id === 'dep-cheap' && /higher expected cost per success/.test(x.reason)));
++ }],
++
++ ['sparse evidence is marked, not trusted: it never dominates', async () => {
++ // dep-thin has a great number but only 3 observations and no confidence: it is sparse.
++ // dep-solid has a worse number but 400 observations at full confidence.
++ // dep-thin is cheaper (9 vs 13 nano-USD), so on price alone it would win; on trusted evidence it must not.
++ const thin = deployment('dep-thin');
++ const solid = deployment('dep-solid');
++ const r = planAi(req(), [thin, solid], opts({
++ rates: { 'dep-thin': RATES, 'dep-solid': { ...RATES, output_nano_usd_per_m: '24000' } },
++ evidence: {
++ 'dep-thin': { p_success: 0.99, samples: 3 },
++ 'dep-solid': { p_success: 0.7, confidence: 1, samples: 400 },
++ },
++ }));
++ const byId = Object.fromEntries(r.explain.candidates.map((c) => [c.id, c]));
++ assert.equal(byId['dep-thin'].sparse_evidence, true, 'thin evidence is marked');
++ assert.equal(byId['dep-solid'].sparse_evidence, false);
++ assert.equal(byId['dep-thin'].confidence, 0, 'a sparse measurement carries no confidence');
++ // The sparse candidate is priced as if it were at the prior (0.9): 9 / 0.9 = 10; the solid one:
++ // 13 / 0.7 = 18.57 -> 19. The solid one is dearer per success, so the thin one wins on price...
++ assert.equal(r.chosen, 'dep-thin');
++ // ...but it never wins on QUALITY: it may not claim a better probability than the prior.
++ assert.ok(byId['dep-thin'].p_success <= 0.9 + 1e-9, 'a sparse measurement is pulled back to the prior');
++ assert.equal(byId['dep-thin'].p_success, 0.9);
++ // And the answer says the evidence is sparse.
++ assert.ok(r.explain.reasons.some((x) => /sparse quality evidence/.test(x)));
++ }],
++
++ ['sparse evidence cannot beat a trusted one when the trusted one is also better priced', async () => {
++ // dep-thin: 9 nano-USD, a sparse 0.99 measurement. dep-solid: 8 nano-USD, a trusted 0.7 measurement.
++ // Priced as at the prior the thin one is 9 / 0.9 = 10 per success; the solid one 8 / 0.7 = 11.43 -> 12.
++ // The thin one wins on the prior price, and that is the point: the sparse measurement never moved it.
++ const thin = deployment('dep-thin');
++ const solid = deployment('dep-solid');
++ const r = planAi(req(), [thin, solid], opts({
++ rates: {
++ 'dep-thin': RATES,
++ 'dep-solid': { ...RATES, input_nano_usd_per_m: '1500', output_nano_usd_per_m: '9000' },
++ },
++ evidence: {
++ 'dep-thin': { p_success: 0.99, samples: 3 },
++ 'dep-solid': { p_success: 0.7, confidence: 1, samples: 400 },
++ },
++ }));
++ const byId = Object.fromEntries(r.explain.candidates.map((c) => [c.id, c]));
++ // dep-solid: input 800 @ 1500/M = 1.2 -> 2; output 400 @ 9000/M = 3.6 -> 4; total 6.
++ assert.equal(byId['dep-solid'].expected_cost_nano_usd, '6');
++ // 6 / 0.7 = 8.57 -> 9; the thin one 9 / 0.9 = 10.
++ assert.equal(byId['dep-solid'].cost_per_success_nano_usd, '9');
++ assert.equal(byId['dep-thin'].cost_per_success_nano_usd, '10');
++ assert.equal(r.chosen, 'dep-solid', 'the trusted evidence wins on the cost per success');
++ }],
++
++ ['the explanation never carries a secret, a host or an endpoint', async () => {
++ const leaky = deployment('dep-leaky', {
++ endpoint: 'http://gpu-001.internal:4800/v1',
++ host: 'gpu-001.internal',
++ operator: 'owner:usr_AAAAAAAAAAAAAAAAAAAAAAAAAA',
++ });
++ const r = planAi(req({ exact_artifact: 'org/other@abc' }), [leaky], opts({ rates: { 'dep-leaky': RATES } }));
++ const text = JSON.stringify(r.explain.rejected);
++ assert.ok(!/gpu-001/.test(text), 'no host');
++ assert.ok(!/internal/.test(text), 'no internal name');
++ assert.ok(!/usr_[A-Z]{26}/.test(text), 'no subject id');
++ assert.ok(!/http/.test(text), 'no endpoint');
++ // safeReason() strips them whatever it is given.
++ const s = safeReason('call http://10.0.0.5/v1 with sk-abcdefgh12345678 for usr_BBBBBBBBBBBBBBBBBBBBBBBBBB');
++ assert.ok(!/10\.0\.0\.5/.test(s));
++ assert.ok(!/sk-abcdefgh12345678/.test(s));
++ assert.ok(!/usr_B/.test(s));
++ }],
++
++ ['hysteresis and affinity stay: the current placement is kept unless another is clearly better', async () => {
++ // dep-a is the current placement and costs 9; dep-b costs 13.
++ // dep-a: 9 / 0.8 = 11.25 -> 12 per success; dep-b: 13 / 0.9 = 14.44 -> 15. dep-a is the better one,
++ // and the current placement stays.
++ const a = deployment('dep-a');
++ const b = deployment('dep-b');
++ const r = planAi(req(), [a, b], opts({
++ current: 'dep-a',
++ rates: { 'dep-a': RATES, 'dep-b': { ...RATES, output_nano_usd_per_m: '24000' } },
++ evidence: {
++ 'dep-a': { p_success: 0.8, confidence: 1, samples: 400 },
++ 'dep-b': { p_success: 0.9, confidence: 1, samples: 400 },
++ },
++ }));
++ assert.equal(r.chosen, 'dep-a');
++ // The current placement is kept when the alternative is only a little better: dep-a 12 per success,
++ // dep-b 15: the gain is (12 - 15)/12, negative, so it stays.
++ // Now a case where the alternative IS clearly better but not by the 15% the planner asks for:
++ // dep-a 9 / 0.5 = 18 per success; dep-b 13 / 0.95 = 13.68 -> 14. The gain is (18 - 14) / 18 = 22%.
++ const moved = planAi(req(), [a, b], opts({
++ current: 'dep-a',
++ minGain: 0.30,
++ rates: { 'dep-a': RATES, 'dep-b': { ...RATES, output_nano_usd_per_m: '24000' } },
++ evidence: {
++ 'dep-a': { p_success: 0.5, confidence: 1, samples: 400 },
++ 'dep-b': { p_success: 0.95, confidence: 1, samples: 400 },
++ },
++ }));
++ assert.equal(moved.chosen, 'dep-a', 'a 22% gain is below the 30% the planner asks for');
++ assert.ok(moved.explain.reasons.some((x) => /only .*% better/.test(x)), 'the reason says how much better the other is');
++ // With the default gain of 15% the same two candidates do move.
++ const moved2 = planAi(req(), [a, b], opts({
++ current: 'dep-a',
++ rates: { 'dep-a': RATES, 'dep-b': { ...RATES, output_nano_usd_per_m: '24000' } },
++ evidence: {
++ 'dep-a': { p_success: 0.5, confidence: 1, samples: 400 },
++ 'dep-b': { p_success: 0.95, confidence: 1, samples: 400 },
++ },
++ }));
++ assert.equal(moved2.chosen, 'dep-b');
++ // Affinity: a warm deployment is kept when it is within the affinity gain.
++ const warm = planAi(req(), [a, b], opts({
++ affinity: { 'dep-a': 0.8 },
++ minGain: 0.30,
++ rates: { 'dep-a': RATES, 'dep-b': { ...RATES, output_nano_usd_per_m: '24000' } },
++ evidence: {
++ 'dep-a': { p_success: 0.5, confidence: 1, samples: 400 },
++ 'dep-b': { p_success: 0.95, confidence: 1, samples: 400 },
++ },
++ }));
++ assert.equal(warm.chosen, 'dep-a');
++ assert.ok(warm.explain.reasons.some((x) => /affinity/.test(x)));
++ }],
++
++ ['the answer carries the objective, the estimate and the rejections', async () => {
++ const r = planAi(req({ objective: 'balanced' }), [deployment('dep-a'), deployment('dep-b', { artifact_id: 'other/other@abc' })], opts({
++ rates: { 'dep-a': RATES, 'dep-b': RATES },
++ }));
++ assert.equal(r.objective, 'balanced');
++ assert.equal(r.explain.objective, 'balanced');
++ assert.equal(r.chosen, 'dep-a');
++ assert.equal(r.estimate.charge_nano_usd, '9');
++ assert.equal(r.estimate.input_tokens, 800);
++ assert.equal(r.estimate.output_tokens, 400);
++ assert.ok(Array.isArray(r.explain.rejected));
++ assert.ok(r.explain.rejected.every((x) => x.id && x.reason));
++ }],
++
++ ['a candidate with no chance of success is not a candidate', async () => {
++ const r = planAi(req(), [deployment('dep-a')], opts({
++ rates: { 'dep-a': RATES },
++ evidence: { 'dep-a': { p_success: 0, confidence: 1, samples: 400 } },
++ }));
++ assert.equal(r.chosen, null);
++ assert.match(r.explain.rejected[0].reason, /no chance of success/);
++ }],
++
++ ['the objective says what was weighed: cheapest takes the cost per success, fastest the latency', async () => {
++ // dep-slow is cheaper per success (9 / 0.9 = 10) but slower; dep-quick costs 12 / 0.9 = 14 and is faster.
++ const slow = deployment('dep-slow', { latency_ms: 800 });
++ const quick = deployment('dep-quick', { latency_ms: 100 });
++ const rates = { 'dep-slow': RATES, 'dep-quick': { ...RATES, output_nano_usd_per_m: '24000' } };
++ const evidence = {
++ 'dep-slow': { p_success: 0.9, confidence: 1, samples: 400 },
++ 'dep-quick': { p_success: 0.9, confidence: 1, samples: 400 },
++ };
++ const cheapest = planAi(req({ objective: 'cheapest' }), [slow, quick], opts({ rates, evidence }));
++ assert.equal(cheapest.chosen, 'dep-slow', 'cheapest takes the lower cost per success');
++ assert.equal(cheapest.explain.objective, 'cheapest');
++ const fastest = planAi(req({ objective: 'fastest' }), [slow, quick], opts({ rates, evidence }));
++ assert.equal(fastest.chosen, 'dep-quick', 'fastest takes the quicker one');
++ assert.equal(fastest.explain.objective, 'fastest');
++ }],
++
++ ['the answer is an ai.route-explain@1 document', async () => {
++ if (!hasRouteExplain) { console.log('ai.route-explain@1: skipped (the contract is not in the pinned openvibe-contracts yet)'); return; }
++ const r = planAi(req({ objective: 'balanced' }), [deployment('dep-a'), deployment('dep-b', { artifact_id: 'other/other@abc' })], opts({
++ rates: { 'dep-a': RATES, 'dep-b': RATES },
++ evidence: { 'dep-a': { p_success: 0.95, confidence: 0.9, samples: 120 } },
++ }));
++ const v = contracts.validate('ai.route-explain@1', {
++ chosen: r.chosen, rejected: r.rejected, objective: r.objective, estimate: r.estimate,
++ });
++ assert.ok(v.valid, `ai.route-explain@1: ${JSON.stringify(v.errors)}`);
++ }],
++]);
ddiff --git a/types/cost.d.ts b/types/cost.d.ts
iindex 5e1843b..9cb1c0a 100644
---- a/types/cost.d.ts
++++ b/types/cost.d.ts
@@@ -15,3 +15,32 @@ export declare function costSnapshot(fields: Partial<CostSnapshot>): CostSnapsho
export declare function estimate(card: RateCard, quantity: number, unit?: string): EstimateResult;
export declare function validateRateCard(record: unknown): { ok: boolean; errors: ValidationError[] };
export declare function validateCostSnapshot(record: unknown): { ok: boolean; errors: ValidationError[] };
++
++// ── The AI Exchange money (decision D1): integer nano-USD, never floats ───────────────────────────────────────
++/** One nano-USD: 1e-9 USD. */
++export declare const NANO_USD: bigint;
++/** Tokens per million: the denominator of every per-million-token rate. */
++export declare const TOKENS_PER_MILLION: bigint;
++/** The four token classes the settlement uses. */
++export declare const TOKEN_CLASSES: readonly ['input_tokens', 'cached_read_tokens', 'cache_write_tokens', 'output_tokens'];
++/** The rate field each token class is priced by. */
++export declare const RATE_FIELDS: Record<string, string>;
++/** The rates of one model: integer nano-USD per million tokens, or the nested settlement shape. */
++export type NanoRate = { version?: string;
++ input_nano_usd_per_m?: string; cached_read_nano_usd_per_m?: string; cache_write_nano_usd_per_m?: string;
++ output_nano_usd_per_m?: string; per_request_nano_usd?: string;
++ nano_usd_per_m?: { input?: string; cached_read?: string; cache_write?: string; output?: string } };
++/** The tokens of one request, in the four classes. */
++export type TokenUsage = { input_tokens?: number; cached_read_tokens?: number; cache_write_tokens?: number; output_tokens?: number };
++/** A money amount as an integer count of nano-USD. A fraction is a bug, not a rounding opportunity. */
++export declare function nanoUsd(value: string | number | bigint | null | undefined): bigint;
++/** The charge of `tokens` at `rateNanoPerM`: ceil(tokens * rate / 1e6), rounded UP. */
++export declare function chargeTokens(tokens: number, rateNanoPerM: string | number | bigint | null | undefined): bigint;
++/** The expected cost of one request: each token class charged once, plus the per-request fee. */
++export declare function expectedCostNano(usage: TokenUsage, rates: NanoRate | null | undefined): bigint;
++/** The cost of one success: expected / max(p_success, floor), rounded UP to a whole nano-USD. */
++export declare function costPerSuccessNano(expectedNano: bigint | string | number, pSuccess: number, floor?: number): bigint | null;
++/** A money amount as the wire string. */
++export declare function formatNanoUsd(nano: bigint | string | number): string;
++/** The rate of one token class, from either shape a rate travels in. */
++export declare function rateFor(rates: NanoRate | null | undefined, tokenClass: string): bigint;
ddiff --git a/types/placement.d.ts b/types/placement.d.ts
iindex b5eade1..fbaa3e1 100644
---- a/types/placement.d.ts
++++ b/types/placement.d.ts
@@@ -13,6 +13,70 @@ export type RateCard = { id: string; provider: string; metric: string; unit_size
export type ProviderState = { provider: string; period_start: string; period_end: string; usage: Record<string, number>; forecast?: Record<string, number>; reserve?: Record<string, number> };
export type Candidate = { id: string; eligible: boolean; excluded_because?: string; estimated_cost_usd?: number; estimated_latency_ms?: number; score?: number };
export type PlacementResult = { selected: string | null; objective: string; reasons: string[]; candidates: Candidate[]; decided_at: string };
++
++// ── The AI Exchange face (plan-state 2026-10-22 §E, G-P1/P7; decisions D1 and D5) ──────────────────────────
++/** The commercial rights of the AI Exchange (ai.inference-deployment@1). */
++export type ProviderAccess = 'OWN_DEPLOYMENT' | 'AUTHORIZED_WHOLESALE' | 'CUSTOMER_BYOK' | 'RESTRICTED' | 'UNVERIFIED';
++/** The privacy classes, from the strictest to the loosest. */
++export type PrivacyClass = 'zero_retention' | 'no_training' | 'standard' | 'unknown';
++/** The model capabilities (ai.model@1). */
++export type ModelCapability = 'tools' | 'json' | 'vision' | 'stream';
++/** One deployment to place (ai.inference-deployment@1) with its model entry (ai.model@1). */
++export type AiDeployment = { id: string; artifact_id?: string; region?: string;
++ trust_tier?: 'first_party' | 'owner_node' | 'approved_wholesale' | 'unverified';
++ operator?: string; status?: 'warming' | 'ready' | 'full' | 'degraded' | 'offline';
++ limits?: { max_concurrency?: number; context_tokens?: number; max_output_tokens?: number };
++ provider_access?: ProviderAccess; commercial_rights?: ProviderAccess; privacy_policy?: string | null;
++ health?: { healthy?: boolean; last_check_at?: string | null; reason?: string | null };
++ capabilities?: Partial<Record<ModelCapability, boolean>>;
++ model_id?: string; model?: { id?: string; context?: number; context_window?: number; capabilities?: Partial<Record<ModelCapability, boolean>> };
++ latency_ms?: number; retail_rate?: AiRate; rate?: AiRate; paid_managed?: boolean };
++/** The rates of one model: integer nano-USD per million tokens (ai.model@1 retail_rate) or the nested settlement shape. */
++export type AiRate = { version?: string;
++ input_nano_usd_per_m?: string; cached_read_nano_usd_per_m?: string; cache_write_nano_usd_per_m?: string;
++ output_nano_usd_per_m?: string; per_request_nano_usd?: string;
++ nano_usd_per_m?: { input?: string; cached_read?: string; cache_write?: string; output?: string } };
++/** The tokens of one request, in the four classes the settlement uses. */
++export type AiUsage = { input_tokens?: number; cached_read_tokens?: number; cache_write_tokens?: number; output_tokens?: number };
++/** The quality evidence for one candidate: a measured chance and how much it may be trusted. */
++export type AiEvidence = { p_success: number; confidence?: number; samples?: number; source?: string };
++/** What the workload needs of the deployment. */
++export type AiRequirements = { objective?: 'cheapest' | 'balanced' | 'fastest' | 'best' | 'private' | 'exact';
++ exact_artifact?: string; artifact_id?: string; exact_model?: string;
++ paid_managed?: boolean; allow_restricted?: boolean;
++ privacy_class?: PrivacyClass; trust?: string[]; capabilities?: ModelCapability[];
++ context_tokens?: number; max_cost_nano_usd?: string; max_cost_usd?: number };
++/** One candidate in the explanation. */
++export type AiCandidate = { id: string; model?: string; expected_cost_nano_usd: string; cost_per_success_nano_usd: string;
++ p_success: number; confidence: number; samples: number; sparse_evidence: boolean; evidence_source: string; selected: boolean };
++/** The explanation: what was left out and why, and what the planner optimised for. */
++export type AiExplain = { objective: string; rejected: { id: string; reason: string }[]; reasons?: string[];
++ candidates: AiCandidate[]; decided_at: string; disabled?: boolean; no_eligible_candidate?: boolean };
++/** The AI Exchange answer: ai.route-explain@1 shaped. */
++export type AiPlanResult = { chosen: string | null; rejected: { id: string; reason: string }[]; objective: string;
++ estimate?: { charge_nano_usd: string; input_tokens: number; cached_read_tokens: number; cache_write_tokens: number; output_tokens: number };
++ explain: AiExplain; disabled?: boolean };
++/**
++ * The AI Exchange planner. OFF by default: without `enabled: true` it answers nothing and changes no behaviour.
++ * Money is integer nano-USD; the objective is the expected cost per success.
++ */
++export declare function planAi(req: AiRequirements, candidates: AiDeployment[], opts?: { enabled?: boolean; now?: number;
++ current?: string | null; minGain?: number; usage?: AiUsage; evidence?: Record<string, AiEvidence> | Map<string, AiEvidence>;
++ rates?: Record<string, AiRate> | Map<string, AiRate>; pFloor?: number; affinity?: Record<string, number> | Map<string, number>;
++ affinityGain?: number; prior?: number; sparseSampleFloor?: number }): AiPlanResult;
++/** Why a deployment may not run the workload, or null when it may. */
++export declare function aiExcluded(req: AiRequirements, c: AiDeployment, ctx?: any): string | null;
++/** The chance a request succeeds on a candidate, and how much that evidence may be trusted. */
++export declare function qualityOf(ev: AiEvidence | null | undefined, opts?: { prior?: number; floor?: number }):
++ { p_success: number; confidence: number; samples: number; sparse: boolean; source: string };
++/** A reason with every secret, host, endpoint and path stripped. */
++export declare function safeReason(text: unknown): string;
++export declare const PROVIDER_ACCESS: readonly ProviderAccess[];
++export declare const PAID_PROVIDER_ACCESS: readonly ProviderAccess[];
++export declare const PRIVACY_CLASSES: readonly PrivacyClass[];
++export declare const MODEL_CAPABILITIES: readonly ModelCapability[];
++export declare const DEFAULT_P_FLOOR: number;
++export declare const SPARSE_SAMPLE_COUNT: number;
export declare function plan(req: Requirements, offers: Offer[], opts?: { rateCards?: RateCard[] | Map<string, RateCard>; states?: ProviderState[] | Map<string, ProviderState>;
now?: number; current?: string | null; minGain?: number; weights?: { cost: number; latency: number }; owned?: { busy?: number; full?: number; maxUsd?: number };
success?: Record<string, number> | Map<string, number> }): PlacementResult;