openvibers/openvibe.ai Commits
Integrate: AI Exchange P7 evidence, project Balance, compatibility fixtures, console, D11 settings, usage series (#297)
Changes
@@@ -68,6 +68,19 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000#USAGE_SAMPLES=on#USAGE_SAMPLES_INTERVAL_MS=2000++# ── Billing Balance (AI Exchange F, CHECKPOINT D2/D9/D10/D12), off by default ──++# A request on a paid managed route (the model catalog's provider_access OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE)++# holds its worst-case cost before a provider is called, extends the hold while streaming and settles once.++# Network must grant client `ai` billing.credit.hold.{create,extend,settle,release} on openvibe.billing. An app++# holds against its project's Balance (subject app + project_id); a person against their own (no project_id); no++# network.project.read is needed.++# Reads OV_BILLING_INTERNAL_URL, OV_NETWORK_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET above; a missing Billing refuses++# paid requests (503). A settle Billing could not take is retried from credit_settle_queue with backoff.++#AI_CREDITS=0++#AI_CREDITS_TIMEOUT_MS=5000++#AI_CREDITS_SETTLE_RETRY_MS=5000++#AI_CREDITS_SETTLE_MAX_RETRY_MS=300000++# ── Tier budgets (server/govern.js, T6 step 8): openvibe-sdk/govern in Valkey (VALKEY_URL above), off by default ──# Units ai-token and ai-usd per subject and per project; tiers free, paid, staff. See README "Tier budgets".#AI_GOVERN_TIERS=0@@@ -84,6 +97,16 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000# stored key unreadable: the storing service then has to send them again.#AI_CREDENTIALS_KEY=++# ── AI owner settings (D11, server/settings.js; openvibe-shared/config) ──++# The ai.exchange and ai.provider_keys namespaces. OV_CONFIG_KEKS is "<id>:<base64 32-byte key>[,<id>:…]" and++# OV_CONFIG_KEK_CURRENT names the one to use; they come from the host's env file, never the database. With a KEK,++# secret values (each provider's ai.provider_keys "<provider>.key") are sealed envelopes in the config tables and a++# DB dump has no key; unset, the store still works and reports "sealed": false. At boot AI moves the old++# provider_secrets rows (AI_CREDENTIALS_KEY) into sealed "<provider>.key" values, bound to each provider's origin,++# and deletes them after a successful read-back.++#OV_CONFIG_KEKS=++#OV_CONFIG_KEK_CURRENT=++# ── Memory (server/memory/, Memory M2a) ──# 64 hex characters (openssl rand -hex 32). Memory item text and source references are AES-256-GCM sealed with it;# the dedupe MAC is HKDF-SHA256 (info "memory-mac") of the same key. Unset turns /api/v1/memory off (503@@@ -156,6 +179,20 @@ AI_MODEL=# production, off in production (synthetic output must never stand in for a real answer there).#AI_STUB_FALLBACK=false++# ── AI Exchange P4 (CHECKPOINT D3): the truthful model catalog, exact ids and dynamic aliases ──++# Off: today's behaviour (one virtual `openvibe` model, no disclosure object). On:++# - GET /v1/models lists the registry's real models in the ai.model@1 shape plus the dynamic aliases++# openvibe/auto, openvibe/economy, openvibe/balanced, openvibe/fast, openvibe/private (legacy openvibe = auto);++# - `model` may be an exact id `<org>/<model>[:<quant>]@<revision>` (it runs only that artifact, or fails with++# 400 ai.model_unavailable / 404 ai.model_not_found, and never substitutes another model), or one of those aliases;++# - every response carries the `openvibe` disclosure (a leading SSE comment and the final chunk when streaming);++# - paid managed routing uses only offers whose provider_access (API /api/v1/providers) is OWN_DEPLOYMENT or++# AUTHORIZED_WHOLESALE. RESTRICTED and UNVERIFIED (the default) offers are not resold; a person's own key is++# CUSTOMER_BYOK and never goes through that gate.++# Exact ids need artifact metadata on the model row (POST/PATCH /api/v1/models `metadata`: org, model, quant,++# revision, family, variant, sha256, license, rate_version).++#AI_MODEL_CATALOG=1++# ── Local speech-to-text: whisper.cpp, the same names Live uses ──#WHISPER_BIN=/home/ubuntu/whisper.cpp/build/bin/whisper-cli#WHISPER_MODEL=/home/ubuntu/whisper.cpp/models/ggml-base.en.bin@@@ -128,6 +128,7 @@ idempotent replay):| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` || `POST/PATCH /api/v1/providers…`, `…/disable\|enable\|reset`, `POST/PATCH /api/v1/models…`, `POST /api/v1/quotas`, `DELETE /api/v1/cache` | `ai.provider.manage` || `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |++| `GET /api/v1/usage/series?days=N` (N 1–90) — aggregate demand per UTC day and model: requests, successes and the four token totals, never a caller id or text (`AI_USAGE_SERIES=on`) | `ai.usage.read` || `GET /api/health`, `GET /api/ready`, `GET /release.json` | public || `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) || `GET /console…`, `/auth/login\|callback`, `POST /auth/logout` | Network staff, signed in (see [Operator console](#operator-console)) |@@@ -172,6 +173,43 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: ``network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).++## Model catalog, exact ids and the disclosure++++Behind `AI_MODEL_CATALOG` (off by default; with it off both `GET /v1/models` endpoints answer the legacy virtual++`openvibe` model and no disclosure is added; an exact id is still a pin, so it is refused with 400++`ai.model_unavailable` rather than run on another model). It applies to both `POST /v1/chat/completions` and++`POST /v1/messages`.++++- **`GET /v1/models` / `GET /v1/models/{id}`:** the registry's real models as `ai.model@1` catalog entries++ (`provider_key`, `model_key`, `id`, family, variant, quant, revision, sha256, license, context, capabilities,++ modalities, retail rate in integer nano-USD, availability) plus the dynamic policy aliases `openvibe/auto`,++ `openvibe/economy`, `openvibe/balanced`, `openvibe/fast`, `openvibe/private` and legacy `openvibe` (= auto). An++ alias carries `dynamic: true` and its `policy`. Every entry also carries the response shape of the protocol it was++ asked in: OpenAI's `object`/`created`/`owned_by`, or Anthropic's `type`/`display_name`/`created_at`. An Anthropic++ client identifies itself with its `anthropic-version` header (or `?format=anthropic`).++- **Exact ids:** `model` may be `<org>/<model>[:<quant>]@<revision>` (for example++ `qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9`). It runs only that artifact. An artifact that does not exist is 404++ `ai.model_not_found`; one that exists but cannot run the request (no active deployment, unmet capability, down++ provider, or an offer that may not be resold) is 400 `ai.model_unavailable`. It is never substituted with another++ model, in fallback or otherwise. A row without artifact metadata has no exact id; it is still listed and reachable++ by its legacy `provider/model` id.++- **Aliases:** the dynamic names resolve through the same provider placement. `economy` ranks price first, `fast`++ measured latency first, `balanced` weighs both, `private` never leaves local/own capacity, and `auto` keeps the++ existing task-aware router. User preferences (`only_my_keys`, `free_first`, own keys and machines) still apply.++- **The disclosure, inside `x_openvibe`:** `resolved_model`, `revision`, `deployment`, `provider_access`,++ `rate_version`, `policy` and `fallback_reason` join the `x_openvibe` object responses already carry (run, provider,++ cost). There is no second object.++ - On a JSON response it is in the top-level `x_openvibe`.++ - On SSE it is the first line, as an SSE comment (`: x_openvibe {...}`), and it is on the final chunk++ (`message_stop` for Messages).++- **Commercial access:** `provider_access` is a provider field (`POST/PATCH /api/v1/providers`, default `UNVERIFIED`).++ Paid managed routing uses only `OWN_DEPLOYMENT` and `AUTHORIZED_WHOLESALE`; `RESTRICTED`, `UNVERIFIED` and++ `CUSTOMER_BYOK` offers stay out of the shared pool. A person's own key or project provider is disclosed as++ `CUSTOMER_BYOK` and is unaffected.++- **Tokens:** developer apps call with the public `ai.app.run` they already hold (ADR-014). The separate++ `ai.inference.create` once planned for this was retired (Contracts 0.285.0). `ai.run.create` remains the++ first-party service capability.++## OpenAI-compatible endpoint`POST /v1/chat/completions` and `GET /v1/models` (`server/api/openai.js`), so a client built for OpenAI's API (the SDKs,@@@ -314,14 +352,15 @@ needs scripts and inline styles), like Billing's staff console. Code: `server/co|---|---|| `/console` | providers needing attention, runs and failures of the last 24 h by error code, today's spend, the queue, the busiest quota windows || `/console/providers` | every provider (kind, status, **secret reference name and whether it resolves, never a value**, circuit, base URL without query/user info, default model, priority/timeout) and the models; for the owner, per provider: replace its key, register a model (with prices typed from the provider's own price page, which the form requires) |--| `/console/providers/new` | the owner adds a provider in a few clicks: DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API (`server/console/presets.js`), with its API key, which is kept encrypted (`vault:<provider>`, below) |++| `/console/providers/new` | the owner adds a provider in a few clicks: DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API (`server/console/presets.js`), with its API key, which is kept sealed in the config store, bound to the provider's origin (`vault:<provider>`, below) || `/console/routes/:key` (owner form) | **use another provider**: a new route version whose primary is the provider and model chosen, with the previous primary kept as the first fallback; **back to the default** writes the newest seeded version again || `/console/routes`, `/templates`, `/workflows` (`/:key[?version=]`) | every key's newest version and the version in use; per key every version with its status and content (primary and fallbacks in order, prompts, steps, schemas) || `/console/runs` | runs newest first, filtered by status (tabs), workflow, requester (`live` = `service:live`), error code and a UTC time range; failed runs grouped by error code || `/console/runs/:id` | versions used, provider/model, fallback, attempts, tokens, cost, the Billing reading with its delivery state, free share and last send error, error code and detail, requester/attribution/target, grounding, citations, the request log (hashes only), and the stored input/output **bounded**: strings cut at 240 characters, lists at 20 items, 6 KB in all, credential-named keys blanked; raw debug fields never || `/console/quotas`, `/console/usage` | active quotas with their current windows and counters, every quota; usage by day, requester, workflow and provider/model over a day range (`quotas.usage`); the **free allowance this period** per subject × provider × metric (free used, allowance, remaining, period end; `quotas.freeAllowance.current()`); the `platform.usage-sample@1` readings by delivery state (queued, retrying, sent, failed) and the last send errors with their run. Staff only: the public `/stats` page never shows a subject |++| `/console/exchange` | AI Exchange operational state, only with `AI_CREDITS` and/or `AI_ROUTING_EVIDENCE` on: the Credits settle queue's counts by status (queued, sent, review), the oldest queued row's age, the review rows (hold id, request id, tries, last error, created) with one **mark resolved** action that never retries, and the four settle-body usage counts only; per deployment samples, success and timeout rates, TTFT and decode EWMA and cache-hit ratio; the incumbents per decision key with the challenger and its streak || `/console/cache` | live entries and hits by workflow |--| `/console/audit` | the `audit_log`, filtered by kind (configuration changes by default, runs, console, everything), action, actor, target |++| `/console/audit` | the `audit_log`, filtered by kind (configuration changes by default, runs, AI Exchange, console, everything), action, actor, target |**Who may use what.** Sign-in is OpenVibe.Network SSO (authorization code + PKCE S256 as OAuth client`ai`; the refresh token is revoked at once). The staff claims of the Network token (contracts staff@@@ -331,7 +370,7 @@ the same one the admin API does:| Network staff capability | Held by | Console capability | Allows ||---|---|---|---|| `staff.site.view` | admin, owner | `ai.usage.read` | open the console, read every page |--| `staff.ai.manage` | admin, owner | `ai.workflow.manage` | set a route/template/workflow version's status; cancel a queued or running run |++| `staff.ai.manage` | admin, owner | `ai.workflow.manage` | set a route/template/workflow version's status; cancel a queued or running run; mark a Credits settle review row resolved (never retried) || `staff.secrets.manage` | owner | `ai.provider.manage` | add a provider with its key, replace a key, register a model, choose a route's provider and model, enable/disable a provider, reset its circuit, add/change a quota, purge the cache (one workflow, or all with a confirmation) |A `global_mod` (only `staff.console.access`), a user or a token without a `usr_` subject is refused@@@ -364,12 +403,16 @@ the Action Center is separate: a first-party service token with `ai.actions.read### Provider keys added from the console (`vault:`)--A provider the owner adds from the console keeps its key in `provider_secrets` (migration `0010_provider_secrets.sql`,--`server/vault.js`): AES-256-GCM with `AI_CREDENTIALS_KEY`, bound to the provider's key so a row copied to another--provider does not decrypt, with only a four-character hint ever shown. The provider's `secret_ref` is--`vault:<provider key>`, which `util.resolveSecret` reads from this process's decrypted copy (loaded at boot and after--every change; providers resolve secrets synchronously). Audit rows say a key was replaced, never what it is. Without--`AI_CREDENTIALS_KEY` the form says keys cannot be stored and nothing is added.++A provider the owner adds from the console keeps its key in the `ai.provider_keys` namespace of the configuration++store (D11, `server/settings.js`, `migrations/0028_config.sql`). For each provider `<p>.origin` is the origin of its++base URL and `<p>.key` is a write-only secret sealed under `OV_CONFIG_KEKS` and x-bind'ed to `<p>.origin`; changing the++base URL locks the key until it is entered again. The provider's `secret_ref` is `vault:<provider key>`, which++`util.resolveSecret` reads from this process's cache (refreshed by the store's `onActivate`; providers resolve secrets++synchronously). Only a four-character hint is ever shown, and audit rows say a key was replaced, never what it is.++Without a KEK the store still works and reports `"sealed": false`. At boot, when `AI_CREDENTIALS_KEY` and++`OV_CONFIG_KEKS` are both set, the old `provider_secrets` rows (migration `0010_provider_secrets.sql`) are opened,++written as sealed `<p>.key` values bound to their current origin, read back and only then deleted; counts are logged,++never a value.## The eleven record groups@@@ -440,7 +483,38 @@ run, and a pinned run is never served from the shared cache.reading its `idempotency_key`, `state` (`queued` until Billing accepts it, `sent`, or `failed` when Billingrefused it), `attempts`, `queued_at`, `sent_at`, `rejected_at`, `next_attempt_at`, `last_error` (theoutbox's last send error, also while a queued reading is retried) and `free_allowance_used` when some--was free; a sent reading the relay has pruned reads `{ state: 'sent', pruned: true }`. Speech and embeddings use placement pools of registered `stt` and `embedding` models; until++**Balance (AI Exchange F; `AI_CREDITS`, off).** A request on a paid managed route — the model catalog's++`provider_access` is `OWN_DEPLOYMENT` or `AUTHORIZED_WHOLESALE` and the offer is metered — reserves its++worst case in OpenVibe.Billing before any provider is called: the input estimate plus `max_tokens` of output++at the model's retail card (`ai.model@1` `retail_rate`). Who pays is the payer's own Balance (D12): an app's++run holds against its project's Balance, with the hold's subject `{type: "app", id}` and its `project_id`, so++no Network owner lookup is made; a person's own run holds against their personal Balance, subject++`{type: "user", id}` and no `project_id`. A first-party service with no payer (no person and no app) is never++served a paid offer that carries a versioned card (`payer_required`); offers without a card behave as before.++An exact id whose only offer is skipped that way is `402 billing.payer_required`.++++`max_cost_usd` is parsed as an exact decimal (up to 9 fractional digits, finer digits round up; exponent forms++are 422) into integer nano-USD. When it binds, the affordable output is computed in BigInt from nano-USD per++million tokens and the provider receives that clamped `max_tokens`; fewer than one affordable token is 402++before the provider. A pricier managed fallback extends the hold by the difference (clamped the same way); if++Billing refuses the extension the offer is skipped as `hold_not_extended`. A stream extends the hold in steps++when the output nears it.++++The hold's idempotency key is the request id; its fingerprint covers the resolved model, the token caps, the++rate version, the payer and (for an app) the project. Billing's `402 billing.insufficient_funds` becomes AI's++own 402 `Not enough Balance`; any other Billing failure refuses the run with `503 billing.unavailable` rather++than serving unpaid capacity. At the end one settle carries the four token counts, the answering model's retail++card version, the supply cost and every attempt in `billing.credit-hold-settle-request@1`'s exact shape++(`deployment_id`, `provider_access`, start/end, `error_code`); a retried request replays the same settlement++instead of charging twice. A settle Billing cannot take is kept in `credit_settle_queue` (migration 0025) and++retried with backoff and the same key; a 409 or 422 marks it `review`, never a retry. A failure before the++first token releases the hold, an abort settles the partial usage. BYOK — a person's key, their machine, a project's own provider — is never held and++never charged; Billing is not called at all. A sandbox app token is never offered paid supply (D10), so it++spends no Balance and never opens a hold. Requires `AI_MODEL_CATALOG` for the access labels; with either++flag off nothing changes. Code: `server/credits.js`, `server/credit-settlements.js`; the fake-Billing tests are++`test/credits.test.js` and `test/credits-run.test.js`.++++Speech and embeddings use placement pools of registered `stt` and `embedding` models; untila second upstream exists, an unavailable sole provider answers `503 provider.unavailable`. Mediauses separate local and paid pools so paid work still requires its explicit budget. The syntheticstub can join general text routes as a last resort outside production only (`AI_STUB_FALLBACK`).@@@ -653,6 +727,7 @@ mix vectors from different providers (re-embed when the provider changes).| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` || The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` || Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |++| OpenAI Chat Completions and Anthropic Messages pass the official-client fixtures (tool call + tool result, JSON response_format / structured tool, multi-turn system prompt, exact id, every alias) in JSON and SSE; usage is present and x_openvibe is top-level only | `test/protocol-compat.test.js` || Parakeet: engine by language, 30 s pieces past 45 s, fallback to whisper, `ASR_ENGINE=whisper` | `test/parakeet.test.js` || Word timing (`live.media.transcribe` with `words: true`): whisper's `-ojf` tokens and Parakeet's token lines become each segment's words with their times; punctuation joins a word without moving its end; one unit per token in Japanese; fillers and control tokens are not words | `test/word-timing.test.js` || `media.analyze` on a real 28 s fixture: signals, scenes and loud moments measured, highlights with evidence, the local model's overview from the facts only; the local model down → extractive and said; paid only with `allow_paid` and a `media.paid` budget, never past it; too little disk refused before downloading | `test/media-analysis.test.js` |@@@ -753,7 +828,7 @@ ai.openvibe.network notice from OpenVibe.Sites.Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.262.0--- openvibe-sdk: v0.82.0--- openvibe-shared: v4.109.2++- openvibe-contracts: v0.289.1++- openvibe-sdk: v0.83.0++- openvibe-shared: v4.110.0<!-- versions:end -->@@@ -7,9 +7,9 @@"runtime": "node 22, express, postgresql","port": 4700,"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",-- "contracts": "openvibe-contracts v0.262.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)",-- "sdk": "openvibe-sdk v0.82.0",-- "shared": "openvibe-shared v4.109.2",++ "contracts": "openvibe-contracts v0.289.1 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",++ "sdk": "openvibe-sdk v0.83.0",++ "shared": "openvibe-shared v4.110.0","capabilities": ["ai.run.create","ai.run.read",@@@ -22,7 +22,8 @@"ai.memory.read","ai.memory.write","ai.me.read",-- "ai.me.manage"++ "ai.me.manage",++ "ai.config.manage"],"capabilitiesRegistered": true,"providers": [nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "ai.config.manage",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "internal",++ "description": "Manage the ai.exchange and ai.provider_keys namespaces of OpenVibe.AI through openvibe-shared/config (D11): GET /internal/config lists the namespace summaries, GET /internal/config/:namespace returns one as common.config-namespace@1, POST applies common.config-change@1, and PUT /internal/config/:namespace/secrets/:key sets a secret with common.config-secret-put@1 (write-only; the answer is the marker) while DELETE removes it. Only Network's admin proxy, acting for the owner, holds this capability; it is never grantable to apps. Each request carries the owner as actor, and the service audits every write with that actor and key, never a secret value.",++ "permissions": [++ "config:manage"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "config-manage",++ "events": [],++ "implementedBy": [++ "GET /internal/config",++ "GET|POST /internal/config/:namespace",++ "PUT|DELETE /internal/config/:namespace/secrets/:key"++ ],++ "inputSchema": "common.config-change@1",++ "outputSchema": "common.config-namespace@1"++}@@@ -22,6 +22,7 @@"capabilities": ["ai.actions.read","ai.app.run",++ "ai.config.manage","ai.credential.manage","ai.provider.manage","ai.quota.attribution.manage",nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- AI Exchange D3: the commercial access a provider's capacity is offered under. Paid managed routing on the++-- catalog flag (AI_MODEL_CATALOG) uses only OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE offers; a person's own key++-- (CUSTOMER_BYOK) never goes through this pool. The default is UNVERIFIED: an offer that has not recorded an++-- agreement or an owned deployment is never silently treated as resellable. Additive only: every existing row++-- keeps the UNVERIFIED default until an operator records its access.++ALTER TABLE providers ADD COLUMN provider_access text COLLATE "C" NOT NULL DEFAULT 'UNVERIFIED'; -- OWN_DEPLOYMENT | AUTHORIZED_WHOLESALE | CUSTOMER_BYOK | RESTRICTED | UNVERIFIEDnnew file mode 100644@@@ -0,0 +1,18 @@++-- phase: expand++-- AI Exchange F (P7): a settle Billing could not take is kept here and retried with backoff, with the same++-- idempotency key, until Billing answers 2xx. A 409 or 422 marks the row for review; it is not retried.++-- Expand only: no existing table is touched.++CREATE TABLE IF NOT EXISTS credit_settle_queue (++ id text PRIMARY KEY, -- the settle idempotency key (ai-settle:<request id>)++ hold_id text NOT NULL,++ request_id text,++ settle_body jsonb NOT NULL, -- the full POST .../settle body, key included++ attempts jsonb NOT NULL DEFAULT '[]'::jsonb,++ status text NOT NULL DEFAULT 'queued', -- queued | sent | review++ tries integer NOT NULL DEFAULT 0,++ next_at timestamptz NOT NULL,++ last_error text,++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS credit_settle_queue_due ON credit_settle_queue (status, next_at);nnew file mode 100644@@@ -0,0 +1,35 @@++-- phase: expand++-- AI Exchange P7 (PLAN §D ObservedPerformance, §E): measured per-deployment evidence for alias routing.++-- Behind AI_ROUTING_EVIDENCE (off by default); additive only: no existing table or column is changed.++--++-- provider_metrics is one EWMA row per deployment (provider_key, model_key). Every attempt AI already logs++-- updates it, and a restart keeps it. Counts are separate per measurement so a missing TTFT (a non-streamed++-- answer) never drags the latency average to zero. The rates are EWMA values in [0, 1]; ttft_ms and++-- decode_tps are null until the first measurement.++CREATE TABLE provider_metrics (++ provider_key text COLLATE "C" NOT NULL,++ model_key text COLLATE "C" NOT NULL,++ ttft_ms double precision, -- EWMA of the first-token time (streamed answers)++ ttft_samples bigint NOT NULL DEFAULT 0,++ decode_tps double precision, -- EWMA of output tokens / (finish - first token)++ decode_samples bigint NOT NULL DEFAULT 0,++ success_rate double precision NOT NULL DEFAULT 0, -- EWMA of 1 (ok) / 0 (failed or timed out)++ timeout_rate double precision NOT NULL DEFAULT 0, -- EWMA of 1 (provider.timeout) / 0++ cache_hit_ratio double precision NOT NULL DEFAULT 0, -- EWMA of cached / prompt tokens++ cache_samples bigint NOT NULL DEFAULT 0,++ sample_count bigint NOT NULL DEFAULT 0, -- every attempt (the n in the explanation)++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (provider_key, model_key)++);++++-- The incumbent an alias route is keeping for hysteresis, and the challenger's consecutive-decision streak.++-- route_key is the router's route key plus the alias policy, so each alias keeps its own incumbent.++CREATE TABLE routing_incumbents (++ decision_key text COLLATE "C" PRIMARY KEY,++ provider_key text COLLATE "C",++ model_key text COLLATE "C",++ challenger_provider text COLLATE "C",++ challenger_model text COLLATE "C",++ challenger_streak integer NOT NULL DEFAULT 0,++ updated_at timestamptz NOT NULL DEFAULT now()++);nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- AI Exchange operations console (P7): a person has looked at a settle row in review and resolved it.++-- The settle is never retried from here; these columns say who ended the review and when. Additive only.++ALTER TABLE credit_settle_queue ADD COLUMN IF NOT EXISTS resolved_at timestamptz;++ALTER TABLE credit_settle_queue ADD COLUMN IF NOT EXISTS resolved_by text;nnew file mode 100644@@@ -0,0 +1,31 @@++-- phase: expand++-- D11 owner settings: openvibe-shared/config (shared 4.110.0) keeps every service namespace here. AI's namespaces are++-- ai.exchange (the AI Exchange switches) and ai.provider_keys (the owner's provider keys, replacing provider_secrets).++-- Additive only: nothing existing is changed. config_keys holds the per-namespace fingerprint key, wrapped under the++-- service's KEK (OV_CONFIG_KEKS) when one is configured; secret values are sealed envelopes in values_json.++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++-- The fingerprint key of each namespace. Never served, never logged.++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK++ created_at text COLLATE "C" NOT NULL++);@@@ -13,9 +13,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"devDependencies": {@@@ -1540,9 +1540,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.262.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",-- "integrity": "sha512-E5zcp/ucdLH5q22jWxzdw1tgDRt9Y4y4TEVZvp3C6xwe2DOlDVDYgASHv8lF7QSHHmkmm9narAeQKmLrMBBhLA==",++ "version": "0.289.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1556,9 +1556,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.82.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",++ "version": "0.83.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0",++ "integrity": "sha512-xHFcsZmRVuQX0HlMFA7Kc53GMpdZUXxkcR17whknFgUQznuWCwcnhwS87HnnqsrmnHp8AHC0d5uywLjfOwOYBw==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1584,9 +1584,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.109.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",++ "version": "4.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -25,9 +25,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"optionalDependencies": {@@@ -41,6 +41,8 @@ const TABLES = [{ table: 'memory_profiles', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },{ table: 'memory_reads', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },++ // D11 owner settings: the revisions are the service's configuration audit trail, kept like the catalogues.++ { table: 'config_snapshots', subject: 'created_by', file: null, erase: { keep: 'the configuration history is the service\'s; created_by is the operator audit trail' } },{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },{ table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },@@@ -15,6 +15,7 @@* (routes/templates/workflows: read with ai.workflow.manage, ai.run.create or ai.usage.read; write with ai.workflow.manage)* GET /api/v1/quotas POST /api/v1/quotas (ai.provider.manage)* GET /api/v1/usage, /api/v1/requests, /api/v1/audit, /api/v1/cache (ai.usage.read)++ * GET /api/v1/usage/series aggregate demand, only with AI_USAGE_SERIES=on (ai.usage.read)* DELETE /api/v1/cache[?workflow=] purge (ai.provider.manage)** Provider status, circuit reset, cache purge and the status summary are server/ops.js, shared with@@@ -24,8 +25,9 @@ const express = require('express');const { CAPS } = require('../auth');const { AiError, sendError } = require('../util');const { createOps } = require('../ops');++const { originOf } = require('../settings');--function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = createOps({ db, registry, pool, quotas, cache, runs }), log = console }) {++function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, auth, settings = null, ops = createOps({ db, registry, pool, quotas, cache, runs }), log = console }) {const r = express.Router();const provRead = auth.requireCap(CAPS.providerManage, CAPS.usageRead);const provWrite = auth.requireCap(CAPS.providerManage);@@@ -51,14 +53,34 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crear.post('/api/v1/providers', provWrite, wrap(async (req, res) => {const b = body(req);if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');++ const provider = await registry.upsertProvider(b, { ...actor(req), origin: 'admin' });++ // D11: the provider's origin is the bind of its ai.provider_keys secret; AI writes it itself.++ if (settings && provider.base_url) {++ await settings.providerKeys.setOrigin(provider.key, provider.base_url, { actor: { type: 'service', id: 'ai' }, reason: 'provider created' })++ .catch((err) => log.warn(`[config] ${provider.key} origin: ${err.message}`));++ }res.status(201);-- return { provider: await view(await registry.upsertProvider(b, { ...actor(req), origin: 'admin' })) };++ return { provider: await view(provider) };}));r.patch('/api/v1/providers/:key', provWrite, wrap(async (req) => {const b = body(req);if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');-- if (!await registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');-- return { provider: await view(await registry.upsertProvider({ ...b, key: req.params.key }, { ...actor(req), origin: 'admin' })) };++ const prev = await registry.getProvider(req.params.key);++ if (!prev) throw new AiError(404, 'ai.not_found', 'no such provider');++ const moved = b.base_url !== undefined && originOf(b.base_url) !== originOf(prev.base_url);++ // Move the bind before the record: a key sealed to the old origin stops opening at once, so it can never be++ // sent to the new host while the provider row still points at the old one (or if this request then fails).++ if (moved && settings) {++ if (!String(b.base_url || '').trim()) throw new AiError(422, 'ai.invalid', 'a provider needs a base URL');++ await settings.providerKeys.setOrigin(prev.key, b.base_url, { actor: { type: 'service', id: 'ai' }, reason: 'provider base_url changed' });++ }++ const provider = await registry.upsertProvider({ ...b, key: req.params.key }, { ...actor(req), origin: 'admin' });++ const answer = { provider: await view(provider) };++ if (moved && String(provider.secret_ref || '').startsWith('vault:')) {++ answer.key_reentry_required = true;++ answer.notice = 'the key must be entered again for the new origin';++ }++ return answer;}));for (const [action, status] of [['disable', 'disabled'], ['enable', 'active']]) {r.post(`/api/v1/providers/:key/${action}`, provWrite, wrap(async (req) => ({ provider: await view(await ops.setProviderStatus(req.params.key, status, actor(req))) })));@@@ -177,6 +199,11 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea// Quotas, usage, logs, audit, cacher.get('/api/v1/quotas', usageRead, wrap(async () => ({ quotas: await quotas.list(), current: await quotas.counters() })));++ // Aggregate demand for Host's inference planner (AI_USAGE_SERIES, off by default): per day and++ // model only, no caller id, prompt, answer, project or cost.++ if (config.usageSeries && config.usageSeries.enabled) {++ r.get('/api/v1/usage/series', usageRead, wrap(async (req) => await quotas.usageSeries({ days: req.query.days })));++ }r.post('/api/v1/quotas', provWrite, wrap(async (req, res) => { res.status(201); return { quota: await quotas.upsert(body(req), { ...actor(req), origin: 'admin' }) }; }));r.get('/api/v1/usage', usageRead, wrap(async (req) => ({ usage: await quotas.usage({ from: req.query.from, to: req.query.to, requester: req.query.requester }) })));r.get('/api/v1/requests', usageRead, wrap(async (req) => {@@@ -3,14 +3,21 @@* The Anthropic-compatible endpoint: POST /v1/messages on ai.openvibe.services, so a client built for Anthropic's* Messages API (Claude Code, an SDK) points ANTHROPIC_BASE_URL at https://ai.openvibe.services and works (Codes C3).*-- * Authorization: Bearer <a Network token> an app's token (ai.app.run, its project pays) or a first-party service's-- * (ai.run.create), exactly as POST /v1/chat/completions takes them++ * Authorization: Bearer <a Network token> an app's token (ai.app.run++ * on; its project pays) or a first-party service's (ai.run.create)* x-openvibe-credential: <provider> the person's own key for that provider instead of the router: anthropic,* deepseek or minimax (their Anthropic-compatible endpoints), or a custom* endpoint. The person is `<provider>:<subject>`, or the token's* on_behalf_of (an agent acting for them)* body Anthropic's Messages API: messages, system, tools, tool_choice,* stop_sequences, temperature, top_p, top_k, max_tokens, metadata, stream++ * model with AI_MODEL_CATALOG off, ignored as before (an exact id is refused). On, an exact id++ * `<org>/<model>[:<quant>]@<revision>` (runs only that artifact, never a++ * substitute) or a dynamic alias (openvibe/auto|economy|balanced|fast|++ * private; legacy openvibe = auto), with the `openvibe` disclosure: the++ * first SSE comment when streaming and a field on the final message_stop.++ * GET /v1/models[/{id}] lists them in Anthropic's shape; an OpenAI client++ * (no anthropic-version) is served by server/api/openai.js.** Each request is a run of workflow ai.messages, so it has the run's quota, free allowance, cost, audit and explain* (the run id rides X-OpenVibe-Run-Id) and the prompt is not kept. Without the header, AI's router picks an upstream that@@@ -24,6 +31,9 @@ const express = require('express');const { CAPS } = require('../auth');const { appAttribution } = require('../apps');const { credentialFor } = require('./credential');++const models = require('./models');++const modelCatalog = require('../providers/model-catalog');++const { EXACT_ID } = modelCatalog;const FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];const MAX_TOKENS = 64000;@@@ -40,28 +50,41 @@ function errorType(status) {return 'api_error';}--function anthropicError(res, status, message, type) {-- res.status(status).json({ type: 'error', error: { type: type || errorType(status), message: String(message || 'error').slice(0, 1000), code: status === 402 ? 'ai.run_budget_reached' : null } });++function anthropicError(res, status, message, type, code = null) {++ res.status(status).json({ type: 'error', error: { type: type || errorType(status), message: String(message || 'error').slice(0, 1000), code: code || (status === 402 ? 'ai.run_budget_reached' : null) } });}/*** The event stream of one streamed message: each upstream event goes out as `event: <name>` + its JSON as it arrives (or* waits until open(run) has sent the headers). fail() sends Anthropic's error event; done() ends the stream.++ *++ * AI Exchange D3: once the router has picked the offer (route(info)), the stream's first line is the `x_openvibe`++ * disclosure as an SSE comment, before any event; the final message_stop event carries the whole x_openvibe object.*/function createEventStream(res) {let opened = false;let any = false;-- let stopped = false;++ let routeInfo = null;++ let commented = false;++ let finalEvent = null;const waiting = [];const alive = () => !res.writableEnded && !res.destroyed;-- const write = (event, data) => { if (alive()) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); };++ const comment = () => {++ if (commented || !routeInfo || !alive()) return;++ res.write(`: x_openvibe ${JSON.stringify(routeInfo)}\n\n`);++ commented = true;++ };++ const write = (event, data) => { if (alive()) { comment(); res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } };const emit = (event, data) => {if (!data) return;-- if (event === 'message_stop') stopped = true;++ // message_stop is held until the run has settled: the final chunk carries the whole x_openvibe object (run_id,++ // provider, cost_usd and the route disclosure), and the cost is only known once the provider's usage is recorded.++ if (event === 'message_stop') { finalEvent = data; return; }write(event, data);};const out = {get opened() { return opened; },++ route(info) { if (info) routeInfo = info; },event(e) {if (!e || !e.event || !e.data) return;any = true;@@@ -80,7 +103,13 @@ function createEventStream(res) {},emit,sentAny: () => any,-- get stopped() { return stopped; },++ /** The held message_stop with the final x_openvibe object, then end the stream. */++ finish(xOpenVibe) {++ const data = finalEvent || { type: 'message_stop' };++ finalEvent = null;++ write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe } : data);++ return out.done();++ },fail(message, code) {const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 529;emit('error', { type: 'error', error: { type: errorType(status), message: String(message || 'error').slice(0, 1000), code: code || null } });@@@ -108,8 +137,9 @@ function replay(res, message, run) {res.emit('message_stop', { type: 'message_stop' });}--function messagesRouter({ runs, auth, config, log = console }) {++function messagesRouter({ runs, auth, config, registry, log = console }) {const r = express.Router();++ // Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);// The auth layer writes problem+json; a client built for Anthropic reads { type: 'error', error: { … } }. Convert it@@@ -139,6 +169,32 @@ function messagesRouter({ runs, auth, config, log = console }) {if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];++ // AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension).++ if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;++ // AI Exchange D3 (AI_MODEL_CATALOG): an exact id or a dynamic alias. With the flag off the field is ignored++ // exactly as before, and the router picks, except an exact id: a pin is never silently run on another model (a++ // rollback of the flag included), so it is refused.++ const catalogOn = Boolean(config.modelCatalog && config.modelCatalog.enabled);++ if (!catalogOn && typeof b.model === 'string' && EXACT_ID.test(b.model.trim())) {++ return anthropicError(res, 400, 'model: an exact model id needs the model catalog, which is off: name a dynamic model (openvibe) instead (ai.model_unavailable)');++ }++ if (catalogOn) {++ if (b.model !== undefined && b.model !== null && typeof b.model !== 'string') return anthropicError(res, 400, 'model: must be a string');++ const requested = b.model == null ? 'openvibe/auto' : String(b.model).trim();++ if (!requested || requested.length > 256) return anthropicError(res, 400, 'model: must name an exact id or a dynamic alias');++ input.model = requested;++ // An exact id is a pin: an unknown or inactive artifact is refused here, before the run (and its stream)++ // exists, so a streaming client gets the 404/400 it expects rather than a 200 SSE error. The engine selects++ // the same artifact again when the run executes.++ if (EXACT_ID.test(requested)) {++ try {++ await modelCatalog.select({ registry, model: requested, route: null });++ } catch (err) {++ if (err && err.status) return anthropicError(res, err.status, err.detail || err.message, null, err.code);++ throw err;++ }++ }++ }let body = { workflow: 'ai.messages', input };// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.if (b.prefer) body = { ...body, prefer: b.prefer };@@@ -151,7 +207,9 @@ function messagesRouter({ runs, auth, config, log = console }) {const live = b.stream === true ? createEventStream(res) : null;let run;try {-- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, ...(live ? { onDelta: live.event } : {}) });++ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,++ ...(live ? { onDelta: live.event } : {}),++ ...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });if (live) live.open(created.run);run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));} catch (err) {@@@ -165,9 +223,10 @@ function messagesRouter({ runs, auth, config, log = console }) {}if (run.status === 'failed' || run.status === 'cancelled') {const code = (run.error && run.error.code) || 'run.failed';-- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429-- : code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404 : 529;-- return anthropicError(res, status, (run.error && run.error.detail) || code);++ const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429++ : code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404++ : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 529;++ return anthropicError(res, status, (run.error && run.error.detail) || code, null, code);}if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.content)) {return anthropicError(res, 529, `the message is still running; GET /api/v1/runs/${run.id}`, 'overloaded_error');@@@ -184,21 +243,39 @@ function messagesRouter({ runs, auth, config, log = console }) {...(out.usage && out.usage.cache_creation_input_tokens != null ? { cache_creation_input_tokens: out.usage.cache_creation_input_tokens } : {}),},};++ const disclosure = await models.disclosureOf({ registry, config, run });++ if (live) live.route(disclosure);++ // D8': the route disclosure joins the existing x_openvibe object (run, provider, fallback, cost), never a second object.++ const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used),++ cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic) };++ const xOpenVibe = disclosure ? { ...x, ...disclosure } : null;if (live) {// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot-- // stream, a replayed run) is sent as the events it is, and a stream that never closed is closed.++ // stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole++ // x_openvibe object, then ends the stream.if (!live.sentAny()) replay(live, message, run);-- else if (!live.stopped) live.emit('message_stop', { type: 'message_stop' });-- return live.done();++ return live.finish(xOpenVibe);}res.setHeader('X-OpenVibe-Run-Id', run.id);-- return res.status(200).json(message);++ return res.status(200).json(xOpenVibe ? { ...message, x_openvibe: xOpenVibe } : message);});-- // The model list a client asks for: one router model, the upstreams are AI's choice.-- r.get('/v1/models', asAnthropicErrors, allow, (req, res) => {-- res.json({ data: [{ type: 'model', id: 'openvibe', display_name: 'OpenVibe AI', created_at: '2026-10-30T00:00:00Z' }], has_more: false, first_id: 'openvibe', last_id: 'openvibe' });-- });++ // The model list a client asks for. An Anthropic client identifies itself with `anthropic-version`; the OpenAI++ // router steps aside for it, so the response, the access check and the error object are Anthropic's. With++ // AI_MODEL_CATALOG off this is exactly the legacy single virtual model.++ const onlyAnthropic = (req, res, next) => (models.wantsAnthropic(req) ? next() : next('router'));++ const modelRoute = (fn) => async (req, res) => {++ try { await fn(req, res); } catch (err) {++ log.error(`[ai] ${req.method} ${req.path}: ${(err && err.stack) || err}`);++ if (!res.headersSent) models.sendError(res, 'anthropic', 500, 'internal error');++ }++ };++ r.get('/v1/models', asAnthropicErrors, onlyAnthropic, allow, modelRoute(async (req, res) => {++ await models.sendList(req, res, { registry, config, protocol: 'anthropic' });++ }));++ r.get('/v1/models/*', asAnthropicErrors, onlyAnthropic, allow, modelRoute(async (req, res) => {++ await models.sendOne(req, res, { registry, config, protocol: 'anthropic' });++ }));return r;}nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++/**++ * GET /v1/models and GET /v1/models/{id} in both wire protocols (AI Exchange P4, CHECKPOINT D3/D4).++ *++ * With AI_MODEL_CATALOG on, the list is the registry's real models as ai.model@1 catalog entries plus the dynamic++ * policy aliases (openvibe/auto, economy, balanced, fast, private; legacy openvibe), each alias carrying dynamic:true.++ * With the flag off, both protocols answer exactly the legacy single virtual `openvibe` model.++ *++ * The same path serves OpenAI and Anthropic clients. Anthropic's SDK identifies itself with `anthropic-version`;++ * this module reads that header (or ?format=anthropic) so the response and the error shape match the protocol.++ * A client that sends neither gets OpenAI's shape. The OpenAI and Anthropic routers each register the routes; the++ * OpenAI router steps aside for an Anthropic request before its auth guard, so that router's error shape applies.++ */++const modelCatalog = require('../providers/model-catalog');++++const LEGACY_MODEL = 'openvibe';++const LEGACY_CREATED = 1760486400;++const LEGACY_CREATED_AT = '2026-10-30T00:00:00Z';++++/** Does the caller speak Anthropic's models protocol? */++function wantsAnthropic(req) {++ return Boolean(String((req.headers && req.headers['anthropic-version']) || '').trim()) || Boolean(req.query && req.query.format === 'anthropic');++}++++const epochOf = (iso) => {++ const ms = Date.parse(iso || '');++ return Number.isFinite(ms) ? Math.floor(ms / 1000) : LEGACY_CREATED;++};++const aliasEntry = (alias) => ({++ ...alias,++ object: 'model',++ type: 'chat',++ owned_by: 'openvibe',++ created: LEGACY_CREATED,++ created_at: LEGACY_CREATED_AT,++ display_name: alias.name,++});++const catalogOpenAi = (x) => ({++ ...x.entry,++ id: x.entry.id || `${x.entry.provider_key}/${x.entry.model_key}`,++ object: 'model',++ created: epochOf(x.entry.created_at),++ owned_by: x.entry.provider_key,++ dynamic: false,++});++const catalogAnthropic = (x) => ({++ ...x.entry,++ type: 'model',++ id: x.entry.id || `${x.entry.provider_key}/${x.entry.model_key}`,++ display_name: x.entry.display_name || x.entry.model_key,++ created_at: x.entry.created_at || LEGACY_CREATED_AT,++ dynamic: false,++});++const anthropicAlias = (alias) => ({++ ...alias,++ type: 'model',++ display_name: alias.name,++ created_at: LEGACY_CREATED_AT,++ object: undefined,++ owned_by: undefined,++ created: undefined,++});++++/** The catalog list in one protocol. Flag off: today's single virtual model. */++async function list({ registry, config, protocol }) {++ const enabled = Boolean(config.modelCatalog && config.modelCatalog.enabled);++ if (!enabled) {++ if (protocol === 'anthropic') return { data: [{ type: 'model', id: LEGACY_MODEL, display_name: 'OpenVibe AI', created_at: LEGACY_CREATED_AT }], has_more: false, first_id: LEGACY_MODEL, last_id: LEGACY_MODEL };++ return { object: 'list', data: [{ id: LEGACY_MODEL, object: 'model', created: LEGACY_CREATED, owned_by: 'openvibe' }] };++ }++ const aliases = modelCatalog.ALIASES.map((a) => (protocol === 'anthropic' ? anthropicAlias(a) : aliasEntry(a)));++ const models = (await modelCatalog.entries({ registry })).map((x) => (protocol === 'anthropic' ? catalogAnthropic(x) : catalogOpenAi(x)));++ const data = [...aliases, ...models];++ if (protocol === 'anthropic') return { data, has_more: false, first_id: data.length ? data[0].id : null, last_id: data.length ? data[data.length - 1].id : null };++ return { object: 'list', data };++}++++/** One model (or dynamic alias) in one protocol; null when it does not exist. */++async function find({ registry, config, protocol, id }) {++ const enabled = Boolean(config.modelCatalog && config.modelCatalog.enabled);++ if (!enabled) return null;++ const alias = modelCatalog.ALIASES.find((a) => a.id === id);++ if (alias) return protocol === 'anthropic' ? anthropicAlias(alias) : aliasEntry(alias);++ const found = await modelCatalog.entries({ registry });++ const x = found.find((m) => m.entry.id === id) || found.find((m) => `${m.entry.provider_key}/${m.entry.model_key}` === id);++ if (!x) return null;++ return protocol === 'anthropic' ? catalogAnthropic(x) : catalogOpenAi(x);++}++++/** Write a list response in the handler's protocol. */++async function sendList(req, res, { registry, config, protocol }) {++ const body = await list({ registry, config, protocol: protocol || (wantsAnthropic(req) ? 'anthropic' : 'openai') });++ res.json(body);++}++++/** Write a single-model response, or the protocol's 404. */++async function sendOne(req, res, { registry, config, protocol }) {++ const proto = protocol || (wantsAnthropic(req) ? 'anthropic' : 'openai');++ let id = req.params && req.params[0] != null ? String(req.params[0]) : '';++ try { id = decodeURIComponent(id); } catch { /* keep the raw id */ }++ const body = await find({ registry, config, protocol: proto, id });++ if (!body) {++ if (proto === 'anthropic') return res.status(404).json({ type: 'error', error: { type: 'not_found_error', message: `no model ${id}`, code: 'ai.model_not_found' } });++ return res.status(404).json({ error: { message: `no model ${id}`, type: 'invalid_request_error', code: 'ai.model_not_found', param: null } });++ }++ return res.json(body);++}++++/** The protocol's error object (a registry failure inside a models route, which Express 4 does not catch by itself). */++function sendError(res, protocol, status, message, code) {++ if (protocol === 'anthropic') return res.status(status).json({ type: 'error', error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: String(message || 'error').slice(0, 1000), code: code || null } });++ return res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: status >= 500 ? 'api_error' : 'invalid_request_error', code: code || null, param: null } });++}++++/**++ * The `openvibe` disclosure of a finished run (D3), only while the catalog flag is on. Null with the flag off: no++ * field or SSE comment is added, exactly as before.++ */++async function disclosureOf({ registry, config, run }) {++ if (!(config.modelCatalog && config.modelCatalog.enabled)) return null;++ return modelCatalog.disclosureOf({ registry, run });++}++++module.exports = { wantsAnthropic, list, find, sendList, sendOne, sendError, disclosureOf, LEGACY_MODEL };@@@ -8,10 +8,13 @@* body OpenAI Chat Completions: messages (with tool calls and tool results),* tools, tool_choice, parallel_tool_calls, response_format, stop, seed,* temperature, top_p, max_tokens / max_completion_tokens, stream-- * model "openvibe" (or anything else): AI's router picks the upstream for-- * route default.chat by price, health and what the request needs-- * (tools need a provider that declares them); the answer names the-- * model that ran++ * model with AI_MODEL_CATALOG off, ignored (an exact id is refused): AI's router picks the upstream++ * for route default.chat. On, an exact id++ * `<org>/<model>[:<quant>]@<revision>` (runs only that artifact, never a++ * substitute) or a dynamic alias (`openvibe/auto|economy|balanced|fast|++ * private`, legacy `openvibe` = auto); the answer names the model that ran++ * and carries the `openvibe` disclosure (an SSE comment first when++ * streaming, and a field on the final chunk)** x-openvibe-credential the person's own key for a provider instead of the router* (Codes C3): <provider> or <provider>:<subject>@@@ -20,6 +23,10 @@* to ai for reveal to that origin, ADR-056) }; the key is revealed for* that one run, and the project pays its provider directly*++ * GET /v1/models lists the registry's real models in ai.model@1 shape plus the dynamic aliases (behind++ * AI_MODEL_CATALOG; off, the legacy single `openvibe` model); GET /v1/models/{id} answers one. Both protocols share++ * the path: an Anthropic client (anthropic-version, or ?format=anthropic) is served by server/api/messages.js.++ ** Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain* (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions* chunk format as the upstream streams them (an OpenAI-format upstream, the router's or the project's own): each delta@@@ -32,6 +39,9 @@ const express = require('express');const { CAPS } = require('../auth');const { appAttribution } = require('../apps');const { credentialFor } = require('./credential');++const models = require('./models');++const modelCatalog = require('../providers/model-catalog');++const { EXACT_ID } = modelCatalog;const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];@@@ -44,20 +54,31 @@ const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status* The event stream of one streamed completion. delta(d) sends a chunk as it arrives (or keeps it until open(run) has* sent the headers, which happens once the run exists); write(o) sends any chunk; fail() sends OpenAI's error object as* an event; done() ends with [DONE]. A closed connection drops what is left, quietly.++ *++ * AI Exchange D3: once the router has picked the offer (route(info)), the stream's first line is the `openvibe`++ * disclosure as an SSE comment, before any data event. The final chunk carries the same object as `openvibe`.*/function createLiveStream(res) {let run = null;let opened = false;let any = false;++ let routeInfo = null;++ let commented = false;const waiting = [];const finishedIdx = new Set();const created = Math.floor(Date.now() / 1000);const alive = () => !res.writableEnded && !res.destroyed;-- const write = (o) => { if (alive()) res.write(`data: ${JSON.stringify(o)}\n\n`); };++ const comment = () => {++ if (commented || !routeInfo || !alive()) return;++ res.write(`: x_openvibe ${JSON.stringify(routeInfo)}\n\n`);++ commented = true;++ };++ const write = (o) => { if (alive()) { comment(); res.write(`data: ${JSON.stringify(o)}\n\n`); } };const chunk = (d) => ({ id: `chatcmpl-${run.id}`, object: 'chat.completion.chunk', created, model: d.model || 'openvibe', system_fingerprint: null,choices: [{ index: d.index || 0, delta: d.delta || {}, finish_reason: d.finish_reason || null }] });const out = {get opened() { return opened; },++ route(info) { if (info) routeInfo = info; },delta(d) {any = true;if (d.finish_reason) finishedIdx.add(d.index || 0);@@@ -79,7 +100,7 @@ function createLiveStream(res) {sentAny: () => any,finished: (i) => finishedIdx.has(i),fail(message, code) {-- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;++ const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;write({ error: { message: String(message || 'error').slice(0, 1000), type: status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : 'api_error', code: code || null, param: null } });return out.done();},@@@ -92,14 +113,18 @@ function openaiError(res, status, message, code, type) {res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });}--function openaiRouter({ runs, auth, config, log = console }) {++function openaiRouter({ runs, auth, config, registry, log = console }) {const r = express.Router();++ // Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);// A problem from the auth layer arrives as problem+json; a client built for OpenAI reads { error }: convert it as// it is written, whichever way the layer writes it.const typeFor = (status) => (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error');const asOpenAiErrors = (req, res, next) => {++ // An Anthropic models request is handled by the Messages router (which has Anthropic's error shape); step++ // out of the way here before that router's auth guard writes anything.++ if (req.method === 'GET' && /^\/v1\/models(\/|$)/.test(req.path) && models.wantsAnthropic(req)) return next();const end = res.end.bind(res);res.end = (chunk, ...rest) => {const ct = String(res.getHeader('Content-Type') || '');@@@ -124,8 +149,34 @@ function openaiRouter({ runs, auth, config, log = console }) {if (b.n != null && Number(b.n) !== 1) return openaiError(res, 400, 'n must be 1', 'input.invalid');const input = {};for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];++ // AI Exchange D3 (AI_MODEL_CATALOG): the `model` the caller names is exact, or one of the dynamic aliases.++ // With the flag off it is ignored exactly as before (the router picks), except an exact id: that is a pin, and a++ // pin is never silently run on another model (a rollback of the flag included), so it is refused.++ const catalogOn = Boolean(config.modelCatalog && config.modelCatalog.enabled);++ if (!catalogOn && typeof b.model === 'string' && EXACT_ID.test(b.model.trim())) {++ return openaiError(res, 400, 'an exact model id needs the model catalog, which is off: name a dynamic model (openvibe) instead', 'ai.model_unavailable');++ }++ if (catalogOn) {++ if (b.model !== undefined && b.model !== null && typeof b.model !== 'string') return openaiError(res, 400, 'model must be a string', 'input.invalid');++ const requested = b.model == null ? 'openvibe/auto' : String(b.model).trim();++ if (!requested || requested.length > 256) return openaiError(res, 400, 'model must name an exact id or a dynamic alias', 'input.invalid');++ input.model = requested;++ // An exact id is a pin: an unknown or inactive artifact is refused here, before the run (and its stream)++ // exists, so a streaming client gets the 404/400 it expects rather than a 200 SSE error. The engine selects++ // the same artifact again when the run executes.++ if (EXACT_ID.test(requested)) {++ try {++ await modelCatalog.select({ registry, model: requested, route: null });++ } catch (err) {++ if (err && err.status) return openaiError(res, err.status, err.detail || err.message, err.code);++ throw err;++ }++ }++ }const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));++ // AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).++ if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;let body = { workflow: 'ai.complete', input };// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.if (b.prefer) body = { ...body, prefer: b.prefer };@@@ -141,7 +192,9 @@ function openaiRouter({ runs, auth, config, log = console }) {// delta the upstream sends goes out as it arrives. A delta that came before the stream opened waits for it.const live = b.stream === true ? createLiveStream(res) : null;try {-- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, ...(live ? { onDelta: live.delta } : {}) });++ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,++ ...(live ? { onDelta: live.delta } : {}),++ ...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });if (live) live.open(created.run);run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));} catch (err) {@@@ -157,13 +210,16 @@ function openaiRouter({ runs, auth, config, log = console }) {}if (run.status === 'failed' || run.status === 'cancelled') {const code = (run.error && run.error.code) || 'run.failed';-- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429-- : code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422 : 503;++ const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429++ : code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422++ : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 503;return openaiError(res, status, (run.error && run.error.detail) || code, code);}if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');}++ const disclosure = await models.disclosureOf({ registry, config, run });++ if (live) live.route(disclosure);const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);const id = `chatcmpl-${run.id}`;const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';@@@ -184,16 +240,28 @@ function openaiRouter({ runs, auth, config, log = console }) {send({ ...base, choices: [{ index: c.index || 0, delta: {}, finish_reason: c.finish_reason || 'stop' }] });}if (b.stream_options && b.stream_options.include_usage) send({ ...base, choices: [], usage });-- send({ ...base, choices: [], x_openvibe: x });++ send({ ...base, choices: [], x_openvibe: disclosure ? { ...x, ...disclosure } : x });return live.done();}-- return res.status(200).json({ id, object: 'chat.completion', created, model, choices: run.output.choices, usage, system_fingerprint: run.output.system_fingerprint || null, x_openvibe: x });++ return res.status(200).json({ id, object: 'chat.completion', created, model, choices: run.output.choices, usage, system_fingerprint: run.output.system_fingerprint || null, x_openvibe: disclosure ? { ...x, ...disclosure } : x });});-- // The model list a client asks for first: one router model, the upstreams are AI's choice.-- r.get('/v1/models', asOpenAiErrors, allow, (req, res) => {-- res.json({ object: 'list', data: [{ id: 'openvibe', object: 'model', created: 1760486400, owned_by: 'openvibe' }] });-- });++ // The model list a client asks for first. With AI_MODEL_CATALOG on it is the registry's real models plus the++ // dynamic aliases (ai.model@1 shape); with it off, the legacy single `openvibe` model. An Anthropic client (one++ // that sends anthropic-version) is served by the Messages router with that protocol's shape and error object.++ const notAnthropic = (req, res, next) => (models.wantsAnthropic(req) ? next('router') : next());++ const modelRoute = (fn) => async (req, res) => {++ try { await fn(req, res); } catch (err) {++ log.error(`[ai] ${req.method} ${req.path}: ${(err && err.stack) || err}`);++ if (!res.headersSent) models.sendError(res, 'openai', 500, 'internal error');++ }++ };++ r.get('/v1/models', asOpenAiErrors, notAnthropic, allow, modelRoute(async (req, res) => {++ await models.sendList(req, res, { registry, config, protocol: 'openai' });++ }));++ r.get('/v1/models/*', asOpenAiErrors, notAnthropic, allow, modelRoute(async (req, res) => {++ await models.sendOne(req, res, { registry, config, protocol: 'openai' });++ }));return r;}@@@ -11,6 +11,7 @@ const { actionsRouter } = require('./api/actions');const { sendError } = require('./util');const openapi = require('./openapi');const { adminRouter } = require('./api/admin');++const { CAPS } = require('./auth');const { createOps } = require('./ops');const { consoleRouter } = require('./console');const { createPersonSessions } = require('./person-session');@@@ -21,7 +22,9 @@ const ovServe = require('openvibe-shared/serve');const home = require('./home');const pkg = require('../package.json');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {++const { adminRoutes } = require('openvibe-shared/config');++++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -118,20 +121,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys});if (benchEvents) app.use(benchEvents.router);++ // D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and writes++ // ai.exchange and ai.provider_keys. Loopback only (the /internal guard above), guarded by ai.config.manage; the++ // actor is Network's X-OV-Subject (usr_…), and a secret or x-danger change needs X-OV-Fresh: 1.++ if (settings) {++ const configRoutes = adminRoutes([settings.exchange, settings.providerKeys.proxy], {++ basePath: '/internal/config',++ requireAdmin: auth.requireCap(CAPS.configManage),++ actor: (req) => {++ const id = String(req.get('x-ov-subject') || '').trim();++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;++ },++ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',++ log,++ });++ app.use(configRoutes.handle);++ }++// Operator actions shared by the admin API and the operator console (one implementation, one audit row).const ops = createOps({ db, registry, pool, quotas, cache, runs });app.use(runsRouter({ runs, registry, auth, config, log }));app.use(actionsRouter({ runs, registry, auth, log }));// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.-- app.use(require('./api/openai').openaiRouter({ runs, auth, config, log }));++ app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, log }));// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).-- app.use(require('./api/messages').messagesRouter({ runs, auth, config, log }));++ app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, log }));app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));if (memory) app.use(require('./memory').memoryRouter({ memory, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));-- app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));++ app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).const personSessions = createPersonSessions({ db, config, clock });@@@ -142,7 +162,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// The same settings as a page for people (GET /you, plain forms on AI's own person session).app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));}-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions }));app.use(runPageRouter({ runs, personSessions, log }));// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,@@@ -34,7 +34,10 @@ const CAPS = Object.freeze({credentialManage: 'ai.credential.manage',quotaAttributionManage: 'ai.quota.attribution.manage',appRun: 'ai.app.run',++ // AI Exchange D8: the public capability a developer app calls the inference routes with (Contracts 0.284.0).actionsRead: 'ai.actions.read',++ // D11: Network's admin proxy, acting for the owner, manages ai.exchange and ai.provider_keys (Contracts 0.288.0).++ configManage: 'ai.config.manage',});/**@@@ -90,6 +93,8 @@ function principalSubject(sub) {}function createAuth({ config, log = console }) {++ function allowsLocal(claims, id) { return allows(claims, id); }++/** The kid a token's header names (null when absent or undecodable). */function headerKid(token) {try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }@@@ -135,7 +140,7 @@ function createAuth({ config, log = console }) {if (isAppClaims(r.claims)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });let decision = null;for (const id of anyOf) {-- decision = allows(r.claims, id);++ decision = allowsLocal(r.claims, id);if (decision.allowed) break;}if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });@@@ -158,6 +163,7 @@ function createAuth({ config, log = console }) {* app's namespaces.*/function appOrCap(appCap, ...anyOf) {++ const appCaps = Array.isArray(appCap) ? appCap : [appCap];const serviceGuard = requireCap(...anyOf);return async function appOrCapGuard(req, res, next) {const ctx = req.ov;@@@ -168,8 +174,12 @@ function createAuth({ config, log = console }) {if (!r.ok || !isAppClaims(r.claims)) return await serviceGuard(req, res, next);const app = apps.appPrincipal(r.claims);if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });-- const decision = allows(r.claims, appCap);-- if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: decision.reason, ctx });++ let decision = null;++ for (const id of appCaps) {++ decision = allowsLocal(r.claims, id);++ if (decision.allowed) break;++ }++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: appCaps.length > 1 ? `one of ${appCaps.join(', ')} is required` : decision.reason, ctx });req.principal = {kind: 'app', sub: r.claims.sub, subject: { type: 'project', id: app.projectId },appId: app.appId, projectId: app.projectId, projectKey: app.projectKey, env: app.env, onBehalfOf: app.onBehalfOf,@@@ -142,6 +142,27 @@ function load(env = process.env) {routing: {maxAttempts: Math.max(1, int(env.AI_ROUTING_MAX_ATTEMPTS, 3)),},++ // AI Exchange P7 (PLAN §D/§E): alias routes ranked by measured evidence. Off by default; with the flag++ // off nothing is written and no order changes. The EWMA alpha, the minimum sample count below which++ // evidence is sparse (and reported, never trusted), the probability floor, the decisions a challenger++ // must win, and the exploration share (0 = never leave the incumbent).++ routingEvidence: {++ enabled: bool(env.AI_ROUTING_EVIDENCE, false),++ alpha: Math.max(0.01, Math.min(1, float(env.AI_ROUTING_EVIDENCE_ALPHA, 0.2))),++ minSamples: Math.max(1, int(env.AI_ROUTING_EVIDENCE_MIN_SAMPLES, 20)),++ successFloor: Math.max(0.01, Math.min(1, float(env.AI_ROUTING_EVIDENCE_SUCCESS_FLOOR, 0.1))),++ prior: Math.max(0.01, Math.min(1, float(env.AI_ROUTING_EVIDENCE_PRIOR, 0.9))),++ hysteresisDecisions: Math.max(1, int(env.AI_ROUTING_EVIDENCE_DECISIONS, 3)),++ explorationShare: Math.max(0, Math.min(1, float(env.AI_ROUTING_EVIDENCE_EXPLORE, 0))),++ affinityGain: Math.max(0, Math.min(1, float(env.AI_ROUTING_EVIDENCE_AFFINITY, 0.05))),++ minGain: Math.max(0, Math.min(1, float(env.AI_ROUTING_EVIDENCE_MIN_GAIN, 0.15))),++ expectedOutputTokens: Math.max(1, int(env.AI_ROUTING_EVIDENCE_OUTPUT_TOKENS, 512)),++ },++ // AI Exchange P4 (CHECKPOINT D3): the truthful model catalog, exact ids and dynamic aliases. Off by++ // default: today's /v1 behaviour, the single `openvibe` model and no disclosure object, is unchanged.++ modelCatalog: {++ enabled: bool(env.AI_MODEL_CATALOG, false),++ },// Configurable local HTTP seam: POST {operation, request} JSON to this URL.httpSeamUrl: trimUrl(env.AI_HTTP_SEAM_URL),// A local model server speaking the OpenAI API (llama.cpp's llama-server, Ollama's /v1): the provider `local`,@@@ -240,6 +261,9 @@ function load(env = process.env) {maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),},++ // Aggregate demand for Host's inference planner (GET /api/v1/usage/series): off by default;++ // the capability gate (ai.usage.read) is still required when it is on.++ usageSeries: { enabled: bool(env.AI_USAGE_SERIES, false) },// Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of// whose schemas are caller-supplied, so it is bounded by count and by total schema size.schemaCache: {@@@ -274,6 +298,21 @@ function load(env = process.env) {timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),},++ // ── Billing Balance (AI Exchange F, CHECKPOINT D2/D9/D10/D12), off by default. A paid managed request holds++ // its worst-case cost before any provider call, extends the hold while streaming, and settles once. Reads the++ // same OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET as the usage samples; the capability is one per++ // operation (contracts v0.289.1): billing.credit.hold.{create,extend,settle,release}. An app holds against++ // its project's Balance and a person against their own; no Network owner lookup is made.++ credits: {++ enabled: bool(env.AI_CREDITS, false),++ billingUrl: trimUrl(env.OV_BILLING_INTERNAL_URL),++ audience: 'openvibe.billing',++ timeoutMs: Math.max(200, Math.min(30000, int(env.AI_CREDITS_TIMEOUT_MS, 5000))),++ // A settle Billing could not take is retried by the background queue with exponential backoff.++ settleRetryMs: Math.max(1000, Math.min(5 * 60 * 1000, int(env.AI_CREDITS_SETTLE_RETRY_MS, 5000))),++ settleMaxRetryMs: Math.max(1000, Math.min(60 * 60 * 1000, int(env.AI_CREDITS_SETTLE_MAX_RETRY_MS, 5 * 60 * 1000))),++ },++// ── Billing (plan T5 lane F): one platform.usage-sample@1 reading per accounted run, posted by// server/usage-samples.js to billing.usage.record (POST /api/v1/usage, audience openvibe.billing).// Readings are queued unless USAGE_SAMPLES=off; they are sent while OV_BILLING_INTERNAL_URL is set.@@@ -19,6 +19,8 @@* Billing readings by delivery state, usage-samples deliveryReport)* GET /console/cache; POST /console/cache/purge (writes: ai.provider.manage; ops.purgeCache)* GET /console/audit[?kind&action&actor&target_type&target_id&before]++ * GET /console/exchange AI Exchange state (AI_CREDITS or AI_ROUTING_EVIDENCE)++ * POST /console/exchange/credits/:id/resolve mark a review settle row resolved, never retried** Who: a Network person (usr_ subject) whose sign-in token holds staff capabilities of the contracts* staff map (policy.staff-role-map@1, ADR-022). A console session holds the AI capability ids of@@@ -39,7 +41,7 @@*/const crypto = require('crypto');const express = require('express');--const { AiError, resolveSecret } = require('../util');++const { AiError, resolveSecret, iso } = require('../util');const usageSamples = require('../usage-samples');const { CAPS } = require('../auth');const { staff: staffMap } = require('openvibe-contracts');@@@ -98,6 +100,7 @@ const NOTICES = {quota_saved: 'Quota saved.',cache_purged: 'Cache purged.',run_cancelled: 'Run cancelled.',++ settle_resolved: 'Settle queue row marked resolved. Nothing was retried.',};const DEF_KINDS = {routes: { one: 'route', list: 'listRoutes', get: 'getRoute' },@@@ -106,8 +109,11 @@ const DEF_KINDS = {};const DAY_MS = 86400000;--function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, personSessions = null }) {++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null }) {const cc = config.console;++ const creditsOn = Boolean(config.credits && config.credits.enabled);++ const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);++ const exchangeOn = creditsOn || routingOn;const r = express.Router();const scoped = ['/console', '/auth'];const baseOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();@@@ -139,7 +145,8 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e}const send = async (res, status, text) => res.status(status).type('html').send(await scrub(text));const who = (req) => ({ actor: req.staff.subject, trace: req.ov ? req.ov.traceId : null });-- const common = (req) => ({ staff: req.staff, csrf: req.staff ? req.staff.csrf : '' });++ // staff.exchange only picks the extra navigation entry; it is a copy, so the signed-in claims stay as read.++ const common = (req) => ({ staff: req.staff ? { ...req.staff, exchange: exchangeOn } : req.staff, csrf: req.staff ? req.staff.csrf : '' });const audit = async (actor, action, targetType, targetId, metadata, req) => await registry.audit(actor || 'anonymous', action, targetType, targetId, { trace: req && req.ov ? req.ov.traceId : null, metadata });const notice = (req) => {const base = NOTICES[req.query.done];@@@ -328,10 +335,10 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, econst presets = require('./presets');const KEY_RE = /^[a-z0-9][a-z0-9._-]{1,40}$/;r.get('/console/providers/new', needs(CAPS.providerManage), wrap(async (req, res) => await send(res, 200, pages.providerNew({-- ...common(req), presets: presets.PRESETS, preset: presets.byId(String(req.query.preset || '')) || null, vaultReady: !!vault && /^[0-9a-f]{64}$/i.test(String(config.credentialsKey || '')),++ ...common(req), presets: presets.PRESETS, preset: presets.byId(String(req.query.preset || '')) || null, vaultReady: !!vault && !!settings,}))));r.post('/console/providers', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!vault) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');++ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');const b = req.body || {};const preset = presets.byId(String(b.preset || 'custom')) || presets.byId('custom');const key = String(b.key || preset.id).trim().toLowerCase();@@@ -339,20 +346,21 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, eif (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', `a provider called ${key} already exists: replace its key on the providers page`);const baseUrl = String(b.base_url || preset.base_url || '').trim();if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL is an https address');-- await vault.put(key, b.api_key, who(req));const caps = [...presets.TEXT_CAPS, ...(b.vision ? ['vision'] : [])];++ // The provider comes first: its base URL is the origin its key is sealed to (D11).await registry.upsertProvider({key, display_name: String(b.display_name || preset.name || key).slice(0, 80), kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },}, { ...who(req), origin: 'admin' });++ await vault.put(key, b.api_key, who(req));// Its model list, read right away (best effort: a provider whose list cannot be read is still added).const added = await registry.getProvider(key);await catalog.sync({ registry, provider: added, apiKey: resolveSecret(added.secret_ref, env), fetchImpl, actor: who(req).actor }).catch(() => null);res.redirect(303, '/console/providers?done=provider_added');}));r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {-- if (!vault) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');++ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');const p = await registry.getProvider(req.params.key);if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');await vault.put(p.key, (req.body || {}).api_key, who(req));@@@ -521,6 +529,37 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e}));}));++ // ── AI Exchange (P7): Credits settle queue and routing evidence ──++ // Read-only except "mark resolved", which acknowledges one review row (a 409/422 needs a person) and++ // deliberately does not retry it. Both sections are absent unless their flag is on.++ if (exchangeOn) {++ r.get('/console/exchange', read, wrap(async (req, res) => {++ await send(res, 200, pages.exchange({++ ...common(req), canResolve: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),++ credits: creditsOn ? await q.settleQueue(db, clock.now()) : null,++ routing: routingOn ? await q.routingEvidence(db) : null,++ }));++ }));++ }++ if (creditsOn) {++ r.post('/console/exchange/credits/:id/resolve', needs(CAPS.workflowManage), wrap(async (req, res) => {++ const id = String(req.params.id || '').slice(0, 300);++ const row = id ? await db.prepare('SELECT id, hold_id, request_id, status FROM credit_settle_queue WHERE id = ?').get(id) : null;++ if (!row) throw new AiError(404, 'ai.not_found', 'no such settle queue row');++ if (row.status !== 'review') throw new AiError(409, 'ai.conflict', 'only a row in review can be marked resolved');++ let changed = false;++ await db.tx(async () => {++ const upd = await db.prepare(`UPDATE credit_settle_queue SET status = 'resolved', resolved_at = ?::timestamptz,++ resolved_by = ?, updated_at = ?::timestamptz WHERE id = ? AND status = 'review'`)++ .run(iso(clock.now()), req.staff.subject, iso(clock.now()), id);++ changed = Number(upd && upd.changes) > 0;++ if (changed) await audit(req.staff.subject, 'credit_settle.resolve', 'credit_settle', id, { hold_id: row.hold_id, request_id: row.request_id, from: 'review', to: 'resolved' }, req);++ });++ if (!changed) throw new AiError(409, 'ai.conflict', 'the row is no longer in review');++ return res.redirect(303, '/console/exchange?done=settle_resolved');++ }));++ }++// ── Cache ────────────────────────────────────────────────const cachePage = async (req, res, status, extra = {}) => await send(res, status, pages.cache({...common(req), stats: await cache.stats(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,@@@ -144,6 +144,7 @@ function preview(value, { string = PREVIEW_STRING, bytes = PREVIEW_BYTES } = {})const AUDIT_KINDS = Object.freeze({changes: { label: 'Configuration changes', where: "target_type IN ('provider', 'model', 'route', 'template', 'workflow', 'quota', 'cache')" },runs: { label: 'Runs', where: "target_type = 'run'" },++ exchange: { label: 'AI Exchange', where: "target_type IN ('credit_settle', 'routing_incumbent')" },console: { label: 'Console sign-ins and refusals', where: "action ILIKE 'console.%'" },all: { label: 'Everything', where: null },});@@@ -219,7 +220,71 @@ async function usageReport(quotas, f) {};}++// ── AI Exchange (P7 console) ─────────────────────────────────++const REVIEW_ROWS = 100;++const SETTLE_STATUSES = ['queued', 'sent', 'review'];++const TOKEN_COUNTS = ['input_tokens', 'cached_read_tokens', 'cache_write_tokens', 'output_tokens'];++const tokenCount = (v) => { const x = Number(v); return Number.isFinite(x) && x > 0 ? Math.trunc(x) : 0; };++const parseBody = (v) => {++ if (v && typeof v === 'object') return v;++ try { const x = JSON.parse(v); return x && typeof x === 'object' ? x : {}; } catch { return {}; }++};++++/**++ * The Credits settle queue at a glance. Only the queue's own columns and the settle body's four usage++ * token counts leave this function; the body's rates, supply cost, attempts and byok flag never do.++ * No subject is selected or joined.++ */++async function settleQueue(db, nowMs = Date.now(), limit = REVIEW_ROWS) {++ const counts = Object.fromEntries(SETTLE_STATUSES.map((s) => [s, 0]));++ for (const row of await db.prepare('SELECT status, COUNT(*) AS n FROM credit_settle_queue GROUP BY status').all()) {++ if (counts[row.status] != null) counts[row.status] = Number(row.n) || 0;++ }++ const oldest = await db.prepare("SELECT MIN(created_at) AS at FROM credit_settle_queue WHERE status = 'queued'").get();++ const oldestIso = oldest && oldest.at ? String(oldest.at) : null;++ const oldestMs = oldestIso ? Date.parse(oldestIso) : NaN;++ const reviewUsage = Object.fromEntries(TOKEN_COUNTS.map((k) => [k, 0]));++ const raw = await db.prepare(`SELECT id, hold_id, request_id, tries, last_error, created_at, settle_body++ FROM credit_settle_queue WHERE status = 'review' ORDER BY created_at, id LIMIT ?`).all(Math.max(1, Math.min(500, Number(limit) || REVIEW_ROWS)));++ const review = raw.map((r) => {++ const usage = parseBody(r.settle_body).usage || {};++ for (const k of TOKEN_COUNTS) reviewUsage[k] += tokenCount(usage[k]);++ return {++ id: r.id, hold_id: r.hold_id, request_id: r.request_id || null, tries: Number(r.tries) || 0,++ last_error: r.last_error || null, created_at: r.created_at || null,++ };++ });++ return {++ counts, oldest_queued_at: oldestIso,++ oldest_queued_seconds: Number.isFinite(oldestMs) ? Math.max(0, Math.floor((Number(nowMs) - oldestMs) / 1000)) : null,++ review, review_total: counts.review, review_truncated: counts.review > review.length, review_usage: reviewUsage,++ };++}++++/** Measured per-deployment rows (EWMA) and the routing incumbents: numbers and ids only. */++async function routingEvidence(db, limit = 200) {++ const max = Math.max(1, Math.min(500, Number(limit) || 200));++ const deployments = (await db.prepare(`SELECT provider_key, model_key, sample_count, success_rate, timeout_rate,++ ttft_ms, ttft_samples, decode_tps, decode_samples, cache_hit_ratio, cache_samples, updated_at++ FROM provider_metrics ORDER BY sample_count DESC, provider_key, model_key LIMIT ?`).all(max)).map((r) => ({++ provider_key: r.provider_key, model_key: r.model_key || '',++ sample_count: Number(r.sample_count) || 0, success_rate: Number(r.success_rate) || 0, timeout_rate: Number(r.timeout_rate) || 0,++ ttft_ms: r.ttft_ms == null ? null : Number(r.ttft_ms), ttft_samples: Number(r.ttft_samples) || 0,++ decode_tps: r.decode_tps == null ? null : Number(r.decode_tps), decode_samples: Number(r.decode_samples) || 0,++ cache_hit_ratio: Number(r.cache_samples) > 0 ? Number(r.cache_hit_ratio) || 0 : null,++ cache_samples: Number(r.cache_samples) || 0, updated_at: r.updated_at || null,++ }));++ const incumbents = (await db.prepare(`SELECT decision_key, provider_key, model_key, challenger_provider, challenger_model,++ challenger_streak, updated_at FROM routing_incumbents ORDER BY decision_key LIMIT ?`).all(max)).map((r) => ({++ decision_key: r.decision_key, provider_key: r.provider_key || null, model_key: r.model_key || '',++ challenger_provider: r.challenger_provider || null, challenger_model: r.challenger_model || '',++ challenger_streak: Number(r.challenger_streak) || 0, updated_at: r.updated_at || null,++ }));++ return { deployments, incumbents };++}++module.exports = {RUN_STATUSES, AUDIT_KINDS, PAGE, parseRunFilters, listRuns, failures, statusCounts, runFacets, preview,parseAuditFilters, listAudit, usernames, parseUsageFilters, usageReport, parseTime, requesterOf,++ settleQueue, routingEvidence,};@@@ -81,6 +81,8 @@ function usd(v) {return `$${x < 0.01 ? x.toFixed(6) : x.toFixed(x < 100 ? 4 : 2)}`;}const when = (s) => (s ? html`<time datetime="${s}">${String(s).replace('T', ' ').slice(0, 19)} UTC</time>` : '—');++/** A 0..1 EWMA as a percentage, or an em dash when it was never measured. */++const pct = (v) => (v == null ? '—' : `${(100 * Number(v)).toFixed(1)} %`);function span(fromIso, toIso) {if (!fromIso || !toIso) return '';const ms = Date.parse(toIso) - Date.parse(fromIso);@@@ -129,10 +131,11 @@ function shareTags({ title, description, canonical }) {return html`<meta property="og:type" content="website"><meta property="og:site_name" content="OpenVibe.AI"><meta property="og:title" content="${title}">${description ? html`<meta property="og:description" content="${description}">` : ''}<meta property="og:url" content="${canonical}"><meta property="og:image" content="${image}"><meta name="twitter:card" content="summary_large_image">`;}function layout({ title, section, staff, csrf, notice, error, body, site = 'operator console', robots = 'noindex,nofollow', description = null, canonical = null }) {++ const nav = staff && staff.exchange ? [...NAV, ['/console/exchange', 'Exchange', 'exchange']] : NAV;return `<!doctype html>${render(html`<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="${robots}">${description ? html`<meta name="description" content="${description}">` : ''}${canonical ? html`<link rel="canonical" href="${canonical}">${shareTags({ title: `${title} · AI ${site}`, description, canonical })}` : ''}<meta name="referrer" content="no-referrer"><title>${title} · AI ${site}</title><style>${new Raw(CSS)}</style></head><body><a class="skip" href="#main">Skip to content</a><header><div class="bar"><span class="brand">OpenVibe.AI · ${site}</span>--${staff ? html`<nav aria-label="Console"><ul>${NAV.map(([href, label, id]) => html`<li><a href="${href}"${id === section ? html` aria-current="page"` : ''}>${label}</a></li>`)}</ul></nav>++${staff ? html`<nav aria-label="Console"><ul>${nav.map(([href, label, id]) => html`<li><a href="${href}"${id === section ? html` aria-current="page"` : ''}>${label}</a></li>`)}</ul></nav><div class="who"><span>${staff.username ? `@${staff.username} ` : ''}<code>${staff.subject}</code> · ${staff.role}</span><form class="inline" method="post" action="/auth/logout">${csrfField(csrf)}<button class="link" type="submit">Sign out</button></form></div>` : ''}</div></header><main id="main" tabindex="-1">@@@ -222,7 +225,7 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,return layout({title: 'Providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>Providers and models</h1>${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a route's page.</span></p>` : ''}--<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>, or <code>vault:NAME</code> for a key added here, kept encrypted) resolved inside the service; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>++<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>, or <code>vault:NAME</code> for a key added here, sealed in the AI configuration store and bound to the provider's origin) resolved inside the service; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>${table('Providers', ['Provider', 'Kind', 'Status', 'Credentials', 'Circuit', 'Base URL', 'Default model', 'Can do', 'Priority / timeout', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr><td><code>${p.key}</code><br><span class="muted">${p.display_name}</span></td><td>${p.kind}</td><td>${badge(p.status)}</td><td>${badge(p.credentials)}${p.secret_ref ? html`<br><code>${p.secret_ref}</code>` : ''}${keyCell(p)}<br><span class="muted">${p.auth_mode}</span></td>@@@ -247,8 +250,8 @@ function providerNew({ staff, csrf, presets, preset, vaultReady, notice, error }const p = preset || presets.find((x) => x.id === 'deepseek');return layout({title: 'Add a provider', section: 'providers', staff, csrf, notice, error, body: html`<h1>Add a provider</h1>--<p class="muted">Pick a provider, paste an API key from its site and name its default model. The key is stored encrypted inside OpenVibe.AI and never shown again (only its last four characters). Nothing uses the provider until you choose it on a route's page.</p>--${vaultReady ? '' : html`<p class="bad">Keys cannot be stored on this server: <code>AI_CREDENTIALS_KEY</code> is not set.</p>`}++<p class="muted">Pick a provider, paste an API key from its site and name its default model. The key is sealed in AI's configuration store, bound to the provider's origin, and never shown again (only its last four characters). Nothing uses the provider until you choose it on a route's page.</p>++${vaultReady ? '' : html`<p class="bad">Keys cannot be stored on this server: the configuration store is unavailable.</p>`}<nav aria-label="Providers"><ul class="tabs">${presets.map((x) => html`<li><a href="/console/providers/new?preset=${enc(x.id)}"${x.id === p.id ? html` aria-current="page"` : ''}>${x.name}</a></li>`)}</ul></nav><form method="post" action="/console/providers" class="stack">${csrfField(csrf)}<input type="hidden" name="preset" value="${p.id}"><label>Name in OpenVibe <input type="text" name="key" required pattern="[a-z0-9][a-z0-9._-]{1,40}" value="${p.id === 'custom' ? '' : p.id}" spellcheck="false"></label>@@@ -509,6 +512,52 @@ ${table('Last send errors', ['Reading', 'Run', 'State', 'Attempts', 'Queued', 'L});}++function creditsSection({ csrf, credits, canResolve }) {++ const c = credits.counts;++ const usage = credits.review_usage;++ const rows = credits.review.map((r) => html`<tr>++<td><code>${r.hold_id}</code></td><td>${r.request_id ? html`<code>${r.request_id}</code>` : '—'}</td><td>${n(r.tries)}</td>++<td>${r.last_error ? clip(r.last_error, 300) : '—'}</td><td class="nowrap">${when(r.created_at)}</td>++${canResolve ? html`<td><form class="inline" method="post" action="/console/exchange/credits/${enc(r.id)}/resolve">${csrfField(csrf)}<button type="submit">Mark resolved<span class="sr-only"> ${r.hold_id}</span></button></form></td>` : ''}</tr>`);++ return html`<h2>Credits settle queue</h2>++<div class="grid">++<div class="card"><div class="k">Queued</div><div class="v">${n(c.queued)}</div></div>++<div class="card"><div class="k">Sent</div><div class="v">${n(c.sent)}</div></div>++<div class="card"><div class="k">Review</div><div class="v">${n(c.review)}</div></div>++<div class="card"><div class="k">Oldest queued</div><div class="v">${credits.oldest_queued_seconds == null ? '—' : secs(credits.oldest_queued_seconds)}</div>++<div class="muted">${credits.oldest_queued_at ? when(credits.oldest_queued_at) : 'nothing queued'}</div></div>++</div>++<p class="muted">Rows shown: ${n(credits.review.length)}${credits.review_truncated ? ` of ${n(credits.review_total)}` : ''} · tokens in ${n(usage.input_tokens)} · cached read ${n(usage.cached_read_tokens)} · cache write ${n(usage.cache_write_tokens)} · out ${n(usage.output_tokens)}. These are the only settle-body usage counts this page reads.</p>++${table('Settle rows in review', ['Hold id', 'Request id', 'Tries', 'Last error', 'Created', ...(canResolve ? ['Action'] : [])], rows, 'no settle row is waiting for review.')}++${canResolve ? '' : html`<p class="muted">Marking a review row resolved needs <code>ai.workflow.manage</code> (<code>staff.ai.manage</code>). It never retries the settle.</p>`}`;++}++++function routingSection({ routing }) {++ const deployments = routing.deployments.map((r) => html`<tr>++<td><code>${r.provider_key}${r.model_key ? `:${r.model_key}` : ''}</code></td><td>${n(r.sample_count)}</td>++<td>${r.sample_count ? pct(r.success_rate) : '—'}</td><td>${r.sample_count ? pct(r.timeout_rate) : '—'}</td>++<td>${r.ttft_ms == null ? '—' : html`${n(Math.round(r.ttft_ms))} ms<br><span class="muted">n=${n(r.ttft_samples)}</span>`}</td>++<td>${r.decode_tps == null ? '—' : html`${Number(r.decode_tps).toFixed(1)}<br><span class="muted">n=${n(r.decode_samples)}</span>`}</td>++<td>${r.cache_hit_ratio == null ? '—' : pct(r.cache_hit_ratio)}${r.cache_samples ? html`<br><span class="muted">n=${n(r.cache_samples)}</span>` : ''}</td></tr>`);++ const incumbents = routing.incumbents.map((r) => html`<tr><td><code>${r.decision_key}</code></td>++<td>${r.provider_key ? html`<code>${r.provider_key}${r.model_key ? `:${r.model_key}` : ''}</code>` : '—'}</td>++<td>${r.challenger_provider ? html`<code>${r.challenger_provider}${r.challenger_model ? `:${r.challenger_model}` : ''}</code>` : '—'}</td>++<td>${n(r.challenger_streak)}</td><td class="nowrap">${when(r.updated_at)}</td></tr>`);++ return html`<h2>Routing evidence</h2>++<p class="muted">Measured per deployment: success and timeout rates, the TTFT and decode EWMA, and the cache-hit ratio. A metric with no measurement is shown as —, never as zero.</p>++${table('Deployments', ['Deployment', 'Samples', 'Success rate', 'Timeout rate', 'TTFT', 'Decode tokens/s', 'Cache-hit ratio'], deployments, 'no deployment has been measured.')}++${table('Routing incumbents', ['Decision key', 'Incumbent', 'Challenger', 'Streak', 'Updated'], incumbents, 'no incumbent recorded.')}`;++}++++function exchange({ staff, csrf, credits, routing, canResolve, notice, error }) {++ return layout({++ title: 'AI Exchange', section: 'exchange', staff, csrf, notice, error, body: html`<h1>AI Exchange</h1>++<p class="muted">Operational state of the AI Exchange, read-only.${credits ? html` A settle row in review can be marked resolved below; that never retries it.` : ''}</p>++${credits ? creditsSection({ csrf, credits, canResolve }) : ''}++${routing ? routingSection({ routing }) : ''}`,++ });++}++function cache({ staff, csrf, stats, canManage, notice, error }) {const entries = stats.reduce((a, s) => a + s.entries, 0);const hits = stats.reduce((a, s) => a + (s.hits || 0), 0);@@@ -616,4 +665,4 @@ function allowance(cards) {/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };--module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit } };++module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit, exchange } };nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * Durable Billing Credit settles (AI Exchange F, P7). A settle the service could not deliver — Billing down,++ * slow, or refusing with a 5xx — is written to `credit_settle_queue` (migrations/0025) with its full body and++ * the same idempotency key, and a background loop retries it with exponential backoff until Billing answers 2xx.++ * A 409 or 422 is a decision, not an outage: the row is marked `review` and never retried. The loop is off in++ * test mode (NODE_ENV=test, the way the memory jobs are) and off while a provider is in a drill, so a drill++ * never writes money; a test drives runOnce() directly.++ *++ * The queue never stores a prompt, an answer or a secret: a settle body is token counts, prices and ids.++ */++const QUIET_MS = 5000;++const MAX_MS = 5 * 60 * 1000;++const BATCH = 20;++++function createCreditSettlements({ db, clock = { now: () => Date.now() }, log = console, registry = null, send, retryMs = QUIET_MS, maxRetryMs = MAX_MS, batch = BATCH }) {++ const iso = (ms) => new Date(ms).toISOString();++ const base = Math.max(1000, Number(retryMs) || QUIET_MS);++ const ceiling = Math.max(base, Number(maxRetryMs) || MAX_MS);++ let timer = null;++ let running = false;++++ const parse = (v, fallback) => {++ if (v == null) return fallback;++ if (typeof v === 'object') return v;++ try { return JSON.parse(v); } catch { return fallback; }++ };++++ /** Still a provider drill? Money must not move under one (the same guard the memory jobs use). */++ async function drillActive() {++ if (!registry) return false;++ const providers = await registry.listProviders();++ return providers.some((p) => p && p.metadata && (p.metadata.fail || p.metadata.drill));++ }++++ /** Keep a failed settle. The id is its idempotency key, so the later delivery pays it exactly once. */++ async function enqueue({ holdId, requestId = null, body, error = null }) {++ if (!holdId || !body || !body.idempotency_key) return null;++ const now = clock.now();++ // A 409 (the hold is already settled/terminal) or a 422 (Billing refused the body) is a decision, not an++ // outage: keep the row for a person to look at, never retry it.++ const decided = error && (error.status === 409 || error.status === 422);++ await db.prepare(`INSERT INTO credit_settle_queue++ (id, hold_id, request_id, settle_body, attempts, status, tries, next_at, last_error, created_at, updated_at)++ VALUES (?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?::timestamptz, ?, ?::timestamptz, ?::timestamptz)++ ON CONFLICT (id) DO NOTHING`).run(++ body.idempotency_key, holdId, requestId, JSON.stringify(body), JSON.stringify(body.attempts || []),++ decided ? 'review' : 'queued', decided ? 1 : 0, iso(now + base),++ error ? String(error.message || error.code || 'settle failed').slice(0, 500) : null, iso(now), iso(now));++ return body.idempotency_key;++ }++++ async function due(limit, force) {++ const now = iso(clock.now());++ return force++ ? db.prepare(`SELECT id, hold_id, request_id, settle_body, attempts, tries FROM credit_settle_queue++ WHERE status = 'queued' ORDER BY next_at, id LIMIT ?`).all(Math.max(1, Math.min(100, limit)))++ : db.prepare(`SELECT id, hold_id, request_id, settle_body, attempts, tries FROM credit_settle_queue++ WHERE status = 'queued' AND next_at <= ?::timestamptz ORDER BY next_at, id LIMIT ?`).all(now, Math.max(1, Math.min(100, limit)));++ }++++ async function sent(id, now) {++ await db.prepare("UPDATE credit_settle_queue SET status = 'sent', last_error = NULL, updated_at = ?::timestamptz WHERE id = ?").run(iso(now), id);++ }++++ async function review(id, err, now) {++ await db.prepare("UPDATE credit_settle_queue SET status = 'review', tries = tries + 1, last_error = ?, updated_at = ?::timestamptz WHERE id = ?")++ .run(String((err && (err.message || err.code)) || 'refused').slice(0, 500), iso(now), id);++ }++++ async function reschedule(row, err, now) {++ const tries = Number(row.tries || 0) + 1;++ const delay = Math.min(ceiling, base * (2 ** Math.min(tries, 10)));++ await db.prepare(`UPDATE credit_settle_queue SET status = 'queued', tries = ?, next_at = ?::timestamptz, last_error = ?, updated_at = ?::timestamptz WHERE id = ?`)++ .run(tries, iso(now + delay), String((err && (err.message || err.code)) || 'settle failed').slice(0, 500), iso(now), row.id);++ return delay;++ }++++ /**++ * One pass over the due rows. Never throws: each row's outcome is counted. A successful send is final; a++ * 409/422 is `review`; anything else is queued again with backoff.++ */++ async function runOnce({ force = false } = {}) {++ if (running) return { skipped: 'running' };++ running = true;++ const out = { sent: 0, review: 0, retry: 0, error: null };++ try {++ if (await drillActive()) return { skipped: 'drill' };++ const rows = await due(batch, force);++ for (const row of rows) {++ const now = clock.now();++ try {++ await send({ ...row, body: parse(row.settle_body, {}) });++ await sent(row.id, now);++ out.sent++;++ } catch (err) {++ const status = err && err.status;++ if (status === 409 || status === 422) {++ await review(row.id, err, now);++ out.review++;++ } else {++ await reschedule(row, err, now);++ out.retry++;++ }++ }++ }++ } catch (err) {++ out.error = (err && err.code) || 'error';++ log.warn(`[credits] settle queue stopped early: ${out.error}`);++ } finally { running = false; }++ return out;++ }++++ function start() {++ if (timer) return timer;++ timer = setInterval(() => { runOnce().catch(() => {}); }, Math.max(1000, Math.min(60000, base)));++ timer.unref?.();++ return timer;++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ /** The queued rows, for the operator and the tests (no body: it carries ids, not text). */++ async function pending() {++ return db.prepare('SELECT id, hold_id, request_id, status, tries, next_at, last_error FROM credit_settle_queue ORDER BY created_at, id').all();++ }++++ return { enqueue, runOnce, start, stop, pending };++}++++module.exports = { createCreditSettlements };nnew file mode 100644@@@ -0,0 +1,465 @@++'use strict';++/**++ * OpenVibe AI ↔ Billing Balance (AI Exchange F, P7; CHECKPOINT D2, D9, D10, D12), behind `AI_CREDITS` (off).++ *++ * A request on a PAID MANAGED route (the catalog's `provider_access` is OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE)++ * reserves its worst case before any provider is called:++ *++ * hold = ceil(input estimate × retail input) + ceil(max_tokens × retail output) + per-request++ *++ * capped by the caller's optional `max_cost_usd` (parsed as an exact decimal, at most 9 fractional digits, finer++ * digits rounded up to the next nano-USD; never a float). The hold is keyed by the request id and fingerprinted++ * with the resolved model, the token caps, the retail rate version, the payer and the project. Billing's 402++ * `billing.insufficient_funds` becomes AI's own 402 before the provider runs; any other Billing failure fails++ * closed (503 `billing.unavailable`) rather than serving unpaid capacity.++ *++ * Who pays (D12). An app's run holds against its PROJECT's Balance: the hold carries the app subject++ * ({ type: 'app', id }) and its project_id, and Billing needs no owner lookup. A person's own run holds against++ * their PERSONAL Balance: the user subject ({ type: 'user', id }) and no project_id. A first-party run with no++ * payer (no person and no app) has no account: the pool skips paid offers that carry a versioned retail card++ * (`payer_required`) rather than serve them free. A sandbox app is free-only (D10), so it never holds.++ *++ * When `max_cost_usd` binds, the affordable output is computed in BigInt from nano-USD per million tokens++ * (floor((cap - input charge - per-request) × 1e6 / output rate)); the provider receives that clamped++ * `max_tokens`. Fewer than one affordable token is a 402 before the provider. A later managed fallback whose++ * own worst case exceeds the hold extends the hold by the difference (capped by max_cost_usd, clamping its++ * output the same way); if Billing refuses the extension the attempt is skipped as `hold_not_extended`.++ *++ * While a stream runs, `observe()` estimates the output from the deltas and extends the hold in steps when the++ * projected charge nears it (capped by max_cost_usd; a refused extension never loses the usage — Billing settles++ * the overage as a recorded shortfall). At the end, one settle carries the four token counts, the answering++ * model's retail card (version included), the supply cost and the attempts the router made, in the contract's++ * exact attempt shape. The settle key is the request id too, so a retried request replays Billing's answer++ * instead of charging twice. A settle Billing cannot take is queued (`credit_settle_queue`) and retried with++ * backoff; a 409 or 422 marks the row for review. An error before the first token releases the hold; an abort++ * settles what was streamed.++ *++ * BYOK is never charged and never held: a run on a person's own key, their machine or a project's own provider++ * only produces CUSTOMER_BYOK attempts, so no hold is ever created for it (there is no hold to settle `byok` on;++ * Billing's settle needs one). A run that falls back from BYOK to managed supply holds and settles the managed++ * part normally.++ *++ * Money is integer nano-USD in BigInt here, never a float (D1).++ */++const { AiError, sha256, iso } = require('./util');++const { approxTokens } = require('./providers/common');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++const MANAGED_ACCESS = new Set(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE']);++const PER_MILLION = 1000000n;++const NANO_PER_USD = 1000000000n;++const USR_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++/** The four capabilities Billing names for AI (Contracts v0.289.1, one per operation). */++const CAPABILITIES = ['billing.credit.hold.create', 'billing.credit.hold.extend', 'billing.credit.hold.settle', 'billing.credit.hold.release'];++/** A prepaid answer has no retail charge; a settle still needs a card, and this says why the charge is zero. */++const ZERO_RETAIL = Object.freeze({ version: 'prepaid', nano_usd_per_m: Object.freeze({ input: '0', cached_read: '0', cache_write: '0', output: '0' }), per_request_nano_usd: '0' });++++/**++ * `max_cost_usd` as an exact decimal amount: a JSON number is stringified first, exponent forms are refused++ * (422), up to 9 fractional digits are exact and any finer non-zero digit rounds up to the next nano-USD.++ * Returns BigInt nano-USD, or null when absent.++ */++function parseCostUsd(value, field = 'max_cost_usd') {++ if (value == null) return null;++ const raw = (typeof value === 'number' ? String(value) : String(value)).trim();++ if (!/^[0-9]+(?:\.[0-9]+)?$/.test(raw)) throw new AiError(422, 'input.invalid', `${field} must be a plain decimal amount with no exponent (for example 0.000000001)`);++ const [whole, frac = ''] = raw.split('.');++ let nano;++ try { nano = BigInt(whole) * NANO_PER_USD; } catch { throw new AiError(422, 'input.invalid', `${field} is too large`); }++ nano += BigInt((frac + '000000000').slice(0, 9));++ if (/[1-9]/.test(frac.slice(9))) nano += 1n;++ // Billing's money fields are 1-19 digits; a bigger amount is refused here rather than by Billing later.++ if (nano >= 10000000000000000000n) throw new AiError(422, 'input.invalid', `${field} is too large`);++ return nano;++}++++/** ceil(tokens × nano-per-million / 1e6), the D1 rule, per component. */++function chargeNano(tokens, nanoPerM) {++ const t = BigInt(Math.max(0, Math.trunc(Number(tokens) || 0)));++ const rate = BigInt(String(nanoPerM == null ? '0' : nanoPerM));++ if (t === 0n || rate === 0n) return 0n;++ const num = t * rate;++ const div = num / PER_MILLION;++ return num % PER_MILLION === 0n ? div : div + 1n;++}++++/** A model row's `retail_rate` (catalog) in the Billing settle shape; null when there is no versioned card. */++function retailOf(rate) {++ if (!rate || typeof rate !== 'object') return null;++ const version = rate.version == null ? '' : String(rate.version).trim();++ if (!version) return null;++ const perM = {++ input: String(rate.input_nano_usd_per_m == null ? '0' : rate.input_nano_usd_per_m),++ cached_read: String(rate.cached_read_nano_usd_per_m == null ? '0' : rate.cached_read_nano_usd_per_m),++ cache_write: String(rate.cache_write_nano_usd_per_m == null ? '0' : rate.cache_write_nano_usd_per_m),++ output: String(rate.output_nano_usd_per_m == null ? '0' : rate.output_nano_usd_per_m),++ };++ return { version, nano_usd_per_m: perM, per_request_nano_usd: String(rate.per_request_nano_usd == null ? '0' : rate.per_request_nano_usd) };++}++++/** The retail charge for some usage, the same arithmetic Billing applies (used for the hold and the extension). */++function retailChargeNano(retail, { input = 0, cached = 0, cacheWrite = 0, output = 0 } = {}) {++ const perM = (retail && retail.nano_usd_per_m) || {};++ return BigInt(retail && retail.per_request_nano_usd ? retail.per_request_nano_usd : '0')++ + chargeNano(input, perM.input) + chargeNano(cached, perM.cached_read)++ + chargeNano(cacheWrite, perM.cache_write) + chargeNano(output, perM.output);++}++++/**++ * The hold a request can afford: its own card, the input estimate and the requested output cap, clamped by++ * max_cost_usd. Returns { maxTokens, amount }; throws 402 when the cap cannot buy even one output token.++ */++function affordable(retail, { inputTokens, maxTokens, maxCostNano = null }) {++ const perM = retail.nano_usd_per_m || {};++ const base = BigInt(retail.per_request_nano_usd || '0') + chargeNano(inputTokens, perM.input);++ const requested = Math.max(1, Math.trunc(Number(maxTokens) || 1024));++ let tokens = requested;++ if (maxCostNano != null) {++ if (maxCostNano < base) throw new AiError(402, 'billing.insufficient_funds', 'max_cost_usd is below this request\'s minimum cost');++ const rate = BigInt(String(perM.output == null ? '0' : perM.output));++ if (rate > 0n) {++ // floor((cap - base) × 1e6 / rate-per-million), in BigInt: the review's D1 arithmetic.++ const can = ((maxCostNano - base) * PER_MILLION) / rate;++ if (can < 1n) throw new AiError(402, 'billing.insufficient_funds', 'max_cost_usd leaves no room for any output token');++ if (can < BigInt(tokens)) tokens = Number(can);++ }++ }++ const amount = base + chargeNano(tokens, perM.output);++ return { maxTokens: tokens, amount };++}++++/** The text a streamed delta carries, for the output estimate (OpenAI and Anthropic event shapes). */++function deltaText(d) {++ if (!d) return '';++ const delta = d.delta || (d.data && d.data.delta) || null;++ if (!delta) return '';++ if (typeof delta.content === 'string') return delta.content;++ if (typeof delta.text === 'string') return delta.text;++ if (typeof delta.partial_json === 'string') return delta.partial_json;++ if (Array.isArray(delta.tool_calls)) return delta.tool_calls.map((t) => (t && t.function && t.function.arguments) || '').join('');++ return '';++}++++/**++ * Who pays (D12): an app (subject { type: 'app', id }) holds against its project's Balance; otherwise a person++ * (the request's `on_behalf_of`, subject { type: 'user', id }) holds against their personal Balance. An app is++ * always the app, whatever claim a service set on its token. A first-party service with neither has no payer and++ * gets no session (the pool refuses it paid offers).++ */++function payerOf({ onBehalfOf = null, appId = null } = {}) {++ if (appId) return { type: 'app', id: String(appId) };++ if (onBehalfOf && USR_RE.test(String(onBehalfOf))) return { type: 'user', id: String(onBehalfOf) };++ return null;++}++++/** The project whose Balance an app's hold spends: its own project, never a person's attribution. */++function projectOf({ projectId = null, attribution = null } = {}) {++ if (projectId && PROJECT_RE.test(String(projectId))) return String(projectId);++ const id = attribution && attribution.type === 'project' ? String(attribution.id || '') : '';++ return PROJECT_RE.test(id) ? id : null;++}++++/** A Billing error -> the API's own error: 402 keeps its code, everything else fails closed as unavailable. */++function billingError(status, body, what) {++ const detail = (body && (body.detail || body.title || body.message)) || `Billing ${what} failed`;++ // The public word is Balance, never Credits: 402 keeps its code and says what is short.++ if (status === 402) return new AiError(402, 'billing.insufficient_funds', 'Not enough Balance');++ if (status === 409) return new AiError(409, 'billing.conflict', String(detail).slice(0, 500));++ if (status === 422) return new AiError(422, 'billing.invalid_request', String(detail).slice(0, 500));++ return new AiError(503, 'billing.unavailable', `Billing ${what} is unavailable (${status})`);++}++++function createBillingClient({ billingUrl, audience, tokens, fetchImpl = globalThis.fetch, timeoutMs = 5000 }) {++ const base = String(billingUrl || '').replace(/\/+$/, '');++ async function call(method, path, body, requestId) {++ const token = await tokens.getToken({ audience });++ const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ if (requestId) headers['X-OpenVibe-Request-Id'] = requestId;++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(timeoutMs) });++ } catch (err) {++ throw new AiError(503, 'billing.unavailable', `Billing is unreachable: ${(err && err.name === 'TimeoutError') ? 'timeout' : 'network error'}`);++ }++ const json = await res.json().catch(() => null);++ if (!res.ok) throw billingError(res.status, json, path);++ return json || {};++ }++ return {++ hold: (body, requestId) => call('POST', '/internal/credits/holds', body, requestId),++ extend: (id, body, requestId) => call('POST', `/internal/credits/holds/${encodeURIComponent(id)}/extend`, body, requestId),++ settle: (id, body, requestId) => call('POST', `/internal/credits/holds/${encodeURIComponent(id)}/settle`, body, requestId),++ release: (id, requestId) => call('POST', `/internal/credits/holds/${encodeURIComponent(id)}/release`, {}, requestId),++ };++}++++/**++ * One request's Balance session. Created by `begin()`; the run drives it:++ *++ * onRoute(facts, meta) before each provider attempt; returns { maxTokens } to clamp the attempt,++ * { skip: 'hold_not_extended' } to pass on that offer, or null (not a Balance offer)++ * observe(delta) each streamed delta; extends the hold when the output nears it++ * settle({...}) once, after the answer (or after the partial usage of an abort/failure)++ * release() once, when no token was produced at all++ */++function createSession({ manager, requestId, payer, projectId, credentialId = null, inputTokens, maxCostNano = null, startedAt }) {++ const s = {++ requestId: String(requestId),++ payer, projectId, credentialId,++ subject: payer,++ inputTokens: Math.max(0, Math.trunc(inputTokens)),++ maxCostNano,++ holdId: null,++ heldNano: 0n,++ retail: null,++ streamedChars: 0,++ settled: false,++ released: false,++ extensionRefused: false,++ pending: null,++ startedAt,++ };++ const holdKey = `ai-hold:${s.requestId}`;++ const settleKey = `ai-settle:${s.requestId}`;++++ /**++ * The first managed paid attempt holds its worst case. A later managed fallback with a bigger worst case++ * extends the hold by the difference (never above max_cost_usd); a refused extension skips that offer.++ */++ async function onRoute(facts, meta) {++ if (s.settled || s.released) return null;++ if (!meta) return null; // a person's own key or machine++ const card = retailOf(meta.retail);++ // A prepaid offer (free, subscription, BYOK, the stub) never spends Balance. After an earlier managed++ // attempt it may still answer (a free fallback): the settle then uses its card, or zero.++ if (meta.paid === false) { if (s.holdId) s.retail = card || ZERO_RETAIL; return null; }++ if (!MANAGED_ACCESS.has(facts && facts.provider_access)) return null; // BYOK/unverified: no managed offer++ const retail = card;++ if (!retail) throw new AiError(503, 'billing.rate_unavailable', 'this route has no versioned retail rate card, so its credit cannot be held');++ const requested = Math.max(1, Math.trunc(Number(meta.maxTokens) || 1024));++ let plan;++ try {++ plan = affordable(retail, { inputTokens: s.inputTokens, maxTokens: requested, maxCostNano: s.maxCostNano });++ } catch (err) {++ // The first offer cannot be bought at this cap: 402 before the provider. A fallback simply cannot be++ // covered, so it is skipped and a cheaper offer (or a later failure) decides the run.++ if (s.holdId) return { skip: 'hold_not_extended' };++ throw err;++ }++ s.retail = retail;++ if (!s.holdId) {++ s.heldNano = plan.amount;++ const fingerprint = sha256({++ resolved_model: (facts && facts.resolved_model) || null, revision: (facts && facts.revision) || null,++ input_tokens: s.inputTokens, output_tokens: plan.maxTokens, max_cost_usd: s.maxCostNano == null ? null : String(s.maxCostNano),++ rate_version: retail.version, subject: s.subject, project_id: s.projectId,++ });++ // D12: an app names its project's Balance; a person's hold is personal and carries no project_id.++ const created = await manager.hold({++ idempotency_key: holdKey, subject: s.subject, ...(s.projectId ? { project_id: s.projectId } : {}), ...(s.credentialId ? { credential_id: s.credentialId } : {}),++ max_nano_usd: String(plan.amount), rate_version: retail.version, request_fingerprint: fingerprint, service: 'ai',++ }, s.requestId);++ s.holdId = created.hold_id;++ s.heldNano = BigInt(String(created.held_nano_usd == null ? plan.amount : created.held_nano_usd));++ return { maxTokens: plan.maxTokens };++ }++ if (plan.amount > s.heldNano) {++ const add = plan.amount - s.heldNano;++ try {++ await manager.extend(s.holdId, { add_nano_usd: String(add) }, s.requestId);++ s.heldNano = plan.amount;++ } catch (err) {++ if (err && (err.code === 'billing.insufficient_funds' || err.code === 'billing.conflict')) return { skip: 'hold_not_extended' };++ throw err;++ }++ }++ return { maxTokens: plan.maxTokens };++ }++++ /** The projected output charge of what has streamed so far. */++ function projectedNano() {++ const output = Math.ceil(s.streamedChars / 4);++ return { output, charge: s.retail ? retailChargeNano(s.retail, { output }) : 0n };++ }++++ /** Extend once when the stream nears the hold; the step is another hold's worth, capped by max_cost_usd. */++ function maybeExtend() {++ if (!s.holdId || s.extensionRefused || s.settled || s.released || s.pending) return;++ const { charge } = projectedNano();++ const near = s.heldNano > 0n ? (charge * 5n >= s.heldNano * 4n) : charge > 0n;++ if (!near) return;++ let step = s.heldNano > 0n ? s.heldNano : charge;++ if (step <= 0n) step = 1n;++ if (s.maxCostNano != null) {++ const room = s.maxCostNano - s.heldNano;++ if (room <= 0n) { s.extensionRefused = true; return; }++ if (step > room) step = room;++ }++ s.heldNano += step;++ s.pending = manager.extend(s.holdId, { add_nano_usd: String(step) }, s.requestId).catch((err) => {++ s.heldNano -= step;++ // A short balance mid-stream stops the growth; settle records the overage as Billing's shortfall.++ if (err && (err.code === 'billing.insufficient_funds' || err.code === 'billing.conflict')) s.extensionRefused = true;++ else manager.log.warn(`[credits] extend ${s.holdId} failed: ${err && err.message}`);++ }).finally(() => { s.pending = null; });++ }++++ function observe(delta) {++ if (!s.holdId || s.settled || s.released || s.extensionRefused) return;++ const text = deltaText(delta);++ if (!text) return;++ s.streamedChars += text.length;++ maybeExtend();++ }++++ function usageOf(usage = {}) {++ const cached = Math.max(0, Math.trunc(Number(usage.cached) || 0));++ const cacheWrite = Math.max(0, Math.trunc(Number(usage.cache_write) || 0));++ const whole = Math.max(0, Math.trunc(Number(usage.input) || 0));++ return { input_tokens: Math.max(0, whole - cached - cacheWrite), cached_read_tokens: cached, cache_write_tokens: cacheWrite, output_tokens: Math.max(0, Math.trunc(Number(usage.output) || 0)) };++ }++++ /** One settle, with the four token counts, the answering card, the supply cost and the attempts. */++ async function settle({ usage = {}, supplyCostUsd = 0, attempts = [] } = {}) {++ if (!s.holdId || s.settled || s.released) return null;++ if (s.pending) await s.pending;++ // The provider's own cost comes as a float from the rate cards; turn it into integer nano-USD through a++ // decimal string (never a nano conversion by float multiplication).++ let supply = 0n;++ if (Number.isFinite(supplyCostUsd) && supplyCostUsd > 0) {++ try { supply = parseCostUsd(Number(supplyCostUsd).toFixed(9)) || 0n; } catch { supply = 0n; }++ }++ const body = {++ idempotency_key: settleKey,++ usage: usageOf(usage),++ retail: s.retail,++ supply_cost_nano_usd: String(supply),++ byok: false,++ attempts: Array.isArray(attempts) ? attempts : [],++ };++ s.settled = true;++ try {++ return await manager.settle(s.holdId, body, s.requestId);++ } catch (err) {++ s.settled = false;++ // Durable: Billing may be down or slow now; the same body and key are retried by the outbox.++ if (manager.enqueueSettle) {++ try { await manager.enqueueSettle({ holdId: s.holdId, requestId: s.requestId, body, error: err }); }++ catch (qerr) { manager.log.warn(`[credits] queue settle ${settleKey} failed: ${qerr && qerr.message}`); }++ }++ throw err;++ }++ }++++ /** Release a hold whose request never produced a token; harmless when no hold was ever made. */++ async function release() {++ if (!s.holdId || s.settled || s.released) return null;++ s.released = true;++ try {++ return await manager.release(s.holdId, s.requestId);++ } catch (err) {++ s.released = false;++ throw err;++ }++ }++++ return {++ onRoute, observe, settle, release, projectedNano, usageOf, requestId: s.requestId,++ get holdId() { return s.holdId; }, get heldNano() { return s.heldNano; }, get retail() { return s.retail; },++ get streamedChars() { return s.streamedChars; }, get settled() { return s.settled; }, get released() { return s.released; },++ get inputTokens() { return s.inputTokens; }, get payer() { return s.payer; },++ };++}++++/**++ * The manager the service builds at boot (null when AI_CREDITS is off, so `runs` never touches Billing).++ * `tokens` is injectable for tests; production mints a service token for openvibe.billing (the four hold++ * capabilities). Network is reached only for the OAuth token, never to resolve a project's owner (D12).++ */++function createCredits({ config, db = null, registry = null, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, networkUrl = '' }) {++ const cfg = (config && config.credits) || {};++ if (!cfg.enabled) return null;++ const billingUrl = String(cfg.billingUrl || '').replace(/\/+$/, '');++ const clientSecret = env.OV_OAUTH_CLIENT_SECRET;++ const network = String(networkUrl || cfg.networkUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ const clientId = (config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';++ let tokenClient = tokens;++ if (!tokenClient && clientSecret) {++ tokenClient = createServiceTokenClient({++ tokenUrl: `${network}/oauth/token`, clientId, clientSecret,++ scope: { [cfg.audience || 'openvibe.billing']: CAPABILITIES }, fetch: fetchImpl,++ });++ }++ const available = Boolean(billingUrl && tokenClient);++ if (!available) log.warn('[credits] AI_CREDITS is on but Billing is not configured (OV_BILLING_INTERNAL_URL / OV_OAUTH_CLIENT_SECRET); paid managed requests will be refused');++ const billing = available ? createBillingClient({ billingUrl, audience: cfg.audience || 'openvibe.billing', tokens: tokenClient, fetchImpl, timeoutMs: cfg.timeoutMs || 5000 }) : null;++++ const settlements = db && billing++ ? require('./credit-settlements').createCreditSettlements({++ db, clock, log, registry,++ send: (row) => billing.settle(row.hold_id, row.body, row.request_id),++ retryMs: cfg.settleRetryMs, maxRetryMs: cfg.settleMaxRetryMs,++ })++ : null;++++ const manager = {++ enabled: true,++ log,++ accessOf: accessOfProvider,++ async hold(body, requestId) {++ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');++ return billing.hold(body, requestId);++ },++ async extend(holdId, body, requestId) {++ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');++ return billing.extend(holdId, body, requestId);++ },++ async settle(holdId, body, requestId) {++ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');++ return billing.settle(holdId, body, requestId);++ },++ async release(holdId, requestId) {++ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');++ return billing.release(holdId, requestId);++ },++ enqueueSettle: settlements ? (row) => settlements.enqueue(row) : null,++ drainSettlements: settlements ? (opts) => settlements.runOnce(opts) : async () => ({ skipped: 'disabled' }),++ pendingSettlements: settlements ? () => settlements.pending() : async () => [],++ startSettlements: settlements ? () => settlements.start() : () => null,++ stopSettlements: settlements ? () => settlements.stop() : () => {},++ /**++ * Begin a request's Balance session, or null when there is no payer (the pool then refuses paid offers++ * that carry a versioned card), when an app names no project, or for a BYOK run (no managed route).++ */++ begin({ requestId, ctx = {}, input = {}, attribution = null, onBehalfOf = null, credentialId = null } = {}) {++ const payer = payerOf({ onBehalfOf: onBehalfOf || ctx.onBehalfOf || null, appId: ctx.appId });++ if (!payer) return null;++ // D12: an app spends its project's Balance; a person's own run is personal, so an attribution is++ // never turned into a project_id on a user's hold.++ const projectId = payer.type === 'app' ? projectOf({ projectId: ctx.projectId, attribution }) : null;++ if (payer.type === 'app' && !projectId) return null;++ if (ctx.byok) return null; // a credential/project provider: no managed route in this run++ const requestKey = String(requestId || ctx.runId || '');++ if (!requestKey) return null; // no stable key: never invent one from the clock++ const inputTokens = approxTokens(JSON.stringify(input || {})) + 16;++ let maxCostNano = null;++ if (ctx.maxCostNano != null) {++ try { maxCostNano = BigInt(String(ctx.maxCostNano)); } catch { throw new AiError(422, 'input.invalid', 'max_cost_usd is invalid'); }++ } else if (ctx.maxCostUsd != null) maxCostNano = parseCostUsd(ctx.maxCostUsd);++ return createSession({ manager, requestId: requestKey, payer, projectId, credentialId, inputTokens, maxCostNano, startedAt: iso(clock.now()) });++ },++ };++ return manager;++}++++/** A provider record's commercial access (the catalog's rule), for the attempts a settle reports. */++function accessOfProvider(provider) {++ try { return require('./providers/model-catalog').accessOf(provider); } catch { return null; }++}++++module.exports = {++ createCredits, createBillingClient, createSession, retailOf, retailChargeNano, affordable, parseCostUsd,++ deltaText, payerOf, projectOf, accessOfProvider, CAPABILITIES, MANAGED_ACCESS,++};@@@ -28,21 +28,37 @@ const { createAuth, createNetworkKeys } = require('./auth');const { jwksClient } = require('openvibe-sdk/auth');const schemas = require('./schemas');const { createApp } = require('./app');++const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null } = {}) {++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.const db = givenDb || await openDb(config, { log });const registry = createRegistry(db, { clock, env });-- // Provider keys an owner stored from the console (server/vault.js): into this process before any provider is built.-- const vault = require('./vault').createVault({ db, config, clock, log });++ // AI Exchange F: Billing Credits for paid managed inference (AI_CREDITS, off). The manager is created when the++ // flag is on, including when an owner turns it on live (D11 onExchange below); runs use the facade.++ let credits = null;++ function ensureCredits() {++ if (credits || !(config.credits && config.credits.enabled)) return credits;++ credits = require('./credits').createCredits({ config, db, registry, env, fetchImpl, clock, log, networkUrl: config.networkInternalUrl });++ if (credits && !(config.modelCatalog && config.modelCatalog.enabled)) {++ log.warn('[credits] AI_CREDITS is on but AI_MODEL_CATALOG is off: no offer declares managed access, so no request is charged');++ }++ if (credits && listen && config.nodeEnv !== 'test') credits.startSettlements();++ return credits;++ }++ // D11 owner settings: ai.exchange and ai.provider_keys on openvibe-shared/config. The provider-key store also++ // refreshes the synchronous vault cache, and its one-time migration runs before any provider is built.++ const settings = await require('./settings').createSettings({ db, config, registry, clock, env, log, onExchange: () => ensureCredits() });++ const vault = require('./vault').createVault({ settings, clock, log });++ await require('./vault').migrate({ db, config, settings, log }).catch((err) => log.warn(`[Vault] migration failed: ${err.message}`));await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });-- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels, bench });++ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels, bench, variantEvidence });// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.const tiers = createTiers({ config, valkey, clock, log });// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);@@@ -71,8 +87,28 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.const preferences = require('./person-preferences').createPreferences({ db, clock });const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets });++ ensureCredits();++ // A stable facade: the store may create the Credits manager after boot (AI_CREDITS turned on live), and the runs++ // keep the same object. enabled is the live flag, so turning Credits off stops new holds at once.++ const creditsRef = {++ get enabled() { return Boolean(credits && config.credits && config.credits.enabled); },++ get accessOf() { return credits ? credits.accessOf : require('./credits').accessOfProvider; },++ get manager() { return credits; },++ begin(o) {++ if (!credits) throw new AiError(503, 'billing.unavailable', 'Billing Credits is not configured on this service');++ return credits.begin(o);++ },++ pendingSettlements() { return credits ? credits.pendingSettlements() : Promise.resolve([]); },++ drainSettlements(o) { return credits ? credits.drainSettlements(o) : Promise.resolve({ skipped: 'disabled' }); },++ startSettlements() { return credits ? credits.startSettlements() : null; },++ stopSettlements() { return credits ? credits.stopSettlements() : undefined; },++ };++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef });await seed({ registry, quotas, config, env, db });++ // The seed creates or moves providers from the environment: publish their origins (the schema and every key's++ // bind follow), so a key is bound to the host it is for from the first boot.++ await settings.providerKeys.syncOrigins({ actor: { type: 'service', id: 'ai' }, reason: 'providers seeded' })++ .catch((err) => log.warn(`[config] provider origins: ${err.message}`));const interrupted = await runs.recoverInterrupted();if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);@@@ -93,7 +129,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, benchEvents, runBudgets, memory });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -117,6 +153,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowlet accountSubscription = null;if (listen) userModules.start();if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();++ // Durable Credit settles: the same jobs rule as Memory (never in test mode, never under a provider drill; the++ // queue's runOnce checks the drill itself).++ if (listen && credits && config.nodeEnv !== 'test') credits.startSettlements();if (listen) {server = await new Promise((resolve, reject) => {// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy@@@ -147,6 +186,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (accountSubscription) accountSubscription.stop();userModules.stop();memoryJobs.stop();++ if (credits) credits.stopSettlements();if (jwks) jwks.stop();if (server) {server.closeAllConnections?.();@@@ -161,7 +201,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, close };}if (require.main === module) {nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * AI Exchange P7: OpenVibe.Bench's per-artifact evidence (Bench PR #120, GET /api/v1/variant-evidence/:exactId).++ *++ * The only facts this client accepts are the ones Bench measured for an exact id (D3:++ * org/model[:quant]@revision): sample_count, success_rate, timeout_rate, TTFT p50/p95 and decode tokens/s.++ * A sparse row is returned with its sample count and status `sparse`, never dropped and never filled in; the++ * router reports it as sparse. A 404 is `absent`; a network failure is `unavailable` and never fails a request.++ *++ * Reads are cached for a short TTL per exact id (positive and negative), so a burst of alias traffic makes one++ * Bench call, and an unavailable Bench does not get hammered. No prompt, output or request id is sent.++ */++const DEFAULT_TTL_MS = 60 * 1000;++const FAILURE_TTL_MS = 5 * 1000;++++function normalise(evidence) {++ if (!evidence || typeof evidence !== 'object') return null;++ const samples = Number.isSafeInteger(Number(evidence.sample_count)) && Number(evidence.sample_count) >= 0 ? Number(evidence.sample_count) : 0;++ const metrics = evidence.metrics && typeof evidence.metrics === 'object' ? evidence.metrics : {};++ const rate = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.max(0, Math.min(1, Number(v))));++ const tps = (v) => (v == null || !Number.isFinite(Number(v)) || Number(v) < 0 ? null : Number(v));++ return {++ exact_id: typeof evidence.exact_id === 'string' ? evidence.exact_id : null,++ samples,++ success_rate: rate(evidence.success_rate),++ timeout_rate: rate(evidence.timeout_rate),++ ttft_p50_ms: tps(metrics.ttft_p50_ms != null ? metrics.ttft_p50_ms : evidence.ttft_p50_ms),++ ttft_p95_ms: tps(metrics.ttft_p95_ms != null ? metrics.ttft_p95_ms : evidence.ttft_p95_ms),++ decode_tokens_per_s: tps(metrics.decode_tokens_per_s != null ? metrics.decode_tokens_per_s : evidence.decode_tokens_per_s),++ measured_at: evidence.measured_at || null,++ sparse: evidence.sparse === true || Boolean(evidence.confidence && evidence.confidence.sparse),++ confidence: evidence.confidence || null,++ };++}++++function createVariantEvidence({ config = {}, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() }, log = console, ttlMs = DEFAULT_TTL_MS } = {}) {++ const base = String((config.bench && config.bench.url) || '').replace(/\/+$/, '');++ const timeoutMs = Math.max(100, Number(config.bench && config.bench.timeoutMs) || 15000);++ const cache = new Map(); // exact id -> { at, expires, result }++++ function remember(id, result, ttl = result.status === 'unavailable' ? FAILURE_TTL_MS : ttlMs) {++ if (cache.size > 512) cache.delete(cache.keys().next().value);++ cache.set(id, { at: clock.now(), expires: ttl, result });++ return result;++ }++++ /**++ * → { status: 'ok' | 'sparse' | 'absent' | 'unavailable', samples, success_rate, timeout_rate,++ * ttft_p50_ms, decode_tokens_per_s, exact_id, sparse }++ */++ async function get(exactId) {++ const id = typeof exactId === 'string' ? exactId.trim() : '';++ if (!id) return { status: 'absent', samples: 0, exact_id: null };++ const hit = cache.get(id);++ if (hit && clock.now() - hit.at <= hit.expires) return hit.result;++ if (!base || typeof fetchImpl !== 'function') return remember(id, { status: 'absent', samples: 0, exact_id: id });++ let res;++ try {++ res = await fetchImpl(`${base}/api/v1/variant-evidence/${encodeURIComponent(id)}`, {++ method: 'GET', headers: { Accept: 'application/json' }, signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined,++ });++ } catch (err) {++ log.warn(`[routing-evidence] Bench evidence unavailable for ${id}: ${String(err && err.message || err).slice(0, 160)}`);++ return remember(id, { status: 'unavailable', samples: 0, exact_id: id });++ }++ if (res.status === 404) return remember(id, { status: 'absent', samples: 0, exact_id: id });++ if (!res.ok) return remember(id, { status: 'unavailable', samples: 0, exact_id: id });++ let body = null;++ try { body = await res.json(); } catch { body = null; }++ const evidence = normalise(body && body.evidence);++ if (!evidence) return remember(id, { status: 'absent', samples: 0, exact_id: id });++ return remember(id, { ...evidence, status: evidence.sparse ? 'sparse' : 'ok' });++ }++++ return { get, clear: () => cache.clear(), base };++}++++module.exports = { createVariantEvidence, normalise };nnew file mode 100644@@@ -0,0 +1,490 @@++'use strict';++/**++ * AI Exchange P7: expected cost per successful alias request, from measured evidence (PLAN §D, §E).++ *++ * This is AI's router using the ONE planner, openvibe-sdk/placement planAi(): planAi keeps the hard gates, the++ * expected-cost-per-success arithmetic in integer nano-USD (D1), the sparse-evidence marking and its hysteresis++ * margin. This module only prepares the planner's AI Exchange inputs from what AI has measured, adds the++ * N-consecutive-decision rule, the cache affinity and the small exploration share, and explains the answer.++ *++ * economy the lowest expected cost per success, where++ * price_after_cache = input*(1-hit)*retail_in + input*hit*retail_cached + output*retail_out (+ fee)++ * expected cost per success = price_after_cache / max(success_rate, floor)++ * fast the lowest p50 TTFT, then the highest decode tokens/s (planAi's latency objective, priced flat so++ * the measured latency is what decides)++ * balanced the normalised cost per success and normalised TTFT weighed together++ * private never reaches here: the route already excludes every non-local offer++ *++ * Nothing here is a second planner: the order planAi produces is the order the router tries, with the++ * incumbent kept for N decisions and a warm prompt-prefix cache as a tie-break. The explanation carries only++ * ids, counts, nano-USD and rates — never a prompt, an endpoint, a key or a host.++ */++const placement = require('openvibe-sdk/placement');++const { nanoUsd, chargeTokens, costPerSuccessNano, formatNanoUsd } = require('openvibe-sdk/cost');++const { tokensOf, leadingText } = require('./task');++const { offerKey } = require('./metrics');++++const POLICIES = { cheapest: 'economy', fastest: 'fast', balanced: 'balanced', auto: 'auto', private: 'private', exact: 'exact' };++const OBJECTIVE = { cheapest: 'cheapest', fastest: 'fastest', balanced: 'balanced', auto: 'balanced' };++const MIN_PROMPT_TOKENS = 1;++const PPM = 1000000n;++++const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));++const nanoFromUsd = (usd) => {++ const n = Number(usd);++ if (!Number.isFinite(n) || n < 0) return null;++ return String(Math.round(n * 1e9));++};++++/** Integer nano-USD per million rates from the retail card, else from the route's rate cards (free: zeros). */++function nanoRatesOf({ free = false, retail = null, cards = null } = {}) {++ const zero = { input_nano_usd_per_m: '0', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', output_nano_usd_per_m: '0', per_request_nano_usd: '0' };++ if (free) return zero;++ if (retail && retail.input_nano_usd_per_m != null && retail.output_nano_usd_per_m != null) {++ const cardCached = cards && cards.cached ? nanoFromUsd(cards.cached.unit_price_usd) : null;++ return {++ input_nano_usd_per_m: String(retail.input_nano_usd_per_m),++ cached_read_nano_usd_per_m: retail.cached_read_nano_usd_per_m != null ? String(retail.cached_read_nano_usd_per_m) : (cardCached != null ? cardCached : String(retail.input_nano_usd_per_m)),++ cache_write_nano_usd_per_m: retail.cache_write_nano_usd_per_m != null ? String(retail.cache_write_nano_usd_per_m) : '0',++ output_nano_usd_per_m: String(retail.output_nano_usd_per_m),++ per_request_nano_usd: retail.per_request_nano_usd != null ? String(retail.per_request_nano_usd) : '0',++ };++ }++ if (!cards) return null;++ const input = nanoFromUsd(cards.in && cards.in.unit_price_usd);++ const cached = nanoFromUsd(cards.cached && cards.cached.unit_price_usd);++ const put = nanoFromUsd(cards.cache_write && cards.cache_write.unit_price_usd);++ const output = nanoFromUsd(cards.out && cards.out.unit_price_usd);++ if (input == null || cached == null || output == null) return null;++ return {++ input_nano_usd_per_m: input, cached_read_nano_usd_per_m: cached, cache_write_nano_usd_per_m: put || '0',++ output_nano_usd_per_m: output, per_request_nano_usd: '0',++ };++}++++/**++ * The retail card's input and cached_read rates weighted by the measured cache-hit ratio, plus output at the++ * expected output length, in integer nano-USD. All BigInt; each component is rounded up once (D1).++ */++function priceAfterCacheNano({ promptTokens = 0, outputTokens = 0, cacheHitRatio = 0, rates } = {}) {++ const input = BigInt(Math.max(0, Math.floor(Number(promptTokens) || 0)));++ const output = BigInt(Math.max(0, Math.floor(Number(outputTokens) || 0)));++ const ppm = BigInt(Math.max(0, Math.min(1000000, Math.round(clamp01(cacheHitRatio) * 1000000))));++ const cached = (input * ppm) / PPM;++ const uncached = input - cached;++ return chargeTokens(uncached, rates.input_nano_usd_per_m)++ + chargeTokens(cached, rates.cached_read_nano_usd_per_m)++ + chargeTokens(output, rates.output_nano_usd_per_m)++ + nanoUsd(rates.per_request_nano_usd);++}++++/**++ * One integer input rate that prices the whole prompt at the same total as the two-part price. planAi's cost++ * function takes one usage and one rate card per candidate, so the measured cache ratio is carried in the++ * blended rate; this picks the integer rate whose ceiling charge equals the exact two-part charge when one++ * exists (otherwise the rounded-up nearest, never a cheaper rate).++ */++function blendedInputRateNano({ promptTokens = 0, cacheHitRatio = 0, rates } = {}) {++ const input = BigInt(Math.max(0, Math.floor(Number(promptTokens) || 0)));++ if (!input) return '0';++ const ppm = BigInt(Math.max(0, Math.min(1000000, Math.round(clamp01(cacheHitRatio) * 1000000))));++ const cached = (input * ppm) / PPM;++ const uncached = input - cached;++ const desired = chargeTokens(uncached, rates.input_nano_usd_per_m) + chargeTokens(cached, rates.cached_read_nano_usd_per_m);++ if (!desired) return '0';++ const base = (desired * PPM) / input;++ for (const candidate of [base - 1n, base, base + 1n, base + 2n].filter((x) => x >= 0n)) {++ if (chargeTokens(input, candidate) === desired) return candidate.toString();++ }++ return ((desired * PPM + input - 1n) / input).toString();++}++++/** The measured p_success and its count: Bench is used only when it is not sparse and has a success rate. */++function combineEvidence({ metric = null, bench = null, minSamples = 20, prior = 0.9 } = {}) {++ const floor = Math.max(1, Number(minSamples) || 20);++ const sources = [];++ if (metric && metric.sample_count >= floor && Number.isFinite(metric.success_rate)) {++ sources.push({ p: clamp01(metric.success_rate), n: metric.sample_count, source: 'ewma' });++ }++ if (bench && bench.status === 'ok' && bench.samples >= floor && bench.success_rate != null) {++ sources.push({ p: clamp01(bench.success_rate), n: bench.samples, source: 'bench' });++ }++ const total = sources.reduce((n, s) => n + s.n, 0);++ const p = total ? sources.reduce((sum, s) => sum + s.p * s.n, 0) / total : null;++ const raw = metric && metric.sample_count > 0 ? { p_success: clamp01(metric.success_rate), confidence: 1, samples: metric.sample_count, source: 'ewma' } : null;++ return {++ // planAi rejects p <= 0 as 'no chance'; the brief prices it at the success floor instead, so a measured++ // zero stays an eligible (very expensive) last resort and the floor decides its cost per success.++ evidence: sources.length ? { p_success: Math.max(1e-9, p), confidence: 1, samples: total, source: sources.map((s) => s.source).join('+') } : raw,++ p_success: sources.length ? Math.max(1e-9, p) : null,++ samples: total || (metric ? metric.sample_count : 0),++ sparse: !sources.length,++ source: sources.length ? sources.map((s) => s.source).join('+') : (raw ? 'ewma' : 'prior'),++ bench,++ metric,++ minSamples: floor,++ prior,++ };++}++++/** The measured TTFT: Bench's p50 when it is usable, else the EWMA of the first-token time. */++function measuredTtft({ metric = null, bench = null, minSamples = 20 } = {}) {++ const floor = Math.max(1, Number(minSamples) || 20);++ if (bench && bench.status === 'ok' && bench.samples >= floor && bench.ttft_p50_ms != null) {++ return { ms: Number(bench.ttft_p50_ms), source: 'bench_p50', samples: bench.samples };++ }++ if (metric && metric.ttft_samples >= floor && metric.ttft_ms != null) {++ return { ms: Number(metric.ttft_ms), source: 'ewma', samples: metric.ttft_samples };++ }++ return { ms: null, source: metric && metric.ttft_samples > 0 ? 'sparse' : 'absent', samples: metric ? metric.ttft_samples : 0 };++}++++/** The measured decode throughput: Bench first, else the EWMA of output tokens per decode second. */++function measuredDecode({ metric = null, bench = null, minSamples = 20 } = {}) {++ const floor = Math.max(1, Number(minSamples) || 20);++ if (bench && bench.status === 'ok' && bench.samples >= floor && bench.decode_tokens_per_s != null) {++ return { tps: Number(bench.decode_tokens_per_s), source: 'bench', samples: bench.samples };++ }++ if (metric && metric.decode_samples >= floor && metric.decode_tps != null) {++ return { tps: Number(metric.decode_tps), source: 'ewma', samples: metric.decode_samples };++ }++ return { tps: null, source: metric && metric.decode_samples > 0 ? 'sparse' : 'absent', samples: metric ? metric.decode_samples : 0 };++}++++/** A measured cache-hit ratio, or zero (the conservative, uncached price) when it is not measured enough. */++function measuredCache({ metric = null, minSamples = 20 } = {}) {++ const floor = Math.max(1, Number(minSamples) || 20);++ if (metric && metric.cache_samples >= floor) return { ratio: clamp01(metric.cache_hit_ratio), source: 'ewma', samples: metric.cache_samples };++ return { ratio: 0, source: metric && metric.cache_samples > 0 ? 'sparse' : 'absent', samples: metric ? metric.cache_samples : 0 };++}++++function labelFor(policy) { return POLICIES[policy] || policy || 'router'; }++++function reasonFor({ policy, chosen = null, samples = 0, sparse = true }) {++ const n = Math.max(0, Number(samples) || 0);++ const label = labelFor(policy);++ if (!chosen) return `${label}: no eligible candidate`;++ if (policy === 'cheapest') return sparse ? `${label}: lowest measured price, no measured success yet (n=${n})` : `${label}: lowest cost per success (n=${n})`;++ if (policy === 'fastest') return sparse ? `${label}: lowest measured TTFT, no measured success yet (n=${n})` : `${label}: lowest measured TTFT (n=${n})`;++ return sparse ? `${label}: cost and latency, no measured successful rate yet (n=${n})` : `${label}: cost and latency weighed (n=${n})`;++}++++/** The incumbent and the challenger's consecutive-decision streak, persisted (migrations/0026). */++function createIncumbents({ db, clock = { now: () => Date.now() }, decisions = 3 } = {}) {++ const decisionsNow = () => Math.max(1, Number(typeof decisions === 'function' ? decisions() : decisions) || 3);++ const select = db.prepare('SELECT * FROM routing_incumbents WHERE decision_key = ?');++ const upsert = db.prepare(`INSERT INTO routing_incumbents++ (decision_key, provider_key, model_key, challenger_provider, challenger_model, challenger_streak, updated_at)++ VALUES (@decision_key, @provider_key, @model_key, @challenger_provider, @challenger_model, @challenger_streak, @updated_at)++ ON CONFLICT(decision_key) DO UPDATE SET++ provider_key = excluded.provider_key, model_key = excluded.model_key,++ challenger_provider = excluded.challenger_provider, challenger_model = excluded.challenger_model,++ challenger_streak = excluded.challenger_streak, updated_at = excluded.updated_at`);++ const idOf = (p, m) => offerKey(p, m);++ const split = (id) => {++ const i = String(id || '').indexOf(':');++ return i < 0 ? { provider: String(id || ''), model: '' } : { provider: String(id).slice(0, i), model: String(id).slice(i + 1) };++ };++ const stamp = () => new Date(clock.now()).toISOString();++++ /**++ * planAi chose `chosen` under the configured minGain. The incumbent is replaced only after `decisions`++ * consecutive choices of the same challenger; a challenger that goes away resets the streak.++ */++ async function apply(key, chosenId, inPool) {++ const row = await select.get(String(key));++ if (!row || !row.provider_key) {++ const chosen = split(chosenId);++ await upsert.run({ decision_key: String(key), provider_key: chosen.provider, model_key: chosen.model,++ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });++ return { incumbent: chosenId, chosen: chosenId, switched: true, initial: true, streak: 0, decisions: decisionsNow() };++ }++ const incumbentId = idOf(row.provider_key, row.model_key || '');++ if (!inPool.has(incumbentId)) {++ const chosen = split(chosenId);++ await upsert.run({ decision_key: String(key), provider_key: chosen.provider, model_key: chosen.model,++ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });++ return { incumbent: chosenId, chosen: chosenId, switched: true, initial: false, failover: true, streak: 0, decisions: decisionsNow() };++ }++ if (chosenId === incumbentId) {++ await upsert.run({ decision_key: String(key), ...row, provider_key: row.provider_key, model_key: row.model_key,++ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });++ return { incumbent: incumbentId, chosen: chosenId, switched: false, initial: false, streak: 0, decisions: decisionsNow() };++ }++ const same = row.challenger_provider === split(chosenId).provider && (row.challenger_model || '') === split(chosenId).model;++ const streak = same ? Number(row.challenger_streak || 0) + 1 : 1;++ if (streak >= decisionsNow()) {++ const chosen = split(chosenId);++ await upsert.run({ decision_key: String(key), provider_key: chosen.provider, model_key: chosen.model,++ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });++ return { incumbent: chosenId, chosen: chosenId, switched: true, initial: false, streak: 0, decisions: decisionsNow() };++ }++ const challenger = split(chosenId);++ await upsert.run({ decision_key: String(key), provider_key: row.provider_key, model_key: row.model_key,++ challenger_provider: challenger.provider, challenger_model: challenger.model, challenger_streak: streak, updated_at: stamp() });++ return { incumbent: incumbentId, chosen: incumbentId, switched: false, initial: false, streak, decisions: decisionsNow() };++ }++++ async function get(key) {++ const row = await select.get(String(key));++ if (!row || !row.provider_key) return null;++ return { provider: row.provider_key, model: row.model_key || '', id: idOf(row.provider_key, row.model_key || ''), streak: Number(row.challenger_streak) || 0 };++ }++++ return { apply, get, decisions: decisionsNow };++}++++function createRoutingEvidence({ db, config = {}, clock = { now: () => Date.now() }, metrics, bench = null, resolveOffer, random = Math.random, log = console } = {}) {++ // D11 owner settings: the feature flag and its parameters are read live, so an ai.exchange change applies from++ // the next decision (the pool gates record/choose on enabled()).++ let minSamples, alpha, floor, prior, affinityGain, minGain, decisions, share, expectedOutput;++ function refreshCfg() {++ const cfg = config.routingEvidence || config || {};++ minSamples = Math.max(1, Number(cfg.minSamples) || 20);++ alpha = cfg.alpha;++ floor = Math.max(0.01, Math.min(1, Number(cfg.successFloor) || 0.1));++ prior = Math.max(0.01, Math.min(1, Number(cfg.prior) || 0.9));++ affinityGain = Math.max(0, Math.min(1, Number(cfg.affinityGain) || 0.05));++ minGain = Math.max(0, Math.min(1, cfg.minGain != null ? Number(cfg.minGain) : Number(config.placement && config.placement.minGain) || 0.15));++ decisions = Math.max(1, Number(cfg.hysteresisDecisions) || 3);++ share = Math.max(0, Math.min(1, Number(cfg.explorationShare) || 0));++ expectedOutput = Math.max(1, Number(cfg.expectedOutputTokens) || 512);++ }++ refreshCfg();++ const incumbents = createIncumbents({ db, clock, decisions: () => decisions });++ const store = metrics || require('./metrics').createProviderMetrics({ db, clock, alpha: () => alpha });++++ async function record(attempt) { refreshCfg(); return await store.record(attempt); }++++ function promptTokensOf(req) {++ const tokens = tokensOf(leadingText(req || {}));++ return Math.max(MIN_PROMPT_TOKENS, tokens);++ }++++ function outputTokensOf(req) {++ const wanted = Number(req && req.maxTokens);++ return Math.max(1, Math.floor(Number.isFinite(wanted) && wanted > 0 ? wanted : expectedOutput));++ }++++ function optOut(ctx = {}, prefs = {}) {++ return Boolean(ctx.exact || ctx.credential || ctx.routePin || ctx.exploration === false++ || (prefs && (prefs.only_my_keys === true || prefs.exploration === false))++ || (Array.isArray(ctx.selfCredentials) && ctx.selfCredentials.length));++ }++++ /**++ * Prepare one candidate's planner inputs from the retail card, the measured row and Bench's exact-id++ * evidence. `resolveOffer` is the pool's own catalogue lookup, so no second catalogue is read here.++ */++ async function prepare(candidate, { promptTokens, outputTokens, metricsById = null }) {++ refreshCfg();++ const offer = await resolveOffer(candidate);++ if (!offer) return null;++ const metric = (metricsById ? metricsById.get(offer.id) : (await store.all()).get(offer.id)) || null;++ const benchRow = offer.exactId && bench ? await bench.get(offer.exactId) : { status: 'absent', samples: 0, exact_id: offer.exactId || null };++ const evidence = combineEvidence({ metric, bench: benchRow, minSamples, prior });++ const ttft = measuredTtft({ metric, bench: benchRow, minSamples });++ const decode = measuredDecode({ metric, bench: benchRow, minSamples });++ const cache = measuredCache({ metric, minSamples });++ const rates = nanoRatesOf({ free: offer.free, retail: offer.retail, cards: offer.cards });++ if (!rates) return { ...offer, metric, bench: benchRow, evidence, ttft, decode, cache, rates: null, priceNano: null, costPerSuccess: null };++ const priceNano = priceAfterCacheNano({ promptTokens, outputTokens, cacheHitRatio: cache.ratio, rates });++ const blended = blendedInputRateNano({ promptTokens, cacheHitRatio: cache.ratio, rates });++ const plannerRate = { ...rates, input_nano_usd_per_m: blended, cached_read_nano_usd_per_m: '0' };++ return { ...offer, metric, bench: benchRow, evidence, ttft, decode, cache, rates, plannerRate, priceNano, costPerSuccess: null };++ }++++ function policyOrder(policy, prepared, weights, warmId = null, affinityGain = 0.05) {++ const key = (p) => (p.costPerSuccess == null ? Infinity : Number(p.costPerSuccess));++ const maxCost = Math.max(0, ...prepared.map(key).filter(Number.isFinite));++ const maxTtft = Math.max(0, ...prepared.map((p) => (p.ttft.ms == null ? 0 : p.ttft.ms)));++ const tps = (p) => (p.decode.tps == null ? -1 : p.decode.tps);++ const costScore = (p) => (maxCost ? key(p) / maxCost : 0);++ const ttftScore = (p) => (p.ttft.ms == null ? 1 : p.ttft.ms / (maxTtft || 1));++ for (const p of prepared) {++ p.score = policy === 'cheapest' ? key(p)++ : policy === 'fastest' ? (p.ttft.ms == null ? Infinity : p.ttft.ms)++ : costScore(p) * weights.cost + ttftScore(p) * weights.latency;++ }++ const sorted = [...prepared].sort((a, b) => {++ if (policy === 'cheapest') {++ if (key(a) !== key(b)) return key(a) - key(b);++ } else if (policy === 'fastest') {++ const ta = a.ttft.ms == null ? Infinity : a.ttft.ms;++ const tb = b.ttft.ms == null ? Infinity : b.ttft.ms;++ if (ta !== tb) return ta - tb;++ if (tps(a) !== tps(b)) return tps(b) - tps(a);++ } else {++ if (a.score !== b.score) return a.score - b.score;++ if (tps(a) !== tps(b)) return tps(b) - tps(a);++ }++ if (key(a) !== key(b)) return key(a) - key(b);++ return String(a.id).localeCompare(String(b.id));++ });++ // Cache affinity (L36 Y7): the deployment that served the same prompt prefix last stays first while it++ // is within the configured margin of the policy's best. It is a preference, not a gate.++ const warm = warmId ? sorted.find((p) => p.id === warmId) : null;++ if (warm && sorted[0] !== warm) {++ const best = sorted[0].score;++ const gap = warm.score - best;++ const within = policy === 'fastest'++ ? Number.isFinite(best) && gap <= best * affinityGain++ : Number.isFinite(best) && best >= 0 && gap <= Math.max(best * affinityGain, affinityGain * 1e-9);++ if (within) return { sorted: [warm, ...sorted.filter((p) => p !== warm)], affinity: true };++ }++ return { sorted, affinity: false };++ }++++ /**++ * The router's evidence decision for one alias request. Returns the full order and the explain view.++ * `list` is the router's already-eligible order; `ctx.exact` is never passed here.++ */++ async function choose({ routeKey = null, policy, list = [], req = {}, ctx = {}, warmDeployment = null, preferred = null } = {}) {++ refreshCfg();++ const preparedRaw = [];++ const promptTokens = promptTokensOf(req);++ const outputTokens = outputTokensOf(req);++ const metricsById = await store.all();++ for (const c of list) {++ if (!c || c.provider === 'stub') continue;++ const prepared = await prepare(c, { promptTokens, outputTokens, metricsById });++ if (prepared) preparedRaw.push(prepared);++ }++ const prepared = preparedRaw.filter((p) => p.plannerRate && p.priceNano != null);++ const plannedIds = new Set(prepared.map((p) => p.id));++ const unplanned = list.filter((c) => c && (c.provider === 'stub' || !plannedIds.has(offerKey(c.provider, c.model || ''))))++ .map((c) => ({ provider: c.provider, model: c.model || null }));++ for (const p of prepared) {++ const eff = p.evidence.samples >= minSamples && p.evidence.p_success != null ? p.evidence.p_success : prior;++ p.pSuccess = eff;++ p.costPerSuccess = costPerSuccessNano(p.priceNano, eff, floor);++ }++ if (!prepared.length) {++ return {++ order: list,++ view: {++ policy, objective: OBJECTIVE[policy] || 'balanced', reason: `${labelFor(policy)}: no candidate has a retail rate`,++ chosen: null, incumbent: null, switched: false, streak: 0, decisions,++ prompt_tokens: promptTokens, output_tokens: outputTokens, cache_hit_ratio: 0, cache_evidence: 'absent',++ bench: { status: 'absent', samples: 0, minimum: minSamples },++ exploration: { share, used: false }, candidates: [],++ },++ };++ }++ const weights = { cost: Number(config.placement && config.placement.costWeight) || 0.5, latency: Number(config.placement && config.placement.latencyWeight) || 0.5 };++ const warmId = warmDeployment && warmDeployment.provider ? offerKey(warmDeployment.provider, warmDeployment.model || '') : null;++ const ordered = policyOrder(policy, prepared, weights, warmId, affinityGain);++ const sorted = ordered.sorted;++ const decisionKey = `${String(routeKey || (ctx && ctx.routeKey) || 'alias')}|${policy}`;++ const inPool = new Set(prepared.map((p) => p.id));++ const incumbentRow = await incumbents.get(decisionKey);++ const incumbentId = incumbentRow && inPool.has(incumbentRow.id) ? incumbentRow.id : null;++++ // planAi is the one planner: hard gates already passed, its job here is the measured cost per success,++ // the sparse marking and the hysteresis margin. Cache affinity is a tie-break applied to the order.++ const objective = OBJECTIVE[policy] || 'balanced';++ const zero = { input_nano_usd_per_m: '0', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', output_nano_usd_per_m: '0', per_request_nano_usd: '0' };++ const plannerRates = {};++ const candidates = prepared.map((p) => {++ plannerRates[p.id] = policy === 'fastest' ? zero : p.plannerRate;++ // Fast is a strict p50 TTFT order: encode the decode-tiebreak as a sub-millisecond part so planAi's++ // latency comparison keeps TTFT primary and decode tokens/s second.++ const latency = p.ttft.ms == null ? null : (policy === 'fastest' && p.decode.tps != null ? p.ttft.ms + (1 / p.decode.tps) * 1e-6 : p.ttft.ms);++ return {++ id: p.id, deployment_id: p.id, model_id: p.exactId || p.id,++ model: { id: p.exactId || p.id }, provider_access: p.access || null, paid_managed: false,++ status: 'ready', latency_ms: latency, retail_rate: plannerRates[p.id],++ };++ });++ const evidenceMap = {};++ for (const p of prepared) if (p.evidence.evidence) evidenceMap[p.id] = p.evidence.evidence;++ const planned = prepared.length ? placement.planAi(++ { objective, capabilities: [], paid_managed: false },++ candidates,++ { enabled: true, now: clock.now(), current: incumbentId, minGain, usage: { input_tokens: promptTokens, output_tokens: outputTokens },++ evidence: evidenceMap, rates: plannerRates, pFloor: floor, prior, sparseSampleFloor: minSamples },++ ) : { chosen: null, explain: { candidates: [] } };++ let chosenId = planned.chosen || (sorted[0] && sorted[0].id) || null;++ const decision = await incumbents.apply(decisionKey, chosenId, inPool);++ const keeperId = decision.incumbent || chosenId;++ const chosen = prepared.find((p) => p.id === keeperId) || sorted[0] || null;++++ // Order: the incumbent first (hysteresis), then the policy order. Exploration may put one non-incumbent++ // first for exactly one request; it never changes the incumbent or the streak.++ // Cache affinity can put the warm deployment first even when the incumbent is kept for hysteresis;++ // it stays within the margin. Otherwise the incumbent comes first.++ const warmFirst = ordered.affinity ? sorted[0] : null;++ let order = sorted.map((p) => p.id);++ if (warmFirst) order = [warmFirst.id, ...order.filter((id) => id !== warmFirst.id)];++ else if (chosen) order = [chosen.id, ...order.filter((id) => id !== chosen.id)];++ let exploration = null;++ if (share > 0 && !optOut(ctx, ctx.selfPreferences) && sorted.length > 1 && random() < share) {++ const pick = sorted.find((p) => p.id !== keeperId);++ if (pick) {++ exploration = { share, provider: pick.provider, model: pick.model };++ order = [pick.id, ...order.filter((id) => id !== pick.id)];++ }++ }++ // L37: a soft preference still leads, as it does in the existing router; evidence ranks the rest.++ const preferredOffer = preferred++ ? prepared.find((p) => (!preferred.provider || p.provider === preferred.provider) && (!preferred.model || p.model === preferred.model))++ : null;++ if (preferredOffer) {++ order = [preferredOffer.id, ...order.filter((id) => id !== preferredOffer.id)];++ exploration = null;++ }++ const orderAsCandidates = order.map((id) => prepared.find((p) => p.id === id)).filter(Boolean).map((p) => ({ provider: p.provider, model: p.model }));++ const first = orderAsCandidates[0] || null;++ const candidatesView = sorted.map((p) => {++ const plannerCand = planned.explain && (planned.explain.candidates || []).find((c) => c.id === p.id);++ return {++ provider: p.provider, model: p.model,++ expected_cost_nano_usd: formatNanoUsd(p.priceNano),++ price_after_cache_nano_usd: formatNanoUsd(p.priceNano),++ cost_per_success_nano_usd: p.costPerSuccess == null ? null : formatNanoUsd(p.costPerSuccess),++ p_success: Number((plannerCand ? plannerCand.p_success : p.pSuccess).toFixed(6)),++ samples: p.evidence.samples,++ sparse_evidence: plannerCand ? Boolean(plannerCand.sparse_evidence) : p.evidence.sparse,++ evidence_source: plannerCand ? plannerCand.evidence_source : p.evidence.source,++ cache_hit_ratio: p.cache.ratio,++ cache_evidence: p.cache.source,++ cache_samples: p.metric ? p.metric.cache_samples : 0,++ timeout_rate: p.metric ? p.metric.timeout_rate : (p.bench && p.bench.timeout_rate != null ? p.bench.timeout_rate : null),++ metric_samples: p.metric ? p.metric.sample_count : 0,++ ttft_ms: p.ttft.ms,++ ttft_p50_ms: p.ttft.source === 'bench_p50' ? p.ttft.ms : null,++ ttft_source: p.ttft.source,++ measured_success_rate: p.metric ? p.metric.success_rate : (p.bench && p.bench.success_rate != null ? p.bench.success_rate : null),++ decode_tokens_per_s: p.decode.tps,++ decode_source: p.decode.source,++ bench: { status: p.bench.status, samples: p.bench.samples || 0, minimum: minSamples },++ selected: first ? p.id === offerKey(first.provider, first.model || '') : false,++ };++ });++ const actual = first ? prepared.find((p) => p.id === offerKey(first.provider, first.model || '')) : chosen;++ const chosenBench = actual ? actual.bench : { status: 'absent', samples: 0 };++ const reason = reasonFor({ policy, chosen, samples: chosen ? chosen.evidence.samples : 0, sparse: chosen ? chosen.evidence.sparse : true });++ const view = {++ policy, objective,++ reason: warmFirst ? `${reason}; cache affinity` : (exploration ? `${reason}; exploring one alternative` : reason),++ affinity: warmFirst && !preferredOffer ? { provider: warmFirst.provider, model: warmFirst.model, within_margin: true } : null,++ chosen: first ? offerKey(first.provider, first.model || '') : (chosen ? chosen.id : null), incumbent: decision.incumbent, switched: decision.switched,++ streak: decision.streak, decisions: decision.decisions,++ prompt_tokens: promptTokens, output_tokens: outputTokens,++ cache_hit_ratio: actual ? actual.cache.ratio : 0, cache_evidence: actual ? actual.cache.source : 'absent',++ bench: { status: chosenBench.status || 'absent', samples: chosenBench.samples || 0, minimum: minSamples },++ exploration: exploration ? { ...exploration, used: true } : { share, used: false },++ preferred: preferredOffer ? { provider: preferredOffer.provider, model: preferredOffer.model } : null,++ candidates: candidatesView,++ };++ return { order: [...orderAsCandidates, ...unplanned], view };++ }++++ return { record, choose, prepare, incumbents, store, enabled: () => Boolean(config.routingEvidence && config.routingEvidence.enabled), minSamples, alpha, floor, prior, share, decisions };++}++++module.exports = {++ createRoutingEvidence, createIncumbents, nanoRatesOf, priceAfterCacheNano, blendedInputRateNano,++ combineEvidence, measuredTtft, measuredDecode, measuredCache, reasonFor,++ POLICIES, OBJECTIVE, MIN_PROMPT_TOKENS,++};@@@ -33,7 +33,11 @@ const { createHttpSeamProvider } = require('./http');const { createWhisperProvider } = require('./whisper');const rates = require('./rate-cards');const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');++const modelCatalog = require('./model-catalog');const { createWarmCache } = require('./warm-cache');++const { createProviderMetrics } = require('./metrics');++const { createVariantEvidence } = require('./bench-variant');++const { createRoutingEvidence } = require('./evidence');/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */function callerFault(err) {@@@ -130,13 +134,42 @@ function selfModelFor(cred, operation, req, ctx, prefs) {return { model, kind, why };}--function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {++function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null, variantEvidence = null }) {// A person's own machine as their model (credential provider node, server/machines.js).const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });const cache = new Map(); // key -> { stamp, adapter }const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)// L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).const warm = createWarmCache({ now: () => clock.now() });++ // AI Exchange P7: measured per-deployment evidence for alias routing. The feature flag is read live (D11++ // owner settings), so turning routing_evidence on or off in the config store needs no restart; while it is off++ // no metrics are written and no order changes.++ const routingEvidence = createRoutingEvidence({++ db, config, clock, log,++ metrics: createProviderMetrics({ db, clock, alpha: () => (config.routingEvidence && config.routingEvidence.alpha) }),++ bench: variantEvidence || createVariantEvidence({ config, fetchImpl, clock, log }),++ resolveOffer: async (c) => {++ const entry = await adapter(c.provider);++ const p = entry && entry.record;++ if (!p) return null;++ const model = c.model || p.default_model || null;++ const row = model ? await registry.getModel(p.key, model) : null;++ const artifact = row ? modelCatalog.artifactOf(row, p) : null;++ return {++ id: offerId(p.key, model), provider: p.key, model,++ exactId: artifact ? artifact.id : null,++ access: modelCatalog.accessOf(p),++ free: isFreeProvider(p),++ retail: row ? modelCatalog.retailRate(row) : null,++ cards: isFreeProvider(p) ? null : await rateCardsFor(p.key, model),++ };++ },++ });++ /** Evidence writes must never fail a run: a metrics problem is logged and routing continues. */++ async function recordEvidence(attempt) {++ if (!routingEvidence || !routingEvidence.enabled()) return;++ try { await routingEvidence.record(attempt); } catch (err) { log.warn(`[routing-evidence] ${String(err && err.message || err).slice(0, 200)}`); }++ }function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }@@@ -263,6 +296,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (ctx.freeOnly && !isFreeProvider(p)) return false;return true;}++ /**++ * AI Exchange D3: may the network route paid, managed work to this offer? With the catalog flag on, only an++ * owned deployment or an authorized wholesale agreement may be resold; UNVERIFIED (the default), RESTRICTED and++ * CUSTOMER_BYOK offers stay out of the pool. A person's own key and their own machine never pass through here.++ */++ function accessOffered(p) {++ if (!(config.modelCatalog && config.modelCatalog.enabled)) return true;++ return Boolean(p) && modelCatalog.managedAccessAllowed(p);++ }/** Operations served only by registered models of one type (never a provider's default chat model). */const MODEL_TYPE = { embed: 'embedding', transcribe: 'stt', image: 'image' };const offerId = (provider, model) => (model ? `${provider}:${model}` : provider);@@@ -294,6 +336,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst rateCards = [];const cardsByProvider = {};const seen = new Set();++ const accessExcluded = [];++ // A dynamic policy alias (D3) looks at every active chat/vision model the provider registered, not only its++ // default; the placement and the task router then choose among them. Auto keeps today's one-offer-per-provider.++ const expandModels = Boolean(ctx.aliasPolicy && ctx.aliasPolicy !== 'auto') && !(route.constraints && route.constraints.model);const addOffer = async (p, model, caps) => {const id = offerId(p.key, model);if (seen.has(id)) return;@@@ -327,12 +373,21 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (!appOffered(ctx, p)) continue;if (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;++ if (!accessOffered(p)) {++ accessExcluded.push({ provider: p.key, model: (route.constraints && route.constraints.model) || p.default_model || null, eligible: false,++ excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });++ continue;++ }if (MODEL_TYPE[operation]) {// A chat model advertised by an OpenAI-shaped provider is not an embedding, STT or image model.const type = MODEL_TYPE[operation];for (const m of await registry.listModels({ provider: p.key })) {if (m.type === type && m.status === 'active') await addOffer(p, m.model_key, caps);}++ } else if (expandModels) {++ const models = (await registry.listModels({ provider: p.key })).filter((m) => m.status === 'active' && (m.type === 'chat' || m.type === 'vision'));++ if (models.length) for (const m of models) await addOffer(p, m.model_key, caps);++ else await addOffer(p, p.default_model || null, caps);} else {await addOffer(p, (route.constraints && route.constraints.model) || p.default_model || null, caps);}@@@ -346,6 +401,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (!appOffered(ctx, p)) continue;if (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;++ if (!accessOffered(p)) {++ accessExcluded.push({ provider: p.key, model: pin.model || p.default_model || null, eligible: false,++ excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });++ continue;++ }const declared = await offeredCapabilities(p);const caps = route.capability && !declared.includes(route.capability) ? [...declared, route.capability] : declared;await addOffer(p, pin.model || p.default_model || null, caps);@@@ -383,6 +443,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noexcluded_reason: x.excluded_because || null, cost: x.estimated_cost_usd == null ? null : x.estimated_cost_usd, latency: x.estimated_latency_ms == null ? null : x.estimated_latency_ms };}),};++ // An offer the commercial gate kept out is named with its reason, never silently missing from the explain.++ for (const x of accessExcluded) if (!explain.candidates.some((c) => c.provider === x.provider && c.model === x.model)) explain.candidates.push(x);const order = [];if (result.selected) order.push(result.selected);const rest = result.candidates.filter(x => x.id !== result.selected).sort((a, b) => {@@@ -408,11 +470,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst p = entry && entry.record;const why = await skipReason(p, entry && entry.adapter, features);const offered = appOffered(ctx, p);++ const access = accessOffered(p);++ const excluded = !offered ? (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only')++ : !access ? `provider_access ${modelCatalog.accessOf(p)}` : why;explain.candidates.push({-- provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: offered && !why,-- excluded_reason: offered ? why : (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only'), cost: null, latency: null,++ provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: offered && access && !why,++ excluded_reason: excluded, cost: null, latency: null,});-- if (offered) order.push({ provider: c.provider, model: c.model || null });++ if (offered && access) order.push({ provider: c.provider, model: c.model || null });}return { order, explain };}@@@ -504,7 +569,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no* then fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength* tier first. The explain says what the task was and why every candidate is there.*/-- async function rankCandidates({ list, explain, task, route = null, warmProvider = null, preferred = null, ceiling = null, tierFirst = null, keepOrder = false }) {++ async function rankCandidates({ list, explain, task, route = null, warmProvider = null, preferred = null, ceiling = null, tierFirst = null, keepOrder = false, policy = null }) {const byKey = new Map((explain.candidates || []).map((c) => [c.provider + ':' + (c.model || ''), c]));const authority = route && route.constraints ? (route.constraints.authority || null) : null;const entries = [];@@@ -533,7 +598,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}const ranked = keepOrder? { order: entries.map((e) => e.candidate), ranked: entries.slice(0, 5) }-- : rank(entries, { task, warm: warmProvider, preferred, authority, tierFirst });++ : rank(entries, { task, warm: warmProvider, preferred, authority, tierFirst, policy });const order = keepOrder ? ranked.order : [...ranked.order.map((e) => ({ provider: e.provider, model: e.model })), ...rest];if (stub) order.push(stub);const view = ranked.ranked.map((e) => ({ provider: e.provider, model: e.model, why: reasonFor(e, task), cost: Number.isFinite(e.price) ? e.price : null, tier: e.tier }));@@@ -580,6 +645,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no];const tools = completing ? machineTools(o.tools) : null;const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };++ // A person's own machine is their own capacity: CUSTOMER_BYOK, never a managed resale.++ const ownFacts = { resolved_model: `${cred.provider}/${model}`, revision: null, deployment: cred.providerKey, provider_access: 'CUSTOMER_BYOK', rate_version: null, policy: 'byok', fallback_reason: null };++ if (typeof ctx.onRoute === 'function') await ctx.onRoute(ownFacts);const t0 = Date.now();try {const result = await machines.chat({ machine: cred.machine, subject: cred.subject, model, messages, maxTokens: req.maxTokens, temperature: req.temperature, signal: ctx.signal, timeoutMs: Math.max(5000, Math.min(req.timeoutMs || 120000, 300000)),@@@ -596,7 +664,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.nopaid_by: 'your machine',selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),-- attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }] } };++ attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }],++ openvibe: ownFacts } };} catch (err) {if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');@@@ -642,12 +711,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noattempts: [{ key: paidBy, provider: cred.provider, model, why: 'your own key', status: 'ok', error: null }],};const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));++ // The person's own key (BYOK) is never a managed resale offer: the disclosure says CUSTOMER_BYOK.++ const ownFacts = { resolved_model: `${cred.provider}/${model}`, revision: null, deployment: cred.providerKey, provider_access: 'CUSTOMER_BYOK', rate_version: null, policy: 'byok', fallback_reason: null };const call = messaging ? a.messages : completing ? a.complete : a.chat;let streamed = false;const onDelta = (completing || messaging) && typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;let lastErr = null;for (let attempt = 0; attempt <= config.providerRetries; attempt++) {if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');++ if (typeof ctx.onRoute === 'function') await ctx.onRoute(ownFacts);const t0 = Date.now();try {const result = await call({ ...req, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });@@@ -659,7 +731,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.notokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });}return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [],-- startedAt: t0, explain: { ...explain, selected: `${cred.provider}/${result.model || model}` } };++ startedAt: t0, explain: { ...explain, selected: `${cred.provider}/${result.model || model}`, openvibe: ownFacts } };} catch (err) {lastErr = err;if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');@@@ -811,7 +883,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}}-- let { order: list, explain } = await candidates(route, features, operation, ctx);++ // CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of++ // that id, filtered first by commercial access and what the request needs; no other model is ever added.++ const exact = ctx.exact || null;++ const policy = ctx.policy || (exact ? 'exact' : null);++ let list;++ let explain;++ if (exact) {++ const candidatesView = [];++ const usable = [];++ let payerSkipped = false;++ for (const offer of exact.offers || []) {++ const p = await registry.getProvider(offer.provider);++ const entry = p ? await adapter(offer.provider) : null;++ const row = offer.model ? await registry.getModel(offer.provider, offer.model) : null;++ const missing = hardNeeds(capabilitiesOf(row, p), task);++ const why = [];++ const card = row ? modelCatalog.retailRate(row) : null;++ if (!p) why.push('unknown_provider');++ else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }++ else {++ if (!accessOffered(p)) why.push(`provider_access ${modelCatalog.accessOf(p)}`);++ const skip = await skipReason(p, entry && entry.adapter, features);++ if (skip) why.push(skip);++ }++ if (missing.length) why.push(...missing);++ const reason = why.length ? [...new Set(why)].join(',') : null;++ candidatesView.push({ provider: offer.provider, model: offer.model, eligible: !reason, excluded_reason: reason, cost: null, latency: null });++ if (!reason) usable.push({ provider: offer.provider, model: offer.model });++ }++ explain = { objective: 'exact', policy: 'exact', reasons: [`exact model ${exact.id}: only its own deployment may answer`], selected: null, candidates: candidatesView };++ if (!usable.length) {++ if (payerSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} needs a Balance payer (a person, or an app with its project); it was not run`, { explain });++ throw new AiError(400, 'ai.model_unavailable', `the artifact ${exact.id} cannot run this request`, { explain });++ }++ list = usable;++ } else {++ ({ order: list, explain } = await candidates(route, features, operation, ctx));++ if (policy) explain.policy = policy;++ }// L36 Y3: free_first puts the free tiers ahead of metered ones for this person's fallback. Own-key attempts above// are already free to them, so this only reorders the network.if (prefs.free_first && ctx.selfSubject) {@@@ -825,18 +935,23 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}// L36 Y7: pick for the task. The hard needs first (a model that cannot do the job is never tried), then// fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength tier.-- const warmProvider = warm.get(warmSubject(ctx, route), leadingText(req));++ const warmDeployment = warm.getDeployment(warmSubject(ctx, route), leadingText(req));++ const warmProvider = warmDeployment ? warmDeployment.provider : null;// L36 Y3: with free_first on, the free tiers lead (the price decides); with it off, the strength tier// leads and the free tiers are the failover, not the first choice.const freeFirst = ctx.selfSubject ? prefs.free_first !== false : true;-- const ranked = await rankCandidates({-- list, explain, task, route, warmProvider,-- preferred: preferredModelFor(prefs, operation, req, ctx),-- ceiling: prefs.price_ceiling_usd_per_mtok,-- tierFirst: task.weight === 'heavy' || !freeFirst,-- // A pinned route keeps its own order: the pin is the admin's decision, not the router's.-- keepOrder: !(route.capability && operation),-- });++ const ranked = exact++ // The exact id's own deployment order is already the only order there is: never re-rank in another model.++ ? { order: list, ranked: [], reasons: new Map(), keepOrder: true }++ : await rankCandidates({++ list, explain, task, route, warmProvider,++ preferred: preferredModelFor(prefs, operation, req, ctx),++ ceiling: prefs.price_ceiling_usd_per_mtok,++ tierFirst: task.weight === 'heavy' || !freeFirst,++ policy,++ // A pinned route keeps its own order: the pin is the admin's decision, not the router's.++ keepOrder: !(route.capability && operation),++ });list = ranked.order;explain.task = task;explain.ranked = ranked.ranked;@@@ -850,7 +965,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (check.missing.length) preferState.why = check.missing.includes('no_vision') ? 'the model you named cannot see' : 'the model you named cannot do this job';}if (ranked.keepOrder) {-- preferState.why = 'this route is pinned; the pin decides';++ preferState.why = exact ? 'the exact model is the pin; nothing else may answer' : 'this route is pinned; the pin decides';} else {const at = list.findIndex((c) => preferMatches(c.provider, c.model, prefer));if (at > 0) {@@@ -862,6 +977,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}explain.prefer = preferState;}++ // AI Exchange P7: an alias route (economy/fast/balanced/auto) is ranked by measured evidence when++ // AI_ROUTING_EVIDENCE is on. planAi (openvibe-sdk/placement) is the planner: this only prepares its++ // inputs, keeps the incumbent for N decisions and explains the answer. Private never leaves local++ // capacity, and an exact id never reaches here.++ if (routingEvidence && routingEvidence.enabled() && ctx.aliasPolicy && ctx.aliasPolicy !== 'private' && !exact) {++ const picked = await routingEvidence.choose({ routeKey: route.key, policy: ctx.aliasPolicy, list, req, ctx, warmDeployment, preferred: prefer });++ if (picked && picked.view) {++ list = picked.order;++ explain.evidence = picked.view;++ explain.policy = ctx.aliasPolicy;++ explain.reasons = [picked.view.reason, ...(explain.reasons || [])];++ }++ }++ const evidenceReason = explain.evidence ? explain.evidence.reason : null;const attempts = [...selfAttempts];explain.paid_by = 'openvibe';explain.attempts = attempts;@@@ -869,7 +998,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// A first-party service's generate run may name one offer from GET /api/v1/routes. The run then// tries that offer alone: no fallback to another model, so the caller gets the model it asked for// or an explicit failure. server/runs.js validates the pin against this same offer list first.-- if (ctx.routePin && operation === 'generate') {++ if (ctx.routePin && operation === 'generate' && !exact) {const pin = ctx.routePin;const chosen = list.find((c) => c.provider === pin.provider && (pin.model == null || c.model === pin.model));if (!chosen) {@@@ -881,6 +1010,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// L36 Y7: the fallback chain stops after three providers.const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;let attemptsMade = 0;++ candidate:for (let i = 0; i < list.length; i++) {const c = list[i];const entry = await adapter(c.provider);@@@ -904,6 +1034,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.nocontinue;}}++ // AI Exchange F: a run with no payer (no person and no app project) must not be served a paid managed++ // offer that carries a versioned retail card: nobody could be charged for it. Offers without a card keep++ // today's behaviour, as do free and prepaid ones.++ if (ctx.creditsRequired && p && !isFreeProvider(p)) {++ const row = model ? await registry.getModel(p.key, model) : null;++ const card = row ? modelCatalog.retailRate(row) : null;++ if (card && card.version) {++ statFor(c.provider).skipped++;++ tried.push({ provider: c.provider, skipped: 'payer_required' });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: 'payer_required' });++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'payer_required' });++ continue;++ }++ }const why = await skipReason(p, a, features);if (why) {statFor(c.provider).skipped++;@@@ -929,16 +1073,40 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst started = clock.now();const t0 = Date.now();attemptStart = t0;++ // The disclosure is known before the call starts, so a streamed answer can carry it as its first++ // SSE comment without waiting for the run to finish (server/api/{openai,messages}.js).++ const attemptFacts = await modelCatalog.facts({++ registry, providerRecord: p, model,++ policy: policy || (exact ? 'exact' : null),++ policyReason: evidenceReason,++ fallbackReason: fallback ? `the first eligible offer did not answer; a later offer on this same route answered${evidenceReason ? ` (${evidenceReason})` : ''}` : null,++ });++ // The hold needs the offer's retail card, whether it is prepaid, and the real output cap.++ const attemptRow = p && model ? await registry.getModel(p.key, model) : null;++ const attemptMeta = { provider: p ? p.key : null, model, maxTokens: req.maxTokens, paid: !isFreeProvider(p), retail: modelCatalog.retailRate(attemptRow) };++ const routed = typeof ctx.onRoute === 'function' ? await ctx.onRoute(attemptFacts, attemptMeta) : null;++ if (routed && routed.skip) {++ statFor(c.provider).skipped++;++ tried.push({ provider: c.provider, skipped: routed.skip });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: routed.skip });++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: routed.skip });++ continue candidate;++ }++ // max_cost_usd, when it binds, comes back as the affordable output cap for this attempt.++ const callMax = routed && Number.isSafeInteger(routed.maxTokens) && routed.maxTokens >= 1 && routed.maxTokens !== req.maxTokens ? routed.maxTokens : null;++ const callReq = callMax == null ? req : { ...req, maxTokens: callMax };try {const fn = a[operation];if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });statFor(c.provider).calls += p.kind === 'stub' ? 0 : 1; // stub counts its own calls-- const result = await fn({ ...req, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });++ const result = await fn({ ...callReq, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });if (operation === 'embed' && (!Array.isArray(result.vectors) || result.vectors.length !== req.input.length|| result.vectors.some(v => !Array.isArray(v) || !v.length || v.some(n => typeof n !== 'number' || !Number.isFinite(n)))))throw new ProviderError('embedding response has invalid vectors', { code: 'provider.output' });const latency = Date.now() - t0;const usage = result.usage || { input: 0, output: 0, cached: 0 };++ await recordEvidence({ provider: c.provider, model, ok: true, timeout: false, ttftMs, latencyMs: latency,++ outputTokens: usage.output || 0, inputTokens: usage.input || 0, cachedTokens: usage.cached || 0 });// An image is metered per image (usage.output = images), and no rate card prices one yet (an owner/T5// item): its cost is 0 rather than the image count read as output tokens on the token cards.const cost = operation === 'image' ? 0 : await costOf(c.provider, result.model || model, usage);@@@ -961,8 +1129,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.nopreferState.why = 'the ' + c.provider + ' offer answered, as you asked';}attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: chosen || (fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route'), status: 'ok', error: null });-- warm.remember(warmSubject(ctx, route), leadingText(req), c.provider);++ warm.remember(warmSubject(ctx, route), leadingText(req), c.provider, model);explain.attempts = attempts;++ explain.openvibe = attemptFacts;return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, ttftMs, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };} catch (err) {lastErr = err;@@@ -970,6 +1139,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (ctx.logRequest) ctx.logRequest({ ...base, status: 'cancelled', latency_ms: Date.now() - t0, error: 'cancelled' });throw new AiError(409, 'run.cancelled', 'run was cancelled');}++ await recordEvidence({ provider: c.provider, model, ok: false, timeout: Boolean(err && err.code === 'provider.timeout'), latencyMs: Date.now() - t0 });statFor(c.provider).failures++;if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${String(err && err.message || err).slice(0, 200)}`, { tried: [...tried, { provider: c.provider, error: String(err && err.message || err).slice(0, 200) }], explain });@@@ -1000,7 +1170,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.nothrow new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });}-- return { adapter, health, resetHealth, execute, executeWithCredential, offers, benchView, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates, machines };++ return { adapter, health, resetHealth, execute, executeWithCredential, offers, benchView, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates, machines, routingEvidence };}module.exports = { createProviderPool, machineMessages, machineTools };nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++/**++ * AI Exchange P7: per-deployment observed performance (PLAN §D "ObservedPerformance", §E), behind++ * AI_ROUTING_EVIDENCE.++ *++ * Every attempt the router already makes updates one EWMA row per deployment (provider:model): TTFT, decode++ * tokens/s, success rate, timeout rate and the cache-hit ratio. The row is written to provider_metrics++ * (migrations/0026) so a restart keeps the measurements; the counts are kept apart, so a streamed answer that++ * has a TTFT and a non-streamed one that has none do not dilute each other.++ *++ * The arithmetic is deliberately simple and honest: alpha is the configured weight of the new observation++ * (default 0.2). A metric with no observations stays null and is reported as unmeasured, never as zero.++ */++const { iso } = require('../util');++++/** EWMA: alpha is the new observation's weight; the first observation is the value itself. */++function ewma(prev, value, alpha) {++ if (!Number.isFinite(Number(value))) return prev == null ? null : Number(prev);++ if (prev == null || !Number.isFinite(Number(prev))) return Number(value);++ const a = Math.max(0, Math.min(1, Number(alpha)));++ return a * Number(value) + (1 - a) * Number(prev);++}++++const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));++const offerKey = (provider, model) => `${provider}:${model || ''}`;++++/** Decode tokens per second: output tokens over the time between the first token and the answer's end. */++function decodeTps({ ttftMs, latencyMs, outputTokens }) {++ if (ttftMs == null || latencyMs == null || outputTokens == null) return null;++ const ttft = Number(ttftMs);++ const latency = Number(latencyMs);++ const output = Number(outputTokens);++ if (!Number.isFinite(ttft) || !Number.isFinite(latency) || !Number.isFinite(output)) return null;++ const decodeMs = latency - ttft;++ if (decodeMs <= 0 || output <= 0) return null;++ return output / (decodeMs / 1000);++}++++function createProviderMetrics({ db, clock = { now: () => Date.now() }, alpha = 0.2 } = {}) {++ // alpha may be a function: the AI Exchange setting is read live (D11), so an owner change applies to the next attempt.++ const alphaNow = () => (typeof alpha === 'function' ? alpha() : alpha);++ const selectAll = db.prepare('SELECT * FROM provider_metrics');++ const selectOne = db.prepare('SELECT * FROM provider_metrics WHERE provider_key = ? AND model_key = ?');++ const upsert = db.prepare(`INSERT INTO provider_metrics++ (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate,++ cache_hit_ratio, cache_samples, sample_count, updated_at)++ VALUES (@provider_key, @model_key, @ttft_ms, @ttft_samples, @decode_tps, @decode_samples, @success_rate, @timeout_rate,++ @cache_hit_ratio, @cache_samples, @sample_count, @updated_at)++ ON CONFLICT(provider_key, model_key) DO UPDATE SET++ ttft_ms = excluded.ttft_ms, ttft_samples = excluded.ttft_samples,++ decode_tps = excluded.decode_tps, decode_samples = excluded.decode_samples,++ success_rate = excluded.success_rate, timeout_rate = excluded.timeout_rate,++ cache_hit_ratio = excluded.cache_hit_ratio, cache_samples = excluded.cache_samples,++ sample_count = excluded.sample_count, updated_at = excluded.updated_at`);++++ function view(row) {++ if (!row) return null;++ return {++ provider: row.provider_key,++ model: row.model_key,++ ttft_ms: row.ttft_ms == null ? null : Number(row.ttft_ms),++ ttft_samples: Number(row.ttft_samples) || 0,++ decode_tps: row.decode_tps == null ? null : Number(row.decode_tps),++ decode_samples: Number(row.decode_samples) || 0,++ success_rate: Number(row.success_rate) || 0,++ timeout_rate: Number(row.timeout_rate) || 0,++ cache_hit_ratio: Number(row.cache_hit_ratio) || 0,++ cache_samples: Number(row.cache_samples) || 0,++ sample_count: Number(row.sample_count) || 0,++ updated_at: row.updated_at || null,++ };++ }++++ async function all() {++ const map = new Map();++ for (const row of await selectAll.all()) map.set(offerKey(row.provider_key, row.model_key), view(row));++ return map;++ }++++ async function get(provider, model) {++ return view(await selectOne.get(String(provider), String(model || '')));++ }++++ /**++ * One attempt. `ok` and `timeout` always update; TTFT, decode and the cache ratio update only when the++ * attempt actually measured them (a null never becomes a zero observation).++ */++ async function record({ provider, model = '', ok = false, timeout = false, ttftMs = null, latencyMs = null,++ outputTokens = 0, inputTokens = 0, cachedTokens = 0 } = {}) {++ if (!provider) return null;++ const prev = view(await selectOne.get(String(provider), String(model || '')));++ const samples = (prev ? prev.sample_count : 0) + 1;++ const next = {++ provider_key: String(provider), model_key: String(model || ''),++ ttft_ms: prev ? prev.ttft_ms : null,++ ttft_samples: prev ? prev.ttft_samples : 0,++ decode_tps: prev ? prev.decode_tps : null,++ decode_samples: prev ? prev.decode_samples : 0,++ success_rate: ewma(prev ? prev.success_rate : null, ok ? 1 : 0, alphaNow()) || 0,++ timeout_rate: ewma(prev ? prev.timeout_rate : null, timeout ? 1 : 0, alphaNow()) || 0,++ cache_hit_ratio: prev && prev.cache_samples > 0 ? prev.cache_hit_ratio : null,++ cache_samples: prev ? prev.cache_samples : 0,++ sample_count: samples,++ updated_at: iso(clock.now()),++ };++ const ttft = ttftMs == null || ttftMs === '' ? null : Number(ttftMs);++ if (ttft != null && Number.isFinite(ttft) && ttft >= 0) {++ next.ttft_ms = ewma(next.ttft_ms, ttft, alphaNow());++ next.ttft_samples += 1;++ }++ const tps = decodeTps({ ttftMs, latencyMs, outputTokens });++ if (tps != null) {++ next.decode_tps = ewma(next.decode_tps, tps, alphaNow());++ next.decode_samples += 1;++ }++ const input = Number(inputTokens);++ const cached = Number(cachedTokens);++ if (Number.isFinite(input) && input > 0 && Number.isFinite(cached) && cached >= 0) {++ next.cache_hit_ratio = clamp01(ewma(next.cache_hit_ratio, clamp01(cached / input), alphaNow()));++ next.cache_samples += 1;++ }++ await upsert.run(next);++ return next;++ }++++ return { record, all, get };++}++++module.exports = { createProviderMetrics, ewma, decodeTps, offerKey };nnew file mode 100644@@@ -0,0 +1,242 @@++'use strict';++/**++ * The truthful model catalog (AI Exchange P4, CHECKPOINT D3/D4), behind AI_MODEL_CATALOG.++ *++ * A registry model row becomes an `ai.model@1` catalog entry: its legacy provider/model fields, plus an exact id++ * `<org>/<model>[:<quant>]@<revision>` and the artifact facts (family, variant, quant, revision, sha256, license,++ * capabilities, modalities, retail rate, availability) when the row's metadata names them. Nothing is invented: a++ * row without an artifact revision has no exact id and can only be reached through an alias or the legacy model.++ *++ * aliases the dynamic policies (openvibe/auto, economy, balanced, fast, private; legacy openvibe)++ * entries({ registry }) the registry's real (non-synthetic) models in the ai.model@1 shape++ * exactOffers({ registry, id }) the provider offers of one exact artifact (never another model)++ * select({ registry, model, route })++ * a request's `model`: an alias turns into the same route with the alias policy's++ * placement constraints; an exact id returns only its own deployments (never a substitute)++ * facts({ registry, providerRecord, model, policy, policyReason, fallbackReason })++ * one `openvibe` disclosure: resolved_model, revision, deployment, provider_access,++ * rate_version, policy, policy_reason, fallback_reason++ *++ * Commercial access is the provider offer's `provider_access` (migrations/0024), defaulting to UNVERIFIED. Paid++ * managed routing (the network's providers, never a person's own key) accepts only OWN_DEPLOYMENT and++ * AUTHORIZED_WHOLESALE; RESTRICTED, CUSTOMER_BYOK and UNVERIFIED offers are never resold as managed capacity.++ *++ * The ai.model@1 catalog shape is CHECKPOINT D4 (Contracts 0.284.0); apps call with ai.app.run (D8'), and the++ * route disclosure joins x_openvibe (D3').++ */++const { AiError } = require('../util');++++const ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];++const MANAGED_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE'];++const EXACT_ID = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?@[a-zA-Z0-9._-]+$/;++++/** The dynamic policy aliases (D3). Legacy `openvibe` is `openvibe/auto` under its old name. */++const ALIASES = [++ { id: 'openvibe', legacy: 'openvibe', policy: 'auto', name: 'OpenVibe AI', description: 'The legacy name of openvibe/auto: the balanced router picks the model per request and says which one answered.' },++ { id: 'openvibe/auto', policy: 'auto', name: 'Auto', description: 'Picks among the eligible models per request; the response says which model answered and why.' },++ { id: 'openvibe/economy', policy: 'cheapest', name: 'Economy', description: 'The lowest cost per successful answer among the eligible models.' },++ { id: 'openvibe/balanced', policy: 'balanced', name: 'Balanced', description: 'Cost, health and measured latency weighed together.' },++ { id: 'openvibe/fast', policy: 'fastest', name: 'Fast', description: 'The quickest measured offer among the eligible models.' },++ { id: 'openvibe/private', policy: 'private', name: 'Private', description: 'Only OpenVibe-run or local capacity; never an external provider.' },++].map((a) => ({++ ...a,++ object: 'model',++ type: 'chat',++ dynamic: true,++ capabilities: ['chat', 'json', 'stream'],++ modalities: ['text'],++ availability: 'available',++}));++++const accessOf = (provider) => (provider && ACCESS.includes(provider.provider_access) ? provider.provider_access : 'UNVERIFIED');++const managedAccessAllowed = (provider) => MANAGED_ACCESS.includes(accessOf(provider));++++/** The artifact an exact id names, from a model row's metadata (or its own metadata.id). Null when unknown. */++function artifactOf(row, provider) {++ const meta = (row && row.metadata) || {};++ if (typeof meta.id === 'string' && EXACT_ID.test(meta.id)) {++ const m = /^([^/]+)\/([^:@]+)(?::([^@]+))?@(.+)$/.exec(meta.id);++ return { id: meta.id, org: m[1], model: m[2], quant: m[3] || null, revision: m[4] };++ }++ if (!meta.revision) return null;++ const key = String((row && row.model_key) || '');++ const at = key.lastIndexOf('@');++ const base = at > 0 ? key.slice(0, at) : key;++ const slash = base.indexOf('/');++ const org = meta.org || (slash > 0 ? base.slice(0, slash) : (provider && provider.key));++ const model = meta.model || (slash > 0 ? base.slice(slash + 1) : base);++ if (!org || !model) return null;++ const quant = meta.quant || null;++ const id = `${org}/${model}${quant ? `:${quant}` : ''}@${meta.revision}`;++ return EXACT_ID.test(id) ? { id, org, model, quant, revision: String(meta.revision) } : null;++}++++/** Integer nano-USD per million tokens from a USD-per-million price (D1); null when unpriced. */++function nanoPerMtok(usd) {++ if (usd == null || usd === '' || !Number.isFinite(Number(usd)) || Number(usd) < 0) return null;++ const n = Math.round(Number(usd) * 1e9);++ return Number.isSafeInteger(n) ? String(n) : null;++}++++function retailRate(row) {++ const cost = (row && row.cost) || {};++ const rate = {};++ const put = (key, value) => { if (value != null) rate[key] = value; };++ put('input_nano_usd_per_m', nanoPerMtok(cost.in_per_mtok));++ put('cached_read_nano_usd_per_m', nanoPerMtok(cost.cached_per_mtok));++ put('cache_write_nano_usd_per_m', nanoPerMtok(cost.cache_write_per_mtok));++ put('output_nano_usd_per_m', nanoPerMtok(cost.out_per_mtok));++ if (!Object.keys(rate).length) return null;++ rate.per_request_nano_usd = '0';++ const version = row.metadata && row.metadata.rate_version;++ if (typeof version === 'string' && version) rate.version = version;++ return rate;++}++++function modalitiesOf(row) {++ const type = (row && row.type) || 'chat';++ const vision = Boolean(row && row.supports && row.supports.vision) || type === 'vision';++ const seen = new Set(['text']);++ if (vision || type === 'image') seen.add('image');++ if (type === 'stt' || type === 'speech') { seen.add('audio'); }++ return [...seen];++}++++function availabilityOf(row, provider) {++ if (row && row.metadata && row.metadata.missing_since) return 'deprecated';++ if (!row || row.status !== 'active' || !provider || provider.status !== 'active') return 'unavailable';++ return 'available';++}++++/** One registry model as an ai.model@1 catalog entry. Legacy fields stay for readers of the old shape. */++function entryOf(row, provider) {++ const meta = (row && row.metadata) || {};++ const artifact = artifactOf(row, provider);++ const out = {++ provider_key: row.provider_key,++ model_key: row.model_key,++ display_name: row.display_name || row.model_key,++ type: row.type,++ status: row.status,++ context_window: row.context_window == null ? null : Number(row.context_window),++ max_output: row.max_output == null ? null : Number(row.max_output),++ cost: {++ in_per_mtok: row.cost.in_per_mtok == null ? null : Number(row.cost.in_per_mtok),++ out_per_mtok: row.cost.out_per_mtok == null ? null : Number(row.cost.out_per_mtok),++ cached_per_mtok: row.cost.cached_per_mtok == null ? null : Number(row.cost.cached_per_mtok),++ ...(row.cost.cache_write_per_mtok == null ? {} : { cache_write_per_mtok: Number(row.cost.cache_write_per_mtok) }),++ },++ supports: { ...row.supports },++ metadata: meta,++ created_at: row.created_at || null,++ updated_at: row.updated_at || null,++ availability: availabilityOf(row, provider),++ capabilities: { tools: Boolean(row.supports.tools), json: Boolean(row.supports.json), vision: Boolean(row.supports.vision), stream: Boolean(row.supports.streaming) },++ modalities: modalitiesOf(row),++ };++ if (artifact) {++ out.id = artifact.id;++ out.revision = artifact.revision;++ if (artifact.quant) out.quant = artifact.quant;++ if (meta.family) out.family = meta.family;++ if (meta.variant) out.variant = meta.variant;++ if (typeof meta.sha256 === 'string' && /^[a-f0-9]{64}$/.test(meta.sha256)) out.sha256 = meta.sha256;++ if (meta.license) out.license = meta.license;++ }++ if (Number(row.context_window) > 0) out.context = Number(row.context_window);++ const retail = retailRate(row);++ if (retail) out.retail_rate = retail;++ return out;++}++++/** Every non-synthetic registry model as a catalog entry, with its provider record. */++async function entries({ registry }) {++ const providers = new Map((await registry.listProviders()).map((p) => [p.key, p]));++ const out = [];++ for (const row of await registry.listModels()) {++ const provider = providers.get(row.provider_key);++ if (!provider || provider.kind === 'stub' || (provider.metadata && provider.metadata.synthetic)) continue;++ out.push({ row, provider, entry: entryOf(row, provider) });++ }++ return out;++}++++/** Model ids an exact-id request may run: one entry per provider offer of that artifact. */++async function exactOffers({ registry, id }) {++ return (await entries({ registry })).filter((x) => x.entry.id === id);++}++++/**++ * Resolve a request's model:++ * an alias → { kind:'alias', alias, policy, route } with the route's placement constraints adjusted in a copy++ * an exact → { kind:'exact', id, offers:[{provider, model, row, providerRecord}] } (active deployments only)++ * anything else → ai.model_not_found; an id that exists but has no usable deployment → ai.model_unavailable.++ */++async function select({ registry, model, route }) {++ const requested = String(model == null ? '' : model).trim();++ const alias = ALIASES.find((a) => a.id === requested);++ if (alias) {++ const constraints = { ...((route && route.constraints) || {}) };++ if (alias.policy === 'cheapest') { constraints.objective = 'cheapest'; delete constraints.authority; }++ else if (alias.policy === 'fastest') { constraints.objective = 'lowest-latency'; delete constraints.authority; }++ else if (alias.policy === 'balanced') { constraints.objective = 'balanced'; delete constraints.authority; }++ else if (alias.policy === 'private') {++ constraints.objective = 'private';++ constraints.provider_scope = 'local';++ // placement's `private` objective defaults to first-party trust, while the providers file classes every++ // local offer `community` (server/providers/index.js). Name both local trust classes explicitly; the local++ // scope above still keeps every external offer out, which is the alias's promise.++ if (!Array.isArray(constraints.trust) || !constraints.trust.length) constraints.trust = ['first-party', 'community'];++ delete constraints.authority;++ }++ return { kind: 'alias', requested, alias, policy: alias.policy, route: { ...route, constraints } };++ }++ if (!EXACT_ID.test(requested)) {++ throw new AiError(404, 'ai.model_not_found', `no model ${requested || '(none)'}: send an exact id <org>/<model>[:<quant>]@<revision> or one of ${ALIASES.map((a) => a.id).join(', ')}`);++ }++ const found = await exactOffers({ registry, id: requested });++ if (!found.length) throw new AiError(404, 'ai.model_not_found', `no artifact ${requested}`);++ const usable = found.filter((x) => x.row.status === 'active' && x.provider.status === 'active');++ if (!usable.length) throw new AiError(400, 'ai.model_unavailable', `the artifact ${requested} has no active deployment`);++ return {++ kind: 'exact', requested, id: requested, policy: 'exact', route,++ offers: usable.map((x) => ({ provider: x.provider.key, model: x.row.model_key, row: x.row, providerRecord: x.provider })),++ };++}++++/** One `openvibe` disclosure object (D3). Unknown facts stay null, never invented. */++async function facts({ registry, providerRecord, model, policy = null, policyReason = null, fallbackReason = null }) {++ const provider = providerRecord || null;++ const row = provider && model ? await registry.getModel(provider.key, model) : null;++ const artifact = row ? artifactOf(row, provider) : null;++ const meta = (row && row.metadata) || {};++ return {++ resolved_model: artifact ? artifact.id : (provider && model ? `${provider.key}/${model}` : (model || null)),++ revision: artifact ? artifact.revision : (meta.revision ? String(meta.revision) : null),++ deployment: provider ? provider.key : null,++ provider_access: accessOf(provider),++ rate_version: meta.rate_version ? String(meta.rate_version) : null,++ policy: policy || null,++ ...(policyReason ? { policy_reason: String(policyReason).slice(0, 160) } : {}),++ fallback_reason: fallbackReason || null,++ };++}++++/** The `openvibe` object of a finished run (a run's own facts when the pool recorded them; otherwise from provenance). */++async function disclosureOf({ registry, run }) {++ const explain = (run && run.explain) || null;++ if (explain && explain.openvibe && typeof explain.openvibe === 'object') return explain.openvibe;++ const provenance = (run && run.provenance) || {};++ const provider = provenance.provider && !String(provenance.provider).startsWith('byo:') && !String(provenance.provider).startsWith('project:')++ ? await registry.getProvider(provenance.provider) : null;++ const base = await facts({ registry, providerRecord: provider, model: provenance.model || null, policy: explain && (explain.policy || explain.objective) || null });++ if (!base.deployment && provenance.provider) base.deployment = provenance.provider;++ if (provenance.cached_from) base.fallback_reason = `the answer was reused from run ${provenance.cached_from}; no provider was called`;++ else if (provenance.fallback_used) base.fallback_reason = 'the first eligible offer did not answer; a later offer on the same route answered';++ return base;++}++++module.exports = {++ ACCESS, MANAGED_ACCESS, ALIASES, EXACT_ID,++ accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate,++};@@@ -177,7 +177,7 @@ const tierRank = (tier) => (TIER_RANK[tier] === undefined ? TIER_RANK[DEFAULT_TI* A warm prompt cache is worth up to 10 % of the price, so a near tie goes to the provider that already* has this prompt cached (L36 Y7). The order is stable: two candidates that tie keep the pool's order.*/--function rank(candidates, { task, warm = null, preferred = null, authority = null, tierFirst = null, keepOrder = false } = {}) {++function rank(candidates, { task, warm = null, preferred = null, authority = null, tierFirst = null, keepOrder = false, policy = null } = {}) {const isAuthority = (c) => Boolean(authority && (authority === c.provider || authority === c.provider + ':' + (c.model || '')));const lead = tierFirst == null ? task.weight === 'heavy' : Boolean(tierFirst);const prepared = candidates.map((c, index) => {@@@ -189,6 +189,14 @@ function rank(candidates, { task, warm = null, preferred = null, authority = nulif (keepOrder) return a.index - b.index; // a pinned route keeps its own orderif (a.authority !== b.authority) return a.authority ? -1 : 1;if (preferred && (a.model === preferred) !== (b.model === preferred)) return a.model === preferred ? -1 : 1;++ // A dynamic policy alias (AI Exchange D3) names what it optimises; the pool's eligibility and the++ // person's preferences still come first, and a tie keeps the existing tier/health/latency order.++ if (policy === 'cheapest' && a.effective !== b.effective) return a.effective - b.effective;++ if (policy === 'fastest') {++ const la = a.latency == null ? Infinity : a.latency;++ const lb = b.latency == null ? Infinity : b.latency;++ if (la !== lb) return la - lb;++ }if (lead && a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;if (a.effective !== b.effective) return a.effective - b.effective;if (a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;@@@ -2,8 +2,9 @@/*** A tiny, in-process memory of the provider that last answered a prompt (L36 Y7: prefer the provider whose* prompt cache is already warm). The key is the person or app plus a hash of the first 2 KB of the leading-- * prompt; the value is the provider key; it expires after five minutes. Nothing is persisted, nothing leaves-- * the process, and the map is capped so it can never grow into a cache.++ * prompt; the value is the provider key and, when the caller knows it, the model that answered. It expires++ * after five minutes. Nothing is persisted, nothing leaves the process, and the map is capped so it can never++ * grow into a cache.*/const crypto = require('crypto');@@@ -15,24 +16,35 @@ const PROMPT_BYTES = 2048;const hashOf = (text) => crypto.createHash('sha256').update(String(text || '').slice(0, PROMPT_BYTES)).digest('hex');function createWarmCache({ ttlMs = DEFAULT_TTL_MS, max = DEFAULT_MAX, now = () => Date.now() } = {}) {-- const entries = new Map(); // key → { provider, at }++ const entries = new Map(); // key → { provider, model, at }-- function remember(subject, prompt, provider) {++ function remember(subject, prompt, provider, model = null) {if (!provider) return;const key = String(subject || 'anonymous') + '|' + hashOf(prompt);-- entries.set(key, { provider, at: now() });++ entries.set(key, { provider, model: model || null, at: now() });while (entries.size > max) entries.delete(entries.keys().next().value); // the oldest goes first}function get(subject, prompt) {++ const hit = entry(subject, prompt);++ return hit ? hit.provider : null;++ }++++ /** The deployment (provider + model) that last answered this prompt prefix, or null when it is cold. */++ function getDeployment(subject, prompt) {++ const hit = entry(subject, prompt);++ return hit ? { provider: hit.provider, model: hit.model || null } : null;++ }++++ function entry(subject, prompt) {const key = String(subject || 'anonymous') + '|' + hashOf(prompt);const hit = entries.get(key);if (!hit) return null;if (now() - hit.at > ttlMs) { entries.delete(key); return null; }-- return hit.provider;++ return hit;}-- return { remember, get, hashOf, size: () => entries.size, clear: () => entries.clear() };++ return { remember, get, getDeployment, hashOf, size: () => entries.size, clear: () => entries.clear() };}module.exports = { createWarmCache, hashOf, DEFAULT_TTL_MS, DEFAULT_MAX, PROMPT_BYTES };@@@ -31,6 +31,30 @@ const WINDOWS = { minute: 60, hour: 3600, day: 86400 };const EWMA_ALPHA = 0.2; // documented: 20 % of the new sample, 80 % of the historyconst LATENCY_BUCKETS_MS = [50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600];++// The exact id shape of D3: `<org>/<model>[:<quant>]@<revision>`.++const EXACT_ID = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?@[a-zA-Z0-9._-]+$/;++++/**++ * The usage series names a model with its exact id when the model row's metadata knows the artifact++ * (metadata.id, or metadata.revision with the org/model/quant facts), else `<provider>/<model_key>`.++ * Nothing is invented: a row without a revision has no exact id.++ */++function exactModelId(provider, modelKey, meta) {++ const m = meta || {};++ if (typeof m.id === 'string' && EXACT_ID.test(m.id)) return m.id;++ if (m.revision) {++ const key = String(modelKey || '');++ const at = key.lastIndexOf('@');++ const base = at > 0 ? key.slice(0, at) : key;++ const slash = base.indexOf('/');++ const org = m.org || (slash > 0 ? base.slice(0, slash) : provider);++ const model = m.model || (slash > 0 ? base.slice(slash + 1) : base);++ const id = `${org}/${model}${m.quant ? `:${m.quant}` : ''}@${m.revision}`;++ if (org && model && EXACT_ID.test(id)) return id;++ }++ return modelKey ? `${provider}/${modelKey}` : provider;++}++function latencyBucket(ms) {const i = LATENCY_BUCKETS_MS.findIndex((b) => ms <= b);return i === -1 ? LATENCY_BUCKETS_MS.length : i; // -1 → the overflow bucket@@@ -268,6 +292,71 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAllreturn await db.prepare(`SELECT * FROM usage_daily ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY day DESC, cost_usd DESC LIMIT 1000`).all(...args);}++ /**++ * Aggregate demand for the inference planner (Host's `HOST_INFERENCE_PLANNER_SOURCE=ai`), never++ * per person, app or project. One row per UTC day and provider/model:++ * requests, successes provider attempts (provider_stats_daily.requests / .ok), the same counts++ * the router rolls up; a retry is a second attempt++ * tokens.input tokens_in minus cache reads; cache writes are not stored apart on this++ * release, so they are still inside this number++ * tokens.cached_read usage_daily.tokens_cached, summed across every requester and attribution++ * tokens.cache_write 0: AI records cache-write tokens inside the whole prompt, not apart++ * tokens.output tokens_out++ *++ * `model` is the exact id when the model row's metadata names the artifact, else++ * `<provider>/<model_key>`. The response carries no caller id, prompt, answer, project or cost.++ */++ async function usageSeries({ days } = {}) {++ let n = 30;++ if (days !== undefined && days !== null && days !== '') {++ if (typeof days !== 'string' || !/^\d+$/.test(days)) throw new AiError(422, 'ai.invalid', 'days must be an integer from 1 to 90');++ n = Number(days);++ if (!Number.isSafeInteger(n) || n < 1 || n > 90) throw new AiError(422, 'ai.invalid', 'days must be between 1 and 90');++ }++ const end = new Date(clock.now());++ const to = end.toISOString().slice(0, 10);++ const from = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()) - (n - 1) * 86400000).toISOString().slice(0, 10);++ const rows = await db.prepare(`++ SELECT to_char(s.day, 'YYYY-MM-DD') AS day, s.provider, s.model,++ SUM(s.requests)::bigint AS requests, SUM(s.ok)::bigint AS successes,++ SUM(s.tokens_in)::bigint AS tokens_in, SUM(s.tokens_out)::bigint AS tokens_out,++ COALESCE(MAX(u.tokens_cached), 0)::bigint AS tokens_cached++ FROM provider_stats_daily s++ LEFT JOIN (++ SELECT day, provider_key, model_key,++ SUM(tokens_cached)::bigint AS tokens_cached++ FROM usage_daily WHERE day >= ? GROUP BY day, provider_key, model_key++ ) u ON u.day = to_char(s.day, 'YYYY-MM-DD') AND u.provider_key = s.provider AND u.model_key = s.model++ WHERE s.day >= ?++ GROUP BY s.day, s.provider, s.model++ ORDER BY s.day, s.provider, s.model`).all(from, from);++ const models = new Map((await registry.listModels()).map((m) => [`${m.provider_key}|${m.model_key}`, m]));++ return {++ days: n,++ from,++ to,++ note: 'cache_write is 0 on this release: AI records cache-write tokens inside the whole prompt, not apart',++ series: rows.map((r) => {++ const meta = (models.get(`${r.provider}|${r.model}`) || {}).metadata || {};++ const cached = Number(r.tokens_cached) || 0;++ return {++ day: r.day,++ model: exactModelId(r.provider, r.model, meta),++ provider: r.provider,++ model_key: r.model,++ requests: Number(r.requests) || 0,++ successes: Number(r.successes) || 0,++ tokens: {++ input: Math.max(0, (Number(r.tokens_in) || 0) - cached),++ cached_read: cached,++ cache_write: 0,++ output: Number(r.tokens_out) || 0,++ },++ };++ }),++ };++ }++async function counters(ctxLike) {const qs = ctxLike ? await applicable(ctxLike) : (await list()).filter(q => q.status === 'active' && (q.scope_type === 'global' || q.scope_id !== '*')).map(q => ({ q, cid: q.scope_id }));const t = nowSec();@@@ -292,7 +381,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAllreturn { max_cost_usd: Math.min(...rows.map((r) => r.max_cost_usd)), spent, window: 'day' };}-- return { reserve, account, statsFor, list, upsert, usage, counters, paidBudget, freeAllowance, WINDOWS };++ return { reserve, account, statsFor, list, upsert, usage, usageSeries, counters, paidBudget, freeAllowance, WINDOWS };}module.exports = { createQuotas, WINDOWS };@@@ -15,6 +15,8 @@ const { provenance } = require('./providers/rate-cards');const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];++// AI Exchange D3: who may resell this offer's capacity. Defaults to UNVERIFIED (migrations/0024).++const PROVIDER_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];const FEATURES = ['chat', 'complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image'];const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;@@@ -49,6 +51,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e// The billing profile and shared pool (migrations/0008): exposed on every provider response; pool_key// is a name, never a secret value.billing_profile: r.billing_profile || 'metered', pool_key: r.pool_key == null ? null : r.pool_key,++ provider_access: r.provider_access || 'UNVERIFIED',metadata: parseJson(r.metadata, {}), origin: r.origin, created_at: r.created_at, updated_at: r.updated_at,};}@@@ -70,6 +73,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.eif (!secretRefValid(p.secret_ref)) throw new AiError(422, 'ai.invalid', "secret_ref must be 'env:NAME' or 'vault:<provider key>' (a reference, never a secret value)");if (p.base_url && !/^https?:\/\/[^\s]+$/i.test(p.base_url)) throw new AiError(422, 'ai.invalid', 'base_url must be an http(s) URL');if (!BILLING_PROFILES.includes(p.billing_profile)) throw new AiError(422, 'ai.invalid', `billing_profile must be one of ${BILLING_PROFILES.join(', ')}`);++ if (!PROVIDER_ACCESS.includes(p.provider_access)) throw new AiError(422, 'ai.invalid', `provider_access must be one of ${PROVIDER_ACCESS.join(', ')}`);// metadata.billing_profile only seeds the column (upsertProvider), but it is refused here too so an// unknown value can never be stored as an inert, silently-ignored profile.if (p.metadata && p.metadata.billing_profile !== undefined && !BILLING_PROFILES.includes(p.metadata.billing_profile))@@@ -99,16 +103,18 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e// a later metadata edit can never silently re-route the provider.billing_profile: input.billing_profile || (prev && prev.billing_profile) || (input.metadata && input.metadata.billing_profile) || 'metered',pool_key: input.pool_key !== undefined ? (input.pool_key || null) : (prev ? prev.pool_key : null),++ // The offer's commercial access (D3): set on the record, never inferred from its billing profile.++ provider_access: input.provider_access || (prev && prev.provider_access) || 'UNVERIFIED',metadata: input.metadata || (prev && prev.metadata) || {},};checkProvider(p);const t = now();-- await db.prepare(`INSERT INTO providers (key, display_name, kind, status, base_url, auth_mode, secret_ref, default_model, capabilities, timeout_ms, priority, billing_profile, pool_key, metadata, origin, created_at, updated_at)-- VALUES (@key, @display_name, @kind, @status, @base_url, @auth_mode, @secret_ref, @default_model, @capabilities, @timeout_ms, @priority, @billing_profile, @pool_key, @metadata, @origin, @t, @t)++ await db.prepare(`INSERT INTO providers (key, display_name, kind, status, base_url, auth_mode, secret_ref, default_model, capabilities, timeout_ms, priority, billing_profile, pool_key, provider_access, metadata, origin, created_at, updated_at)++ VALUES (@key, @display_name, @kind, @status, @base_url, @auth_mode, @secret_ref, @default_model, @capabilities, @timeout_ms, @priority, @billing_profile, @pool_key, @provider_access, @metadata, @origin, @t, @t)ON CONFLICT(key) DO UPDATE SET display_name = excluded.display_name, kind = excluded.kind, status = excluded.status, base_url = excluded.base_url,auth_mode = excluded.auth_mode, secret_ref = excluded.secret_ref, default_model = excluded.default_model, capabilities = excluded.capabilities,timeout_ms = excluded.timeout_ms, priority = excluded.priority, billing_profile = excluded.billing_profile, pool_key = excluded.pool_key,-- metadata = excluded.metadata, origin = excluded.origin, updated_at = excluded.updated_at`)++ provider_access = excluded.provider_access, metadata = excluded.metadata, origin = excluded.origin, updated_at = excluded.updated_at`).run({ ...p, capabilities: JSON.stringify(p.capabilities), metadata: JSON.stringify(p.metadata), origin, t });await audit(actor, prev ? 'provider.update' : 'provider.create', 'provider', p.key, { trace, metadata: { kind: p.kind, status: p.status, base_url: p.base_url, secret_ref: p.secret_ref, origin } });return await getProvider(p.key);@@@ -419,4 +425,4 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e};}--module.exports = { createRegistry, PROVIDER_KINDS, FEATURES, LIFECYCLE };++module.exports = { createRegistry, PROVIDER_KINDS, FEATURES, LIFECYCLE, PROVIDER_ACCESS };@@@ -18,6 +18,7 @@*/const { ids } = require('openvibe-contracts');const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');++const { parseCostUsd } = require('./credits');const { CACHE_INPUT } = require('./workflows/hooks');const { preferenceLines } = require('./user-modules');const { preferredTarget } = require('./providers/task');@@@ -102,7 +103,7 @@ function routePinRequest(value, principal, wf) {return { provider, model };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }const queue = []; // run ids waiting for the workerlet active = 0;@@@ -183,7 +184,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now* principal: { sub, subject: {type, id} }* Returns { run, created, promise? }.*/-- async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null } = {}) {++ async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null } = {}) {if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');const wfKey = String(body.workflow || '');const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);@@@ -263,17 +264,30 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst refusal = await runBudgets.refuse(principal.jti);if (refusal) throw refusal;}++ // AI Exchange F: the caller's optional liability cap on a Balance hold (`max_cost_usd`). It is a request++ // field on the OpenAI and Messages routes and an input/option field on the direct ones; parsed as an exact++ // decimal into integer nano-USD, never through a float (0.000000001 is one nano, 0.0000000011 rounds up).++ const rawMaxCost = body.max_cost_usd != null ? body.max_cost_usd : options.max_cost_usd != null ? options.max_cost_usd : input.max_cost_usd;++ const maxCostNano = rawMaxCost == null ? null : parseCostUsd(rawMaxCost);const ctx = {requesterType: requester.type, requesterId: requester.id,...(principal.jti ? { runJti: principal.jti } : {}),actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,-- ...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox } : {}),++ ...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox, appId: principal.appId } : {}),++ // Who pays a Balance hold (D12): the app and its project, else the person a token acts for, else the service.++ onBehalfOf: personSubject || principal.onBehalfOf || null,++ // A credential or a project's own provider never reaches managed supply: no Balance hold for that run.++ ...(credentialSubject !== null || projectProvider ? { byok: true } : {}),++ ...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),...(pinned ? { routePin: pinned } : {}),// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only)....(typeof onDelta === 'function' ? { onDelta } : {}),++ // The offer the router picked, before the first token: the SSE disclosure's first comment (D3).++ ...(typeof onRoute === 'function' ? { onRoute } : {}),};const now = iso(clock.now());const id = newRunId();++ ctx.runId = id;const kept = retainedInput(input, wf, Boolean(config.debugRawLog && options.debug));const base = {id, workflow_key: wf.key, workflow_version: wf.version, template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null,@@@ -479,6 +493,40 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (p && p.metadata && p.metadata.allowance === 'network') return { paid_by: 'network_allowance', detail: name, provider: name };return { paid_by: 'openvibe_key', detail: name, provider: name };}++ /** The attempts a Balance settle reports, in billing.credit-hold-settle-request@1's exact attempt shape. */++ async function billingAttempts({ explain, logged, startedAt, endedAt }) {++ const source = explain && Array.isArray(explain.attempts) && explain.attempts.length ? explain.attempts : (logged || []);++ const out = [];++ for (const a of source) {++ if (!a) continue;++ const provider = a.provider || a.provider_key || null;++ if (!provider || a.status === 'skipped') continue;++ const model = a.model || a.model_key || null;++ let access = null;++ try { const p = await registry.getProvider(provider); access = p && credits ? credits.accessOf(p) : null; } catch { access = null; }++ const ok = a.status === 'ok' || a.status === 'succeeded';++ out.push({++ deployment_id: `${provider}:${model || ''}`,++ ...(access ? { provider_access: access } : {}),++ started_at: startedAt, ended_at: endedAt,++ error_code: ok ? null : String(a.error || a.error_code || a.status || 'error').slice(0, 120),++ });++ }++ return out;++ }++++ /** Settle the request's Balance hold once; a failure is logged, never allowed to fail an answer already made. */++ async function settleCredits(credit, { usage, costUsd, explain, logged = [], startedAt, endedAt }) {++ if (!credit || !credit.holdId) return null;++ try {++ const attempts = await billingAttempts({ explain, logged, startedAt, endedAt });++ return await credit.settle({ usage, supplyCostUsd: costUsd, attempts });++ } catch (err) {++ log.warn(`[credits] settle ${credit.holdId} failed; queued for retry (a 409/422 needs review): ${err && err.message}`);++ return null;++ }++ }++async function execute(id, { controller, reserved, scope, ctx, input, hold = null }) {const row = await getRow(id);if (!row || row.status !== 'queued') { inflight.delete(id); return; }@@@ -500,7 +548,30 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowcreated_at: iso(clock.now()),});};++ let credit = null;++ let billingDelta = null;++ let onRoute = null;try {++ // AI Exchange F: hold prepaid Balance before the first managed provider call (AI_CREDITS, off by++ // default). A run with no project to bill (a first-party service, a cache hit, BYOK) begins no session++ // and never touches Billing.++ if (credits && credits.enabled) {++ credit = credits.begin({ requestId: row.request_id || id, ctx, input, attribution: parseJson(row.attribution, null), onBehalfOf: ctx.onBehalfOf || null });++ // No payer (or no project) on a run that is not BYOK: the pool must not serve a paid managed offer++ // with a versioned card, because nobody could be charged for it.++ ctx.creditsRequired = !credit && !ctx.byok;++ }++ billingDelta = typeof ctx.onDelta === 'function'++ ? (credit ? (d) => { credit.observe(d); ctx.onDelta(d); } : ctx.onDelta)++ : null;++ // Balance holds first (a 402 must land before the provider), then the router's own disclosure hook.++ onRoute = (credit || typeof ctx.onRoute === 'function')++ ? async (facts, meta) => {++ const routed = credit ? await credit.onRoute(facts, meta) : null;++ if (typeof ctx.onRoute === 'function') await ctx.onRoute(facts, meta);++ return routed;++ }++ : null;// A run on a person's behalf follows their ai.preferences (Network user module): style, length and// perspective shape the prompt; history: false keeps neither their input nor a cache entry.const preferences = userModules && row.on_behalf_of ? await userModules.preferencesFor(row.on_behalf_of) : {};@@@ -526,13 +597,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowappRun: Boolean(ctx.appRun), freeOnly: Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled))),// Who asked, for a step that must not call the caller back (research: Search's own web ladder).requesterType: ctx.requesterType, requesterId: ctx.requesterId,++ // AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.++ creditsRequired: Boolean(ctx.creditsRequired),...(ctx.routePin ? { routePin: ctx.routePin } : {}),// L37: the soft preference the caller made (provider, and a model if it named one).prefer: opts.prefer || null,...(onCredentialFailed ? { onCredentialFailed } : {}),...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),-- ...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}) });++ ...(billingDelta ? { onDelta: billingDelta } : {}),++ ...(onRoute ? { onRoute } : {}) });if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');// The status, its citations and the ai.run.succeeded event commit together.const done = await db.tx(async () => {@@@ -546,6 +620,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowreturn d;});if (!done.changes) return; // cancelled while finishing++ // One settle for the request, with the four token counts, the answering card, the supply cost and++ // every attempt. A failure never turns the answer into an error: Billing expires an unsettled hold.++ if (credit) await settleCredits(credit, { usage: r.usage, costUsd: r.cost, explain: r.explain, logged, startedAt: row.started_at || iso(clock.now()), endedAt: iso(clock.now()) });// A self-key run has no reservation and no tier hold; if it fell back to the network, its usage is still// recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.const usedOwnKey = String(r.provider || '').startsWith('byo:self:');@@@ -574,6 +651,26 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (f.changes) await events.runChanged(await getRow(id));});const spent = await db.prepare("SELECT COALESCE(SUM(tokens_in),0)::bigint ti, COALESCE(SUM(tokens_out),0)::bigint tout, COALESCE(SUM(tokens_cached),0)::bigint tc, COALESCE(SUM(cost_usd),0) c FROM requests WHERE run_id = ? AND status = 'ok'").get(id);++ // Balance: nothing reached a provider (or it failed before the first token) releases the hold; an abort++ // or a failure after tokens streamed settles the partial usage it did produce.++ if (credit && credit.holdId) {++ try {++ const streamedOut = Math.ceil(credit.streamedChars / 4);++ const produced = Number(spent.ti) > 0 || Number(spent.tout) > 0 || streamedOut > 0 || logged.some((e) => e.status === 'ok');++ if (produced) {++ await settleCredits(credit, {++ usage: { input: Math.max(Number(spent.ti) || 0, credit.inputTokens), output: Math.max(Number(spent.tout) || 0, streamedOut), cached: Number(spent.tc) || 0, cache_write: 0 },++ costUsd: Number(spent.c) || 0,++ explain: err instanceof AiError && err.extra && err.extra.explain ? err.extra.explain : null, logged,++ startedAt: row.started_at || iso(clock.now()), endedAt: iso(clock.now()),++ });++ } else {++ await credit.release();++ }++ } catch (cers) {++ log.warn(`[credits] ${id} ${cers && cers.code || 'release/settle'}: ${cers && cers.message}`);++ }++ }// A run that broke still spent: its record says what it cost and who paid (L40).await publishUsage({ row: await getRow(id), outcome: cancelled ? 'cancelled' : 'failed',usage: { input: Number(spent.ti) || 0, output: Number(spent.tout) || 0, cached: Number(spent.tc) || 0 },nnew file mode 100644@@@ -0,0 +1,281 @@++'use strict';++/**++ * Owner settings (D11): AI's configuration on openvibe-shared/config.++ *++ * ai.exchange the AI Exchange's switches: the model catalogue, Billing Credits (danger) and the++ * routing-evidence flag with its parameters. Every key keeps today's environment variable++ * as its x-env pin, so the env wins while it is set and nothing changes until the owner sets++ * one; onActivate puts a change into the live config object.++ * ai.provider_keys the home of the owner's provider keys, replacing the provider_secrets vault. For each++ * provider `<p>.origin` is the origin of its base URL (AI writes it whenever a provider is++ * created or its base URL changes) and `<p>.key` is the secret, x-bind'ed to `<p>.origin`, so++ * a key can never be sent to a host it was not entered for.++ *++ * The provider pool resolves a key synchronously (server/vault.js holds the in-process cache), so the provider-key++ * store refreshes that cache in onActivate. The proxy below lets the factory rebuild the namespace's schema when a++ * provider is added, without remounting the admin routes.++ */++const { createConfigStore, ConfigError } = require('openvibe-shared/config');++const { AiError } = require('./util');++++const EXCHANGE_NAMESPACE = 'ai.exchange';++const PROVIDER_KEYS_NAMESPACE = 'ai.provider_keys';++const SERVICE = 'ai';++const SELF = Object.freeze({ type: 'service', id: SERVICE });++const GROUP = 'AI Exchange';++const PROVIDER_GROUP = 'Provider keys';++++/** Bounds from server/config.js (load()), so the schema and the code agree. */++const EXCHANGE_SCHEMA = Object.freeze({++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'AI Exchange',++ type: 'object',++ additionalProperties: false,++ properties: {++ model_catalog: {++ type: 'boolean', title: 'Model catalogue', 'x-group': GROUP, 'x-env': 'AI_MODEL_CATALOG',++ description: 'Serve the truthful model catalogue: exact ids, dynamic aliases and the offers each route can use. Off means today\'s single `openvibe` model.',++ },++ credits: {++ type: 'boolean', title: 'Credits', 'x-group': GROUP, 'x-env': 'AI_CREDITS', 'x-danger': true,++ description: 'Paid AI requests hold and spend Credits',++ },++ routing_evidence: {++ type: 'boolean', title: 'Routing evidence', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE',++ description: 'Rank alias routes by measured cost per success instead of their recorded order.',++ },++ alpha: {++ type: 'number', title: 'Evidence EWMA alpha', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_ALPHA',++ minimum: 0.01, maximum: 1,++ description: 'How fast a new measurement moves the per-deployment EWMA (0.01 keeps the most history, 1 trusts the last call).',++ },++ min_samples: {++ type: 'integer', title: 'Minimum samples', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_SAMPLES',++ minimum: 1,++ description: 'Below this many attempts evidence is sparse: it is reported, never trusted to reorder a route.',++ },++ success_floor: {++ type: 'number', title: 'Success floor', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_SUCCESS_FLOOR',++ minimum: 0.01, maximum: 1,++ description: 'A candidate below this success probability is never promoted by evidence.',++ },++ hysteresis_decisions: {++ type: 'integer', title: 'Hysteresis decisions', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_DECISIONS',++ minimum: 1,++ description: 'Consecutive decisions a challenger must win before it replaces the incumbent.',++ },++ exploration_share: {++ type: 'number', title: 'Exploration share', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_EXPLORE',++ minimum: 0, maximum: 1,++ description: 'The share of decisions that explore a challenger; 0 never leaves the incumbent.',++ },++ affinity_gain: {++ type: 'number', title: 'Affinity gain', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_AFFINITY',++ minimum: 0, maximum: 1,++ description: 'How much a deployment\'s familiarity with a prompt family adds to its measured score.',++ },++ min_gain: {++ type: 'number', title: 'Minimum gain', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_GAIN',++ minimum: 0, maximum: 1,++ description: 'A challenger replaces the incumbent only when it scores at least this much better.',++ },++ expected_output_tokens: {++ type: 'integer', title: 'Expected output tokens', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_OUTPUT_TOKENS',++ minimum: 1,++ description: 'The output-token estimate used to price a candidate before it answers.',++ },++ },++});++++/** The store's effective values put into the live config object (undefined keys keep the code/env default). */++function applyExchange(config, values = {}) {++ const has = (k) => Object.prototype.hasOwnProperty.call(values, k) && values[k] !== undefined;++ if (has('model_catalog')) config.modelCatalog.enabled = Boolean(values.model_catalog);++ if (has('credits')) config.credits.enabled = Boolean(values.credits);++ const re = config.routingEvidence;++ if (has('routing_evidence')) re.enabled = Boolean(values.routing_evidence);++ for (const key of ['alpha', 'min_samples', 'success_floor', 'hysteresis_decisions', 'exploration_share', 'affinity_gain', 'min_gain', 'expected_output_tokens']) {++ if (has(key)) re[key] = Number(values[key]);++ }++}++++/** The origin a provider key is bound to ('' when the base URL is missing or not http(s)). */++function originOf(baseUrl) {++ if (!baseUrl) return '';++ try {++ const u = new URL(String(baseUrl).trim());++ return u.protocol === 'https:' || u.protocol === 'http:' ? u.origin : '';++ } catch { return ''; }++}++++/** The namespace for the providers there are today: <p>.origin (internal) and <p>.key (secret, bound to it). */++function providerKeysSchema(providers) {++ const properties = {};++ for (const key of providers) {++ properties[`${key}.origin`] = {++ type: 'string', title: `${key} origin`, 'x-group': PROVIDER_GROUP,++ description: `The origin of ${key}'s base URL. AI writes it; the key below is sealed to it.`,++ };++ properties[`${key}.key`] = {++ type: 'string', title: `${key} key`, 'x-group': PROVIDER_GROUP, 'x-bind': `${key}.origin`,++ description: `The owner's write-only API key for ${key}, sealed to its origin; changing the base URL locks it until it is entered again.`,++ };++ }++ return { $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'AI provider keys', type: 'object', additionalProperties: false, properties };++}++++const classify = (key) => (String(key).endsWith('.key') ? 'secret' : 'internal');++++/**++ * The ai.provider_keys store, with the schema rebuilt when a provider is added. `store` is a Proxy so the admin++ * routes, mounted once, keep using the current store; the vault cache is refreshed in onActivate.++ */++function createProviderKeys({ db, registry, env = process.env, log = console, onChange }) {++ const vault = require('./vault');++ let store = null;++ let names = [];++ let queue = Promise.resolve();++ const serialized = (fn) => { const run = queue.then(fn, fn); queue = run.catch(() => {}); return run; };++++ /** onActivate: refresh the synchronous cache and rebuild the adapters of providers whose key changed. */++ async function activate(values, prev) {++ vault.refresh(values);++ const changed = new Set();++ for (const [k, v] of Object.entries(values || {})) {++ if (k.endsWith('.key') && (!prev || prev[k] !== v)) changed.add(k.slice(0, -4));++ }++ for (const k of Object.keys(prev || {})) if (k.endsWith('.key') && !(values && Object.prototype.hasOwnProperty.call(values, k))) changed.add(k.slice(0, -4));++ for (const key of changed) {++ if (!await registry.getProvider(key)) continue;++ // The pool's adapter cache stamps itself with updated_at: bumping it rebuilds the adapter with the new key.++ await registry.upsertProvider({ key }, { actor: `service:${SERVICE}`, origin: 'settings' }).catch((err) => log.warn(`[config] ${PROVIDER_KEYS_NAMESPACE}: ${key} adapter refresh failed: ${err.message}`));++ }++ }++++ async function rebuild() {++ const providers = await registry.listProviders();++ const next = [...new Set(providers.map((p) => p.key))].sort();++ if (store && next.length === names.length && next.every((k, i) => k === names[i])) return store;++ store = await createConfigStore({++ db, service: SERVICE, namespace: PROVIDER_KEYS_NAMESPACE, schema: providerKeysSchema(next),++ classify, env, log, onChange, onActivate: activate,++ });++ names = next;++ vault.refresh(store.get());++ return store;++ }++++ const proxy = new Proxy({}, {++ get(_target, prop) {++ if (prop === 'namespace') return PROVIDER_KEYS_NAMESPACE;++ if (prop === 'service') return SERVICE;++ if (prop === 'then') return undefined;++ if (prop === 'setSecret') return async (key, value, o) => {++ try { return await setKey(String(key).replace(/\.key$/, ''), value, o); }++ catch (err) { throw err instanceof ConfigError ? err : new ConfigError(err.code || 'config.secret_invalid', err.message, { status: err.status || 422 }); }++ };++ if (!store) throw new AiError(503, 'config.unavailable', 'the provider keys store is not ready');++ const value = store[prop];++ return typeof value === 'function' ? value.bind(store) : value;++ },++ });++++ async function refresh() { return serialized(rebuild); }++++ async function ensure(providerKey) {++ if (!names.includes(providerKey)) await rebuild();++ if (!names.includes(providerKey)) throw new AiError(422, 'ai.invalid', `add the provider ${providerKey} before its key`);++ }++++ function setOrigin(providerKey, baseUrl, { actor = SELF, reason = 'provider origin' } = {}) {++ return serialized(async () => {++ await ensure(providerKey);++ const origin = originOf(baseUrl);++ if (!origin) return '';++ const key = `${providerKey}.origin`;++ const changes = await store.changes({ [key]: origin }, { merge: true });++ if (changes.length) await store.apply({ [key]: origin }, { merge: true, actor, reason });++ return origin;++ });++ }++++ /** A provider key: write-only, sealed and bound to the provider's current origin. */++ function setKey(providerKey, value, { actor = SELF, reason = 'provider key' } = {}) {++ return serialized(async () => {++ const provider = await registry.getProvider(providerKey);++ if (!provider) throw new AiError(422, 'ai.invalid', `add the provider ${providerKey} before its key`);++ await ensure(providerKey);++ const origin = originOf(provider.base_url);++ if (!origin) throw new AiError(422, 'ai.invalid', `${providerKey} has no base URL to bind its key to`);++ const originKey = `${providerKey}.origin`;++ if ((await store.changes({ [originKey]: origin }, { merge: true })).length) {++ await store.apply({ [originKey]: origin }, { merge: true, actor: SELF, reason: 'provider origin' });++ }++ return await store.setSecret(`${providerKey}.key`, value, { actor, reason });++ });++ }++++ function deleteKey(providerKey, o = {}) {++ return serialized(async () => {++ if (!names.includes(providerKey)) return null;++ return await store.deleteSecret(`${providerKey}.key`, o);++ });++ }++++ /** Write every provider's current origin (boot, and a provider created or moved by the API). */++ function syncOrigins({ actor = SELF, reason = 'provider origin' } = {}) {++ return serialized(async () => {++ await rebuild();++ const values = {};++ for (const p of await registry.listProviders()) {++ const origin = originOf(p.base_url);++ if (origin) values[`${p.key}.origin`] = origin;++ }++ const changes = await store.changes(values, { merge: true });++ if (changes.length) await store.apply(values, { merge: true, actor, reason });++ return changes.length;++ });++ }++++ return {++ proxy,++ refresh,++ setOrigin,++ setKey,++ deleteKey,++ syncOrigins,++ namespace: PROVIDER_KEYS_NAMESPACE,++ get sealed() { return Boolean(store && store.sealed); },++ get store() { return store; },++ get providers() { return [...names]; },++ };++}++++/**++ * Create both namespaces (the DDL is migrations/0028_config.sql), apply ai.exchange to the live config object,++ * load the provider-key cache and reseal plaintext/old-KEK rows once when OV_CONFIG_KEKS is set.++ */++async function createSettings({ db, config, registry, clock = { now: () => Date.now() }, env = process.env, log = console, onExchange = null }) {++ const onChange = async ({ namespace, keys, actor, revision }) => {++ const who = actor ? `${actor.type}:${actor.id}` : 'system';++ await registry.audit(who, 'config.change', 'config', namespace, { metadata: { service: SERVICE, namespace, keys, actor: who, revision } });++ };++ const exchange = await createConfigStore({++ db, service: SERVICE, namespace: EXCHANGE_NAMESPACE, schema: EXCHANGE_SCHEMA, env, log, onChange,++ onActivate: (values) => {++ applyExchange(config, values);++ return onExchange ? onExchange(values) : undefined;++ },++ });++ applyExchange(config, exchange.get());++ const providerKeys = createProviderKeys({ db, registry, env, log, onChange });++ await providerKeys.refresh();++ if (exchange.sealed) await exchange.reseal().catch((err) => log.warn(`[config] ${EXCHANGE_NAMESPACE}: reseal failed: ${err.message}`));++ if (providerKeys.sealed) await providerKeys.store.reseal().catch((err) => log.warn(`[config] ${PROVIDER_KEYS_NAMESPACE}: reseal failed: ${err.message}`));++ return { exchange, providerKeys, onChange };++}++++module.exports = {++ EXCHANGE_NAMESPACE, PROVIDER_KEYS_NAMESPACE, SERVICE, SELF, EXCHANGE_SCHEMA,++ applyExchange, originOf, providerKeysSchema, createSettings,++};@@@ -1,11 +1,16 @@'use strict';/**-- * Provider keys an owner adds from the operator console (migrations/0010_provider_secrets.sql). A provider whose-- * secret_ref is 'vault:<provider key>' (util.resolveSecret) reads its key from here.++ * Provider keys, now kept in the ai.provider_keys configuration namespace (D11) instead of the provider_secrets++ * table. The owner's key is a write-only secret-class config value bound to the provider's origin, so a key can++ * never be sent to a host it was not entered for.*-- * At rest each key is AES-256-GCM encrypted with AI_CREDENTIALS_KEY, bound to its provider key (the additional data).-- * The decrypted keys are held in this process only (load() at boot and after every change), because the provider pool-- * resolves secrets synchronously; nothing here logs, returns or audits a value — only a four-character hint.++ * The provider pool resolves a key synchronously (util.resolveSecret → get), so this module still holds the++ * in-process cache: the config store's onActivate refreshes it (server/settings.js). Nothing here logs, returns or++ * audits a value — only a four-character hint, from the store's marker.++ *++ * The legacy provider_secrets rows (AES-256-GCM under AI_CREDENTIALS_KEY) are migrated once at boot by migrate():++ * each one is opened, written as the sealed `<provider>.key` bound to the provider's current origin, read back, and++ * only then deleted. It is idempotent, and logs counts only.*/const crypto = require('crypto');const { AiError } = require('./util');@@@ -15,10 +20,11 @@ const keys = new Map(); // provider key → plaintext, this process onlyfunction secretKey(config) {const hex = String((config && config.credentialsKey) || '');-- if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here: AI_CREDENTIALS_KEY is not set');++ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be read here: AI_CREDENTIALS_KEY is not set');return Buffer.from(hex, 'hex');}++/** The legacy vault's seal (provider key as AAD), read only by the one-time migration. */function seal(config, plain, aad) {const iv = crypto.randomBytes(12);const c = crypto.createCipheriv('aes-256-gcm', secretKey(config), iv);@@@ -36,44 +42,90 @@ function open(config, sealed, aad) {return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');}--function createVault({ db, config, clock = { now: () => Date.now() }, log = console }) {-- /** Read every stored key into this process (a key that no longer decrypts is skipped and named in the log). */-- async function load() {-- const rows = await db.prepare('SELECT provider_key, sealed FROM provider_secrets').all();-- keys.clear();-- for (const r of rows) {-- try { keys.set(r.provider_key, open(config, r.sealed, r.provider_key)); } catch { log.warn(`[Vault] the key of provider ${r.provider_key} does not decrypt (was AI_CREDENTIALS_KEY changed?)`); }-- }-- return keys.size;++/** Fill the synchronous cache from the store's effective values (onActivate, boot). Locked secrets are absent. */++function refresh(values = {}) {++ keys.clear();++ for (const [k, v] of Object.entries(values)) {++ if (k.endsWith('.key') && typeof v === 'string' && v) keys.set(k.slice(0, -4), v);}++ return keys.size;++}++++/** The key of a provider whose secret_ref is 'vault:<key>' ('' when none is stored or it does not open). */++function get(providerKey) { return keys.get(providerKey) || ''; }++++function actorRef(actor) {++ if (actor && typeof actor === 'object' && actor.type && actor.id) return { type: String(actor.type), id: String(actor.id) };++ return { type: 'service', id: 'ai' };++}++++function createVault({ settings, clock = { now: () => Date.now() }, log = console }) {++ if (!settings || !settings.providerKeys) throw new TypeError('createVault: pass the D11 settings');++++ async function load() { return refresh(settings.providerKeys.store.get()); }async function put(providerKey, plain, { actor = 'system' } = {}) {if (!KEY_RE.test(providerKey || '')) throw new AiError(422, 'ai.invalid', 'provider key must be lowercase dotted/dashed');const value = String(plain || '').trim();if (value.length < 8 || value.length > 4000 || /\s/.test(value)) throw new AiError(422, 'ai.invalid', 'that does not look like an API key');-- const sealed = seal(config, value, providerKey);-- const t = new Date(clock.now()).toISOString();-- await db.prepare(`INSERT INTO provider_secrets (provider_key, sealed, hint, updated_by, updated_at) VALUES (?, ?, ?, ?, ?)-- ON CONFLICT (provider_key) DO UPDATE SET sealed = excluded.sealed, hint = excluded.hint, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)-- .run(providerKey, sealed, `…${value.slice(-4)}`, actor, t);-- keys.set(providerKey, value);-- return { provider_key: providerKey, hint: `…${value.slice(-4)}`, updated_at: t };++ const marker = await settings.providerKeys.setKey(providerKey, value, { actor: actorRef(actor), reason: 'provider key set from the console' });++ return { provider_key: providerKey, hint: marker.hint, updated_at: marker.set_at };}async function remove(providerKey) {-- await db.prepare('DELETE FROM provider_secrets WHERE provider_key = ?').run(providerKey);-- keys.delete(providerKey);++ await settings.providerKeys.deleteKey(providerKey, { actor: { type: 'service', id: 'ai' }, reason: 'provider key removed' });}++ /** Name → marker (hint, set_at, set_by) for the console's provider page; never a value. */async function hints() {-- const rows = await db.prepare('SELECT provider_key, hint, updated_by, updated_at FROM provider_secrets').all();-- return new Map(rows.map((r) => [r.provider_key, { hint: r.hint, updated_by: r.updated_by, updated_at: r.updated_at }]));++ const summary = await settings.providerKeys.store.summary();++ const out = new Map();++ for (const [k, m] of Object.entries(summary.secrets || {})) {++ if (!k.endsWith('.key')) continue;++ out.set(k.slice(0, -4), { hint: m.hint ? `…${m.hint}` : null, updated_by: m.set_by, updated_at: m.set_at });++ }++ return out;}return { load, put, remove, hints };}--/** The key of a provider whose secret_ref is 'vault:<key>' ('' when none is stored). */--function get(providerKey) { return keys.get(providerKey) || ''; }++/**++ * One-time, idempotent vault → ai.provider_keys migration (AI_CREDENTIALS_KEY + OV_CONFIG_KEKS set). Returns++ * { migrated, already, failed, skipped } counts; a row is deleted only after the sealed key reads back.++ */++async function migrate({ db, config, settings, log = console }) {++ const none = { migrated: 0, already: 0, failed: 0, skipped: 0 };++ if (!settings || !settings.providerKeys) return none;++ if (!/^[0-9a-f]{64}$/i.test(String((config && config.credentialsKey) || ''))) return none;++ if (!settings.providerKeys.sealed) return none;++ const rows = await db.prepare('SELECT provider_key, sealed FROM provider_secrets').all();++ const report = { ...none };++ if (!rows.length) return report;++ const store = () => settings.providerKeys.store;++ const before = new Set(Object.keys((await store().summary()).secrets || {}));++ for (const row of rows) {++ let plain;++ try { plain = open(config, row.sealed, row.provider_key); } catch { report.failed++; continue; }++ const key = `${row.provider_key}.key`;++ try {++ if (before.has(key)) {++ if (store().get(key) === plain) { await db.prepare('DELETE FROM provider_secrets WHERE provider_key = ?').run(row.provider_key); report.already++; }++ else report.failed++;++ continue;++ }++ await settings.providerKeys.setKey(row.provider_key, plain, { actor: { type: 'service', id: 'ai' }, reason: 'migrated from AI_CREDENTIALS_KEY vault' });++ if (store().get(key) !== plain) throw new Error('the sealed key did not read back');++ await db.prepare('DELETE FROM provider_secrets WHERE provider_key = ?').run(row.provider_key);++ before.add(key);++ report.migrated++;++ } catch (err) {++ if (/no base URL/.test(String(err && err.message))) report.skipped++;++ else report.failed++;++ }++ }++ log.log(`[Vault] provider-key migration: ${report.migrated} moved, ${report.already} already, ${report.skipped} without an origin, ${report.failed} failed`);++ return report;++}--module.exports = { createVault, get, KEY_RE, _seal: seal, _open: open, _keys: keys };++module.exports = { createVault, migrate, get, refresh, KEY_RE, _seal: seal, _open: open, _keys: keys };@@@ -8,7 +8,9 @@ const { PASSTHROUGH_OUTPUT } = require('./live');const STR = (n) => ({ type: 'string', maxLength: n });const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, required, properties: props });--const COMMON = { max_tokens: { type: 'integer', minimum: 1, maximum: 16000 }, temperature: { type: ['number', 'null'], minimum: 0, maximum: 2 } };++const COMMON = { max_tokens: { type: 'integer', minimum: 1, maximum: 16000 }, temperature: { type: ['number', 'null'], minimum: 0, maximum: 2 },++ // AI Exchange F: a liability cap on the Balance hold for this request (optional; nothing when unset).++ max_cost_usd: { type: ['number', 'string', 'null'] } };const templates = [{@@@ -90,6 +92,8 @@ const workflows = [// the prompt (passthrough). The output is the completion's choices.key: 'ai.complete', name: 'Chat completion (OpenAI-compatible)', namespace: 'ai',input_schema: OBJ({++ // CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/openai.js).++ model: STR(256),messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {role: { enum: ['system', 'developer', 'user', 'assistant', 'tool'] },content: { anyOf: [{ type: 'string', maxLength: 400000 }, { type: 'array', maxItems: 100 }, { type: 'null' }] },@@@ -116,6 +120,8 @@ const workflows = [// cost and explain but not the prompt (passthrough). The output is the message it answered with.key: 'ai.messages', name: 'Messages API (Anthropic-compatible)', namespace: 'ai',input_schema: OBJ({++ // CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/messages.js).++ model: STR(256),messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {role: { enum: ['user', 'assistant'] },content: { anyOf: [STR(400000), { type: 'array', maxItems: 500 }, { type: 'null' }] },@@@ -124,6 +130,7 @@ const workflows = [tools: { type: 'array', maxItems: 128, items: { type: 'object' } },tool_choice: { type: 'object' },max_tokens: { type: 'integer', minimum: 1, maximum: 64000 },++ max_cost_usd: { type: ['number', 'string', 'null'] },stop_sequences: { type: 'array', maxItems: 8, items: STR(200) },temperature: { type: ['number', 'null'], minimum: 0, maximum: 1 },top_p: { type: ['number', 'null'], minimum: 0, maximum: 1 },@@@ -33,6 +33,7 @@ const { render } = require('../templates');const { preferenceLines } = require('../user-modules');const schemas = require('../schemas');const { PREPARE, POSTPROCESS } = require('./hooks');++const modelCatalog = require('../providers/model-catalog');const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };const INLINE_AUDIO_MAX = 1024 * 1024;@@@ -215,9 +216,27 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spareturn { output: { text: exec.result.text || '', json }, exec, route };}++ /**++ * The model the caller named (AI Exchange D3/D4, AI_MODEL_CATALOG): an exact id runs only its own artifact, a++ * dynamic alias turns into the same route with that policy's placement constraints. With the flag off this is++ * never called, so today's route selection is unchanged.++ */++ async function selectedRoute(base, input) {++ if (!(config.modelCatalog && config.modelCatalog.enabled) || typeof input.model !== 'string' || !input.model.trim()) return { route: base, selection: null };++ const selection = await modelCatalog.select({ registry, model: input.model.trim(), route: base });++ return { route: selection.route, selection };++ }++++ function selectionCtx(selection) {++ if (!selection) return {};++ return selection.kind === 'exact'++ ? { policy: 'exact', exact: { id: selection.id, offers: selection.offers.map((o) => ({ provider: o.provider, model: o.model })) } }++ : { policy: selection.policy, aliasPolicy: selection.policy };++ }++/** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */async function completeStep(step, input, wf, run, ctx) {-- const route = await routeFor(step.route || 'default.chat');++ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.chat'), input);const openai = {};for (const k of ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {if (input[k] !== undefined) openai[k] = input[k];@@@ -230,7 +249,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spaprefer: ctx.prefer || null,...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),};-- const exec = await pool.execute(route, 'complete', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });++ const exec = await pool.execute(route, 'complete', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });const c = exec.result.completion;if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };@@@ -241,7 +260,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa* upstream that speaks that format. The run's output is the message (consumed by server/api/messages.js).*/async function messagesStep(step, input, wf, run, ctx) {-- const route = await routeFor(step.route || 'default.messages');++ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.messages'), input);const req = {messages: input.messages,maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),@@@ -252,7 +271,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spafor (const k of ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata']) {if (input[k] !== undefined) req[k] = input[k];}-- const exec = await pool.execute(route, 'messages', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: input.messages, t: input.tools || null }) });++ const exec = await pool.execute(route, 'messages', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: input.messages, t: input.tools || null }) });const m = exec.result.message;if (!m || !Array.isArray(m.content)) throw new AiError(502, 'output.empty', 'the provider answered, but not with a message');return { output: { ...m, model: m.model || exec.model }, exec, route };@@@ -51,16 +51,16 @@ function sharedProviderRecord(config, env) {function providerRecords(config, env) {const out = [-- { key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, metadata: { synthetic: true, billing_profile: 'free' } },++ { key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, provider_access: 'OWN_DEPLOYMENT', metadata: { synthetic: true, billing_profile: 'free' } },sharedProviderRecord(config, env),-- { key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },++ { key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, provider_access: 'OWN_DEPLOYMENT', metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },];if (config.localLlm && config.localLlm.url) {// A local model server (llama.cpp, Ollama) speaking the OpenAI API: no key, never paid (WS-O task 5).// No json: the small local model must not be offered the product JSON routes. Embed only with an embedding model.const localCaps = ['chat', 'generate', 'summarize', 'classify', 'extract'];if (config.localLlm.embedModel) localCaps.push('embed');-- out.push({ key: 'local', display_name: 'Local model (OpenAI-compatible server on this host)', kind: 'openai', status: 'active', base_url: config.localLlm.url, auth_mode: 'none', secret_ref: null, default_model: config.localLlm.model || 'local', capabilities: localCaps, timeout_ms: config.localLlm.timeoutMs, priority: 5, metadata: { configured_by: 'AI_LOCAL_LLM_URL', embed_url: config.localLlm.embedUrl || config.localLlm.url, local: true, paid: false, billing_profile: 'free' } });++ out.push({ key: 'local', display_name: 'Local model (OpenAI-compatible server on this host)', kind: 'openai', status: 'active', base_url: config.localLlm.url, auth_mode: 'none', secret_ref: null, default_model: config.localLlm.model || 'local', capabilities: localCaps, timeout_ms: config.localLlm.timeoutMs, priority: 5, provider_access: 'OWN_DEPLOYMENT', metadata: { configured_by: 'AI_LOCAL_LLM_URL', embed_url: config.localLlm.embedUrl || config.localLlm.url, local: true, paid: false, billing_profile: 'free' } });}if (config.httpSeamUrl) {out.push({ key: 'http-seam', display_name: 'Local HTTP seam', kind: 'http', status: 'active', base_url: config.httpSeamUrl, auth_mode: 'none', default_model: null, capabilities: [], timeout_ms: 30000, priority: 60, metadata: { configured_by: 'AI_HTTP_SEAM_URL', billing_profile: 'metered' } });@@@ -91,6 +91,7 @@ t.test('every other route is first-party only: an app token gets 403', async ()['POST', `/api/v1/runs/${genRun.id}/cancel`, undefined],['POST', '/api/v1/enrich', { record: {}, instructions: 'x' }],['GET', '/api/v1/providers', undefined],++ ['GET', '/internal/config', undefined],];for (const [method, path, body] of routes) {const r = await request(h.base, method, path, { tok: appA, body });@@@ -41,7 +41,7 @@ t.test('a token without the capability is 403 capability.denied', async () => {r = await request(h.base, 'POST', '/api/v1/summarize', { tok: token('live', ['events.event.publish']), body: { text: 'x' } });assert.strictEqual(r.status, 403);const runner = token('live', ['ai.run.create']);-- for (const [m, p] of [['GET', '/api/v1/providers'], ['POST', '/api/v1/providers'], ['POST', '/api/v1/templates/ai.generate/versions'], ['GET', '/api/v1/usage'], ['GET', '/api/v1/audit'], ['POST', '/api/v1/quotas'], ['DELETE', '/api/v1/cache']]) {++ for (const [m, p] of [['GET', '/api/v1/providers'], ['POST', '/api/v1/providers'], ['POST', '/api/v1/templates/ai.generate/versions'], ['GET', '/api/v1/usage'], ['GET', '/api/v1/audit'], ['POST', '/api/v1/quotas'], ['DELETE', '/api/v1/cache'], ['GET', '/internal/config'], ['POST', '/internal/config/ai.exchange']]) {const x = await request(h.base, m, p, { tok: runner, body: m === 'GET' || m === 'DELETE' ? undefined : {} });assert.strictEqual(x.status, 403, `${m} ${p}`);}nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++// D11: the one-time, idempotent vault (provider_secrets) -> ai.provider_keys migration. AI_CREDENTIALS_KEY and++// OV_CONFIG_KEKS are both set; every row is opened, sealed as <provider>.key bound to its origin, read back, and only++// then deleted. An env:NAME key is left alone. Logs carry counts only.++const assert = require('assert');++const crypto = require('crypto');++const { boot, request, token, suite, tmpDir } = require('./helpers');++++const t = suite('config-migration');++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const KEK = crypto.randomBytes(32).toString('base64');++const VAULT_KEY = crypto.randomBytes(32).toString('hex');++const PLAIN = 'made-up-migrated-key-9c1d3e5f';++const ENV_KEY_VALUE = 'env-sourced-provider-key-4a6b8c0d';++const KEK_ENV = { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1' };++const cfgTok = token('network', ['ai.config.manage', 'ai.provider.manage']);++const logs = [];++const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++const cfgReq = (h, method, path, { body } = {}) => request(h.base, method, path, { tok: cfgTok, body, headers: { 'X-OV-Subject': OWNER, 'X-OV-Fresh': '1' } });++++t.test('vault rows migrate once, sealed and bound to the origin; env-sourced keys are untouched', async () => {++ const dir = tmpDir();++ let first = await boot({ dir, env: { AI_CREDENTIALS_KEY: VAULT_KEY, CONFIG_TEST_KEY: ENV_KEY_VALUE, AI_USER_MODULES: 'off' } });++ try {++ await first.registry.upsertProvider({ key: 'oldprov', display_name: 'Old vault provider', kind: 'http', status: 'active', base_url: 'https://old.example/v1', auth_mode: 'bearer', secret_ref: 'vault:oldprov', capabilities: ['chat'], timeout_ms: 5000 });++ await first.registry.upsertProvider({ key: 'envprov', display_name: 'Env provider', kind: 'http', status: 'active', base_url: 'https://env.example/v1', auth_mode: 'bearer', secret_ref: 'env:CONFIG_TEST_KEY', capabilities: ['chat'], timeout_ms: 5000 });++ const vault = require('../server/vault');++ await first.db.prepare('INSERT INTO provider_secrets (provider_key, sealed, hint, updated_by, updated_at) VALUES (?, ?, ?, ?, ?)')++ .run('oldprov', vault._seal(first.config, PLAIN, 'oldprov'), `…${PLAIN.slice(-4)}`, 'usr:owner', new Date().toISOString());++ } finally {++ await first.stop();++ }++ const env = { ...KEK_ENV, AI_CREDENTIALS_KEY: VAULT_KEY, CONFIG_TEST_KEY: ENV_KEY_VALUE, AI_USER_MODULES: 'off' };++ let second = await boot({ dir, env, log: capture });++ let revision;++ try {++ assert.strictEqual(second.settings.providerKeys.store.get('oldprov.key'), PLAIN, 'the migrated key opens');++ assert.strictEqual(require('../server/util').resolveSecret('vault:oldprov'), PLAIN, 'the pool resolves the migrated key');++ const left = await second.db.prepare('SELECT COUNT(*) AS n FROM provider_secrets').get();++ assert.strictEqual(Number(left.n), 0, 'the vault rows are deleted after the read-back');++ const row = await second.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();++ assert.match(JSON.parse(row.values_json)['oldprov.key'], /^v1:k1:/);++ assert.ok(!row.values_json.includes(PLAIN));++ const summary = await cfgReq(second, 'GET', '/internal/config/ai.provider_keys');++ assert.strictEqual(summary.body.secrets['oldprov.key'].bound_to, 'https://old.example');++ assert.strictEqual(second.settings.providerKeys.store.get('envprov.key'), undefined, 'an env: key is not copied into the store');++ assert.strictEqual(require('../server/util').resolveSecret('env:CONFIG_TEST_KEY', second.env), ENV_KEY_VALUE);++ revision = second.settings.providerKeys.store.revision();++ assert.ok(!logs.some((line) => line.includes(PLAIN)), 'the migration logs counts only');++ } finally {++ await second.stop();++ }++ const third = await boot({ dir, env, log: capture });++ try {++ assert.strictEqual(third.settings.providerKeys.store.get('oldprov.key'), PLAIN, 'a restart reads the same sealed key');++ assert.strictEqual(third.settings.providerKeys.store.revision(), revision, 'the second boot writes nothing (idempotent)');++ assert.strictEqual(Number((await third.db.prepare('SELECT COUNT(*) AS n FROM provider_secrets').get()).n), 0);++ assert.strictEqual(require('../server/util').resolveSecret('env:CONFIG_TEST_KEY', third.env), ENV_KEY_VALUE);++ } finally {++ await third.stop();++ }++});++++(async () => {++ await t.run();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++// D11: a secret stored while no KEK was configured is resealed as an envelope at the next boot with OV_CONFIG_KEKS,++// and stays readable (the store's reseal() runs once at boot).++const assert = require('assert');++const crypto = require('crypto');++const { boot, request, token, suite, tmpDir } = require('./helpers');++++const t = suite('config-reseal');++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const KEK = crypto.randomBytes(32).toString('base64');++const KEK_ENV = { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1' };++const SECRET = 'made-up-config-key-THIS-MUST-NEVER-BE-SHOWN-7f3a9c2e1b';++const cfgTok = token('network', ['ai.config.manage', 'ai.provider.manage']);++const cfgReq = (h, method, path, { body } = {}) => request(h.base, method, path, { tok: cfgTok, body, headers: { 'X-OV-Subject': OWNER, 'X-OV-Fresh': '1' } });++++t.test('a plaintext row from a no-KEK boot is resealed at the next boot with a KEK', async () => {++ const dir = tmpDir();++ const first = await boot({ dir, env: { AI_USER_MODULES: 'off' } });++ try {++ await first.registry.upsertProvider({ key: 'resealprov', display_name: 'Reseal', kind: 'http', status: 'active', base_url: 'https://reseal.example/v1', auth_mode: 'bearer', secret_ref: 'vault:resealprov', capabilities: ['chat'], timeout_ms: 5000 });++ const summary = await cfgReq(first, 'GET', '/internal/config');++ assert.strictEqual(summary.status, 200, summary.text);++ assert.strictEqual(summary.body.namespaces.find((n) => n.namespace === 'ai.provider_keys').sealed, false, 'no KEK: the store still works and says so');++ const put = await cfgReq(first, 'PUT', '/internal/config/ai.provider_keys/secrets/resealprov.key', { body: { value: SECRET, reason: 'unsealed' } });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual(first.settings.providerKeys.store.get('resealprov.key'), SECRET);++ const row = await first.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();++ assert.strictEqual(JSON.parse(row.values_json)['resealprov.key'], SECRET, 'no KEK: the store keeps the value as it is');++ } finally {++ await first.stop();++ }++ const second = await boot({ dir, env: { ...KEK_ENV, AI_USER_MODULES: 'off' } });++ try {++ assert.strictEqual(second.settings.providerKeys.store.get('resealprov.key'), SECRET, 'reseal kept the value readable');++ const row = await second.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();++ assert.match(JSON.parse(row.values_json)['resealprov.key'], /^v1:k1:/, 'reseal made it an envelope');++ assert.ok(!row.values_json.includes(SECRET));++ assert.strictEqual((await second.settings.providerKeys.store.summary()).sealed, true);++ } finally {++ await second.stop();++ }++});++++(async () => {++ await t.run();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/**++ * D11 owner settings: ai.exchange and ai.provider_keys on openvibe-shared/config, the /internal/config admin++ * routes behind ai.config.manage, the vault → provider_keys migration, x-bind against a moved provider origin,++ * secrets that are write-only and never appear in a summary/log/error, env pins and both KEK states.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, request, token, suite } = require('./helpers');++++const t = suite('config');++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const KEK = crypto.randomBytes(32).toString('base64');++const VAULT_KEY = crypto.randomBytes(32).toString('hex');++const SECRET = 'made-up-config-key-THIS-MUST-NEVER-BE-SHOWN-7f3a9c2e1b';++const SECRET2 = 'made-up-config-key-ROTATED-THIS-MUST-NOT-SHOW-2b4d6f8a';++++const KEK_ENV = { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1' };++const ALL_CAPS = ['ai.config.manage', 'ai.provider.manage', 'ai.usage.read'];++const cfgTok = token('network', ALL_CAPS);++const opsTok = token('network', ['ai.provider.manage']);++const noCap = token('live', ['ai.run.read']);++const headers = (fresh = true, actor = OWNER) => ({++ ...(actor ? { 'X-OV-Subject': actor } : {}),++ ...(fresh ? { 'X-OV-Fresh': '1' } : {}),++});++const cfgReq = (h, method, path, { tok = cfgTok, body, fresh = true, actor = OWNER } = {}) =>++ request(h.base, method, path, { tok, body, headers: headers(fresh, actor) });++++let h;++const logs = [];++const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++++t.test('boot with a KEK and the model-catalogue env pin', async () => {++ h = await boot({++ env: { ...KEK_ENV, AI_CREDENTIALS_KEY: VAULT_KEY, AI_MODEL_CATALOG: '1', AI_USER_MODULES: 'off' },++ log: capture,++ });++ assert.strictEqual(h.settings.exchange.sealed, true);++ assert.strictEqual(h.settings.providerKeys.sealed, true);++});++++t.test('the summaries carry the schemas and the owner annotations', async () => {++ const r = await cfgReq(h, 'GET', '/internal/config');++ assert.strictEqual(r.status, 200, r.text);++ const ex = r.body.namespaces.find((n) => n.namespace === 'ai.exchange');++ assert.strictEqual(ex.schema.properties.credits['x-env'], 'AI_CREDITS');++ assert.strictEqual(ex.schema.properties.credits['x-danger'], true);++ assert.strictEqual(ex.schema.properties.model_catalog['x-env'], 'AI_MODEL_CATALOG');++ assert.strictEqual(ex.schema.properties.alpha['x-env'], 'AI_ROUTING_EVIDENCE_ALPHA');++ assert.deepStrictEqual([ex.schema.properties.alpha.minimum, ex.schema.properties.alpha.maximum], [0.01, 1]);++ const keys = r.body.namespaces.find((n) => n.namespace === 'ai.provider_keys');++ assert.strictEqual(keys.schema.properties['shared.origin'].type, 'string');++ assert.strictEqual(keys.schema.properties['shared.key']['x-bind'], 'shared.origin');++});++++t.test('a non-capability caller gets 403', async () => {++ const r = await request(h.base, 'GET', '/internal/config', { tok: noCap });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied']);++});++++t.test('the env pin wins and a write to it is refused', async () => {++ const r = await cfgReq(h, 'POST', '/internal/config/ai.exchange', { body: { values: { model_catalog: false }, reason: 'pin test' } });++ assert.deepStrictEqual([r.status, r.body.code], [409, 'config.pinned_in_env']);++ assert.strictEqual(h.config.modelCatalog.enabled, true);++});++++t.test('a stale x-danger change without X-OV-Fresh gets 403', async () => {++ const r = await cfgReq(h, 'POST', '/internal/config/ai.exchange', { body: { values: { credits: true }, reason: 'stale' }, fresh: false });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'config.fresh_required']);++ assert.strictEqual(h.config.credits.enabled, false, 'nothing turned on');++});++++t.test('an owner change applies live and is audited without a value', async () => {++ const r = await cfgReq(h, 'POST', '/internal/config/ai.exchange', { body: { values: { credits: true, routing_evidence: true, alpha: 0.35 }, reason: 'owner change' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(h.config.credits.enabled, true);++ assert.strictEqual(h.credits.enabled, true, 'the Credits facade follows the live flag');++ assert.ok(h.credits.manager, 'the Credits manager is created when the owner turns it on');++ assert.strictEqual(h.config.routingEvidence.enabled, true);++ assert.strictEqual(h.config.routingEvidence.alpha, 0.35);++ assert.ok(h.pool.routingEvidence && h.pool.routingEvidence.enabled(), 'routing evidence follows the live flag');++ const rows = await h.db.prepare("SELECT actor, target_id, metadata FROM audit_log WHERE action = 'config.change' ORDER BY id DESC").all();++ assert.ok(rows.some((row) => row.target_id === 'ai.exchange' && JSON.parse(row.metadata).keys.includes('credits')), 'the change is audited');++ assert.ok(rows.every((row) => !String(row.metadata).includes(SECRET)), 'never a value');++});++++t.test('a provider key is write-only, sealed, and never reaches a summary, a log or an error', async () => {++ const created = await request(h.base, 'POST', '/api/v1/providers', {++ tok: opsTok,++ body: {++ key: 'cfgprov', display_name: 'Config provider', kind: 'http', status: 'active', base_url: 'https://cfg.example/v1',++ auth_mode: 'bearer', secret_ref: 'vault:cfgprov', capabilities: ['chat'], timeout_ms: 5000,++ },++ });++ assert.strictEqual(created.status, 201, created.text);++ const put = await cfgReq(h, 'PUT', '/internal/config/ai.provider_keys/secrets/cfgprov.key', { body: { value: SECRET, reason: 'the key' } });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual(put.body.hint, SECRET.slice(-4));++ assert.strictEqual(put.body.bound_to, 'https://cfg.example');++ assert.strictEqual(h.settings.providerKeys.store.get('cfgprov.key'), SECRET);++ assert.strictEqual(require('../server/util').resolveSecret('vault:cfgprov'), SECRET, 'the pool resolves it synchronously');++ const row = await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();++ assert.match(JSON.parse(row.values_json)['cfgprov.key'], /^v1:k1:/);++ assert.ok(!row.values_json.includes(SECRET), 'values_json holds the envelope, not the key');++ const summary = await cfgReq(h, 'GET', '/internal/config/ai.provider_keys');++ assert.strictEqual(summary.status, 200, summary.text);++ assert.ok(!summary.text.includes(SECRET), 'the summary leaks no key');++ assert.deepStrictEqual(summary.body.secrets['cfgprov.key'].set_by, { type: 'user', id: OWNER });++ const carried = await cfgReq(h, 'POST', '/internal/config/ai.provider_keys', { body: { values: { 'cfgprov.key': SECRET }, reason: 'not here' } });++ assert.deepStrictEqual([carried.status, carried.body.code], [422, 'config.secret_write_only']);++ assert.ok(!carried.text.includes(SECRET), 'the error leaks no key');++ assert.ok(logs.every((line) => !line.includes(SECRET) && !line.includes(SECRET2)), 'no log line leaks a key');++ const history = await cfgReq(h, 'GET', '/internal/config/ai.provider_keys/history');++ assert.strictEqual(history.status, 200);++ assert.ok(!history.text.includes(SECRET), 'the history leaks no key');++});++++t.test('x-bind refuses the key after a base_url change, and entering it again binds the new origin', async () => {++ const patched = await request(h.base, 'PATCH', '/api/v1/providers/cfgprov', { tok: opsTok, body: { base_url: 'https://moved.example/v1' } });++ assert.strictEqual(patched.status, 200, patched.text);++ assert.strictEqual(patched.body.key_reentry_required, true);++ assert.match(patched.body.notice, /the key must be entered again for the new origin/);++ assert.strictEqual(h.settings.providerKeys.store.get('cfgprov.key'), undefined, 'the old key no longer opens');++ assert.strictEqual(require('../server/util').resolveSecret('vault:cfgprov'), '', 'the pool gets no key for the new host');++ const locked = await cfgReq(h, 'GET', '/internal/config/ai.provider_keys');++ assert.strictEqual(locked.body.secrets['cfgprov.key'].bound_to, 'https://cfg.example', 'the marker names what it was sealed to');++ assert.ok(!locked.text.includes(SECRET), 'the locked summary leaks no key');++ const again = await cfgReq(h, 'PUT', '/internal/config/ai.provider_keys/secrets/cfgprov.key', { body: { value: SECRET2, reason: 'for the new host' } });++ assert.strictEqual(again.status, 200, again.text);++ assert.strictEqual(again.body.bound_to, 'https://moved.example');++ assert.strictEqual(h.settings.providerKeys.store.get('cfgprov.key'), SECRET2);++});++++(async () => {++ await t.run();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,251 @@++'use strict';++// AI Exchange on the operator console (AI_CREDITS / AI_ROUTING_EVIDENCE): staff only, the Credits settle++// queue counts and review rows with one audited "mark resolved" action that never retries, the measured++// routing evidence and incumbents, nothing from a settle body beyond the four usage counts, and no++// Exchange page or navigation when both flags are off.++++const assert = require('assert');++const crypto = require('crypto');++const { serviceAuth, ids } = require('openvibe-contracts');++const { boot, suite, privateKey, ISSUER } = require('./helpers');++++const t = suite('console-exchange');++const NETWORK = 'http://network.test';++const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;++const SESSION_SECRET = crypto.randomBytes(32).toString('hex');++const people = { user: ids.newId('user'), admin: ids.newId('user') };++++const codes = new Map();++const revoked = [];++function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', staff_caps, challenge, redirect_uri }) {++ const code = crypto.randomBytes(16).toString('hex');++ codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri });++ return code;++}++function userToken(u) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ sub: 42, id: 42, ...(u.subject_id ? { subject_id: u.subject_id } : {}), username: u.username, role: u.role, ...(u.is_owner ? { is_owner: true } : {}),++ ...(u.staff_caps ? { staff_caps: u.staff_caps, staff_map: '1.1.0' } : {}),++ iss: ISSUER, aud: ['openvibe.live', 'openvibe.network'], iat: now, exp: now + 3600,++ }, privateKey);++}++const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++async function fakeFetch(url, opts = {}) {++ const u = String(url);++ if (u === `${NETWORK}/oauth/token`) {++ const body = Object.fromEntries(new URLSearchParams(String(opts.body)));++ if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });++ const c = codes.get(body.code);++ if (!c) return json(400, { error: 'invalid_grant' });++ codes.delete(body.code);++ if (c.redirect_uri !== body.redirect_uri) return json(400, { error: 'invalid_grant' });++ if (crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url') !== c.challenge) return json(400, { error: 'invalid_grant' });++ return json(200, { access_token: userToken(c), refresh_token: `rt_${crypto.randomBytes(6).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });++ }++ if (u === `${NETWORK}/oauth/revoke`) { revoked.push(JSON.parse(String(opts.body)).token); return json(200, { revoked: true }); }++ return globalThis.fetch(url, opts);++}++++const baseEnv = {++ BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET,++ AI_CONSOLE_SESSION_SECRET: SESSION_SECRET, AI_USER_MODULES: 'off', AI_STUB_FALLBACK: 'false',++};++let h;++let adminCookie = null;++const setCookies = (res) => (res.headers.getSetCookie ? res.headers.getSetCookie() : []);++async function get(p, cookie) {++ const res = await fetch(h.base + p, { redirect: 'manual', headers: cookie ? { Cookie: cookie } : {} });++ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };++}++async function post(p, cookie, form) {++ const res = await fetch(h.base + p, {++ method: 'POST', redirect: 'manual',++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(cookie ? { Cookie: cookie } : {}) },++ body: new URLSearchParams(form).toString(),++ });++ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };++}++async function signIn(subject, { role = 'admin', is_owner = false, username = 'staffer', staff_caps, next = '/console/exchange' } = {}) {++ const login = await get(`/auth/login?next=${encodeURIComponent(next)}`);++ assert.strictEqual(login.status, 302);++ const loc = new URL(login.headers.get('location'));++ const flow = login.cookies.find((c) => c.startsWith('ovai_flow='));++ const code = authorize({ subject_id: subject, role, is_owner, username, staff_caps, challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri') });++ const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow.split(';')[0]);++ const sess = cb.cookies.find((c) => c.startsWith('__Host-ovai_staff=') && !c.startsWith('__Host-ovai_staff=;'));++ return { cb, cookie: sess ? sess.split(';')[0] : null };++}++const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];++const sessionCount = async () => (await h.db.prepare('SELECT COUNT(*) AS n FROM console_sessions').get()).n;++function assertPage(r, p) {++ assert.strictEqual(r.status, 200, `${p}: ${r.status} ${r.text.slice(0, 300)}`);++ assert.match(r.headers.get('content-type'), /text\/html/, p);++ assert.ok(!/<script/i.test(r.text), `${p}: no script`);++ const csp = r.headers.get('content-security-policy') || '';++ assert.ok(csp.includes("default-src 'none'") && csp.includes("form-action 'self'") && !csp.includes('unsafe-inline'), `${p}: CSP`);++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow', p);++ assert.match(r.headers.get('cache-control') || '', /no-store/, p);++ assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, `${p}: one h1`);++ assert.strictEqual((r.text.match(/<table/g) || []).length, (r.text.match(/<caption>/g) || []).length, `${p}: every table has a caption`);++ assert.ok(!r.text.includes(CLIENT_SECRET) && !r.text.includes(SESSION_SECRET), `${p}: no secret`);++}++++const REVIEW_A = 'ai-settle:req_REVIEWA';++const REVIEW_B = 'ai-settle:req_REVIEWB';++const REVIEW_BODY_A = JSON.stringify({++ idempotency_key: REVIEW_A,++ subject: 'SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00',++ usage: { input_tokens: 111, cached_read_tokens: 222, cache_write_tokens: 333, output_tokens: 444 },++ retail: { version: 'RETAIL-MARKER-9f3a', nano_usd_per_m: { input: 'SUPPLY-MARKER-111', cached_read: 'SUPPLY-MARKER-222', cache_write: 'SUPPLY-MARKER-333', output: 'SUPPLY-MARKER-444' }, per_request_nano_usd: 'SUPPLY-MARKER-555' },++ supply_cost_nano_usd: 'SUPPLY-MARKER-666',++ byok: false,++ attempts: [{ deployment_id: 'DEPLOY-MARKER-xyz', started_at: '2026-11-05T10:00:00.000Z', ended_at: '2026-11-05T10:00:01.000Z', error_code: null }],++});++const REVIEW_BODY_B = JSON.stringify({++ idempotency_key: REVIEW_B,++ usage: { input_tokens: 5, cached_read_tokens: 6, cache_write_tokens: 7, output_tokens: 8 },++ retail: { version: 'SECOND-MARKER', nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '0' },++ supply_cost_nano_usd: '0', byok: false, attempts: [],++});++++async function seedQueue({ id, hold, request, body, status, tries, createdAt, lastError = null }) {++ const at = new Date(createdAt).toISOString();++ await h.db.prepare(`INSERT INTO credit_settle_queue++ (id, hold_id, request_id, settle_body, attempts, status, tries, next_at, last_error, created_at, updated_at)++ VALUES (?, ?, ?, ?::jsonb, '[]'::jsonb, ?, ?, ?::timestamptz, ?, ?::timestamptz, ?::timestamptz)`).run(++ id, hold, request, body, status, tries, at, lastError, at, at);++}++++t.test('boot with both flags on and seed queue, metrics and incumbent rows', async () => {++ h = await boot({ env: { ...baseEnv, AI_CREDITS: 'true', AI_ROUTING_EVIDENCE: 'true' }, fetchImpl: fakeFetch });++ const now = Date.now();++ await seedQueue({ id: 'ai-settle:req_QUEUED', hold: 'hold_queued', request: 'req_QUEUED', body: REVIEW_BODY_B, status: 'queued', tries: 4, createdAt: now - 300000, lastError: 'billing unavailable' });++ await seedQueue({ id: 'ai-settle:req_SENT', hold: 'hold_sent', request: 'req_SENT', body: REVIEW_BODY_B, status: 'sent', tries: 0, createdAt: now - 200000 });++ await seedQueue({ id: REVIEW_A, hold: 'hold_review_a', request: 'req_REVIEWA', body: REVIEW_BODY_A, status: 'review', tries: 2, createdAt: now - 150000, lastError: 'billing.invalid_request: output_tokens' });++ await seedQueue({ id: REVIEW_B, hold: 'hold_review_b', request: 'req_REVIEWB', body: REVIEW_BODY_B, status: 'review', tries: 1, createdAt: now - 100000 });++ await h.db.prepare(`INSERT INTO provider_metrics++ (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)++ VALUES ('alpha', 'model-a', 420, 30, 55.5, 25, 0.97, 0.01, 0.42, 22, 40, ?::timestamptz)`).run(new Date(now).toISOString());++ await h.db.prepare(`INSERT INTO provider_metrics++ (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)++ VALUES ('beta', 'model-b', NULL, 0, NULL, 0, 0.5, 0.25, 0, 0, 2, ?::timestamptz)`).run(new Date(now).toISOString());++ await h.db.prepare(`INSERT INTO routing_incumbents++ (decision_key, provider_key, model_key, challenger_provider, challenger_model, challenger_streak, updated_at)++ VALUES ('openvibe/auto|chat|balanced', 'alpha', 'model-a', 'beta', 'model-b', 2, ?::timestamptz)`).run(new Date(now).toISOString());++});++++t.test('anonymous get the sign-in page (401), never data', async () => {++ const r = await get('/console/exchange');++ assert.strictEqual(r.status, 401);++ assert.ok(r.text.includes('Sign in with OpenVibe.Network'));++ assert.ok(!r.text.includes('<table'));++});++++t.test('non-staff are refused at sign-in (403) and get no session; the refusal is audited', async () => {++ const r = await signIn(people.user, { role: 'user' });++ assert.strictEqual(r.cb.status, 403);++ assert.strictEqual(r.cookie, null);++ assert.ok(r.cb.text.includes('only for OpenVibe.Network staff'));++ assert.strictEqual(await sessionCount(), 0);++ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'console.sign_in.refused' AND actor = ?").get(people.user)).n, 1);++ assert.ok(revoked.length >= 1, 'the Network refresh token is revoked');++});++++t.test('an admin sees both sections from the seeded rows; no settle body text beyond the four counts', async () => {++ const s = await signIn(people.admin, { username: 'ops' });++ assert.strictEqual(s.cb.status, 303);++ assert.strictEqual(s.cb.headers.get('location'), '/console/exchange');++ assert.ok(s.cookie);++ adminCookie = s.cookie;++ const page = await get('/console/exchange', s.cookie);++ assertPage(page, '/console/exchange');++ const text = page.text;++ assert.ok(text.includes('<a href="/console/exchange" aria-current="page">Exchange</a>'), 'the nav entry is current');++ assert.ok(text.includes('Credits settle queue') && text.includes('Routing evidence'), 'both sections');++ assert.match(text, /<div class="k">Queued<\/div><div class="v">1<\/div>/);++ assert.match(text, /<div class="k">Sent<\/div><div class="v">1<\/div>/);++ assert.match(text, /<div class="k">Review<\/div><div class="v">2<\/div>/);++ assert.match(text, /<div class="k">Oldest queued<\/div><div class="v">[4-6] min<\/div>/);++ assert.ok(text.includes('hold_review_a') && text.includes('hold_review_b'));++ assert.ok(text.includes('req_REVIEWA') && text.includes('billing.invalid_request: output_tokens'));++ const wraps = (text.match(/class="tablewrap"/g) || []).length;++ assert.ok(wraps >= 3, `every table is inside a scroll wrapper (${wraps})`);++ // The four settle usage counts (summed over the review rows) are the only settle-body data shown.++ assert.match(text, /tokens in 116 · cached read 228 · cache write 340 · out 452/);++ for (const marker of ['SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00', 'RETAIL-MARKER-9f3a', 'SUPPLY-MARKER-111', 'SUPPLY-MARKER-666', 'DEPLOY-MARKER-xyz', 'SECOND-MARKER', '"byok"', 'supply_cost_nano_usd', 'nano_usd_per_m']) {++ assert.ok(!text.includes(marker), `settle body field leaked: ${marker}`);++ }++ assert.ok(text.includes('alpha:model-a') && text.includes('beta:model-b'));++ assert.ok(text.includes('97.0 %') && text.includes('1.0 %') && text.includes('420 ms') && text.includes('55.5') && text.includes('42.0 %'));++ assert.ok(text.includes('openvibe/auto|chat|balanced') && text.includes('n=30') && text.includes('n=25') && text.includes('n=22'));++});++++t.test('"mark resolved" changes only that row, never retries it, and writes the console audit row', async () => {++ const page = await get('/console/exchange', adminCookie);++ const csrf = csrfOf(page);++ assert.ok(csrf, 'the page carries the CSRF token');++ const before = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);++ const other = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);++ const url = `/console/exchange/credits/${encodeURIComponent(REVIEW_A)}/resolve`;++ const r = await post(url, adminCookie, { _csrf: csrf });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.strictEqual(r.headers.get('location'), '/console/exchange?done=settle_resolved');++ const after = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);++ assert.strictEqual(after.status, 'resolved');++ assert.strictEqual(after.tries, before.tries, 'no retry');++ assert.strictEqual(after.next_at, before.next_at, 'the retry schedule is untouched');++ assert.strictEqual(after.resolved_by, people.admin);++ assert.ok(after.resolved_at);++ const otherAfter = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);++ assert.strictEqual(otherAfter.status, other.status, 'the other review row is unchanged');++ assert.strictEqual(otherAfter.tries, other.tries);++ const auditPage = await get('/console/audit?kind=exchange', adminCookie);++ assert.ok(auditPage.text.includes('credit_settle.resolve') && auditPage.text.includes('AI Exchange'), 'the audit page lists it under AI Exchange');++ const audited = await h.db.prepare("SELECT * FROM audit_log WHERE action = 'credit_settle.resolve' ORDER BY id").all();++ assert.strictEqual(audited.length, 1);++ assert.strictEqual(audited[0].actor, people.admin);++ assert.strictEqual(audited[0].target_type, 'credit_settle');++ assert.strictEqual(audited[0].target_id, REVIEW_A);++ assert.strictEqual(JSON.parse(audited[0].metadata).hold_id, 'hold_review_a');++ // A resolved row is no longer offered, and a second attempt is a 409 that changes and audits nothing.++ const shown = await get('/console/exchange', adminCookie);++ assert.match(shown.text, /<div class="k">Review<\/div><div class="v">1<\/div>/);++ assert.ok(!shown.text.includes('hold_review_a'));++ const again = await post(url, adminCookie, { _csrf: csrf });++ assert.strictEqual(again.status, 409, again.text.slice(0, 300));++ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'credit_settle.resolve'").get()).n, 1);++ // A missing CSRF token is refused and does not resolve the other row.++ const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, adminCookie, {});++ assert.strictEqual(denied.status, 403);++ assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');++});++++t.test('a staff session without ai.workflow.manage reads the page but cannot mark a row resolved', async () => {++ const s = await signIn(people.admin, { username: 'reader', staff_caps: ['staff.site.view'] });++ assert.strictEqual(s.cb.status, 303);++ const page = await get('/console/exchange', s.cookie);++ assertPage(page, '/console/exchange (reader)');++ assert.ok(page.text.includes('Credits settle queue'));++ assert.ok(!page.text.includes('Mark resolved'), 'no action without ai.workflow.manage');++ assert.ok(page.text.toLowerCase().includes('never retries the settle'));++ const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, s.cookie, { _csrf: csrfOf(page) });++ assert.strictEqual(denied.status, 403);++ assert.ok(denied.text.includes('This needs ai.workflow.manage'));++ assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');++});++++t.test('with both flags off the Exchange page and its sections are absent', async () => {++ await h.stop();++ h = await boot({ env: { ...baseEnv, AI_CREDITS: 'false', AI_ROUTING_EVIDENCE: 'false' }, fetchImpl: fakeFetch });++ const s = await signIn(people.admin, { username: 'ops' });++ assert.strictEqual(s.cb.status, 303);++ const overview = await get('/console', s.cookie);++ assertPage(overview, '/console');++ assert.ok(!overview.text.includes('href="/console/exchange"'), 'no Exchange navigation');++ const page = await get('/console/exchange', s.cookie);++ assert.strictEqual(page.status, 404);++ assert.ok(!page.text.includes('Credits settle queue') && !page.text.includes('Routing evidence'));++});++++t.run();@@@ -24,9 +24,10 @@ const PROVIDER_SECRET = `sentinel-provider-${crypto.randomBytes(12).toString('heconst CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;const SESSION_SECRET = crypto.randomBytes(32).toString('hex');const VAULT_KEY = crypto.randomBytes(32).toString('hex');++const KEK = crypto.randomBytes(32).toString('base64');const ADDED_KEY = `sentinel-added-${crypto.randomBytes(12).toString('hex')}`;const REPLACED_KEY = `sentinel-replaced-${crypto.randomBytes(12).toString('hex')}`;--const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET, VAULT_KEY, ADDED_KEY, REPLACED_KEY];++const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET, VAULT_KEY, KEK, ADDED_KEY, REPLACED_KEY];const ops = token('ops', ALL);const live = token('live', ['ai.run.create', 'ai.run.read']);const news = token('news', ['ai.run.create', 'ai.run.read']);@@@ -139,6 +140,7 @@ t.test('boot with a provider secret, a seam provider, a failing stub and a few renv: {BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, AI_CONSOLE_SESSION_SECRET: SESSION_SECRET,AI_USER_MODULES: 'off', AI_API_KEY: PROVIDER_SECRET, AI_PROVIDER: 'openai', AI_BASE_URL: 'http://127.0.0.1:1/v1', AI_STUB_FALLBACK: 'false', AI_CREDENTIALS_KEY: VAULT_KEY,++ OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1',},fetchImpl: fakeFetch,});@@@ -382,12 +384,13 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, reassert.strictEqual(x.status, 303, x.text);const p = await h.registry.getProvider('cheapapi');assert.deepStrictEqual([p.kind, p.secret_ref, p.base_url, p.default_model, p.auth_mode], ['openai', 'vault:cheapapi', 'https://api.cheap.test/v1', 'cheap-1', 'bearer']);-- const row = await h.db.prepare('SELECT sealed, hint FROM provider_secrets WHERE provider_key = ?').get('cheapapi');-- assert.ok(row && !row.sealed.includes(ADDED_KEY), 'encrypted at rest');-- assert.strictEqual(row.hint, `…${ADDED_KEY.slice(-4)}`);++ const row = await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();++ assert.match(JSON.parse(row.values_json)['cheapapi.key'], /^v1:k1:/, 'sealed at rest in the D11 store');++ assert.ok(!row.values_json.includes(ADDED_KEY), 'encrypted at rest');++ assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), ADDED_KEY);assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), ADDED_KEY, 'the pool resolves it');-- assert.strictEqual(require('../server/vault')._open({ credentialsKey: VAULT_KEY }, row.sealed, 'cheapapi'), ADDED_KEY);-- assert.throws(() => require('../server/vault')._open({ credentialsKey: VAULT_KEY }, row.sealed, 'otherapi'), 'bound to its provider');++ assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].bound_to, 'https://api.cheap.test');++ assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].hint, ADDED_KEY.slice(-4));x = await get('/console/providers', cookies.owner);assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');assert.match(x.text, new RegExp(`key …${ADDED_KEY.slice(-4)}`));@@@ -396,6 +399,7 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, rex = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });assert.strictEqual(x.status, 303);assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), REPLACED_KEY);++ assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), REPLACED_KEY, 'the store holds the replaced key');x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', _csrf: csrf.owner });assert.ok(x.status >= 400, 'prices name the page they come from');x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', price_source: 'https://cheap.test/pricing', tools: '1', _csrf: csrf.owner });nnew file mode 100644@@@ -0,0 +1,486 @@++'use strict';++// AI Exchange F / P7 + D12 end to end, against a fake Billing. A paid managed request holds Balance before the++// provider runs: an app against its project's Balance (subject app + project_id, no Network lookup) and a person++// against their personal Balance (no project_id). max_cost_usd clamps the output; a pricier managed fallback++// extends the hold (or is skipped if Billing refuses); a settle Billing cannot take is queued and retried exactly++// once; a run with no payer never reaches a paid offer with a versioned card; an exact id then fails 402; a++// provider without a card behaves as before; a sandbox app token never reaches paid supply; with the flag off++// nothing changes.++const assert = require('assert');++const http = require('http');++const { boot, request, token, suite, ALL } = require('./helpers');++++const t = suite('credits-run');++const service = token('live', ALL);++const ULID = '01JAB2C3D4E5F6G7H8J9K0MNPQ';++const PRJ = `prj_${ULID}`;++const APP = `app_${ULID}`;++const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';++const appToken = (env) => token('app', ['ai.app.run'], { sub: `app:app_${ULID}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env } });++const producer = appToken('production');++const sandbox = appToken('sandbox');++++const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++++/** A local fake Billing: the four hold routes, OAuth and the usage relay. D12 makes no Network call. */++async function fakeBilling() {++ const state = { balance: 10_000_000_000n, held: 0n, short: false, failSettle: 0, refuseExtend: false,++ holds: new Map(), settlements: new Map(), releases: [], calls: [], oauthScopes: [], oauthAudiences: [], seq: 0 };++ const available = () => state.balance - state.held;++ const part = (tokens, rate) => {++ const n = BigInt(tokens || 0) * BigInt(rate || 0);++ return n === 0n ? 0n : (n % 1000000n === 0n ? n / 1000000n : n / 1000000n + 1n);++ };++ const chargeOf = (usage, retail) => {++ const perM = retail.nano_usd_per_m || {};++ return part(usage.input_tokens, perM.input) + part(usage.cached_read_tokens, perM.cached_read)++ + part(usage.cache_write_tokens, perM.cache_write) + part(usage.output_tokens, perM.output) + BigInt(retail.per_request_nano_usd || 0);++ };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const path = new URL(req.url, 'http://x').pathname;++ const body = (() => { try { return raw ? JSON.parse(raw) : {}; } catch { return {}; } })();++ state.calls.push({ path, body });++ const send = (status, out) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(out)); };++ if (path === '/oauth/token') {++ const params = new URLSearchParams(raw);++ state.oauthAudiences.push(String(params.get('audience') || ''));++ state.oauthScopes.push(String(params.get('scope') || ''));++ return send(200, { access_token: 'test-token', token_type: 'Bearer', expires_in: 300, scope: String(params.get('scope') || '') });++ }++ if (path === '/api/v1/usage') return send(201, { ok: true });++ if (path === '/internal/credits/holds') {++ const key = body.idempotency_key;++ const prev = state.holds.get(key);++ if (prev) {++ if (prev.fingerprint !== body.request_fingerprint) return send(409, { code: 'billing.hold_key_reused', detail: 'different fingerprint' });++ return send(201, { hold_id: prev.hold_id, held_nano_usd: prev.held_nano_usd, available_nano_usd: String(available() < 0n ? 0n : available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });++ }++ const amount = BigInt(String(body.max_nano_usd));++ if (state.short || available() < amount) return send(402, { code: 'billing.insufficient_funds', detail: `need ${amount}, have ${state.short ? 0 : available()}` });++ const hold = { hold_id: `hld_${String(++state.seq).padStart(26, '0')}`, held_nano_usd: String(amount), fingerprint: body.request_fingerprint, status: 'active' };++ state.holds.set(key, hold);++ state.held += amount;++ return send(201, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });++ }++ let m = /^\/internal\/credits\/holds\/([^/]+)\/extend$/.exec(path);++ if (m) {++ const hold = [...state.holds.values()].find((h) => h.hold_id === decodeURIComponent(m[1]));++ const add = BigInt(String(body.add_nano_usd));++ if (!hold || state.refuseExtend || state.short || available() < add) return send(402, { code: 'billing.insufficient_funds', detail: 'cannot extend' });++ hold.held_nano_usd = String(BigInt(hold.held_nano_usd) + add);++ hold.status = 'extended';++ state.held += add;++ return send(200, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });++ }++ m = /^\/internal\/credits\/holds\/([^/]+)\/settle$/.exec(path);++ if (m) {++ if (state.failSettle > 0) { state.failSettle--; return send(500, { code: 'billing.internal', detail: 'settle is down' }); }++ const prev = state.settlements.get(body.idempotency_key);++ if (prev) return send(201, prev);++ const hold = [...state.holds.values()].find((h) => h.hold_id === decodeURIComponent(m[1]));++ if (!hold) return send(404, { code: 'billing.hold_not_found' });++ const charge = body.byok ? 0n : chargeOf(body.usage, body.retail);++ const held = BigInt(hold.held_nano_usd);++ const out = { charge_nano_usd: String(charge), released_nano_usd: String(charge >= held ? 0n : held - charge), journal_id: `txn_${state.settlements.size + 1}`, shortfall_nano_usd: '0' };++ state.settlements.set(body.idempotency_key, out);++ hold.status = 'settled';++ state.held -= held;++ state.balance -= charge;++ return send(201, out);++ }++ m = /^\/internal\/credits\/holds\/([^/]+)\/release$/.exec(path);++ if (m) {++ const id = decodeURIComponent(m[1]);++ const hold = [...state.holds.values()].find((h) => h.hold_id === id);++ if (hold && hold.status !== 'settled') {++ state.releases.push({ hold_id: id, released_nano_usd: hold.held_nano_usd });++ state.held -= BigInt(hold.held_nano_usd);++ hold.status = 'released';++ }++ return send(200, state.releases[state.releases.length - 1] || { hold_id: id, released_nano_usd: '0' });++ }++ return send(404, { code: 'not_found', detail: path });++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ state.url = `http://127.0.0.1:${server.address().port}`;++ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });++ return state;++}++++/** A local OpenAI-format upstream whose answer each test scripts; it honours max_tokens for its usage. */++async function upstream(kind = 'ok') {++ const state = { kind, calls: 0, bodies: [] };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ let body = {};++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }++ state.calls++;++ state.bodies.push(body);++ const completion = Math.min(12, Number(body.max_tokens || body.max_completion_tokens) || 12);++ if (state.kind === 'fail') {++ res.writeHead(500, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ error: { message: 'upstream is down' } }));++ }++ if (body.stream) {++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ const chunk = (o) => res.write(`data: ${JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion.chunk', model: 'gpt-up', ...o })}\n\n`);++ chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'from ' }, finish_reason: null }] });++ chunk({ choices: [{ index: 0, delta: { content: 'the upstream' }, finish_reason: null }] });++ if (state.kind === 'slow') {++ state.timer = setInterval(() => chunk({ choices: [{ index: 0, delta: { content: ' more' }, finish_reason: null }] }), 25);++ req.on('close', () => clearInterval(state.timer));++ return;++ }++ chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });++ chunk({ choices: [], usage: { prompt_tokens: 60, completion_tokens: completion, total_tokens: 60 + completion } });++ res.write('data: [DONE]\n\n');++ return res.end();++ }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion', created: 1, model: 'gpt-up', choices: [{ index: 0, message: { role: 'assistant', content: 'an answer from upstream' }, finish_reason: 'stop' }], usage: { prompt_tokens: 60, completion_tokens: completion, total_tokens: 60 + completion } }));++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ state.url = `http://127.0.0.1:${server.address().port}/v1`;++ state.close = () => new Promise((r) => { server.closeAllConnections?.(); clearInterval(state.timer); server.close(() => r()); });++ return state;++}++++async function seed(h, up, { cost = { in_per_mtok: 1, out_per_mtok: 2 }, rateVersion = 'retail-1', fallbacks = [] } = {}) {++ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {++ key: 'up', kind: 'openai', base_url: up.url, auth_mode: 'none', default_model: 'gpt-up', capabilities: ['chat'],++ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } });++ assert.ok(made.status < 300, made.text);++ const model = await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {++ provider_key: 'up', model_key: 'gpt-up', type: 'chat', supports: { streaming: true }, cost,++ metadata: { ...(rateVersion ? { rate_version: rateVersion } : {}) } } });++ assert.ok(model.status < 300, model.text);++ const route = await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {++ primary: { provider: 'up', model: 'gpt-up' }, fallbacks, timeout_ms: 5000 } });++ assert.ok(route.status < 300, route.text);++}++++async function bootWith(billing, env = {}) {++ return boot({ env: {++ AI_CREDITS: '1', AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0',++ AI_GOVERN_TIERS: '1', AI_GOVERN_POLICY_JSON: JSON.stringify({ 'ai-token': { free: {} }, 'ai-usd': { free: {} } }),++ OV_BILLING_INTERNAL_URL: billing.url, OV_NETWORK_INTERNAL_URL: billing.url, OV_OAUTH_CLIENT_SECRET: 'test-secret', OV_OAUTH_CLIENT_ID: 'ai',++ USAGE_SAMPLES: 'off',++ ...env,++ } });++}++++const chat = (h, { tok = producer, body = {}, headers = {} } = {}) => request(h.base, 'POST', '/v1/chat/completions', {++ tok, headers, body: { messages: [{ role: 'user', content: 'hello' }], ...body } });++++/** The run id from an OpenAI answer's x_openvibe. */++const runIdOf = (r) => r.body.x_openvibe && r.body.x_openvibe.run_id;++++t.test('a paid app request holds its project\'s Balance: the hold names the app and its project, no Network call', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const first = await chat(h, {});++ assert.strictEqual(first.status, 200, first.text);++ const holds = billing.calls.filter((c) => c.path === '/internal/credits/holds');++ assert.strictEqual(holds.length, 1);++ assert.deepStrictEqual(holds[0].body.subject, { type: 'app', id: APP }, 'an app pays, not its owner');++ assert.strictEqual(holds[0].body.project_id, PRJ, 'the project Balance is the account');++ const second = await chat(h, {});++ assert.strictEqual(second.status, 200, second.text);++ assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');++ assert.ok(!billing.oauthAudiences.includes('openvibe.network'), 'no Network service token was minted');++ assert.ok(billing.oauthScopes.every((scope) => !/network\.project\.read/.test(scope)), 'network.project.read was never requested');++ assert.strictEqual(billing.settlements.size, 2);++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a person\'s own run holds their personal Balance: no project_id and no Network call', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: service, body: {++ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] },++ on_behalf_of: { type: 'user', id: PERSON } } });++ assert.ok(r.status < 300, r.text);++ const holds = billing.calls.filter((c) => c.path === '/internal/credits/holds');++ assert.strictEqual(holds.length, 1);++ assert.deepStrictEqual(holds[0].body.subject, { type: 'user', id: PERSON });++ assert.ok(!('project_id' in holds[0].body), 'a personal hold has no project_id');++ assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('insufficient credit: a 402 billing.insufficient_funds, and the provider is never called', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ billing.short = true;++ const r = await chat(h, {});++ assert.strictEqual(r.status, 402, r.text);++ assert.strictEqual(r.body.error.code, 'billing.insufficient_funds');++ assert.strictEqual(r.body.error.message, 'Not enough Balance');++ assert.strictEqual(up.calls, 0, 'no provider call was made');++ assert.strictEqual(billing.settlements.size, 0);++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('max_cost_usd clamps the provider\'s max_tokens and the settled charge never exceeds the cap', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ // Input is free here so the cap is entirely about output: $0.002/M = 2 nano per token, cap 10 nano = 5 tokens.++ await seed(h, up, { cost: { in_per_mtok: 0, out_per_mtok: 0.002 } });++ const r = await chat(h, { body: { max_tokens: 100, max_cost_usd: '0.00000001' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(up.bodies[0].max_tokens, 5, 'the provider was asked for the affordable output only');++ const settle = billing.calls.filter((c) => c.path.endsWith('/settle'))[0];++ assert.strictEqual(settle.body.usage.output_tokens, 5);++ const charged = BigInt([...billing.settlements.values()][0].charge_nano_usd);++ assert.ok(charged <= 10n, `charge ${charged} <= cap 10 nano`);++ assert.strictEqual(settle.body.retail.version, 'retail-1');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a pricier managed fallback extends the hold by the difference (clamped by max_cost_usd)', async () => {++ const billing = await fakeBilling();++ const bad = await upstream('fail');++ const good = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, bad, { cost: { in_per_mtok: 0, out_per_mtok: 0.001 } });++ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {++ key: 'up2', kind: 'openai', base_url: good.url, auth_mode: 'none', default_model: 'gpt-up2', capabilities: ['chat'],++ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } });++ assert.ok(made.status < 300, made.text);++ assert.ok((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {++ provider_key: 'up2', model_key: 'gpt-up2', type: 'chat', cost: { in_per_mtok: 0, out_per_mtok: 0.004 }, metadata: { rate_version: 'retail-2' } } })).status < 300);++ assert.ok((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {++ primary: { provider: 'up', model: 'gpt-up' }, fallbacks: [{ provider: 'up2', model: 'gpt-up2' }], timeout_ms: 5000 } })).status < 300);++ // max_tokens 100: up holds 100 nano; up2's worst is 400, capped at 250 => 62 output tokens / 248 nano.++ const r = await chat(h, { body: { max_tokens: 100, max_cost_usd: '0.00000025' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(bad.calls, 1);++ assert.strictEqual(good.calls, 1);++ const extend = billing.calls.filter((c) => c.path.endsWith('/extend'));++ assert.strictEqual(extend.length, 1, 'the hold grew for the pricier fallback');++ assert.strictEqual(extend[0].body.add_nano_usd, '148', '250 cap - 100 held, with 62 affordable tokens');++ assert.strictEqual(good.bodies[0].max_tokens, 62, 'the fallback got the clamped output');++ assert.strictEqual(billing.settlements.size, 1, 'one settlement');++ const settle = billing.calls.filter((c) => c.path.endsWith('/settle'))[0];++ assert.strictEqual(settle.body.attempts.length, 2);++ assert.deepStrictEqual(Object.keys(settle.body.attempts[0]).sort(), ['deployment_id', 'ended_at', 'error_code', 'provider_access', 'started_at']);++ } finally { await h.stop(); await bad.close(); await good.close(); await billing.close(); }++});++++t.test('a fallback whose hold cannot be extended is skipped, not run', async () => {++ const billing = await fakeBilling();++ const bad = await upstream('fail');++ const good = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, bad, { cost: { in_per_mtok: 0, out_per_mtok: 0.001 } });++ assert.ok((await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {++ key: 'up2', kind: 'openai', base_url: good.url, auth_mode: 'none', default_model: 'gpt-up2', capabilities: ['chat'],++ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } })).status < 300);++ assert.ok((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {++ provider_key: 'up2', model_key: 'gpt-up2', type: 'chat', cost: { in_per_mtok: 0, out_per_mtok: 0.004 }, metadata: { rate_version: 'retail-2' } } })).status < 300);++ assert.ok((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {++ primary: { provider: 'up', model: 'gpt-up' }, fallbacks: [{ provider: 'up2', model: 'gpt-up2' }], timeout_ms: 5000 } })).status < 300);++ billing.refuseExtend = true;++ const r = await chat(h, { body: { max_tokens: 100 } });++ assert.ok(r.status >= 500, r.text);++ assert.strictEqual(good.calls, 0, 'the pricier fallback never ran');++ // The skipped attempt is named in the run's explain.++ const listed = await request(h.base, 'GET', '/api/v1/runs?limit=1&all=1', { tok: service });++ const attempts = (listed.body.runs[0].explain && listed.body.runs[0].explain.attempts) || [];++ assert.ok(attempts.some((a) => a.status === 'skipped' && a.error === 'hold_not_extended'), JSON.stringify(attempts));++ assert.strictEqual(billing.releases.length, 1, 'the first hold was released');++ } finally { await h.stop(); await bad.close(); await good.close(); await billing.close(); }++});++++t.test('an abort mid-stream settles the partial usage, never a release', async () => {++ const billing = await fakeBilling();++ const up = await upstream('slow');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const res = await fetch(`${h.base}/v1/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${producer}`, 'Content-Type': 'application/json' }, body: JSON.stringify({++ stream: true, messages: [{ role: 'user', content: 'stream please' }] }) });++ assert.strictEqual(res.status, 200);++ const runId = res.headers.get('x-openvibe-run-id');++ assert.match(String(runId), /^run_/);++ const reader = res.body.getReader();++ const { value } = await reader.read();++ assert.ok(value && value.length > 0, 'a first chunk arrived');++ // The cancel route takes first-party service tokens (ai.workflow.manage), not app tokens.++ const cancelled = await request(h.base, 'POST', `/api/v1/runs/${runId}/cancel`, { tok: service });++ assert.ok(cancelled.status < 300, cancelled.text);++ await reader.cancel().catch(() => {});++ let run = null;++ for (let i = 0; i < 100; i++) {++ const got = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: service });++ if (['cancelled', 'failed', 'succeeded'].includes(got.body.run.status)) { run = got.body.run; break; }++ await new Promise((r) => setTimeout(r, 30));++ }++ assert.ok(run, 'the run reached a terminal state');++ assert.strictEqual(run.status, 'cancelled');++ assert.strictEqual(billing.releases.length, 0, 'an abort settles; it does not release');++ assert.strictEqual(billing.settlements.size, 1);++ const settleCall = billing.calls.filter((c) => c.path.endsWith('/settle'))[0];++ assert.ok(settleCall.body.usage.output_tokens > 0, `partial output was settled (${JSON.stringify(settleCall.body.usage)})`);++ assert.ok(BigInt([...billing.settlements.values()][0].charge_nano_usd) > 0n);++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('one settle per request, even when the request is retried with the same request id', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const headers = { 'x-openvibe-request-id': 'req_credits_retry_1' };++ const first = await chat(h, { headers });++ const second = await chat(h, { headers });++ assert.strictEqual(first.status, 200, first.text);++ assert.strictEqual(second.status, 200, second.text);++ const settles = billing.calls.filter((c) => c.path.endsWith('/settle'));++ assert.strictEqual(settles.length, 2, 'each attempt asked Billing');++ assert.strictEqual(billing.settlements.size, 1, '...and Billing settled once: the second is a replay');++ const settled = [...billing.settlements.values()][0];++ assert.ok(BigInt(settled.charge_nano_usd) > 0n);++ assert.strictEqual(settles[0].body.attempts.length, 1, 'the single managed attempt is reported');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a provider failure before the first token releases the hold', async () => {++ const billing = await fakeBilling();++ const up = await upstream('fail');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ const r = await chat(h, {});++ assert.ok(r.status >= 500, r.text);++ assert.strictEqual(billing.releases.length, 1, 'the hold was released');++ assert.strictEqual(billing.settlements.size, 0, 'nothing was charged');++ assert.strictEqual(up.calls, 1);++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a settle Billing cannot take is queued and retried with the same key: exactly one settlement', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up);++ billing.failSettle = 2;++ const r = await chat(h, { headers: { 'x-openvibe-request-id': 'req_credits_queue_1' } });++ assert.strictEqual(r.status, 200, r.text);++ const queued = await h.credits.pendingSettlements();++ assert.strictEqual(queued.length, 1, 'the failed settle is kept');++ assert.strictEqual(queued[0].status, 'queued');++ assert.strictEqual(billing.settlements.size, 0, 'nothing was charged yet');++ billing.failSettle = 0;++ const out = await h.credits.drainSettlements({ force: true });++ assert.strictEqual(out.sent, 1, JSON.stringify(out));++ assert.strictEqual(billing.settlements.size, 1, 'exactly one settlement');++ const after = await h.credits.pendingSettlements();++ assert.strictEqual(after[0].status, 'sent');++ const settles = billing.calls.filter((c) => c.path.endsWith('/settle'));++ assert.strictEqual(settles[0].body.idempotency_key, settles[settles.length - 1].body.idempotency_key, 'the same key was retried');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('no payer: paid offers with a versioned card are skipped (payer_required) and a free offer answers', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up, { fallbacks: [{ provider: 'stub', model: 'stub-1' }] });++ const r = await chat(h, { tok: service });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(up.calls, 0, 'the paid managed offer was not served for free');++ assert.strictEqual(r.body.x_openvibe.provider, 'stub', 'the free local offer answered');++ const run = await request(h.base, 'GET', `/api/v1/runs/${runIdOf(r)}`, { tok: service });++ assert.ok(run.body.run.explain.attempts.some((a) => a.status === 'skipped' && a.error === 'payer_required'), JSON.stringify(run.body.run.explain.attempts));++ assert.strictEqual(billing.settlements.size, 0, 'no Balance was spent');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('an exact id whose only offer has a versioned card fails 402 billing.payer_required for a no-payer run', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {++ key: 'up', kind: 'openai', base_url: up.url, auth_mode: 'none', default_model: 'gpt-up', capabilities: ['chat'],++ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } });++ assert.ok(made.status < 300, made.text);++ assert.ok((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {++ provider_key: 'up', model_key: 'gpt-up', type: 'chat', cost: { in_per_mtok: 1, out_per_mtok: 2 },++ metadata: { id: 'acme/only@r1', rate_version: 'retail-1' } } })).status < 300);++ const r = await chat(h, { tok: service, body: { model: 'acme/only@r1' } });++ assert.strictEqual(r.status, 402, r.text);++ assert.strictEqual(r.body.error.code, 'billing.payer_required');++ assert.strictEqual(up.calls, 0);++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a paid offer without a versioned card behaves as before for a no-payer run', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up, { rateVersion: null });++ const r = await chat(h, { tok: service });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(up.calls, 1, 'no card: the offer is not a Balance offer and still runs');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('a sandbox app token never reaches paid supply, and is never charged (D10)', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing);++ try {++ await seed(h, up, { fallbacks: [{ provider: 'stub', model: 'stub-1' }] });++ const paid = await chat(h, {});++ assert.strictEqual(paid.status, 200, paid.text);++ assert.strictEqual(up.calls, 1);++ const r = await chat(h, { tok: sandbox });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.x_openvibe.provider, 'stub', 'the free local offer answered');++ assert.strictEqual(up.calls, 1, 'paid supply was never called for a sandbox token');++ assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'a sandbox run resolves no payer');++ assert.strictEqual(billing.calls.filter((c) => c.path === '/internal/credits/holds').length, 1, 'only the production run held Balance');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.test('with AI_CREDITS off, a request runs exactly as before and Billing is never called', async () => {++ const billing = await fakeBilling();++ const up = await upstream('ok');++ const h = await bootWith(billing, { AI_CREDITS: '0' });++ try {++ await seed(h, up);++ const r = await chat(h, {});++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.choices[0].message.content, 'an answer from upstream');++ assert.strictEqual(up.calls, 1);++ assert.strictEqual(billing.calls.length, 0, 'no hold, extend, settle or release');++ } finally { await h.stop(); await up.close(); await billing.close(); }++});++++t.run();nnew file mode 100644@@@ -0,0 +1,345 @@++'use strict';++// AI Exchange F / P7 + D12: the Balance session (server/credits.js) against a fake Billing, without HTTP. Covers++// the review's money rules and D12's payer: the worst-case hold at the retail card, a decimal-only max_cost_usd++// that clamps the output, an app holding its project's Balance (subject app + project_id, no Network lookup), a++// person holding their personal Balance (no project_id), a pricier managed fallback extending the hold, one settle++// per request, the partial usage of an abort, release before a first token, BYOK never held, the streaming++// extension, contract-valid request bodies, and AI_CREDITS off.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { suite } = require('./helpers');++const { createCredits, retailOf, parseCostUsd, affordable } = require('../server/credits');++++const t = suite('credits');++++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const APP = 'app_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';++const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++const R1 = { version: 'retail-1', input_nano_usd_per_m: '1000000000', output_nano_usd_per_m: '2000000000', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', per_request_nano_usd: '0' };++const R2 = { version: 'retail-2', input_nano_usd_per_m: '500000000', output_nano_usd_per_m: '4000000000', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', per_request_nano_usd: '0' };++const FACTS = { resolved_model: 'org/model@r1', revision: 'r1', deployment: 'up', provider_access: 'OWN_DEPLOYMENT', rate_version: 'retail-1', policy: null, fallback_reason: null };++const BYOK_FACTS = { ...FACTS, provider_access: 'CUSTOMER_BYOK' };++++/** A tiny Billing: holds/settlements like PR #187. Network is not part of D12's hold path. */++function fakeBilling({ balance = '100000000000' } = {}) {++ const state = { balance: BigInt(balance), held: 0n, holds: new Map(), settlements: new Map(), released: new Map(), calls: [], seq: 0 };++ const available = () => state.balance - state.held;++ const chargeOf = (usage, retail) => {++ const perM = retail.nano_usd_per_m || {};++ const part = (tokens, rate) => {++ const n = BigInt(tokens || 0) * BigInt(rate || 0);++ return n === 0n ? 0n : (n % 1000000n === 0n ? n / 1000000n : n / 1000000n + 1n);++ };++ return part(usage.input_tokens, perM.input) + part(usage.cached_read_tokens, perM.cached_read)++ + part(usage.cache_write_tokens, perM.cache_write) + part(usage.output_tokens, perM.output) + BigInt(retail.per_request_nano_usd || 0);++ };++ async function fetchImpl(url, opts = {}) {++ const path = new URL(url).pathname;++ const body = opts.body ? JSON.parse(opts.body) : {};++ state.calls.push({ path, body });++ if (path === '/internal/credits/holds') {++ const key = body.idempotency_key;++ const prev = state.holds.get(key);++ if (prev) {++ if (prev.fingerprint !== body.request_fingerprint) return json(409, { code: 'billing.hold_key_reused', detail: 'different fingerprint' });++ return json(201, { hold_id: prev.hold_id, held_nano_usd: prev.held_nano_usd, available_nano_usd: String(available() < 0n ? 0n : available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });++ }++ const amount = BigInt(String(body.max_nano_usd));++ if (available() < amount) return json(402, { code: 'billing.insufficient_funds', detail: `need ${amount}, have ${available()}` });++ const hold = { hold_id: `hld_${String(++state.seq).padStart(26, '0')}`, held_nano_usd: String(amount), fingerprint: body.request_fingerprint, status: 'active', rate_version: body.rate_version };++ state.holds.set(key, hold);++ state.held += amount;++ return json(201, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });++ }++ const extend = /^\/internal\/credits\/holds\/([^/]+)\/extend$/.exec(path);++ if (extend) {++ const hold = [...state.holds.values()].find((h) => h.hold_id === decodeURIComponent(extend[1]));++ if (!hold) return json(404, { code: 'billing.hold_not_found' });++ const add = BigInt(String(body.add_nano_usd));++ if (state.refuseExtend) return json(402, { code: 'billing.insufficient_funds', detail: 'cannot extend' });++ if (available() < add) return json(402, { code: 'billing.insufficient_funds', detail: 'cannot extend' });++ hold.held_nano_usd = String(BigInt(hold.held_nano_usd) + add);++ hold.status = 'extended';++ state.held += add;++ return json(200, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });++ }++ const settle = /^\/internal\/credits\/holds\/([^/]+)\/settle$/.exec(path);++ if (settle) {++ const id = decodeURIComponent(settle[1]);++ const prev = state.settlements.get(body.idempotency_key);++ if (prev) return json(201, prev.result);++ const hold = [...state.holds.values()].find((h) => h.hold_id === id);++ if (!hold) return json(404, { code: 'billing.hold_not_found' });++ const charge = body.byok ? 0n : chargeOf(body.usage, body.retail);++ const held = BigInt(hold.held_nano_usd);++ const result = { charge_nano_usd: String(charge), released_nano_usd: String(charge >= held ? 0n : held - charge), journal_id: `txn_${state.settlements.size + 1}`, settlement: { usage: body.usage, retail: body.retail, attempts: body.attempts } };++ state.settlements.set(body.idempotency_key, { hold_id: id, result });++ hold.status = 'settled';++ state.held -= held;++ state.balance -= charge;++ return json(201, result);++ }++ const release = /^\/internal\/credits\/holds\/([^/]+)\/release$/.exec(path);++ if (release) {++ const id = decodeURIComponent(release[1]);++ const hold = [...state.holds.values()].find((h) => h.hold_id === id);++ if (hold && hold.status !== 'settled') {++ state.released.set(id, { hold_id: id, released_nano_usd: hold.held_nano_usd });++ state.held -= BigInt(hold.held_nano_usd);++ hold.status = 'released';++ }++ return json(200, state.released.get(id) || { hold_id: id, released_nano_usd: '0' });++ }++ return json(404, { code: 'not_found', detail: path });++ }++ return { state, fetchImpl, available, chargeOf };++}++++function managerFor(billing, overrides = {}) {++ return createCredits({++ config: { credits: { enabled: true, billingUrl: 'http://billing.test', audience: 'openvibe.billing', timeoutMs: 1000 }, console: { clientId: 'ai' } },++ tokens: { getToken: async () => 'billing-token' },++ fetchImpl: billing.fetchImpl, log: { warn() {}, log() {}, error() {} },++ ...overrides,++ });++}++++function begin(manager, { requestId = 'req_credits_0001', input = {}, extra = {} } = {}) {++ return manager.begin({ requestId, ctx: { runId: 'run_1', requesterType: 'app', requesterId: APP, appId: APP, projectId: PRJ, ...extra }, input: { messages: [{ role: 'user', content: 'hello' }], ...input } });++}++++/** A person's own run: a user subject, and (deliberately) a project attribution the hold must not use. */++function beginPerson(manager, { requestId = 'req_credits_person_0001', input = {}, attribution = { type: 'project', id: PRJ } } = {}) {++ return manager.begin({ requestId, ctx: { runId: 'run_person', requesterType: 'service', requesterId: 'live' }, attribution, onBehalfOf: OWNER, input: { messages: [{ role: 'user', content: 'hello' }], ...input } });++}++++const callsTo = (billing, suffix) => billing.state.calls.filter((c) => c.path.endsWith(suffix));++++t.test('max_cost_usd is an exact decimal: 0.29, 0.000000001, and 0.0000000011 rounding up to 2 nano', () => {++ assert.strictEqual(parseCostUsd(0.29), 290000000n);++ assert.strictEqual(parseCostUsd('0.000000001'), 1n);++ assert.strictEqual(parseCostUsd('0.0000000011'), 2n);++ assert.strictEqual(parseCostUsd('1.0000000004'), 1000000001n);++ assert.strictEqual(parseCostUsd('0'), 0n);++ assert.throws(() => parseCostUsd('1e-9'), (err) => err.status === 422 && err.code === 'input.invalid');++ assert.throws(() => parseCostUsd('0.1e2'), (err) => err.status === 422);++ assert.throws(() => parseCostUsd('-1'), (err) => err.status === 422);++ assert.throws(() => parseCostUsd('abc'), (err) => err.status === 422);++});++++t.test('max_cost_usd caps the worst case by clamping the affordable output tokens in BigInt', () => {++ const retail = retailOf({ ...R1, input_nano_usd_per_m: '0' });++ assert.deepStrictEqual(affordable(retail, { inputTokens: 0, maxTokens: 100, maxCostNano: 10000n }), { maxTokens: 5, amount: 10000n });++ assert.deepStrictEqual(affordable(retail, { inputTokens: 0, maxTokens: 100, maxCostNano: 9999n }).maxTokens, 4);++ assert.throws(() => affordable(retail, { inputTokens: 0, maxTokens: 100, maxCostNano: 1999n }), (err) => err.status === 402 && err.code === 'billing.insufficient_funds');++ assert.deepStrictEqual(affordable(retail, { inputTokens: 0, maxTokens: 3 }), { maxTokens: 3, amount: 6000n });++});++++t.test('an app\'s hold names the app and its project: no Network lookup, and nothing is cached', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ const routed = await session.onRoute(FACTS, { retail: R1, maxTokens: 1000 });++ const call = billing.state.calls.find((c) => c.path === '/internal/credits/holds');++ const expected = BigInt(session.inputTokens) * 1000n + 1000n * 2000n; // $1/M input, $2/M output, in nano++ assert.strictEqual(call.body.max_nano_usd, String(expected));++ assert.strictEqual(call.body.rate_version, 'retail-1');++ assert.deepStrictEqual(call.body.subject, { type: 'app', id: APP }, 'an app is the payer, not its owner');++ assert.strictEqual(call.body.project_id, PRJ, 'the hold spends the project\'s Balance');++ assert.match(call.body.request_fingerprint, /^[0-9a-f]{64}$/);++ assert.deepStrictEqual(routed, { maxTokens: 1000 });++ // A second app request holds too; there is no owner to resolve or cache.++ const second = begin(manager, { requestId: 'req_credits_0002' });++ await second.onRoute(FACTS, { retail: R1, maxTokens: 10 });++ assert.strictEqual(callsTo(billing, '/holds').length, 2);++ assert.strictEqual(billing.state.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');++});++++t.test('a person\'s own run holds their personal Balance: subject user, no project_id', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = beginPerson(manager);++ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ const call = callsTo(billing, '/holds').pop();++ assert.deepStrictEqual(call.body.subject, { type: 'user', id: OWNER });++ assert.ok(!Object.prototype.hasOwnProperty.call(call.body, 'project_id'), 'a personal hold carries no project_id');++ assert.strictEqual(callsTo(billing, '/holds').length, 1);++ assert.strictEqual(billing.state.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');++});++++t.test('an app without a project gets no session, so no unpaid paid offer can run', () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const raw = manager.begin({ requestId: 'req_credits_noproject', ctx: { requesterType: 'app', requesterId: APP, appId: APP }, input: { messages: [] } });++ assert.strictEqual(raw, null);++ assert.strictEqual(billing.state.calls.length, 0);++});++++t.test('a 402 from Billing is AI\'s own 402, raised before the provider (onRoute never returns)', async () => {++ const billing = fakeBilling({ balance: '0' });++ const manager = managerFor(billing);++ const session = begin(manager);++ await assert.rejects(session.onRoute(FACTS, { retail: R1, maxTokens: 100 }), (err) => err.status === 402 && err.code === 'billing.insufficient_funds' && err.detail === 'Not enough Balance');++ assert.strictEqual(session.holdId, null, 'no hold exists to settle or release');++ assert.strictEqual(billing.state.settlements.size, 0);++});++++t.test('one settle per request: the second call is a no-op, and a retried request replays Billing\'s answer', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ const first = await session.settle({ usage: { input: 100, output: 10 }, supplyCostUsd: 0.000001, attempts: [{ deployment_id: 'up:gpt-up', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: null }] });++ const again = await session.settle({ usage: { input: 100, output: 10 } });++ assert.ok(first && first.charge_nano_usd);++ assert.strictEqual(again, null, 'the same session settles once');++ assert.strictEqual(billing.state.settlements.size, 1, 'one settlement');++ // A retried request (same request id) replays the hold and the settlement: no second charge.++ const retried = begin(manager);++ await retried.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ const replay = await retried.settle({ usage: { input: 100, output: 10 }, attempts: [] });++ assert.strictEqual(replay.charge_nano_usd, first.charge_nano_usd);++ assert.strictEqual(billing.state.settlements.size, 1, 'still one settlement, replayed');++ assert.strictEqual(billing.chargeOf({ input_tokens: 100, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 10 }, retailOf(R1)).toString(), first.charge_nano_usd);++});++++t.test('an abort mid-stream settles the partial usage it streamed', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ await session.onRoute(FACTS, { retail: R1, maxTokens: 1000 });++ session.observe({ delta: { content: 'x'.repeat(80) } }); // 20 output tokens estimated++ assert.strictEqual(session.streamedChars, 80);++ await session.settle({ usage: { input: session.inputTokens, output: Math.ceil(session.streamedChars / 4) } });++ const settlement = [...billing.state.settlements.values()][0];++ assert.strictEqual(settlement.result.settlement.usage.output_tokens, 20);++ assert.ok(BigInt(settlement.result.charge_nano_usd) > 0n, 'the partial answer was charged');++});++++t.test('an error before the first token releases the hold with nothing charged', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ const out = await session.release();++ assert.ok(out.released_nano_usd);++ assert.strictEqual(billing.state.settlements.size, 0);++ assert.strictEqual(await session.settle({ usage: { input: 10, output: 0 } }), null);++});++++t.test('a pricier managed fallback extends the hold by the difference, and a refused extension skips it', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ const before = BigInt(session.heldNano);++ const routed = await session.onRoute({ ...FACTS, resolved_model: 'org/two@r1', deployment: 'up2', rate_version: 'retail-2' }, { retail: R2, maxTokens: 100 });++ const extend = callsTo(billing, '/extend').pop();++ const after = BigInt(session.heldNano);++ assert.ok(after > before, 'the hold grew');++ assert.strictEqual(extend.body.add_nano_usd, String(after - before));++ assert.deepStrictEqual(routed, { maxTokens: 100 });++ const out = await session.settle({ usage: { input: 100, output: 20 }, attempts: [++ { deployment_id: 'up:one', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: 'provider.error' },++ { deployment_id: 'up2:two', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: null },++ ] });++ assert.strictEqual(billing.state.settlements.size, 1);++ const saved = [...billing.state.settlements.values()][0];++ assert.strictEqual(saved.result.settlement.retail.version, 'retail-2', 'the card of the model that answered');++ assert.strictEqual(saved.result.settlement.attempts.length, 2);++ assert.ok(out.charge_nano_usd);++++ // The same fallback, but Billing refuses the extension: the offer is skipped, never run.++ const refused = fakeBilling();++ refused.state.refuseExtend = true;++ const m2 = managerFor(refused);++ const s2 = begin(m2);++ await s2.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ const skip = await s2.onRoute({ ...FACTS, resolved_model: 'org/two@r1', deployment: 'up2' }, { retail: R2, maxTokens: 100 });++ assert.deepStrictEqual(skip, { skip: 'hold_not_extended' });++});++++t.test('a stream near the hold is extended in a step (and never past max_cost_usd)', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ // A tiny hold: $1/M output, max_tokens 1 => 1000 nano-USD held for the output.++ const cheap = { ...R1, input_nano_usd_per_m: '0', output_nano_usd_per_m: '1000000000' };++ await session.onRoute(FACTS, { retail: cheap, maxTokens: 1 });++ const hold = BigInt(session.heldNano);++ assert.ok(hold > 0n);++ // 40 chars => 10 output tokens => 10,000 nano, past 80% of the hold.++ session.observe({ delta: { content: 'y'.repeat(40) } });++ await new Promise((r) => setTimeout(r, 20));++ const extend = billing.state.calls.find((c) => c.path.endsWith('/extend'));++ assert.ok(extend, 'the hold was extended');++ assert.ok(BigInt(extend.body.add_nano_usd) > 0n);++++ // max_cost_usd caps the total: a second session holds at most the cap and clamps its max_tokens.++ const capped = begin(manager, { requestId: 'req_credits_cap', extra: { maxCostNano: '2000' } });++ const routed = await capped.onRoute(FACTS, { retail: cheap, maxTokens: 16000 });++ assert.deepStrictEqual(routed, { maxTokens: 2 });++ assert.strictEqual(capped.heldNano, 2000n);++});++++t.test('the outgoing app and personal hold, extend and settle bodies validate against v0.289.1', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ await session.onRoute({ ...FACTS, resolved_model: 'org/two@r1', deployment: 'up2', rate_version: 'retail-2' }, { retail: R2, maxTokens: 100 });++ await session.settle({ usage: { input: 100, output: 20 }, attempts: [++ { deployment_id: 'up:one', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: 'provider.error' },++ { deployment_id: 'up2:two', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: null },++ ] });++ const person = beginPerson(manager);++ await person.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ await person.settle({ usage: { input: 100, output: 20 }, attempts: [] });++ const holds = callsTo(billing, '/holds').map((c) => c.body);++ const extend = callsTo(billing, '/extend').pop().body;++ const settle = callsTo(billing, '/settle')[0].body;++ assert.strictEqual(holds.length, 2);++ assert.strictEqual(holds[0].project_id, PRJ, 'the app hold names its project');++ assert.ok(!('project_id' in holds[1]), 'the personal hold does not');++ for (const hold of holds) {++ const v = contracts.validate('billing.credit-hold-create-request@1', hold);++ assert.ok(v.valid, `billing.credit-hold-create-request@1: ${JSON.stringify(v.errors && v.errors.slice(0, 3))}`);++ }++ for (const [schema, body] of [['billing.credit-hold-extend-request@1', extend], ['billing.credit-hold-settle-request@1', settle]]) {++ const v = contracts.validate(schema, body);++ assert.ok(v.valid, `${schema}: ${JSON.stringify(v.errors && v.errors.slice(0, 3))}`);++ }++ assert.deepStrictEqual(Object.keys(settle.attempts[0]).sort(), ['deployment_id', 'ended_at', 'error_code', 'provider_access', 'started_at'], 'the contract\'s exact attempt shape');++});++++t.test('BYOK is never held, never charged and Billing is never called', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ const session = begin(manager);++ await session.onRoute(BYOK_FACTS, { retail: R1, maxTokens: 100 }); // a person's own key: no meta, or CUSTOMER_BYOK++ await session.onRoute(BYOK_FACTS);++ assert.strictEqual(session.holdId, null);++ assert.strictEqual(billing.state.calls.length, 0, 'Billing was never called');++ assert.strictEqual(await session.settle({ usage: { input: 100, output: 20 } }), null);++});++++t.test('no payer means no session; a person without a project does get one; no Billing refuses paid offers', async () => {++ const billing = fakeBilling();++ const manager = managerFor(billing);++ assert.strictEqual(manager.begin({ requestId: 'req_1', ctx: { requesterType: 'service', requesterId: 'live' } }), null, 'a first-party service with no person has no payer');++ const person = manager.begin({ requestId: 'req_2', ctx: { requesterType: 'service', requesterId: 'live' }, onBehalfOf: OWNER });++ assert.ok(person, 'a person gets a personal session with no project');++ const created = await person.onRoute(FACTS, { retail: R1, maxTokens: 100 });++ assert.deepStrictEqual(created, { maxTokens: 100 });++ const hold = callsTo(billing, '/holds')[0].body;++ assert.deepStrictEqual(hold.subject, { type: 'user', id: OWNER });++ assert.ok(!('project_id' in hold));++ assert.strictEqual(createCredits({ config: { credits: { enabled: false } }, tokens: { getToken: async () => 't' }, fetchImpl: billing.fetchImpl }), null, 'AI_CREDITS off -> no manager, nothing changes');++ // Billing on, Billing URL missing: a paid managed offer must refuse rather than run free.++ const unconfigured = createCredits({ config: { credits: { enabled: true, billingUrl: '' }, console: { clientId: 'ai' } }, env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' }, tokens: { getToken: async () => 't' }, fetchImpl: billing.fetchImpl, log: { warn() {}, log() {}, error() {} } });++ const blocked = unconfigured.begin({ requestId: 'req_4', ctx: { requesterType: 'service', requesterId: 'live' }, onBehalfOf: OWNER });++ await assert.rejects(blocked.onRoute(FACTS, { retail: R1, maxTokens: 100 }), (err) => err.status === 503 && err.code === 'billing.unavailable');++});++++t.run();nnew file mode 100644@@@ -0,0 +1,1055 @@++{++ "protocol": "anthropic-messages",++ "source": "Shapes transcribed from the public Anthropic Messages API reference and the official Anthropic SDK examples (request, non-streaming message, and the named SSE events message_start / content_block_* / message_delta / message_stop). No production call was made. The Messages API has no response_format; structured JSON is requested through a forced tool, which the json_structured scenario covers.",++ "scenarios": {++ "text": {++ "request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "system": "Be concise.",++ "messages": [++ {++ "role": "user",++ "content": "Say hello."++ }++ ]++ },++ "stream_request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "system": "Be concise.",++ "messages": [++ {++ "role": "user",++ "content": "Say hello."++ }++ ],++ "stream": true++ },++ "response": {++ "id": "msg_up_text",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-text",++ "content": [++ {++ "type": "text",++ "text": "Hello there"++ }++ ],++ "stop_reason": "end_turn",++ "stop_sequence": null,++ "usage": {++ "input_tokens": 18,++ "output_tokens": 3++ }++ },++ "stream": [++ {++ "event": "message_start",++ "data": {++ "type": "message_start",++ "message": {++ "id": "msg_up_text",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-text",++ "content": [],++ "stop_reason": null,++ "stop_sequence": null,++ "usage": {++ "input_tokens": 18,++ "output_tokens": 0++ }++ }++ }++ },++ {++ "event": "content_block_start",++ "data": {++ "type": "content_block_start",++ "index": 0,++ "content_block": {++ "type": "text",++ "text": ""++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": "Hello"++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": " there"++ }++ }++ },++ {++ "event": "content_block_stop",++ "data": {++ "type": "content_block_stop",++ "index": 0++ }++ },++ {++ "event": "message_delta",++ "data": {++ "type": "message_delta",++ "delta": {++ "stop_reason": "end_turn",++ "stop_sequence": null++ },++ "usage": {++ "output_tokens": 3++ }++ }++ },++ {++ "event": "message_stop",++ "data": {++ "type": "message_stop"++ }++ }++ ]++ },++ "tool_use": {++ "request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "What is the weather in Lisbon?"++ }++ ],++ "tools": [++ {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "input_schema": {++ "type": "object",++ "properties": {++ "city": {++ "type": "string"++ }++ },++ "required": [++ "city"++ ]++ }++ }++ ],++ "tool_choice": {++ "type": "auto"++ }++ },++ "stream_request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "What is the weather in Lisbon?"++ }++ ],++ "tools": [++ {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "input_schema": {++ "type": "object",++ "properties": {++ "city": {++ "type": "string"++ }++ },++ "required": [++ "city"++ ]++ }++ }++ ],++ "tool_choice": {++ "type": "auto"++ },++ "stream": true++ },++ "response": {++ "id": "msg_up_tool",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-tool",++ "content": [++ {++ "type": "text",++ "text": "Let me check."++ },++ {++ "type": "tool_use",++ "id": "toolu_weather_1",++ "name": "get_weather",++ "input": {++ "city": "Lisbon"++ }++ }++ ],++ "stop_reason": "tool_use",++ "stop_sequence": null,++ "usage": {++ "input_tokens": 50,++ "output_tokens": 12++ }++ },++ "stream": [++ {++ "event": "message_start",++ "data": {++ "type": "message_start",++ "message": {++ "id": "msg_up_tool",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-tool",++ "content": [],++ "stop_reason": null,++ "stop_sequence": null,++ "usage": {++ "input_tokens": 50,++ "output_tokens": 0++ }++ }++ }++ },++ {++ "event": "content_block_start",++ "data": {++ "type": "content_block_start",++ "index": 0,++ "content_block": {++ "type": "text",++ "text": ""++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": "Let me check."++ }++ }++ },++ {++ "event": "content_block_stop",++ "data": {++ "type": "content_block_stop",++ "index": 0++ }++ },++ {++ "event": "content_block_start",++ "data": {++ "type": "content_block_start",++ "index": 1,++ "content_block": {++ "type": "tool_use",++ "id": "toolu_weather_1",++ "name": "get_weather",++ "input": {}++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 1,++ "delta": {++ "type": "input_json_delta",++ "partial_json": "{\"city\":\"Lisbon\"}"++ }++ }++ },++ {++ "event": "content_block_stop",++ "data": {++ "type": "content_block_stop",++ "index": 1++ }++ },++ {++ "event": "message_delta",++ "data": {++ "type": "message_delta",++ "delta": {++ "stop_reason": "tool_use",++ "stop_sequence": null++ },++ "usage": {++ "output_tokens": 12++ }++ }++ },++ {++ "event": "message_stop",++ "data": {++ "type": "message_stop"++ }++ }++ ]++ },++ "tool_result": {++ "request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "What is the weather in Lisbon?"++ },++ {++ "role": "assistant",++ "content": [++ {++ "type": "text",++ "text": "Let me check."++ },++ {++ "type": "tool_use",++ "id": "toolu_weather_1",++ "name": "get_weather",++ "input": {++ "city": "Lisbon"++ }++ }++ ]++ },++ {++ "role": "user",++ "content": [++ {++ "type": "tool_result",++ "tool_use_id": "toolu_weather_1",++ "content": "19 C and sunny"++ }++ ]++ }++ ],++ "tools": [++ {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "input_schema": {++ "type": "object",++ "properties": {++ "city": {++ "type": "string"++ }++ },++ "required": [++ "city"++ ]++ }++ }++ ]++ },++ "stream_request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "What is the weather in Lisbon?"++ },++ {++ "role": "assistant",++ "content": [++ {++ "type": "text",++ "text": "Let me check."++ },++ {++ "type": "tool_use",++ "id": "toolu_weather_1",++ "name": "get_weather",++ "input": {++ "city": "Lisbon"++ }++ }++ ]++ },++ {++ "role": "user",++ "content": [++ {++ "type": "tool_result",++ "tool_use_id": "toolu_weather_1",++ "content": "19 C and sunny"++ }++ ]++ }++ ],++ "tools": [++ {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "input_schema": {++ "type": "object",++ "properties": {++ "city": {++ "type": "string"++ }++ },++ "required": [++ "city"++ ]++ }++ }++ ],++ "stream": true++ },++ "response": {++ "id": "msg_up_tool_result",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-tool-result",++ "content": [++ {++ "type": "text",++ "text": "It is 19 C and sunny in Lisbon."++ }++ ],++ "stop_reason": "end_turn",++ "stop_sequence": null,++ "usage": {++ "input_tokens": 68,++ "output_tokens": 8++ }++ },++ "stream": [++ {++ "event": "message_start",++ "data": {++ "type": "message_start",++ "message": {++ "id": "msg_up_tool_result",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-tool-result",++ "content": [],++ "stop_reason": null,++ "stop_sequence": null,++ "usage": {++ "input_tokens": 68,++ "output_tokens": 0++ }++ }++ }++ },++ {++ "event": "content_block_start",++ "data": {++ "type": "content_block_start",++ "index": 0,++ "content_block": {++ "type": "text",++ "text": ""++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": "It is 19 C"++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": " and sunny in Lisbon."++ }++ }++ },++ {++ "event": "content_block_stop",++ "data": {++ "type": "content_block_stop",++ "index": 0++ }++ },++ {++ "event": "message_delta",++ "data": {++ "type": "message_delta",++ "delta": {++ "stop_reason": "end_turn",++ "stop_sequence": null++ },++ "usage": {++ "output_tokens": 8++ }++ }++ },++ {++ "event": "message_stop",++ "data": {++ "type": "message_stop"++ }++ }++ ]++ },++ "json_structured": {++ "request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "Weather in Lisbon as structured JSON."++ }++ ],++ "tools": [++ {++ "name": "weather",++ "description": "Return the weather result.",++ "input_schema": {++ "type": "object",++ "properties": {++ "city": {++ "type": "string"++ },++ "temp_c": {++ "type": "integer"++ },++ "conditions": {++ "type": "string"++ }++ },++ "required": [++ "city",++ "temp_c",++ "conditions"++ ]++ }++ }++ ],++ "tool_choice": {++ "type": "tool",++ "name": "weather"++ },++ "system": "Return the answer through the tool."++ },++ "stream_request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "Weather in Lisbon as structured JSON."++ }++ ],++ "tools": [++ {++ "name": "weather",++ "description": "Return the weather result.",++ "input_schema": {++ "type": "object",++ "properties": {++ "city": {++ "type": "string"++ },++ "temp_c": {++ "type": "integer"++ },++ "conditions": {++ "type": "string"++ }++ },++ "required": [++ "city",++ "temp_c",++ "conditions"++ ]++ }++ }++ ],++ "tool_choice": {++ "type": "tool",++ "name": "weather"++ },++ "stream": true,++ "system": "Return the answer through the tool."++ },++ "response": {++ "id": "msg_up_json",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-json",++ "content": [++ {++ "type": "tool_use",++ "id": "toolu_weather_json",++ "name": "weather",++ "input": {++ "city": "Lisbon",++ "temp_c": 19,++ "conditions": "sunny"++ }++ }++ ],++ "stop_reason": "tool_use",++ "stop_sequence": null,++ "usage": {++ "input_tokens": 39,++ "output_tokens": 15++ }++ },++ "stream": [++ {++ "event": "message_start",++ "data": {++ "type": "message_start",++ "message": {++ "id": "msg_up_json",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-json",++ "content": [],++ "stop_reason": null,++ "stop_sequence": null,++ "usage": {++ "input_tokens": 39,++ "output_tokens": 0++ }++ }++ }++ },++ {++ "event": "content_block_start",++ "data": {++ "type": "content_block_start",++ "index": 0,++ "content_block": {++ "type": "tool_use",++ "id": "toolu_weather_json",++ "name": "weather",++ "input": {}++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "input_json_delta",++ "partial_json": "{\"city\":\"Lisbon\",\"temp_c\":19,"++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "input_json_delta",++ "partial_json": "\"conditions\":\"sunny\"}"++ }++ }++ },++ {++ "event": "content_block_stop",++ "data": {++ "type": "content_block_stop",++ "index": 0++ }++ },++ {++ "event": "message_delta",++ "data": {++ "type": "message_delta",++ "delta": {++ "stop_reason": "tool_use",++ "stop_sequence": null++ },++ "usage": {++ "output_tokens": 15++ }++ }++ },++ {++ "event": "message_stop",++ "data": {++ "type": "message_stop"++ }++ }++ ]++ },++ "multi_turn": {++ "request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "system": "You are a concise travel assistant.",++ "messages": [++ {++ "role": "user",++ "content": "I am going to Lisbon."++ },++ {++ "role": "assistant",++ "content": "Lisbon is a good choice."++ },++ {++ "role": "user",++ "content": "What should I pack?"++ }++ ]++ },++ "stream_request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "system": "You are a concise travel assistant.",++ "messages": [++ {++ "role": "user",++ "content": "I am going to Lisbon."++ },++ {++ "role": "assistant",++ "content": "Lisbon is a good choice."++ },++ {++ "role": "user",++ "content": "What should I pack?"++ }++ ],++ "stream": true++ },++ "response": {++ "id": "msg_up_multi",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-multi",++ "content": [++ {++ "type": "text",++ "text": "Pack a light jacket and sunscreen."++ }++ ],++ "stop_reason": "end_turn",++ "stop_sequence": null,++ "usage": {++ "input_tokens": 51,++ "output_tokens": 8++ }++ },++ "stream": [++ {++ "event": "message_start",++ "data": {++ "type": "message_start",++ "message": {++ "id": "msg_up_multi",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-multi",++ "content": [],++ "stop_reason": null,++ "stop_sequence": null,++ "usage": {++ "input_tokens": 51,++ "output_tokens": 0++ }++ }++ }++ },++ {++ "event": "content_block_start",++ "data": {++ "type": "content_block_start",++ "index": 0,++ "content_block": {++ "type": "text",++ "text": ""++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": "Pack a light jacket"++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": " and sunscreen."++ }++ }++ },++ {++ "event": "content_block_stop",++ "data": {++ "type": "content_block_stop",++ "index": 0++ }++ },++ {++ "event": "message_delta",++ "data": {++ "type": "message_delta",++ "delta": {++ "stop_reason": "end_turn",++ "stop_sequence": null++ },++ "usage": {++ "output_tokens": 8++ }++ }++ },++ {++ "event": "message_stop",++ "data": {++ "type": "message_stop"++ }++ }++ ]++ },++ "exact_id": {++ "request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "Who answered this exact id?"++ }++ ]++ },++ "stream_request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 256,++ "messages": [++ {++ "role": "user",++ "content": "Who answered this exact id?"++ }++ ],++ "stream": true++ },++ "response": {++ "id": "msg_up_exact",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-exact",++ "content": [++ {++ "type": "text",++ "text": "the exact artifact answered"++ }++ ],++ "stop_reason": "end_turn",++ "stop_sequence": null,++ "usage": {++ "input_tokens": 12,++ "output_tokens": 5++ }++ },++ "stream": [++ {++ "event": "message_start",++ "data": {++ "type": "message_start",++ "message": {++ "id": "msg_up_exact",++ "type": "message",++ "role": "assistant",++ "model": "claude-up-exact",++ "content": [],++ "stop_reason": null,++ "stop_sequence": null,++ "usage": {++ "input_tokens": 12,++ "output_tokens": 0++ }++ }++ }++ },++ {++ "event": "content_block_start",++ "data": {++ "type": "content_block_start",++ "index": 0,++ "content_block": {++ "type": "text",++ "text": ""++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": "the exact artifact"++ }++ }++ },++ {++ "event": "content_block_delta",++ "data": {++ "type": "content_block_delta",++ "index": 0,++ "delta": {++ "type": "text_delta",++ "text": " answered"++ }++ }++ },++ {++ "event": "content_block_stop",++ "data": {++ "type": "content_block_stop",++ "index": 0++ }++ },++ {++ "event": "message_delta",++ "data": {++ "type": "message_delta",++ "delta": {++ "stop_reason": "end_turn",++ "stop_sequence": null++ },++ "usage": {++ "output_tokens": 5++ }++ }++ },++ {++ "event": "message_stop",++ "data": {++ "type": "message_stop"++ }++ }++ ]++ }++ },++ "refusals": {++ "exact_missing": {++ "request": {++ "model": "anthropic/nope@rev0",++ "max_tokens": 64,++ "messages": [++ {++ "role": "user",++ "content": "This artifact does not exist."++ }++ ]++ },++ "stream_request": {++ "model": "anthropic/nope@rev0",++ "max_tokens": 64,++ "messages": [++ {++ "role": "user",++ "content": "This artifact does not exist."++ }++ ],++ "stream": true++ },++ "status": 404,++ "code": "ai.model_not_found"++ },++ "exact_unavailable": {++ "request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 64,++ "messages": [++ {++ "role": "user",++ "content": "The artifact has no active deployment."++ }++ ]++ },++ "stream_request": {++ "model": "anthropic/claude-test@revA",++ "max_tokens": 64,++ "messages": [++ {++ "role": "user",++ "content": "The artifact has no active deployment."++ }++ ],++ "stream": true++ },++ "status": 400,++ "code": "ai.model_unavailable"++ }++ },++ "aliases": [++ {++ "model": "openvibe",++ "policy": "auto"++ },++ {++ "model": "openvibe/auto",++ "policy": "auto"++ },++ {++ "model": "openvibe/economy",++ "policy": "cheapest"++ },++ {++ "model": "openvibe/balanced",++ "policy": "balanced"++ },++ {++ "model": "openvibe/fast",++ "policy": "fastest"++ },++ {++ "model": "openvibe/private",++ "policy": "private",++ "deployment": "delta-local"++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,653 @@++{++ "protocol": "openai-chat-completions",++ "source": "Shapes transcribed from the public OpenAI Chat Completions API reference and the official OpenAI SDK examples (request, non-streaming response, streamed chat.completion.chunk, [DONE]). No production call was made.",++ "scenarios": {++ "text": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "Be concise." },++ { "role": "user", "content": "Say hello." }++ ]++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "Be concise." },++ { "role": "user", "content": "Say hello." }++ ],++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "response": {++ "id": "chatcmpl-up-text",++ "object": "chat.completion",++ "created": 1762300000,++ "model": "gpt-up-text",++ "choices": [++ {++ "index": 0,++ "message": { "role": "assistant", "content": "Hello there" },++ "finish_reason": "stop",++ "logprobs": null++ }++ ],++ "usage": { "prompt_tokens": 18, "completion_tokens": 3, "total_tokens": 21 },++ "system_fingerprint": "fp_text"++ },++ "stream": [++ {++ "id": "chatcmpl-up-text",++ "object": "chat.completion.chunk",++ "created": 1762300000,++ "model": "gpt-up-text",++ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "Hello" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-text",++ "object": "chat.completion.chunk",++ "created": 1762300000,++ "model": "gpt-up-text",++ "choices": [{ "index": 0, "delta": { "content": " there" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-text",++ "object": "chat.completion.chunk",++ "created": 1762300000,++ "model": "gpt-up-text",++ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]++ },++ {++ "id": "chatcmpl-up-text",++ "object": "chat.completion.chunk",++ "created": 1762300000,++ "model": "gpt-up-text",++ "choices": [],++ "usage": { "prompt_tokens": 18, "completion_tokens": 3, "total_tokens": 21 }++ }++ ]++ },++ "tool_call": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [{ "role": "user", "content": "What is the weather in Lisbon?" }],++ "tools": [++ {++ "type": "function",++ "function": {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "parameters": {++ "type": "object",++ "properties": { "city": { "type": "string" } },++ "required": ["city"],++ "additionalProperties": false++ },++ "strict": true++ }++ }++ ],++ "tool_choice": "auto"++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [{ "role": "user", "content": "What is the weather in Lisbon?" }],++ "tools": [++ {++ "type": "function",++ "function": {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "parameters": {++ "type": "object",++ "properties": { "city": { "type": "string" } },++ "required": ["city"],++ "additionalProperties": false++ },++ "strict": true++ }++ }++ ],++ "tool_choice": "auto",++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "response": {++ "id": "chatcmpl-up-tool",++ "object": "chat.completion",++ "created": 1762300001,++ "model": "gpt-up-tool",++ "choices": [++ {++ "index": 0,++ "message": {++ "role": "assistant",++ "content": null,++ "tool_calls": [++ {++ "id": "call_weather_1",++ "type": "function",++ "function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\"}" }++ }++ ]++ },++ "finish_reason": "tool_calls",++ "logprobs": null++ }++ ],++ "usage": { "prompt_tokens": 42, "completion_tokens": 17, "total_tokens": 59 },++ "system_fingerprint": "fp_tool"++ },++ "stream": [++ {++ "id": "chatcmpl-up-tool",++ "object": "chat.completion.chunk",++ "created": 1762300001,++ "model": "gpt-up-tool",++ "choices": [{++ "index": 0,++ "delta": {++ "role": "assistant",++ "content": null,++ "tool_calls": [{++ "index": 0,++ "id": "call_weather_1",++ "type": "function",++ "function": { "name": "get_weather", "arguments": "" }++ }]++ },++ "finish_reason": null++ }]++ },++ {++ "id": "chatcmpl-up-tool",++ "object": "chat.completion.chunk",++ "created": 1762300001,++ "model": "gpt-up-tool",++ "choices": [{++ "index": 0,++ "delta": { "tool_calls": [{ "index": 0, "function": { "arguments": "{\"city\":" } }] },++ "finish_reason": null++ }]++ },++ {++ "id": "chatcmpl-up-tool",++ "object": "chat.completion.chunk",++ "created": 1762300001,++ "model": "gpt-up-tool",++ "choices": [{++ "index": 0,++ "delta": { "tool_calls": [{ "index": 0, "function": { "arguments": "\"Lisbon\"}" } }] },++ "finish_reason": null++ }]++ },++ {++ "id": "chatcmpl-up-tool",++ "object": "chat.completion.chunk",++ "created": 1762300001,++ "model": "gpt-up-tool",++ "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }]++ },++ {++ "id": "chatcmpl-up-tool",++ "object": "chat.completion.chunk",++ "created": 1762300001,++ "model": "gpt-up-tool",++ "choices": [],++ "usage": { "prompt_tokens": 42, "completion_tokens": 17, "total_tokens": 59 }++ }++ ]++ },++ "tool_result": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "user", "content": "What is the weather in Lisbon?" },++ {++ "role": "assistant",++ "content": null,++ "tool_calls": [++ {++ "id": "call_weather_1",++ "type": "function",++ "function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\"}" }++ }++ ]++ },++ { "role": "tool", "tool_call_id": "call_weather_1", "content": "19 C and sunny" }++ ],++ "tools": [++ {++ "type": "function",++ "function": {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "parameters": {++ "type": "object",++ "properties": { "city": { "type": "string" } },++ "required": ["city"],++ "additionalProperties": false++ },++ "strict": true++ }++ }++ ]++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "user", "content": "What is the weather in Lisbon?" },++ {++ "role": "assistant",++ "content": null,++ "tool_calls": [++ {++ "id": "call_weather_1",++ "type": "function",++ "function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\"}" }++ }++ ]++ },++ { "role": "tool", "tool_call_id": "call_weather_1", "content": "19 C and sunny" }++ ],++ "tools": [++ {++ "type": "function",++ "function": {++ "name": "get_weather",++ "description": "Get the current weather for a city.",++ "parameters": {++ "type": "object",++ "properties": { "city": { "type": "string" } },++ "required": ["city"],++ "additionalProperties": false++ },++ "strict": true++ }++ }++ ],++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "response": {++ "id": "chatcmpl-up-tool-result",++ "object": "chat.completion",++ "created": 1762300002,++ "model": "gpt-up-tool-result",++ "choices": [++ {++ "index": 0,++ "message": { "role": "assistant", "content": "It is 19 C and sunny in Lisbon." },++ "finish_reason": "stop",++ "logprobs": null++ }++ ],++ "usage": { "prompt_tokens": 58, "completion_tokens": 8, "total_tokens": 66 },++ "system_fingerprint": "fp_tool_result"++ },++ "stream": [++ {++ "id": "chatcmpl-up-tool-result",++ "object": "chat.completion.chunk",++ "created": 1762300002,++ "model": "gpt-up-tool-result",++ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "It is " }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-tool-result",++ "object": "chat.completion.chunk",++ "created": 1762300002,++ "model": "gpt-up-tool-result",++ "choices": [{ "index": 0, "delta": { "content": "19 C and sunny in Lisbon." }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-tool-result",++ "object": "chat.completion.chunk",++ "created": 1762300002,++ "model": "gpt-up-tool-result",++ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]++ },++ {++ "id": "chatcmpl-up-tool-result",++ "object": "chat.completion.chunk",++ "created": 1762300002,++ "model": "gpt-up-tool-result",++ "choices": [],++ "usage": { "prompt_tokens": 58, "completion_tokens": 8, "total_tokens": 66 }++ }++ ]++ },++ "json_object": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "Answer with a JSON object." },++ { "role": "user", "content": "Weather in Lisbon as JSON." }++ ],++ "response_format": { "type": "json_object" }++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "Answer with a JSON object." },++ { "role": "user", "content": "Weather in Lisbon as JSON." }++ ],++ "response_format": { "type": "json_object" },++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "response": {++ "id": "chatcmpl-up-json-object",++ "object": "chat.completion",++ "created": 1762300003,++ "model": "gpt-up-json-object",++ "choices": [++ {++ "index": 0,++ "message": { "role": "assistant", "content": "{\"city\":\"Lisbon\",\"temp_c\":19}" },++ "finish_reason": "stop",++ "logprobs": null++ }++ ],++ "usage": { "prompt_tokens": 31, "completion_tokens": 11, "total_tokens": 42 },++ "system_fingerprint": "fp_json_object"++ },++ "stream": [++ {++ "id": "chatcmpl-up-json-object",++ "object": "chat.completion.chunk",++ "created": 1762300003,++ "model": "gpt-up-json-object",++ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "{\"city\":" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-json-object",++ "object": "chat.completion.chunk",++ "created": 1762300003,++ "model": "gpt-up-json-object",++ "choices": [{ "index": 0, "delta": { "content": "\"Lisbon\",\"temp_c\":19}" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-json-object",++ "object": "chat.completion.chunk",++ "created": 1762300003,++ "model": "gpt-up-json-object",++ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]++ },++ {++ "id": "chatcmpl-up-json-object",++ "object": "chat.completion.chunk",++ "created": 1762300003,++ "model": "gpt-up-json-object",++ "choices": [],++ "usage": { "prompt_tokens": 31, "completion_tokens": 11, "total_tokens": 42 }++ }++ ]++ },++ "json_schema": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "Answer with a JSON object that matches the schema." },++ { "role": "user", "content": "Weather in Lisbon as JSON." }++ ],++ "response_format": {++ "type": "json_schema",++ "json_schema": {++ "name": "weather",++ "strict": true,++ "schema": {++ "type": "object",++ "properties": {++ "city": { "type": "string" },++ "temp_c": { "type": "integer" },++ "conditions": { "type": "string" }++ },++ "required": ["city", "temp_c", "conditions"],++ "additionalProperties": false++ }++ }++ }++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "Answer with a JSON object that matches the schema." },++ { "role": "user", "content": "Weather in Lisbon as JSON." }++ ],++ "response_format": {++ "type": "json_schema",++ "json_schema": {++ "name": "weather",++ "strict": true,++ "schema": {++ "type": "object",++ "properties": {++ "city": { "type": "string" },++ "temp_c": { "type": "integer" },++ "conditions": { "type": "string" }++ },++ "required": ["city", "temp_c", "conditions"],++ "additionalProperties": false++ }++ }++ },++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "response": {++ "id": "chatcmpl-up-json-schema",++ "object": "chat.completion",++ "created": 1762300004,++ "model": "gpt-up-json-schema",++ "choices": [++ {++ "index": 0,++ "message": { "role": "assistant", "content": "{\"city\":\"Lisbon\",\"temp_c\":19,\"conditions\":\"sunny\"}" },++ "finish_reason": "stop",++ "logprobs": null++ }++ ],++ "usage": { "prompt_tokens": 44, "completion_tokens": 14, "total_tokens": 58 },++ "system_fingerprint": "fp_json_schema"++ },++ "stream": [++ {++ "id": "chatcmpl-up-json-schema",++ "object": "chat.completion.chunk",++ "created": 1762300004,++ "model": "gpt-up-json-schema",++ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "{\"city\":\"Lisbon\"," }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-json-schema",++ "object": "chat.completion.chunk",++ "created": 1762300004,++ "model": "gpt-up-json-schema",++ "choices": [{ "index": 0, "delta": { "content": "\"temp_c\":19,\"conditions\":\"sunny\"}" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-json-schema",++ "object": "chat.completion.chunk",++ "created": 1762300004,++ "model": "gpt-up-json-schema",++ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]++ },++ {++ "id": "chatcmpl-up-json-schema",++ "object": "chat.completion.chunk",++ "created": 1762300004,++ "model": "gpt-up-json-schema",++ "choices": [],++ "usage": { "prompt_tokens": 44, "completion_tokens": 14, "total_tokens": 58 }++ }++ ]++ },++ "multi_turn": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "You are a concise travel assistant." },++ { "role": "user", "content": "I am going to Lisbon." },++ { "role": "assistant", "content": "Lisbon is a good choice." },++ { "role": "user", "content": "What should I pack?" }++ ]++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [++ { "role": "system", "content": "You are a concise travel assistant." },++ { "role": "user", "content": "I am going to Lisbon." },++ { "role": "assistant", "content": "Lisbon is a good choice." },++ { "role": "user", "content": "What should I pack?" }++ ],++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "response": {++ "id": "chatcmpl-up-multi",++ "object": "chat.completion",++ "created": 1762300005,++ "model": "gpt-up-multi",++ "choices": [++ {++ "index": 0,++ "message": { "role": "assistant", "content": "Pack a light jacket and sunscreen." },++ "finish_reason": "stop",++ "logprobs": null++ }++ ],++ "usage": { "prompt_tokens": 51, "completion_tokens": 8, "total_tokens": 59 },++ "system_fingerprint": "fp_multi"++ },++ "stream": [++ {++ "id": "chatcmpl-up-multi",++ "object": "chat.completion.chunk",++ "created": 1762300005,++ "model": "gpt-up-multi",++ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "Pack a light jacket" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-multi",++ "object": "chat.completion.chunk",++ "created": 1762300005,++ "model": "gpt-up-multi",++ "choices": [{ "index": 0, "delta": { "content": " and sunscreen." }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-multi",++ "object": "chat.completion.chunk",++ "created": 1762300005,++ "model": "gpt-up-multi",++ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]++ },++ {++ "id": "chatcmpl-up-multi",++ "object": "chat.completion.chunk",++ "created": 1762300005,++ "model": "gpt-up-multi",++ "choices": [],++ "usage": { "prompt_tokens": 51, "completion_tokens": 8, "total_tokens": 59 }++ }++ ]++ },++ "exact_id": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [{ "role": "user", "content": "Who answered this exact id?" }]++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [{ "role": "user", "content": "Who answered this exact id?" }],++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "response": {++ "id": "chatcmpl-up-exact",++ "object": "chat.completion",++ "created": 1762300006,++ "model": "gpt-up-exact",++ "choices": [++ {++ "index": 0,++ "message": { "role": "assistant", "content": "the exact artifact answered" },++ "finish_reason": "stop",++ "logprobs": null++ }++ ],++ "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 },++ "system_fingerprint": "fp_exact"++ },++ "stream": [++ {++ "id": "chatcmpl-up-exact",++ "object": "chat.completion.chunk",++ "created": 1762300006,++ "model": "gpt-up-exact",++ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "the exact artifact" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-exact",++ "object": "chat.completion.chunk",++ "created": 1762300006,++ "model": "gpt-up-exact",++ "choices": [{ "index": 0, "delta": { "content": " answered" }, "finish_reason": null }]++ },++ {++ "id": "chatcmpl-up-exact",++ "object": "chat.completion.chunk",++ "created": 1762300006,++ "model": "gpt-up-exact",++ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]++ },++ {++ "id": "chatcmpl-up-exact",++ "object": "chat.completion.chunk",++ "created": 1762300006,++ "model": "gpt-up-exact",++ "choices": [],++ "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 }++ }++ ]++ }++ },++ "refusals": {++ "exact_missing": {++ "request": {++ "model": "acme/nope@rev0",++ "messages": [{ "role": "user", "content": "This artifact does not exist." }]++ },++ "stream_request": {++ "model": "acme/nope@rev0",++ "messages": [{ "role": "user", "content": "This artifact does not exist." }],++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "status": 404,++ "code": "ai.model_not_found"++ },++ "exact_unavailable": {++ "request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [{ "role": "user", "content": "The artifact has no active deployment." }]++ },++ "stream_request": {++ "model": "acme/m1:q4_k_m@rev1",++ "messages": [{ "role": "user", "content": "The artifact has no active deployment." }],++ "stream": true,++ "stream_options": { "include_usage": true }++ },++ "status": 400,++ "code": "ai.model_unavailable"++ }++ },++ "aliases": [++ { "model": "openvibe", "policy": "auto" },++ { "model": "openvibe/auto", "policy": "auto" },++ { "model": "openvibe/economy", "policy": "cheapest" },++ { "model": "openvibe/balanced", "policy": "balanced" },++ { "model": "openvibe/fast", "policy": "fastest" },++ { "model": "openvibe/private", "policy": "private", "deployment": "local" }++ ]++}@@@ -45,7 +45,7 @@ function tmpDir() {// A boot on a dir used before is a restart on the same database: its database stays open.const restartable = new Map();--async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log } = {}) {++async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence } = {}) {const reuse = dir !== undefined;if (dir === undefined) dir = tmpDir();const fullEnv = {@@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, researchif (reuse) restartable.set(dir, testdb);// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });-- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels });++ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };}nnew file mode 100644@@@ -0,0 +1,462 @@++'use strict';++// AI Exchange H: the official-client compatibility fixtures for both wire protocols, with AI_MODEL_CATALOG on.++//++// test/fixtures/protocol-compat/openai-chat-completions.json OpenAI Chat Completions request / response / SSE shapes++// test/fixtures/protocol-compat/anthropic-messages.json Anthropic Messages request / message / named-SSE shapes++//++// Every fixture is replayed by a local fake upstream. The test then reads the answer exactly as an official client++// would: OpenAI's chat.completion / chat.completion.chunk + [DONE], Anthropic's message / message_start……message_stop.++// It asserts the protocol's own shape, usage, the x_openvibe disclosure (top-level and, on SSE, only the leading++// comment and the final chunk), and that an exact id is answered by its artifact or refused 400/404, never substituted.++// The aliases are exercised one by one in both protocols. The repo has no official openai / @anthropic-ai/sdk++// devDependency, so the fixtures go over HTTP; OpenVibe.Examples #55 covers the SDKs themselves.++const assert = require('assert');++const http = require('http');++const { boot, request, token, suite, ALL } = require('./helpers');++const openaiFixture = require('./fixtures/protocol-compat/openai-chat-completions.json');++const anthropicFixture = require('./fixtures/protocol-compat/anthropic-messages.json');++++const t = suite('protocol-compat');++const service = token('live', ALL);++const ANTHROPIC_HEADERS = { 'anthropic-version': '2023-06-01' };++++const ALPHA_ID = 'acme/m1:q4_k_m@rev1';++const BETA_ID = 'acme/m2@rev2';++const LOCAL_ID = 'acme/local@revL';++const DELTA_ID = 'anthropic/claude-test@revA';++const DELTA_LOCAL_ID = 'anthropic/claude-local@revB';++const IDS = { alpha: ALPHA_ID, beta: BETA_ID, local: LOCAL_ID, delta: DELTA_ID, 'delta-local': DELTA_LOCAL_ID };++++let h;++let alpha;++let beta;++let local;++let delta;++let deltaLocal;++++/* ── fake upstreams: replay one fixture scenario over the real wire protocol ───────────────── */++async function fakeUpstream(protocol, fixture) {++ const state = { calls: [], scenario: 'text', url: null };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ let body = {};++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }++ state.calls.push({ url: req.url, body });++ const scenario = fixture.scenarios[state.scenario];++ if (!scenario) {++ res.writeHead(500, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ error: `no fixture scenario ${state.scenario}` }));++ }++ if (body.stream) {++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ if (protocol === 'openai') {++ for (const chunk of scenario.stream) res.write(`data: ${JSON.stringify(chunk)}\n\n`);++ res.write('data: [DONE]\n\n');++ } else {++ for (const frame of scenario.stream) res.write(`event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`);++ }++ return res.end();++ }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify(scenario.response));++ });++ });++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ state.url = `http://127.0.0.1:${server.address().port}`;++ state.close = () => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });++ return state;++}++++const callCounts = () => [alpha.calls.length, beta.calls.length, local.calls.length, delta.calls.length, deltaLocal.calls.length];++const scenario = (upstream, name) => { upstream.scenario = name; };++++/* ── reading the answer the way an official client does ───────────────────────────────────── */++const blocks = (text) => text.split('\n\n').filter((b) => b.trim());++const comment = (block) => {++ assert.match(block, /^: x_openvibe /, 'the leading block is the disclosure comment');++ return JSON.parse(block.slice(': x_openvibe '.length));++};++function parseOpenAiSse(text) {++ const all = blocks(text);++ const comments = all.filter((b) => b.startsWith(': '));++ const data = all.filter((b) => b.startsWith('data: ')).map((b) => b.slice('data: '.length));++ const done = data.length > 0 && data[data.length - 1] === '[DONE]';++ const chunks = data.filter((d) => d !== '[DONE]').map((d) => JSON.parse(d));++ return { all, comments, chunks, done };++}++function parseAnthropicSse(text) {++ const all = blocks(text);++ const comments = all.filter((b) => b.startsWith(': '));++ const frames = [];++ for (const block of all) {++ const e = /^event: (.+)$/m.exec(block);++ const d = /^data: (.*)$/m.exec(block);++ if (!e && !d) continue;++ assert.ok(e && d, `a complete named SSE frame: ${block}`);++ frames.push({ event: e[1].trim(), data: JSON.parse(d[1]) });++ }++ return { all, comments, frames };++}++++function assertRouteDisclosure(x) {++ assert.ok(x && typeof x === 'object', 'the disclosure is an object');++ for (const k of ['resolved_model', 'revision', 'deployment', 'provider_access', 'rate_version', 'policy', 'fallback_reason']) {++ assert.ok(k in x, `x_openvibe.${k} is present`);++ }++}++function assertDisclosure(x) {++ assertRouteDisclosure(x);++ for (const k of ['run_id', 'provider', 'fallback_used', 'cost_usd']) assert.ok(k in x, `x_openvibe.${k} is present`);++ assert.match(String(x.run_id), /^run_/);++}++++function assertOpenAiJson(body, expected) {++ assert.strictEqual(body.object, 'chat.completion');++ assert.match(body.id, /^chatcmpl-run_/);++ assert.strictEqual(typeof body.created, 'number');++ assert.strictEqual(body.model, expected.response.model);++ assert.strictEqual(body.system_fingerprint, expected.response.system_fingerprint);++ assert.deepStrictEqual(body.choices, expected.response.choices, 'the upstream choices are passed through untranslated');++ assert.ok(body.usage, 'usage is present');++ assert.deepStrictEqual(++ [body.usage.prompt_tokens, body.usage.completion_tokens, body.usage.total_tokens],++ [expected.response.usage.prompt_tokens, expected.response.usage.completion_tokens, expected.response.usage.total_tokens]++ );++ assertDisclosure(body.x_openvibe);++ assert.strictEqual(body.x_openvibe.run_id, body.id.slice('chatcmpl-'.length));++ assert.ok(!JSON.stringify(body.choices).includes('x_openvibe'), 'x_openvibe never rides inside choices');++}++++function assertOpenAiStream(parsed, expected) {++ assert.strictEqual(parsed.comments.length, 1, 'exactly one SSE comment');++ assert.match(parsed.all[0], /^: x_openvibe /, 'the disclosure is the leading SSE block');++ const head = comment(parsed.comments[0]);++ assertRouteDisclosure(head);++ assert.ok(parsed.done, '[DONE] is last');++ assert.ok(!parsed.all.some((b) => /^event:/m.test(b)), 'OpenAI SSE carries no event names');++ assert.ok(parsed.chunks.length >= 2, 'deltas, then the final chunk');++ assert.ok(parsed.chunks.every((c) => c.object === 'chat.completion.chunk' && Array.isArray(c.choices)), 'chat.completion.chunk frames');++ const last = parsed.chunks[parsed.chunks.length - 1];++ assert.ok(last.x_openvibe, 'the final chunk carries x_openvibe');++ assert.deepStrictEqual(last.choices, []);++ assert.ok(parsed.chunks.slice(0, -1).every((c) => !('x_openvibe' in c)), 'only the final chunk carries x_openvibe');++ assert.ok(!JSON.stringify(parsed.chunks.map((c) => c.choices)).includes('x_openvibe'), 'x_openvibe never rides inside choices');++ assert.deepStrictEqual(++ parsed.chunks.filter((c) => c.choices.length).map((c) => c.choices),++ expected.stream.filter((c) => c.choices.length).map((c) => c.choices),++ 'the upstream deltas are passed through in order'++ );++ const usage = parsed.chunks.find((c) => c.usage);++ assert.ok(usage, 'usage is present when stream_options.include_usage is asked');++ assert.deepStrictEqual(++ [usage.usage.prompt_tokens, usage.usage.completion_tokens, usage.usage.total_tokens],++ [expected.response.usage.prompt_tokens, expected.response.usage.completion_tokens, expected.response.usage.total_tokens]++ );++ assert.ok(parsed.chunks.indexOf(last) > parsed.chunks.indexOf(usage), 'x_openvibe is on the final chunk, after usage');++ assertDisclosure(last.x_openvibe);++ assert.deepStrictEqual(++ [head.resolved_model, head.deployment, head.provider_access, head.policy],++ [last.x_openvibe.resolved_model, last.x_openvibe.deployment, last.x_openvibe.provider_access, last.x_openvibe.policy],++ 'the comment and the final chunk disclose the same route'++ );++}++++function assertAnthropicJson(body, expected) {++ assert.deepStrictEqual([body.type, body.role], ['message', 'assistant']);++ assert.strictEqual(body.id, expected.response.id);++ assert.strictEqual(body.model, expected.response.model);++ assert.deepStrictEqual(body.content, expected.response.content, 'the upstream content blocks are passed through untranslated');++ assert.strictEqual(body.stop_reason, expected.response.stop_reason);++ assert.strictEqual(body.stop_sequence, expected.response.stop_sequence);++ assert.deepStrictEqual(++ [body.usage.input_tokens, body.usage.output_tokens],++ [expected.response.usage.input_tokens, expected.response.usage.output_tokens]++ );++ assertDisclosure(body.x_openvibe);++ assert.ok(!JSON.stringify(body.content).includes('x_openvibe'), 'x_openvibe never rides inside content');++}++++function assertAnthropicStream(parsed, expected) {++ assert.strictEqual(parsed.comments.length, 1, 'exactly one SSE comment');++ assert.match(parsed.all[0], /^: x_openvibe /, 'the disclosure is the leading SSE block');++ const head = comment(parsed.comments[0]);++ assertRouteDisclosure(head);++ const last = parsed.frames[parsed.frames.length - 1];++ assert.ok(parsed.frames.length >= 2, 'message_start through message_stop');++ assert.deepStrictEqual(parsed.frames.map((f) => f.event), expected.stream.map((f) => f.event), 'the named events, in protocol order');++ assert.deepStrictEqual(parsed.frames.slice(0, -1).map((f) => f.data), expected.stream.slice(0, -1).map((f) => f.data), 'every event before message_stop is passed through');++ assert.ok(parsed.frames.every((f) => f.event === f.data.type), 'the event name matches the data type');++ assert.deepStrictEqual([last.event, last.data.type], ['message_stop', 'message_stop']);++ assert.ok(last.data.x_openvibe, 'message_stop carries x_openvibe');++ assert.ok(parsed.frames.slice(0, -1).every((f) => !('x_openvibe' in f.data)), 'only message_stop carries x_openvibe');++ assert.ok(!JSON.stringify(parsed.frames.map((f) => f.data.content_block || {})).includes('x_openvibe'), 'x_openvibe never rides inside a content block');++ const start = parsed.frames.find((f) => f.event === 'message_start');++ const stop = parsed.frames.find((f) => f.event === 'message_delta');++ assert.strictEqual(start.data.message.usage.input_tokens, expected.response.usage.input_tokens, 'input usage is present on message_start');++ assert.strictEqual(stop.data.usage.output_tokens, expected.response.usage.output_tokens, 'output usage is present on message_delta');++ assert.strictEqual(stop.data.delta.stop_reason, expected.response.stop_reason);++ assertDisclosure(last.data.x_openvibe);++ assert.deepStrictEqual(++ [head.resolved_model, head.deployment, head.provider_access, head.policy],++ [last.data.x_openvibe.resolved_model, last.data.x_openvibe.deployment, last.data.x_openvibe.provider_access, last.data.x_openvibe.policy],++ 'the comment and message_stop disclose the same route'++ );++}++++/* ── HTTP helpers ──────────────────────────────────────────────────────────────────────────── */++const post = (path, body, headers = {}) => request(h.base, 'POST', path, { tok: service, body, headers });++async function stream(path, body, headers = {}) {++ const res = await fetch(`${h.base}${path}`, {++ method: 'POST',++ headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json', ...headers },++ body: JSON.stringify(body),++ });++ return { res, text: await res.text() };++}++++/* ── boot ──────────────────────────────────────────────────────────────────────────────────── */++t.test('boot: the catalog is on, with an exact artifact for each protocol and a local private one', async () => {++ [alpha, beta, local] = await Promise.all([fakeUpstream('openai', openaiFixture), fakeUpstream('openai', openaiFixture), fakeUpstream('openai', openaiFixture)]);++ [delta, deltaLocal] = await Promise.all([fakeUpstream('anthropic', anthropicFixture), fakeUpstream('anthropic', anthropicFixture)]);++ h = await boot({ env: { AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ const providers = [++ { key: 'alpha', kind: 'openai', base_url: `${alpha.url}/v1`, auth_mode: 'none', capabilities: ['chat', 'complete', 'tools', 'json'], default_model: 'alpha-1', provider_access: 'OWN_DEPLOYMENT' },++ { key: 'beta', kind: 'openai', base_url: `${beta.url}/v1`, auth_mode: 'none', capabilities: ['chat', 'complete', 'tools', 'json'], default_model: 'beta-1', provider_access: 'AUTHORIZED_WHOLESALE' },++ { key: 'local', kind: 'openai', base_url: `${local.url}/v1`, auth_mode: 'none', capabilities: ['chat', 'complete', 'tools', 'json'], default_model: 'local-1', provider_access: 'OWN_DEPLOYMENT', metadata: { local: true } },++ { key: 'delta', kind: 'anthropic', base_url: delta.url, auth_mode: 'none', capabilities: ['chat', 'messages', 'tools', 'json'], default_model: 'delta-1', provider_access: 'OWN_DEPLOYMENT' },++ { key: 'delta-local', kind: 'anthropic', base_url: deltaLocal.url, auth_mode: 'none', capabilities: ['chat', 'messages', 'tools', 'json'], default_model: 'delta-local-1', provider_access: 'OWN_DEPLOYMENT', metadata: { local: true } },++ ];++ for (const provider of providers) {++ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: provider });++ assert.strictEqual(made.status, 201, made.text);++ }++ const cost = (input, output) => ({ in_per_mtok: input, out_per_mtok: output, cached_per_mtok: input / 10, source: 'https://example.com/pricing', verified_at: '2026-11-01' });++ const models = [++ { provider_key: 'alpha', model_key: 'alpha-1', metadata: { org: 'acme', model: 'm1', quant: 'q4_k_m', revision: 'rev1', family: 'm', variant: 'original', license: 'mit', rate_version: 'rr-alpha' }, cost: cost(1, 2) },++ { provider_key: 'beta', model_key: 'beta-1', metadata: { org: 'acme', model: 'm2', revision: 'rev2', rate_version: 'rr-beta' }, cost: cost(5, 10) },++ { provider_key: 'local', model_key: 'local-1', metadata: { org: 'acme', model: 'local', revision: 'revL', rate_version: 'rr-local' }, cost: cost(10, 20) },++ { provider_key: 'delta', model_key: 'delta-1', metadata: { org: 'anthropic', model: 'claude-test', revision: 'revA', rate_version: 'rr-delta' }, cost: cost(3, 6) },++ { provider_key: 'delta-local', model_key: 'delta-local-1', metadata: { org: 'anthropic', model: 'claude-local', revision: 'revB', rate_version: 'rr-delta-local' }, cost: cost(4, 8) },++ ];++ for (const model of models) {++ const made = await request(h.base, 'POST', '/api/v1/models', {++ tok: service,++ body: { type: 'chat', status: 'active', context_window: 32768, max_output: 4096, supports: { json: true, tools: true, streaming: true, vision: false }, ...model },++ });++ assert.strictEqual(made.status, 201, made.text);++ }++ // The aliases resolve through the same catalog.++ for (const id of [...Object.values(IDS)]) {++ const found = await request(h.base, 'GET', `/v1/models/${encodeURIComponent(id)}`, { tok: service });++ assert.strictEqual(found.status, 200, `${id}: ${found.text}`);++ }++});++++/* ── OpenAI: every fixture, non-streamed and streamed ─────────────────────────────────────── */++t.test('OpenAI Chat Completions: text, tool call, tool result, json_object, json_schema, multi-turn, exact id — JSON and SSE', async () => {++ const before = callCounts();++ for (const name of ['text', 'tool_call', 'tool_result', 'json_object', 'json_schema', 'multi_turn', 'exact_id']) {++ const expected = openaiFixture.scenarios[name];++ scenario(alpha, name);++++ const plain = await post('/v1/chat/completions', expected.request);++ assert.strictEqual(plain.status, 200, `${name}: ${plain.text}`);++ assertOpenAiJson(plain.body, expected);++ const sent = alpha.calls[alpha.calls.length - 1].body;++ assert.strictEqual(sent.model, 'alpha-1', `${name}: the exact artifact's own model ran`);++ assert.deepStrictEqual(sent.messages, expected.request.messages, `${name}: messages pass through as written`);++ if (expected.request.tools) assert.deepStrictEqual(sent.tools, expected.request.tools, `${name}: tools pass through`);++ if (expected.request.tool_choice !== undefined) assert.deepStrictEqual(sent.tool_choice, expected.request.tool_choice, `${name}: tool_choice passes through`);++ if (expected.request.response_format) assert.deepStrictEqual(sent.response_format, expected.request.response_format, `${name}: response_format passes through`);++++ scenario(alpha, name);++ const streamed = await stream('/v1/chat/completions', expected.stream_request);++ assert.strictEqual(streamed.res.status, 200, `${name} stream: ${streamed.text}`);++ assert.match(streamed.res.headers.get('content-type'), /^text\/event-stream/);++ const parsed = parseOpenAiSse(streamed.text);++ assertOpenAiStream(parsed, expected);++ const sentStream = alpha.calls[alpha.calls.length - 1].body;++ assert.deepStrictEqual([sentStream.stream, sentStream.stream_options], [true, { include_usage: true }], `${name}: the upstream is asked to stream its usage`);++ assert.deepStrictEqual(sentStream.messages, expected.stream_request.messages);++ if (expected.stream_request.response_format) assert.deepStrictEqual(sentStream.response_format, expected.stream_request.response_format);++ }++ assert.strictEqual(alpha.calls.length, before[0] + 14, 'alpha answered every OpenAI fixture');++ assert.deepStrictEqual([beta.calls.length, local.calls.length, delta.calls.length, deltaLocal.calls.length], [before[1], before[2], before[3], before[4]], 'the exact id never substituted another provider');++});++++/* ── OpenAI exact ids: 404, 400, no substitution ──────────────────────────────────────────── */++t.test('OpenAI exact ids: an unknown id is 404, an unavailable one is 400, and neither falls back', async () => {++ // Missing: every provider is up, so a substitution would be visible.++ const beforeMissing = callCounts();++ for (const body of [openaiFixture.refusals.exact_missing.request, openaiFixture.refusals.exact_missing.stream_request]) {++ const res = await post('/v1/chat/completions', body);++ assert.strictEqual(res.status, 404, res.text);++ assert.strictEqual(res.body.error.code, 'ai.model_not_found');++ assert.strictEqual(res.body.error.type, 'invalid_request_error');++ assert.match(res.headers.get('content-type'), /application\/json/, 'a refused exact id is an HTTP error, not an SSE stream');++ }++ assert.deepStrictEqual(callCounts(), beforeMissing, 'the unknown artifact called no provider');++++ // Unavailable: the artifact exists, but its only deployment is disabled; the other providers stay up.++ const beforeUnavailable = callCounts();++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/alpha/disable', { tok: service, body: {} })).status, 200);++ try {++ for (const body of [openaiFixture.refusals.exact_unavailable.request, openaiFixture.refusals.exact_unavailable.stream_request]) {++ const res = await post('/v1/chat/completions', body);++ assert.strictEqual(res.status, 400, res.text);++ assert.strictEqual(res.body.error.code, 'ai.model_unavailable');++ assert.strictEqual(res.body.error.type, 'invalid_request_error');++ assert.match(res.headers.get('content-type'), /application\/json/, 'a refused exact id is an HTTP error, not an SSE stream');++ }++ } finally {++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/alpha/enable', { tok: service, body: {} })).status, 200);++ }++ assert.deepStrictEqual(callCounts(), beforeUnavailable, 'the unavailable artifact fell back to no provider');++++ scenario(alpha, 'exact_id');++ const exact = await post('/v1/chat/completions', openaiFixture.scenarios.exact_id.request);++ assert.strictEqual(exact.status, 200, exact.text);++ assert.deepStrictEqual(++ [exact.body.x_openvibe.resolved_model, exact.body.x_openvibe.deployment, exact.body.x_openvibe.policy],++ [ALPHA_ID, 'alpha', 'exact'],++ 'the exact artifact answered, and the disclosure says so'++ );++});++++/* ── OpenAI aliases: each one, JSON and SSE ───────────────────────────────────────────────── */++t.test('OpenAI aliases: every dynamic alias answers, and x_openvibe names its policy', async () => {++ for (const upstream of [alpha, beta, local]) scenario(upstream, 'text');++ const base = openaiFixture.scenarios.text.request;++ for (const alias of openaiFixture.aliases) {++ const before = callCounts();++ const body = { ...base, model: alias.model };++ const plain = await post('/v1/chat/completions', body);++ assert.strictEqual(plain.status, 200, `${alias.model}: ${plain.text}`);++ assertOpenAiJson(plain.body, openaiFixture.scenarios.text);++ assert.strictEqual(plain.body.x_openvibe.policy, alias.policy, `${alias.model}: the alias policy is disclosed`);++ assert.strictEqual(plain.body.x_openvibe.resolved_model, IDS[plain.body.x_openvibe.deployment], `${alias.model}: resolved_model matches the deployment`);++ const after = callCounts();++ const increased = ['alpha', 'beta', 'local'].filter((key, i) => after[i] === before[i] + 1);++ assert.strictEqual(increased.length, 1, `${alias.model}: exactly one provider answered`);++ assert.strictEqual(increased[0], plain.body.x_openvibe.deployment, `${alias.model}: the disclosed deployment answered`);++ if (alias.deployment) assert.strictEqual(plain.body.x_openvibe.deployment, alias.deployment, `${alias.model}: private stays local`);++ assert.deepStrictEqual([after[3], after[4]], [before[3], before[4]], `${alias.model}: no Anthropic upstream was called`);++++ const streamed = await stream('/v1/chat/completions', { ...body, stream: true, stream_options: { include_usage: true } });++ assert.strictEqual(streamed.res.status, 200, `${alias.model} stream: ${streamed.text}`);++ const parsed = parseOpenAiSse(streamed.text);++ assertOpenAiStream(parsed, openaiFixture.scenarios.text);++ assert.strictEqual(comment(parsed.comments[0]).policy, alias.policy, `${alias.model}: the leading comment discloses the alias policy`);++ assert.strictEqual(parsed.chunks[parsed.chunks.length - 1].x_openvibe.policy, alias.policy);++ if (alias.deployment) assert.strictEqual(parsed.chunks[parsed.chunks.length - 1].x_openvibe.deployment, alias.deployment);++ }++});++++/* ── Anthropic: every fixture, non-streamed and streamed ──────────────────────────────────── */++t.test('Anthropic Messages: text, tool_use, tool_result, structured JSON tool, multi-turn, exact id — message and named SSE', async () => {++ const before = callCounts();++ for (const name of ['text', 'tool_use', 'tool_result', 'json_structured', 'multi_turn', 'exact_id']) {++ const expected = anthropicFixture.scenarios[name];++ scenario(delta, name);++++ const plain = await post('/v1/messages', expected.request, ANTHROPIC_HEADERS);++ assert.strictEqual(plain.status, 200, `${name}: ${plain.text}`);++ assertAnthropicJson(plain.body, expected);++ const sent = delta.calls[delta.calls.length - 1].body;++ assert.strictEqual(sent.model, 'delta-1', `${name}: the exact artifact's own model ran`);++ assert.deepStrictEqual(sent.messages, expected.request.messages, `${name}: messages pass through as written`);++ if (expected.request.system !== undefined) assert.deepStrictEqual(sent.system, expected.request.system, `${name}: the system prompt passes through`);++ if (expected.request.tools) assert.deepStrictEqual(sent.tools, expected.request.tools, `${name}: tools pass through`);++ if (expected.request.tool_choice !== undefined) assert.deepStrictEqual(sent.tool_choice, expected.request.tool_choice, `${name}: tool_choice passes through`);++++ scenario(delta, name);++ const streamed = await stream('/v1/messages', expected.stream_request, ANTHROPIC_HEADERS);++ assert.strictEqual(streamed.res.status, 200, `${name} stream: ${streamed.text}`);++ assert.match(streamed.res.headers.get('content-type'), /^text\/event-stream/);++ const parsed = parseAnthropicSse(streamed.text);++ assertAnthropicStream(parsed, expected);++ const sentStream = delta.calls[delta.calls.length - 1].body;++ assert.strictEqual(sentStream.stream, true);++ assert.deepStrictEqual(sentStream.messages, expected.stream_request.messages);++ }++ assert.strictEqual(delta.calls.length, before[3] + 12, 'delta answered every Anthropic fixture');++ assert.deepStrictEqual([alpha.calls.length, beta.calls.length, local.calls.length, deltaLocal.calls.length], [before[0], before[1], before[2], before[4]], 'the exact id never substituted another provider');++});++++/* ── Anthropic exact ids: 404, 400, no substitution ───────────────────────────────────────── */++t.test('Anthropic exact ids: an unknown id is 404, an unavailable one is 400, and neither falls back', async () => {++ // Missing: every provider is up, so a substitution would be visible.++ const beforeMissing = callCounts();++ for (const body of [anthropicFixture.refusals.exact_missing.request, anthropicFixture.refusals.exact_missing.stream_request]) {++ const res = await post('/v1/messages', body, ANTHROPIC_HEADERS);++ assert.strictEqual(res.status, 404, res.text);++ assert.strictEqual(res.body.type, 'error');++ assert.deepStrictEqual([res.body.error.type, res.body.error.code], ['not_found_error', 'ai.model_not_found']);++ assert.ok(!res.headers.get('content-type').startsWith('text/event-stream'), 'a refused exact id is an HTTP error, not an SSE stream');++ }++ assert.deepStrictEqual(callCounts(), beforeMissing, 'the unknown artifact called no provider');++++ // Unavailable: the artifact exists, but its only deployment is disabled; the other providers stay up.++ const beforeUnavailable = callCounts();++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/delta/disable', { tok: service, body: {} })).status, 200);++ try {++ for (const body of [anthropicFixture.refusals.exact_unavailable.request, anthropicFixture.refusals.exact_unavailable.stream_request]) {++ const res = await post('/v1/messages', body, ANTHROPIC_HEADERS);++ assert.strictEqual(res.status, 400, res.text);++ assert.strictEqual(res.body.type, 'error');++ assert.deepStrictEqual([res.body.error.type, res.body.error.code], ['invalid_request_error', 'ai.model_unavailable']);++ assert.ok(!res.headers.get('content-type').startsWith('text/event-stream'), 'a refused exact id is an HTTP error, not an SSE stream');++ }++ } finally {++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/delta/enable', { tok: service, body: {} })).status, 200);++ }++ assert.deepStrictEqual(callCounts(), beforeUnavailable, 'the unavailable artifact fell back to no provider');++++ scenario(delta, 'exact_id');++ const exact = await post('/v1/messages', anthropicFixture.scenarios.exact_id.request, ANTHROPIC_HEADERS);++ assert.strictEqual(exact.status, 200, exact.text);++ assert.deepStrictEqual(++ [exact.body.x_openvibe.resolved_model, exact.body.x_openvibe.deployment, exact.body.x_openvibe.policy],++ [DELTA_ID, 'delta', 'exact'],++ 'the exact artifact answered, and the disclosure says so'++ );++});++++/* ── Anthropic aliases: each one, message and SSE ─────────────────────────────────────────── */++t.test('Anthropic aliases: every dynamic alias answers, and x_openvibe names its policy', async () => {++ for (const upstream of [delta, deltaLocal]) scenario(upstream, 'text');++ const base = anthropicFixture.scenarios.text.request;++ for (const alias of anthropicFixture.aliases) {++ const before = callCounts();++ const body = { ...base, model: alias.model };++ const plain = await post('/v1/messages', body, ANTHROPIC_HEADERS);++ assert.strictEqual(plain.status, 200, `${alias.model}: ${plain.text}`);++ assertAnthropicJson(plain.body, anthropicFixture.scenarios.text);++ assert.strictEqual(plain.body.x_openvibe.policy, alias.policy, `${alias.model}: the alias policy is disclosed`);++ assert.strictEqual(plain.body.x_openvibe.resolved_model, IDS[plain.body.x_openvibe.deployment], `${alias.model}: resolved_model matches the deployment`);++ const after = callCounts();++ const increased = ['delta', 'delta-local'].filter((key, i) => after[i + 3] === before[i + 3] + 1);++ assert.strictEqual(increased.length, 1, `${alias.model}: exactly one provider answered`);++ assert.strictEqual(increased[0], plain.body.x_openvibe.deployment, `${alias.model}: the disclosed deployment answered`);++ if (alias.deployment) assert.strictEqual(plain.body.x_openvibe.deployment, alias.deployment, `${alias.model}: private stays local`);++ assert.deepStrictEqual([after[0], after[1], after[2]], [before[0], before[1], before[2]], `${alias.model}: no OpenAI upstream was called`);++++ const streamed = await stream('/v1/messages', { ...body, stream: true }, ANTHROPIC_HEADERS);++ assert.strictEqual(streamed.res.status, 200, `${alias.model} stream: ${streamed.text}`);++ const parsed = parseAnthropicSse(streamed.text);++ assertAnthropicStream(parsed, anthropicFixture.scenarios.text);++ const last = parsed.frames[parsed.frames.length - 1];++ assert.strictEqual(comment(parsed.comments[0]).policy, alias.policy, `${alias.model}: the leading comment discloses the alias policy`);++ assert.strictEqual(last.data.x_openvibe.policy, alias.policy);++ if (alias.deployment) assert.strictEqual(last.data.x_openvibe.deployment, alias.deployment);++ }++});++++t.test('stop', async () => {++ await h.stop();++ await Promise.all([alpha.close(), beta.close(), local.close(), delta.close(), deltaLocal.close()]);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,312 @@++'use strict';++/**++ * AI Exchange P7: alias routing by measured evidence (AI_ROUTING_EVIDENCE).++ *++ * - the per-deployment EWMA updates from each attempt and survives a new reader (the table is the store);++ * - price_after_cache is integer BigInt arithmetic on the retail card and the measured cache-hit ratio;++ * - economy orders by expected cost per success, fast by p50 TTFT then decode tokens/s;++ * - sparse Bench evidence is ignored and reported, never filled in;++ * - hysteresis keeps the incumbent within the margin and switches after N consecutive challenger wins;++ * - cache affinity prefers the deployment that served the same prompt prefix while it is within the margin;++ * - exploration is 0 by default and never runs on an exact id, BYOK or an opted-out preference;++ * - with the flag off nothing is written and the router's order is unchanged.++ */++const assert = require('assert');++const { suite, boot, request, token, seamServer, ALL } = require('./helpers');++const { testDb } = require('./db');++const { createProviderMetrics, ewma } = require('../server/providers/metrics');++const { createRoutingEvidence, priceAfterCacheNano, blendedInputRateNano, combineEvidence } = require('../server/providers/evidence');++const { createVariantEvidence } = require('../server/providers/bench-variant');++++const t = suite('routing-evidence');++const tok = token('live', ALL);++const CLOCK = { now: () => Date.parse('2026-11-04T00:00:00.000Z') };++const CONFIG = (over = {}) => ({++ routingEvidence: {++ enabled: true, alpha: 0.2, minSamples: 2, successFloor: 0.1, prior: 0.9,++ hysteresisDecisions: 3, explorationShare: 0, affinityGain: 0.05, expectedOutputTokens: 512, ...over,++ },++ placement: { costWeight: 0.5, latencyWeight: 0.5 },++});++const RETAIL = (inM, cachedM, outM, fee = '0') => ({++ input_nano_usd_per_m: String(inM), cached_read_nano_usd_per_m: String(cachedM),++ cache_write_nano_usd_per_m: '0', output_nano_usd_per_m: String(outM), per_request_nano_usd: fee,++});++++async function seed(metrics, provider, { n = 3, ok = true, ttft = null, latency = 1000, input = 1000, cached = 0, output = 500 } = {}) {++ for (let i = 0; i < n; i++) await metrics.record({ provider, model: 'm', ok, ttftMs: ttft, latencyMs: latency, inputTokens: input, cachedTokens: cached, outputTokens: output });++}++++async function harness({ prices = {}, config = CONFIG(), bench = null, metrics: givenMetrics = null } = {}) {++ const db = await testDb();++ const metrics = givenMetrics || createProviderMetrics({ db: db.db, clock: CLOCK });++ const resolveOffer = async (c) => {++ const p = prices[c.provider];++ if (!p) return null;++ return { id: `${c.provider}:${c.model}`, provider: c.provider, model: c.model, exactId: p.exactId || null, access: 'OWN_DEPLOYMENT', free: false, retail: p.retail || RETAIL(10, 1, 10) };++ };++ const ev = createRoutingEvidence({ db: db.db, clock: CLOCK, metrics, bench, resolveOffer, config });++ return { db, metrics, ev, stop: () => db.close() };++}++++t.test('the EWMA updates and the table keeps the measurements for a new reader', async () => {++ const db = await testDb();++ const first = createProviderMetrics({ db: db.db, clock: CLOCK });++ await first.record({ provider: 'p', model: 'm', ok: true, ttftMs: 100, latencyMs: 1100, inputTokens: 1000, cachedTokens: 500, outputTokens: 1000 });++ await first.record({ provider: 'p', model: 'm', ok: false, timeout: true, latencyMs: 400, inputTokens: 100, cachedTokens: 0, outputTokens: 0 });++ const row = await first.get('p', 'm');++ assert.strictEqual(row.sample_count, 2);++ assert.strictEqual(row.ttft_samples, 1);++ assert.strictEqual(row.decode_samples, 1);++ assert.strictEqual(row.cache_samples, 2);++ assert.strictEqual(row.success_rate, ewma(1, 0, 0.2), 'one failure after a success is an EWMA, not a reset');++ assert.strictEqual(row.timeout_rate, 0.2);++ assert.strictEqual(row.cache_hit_ratio, 0.2 * 0 + 0.8 * 0.5, 'the cache ratio follows its own observations');++ const second = createProviderMetrics({ db: db.db, clock: CLOCK });++ const again = await second.get('p', 'm');++ assert.deepStrictEqual(again, row, 'a new reader (a restart) sees the persisted row');++ await db.close();++});++++t.test('price_after_cache is exact integer nano-USD weighted by the cache-hit ratio', () => {++ const rates = RETAIL(3000000, 300000, 15000000, '5');++ const allUncached = priceAfterCacheNano({ promptTokens: 1000000, outputTokens: 500000, cacheHitRatio: 0, rates });++ const half = priceAfterCacheNano({ promptTokens: 1000000, outputTokens: 500000, cacheHitRatio: 0.5, rates });++ const allCached = priceAfterCacheNano({ promptTokens: 1000000, outputTokens: 500000, cacheHitRatio: 1, rates });++ assert.strictEqual(allUncached, BigInt(3000000 + 7500000 + 5));++ assert.strictEqual(half, BigInt(1500000 + 150000 + 7500000 + 5));++ assert.strictEqual(allCached, BigInt(300000 + 7500000 + 5));++ assert.strictEqual(typeof half, 'bigint');++ // A fractional component rounds up once (D1): 1 token at 1.5 nano per million is 2 nano, not 1.++ assert.strictEqual(priceAfterCacheNano({ promptTokens: 1, outputTokens: 0, cacheHitRatio: 0, rates: RETAIL(1500000, 0, 0) }), 2n);++ // The blended single rate used by the SDK planner prices the same prompt at the same total.++ for (const ratio of [0, 0.1, 0.5, 0.9, 1]) {++ const exact = BigInt(priceAfterCacheNano({ promptTokens: 12345, outputTokens: 0, cacheHitRatio: ratio, rates: RETAIL(3000000, 300000, 0) }));++ const blended = blendedInputRateNano({ promptTokens: 12345, cacheHitRatio: ratio, rates: RETAIL(3000000, 300000, 0) });++ assert.strictEqual(BigInt(priceAfterCacheNano({ promptTokens: 12345, outputTokens: 0, cacheHitRatio: 0, rates: RETAIL(blended, 0, 0) })), exact);++ }++});++++t.test('economy orders by expected cost per success', async () => {++ const h = await harness({ prices: {++ cheap: { retail: RETAIL(1000000, 100000, 1000000) },++ pricey: { retail: RETAIL(9000000, 900000, 9000000) },++ } });++ await seed(h.metrics, 'cheap');++ await seed(h.metrics, 'pricey');++ const picked = await h.ev.choose({ routeKey: 'economy', policy: 'cheapest', list: [{ provider: 'pricey', model: 'm' }, { provider: 'cheap', model: 'm' }], req: { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 }, ctx: {} });++ assert.strictEqual(picked.order[0].provider, 'cheap', picked.view.reason);++ assert.strictEqual(picked.view.policy, 'cheapest');++ assert.match(picked.view.reason, /lowest cost per success/);++ const byProvider = Object.fromEntries(picked.view.candidates.map((c) => [c.provider, c]));++ assert.ok(BigInt(byProvider.cheap.cost_per_success_nano_usd) < BigInt(byProvider.pricey.cost_per_success_nano_usd));++ await h.stop();++});++++t.test('fast orders by p50 TTFT, then decode tokens/s', async () => {++ const h = await harness({ prices: {++ quick: { retail: RETAIL(1000000, 100000, 1000000) },++ slow: { retail: RETAIL(1000000, 100000, 1000000) },++ equal: { retail: RETAIL(1000000, 100000, 1000000) },++ } });++ await seed(h.metrics, 'quick', { ttft: 100, latency: 600, output: 1000 });++ await seed(h.metrics, 'slow', { ttft: 300, latency: 800, output: 1000 });++ await seed(h.metrics, 'equal', { ttft: 100, latency: 500, output: 2000 });++ const list = [{ provider: 'slow', model: 'm' }, { provider: 'quick', model: 'm' }, { provider: 'equal', model: 'm' }];++ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };++ const r = await h.ev.choose({ routeKey: 'fast', policy: 'fastest', list, req, ctx: {} });++ assert.strictEqual(r.order[0].provider, 'equal', 'the equal-TTFT deployment with the higher decode rate wins the tie');++ assert.strictEqual(r.view.candidates.find((c) => c.provider === 'equal').decode_tokens_per_s, 5000);++ await h.stop();++});++++t.test('balanced weighs the normalised cost per success and TTFT', async () => {++ const h = await harness({ prices: {++ cheapSlow: { retail: RETAIL(100000, 0, 100000) },++ priceyFast: { retail: RETAIL(5000000, 0, 5000000) },++ } });++ await seed(h.metrics, 'cheapSlow', { ttft: 500, latency: 1000 });++ await seed(h.metrics, 'priceyFast', { ttft: 100, latency: 300 });++ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };++ const r = await h.ev.choose({ routeKey: 'balanced', policy: 'balanced', list: [{ provider: 'cheapSlow', model: 'm' }, { provider: 'priceyFast', model: 'm' }], req, ctx: {} });++ assert.ok(r.view.candidates.length === 2);++ assert.ok(['cheapSlow', 'priceyFast'].includes(r.order[0].provider));++ await h.stop();++});++++t.test('a deployment with no measured successes is priced at the floor, not excluded', async () => {++ const h = await harness({ prices: { bad: { retail: RETAIL(1000000, 0, 1000000) }, good: { retail: RETAIL(2000000, 0, 2000000) } } });++ await seed(h.metrics, 'bad', { n: 3, ok: false });++ await seed(h.metrics, 'good', { n: 3, ok: true });++ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };++ const r = await h.ev.choose({ routeKey: 'floor', policy: 'cheapest', list: [{ provider: 'bad', model: 'm' }, { provider: 'good', model: 'm' }], req, ctx: {} });++ assert.strictEqual(r.order[0].provider, 'good');++ const bad = r.view.candidates.find((c) => c.provider === 'bad');++ assert.ok(bad, 'the failing deployment stays in the candidate list');++ assert.ok(BigInt(bad.cost_per_success_nano_usd) > BigInt(r.view.candidates.find((c) => c.provider === 'good').cost_per_success_nano_usd));++ await h.stop();++});++++t.test('sparse Bench evidence is ignored and reported, never filled in', async () => {++ const sparse = { get: async () => ({ status: 'sparse', samples: 5, success_rate: 0.1, ttft_p50_ms: 5, decode_tokens_per_s: 500, exact_id: 'org/m@r1' }) };++ const h = await harness({ bench: sparse, prices: { a: { retail: RETAIL(1000000, 100000, 1000000), exactId: 'org/m@r1' } } });++ await seed(h.metrics, 'a', { n: 1 });++ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };++ const r = await h.ev.choose({ routeKey: 'sparse', policy: 'cheapest', list: [{ provider: 'a', model: 'm' }], req, ctx: {} });++ const c = r.view.candidates[0];++ assert.strictEqual(r.view.bench.status, 'sparse');++ assert.strictEqual(c.sparse_evidence, true);++ assert.strictEqual(c.p_success, 0.9, 'sparse evidence keeps the neutral prior, never Bench’s 0.1');++ assert.strictEqual(c.bench.status, 'sparse');++ assert.strictEqual(c.samples, 1, 'the EWMA sample count is reported, not Bench’s 5');++ const absent = { get: async () => ({ status: 'absent', samples: 0, exact_id: 'org/m@r1' }) };++ assert.strictEqual(combineEvidence({ metric: null, bench: await absent.get('x'), minSamples: 2 }).sparse, true);++ await h.stop();++});++++t.test('non-sparse Bench evidence is an input', async () => {++ const bench = { get: async () => ({ status: 'ok', samples: 30, success_rate: 0.5, ttft_p50_ms: 50, decode_tokens_per_s: 900, exact_id: 'org/m@r1' }) };++ const h = await harness({ bench, prices: { a: { retail: RETAIL(1000000, 100000, 1000000), exactId: 'org/m@r1' } } });++ await seed(h.metrics, 'a', { n: 30, ok: true });++ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };++ const r = await h.ev.choose({ routeKey: 'bench', policy: 'cheapest', list: [{ provider: 'a', model: 'm' }], req, ctx: {} });++ const c = r.view.candidates[0];++ assert.strictEqual(c.sparse_evidence, false);++ assert.strictEqual(c.p_success, 0.75, 'the EWMA success and Bench’s success are sample-weighted');++ assert.strictEqual(c.ttft_p50_ms, 50, 'Bench’s measured p50 is the latency when it is not sparse');++ assert.strictEqual(c.ttft_source, 'bench_p50');++ assert.strictEqual(r.view.bench.status, 'ok');++ await h.stop();++});++++t.test('hysteresis keeps the incumbent within the margin and switches after N', async () => {++ let prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(1050000, 0, 1050000) } };++ const db = await testDb();++ const metrics = createProviderMetrics({ db: db.db, clock: CLOCK });++ await seed(metrics, 'a');++ await seed(metrics, 'b');++ const ev = createRoutingEvidence({ db: db.db, clock: CLOCK, metrics, config: CONFIG({ minSamples: 2, hysteresisDecisions: 3 }), resolveOffer: async (c) => ({ id: `${c.provider}:m`, provider: c.provider, model: 'm', exactId: null, free: false, retail: prices[c.provider].retail }) });++ const list = [{ provider: 'a', model: 'm' }, { provider: 'b', model: 'm' }];++ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };++ const first = await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} });++ assert.strictEqual(first.order[0].provider, 'a');++ // b is only 5% cheaper: planAi's minGain keeps the incumbent.++ prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(950000, 0, 950000) } };++ for (let i = 0; i < 3; i++) {++ const r = await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} });++ assert.strictEqual(r.order[0].provider, 'a', `within the margin, decision ${i + 1}`);++ assert.ok(r.view.streak <= 0, 'a challenger within the margin never builds a streak');++ }++ // b is now 50% cheaper: the third consecutive decision switches, and the switch is persisted.++ prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(500000, 0, 500000) } };++ const calls = [];++ for (let i = 0; i < 3; i++) calls.push(await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} }));++ assert.deepStrictEqual(calls.map((c) => c.order[0].provider), ['a', 'a', 'b']);++ assert.deepStrictEqual(calls.map((c) => c.view.streak), [1, 2, 0]);++ assert.strictEqual(calls[2].view.switched, true);++ const again = await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} });++ assert.strictEqual(again.order[0].provider, 'b', 'the new incumbent is persisted');++ assert.strictEqual(again.view.incumbent, 'b:m');++ await db.close();++});++++t.test('cache affinity prefers the warm deployment while it is within the margin', async () => {++ const prices = { cold: { retail: RETAIL(1000000, 0, 1000000) }, warm: { retail: RETAIL(1000000, 0, 1000000) } };++ const h = await harness({ prices });++ await seed(h.metrics, 'cold', { ttft: 100, latency: 500, output: 1000 });++ await seed(h.metrics, 'warm', { ttft: 103, latency: 503, output: 1000 });++ const req = { messages: [{ role: 'user', content: 'the same prompt prefix' }], maxTokens: 100 };++ const list = [{ provider: 'cold', model: 'm' }, { provider: 'warm', model: 'm' }];++ const near = await h.ev.choose({ routeKey: 'affinity-near', policy: 'fastest', list, req, ctx: {}, warmDeployment: { provider: 'warm', model: 'm' } });++ assert.strictEqual(near.order[0].provider, 'warm', '3% slower is within the 5% affinity margin');++ assert.ok(near.view.affinity && near.view.affinity.within_margin);++ await seed(h.metrics, 'warm', { ttft: 130, latency: 530, output: 1000 });++ const far = await h.ev.choose({ routeKey: 'affinity-far', policy: 'fastest', list, req, ctx: {}, warmDeployment: { provider: 'warm', model: 'm' } });++ assert.strictEqual(far.order[0].provider, 'cold', '30% slower is outside the margin');++ assert.strictEqual(far.view.affinity, null);++ await h.stop();++});++++t.test('exploration is 0 by default and never on an exact id or an opted-out preference', async () => {++ const prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(900000, 0, 900000) } };++ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };++ const list = [{ provider: 'a', model: 'm' }, { provider: 'b', model: 'm' }];++ const h = await harness({ prices });++ await seed(h.metrics, 'a'); await seed(h.metrics, 'b');++ const off = await h.ev.choose({ routeKey: 'explore-off', policy: 'cheapest', list, req, ctx: {} });++ assert.strictEqual(off.view.exploration.used, false);++ assert.strictEqual(off.order[0].provider, 'b', 'the policy order decides, with no exploration');++ await h.stop();++++ const on = await harness({ prices, config: CONFIG({ explorationShare: 1 }) });++ await seed(on.metrics, 'a'); await seed(on.metrics, 'b');++ const explored = await on.ev.choose({ routeKey: 'explore-on', policy: 'cheapest', list, req, ctx: {} });++ assert.strictEqual(explored.view.exploration.used, true);++ assert.notStrictEqual(explored.order[0].provider, explored.view.incumbent.split(':')[0], 'the incumbent is not the exploration target');++ for (const ctx of [{ exact: { id: 'org/m@r1' } }, { credential: { provider: 'openai' } }, { selfCredentials: [{ provider: 'openai' }] }, { exploration: false }]) {++ const r = await on.ev.choose({ routeKey: 'explore-blocked-' + Object.keys(ctx)[0], policy: 'cheapest', list, req, ctx });++ assert.strictEqual(r.view.exploration.used, false, `never explores with ${Object.keys(ctx)[0]}`);++ }++ for (const prefs of [{ only_my_keys: true }, { exploration: false }]) {++ const r = await on.ev.choose({ routeKey: 'explore-prefs-' + Object.keys(prefs)[0], policy: 'cheapest', list, req, ctx: { selfPreferences: prefs } });++ assert.strictEqual(r.view.exploration.used, false, `never explores when preferences opt out (${Object.keys(prefs)[0]})`);++ }++ await on.stop();++});++++t.test('with the flag off nothing is written and the alias order is unchanged', async () => {++ const one = await seamServer(() => ({ text: 'from one', model: 'm', usage: { input: 10, output: 5 } }));++ const two = await seamServer(() => ({ text: 'from two', model: 'm', usage: { input: 10, output: 5 } }));++ let h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ const mk = async (key, url, cost) => {++ const p = await request(h.base, 'POST', '/api/v1/providers', { tok, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, key, base_url: url, capabilities: ['chat', 'generate'], default_model: 'm' } });++ assert.strictEqual(p.status, 201, p.text);++ const m = await request(h.base, 'POST', '/api/v1/models', { tok, body: { provider_key: key, model_key: 'm', type: 'chat', status: 'active', cost: { in_per_mtok: cost, out_per_mtok: cost }, supports: { json: true } } });++ assert.strictEqual(m.status, 201, m.text);++ };++ await mk('one', one.url, 10);++ await mk('two', two.url, 1);++ const r = await request(h.base, 'POST', '/api/v1/routes/evidence.off/versions', { tok, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });++ assert.strictEqual(r.status, 201, r.text);++ const ctx = { routeKey: r.body.route.key, routeVersion: r.body.route.version, aliasPolicy: 'cheapest', policy: 'cheapest' };++ const exec = await h.pool.execute(r.body.route, 'chat', { messages: [{ role: 'user', content: 'hello there' }], timeoutMs: 3000 }, ctx);++ assert.strictEqual(exec.explain.evidence, undefined, 'no evidence decision');++ assert.strictEqual(exec.explain.policy, 'cheapest', 'the alias policy still reaches the explain as before');++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM provider_metrics').get()).n, 0, 'no metrics are written with the flag off');++ await h.stop();++++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_ROUTING_EVIDENCE: '1', AI_ROUTING_EVIDENCE_MIN_SAMPLES: '1', AI_ROUTING_EVIDENCE_DECISIONS: '1' } });++ await mk('one', one.url, 10);++ await mk('two', two.url, 1);++ const r2 = await request(h.base, 'POST', '/api/v1/routes/evidence.on/versions', { tok, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });++ assert.strictEqual(r2.status, 201, r2.text);++ const facts = [];++ const exec2 = await h.pool.execute(r2.body.route, 'chat', { messages: [{ role: 'user', content: 'hello there' }], timeoutMs: 3000 }, { routeKey: r2.body.route.key, routeVersion: r2.body.route.version, aliasPolicy: 'cheapest', policy: 'cheapest', onRoute: async (f) => { facts.push(f); } });++ assert.ok(exec2.explain.evidence, 'the alias request carries the evidence explanation');++ assert.match(exec2.explain.evidence.reason, /lowest cost per success|no measured success/);++ assert.ok(facts.some((f) => f.policy_reason && /lowest cost per success|no measured success/.test(f.policy_reason)), 'x_openvibe gains the short policy reason');++ assert.ok((await h.db.prepare('SELECT COUNT(*) AS n FROM provider_metrics').get()).n >= 1, 'the attempt is measured');++ await h.stop();++ await one.close(); await two.close();++});++++t.test('the Bench client marks a sparse or absent exact id and never throws', async () => {++ const responses = [];++ const fetchImpl = async (url) => {++ responses.push(url);++ if (String(url).includes('sparse%2Fm')) return { ok: true, status: 200, json: async () => ({ evidence: { exact_id: 'sparse/m@r1', sample_count: 5, success_rate: 1, metrics: { ttft_p50_ms: 100 }, sparse: true } }) };++ if (String(url).includes('absent')) return { ok: false, status: 404, json: async () => ({}) };++ return { ok: false, status: 503, json: async () => ({}) };++ };++ const client = createVariantEvidence({ config: { bench: { url: 'http://bench.invalid', timeoutMs: 1000 } }, fetchImpl, clock: CLOCK, log: { warn() {} } });++ const sparse = await client.get('sparse/m@r1');++ assert.strictEqual(sparse.status, 'sparse');++ assert.strictEqual(sparse.samples, 5);++ assert.strictEqual((await client.get('absent')).status, 'absent');++ assert.strictEqual((await client.get('down')).status, 'unavailable');++ const before = responses.length;++ await client.get('sparse/m@r1');++ assert.strictEqual(responses.length, before, 'a cached read does not call Bench again');++});++++t.run();nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++// GET /api/v1/usage/series: aggregate demand for Host's inference planner. Per UTC day and model only —++// requests, successes and the four token totals — never a person, app, project, prompt or answer.++// The table rows are inserted directly because the point is the query, not the run that wrote them.++++const assert = require('assert');++const { boot, request, token, suite } = require('./helpers');++++const t = suite('usage-series');++const clock = { now: () => Date.parse('2026-11-03T12:00:00Z') };++const TODAY = '2026-11-03';++const YESTERDAY = '2026-11-02';++const OLD = '2026-10-20'; // inside 30 days++const OUTSIDE = '2026-08-01'; // outside 30 days++let h;++++const addStats = (day, provider, model, { requests, ok, errors = 0, tokensIn = 0, tokensOut = 0 }) =>++ h.db.prepare(`INSERT INTO provider_stats_daily (day, provider, model, requests, ok, errors, tokens_in, tokens_out)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(day, provider, model, requests, ok, errors, tokensIn, tokensOut);++++const addUsage = (day, requester, attribution, workflow, provider, model, cached) =>++ h.db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)++ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, 0)`).run(day, requester, attribution, workflow, provider, model, cached * 2, cached, cached);++++async function series(query = '') {++ return await request(h.base, 'GET', `/api/v1/usage/series${query}`, { tok: token('planner', ['ai.usage.read']) });++}++++t.test('boot with the aggregate demand feed on and two models, one with exact-id metadata', async () => {++ h = await boot({ clock, env: { AI_USAGE_SERIES: 'on' } });++ const now = new Date(clock.now()).toISOString();++ await h.db.prepare(`INSERT INTO models (provider_key, model_key, type, status, metadata, created_at, updated_at)++ VALUES (?, ?, 'chat', 'active', ?, ?, ?), (?, ?, 'chat', 'active', ?, ?, ?)`)++ .run('openai', 'gpt-4o-mini', JSON.stringify({ revision: '3f2a1c9' }), now, now,++ 'anthropic', 'claude-sonnet-4-5', '{}', now, now);++ // Provider attempts once rolled up (a retry would be a second attempt): today and yesterday.++ await addStats(TODAY, 'openai', 'gpt-4o-mini', { requests: 3, ok: 2, errors: 1, tokensIn: 1000, tokensOut: 200 });++ await addStats(TODAY, 'anthropic', 'claude-sonnet-4-5', { requests: 1, ok: 1, tokensIn: 5000, tokensOut: 100 });++ await addStats(YESTERDAY, 'openai', 'gpt-4o-mini', { requests: 4, ok: 4, tokensIn: 2000, tokensOut: 400 });++ await addStats(OLD, 'openai', 'gpt-4o-mini', { requests: 1, ok: 1, tokensIn: 10, tokensOut: 1 });++ await addStats(OUTSIDE, 'openai', 'gpt-4o-mini', { requests: 99, ok: 99, tokensIn: 1, tokensOut: 1 });++ // Two callers, a service and an app, on the same model and day: their cache reads aggregate.++ await addUsage(TODAY, 'service:live', 'live:user:42', 'live.translate', 'openai', 'gpt-4o-mini', 100);++ await addUsage(TODAY, 'app:app_01J8', '', 'app.summarise', 'openai', 'gpt-4o-mini', 50);++ await addUsage(TODAY, 'service:live', '', 'live.translate', 'anthropic', 'claude-sonnet-4-5', 500);++ await addUsage(OUTSIDE, 'service:live', '', 'live.translate', 'openai', 'gpt-4o-mini', 777);++});++++t.test('the shape: per day and model, four token totals, success count, and no caller or text', async () => {++ const r = await series('?days=30');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.days, 30);++ assert.strictEqual(r.body.to, TODAY);++ assert.strictEqual(r.body.from, '2026-10-05');++ assert.match(r.body.note, /cache_write/);++ assert.ok(Array.isArray(r.body.series));++++ const today = r.body.series.filter((x) => x.day === TODAY);++ const openai = today.find((x) => x.provider === 'openai');++ assert.deepStrictEqual(openai, {++ day: TODAY,++ model: 'openai/gpt-4o-mini@3f2a1c9',++ provider: 'openai',++ model_key: 'gpt-4o-mini',++ requests: 3,++ successes: 2,++ tokens: { input: 850, cached_read: 150, cache_write: 0, output: 200 },++ });++ const anthropic = today.find((x) => x.provider === 'anthropic');++ assert.strictEqual(anthropic.model, 'anthropic/claude-sonnet-4-5', 'no artifact revision: provider/model');++ assert.strictEqual(anthropic.requests, 1);++ assert.strictEqual(anthropic.successes, 1);++ assert.deepStrictEqual(anthropic.tokens, { input: 4500, cached_read: 500, cache_write: 0, output: 100 });++ assert.strictEqual(r.body.series.find((x) => x.day === YESTERDAY).requests, 4);++++ // The caller rows (a service and an app) are one number here: no person, app, project or cost, and++ // no prompt or answer text.++ const text = JSON.stringify(r.body.series);++ for (const forbidden of ['requester', 'attribution', 'project', 'prompt', 'answer', 'cost', 'live:user:42', 'app_01J8']) {++ assert.ok(!text.includes(forbidden), `the aggregate carries no ${forbidden}`);++ }++ assert.ok(!r.body.series.some((x) => x.day === OUTSIDE), 'outside the requested window');++});++++t.test('days is an integer from 1 to 90', async () => {++ assert.strictEqual((await series()).body.days, 30, 'default 30');++ assert.strictEqual((await series('?days=1')).body.days, 1);++ assert.strictEqual((await series('?days=90')).body.days, 90);++ for (const q of ['?days=0', '?days=91', '?days=abc', '?days=1.5', '?days=-1']) {++ const r = await series(q);++ assert.strictEqual(r.status, 422, q);++ assert.strictEqual(r.body.code, 'ai.invalid', q);++ }++});++++t.test('the capability gate: ai.usage.read, or 401 without a token', async () => {++ const denied = await request(h.base, 'GET', '/api/v1/usage/series?days=30', { tok: token('planner', ['ai.run.read']) });++ assert.strictEqual(denied.status, 403);++ assert.strictEqual(denied.body.code, 'capability.denied');++ const missing = await request(h.base, 'GET', '/api/v1/usage/series?days=30');++ assert.strictEqual(missing.status, 401);++ assert.strictEqual(missing.body.code, 'token.missing');++});++++t.test('AI_USAGE_SERIES off: the route is absent', async () => {++ const off = await boot({ clock, env: { AI_USAGE_SERIES: 'off' } });++ try {++ const r = await request(off.base, 'GET', '/api/v1/usage/series?days=30', { tok: token('planner', ['ai.usage.read']) });++ assert.strictEqual(r.status, 404);++ assert.strictEqual(r.body.code, 'ai.not_found');++ } finally { await off.close(); }++});++++(async () => { await t.run(); })();nnew file mode 100644@@@ -0,0 +1,295 @@++'use strict';++// AI Exchange P4 (CHECKPOINT D3/D4/D8), behind AI_MODEL_CATALOG:++// - GET /v1/models and /v1/models/{id} in both wire protocols: the registry's real models as ai.model@1 entries++// plus the dynamic policy aliases, marked dynamic;++// - an exact id runs only its artifact (never a substitute); an alias resolves through the same placement;++// - the route disclosure inside x_openvibe (D3') on JSON responses, as the first SSE comment and on the final chunk;++// - paid managed routing refuses UNVERIFIED/RESTRICTED offers, while an own deployment and CUSTOMER_BYOK work;++// - with the flag off, the legacy single `openvibe` model and no disclosure, exactly as before.++const assert = require('assert');++const http = require('http');++const { boot, request, token, suite, ALL } = require('./helpers');++++const t = suite('v1-models');++const service = token('live', ALL);++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';++const CREDENTIAL_KEY = 'cd'.repeat(32);++const ACTION = token('codes', ALL, { extra: { on_behalf_of: PERSON } });++const MANAGER = token('codes', [...ALL, 'ai.credential.manage'], { extra: { on_behalf_of: PERSON } });++const APP_D8 = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });++const APP_LEGACY = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });++++const ALPHA_ID = 'acme/m1:q4_k_m@rev1';++const BETA_ID = 'acme/m2@rev2';++const GAMMA_ID = 'acme/m3@rev3';++const DELTA_ID = 'anthropic/claude-test@revA';++const WEATHER = { type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } };++++/* ── a fake OpenAI-compatible upstream (JSON and SSE) ─────────────────────── */++function openAiUpstream() {++ const state = { mode: 'ok', calls: [], url: null, close: null };++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ let body = {};++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }++ state.calls.push({ url: req.url, body });++ if (state.mode === 'fail') {++ res.writeHead(500, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ error: { message: 'the artifact is down' } }));++ }++ const chunk = (choices, extra = {}) => JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion.chunk', created: 1, model: body.model, choices, ...extra });++ if (body.stream) {++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ res.write(`data: ${chunk([{ index: 0, delta: { role: 'assistant', content: 'Hello' }, finish_reason: null }])}\n\n`);++ res.write(`data: ${chunk([{ index: 0, delta: {}, finish_reason: 'stop' }])}\n\n`);++ res.write(`data: ${chunk([], { usage: { prompt_tokens: 5, completion_tokens: 2 } })}\n\n`);++ res.write('data: [DONE]\n\n');++ return res.end();++ }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion', model: body.model,++ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello there' }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 5, completion_tokens: 2 } }));++ });++ });++ server.listen(0, '127.0.0.1', () => { state.url = `http://127.0.0.1:${server.address().port}/v1`; });++ state.ready = new Promise((resolve) => server.on('listening', resolve));++ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });++ return state;++}++++/* ── a fake Anthropic Messages upstream (JSON and Anthropic's SSE frames) ──── */++function anthropicUpstream() {++ const state = { calls: [], url: null, close: null };++ const FRAMES = [++ ['message_start', { type: 'message_start', message: { id: 'msg_up', type: 'message', role: 'assistant', model: 'delta-1', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 5, output_tokens: 0 } } }],++ ['content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }],++ ['content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }],++ ['content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' there' } }],++ ['content_block_stop', { type: 'content_block_stop', index: 0 }],++ ['message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 2 } }],++ ['message_stop', { type: 'message_stop' }],++ ];++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', async () => {++ let body = {};++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }++ state.calls.push({ url: req.url, body });++ if (body.stream) {++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ for (const [event, data] of FRAMES) {++ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);++ await new Promise((r) => setTimeout(r, 2));++ }++ return res.end();++ }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ id: 'msg_up', type: 'message', role: 'assistant', model: body.model,++ content: [{ type: 'text', text: 'Hello there' }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 5, output_tokens: 2 } }));++ });++ });++ server.listen(0, '127.0.0.1', () => { state.url = `http://127.0.0.1:${server.address().port}`; });++ state.ready = new Promise((resolve) => server.on('listening', resolve));++ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });++ return state;++}++++const alpha = openAiUpstream();++const beta = openAiUpstream();++const gamma = openAiUpstream();++const delta = anthropicUpstream();++const freecap = openAiUpstream();++let h;++let ownCalls = 0;++const credentialFetch = async (url, init) => {++ ownCalls++;++ const body = JSON.parse(init.body);++ return new Response(JSON.stringify({ id: 'msg_own', type: 'message', role: 'assistant', model: body.model,++ content: [{ type: 'text', text: 'from your own key' }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 3, output_tokens: 2 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });++};++const post = (path, body, tok = service, headers = {}) => fetch(`${h.base}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });++const chat = (model, extra = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok: service, body: { model, messages: [{ role: 'user', content: 'Say hello' }], ...extra } });++const mkProvider = (body) => request(h.base, 'POST', '/api/v1/providers', { tok: service, body });++const mkModel = (body) => request(h.base, 'POST', '/api/v1/models', { tok: service, body });++++t.test('boot: the catalog is on, with own, wholesale and unverified offers', async () => {++ await Promise.all([alpha.ready, beta.ready, gamma.ready, delta.ready, freecap.ready]);++ h = await boot({ env: { AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_CREDENTIALS_KEY: CREDENTIAL_KEY }, credentialFetch });++ const providers = [++ { key: 'alpha', kind: 'openai', base_url: alpha.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'alpha-1', provider_access: 'OWN_DEPLOYMENT' },++ { key: 'beta', kind: 'openai', base_url: beta.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'beta-1', provider_access: 'AUTHORIZED_WHOLESALE' },++ { key: 'gamma', kind: 'openai', base_url: gamma.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'gamma-1' },++ { key: 'delta', kind: 'anthropic', base_url: delta.url, auth_mode: 'none', capabilities: ['chat', 'json'], default_model: 'delta-1', provider_access: 'OWN_DEPLOYMENT' },++ ];++ for (const p of providers) assert.strictEqual((await mkProvider(p)).status, 201);++ const cost = (input, output) => ({ in_per_mtok: input, out_per_mtok: output, cached_per_mtok: input / 10, source: 'https://example.com/pricing', verified_at: '2026-11-01' });++ const model = (provider_key, model_key, metadata, inUsd, outUsd, supports = { json: true, tools: true, streaming: true, vision: false }) =>++ mkModel({ provider_key, model_key, type: 'chat', status: 'active', context_window: 32768, max_output: 4096, supports, cost: cost(inUsd, outUsd), metadata });++ assert.strictEqual((await model('alpha', 'alpha-1', { org: 'acme', model: 'm1', quant: 'q4_k_m', revision: 'rev1', family: 'm', variant: 'original', license: 'mit', sha256: 'a'.repeat(64), rate_version: 'rr-1' }, 1, 2)).status, 201);++ assert.strictEqual((await model('beta', 'beta-1', { org: 'acme', model: 'm2', revision: 'rev2', rate_version: 'rr-2' }, 5, 10)).status, 201);++ assert.strictEqual((await model('gamma', 'gamma-1', { org: 'acme', model: 'm3', revision: 'rev3' }, 0.1, 0.2)).status, 201);++ assert.strictEqual((await model('delta', 'delta-1', { org: 'anthropic', model: 'claude-test', revision: 'revA' }, 3, 6)).status, 201);++});++++t.test('both protocols list the real models and the dynamic aliases', async () => {++ const openai = await request(h.base, 'GET', '/v1/models', { tok: service });++ assert.strictEqual(openai.status, 200, openai.text);++ assert.strictEqual(openai.body.object, 'list');++ const byId = new Map(openai.body.data.map((m) => [m.id, m]));++ for (const id of ['openvibe', 'openvibe/auto', 'openvibe/economy', 'openvibe/balanced', 'openvibe/fast', 'openvibe/private', ALPHA_ID, BETA_ID, GAMMA_ID, DELTA_ID]) assert.ok(byId.has(id), `lists ${id}`);++ assert.deepStrictEqual([byId.get('openvibe/auto').dynamic, byId.get('openvibe/auto').policy], [true, 'auto']);++ assert.deepStrictEqual([byId.get('openvibe/economy').dynamic, byId.get('openvibe/economy').policy], [true, 'cheapest']);++ assert.strictEqual(byId.get(ALPHA_ID).dynamic, false);++ assert.deepStrictEqual([byId.get(ALPHA_ID).family, byId.get(ALPHA_ID).revision, byId.get(ALPHA_ID).retail_rate.input_nano_usd_per_m],++ ['m', 'rev1', '1000000000'], 'the ai.model@1 catalog facts, in integer nano-USD');++++ const detail = await request(h.base, 'GET', `/v1/models/${encodeURIComponent(ALPHA_ID)}`, { tok: service });++ assert.strictEqual(detail.status, 200, detail.text);++ assert.deepStrictEqual([detail.body.id, detail.body.variant, detail.body.quant], [ALPHA_ID, 'original', 'q4_k_m']);++ const alias = await request(h.base, 'GET', '/v1/models/openvibe/private', { tok: service });++ assert.deepStrictEqual([alias.status, alias.body.dynamic, alias.body.policy], [200, true, 'private']);++ const missing = await request(h.base, 'GET', '/v1/models/acme/nope@rev0', { tok: service });++ assert.deepStrictEqual([missing.status, missing.body.error.code], [404, 'ai.model_not_found']);++++ const anthropic = await request(h.base, 'GET', '/v1/models', { tok: service, headers: { 'anthropic-version': '2023-06-01' } });++ assert.strictEqual(anthropic.status, 200, anthropic.text);++ assert.strictEqual(anthropic.body.object, undefined);++ assert.strictEqual(anthropic.body.has_more, false);++ assert.strictEqual(anthropic.body.data.find((m) => m.id === 'openvibe/economy').dynamic, true);++ assert.strictEqual(anthropic.body.data.find((m) => m.id === ALPHA_ID).type, 'model');++ const one = await request(h.base, 'GET', `/v1/models/${encodeURIComponent(ALPHA_ID)}`, { tok: service, headers: { 'anthropic-version': '2023-06-01' } });++ assert.deepStrictEqual([one.status, one.body.id, one.body.display_name], [200, ALPHA_ID, 'alpha-1']);++});++++t.test('an exact id runs only that artifact; an alias is dynamic and says which model answered', async () => {++ alpha.calls.length = 0; beta.calls.length = 0; gamma.calls.length = 0;++ const exact = await chat(ALPHA_ID);++ assert.strictEqual(exact.status, 200, exact.text);++ const exactBody = exact.body;++ assert.strictEqual(alpha.calls.length, 1, 'alpha ran');++ assert.deepStrictEqual([beta.calls.length, gamma.calls.length], [0, 0], 'no other artifact was called');++ assert.deepStrictEqual([exactBody.x_openvibe.resolved_model, exactBody.x_openvibe.deployment, exactBody.x_openvibe.provider_access, exactBody.x_openvibe.policy],++ [ALPHA_ID, 'alpha', 'OWN_DEPLOYMENT', 'exact'], JSON.stringify(exactBody.x_openvibe));++ assert.deepStrictEqual([exactBody.model, exactBody.x_openvibe.rate_version], ['alpha-1', 'rr-1']);++++ alpha.calls.length = 0; beta.calls.length = 0; gamma.calls.length = 0;++ const alias = await chat('openvibe/economy');++ assert.strictEqual(alias.status, 200, alias.text);++ const aliasBody = alias.body;++ assert.strictEqual(alpha.calls.length, 1, 'economy picked the cheapest eligible offer, alpha');++ assert.deepStrictEqual([beta.calls.length, gamma.calls.length], [0, 0], 'beta costs more; gamma is UNVERIFIED');++ assert.deepStrictEqual([aliasBody.x_openvibe.resolved_model, aliasBody.x_openvibe.deployment, aliasBody.x_openvibe.policy],++ [ALPHA_ID, 'alpha', 'cheapest']);++});++++t.test('an exact id whose artifact is down fails, and never substitutes another model', async () => {++ await request(h.base, 'POST', '/api/v1/providers/alpha/disable', { tok: service, body: {} });++ beta.calls.length = 0;++ const down = await chat(ALPHA_ID);++ assert.deepStrictEqual([down.status, down.body.error.code], [400, 'ai.model_unavailable'], down.text);++ assert.strictEqual(beta.calls.length, 0, 'the other artifact was never called');++++ // The deployment is active again but its upstream fails: still an error, still no substitute.++ await request(h.base, 'POST', '/api/v1/providers/alpha/enable', { tok: service, body: {} });++ alpha.mode = 'fail';++ beta.calls.length = 0;++ const failed = await chat(ALPHA_ID);++ assert.strictEqual(failed.status, 503, failed.text);++ assert.strictEqual(beta.calls.length, 0, 'not substituted after the artifact failed');++ alpha.mode = 'ok';++});++++t.test('the UNVERIFIED gate: gamma is never resold, while own and wholesale offers work', async () => {++ gamma.calls.length = 0;++ const blocked = await chat(GAMMA_ID);++ assert.deepStrictEqual([blocked.status, blocked.body.error.code], [400, 'ai.model_unavailable']);++ assert.strictEqual(gamma.calls.length, 0, 'the UNVERIFIED offer was never called');++ // Both allowed accesses still serve a paid managed request.++ assert.strictEqual((await chat(BETA_ID)).status, 200);++ assert.strictEqual((await chat(ALPHA_ID)).status, 200);++});++++t.test('BYOK still works with the catalog on, disclosed as CUSTOMER_BYOK', async () => {++ const put = await request(h.base, 'PUT', `/api/v1/credentials/${PERSON}/anthropic`, { tok: MANAGER, body: { key: 'sk-ant-test-person-0123456789', models: { chat: 'claude-test-1' } } });++ assert.strictEqual(put.status, 200, put.text);++ ownCalls = 0;++ const before = delta.calls.length;++ const res = await request(h.base, 'POST', '/v1/messages', { tok: ACTION, headers: { 'x-openvibe-credential': 'anthropic' }, body: { model: 'openvibe/auto', max_tokens: 64, messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(res.status, 200, res.text);++ const body = res.body;++ assert.strictEqual(body.x_openvibe.provider_access, 'CUSTOMER_BYOK');++ assert.strictEqual(body.x_openvibe.policy, 'byok');++ assert.strictEqual(ownCalls, 1, 'the person\'s own key answered');++ assert.strictEqual(delta.calls.length, before, 'the managed Anthropic offer was not called');++ await request(h.base, 'DELETE', `/api/v1/credentials/${PERSON}/anthropic`, { tok: MANAGER });++});++++t.test('the SSE disclosure: a leading comment and a field on the final chunk, both protocols', async () => {++ const openai = await post('/v1/chat/completions', { model: ALPHA_ID, stream: true, messages: [{ role: 'user', content: 'stream' }] });++ assert.strictEqual(openai.status, 200);++ const blocks = (await openai.text()).split('\n\n').filter(Boolean);++ assert.match(blocks[0], /^: x_openvibe /, 'the first SSE block is the disclosure comment');++ const head = JSON.parse(blocks[0].slice(': x_openvibe '.length));++ assert.deepStrictEqual([head.resolved_model, head.deployment, head.provider_access], [ALPHA_ID, 'alpha', 'OWN_DEPLOYMENT']);++ const chunks = blocks.filter((b) => b.startsWith('data: ') && !b.includes('[DONE]')).map((b) => JSON.parse(b.slice(6)));++ assert.deepStrictEqual([chunks[chunks.length - 1].x_openvibe.resolved_model, chunks[chunks.length - 1].x_openvibe.policy], [ALPHA_ID, 'exact']);++++ delta.calls.length = 0;++ const anthropic = await post('/v1/messages', { model: DELTA_ID, max_tokens: 32, stream: true, messages: [{ role: 'user', content: 'stream' }] }, service, { 'anthropic-version': '2023-06-01' });++ assert.strictEqual(anthropic.status, 200);++ const frames = (await anthropic.text()).split('\n\n').filter(Boolean);++ assert.match(frames[0], /^: x_openvibe /, 'the first Anthropic SSE block is the disclosure comment');++ const anthropicHead = JSON.parse(frames[0].slice(': x_openvibe '.length));++ assert.deepStrictEqual([anthropicHead.resolved_model, anthropicHead.deployment, anthropicHead.policy], [DELTA_ID, 'delta', 'exact']);++ const stop = frames.map((b) => { const d = /^data: (.*)$/m.exec(b); return d ? JSON.parse(d[1]) : null; }).filter((d) => d && d.type === 'message_stop');++ assert.strictEqual(stop.length, 1);++ assert.strictEqual(stop[0].x_openvibe.resolved_model, DELTA_ID, 'the final Anthropic chunk carries the disclosure');++});++++t.test("D8': developer apps use ai.app.run on /v1/models and /v1/chat/completions", async () => {++ const list = await request(h.base, 'GET', '/v1/models', { tok: APP_D8 });++ assert.strictEqual(list.status, 200, list.text);++ assert.ok(list.body.data.some((m) => m.id === ALPHA_ID));++ assert.strictEqual((await request(h.base, 'GET', '/v1/models', { tok: APP_LEGACY })).status, 200, 'ai.app.run still works');++ // A free own deployment lets an app's free-only run answer through the same guard.++ assert.strictEqual((await mkProvider({ key: 'freecap', kind: 'openai', base_url: freecap.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'free-1', billing_profile: 'free', provider_access: 'OWN_DEPLOYMENT' })).status, 201);++ assert.strictEqual((await mkModel({ provider_key: 'freecap', model_key: 'free-1', type: 'chat', status: 'active', supports: { json: true, streaming: true } })).status, 201);++ const res = await chat('openvibe/auto', {});++ assert.strictEqual(res.status, 200);++ const appRes = await request(h.base, 'POST', '/v1/chat/completions', { tok: APP_D8, body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'app' }] } });++ assert.strictEqual(appRes.status, 200, appRes.text);++});++++t.test('flag off: the legacy single model and no disclosure, exactly as before', async () => {++ const off = await boot();++ try {++ const list = await request(off.base, 'GET', '/v1/models', { tok: service });++ assert.deepStrictEqual(list.body.data.map((m) => m.id), ['openvibe']);++ const anthropic = await request(off.base, 'GET', '/v1/models', { tok: service, headers: { 'anthropic-version': '2023-06-01' } });++ assert.deepStrictEqual(anthropic.body.data.map((m) => m.id), ['openvibe']);++ const res = await request(off.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: 'not-a-catalog-id', messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual((res.body.x_openvibe || {}).resolved_model, undefined, 'no route disclosure with the flag off');++ assert.strictEqual((await request(off.base, 'GET', '/v1/models', { tok: APP_D8 })).status, 200, 'an ai.app.run app lists the legacy model as before');++ // An exact id is a pin: with the catalog off (a rollback of the flag) it is refused, never run on another model.++ const pin = 'qwen/qwen3-8b:q4_k_m@3f2a1c9';++ const chat = await request(off.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: pin, messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(chat.status, 400, 'an exact id with the catalog off is refused');++ assert.strictEqual(chat.body.error.code, 'ai.model_unavailable');++ const msg = await request(off.base, 'POST', '/v1/messages', { tok: service, body: { model: pin, max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] } });++ assert.strictEqual(msg.status, 400, 'and on Messages');++ assert.match(msg.body.error.message, /ai\.model_unavailable/);++ } finally { await off.stop(); }++});++++t.test('stop', async () => {++ await h.stop();++ await Promise.all([alpha.close(), beta.close(), gamma.close(), delta.close(), freecap.close()]);++});++++t.run();
@@ -68,6 +68,19 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -68,6 +68,19 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
#USAGE_SAMPLES=on
#USAGE_SAMPLES=on
#USAGE_SAMPLES_INTERVAL_MS=2000
#USAGE_SAMPLES_INTERVAL_MS=2000
+# ── Billing Balance (AI Exchange F, CHECKPOINT D2/D9/D10/D12), off by default ──
+# A request on a paid managed route (the model catalog's provider_access OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE)
+# holds its worst-case cost before a provider is called, extends the hold while streaming and settles once.
+# Network must grant client `ai` billing.credit.hold.{create,extend,settle,release} on openvibe.billing. An app
+# holds against its project's Balance (subject app + project_id); a person against their own (no project_id); no
+# network.project.read is needed.
+# Reads OV_BILLING_INTERNAL_URL, OV_NETWORK_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET above; a missing Billing refuses
+# paid requests (503). A settle Billing could not take is retried from credit_settle_queue with backoff.
+#AI_CREDITS=0
+#AI_CREDITS_TIMEOUT_MS=5000
+#AI_CREDITS_SETTLE_RETRY_MS=5000
+#AI_CREDITS_SETTLE_MAX_RETRY_MS=300000
+
# ── Tier budgets (server/govern.js, T6 step 8): openvibe-sdk/govern in Valkey (VALKEY_URL above), off by default ──
# ── Tier budgets (server/govern.js, T6 step 8): openvibe-sdk/govern in Valkey (VALKEY_URL above), off by default ──
# Units ai-token and ai-usd per subject and per project; tiers free, paid, staff. See README "Tier budgets".
# Units ai-token and ai-usd per subject and per project; tiers free, paid, staff. See README "Tier budgets".
#AI_GOVERN_TIERS=0
#AI_GOVERN_TIERS=0
@@ -84,6 +97,16 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -84,6 +97,16 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# stored key unreadable: the storing service then has to send them again.
# stored key unreadable: the storing service then has to send them again.
#AI_CREDENTIALS_KEY=
#AI_CREDENTIALS_KEY=
+# ── AI owner settings (D11, server/settings.js; openvibe-shared/config) ──
+# The ai.exchange and ai.provider_keys namespaces. OV_CONFIG_KEKS is "<id>:<base64 32-byte key>[,<id>:…]" and
+# OV_CONFIG_KEK_CURRENT names the one to use; they come from the host's env file, never the database. With a KEK,
+# secret values (each provider's ai.provider_keys "<provider>.key") are sealed envelopes in the config tables and a
+# DB dump has no key; unset, the store still works and reports "sealed": false. At boot AI moves the old
+# provider_secrets rows (AI_CREDENTIALS_KEY) into sealed "<provider>.key" values, bound to each provider's origin,
+# and deletes them after a successful read-back.
+#OV_CONFIG_KEKS=
+#OV_CONFIG_KEK_CURRENT=
+
# ── Memory (server/memory/, Memory M2a) ──
# ── Memory (server/memory/, Memory M2a) ──
# 64 hex characters (openssl rand -hex 32). Memory item text and source references are AES-256-GCM sealed with it;
# 64 hex characters (openssl rand -hex 32). Memory item text and source references are AES-256-GCM sealed with it;
# the dedupe MAC is HKDF-SHA256 (info "memory-mac") of the same key. Unset turns /api/v1/memory off (503
# the dedupe MAC is HKDF-SHA256 (info "memory-mac") of the same key. Unset turns /api/v1/memory off (503
@@ -156,6 +179,20 @@ AI_MODEL=
@@ -156,6 +179,20 @@ AI_MODEL=
# production, off in production (synthetic output must never stand in for a real answer there).
# production, off in production (synthetic output must never stand in for a real answer there).
#AI_STUB_FALLBACK=false
#AI_STUB_FALLBACK=false
+# ── AI Exchange P4 (CHECKPOINT D3): the truthful model catalog, exact ids and dynamic aliases ──
+# Off: today's behaviour (one virtual `openvibe` model, no disclosure object). On:
+# - GET /v1/models lists the registry's real models in the ai.model@1 shape plus the dynamic aliases
+# openvibe/auto, openvibe/economy, openvibe/balanced, openvibe/fast, openvibe/private (legacy openvibe = auto);
+# - `model` may be an exact id `<org>/<model>[:<quant>]@<revision>` (it runs only that artifact, or fails with
+# 400 ai.model_unavailable / 404 ai.model_not_found, and never substitutes another model), or one of those aliases;
+# - every response carries the `openvibe` disclosure (a leading SSE comment and the final chunk when streaming);
+# - paid managed routing uses only offers whose provider_access (API /api/v1/providers) is OWN_DEPLOYMENT or
+# AUTHORIZED_WHOLESALE. RESTRICTED and UNVERIFIED (the default) offers are not resold; a person's own key is
+# CUSTOMER_BYOK and never goes through that gate.
+# Exact ids need artifact metadata on the model row (POST/PATCH /api/v1/models `metadata`: org, model, quant,
+# revision, family, variant, sha256, license, rate_version).
+#AI_MODEL_CATALOG=1
+
# ── Local speech-to-text: whisper.cpp, the same names Live uses ──
# ── Local speech-to-text: whisper.cpp, the same names Live uses ──
#WHISPER_BIN=/home/ubuntu/whisper.cpp/build/bin/whisper-cli
#WHISPER_BIN=/home/ubuntu/whisper.cpp/build/bin/whisper-cli
#WHISPER_MODEL=/home/ubuntu/whisper.cpp/models/ggml-base.en.bin
#WHISPER_MODEL=/home/ubuntu/whisper.cpp/models/ggml-base.en.bin
@@ -128,6 +128,7 @@ idempotent replay):
@@ -128,6 +128,7 @@ idempotent replay):
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
| `POST/PATCH /api/v1/providers…`, `…/disable\|enable\|reset`, `POST/PATCH /api/v1/models…`, `POST /api/v1/quotas`, `DELETE /api/v1/cache` | `ai.provider.manage` |
| `POST/PATCH /api/v1/providers…`, `…/disable\|enable\|reset`, `POST/PATCH /api/v1/models…`, `POST /api/v1/quotas`, `DELETE /api/v1/cache` | `ai.provider.manage` |
| `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |
| `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |
+| `GET /api/v1/usage/series?days=N` (N 1–90) — aggregate demand per UTC day and model: requests, successes and the four token totals, never a caller id or text (`AI_USAGE_SERIES=on`) | `ai.usage.read` |
| `GET /api/health`, `GET /api/ready`, `GET /release.json` | public |
| `GET /api/health`, `GET /api/ready`, `GET /release.json` | public |
| `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) |
| `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) |
| `GET /console…`, `/auth/login\|callback`, `POST /auth/logout` | Network staff, signed in (see [Operator console](#operator-console)) |
| `GET /console…`, `/auth/login\|callback`, `POST /auth/logout` | Network staff, signed in (see [Operator console](#operator-console)) |
@@ -172,6 +173,43 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `
@@ -172,6 +173,43 @@ no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `
`network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)
`network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`)
and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).
and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23).
+## Model catalog, exact ids and the disclosure
+
+Behind `AI_MODEL_CATALOG` (off by default; with it off both `GET /v1/models` endpoints answer the legacy virtual
+`openvibe` model and no disclosure is added; an exact id is still a pin, so it is refused with 400
+`ai.model_unavailable` rather than run on another model). It applies to both `POST /v1/chat/completions` and
+`POST /v1/messages`.
+
+- **`GET /v1/models` / `GET /v1/models/{id}`:** the registry's real models as `ai.model@1` catalog entries
+ (`provider_key`, `model_key`, `id`, family, variant, quant, revision, sha256, license, context, capabilities,
+ modalities, retail rate in integer nano-USD, availability) plus the dynamic policy aliases `openvibe/auto`,
+ `openvibe/economy`, `openvibe/balanced`, `openvibe/fast`, `openvibe/private` and legacy `openvibe` (= auto). An
+ alias carries `dynamic: true` and its `policy`. Every entry also carries the response shape of the protocol it was
+ asked in: OpenAI's `object`/`created`/`owned_by`, or Anthropic's `type`/`display_name`/`created_at`. An Anthropic
+ client identifies itself with its `anthropic-version` header (or `?format=anthropic`).
+- **Exact ids:** `model` may be `<org>/<model>[:<quant>]@<revision>` (for example
+ `qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9`). It runs only that artifact. An artifact that does not exist is 404
+ `ai.model_not_found`; one that exists but cannot run the request (no active deployment, unmet capability, down
+ provider, or an offer that may not be resold) is 400 `ai.model_unavailable`. It is never substituted with another
+ model, in fallback or otherwise. A row without artifact metadata has no exact id; it is still listed and reachable
+ by its legacy `provider/model` id.
+- **Aliases:** the dynamic names resolve through the same provider placement. `economy` ranks price first, `fast`
+ measured latency first, `balanced` weighs both, `private` never leaves local/own capacity, and `auto` keeps the
+ existing task-aware router. User preferences (`only_my_keys`, `free_first`, own keys and machines) still apply.
+- **The disclosure, inside `x_openvibe`:** `resolved_model`, `revision`, `deployment`, `provider_access`,
+ `rate_version`, `policy` and `fallback_reason` join the `x_openvibe` object responses already carry (run, provider,
+ cost). There is no second object.
+ - On a JSON response it is in the top-level `x_openvibe`.
+ - On SSE it is the first line, as an SSE comment (`: x_openvibe {...}`), and it is on the final chunk
+ (`message_stop` for Messages).
+- **Commercial access:** `provider_access` is a provider field (`POST/PATCH /api/v1/providers`, default `UNVERIFIED`).
+ Paid managed routing uses only `OWN_DEPLOYMENT` and `AUTHORIZED_WHOLESALE`; `RESTRICTED`, `UNVERIFIED` and
+ `CUSTOMER_BYOK` offers stay out of the shared pool. A person's own key or project provider is disclosed as
+ `CUSTOMER_BYOK` and is unaffected.
+- **Tokens:** developer apps call with the public `ai.app.run` they already hold (ADR-014). The separate
+ `ai.inference.create` once planned for this was retired (Contracts 0.285.0). `ai.run.create` remains the
+ first-party service capability.
+
## OpenAI-compatible endpoint
## OpenAI-compatible endpoint
`POST /v1/chat/completions` and `GET /v1/models` (`server/api/openai.js`), so a client built for OpenAI's API (the SDKs,
`POST /v1/chat/completions` and `GET /v1/models` (`server/api/openai.js`), so a client built for OpenAI's API (the SDKs,
@@ -314,14 +352,15 @@ needs scripts and inline styles), like Billing's staff console. Code: `server/co
@@ -314,14 +352,15 @@ needs scripts and inline styles), like Billing's staff console. Code: `server/co
|---|---|
|---|---|
| `/console` | providers needing attention, runs and failures of the last 24 h by error code, today's spend, the queue, the busiest quota windows |
| `/console` | providers needing attention, runs and failures of the last 24 h by error code, today's spend, the queue, the busiest quota windows |
| `/console/providers` | every provider (kind, status, **secret reference name and whether it resolves, never a value**, circuit, base URL without query/user info, default model, priority/timeout) and the models; for the owner, per provider: replace its key, register a model (with prices typed from the provider's own price page, which the form requires) |
| `/console/providers` | every provider (kind, status, **secret reference name and whether it resolves, never a value**, circuit, base URL without query/user info, default model, priority/timeout) and the models; for the owner, per provider: replace its key, register a model (with prices typed from the provider's own price page, which the form requires) |
-| `/console/providers/new` | the owner adds a provider in a few clicks: DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API (`server/console/presets.js`), with its API key, which is kept encrypted (`vault:<provider>`, below) |
+| `/console/providers/new` | the owner adds a provider in a few clicks: DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API (`server/console/presets.js`), with its API key, which is kept sealed in the config store, bound to the provider's origin (`vault:<provider>`, below) |
| `/console/routes/:key` (owner form) | **use another provider**: a new route version whose primary is the provider and model chosen, with the previous primary kept as the first fallback; **back to the default** writes the newest seeded version again |
| `/console/routes/:key` (owner form) | **use another provider**: a new route version whose primary is the provider and model chosen, with the previous primary kept as the first fallback; **back to the default** writes the newest seeded version again |
| `/console/routes`, `/templates`, `/workflows` (`/:key[?version=]`) | every key's newest version and the version in use; per key every version with its status and content (primary and fallbacks in order, prompts, steps, schemas) |
| `/console/routes`, `/templates`, `/workflows` (`/:key[?version=]`) | every key's newest version and the version in use; per key every version with its status and content (primary and fallbacks in order, prompts, steps, schemas) |
| `/console/runs` | runs newest first, filtered by status (tabs), workflow, requester (`live` = `service:live`), error code and a UTC time range; failed runs grouped by error code |
| `/console/runs` | runs newest first, filtered by status (tabs), workflow, requester (`live` = `service:live`), error code and a UTC time range; failed runs grouped by error code |
| `/console/runs/:id` | versions used, provider/model, fallback, attempts, tokens, cost, the Billing reading with its delivery state, free share and last send error, error code and detail, requester/attribution/target, grounding, citations, the request log (hashes only), and the stored input/output **bounded**: strings cut at 240 characters, lists at 20 items, 6 KB in all, credential-named keys blanked; raw debug fields never |
| `/console/runs/:id` | versions used, provider/model, fallback, attempts, tokens, cost, the Billing reading with its delivery state, free share and last send error, error code and detail, requester/attribution/target, grounding, citations, the request log (hashes only), and the stored input/output **bounded**: strings cut at 240 characters, lists at 20 items, 6 KB in all, credential-named keys blanked; raw debug fields never |
| `/console/quotas`, `/console/usage` | active quotas with their current windows and counters, every quota; usage by day, requester, workflow and provider/model over a day range (`quotas.usage`); the **free allowance this period** per subject × provider × metric (free used, allowance, remaining, period end; `quotas.freeAllowance.current()`); the `platform.usage-sample@1` readings by delivery state (queued, retrying, sent, failed) and the last send errors with their run. Staff only: the public `/stats` page never shows a subject |
| `/console/quotas`, `/console/usage` | active quotas with their current windows and counters, every quota; usage by day, requester, workflow and provider/model over a day range (`quotas.usage`); the **free allowance this period** per subject × provider × metric (free used, allowance, remaining, period end; `quotas.freeAllowance.current()`); the `platform.usage-sample@1` readings by delivery state (queued, retrying, sent, failed) and the last send errors with their run. Staff only: the public `/stats` page never shows a subject |
+| `/console/exchange` | AI Exchange operational state, only with `AI_CREDITS` and/or `AI_ROUTING_EVIDENCE` on: the Credits settle queue's counts by status (queued, sent, review), the oldest queued row's age, the review rows (hold id, request id, tries, last error, created) with one **mark resolved** action that never retries, and the four settle-body usage counts only; per deployment samples, success and timeout rates, TTFT and decode EWMA and cache-hit ratio; the incumbents per decision key with the challenger and its streak |
| `/console/cache` | live entries and hits by workflow |
| `/console/cache` | live entries and hits by workflow |
-| `/console/audit` | the `audit_log`, filtered by kind (configuration changes by default, runs, console, everything), action, actor, target |
+| `/console/audit` | the `audit_log`, filtered by kind (configuration changes by default, runs, AI Exchange, console, everything), action, actor, target |
**Who may use what.** Sign-in is OpenVibe.Network SSO (authorization code + PKCE S256 as OAuth client
**Who may use what.** Sign-in is OpenVibe.Network SSO (authorization code + PKCE S256 as OAuth client
`ai`; the refresh token is revoked at once). The staff claims of the Network token (contracts staff
`ai`; the refresh token is revoked at once). The staff claims of the Network token (contracts staff
@@ -331,7 +370,7 @@ the same one the admin API does:
@@ -331,7 +370,7 @@ the same one the admin API does:
| Network staff capability | Held by | Console capability | Allows |
| Network staff capability | Held by | Console capability | Allows |
|---|---|---|---|
|---|---|---|---|
| `staff.site.view` | admin, owner | `ai.usage.read` | open the console, read every page |
| `staff.site.view` | admin, owner | `ai.usage.read` | open the console, read every page |
-| `staff.ai.manage` | admin, owner | `ai.workflow.manage` | set a route/template/workflow version's status; cancel a queued or running run |
+| `staff.ai.manage` | admin, owner | `ai.workflow.manage` | set a route/template/workflow version's status; cancel a queued or running run; mark a Credits settle review row resolved (never retried) |
| `staff.secrets.manage` | owner | `ai.provider.manage` | add a provider with its key, replace a key, register a model, choose a route's provider and model, enable/disable a provider, reset its circuit, add/change a quota, purge the cache (one workflow, or all with a confirmation) |
| `staff.secrets.manage` | owner | `ai.provider.manage` | add a provider with its key, replace a key, register a model, choose a route's provider and model, enable/disable a provider, reset its circuit, add/change a quota, purge the cache (one workflow, or all with a confirmation) |
A `global_mod` (only `staff.console.access`), a user or a token without a `usr_` subject is refused
A `global_mod` (only `staff.console.access`), a user or a token without a `usr_` subject is refused
@@ -364,12 +403,16 @@ the Action Center is separate: a first-party service token with `ai.actions.read
@@ -364,12 +403,16 @@ the Action Center is separate: a first-party service token with `ai.actions.read
### Provider keys added from the console (`vault:`)
### Provider keys added from the console (`vault:`)
-A provider the owner adds from the console keeps its key in `provider_secrets` (migration `0010_provider_secrets.sql`,
-`server/vault.js`): AES-256-GCM with `AI_CREDENTIALS_KEY`, bound to the provider's key so a row copied to another
-provider does not decrypt, with only a four-character hint ever shown. The provider's `secret_ref` is
-`vault:<provider key>`, which `util.resolveSecret` reads from this process's decrypted copy (loaded at boot and after
-every change; providers resolve secrets synchronously). Audit rows say a key was replaced, never what it is. Without
-`AI_CREDENTIALS_KEY` the form says keys cannot be stored and nothing is added.
+A provider the owner adds from the console keeps its key in the `ai.provider_keys` namespace of the configuration
+store (D11, `server/settings.js`, `migrations/0028_config.sql`). For each provider `<p>.origin` is the origin of its
+base URL and `<p>.key` is a write-only secret sealed under `OV_CONFIG_KEKS` and x-bind'ed to `<p>.origin`; changing the
+base URL locks the key until it is entered again. The provider's `secret_ref` is `vault:<provider key>`, which
+`util.resolveSecret` reads from this process's cache (refreshed by the store's `onActivate`; providers resolve secrets
+synchronously). Only a four-character hint is ever shown, and audit rows say a key was replaced, never what it is.
+Without a KEK the store still works and reports `"sealed": false`. At boot, when `AI_CREDENTIALS_KEY` and
+`OV_CONFIG_KEKS` are both set, the old `provider_secrets` rows (migration `0010_provider_secrets.sql`) are opened,
+written as sealed `<p>.key` values bound to their current origin, read back and only then deleted; counts are logged,
+never a value.
## The eleven record groups
## The eleven record groups
@@ -440,7 +483,38 @@ run, and a pinned run is never served from the shared cache.
@@ -440,7 +483,38 @@ run, and a pinned run is never served from the shared cache.
reading its `idempotency_key`, `state` (`queued` until Billing accepts it, `sent`, or `failed` when Billing
reading its `idempotency_key`, `state` (`queued` until Billing accepts it, `sent`, or `failed` when Billing
refused it), `attempts`, `queued_at`, `sent_at`, `rejected_at`, `next_attempt_at`, `last_error` (the
refused it), `attempts`, `queued_at`, `sent_at`, `rejected_at`, `next_attempt_at`, `last_error` (the
outbox's last send error, also while a queued reading is retried) and `free_allowance_used` when some
outbox's last send error, also while a queued reading is retried) and `free_allowance_used` when some
-was free; a sent reading the relay has pruned reads `{ state: 'sent', pruned: true }`. Speech and embeddings use placement pools of registered `stt` and `embedding` models; until
+**Balance (AI Exchange F; `AI_CREDITS`, off).** A request on a paid managed route — the model catalog's
+`provider_access` is `OWN_DEPLOYMENT` or `AUTHORIZED_WHOLESALE` and the offer is metered — reserves its
+worst case in OpenVibe.Billing before any provider is called: the input estimate plus `max_tokens` of output
+at the model's retail card (`ai.model@1` `retail_rate`). Who pays is the payer's own Balance (D12): an app's
+run holds against its project's Balance, with the hold's subject `{type: "app", id}` and its `project_id`, so
+no Network owner lookup is made; a person's own run holds against their personal Balance, subject
+`{type: "user", id}` and no `project_id`. A first-party service with no payer (no person and no app) is never
+served a paid offer that carries a versioned card (`payer_required`); offers without a card behave as before.
+An exact id whose only offer is skipped that way is `402 billing.payer_required`.
+
+`max_cost_usd` is parsed as an exact decimal (up to 9 fractional digits, finer digits round up; exponent forms
+are 422) into integer nano-USD. When it binds, the affordable output is computed in BigInt from nano-USD per
+million tokens and the provider receives that clamped `max_tokens`; fewer than one affordable token is 402
+before the provider. A pricier managed fallback extends the hold by the difference (clamped the same way); if
+Billing refuses the extension the offer is skipped as `hold_not_extended`. A stream extends the hold in steps
+when the output nears it.
+
+The hold's idempotency key is the request id; its fingerprint covers the resolved model, the token caps, the
+rate version, the payer and (for an app) the project. Billing's `402 billing.insufficient_funds` becomes AI's
+own 402 `Not enough Balance`; any other Billing failure refuses the run with `503 billing.unavailable` rather
+than serving unpaid capacity. At the end one settle carries the four token counts, the answering model's retail
+card version, the supply cost and every attempt in `billing.credit-hold-settle-request@1`'s exact shape
+(`deployment_id`, `provider_access`, start/end, `error_code`); a retried request replays the same settlement
+instead of charging twice. A settle Billing cannot take is kept in `credit_settle_queue` (migration 0025) and
+retried with backoff and the same key; a 409 or 422 marks it `review`, never a retry. A failure before the
+first token releases the hold, an abort settles the partial usage. BYOK — a person's key, their machine, a project's own provider — is never held and
+never charged; Billing is not called at all. A sandbox app token is never offered paid supply (D10), so it
+spends no Balance and never opens a hold. Requires `AI_MODEL_CATALOG` for the access labels; with either
+flag off nothing changes. Code: `server/credits.js`, `server/credit-settlements.js`; the fake-Billing tests are
+`test/credits.test.js` and `test/credits-run.test.js`.
+
+Speech and embeddings use placement pools of registered `stt` and `embedding` models; until
a second upstream exists, an unavailable sole provider answers `503 provider.unavailable`. Media
a second upstream exists, an unavailable sole provider answers `503 provider.unavailable`. Media
uses separate local and paid pools so paid work still requires its explicit budget. The synthetic
uses separate local and paid pools so paid work still requires its explicit budget. The synthetic
stub can join general text routes as a last resort outside production only (`AI_STUB_FALLBACK`).
stub can join general text routes as a last resort outside production only (`AI_STUB_FALLBACK`).
@@ -653,6 +727,7 @@ mix vectors from different providers (re-embed when the provider changes).
@@ -653,6 +727,7 @@ mix vectors from different providers (re-embed when the provider changes).
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | `test/stats-page.test.js` |
| The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` |
| The OpenAI Responses adapter (`kind: responses`) with a stubbed fetch: request shape, output and usage parsing, `text.format`, error mapping, the key never in an error | `test/provider-responses.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
+| OpenAI Chat Completions and Anthropic Messages pass the official-client fixtures (tool call + tool result, JSON response_format / structured tool, multi-turn system prompt, exact id, every alias) in JSON and SSE; usage is present and x_openvibe is top-level only | `test/protocol-compat.test.js` |
| Parakeet: engine by language, 30 s pieces past 45 s, fallback to whisper, `ASR_ENGINE=whisper` | `test/parakeet.test.js` |
| Parakeet: engine by language, 30 s pieces past 45 s, fallback to whisper, `ASR_ENGINE=whisper` | `test/parakeet.test.js` |
| Word timing (`live.media.transcribe` with `words: true`): whisper's `-ojf` tokens and Parakeet's token lines become each segment's words with their times; punctuation joins a word without moving its end; one unit per token in Japanese; fillers and control tokens are not words | `test/word-timing.test.js` |
| Word timing (`live.media.transcribe` with `words: true`): whisper's `-ojf` tokens and Parakeet's token lines become each segment's words with their times; punctuation joins a word without moving its end; one unit per token in Japanese; fillers and control tokens are not words | `test/word-timing.test.js` |
| `media.analyze` on a real 28 s fixture: signals, scenes and loud moments measured, highlights with evidence, the local model's overview from the facts only; the local model down → extractive and said; paid only with `allow_paid` and a `media.paid` budget, never past it; too little disk refused before downloading | `test/media-analysis.test.js` |
| `media.analyze` on a real 28 s fixture: signals, scenes and loud moments measured, highlights with evidence, the local model's overview from the facts only; the local model down → extractive and said; paid only with `allow_paid` and a `media.paid` budget, never past it; too little disk refused before downloading | `test/media-analysis.test.js` |
@@ -753,7 +828,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
@@ -753,7 +828,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.262.0
-- openvibe-sdk: v0.82.0
-- openvibe-shared: v4.109.2
+- openvibe-contracts: v0.289.1
+- openvibe-sdk: v0.83.0
+- openvibe-shared: v4.110.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,9 +7,9 @@
@@ -7,9 +7,9 @@
"runtime": "node 22, express, postgresql",
"runtime": "node 22, express, postgresql",
"port": 4700,
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
- "contracts": "openvibe-contracts v0.262.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)",
- "sdk": "openvibe-sdk v0.82.0",
- "shared": "openvibe-shared v4.109.2",
+ "contracts": "openvibe-contracts v0.289.1 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",
+ "sdk": "openvibe-sdk v0.83.0",
+ "shared": "openvibe-shared v4.110.0",
"capabilities": [
"capabilities": [
"ai.run.create",
"ai.run.create",
"ai.run.read",
"ai.run.read",
@@ -22,7 +22,8 @@
@@ -22,7 +22,8 @@
"ai.memory.read",
"ai.memory.read",
"ai.memory.write",
"ai.memory.write",
"ai.me.read",
"ai.me.read",
- "ai.me.manage"
+ "ai.me.manage",
+ "ai.config.manage"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"providers": [
"providers": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "ai.config.manage",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Manage the ai.exchange and ai.provider_keys namespaces of OpenVibe.AI through openvibe-shared/config (D11): GET /internal/config lists the namespace summaries, GET /internal/config/:namespace returns one as common.config-namespace@1, POST applies common.config-change@1, and PUT /internal/config/:namespace/secrets/:key sets a secret with common.config-secret-put@1 (write-only; the answer is the marker) while DELETE removes it. Only Network's admin proxy, acting for the owner, holds this capability; it is never grantable to apps. Each request carries the owner as actor, and the service audits every write with that actor and key, never a secret value.",
+ "permissions": [
+ "config:manage"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "config-manage",
+ "events": [],
+ "implementedBy": [
+ "GET /internal/config",
+ "GET|POST /internal/config/:namespace",
+ "PUT|DELETE /internal/config/:namespace/secrets/:key"
+ ],
+ "inputSchema": "common.config-change@1",
+ "outputSchema": "common.config-namespace@1"
+}
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
"capabilities": [
"capabilities": [
"ai.actions.read",
"ai.actions.read",
"ai.app.run",
"ai.app.run",
+ "ai.config.manage",
"ai.credential.manage",
"ai.credential.manage",
"ai.provider.manage",
"ai.provider.manage",
"ai.quota.attribution.manage",
"ai.quota.attribution.manage",
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- AI Exchange D3: the commercial access a provider's capacity is offered under. Paid managed routing on the
+-- catalog flag (AI_MODEL_CATALOG) uses only OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE offers; a person's own key
+-- (CUSTOMER_BYOK) never goes through this pool. The default is UNVERIFIED: an offer that has not recorded an
+-- agreement or an owned deployment is never silently treated as resellable. Additive only: every existing row
+-- keeps the UNVERIFIED default until an operator records its access.
+ALTER TABLE providers ADD COLUMN provider_access text COLLATE "C" NOT NULL DEFAULT 'UNVERIFIED'; -- OWN_DEPLOYMENT | AUTHORIZED_WHOLESALE | CUSTOMER_BYOK | RESTRICTED | UNVERIFIED
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+-- phase: expand
+-- AI Exchange F (P7): a settle Billing could not take is kept here and retried with backoff, with the same
+-- idempotency key, until Billing answers 2xx. A 409 or 422 marks the row for review; it is not retried.
+-- Expand only: no existing table is touched.
+CREATE TABLE IF NOT EXISTS credit_settle_queue (
+ id text PRIMARY KEY, -- the settle idempotency key (ai-settle:<request id>)
+ hold_id text NOT NULL,
+ request_id text,
+ settle_body jsonb NOT NULL, -- the full POST .../settle body, key included
+ attempts jsonb NOT NULL DEFAULT '[]'::jsonb,
+ status text NOT NULL DEFAULT 'queued', -- queued | sent | review
+ tries integer NOT NULL DEFAULT 0,
+ next_at timestamptz NOT NULL,
+ last_error text,
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS credit_settle_queue_due ON credit_settle_queue (status, next_at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+-- phase: expand
+-- AI Exchange P7 (PLAN §D ObservedPerformance, §E): measured per-deployment evidence for alias routing.
+-- Behind AI_ROUTING_EVIDENCE (off by default); additive only: no existing table or column is changed.
+--
+-- provider_metrics is one EWMA row per deployment (provider_key, model_key). Every attempt AI already logs
+-- updates it, and a restart keeps it. Counts are separate per measurement so a missing TTFT (a non-streamed
+-- answer) never drags the latency average to zero. The rates are EWMA values in [0, 1]; ttft_ms and
+-- decode_tps are null until the first measurement.
+CREATE TABLE provider_metrics (
+ provider_key text COLLATE "C" NOT NULL,
+ model_key text COLLATE "C" NOT NULL,
+ ttft_ms double precision, -- EWMA of the first-token time (streamed answers)
+ ttft_samples bigint NOT NULL DEFAULT 0,
+ decode_tps double precision, -- EWMA of output tokens / (finish - first token)
+ decode_samples bigint NOT NULL DEFAULT 0,
+ success_rate double precision NOT NULL DEFAULT 0, -- EWMA of 1 (ok) / 0 (failed or timed out)
+ timeout_rate double precision NOT NULL DEFAULT 0, -- EWMA of 1 (provider.timeout) / 0
+ cache_hit_ratio double precision NOT NULL DEFAULT 0, -- EWMA of cached / prompt tokens
+ cache_samples bigint NOT NULL DEFAULT 0,
+ sample_count bigint NOT NULL DEFAULT 0, -- every attempt (the n in the explanation)
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (provider_key, model_key)
+);
+
+-- The incumbent an alias route is keeping for hysteresis, and the challenger's consecutive-decision streak.
+-- route_key is the router's route key plus the alias policy, so each alias keeps its own incumbent.
+CREATE TABLE routing_incumbents (
+ decision_key text COLLATE "C" PRIMARY KEY,
+ provider_key text COLLATE "C",
+ model_key text COLLATE "C",
+ challenger_provider text COLLATE "C",
+ challenger_model text COLLATE "C",
+ challenger_streak integer NOT NULL DEFAULT 0,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- AI Exchange operations console (P7): a person has looked at a settle row in review and resolved it.
+-- The settle is never retried from here; these columns say who ended the review and when. Additive only.
+ALTER TABLE credit_settle_queue ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
+ALTER TABLE credit_settle_queue ADD COLUMN IF NOT EXISTS resolved_by text;
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+-- phase: expand
+-- D11 owner settings: openvibe-shared/config (shared 4.110.0) keeps every service namespace here. AI's namespaces are
+-- ai.exchange (the AI Exchange switches) and ai.provider_keys (the owner's provider keys, replacing provider_secrets).
+-- Additive only: nothing existing is changed. config_keys holds the per-namespace fingerprint key, wrapped under the
+-- service's KEK (OV_CONFIG_KEKS) when one is configured; secret values are sealed envelopes in values_json.
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+-- The fingerprint key of each namespace. Never served, never logged.
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
+ created_at text COLLATE "C" NOT NULL
+);
@@ -13,9 +13,9 @@
@@ -13,9 +13,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1540,9 +1540,9 @@
@@ -1540,9 +1540,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.262.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",
- "integrity": "sha512-E5zcp/ucdLH5q22jWxzdw1tgDRt9Y4y4TEVZvp3C6xwe2DOlDVDYgASHv8lF7QSHHmkmm9narAeQKmLrMBBhLA==",
+ "version": "0.289.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1556,9 +1556,9 @@
@@ -1556,9 +1556,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.82.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",
+ "version": "0.83.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0",
+ "integrity": "sha512-xHFcsZmRVuQX0HlMFA7Kc53GMpdZUXxkcR17whknFgUQznuWCwcnhwS87HnnqsrmnHp8AHC0d5uywLjfOwOYBw==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1584,9 +1584,9 @@
@@ -1584,9 +1584,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.109.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
+ "version": "4.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
+ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -25,9 +25,9 @@
@@ -25,9 +25,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.83.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"optionalDependencies": {
"optionalDependencies": {
@@ -41,6 +41,8 @@ const TABLES = [
@@ -41,6 +41,8 @@ const TABLES = [
{ table: 'memory_profiles', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'memory_profiles', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'memory_reads', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'memory_reads', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
+ // D11 owner settings: the revisions are the service's configuration audit trail, kept like the catalogues.
+ { table: 'config_snapshots', subject: 'created_by', file: null, erase: { keep: 'the configuration history is the service\'s; created_by is the operator audit trail' } },
{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* (routes/templates/workflows: read with ai.workflow.manage, ai.run.create or ai.usage.read; write with ai.workflow.manage)
* (routes/templates/workflows: read with ai.workflow.manage, ai.run.create or ai.usage.read; write with ai.workflow.manage)
* GET /api/v1/quotas POST /api/v1/quotas (ai.provider.manage)
* GET /api/v1/quotas POST /api/v1/quotas (ai.provider.manage)
* GET /api/v1/usage, /api/v1/requests, /api/v1/audit, /api/v1/cache (ai.usage.read)
* GET /api/v1/usage, /api/v1/requests, /api/v1/audit, /api/v1/cache (ai.usage.read)
+ * GET /api/v1/usage/series aggregate demand, only with AI_USAGE_SERIES=on (ai.usage.read)
* DELETE /api/v1/cache[?workflow=] purge (ai.provider.manage)
* DELETE /api/v1/cache[?workflow=] purge (ai.provider.manage)
*
*
* Provider status, circuit reset, cache purge and the status summary are server/ops.js, shared with
* Provider status, circuit reset, cache purge and the status summary are server/ops.js, shared with
@@ -24,8 +25,9 @@ const express = require('express');
@@ -24,8 +25,9 @@ const express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { AiError, sendError } = require('../util');
const { AiError, sendError } = require('../util');
const { createOps } = require('../ops');
const { createOps } = require('../ops');
+const { originOf } = require('../settings');
-function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = createOps({ db, registry, pool, quotas, cache, runs }), log = console }) {
+function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, auth, settings = null, ops = createOps({ db, registry, pool, quotas, cache, runs }), log = console }) {
const r = express.Router();
const r = express.Router();
const provRead = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
const provRead = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
const provWrite = auth.requireCap(CAPS.providerManage);
const provWrite = auth.requireCap(CAPS.providerManage);
@@ -51,14 +53,34 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea
@@ -51,14 +53,34 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea
r.post('/api/v1/providers', provWrite, wrap(async (req, res) => {
r.post('/api/v1/providers', provWrite, wrap(async (req, res) => {
const b = body(req);
const b = body(req);
if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');
if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');
+ const provider = await registry.upsertProvider(b, { ...actor(req), origin: 'admin' });
+ // D11: the provider's origin is the bind of its ai.provider_keys secret; AI writes it itself.
+ if (settings && provider.base_url) {
+ await settings.providerKeys.setOrigin(provider.key, provider.base_url, { actor: { type: 'service', id: 'ai' }, reason: 'provider created' })
+ .catch((err) => log.warn(`[config] ${provider.key} origin: ${err.message}`));
+ }
res.status(201);
res.status(201);
- return { provider: await view(await registry.upsertProvider(b, { ...actor(req), origin: 'admin' })) };
+ return { provider: await view(provider) };
}));
}));
r.patch('/api/v1/providers/:key', provWrite, wrap(async (req) => {
r.patch('/api/v1/providers/:key', provWrite, wrap(async (req) => {
const b = body(req);
const b = body(req);
if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');
if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');
- if (!await registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');
- return { provider: await view(await registry.upsertProvider({ ...b, key: req.params.key }, { ...actor(req), origin: 'admin' })) };
+ const prev = await registry.getProvider(req.params.key);
+ if (!prev) throw new AiError(404, 'ai.not_found', 'no such provider');
+ const moved = b.base_url !== undefined && originOf(b.base_url) !== originOf(prev.base_url);
+ // Move the bind before the record: a key sealed to the old origin stops opening at once, so it can never be
+ // sent to the new host while the provider row still points at the old one (or if this request then fails).
+ if (moved && settings) {
+ if (!String(b.base_url || '').trim()) throw new AiError(422, 'ai.invalid', 'a provider needs a base URL');
+ await settings.providerKeys.setOrigin(prev.key, b.base_url, { actor: { type: 'service', id: 'ai' }, reason: 'provider base_url changed' });
+ }
+ const provider = await registry.upsertProvider({ ...b, key: req.params.key }, { ...actor(req), origin: 'admin' });
+ const answer = { provider: await view(provider) };
+ if (moved && String(provider.secret_ref || '').startsWith('vault:')) {
+ answer.key_reentry_required = true;
+ answer.notice = 'the key must be entered again for the new origin';
+ }
+ return answer;
}));
}));
for (const [action, status] of [['disable', 'disabled'], ['enable', 'active']]) {
for (const [action, status] of [['disable', 'disabled'], ['enable', 'active']]) {
r.post(`/api/v1/providers/:key/${action}`, provWrite, wrap(async (req) => ({ provider: await view(await ops.setProviderStatus(req.params.key, status, actor(req))) })));
r.post(`/api/v1/providers/:key/${action}`, provWrite, wrap(async (req) => ({ provider: await view(await ops.setProviderStatus(req.params.key, status, actor(req))) })));
@@ -177,6 +199,11 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea
@@ -177,6 +199,11 @@ function adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops = crea
// Quotas, usage, logs, audit, cache
// Quotas, usage, logs, audit, cache
r.get('/api/v1/quotas', usageRead, wrap(async () => ({ quotas: await quotas.list(), current: await quotas.counters() })));
r.get('/api/v1/quotas', usageRead, wrap(async () => ({ quotas: await quotas.list(), current: await quotas.counters() })));
+ // Aggregate demand for Host's inference planner (AI_USAGE_SERIES, off by default): per day and
+ // model only, no caller id, prompt, answer, project or cost.
+ if (config.usageSeries && config.usageSeries.enabled) {
+ r.get('/api/v1/usage/series', usageRead, wrap(async (req) => await quotas.usageSeries({ days: req.query.days })));
+ }
r.post('/api/v1/quotas', provWrite, wrap(async (req, res) => { res.status(201); return { quota: await quotas.upsert(body(req), { ...actor(req), origin: 'admin' }) }; }));
r.post('/api/v1/quotas', provWrite, wrap(async (req, res) => { res.status(201); return { quota: await quotas.upsert(body(req), { ...actor(req), origin: 'admin' }) }; }));
r.get('/api/v1/usage', usageRead, wrap(async (req) => ({ usage: await quotas.usage({ from: req.query.from, to: req.query.to, requester: req.query.requester }) })));
r.get('/api/v1/usage', usageRead, wrap(async (req) => ({ usage: await quotas.usage({ from: req.query.from, to: req.query.to, requester: req.query.requester }) })));
r.get('/api/v1/requests', usageRead, wrap(async (req) => {
r.get('/api/v1/requests', usageRead, wrap(async (req) => {
@@ -3,14 +3,21 @@
@@ -3,14 +3,21 @@
* The Anthropic-compatible endpoint: POST /v1/messages on ai.openvibe.services, so a client built for Anthropic's
* The Anthropic-compatible endpoint: POST /v1/messages on ai.openvibe.services, so a client built for Anthropic's
* Messages API (Claude Code, an SDK) points ANTHROPIC_BASE_URL at https://ai.openvibe.services and works (Codes C3).
* Messages API (Claude Code, an SDK) points ANTHROPIC_BASE_URL at https://ai.openvibe.services and works (Codes C3).
*
*
- * Authorization: Bearer <a Network token> an app's token (ai.app.run, its project pays) or a first-party service's
- * (ai.run.create), exactly as POST /v1/chat/completions takes them
+ * Authorization: Bearer <a Network token> an app's token (ai.app.run
+ * on; its project pays) or a first-party service's (ai.run.create)
* x-openvibe-credential: <provider> the person's own key for that provider instead of the router: anthropic,
* x-openvibe-credential: <provider> the person's own key for that provider instead of the router: anthropic,
* deepseek or minimax (their Anthropic-compatible endpoints), or a custom
* deepseek or minimax (their Anthropic-compatible endpoints), or a custom
* endpoint. The person is `<provider>:<subject>`, or the token's
* endpoint. The person is `<provider>:<subject>`, or the token's
* on_behalf_of (an agent acting for them)
* on_behalf_of (an agent acting for them)
* body Anthropic's Messages API: messages, system, tools, tool_choice,
* body Anthropic's Messages API: messages, system, tools, tool_choice,
* stop_sequences, temperature, top_p, top_k, max_tokens, metadata, stream
* stop_sequences, temperature, top_p, top_k, max_tokens, metadata, stream
+ * model with AI_MODEL_CATALOG off, ignored as before (an exact id is refused). On, an exact id
+ * `<org>/<model>[:<quant>]@<revision>` (runs only that artifact, never a
+ * substitute) or a dynamic alias (openvibe/auto|economy|balanced|fast|
+ * private; legacy openvibe = auto), with the `openvibe` disclosure: the
+ * first SSE comment when streaming and a field on the final message_stop.
+ * GET /v1/models[/{id}] lists them in Anthropic's shape; an OpenAI client
+ * (no anthropic-version) is served by server/api/openai.js.
*
*
* Each request is a run of workflow ai.messages, so it has the run's quota, free allowance, cost, audit and explain
* Each request is a run of workflow ai.messages, so it has the run's quota, free allowance, cost, audit and explain
* (the run id rides X-OpenVibe-Run-Id) and the prompt is not kept. Without the header, AI's router picks an upstream that
* (the run id rides X-OpenVibe-Run-Id) and the prompt is not kept. Without the header, AI's router picks an upstream that
@@ -24,6 +31,9 @@ const express = require('express');
@@ -24,6 +31,9 @@ const express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { appAttribution } = require('../apps');
const { appAttribution } = require('../apps');
const { credentialFor } = require('./credential');
const { credentialFor } = require('./credential');
+const models = require('./models');
+const modelCatalog = require('../providers/model-catalog');
+const { EXACT_ID } = modelCatalog;
const FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];
const FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];
const MAX_TOKENS = 64000;
const MAX_TOKENS = 64000;
@@ -40,28 +50,41 @@ function errorType(status) {
@@ -40,28 +50,41 @@ function errorType(status) {
return 'api_error';
return 'api_error';
}
}
-function anthropicError(res, status, message, type) {
- res.status(status).json({ type: 'error', error: { type: type || errorType(status), message: String(message || 'error').slice(0, 1000), code: status === 402 ? 'ai.run_budget_reached' : null } });
+function anthropicError(res, status, message, type, code = null) {
+ res.status(status).json({ type: 'error', error: { type: type || errorType(status), message: String(message || 'error').slice(0, 1000), code: code || (status === 402 ? 'ai.run_budget_reached' : null) } });
}
}
/**
/**
* The event stream of one streamed message: each upstream event goes out as `event: <name>` + its JSON as it arrives (or
* The event stream of one streamed message: each upstream event goes out as `event: <name>` + its JSON as it arrives (or
* waits until open(run) has sent the headers). fail() sends Anthropic's error event; done() ends the stream.
* waits until open(run) has sent the headers). fail() sends Anthropic's error event; done() ends the stream.
+ *
+ * AI Exchange D3: once the router has picked the offer (route(info)), the stream's first line is the `x_openvibe`
+ * disclosure as an SSE comment, before any event; the final message_stop event carries the whole x_openvibe object.
*/
*/
function createEventStream(res) {
function createEventStream(res) {
let opened = false;
let opened = false;
let any = false;
let any = false;
- let stopped = false;
+ let routeInfo = null;
+ let commented = false;
+ let finalEvent = null;
const waiting = [];
const waiting = [];
const alive = () => !res.writableEnded && !res.destroyed;
const alive = () => !res.writableEnded && !res.destroyed;
- const write = (event, data) => { if (alive()) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); };
+ const comment = () => {
+ if (commented || !routeInfo || !alive()) return;
+ res.write(`: x_openvibe ${JSON.stringify(routeInfo)}\n\n`);
+ commented = true;
+ };
+ const write = (event, data) => { if (alive()) { comment(); res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } };
const emit = (event, data) => {
const emit = (event, data) => {
if (!data) return;
if (!data) return;
- if (event === 'message_stop') stopped = true;
+ // message_stop is held until the run has settled: the final chunk carries the whole x_openvibe object (run_id,
+ // provider, cost_usd and the route disclosure), and the cost is only known once the provider's usage is recorded.
+ if (event === 'message_stop') { finalEvent = data; return; }
write(event, data);
write(event, data);
};
};
const out = {
const out = {
get opened() { return opened; },
get opened() { return opened; },
+ route(info) { if (info) routeInfo = info; },
event(e) {
event(e) {
if (!e || !e.event || !e.data) return;
if (!e || !e.event || !e.data) return;
any = true;
any = true;
@@ -80,7 +103,13 @@ function createEventStream(res) {
@@ -80,7 +103,13 @@ function createEventStream(res) {
},
},
emit,
emit,
sentAny: () => any,
sentAny: () => any,
- get stopped() { return stopped; },
+ /** The held message_stop with the final x_openvibe object, then end the stream. */
+ finish(xOpenVibe) {
+ const data = finalEvent || { type: 'message_stop' };
+ finalEvent = null;
+ write('message_stop', xOpenVibe ? { ...data, x_openvibe: xOpenVibe } : data);
+ return out.done();
+ },
fail(message, code) {
fail(message, code) {
const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 529;
const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 529;
emit('error', { type: 'error', error: { type: errorType(status), message: String(message || 'error').slice(0, 1000), code: code || null } });
emit('error', { type: 'error', error: { type: errorType(status), message: String(message || 'error').slice(0, 1000), code: code || null } });
@@ -108,8 +137,9 @@ function replay(res, message, run) {
@@ -108,8 +137,9 @@ function replay(res, message, run) {
res.emit('message_stop', { type: 'message_stop' });
res.emit('message_stop', { type: 'message_stop' });
}
}
-function messagesRouter({ runs, auth, config, log = console }) {
+function messagesRouter({ runs, auth, config, registry, log = console }) {
const r = express.Router();
const r = express.Router();
+ // Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
// The auth layer writes problem+json; a client built for Anthropic reads { type: 'error', error: { … } }. Convert it
// The auth layer writes problem+json; a client built for Anthropic reads { type: 'error', error: { … } }. Convert it
@@ -139,6 +169,32 @@ function messagesRouter({ runs, auth, config, log = console }) {
@@ -139,6 +169,32 @@ function messagesRouter({ runs, auth, config, log = console }) {
if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');
if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
+ // AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension).
+ if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
+ // AI Exchange D3 (AI_MODEL_CATALOG): an exact id or a dynamic alias. With the flag off the field is ignored
+ // exactly as before, and the router picks, except an exact id: a pin is never silently run on another model (a
+ // rollback of the flag included), so it is refused.
+ const catalogOn = Boolean(config.modelCatalog && config.modelCatalog.enabled);
+ if (!catalogOn && typeof b.model === 'string' && EXACT_ID.test(b.model.trim())) {
+ return anthropicError(res, 400, 'model: an exact model id needs the model catalog, which is off: name a dynamic model (openvibe) instead (ai.model_unavailable)');
+ }
+ if (catalogOn) {
+ if (b.model !== undefined && b.model !== null && typeof b.model !== 'string') return anthropicError(res, 400, 'model: must be a string');
+ const requested = b.model == null ? 'openvibe/auto' : String(b.model).trim();
+ if (!requested || requested.length > 256) return anthropicError(res, 400, 'model: must name an exact id or a dynamic alias');
+ input.model = requested;
+ // An exact id is a pin: an unknown or inactive artifact is refused here, before the run (and its stream)
+ // exists, so a streaming client gets the 404/400 it expects rather than a 200 SSE error. The engine selects
+ // the same artifact again when the run executes.
+ if (EXACT_ID.test(requested)) {
+ try {
+ await modelCatalog.select({ registry, model: requested, route: null });
+ } catch (err) {
+ if (err && err.status) return anthropicError(res, err.status, err.detail || err.message, null, err.code);
+ throw err;
+ }
+ }
+ }
let body = { workflow: 'ai.messages', input };
let body = { workflow: 'ai.messages', input };
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
if (b.prefer) body = { ...body, prefer: b.prefer };
if (b.prefer) body = { ...body, prefer: b.prefer };
@@ -151,7 +207,9 @@ function messagesRouter({ runs, auth, config, log = console }) {
@@ -151,7 +207,9 @@ function messagesRouter({ runs, auth, config, log = console }) {
const live = b.stream === true ? createEventStream(res) : null;
const live = b.stream === true ? createEventStream(res) : null;
let run;
let run;
try {
try {
- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, ...(live ? { onDelta: live.event } : {}) });
+ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,
+ ...(live ? { onDelta: live.event } : {}),
+ ...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
if (live) live.open(created.run);
if (live) live.open(created.run);
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
} catch (err) {
} catch (err) {
@@ -165,9 +223,10 @@ function messagesRouter({ runs, auth, config, log = console }) {
@@ -165,9 +223,10 @@ function messagesRouter({ runs, auth, config, log = console }) {
}
}
if (run.status === 'failed' || run.status === 'cancelled') {
if (run.status === 'failed' || run.status === 'cancelled') {
const code = (run.error && run.error.code) || 'run.failed';
const code = (run.error && run.error.code) || 'run.failed';
- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
- : code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404 : 529;
- return anthropicError(res, status, (run.error && run.error.detail) || code);
+ const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
+ : code === 'credential.unsupported' ? 422 : code === 'credential.not_found' ? 404
+ : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 529;
+ return anthropicError(res, status, (run.error && run.error.detail) || code, null, code);
}
}
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.content)) {
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.content)) {
return anthropicError(res, 529, `the message is still running; GET /api/v1/runs/${run.id}`, 'overloaded_error');
return anthropicError(res, 529, `the message is still running; GET /api/v1/runs/${run.id}`, 'overloaded_error');
@@ -184,21 +243,39 @@ function messagesRouter({ runs, auth, config, log = console }) {
@@ -184,21 +243,39 @@ function messagesRouter({ runs, auth, config, log = console }) {
...(out.usage && out.usage.cache_creation_input_tokens != null ? { cache_creation_input_tokens: out.usage.cache_creation_input_tokens } : {}),
...(out.usage && out.usage.cache_creation_input_tokens != null ? { cache_creation_input_tokens: out.usage.cache_creation_input_tokens } : {}),
},
},
};
};
+ const disclosure = await models.disclosureOf({ registry, config, run });
+ if (live) live.route(disclosure);
+ // D8': the route disclosure joins the existing x_openvibe object (run, provider, fallback, cost), never a second object.
+ const x = { run_id: run.id, provider: run.provenance && run.provenance.provider, fallback_used: Boolean(run.provenance && run.provenance.fallback_used),
+ cost_usd: run.usage.cost_usd, synthetic: Boolean(run.synthetic) };
+ const xOpenVibe = disclosure ? { ...x, ...disclosure } : null;
if (live) {
if (live) {
// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot
// The events already went out as they arrived; an answer that came in one piece (an upstream that cannot
- // stream, a replayed run) is sent as the events it is, and a stream that never closed is closed.
+ // stream, a replayed run) is sent as the events it is. finish() sends the held message_stop with the whole
+ // x_openvibe object, then ends the stream.
if (!live.sentAny()) replay(live, message, run);
if (!live.sentAny()) replay(live, message, run);
- else if (!live.stopped) live.emit('message_stop', { type: 'message_stop' });
- return live.done();
+ return live.finish(xOpenVibe);
}
}
res.setHeader('X-OpenVibe-Run-Id', run.id);
res.setHeader('X-OpenVibe-Run-Id', run.id);
- return res.status(200).json(message);
+ return res.status(200).json(xOpenVibe ? { ...message, x_openvibe: xOpenVibe } : message);
});
});
- // The model list a client asks for: one router model, the upstreams are AI's choice.
- r.get('/v1/models', asAnthropicErrors, allow, (req, res) => {
- res.json({ data: [{ type: 'model', id: 'openvibe', display_name: 'OpenVibe AI', created_at: '2026-10-30T00:00:00Z' }], has_more: false, first_id: 'openvibe', last_id: 'openvibe' });
- });
+ // The model list a client asks for. An Anthropic client identifies itself with `anthropic-version`; the OpenAI
+ // router steps aside for it, so the response, the access check and the error object are Anthropic's. With
+ // AI_MODEL_CATALOG off this is exactly the legacy single virtual model.
+ const onlyAnthropic = (req, res, next) => (models.wantsAnthropic(req) ? next() : next('router'));
+ const modelRoute = (fn) => async (req, res) => {
+ try { await fn(req, res); } catch (err) {
+ log.error(`[ai] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
+ if (!res.headersSent) models.sendError(res, 'anthropic', 500, 'internal error');
+ }
+ };
+ r.get('/v1/models', asAnthropicErrors, onlyAnthropic, allow, modelRoute(async (req, res) => {
+ await models.sendList(req, res, { registry, config, protocol: 'anthropic' });
+ }));
+ r.get('/v1/models/*', asAnthropicErrors, onlyAnthropic, allow, modelRoute(async (req, res) => {
+ await models.sendOne(req, res, { registry, config, protocol: 'anthropic' });
+ }));
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+/**
+ * GET /v1/models and GET /v1/models/{id} in both wire protocols (AI Exchange P4, CHECKPOINT D3/D4).
+ *
+ * With AI_MODEL_CATALOG on, the list is the registry's real models as ai.model@1 catalog entries plus the dynamic
+ * policy aliases (openvibe/auto, economy, balanced, fast, private; legacy openvibe), each alias carrying dynamic:true.
+ * With the flag off, both protocols answer exactly the legacy single virtual `openvibe` model.
+ *
+ * The same path serves OpenAI and Anthropic clients. Anthropic's SDK identifies itself with `anthropic-version`;
+ * this module reads that header (or ?format=anthropic) so the response and the error shape match the protocol.
+ * A client that sends neither gets OpenAI's shape. The OpenAI and Anthropic routers each register the routes; the
+ * OpenAI router steps aside for an Anthropic request before its auth guard, so that router's error shape applies.
+ */
+const modelCatalog = require('../providers/model-catalog');
+
+const LEGACY_MODEL = 'openvibe';
+const LEGACY_CREATED = 1760486400;
+const LEGACY_CREATED_AT = '2026-10-30T00:00:00Z';
+
+/** Does the caller speak Anthropic's models protocol? */
+function wantsAnthropic(req) {
+ return Boolean(String((req.headers && req.headers['anthropic-version']) || '').trim()) || Boolean(req.query && req.query.format === 'anthropic');
+}
+
+const epochOf = (iso) => {
+ const ms = Date.parse(iso || '');
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : LEGACY_CREATED;
+};
+const aliasEntry = (alias) => ({
+ ...alias,
+ object: 'model',
+ type: 'chat',
+ owned_by: 'openvibe',
+ created: LEGACY_CREATED,
+ created_at: LEGACY_CREATED_AT,
+ display_name: alias.name,
+});
+const catalogOpenAi = (x) => ({
+ ...x.entry,
+ id: x.entry.id || `${x.entry.provider_key}/${x.entry.model_key}`,
+ object: 'model',
+ created: epochOf(x.entry.created_at),
+ owned_by: x.entry.provider_key,
+ dynamic: false,
+});
+const catalogAnthropic = (x) => ({
+ ...x.entry,
+ type: 'model',
+ id: x.entry.id || `${x.entry.provider_key}/${x.entry.model_key}`,
+ display_name: x.entry.display_name || x.entry.model_key,
+ created_at: x.entry.created_at || LEGACY_CREATED_AT,
+ dynamic: false,
+});
+const anthropicAlias = (alias) => ({
+ ...alias,
+ type: 'model',
+ display_name: alias.name,
+ created_at: LEGACY_CREATED_AT,
+ object: undefined,
+ owned_by: undefined,
+ created: undefined,
+});
+
+/** The catalog list in one protocol. Flag off: today's single virtual model. */
+async function list({ registry, config, protocol }) {
+ const enabled = Boolean(config.modelCatalog && config.modelCatalog.enabled);
+ if (!enabled) {
+ if (protocol === 'anthropic') return { data: [{ type: 'model', id: LEGACY_MODEL, display_name: 'OpenVibe AI', created_at: LEGACY_CREATED_AT }], has_more: false, first_id: LEGACY_MODEL, last_id: LEGACY_MODEL };
+ return { object: 'list', data: [{ id: LEGACY_MODEL, object: 'model', created: LEGACY_CREATED, owned_by: 'openvibe' }] };
+ }
+ const aliases = modelCatalog.ALIASES.map((a) => (protocol === 'anthropic' ? anthropicAlias(a) : aliasEntry(a)));
+ const models = (await modelCatalog.entries({ registry })).map((x) => (protocol === 'anthropic' ? catalogAnthropic(x) : catalogOpenAi(x)));
+ const data = [...aliases, ...models];
+ if (protocol === 'anthropic') return { data, has_more: false, first_id: data.length ? data[0].id : null, last_id: data.length ? data[data.length - 1].id : null };
+ return { object: 'list', data };
+}
+
+/** One model (or dynamic alias) in one protocol; null when it does not exist. */
+async function find({ registry, config, protocol, id }) {
+ const enabled = Boolean(config.modelCatalog && config.modelCatalog.enabled);
+ if (!enabled) return null;
+ const alias = modelCatalog.ALIASES.find((a) => a.id === id);
+ if (alias) return protocol === 'anthropic' ? anthropicAlias(alias) : aliasEntry(alias);
+ const found = await modelCatalog.entries({ registry });
+ const x = found.find((m) => m.entry.id === id) || found.find((m) => `${m.entry.provider_key}/${m.entry.model_key}` === id);
+ if (!x) return null;
+ return protocol === 'anthropic' ? catalogAnthropic(x) : catalogOpenAi(x);
+}
+
+/** Write a list response in the handler's protocol. */
+async function sendList(req, res, { registry, config, protocol }) {
+ const body = await list({ registry, config, protocol: protocol || (wantsAnthropic(req) ? 'anthropic' : 'openai') });
+ res.json(body);
+}
+
+/** Write a single-model response, or the protocol's 404. */
+async function sendOne(req, res, { registry, config, protocol }) {
+ const proto = protocol || (wantsAnthropic(req) ? 'anthropic' : 'openai');
+ let id = req.params && req.params[0] != null ? String(req.params[0]) : '';
+ try { id = decodeURIComponent(id); } catch { /* keep the raw id */ }
+ const body = await find({ registry, config, protocol: proto, id });
+ if (!body) {
+ if (proto === 'anthropic') return res.status(404).json({ type: 'error', error: { type: 'not_found_error', message: `no model ${id}`, code: 'ai.model_not_found' } });
+ return res.status(404).json({ error: { message: `no model ${id}`, type: 'invalid_request_error', code: 'ai.model_not_found', param: null } });
+ }
+ return res.json(body);
+}
+
+/** The protocol's error object (a registry failure inside a models route, which Express 4 does not catch by itself). */
+function sendError(res, protocol, status, message, code) {
+ if (protocol === 'anthropic') return res.status(status).json({ type: 'error', error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: String(message || 'error').slice(0, 1000), code: code || null } });
+ return res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: status >= 500 ? 'api_error' : 'invalid_request_error', code: code || null, param: null } });
+}
+
+/**
+ * The `openvibe` disclosure of a finished run (D3), only while the catalog flag is on. Null with the flag off: no
+ * field or SSE comment is added, exactly as before.
+ */
+async function disclosureOf({ registry, config, run }) {
+ if (!(config.modelCatalog && config.modelCatalog.enabled)) return null;
+ return modelCatalog.disclosureOf({ registry, run });
+}
+
+module.exports = { wantsAnthropic, list, find, sendList, sendOne, sendError, disclosureOf, LEGACY_MODEL };
@@ -8,10 +8,13 @@
@@ -8,10 +8,13 @@
* body OpenAI Chat Completions: messages (with tool calls and tool results),
* body OpenAI Chat Completions: messages (with tool calls and tool results),
* tools, tool_choice, parallel_tool_calls, response_format, stop, seed,
* tools, tool_choice, parallel_tool_calls, response_format, stop, seed,
* temperature, top_p, max_tokens / max_completion_tokens, stream
* temperature, top_p, max_tokens / max_completion_tokens, stream
- * model "openvibe" (or anything else): AI's router picks the upstream for
- * route default.chat by price, health and what the request needs
- * (tools need a provider that declares them); the answer names the
- * model that ran
+ * model with AI_MODEL_CATALOG off, ignored (an exact id is refused): AI's router picks the upstream
+ * for route default.chat. On, an exact id
+ * `<org>/<model>[:<quant>]@<revision>` (runs only that artifact, never a
+ * substitute) or a dynamic alias (`openvibe/auto|economy|balanced|fast|
+ * private`, legacy `openvibe` = auto); the answer names the model that ran
+ * and carries the `openvibe` disclosure (an SSE comment first when
+ * streaming, and a field on the final chunk)
*
*
* x-openvibe-credential the person's own key for a provider instead of the router
* x-openvibe-credential the person's own key for a provider instead of the router
* (Codes C3): <provider> or <provider>:<subject>
* (Codes C3): <provider> or <provider>:<subject>
@@ -20,6 +23,10 @@
@@ -20,6 +23,10 @@
* to ai for reveal to that origin, ADR-056) }; the key is revealed for
* to ai for reveal to that origin, ADR-056) }; the key is revealed for
* that one run, and the project pays its provider directly
* that one run, and the project pays its provider directly
*
*
+ * GET /v1/models lists the registry's real models in ai.model@1 shape plus the dynamic aliases (behind
+ * AI_MODEL_CATALOG; off, the legacy single `openvibe` model); GET /v1/models/{id} answers one. Both protocols share
+ * the path: an Anthropic client (anthropic-version, or ?format=anthropic) is served by server/api/messages.js.
+ *
* Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain
* Each request is a run of workflow ai.complete, so it has the run's quota, free allowance, cost, audit and explain
* (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions
* (x_openvibe in the answer); the prompt is not kept. stream: true answers server-sent events in the Chat Completions
* chunk format as the upstream streams them (an OpenAI-format upstream, the router's or the project's own): each delta
* chunk format as the upstream streams them (an OpenAI-format upstream, the router's or the project's own): each delta
@@ -32,6 +39,9 @@ const express = require('express');
@@ -32,6 +39,9 @@ const express = require('express');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { appAttribution } = require('../apps');
const { appAttribution } = require('../apps');
const { credentialFor } = require('./credential');
const { credentialFor } = require('./credential');
+const models = require('./models');
+const modelCatalog = require('../providers/model-catalog');
+const { EXACT_ID } = modelCatalog;
const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
const FIELDS = ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty', 'temperature'];
@@ -44,20 +54,31 @@ const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status
@@ -44,20 +54,31 @@ const gatewaySafe = (status) => (status === 502 || status === 504 ? 503 : status
* The event stream of one streamed completion. delta(d) sends a chunk as it arrives (or keeps it until open(run) has
* The event stream of one streamed completion. delta(d) sends a chunk as it arrives (or keeps it until open(run) has
* sent the headers, which happens once the run exists); write(o) sends any chunk; fail() sends OpenAI's error object as
* sent the headers, which happens once the run exists); write(o) sends any chunk; fail() sends OpenAI's error object as
* an event; done() ends with [DONE]. A closed connection drops what is left, quietly.
* an event; done() ends with [DONE]. A closed connection drops what is left, quietly.
+ *
+ * AI Exchange D3: once the router has picked the offer (route(info)), the stream's first line is the `openvibe`
+ * disclosure as an SSE comment, before any data event. The final chunk carries the same object as `openvibe`.
*/
*/
function createLiveStream(res) {
function createLiveStream(res) {
let run = null;
let run = null;
let opened = false;
let opened = false;
let any = false;
let any = false;
+ let routeInfo = null;
+ let commented = false;
const waiting = [];
const waiting = [];
const finishedIdx = new Set();
const finishedIdx = new Set();
const created = Math.floor(Date.now() / 1000);
const created = Math.floor(Date.now() / 1000);
const alive = () => !res.writableEnded && !res.destroyed;
const alive = () => !res.writableEnded && !res.destroyed;
- const write = (o) => { if (alive()) res.write(`data: ${JSON.stringify(o)}\n\n`); };
+ const comment = () => {
+ if (commented || !routeInfo || !alive()) return;
+ res.write(`: x_openvibe ${JSON.stringify(routeInfo)}\n\n`);
+ commented = true;
+ };
+ const write = (o) => { if (alive()) { comment(); res.write(`data: ${JSON.stringify(o)}\n\n`); } };
const chunk = (d) => ({ id: `chatcmpl-${run.id}`, object: 'chat.completion.chunk', created, model: d.model || 'openvibe', system_fingerprint: null,
const chunk = (d) => ({ id: `chatcmpl-${run.id}`, object: 'chat.completion.chunk', created, model: d.model || 'openvibe', system_fingerprint: null,
choices: [{ index: d.index || 0, delta: d.delta || {}, finish_reason: d.finish_reason || null }] });
choices: [{ index: d.index || 0, delta: d.delta || {}, finish_reason: d.finish_reason || null }] });
const out = {
const out = {
get opened() { return opened; },
get opened() { return opened; },
+ route(info) { if (info) routeInfo = info; },
delta(d) {
delta(d) {
any = true;
any = true;
if (d.finish_reason) finishedIdx.add(d.index || 0);
if (d.finish_reason) finishedIdx.add(d.index || 0);
@@ -79,7 +100,7 @@ function createLiveStream(res) {
@@ -79,7 +100,7 @@ function createLiveStream(res) {
sentAny: () => any,
sentAny: () => any,
finished: (i) => finishedIdx.has(i),
finished: (i) => finishedIdx.has(i),
fail(message, code) {
fail(message, code) {
- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;
+ const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code || '') ? 400 : /quota|allowance|budget/.test(code || '') ? 429 : 503;
write({ error: { message: String(message || 'error').slice(0, 1000), type: status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : 'api_error', code: code || null, param: null } });
write({ error: { message: String(message || 'error').slice(0, 1000), type: status === 400 ? 'invalid_request_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : 'api_error', code: code || null, param: null } });
return out.done();
return out.done();
},
},
@@ -92,14 +113,18 @@ function openaiError(res, status, message, code, type) {
@@ -92,14 +113,18 @@ function openaiError(res, status, message, code, type) {
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
res.status(status).json({ error: { message: String(message || 'error').slice(0, 1000), type: type || (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status === 402 ? 'insufficient_quota' : status >= 500 ? 'api_error' : 'invalid_request_error'), code: code || null, param: null } });
}
}
-function openaiRouter({ runs, auth, config, log = console }) {
+function openaiRouter({ runs, auth, config, registry, log = console }) {
const r = express.Router();
const r = express.Router();
+ // Developer apps call with ai.app.run, the public capability they already hold (ADR-014; AI Exchange D8').
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
// A problem from the auth layer arrives as problem+json; a client built for OpenAI reads { error }: convert it as
// A problem from the auth layer arrives as problem+json; a client built for OpenAI reads { error }: convert it as
// it is written, whichever way the layer writes it.
// it is written, whichever way the layer writes it.
const typeFor = (status) => (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error');
const typeFor = (status) => (status === 401 || status === 403 ? 'authentication_error' : status === 429 ? 'rate_limit_error' : status >= 500 ? 'api_error' : 'invalid_request_error');
const asOpenAiErrors = (req, res, next) => {
const asOpenAiErrors = (req, res, next) => {
+ // An Anthropic models request is handled by the Messages router (which has Anthropic's error shape); step
+ // out of the way here before that router's auth guard writes anything.
+ if (req.method === 'GET' && /^\/v1\/models(\/|$)/.test(req.path) && models.wantsAnthropic(req)) return next();
const end = res.end.bind(res);
const end = res.end.bind(res);
res.end = (chunk, ...rest) => {
res.end = (chunk, ...rest) => {
const ct = String(res.getHeader('Content-Type') || '');
const ct = String(res.getHeader('Content-Type') || '');
@@ -124,8 +149,34 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -124,8 +149,34 @@ function openaiRouter({ runs, auth, config, log = console }) {
if (b.n != null && Number(b.n) !== 1) return openaiError(res, 400, 'n must be 1', 'input.invalid');
if (b.n != null && Number(b.n) !== 1) return openaiError(res, 400, 'n must be 1', 'input.invalid');
const input = {};
const input = {};
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
+ // AI Exchange D3 (AI_MODEL_CATALOG): the `model` the caller names is exact, or one of the dynamic aliases.
+ // With the flag off it is ignored exactly as before (the router picks), except an exact id: that is a pin, and a
+ // pin is never silently run on another model (a rollback of the flag included), so it is refused.
+ const catalogOn = Boolean(config.modelCatalog && config.modelCatalog.enabled);
+ if (!catalogOn && typeof b.model === 'string' && EXACT_ID.test(b.model.trim())) {
+ return openaiError(res, 400, 'an exact model id needs the model catalog, which is off: name a dynamic model (openvibe) instead', 'ai.model_unavailable');
+ }
+ if (catalogOn) {
+ if (b.model !== undefined && b.model !== null && typeof b.model !== 'string') return openaiError(res, 400, 'model must be a string', 'input.invalid');
+ const requested = b.model == null ? 'openvibe/auto' : String(b.model).trim();
+ if (!requested || requested.length > 256) return openaiError(res, 400, 'model must name an exact id or a dynamic alias', 'input.invalid');
+ input.model = requested;
+ // An exact id is a pin: an unknown or inactive artifact is refused here, before the run (and its stream)
+ // exists, so a streaming client gets the 404/400 it expects rather than a 200 SSE error. The engine selects
+ // the same artifact again when the run executes.
+ if (EXACT_ID.test(requested)) {
+ try {
+ await modelCatalog.select({ registry, model: requested, route: null });
+ } catch (err) {
+ if (err && err.status) return openaiError(res, err.status, err.detail || err.message, err.code);
+ throw err;
+ }
+ }
+ }
const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;
const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
+ // AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
+ if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
let body = { workflow: 'ai.complete', input };
let body = { workflow: 'ai.complete', input };
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
// L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
if (b.prefer) body = { ...body, prefer: b.prefer };
if (b.prefer) body = { ...body, prefer: b.prefer };
@@ -141,7 +192,9 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -141,7 +192,9 @@ function openaiRouter({ runs, auth, config, log = console }) {
// delta the upstream sends goes out as it arrives. A delta that came before the stream opened waits for it.
// delta the upstream sends goes out as it arrives. A delta that came before the stream opened waits for it.
const live = b.stream === true ? createLiveStream(res) : null;
const live = b.stream === true ? createLiveStream(res) : null;
try {
try {
- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, ...(live ? { onDelta: live.delta } : {}) });
+ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,
+ ...(live ? { onDelta: live.delta } : {}),
+ ...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
if (live) live.open(created.run);
if (live) live.open(created.run);
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
run = await runs.explained(await runs.wait(created, Math.max(config.runs.maxWaitMs, 120000)));
} catch (err) {
} catch (err) {
@@ -157,13 +210,16 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -157,13 +210,16 @@ function openaiRouter({ runs, auth, config, log = console }) {
}
}
if (run.status === 'failed' || run.status === 'cancelled') {
if (run.status === 'failed' || run.status === 'cancelled') {
const code = (run.error && run.error.code) || 'run.failed';
const code = (run.error && run.error.code) || 'run.failed';
- const status = code === 'ai.run_budget_reached' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
- : code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422 : 503;
+ const status = code === 'ai.run_budget_reached' || code === 'billing.insufficient_funds' || code === 'billing.payer_required' ? 402 : /^(input|validation)\./.test(code) ? 400 : /quota|allowance|budget/.test(code) ? 429
+ : code === 'project_secret.refused' ? 403 : code === 'credential.endpoint_refused' ? 422
+ : code === 'ai.model_unavailable' ? 400 : code === 'ai.model_not_found' ? 404 : 503;
return openaiError(res, status, (run.error && run.error.detail) || code, code);
return openaiError(res, status, (run.error && run.error.detail) || code, code);
}
}
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
if (!['succeeded', 'cached'].includes(run.status) || !run.output || !Array.isArray(run.output.choices)) {
return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');
return openaiError(res, 504, `the completion is still running; GET /api/v1/runs/${run.id}`, 'run.timeout');
}
}
+ const disclosure = await models.disclosureOf({ registry, config, run });
+ if (live) live.route(disclosure);
const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);
const created = Math.floor(Date.parse(run.created_at || new Date().toISOString()) / 1000) || Math.floor(Date.now() / 1000);
const id = `chatcmpl-${run.id}`;
const id = `chatcmpl-${run.id}`;
const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';
const model = run.output.model || (run.provenance && run.provenance.model) || 'openvibe';
@@ -184,16 +240,28 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -184,16 +240,28 @@ function openaiRouter({ runs, auth, config, log = console }) {
send({ ...base, choices: [{ index: c.index || 0, delta: {}, finish_reason: c.finish_reason || 'stop' }] });
send({ ...base, choices: [{ index: c.index || 0, delta: {}, finish_reason: c.finish_reason || 'stop' }] });
}
}
if (b.stream_options && b.stream_options.include_usage) send({ ...base, choices: [], usage });
if (b.stream_options && b.stream_options.include_usage) send({ ...base, choices: [], usage });
- send({ ...base, choices: [], x_openvibe: x });
+ send({ ...base, choices: [], x_openvibe: disclosure ? { ...x, ...disclosure } : x });
return live.done();
return live.done();
}
}
- return res.status(200).json({ id, object: 'chat.completion', created, model, choices: run.output.choices, usage, system_fingerprint: run.output.system_fingerprint || null, x_openvibe: x });
+ return res.status(200).json({ id, object: 'chat.completion', created, model, choices: run.output.choices, usage, system_fingerprint: run.output.system_fingerprint || null, x_openvibe: disclosure ? { ...x, ...disclosure } : x });
});
});
- // The model list a client asks for first: one router model, the upstreams are AI's choice.
- r.get('/v1/models', asOpenAiErrors, allow, (req, res) => {
- res.json({ object: 'list', data: [{ id: 'openvibe', object: 'model', created: 1760486400, owned_by: 'openvibe' }] });
- });
+ // The model list a client asks for first. With AI_MODEL_CATALOG on it is the registry's real models plus the
+ // dynamic aliases (ai.model@1 shape); with it off, the legacy single `openvibe` model. An Anthropic client (one
+ // that sends anthropic-version) is served by the Messages router with that protocol's shape and error object.
+ const notAnthropic = (req, res, next) => (models.wantsAnthropic(req) ? next('router') : next());
+ const modelRoute = (fn) => async (req, res) => {
+ try { await fn(req, res); } catch (err) {
+ log.error(`[ai] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
+ if (!res.headersSent) models.sendError(res, 'openai', 500, 'internal error');
+ }
+ };
+ r.get('/v1/models', asOpenAiErrors, notAnthropic, allow, modelRoute(async (req, res) => {
+ await models.sendList(req, res, { registry, config, protocol: 'openai' });
+ }));
+ r.get('/v1/models/*', asOpenAiErrors, notAnthropic, allow, modelRoute(async (req, res) => {
+ await models.sendOne(req, res, { registry, config, protocol: 'openai' });
+ }));
return r;
return r;
}
}
@@ -11,6 +11,7 @@ const { actionsRouter } = require('./api/actions');
@@ -11,6 +11,7 @@ const { actionsRouter } = require('./api/actions');
const { sendError } = require('./util');
const { sendError } = require('./util');
const openapi = require('./openapi');
const openapi = require('./openapi');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
+const { CAPS } = require('./auth');
const { createOps } = require('./ops');
const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
const { createPersonSessions } = require('./person-session');
const { createPersonSessions } = require('./person-session');
@@ -21,7 +22,9 @@ const ovServe = require('openvibe-shared/serve');
@@ -21,7 +22,9 @@ const ovServe = require('openvibe-shared/serve');
const home = require('./home');
const home = require('./home');
const pkg = require('../package.json');
const pkg = require('../package.json');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
+const { adminRoutes } = require('openvibe-shared/config');
+
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -118,20 +121,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -118,20 +121,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
});
});
if (benchEvents) app.use(benchEvents.router);
if (benchEvents) app.use(benchEvents.router);
+ // D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and writes
+ // ai.exchange and ai.provider_keys. Loopback only (the /internal guard above), guarded by ai.config.manage; the
+ // actor is Network's X-OV-Subject (usr_…), and a secret or x-danger change needs X-OV-Fresh: 1.
+ if (settings) {
+ const configRoutes = adminRoutes([settings.exchange, settings.providerKeys.proxy], {
+ basePath: '/internal/config',
+ requireAdmin: auth.requireCap(CAPS.configManage),
+ actor: (req) => {
+ const id = String(req.get('x-ov-subject') || '').trim();
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;
+ },
+ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
+ log,
+ });
+ app.use(configRoutes.handle);
+ }
+
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
const ops = createOps({ db, registry, pool, quotas, cache, runs });
const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ runs, registry, auth, config, log }));
app.use(runsRouter({ runs, registry, auth, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
- app.use(require('./api/openai').openaiRouter({ runs, auth, config, log }));
+ app.use(require('./api/openai').openaiRouter({ runs, auth, config, registry, log }));
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
- app.use(require('./api/messages').messagesRouter({ runs, auth, config, log }));
+ app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, log }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (memory) app.use(require('./memory').memoryRouter({ memory, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (memory) app.use(require('./memory').memoryRouter({ memory, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
- app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
+ app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
const personSessions = createPersonSessions({ db, config, clock });
const personSessions = createPersonSessions({ db, config, clock });
@@ -142,7 +162,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -142,7 +162,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
}
}
- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
+ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
app.use(runPageRouter({ runs, personSessions, log }));
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
@@ -34,7 +34,10 @@ const CAPS = Object.freeze({
@@ -34,7 +34,10 @@ const CAPS = Object.freeze({
credentialManage: 'ai.credential.manage',
credentialManage: 'ai.credential.manage',
quotaAttributionManage: 'ai.quota.attribution.manage',
quotaAttributionManage: 'ai.quota.attribution.manage',
appRun: 'ai.app.run',
appRun: 'ai.app.run',
+ // AI Exchange D8: the public capability a developer app calls the inference routes with (Contracts 0.284.0).
actionsRead: 'ai.actions.read',
actionsRead: 'ai.actions.read',
+ // D11: Network's admin proxy, acting for the owner, manages ai.exchange and ai.provider_keys (Contracts 0.288.0).
+ configManage: 'ai.config.manage',
});
});
/**
/**
@@ -90,6 +93,8 @@ function principalSubject(sub) {
@@ -90,6 +93,8 @@ function principalSubject(sub) {
}
}
function createAuth({ config, log = console }) {
function createAuth({ config, log = console }) {
+ function allowsLocal(claims, id) { return allows(claims, id); }
+
/** The kid a token's header names (null when absent or undecodable). */
/** The kid a token's header names (null when absent or undecodable). */
function headerKid(token) {
function headerKid(token) {
try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }
try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }
@@ -135,7 +140,7 @@ function createAuth({ config, log = console }) {
@@ -135,7 +140,7 @@ function createAuth({ config, log = console }) {
if (isAppClaims(r.claims)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });
if (isAppClaims(r.claims)) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });
let decision = null;
let decision = null;
for (const id of anyOf) {
for (const id of anyOf) {
- decision = allows(r.claims, id);
+ decision = allowsLocal(r.claims, id);
if (decision.allowed) break;
if (decision.allowed) break;
}
}
if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });
if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });
@@ -158,6 +163,7 @@ function createAuth({ config, log = console }) {
@@ -158,6 +163,7 @@ function createAuth({ config, log = console }) {
* app's namespaces.
* app's namespaces.
*/
*/
function appOrCap(appCap, ...anyOf) {
function appOrCap(appCap, ...anyOf) {
+ const appCaps = Array.isArray(appCap) ? appCap : [appCap];
const serviceGuard = requireCap(...anyOf);
const serviceGuard = requireCap(...anyOf);
return async function appOrCapGuard(req, res, next) {
return async function appOrCapGuard(req, res, next) {
const ctx = req.ov;
const ctx = req.ov;
@@ -168,8 +174,12 @@ function createAuth({ config, log = console }) {
@@ -168,8 +174,12 @@ function createAuth({ config, log = console }) {
if (!r.ok || !isAppClaims(r.claims)) return await serviceGuard(req, res, next);
if (!r.ok || !isAppClaims(r.claims)) return await serviceGuard(req, res, next);
const app = apps.appPrincipal(r.claims);
const app = apps.appPrincipal(r.claims);
if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });
if (app.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: app.error, ctx });
- const decision = allows(r.claims, appCap);
- if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: decision.reason, ctx });
+ let decision = null;
+ for (const id of appCaps) {
+ decision = allowsLocal(r.claims, id);
+ if (decision.allowed) break;
+ }
+ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: appCaps.length > 1 ? `one of ${appCaps.join(', ')} is required` : decision.reason, ctx });
req.principal = {
req.principal = {
kind: 'app', sub: r.claims.sub, subject: { type: 'project', id: app.projectId },
kind: 'app', sub: r.claims.sub, subject: { type: 'project', id: app.projectId },
appId: app.appId, projectId: app.projectId, projectKey: app.projectKey, env: app.env, onBehalfOf: app.onBehalfOf,
appId: app.appId, projectId: app.projectId, projectKey: app.projectKey, env: app.env, onBehalfOf: app.onBehalfOf,
@@ -142,6 +142,27 @@ function load(env = process.env) {
@@ -142,6 +142,27 @@ function load(env = process.env) {
routing: {
routing: {
maxAttempts: Math.max(1, int(env.AI_ROUTING_MAX_ATTEMPTS, 3)),
maxAttempts: Math.max(1, int(env.AI_ROUTING_MAX_ATTEMPTS, 3)),
},
},
+ // AI Exchange P7 (PLAN §D/§E): alias routes ranked by measured evidence. Off by default; with the flag
+ // off nothing is written and no order changes. The EWMA alpha, the minimum sample count below which
+ // evidence is sparse (and reported, never trusted), the probability floor, the decisions a challenger
+ // must win, and the exploration share (0 = never leave the incumbent).
+ routingEvidence: {
+ enabled: bool(env.AI_ROUTING_EVIDENCE, false),
+ alpha: Math.max(0.01, Math.min(1, float(env.AI_ROUTING_EVIDENCE_ALPHA, 0.2))),
+ minSamples: Math.max(1, int(env.AI_ROUTING_EVIDENCE_MIN_SAMPLES, 20)),
+ successFloor: Math.max(0.01, Math.min(1, float(env.AI_ROUTING_EVIDENCE_SUCCESS_FLOOR, 0.1))),
+ prior: Math.max(0.01, Math.min(1, float(env.AI_ROUTING_EVIDENCE_PRIOR, 0.9))),
+ hysteresisDecisions: Math.max(1, int(env.AI_ROUTING_EVIDENCE_DECISIONS, 3)),
+ explorationShare: Math.max(0, Math.min(1, float(env.AI_ROUTING_EVIDENCE_EXPLORE, 0))),
+ affinityGain: Math.max(0, Math.min(1, float(env.AI_ROUTING_EVIDENCE_AFFINITY, 0.05))),
+ minGain: Math.max(0, Math.min(1, float(env.AI_ROUTING_EVIDENCE_MIN_GAIN, 0.15))),
+ expectedOutputTokens: Math.max(1, int(env.AI_ROUTING_EVIDENCE_OUTPUT_TOKENS, 512)),
+ },
+ // AI Exchange P4 (CHECKPOINT D3): the truthful model catalog, exact ids and dynamic aliases. Off by
+ // default: today's /v1 behaviour, the single `openvibe` model and no disclosure object, is unchanged.
+ modelCatalog: {
+ enabled: bool(env.AI_MODEL_CATALOG, false),
+ },
// Configurable local HTTP seam: POST {operation, request} JSON to this URL.
// Configurable local HTTP seam: POST {operation, request} JSON to this URL.
httpSeamUrl: trimUrl(env.AI_HTTP_SEAM_URL),
httpSeamUrl: trimUrl(env.AI_HTTP_SEAM_URL),
// A local model server speaking the OpenAI API (llama.cpp's llama-server, Ollama's /v1): the provider `local`,
// A local model server speaking the OpenAI API (llama.cpp's llama-server, Ollama's /v1): the provider `local`,
@@ -240,6 +261,9 @@ function load(env = process.env) {
@@ -240,6 +261,9 @@ function load(env = process.env) {
maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),
maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),
retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),
retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),
},
},
+ // Aggregate demand for Host's inference planner (GET /api/v1/usage/series): off by default;
+ // the capability gate (ai.usage.read) is still required when it is on.
+ usageSeries: { enabled: bool(env.AI_USAGE_SERIES, false) },
// Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of
// Compiled JSON Schema validators (server/schemas.js): an LRU shared by every caller, some of
// whose schemas are caller-supplied, so it is bounded by count and by total schema size.
// whose schemas are caller-supplied, so it is bounded by count and by total schema size.
schemaCache: {
schemaCache: {
@@ -274,6 +298,21 @@ function load(env = process.env) {
@@ -274,6 +298,21 @@ function load(env = process.env) {
timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),
timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),
},
},
+ // ── Billing Balance (AI Exchange F, CHECKPOINT D2/D9/D10/D12), off by default. A paid managed request holds
+ // its worst-case cost before any provider call, extends the hold while streaming, and settles once. Reads the
+ // same OV_BILLING_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET as the usage samples; the capability is one per
+ // operation (contracts v0.289.1): billing.credit.hold.{create,extend,settle,release}. An app holds against
+ // its project's Balance and a person against their own; no Network owner lookup is made.
+ credits: {
+ enabled: bool(env.AI_CREDITS, false),
+ billingUrl: trimUrl(env.OV_BILLING_INTERNAL_URL),
+ audience: 'openvibe.billing',
+ timeoutMs: Math.max(200, Math.min(30000, int(env.AI_CREDITS_TIMEOUT_MS, 5000))),
+ // A settle Billing could not take is retried by the background queue with exponential backoff.
+ settleRetryMs: Math.max(1000, Math.min(5 * 60 * 1000, int(env.AI_CREDITS_SETTLE_RETRY_MS, 5000))),
+ settleMaxRetryMs: Math.max(1000, Math.min(60 * 60 * 1000, int(env.AI_CREDITS_SETTLE_MAX_RETRY_MS, 5 * 60 * 1000))),
+ },
+
// ── Billing (plan T5 lane F): one platform.usage-sample@1 reading per accounted run, posted by
// ── Billing (plan T5 lane F): one platform.usage-sample@1 reading per accounted run, posted by
// server/usage-samples.js to billing.usage.record (POST /api/v1/usage, audience openvibe.billing).
// server/usage-samples.js to billing.usage.record (POST /api/v1/usage, audience openvibe.billing).
// Readings are queued unless USAGE_SAMPLES=off; they are sent while OV_BILLING_INTERNAL_URL is set.
// Readings are queued unless USAGE_SAMPLES=off; they are sent while OV_BILLING_INTERNAL_URL is set.
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* Billing readings by delivery state, usage-samples deliveryReport)
* Billing readings by delivery state, usage-samples deliveryReport)
* GET /console/cache; POST /console/cache/purge (writes: ai.provider.manage; ops.purgeCache)
* GET /console/cache; POST /console/cache/purge (writes: ai.provider.manage; ops.purgeCache)
* GET /console/audit[?kind&action&actor&target_type&target_id&before]
* GET /console/audit[?kind&action&actor&target_type&target_id&before]
+ * GET /console/exchange AI Exchange state (AI_CREDITS or AI_ROUTING_EVIDENCE)
+ * POST /console/exchange/credits/:id/resolve mark a review settle row resolved, never retried
*
*
* Who: a Network person (usr_ subject) whose sign-in token holds staff capabilities of the contracts
* Who: a Network person (usr_ subject) whose sign-in token holds staff capabilities of the contracts
* staff map (policy.staff-role-map@1, ADR-022). A console session holds the AI capability ids of
* staff map (policy.staff-role-map@1, ADR-022). A console session holds the AI capability ids of
@@ -39,7 +41,7 @@
@@ -39,7 +41,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { AiError, resolveSecret } = require('../util');
+const { AiError, resolveSecret, iso } = require('../util');
const usageSamples = require('../usage-samples');
const usageSamples = require('../usage-samples');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { staff: staffMap } = require('openvibe-contracts');
const { staff: staffMap } = require('openvibe-contracts');
@@ -98,6 +100,7 @@ const NOTICES = {
@@ -98,6 +100,7 @@ const NOTICES = {
quota_saved: 'Quota saved.',
quota_saved: 'Quota saved.',
cache_purged: 'Cache purged.',
cache_purged: 'Cache purged.',
run_cancelled: 'Run cancelled.',
run_cancelled: 'Run cancelled.',
+ settle_resolved: 'Settle queue row marked resolved. Nothing was retried.',
};
};
const DEF_KINDS = {
const DEF_KINDS = {
routes: { one: 'route', list: 'listRoutes', get: 'getRoute' },
routes: { one: 'route', list: 'listRoutes', get: 'getRoute' },
@@ -106,8 +109,11 @@ const DEF_KINDS = {
@@ -106,8 +109,11 @@ const DEF_KINDS = {
};
};
const DAY_MS = 86400000;
const DAY_MS = 86400000;
-function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, personSessions = null }) {
+function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null }) {
const cc = config.console;
const cc = config.console;
+ const creditsOn = Boolean(config.credits && config.credits.enabled);
+ const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);
+ const exchangeOn = creditsOn || routingOn;
const r = express.Router();
const r = express.Router();
const scoped = ['/console', '/auth'];
const scoped = ['/console', '/auth'];
const baseOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
const baseOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
@@ -139,7 +145,8 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -139,7 +145,8 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
}
}
const send = async (res, status, text) => res.status(status).type('html').send(await scrub(text));
const send = async (res, status, text) => res.status(status).type('html').send(await scrub(text));
const who = (req) => ({ actor: req.staff.subject, trace: req.ov ? req.ov.traceId : null });
const who = (req) => ({ actor: req.staff.subject, trace: req.ov ? req.ov.traceId : null });
- const common = (req) => ({ staff: req.staff, csrf: req.staff ? req.staff.csrf : '' });
+ // staff.exchange only picks the extra navigation entry; it is a copy, so the signed-in claims stay as read.
+ const common = (req) => ({ staff: req.staff ? { ...req.staff, exchange: exchangeOn } : req.staff, csrf: req.staff ? req.staff.csrf : '' });
const audit = async (actor, action, targetType, targetId, metadata, req) => await registry.audit(actor || 'anonymous', action, targetType, targetId, { trace: req && req.ov ? req.ov.traceId : null, metadata });
const audit = async (actor, action, targetType, targetId, metadata, req) => await registry.audit(actor || 'anonymous', action, targetType, targetId, { trace: req && req.ov ? req.ov.traceId : null, metadata });
const notice = (req) => {
const notice = (req) => {
const base = NOTICES[req.query.done];
const base = NOTICES[req.query.done];
@@ -328,10 +335,10 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -328,10 +335,10 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
const presets = require('./presets');
const presets = require('./presets');
const KEY_RE = /^[a-z0-9][a-z0-9._-]{1,40}$/;
const KEY_RE = /^[a-z0-9][a-z0-9._-]{1,40}$/;
r.get('/console/providers/new', needs(CAPS.providerManage), wrap(async (req, res) => await send(res, 200, pages.providerNew({
r.get('/console/providers/new', needs(CAPS.providerManage), wrap(async (req, res) => await send(res, 200, pages.providerNew({
- ...common(req), presets: presets.PRESETS, preset: presets.byId(String(req.query.preset || '')) || null, vaultReady: !!vault && /^[0-9a-f]{64}$/i.test(String(config.credentialsKey || '')),
+ ...common(req), presets: presets.PRESETS, preset: presets.byId(String(req.query.preset || '')) || null, vaultReady: !!vault && !!settings,
}))));
}))));
r.post('/console/providers', needs(CAPS.providerManage), wrap(async (req, res) => {
r.post('/console/providers', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!vault) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
+ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
const b = req.body || {};
const b = req.body || {};
const preset = presets.byId(String(b.preset || 'custom')) || presets.byId('custom');
const preset = presets.byId(String(b.preset || 'custom')) || presets.byId('custom');
const key = String(b.key || preset.id).trim().toLowerCase();
const key = String(b.key || preset.id).trim().toLowerCase();
@@ -339,20 +346,21 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -339,20 +346,21 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
if (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', `a provider called ${key} already exists: replace its key on the providers page`);
if (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', `a provider called ${key} already exists: replace its key on the providers page`);
const baseUrl = String(b.base_url || preset.base_url || '').trim();
const baseUrl = String(b.base_url || preset.base_url || '').trim();
if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL is an https address');
if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL is an https address');
- await vault.put(key, b.api_key, who(req));
const caps = [...presets.TEXT_CAPS, ...(b.vision ? ['vision'] : [])];
const caps = [...presets.TEXT_CAPS, ...(b.vision ? ['vision'] : [])];
+ // The provider comes first: its base URL is the origin its key is sealed to (D11).
await registry.upsertProvider({
await registry.upsertProvider({
key, display_name: String(b.display_name || preset.name || key).slice(0, 80), kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),
key, display_name: String(b.display_name || preset.name || key).slice(0, 80), kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),
auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,
auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,
capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },
capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },
}, { ...who(req), origin: 'admin' });
}, { ...who(req), origin: 'admin' });
+ await vault.put(key, b.api_key, who(req));
// Its model list, read right away (best effort: a provider whose list cannot be read is still added).
// Its model list, read right away (best effort: a provider whose list cannot be read is still added).
const added = await registry.getProvider(key);
const added = await registry.getProvider(key);
await catalog.sync({ registry, provider: added, apiKey: resolveSecret(added.secret_ref, env), fetchImpl, actor: who(req).actor }).catch(() => null);
await catalog.sync({ registry, provider: added, apiKey: resolveSecret(added.secret_ref, env), fetchImpl, actor: who(req).actor }).catch(() => null);
res.redirect(303, '/console/providers?done=provider_added');
res.redirect(303, '/console/providers?done=provider_added');
}));
}));
r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {
r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {
- if (!vault) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
+ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
const p = await registry.getProvider(req.params.key);
const p = await registry.getProvider(req.params.key);
if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
await vault.put(p.key, (req.body || {}).api_key, who(req));
await vault.put(p.key, (req.body || {}).api_key, who(req));
@@ -521,6 +529,37 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -521,6 +529,37 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
}));
}));
}));
}));
+ // ── AI Exchange (P7): Credits settle queue and routing evidence ──
+ // Read-only except "mark resolved", which acknowledges one review row (a 409/422 needs a person) and
+ // deliberately does not retry it. Both sections are absent unless their flag is on.
+ if (exchangeOn) {
+ r.get('/console/exchange', read, wrap(async (req, res) => {
+ await send(res, 200, pages.exchange({
+ ...common(req), canResolve: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),
+ credits: creditsOn ? await q.settleQueue(db, clock.now()) : null,
+ routing: routingOn ? await q.routingEvidence(db) : null,
+ }));
+ }));
+ }
+ if (creditsOn) {
+ r.post('/console/exchange/credits/:id/resolve', needs(CAPS.workflowManage), wrap(async (req, res) => {
+ const id = String(req.params.id || '').slice(0, 300);
+ const row = id ? await db.prepare('SELECT id, hold_id, request_id, status FROM credit_settle_queue WHERE id = ?').get(id) : null;
+ if (!row) throw new AiError(404, 'ai.not_found', 'no such settle queue row');
+ if (row.status !== 'review') throw new AiError(409, 'ai.conflict', 'only a row in review can be marked resolved');
+ let changed = false;
+ await db.tx(async () => {
+ const upd = await db.prepare(`UPDATE credit_settle_queue SET status = 'resolved', resolved_at = ?::timestamptz,
+ resolved_by = ?, updated_at = ?::timestamptz WHERE id = ? AND status = 'review'`)
+ .run(iso(clock.now()), req.staff.subject, iso(clock.now()), id);
+ changed = Number(upd && upd.changes) > 0;
+ if (changed) await audit(req.staff.subject, 'credit_settle.resolve', 'credit_settle', id, { hold_id: row.hold_id, request_id: row.request_id, from: 'review', to: 'resolved' }, req);
+ });
+ if (!changed) throw new AiError(409, 'ai.conflict', 'the row is no longer in review');
+ return res.redirect(303, '/console/exchange?done=settle_resolved');
+ }));
+ }
+
// ── Cache ────────────────────────────────────────────────
// ── Cache ────────────────────────────────────────────────
const cachePage = async (req, res, status, extra = {}) => await send(res, status, pages.cache({
const cachePage = async (req, res, status, extra = {}) => await send(res, status, pages.cache({
...common(req), stats: await cache.stats(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,
...common(req), stats: await cache.stats(), canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), ...extra,
@@ -144,6 +144,7 @@ function preview(value, { string = PREVIEW_STRING, bytes = PREVIEW_BYTES } = {})
@@ -144,6 +144,7 @@ function preview(value, { string = PREVIEW_STRING, bytes = PREVIEW_BYTES } = {})
const AUDIT_KINDS = Object.freeze({
const AUDIT_KINDS = Object.freeze({
changes: { label: 'Configuration changes', where: "target_type IN ('provider', 'model', 'route', 'template', 'workflow', 'quota', 'cache')" },
changes: { label: 'Configuration changes', where: "target_type IN ('provider', 'model', 'route', 'template', 'workflow', 'quota', 'cache')" },
runs: { label: 'Runs', where: "target_type = 'run'" },
runs: { label: 'Runs', where: "target_type = 'run'" },
+ exchange: { label: 'AI Exchange', where: "target_type IN ('credit_settle', 'routing_incumbent')" },
console: { label: 'Console sign-ins and refusals', where: "action ILIKE 'console.%'" },
console: { label: 'Console sign-ins and refusals', where: "action ILIKE 'console.%'" },
all: { label: 'Everything', where: null },
all: { label: 'Everything', where: null },
});
});
@@ -219,7 +220,71 @@ async function usageReport(quotas, f) {
@@ -219,7 +220,71 @@ async function usageReport(quotas, f) {
};
};
}
}
+// ── AI Exchange (P7 console) ─────────────────────────────────
+const REVIEW_ROWS = 100;
+const SETTLE_STATUSES = ['queued', 'sent', 'review'];
+const TOKEN_COUNTS = ['input_tokens', 'cached_read_tokens', 'cache_write_tokens', 'output_tokens'];
+const tokenCount = (v) => { const x = Number(v); return Number.isFinite(x) && x > 0 ? Math.trunc(x) : 0; };
+const parseBody = (v) => {
+ if (v && typeof v === 'object') return v;
+ try { const x = JSON.parse(v); return x && typeof x === 'object' ? x : {}; } catch { return {}; }
+};
+
+/**
+ * The Credits settle queue at a glance. Only the queue's own columns and the settle body's four usage
+ * token counts leave this function; the body's rates, supply cost, attempts and byok flag never do.
+ * No subject is selected or joined.
+ */
+async function settleQueue(db, nowMs = Date.now(), limit = REVIEW_ROWS) {
+ const counts = Object.fromEntries(SETTLE_STATUSES.map((s) => [s, 0]));
+ for (const row of await db.prepare('SELECT status, COUNT(*) AS n FROM credit_settle_queue GROUP BY status').all()) {
+ if (counts[row.status] != null) counts[row.status] = Number(row.n) || 0;
+ }
+ const oldest = await db.prepare("SELECT MIN(created_at) AS at FROM credit_settle_queue WHERE status = 'queued'").get();
+ const oldestIso = oldest && oldest.at ? String(oldest.at) : null;
+ const oldestMs = oldestIso ? Date.parse(oldestIso) : NaN;
+ const reviewUsage = Object.fromEntries(TOKEN_COUNTS.map((k) => [k, 0]));
+ const raw = await db.prepare(`SELECT id, hold_id, request_id, tries, last_error, created_at, settle_body
+ FROM credit_settle_queue WHERE status = 'review' ORDER BY created_at, id LIMIT ?`).all(Math.max(1, Math.min(500, Number(limit) || REVIEW_ROWS)));
+ const review = raw.map((r) => {
+ const usage = parseBody(r.settle_body).usage || {};
+ for (const k of TOKEN_COUNTS) reviewUsage[k] += tokenCount(usage[k]);
+ return {
+ id: r.id, hold_id: r.hold_id, request_id: r.request_id || null, tries: Number(r.tries) || 0,
+ last_error: r.last_error || null, created_at: r.created_at || null,
+ };
+ });
+ return {
+ counts, oldest_queued_at: oldestIso,
+ oldest_queued_seconds: Number.isFinite(oldestMs) ? Math.max(0, Math.floor((Number(nowMs) - oldestMs) / 1000)) : null,
+ review, review_total: counts.review, review_truncated: counts.review > review.length, review_usage: reviewUsage,
+ };
+}
+
+/** Measured per-deployment rows (EWMA) and the routing incumbents: numbers and ids only. */
+async function routingEvidence(db, limit = 200) {
+ const max = Math.max(1, Math.min(500, Number(limit) || 200));
+ const deployments = (await db.prepare(`SELECT provider_key, model_key, sample_count, success_rate, timeout_rate,
+ ttft_ms, ttft_samples, decode_tps, decode_samples, cache_hit_ratio, cache_samples, updated_at
+ FROM provider_metrics ORDER BY sample_count DESC, provider_key, model_key LIMIT ?`).all(max)).map((r) => ({
+ provider_key: r.provider_key, model_key: r.model_key || '',
+ sample_count: Number(r.sample_count) || 0, success_rate: Number(r.success_rate) || 0, timeout_rate: Number(r.timeout_rate) || 0,
+ ttft_ms: r.ttft_ms == null ? null : Number(r.ttft_ms), ttft_samples: Number(r.ttft_samples) || 0,
+ decode_tps: r.decode_tps == null ? null : Number(r.decode_tps), decode_samples: Number(r.decode_samples) || 0,
+ cache_hit_ratio: Number(r.cache_samples) > 0 ? Number(r.cache_hit_ratio) || 0 : null,
+ cache_samples: Number(r.cache_samples) || 0, updated_at: r.updated_at || null,
+ }));
+ const incumbents = (await db.prepare(`SELECT decision_key, provider_key, model_key, challenger_provider, challenger_model,
+ challenger_streak, updated_at FROM routing_incumbents ORDER BY decision_key LIMIT ?`).all(max)).map((r) => ({
+ decision_key: r.decision_key, provider_key: r.provider_key || null, model_key: r.model_key || '',
+ challenger_provider: r.challenger_provider || null, challenger_model: r.challenger_model || '',
+ challenger_streak: Number(r.challenger_streak) || 0, updated_at: r.updated_at || null,
+ }));
+ return { deployments, incumbents };
+}
+
module.exports = {
module.exports = {
RUN_STATUSES, AUDIT_KINDS, PAGE, parseRunFilters, listRuns, failures, statusCounts, runFacets, preview,
RUN_STATUSES, AUDIT_KINDS, PAGE, parseRunFilters, listRuns, failures, statusCounts, runFacets, preview,
parseAuditFilters, listAudit, usernames, parseUsageFilters, usageReport, parseTime, requesterOf,
parseAuditFilters, listAudit, usernames, parseUsageFilters, usageReport, parseTime, requesterOf,
+ settleQueue, routingEvidence,
};
};
@@ -81,6 +81,8 @@ function usd(v) {
@@ -81,6 +81,8 @@ function usd(v) {
return `$${x < 0.01 ? x.toFixed(6) : x.toFixed(x < 100 ? 4 : 2)}`;
return `$${x < 0.01 ? x.toFixed(6) : x.toFixed(x < 100 ? 4 : 2)}`;
}
}
const when = (s) => (s ? html`<time datetime="${s}">${String(s).replace('T', ' ').slice(0, 19)} UTC</time>` : '—');
const when = (s) => (s ? html`<time datetime="${s}">${String(s).replace('T', ' ').slice(0, 19)} UTC</time>` : '—');
+/** A 0..1 EWMA as a percentage, or an em dash when it was never measured. */
+const pct = (v) => (v == null ? '—' : `${(100 * Number(v)).toFixed(1)} %`);
function span(fromIso, toIso) {
function span(fromIso, toIso) {
if (!fromIso || !toIso) return '';
if (!fromIso || !toIso) return '';
const ms = Date.parse(toIso) - Date.parse(fromIso);
const ms = Date.parse(toIso) - Date.parse(fromIso);
@@ -129,10 +131,11 @@ function shareTags({ title, description, canonical }) {
@@ -129,10 +131,11 @@ function shareTags({ title, description, canonical }) {
return html`<meta property="og:type" content="website"><meta property="og:site_name" content="OpenVibe.AI"><meta property="og:title" content="${title}">${description ? html`<meta property="og:description" content="${description}">` : ''}<meta property="og:url" content="${canonical}"><meta property="og:image" content="${image}"><meta name="twitter:card" content="summary_large_image">`;
return html`<meta property="og:type" content="website"><meta property="og:site_name" content="OpenVibe.AI"><meta property="og:title" content="${title}">${description ? html`<meta property="og:description" content="${description}">` : ''}<meta property="og:url" content="${canonical}"><meta property="og:image" content="${image}"><meta name="twitter:card" content="summary_large_image">`;
}
}
function layout({ title, section, staff, csrf, notice, error, body, site = 'operator console', robots = 'noindex,nofollow', description = null, canonical = null }) {
function layout({ title, section, staff, csrf, notice, error, body, site = 'operator console', robots = 'noindex,nofollow', description = null, canonical = null }) {
+ const nav = staff && staff.exchange ? [...NAV, ['/console/exchange', 'Exchange', 'exchange']] : NAV;
return `<!doctype html>${render(html`<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
return `<!doctype html>${render(html`<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="${robots}">${description ? html`<meta name="description" content="${description}">` : ''}${canonical ? html`<link rel="canonical" href="${canonical}">${shareTags({ title: `${title} · AI ${site}`, description, canonical })}` : ''}<meta name="referrer" content="no-referrer"><title>${title} · AI ${site}</title><style>${new Raw(CSS)}</style></head>
<meta name="robots" content="${robots}">${description ? html`<meta name="description" content="${description}">` : ''}${canonical ? html`<link rel="canonical" href="${canonical}">${shareTags({ title: `${title} · AI ${site}`, description, canonical })}` : ''}<meta name="referrer" content="no-referrer"><title>${title} · AI ${site}</title><style>${new Raw(CSS)}</style></head>
<body><a class="skip" href="#main">Skip to content</a><header><div class="bar"><span class="brand">OpenVibe.AI · ${site}</span>
<body><a class="skip" href="#main">Skip to content</a><header><div class="bar"><span class="brand">OpenVibe.AI · ${site}</span>
-${staff ? html`<nav aria-label="Console"><ul>${NAV.map(([href, label, id]) => html`<li><a href="${href}"${id === section ? html` aria-current="page"` : ''}>${label}</a></li>`)}</ul></nav>
+${staff ? html`<nav aria-label="Console"><ul>${nav.map(([href, label, id]) => html`<li><a href="${href}"${id === section ? html` aria-current="page"` : ''}>${label}</a></li>`)}</ul></nav>
<div class="who"><span>${staff.username ? `@${staff.username} ` : ''}<code>${staff.subject}</code> · ${staff.role}</span>
<div class="who"><span>${staff.username ? `@${staff.username} ` : ''}<code>${staff.subject}</code> · ${staff.role}</span>
<form class="inline" method="post" action="/auth/logout">${csrfField(csrf)}<button class="link" type="submit">Sign out</button></form></div>` : ''}
<form class="inline" method="post" action="/auth/logout">${csrfField(csrf)}<button class="link" type="submit">Sign out</button></form></div>` : ''}
</div></header><main id="main" tabindex="-1">
</div></header><main id="main" tabindex="-1">
@@ -222,7 +225,7 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,
@@ -222,7 +225,7 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,
return layout({
return layout({
title: 'Providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>Providers and models</h1>
title: 'Providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>Providers and models</h1>
${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a route's page.</span></p>` : ''}
${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a route's page.</span></p>` : ''}
-<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>, or <code>vault:NAME</code> for a key added here, kept encrypted) resolved inside the service; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>
+<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>, or <code>vault:NAME</code> for a key added here, sealed in the AI configuration store and bound to the provider's origin) resolved inside the service; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>
${table('Providers', ['Provider', 'Kind', 'Status', 'Credentials', 'Circuit', 'Base URL', 'Default model', 'Can do', 'Priority / timeout', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
${table('Providers', ['Provider', 'Kind', 'Status', 'Credentials', 'Circuit', 'Base URL', 'Default model', 'Can do', 'Priority / timeout', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
<td><code>${p.key}</code><br><span class="muted">${p.display_name}</span></td><td>${p.kind}</td><td>${badge(p.status)}</td>
<td><code>${p.key}</code><br><span class="muted">${p.display_name}</span></td><td>${p.kind}</td><td>${badge(p.status)}</td>
<td>${badge(p.credentials)}${p.secret_ref ? html`<br><code>${p.secret_ref}</code>` : ''}${keyCell(p)}<br><span class="muted">${p.auth_mode}</span></td>
<td>${badge(p.credentials)}${p.secret_ref ? html`<br><code>${p.secret_ref}</code>` : ''}${keyCell(p)}<br><span class="muted">${p.auth_mode}</span></td>
@@ -247,8 +250,8 @@ function providerNew({ staff, csrf, presets, preset, vaultReady, notice, error }
@@ -247,8 +250,8 @@ function providerNew({ staff, csrf, presets, preset, vaultReady, notice, error }
const p = preset || presets.find((x) => x.id === 'deepseek');
const p = preset || presets.find((x) => x.id === 'deepseek');
return layout({
return layout({
title: 'Add a provider', section: 'providers', staff, csrf, notice, error, body: html`<h1>Add a provider</h1>
title: 'Add a provider', section: 'providers', staff, csrf, notice, error, body: html`<h1>Add a provider</h1>
-<p class="muted">Pick a provider, paste an API key from its site and name its default model. The key is stored encrypted inside OpenVibe.AI and never shown again (only its last four characters). Nothing uses the provider until you choose it on a route's page.</p>
-${vaultReady ? '' : html`<p class="bad">Keys cannot be stored on this server: <code>AI_CREDENTIALS_KEY</code> is not set.</p>`}
+<p class="muted">Pick a provider, paste an API key from its site and name its default model. The key is sealed in AI's configuration store, bound to the provider's origin, and never shown again (only its last four characters). Nothing uses the provider until you choose it on a route's page.</p>
+${vaultReady ? '' : html`<p class="bad">Keys cannot be stored on this server: the configuration store is unavailable.</p>`}
<nav aria-label="Providers"><ul class="tabs">${presets.map((x) => html`<li><a href="/console/providers/new?preset=${enc(x.id)}"${x.id === p.id ? html` aria-current="page"` : ''}>${x.name}</a></li>`)}</ul></nav>
<nav aria-label="Providers"><ul class="tabs">${presets.map((x) => html`<li><a href="/console/providers/new?preset=${enc(x.id)}"${x.id === p.id ? html` aria-current="page"` : ''}>${x.name}</a></li>`)}</ul></nav>
<form method="post" action="/console/providers" class="stack">${csrfField(csrf)}<input type="hidden" name="preset" value="${p.id}">
<form method="post" action="/console/providers" class="stack">${csrfField(csrf)}<input type="hidden" name="preset" value="${p.id}">
<label>Name in OpenVibe <input type="text" name="key" required pattern="[a-z0-9][a-z0-9._-]{1,40}" value="${p.id === 'custom' ? '' : p.id}" spellcheck="false"></label>
<label>Name in OpenVibe <input type="text" name="key" required pattern="[a-z0-9][a-z0-9._-]{1,40}" value="${p.id === 'custom' ? '' : p.id}" spellcheck="false"></label>
@@ -509,6 +512,52 @@ ${table('Last send errors', ['Reading', 'Run', 'State', 'Attempts', 'Queued', 'L
@@ -509,6 +512,52 @@ ${table('Last send errors', ['Reading', 'Run', 'State', 'Attempts', 'Queued', 'L
});
});
}
}
+function creditsSection({ csrf, credits, canResolve }) {
+ const c = credits.counts;
+ const usage = credits.review_usage;
+ const rows = credits.review.map((r) => html`<tr>
+<td><code>${r.hold_id}</code></td><td>${r.request_id ? html`<code>${r.request_id}</code>` : '—'}</td><td>${n(r.tries)}</td>
+<td>${r.last_error ? clip(r.last_error, 300) : '—'}</td><td class="nowrap">${when(r.created_at)}</td>
+${canResolve ? html`<td><form class="inline" method="post" action="/console/exchange/credits/${enc(r.id)}/resolve">${csrfField(csrf)}<button type="submit">Mark resolved<span class="sr-only"> ${r.hold_id}</span></button></form></td>` : ''}</tr>`);
+ return html`<h2>Credits settle queue</h2>
+<div class="grid">
+<div class="card"><div class="k">Queued</div><div class="v">${n(c.queued)}</div></div>
+<div class="card"><div class="k">Sent</div><div class="v">${n(c.sent)}</div></div>
+<div class="card"><div class="k">Review</div><div class="v">${n(c.review)}</div></div>
+<div class="card"><div class="k">Oldest queued</div><div class="v">${credits.oldest_queued_seconds == null ? '—' : secs(credits.oldest_queued_seconds)}</div>
+<div class="muted">${credits.oldest_queued_at ? when(credits.oldest_queued_at) : 'nothing queued'}</div></div>
+</div>
+<p class="muted">Rows shown: ${n(credits.review.length)}${credits.review_truncated ? ` of ${n(credits.review_total)}` : ''} · tokens in ${n(usage.input_tokens)} · cached read ${n(usage.cached_read_tokens)} · cache write ${n(usage.cache_write_tokens)} · out ${n(usage.output_tokens)}. These are the only settle-body usage counts this page reads.</p>
+${table('Settle rows in review', ['Hold id', 'Request id', 'Tries', 'Last error', 'Created', ...(canResolve ? ['Action'] : [])], rows, 'no settle row is waiting for review.')}
+${canResolve ? '' : html`<p class="muted">Marking a review row resolved needs <code>ai.workflow.manage</code> (<code>staff.ai.manage</code>). It never retries the settle.</p>`}`;
+}
+
+function routingSection({ routing }) {
+ const deployments = routing.deployments.map((r) => html`<tr>
+<td><code>${r.provider_key}${r.model_key ? `:${r.model_key}` : ''}</code></td><td>${n(r.sample_count)}</td>
+<td>${r.sample_count ? pct(r.success_rate) : '—'}</td><td>${r.sample_count ? pct(r.timeout_rate) : '—'}</td>
+<td>${r.ttft_ms == null ? '—' : html`${n(Math.round(r.ttft_ms))} ms<br><span class="muted">n=${n(r.ttft_samples)}</span>`}</td>
+<td>${r.decode_tps == null ? '—' : html`${Number(r.decode_tps).toFixed(1)}<br><span class="muted">n=${n(r.decode_samples)}</span>`}</td>
+<td>${r.cache_hit_ratio == null ? '—' : pct(r.cache_hit_ratio)}${r.cache_samples ? html`<br><span class="muted">n=${n(r.cache_samples)}</span>` : ''}</td></tr>`);
+ const incumbents = routing.incumbents.map((r) => html`<tr><td><code>${r.decision_key}</code></td>
+<td>${r.provider_key ? html`<code>${r.provider_key}${r.model_key ? `:${r.model_key}` : ''}</code>` : '—'}</td>
+<td>${r.challenger_provider ? html`<code>${r.challenger_provider}${r.challenger_model ? `:${r.challenger_model}` : ''}</code>` : '—'}</td>
+<td>${n(r.challenger_streak)}</td><td class="nowrap">${when(r.updated_at)}</td></tr>`);
+ return html`<h2>Routing evidence</h2>
+<p class="muted">Measured per deployment: success and timeout rates, the TTFT and decode EWMA, and the cache-hit ratio. A metric with no measurement is shown as —, never as zero.</p>
+${table('Deployments', ['Deployment', 'Samples', 'Success rate', 'Timeout rate', 'TTFT', 'Decode tokens/s', 'Cache-hit ratio'], deployments, 'no deployment has been measured.')}
+${table('Routing incumbents', ['Decision key', 'Incumbent', 'Challenger', 'Streak', 'Updated'], incumbents, 'no incumbent recorded.')}`;
+}
+
+function exchange({ staff, csrf, credits, routing, canResolve, notice, error }) {
+ return layout({
+ title: 'AI Exchange', section: 'exchange', staff, csrf, notice, error, body: html`<h1>AI Exchange</h1>
+<p class="muted">Operational state of the AI Exchange, read-only.${credits ? html` A settle row in review can be marked resolved below; that never retries it.` : ''}</p>
+${credits ? creditsSection({ csrf, credits, canResolve }) : ''}
+${routing ? routingSection({ routing }) : ''}`,
+ });
+}
+
function cache({ staff, csrf, stats, canManage, notice, error }) {
function cache({ staff, csrf, stats, canManage, notice, error }) {
const entries = stats.reduce((a, s) => a + s.entries, 0);
const entries = stats.reduce((a, s) => a + s.entries, 0);
const hits = stats.reduce((a, s) => a + (s.hits || 0), 0);
const hits = stats.reduce((a, s) => a + (s.hits || 0), 0);
@@ -616,4 +665,4 @@ function allowance(cards) {
@@ -616,4 +665,4 @@ function allowance(cards) {
/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */
/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */
const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };
const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };
-module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit } };
+module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit, exchange } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * Durable Billing Credit settles (AI Exchange F, P7). A settle the service could not deliver — Billing down,
+ * slow, or refusing with a 5xx — is written to `credit_settle_queue` (migrations/0025) with its full body and
+ * the same idempotency key, and a background loop retries it with exponential backoff until Billing answers 2xx.
+ * A 409 or 422 is a decision, not an outage: the row is marked `review` and never retried. The loop is off in
+ * test mode (NODE_ENV=test, the way the memory jobs are) and off while a provider is in a drill, so a drill
+ * never writes money; a test drives runOnce() directly.
+ *
+ * The queue never stores a prompt, an answer or a secret: a settle body is token counts, prices and ids.
+ */
+const QUIET_MS = 5000;
+const MAX_MS = 5 * 60 * 1000;
+const BATCH = 20;
+
+function createCreditSettlements({ db, clock = { now: () => Date.now() }, log = console, registry = null, send, retryMs = QUIET_MS, maxRetryMs = MAX_MS, batch = BATCH }) {
+ const iso = (ms) => new Date(ms).toISOString();
+ const base = Math.max(1000, Number(retryMs) || QUIET_MS);
+ const ceiling = Math.max(base, Number(maxRetryMs) || MAX_MS);
+ let timer = null;
+ let running = false;
+
+ const parse = (v, fallback) => {
+ if (v == null) return fallback;
+ if (typeof v === 'object') return v;
+ try { return JSON.parse(v); } catch { return fallback; }
+ };
+
+ /** Still a provider drill? Money must not move under one (the same guard the memory jobs use). */
+ async function drillActive() {
+ if (!registry) return false;
+ const providers = await registry.listProviders();
+ return providers.some((p) => p && p.metadata && (p.metadata.fail || p.metadata.drill));
+ }
+
+ /** Keep a failed settle. The id is its idempotency key, so the later delivery pays it exactly once. */
+ async function enqueue({ holdId, requestId = null, body, error = null }) {
+ if (!holdId || !body || !body.idempotency_key) return null;
+ const now = clock.now();
+ // A 409 (the hold is already settled/terminal) or a 422 (Billing refused the body) is a decision, not an
+ // outage: keep the row for a person to look at, never retry it.
+ const decided = error && (error.status === 409 || error.status === 422);
+ await db.prepare(`INSERT INTO credit_settle_queue
+ (id, hold_id, request_id, settle_body, attempts, status, tries, next_at, last_error, created_at, updated_at)
+ VALUES (?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?::timestamptz, ?, ?::timestamptz, ?::timestamptz)
+ ON CONFLICT (id) DO NOTHING`).run(
+ body.idempotency_key, holdId, requestId, JSON.stringify(body), JSON.stringify(body.attempts || []),
+ decided ? 'review' : 'queued', decided ? 1 : 0, iso(now + base),
+ error ? String(error.message || error.code || 'settle failed').slice(0, 500) : null, iso(now), iso(now));
+ return body.idempotency_key;
+ }
+
+ async function due(limit, force) {
+ const now = iso(clock.now());
+ return force
+ ? db.prepare(`SELECT id, hold_id, request_id, settle_body, attempts, tries FROM credit_settle_queue
+ WHERE status = 'queued' ORDER BY next_at, id LIMIT ?`).all(Math.max(1, Math.min(100, limit)))
+ : db.prepare(`SELECT id, hold_id, request_id, settle_body, attempts, tries FROM credit_settle_queue
+ WHERE status = 'queued' AND next_at <= ?::timestamptz ORDER BY next_at, id LIMIT ?`).all(now, Math.max(1, Math.min(100, limit)));
+ }
+
+ async function sent(id, now) {
+ await db.prepare("UPDATE credit_settle_queue SET status = 'sent', last_error = NULL, updated_at = ?::timestamptz WHERE id = ?").run(iso(now), id);
+ }
+
+ async function review(id, err, now) {
+ await db.prepare("UPDATE credit_settle_queue SET status = 'review', tries = tries + 1, last_error = ?, updated_at = ?::timestamptz WHERE id = ?")
+ .run(String((err && (err.message || err.code)) || 'refused').slice(0, 500), iso(now), id);
+ }
+
+ async function reschedule(row, err, now) {
+ const tries = Number(row.tries || 0) + 1;
+ const delay = Math.min(ceiling, base * (2 ** Math.min(tries, 10)));
+ await db.prepare(`UPDATE credit_settle_queue SET status = 'queued', tries = ?, next_at = ?::timestamptz, last_error = ?, updated_at = ?::timestamptz WHERE id = ?`)
+ .run(tries, iso(now + delay), String((err && (err.message || err.code)) || 'settle failed').slice(0, 500), iso(now), row.id);
+ return delay;
+ }
+
+ /**
+ * One pass over the due rows. Never throws: each row's outcome is counted. A successful send is final; a
+ * 409/422 is `review`; anything else is queued again with backoff.
+ */
+ async function runOnce({ force = false } = {}) {
+ if (running) return { skipped: 'running' };
+ running = true;
+ const out = { sent: 0, review: 0, retry: 0, error: null };
+ try {
+ if (await drillActive()) return { skipped: 'drill' };
+ const rows = await due(batch, force);
+ for (const row of rows) {
+ const now = clock.now();
+ try {
+ await send({ ...row, body: parse(row.settle_body, {}) });
+ await sent(row.id, now);
+ out.sent++;
+ } catch (err) {
+ const status = err && err.status;
+ if (status === 409 || status === 422) {
+ await review(row.id, err, now);
+ out.review++;
+ } else {
+ await reschedule(row, err, now);
+ out.retry++;
+ }
+ }
+ }
+ } catch (err) {
+ out.error = (err && err.code) || 'error';
+ log.warn(`[credits] settle queue stopped early: ${out.error}`);
+ } finally { running = false; }
+ return out;
+ }
+
+ function start() {
+ if (timer) return timer;
+ timer = setInterval(() => { runOnce().catch(() => {}); }, Math.max(1000, Math.min(60000, base)));
+ timer.unref?.();
+ return timer;
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ /** The queued rows, for the operator and the tests (no body: it carries ids, not text). */
+ async function pending() {
+ return db.prepare('SELECT id, hold_id, request_id, status, tries, next_at, last_error FROM credit_settle_queue ORDER BY created_at, id').all();
+ }
+
+ return { enqueue, runOnce, start, stop, pending };
+}
+
+module.exports = { createCreditSettlements };
new file mode 100644
new file mode 100644
@@ -0,0 +1,465 @@
@@ -0,0 +1,465 @@
+'use strict';
+/**
+ * OpenVibe AI ↔ Billing Balance (AI Exchange F, P7; CHECKPOINT D2, D9, D10, D12), behind `AI_CREDITS` (off).
+ *
+ * A request on a PAID MANAGED route (the catalog's `provider_access` is OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE)
+ * reserves its worst case before any provider is called:
+ *
+ * hold = ceil(input estimate × retail input) + ceil(max_tokens × retail output) + per-request
+ *
+ * capped by the caller's optional `max_cost_usd` (parsed as an exact decimal, at most 9 fractional digits, finer
+ * digits rounded up to the next nano-USD; never a float). The hold is keyed by the request id and fingerprinted
+ * with the resolved model, the token caps, the retail rate version, the payer and the project. Billing's 402
+ * `billing.insufficient_funds` becomes AI's own 402 before the provider runs; any other Billing failure fails
+ * closed (503 `billing.unavailable`) rather than serving unpaid capacity.
+ *
+ * Who pays (D12). An app's run holds against its PROJECT's Balance: the hold carries the app subject
+ * ({ type: 'app', id }) and its project_id, and Billing needs no owner lookup. A person's own run holds against
+ * their PERSONAL Balance: the user subject ({ type: 'user', id }) and no project_id. A first-party run with no
+ * payer (no person and no app) has no account: the pool skips paid offers that carry a versioned retail card
+ * (`payer_required`) rather than serve them free. A sandbox app is free-only (D10), so it never holds.
+ *
+ * When `max_cost_usd` binds, the affordable output is computed in BigInt from nano-USD per million tokens
+ * (floor((cap - input charge - per-request) × 1e6 / output rate)); the provider receives that clamped
+ * `max_tokens`. Fewer than one affordable token is a 402 before the provider. A later managed fallback whose
+ * own worst case exceeds the hold extends the hold by the difference (capped by max_cost_usd, clamping its
+ * output the same way); if Billing refuses the extension the attempt is skipped as `hold_not_extended`.
+ *
+ * While a stream runs, `observe()` estimates the output from the deltas and extends the hold in steps when the
+ * projected charge nears it (capped by max_cost_usd; a refused extension never loses the usage — Billing settles
+ * the overage as a recorded shortfall). At the end, one settle carries the four token counts, the answering
+ * model's retail card (version included), the supply cost and the attempts the router made, in the contract's
+ * exact attempt shape. The settle key is the request id too, so a retried request replays Billing's answer
+ * instead of charging twice. A settle Billing cannot take is queued (`credit_settle_queue`) and retried with
+ * backoff; a 409 or 422 marks the row for review. An error before the first token releases the hold; an abort
+ * settles what was streamed.
+ *
+ * BYOK is never charged and never held: a run on a person's own key, their machine or a project's own provider
+ * only produces CUSTOMER_BYOK attempts, so no hold is ever created for it (there is no hold to settle `byok` on;
+ * Billing's settle needs one). A run that falls back from BYOK to managed supply holds and settles the managed
+ * part normally.
+ *
+ * Money is integer nano-USD in BigInt here, never a float (D1).
+ */
+const { AiError, sha256, iso } = require('./util');
+const { approxTokens } = require('./providers/common');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const MANAGED_ACCESS = new Set(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE']);
+const PER_MILLION = 1000000n;
+const NANO_PER_USD = 1000000000n;
+const USR_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+/** The four capabilities Billing names for AI (Contracts v0.289.1, one per operation). */
+const CAPABILITIES = ['billing.credit.hold.create', 'billing.credit.hold.extend', 'billing.credit.hold.settle', 'billing.credit.hold.release'];
+/** A prepaid answer has no retail charge; a settle still needs a card, and this says why the charge is zero. */
+const ZERO_RETAIL = Object.freeze({ version: 'prepaid', nano_usd_per_m: Object.freeze({ input: '0', cached_read: '0', cache_write: '0', output: '0' }), per_request_nano_usd: '0' });
+
+/**
+ * `max_cost_usd` as an exact decimal amount: a JSON number is stringified first, exponent forms are refused
+ * (422), up to 9 fractional digits are exact and any finer non-zero digit rounds up to the next nano-USD.
+ * Returns BigInt nano-USD, or null when absent.
+ */
+function parseCostUsd(value, field = 'max_cost_usd') {
+ if (value == null) return null;
+ const raw = (typeof value === 'number' ? String(value) : String(value)).trim();
+ if (!/^[0-9]+(?:\.[0-9]+)?$/.test(raw)) throw new AiError(422, 'input.invalid', `${field} must be a plain decimal amount with no exponent (for example 0.000000001)`);
+ const [whole, frac = ''] = raw.split('.');
+ let nano;
+ try { nano = BigInt(whole) * NANO_PER_USD; } catch { throw new AiError(422, 'input.invalid', `${field} is too large`); }
+ nano += BigInt((frac + '000000000').slice(0, 9));
+ if (/[1-9]/.test(frac.slice(9))) nano += 1n;
+ // Billing's money fields are 1-19 digits; a bigger amount is refused here rather than by Billing later.
+ if (nano >= 10000000000000000000n) throw new AiError(422, 'input.invalid', `${field} is too large`);
+ return nano;
+}
+
+/** ceil(tokens × nano-per-million / 1e6), the D1 rule, per component. */
+function chargeNano(tokens, nanoPerM) {
+ const t = BigInt(Math.max(0, Math.trunc(Number(tokens) || 0)));
+ const rate = BigInt(String(nanoPerM == null ? '0' : nanoPerM));
+ if (t === 0n || rate === 0n) return 0n;
+ const num = t * rate;
+ const div = num / PER_MILLION;
+ return num % PER_MILLION === 0n ? div : div + 1n;
+}
+
+/** A model row's `retail_rate` (catalog) in the Billing settle shape; null when there is no versioned card. */
+function retailOf(rate) {
+ if (!rate || typeof rate !== 'object') return null;
+ const version = rate.version == null ? '' : String(rate.version).trim();
+ if (!version) return null;
+ const perM = {
+ input: String(rate.input_nano_usd_per_m == null ? '0' : rate.input_nano_usd_per_m),
+ cached_read: String(rate.cached_read_nano_usd_per_m == null ? '0' : rate.cached_read_nano_usd_per_m),
+ cache_write: String(rate.cache_write_nano_usd_per_m == null ? '0' : rate.cache_write_nano_usd_per_m),
+ output: String(rate.output_nano_usd_per_m == null ? '0' : rate.output_nano_usd_per_m),
+ };
+ return { version, nano_usd_per_m: perM, per_request_nano_usd: String(rate.per_request_nano_usd == null ? '0' : rate.per_request_nano_usd) };
+}
+
+/** The retail charge for some usage, the same arithmetic Billing applies (used for the hold and the extension). */
+function retailChargeNano(retail, { input = 0, cached = 0, cacheWrite = 0, output = 0 } = {}) {
+ const perM = (retail && retail.nano_usd_per_m) || {};
+ return BigInt(retail && retail.per_request_nano_usd ? retail.per_request_nano_usd : '0')
+ + chargeNano(input, perM.input) + chargeNano(cached, perM.cached_read)
+ + chargeNano(cacheWrite, perM.cache_write) + chargeNano(output, perM.output);
+}
+
+/**
+ * The hold a request can afford: its own card, the input estimate and the requested output cap, clamped by
+ * max_cost_usd. Returns { maxTokens, amount }; throws 402 when the cap cannot buy even one output token.
+ */
+function affordable(retail, { inputTokens, maxTokens, maxCostNano = null }) {
+ const perM = retail.nano_usd_per_m || {};
+ const base = BigInt(retail.per_request_nano_usd || '0') + chargeNano(inputTokens, perM.input);
+ const requested = Math.max(1, Math.trunc(Number(maxTokens) || 1024));
+ let tokens = requested;
+ if (maxCostNano != null) {
+ if (maxCostNano < base) throw new AiError(402, 'billing.insufficient_funds', 'max_cost_usd is below this request\'s minimum cost');
+ const rate = BigInt(String(perM.output == null ? '0' : perM.output));
+ if (rate > 0n) {
+ // floor((cap - base) × 1e6 / rate-per-million), in BigInt: the review's D1 arithmetic.
+ const can = ((maxCostNano - base) * PER_MILLION) / rate;
+ if (can < 1n) throw new AiError(402, 'billing.insufficient_funds', 'max_cost_usd leaves no room for any output token');
+ if (can < BigInt(tokens)) tokens = Number(can);
+ }
+ }
+ const amount = base + chargeNano(tokens, perM.output);
+ return { maxTokens: tokens, amount };
+}
+
+/** The text a streamed delta carries, for the output estimate (OpenAI and Anthropic event shapes). */
+function deltaText(d) {
+ if (!d) return '';
+ const delta = d.delta || (d.data && d.data.delta) || null;
+ if (!delta) return '';
+ if (typeof delta.content === 'string') return delta.content;
+ if (typeof delta.text === 'string') return delta.text;
+ if (typeof delta.partial_json === 'string') return delta.partial_json;
+ if (Array.isArray(delta.tool_calls)) return delta.tool_calls.map((t) => (t && t.function && t.function.arguments) || '').join('');
+ return '';
+}
+
+/**
+ * Who pays (D12): an app (subject { type: 'app', id }) holds against its project's Balance; otherwise a person
+ * (the request's `on_behalf_of`, subject { type: 'user', id }) holds against their personal Balance. An app is
+ * always the app, whatever claim a service set on its token. A first-party service with neither has no payer and
+ * gets no session (the pool refuses it paid offers).
+ */
+function payerOf({ onBehalfOf = null, appId = null } = {}) {
+ if (appId) return { type: 'app', id: String(appId) };
+ if (onBehalfOf && USR_RE.test(String(onBehalfOf))) return { type: 'user', id: String(onBehalfOf) };
+ return null;
+}
+
+/** The project whose Balance an app's hold spends: its own project, never a person's attribution. */
+function projectOf({ projectId = null, attribution = null } = {}) {
+ if (projectId && PROJECT_RE.test(String(projectId))) return String(projectId);
+ const id = attribution && attribution.type === 'project' ? String(attribution.id || '') : '';
+ return PROJECT_RE.test(id) ? id : null;
+}
+
+/** A Billing error -> the API's own error: 402 keeps its code, everything else fails closed as unavailable. */
+function billingError(status, body, what) {
+ const detail = (body && (body.detail || body.title || body.message)) || `Billing ${what} failed`;
+ // The public word is Balance, never Credits: 402 keeps its code and says what is short.
+ if (status === 402) return new AiError(402, 'billing.insufficient_funds', 'Not enough Balance');
+ if (status === 409) return new AiError(409, 'billing.conflict', String(detail).slice(0, 500));
+ if (status === 422) return new AiError(422, 'billing.invalid_request', String(detail).slice(0, 500));
+ return new AiError(503, 'billing.unavailable', `Billing ${what} is unavailable (${status})`);
+}
+
+function createBillingClient({ billingUrl, audience, tokens, fetchImpl = globalThis.fetch, timeoutMs = 5000 }) {
+ const base = String(billingUrl || '').replace(/\/+$/, '');
+ async function call(method, path, body, requestId) {
+ const token = await tokens.getToken({ audience });
+ const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ if (requestId) headers['X-OpenVibe-Request-Id'] = requestId;
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(timeoutMs) });
+ } catch (err) {
+ throw new AiError(503, 'billing.unavailable', `Billing is unreachable: ${(err && err.name === 'TimeoutError') ? 'timeout' : 'network error'}`);
+ }
+ const json = await res.json().catch(() => null);
+ if (!res.ok) throw billingError(res.status, json, path);
+ return json || {};
+ }
+ return {
+ hold: (body, requestId) => call('POST', '/internal/credits/holds', body, requestId),
+ extend: (id, body, requestId) => call('POST', `/internal/credits/holds/${encodeURIComponent(id)}/extend`, body, requestId),
+ settle: (id, body, requestId) => call('POST', `/internal/credits/holds/${encodeURIComponent(id)}/settle`, body, requestId),
+ release: (id, requestId) => call('POST', `/internal/credits/holds/${encodeURIComponent(id)}/release`, {}, requestId),
+ };
+}
+
+/**
+ * One request's Balance session. Created by `begin()`; the run drives it:
+ *
+ * onRoute(facts, meta) before each provider attempt; returns { maxTokens } to clamp the attempt,
+ * { skip: 'hold_not_extended' } to pass on that offer, or null (not a Balance offer)
+ * observe(delta) each streamed delta; extends the hold when the output nears it
+ * settle({...}) once, after the answer (or after the partial usage of an abort/failure)
+ * release() once, when no token was produced at all
+ */
+function createSession({ manager, requestId, payer, projectId, credentialId = null, inputTokens, maxCostNano = null, startedAt }) {
+ const s = {
+ requestId: String(requestId),
+ payer, projectId, credentialId,
+ subject: payer,
+ inputTokens: Math.max(0, Math.trunc(inputTokens)),
+ maxCostNano,
+ holdId: null,
+ heldNano: 0n,
+ retail: null,
+ streamedChars: 0,
+ settled: false,
+ released: false,
+ extensionRefused: false,
+ pending: null,
+ startedAt,
+ };
+ const holdKey = `ai-hold:${s.requestId}`;
+ const settleKey = `ai-settle:${s.requestId}`;
+
+ /**
+ * The first managed paid attempt holds its worst case. A later managed fallback with a bigger worst case
+ * extends the hold by the difference (never above max_cost_usd); a refused extension skips that offer.
+ */
+ async function onRoute(facts, meta) {
+ if (s.settled || s.released) return null;
+ if (!meta) return null; // a person's own key or machine
+ const card = retailOf(meta.retail);
+ // A prepaid offer (free, subscription, BYOK, the stub) never spends Balance. After an earlier managed
+ // attempt it may still answer (a free fallback): the settle then uses its card, or zero.
+ if (meta.paid === false) { if (s.holdId) s.retail = card || ZERO_RETAIL; return null; }
+ if (!MANAGED_ACCESS.has(facts && facts.provider_access)) return null; // BYOK/unverified: no managed offer
+ const retail = card;
+ if (!retail) throw new AiError(503, 'billing.rate_unavailable', 'this route has no versioned retail rate card, so its credit cannot be held');
+ const requested = Math.max(1, Math.trunc(Number(meta.maxTokens) || 1024));
+ let plan;
+ try {
+ plan = affordable(retail, { inputTokens: s.inputTokens, maxTokens: requested, maxCostNano: s.maxCostNano });
+ } catch (err) {
+ // The first offer cannot be bought at this cap: 402 before the provider. A fallback simply cannot be
+ // covered, so it is skipped and a cheaper offer (or a later failure) decides the run.
+ if (s.holdId) return { skip: 'hold_not_extended' };
+ throw err;
+ }
+ s.retail = retail;
+ if (!s.holdId) {
+ s.heldNano = plan.amount;
+ const fingerprint = sha256({
+ resolved_model: (facts && facts.resolved_model) || null, revision: (facts && facts.revision) || null,
+ input_tokens: s.inputTokens, output_tokens: plan.maxTokens, max_cost_usd: s.maxCostNano == null ? null : String(s.maxCostNano),
+ rate_version: retail.version, subject: s.subject, project_id: s.projectId,
+ });
+ // D12: an app names its project's Balance; a person's hold is personal and carries no project_id.
+ const created = await manager.hold({
+ idempotency_key: holdKey, subject: s.subject, ...(s.projectId ? { project_id: s.projectId } : {}), ...(s.credentialId ? { credential_id: s.credentialId } : {}),
+ max_nano_usd: String(plan.amount), rate_version: retail.version, request_fingerprint: fingerprint, service: 'ai',
+ }, s.requestId);
+ s.holdId = created.hold_id;
+ s.heldNano = BigInt(String(created.held_nano_usd == null ? plan.amount : created.held_nano_usd));
+ return { maxTokens: plan.maxTokens };
+ }
+ if (plan.amount > s.heldNano) {
+ const add = plan.amount - s.heldNano;
+ try {
+ await manager.extend(s.holdId, { add_nano_usd: String(add) }, s.requestId);
+ s.heldNano = plan.amount;
+ } catch (err) {
+ if (err && (err.code === 'billing.insufficient_funds' || err.code === 'billing.conflict')) return { skip: 'hold_not_extended' };
+ throw err;
+ }
+ }
+ return { maxTokens: plan.maxTokens };
+ }
+
+ /** The projected output charge of what has streamed so far. */
+ function projectedNano() {
+ const output = Math.ceil(s.streamedChars / 4);
+ return { output, charge: s.retail ? retailChargeNano(s.retail, { output }) : 0n };
+ }
+
+ /** Extend once when the stream nears the hold; the step is another hold's worth, capped by max_cost_usd. */
+ function maybeExtend() {
+ if (!s.holdId || s.extensionRefused || s.settled || s.released || s.pending) return;
+ const { charge } = projectedNano();
+ const near = s.heldNano > 0n ? (charge * 5n >= s.heldNano * 4n) : charge > 0n;
+ if (!near) return;
+ let step = s.heldNano > 0n ? s.heldNano : charge;
+ if (step <= 0n) step = 1n;
+ if (s.maxCostNano != null) {
+ const room = s.maxCostNano - s.heldNano;
+ if (room <= 0n) { s.extensionRefused = true; return; }
+ if (step > room) step = room;
+ }
+ s.heldNano += step;
+ s.pending = manager.extend(s.holdId, { add_nano_usd: String(step) }, s.requestId).catch((err) => {
+ s.heldNano -= step;
+ // A short balance mid-stream stops the growth; settle records the overage as Billing's shortfall.
+ if (err && (err.code === 'billing.insufficient_funds' || err.code === 'billing.conflict')) s.extensionRefused = true;
+ else manager.log.warn(`[credits] extend ${s.holdId} failed: ${err && err.message}`);
+ }).finally(() => { s.pending = null; });
+ }
+
+ function observe(delta) {
+ if (!s.holdId || s.settled || s.released || s.extensionRefused) return;
+ const text = deltaText(delta);
+ if (!text) return;
+ s.streamedChars += text.length;
+ maybeExtend();
+ }
+
+ function usageOf(usage = {}) {
+ const cached = Math.max(0, Math.trunc(Number(usage.cached) || 0));
+ const cacheWrite = Math.max(0, Math.trunc(Number(usage.cache_write) || 0));
+ const whole = Math.max(0, Math.trunc(Number(usage.input) || 0));
+ return { input_tokens: Math.max(0, whole - cached - cacheWrite), cached_read_tokens: cached, cache_write_tokens: cacheWrite, output_tokens: Math.max(0, Math.trunc(Number(usage.output) || 0)) };
+ }
+
+ /** One settle, with the four token counts, the answering card, the supply cost and the attempts. */
+ async function settle({ usage = {}, supplyCostUsd = 0, attempts = [] } = {}) {
+ if (!s.holdId || s.settled || s.released) return null;
+ if (s.pending) await s.pending;
+ // The provider's own cost comes as a float from the rate cards; turn it into integer nano-USD through a
+ // decimal string (never a nano conversion by float multiplication).
+ let supply = 0n;
+ if (Number.isFinite(supplyCostUsd) && supplyCostUsd > 0) {
+ try { supply = parseCostUsd(Number(supplyCostUsd).toFixed(9)) || 0n; } catch { supply = 0n; }
+ }
+ const body = {
+ idempotency_key: settleKey,
+ usage: usageOf(usage),
+ retail: s.retail,
+ supply_cost_nano_usd: String(supply),
+ byok: false,
+ attempts: Array.isArray(attempts) ? attempts : [],
+ };
+ s.settled = true;
+ try {
+ return await manager.settle(s.holdId, body, s.requestId);
+ } catch (err) {
+ s.settled = false;
+ // Durable: Billing may be down or slow now; the same body and key are retried by the outbox.
+ if (manager.enqueueSettle) {
+ try { await manager.enqueueSettle({ holdId: s.holdId, requestId: s.requestId, body, error: err }); }
+ catch (qerr) { manager.log.warn(`[credits] queue settle ${settleKey} failed: ${qerr && qerr.message}`); }
+ }
+ throw err;
+ }
+ }
+
+ /** Release a hold whose request never produced a token; harmless when no hold was ever made. */
+ async function release() {
+ if (!s.holdId || s.settled || s.released) return null;
+ s.released = true;
+ try {
+ return await manager.release(s.holdId, s.requestId);
+ } catch (err) {
+ s.released = false;
+ throw err;
+ }
+ }
+
+ return {
+ onRoute, observe, settle, release, projectedNano, usageOf, requestId: s.requestId,
+ get holdId() { return s.holdId; }, get heldNano() { return s.heldNano; }, get retail() { return s.retail; },
+ get streamedChars() { return s.streamedChars; }, get settled() { return s.settled; }, get released() { return s.released; },
+ get inputTokens() { return s.inputTokens; }, get payer() { return s.payer; },
+ };
+}
+
+/**
+ * The manager the service builds at boot (null when AI_CREDITS is off, so `runs` never touches Billing).
+ * `tokens` is injectable for tests; production mints a service token for openvibe.billing (the four hold
+ * capabilities). Network is reached only for the OAuth token, never to resolve a project's owner (D12).
+ */
+function createCredits({ config, db = null, registry = null, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, networkUrl = '' }) {
+ const cfg = (config && config.credits) || {};
+ if (!cfg.enabled) return null;
+ const billingUrl = String(cfg.billingUrl || '').replace(/\/+$/, '');
+ const clientSecret = env.OV_OAUTH_CLIENT_SECRET;
+ const network = String(networkUrl || cfg.networkUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ const clientId = (config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';
+ let tokenClient = tokens;
+ if (!tokenClient && clientSecret) {
+ tokenClient = createServiceTokenClient({
+ tokenUrl: `${network}/oauth/token`, clientId, clientSecret,
+ scope: { [cfg.audience || 'openvibe.billing']: CAPABILITIES }, fetch: fetchImpl,
+ });
+ }
+ const available = Boolean(billingUrl && tokenClient);
+ if (!available) log.warn('[credits] AI_CREDITS is on but Billing is not configured (OV_BILLING_INTERNAL_URL / OV_OAUTH_CLIENT_SECRET); paid managed requests will be refused');
+ const billing = available ? createBillingClient({ billingUrl, audience: cfg.audience || 'openvibe.billing', tokens: tokenClient, fetchImpl, timeoutMs: cfg.timeoutMs || 5000 }) : null;
+
+ const settlements = db && billing
+ ? require('./credit-settlements').createCreditSettlements({
+ db, clock, log, registry,
+ send: (row) => billing.settle(row.hold_id, row.body, row.request_id),
+ retryMs: cfg.settleRetryMs, maxRetryMs: cfg.settleMaxRetryMs,
+ })
+ : null;
+
+ const manager = {
+ enabled: true,
+ log,
+ accessOf: accessOfProvider,
+ async hold(body, requestId) {
+ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');
+ return billing.hold(body, requestId);
+ },
+ async extend(holdId, body, requestId) {
+ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');
+ return billing.extend(holdId, body, requestId);
+ },
+ async settle(holdId, body, requestId) {
+ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');
+ return billing.settle(holdId, body, requestId);
+ },
+ async release(holdId, requestId) {
+ if (!billing) throw new AiError(503, 'billing.unavailable', 'Billing is not configured on this service, so Balance cannot be held');
+ return billing.release(holdId, requestId);
+ },
+ enqueueSettle: settlements ? (row) => settlements.enqueue(row) : null,
+ drainSettlements: settlements ? (opts) => settlements.runOnce(opts) : async () => ({ skipped: 'disabled' }),
+ pendingSettlements: settlements ? () => settlements.pending() : async () => [],
+ startSettlements: settlements ? () => settlements.start() : () => null,
+ stopSettlements: settlements ? () => settlements.stop() : () => {},
+ /**
+ * Begin a request's Balance session, or null when there is no payer (the pool then refuses paid offers
+ * that carry a versioned card), when an app names no project, or for a BYOK run (no managed route).
+ */
+ begin({ requestId, ctx = {}, input = {}, attribution = null, onBehalfOf = null, credentialId = null } = {}) {
+ const payer = payerOf({ onBehalfOf: onBehalfOf || ctx.onBehalfOf || null, appId: ctx.appId });
+ if (!payer) return null;
+ // D12: an app spends its project's Balance; a person's own run is personal, so an attribution is
+ // never turned into a project_id on a user's hold.
+ const projectId = payer.type === 'app' ? projectOf({ projectId: ctx.projectId, attribution }) : null;
+ if (payer.type === 'app' && !projectId) return null;
+ if (ctx.byok) return null; // a credential/project provider: no managed route in this run
+ const requestKey = String(requestId || ctx.runId || '');
+ if (!requestKey) return null; // no stable key: never invent one from the clock
+ const inputTokens = approxTokens(JSON.stringify(input || {})) + 16;
+ let maxCostNano = null;
+ if (ctx.maxCostNano != null) {
+ try { maxCostNano = BigInt(String(ctx.maxCostNano)); } catch { throw new AiError(422, 'input.invalid', 'max_cost_usd is invalid'); }
+ } else if (ctx.maxCostUsd != null) maxCostNano = parseCostUsd(ctx.maxCostUsd);
+ return createSession({ manager, requestId: requestKey, payer, projectId, credentialId, inputTokens, maxCostNano, startedAt: iso(clock.now()) });
+ },
+ };
+ return manager;
+}
+
+/** A provider record's commercial access (the catalog's rule), for the attempts a settle reports. */
+function accessOfProvider(provider) {
+ try { return require('./providers/model-catalog').accessOf(provider); } catch { return null; }
+}
+
+module.exports = {
+ createCredits, createBillingClient, createSession, retailOf, retailChargeNano, affordable, parseCostUsd,
+ deltaText, payerOf, projectOf, accessOfProvider, CAPABILITIES, MANAGED_ACCESS,
+};
@@ -28,21 +28,37 @@ const { createAuth, createNetworkKeys } = require('./auth');
@@ -28,21 +28,37 @@ const { createAuth, createNetworkKeys } = require('./auth');
const { jwksClient } = require('openvibe-sdk/auth');
const { jwksClient } = require('openvibe-sdk/auth');
const schemas = require('./schemas');
const schemas = require('./schemas');
const { createApp } = require('./app');
const { createApp } = require('./app');
+const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null } = {}) {
+async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const db = givenDb || await openDb(config, { log });
const registry = createRegistry(db, { clock, env });
const registry = createRegistry(db, { clock, env });
- // Provider keys an owner stored from the console (server/vault.js): into this process before any provider is built.
- const vault = require('./vault').createVault({ db, config, clock, log });
+ // AI Exchange F: Billing Credits for paid managed inference (AI_CREDITS, off). The manager is created when the
+ // flag is on, including when an owner turns it on live (D11 onExchange below); runs use the facade.
+ let credits = null;
+ function ensureCredits() {
+ if (credits || !(config.credits && config.credits.enabled)) return credits;
+ credits = require('./credits').createCredits({ config, db, registry, env, fetchImpl, clock, log, networkUrl: config.networkInternalUrl });
+ if (credits && !(config.modelCatalog && config.modelCatalog.enabled)) {
+ log.warn('[credits] AI_CREDITS is on but AI_MODEL_CATALOG is off: no offer declares managed access, so no request is charged');
+ }
+ if (credits && listen && config.nodeEnv !== 'test') credits.startSettlements();
+ return credits;
+ }
+ // D11 owner settings: ai.exchange and ai.provider_keys on openvibe-shared/config. The provider-key store also
+ // refreshes the synchronous vault cache, and its one-time migration runs before any provider is built.
+ const settings = await require('./settings').createSettings({ db, config, registry, clock, env, log, onExchange: () => ensureCredits() });
+ const vault = require('./vault').createVault({ settings, clock, log });
+ await require('./vault').migrate({ db, config, settings, log }).catch((err) => log.warn(`[Vault] migration failed: ${err.message}`));
await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));
await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
- const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels, bench });
+ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch, machineModels, bench, variantEvidence });
// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
const tiers = createTiers({ config, valkey, clock, log });
const tiers = createTiers({ config, valkey, clock, log });
// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);
// §2.1.8: the provider free allowance per subject, priced off the pool's own rate cards (server/free-allowance.js);
@@ -71,8 +87,28 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -71,8 +87,28 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.
// A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.
const preferences = require('./person-preferences').createPreferences({ db, clock });
const preferences = require('./person-preferences').createPreferences({ db, clock });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets });
+ ensureCredits();
+ // A stable facade: the store may create the Credits manager after boot (AI_CREDITS turned on live), and the runs
+ // keep the same object. enabled is the live flag, so turning Credits off stops new holds at once.
+ const creditsRef = {
+ get enabled() { return Boolean(credits && config.credits && config.credits.enabled); },
+ get accessOf() { return credits ? credits.accessOf : require('./credits').accessOfProvider; },
+ get manager() { return credits; },
+ begin(o) {
+ if (!credits) throw new AiError(503, 'billing.unavailable', 'Billing Credits is not configured on this service');
+ return credits.begin(o);
+ },
+ pendingSettlements() { return credits ? credits.pendingSettlements() : Promise.resolve([]); },
+ drainSettlements(o) { return credits ? credits.drainSettlements(o) : Promise.resolve({ skipped: 'disabled' }); },
+ startSettlements() { return credits ? credits.startSettlements() : null; },
+ stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
+ };
+ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef });
await seed({ registry, quotas, config, env, db });
await seed({ registry, quotas, config, env, db });
+ // The seed creates or moves providers from the environment: publish their origins (the schema and every key's
+ // bind follow), so a key is bound to the host it is for from the first boot.
+ await settings.providerKeys.syncOrigins({ actor: { type: 'service', id: 'ai' }, reason: 'providers seeded' })
+ .catch((err) => log.warn(`[config] provider origins: ${err.message}`));
const interrupted = await runs.recoverInterrupted();
const interrupted = await runs.recoverInterrupted();
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
@@ -93,7 +129,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -93,7 +129,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, benchEvents, runBudgets, memory });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -117,6 +153,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -117,6 +153,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
let accountSubscription = null;
let accountSubscription = null;
if (listen) userModules.start();
if (listen) userModules.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
+ // Durable Credit settles: the same jobs rule as Memory (never in test mode, never under a provider drill; the
+ // queue's runOnce checks the drill itself).
+ if (listen && credits && config.nodeEnv !== 'test') credits.startSettlements();
if (listen) {
if (listen) {
server = await new Promise((resolve, reject) => {
server = await new Promise((resolve, reject) => {
// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy
// Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy
@@ -147,6 +186,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -147,6 +186,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (accountSubscription) accountSubscription.stop();
if (accountSubscription) accountSubscription.stop();
userModules.stop();
userModules.stop();
memoryJobs.stop();
memoryJobs.stop();
+ if (credits) credits.stopSettlements();
if (jwks) jwks.stop();
if (jwks) jwks.stop();
if (server) {
if (server) {
server.closeAllConnections?.();
server.closeAllConnections?.();
@@ -161,7 +201,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -161,7 +201,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, close };
}
}
if (require.main === module) {
if (require.main === module) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * AI Exchange P7: OpenVibe.Bench's per-artifact evidence (Bench PR #120, GET /api/v1/variant-evidence/:exactId).
+ *
+ * The only facts this client accepts are the ones Bench measured for an exact id (D3:
+ * org/model[:quant]@revision): sample_count, success_rate, timeout_rate, TTFT p50/p95 and decode tokens/s.
+ * A sparse row is returned with its sample count and status `sparse`, never dropped and never filled in; the
+ * router reports it as sparse. A 404 is `absent`; a network failure is `unavailable` and never fails a request.
+ *
+ * Reads are cached for a short TTL per exact id (positive and negative), so a burst of alias traffic makes one
+ * Bench call, and an unavailable Bench does not get hammered. No prompt, output or request id is sent.
+ */
+const DEFAULT_TTL_MS = 60 * 1000;
+const FAILURE_TTL_MS = 5 * 1000;
+
+function normalise(evidence) {
+ if (!evidence || typeof evidence !== 'object') return null;
+ const samples = Number.isSafeInteger(Number(evidence.sample_count)) && Number(evidence.sample_count) >= 0 ? Number(evidence.sample_count) : 0;
+ const metrics = evidence.metrics && typeof evidence.metrics === 'object' ? evidence.metrics : {};
+ const rate = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.max(0, Math.min(1, Number(v))));
+ const tps = (v) => (v == null || !Number.isFinite(Number(v)) || Number(v) < 0 ? null : Number(v));
+ return {
+ exact_id: typeof evidence.exact_id === 'string' ? evidence.exact_id : null,
+ samples,
+ success_rate: rate(evidence.success_rate),
+ timeout_rate: rate(evidence.timeout_rate),
+ ttft_p50_ms: tps(metrics.ttft_p50_ms != null ? metrics.ttft_p50_ms : evidence.ttft_p50_ms),
+ ttft_p95_ms: tps(metrics.ttft_p95_ms != null ? metrics.ttft_p95_ms : evidence.ttft_p95_ms),
+ decode_tokens_per_s: tps(metrics.decode_tokens_per_s != null ? metrics.decode_tokens_per_s : evidence.decode_tokens_per_s),
+ measured_at: evidence.measured_at || null,
+ sparse: evidence.sparse === true || Boolean(evidence.confidence && evidence.confidence.sparse),
+ confidence: evidence.confidence || null,
+ };
+}
+
+function createVariantEvidence({ config = {}, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() }, log = console, ttlMs = DEFAULT_TTL_MS } = {}) {
+ const base = String((config.bench && config.bench.url) || '').replace(/\/+$/, '');
+ const timeoutMs = Math.max(100, Number(config.bench && config.bench.timeoutMs) || 15000);
+ const cache = new Map(); // exact id -> { at, expires, result }
+
+ function remember(id, result, ttl = result.status === 'unavailable' ? FAILURE_TTL_MS : ttlMs) {
+ if (cache.size > 512) cache.delete(cache.keys().next().value);
+ cache.set(id, { at: clock.now(), expires: ttl, result });
+ return result;
+ }
+
+ /**
+ * → { status: 'ok' | 'sparse' | 'absent' | 'unavailable', samples, success_rate, timeout_rate,
+ * ttft_p50_ms, decode_tokens_per_s, exact_id, sparse }
+ */
+ async function get(exactId) {
+ const id = typeof exactId === 'string' ? exactId.trim() : '';
+ if (!id) return { status: 'absent', samples: 0, exact_id: null };
+ const hit = cache.get(id);
+ if (hit && clock.now() - hit.at <= hit.expires) return hit.result;
+ if (!base || typeof fetchImpl !== 'function') return remember(id, { status: 'absent', samples: 0, exact_id: id });
+ let res;
+ try {
+ res = await fetchImpl(`${base}/api/v1/variant-evidence/${encodeURIComponent(id)}`, {
+ method: 'GET', headers: { Accept: 'application/json' }, signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined,
+ });
+ } catch (err) {
+ log.warn(`[routing-evidence] Bench evidence unavailable for ${id}: ${String(err && err.message || err).slice(0, 160)}`);
+ return remember(id, { status: 'unavailable', samples: 0, exact_id: id });
+ }
+ if (res.status === 404) return remember(id, { status: 'absent', samples: 0, exact_id: id });
+ if (!res.ok) return remember(id, { status: 'unavailable', samples: 0, exact_id: id });
+ let body = null;
+ try { body = await res.json(); } catch { body = null; }
+ const evidence = normalise(body && body.evidence);
+ if (!evidence) return remember(id, { status: 'absent', samples: 0, exact_id: id });
+ return remember(id, { ...evidence, status: evidence.sparse ? 'sparse' : 'ok' });
+ }
+
+ return { get, clear: () => cache.clear(), base };
+}
+
+module.exports = { createVariantEvidence, normalise };
new file mode 100644
new file mode 100644
@@ -0,0 +1,490 @@
@@ -0,0 +1,490 @@
+'use strict';
+/**
+ * AI Exchange P7: expected cost per successful alias request, from measured evidence (PLAN §D, §E).
+ *
+ * This is AI's router using the ONE planner, openvibe-sdk/placement planAi(): planAi keeps the hard gates, the
+ * expected-cost-per-success arithmetic in integer nano-USD (D1), the sparse-evidence marking and its hysteresis
+ * margin. This module only prepares the planner's AI Exchange inputs from what AI has measured, adds the
+ * N-consecutive-decision rule, the cache affinity and the small exploration share, and explains the answer.
+ *
+ * economy the lowest expected cost per success, where
+ * price_after_cache = input*(1-hit)*retail_in + input*hit*retail_cached + output*retail_out (+ fee)
+ * expected cost per success = price_after_cache / max(success_rate, floor)
+ * fast the lowest p50 TTFT, then the highest decode tokens/s (planAi's latency objective, priced flat so
+ * the measured latency is what decides)
+ * balanced the normalised cost per success and normalised TTFT weighed together
+ * private never reaches here: the route already excludes every non-local offer
+ *
+ * Nothing here is a second planner: the order planAi produces is the order the router tries, with the
+ * incumbent kept for N decisions and a warm prompt-prefix cache as a tie-break. The explanation carries only
+ * ids, counts, nano-USD and rates — never a prompt, an endpoint, a key or a host.
+ */
+const placement = require('openvibe-sdk/placement');
+const { nanoUsd, chargeTokens, costPerSuccessNano, formatNanoUsd } = require('openvibe-sdk/cost');
+const { tokensOf, leadingText } = require('./task');
+const { offerKey } = require('./metrics');
+
+const POLICIES = { cheapest: 'economy', fastest: 'fast', balanced: 'balanced', auto: 'auto', private: 'private', exact: 'exact' };
+const OBJECTIVE = { cheapest: 'cheapest', fastest: 'fastest', balanced: 'balanced', auto: 'balanced' };
+const MIN_PROMPT_TOKENS = 1;
+const PPM = 1000000n;
+
+const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));
+const nanoFromUsd = (usd) => {
+ const n = Number(usd);
+ if (!Number.isFinite(n) || n < 0) return null;
+ return String(Math.round(n * 1e9));
+};
+
+/** Integer nano-USD per million rates from the retail card, else from the route's rate cards (free: zeros). */
+function nanoRatesOf({ free = false, retail = null, cards = null } = {}) {
+ const zero = { input_nano_usd_per_m: '0', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', output_nano_usd_per_m: '0', per_request_nano_usd: '0' };
+ if (free) return zero;
+ if (retail && retail.input_nano_usd_per_m != null && retail.output_nano_usd_per_m != null) {
+ const cardCached = cards && cards.cached ? nanoFromUsd(cards.cached.unit_price_usd) : null;
+ return {
+ input_nano_usd_per_m: String(retail.input_nano_usd_per_m),
+ cached_read_nano_usd_per_m: retail.cached_read_nano_usd_per_m != null ? String(retail.cached_read_nano_usd_per_m) : (cardCached != null ? cardCached : String(retail.input_nano_usd_per_m)),
+ cache_write_nano_usd_per_m: retail.cache_write_nano_usd_per_m != null ? String(retail.cache_write_nano_usd_per_m) : '0',
+ output_nano_usd_per_m: String(retail.output_nano_usd_per_m),
+ per_request_nano_usd: retail.per_request_nano_usd != null ? String(retail.per_request_nano_usd) : '0',
+ };
+ }
+ if (!cards) return null;
+ const input = nanoFromUsd(cards.in && cards.in.unit_price_usd);
+ const cached = nanoFromUsd(cards.cached && cards.cached.unit_price_usd);
+ const put = nanoFromUsd(cards.cache_write && cards.cache_write.unit_price_usd);
+ const output = nanoFromUsd(cards.out && cards.out.unit_price_usd);
+ if (input == null || cached == null || output == null) return null;
+ return {
+ input_nano_usd_per_m: input, cached_read_nano_usd_per_m: cached, cache_write_nano_usd_per_m: put || '0',
+ output_nano_usd_per_m: output, per_request_nano_usd: '0',
+ };
+}
+
+/**
+ * The retail card's input and cached_read rates weighted by the measured cache-hit ratio, plus output at the
+ * expected output length, in integer nano-USD. All BigInt; each component is rounded up once (D1).
+ */
+function priceAfterCacheNano({ promptTokens = 0, outputTokens = 0, cacheHitRatio = 0, rates } = {}) {
+ const input = BigInt(Math.max(0, Math.floor(Number(promptTokens) || 0)));
+ const output = BigInt(Math.max(0, Math.floor(Number(outputTokens) || 0)));
+ const ppm = BigInt(Math.max(0, Math.min(1000000, Math.round(clamp01(cacheHitRatio) * 1000000))));
+ const cached = (input * ppm) / PPM;
+ const uncached = input - cached;
+ return chargeTokens(uncached, rates.input_nano_usd_per_m)
+ + chargeTokens(cached, rates.cached_read_nano_usd_per_m)
+ + chargeTokens(output, rates.output_nano_usd_per_m)
+ + nanoUsd(rates.per_request_nano_usd);
+}
+
+/**
+ * One integer input rate that prices the whole prompt at the same total as the two-part price. planAi's cost
+ * function takes one usage and one rate card per candidate, so the measured cache ratio is carried in the
+ * blended rate; this picks the integer rate whose ceiling charge equals the exact two-part charge when one
+ * exists (otherwise the rounded-up nearest, never a cheaper rate).
+ */
+function blendedInputRateNano({ promptTokens = 0, cacheHitRatio = 0, rates } = {}) {
+ const input = BigInt(Math.max(0, Math.floor(Number(promptTokens) || 0)));
+ if (!input) return '0';
+ const ppm = BigInt(Math.max(0, Math.min(1000000, Math.round(clamp01(cacheHitRatio) * 1000000))));
+ const cached = (input * ppm) / PPM;
+ const uncached = input - cached;
+ const desired = chargeTokens(uncached, rates.input_nano_usd_per_m) + chargeTokens(cached, rates.cached_read_nano_usd_per_m);
+ if (!desired) return '0';
+ const base = (desired * PPM) / input;
+ for (const candidate of [base - 1n, base, base + 1n, base + 2n].filter((x) => x >= 0n)) {
+ if (chargeTokens(input, candidate) === desired) return candidate.toString();
+ }
+ return ((desired * PPM + input - 1n) / input).toString();
+}
+
+/** The measured p_success and its count: Bench is used only when it is not sparse and has a success rate. */
+function combineEvidence({ metric = null, bench = null, minSamples = 20, prior = 0.9 } = {}) {
+ const floor = Math.max(1, Number(minSamples) || 20);
+ const sources = [];
+ if (metric && metric.sample_count >= floor && Number.isFinite(metric.success_rate)) {
+ sources.push({ p: clamp01(metric.success_rate), n: metric.sample_count, source: 'ewma' });
+ }
+ if (bench && bench.status === 'ok' && bench.samples >= floor && bench.success_rate != null) {
+ sources.push({ p: clamp01(bench.success_rate), n: bench.samples, source: 'bench' });
+ }
+ const total = sources.reduce((n, s) => n + s.n, 0);
+ const p = total ? sources.reduce((sum, s) => sum + s.p * s.n, 0) / total : null;
+ const raw = metric && metric.sample_count > 0 ? { p_success: clamp01(metric.success_rate), confidence: 1, samples: metric.sample_count, source: 'ewma' } : null;
+ return {
+ // planAi rejects p <= 0 as 'no chance'; the brief prices it at the success floor instead, so a measured
+ // zero stays an eligible (very expensive) last resort and the floor decides its cost per success.
+ evidence: sources.length ? { p_success: Math.max(1e-9, p), confidence: 1, samples: total, source: sources.map((s) => s.source).join('+') } : raw,
+ p_success: sources.length ? Math.max(1e-9, p) : null,
+ samples: total || (metric ? metric.sample_count : 0),
+ sparse: !sources.length,
+ source: sources.length ? sources.map((s) => s.source).join('+') : (raw ? 'ewma' : 'prior'),
+ bench,
+ metric,
+ minSamples: floor,
+ prior,
+ };
+}
+
+/** The measured TTFT: Bench's p50 when it is usable, else the EWMA of the first-token time. */
+function measuredTtft({ metric = null, bench = null, minSamples = 20 } = {}) {
+ const floor = Math.max(1, Number(minSamples) || 20);
+ if (bench && bench.status === 'ok' && bench.samples >= floor && bench.ttft_p50_ms != null) {
+ return { ms: Number(bench.ttft_p50_ms), source: 'bench_p50', samples: bench.samples };
+ }
+ if (metric && metric.ttft_samples >= floor && metric.ttft_ms != null) {
+ return { ms: Number(metric.ttft_ms), source: 'ewma', samples: metric.ttft_samples };
+ }
+ return { ms: null, source: metric && metric.ttft_samples > 0 ? 'sparse' : 'absent', samples: metric ? metric.ttft_samples : 0 };
+}
+
+/** The measured decode throughput: Bench first, else the EWMA of output tokens per decode second. */
+function measuredDecode({ metric = null, bench = null, minSamples = 20 } = {}) {
+ const floor = Math.max(1, Number(minSamples) || 20);
+ if (bench && bench.status === 'ok' && bench.samples >= floor && bench.decode_tokens_per_s != null) {
+ return { tps: Number(bench.decode_tokens_per_s), source: 'bench', samples: bench.samples };
+ }
+ if (metric && metric.decode_samples >= floor && metric.decode_tps != null) {
+ return { tps: Number(metric.decode_tps), source: 'ewma', samples: metric.decode_samples };
+ }
+ return { tps: null, source: metric && metric.decode_samples > 0 ? 'sparse' : 'absent', samples: metric ? metric.decode_samples : 0 };
+}
+
+/** A measured cache-hit ratio, or zero (the conservative, uncached price) when it is not measured enough. */
+function measuredCache({ metric = null, minSamples = 20 } = {}) {
+ const floor = Math.max(1, Number(minSamples) || 20);
+ if (metric && metric.cache_samples >= floor) return { ratio: clamp01(metric.cache_hit_ratio), source: 'ewma', samples: metric.cache_samples };
+ return { ratio: 0, source: metric && metric.cache_samples > 0 ? 'sparse' : 'absent', samples: metric ? metric.cache_samples : 0 };
+}
+
+function labelFor(policy) { return POLICIES[policy] || policy || 'router'; }
+
+function reasonFor({ policy, chosen = null, samples = 0, sparse = true }) {
+ const n = Math.max(0, Number(samples) || 0);
+ const label = labelFor(policy);
+ if (!chosen) return `${label}: no eligible candidate`;
+ if (policy === 'cheapest') return sparse ? `${label}: lowest measured price, no measured success yet (n=${n})` : `${label}: lowest cost per success (n=${n})`;
+ if (policy === 'fastest') return sparse ? `${label}: lowest measured TTFT, no measured success yet (n=${n})` : `${label}: lowest measured TTFT (n=${n})`;
+ return sparse ? `${label}: cost and latency, no measured successful rate yet (n=${n})` : `${label}: cost and latency weighed (n=${n})`;
+}
+
+/** The incumbent and the challenger's consecutive-decision streak, persisted (migrations/0026). */
+function createIncumbents({ db, clock = { now: () => Date.now() }, decisions = 3 } = {}) {
+ const decisionsNow = () => Math.max(1, Number(typeof decisions === 'function' ? decisions() : decisions) || 3);
+ const select = db.prepare('SELECT * FROM routing_incumbents WHERE decision_key = ?');
+ const upsert = db.prepare(`INSERT INTO routing_incumbents
+ (decision_key, provider_key, model_key, challenger_provider, challenger_model, challenger_streak, updated_at)
+ VALUES (@decision_key, @provider_key, @model_key, @challenger_provider, @challenger_model, @challenger_streak, @updated_at)
+ ON CONFLICT(decision_key) DO UPDATE SET
+ provider_key = excluded.provider_key, model_key = excluded.model_key,
+ challenger_provider = excluded.challenger_provider, challenger_model = excluded.challenger_model,
+ challenger_streak = excluded.challenger_streak, updated_at = excluded.updated_at`);
+ const idOf = (p, m) => offerKey(p, m);
+ const split = (id) => {
+ const i = String(id || '').indexOf(':');
+ return i < 0 ? { provider: String(id || ''), model: '' } : { provider: String(id).slice(0, i), model: String(id).slice(i + 1) };
+ };
+ const stamp = () => new Date(clock.now()).toISOString();
+
+ /**
+ * planAi chose `chosen` under the configured minGain. The incumbent is replaced only after `decisions`
+ * consecutive choices of the same challenger; a challenger that goes away resets the streak.
+ */
+ async function apply(key, chosenId, inPool) {
+ const row = await select.get(String(key));
+ if (!row || !row.provider_key) {
+ const chosen = split(chosenId);
+ await upsert.run({ decision_key: String(key), provider_key: chosen.provider, model_key: chosen.model,
+ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });
+ return { incumbent: chosenId, chosen: chosenId, switched: true, initial: true, streak: 0, decisions: decisionsNow() };
+ }
+ const incumbentId = idOf(row.provider_key, row.model_key || '');
+ if (!inPool.has(incumbentId)) {
+ const chosen = split(chosenId);
+ await upsert.run({ decision_key: String(key), provider_key: chosen.provider, model_key: chosen.model,
+ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });
+ return { incumbent: chosenId, chosen: chosenId, switched: true, initial: false, failover: true, streak: 0, decisions: decisionsNow() };
+ }
+ if (chosenId === incumbentId) {
+ await upsert.run({ decision_key: String(key), ...row, provider_key: row.provider_key, model_key: row.model_key,
+ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });
+ return { incumbent: incumbentId, chosen: chosenId, switched: false, initial: false, streak: 0, decisions: decisionsNow() };
+ }
+ const same = row.challenger_provider === split(chosenId).provider && (row.challenger_model || '') === split(chosenId).model;
+ const streak = same ? Number(row.challenger_streak || 0) + 1 : 1;
+ if (streak >= decisionsNow()) {
+ const chosen = split(chosenId);
+ await upsert.run({ decision_key: String(key), provider_key: chosen.provider, model_key: chosen.model,
+ challenger_provider: null, challenger_model: null, challenger_streak: 0, updated_at: stamp() });
+ return { incumbent: chosenId, chosen: chosenId, switched: true, initial: false, streak: 0, decisions: decisionsNow() };
+ }
+ const challenger = split(chosenId);
+ await upsert.run({ decision_key: String(key), provider_key: row.provider_key, model_key: row.model_key,
+ challenger_provider: challenger.provider, challenger_model: challenger.model, challenger_streak: streak, updated_at: stamp() });
+ return { incumbent: incumbentId, chosen: incumbentId, switched: false, initial: false, streak, decisions: decisionsNow() };
+ }
+
+ async function get(key) {
+ const row = await select.get(String(key));
+ if (!row || !row.provider_key) return null;
+ return { provider: row.provider_key, model: row.model_key || '', id: idOf(row.provider_key, row.model_key || ''), streak: Number(row.challenger_streak) || 0 };
+ }
+
+ return { apply, get, decisions: decisionsNow };
+}
+
+function createRoutingEvidence({ db, config = {}, clock = { now: () => Date.now() }, metrics, bench = null, resolveOffer, random = Math.random, log = console } = {}) {
+ // D11 owner settings: the feature flag and its parameters are read live, so an ai.exchange change applies from
+ // the next decision (the pool gates record/choose on enabled()).
+ let minSamples, alpha, floor, prior, affinityGain, minGain, decisions, share, expectedOutput;
+ function refreshCfg() {
+ const cfg = config.routingEvidence || config || {};
+ minSamples = Math.max(1, Number(cfg.minSamples) || 20);
+ alpha = cfg.alpha;
+ floor = Math.max(0.01, Math.min(1, Number(cfg.successFloor) || 0.1));
+ prior = Math.max(0.01, Math.min(1, Number(cfg.prior) || 0.9));
+ affinityGain = Math.max(0, Math.min(1, Number(cfg.affinityGain) || 0.05));
+ minGain = Math.max(0, Math.min(1, cfg.minGain != null ? Number(cfg.minGain) : Number(config.placement && config.placement.minGain) || 0.15));
+ decisions = Math.max(1, Number(cfg.hysteresisDecisions) || 3);
+ share = Math.max(0, Math.min(1, Number(cfg.explorationShare) || 0));
+ expectedOutput = Math.max(1, Number(cfg.expectedOutputTokens) || 512);
+ }
+ refreshCfg();
+ const incumbents = createIncumbents({ db, clock, decisions: () => decisions });
+ const store = metrics || require('./metrics').createProviderMetrics({ db, clock, alpha: () => alpha });
+
+ async function record(attempt) { refreshCfg(); return await store.record(attempt); }
+
+ function promptTokensOf(req) {
+ const tokens = tokensOf(leadingText(req || {}));
+ return Math.max(MIN_PROMPT_TOKENS, tokens);
+ }
+
+ function outputTokensOf(req) {
+ const wanted = Number(req && req.maxTokens);
+ return Math.max(1, Math.floor(Number.isFinite(wanted) && wanted > 0 ? wanted : expectedOutput));
+ }
+
+ function optOut(ctx = {}, prefs = {}) {
+ return Boolean(ctx.exact || ctx.credential || ctx.routePin || ctx.exploration === false
+ || (prefs && (prefs.only_my_keys === true || prefs.exploration === false))
+ || (Array.isArray(ctx.selfCredentials) && ctx.selfCredentials.length));
+ }
+
+ /**
+ * Prepare one candidate's planner inputs from the retail card, the measured row and Bench's exact-id
+ * evidence. `resolveOffer` is the pool's own catalogue lookup, so no second catalogue is read here.
+ */
+ async function prepare(candidate, { promptTokens, outputTokens, metricsById = null }) {
+ refreshCfg();
+ const offer = await resolveOffer(candidate);
+ if (!offer) return null;
+ const metric = (metricsById ? metricsById.get(offer.id) : (await store.all()).get(offer.id)) || null;
+ const benchRow = offer.exactId && bench ? await bench.get(offer.exactId) : { status: 'absent', samples: 0, exact_id: offer.exactId || null };
+ const evidence = combineEvidence({ metric, bench: benchRow, minSamples, prior });
+ const ttft = measuredTtft({ metric, bench: benchRow, minSamples });
+ const decode = measuredDecode({ metric, bench: benchRow, minSamples });
+ const cache = measuredCache({ metric, minSamples });
+ const rates = nanoRatesOf({ free: offer.free, retail: offer.retail, cards: offer.cards });
+ if (!rates) return { ...offer, metric, bench: benchRow, evidence, ttft, decode, cache, rates: null, priceNano: null, costPerSuccess: null };
+ const priceNano = priceAfterCacheNano({ promptTokens, outputTokens, cacheHitRatio: cache.ratio, rates });
+ const blended = blendedInputRateNano({ promptTokens, cacheHitRatio: cache.ratio, rates });
+ const plannerRate = { ...rates, input_nano_usd_per_m: blended, cached_read_nano_usd_per_m: '0' };
+ return { ...offer, metric, bench: benchRow, evidence, ttft, decode, cache, rates, plannerRate, priceNano, costPerSuccess: null };
+ }
+
+ function policyOrder(policy, prepared, weights, warmId = null, affinityGain = 0.05) {
+ const key = (p) => (p.costPerSuccess == null ? Infinity : Number(p.costPerSuccess));
+ const maxCost = Math.max(0, ...prepared.map(key).filter(Number.isFinite));
+ const maxTtft = Math.max(0, ...prepared.map((p) => (p.ttft.ms == null ? 0 : p.ttft.ms)));
+ const tps = (p) => (p.decode.tps == null ? -1 : p.decode.tps);
+ const costScore = (p) => (maxCost ? key(p) / maxCost : 0);
+ const ttftScore = (p) => (p.ttft.ms == null ? 1 : p.ttft.ms / (maxTtft || 1));
+ for (const p of prepared) {
+ p.score = policy === 'cheapest' ? key(p)
+ : policy === 'fastest' ? (p.ttft.ms == null ? Infinity : p.ttft.ms)
+ : costScore(p) * weights.cost + ttftScore(p) * weights.latency;
+ }
+ const sorted = [...prepared].sort((a, b) => {
+ if (policy === 'cheapest') {
+ if (key(a) !== key(b)) return key(a) - key(b);
+ } else if (policy === 'fastest') {
+ const ta = a.ttft.ms == null ? Infinity : a.ttft.ms;
+ const tb = b.ttft.ms == null ? Infinity : b.ttft.ms;
+ if (ta !== tb) return ta - tb;
+ if (tps(a) !== tps(b)) return tps(b) - tps(a);
+ } else {
+ if (a.score !== b.score) return a.score - b.score;
+ if (tps(a) !== tps(b)) return tps(b) - tps(a);
+ }
+ if (key(a) !== key(b)) return key(a) - key(b);
+ return String(a.id).localeCompare(String(b.id));
+ });
+ // Cache affinity (L36 Y7): the deployment that served the same prompt prefix last stays first while it
+ // is within the configured margin of the policy's best. It is a preference, not a gate.
+ const warm = warmId ? sorted.find((p) => p.id === warmId) : null;
+ if (warm && sorted[0] !== warm) {
+ const best = sorted[0].score;
+ const gap = warm.score - best;
+ const within = policy === 'fastest'
+ ? Number.isFinite(best) && gap <= best * affinityGain
+ : Number.isFinite(best) && best >= 0 && gap <= Math.max(best * affinityGain, affinityGain * 1e-9);
+ if (within) return { sorted: [warm, ...sorted.filter((p) => p !== warm)], affinity: true };
+ }
+ return { sorted, affinity: false };
+ }
+
+ /**
+ * The router's evidence decision for one alias request. Returns the full order and the explain view.
+ * `list` is the router's already-eligible order; `ctx.exact` is never passed here.
+ */
+ async function choose({ routeKey = null, policy, list = [], req = {}, ctx = {}, warmDeployment = null, preferred = null } = {}) {
+ refreshCfg();
+ const preparedRaw = [];
+ const promptTokens = promptTokensOf(req);
+ const outputTokens = outputTokensOf(req);
+ const metricsById = await store.all();
+ for (const c of list) {
+ if (!c || c.provider === 'stub') continue;
+ const prepared = await prepare(c, { promptTokens, outputTokens, metricsById });
+ if (prepared) preparedRaw.push(prepared);
+ }
+ const prepared = preparedRaw.filter((p) => p.plannerRate && p.priceNano != null);
+ const plannedIds = new Set(prepared.map((p) => p.id));
+ const unplanned = list.filter((c) => c && (c.provider === 'stub' || !plannedIds.has(offerKey(c.provider, c.model || ''))))
+ .map((c) => ({ provider: c.provider, model: c.model || null }));
+ for (const p of prepared) {
+ const eff = p.evidence.samples >= minSamples && p.evidence.p_success != null ? p.evidence.p_success : prior;
+ p.pSuccess = eff;
+ p.costPerSuccess = costPerSuccessNano(p.priceNano, eff, floor);
+ }
+ if (!prepared.length) {
+ return {
+ order: list,
+ view: {
+ policy, objective: OBJECTIVE[policy] || 'balanced', reason: `${labelFor(policy)}: no candidate has a retail rate`,
+ chosen: null, incumbent: null, switched: false, streak: 0, decisions,
+ prompt_tokens: promptTokens, output_tokens: outputTokens, cache_hit_ratio: 0, cache_evidence: 'absent',
+ bench: { status: 'absent', samples: 0, minimum: minSamples },
+ exploration: { share, used: false }, candidates: [],
+ },
+ };
+ }
+ const weights = { cost: Number(config.placement && config.placement.costWeight) || 0.5, latency: Number(config.placement && config.placement.latencyWeight) || 0.5 };
+ const warmId = warmDeployment && warmDeployment.provider ? offerKey(warmDeployment.provider, warmDeployment.model || '') : null;
+ const ordered = policyOrder(policy, prepared, weights, warmId, affinityGain);
+ const sorted = ordered.sorted;
+ const decisionKey = `${String(routeKey || (ctx && ctx.routeKey) || 'alias')}|${policy}`;
+ const inPool = new Set(prepared.map((p) => p.id));
+ const incumbentRow = await incumbents.get(decisionKey);
+ const incumbentId = incumbentRow && inPool.has(incumbentRow.id) ? incumbentRow.id : null;
+
+ // planAi is the one planner: hard gates already passed, its job here is the measured cost per success,
+ // the sparse marking and the hysteresis margin. Cache affinity is a tie-break applied to the order.
+ const objective = OBJECTIVE[policy] || 'balanced';
+ const zero = { input_nano_usd_per_m: '0', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', output_nano_usd_per_m: '0', per_request_nano_usd: '0' };
+ const plannerRates = {};
+ const candidates = prepared.map((p) => {
+ plannerRates[p.id] = policy === 'fastest' ? zero : p.plannerRate;
+ // Fast is a strict p50 TTFT order: encode the decode-tiebreak as a sub-millisecond part so planAi's
+ // latency comparison keeps TTFT primary and decode tokens/s second.
+ const latency = p.ttft.ms == null ? null : (policy === 'fastest' && p.decode.tps != null ? p.ttft.ms + (1 / p.decode.tps) * 1e-6 : p.ttft.ms);
+ return {
+ id: p.id, deployment_id: p.id, model_id: p.exactId || p.id,
+ model: { id: p.exactId || p.id }, provider_access: p.access || null, paid_managed: false,
+ status: 'ready', latency_ms: latency, retail_rate: plannerRates[p.id],
+ };
+ });
+ const evidenceMap = {};
+ for (const p of prepared) if (p.evidence.evidence) evidenceMap[p.id] = p.evidence.evidence;
+ const planned = prepared.length ? placement.planAi(
+ { objective, capabilities: [], paid_managed: false },
+ candidates,
+ { enabled: true, now: clock.now(), current: incumbentId, minGain, usage: { input_tokens: promptTokens, output_tokens: outputTokens },
+ evidence: evidenceMap, rates: plannerRates, pFloor: floor, prior, sparseSampleFloor: minSamples },
+ ) : { chosen: null, explain: { candidates: [] } };
+ let chosenId = planned.chosen || (sorted[0] && sorted[0].id) || null;
+ const decision = await incumbents.apply(decisionKey, chosenId, inPool);
+ const keeperId = decision.incumbent || chosenId;
+ const chosen = prepared.find((p) => p.id === keeperId) || sorted[0] || null;
+
+ // Order: the incumbent first (hysteresis), then the policy order. Exploration may put one non-incumbent
+ // first for exactly one request; it never changes the incumbent or the streak.
+ // Cache affinity can put the warm deployment first even when the incumbent is kept for hysteresis;
+ // it stays within the margin. Otherwise the incumbent comes first.
+ const warmFirst = ordered.affinity ? sorted[0] : null;
+ let order = sorted.map((p) => p.id);
+ if (warmFirst) order = [warmFirst.id, ...order.filter((id) => id !== warmFirst.id)];
+ else if (chosen) order = [chosen.id, ...order.filter((id) => id !== chosen.id)];
+ let exploration = null;
+ if (share > 0 && !optOut(ctx, ctx.selfPreferences) && sorted.length > 1 && random() < share) {
+ const pick = sorted.find((p) => p.id !== keeperId);
+ if (pick) {
+ exploration = { share, provider: pick.provider, model: pick.model };
+ order = [pick.id, ...order.filter((id) => id !== pick.id)];
+ }
+ }
+ // L37: a soft preference still leads, as it does in the existing router; evidence ranks the rest.
+ const preferredOffer = preferred
+ ? prepared.find((p) => (!preferred.provider || p.provider === preferred.provider) && (!preferred.model || p.model === preferred.model))
+ : null;
+ if (preferredOffer) {
+ order = [preferredOffer.id, ...order.filter((id) => id !== preferredOffer.id)];
+ exploration = null;
+ }
+ const orderAsCandidates = order.map((id) => prepared.find((p) => p.id === id)).filter(Boolean).map((p) => ({ provider: p.provider, model: p.model }));
+ const first = orderAsCandidates[0] || null;
+ const candidatesView = sorted.map((p) => {
+ const plannerCand = planned.explain && (planned.explain.candidates || []).find((c) => c.id === p.id);
+ return {
+ provider: p.provider, model: p.model,
+ expected_cost_nano_usd: formatNanoUsd(p.priceNano),
+ price_after_cache_nano_usd: formatNanoUsd(p.priceNano),
+ cost_per_success_nano_usd: p.costPerSuccess == null ? null : formatNanoUsd(p.costPerSuccess),
+ p_success: Number((plannerCand ? plannerCand.p_success : p.pSuccess).toFixed(6)),
+ samples: p.evidence.samples,
+ sparse_evidence: plannerCand ? Boolean(plannerCand.sparse_evidence) : p.evidence.sparse,
+ evidence_source: plannerCand ? plannerCand.evidence_source : p.evidence.source,
+ cache_hit_ratio: p.cache.ratio,
+ cache_evidence: p.cache.source,
+ cache_samples: p.metric ? p.metric.cache_samples : 0,
+ timeout_rate: p.metric ? p.metric.timeout_rate : (p.bench && p.bench.timeout_rate != null ? p.bench.timeout_rate : null),
+ metric_samples: p.metric ? p.metric.sample_count : 0,
+ ttft_ms: p.ttft.ms,
+ ttft_p50_ms: p.ttft.source === 'bench_p50' ? p.ttft.ms : null,
+ ttft_source: p.ttft.source,
+ measured_success_rate: p.metric ? p.metric.success_rate : (p.bench && p.bench.success_rate != null ? p.bench.success_rate : null),
+ decode_tokens_per_s: p.decode.tps,
+ decode_source: p.decode.source,
+ bench: { status: p.bench.status, samples: p.bench.samples || 0, minimum: minSamples },
+ selected: first ? p.id === offerKey(first.provider, first.model || '') : false,
+ };
+ });
+ const actual = first ? prepared.find((p) => p.id === offerKey(first.provider, first.model || '')) : chosen;
+ const chosenBench = actual ? actual.bench : { status: 'absent', samples: 0 };
+ const reason = reasonFor({ policy, chosen, samples: chosen ? chosen.evidence.samples : 0, sparse: chosen ? chosen.evidence.sparse : true });
+ const view = {
+ policy, objective,
+ reason: warmFirst ? `${reason}; cache affinity` : (exploration ? `${reason}; exploring one alternative` : reason),
+ affinity: warmFirst && !preferredOffer ? { provider: warmFirst.provider, model: warmFirst.model, within_margin: true } : null,
+ chosen: first ? offerKey(first.provider, first.model || '') : (chosen ? chosen.id : null), incumbent: decision.incumbent, switched: decision.switched,
+ streak: decision.streak, decisions: decision.decisions,
+ prompt_tokens: promptTokens, output_tokens: outputTokens,
+ cache_hit_ratio: actual ? actual.cache.ratio : 0, cache_evidence: actual ? actual.cache.source : 'absent',
+ bench: { status: chosenBench.status || 'absent', samples: chosenBench.samples || 0, minimum: minSamples },
+ exploration: exploration ? { ...exploration, used: true } : { share, used: false },
+ preferred: preferredOffer ? { provider: preferredOffer.provider, model: preferredOffer.model } : null,
+ candidates: candidatesView,
+ };
+ return { order: [...orderAsCandidates, ...unplanned], view };
+ }
+
+ return { record, choose, prepare, incumbents, store, enabled: () => Boolean(config.routingEvidence && config.routingEvidence.enabled), minSamples, alpha, floor, prior, share, decisions };
+}
+
+module.exports = {
+ createRoutingEvidence, createIncumbents, nanoRatesOf, priceAfterCacheNano, blendedInputRateNano,
+ combineEvidence, measuredTtft, measuredDecode, measuredCache, reasonFor,
+ POLICIES, OBJECTIVE, MIN_PROMPT_TOKENS,
+};
@@ -33,7 +33,11 @@ const { createHttpSeamProvider } = require('./http');
@@ -33,7 +33,11 @@ const { createHttpSeamProvider } = require('./http');
const { createWhisperProvider } = require('./whisper');
const { createWhisperProvider } = require('./whisper');
const rates = require('./rate-cards');
const rates = require('./rate-cards');
const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');
const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');
+const modelCatalog = require('./model-catalog');
const { createWarmCache } = require('./warm-cache');
const { createWarmCache } = require('./warm-cache');
+const { createProviderMetrics } = require('./metrics');
+const { createVariantEvidence } = require('./bench-variant');
+const { createRoutingEvidence } = require('./evidence');
/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */
/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */
function callerFault(err) {
function callerFault(err) {
@@ -130,13 +134,42 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
@@ -130,13 +134,42 @@ function selfModelFor(cred, operation, req, ctx, prefs) {
return { model, kind, why };
return { model, kind, why };
}
}
-function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {
+function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null, variantEvidence = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
// A person's own machine as their model (credential provider node, server/machines.js).
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
const cache = new Map(); // key -> { stamp, adapter }
const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)
const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)
// L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).
// L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).
const warm = createWarmCache({ now: () => clock.now() });
const warm = createWarmCache({ now: () => clock.now() });
+ // AI Exchange P7: measured per-deployment evidence for alias routing. The feature flag is read live (D11
+ // owner settings), so turning routing_evidence on or off in the config store needs no restart; while it is off
+ // no metrics are written and no order changes.
+ const routingEvidence = createRoutingEvidence({
+ db, config, clock, log,
+ metrics: createProviderMetrics({ db, clock, alpha: () => (config.routingEvidence && config.routingEvidence.alpha) }),
+ bench: variantEvidence || createVariantEvidence({ config, fetchImpl, clock, log }),
+ resolveOffer: async (c) => {
+ const entry = await adapter(c.provider);
+ const p = entry && entry.record;
+ if (!p) return null;
+ const model = c.model || p.default_model || null;
+ const row = model ? await registry.getModel(p.key, model) : null;
+ const artifact = row ? modelCatalog.artifactOf(row, p) : null;
+ return {
+ id: offerId(p.key, model), provider: p.key, model,
+ exactId: artifact ? artifact.id : null,
+ access: modelCatalog.accessOf(p),
+ free: isFreeProvider(p),
+ retail: row ? modelCatalog.retailRate(row) : null,
+ cards: isFreeProvider(p) ? null : await rateCardsFor(p.key, model),
+ };
+ },
+ });
+ /** Evidence writes must never fail a run: a metrics problem is logged and routing continues. */
+ async function recordEvidence(attempt) {
+ if (!routingEvidence || !routingEvidence.enabled()) return;
+ try { await routingEvidence.record(attempt); } catch (err) { log.warn(`[routing-evidence] ${String(err && err.message || err).slice(0, 200)}`); }
+ }
function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }
function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }
@@ -263,6 +296,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -263,6 +296,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (ctx.freeOnly && !isFreeProvider(p)) return false;
if (ctx.freeOnly && !isFreeProvider(p)) return false;
return true;
return true;
}
}
+ /**
+ * AI Exchange D3: may the network route paid, managed work to this offer? With the catalog flag on, only an
+ * owned deployment or an authorized wholesale agreement may be resold; UNVERIFIED (the default), RESTRICTED and
+ * CUSTOMER_BYOK offers stay out of the pool. A person's own key and their own machine never pass through here.
+ */
+ function accessOffered(p) {
+ if (!(config.modelCatalog && config.modelCatalog.enabled)) return true;
+ return Boolean(p) && modelCatalog.managedAccessAllowed(p);
+ }
/** Operations served only by registered models of one type (never a provider's default chat model). */
/** Operations served only by registered models of one type (never a provider's default chat model). */
const MODEL_TYPE = { embed: 'embedding', transcribe: 'stt', image: 'image' };
const MODEL_TYPE = { embed: 'embedding', transcribe: 'stt', image: 'image' };
const offerId = (provider, model) => (model ? `${provider}:${model}` : provider);
const offerId = (provider, model) => (model ? `${provider}:${model}` : provider);
@@ -294,6 +336,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -294,6 +336,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const rateCards = [];
const rateCards = [];
const cardsByProvider = {};
const cardsByProvider = {};
const seen = new Set();
const seen = new Set();
+ const accessExcluded = [];
+ // A dynamic policy alias (D3) looks at every active chat/vision model the provider registered, not only its
+ // default; the placement and the task router then choose among them. Auto keeps today's one-offer-per-provider.
+ const expandModels = Boolean(ctx.aliasPolicy && ctx.aliasPolicy !== 'auto') && !(route.constraints && route.constraints.model);
const addOffer = async (p, model, caps) => {
const addOffer = async (p, model, caps) => {
const id = offerId(p.key, model);
const id = offerId(p.key, model);
if (seen.has(id)) return;
if (seen.has(id)) return;
@@ -327,12 +373,21 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -327,12 +373,21 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!appOffered(ctx, p)) continue;
if (!appOffered(ctx, p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
+ if (!accessOffered(p)) {
+ accessExcluded.push({ provider: p.key, model: (route.constraints && route.constraints.model) || p.default_model || null, eligible: false,
+ excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });
+ continue;
+ }
if (MODEL_TYPE[operation]) {
if (MODEL_TYPE[operation]) {
// A chat model advertised by an OpenAI-shaped provider is not an embedding, STT or image model.
// A chat model advertised by an OpenAI-shaped provider is not an embedding, STT or image model.
const type = MODEL_TYPE[operation];
const type = MODEL_TYPE[operation];
for (const m of await registry.listModels({ provider: p.key })) {
for (const m of await registry.listModels({ provider: p.key })) {
if (m.type === type && m.status === 'active') await addOffer(p, m.model_key, caps);
if (m.type === type && m.status === 'active') await addOffer(p, m.model_key, caps);
}
}
+ } else if (expandModels) {
+ const models = (await registry.listModels({ provider: p.key })).filter((m) => m.status === 'active' && (m.type === 'chat' || m.type === 'vision'));
+ if (models.length) for (const m of models) await addOffer(p, m.model_key, caps);
+ else await addOffer(p, p.default_model || null, caps);
} else {
} else {
await addOffer(p, (route.constraints && route.constraints.model) || p.default_model || null, caps);
await addOffer(p, (route.constraints && route.constraints.model) || p.default_model || null, caps);
}
}
@@ -346,6 +401,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -346,6 +401,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (!appOffered(ctx, p)) continue;
if (!appOffered(ctx, p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
+ if (!accessOffered(p)) {
+ accessExcluded.push({ provider: p.key, model: pin.model || p.default_model || null, eligible: false,
+ excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });
+ continue;
+ }
const declared = await offeredCapabilities(p);
const declared = await offeredCapabilities(p);
const caps = route.capability && !declared.includes(route.capability) ? [...declared, route.capability] : declared;
const caps = route.capability && !declared.includes(route.capability) ? [...declared, route.capability] : declared;
await addOffer(p, pin.model || p.default_model || null, caps);
await addOffer(p, pin.model || p.default_model || null, caps);
@@ -383,6 +443,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -383,6 +443,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
excluded_reason: x.excluded_because || null, cost: x.estimated_cost_usd == null ? null : x.estimated_cost_usd, latency: x.estimated_latency_ms == null ? null : x.estimated_latency_ms };
excluded_reason: x.excluded_because || null, cost: x.estimated_cost_usd == null ? null : x.estimated_cost_usd, latency: x.estimated_latency_ms == null ? null : x.estimated_latency_ms };
}),
}),
};
};
+ // An offer the commercial gate kept out is named with its reason, never silently missing from the explain.
+ for (const x of accessExcluded) if (!explain.candidates.some((c) => c.provider === x.provider && c.model === x.model)) explain.candidates.push(x);
const order = [];
const order = [];
if (result.selected) order.push(result.selected);
if (result.selected) order.push(result.selected);
const rest = result.candidates.filter(x => x.id !== result.selected).sort((a, b) => {
const rest = result.candidates.filter(x => x.id !== result.selected).sort((a, b) => {
@@ -408,11 +470,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -408,11 +470,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const p = entry && entry.record;
const p = entry && entry.record;
const why = await skipReason(p, entry && entry.adapter, features);
const why = await skipReason(p, entry && entry.adapter, features);
const offered = appOffered(ctx, p);
const offered = appOffered(ctx, p);
+ const access = accessOffered(p);
+ const excluded = !offered ? (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only')
+ : !access ? `provider_access ${modelCatalog.accessOf(p)}` : why;
explain.candidates.push({
explain.candidates.push({
- provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: offered && !why,
- excluded_reason: offered ? why : (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only'), cost: null, latency: null,
+ provider: c.provider, model: c.model || (p && p.default_model) || null, eligible: offered && access && !why,
+ excluded_reason: excluded, cost: null, latency: null,
});
});
- if (offered) order.push({ provider: c.provider, model: c.model || null });
+ if (offered && access) order.push({ provider: c.provider, model: c.model || null });
}
}
return { order, explain };
return { order, explain };
}
}
@@ -504,7 +569,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -504,7 +569,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
* then fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength
* then fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength
* tier first. The explain says what the task was and why every candidate is there.
* tier first. The explain says what the task was and why every candidate is there.
*/
*/
- async function rankCandidates({ list, explain, task, route = null, warmProvider = null, preferred = null, ceiling = null, tierFirst = null, keepOrder = false }) {
+ async function rankCandidates({ list, explain, task, route = null, warmProvider = null, preferred = null, ceiling = null, tierFirst = null, keepOrder = false, policy = null }) {
const byKey = new Map((explain.candidates || []).map((c) => [c.provider + ':' + (c.model || ''), c]));
const byKey = new Map((explain.candidates || []).map((c) => [c.provider + ':' + (c.model || ''), c]));
const authority = route && route.constraints ? (route.constraints.authority || null) : null;
const authority = route && route.constraints ? (route.constraints.authority || null) : null;
const entries = [];
const entries = [];
@@ -533,7 +598,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -533,7 +598,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
const ranked = keepOrder
const ranked = keepOrder
? { order: entries.map((e) => e.candidate), ranked: entries.slice(0, 5) }
? { order: entries.map((e) => e.candidate), ranked: entries.slice(0, 5) }
- : rank(entries, { task, warm: warmProvider, preferred, authority, tierFirst });
+ : rank(entries, { task, warm: warmProvider, preferred, authority, tierFirst, policy });
const order = keepOrder ? ranked.order : [...ranked.order.map((e) => ({ provider: e.provider, model: e.model })), ...rest];
const order = keepOrder ? ranked.order : [...ranked.order.map((e) => ({ provider: e.provider, model: e.model })), ...rest];
if (stub) order.push(stub);
if (stub) order.push(stub);
const view = ranked.ranked.map((e) => ({ provider: e.provider, model: e.model, why: reasonFor(e, task), cost: Number.isFinite(e.price) ? e.price : null, tier: e.tier }));
const view = ranked.ranked.map((e) => ({ provider: e.provider, model: e.model, why: reasonFor(e, task), cost: Number.isFinite(e.price) ? e.price : null, tier: e.tier }));
@@ -580,6 +645,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -580,6 +645,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
];
];
const tools = completing ? machineTools(o.tools) : null;
const tools = completing ? machineTools(o.tools) : null;
const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
+ // A person's own machine is their own capacity: CUSTOMER_BYOK, never a managed resale.
+ const ownFacts = { resolved_model: `${cred.provider}/${model}`, revision: null, deployment: cred.providerKey, provider_access: 'CUSTOMER_BYOK', rate_version: null, policy: 'byok', fallback_reason: null };
+ if (typeof ctx.onRoute === 'function') await ctx.onRoute(ownFacts);
const t0 = Date.now();
const t0 = Date.now();
try {
try {
const result = await machines.chat({ machine: cred.machine, subject: cred.subject, model, messages, maxTokens: req.maxTokens, temperature: req.temperature, signal: ctx.signal, timeoutMs: Math.max(5000, Math.min(req.timeoutMs || 120000, 300000)),
const result = await machines.chat({ machine: cred.machine, subject: cred.subject, model, messages, maxTokens: req.maxTokens, temperature: req.temperature, signal: ctx.signal, timeoutMs: Math.max(5000, Math.min(req.timeoutMs || 120000, 300000)),
@@ -596,7 +664,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -596,7 +664,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
paid_by: 'your machine',
paid_by: 'your machine',
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),
candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),
- attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }] } };
+ attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }],
+ openvibe: ownFacts } };
} catch (err) {
} catch (err) {
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
@@ -642,12 +711,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -642,12 +711,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
attempts: [{ key: paidBy, provider: cred.provider, model, why: 'your own key', status: 'ok', error: null }],
attempts: [{ key: paidBy, provider: cred.provider, model, why: 'your own key', status: 'ok', error: null }],
};
};
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
+ // The person's own key (BYOK) is never a managed resale offer: the disclosure says CUSTOMER_BYOK.
+ const ownFacts = { resolved_model: `${cred.provider}/${model}`, revision: null, deployment: cred.providerKey, provider_access: 'CUSTOMER_BYOK', rate_version: null, policy: 'byok', fallback_reason: null };
const call = messaging ? a.messages : completing ? a.complete : a.chat;
const call = messaging ? a.messages : completing ? a.complete : a.chat;
let streamed = false;
let streamed = false;
const onDelta = (completing || messaging) && typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;
const onDelta = (completing || messaging) && typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;
let lastErr = null;
let lastErr = null;
for (let attempt = 0; attempt <= config.providerRetries; attempt++) {
for (let attempt = 0; attempt <= config.providerRetries; attempt++) {
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
+ if (typeof ctx.onRoute === 'function') await ctx.onRoute(ownFacts);
const t0 = Date.now();
const t0 = Date.now();
try {
try {
const result = await call({ ...req, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });
const result = await call({ ...req, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });
@@ -659,7 +731,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -659,7 +731,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });
tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });
}
}
return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [],
return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [],
- startedAt: t0, explain: { ...explain, selected: `${cred.provider}/${result.model || model}` } };
+ startedAt: t0, explain: { ...explain, selected: `${cred.provider}/${result.model || model}`, openvibe: ownFacts } };
} catch (err) {
} catch (err) {
lastErr = err;
lastErr = err;
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
@@ -811,7 +883,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -811,7 +883,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
}
}
- let { order: list, explain } = await candidates(route, features, operation, ctx);
+ // CHECKPOINT D3: an exact id runs only its own artifact. The candidates are the catalog's deployments of
+ // that id, filtered first by commercial access and what the request needs; no other model is ever added.
+ const exact = ctx.exact || null;
+ const policy = ctx.policy || (exact ? 'exact' : null);
+ let list;
+ let explain;
+ if (exact) {
+ const candidatesView = [];
+ const usable = [];
+ let payerSkipped = false;
+ for (const offer of exact.offers || []) {
+ const p = await registry.getProvider(offer.provider);
+ const entry = p ? await adapter(offer.provider) : null;
+ const row = offer.model ? await registry.getModel(offer.provider, offer.model) : null;
+ const missing = hardNeeds(capabilitiesOf(row, p), task);
+ const why = [];
+ const card = row ? modelCatalog.retailRate(row) : null;
+ if (!p) why.push('unknown_provider');
+ else if (ctx.creditsRequired && !isFreeProvider(p) && card && card.version) { why.push('payer_required'); payerSkipped = true; }
+ else {
+ if (!accessOffered(p)) why.push(`provider_access ${modelCatalog.accessOf(p)}`);
+ const skip = await skipReason(p, entry && entry.adapter, features);
+ if (skip) why.push(skip);
+ }
+ if (missing.length) why.push(...missing);
+ const reason = why.length ? [...new Set(why)].join(',') : null;
+ candidatesView.push({ provider: offer.provider, model: offer.model, eligible: !reason, excluded_reason: reason, cost: null, latency: null });
+ if (!reason) usable.push({ provider: offer.provider, model: offer.model });
+ }
+ explain = { objective: 'exact', policy: 'exact', reasons: [`exact model ${exact.id}: only its own deployment may answer`], selected: null, candidates: candidatesView };
+ if (!usable.length) {
+ if (payerSkipped) throw new AiError(402, 'billing.payer_required', `the exact model ${exact.id} needs a Balance payer (a person, or an app with its project); it was not run`, { explain });
+ throw new AiError(400, 'ai.model_unavailable', `the artifact ${exact.id} cannot run this request`, { explain });
+ }
+ list = usable;
+ } else {
+ ({ order: list, explain } = await candidates(route, features, operation, ctx));
+ if (policy) explain.policy = policy;
+ }
// L36 Y3: free_first puts the free tiers ahead of metered ones for this person's fallback. Own-key attempts above
// L36 Y3: free_first puts the free tiers ahead of metered ones for this person's fallback. Own-key attempts above
// are already free to them, so this only reorders the network.
// are already free to them, so this only reorders the network.
if (prefs.free_first && ctx.selfSubject) {
if (prefs.free_first && ctx.selfSubject) {
@@ -825,18 +935,23 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -825,18 +935,23 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
// L36 Y7: pick for the task. The hard needs first (a model that cannot do the job is never tried), then
// L36 Y7: pick for the task. The hard needs first (a model that cannot do the job is never tried), then
// fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength tier.
// fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength tier.
- const warmProvider = warm.get(warmSubject(ctx, route), leadingText(req));
+ const warmDeployment = warm.getDeployment(warmSubject(ctx, route), leadingText(req));
+ const warmProvider = warmDeployment ? warmDeployment.provider : null;
// L36 Y3: with free_first on, the free tiers lead (the price decides); with it off, the strength tier
// L36 Y3: with free_first on, the free tiers lead (the price decides); with it off, the strength tier
// leads and the free tiers are the failover, not the first choice.
// leads and the free tiers are the failover, not the first choice.
const freeFirst = ctx.selfSubject ? prefs.free_first !== false : true;
const freeFirst = ctx.selfSubject ? prefs.free_first !== false : true;
- const ranked = await rankCandidates({
- list, explain, task, route, warmProvider,
- preferred: preferredModelFor(prefs, operation, req, ctx),
- ceiling: prefs.price_ceiling_usd_per_mtok,
- tierFirst: task.weight === 'heavy' || !freeFirst,
- // A pinned route keeps its own order: the pin is the admin's decision, not the router's.
- keepOrder: !(route.capability && operation),
- });
+ const ranked = exact
+ // The exact id's own deployment order is already the only order there is: never re-rank in another model.
+ ? { order: list, ranked: [], reasons: new Map(), keepOrder: true }
+ : await rankCandidates({
+ list, explain, task, route, warmProvider,
+ preferred: preferredModelFor(prefs, operation, req, ctx),
+ ceiling: prefs.price_ceiling_usd_per_mtok,
+ tierFirst: task.weight === 'heavy' || !freeFirst,
+ policy,
+ // A pinned route keeps its own order: the pin is the admin's decision, not the router's.
+ keepOrder: !(route.capability && operation),
+ });
list = ranked.order;
list = ranked.order;
explain.task = task;
explain.task = task;
explain.ranked = ranked.ranked;
explain.ranked = ranked.ranked;
@@ -850,7 +965,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -850,7 +965,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (check.missing.length) preferState.why = check.missing.includes('no_vision') ? 'the model you named cannot see' : 'the model you named cannot do this job';
if (check.missing.length) preferState.why = check.missing.includes('no_vision') ? 'the model you named cannot see' : 'the model you named cannot do this job';
}
}
if (ranked.keepOrder) {
if (ranked.keepOrder) {
- preferState.why = 'this route is pinned; the pin decides';
+ preferState.why = exact ? 'the exact model is the pin; nothing else may answer' : 'this route is pinned; the pin decides';
} else {
} else {
const at = list.findIndex((c) => preferMatches(c.provider, c.model, prefer));
const at = list.findIndex((c) => preferMatches(c.provider, c.model, prefer));
if (at > 0) {
if (at > 0) {
@@ -862,6 +977,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -862,6 +977,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
explain.prefer = preferState;
explain.prefer = preferState;
}
}
+ // AI Exchange P7: an alias route (economy/fast/balanced/auto) is ranked by measured evidence when
+ // AI_ROUTING_EVIDENCE is on. planAi (openvibe-sdk/placement) is the planner: this only prepares its
+ // inputs, keeps the incumbent for N decisions and explains the answer. Private never leaves local
+ // capacity, and an exact id never reaches here.
+ if (routingEvidence && routingEvidence.enabled() && ctx.aliasPolicy && ctx.aliasPolicy !== 'private' && !exact) {
+ const picked = await routingEvidence.choose({ routeKey: route.key, policy: ctx.aliasPolicy, list, req, ctx, warmDeployment, preferred: prefer });
+ if (picked && picked.view) {
+ list = picked.order;
+ explain.evidence = picked.view;
+ explain.policy = ctx.aliasPolicy;
+ explain.reasons = [picked.view.reason, ...(explain.reasons || [])];
+ }
+ }
+ const evidenceReason = explain.evidence ? explain.evidence.reason : null;
const attempts = [...selfAttempts];
const attempts = [...selfAttempts];
explain.paid_by = 'openvibe';
explain.paid_by = 'openvibe';
explain.attempts = attempts;
explain.attempts = attempts;
@@ -869,7 +998,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -869,7 +998,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// A first-party service's generate run may name one offer from GET /api/v1/routes. The run then
// A first-party service's generate run may name one offer from GET /api/v1/routes. The run then
// tries that offer alone: no fallback to another model, so the caller gets the model it asked for
// tries that offer alone: no fallback to another model, so the caller gets the model it asked for
// or an explicit failure. server/runs.js validates the pin against this same offer list first.
// or an explicit failure. server/runs.js validates the pin against this same offer list first.
- if (ctx.routePin && operation === 'generate') {
+ if (ctx.routePin && operation === 'generate' && !exact) {
const pin = ctx.routePin;
const pin = ctx.routePin;
const chosen = list.find((c) => c.provider === pin.provider && (pin.model == null || c.model === pin.model));
const chosen = list.find((c) => c.provider === pin.provider && (pin.model == null || c.model === pin.model));
if (!chosen) {
if (!chosen) {
@@ -881,6 +1010,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -881,6 +1010,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// L36 Y7: the fallback chain stops after three providers.
// L36 Y7: the fallback chain stops after three providers.
const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;
const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;
let attemptsMade = 0;
let attemptsMade = 0;
+ candidate:
for (let i = 0; i < list.length; i++) {
for (let i = 0; i < list.length; i++) {
const c = list[i];
const c = list[i];
const entry = await adapter(c.provider);
const entry = await adapter(c.provider);
@@ -904,6 +1034,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -904,6 +1034,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
continue;
continue;
}
}
}
}
+ // AI Exchange F: a run with no payer (no person and no app project) must not be served a paid managed
+ // offer that carries a versioned retail card: nobody could be charged for it. Offers without a card keep
+ // today's behaviour, as do free and prepaid ones.
+ if (ctx.creditsRequired && p && !isFreeProvider(p)) {
+ const row = model ? await registry.getModel(p.key, model) : null;
+ const card = row ? modelCatalog.retailRate(row) : null;
+ if (card && card.version) {
+ statFor(c.provider).skipped++;
+ tried.push({ provider: c.provider, skipped: 'payer_required' });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: 'payer_required' });
+ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'payer_required' });
+ continue;
+ }
+ }
const why = await skipReason(p, a, features);
const why = await skipReason(p, a, features);
if (why) {
if (why) {
statFor(c.provider).skipped++;
statFor(c.provider).skipped++;
@@ -929,16 +1073,40 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -929,16 +1073,40 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const started = clock.now();
const started = clock.now();
const t0 = Date.now();
const t0 = Date.now();
attemptStart = t0;
attemptStart = t0;
+ // The disclosure is known before the call starts, so a streamed answer can carry it as its first
+ // SSE comment without waiting for the run to finish (server/api/{openai,messages}.js).
+ const attemptFacts = await modelCatalog.facts({
+ registry, providerRecord: p, model,
+ policy: policy || (exact ? 'exact' : null),
+ policyReason: evidenceReason,
+ fallbackReason: fallback ? `the first eligible offer did not answer; a later offer on this same route answered${evidenceReason ? ` (${evidenceReason})` : ''}` : null,
+ });
+ // The hold needs the offer's retail card, whether it is prepaid, and the real output cap.
+ const attemptRow = p && model ? await registry.getModel(p.key, model) : null;
+ const attemptMeta = { provider: p ? p.key : null, model, maxTokens: req.maxTokens, paid: !isFreeProvider(p), retail: modelCatalog.retailRate(attemptRow) };
+ const routed = typeof ctx.onRoute === 'function' ? await ctx.onRoute(attemptFacts, attemptMeta) : null;
+ if (routed && routed.skip) {
+ statFor(c.provider).skipped++;
+ tried.push({ provider: c.provider, skipped: routed.skip });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: routed.skip });
+ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: routed.skip });
+ continue candidate;
+ }
+ // max_cost_usd, when it binds, comes back as the affordable output cap for this attempt.
+ const callMax = routed && Number.isSafeInteger(routed.maxTokens) && routed.maxTokens >= 1 && routed.maxTokens !== req.maxTokens ? routed.maxTokens : null;
+ const callReq = callMax == null ? req : { ...req, maxTokens: callMax };
try {
try {
const fn = a[operation];
const fn = a[operation];
if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });
if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });
statFor(c.provider).calls += p.kind === 'stub' ? 0 : 1; // stub counts its own calls
statFor(c.provider).calls += p.kind === 'stub' ? 0 : 1; // stub counts its own calls
- const result = await fn({ ...req, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });
+ const result = await fn({ ...callReq, ...(onDelta ? { onDelta } : {}), model, signal: ctx.signal, timeoutMs });
if (operation === 'embed' && (!Array.isArray(result.vectors) || result.vectors.length !== req.input.length
if (operation === 'embed' && (!Array.isArray(result.vectors) || result.vectors.length !== req.input.length
|| result.vectors.some(v => !Array.isArray(v) || !v.length || v.some(n => typeof n !== 'number' || !Number.isFinite(n)))))
|| result.vectors.some(v => !Array.isArray(v) || !v.length || v.some(n => typeof n !== 'number' || !Number.isFinite(n)))))
throw new ProviderError('embedding response has invalid vectors', { code: 'provider.output' });
throw new ProviderError('embedding response has invalid vectors', { code: 'provider.output' });
const latency = Date.now() - t0;
const latency = Date.now() - t0;
const usage = result.usage || { input: 0, output: 0, cached: 0 };
const usage = result.usage || { input: 0, output: 0, cached: 0 };
+ await recordEvidence({ provider: c.provider, model, ok: true, timeout: false, ttftMs, latencyMs: latency,
+ outputTokens: usage.output || 0, inputTokens: usage.input || 0, cachedTokens: usage.cached || 0 });
// An image is metered per image (usage.output = images), and no rate card prices one yet (an owner/T5
// An image is metered per image (usage.output = images), and no rate card prices one yet (an owner/T5
// item): its cost is 0 rather than the image count read as output tokens on the token cards.
// item): its cost is 0 rather than the image count read as output tokens on the token cards.
const cost = operation === 'image' ? 0 : await costOf(c.provider, result.model || model, usage);
const cost = operation === 'image' ? 0 : await costOf(c.provider, result.model || model, usage);
@@ -961,8 +1129,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -961,8 +1129,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
preferState.why = 'the ' + c.provider + ' offer answered, as you asked';
preferState.why = 'the ' + c.provider + ' offer answered, as you asked';
}
}
attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: chosen || (fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route'), status: 'ok', error: null });
attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: chosen || (fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route'), status: 'ok', error: null });
- warm.remember(warmSubject(ctx, route), leadingText(req), c.provider);
+ warm.remember(warmSubject(ctx, route), leadingText(req), c.provider, model);
explain.attempts = attempts;
explain.attempts = attempts;
+ explain.openvibe = attemptFacts;
return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, ttftMs, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, ttftMs, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
} catch (err) {
} catch (err) {
lastErr = err;
lastErr = err;
@@ -970,6 +1139,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -970,6 +1139,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'cancelled', latency_ms: Date.now() - t0, error: 'cancelled' });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'cancelled', latency_ms: Date.now() - t0, error: 'cancelled' });
throw new AiError(409, 'run.cancelled', 'run was cancelled');
throw new AiError(409, 'run.cancelled', 'run was cancelled');
}
}
+ await recordEvidence({ provider: c.provider, model, ok: false, timeout: Boolean(err && err.code === 'provider.timeout'), latencyMs: Date.now() - t0 });
statFor(c.provider).failures++;
statFor(c.provider).failures++;
if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${String(err && err.message || err).slice(0, 200)}`, { tried: [...tried, { provider: c.provider, error: String(err && err.message || err).slice(0, 200) }], explain });
if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${String(err && err.message || err).slice(0, 200)}`, { tried: [...tried, { provider: c.provider, error: String(err && err.message || err).slice(0, 200) }], explain });
@@ -1000,7 +1170,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1000,7 +1170,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });
throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried, explain });
}
}
- return { adapter, health, resetHealth, execute, executeWithCredential, offers, benchView, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates, machines };
+ return { adapter, health, resetHealth, execute, executeWithCredential, offers, benchView, priceFor, costOf, rateCardsFor, providerStates, stats, statFor, candidates, machines, routingEvidence };
}
}
module.exports = { createProviderPool, machineMessages, machineTools };
module.exports = { createProviderPool, machineMessages, machineTools };
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+/**
+ * AI Exchange P7: per-deployment observed performance (PLAN §D "ObservedPerformance", §E), behind
+ * AI_ROUTING_EVIDENCE.
+ *
+ * Every attempt the router already makes updates one EWMA row per deployment (provider:model): TTFT, decode
+ * tokens/s, success rate, timeout rate and the cache-hit ratio. The row is written to provider_metrics
+ * (migrations/0026) so a restart keeps the measurements; the counts are kept apart, so a streamed answer that
+ * has a TTFT and a non-streamed one that has none do not dilute each other.
+ *
+ * The arithmetic is deliberately simple and honest: alpha is the configured weight of the new observation
+ * (default 0.2). A metric with no observations stays null and is reported as unmeasured, never as zero.
+ */
+const { iso } = require('../util');
+
+/** EWMA: alpha is the new observation's weight; the first observation is the value itself. */
+function ewma(prev, value, alpha) {
+ if (!Number.isFinite(Number(value))) return prev == null ? null : Number(prev);
+ if (prev == null || !Number.isFinite(Number(prev))) return Number(value);
+ const a = Math.max(0, Math.min(1, Number(alpha)));
+ return a * Number(value) + (1 - a) * Number(prev);
+}
+
+const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));
+const offerKey = (provider, model) => `${provider}:${model || ''}`;
+
+/** Decode tokens per second: output tokens over the time between the first token and the answer's end. */
+function decodeTps({ ttftMs, latencyMs, outputTokens }) {
+ if (ttftMs == null || latencyMs == null || outputTokens == null) return null;
+ const ttft = Number(ttftMs);
+ const latency = Number(latencyMs);
+ const output = Number(outputTokens);
+ if (!Number.isFinite(ttft) || !Number.isFinite(latency) || !Number.isFinite(output)) return null;
+ const decodeMs = latency - ttft;
+ if (decodeMs <= 0 || output <= 0) return null;
+ return output / (decodeMs / 1000);
+}
+
+function createProviderMetrics({ db, clock = { now: () => Date.now() }, alpha = 0.2 } = {}) {
+ // alpha may be a function: the AI Exchange setting is read live (D11), so an owner change applies to the next attempt.
+ const alphaNow = () => (typeof alpha === 'function' ? alpha() : alpha);
+ const selectAll = db.prepare('SELECT * FROM provider_metrics');
+ const selectOne = db.prepare('SELECT * FROM provider_metrics WHERE provider_key = ? AND model_key = ?');
+ const upsert = db.prepare(`INSERT INTO provider_metrics
+ (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate,
+ cache_hit_ratio, cache_samples, sample_count, updated_at)
+ VALUES (@provider_key, @model_key, @ttft_ms, @ttft_samples, @decode_tps, @decode_samples, @success_rate, @timeout_rate,
+ @cache_hit_ratio, @cache_samples, @sample_count, @updated_at)
+ ON CONFLICT(provider_key, model_key) DO UPDATE SET
+ ttft_ms = excluded.ttft_ms, ttft_samples = excluded.ttft_samples,
+ decode_tps = excluded.decode_tps, decode_samples = excluded.decode_samples,
+ success_rate = excluded.success_rate, timeout_rate = excluded.timeout_rate,
+ cache_hit_ratio = excluded.cache_hit_ratio, cache_samples = excluded.cache_samples,
+ sample_count = excluded.sample_count, updated_at = excluded.updated_at`);
+
+ function view(row) {
+ if (!row) return null;
+ return {
+ provider: row.provider_key,
+ model: row.model_key,
+ ttft_ms: row.ttft_ms == null ? null : Number(row.ttft_ms),
+ ttft_samples: Number(row.ttft_samples) || 0,
+ decode_tps: row.decode_tps == null ? null : Number(row.decode_tps),
+ decode_samples: Number(row.decode_samples) || 0,
+ success_rate: Number(row.success_rate) || 0,
+ timeout_rate: Number(row.timeout_rate) || 0,
+ cache_hit_ratio: Number(row.cache_hit_ratio) || 0,
+ cache_samples: Number(row.cache_samples) || 0,
+ sample_count: Number(row.sample_count) || 0,
+ updated_at: row.updated_at || null,
+ };
+ }
+
+ async function all() {
+ const map = new Map();
+ for (const row of await selectAll.all()) map.set(offerKey(row.provider_key, row.model_key), view(row));
+ return map;
+ }
+
+ async function get(provider, model) {
+ return view(await selectOne.get(String(provider), String(model || '')));
+ }
+
+ /**
+ * One attempt. `ok` and `timeout` always update; TTFT, decode and the cache ratio update only when the
+ * attempt actually measured them (a null never becomes a zero observation).
+ */
+ async function record({ provider, model = '', ok = false, timeout = false, ttftMs = null, latencyMs = null,
+ outputTokens = 0, inputTokens = 0, cachedTokens = 0 } = {}) {
+ if (!provider) return null;
+ const prev = view(await selectOne.get(String(provider), String(model || '')));
+ const samples = (prev ? prev.sample_count : 0) + 1;
+ const next = {
+ provider_key: String(provider), model_key: String(model || ''),
+ ttft_ms: prev ? prev.ttft_ms : null,
+ ttft_samples: prev ? prev.ttft_samples : 0,
+ decode_tps: prev ? prev.decode_tps : null,
+ decode_samples: prev ? prev.decode_samples : 0,
+ success_rate: ewma(prev ? prev.success_rate : null, ok ? 1 : 0, alphaNow()) || 0,
+ timeout_rate: ewma(prev ? prev.timeout_rate : null, timeout ? 1 : 0, alphaNow()) || 0,
+ cache_hit_ratio: prev && prev.cache_samples > 0 ? prev.cache_hit_ratio : null,
+ cache_samples: prev ? prev.cache_samples : 0,
+ sample_count: samples,
+ updated_at: iso(clock.now()),
+ };
+ const ttft = ttftMs == null || ttftMs === '' ? null : Number(ttftMs);
+ if (ttft != null && Number.isFinite(ttft) && ttft >= 0) {
+ next.ttft_ms = ewma(next.ttft_ms, ttft, alphaNow());
+ next.ttft_samples += 1;
+ }
+ const tps = decodeTps({ ttftMs, latencyMs, outputTokens });
+ if (tps != null) {
+ next.decode_tps = ewma(next.decode_tps, tps, alphaNow());
+ next.decode_samples += 1;
+ }
+ const input = Number(inputTokens);
+ const cached = Number(cachedTokens);
+ if (Number.isFinite(input) && input > 0 && Number.isFinite(cached) && cached >= 0) {
+ next.cache_hit_ratio = clamp01(ewma(next.cache_hit_ratio, clamp01(cached / input), alphaNow()));
+ next.cache_samples += 1;
+ }
+ await upsert.run(next);
+ return next;
+ }
+
+ return { record, all, get };
+}
+
+module.exports = { createProviderMetrics, ewma, decodeTps, offerKey };
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+'use strict';
+/**
+ * The truthful model catalog (AI Exchange P4, CHECKPOINT D3/D4), behind AI_MODEL_CATALOG.
+ *
+ * A registry model row becomes an `ai.model@1` catalog entry: its legacy provider/model fields, plus an exact id
+ * `<org>/<model>[:<quant>]@<revision>` and the artifact facts (family, variant, quant, revision, sha256, license,
+ * capabilities, modalities, retail rate, availability) when the row's metadata names them. Nothing is invented: a
+ * row without an artifact revision has no exact id and can only be reached through an alias or the legacy model.
+ *
+ * aliases the dynamic policies (openvibe/auto, economy, balanced, fast, private; legacy openvibe)
+ * entries({ registry }) the registry's real (non-synthetic) models in the ai.model@1 shape
+ * exactOffers({ registry, id }) the provider offers of one exact artifact (never another model)
+ * select({ registry, model, route })
+ * a request's `model`: an alias turns into the same route with the alias policy's
+ * placement constraints; an exact id returns only its own deployments (never a substitute)
+ * facts({ registry, providerRecord, model, policy, policyReason, fallbackReason })
+ * one `openvibe` disclosure: resolved_model, revision, deployment, provider_access,
+ * rate_version, policy, policy_reason, fallback_reason
+ *
+ * Commercial access is the provider offer's `provider_access` (migrations/0024), defaulting to UNVERIFIED. Paid
+ * managed routing (the network's providers, never a person's own key) accepts only OWN_DEPLOYMENT and
+ * AUTHORIZED_WHOLESALE; RESTRICTED, CUSTOMER_BYOK and UNVERIFIED offers are never resold as managed capacity.
+ *
+ * The ai.model@1 catalog shape is CHECKPOINT D4 (Contracts 0.284.0); apps call with ai.app.run (D8'), and the
+ * route disclosure joins x_openvibe (D3').
+ */
+const { AiError } = require('../util');
+
+const ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
+const MANAGED_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE'];
+const EXACT_ID = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?@[a-zA-Z0-9._-]+$/;
+
+/** The dynamic policy aliases (D3). Legacy `openvibe` is `openvibe/auto` under its old name. */
+const ALIASES = [
+ { id: 'openvibe', legacy: 'openvibe', policy: 'auto', name: 'OpenVibe AI', description: 'The legacy name of openvibe/auto: the balanced router picks the model per request and says which one answered.' },
+ { id: 'openvibe/auto', policy: 'auto', name: 'Auto', description: 'Picks among the eligible models per request; the response says which model answered and why.' },
+ { id: 'openvibe/economy', policy: 'cheapest', name: 'Economy', description: 'The lowest cost per successful answer among the eligible models.' },
+ { id: 'openvibe/balanced', policy: 'balanced', name: 'Balanced', description: 'Cost, health and measured latency weighed together.' },
+ { id: 'openvibe/fast', policy: 'fastest', name: 'Fast', description: 'The quickest measured offer among the eligible models.' },
+ { id: 'openvibe/private', policy: 'private', name: 'Private', description: 'Only OpenVibe-run or local capacity; never an external provider.' },
+].map((a) => ({
+ ...a,
+ object: 'model',
+ type: 'chat',
+ dynamic: true,
+ capabilities: ['chat', 'json', 'stream'],
+ modalities: ['text'],
+ availability: 'available',
+}));
+
+const accessOf = (provider) => (provider && ACCESS.includes(provider.provider_access) ? provider.provider_access : 'UNVERIFIED');
+const managedAccessAllowed = (provider) => MANAGED_ACCESS.includes(accessOf(provider));
+
+/** The artifact an exact id names, from a model row's metadata (or its own metadata.id). Null when unknown. */
+function artifactOf(row, provider) {
+ const meta = (row && row.metadata) || {};
+ if (typeof meta.id === 'string' && EXACT_ID.test(meta.id)) {
+ const m = /^([^/]+)\/([^:@]+)(?::([^@]+))?@(.+)$/.exec(meta.id);
+ return { id: meta.id, org: m[1], model: m[2], quant: m[3] || null, revision: m[4] };
+ }
+ if (!meta.revision) return null;
+ const key = String((row && row.model_key) || '');
+ const at = key.lastIndexOf('@');
+ const base = at > 0 ? key.slice(0, at) : key;
+ const slash = base.indexOf('/');
+ const org = meta.org || (slash > 0 ? base.slice(0, slash) : (provider && provider.key));
+ const model = meta.model || (slash > 0 ? base.slice(slash + 1) : base);
+ if (!org || !model) return null;
+ const quant = meta.quant || null;
+ const id = `${org}/${model}${quant ? `:${quant}` : ''}@${meta.revision}`;
+ return EXACT_ID.test(id) ? { id, org, model, quant, revision: String(meta.revision) } : null;
+}
+
+/** Integer nano-USD per million tokens from a USD-per-million price (D1); null when unpriced. */
+function nanoPerMtok(usd) {
+ if (usd == null || usd === '' || !Number.isFinite(Number(usd)) || Number(usd) < 0) return null;
+ const n = Math.round(Number(usd) * 1e9);
+ return Number.isSafeInteger(n) ? String(n) : null;
+}
+
+function retailRate(row) {
+ const cost = (row && row.cost) || {};
+ const rate = {};
+ const put = (key, value) => { if (value != null) rate[key] = value; };
+ put('input_nano_usd_per_m', nanoPerMtok(cost.in_per_mtok));
+ put('cached_read_nano_usd_per_m', nanoPerMtok(cost.cached_per_mtok));
+ put('cache_write_nano_usd_per_m', nanoPerMtok(cost.cache_write_per_mtok));
+ put('output_nano_usd_per_m', nanoPerMtok(cost.out_per_mtok));
+ if (!Object.keys(rate).length) return null;
+ rate.per_request_nano_usd = '0';
+ const version = row.metadata && row.metadata.rate_version;
+ if (typeof version === 'string' && version) rate.version = version;
+ return rate;
+}
+
+function modalitiesOf(row) {
+ const type = (row && row.type) || 'chat';
+ const vision = Boolean(row && row.supports && row.supports.vision) || type === 'vision';
+ const seen = new Set(['text']);
+ if (vision || type === 'image') seen.add('image');
+ if (type === 'stt' || type === 'speech') { seen.add('audio'); }
+ return [...seen];
+}
+
+function availabilityOf(row, provider) {
+ if (row && row.metadata && row.metadata.missing_since) return 'deprecated';
+ if (!row || row.status !== 'active' || !provider || provider.status !== 'active') return 'unavailable';
+ return 'available';
+}
+
+/** One registry model as an ai.model@1 catalog entry. Legacy fields stay for readers of the old shape. */
+function entryOf(row, provider) {
+ const meta = (row && row.metadata) || {};
+ const artifact = artifactOf(row, provider);
+ const out = {
+ provider_key: row.provider_key,
+ model_key: row.model_key,
+ display_name: row.display_name || row.model_key,
+ type: row.type,
+ status: row.status,
+ context_window: row.context_window == null ? null : Number(row.context_window),
+ max_output: row.max_output == null ? null : Number(row.max_output),
+ cost: {
+ in_per_mtok: row.cost.in_per_mtok == null ? null : Number(row.cost.in_per_mtok),
+ out_per_mtok: row.cost.out_per_mtok == null ? null : Number(row.cost.out_per_mtok),
+ cached_per_mtok: row.cost.cached_per_mtok == null ? null : Number(row.cost.cached_per_mtok),
+ ...(row.cost.cache_write_per_mtok == null ? {} : { cache_write_per_mtok: Number(row.cost.cache_write_per_mtok) }),
+ },
+ supports: { ...row.supports },
+ metadata: meta,
+ created_at: row.created_at || null,
+ updated_at: row.updated_at || null,
+ availability: availabilityOf(row, provider),
+ capabilities: { tools: Boolean(row.supports.tools), json: Boolean(row.supports.json), vision: Boolean(row.supports.vision), stream: Boolean(row.supports.streaming) },
+ modalities: modalitiesOf(row),
+ };
+ if (artifact) {
+ out.id = artifact.id;
+ out.revision = artifact.revision;
+ if (artifact.quant) out.quant = artifact.quant;
+ if (meta.family) out.family = meta.family;
+ if (meta.variant) out.variant = meta.variant;
+ if (typeof meta.sha256 === 'string' && /^[a-f0-9]{64}$/.test(meta.sha256)) out.sha256 = meta.sha256;
+ if (meta.license) out.license = meta.license;
+ }
+ if (Number(row.context_window) > 0) out.context = Number(row.context_window);
+ const retail = retailRate(row);
+ if (retail) out.retail_rate = retail;
+ return out;
+}
+
+/** Every non-synthetic registry model as a catalog entry, with its provider record. */
+async function entries({ registry }) {
+ const providers = new Map((await registry.listProviders()).map((p) => [p.key, p]));
+ const out = [];
+ for (const row of await registry.listModels()) {
+ const provider = providers.get(row.provider_key);
+ if (!provider || provider.kind === 'stub' || (provider.metadata && provider.metadata.synthetic)) continue;
+ out.push({ row, provider, entry: entryOf(row, provider) });
+ }
+ return out;
+}
+
+/** Model ids an exact-id request may run: one entry per provider offer of that artifact. */
+async function exactOffers({ registry, id }) {
+ return (await entries({ registry })).filter((x) => x.entry.id === id);
+}
+
+/**
+ * Resolve a request's model:
+ * an alias → { kind:'alias', alias, policy, route } with the route's placement constraints adjusted in a copy
+ * an exact → { kind:'exact', id, offers:[{provider, model, row, providerRecord}] } (active deployments only)
+ * anything else → ai.model_not_found; an id that exists but has no usable deployment → ai.model_unavailable.
+ */
+async function select({ registry, model, route }) {
+ const requested = String(model == null ? '' : model).trim();
+ const alias = ALIASES.find((a) => a.id === requested);
+ if (alias) {
+ const constraints = { ...((route && route.constraints) || {}) };
+ if (alias.policy === 'cheapest') { constraints.objective = 'cheapest'; delete constraints.authority; }
+ else if (alias.policy === 'fastest') { constraints.objective = 'lowest-latency'; delete constraints.authority; }
+ else if (alias.policy === 'balanced') { constraints.objective = 'balanced'; delete constraints.authority; }
+ else if (alias.policy === 'private') {
+ constraints.objective = 'private';
+ constraints.provider_scope = 'local';
+ // placement's `private` objective defaults to first-party trust, while the providers file classes every
+ // local offer `community` (server/providers/index.js). Name both local trust classes explicitly; the local
+ // scope above still keeps every external offer out, which is the alias's promise.
+ if (!Array.isArray(constraints.trust) || !constraints.trust.length) constraints.trust = ['first-party', 'community'];
+ delete constraints.authority;
+ }
+ return { kind: 'alias', requested, alias, policy: alias.policy, route: { ...route, constraints } };
+ }
+ if (!EXACT_ID.test(requested)) {
+ throw new AiError(404, 'ai.model_not_found', `no model ${requested || '(none)'}: send an exact id <org>/<model>[:<quant>]@<revision> or one of ${ALIASES.map((a) => a.id).join(', ')}`);
+ }
+ const found = await exactOffers({ registry, id: requested });
+ if (!found.length) throw new AiError(404, 'ai.model_not_found', `no artifact ${requested}`);
+ const usable = found.filter((x) => x.row.status === 'active' && x.provider.status === 'active');
+ if (!usable.length) throw new AiError(400, 'ai.model_unavailable', `the artifact ${requested} has no active deployment`);
+ return {
+ kind: 'exact', requested, id: requested, policy: 'exact', route,
+ offers: usable.map((x) => ({ provider: x.provider.key, model: x.row.model_key, row: x.row, providerRecord: x.provider })),
+ };
+}
+
+/** One `openvibe` disclosure object (D3). Unknown facts stay null, never invented. */
+async function facts({ registry, providerRecord, model, policy = null, policyReason = null, fallbackReason = null }) {
+ const provider = providerRecord || null;
+ const row = provider && model ? await registry.getModel(provider.key, model) : null;
+ const artifact = row ? artifactOf(row, provider) : null;
+ const meta = (row && row.metadata) || {};
+ return {
+ resolved_model: artifact ? artifact.id : (provider && model ? `${provider.key}/${model}` : (model || null)),
+ revision: artifact ? artifact.revision : (meta.revision ? String(meta.revision) : null),
+ deployment: provider ? provider.key : null,
+ provider_access: accessOf(provider),
+ rate_version: meta.rate_version ? String(meta.rate_version) : null,
+ policy: policy || null,
+ ...(policyReason ? { policy_reason: String(policyReason).slice(0, 160) } : {}),
+ fallback_reason: fallbackReason || null,
+ };
+}
+
+/** The `openvibe` object of a finished run (a run's own facts when the pool recorded them; otherwise from provenance). */
+async function disclosureOf({ registry, run }) {
+ const explain = (run && run.explain) || null;
+ if (explain && explain.openvibe && typeof explain.openvibe === 'object') return explain.openvibe;
+ const provenance = (run && run.provenance) || {};
+ const provider = provenance.provider && !String(provenance.provider).startsWith('byo:') && !String(provenance.provider).startsWith('project:')
+ ? await registry.getProvider(provenance.provider) : null;
+ const base = await facts({ registry, providerRecord: provider, model: provenance.model || null, policy: explain && (explain.policy || explain.objective) || null });
+ if (!base.deployment && provenance.provider) base.deployment = provenance.provider;
+ if (provenance.cached_from) base.fallback_reason = `the answer was reused from run ${provenance.cached_from}; no provider was called`;
+ else if (provenance.fallback_used) base.fallback_reason = 'the first eligible offer did not answer; a later offer on the same route answered';
+ return base;
+}
+
+module.exports = {
+ ACCESS, MANAGED_ACCESS, ALIASES, EXACT_ID,
+ accessOf, managedAccessAllowed, artifactOf, entryOf, entries, exactOffers, select, facts, disclosureOf, retailRate,
+};
@@ -177,7 +177,7 @@ const tierRank = (tier) => (TIER_RANK[tier] === undefined ? TIER_RANK[DEFAULT_TI
@@ -177,7 +177,7 @@ const tierRank = (tier) => (TIER_RANK[tier] === undefined ? TIER_RANK[DEFAULT_TI
* A warm prompt cache is worth up to 10 % of the price, so a near tie goes to the provider that already
* A warm prompt cache is worth up to 10 % of the price, so a near tie goes to the provider that already
* has this prompt cached (L36 Y7). The order is stable: two candidates that tie keep the pool's order.
* has this prompt cached (L36 Y7). The order is stable: two candidates that tie keep the pool's order.
*/
*/
-function rank(candidates, { task, warm = null, preferred = null, authority = null, tierFirst = null, keepOrder = false } = {}) {
+function rank(candidates, { task, warm = null, preferred = null, authority = null, tierFirst = null, keepOrder = false, policy = null } = {}) {
const isAuthority = (c) => Boolean(authority && (authority === c.provider || authority === c.provider + ':' + (c.model || '')));
const isAuthority = (c) => Boolean(authority && (authority === c.provider || authority === c.provider + ':' + (c.model || '')));
const lead = tierFirst == null ? task.weight === 'heavy' : Boolean(tierFirst);
const lead = tierFirst == null ? task.weight === 'heavy' : Boolean(tierFirst);
const prepared = candidates.map((c, index) => {
const prepared = candidates.map((c, index) => {
@@ -189,6 +189,14 @@ function rank(candidates, { task, warm = null, preferred = null, authority = nul
@@ -189,6 +189,14 @@ function rank(candidates, { task, warm = null, preferred = null, authority = nul
if (keepOrder) return a.index - b.index; // a pinned route keeps its own order
if (keepOrder) return a.index - b.index; // a pinned route keeps its own order
if (a.authority !== b.authority) return a.authority ? -1 : 1;
if (a.authority !== b.authority) return a.authority ? -1 : 1;
if (preferred && (a.model === preferred) !== (b.model === preferred)) return a.model === preferred ? -1 : 1;
if (preferred && (a.model === preferred) !== (b.model === preferred)) return a.model === preferred ? -1 : 1;
+ // A dynamic policy alias (AI Exchange D3) names what it optimises; the pool's eligibility and the
+ // person's preferences still come first, and a tie keeps the existing tier/health/latency order.
+ if (policy === 'cheapest' && a.effective !== b.effective) return a.effective - b.effective;
+ if (policy === 'fastest') {
+ const la = a.latency == null ? Infinity : a.latency;
+ const lb = b.latency == null ? Infinity : b.latency;
+ if (la !== lb) return la - lb;
+ }
if (lead && a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;
if (lead && a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;
if (a.effective !== b.effective) return a.effective - b.effective;
if (a.effective !== b.effective) return a.effective - b.effective;
if (a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;
if (a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;
@@ -2,8 +2,9 @@
@@ -2,8 +2,9 @@
/**
/**
* A tiny, in-process memory of the provider that last answered a prompt (L36 Y7: prefer the provider whose
* A tiny, in-process memory of the provider that last answered a prompt (L36 Y7: prefer the provider whose
* prompt cache is already warm). The key is the person or app plus a hash of the first 2 KB of the leading
* prompt cache is already warm). The key is the person or app plus a hash of the first 2 KB of the leading
- * prompt; the value is the provider key; it expires after five minutes. Nothing is persisted, nothing leaves
- * the process, and the map is capped so it can never grow into a cache.
+ * prompt; the value is the provider key and, when the caller knows it, the model that answered. It expires
+ * after five minutes. Nothing is persisted, nothing leaves the process, and the map is capped so it can never
+ * grow into a cache.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
@@ -15,24 +16,35 @@ const PROMPT_BYTES = 2048;
@@ -15,24 +16,35 @@ const PROMPT_BYTES = 2048;
const hashOf = (text) => crypto.createHash('sha256').update(String(text || '').slice(0, PROMPT_BYTES)).digest('hex');
const hashOf = (text) => crypto.createHash('sha256').update(String(text || '').slice(0, PROMPT_BYTES)).digest('hex');
function createWarmCache({ ttlMs = DEFAULT_TTL_MS, max = DEFAULT_MAX, now = () => Date.now() } = {}) {
function createWarmCache({ ttlMs = DEFAULT_TTL_MS, max = DEFAULT_MAX, now = () => Date.now() } = {}) {
- const entries = new Map(); // key → { provider, at }
+ const entries = new Map(); // key → { provider, model, at }
- function remember(subject, prompt, provider) {
+ function remember(subject, prompt, provider, model = null) {
if (!provider) return;
if (!provider) return;
const key = String(subject || 'anonymous') + '|' + hashOf(prompt);
const key = String(subject || 'anonymous') + '|' + hashOf(prompt);
- entries.set(key, { provider, at: now() });
+ entries.set(key, { provider, model: model || null, at: now() });
while (entries.size > max) entries.delete(entries.keys().next().value); // the oldest goes first
while (entries.size > max) entries.delete(entries.keys().next().value); // the oldest goes first
}
}
function get(subject, prompt) {
function get(subject, prompt) {
+ const hit = entry(subject, prompt);
+ return hit ? hit.provider : null;
+ }
+
+ /** The deployment (provider + model) that last answered this prompt prefix, or null when it is cold. */
+ function getDeployment(subject, prompt) {
+ const hit = entry(subject, prompt);
+ return hit ? { provider: hit.provider, model: hit.model || null } : null;
+ }
+
+ function entry(subject, prompt) {
const key = String(subject || 'anonymous') + '|' + hashOf(prompt);
const key = String(subject || 'anonymous') + '|' + hashOf(prompt);
const hit = entries.get(key);
const hit = entries.get(key);
if (!hit) return null;
if (!hit) return null;
if (now() - hit.at > ttlMs) { entries.delete(key); return null; }
if (now() - hit.at > ttlMs) { entries.delete(key); return null; }
- return hit.provider;
+ return hit;
}
}
- return { remember, get, hashOf, size: () => entries.size, clear: () => entries.clear() };
+ return { remember, get, getDeployment, hashOf, size: () => entries.size, clear: () => entries.clear() };
}
}
module.exports = { createWarmCache, hashOf, DEFAULT_TTL_MS, DEFAULT_MAX, PROMPT_BYTES };
module.exports = { createWarmCache, hashOf, DEFAULT_TTL_MS, DEFAULT_MAX, PROMPT_BYTES };
@@ -31,6 +31,30 @@ const WINDOWS = { minute: 60, hour: 3600, day: 86400 };
@@ -31,6 +31,30 @@ const WINDOWS = { minute: 60, hour: 3600, day: 86400 };
const EWMA_ALPHA = 0.2; // documented: 20 % of the new sample, 80 % of the history
const EWMA_ALPHA = 0.2; // documented: 20 % of the new sample, 80 % of the history
const LATENCY_BUCKETS_MS = [50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600];
const LATENCY_BUCKETS_MS = [50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600];
+// The exact id shape of D3: `<org>/<model>[:<quant>]@<revision>`.
+const EXACT_ID = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?@[a-zA-Z0-9._-]+$/;
+
+/**
+ * The usage series names a model with its exact id when the model row's metadata knows the artifact
+ * (metadata.id, or metadata.revision with the org/model/quant facts), else `<provider>/<model_key>`.
+ * Nothing is invented: a row without a revision has no exact id.
+ */
+function exactModelId(provider, modelKey, meta) {
+ const m = meta || {};
+ if (typeof m.id === 'string' && EXACT_ID.test(m.id)) return m.id;
+ if (m.revision) {
+ const key = String(modelKey || '');
+ const at = key.lastIndexOf('@');
+ const base = at > 0 ? key.slice(0, at) : key;
+ const slash = base.indexOf('/');
+ const org = m.org || (slash > 0 ? base.slice(0, slash) : provider);
+ const model = m.model || (slash > 0 ? base.slice(slash + 1) : base);
+ const id = `${org}/${model}${m.quant ? `:${m.quant}` : ''}@${m.revision}`;
+ if (org && model && EXACT_ID.test(id)) return id;
+ }
+ return modelKey ? `${provider}/${modelKey}` : provider;
+}
+
function latencyBucket(ms) {
function latencyBucket(ms) {
const i = LATENCY_BUCKETS_MS.findIndex((b) => ms <= b);
const i = LATENCY_BUCKETS_MS.findIndex((b) => ms <= b);
return i === -1 ? LATENCY_BUCKETS_MS.length : i; // -1 → the overflow bucket
return i === -1 ? LATENCY_BUCKETS_MS.length : i; // -1 → the overflow bucket
@@ -268,6 +292,71 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAll
@@ -268,6 +292,71 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAll
return await db.prepare(`SELECT * FROM usage_daily ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY day DESC, cost_usd DESC LIMIT 1000`).all(...args);
return await db.prepare(`SELECT * FROM usage_daily ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY day DESC, cost_usd DESC LIMIT 1000`).all(...args);
}
}
+ /**
+ * Aggregate demand for the inference planner (Host's `HOST_INFERENCE_PLANNER_SOURCE=ai`), never
+ * per person, app or project. One row per UTC day and provider/model:
+ * requests, successes provider attempts (provider_stats_daily.requests / .ok), the same counts
+ * the router rolls up; a retry is a second attempt
+ * tokens.input tokens_in minus cache reads; cache writes are not stored apart on this
+ * release, so they are still inside this number
+ * tokens.cached_read usage_daily.tokens_cached, summed across every requester and attribution
+ * tokens.cache_write 0: AI records cache-write tokens inside the whole prompt, not apart
+ * tokens.output tokens_out
+ *
+ * `model` is the exact id when the model row's metadata names the artifact, else
+ * `<provider>/<model_key>`. The response carries no caller id, prompt, answer, project or cost.
+ */
+ async function usageSeries({ days } = {}) {
+ let n = 30;
+ if (days !== undefined && days !== null && days !== '') {
+ if (typeof days !== 'string' || !/^\d+$/.test(days)) throw new AiError(422, 'ai.invalid', 'days must be an integer from 1 to 90');
+ n = Number(days);
+ if (!Number.isSafeInteger(n) || n < 1 || n > 90) throw new AiError(422, 'ai.invalid', 'days must be between 1 and 90');
+ }
+ const end = new Date(clock.now());
+ const to = end.toISOString().slice(0, 10);
+ const from = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()) - (n - 1) * 86400000).toISOString().slice(0, 10);
+ const rows = await db.prepare(`
+ SELECT to_char(s.day, 'YYYY-MM-DD') AS day, s.provider, s.model,
+ SUM(s.requests)::bigint AS requests, SUM(s.ok)::bigint AS successes,
+ SUM(s.tokens_in)::bigint AS tokens_in, SUM(s.tokens_out)::bigint AS tokens_out,
+ COALESCE(MAX(u.tokens_cached), 0)::bigint AS tokens_cached
+ FROM provider_stats_daily s
+ LEFT JOIN (
+ SELECT day, provider_key, model_key,
+ SUM(tokens_cached)::bigint AS tokens_cached
+ FROM usage_daily WHERE day >= ? GROUP BY day, provider_key, model_key
+ ) u ON u.day = to_char(s.day, 'YYYY-MM-DD') AND u.provider_key = s.provider AND u.model_key = s.model
+ WHERE s.day >= ?
+ GROUP BY s.day, s.provider, s.model
+ ORDER BY s.day, s.provider, s.model`).all(from, from);
+ const models = new Map((await registry.listModels()).map((m) => [`${m.provider_key}|${m.model_key}`, m]));
+ return {
+ days: n,
+ from,
+ to,
+ note: 'cache_write is 0 on this release: AI records cache-write tokens inside the whole prompt, not apart',
+ series: rows.map((r) => {
+ const meta = (models.get(`${r.provider}|${r.model}`) || {}).metadata || {};
+ const cached = Number(r.tokens_cached) || 0;
+ return {
+ day: r.day,
+ model: exactModelId(r.provider, r.model, meta),
+ provider: r.provider,
+ model_key: r.model,
+ requests: Number(r.requests) || 0,
+ successes: Number(r.successes) || 0,
+ tokens: {
+ input: Math.max(0, (Number(r.tokens_in) || 0) - cached),
+ cached_read: cached,
+ cache_write: 0,
+ output: Number(r.tokens_out) || 0,
+ },
+ };
+ }),
+ };
+ }
+
async function counters(ctxLike) {
async function counters(ctxLike) {
const qs = ctxLike ? await applicable(ctxLike) : (await list()).filter(q => q.status === 'active' && (q.scope_type === 'global' || q.scope_id !== '*')).map(q => ({ q, cid: q.scope_id }));
const qs = ctxLike ? await applicable(ctxLike) : (await list()).filter(q => q.status === 'active' && (q.scope_type === 'global' || q.scope_id !== '*')).map(q => ({ q, cid: q.scope_id }));
const t = nowSec();
const t = nowSec();
@@ -292,7 +381,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAll
@@ -292,7 +381,7 @@ function createQuotas(db, { clock = { now: () => Date.now() }, registry, freeAll
return { max_cost_usd: Math.min(...rows.map((r) => r.max_cost_usd)), spent, window: 'day' };
return { max_cost_usd: Math.min(...rows.map((r) => r.max_cost_usd)), spent, window: 'day' };
}
}
- return { reserve, account, statsFor, list, upsert, usage, counters, paidBudget, freeAllowance, WINDOWS };
+ return { reserve, account, statsFor, list, upsert, usage, usageSeries, counters, paidBudget, freeAllowance, WINDOWS };
}
}
module.exports = { createQuotas, WINDOWS };
module.exports = { createQuotas, WINDOWS };
@@ -15,6 +15,8 @@ const { provenance } = require('./providers/rate-cards');
@@ -15,6 +15,8 @@ const { provenance } = require('./providers/rate-cards');
const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];
const PROVIDER_KINDS = ['stub', 'openai', 'responses', 'anthropic', 'http', 'whisper'];
const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];
const BILLING_PROFILES = ['metered', 'subscription', 'payg', 'free', 'byok'];
+// AI Exchange D3: who may resell this offer's capacity. Defaults to UNVERIFIED (migrations/0024).
+const PROVIDER_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
const FEATURES = ['chat', 'complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image'];
const FEATURES = ['chat', 'complete', 'messages', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'tools', 'transcribe', 'image'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
@@ -49,6 +51,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -49,6 +51,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
// The billing profile and shared pool (migrations/0008): exposed on every provider response; pool_key
// The billing profile and shared pool (migrations/0008): exposed on every provider response; pool_key
// is a name, never a secret value.
// is a name, never a secret value.
billing_profile: r.billing_profile || 'metered', pool_key: r.pool_key == null ? null : r.pool_key,
billing_profile: r.billing_profile || 'metered', pool_key: r.pool_key == null ? null : r.pool_key,
+ provider_access: r.provider_access || 'UNVERIFIED',
metadata: parseJson(r.metadata, {}), origin: r.origin, created_at: r.created_at, updated_at: r.updated_at,
metadata: parseJson(r.metadata, {}), origin: r.origin, created_at: r.created_at, updated_at: r.updated_at,
};
};
}
}
@@ -70,6 +73,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -70,6 +73,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
if (!secretRefValid(p.secret_ref)) throw new AiError(422, 'ai.invalid', "secret_ref must be 'env:NAME' or 'vault:<provider key>' (a reference, never a secret value)");
if (!secretRefValid(p.secret_ref)) throw new AiError(422, 'ai.invalid', "secret_ref must be 'env:NAME' or 'vault:<provider key>' (a reference, never a secret value)");
if (p.base_url && !/^https?:\/\/[^\s]+$/i.test(p.base_url)) throw new AiError(422, 'ai.invalid', 'base_url must be an http(s) URL');
if (p.base_url && !/^https?:\/\/[^\s]+$/i.test(p.base_url)) throw new AiError(422, 'ai.invalid', 'base_url must be an http(s) URL');
if (!BILLING_PROFILES.includes(p.billing_profile)) throw new AiError(422, 'ai.invalid', `billing_profile must be one of ${BILLING_PROFILES.join(', ')}`);
if (!BILLING_PROFILES.includes(p.billing_profile)) throw new AiError(422, 'ai.invalid', `billing_profile must be one of ${BILLING_PROFILES.join(', ')}`);
+ if (!PROVIDER_ACCESS.includes(p.provider_access)) throw new AiError(422, 'ai.invalid', `provider_access must be one of ${PROVIDER_ACCESS.join(', ')}`);
// metadata.billing_profile only seeds the column (upsertProvider), but it is refused here too so an
// metadata.billing_profile only seeds the column (upsertProvider), but it is refused here too so an
// unknown value can never be stored as an inert, silently-ignored profile.
// unknown value can never be stored as an inert, silently-ignored profile.
if (p.metadata && p.metadata.billing_profile !== undefined && !BILLING_PROFILES.includes(p.metadata.billing_profile))
if (p.metadata && p.metadata.billing_profile !== undefined && !BILLING_PROFILES.includes(p.metadata.billing_profile))
@@ -99,16 +103,18 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -99,16 +103,18 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
// a later metadata edit can never silently re-route the provider.
// a later metadata edit can never silently re-route the provider.
billing_profile: input.billing_profile || (prev && prev.billing_profile) || (input.metadata && input.metadata.billing_profile) || 'metered',
billing_profile: input.billing_profile || (prev && prev.billing_profile) || (input.metadata && input.metadata.billing_profile) || 'metered',
pool_key: input.pool_key !== undefined ? (input.pool_key || null) : (prev ? prev.pool_key : null),
pool_key: input.pool_key !== undefined ? (input.pool_key || null) : (prev ? prev.pool_key : null),
+ // The offer's commercial access (D3): set on the record, never inferred from its billing profile.
+ provider_access: input.provider_access || (prev && prev.provider_access) || 'UNVERIFIED',
metadata: input.metadata || (prev && prev.metadata) || {},
metadata: input.metadata || (prev && prev.metadata) || {},
};
};
checkProvider(p);
checkProvider(p);
const t = now();
const t = now();
- await db.prepare(`INSERT INTO providers (key, display_name, kind, status, base_url, auth_mode, secret_ref, default_model, capabilities, timeout_ms, priority, billing_profile, pool_key, metadata, origin, created_at, updated_at)
- VALUES (@key, @display_name, @kind, @status, @base_url, @auth_mode, @secret_ref, @default_model, @capabilities, @timeout_ms, @priority, @billing_profile, @pool_key, @metadata, @origin, @t, @t)
+ await db.prepare(`INSERT INTO providers (key, display_name, kind, status, base_url, auth_mode, secret_ref, default_model, capabilities, timeout_ms, priority, billing_profile, pool_key, provider_access, metadata, origin, created_at, updated_at)
+ VALUES (@key, @display_name, @kind, @status, @base_url, @auth_mode, @secret_ref, @default_model, @capabilities, @timeout_ms, @priority, @billing_profile, @pool_key, @provider_access, @metadata, @origin, @t, @t)
ON CONFLICT(key) DO UPDATE SET display_name = excluded.display_name, kind = excluded.kind, status = excluded.status, base_url = excluded.base_url,
ON CONFLICT(key) DO UPDATE SET display_name = excluded.display_name, kind = excluded.kind, status = excluded.status, base_url = excluded.base_url,
auth_mode = excluded.auth_mode, secret_ref = excluded.secret_ref, default_model = excluded.default_model, capabilities = excluded.capabilities,
auth_mode = excluded.auth_mode, secret_ref = excluded.secret_ref, default_model = excluded.default_model, capabilities = excluded.capabilities,
timeout_ms = excluded.timeout_ms, priority = excluded.priority, billing_profile = excluded.billing_profile, pool_key = excluded.pool_key,
timeout_ms = excluded.timeout_ms, priority = excluded.priority, billing_profile = excluded.billing_profile, pool_key = excluded.pool_key,
- metadata = excluded.metadata, origin = excluded.origin, updated_at = excluded.updated_at`)
+ provider_access = excluded.provider_access, metadata = excluded.metadata, origin = excluded.origin, updated_at = excluded.updated_at`)
.run({ ...p, capabilities: JSON.stringify(p.capabilities), metadata: JSON.stringify(p.metadata), origin, t });
.run({ ...p, capabilities: JSON.stringify(p.capabilities), metadata: JSON.stringify(p.metadata), origin, t });
await audit(actor, prev ? 'provider.update' : 'provider.create', 'provider', p.key, { trace, metadata: { kind: p.kind, status: p.status, base_url: p.base_url, secret_ref: p.secret_ref, origin } });
await audit(actor, prev ? 'provider.update' : 'provider.create', 'provider', p.key, { trace, metadata: { kind: p.kind, status: p.status, base_url: p.base_url, secret_ref: p.secret_ref, origin } });
return await getProvider(p.key);
return await getProvider(p.key);
@@ -419,4 +425,4 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -419,4 +425,4 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
};
};
}
}
-module.exports = { createRegistry, PROVIDER_KINDS, FEATURES, LIFECYCLE };
+module.exports = { createRegistry, PROVIDER_KINDS, FEATURES, LIFECYCLE, PROVIDER_ACCESS };
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
+const { parseCostUsd } = require('./credits');
const { CACHE_INPUT } = require('./workflows/hooks');
const { CACHE_INPUT } = require('./workflows/hooks');
const { preferenceLines } = require('./user-modules');
const { preferenceLines } = require('./user-modules');
const { preferredTarget } = require('./providers/task');
const { preferredTarget } = require('./providers/task');
@@ -102,7 +103,7 @@ function routePinRequest(value, principal, wf) {
@@ -102,7 +103,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
return { provider, model };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {
+function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
const queue = []; // run ids waiting for the worker
let active = 0;
let active = 0;
@@ -183,7 +184,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -183,7 +184,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
* principal: { sub, subject: {type, id} }
* principal: { sub, subject: {type, id} }
* Returns { run, created, promise? }.
* Returns { run, created, promise? }.
*/
*/
- async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null } = {}) {
+ async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null } = {}) {
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
const wfKey = String(body.workflow || '');
const wfKey = String(body.workflow || '');
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
@@ -263,17 +264,30 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -263,17 +264,30 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const refusal = await runBudgets.refuse(principal.jti);
const refusal = await runBudgets.refuse(principal.jti);
if (refusal) throw refusal;
if (refusal) throw refusal;
}
}
+ // AI Exchange F: the caller's optional liability cap on a Balance hold (`max_cost_usd`). It is a request
+ // field on the OpenAI and Messages routes and an input/option field on the direct ones; parsed as an exact
+ // decimal into integer nano-USD, never through a float (0.000000001 is one nano, 0.0000000011 rounds up).
+ const rawMaxCost = body.max_cost_usd != null ? body.max_cost_usd : options.max_cost_usd != null ? options.max_cost_usd : input.max_cost_usd;
+ const maxCostNano = rawMaxCost == null ? null : parseCostUsd(rawMaxCost);
const ctx = {
const ctx = {
requesterType: requester.type, requesterId: requester.id,
requesterType: requester.type, requesterId: requester.id,
...(principal.jti ? { runJti: principal.jti } : {}),
...(principal.jti ? { runJti: principal.jti } : {}),
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
- ...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox } : {}),
+ ...(app ? { appRun: true, projectId: app.projectId, sandbox: app.sandbox, appId: principal.appId } : {}),
+ // Who pays a Balance hold (D12): the app and its project, else the person a token acts for, else the service.
+ onBehalfOf: personSubject || principal.onBehalfOf || null,
+ // A credential or a project's own provider never reaches managed supply: no Balance hold for that run.
+ ...(credentialSubject !== null || projectProvider ? { byok: true } : {}),
+ ...(maxCostNano != null ? { maxCostNano: String(maxCostNano) } : {}),
...(pinned ? { routePin: pinned } : {}),
...(pinned ? { routePin: pinned } : {}),
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
...(typeof onDelta === 'function' ? { onDelta } : {}),
...(typeof onDelta === 'function' ? { onDelta } : {}),
+ // The offer the router picked, before the first token: the SSE disclosure's first comment (D3).
+ ...(typeof onRoute === 'function' ? { onRoute } : {}),
};
};
const now = iso(clock.now());
const now = iso(clock.now());
const id = newRunId();
const id = newRunId();
+ ctx.runId = id;
const kept = retainedInput(input, wf, Boolean(config.debugRawLog && options.debug));
const kept = retainedInput(input, wf, Boolean(config.debugRawLog && options.debug));
const base = {
const base = {
id, workflow_key: wf.key, workflow_version: wf.version, template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null,
id, workflow_key: wf.key, workflow_version: wf.version, template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null,
@@ -479,6 +493,40 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -479,6 +493,40 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (p && p.metadata && p.metadata.allowance === 'network') return { paid_by: 'network_allowance', detail: name, provider: name };
if (p && p.metadata && p.metadata.allowance === 'network') return { paid_by: 'network_allowance', detail: name, provider: name };
return { paid_by: 'openvibe_key', detail: name, provider: name };
return { paid_by: 'openvibe_key', detail: name, provider: name };
}
}
+ /** The attempts a Balance settle reports, in billing.credit-hold-settle-request@1's exact attempt shape. */
+ async function billingAttempts({ explain, logged, startedAt, endedAt }) {
+ const source = explain && Array.isArray(explain.attempts) && explain.attempts.length ? explain.attempts : (logged || []);
+ const out = [];
+ for (const a of source) {
+ if (!a) continue;
+ const provider = a.provider || a.provider_key || null;
+ if (!provider || a.status === 'skipped') continue;
+ const model = a.model || a.model_key || null;
+ let access = null;
+ try { const p = await registry.getProvider(provider); access = p && credits ? credits.accessOf(p) : null; } catch { access = null; }
+ const ok = a.status === 'ok' || a.status === 'succeeded';
+ out.push({
+ deployment_id: `${provider}:${model || ''}`,
+ ...(access ? { provider_access: access } : {}),
+ started_at: startedAt, ended_at: endedAt,
+ error_code: ok ? null : String(a.error || a.error_code || a.status || 'error').slice(0, 120),
+ });
+ }
+ return out;
+ }
+
+ /** Settle the request's Balance hold once; a failure is logged, never allowed to fail an answer already made. */
+ async function settleCredits(credit, { usage, costUsd, explain, logged = [], startedAt, endedAt }) {
+ if (!credit || !credit.holdId) return null;
+ try {
+ const attempts = await billingAttempts({ explain, logged, startedAt, endedAt });
+ return await credit.settle({ usage, supplyCostUsd: costUsd, attempts });
+ } catch (err) {
+ log.warn(`[credits] settle ${credit.holdId} failed; queued for retry (a 409/422 needs review): ${err && err.message}`);
+ return null;
+ }
+ }
+
async function execute(id, { controller, reserved, scope, ctx, input, hold = null }) {
async function execute(id, { controller, reserved, scope, ctx, input, hold = null }) {
const row = await getRow(id);
const row = await getRow(id);
if (!row || row.status !== 'queued') { inflight.delete(id); return; }
if (!row || row.status !== 'queued') { inflight.delete(id); return; }
@@ -500,7 +548,30 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -500,7 +548,30 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
created_at: iso(clock.now()),
created_at: iso(clock.now()),
});
});
};
};
+ let credit = null;
+ let billingDelta = null;
+ let onRoute = null;
try {
try {
+ // AI Exchange F: hold prepaid Balance before the first managed provider call (AI_CREDITS, off by
+ // default). A run with no project to bill (a first-party service, a cache hit, BYOK) begins no session
+ // and never touches Billing.
+ if (credits && credits.enabled) {
+ credit = credits.begin({ requestId: row.request_id || id, ctx, input, attribution: parseJson(row.attribution, null), onBehalfOf: ctx.onBehalfOf || null });
+ // No payer (or no project) on a run that is not BYOK: the pool must not serve a paid managed offer
+ // with a versioned card, because nobody could be charged for it.
+ ctx.creditsRequired = !credit && !ctx.byok;
+ }
+ billingDelta = typeof ctx.onDelta === 'function'
+ ? (credit ? (d) => { credit.observe(d); ctx.onDelta(d); } : ctx.onDelta)
+ : null;
+ // Balance holds first (a 402 must land before the provider), then the router's own disclosure hook.
+ onRoute = (credit || typeof ctx.onRoute === 'function')
+ ? async (facts, meta) => {
+ const routed = credit ? await credit.onRoute(facts, meta) : null;
+ if (typeof ctx.onRoute === 'function') await ctx.onRoute(facts, meta);
+ return routed;
+ }
+ : null;
// A run on a person's behalf follows their ai.preferences (Network user module): style, length and
// A run on a person's behalf follows their ai.preferences (Network user module): style, length and
// perspective shape the prompt; history: false keeps neither their input nor a cache entry.
// perspective shape the prompt; history: false keeps neither their input nor a cache entry.
const preferences = userModules && row.on_behalf_of ? await userModules.preferencesFor(row.on_behalf_of) : {};
const preferences = userModules && row.on_behalf_of ? await userModules.preferencesFor(row.on_behalf_of) : {};
@@ -526,13 +597,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -526,13 +597,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
appRun: Boolean(ctx.appRun), freeOnly: Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled))),
appRun: Boolean(ctx.appRun), freeOnly: Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled))),
// Who asked, for a step that must not call the caller back (research: Search's own web ladder).
// Who asked, for a step that must not call the caller back (research: Search's own web ladder).
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
+ // AI Exchange F: no payer on this run (and not BYOK): the pool skips paid offers with a card.
+ creditsRequired: Boolean(ctx.creditsRequired),
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
// L37: the soft preference the caller made (provider, and a model if it named one).
// L37: the soft preference the caller made (provider, and a model if it named one).
prefer: opts.prefer || null,
prefer: opts.prefer || null,
...(onCredentialFailed ? { onCredentialFailed } : {}),
...(onCredentialFailed ? { onCredentialFailed } : {}),
...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
- ...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}) });
+ ...(billingDelta ? { onDelta: billingDelta } : {}),
+ ...(onRoute ? { onRoute } : {}) });
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
// The status, its citations and the ai.run.succeeded event commit together.
// The status, its citations and the ai.run.succeeded event commit together.
const done = await db.tx(async () => {
const done = await db.tx(async () => {
@@ -546,6 +620,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -546,6 +620,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
return d;
return d;
});
});
if (!done.changes) return; // cancelled while finishing
if (!done.changes) return; // cancelled while finishing
+ // One settle for the request, with the four token counts, the answering card, the supply cost and
+ // every attempt. A failure never turns the answer into an error: Billing expires an unsettled hold.
+ if (credit) await settleCredits(credit, { usage: r.usage, costUsd: r.cost, explain: r.explain, logged, startedAt: row.started_at || iso(clock.now()), endedAt: iso(clock.now()) });
// A self-key run has no reservation and no tier hold; if it fell back to the network, its usage is still
// A self-key run has no reservation and no tier hold; if it fell back to the network, its usage is still
// recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.
// recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.
const usedOwnKey = String(r.provider || '').startsWith('byo:self:');
const usedOwnKey = String(r.provider || '').startsWith('byo:self:');
@@ -574,6 +651,26 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -574,6 +651,26 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (f.changes) await events.runChanged(await getRow(id));
if (f.changes) await events.runChanged(await getRow(id));
});
});
const spent = await db.prepare("SELECT COALESCE(SUM(tokens_in),0)::bigint ti, COALESCE(SUM(tokens_out),0)::bigint tout, COALESCE(SUM(tokens_cached),0)::bigint tc, COALESCE(SUM(cost_usd),0) c FROM requests WHERE run_id = ? AND status = 'ok'").get(id);
const spent = await db.prepare("SELECT COALESCE(SUM(tokens_in),0)::bigint ti, COALESCE(SUM(tokens_out),0)::bigint tout, COALESCE(SUM(tokens_cached),0)::bigint tc, COALESCE(SUM(cost_usd),0) c FROM requests WHERE run_id = ? AND status = 'ok'").get(id);
+ // Balance: nothing reached a provider (or it failed before the first token) releases the hold; an abort
+ // or a failure after tokens streamed settles the partial usage it did produce.
+ if (credit && credit.holdId) {
+ try {
+ const streamedOut = Math.ceil(credit.streamedChars / 4);
+ const produced = Number(spent.ti) > 0 || Number(spent.tout) > 0 || streamedOut > 0 || logged.some((e) => e.status === 'ok');
+ if (produced) {
+ await settleCredits(credit, {
+ usage: { input: Math.max(Number(spent.ti) || 0, credit.inputTokens), output: Math.max(Number(spent.tout) || 0, streamedOut), cached: Number(spent.tc) || 0, cache_write: 0 },
+ costUsd: Number(spent.c) || 0,
+ explain: err instanceof AiError && err.extra && err.extra.explain ? err.extra.explain : null, logged,
+ startedAt: row.started_at || iso(clock.now()), endedAt: iso(clock.now()),
+ });
+ } else {
+ await credit.release();
+ }
+ } catch (cers) {
+ log.warn(`[credits] ${id} ${cers && cers.code || 'release/settle'}: ${cers && cers.message}`);
+ }
+ }
// A run that broke still spent: its record says what it cost and who paid (L40).
// A run that broke still spent: its record says what it cost and who paid (L40).
await publishUsage({ row: await getRow(id), outcome: cancelled ? 'cancelled' : 'failed',
await publishUsage({ row: await getRow(id), outcome: cancelled ? 'cancelled' : 'failed',
usage: { input: Number(spent.ti) || 0, output: Number(spent.tout) || 0, cached: Number(spent.tc) || 0 },
usage: { input: Number(spent.ti) || 0, output: Number(spent.tout) || 0, cached: Number(spent.tc) || 0 },
new file mode 100644
new file mode 100644
@@ -0,0 +1,281 @@
@@ -0,0 +1,281 @@
+'use strict';
+/**
+ * Owner settings (D11): AI's configuration on openvibe-shared/config.
+ *
+ * ai.exchange the AI Exchange's switches: the model catalogue, Billing Credits (danger) and the
+ * routing-evidence flag with its parameters. Every key keeps today's environment variable
+ * as its x-env pin, so the env wins while it is set and nothing changes until the owner sets
+ * one; onActivate puts a change into the live config object.
+ * ai.provider_keys the home of the owner's provider keys, replacing the provider_secrets vault. For each
+ * provider `<p>.origin` is the origin of its base URL (AI writes it whenever a provider is
+ * created or its base URL changes) and `<p>.key` is the secret, x-bind'ed to `<p>.origin`, so
+ * a key can never be sent to a host it was not entered for.
+ *
+ * The provider pool resolves a key synchronously (server/vault.js holds the in-process cache), so the provider-key
+ * store refreshes that cache in onActivate. The proxy below lets the factory rebuild the namespace's schema when a
+ * provider is added, without remounting the admin routes.
+ */
+const { createConfigStore, ConfigError } = require('openvibe-shared/config');
+const { AiError } = require('./util');
+
+const EXCHANGE_NAMESPACE = 'ai.exchange';
+const PROVIDER_KEYS_NAMESPACE = 'ai.provider_keys';
+const SERVICE = 'ai';
+const SELF = Object.freeze({ type: 'service', id: SERVICE });
+const GROUP = 'AI Exchange';
+const PROVIDER_GROUP = 'Provider keys';
+
+/** Bounds from server/config.js (load()), so the schema and the code agree. */
+const EXCHANGE_SCHEMA = Object.freeze({
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'AI Exchange',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ model_catalog: {
+ type: 'boolean', title: 'Model catalogue', 'x-group': GROUP, 'x-env': 'AI_MODEL_CATALOG',
+ description: 'Serve the truthful model catalogue: exact ids, dynamic aliases and the offers each route can use. Off means today\'s single `openvibe` model.',
+ },
+ credits: {
+ type: 'boolean', title: 'Credits', 'x-group': GROUP, 'x-env': 'AI_CREDITS', 'x-danger': true,
+ description: 'Paid AI requests hold and spend Credits',
+ },
+ routing_evidence: {
+ type: 'boolean', title: 'Routing evidence', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE',
+ description: 'Rank alias routes by measured cost per success instead of their recorded order.',
+ },
+ alpha: {
+ type: 'number', title: 'Evidence EWMA alpha', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_ALPHA',
+ minimum: 0.01, maximum: 1,
+ description: 'How fast a new measurement moves the per-deployment EWMA (0.01 keeps the most history, 1 trusts the last call).',
+ },
+ min_samples: {
+ type: 'integer', title: 'Minimum samples', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_SAMPLES',
+ minimum: 1,
+ description: 'Below this many attempts evidence is sparse: it is reported, never trusted to reorder a route.',
+ },
+ success_floor: {
+ type: 'number', title: 'Success floor', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_SUCCESS_FLOOR',
+ minimum: 0.01, maximum: 1,
+ description: 'A candidate below this success probability is never promoted by evidence.',
+ },
+ hysteresis_decisions: {
+ type: 'integer', title: 'Hysteresis decisions', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_DECISIONS',
+ minimum: 1,
+ description: 'Consecutive decisions a challenger must win before it replaces the incumbent.',
+ },
+ exploration_share: {
+ type: 'number', title: 'Exploration share', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_EXPLORE',
+ minimum: 0, maximum: 1,
+ description: 'The share of decisions that explore a challenger; 0 never leaves the incumbent.',
+ },
+ affinity_gain: {
+ type: 'number', title: 'Affinity gain', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_AFFINITY',
+ minimum: 0, maximum: 1,
+ description: 'How much a deployment\'s familiarity with a prompt family adds to its measured score.',
+ },
+ min_gain: {
+ type: 'number', title: 'Minimum gain', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_GAIN',
+ minimum: 0, maximum: 1,
+ description: 'A challenger replaces the incumbent only when it scores at least this much better.',
+ },
+ expected_output_tokens: {
+ type: 'integer', title: 'Expected output tokens', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_OUTPUT_TOKENS',
+ minimum: 1,
+ description: 'The output-token estimate used to price a candidate before it answers.',
+ },
+ },
+});
+
+/** The store's effective values put into the live config object (undefined keys keep the code/env default). */
+function applyExchange(config, values = {}) {
+ const has = (k) => Object.prototype.hasOwnProperty.call(values, k) && values[k] !== undefined;
+ if (has('model_catalog')) config.modelCatalog.enabled = Boolean(values.model_catalog);
+ if (has('credits')) config.credits.enabled = Boolean(values.credits);
+ const re = config.routingEvidence;
+ if (has('routing_evidence')) re.enabled = Boolean(values.routing_evidence);
+ for (const key of ['alpha', 'min_samples', 'success_floor', 'hysteresis_decisions', 'exploration_share', 'affinity_gain', 'min_gain', 'expected_output_tokens']) {
+ if (has(key)) re[key] = Number(values[key]);
+ }
+}
+
+/** The origin a provider key is bound to ('' when the base URL is missing or not http(s)). */
+function originOf(baseUrl) {
+ if (!baseUrl) return '';
+ try {
+ const u = new URL(String(baseUrl).trim());
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.origin : '';
+ } catch { return ''; }
+}
+
+/** The namespace for the providers there are today: <p>.origin (internal) and <p>.key (secret, bound to it). */
+function providerKeysSchema(providers) {
+ const properties = {};
+ for (const key of providers) {
+ properties[`${key}.origin`] = {
+ type: 'string', title: `${key} origin`, 'x-group': PROVIDER_GROUP,
+ description: `The origin of ${key}'s base URL. AI writes it; the key below is sealed to it.`,
+ };
+ properties[`${key}.key`] = {
+ type: 'string', title: `${key} key`, 'x-group': PROVIDER_GROUP, 'x-bind': `${key}.origin`,
+ description: `The owner's write-only API key for ${key}, sealed to its origin; changing the base URL locks it until it is entered again.`,
+ };
+ }
+ return { $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'AI provider keys', type: 'object', additionalProperties: false, properties };
+}
+
+const classify = (key) => (String(key).endsWith('.key') ? 'secret' : 'internal');
+
+/**
+ * The ai.provider_keys store, with the schema rebuilt when a provider is added. `store` is a Proxy so the admin
+ * routes, mounted once, keep using the current store; the vault cache is refreshed in onActivate.
+ */
+function createProviderKeys({ db, registry, env = process.env, log = console, onChange }) {
+ const vault = require('./vault');
+ let store = null;
+ let names = [];
+ let queue = Promise.resolve();
+ const serialized = (fn) => { const run = queue.then(fn, fn); queue = run.catch(() => {}); return run; };
+
+ /** onActivate: refresh the synchronous cache and rebuild the adapters of providers whose key changed. */
+ async function activate(values, prev) {
+ vault.refresh(values);
+ const changed = new Set();
+ for (const [k, v] of Object.entries(values || {})) {
+ if (k.endsWith('.key') && (!prev || prev[k] !== v)) changed.add(k.slice(0, -4));
+ }
+ for (const k of Object.keys(prev || {})) if (k.endsWith('.key') && !(values && Object.prototype.hasOwnProperty.call(values, k))) changed.add(k.slice(0, -4));
+ for (const key of changed) {
+ if (!await registry.getProvider(key)) continue;
+ // The pool's adapter cache stamps itself with updated_at: bumping it rebuilds the adapter with the new key.
+ await registry.upsertProvider({ key }, { actor: `service:${SERVICE}`, origin: 'settings' }).catch((err) => log.warn(`[config] ${PROVIDER_KEYS_NAMESPACE}: ${key} adapter refresh failed: ${err.message}`));
+ }
+ }
+
+ async function rebuild() {
+ const providers = await registry.listProviders();
+ const next = [...new Set(providers.map((p) => p.key))].sort();
+ if (store && next.length === names.length && next.every((k, i) => k === names[i])) return store;
+ store = await createConfigStore({
+ db, service: SERVICE, namespace: PROVIDER_KEYS_NAMESPACE, schema: providerKeysSchema(next),
+ classify, env, log, onChange, onActivate: activate,
+ });
+ names = next;
+ vault.refresh(store.get());
+ return store;
+ }
+
+ const proxy = new Proxy({}, {
+ get(_target, prop) {
+ if (prop === 'namespace') return PROVIDER_KEYS_NAMESPACE;
+ if (prop === 'service') return SERVICE;
+ if (prop === 'then') return undefined;
+ if (prop === 'setSecret') return async (key, value, o) => {
+ try { return await setKey(String(key).replace(/\.key$/, ''), value, o); }
+ catch (err) { throw err instanceof ConfigError ? err : new ConfigError(err.code || 'config.secret_invalid', err.message, { status: err.status || 422 }); }
+ };
+ if (!store) throw new AiError(503, 'config.unavailable', 'the provider keys store is not ready');
+ const value = store[prop];
+ return typeof value === 'function' ? value.bind(store) : value;
+ },
+ });
+
+ async function refresh() { return serialized(rebuild); }
+
+ async function ensure(providerKey) {
+ if (!names.includes(providerKey)) await rebuild();
+ if (!names.includes(providerKey)) throw new AiError(422, 'ai.invalid', `add the provider ${providerKey} before its key`);
+ }
+
+ function setOrigin(providerKey, baseUrl, { actor = SELF, reason = 'provider origin' } = {}) {
+ return serialized(async () => {
+ await ensure(providerKey);
+ const origin = originOf(baseUrl);
+ if (!origin) return '';
+ const key = `${providerKey}.origin`;
+ const changes = await store.changes({ [key]: origin }, { merge: true });
+ if (changes.length) await store.apply({ [key]: origin }, { merge: true, actor, reason });
+ return origin;
+ });
+ }
+
+ /** A provider key: write-only, sealed and bound to the provider's current origin. */
+ function setKey(providerKey, value, { actor = SELF, reason = 'provider key' } = {}) {
+ return serialized(async () => {
+ const provider = await registry.getProvider(providerKey);
+ if (!provider) throw new AiError(422, 'ai.invalid', `add the provider ${providerKey} before its key`);
+ await ensure(providerKey);
+ const origin = originOf(provider.base_url);
+ if (!origin) throw new AiError(422, 'ai.invalid', `${providerKey} has no base URL to bind its key to`);
+ const originKey = `${providerKey}.origin`;
+ if ((await store.changes({ [originKey]: origin }, { merge: true })).length) {
+ await store.apply({ [originKey]: origin }, { merge: true, actor: SELF, reason: 'provider origin' });
+ }
+ return await store.setSecret(`${providerKey}.key`, value, { actor, reason });
+ });
+ }
+
+ function deleteKey(providerKey, o = {}) {
+ return serialized(async () => {
+ if (!names.includes(providerKey)) return null;
+ return await store.deleteSecret(`${providerKey}.key`, o);
+ });
+ }
+
+ /** Write every provider's current origin (boot, and a provider created or moved by the API). */
+ function syncOrigins({ actor = SELF, reason = 'provider origin' } = {}) {
+ return serialized(async () => {
+ await rebuild();
+ const values = {};
+ for (const p of await registry.listProviders()) {
+ const origin = originOf(p.base_url);
+ if (origin) values[`${p.key}.origin`] = origin;
+ }
+ const changes = await store.changes(values, { merge: true });
+ if (changes.length) await store.apply(values, { merge: true, actor, reason });
+ return changes.length;
+ });
+ }
+
+ return {
+ proxy,
+ refresh,
+ setOrigin,
+ setKey,
+ deleteKey,
+ syncOrigins,
+ namespace: PROVIDER_KEYS_NAMESPACE,
+ get sealed() { return Boolean(store && store.sealed); },
+ get store() { return store; },
+ get providers() { return [...names]; },
+ };
+}
+
+/**
+ * Create both namespaces (the DDL is migrations/0028_config.sql), apply ai.exchange to the live config object,
+ * load the provider-key cache and reseal plaintext/old-KEK rows once when OV_CONFIG_KEKS is set.
+ */
+async function createSettings({ db, config, registry, clock = { now: () => Date.now() }, env = process.env, log = console, onExchange = null }) {
+ const onChange = async ({ namespace, keys, actor, revision }) => {
+ const who = actor ? `${actor.type}:${actor.id}` : 'system';
+ await registry.audit(who, 'config.change', 'config', namespace, { metadata: { service: SERVICE, namespace, keys, actor: who, revision } });
+ };
+ const exchange = await createConfigStore({
+ db, service: SERVICE, namespace: EXCHANGE_NAMESPACE, schema: EXCHANGE_SCHEMA, env, log, onChange,
+ onActivate: (values) => {
+ applyExchange(config, values);
+ return onExchange ? onExchange(values) : undefined;
+ },
+ });
+ applyExchange(config, exchange.get());
+ const providerKeys = createProviderKeys({ db, registry, env, log, onChange });
+ await providerKeys.refresh();
+ if (exchange.sealed) await exchange.reseal().catch((err) => log.warn(`[config] ${EXCHANGE_NAMESPACE}: reseal failed: ${err.message}`));
+ if (providerKeys.sealed) await providerKeys.store.reseal().catch((err) => log.warn(`[config] ${PROVIDER_KEYS_NAMESPACE}: reseal failed: ${err.message}`));
+ return { exchange, providerKeys, onChange };
+}
+
+module.exports = {
+ EXCHANGE_NAMESPACE, PROVIDER_KEYS_NAMESPACE, SERVICE, SELF, EXCHANGE_SCHEMA,
+ applyExchange, originOf, providerKeysSchema, createSettings,
+};
@@ -1,11 +1,16 @@
@@ -1,11 +1,16 @@
'use strict';
'use strict';
/**
/**
- * Provider keys an owner adds from the operator console (migrations/0010_provider_secrets.sql). A provider whose
- * secret_ref is 'vault:<provider key>' (util.resolveSecret) reads its key from here.
+ * Provider keys, now kept in the ai.provider_keys configuration namespace (D11) instead of the provider_secrets
+ * table. The owner's key is a write-only secret-class config value bound to the provider's origin, so a key can
+ * never be sent to a host it was not entered for.
*
*
- * At rest each key is AES-256-GCM encrypted with AI_CREDENTIALS_KEY, bound to its provider key (the additional data).
- * The decrypted keys are held in this process only (load() at boot and after every change), because the provider pool
- * resolves secrets synchronously; nothing here logs, returns or audits a value — only a four-character hint.
+ * The provider pool resolves a key synchronously (util.resolveSecret → get), so this module still holds the
+ * in-process cache: the config store's onActivate refreshes it (server/settings.js). Nothing here logs, returns or
+ * audits a value — only a four-character hint, from the store's marker.
+ *
+ * The legacy provider_secrets rows (AES-256-GCM under AI_CREDENTIALS_KEY) are migrated once at boot by migrate():
+ * each one is opened, written as the sealed `<provider>.key` bound to the provider's current origin, read back, and
+ * only then deleted. It is idempotent, and logs counts only.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { AiError } = require('./util');
const { AiError } = require('./util');
@@ -15,10 +20,11 @@ const keys = new Map(); // provider key → plaintext, this process only
@@ -15,10 +20,11 @@ const keys = new Map(); // provider key → plaintext, this process only
function secretKey(config) {
function secretKey(config) {
const hex = String((config && config.credentialsKey) || '');
const hex = String((config && config.credentialsKey) || '');
- if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here: AI_CREDENTIALS_KEY is not set');
+ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be read here: AI_CREDENTIALS_KEY is not set');
return Buffer.from(hex, 'hex');
return Buffer.from(hex, 'hex');
}
}
+/** The legacy vault's seal (provider key as AAD), read only by the one-time migration. */
function seal(config, plain, aad) {
function seal(config, plain, aad) {
const iv = crypto.randomBytes(12);
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', secretKey(config), iv);
const c = crypto.createCipheriv('aes-256-gcm', secretKey(config), iv);
@@ -36,44 +42,90 @@ function open(config, sealed, aad) {
@@ -36,44 +42,90 @@ function open(config, sealed, aad) {
return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');
return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');
}
}
-function createVault({ db, config, clock = { now: () => Date.now() }, log = console }) {
- /** Read every stored key into this process (a key that no longer decrypts is skipped and named in the log). */
- async function load() {
- const rows = await db.prepare('SELECT provider_key, sealed FROM provider_secrets').all();
- keys.clear();
- for (const r of rows) {
- try { keys.set(r.provider_key, open(config, r.sealed, r.provider_key)); } catch { log.warn(`[Vault] the key of provider ${r.provider_key} does not decrypt (was AI_CREDENTIALS_KEY changed?)`); }
- }
- return keys.size;
+/** Fill the synchronous cache from the store's effective values (onActivate, boot). Locked secrets are absent. */
+function refresh(values = {}) {
+ keys.clear();
+ for (const [k, v] of Object.entries(values)) {
+ if (k.endsWith('.key') && typeof v === 'string' && v) keys.set(k.slice(0, -4), v);
}
}
+ return keys.size;
+}
+
+/** The key of a provider whose secret_ref is 'vault:<key>' ('' when none is stored or it does not open). */
+function get(providerKey) { return keys.get(providerKey) || ''; }
+
+function actorRef(actor) {
+ if (actor && typeof actor === 'object' && actor.type && actor.id) return { type: String(actor.type), id: String(actor.id) };
+ return { type: 'service', id: 'ai' };
+}
+
+function createVault({ settings, clock = { now: () => Date.now() }, log = console }) {
+ if (!settings || !settings.providerKeys) throw new TypeError('createVault: pass the D11 settings');
+
+ async function load() { return refresh(settings.providerKeys.store.get()); }
async function put(providerKey, plain, { actor = 'system' } = {}) {
async function put(providerKey, plain, { actor = 'system' } = {}) {
if (!KEY_RE.test(providerKey || '')) throw new AiError(422, 'ai.invalid', 'provider key must be lowercase dotted/dashed');
if (!KEY_RE.test(providerKey || '')) throw new AiError(422, 'ai.invalid', 'provider key must be lowercase dotted/dashed');
const value = String(plain || '').trim();
const value = String(plain || '').trim();
if (value.length < 8 || value.length > 4000 || /\s/.test(value)) throw new AiError(422, 'ai.invalid', 'that does not look like an API key');
if (value.length < 8 || value.length > 4000 || /\s/.test(value)) throw new AiError(422, 'ai.invalid', 'that does not look like an API key');
- const sealed = seal(config, value, providerKey);
- const t = new Date(clock.now()).toISOString();
- await db.prepare(`INSERT INTO provider_secrets (provider_key, sealed, hint, updated_by, updated_at) VALUES (?, ?, ?, ?, ?)
- ON CONFLICT (provider_key) DO UPDATE SET sealed = excluded.sealed, hint = excluded.hint, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
- .run(providerKey, sealed, `…${value.slice(-4)}`, actor, t);
- keys.set(providerKey, value);
- return { provider_key: providerKey, hint: `…${value.slice(-4)}`, updated_at: t };
+ const marker = await settings.providerKeys.setKey(providerKey, value, { actor: actorRef(actor), reason: 'provider key set from the console' });
+ return { provider_key: providerKey, hint: marker.hint, updated_at: marker.set_at };
}
}
async function remove(providerKey) {
async function remove(providerKey) {
- await db.prepare('DELETE FROM provider_secrets WHERE provider_key = ?').run(providerKey);
- keys.delete(providerKey);
+ await settings.providerKeys.deleteKey(providerKey, { actor: { type: 'service', id: 'ai' }, reason: 'provider key removed' });
}
}
+ /** Name → marker (hint, set_at, set_by) for the console's provider page; never a value. */
async function hints() {
async function hints() {
- const rows = await db.prepare('SELECT provider_key, hint, updated_by, updated_at FROM provider_secrets').all();
- return new Map(rows.map((r) => [r.provider_key, { hint: r.hint, updated_by: r.updated_by, updated_at: r.updated_at }]));
+ const summary = await settings.providerKeys.store.summary();
+ const out = new Map();
+ for (const [k, m] of Object.entries(summary.secrets || {})) {
+ if (!k.endsWith('.key')) continue;
+ out.set(k.slice(0, -4), { hint: m.hint ? `…${m.hint}` : null, updated_by: m.set_by, updated_at: m.set_at });
+ }
+ return out;
}
}
return { load, put, remove, hints };
return { load, put, remove, hints };
}
}
-/** The key of a provider whose secret_ref is 'vault:<key>' ('' when none is stored). */
-function get(providerKey) { return keys.get(providerKey) || ''; }
+/**
+ * One-time, idempotent vault → ai.provider_keys migration (AI_CREDENTIALS_KEY + OV_CONFIG_KEKS set). Returns
+ * { migrated, already, failed, skipped } counts; a row is deleted only after the sealed key reads back.
+ */
+async function migrate({ db, config, settings, log = console }) {
+ const none = { migrated: 0, already: 0, failed: 0, skipped: 0 };
+ if (!settings || !settings.providerKeys) return none;
+ if (!/^[0-9a-f]{64}$/i.test(String((config && config.credentialsKey) || ''))) return none;
+ if (!settings.providerKeys.sealed) return none;
+ const rows = await db.prepare('SELECT provider_key, sealed FROM provider_secrets').all();
+ const report = { ...none };
+ if (!rows.length) return report;
+ const store = () => settings.providerKeys.store;
+ const before = new Set(Object.keys((await store().summary()).secrets || {}));
+ for (const row of rows) {
+ let plain;
+ try { plain = open(config, row.sealed, row.provider_key); } catch { report.failed++; continue; }
+ const key = `${row.provider_key}.key`;
+ try {
+ if (before.has(key)) {
+ if (store().get(key) === plain) { await db.prepare('DELETE FROM provider_secrets WHERE provider_key = ?').run(row.provider_key); report.already++; }
+ else report.failed++;
+ continue;
+ }
+ await settings.providerKeys.setKey(row.provider_key, plain, { actor: { type: 'service', id: 'ai' }, reason: 'migrated from AI_CREDENTIALS_KEY vault' });
+ if (store().get(key) !== plain) throw new Error('the sealed key did not read back');
+ await db.prepare('DELETE FROM provider_secrets WHERE provider_key = ?').run(row.provider_key);
+ before.add(key);
+ report.migrated++;
+ } catch (err) {
+ if (/no base URL/.test(String(err && err.message))) report.skipped++;
+ else report.failed++;
+ }
+ }
+ log.log(`[Vault] provider-key migration: ${report.migrated} moved, ${report.already} already, ${report.skipped} without an origin, ${report.failed} failed`);
+ return report;
+}
-module.exports = { createVault, get, KEY_RE, _seal: seal, _open: open, _keys: keys };
+module.exports = { createVault, migrate, get, refresh, KEY_RE, _seal: seal, _open: open, _keys: keys };
@@ -8,7 +8,9 @@ const { PASSTHROUGH_OUTPUT } = require('./live');
@@ -8,7 +8,9 @@ const { PASSTHROUGH_OUTPUT } = require('./live');
const STR = (n) => ({ type: 'string', maxLength: n });
const STR = (n) => ({ type: 'string', maxLength: n });
const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, required, properties: props });
const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, required, properties: props });
-const COMMON = { max_tokens: { type: 'integer', minimum: 1, maximum: 16000 }, temperature: { type: ['number', 'null'], minimum: 0, maximum: 2 } };
+const COMMON = { max_tokens: { type: 'integer', minimum: 1, maximum: 16000 }, temperature: { type: ['number', 'null'], minimum: 0, maximum: 2 },
+ // AI Exchange F: a liability cap on the Balance hold for this request (optional; nothing when unset).
+ max_cost_usd: { type: ['number', 'string', 'null'] } };
const templates = [
const templates = [
{
{
@@ -90,6 +92,8 @@ const workflows = [
@@ -90,6 +92,8 @@ const workflows = [
// the prompt (passthrough). The output is the completion's choices.
// the prompt (passthrough). The output is the completion's choices.
key: 'ai.complete', name: 'Chat completion (OpenAI-compatible)', namespace: 'ai',
key: 'ai.complete', name: 'Chat completion (OpenAI-compatible)', namespace: 'ai',
input_schema: OBJ({
input_schema: OBJ({
+ // CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/openai.js).
+ model: STR(256),
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
role: { enum: ['system', 'developer', 'user', 'assistant', 'tool'] },
role: { enum: ['system', 'developer', 'user', 'assistant', 'tool'] },
content: { anyOf: [{ type: 'string', maxLength: 400000 }, { type: 'array', maxItems: 100 }, { type: 'null' }] },
content: { anyOf: [{ type: 'string', maxLength: 400000 }, { type: 'array', maxItems: 100 }, { type: 'null' }] },
@@ -116,6 +120,8 @@ const workflows = [
@@ -116,6 +120,8 @@ const workflows = [
// cost and explain but not the prompt (passthrough). The output is the message it answered with.
// cost and explain but not the prompt (passthrough). The output is the message it answered with.
key: 'ai.messages', name: 'Messages API (Anthropic-compatible)', namespace: 'ai',
key: 'ai.messages', name: 'Messages API (Anthropic-compatible)', namespace: 'ai',
input_schema: OBJ({
input_schema: OBJ({
+ // CHECKPOINT D3: an exact id or a dynamic alias, when AI_MODEL_CATALOG is on (server/api/messages.js).
+ model: STR(256),
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
messages: { type: 'array', minItems: 1, maxItems: 500, items: { type: 'object', required: ['role'], properties: {
role: { enum: ['user', 'assistant'] },
role: { enum: ['user', 'assistant'] },
content: { anyOf: [STR(400000), { type: 'array', maxItems: 500 }, { type: 'null' }] },
content: { anyOf: [STR(400000), { type: 'array', maxItems: 500 }, { type: 'null' }] },
@@ -124,6 +130,7 @@ const workflows = [
@@ -124,6 +130,7 @@ const workflows = [
tools: { type: 'array', maxItems: 128, items: { type: 'object' } },
tools: { type: 'array', maxItems: 128, items: { type: 'object' } },
tool_choice: { type: 'object' },
tool_choice: { type: 'object' },
max_tokens: { type: 'integer', minimum: 1, maximum: 64000 },
max_tokens: { type: 'integer', minimum: 1, maximum: 64000 },
+ max_cost_usd: { type: ['number', 'string', 'null'] },
stop_sequences: { type: 'array', maxItems: 8, items: STR(200) },
stop_sequences: { type: 'array', maxItems: 8, items: STR(200) },
temperature: { type: ['number', 'null'], minimum: 0, maximum: 1 },
temperature: { type: ['number', 'null'], minimum: 0, maximum: 1 },
top_p: { type: ['number', 'null'], minimum: 0, maximum: 1 },
top_p: { type: ['number', 'null'], minimum: 0, maximum: 1 },
@@ -33,6 +33,7 @@ const { render } = require('../templates');
@@ -33,6 +33,7 @@ const { render } = require('../templates');
const { preferenceLines } = require('../user-modules');
const { preferenceLines } = require('../user-modules');
const schemas = require('../schemas');
const schemas = require('../schemas');
const { PREPARE, POSTPROCESS } = require('./hooks');
const { PREPARE, POSTPROCESS } = require('./hooks');
+const modelCatalog = require('../providers/model-catalog');
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
const INLINE_AUDIO_MAX = 1024 * 1024;
const INLINE_AUDIO_MAX = 1024 * 1024;
@@ -215,9 +216,27 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -215,9 +216,27 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
return { output: { text: exec.result.text || '', json }, exec, route };
return { output: { text: exec.result.text || '', json }, exec, route };
}
}
+ /**
+ * The model the caller named (AI Exchange D3/D4, AI_MODEL_CATALOG): an exact id runs only its own artifact, a
+ * dynamic alias turns into the same route with that policy's placement constraints. With the flag off this is
+ * never called, so today's route selection is unchanged.
+ */
+ async function selectedRoute(base, input) {
+ if (!(config.modelCatalog && config.modelCatalog.enabled) || typeof input.model !== 'string' || !input.model.trim()) return { route: base, selection: null };
+ const selection = await modelCatalog.select({ registry, model: input.model.trim(), route: base });
+ return { route: selection.route, selection };
+ }
+
+ function selectionCtx(selection) {
+ if (!selection) return {};
+ return selection.kind === 'exact'
+ ? { policy: 'exact', exact: { id: selection.id, offers: selection.offers.map((o) => ({ provider: o.provider, model: o.model })) } }
+ : { policy: selection.policy, aliasPolicy: selection.policy };
+ }
+
/** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */
/** complete: the OpenAI Chat Completions request as the caller wrote it (ai.complete), to an upstream that speaks it. */
async function completeStep(step, input, wf, run, ctx) {
async function completeStep(step, input, wf, run, ctx) {
- const route = await routeFor(step.route || 'default.chat');
+ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.chat'), input);
const openai = {};
const openai = {};
for (const k of ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
for (const k of ['messages', 'tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
if (input[k] !== undefined) openai[k] = input[k];
if (input[k] !== undefined) openai[k] = input[k];
@@ -230,7 +249,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -230,7 +249,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
prefer: ctx.prefer || null,
prefer: ctx.prefer || null,
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
};
};
- const exec = await pool.execute(route, 'complete', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });
+ const exec = await pool.execute(route, 'complete', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });
const c = exec.result.completion;
const c = exec.result.completion;
if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');
if (!c || !Array.isArray(c.choices) || !c.choices.length) throw new AiError(502, 'output.empty', 'the provider answered, but with no choices');
return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };
return { output: { choices: c.choices, system_fingerprint: c.system_fingerprint || null, model: exec.model }, exec, route };
@@ -241,7 +260,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -241,7 +260,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
* upstream that speaks that format. The run's output is the message (consumed by server/api/messages.js).
* upstream that speaks that format. The run's output is the message (consumed by server/api/messages.js).
*/
*/
async function messagesStep(step, input, wf, run, ctx) {
async function messagesStep(step, input, wf, run, ctx) {
- const route = await routeFor(step.route || 'default.messages');
+ const { route, selection } = await selectedRoute(await routeFor(step.route || 'default.messages'), input);
const req = {
const req = {
messages: input.messages,
messages: input.messages,
maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),
maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),
@@ -252,7 +271,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -252,7 +271,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
for (const k of ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata']) {
for (const k of ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata']) {
if (input[k] !== undefined) req[k] = input[k];
if (input[k] !== undefined) req[k] = input[k];
}
}
- const exec = await pool.execute(route, 'messages', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: input.messages, t: input.tools || null }) });
+ const exec = await pool.execute(route, 'messages', req, { ...ctx, ...selectionCtx(selection), routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: input.messages, t: input.tools || null }) });
const m = exec.result.message;
const m = exec.result.message;
if (!m || !Array.isArray(m.content)) throw new AiError(502, 'output.empty', 'the provider answered, but not with a message');
if (!m || !Array.isArray(m.content)) throw new AiError(502, 'output.empty', 'the provider answered, but not with a message');
return { output: { ...m, model: m.model || exec.model }, exec, route };
return { output: { ...m, model: m.model || exec.model }, exec, route };
@@ -51,16 +51,16 @@ function sharedProviderRecord(config, env) {
@@ -51,16 +51,16 @@ function sharedProviderRecord(config, env) {
function providerRecords(config, env) {
function providerRecords(config, env) {
const out = [
const out = [
- { key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, metadata: { synthetic: true, billing_profile: 'free' } },
+ { key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, provider_access: 'OWN_DEPLOYMENT', metadata: { synthetic: true, billing_profile: 'free' } },
sharedProviderRecord(config, env),
sharedProviderRecord(config, env),
- { key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },
+ { key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, provider_access: 'OWN_DEPLOYMENT', metadata: { configured_by: 'WHISPER_*', billing_profile: 'free' } },
];
];
if (config.localLlm && config.localLlm.url) {
if (config.localLlm && config.localLlm.url) {
// A local model server (llama.cpp, Ollama) speaking the OpenAI API: no key, never paid (WS-O task 5).
// A local model server (llama.cpp, Ollama) speaking the OpenAI API: no key, never paid (WS-O task 5).
// No json: the small local model must not be offered the product JSON routes. Embed only with an embedding model.
// No json: the small local model must not be offered the product JSON routes. Embed only with an embedding model.
const localCaps = ['chat', 'generate', 'summarize', 'classify', 'extract'];
const localCaps = ['chat', 'generate', 'summarize', 'classify', 'extract'];
if (config.localLlm.embedModel) localCaps.push('embed');
if (config.localLlm.embedModel) localCaps.push('embed');
- out.push({ key: 'local', display_name: 'Local model (OpenAI-compatible server on this host)', kind: 'openai', status: 'active', base_url: config.localLlm.url, auth_mode: 'none', secret_ref: null, default_model: config.localLlm.model || 'local', capabilities: localCaps, timeout_ms: config.localLlm.timeoutMs, priority: 5, metadata: { configured_by: 'AI_LOCAL_LLM_URL', embed_url: config.localLlm.embedUrl || config.localLlm.url, local: true, paid: false, billing_profile: 'free' } });
+ out.push({ key: 'local', display_name: 'Local model (OpenAI-compatible server on this host)', kind: 'openai', status: 'active', base_url: config.localLlm.url, auth_mode: 'none', secret_ref: null, default_model: config.localLlm.model || 'local', capabilities: localCaps, timeout_ms: config.localLlm.timeoutMs, priority: 5, provider_access: 'OWN_DEPLOYMENT', metadata: { configured_by: 'AI_LOCAL_LLM_URL', embed_url: config.localLlm.embedUrl || config.localLlm.url, local: true, paid: false, billing_profile: 'free' } });
}
}
if (config.httpSeamUrl) {
if (config.httpSeamUrl) {
out.push({ key: 'http-seam', display_name: 'Local HTTP seam', kind: 'http', status: 'active', base_url: config.httpSeamUrl, auth_mode: 'none', default_model: null, capabilities: [], timeout_ms: 30000, priority: 60, metadata: { configured_by: 'AI_HTTP_SEAM_URL', billing_profile: 'metered' } });
out.push({ key: 'http-seam', display_name: 'Local HTTP seam', kind: 'http', status: 'active', base_url: config.httpSeamUrl, auth_mode: 'none', default_model: null, capabilities: [], timeout_ms: 30000, priority: 60, metadata: { configured_by: 'AI_HTTP_SEAM_URL', billing_profile: 'metered' } });
@@ -91,6 +91,7 @@ t.test('every other route is first-party only: an app token gets 403', async ()
@@ -91,6 +91,7 @@ t.test('every other route is first-party only: an app token gets 403', async ()
['POST', `/api/v1/runs/${genRun.id}/cancel`, undefined],
['POST', `/api/v1/runs/${genRun.id}/cancel`, undefined],
['POST', '/api/v1/enrich', { record: {}, instructions: 'x' }],
['POST', '/api/v1/enrich', { record: {}, instructions: 'x' }],
['GET', '/api/v1/providers', undefined],
['GET', '/api/v1/providers', undefined],
+ ['GET', '/internal/config', undefined],
];
];
for (const [method, path, body] of routes) {
for (const [method, path, body] of routes) {
const r = await request(h.base, method, path, { tok: appA, body });
const r = await request(h.base, method, path, { tok: appA, body });
@@ -41,7 +41,7 @@ t.test('a token without the capability is 403 capability.denied', async () => {
@@ -41,7 +41,7 @@ t.test('a token without the capability is 403 capability.denied', async () => {
r = await request(h.base, 'POST', '/api/v1/summarize', { tok: token('live', ['events.event.publish']), body: { text: 'x' } });
r = await request(h.base, 'POST', '/api/v1/summarize', { tok: token('live', ['events.event.publish']), body: { text: 'x' } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
const runner = token('live', ['ai.run.create']);
const runner = token('live', ['ai.run.create']);
- for (const [m, p] of [['GET', '/api/v1/providers'], ['POST', '/api/v1/providers'], ['POST', '/api/v1/templates/ai.generate/versions'], ['GET', '/api/v1/usage'], ['GET', '/api/v1/audit'], ['POST', '/api/v1/quotas'], ['DELETE', '/api/v1/cache']]) {
+ for (const [m, p] of [['GET', '/api/v1/providers'], ['POST', '/api/v1/providers'], ['POST', '/api/v1/templates/ai.generate/versions'], ['GET', '/api/v1/usage'], ['GET', '/api/v1/audit'], ['POST', '/api/v1/quotas'], ['DELETE', '/api/v1/cache'], ['GET', '/internal/config'], ['POST', '/internal/config/ai.exchange']]) {
const x = await request(h.base, m, p, { tok: runner, body: m === 'GET' || m === 'DELETE' ? undefined : {} });
const x = await request(h.base, m, p, { tok: runner, body: m === 'GET' || m === 'DELETE' ? undefined : {} });
assert.strictEqual(x.status, 403, `${m} ${p}`);
assert.strictEqual(x.status, 403, `${m} ${p}`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+// D11: the one-time, idempotent vault (provider_secrets) -> ai.provider_keys migration. AI_CREDENTIALS_KEY and
+// OV_CONFIG_KEKS are both set; every row is opened, sealed as <provider>.key bound to its origin, read back, and only
+// then deleted. An env:NAME key is left alone. Logs carry counts only.
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, request, token, suite, tmpDir } = require('./helpers');
+
+const t = suite('config-migration');
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const KEK = crypto.randomBytes(32).toString('base64');
+const VAULT_KEY = crypto.randomBytes(32).toString('hex');
+const PLAIN = 'made-up-migrated-key-9c1d3e5f';
+const ENV_KEY_VALUE = 'env-sourced-provider-key-4a6b8c0d';
+const KEK_ENV = { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1' };
+const cfgTok = token('network', ['ai.config.manage', 'ai.provider.manage']);
+const logs = [];
+const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+const cfgReq = (h, method, path, { body } = {}) => request(h.base, method, path, { tok: cfgTok, body, headers: { 'X-OV-Subject': OWNER, 'X-OV-Fresh': '1' } });
+
+t.test('vault rows migrate once, sealed and bound to the origin; env-sourced keys are untouched', async () => {
+ const dir = tmpDir();
+ let first = await boot({ dir, env: { AI_CREDENTIALS_KEY: VAULT_KEY, CONFIG_TEST_KEY: ENV_KEY_VALUE, AI_USER_MODULES: 'off' } });
+ try {
+ await first.registry.upsertProvider({ key: 'oldprov', display_name: 'Old vault provider', kind: 'http', status: 'active', base_url: 'https://old.example/v1', auth_mode: 'bearer', secret_ref: 'vault:oldprov', capabilities: ['chat'], timeout_ms: 5000 });
+ await first.registry.upsertProvider({ key: 'envprov', display_name: 'Env provider', kind: 'http', status: 'active', base_url: 'https://env.example/v1', auth_mode: 'bearer', secret_ref: 'env:CONFIG_TEST_KEY', capabilities: ['chat'], timeout_ms: 5000 });
+ const vault = require('../server/vault');
+ await first.db.prepare('INSERT INTO provider_secrets (provider_key, sealed, hint, updated_by, updated_at) VALUES (?, ?, ?, ?, ?)')
+ .run('oldprov', vault._seal(first.config, PLAIN, 'oldprov'), `…${PLAIN.slice(-4)}`, 'usr:owner', new Date().toISOString());
+ } finally {
+ await first.stop();
+ }
+ const env = { ...KEK_ENV, AI_CREDENTIALS_KEY: VAULT_KEY, CONFIG_TEST_KEY: ENV_KEY_VALUE, AI_USER_MODULES: 'off' };
+ let second = await boot({ dir, env, log: capture });
+ let revision;
+ try {
+ assert.strictEqual(second.settings.providerKeys.store.get('oldprov.key'), PLAIN, 'the migrated key opens');
+ assert.strictEqual(require('../server/util').resolveSecret('vault:oldprov'), PLAIN, 'the pool resolves the migrated key');
+ const left = await second.db.prepare('SELECT COUNT(*) AS n FROM provider_secrets').get();
+ assert.strictEqual(Number(left.n), 0, 'the vault rows are deleted after the read-back');
+ const row = await second.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();
+ assert.match(JSON.parse(row.values_json)['oldprov.key'], /^v1:k1:/);
+ assert.ok(!row.values_json.includes(PLAIN));
+ const summary = await cfgReq(second, 'GET', '/internal/config/ai.provider_keys');
+ assert.strictEqual(summary.body.secrets['oldprov.key'].bound_to, 'https://old.example');
+ assert.strictEqual(second.settings.providerKeys.store.get('envprov.key'), undefined, 'an env: key is not copied into the store');
+ assert.strictEqual(require('../server/util').resolveSecret('env:CONFIG_TEST_KEY', second.env), ENV_KEY_VALUE);
+ revision = second.settings.providerKeys.store.revision();
+ assert.ok(!logs.some((line) => line.includes(PLAIN)), 'the migration logs counts only');
+ } finally {
+ await second.stop();
+ }
+ const third = await boot({ dir, env, log: capture });
+ try {
+ assert.strictEqual(third.settings.providerKeys.store.get('oldprov.key'), PLAIN, 'a restart reads the same sealed key');
+ assert.strictEqual(third.settings.providerKeys.store.revision(), revision, 'the second boot writes nothing (idempotent)');
+ assert.strictEqual(Number((await third.db.prepare('SELECT COUNT(*) AS n FROM provider_secrets').get()).n), 0);
+ assert.strictEqual(require('../server/util').resolveSecret('env:CONFIG_TEST_KEY', third.env), ENV_KEY_VALUE);
+ } finally {
+ await third.stop();
+ }
+});
+
+(async () => {
+ await t.run();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+// D11: a secret stored while no KEK was configured is resealed as an envelope at the next boot with OV_CONFIG_KEKS,
+// and stays readable (the store's reseal() runs once at boot).
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, request, token, suite, tmpDir } = require('./helpers');
+
+const t = suite('config-reseal');
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const KEK = crypto.randomBytes(32).toString('base64');
+const KEK_ENV = { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1' };
+const SECRET = 'made-up-config-key-THIS-MUST-NEVER-BE-SHOWN-7f3a9c2e1b';
+const cfgTok = token('network', ['ai.config.manage', 'ai.provider.manage']);
+const cfgReq = (h, method, path, { body } = {}) => request(h.base, method, path, { tok: cfgTok, body, headers: { 'X-OV-Subject': OWNER, 'X-OV-Fresh': '1' } });
+
+t.test('a plaintext row from a no-KEK boot is resealed at the next boot with a KEK', async () => {
+ const dir = tmpDir();
+ const first = await boot({ dir, env: { AI_USER_MODULES: 'off' } });
+ try {
+ await first.registry.upsertProvider({ key: 'resealprov', display_name: 'Reseal', kind: 'http', status: 'active', base_url: 'https://reseal.example/v1', auth_mode: 'bearer', secret_ref: 'vault:resealprov', capabilities: ['chat'], timeout_ms: 5000 });
+ const summary = await cfgReq(first, 'GET', '/internal/config');
+ assert.strictEqual(summary.status, 200, summary.text);
+ assert.strictEqual(summary.body.namespaces.find((n) => n.namespace === 'ai.provider_keys').sealed, false, 'no KEK: the store still works and says so');
+ const put = await cfgReq(first, 'PUT', '/internal/config/ai.provider_keys/secrets/resealprov.key', { body: { value: SECRET, reason: 'unsealed' } });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual(first.settings.providerKeys.store.get('resealprov.key'), SECRET);
+ const row = await first.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();
+ assert.strictEqual(JSON.parse(row.values_json)['resealprov.key'], SECRET, 'no KEK: the store keeps the value as it is');
+ } finally {
+ await first.stop();
+ }
+ const second = await boot({ dir, env: { ...KEK_ENV, AI_USER_MODULES: 'off' } });
+ try {
+ assert.strictEqual(second.settings.providerKeys.store.get('resealprov.key'), SECRET, 'reseal kept the value readable');
+ const row = await second.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();
+ assert.match(JSON.parse(row.values_json)['resealprov.key'], /^v1:k1:/, 'reseal made it an envelope');
+ assert.ok(!row.values_json.includes(SECRET));
+ assert.strictEqual((await second.settings.providerKeys.store.summary()).sealed, true);
+ } finally {
+ await second.stop();
+ }
+});
+
+(async () => {
+ await t.run();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/**
+ * D11 owner settings: ai.exchange and ai.provider_keys on openvibe-shared/config, the /internal/config admin
+ * routes behind ai.config.manage, the vault → provider_keys migration, x-bind against a moved provider origin,
+ * secrets that are write-only and never appear in a summary/log/error, env pins and both KEK states.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, request, token, suite } = require('./helpers');
+
+const t = suite('config');
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const KEK = crypto.randomBytes(32).toString('base64');
+const VAULT_KEY = crypto.randomBytes(32).toString('hex');
+const SECRET = 'made-up-config-key-THIS-MUST-NEVER-BE-SHOWN-7f3a9c2e1b';
+const SECRET2 = 'made-up-config-key-ROTATED-THIS-MUST-NOT-SHOW-2b4d6f8a';
+
+const KEK_ENV = { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1' };
+const ALL_CAPS = ['ai.config.manage', 'ai.provider.manage', 'ai.usage.read'];
+const cfgTok = token('network', ALL_CAPS);
+const opsTok = token('network', ['ai.provider.manage']);
+const noCap = token('live', ['ai.run.read']);
+const headers = (fresh = true, actor = OWNER) => ({
+ ...(actor ? { 'X-OV-Subject': actor } : {}),
+ ...(fresh ? { 'X-OV-Fresh': '1' } : {}),
+});
+const cfgReq = (h, method, path, { tok = cfgTok, body, fresh = true, actor = OWNER } = {}) =>
+ request(h.base, method, path, { tok, body, headers: headers(fresh, actor) });
+
+let h;
+const logs = [];
+const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+
+t.test('boot with a KEK and the model-catalogue env pin', async () => {
+ h = await boot({
+ env: { ...KEK_ENV, AI_CREDENTIALS_KEY: VAULT_KEY, AI_MODEL_CATALOG: '1', AI_USER_MODULES: 'off' },
+ log: capture,
+ });
+ assert.strictEqual(h.settings.exchange.sealed, true);
+ assert.strictEqual(h.settings.providerKeys.sealed, true);
+});
+
+t.test('the summaries carry the schemas and the owner annotations', async () => {
+ const r = await cfgReq(h, 'GET', '/internal/config');
+ assert.strictEqual(r.status, 200, r.text);
+ const ex = r.body.namespaces.find((n) => n.namespace === 'ai.exchange');
+ assert.strictEqual(ex.schema.properties.credits['x-env'], 'AI_CREDITS');
+ assert.strictEqual(ex.schema.properties.credits['x-danger'], true);
+ assert.strictEqual(ex.schema.properties.model_catalog['x-env'], 'AI_MODEL_CATALOG');
+ assert.strictEqual(ex.schema.properties.alpha['x-env'], 'AI_ROUTING_EVIDENCE_ALPHA');
+ assert.deepStrictEqual([ex.schema.properties.alpha.minimum, ex.schema.properties.alpha.maximum], [0.01, 1]);
+ const keys = r.body.namespaces.find((n) => n.namespace === 'ai.provider_keys');
+ assert.strictEqual(keys.schema.properties['shared.origin'].type, 'string');
+ assert.strictEqual(keys.schema.properties['shared.key']['x-bind'], 'shared.origin');
+});
+
+t.test('a non-capability caller gets 403', async () => {
+ const r = await request(h.base, 'GET', '/internal/config', { tok: noCap });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied']);
+});
+
+t.test('the env pin wins and a write to it is refused', async () => {
+ const r = await cfgReq(h, 'POST', '/internal/config/ai.exchange', { body: { values: { model_catalog: false }, reason: 'pin test' } });
+ assert.deepStrictEqual([r.status, r.body.code], [409, 'config.pinned_in_env']);
+ assert.strictEqual(h.config.modelCatalog.enabled, true);
+});
+
+t.test('a stale x-danger change without X-OV-Fresh gets 403', async () => {
+ const r = await cfgReq(h, 'POST', '/internal/config/ai.exchange', { body: { values: { credits: true }, reason: 'stale' }, fresh: false });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'config.fresh_required']);
+ assert.strictEqual(h.config.credits.enabled, false, 'nothing turned on');
+});
+
+t.test('an owner change applies live and is audited without a value', async () => {
+ const r = await cfgReq(h, 'POST', '/internal/config/ai.exchange', { body: { values: { credits: true, routing_evidence: true, alpha: 0.35 }, reason: 'owner change' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(h.config.credits.enabled, true);
+ assert.strictEqual(h.credits.enabled, true, 'the Credits facade follows the live flag');
+ assert.ok(h.credits.manager, 'the Credits manager is created when the owner turns it on');
+ assert.strictEqual(h.config.routingEvidence.enabled, true);
+ assert.strictEqual(h.config.routingEvidence.alpha, 0.35);
+ assert.ok(h.pool.routingEvidence && h.pool.routingEvidence.enabled(), 'routing evidence follows the live flag');
+ const rows = await h.db.prepare("SELECT actor, target_id, metadata FROM audit_log WHERE action = 'config.change' ORDER BY id DESC").all();
+ assert.ok(rows.some((row) => row.target_id === 'ai.exchange' && JSON.parse(row.metadata).keys.includes('credits')), 'the change is audited');
+ assert.ok(rows.every((row) => !String(row.metadata).includes(SECRET)), 'never a value');
+});
+
+t.test('a provider key is write-only, sealed, and never reaches a summary, a log or an error', async () => {
+ const created = await request(h.base, 'POST', '/api/v1/providers', {
+ tok: opsTok,
+ body: {
+ key: 'cfgprov', display_name: 'Config provider', kind: 'http', status: 'active', base_url: 'https://cfg.example/v1',
+ auth_mode: 'bearer', secret_ref: 'vault:cfgprov', capabilities: ['chat'], timeout_ms: 5000,
+ },
+ });
+ assert.strictEqual(created.status, 201, created.text);
+ const put = await cfgReq(h, 'PUT', '/internal/config/ai.provider_keys/secrets/cfgprov.key', { body: { value: SECRET, reason: 'the key' } });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual(put.body.hint, SECRET.slice(-4));
+ assert.strictEqual(put.body.bound_to, 'https://cfg.example');
+ assert.strictEqual(h.settings.providerKeys.store.get('cfgprov.key'), SECRET);
+ assert.strictEqual(require('../server/util').resolveSecret('vault:cfgprov'), SECRET, 'the pool resolves it synchronously');
+ const row = await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();
+ assert.match(JSON.parse(row.values_json)['cfgprov.key'], /^v1:k1:/);
+ assert.ok(!row.values_json.includes(SECRET), 'values_json holds the envelope, not the key');
+ const summary = await cfgReq(h, 'GET', '/internal/config/ai.provider_keys');
+ assert.strictEqual(summary.status, 200, summary.text);
+ assert.ok(!summary.text.includes(SECRET), 'the summary leaks no key');
+ assert.deepStrictEqual(summary.body.secrets['cfgprov.key'].set_by, { type: 'user', id: OWNER });
+ const carried = await cfgReq(h, 'POST', '/internal/config/ai.provider_keys', { body: { values: { 'cfgprov.key': SECRET }, reason: 'not here' } });
+ assert.deepStrictEqual([carried.status, carried.body.code], [422, 'config.secret_write_only']);
+ assert.ok(!carried.text.includes(SECRET), 'the error leaks no key');
+ assert.ok(logs.every((line) => !line.includes(SECRET) && !line.includes(SECRET2)), 'no log line leaks a key');
+ const history = await cfgReq(h, 'GET', '/internal/config/ai.provider_keys/history');
+ assert.strictEqual(history.status, 200);
+ assert.ok(!history.text.includes(SECRET), 'the history leaks no key');
+});
+
+t.test('x-bind refuses the key after a base_url change, and entering it again binds the new origin', async () => {
+ const patched = await request(h.base, 'PATCH', '/api/v1/providers/cfgprov', { tok: opsTok, body: { base_url: 'https://moved.example/v1' } });
+ assert.strictEqual(patched.status, 200, patched.text);
+ assert.strictEqual(patched.body.key_reentry_required, true);
+ assert.match(patched.body.notice, /the key must be entered again for the new origin/);
+ assert.strictEqual(h.settings.providerKeys.store.get('cfgprov.key'), undefined, 'the old key no longer opens');
+ assert.strictEqual(require('../server/util').resolveSecret('vault:cfgprov'), '', 'the pool gets no key for the new host');
+ const locked = await cfgReq(h, 'GET', '/internal/config/ai.provider_keys');
+ assert.strictEqual(locked.body.secrets['cfgprov.key'].bound_to, 'https://cfg.example', 'the marker names what it was sealed to');
+ assert.ok(!locked.text.includes(SECRET), 'the locked summary leaks no key');
+ const again = await cfgReq(h, 'PUT', '/internal/config/ai.provider_keys/secrets/cfgprov.key', { body: { value: SECRET2, reason: 'for the new host' } });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.strictEqual(again.body.bound_to, 'https://moved.example');
+ assert.strictEqual(h.settings.providerKeys.store.get('cfgprov.key'), SECRET2);
+});
+
+(async () => {
+ await t.run();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,251 @@
@@ -0,0 +1,251 @@
+'use strict';
+// AI Exchange on the operator console (AI_CREDITS / AI_ROUTING_EVIDENCE): staff only, the Credits settle
+// queue counts and review rows with one audited "mark resolved" action that never retries, the measured
+// routing evidence and incumbents, nothing from a settle body beyond the four usage counts, and no
+// Exchange page or navigation when both flags are off.
+
+const assert = require('assert');
+const crypto = require('crypto');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const { boot, suite, privateKey, ISSUER } = require('./helpers');
+
+const t = suite('console-exchange');
+const NETWORK = 'http://network.test';
+const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;
+const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
+const people = { user: ids.newId('user'), admin: ids.newId('user') };
+
+const codes = new Map();
+const revoked = [];
+function authorize({ subject_id, role = 'user', is_owner = false, username = 'someone', staff_caps, challenge, redirect_uri }) {
+ const code = crypto.randomBytes(16).toString('hex');
+ codes.set(code, { subject_id, role, is_owner, username, staff_caps, challenge, redirect_uri });
+ return code;
+}
+function userToken(u) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ sub: 42, id: 42, ...(u.subject_id ? { subject_id: u.subject_id } : {}), username: u.username, role: u.role, ...(u.is_owner ? { is_owner: true } : {}),
+ ...(u.staff_caps ? { staff_caps: u.staff_caps, staff_map: '1.1.0' } : {}),
+ iss: ISSUER, aud: ['openvibe.live', 'openvibe.network'], iat: now, exp: now + 3600,
+ }, privateKey);
+}
+const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+async function fakeFetch(url, opts = {}) {
+ const u = String(url);
+ if (u === `${NETWORK}/oauth/token`) {
+ const body = Object.fromEntries(new URLSearchParams(String(opts.body)));
+ if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
+ const c = codes.get(body.code);
+ if (!c) return json(400, { error: 'invalid_grant' });
+ codes.delete(body.code);
+ if (c.redirect_uri !== body.redirect_uri) return json(400, { error: 'invalid_grant' });
+ if (crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url') !== c.challenge) return json(400, { error: 'invalid_grant' });
+ return json(200, { access_token: userToken(c), refresh_token: `rt_${crypto.randomBytes(6).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });
+ }
+ if (u === `${NETWORK}/oauth/revoke`) { revoked.push(JSON.parse(String(opts.body)).token); return json(200, { revoked: true }); }
+ return globalThis.fetch(url, opts);
+}
+
+const baseEnv = {
+ BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET,
+ AI_CONSOLE_SESSION_SECRET: SESSION_SECRET, AI_USER_MODULES: 'off', AI_STUB_FALLBACK: 'false',
+};
+let h;
+let adminCookie = null;
+const setCookies = (res) => (res.headers.getSetCookie ? res.headers.getSetCookie() : []);
+async function get(p, cookie) {
+ const res = await fetch(h.base + p, { redirect: 'manual', headers: cookie ? { Cookie: cookie } : {} });
+ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
+}
+async function post(p, cookie, form) {
+ const res = await fetch(h.base + p, {
+ method: 'POST', redirect: 'manual',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(cookie ? { Cookie: cookie } : {}) },
+ body: new URLSearchParams(form).toString(),
+ });
+ return { status: res.status, headers: res.headers, text: await res.text(), cookies: setCookies(res) };
+}
+async function signIn(subject, { role = 'admin', is_owner = false, username = 'staffer', staff_caps, next = '/console/exchange' } = {}) {
+ const login = await get(`/auth/login?next=${encodeURIComponent(next)}`);
+ assert.strictEqual(login.status, 302);
+ const loc = new URL(login.headers.get('location'));
+ const flow = login.cookies.find((c) => c.startsWith('ovai_flow='));
+ const code = authorize({ subject_id: subject, role, is_owner, username, staff_caps, challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri') });
+ const cb = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow.split(';')[0]);
+ const sess = cb.cookies.find((c) => c.startsWith('__Host-ovai_staff=') && !c.startsWith('__Host-ovai_staff=;'));
+ return { cb, cookie: sess ? sess.split(';')[0] : null };
+}
+const csrfOf = (page) => (page.text.match(/name="_csrf" value="([^"]+)"/) || [])[1];
+const sessionCount = async () => (await h.db.prepare('SELECT COUNT(*) AS n FROM console_sessions').get()).n;
+function assertPage(r, p) {
+ assert.strictEqual(r.status, 200, `${p}: ${r.status} ${r.text.slice(0, 300)}`);
+ assert.match(r.headers.get('content-type'), /text\/html/, p);
+ assert.ok(!/<script/i.test(r.text), `${p}: no script`);
+ const csp = r.headers.get('content-security-policy') || '';
+ assert.ok(csp.includes("default-src 'none'") && csp.includes("form-action 'self'") && !csp.includes('unsafe-inline'), `${p}: CSP`);
+ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow', p);
+ assert.match(r.headers.get('cache-control') || '', /no-store/, p);
+ assert.strictEqual((r.text.match(/<h1[ >]/g) || []).length, 1, `${p}: one h1`);
+ assert.strictEqual((r.text.match(/<table/g) || []).length, (r.text.match(/<caption>/g) || []).length, `${p}: every table has a caption`);
+ assert.ok(!r.text.includes(CLIENT_SECRET) && !r.text.includes(SESSION_SECRET), `${p}: no secret`);
+}
+
+const REVIEW_A = 'ai-settle:req_REVIEWA';
+const REVIEW_B = 'ai-settle:req_REVIEWB';
+const REVIEW_BODY_A = JSON.stringify({
+ idempotency_key: REVIEW_A,
+ subject: 'SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00',
+ usage: { input_tokens: 111, cached_read_tokens: 222, cache_write_tokens: 333, output_tokens: 444 },
+ retail: { version: 'RETAIL-MARKER-9f3a', nano_usd_per_m: { input: 'SUPPLY-MARKER-111', cached_read: 'SUPPLY-MARKER-222', cache_write: 'SUPPLY-MARKER-333', output: 'SUPPLY-MARKER-444' }, per_request_nano_usd: 'SUPPLY-MARKER-555' },
+ supply_cost_nano_usd: 'SUPPLY-MARKER-666',
+ byok: false,
+ attempts: [{ deployment_id: 'DEPLOY-MARKER-xyz', started_at: '2026-11-05T10:00:00.000Z', ended_at: '2026-11-05T10:00:01.000Z', error_code: null }],
+});
+const REVIEW_BODY_B = JSON.stringify({
+ idempotency_key: REVIEW_B,
+ usage: { input_tokens: 5, cached_read_tokens: 6, cache_write_tokens: 7, output_tokens: 8 },
+ retail: { version: 'SECOND-MARKER', nano_usd_per_m: { input: '0', cached_read: '0', cache_write: '0', output: '0' }, per_request_nano_usd: '0' },
+ supply_cost_nano_usd: '0', byok: false, attempts: [],
+});
+
+async function seedQueue({ id, hold, request, body, status, tries, createdAt, lastError = null }) {
+ const at = new Date(createdAt).toISOString();
+ await h.db.prepare(`INSERT INTO credit_settle_queue
+ (id, hold_id, request_id, settle_body, attempts, status, tries, next_at, last_error, created_at, updated_at)
+ VALUES (?, ?, ?, ?::jsonb, '[]'::jsonb, ?, ?, ?::timestamptz, ?, ?::timestamptz, ?::timestamptz)`).run(
+ id, hold, request, body, status, tries, at, lastError, at, at);
+}
+
+t.test('boot with both flags on and seed queue, metrics and incumbent rows', async () => {
+ h = await boot({ env: { ...baseEnv, AI_CREDITS: 'true', AI_ROUTING_EVIDENCE: 'true' }, fetchImpl: fakeFetch });
+ const now = Date.now();
+ await seedQueue({ id: 'ai-settle:req_QUEUED', hold: 'hold_queued', request: 'req_QUEUED', body: REVIEW_BODY_B, status: 'queued', tries: 4, createdAt: now - 300000, lastError: 'billing unavailable' });
+ await seedQueue({ id: 'ai-settle:req_SENT', hold: 'hold_sent', request: 'req_SENT', body: REVIEW_BODY_B, status: 'sent', tries: 0, createdAt: now - 200000 });
+ await seedQueue({ id: REVIEW_A, hold: 'hold_review_a', request: 'req_REVIEWA', body: REVIEW_BODY_A, status: 'review', tries: 2, createdAt: now - 150000, lastError: 'billing.invalid_request: output_tokens' });
+ await seedQueue({ id: REVIEW_B, hold: 'hold_review_b', request: 'req_REVIEWB', body: REVIEW_BODY_B, status: 'review', tries: 1, createdAt: now - 100000 });
+ await h.db.prepare(`INSERT INTO provider_metrics
+ (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)
+ VALUES ('alpha', 'model-a', 420, 30, 55.5, 25, 0.97, 0.01, 0.42, 22, 40, ?::timestamptz)`).run(new Date(now).toISOString());
+ await h.db.prepare(`INSERT INTO provider_metrics
+ (provider_key, model_key, ttft_ms, ttft_samples, decode_tps, decode_samples, success_rate, timeout_rate, cache_hit_ratio, cache_samples, sample_count, updated_at)
+ VALUES ('beta', 'model-b', NULL, 0, NULL, 0, 0.5, 0.25, 0, 0, 2, ?::timestamptz)`).run(new Date(now).toISOString());
+ await h.db.prepare(`INSERT INTO routing_incumbents
+ (decision_key, provider_key, model_key, challenger_provider, challenger_model, challenger_streak, updated_at)
+ VALUES ('openvibe/auto|chat|balanced', 'alpha', 'model-a', 'beta', 'model-b', 2, ?::timestamptz)`).run(new Date(now).toISOString());
+});
+
+t.test('anonymous get the sign-in page (401), never data', async () => {
+ const r = await get('/console/exchange');
+ assert.strictEqual(r.status, 401);
+ assert.ok(r.text.includes('Sign in with OpenVibe.Network'));
+ assert.ok(!r.text.includes('<table'));
+});
+
+t.test('non-staff are refused at sign-in (403) and get no session; the refusal is audited', async () => {
+ const r = await signIn(people.user, { role: 'user' });
+ assert.strictEqual(r.cb.status, 403);
+ assert.strictEqual(r.cookie, null);
+ assert.ok(r.cb.text.includes('only for OpenVibe.Network staff'));
+ assert.strictEqual(await sessionCount(), 0);
+ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'console.sign_in.refused' AND actor = ?").get(people.user)).n, 1);
+ assert.ok(revoked.length >= 1, 'the Network refresh token is revoked');
+});
+
+t.test('an admin sees both sections from the seeded rows; no settle body text beyond the four counts', async () => {
+ const s = await signIn(people.admin, { username: 'ops' });
+ assert.strictEqual(s.cb.status, 303);
+ assert.strictEqual(s.cb.headers.get('location'), '/console/exchange');
+ assert.ok(s.cookie);
+ adminCookie = s.cookie;
+ const page = await get('/console/exchange', s.cookie);
+ assertPage(page, '/console/exchange');
+ const text = page.text;
+ assert.ok(text.includes('<a href="/console/exchange" aria-current="page">Exchange</a>'), 'the nav entry is current');
+ assert.ok(text.includes('Credits settle queue') && text.includes('Routing evidence'), 'both sections');
+ assert.match(text, /<div class="k">Queued<\/div><div class="v">1<\/div>/);
+ assert.match(text, /<div class="k">Sent<\/div><div class="v">1<\/div>/);
+ assert.match(text, /<div class="k">Review<\/div><div class="v">2<\/div>/);
+ assert.match(text, /<div class="k">Oldest queued<\/div><div class="v">[4-6] min<\/div>/);
+ assert.ok(text.includes('hold_review_a') && text.includes('hold_review_b'));
+ assert.ok(text.includes('req_REVIEWA') && text.includes('billing.invalid_request: output_tokens'));
+ const wraps = (text.match(/class="tablewrap"/g) || []).length;
+ assert.ok(wraps >= 3, `every table is inside a scroll wrapper (${wraps})`);
+ // The four settle usage counts (summed over the review rows) are the only settle-body data shown.
+ assert.match(text, /tokens in 116 · cached read 228 · cache write 340 · out 452/);
+ for (const marker of ['SUBJECT-MARKER-usr_01JABCDEFGHJKMNPQRSTVWXYZ00', 'RETAIL-MARKER-9f3a', 'SUPPLY-MARKER-111', 'SUPPLY-MARKER-666', 'DEPLOY-MARKER-xyz', 'SECOND-MARKER', '"byok"', 'supply_cost_nano_usd', 'nano_usd_per_m']) {
+ assert.ok(!text.includes(marker), `settle body field leaked: ${marker}`);
+ }
+ assert.ok(text.includes('alpha:model-a') && text.includes('beta:model-b'));
+ assert.ok(text.includes('97.0 %') && text.includes('1.0 %') && text.includes('420 ms') && text.includes('55.5') && text.includes('42.0 %'));
+ assert.ok(text.includes('openvibe/auto|chat|balanced') && text.includes('n=30') && text.includes('n=25') && text.includes('n=22'));
+});
+
+t.test('"mark resolved" changes only that row, never retries it, and writes the console audit row', async () => {
+ const page = await get('/console/exchange', adminCookie);
+ const csrf = csrfOf(page);
+ assert.ok(csrf, 'the page carries the CSRF token');
+ const before = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);
+ const other = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);
+ const url = `/console/exchange/credits/${encodeURIComponent(REVIEW_A)}/resolve`;
+ const r = await post(url, adminCookie, { _csrf: csrf });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.strictEqual(r.headers.get('location'), '/console/exchange?done=settle_resolved');
+ const after = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_A);
+ assert.strictEqual(after.status, 'resolved');
+ assert.strictEqual(after.tries, before.tries, 'no retry');
+ assert.strictEqual(after.next_at, before.next_at, 'the retry schedule is untouched');
+ assert.strictEqual(after.resolved_by, people.admin);
+ assert.ok(after.resolved_at);
+ const otherAfter = await h.db.prepare('SELECT * FROM credit_settle_queue WHERE id = ?').get(REVIEW_B);
+ assert.strictEqual(otherAfter.status, other.status, 'the other review row is unchanged');
+ assert.strictEqual(otherAfter.tries, other.tries);
+ const auditPage = await get('/console/audit?kind=exchange', adminCookie);
+ assert.ok(auditPage.text.includes('credit_settle.resolve') && auditPage.text.includes('AI Exchange'), 'the audit page lists it under AI Exchange');
+ const audited = await h.db.prepare("SELECT * FROM audit_log WHERE action = 'credit_settle.resolve' ORDER BY id").all();
+ assert.strictEqual(audited.length, 1);
+ assert.strictEqual(audited[0].actor, people.admin);
+ assert.strictEqual(audited[0].target_type, 'credit_settle');
+ assert.strictEqual(audited[0].target_id, REVIEW_A);
+ assert.strictEqual(JSON.parse(audited[0].metadata).hold_id, 'hold_review_a');
+ // A resolved row is no longer offered, and a second attempt is a 409 that changes and audits nothing.
+ const shown = await get('/console/exchange', adminCookie);
+ assert.match(shown.text, /<div class="k">Review<\/div><div class="v">1<\/div>/);
+ assert.ok(!shown.text.includes('hold_review_a'));
+ const again = await post(url, adminCookie, { _csrf: csrf });
+ assert.strictEqual(again.status, 409, again.text.slice(0, 300));
+ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'credit_settle.resolve'").get()).n, 1);
+ // A missing CSRF token is refused and does not resolve the other row.
+ const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, adminCookie, {});
+ assert.strictEqual(denied.status, 403);
+ assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');
+});
+
+t.test('a staff session without ai.workflow.manage reads the page but cannot mark a row resolved', async () => {
+ const s = await signIn(people.admin, { username: 'reader', staff_caps: ['staff.site.view'] });
+ assert.strictEqual(s.cb.status, 303);
+ const page = await get('/console/exchange', s.cookie);
+ assertPage(page, '/console/exchange (reader)');
+ assert.ok(page.text.includes('Credits settle queue'));
+ assert.ok(!page.text.includes('Mark resolved'), 'no action without ai.workflow.manage');
+ assert.ok(page.text.toLowerCase().includes('never retries the settle'));
+ const denied = await post(`/console/exchange/credits/${encodeURIComponent(REVIEW_B)}/resolve`, s.cookie, { _csrf: csrfOf(page) });
+ assert.strictEqual(denied.status, 403);
+ assert.ok(denied.text.includes('This needs ai.workflow.manage'));
+ assert.strictEqual((await h.db.prepare('SELECT status FROM credit_settle_queue WHERE id = ?').get(REVIEW_B)).status, 'review');
+});
+
+t.test('with both flags off the Exchange page and its sections are absent', async () => {
+ await h.stop();
+ h = await boot({ env: { ...baseEnv, AI_CREDITS: 'false', AI_ROUTING_EVIDENCE: 'false' }, fetchImpl: fakeFetch });
+ const s = await signIn(people.admin, { username: 'ops' });
+ assert.strictEqual(s.cb.status, 303);
+ const overview = await get('/console', s.cookie);
+ assertPage(overview, '/console');
+ assert.ok(!overview.text.includes('href="/console/exchange"'), 'no Exchange navigation');
+ const page = await get('/console/exchange', s.cookie);
+ assert.strictEqual(page.status, 404);
+ assert.ok(!page.text.includes('Credits settle queue') && !page.text.includes('Routing evidence'));
+});
+
+t.run();
@@ -24,9 +24,10 @@ const PROVIDER_SECRET = `sentinel-provider-${crypto.randomBytes(12).toString('he
@@ -24,9 +24,10 @@ const PROVIDER_SECRET = `sentinel-provider-${crypto.randomBytes(12).toString('he
const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;
const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;
const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
const VAULT_KEY = crypto.randomBytes(32).toString('hex');
const VAULT_KEY = crypto.randomBytes(32).toString('hex');
+const KEK = crypto.randomBytes(32).toString('base64');
const ADDED_KEY = `sentinel-added-${crypto.randomBytes(12).toString('hex')}`;
const ADDED_KEY = `sentinel-added-${crypto.randomBytes(12).toString('hex')}`;
const REPLACED_KEY = `sentinel-replaced-${crypto.randomBytes(12).toString('hex')}`;
const REPLACED_KEY = `sentinel-replaced-${crypto.randomBytes(12).toString('hex')}`;
-const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET, VAULT_KEY, ADDED_KEY, REPLACED_KEY];
+const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET, VAULT_KEY, KEK, ADDED_KEY, REPLACED_KEY];
const ops = token('ops', ALL);
const ops = token('ops', ALL);
const live = token('live', ['ai.run.create', 'ai.run.read']);
const live = token('live', ['ai.run.create', 'ai.run.read']);
const news = token('news', ['ai.run.create', 'ai.run.read']);
const news = token('news', ['ai.run.create', 'ai.run.read']);
@@ -139,6 +140,7 @@ t.test('boot with a provider secret, a seam provider, a failing stub and a few r
@@ -139,6 +140,7 @@ t.test('boot with a provider secret, a seam provider, a failing stub and a few r
env: {
env: {
BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, AI_CONSOLE_SESSION_SECRET: SESSION_SECRET,
BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, AI_CONSOLE_SESSION_SECRET: SESSION_SECRET,
AI_USER_MODULES: 'off', AI_API_KEY: PROVIDER_SECRET, AI_PROVIDER: 'openai', AI_BASE_URL: 'http://127.0.0.1:1/v1', AI_STUB_FALLBACK: 'false', AI_CREDENTIALS_KEY: VAULT_KEY,
AI_USER_MODULES: 'off', AI_API_KEY: PROVIDER_SECRET, AI_PROVIDER: 'openai', AI_BASE_URL: 'http://127.0.0.1:1/v1', AI_STUB_FALLBACK: 'false', AI_CREDENTIALS_KEY: VAULT_KEY,
+ OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1',
},
},
fetchImpl: fakeFetch,
fetchImpl: fakeFetch,
});
});
@@ -382,12 +384,13 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, re
@@ -382,12 +384,13 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, re
assert.strictEqual(x.status, 303, x.text);
assert.strictEqual(x.status, 303, x.text);
const p = await h.registry.getProvider('cheapapi');
const p = await h.registry.getProvider('cheapapi');
assert.deepStrictEqual([p.kind, p.secret_ref, p.base_url, p.default_model, p.auth_mode], ['openai', 'vault:cheapapi', 'https://api.cheap.test/v1', 'cheap-1', 'bearer']);
assert.deepStrictEqual([p.kind, p.secret_ref, p.base_url, p.default_model, p.auth_mode], ['openai', 'vault:cheapapi', 'https://api.cheap.test/v1', 'cheap-1', 'bearer']);
- const row = await h.db.prepare('SELECT sealed, hint FROM provider_secrets WHERE provider_key = ?').get('cheapapi');
- assert.ok(row && !row.sealed.includes(ADDED_KEY), 'encrypted at rest');
- assert.strictEqual(row.hint, `…${ADDED_KEY.slice(-4)}`);
+ const row = await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys' AND state = 'active'").get();
+ assert.match(JSON.parse(row.values_json)['cheapapi.key'], /^v1:k1:/, 'sealed at rest in the D11 store');
+ assert.ok(!row.values_json.includes(ADDED_KEY), 'encrypted at rest');
+ assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), ADDED_KEY);
assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), ADDED_KEY, 'the pool resolves it');
assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), ADDED_KEY, 'the pool resolves it');
- assert.strictEqual(require('../server/vault')._open({ credentialsKey: VAULT_KEY }, row.sealed, 'cheapapi'), ADDED_KEY);
- assert.throws(() => require('../server/vault')._open({ credentialsKey: VAULT_KEY }, row.sealed, 'otherapi'), 'bound to its provider');
+ assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].bound_to, 'https://api.cheap.test');
+ assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].hint, ADDED_KEY.slice(-4));
x = await get('/console/providers', cookies.owner);
x = await get('/console/providers', cookies.owner);
assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');
assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');
assert.match(x.text, new RegExp(`key …${ADDED_KEY.slice(-4)}`));
assert.match(x.text, new RegExp(`key …${ADDED_KEY.slice(-4)}`));
@@ -396,6 +399,7 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, re
@@ -396,6 +399,7 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, re
x = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });
x = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });
assert.strictEqual(x.status, 303);
assert.strictEqual(x.status, 303);
assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), REPLACED_KEY);
assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), REPLACED_KEY);
+ assert.strictEqual(h.settings.providerKeys.store.get('cheapapi.key'), REPLACED_KEY, 'the store holds the replaced key');
x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', _csrf: csrf.owner });
x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', _csrf: csrf.owner });
assert.ok(x.status >= 400, 'prices name the page they come from');
assert.ok(x.status >= 400, 'prices name the page they come from');
x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', price_source: 'https://cheap.test/pricing', tools: '1', _csrf: csrf.owner });
x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', price_source: 'https://cheap.test/pricing', tools: '1', _csrf: csrf.owner });
new file mode 100644
new file mode 100644
@@ -0,0 +1,486 @@
@@ -0,0 +1,486 @@
+'use strict';
+// AI Exchange F / P7 + D12 end to end, against a fake Billing. A paid managed request holds Balance before the
+// provider runs: an app against its project's Balance (subject app + project_id, no Network lookup) and a person
+// against their personal Balance (no project_id). max_cost_usd clamps the output; a pricier managed fallback
+// extends the hold (or is skipped if Billing refuses); a settle Billing cannot take is queued and retried exactly
+// once; a run with no payer never reaches a paid offer with a versioned card; an exact id then fails 402; a
+// provider without a card behaves as before; a sandbox app token never reaches paid supply; with the flag off
+// nothing changes.
+const assert = require('assert');
+const http = require('http');
+const { boot, request, token, suite, ALL } = require('./helpers');
+
+const t = suite('credits-run');
+const service = token('live', ALL);
+const ULID = '01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const PRJ = `prj_${ULID}`;
+const APP = `app_${ULID}`;
+const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';
+const appToken = (env) => token('app', ['ai.app.run'], { sub: `app:app_${ULID}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env } });
+const producer = appToken('production');
+const sandbox = appToken('sandbox');
+
+const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+/** A local fake Billing: the four hold routes, OAuth and the usage relay. D12 makes no Network call. */
+async function fakeBilling() {
+ const state = { balance: 10_000_000_000n, held: 0n, short: false, failSettle: 0, refuseExtend: false,
+ holds: new Map(), settlements: new Map(), releases: [], calls: [], oauthScopes: [], oauthAudiences: [], seq: 0 };
+ const available = () => state.balance - state.held;
+ const part = (tokens, rate) => {
+ const n = BigInt(tokens || 0) * BigInt(rate || 0);
+ return n === 0n ? 0n : (n % 1000000n === 0n ? n / 1000000n : n / 1000000n + 1n);
+ };
+ const chargeOf = (usage, retail) => {
+ const perM = retail.nano_usd_per_m || {};
+ return part(usage.input_tokens, perM.input) + part(usage.cached_read_tokens, perM.cached_read)
+ + part(usage.cache_write_tokens, perM.cache_write) + part(usage.output_tokens, perM.output) + BigInt(retail.per_request_nano_usd || 0);
+ };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const path = new URL(req.url, 'http://x').pathname;
+ const body = (() => { try { return raw ? JSON.parse(raw) : {}; } catch { return {}; } })();
+ state.calls.push({ path, body });
+ const send = (status, out) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(out)); };
+ if (path === '/oauth/token') {
+ const params = new URLSearchParams(raw);
+ state.oauthAudiences.push(String(params.get('audience') || ''));
+ state.oauthScopes.push(String(params.get('scope') || ''));
+ return send(200, { access_token: 'test-token', token_type: 'Bearer', expires_in: 300, scope: String(params.get('scope') || '') });
+ }
+ if (path === '/api/v1/usage') return send(201, { ok: true });
+ if (path === '/internal/credits/holds') {
+ const key = body.idempotency_key;
+ const prev = state.holds.get(key);
+ if (prev) {
+ if (prev.fingerprint !== body.request_fingerprint) return send(409, { code: 'billing.hold_key_reused', detail: 'different fingerprint' });
+ return send(201, { hold_id: prev.hold_id, held_nano_usd: prev.held_nano_usd, available_nano_usd: String(available() < 0n ? 0n : available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });
+ }
+ const amount = BigInt(String(body.max_nano_usd));
+ if (state.short || available() < amount) return send(402, { code: 'billing.insufficient_funds', detail: `need ${amount}, have ${state.short ? 0 : available()}` });
+ const hold = { hold_id: `hld_${String(++state.seq).padStart(26, '0')}`, held_nano_usd: String(amount), fingerprint: body.request_fingerprint, status: 'active' };
+ state.holds.set(key, hold);
+ state.held += amount;
+ return send(201, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });
+ }
+ let m = /^\/internal\/credits\/holds\/([^/]+)\/extend$/.exec(path);
+ if (m) {
+ const hold = [...state.holds.values()].find((h) => h.hold_id === decodeURIComponent(m[1]));
+ const add = BigInt(String(body.add_nano_usd));
+ if (!hold || state.refuseExtend || state.short || available() < add) return send(402, { code: 'billing.insufficient_funds', detail: 'cannot extend' });
+ hold.held_nano_usd = String(BigInt(hold.held_nano_usd) + add);
+ hold.status = 'extended';
+ state.held += add;
+ return send(200, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });
+ }
+ m = /^\/internal\/credits\/holds\/([^/]+)\/settle$/.exec(path);
+ if (m) {
+ if (state.failSettle > 0) { state.failSettle--; return send(500, { code: 'billing.internal', detail: 'settle is down' }); }
+ const prev = state.settlements.get(body.idempotency_key);
+ if (prev) return send(201, prev);
+ const hold = [...state.holds.values()].find((h) => h.hold_id === decodeURIComponent(m[1]));
+ if (!hold) return send(404, { code: 'billing.hold_not_found' });
+ const charge = body.byok ? 0n : chargeOf(body.usage, body.retail);
+ const held = BigInt(hold.held_nano_usd);
+ const out = { charge_nano_usd: String(charge), released_nano_usd: String(charge >= held ? 0n : held - charge), journal_id: `txn_${state.settlements.size + 1}`, shortfall_nano_usd: '0' };
+ state.settlements.set(body.idempotency_key, out);
+ hold.status = 'settled';
+ state.held -= held;
+ state.balance -= charge;
+ return send(201, out);
+ }
+ m = /^\/internal\/credits\/holds\/([^/]+)\/release$/.exec(path);
+ if (m) {
+ const id = decodeURIComponent(m[1]);
+ const hold = [...state.holds.values()].find((h) => h.hold_id === id);
+ if (hold && hold.status !== 'settled') {
+ state.releases.push({ hold_id: id, released_nano_usd: hold.held_nano_usd });
+ state.held -= BigInt(hold.held_nano_usd);
+ hold.status = 'released';
+ }
+ return send(200, state.releases[state.releases.length - 1] || { hold_id: id, released_nano_usd: '0' });
+ }
+ return send(404, { code: 'not_found', detail: path });
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ state.url = `http://127.0.0.1:${server.address().port}`;
+ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });
+ return state;
+}
+
+/** A local OpenAI-format upstream whose answer each test scripts; it honours max_tokens for its usage. */
+async function upstream(kind = 'ok') {
+ const state = { kind, calls: 0, bodies: [] };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ let body = {};
+ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
+ state.calls++;
+ state.bodies.push(body);
+ const completion = Math.min(12, Number(body.max_tokens || body.max_completion_tokens) || 12);
+ if (state.kind === 'fail') {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: { message: 'upstream is down' } }));
+ }
+ if (body.stream) {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ const chunk = (o) => res.write(`data: ${JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion.chunk', model: 'gpt-up', ...o })}\n\n`);
+ chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'from ' }, finish_reason: null }] });
+ chunk({ choices: [{ index: 0, delta: { content: 'the upstream' }, finish_reason: null }] });
+ if (state.kind === 'slow') {
+ state.timer = setInterval(() => chunk({ choices: [{ index: 0, delta: { content: ' more' }, finish_reason: null }] }), 25);
+ req.on('close', () => clearInterval(state.timer));
+ return;
+ }
+ chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
+ chunk({ choices: [], usage: { prompt_tokens: 60, completion_tokens: completion, total_tokens: 60 + completion } });
+ res.write('data: [DONE]\n\n');
+ return res.end();
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion', created: 1, model: 'gpt-up', choices: [{ index: 0, message: { role: 'assistant', content: 'an answer from upstream' }, finish_reason: 'stop' }], usage: { prompt_tokens: 60, completion_tokens: completion, total_tokens: 60 + completion } }));
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ state.url = `http://127.0.0.1:${server.address().port}/v1`;
+ state.close = () => new Promise((r) => { server.closeAllConnections?.(); clearInterval(state.timer); server.close(() => r()); });
+ return state;
+}
+
+async function seed(h, up, { cost = { in_per_mtok: 1, out_per_mtok: 2 }, rateVersion = 'retail-1', fallbacks = [] } = {}) {
+ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {
+ key: 'up', kind: 'openai', base_url: up.url, auth_mode: 'none', default_model: 'gpt-up', capabilities: ['chat'],
+ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } });
+ assert.ok(made.status < 300, made.text);
+ const model = await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {
+ provider_key: 'up', model_key: 'gpt-up', type: 'chat', supports: { streaming: true }, cost,
+ metadata: { ...(rateVersion ? { rate_version: rateVersion } : {}) } } });
+ assert.ok(model.status < 300, model.text);
+ const route = await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {
+ primary: { provider: 'up', model: 'gpt-up' }, fallbacks, timeout_ms: 5000 } });
+ assert.ok(route.status < 300, route.text);
+}
+
+async function bootWith(billing, env = {}) {
+ return boot({ env: {
+ AI_CREDITS: '1', AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0',
+ AI_GOVERN_TIERS: '1', AI_GOVERN_POLICY_JSON: JSON.stringify({ 'ai-token': { free: {} }, 'ai-usd': { free: {} } }),
+ OV_BILLING_INTERNAL_URL: billing.url, OV_NETWORK_INTERNAL_URL: billing.url, OV_OAUTH_CLIENT_SECRET: 'test-secret', OV_OAUTH_CLIENT_ID: 'ai',
+ USAGE_SAMPLES: 'off',
+ ...env,
+ } });
+}
+
+const chat = (h, { tok = producer, body = {}, headers = {} } = {}) => request(h.base, 'POST', '/v1/chat/completions', {
+ tok, headers, body: { messages: [{ role: 'user', content: 'hello' }], ...body } });
+
+/** The run id from an OpenAI answer's x_openvibe. */
+const runIdOf = (r) => r.body.x_openvibe && r.body.x_openvibe.run_id;
+
+t.test('a paid app request holds its project\'s Balance: the hold names the app and its project, no Network call', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const first = await chat(h, {});
+ assert.strictEqual(first.status, 200, first.text);
+ const holds = billing.calls.filter((c) => c.path === '/internal/credits/holds');
+ assert.strictEqual(holds.length, 1);
+ assert.deepStrictEqual(holds[0].body.subject, { type: 'app', id: APP }, 'an app pays, not its owner');
+ assert.strictEqual(holds[0].body.project_id, PRJ, 'the project Balance is the account');
+ const second = await chat(h, {});
+ assert.strictEqual(second.status, 200, second.text);
+ assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');
+ assert.ok(!billing.oauthAudiences.includes('openvibe.network'), 'no Network service token was minted');
+ assert.ok(billing.oauthScopes.every((scope) => !/network\.project\.read/.test(scope)), 'network.project.read was never requested');
+ assert.strictEqual(billing.settlements.size, 2);
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a person\'s own run holds their personal Balance: no project_id and no Network call', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: service, body: {
+ workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello as me' }] },
+ on_behalf_of: { type: 'user', id: PERSON } } });
+ assert.ok(r.status < 300, r.text);
+ const holds = billing.calls.filter((c) => c.path === '/internal/credits/holds');
+ assert.strictEqual(holds.length, 1);
+ assert.deepStrictEqual(holds[0].body.subject, { type: 'user', id: PERSON });
+ assert.ok(!('project_id' in holds[0].body), 'a personal hold has no project_id');
+ assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('insufficient credit: a 402 billing.insufficient_funds, and the provider is never called', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ billing.short = true;
+ const r = await chat(h, {});
+ assert.strictEqual(r.status, 402, r.text);
+ assert.strictEqual(r.body.error.code, 'billing.insufficient_funds');
+ assert.strictEqual(r.body.error.message, 'Not enough Balance');
+ assert.strictEqual(up.calls, 0, 'no provider call was made');
+ assert.strictEqual(billing.settlements.size, 0);
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('max_cost_usd clamps the provider\'s max_tokens and the settled charge never exceeds the cap', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ // Input is free here so the cap is entirely about output: $0.002/M = 2 nano per token, cap 10 nano = 5 tokens.
+ await seed(h, up, { cost: { in_per_mtok: 0, out_per_mtok: 0.002 } });
+ const r = await chat(h, { body: { max_tokens: 100, max_cost_usd: '0.00000001' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(up.bodies[0].max_tokens, 5, 'the provider was asked for the affordable output only');
+ const settle = billing.calls.filter((c) => c.path.endsWith('/settle'))[0];
+ assert.strictEqual(settle.body.usage.output_tokens, 5);
+ const charged = BigInt([...billing.settlements.values()][0].charge_nano_usd);
+ assert.ok(charged <= 10n, `charge ${charged} <= cap 10 nano`);
+ assert.strictEqual(settle.body.retail.version, 'retail-1');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a pricier managed fallback extends the hold by the difference (clamped by max_cost_usd)', async () => {
+ const billing = await fakeBilling();
+ const bad = await upstream('fail');
+ const good = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, bad, { cost: { in_per_mtok: 0, out_per_mtok: 0.001 } });
+ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {
+ key: 'up2', kind: 'openai', base_url: good.url, auth_mode: 'none', default_model: 'gpt-up2', capabilities: ['chat'],
+ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } });
+ assert.ok(made.status < 300, made.text);
+ assert.ok((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {
+ provider_key: 'up2', model_key: 'gpt-up2', type: 'chat', cost: { in_per_mtok: 0, out_per_mtok: 0.004 }, metadata: { rate_version: 'retail-2' } } })).status < 300);
+ assert.ok((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {
+ primary: { provider: 'up', model: 'gpt-up' }, fallbacks: [{ provider: 'up2', model: 'gpt-up2' }], timeout_ms: 5000 } })).status < 300);
+ // max_tokens 100: up holds 100 nano; up2's worst is 400, capped at 250 => 62 output tokens / 248 nano.
+ const r = await chat(h, { body: { max_tokens: 100, max_cost_usd: '0.00000025' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(bad.calls, 1);
+ assert.strictEqual(good.calls, 1);
+ const extend = billing.calls.filter((c) => c.path.endsWith('/extend'));
+ assert.strictEqual(extend.length, 1, 'the hold grew for the pricier fallback');
+ assert.strictEqual(extend[0].body.add_nano_usd, '148', '250 cap - 100 held, with 62 affordable tokens');
+ assert.strictEqual(good.bodies[0].max_tokens, 62, 'the fallback got the clamped output');
+ assert.strictEqual(billing.settlements.size, 1, 'one settlement');
+ const settle = billing.calls.filter((c) => c.path.endsWith('/settle'))[0];
+ assert.strictEqual(settle.body.attempts.length, 2);
+ assert.deepStrictEqual(Object.keys(settle.body.attempts[0]).sort(), ['deployment_id', 'ended_at', 'error_code', 'provider_access', 'started_at']);
+ } finally { await h.stop(); await bad.close(); await good.close(); await billing.close(); }
+});
+
+t.test('a fallback whose hold cannot be extended is skipped, not run', async () => {
+ const billing = await fakeBilling();
+ const bad = await upstream('fail');
+ const good = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, bad, { cost: { in_per_mtok: 0, out_per_mtok: 0.001 } });
+ assert.ok((await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {
+ key: 'up2', kind: 'openai', base_url: good.url, auth_mode: 'none', default_model: 'gpt-up2', capabilities: ['chat'],
+ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } })).status < 300);
+ assert.ok((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {
+ provider_key: 'up2', model_key: 'gpt-up2', type: 'chat', cost: { in_per_mtok: 0, out_per_mtok: 0.004 }, metadata: { rate_version: 'retail-2' } } })).status < 300);
+ assert.ok((await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: service, body: {
+ primary: { provider: 'up', model: 'gpt-up' }, fallbacks: [{ provider: 'up2', model: 'gpt-up2' }], timeout_ms: 5000 } })).status < 300);
+ billing.refuseExtend = true;
+ const r = await chat(h, { body: { max_tokens: 100 } });
+ assert.ok(r.status >= 500, r.text);
+ assert.strictEqual(good.calls, 0, 'the pricier fallback never ran');
+ // The skipped attempt is named in the run's explain.
+ const listed = await request(h.base, 'GET', '/api/v1/runs?limit=1&all=1', { tok: service });
+ const attempts = (listed.body.runs[0].explain && listed.body.runs[0].explain.attempts) || [];
+ assert.ok(attempts.some((a) => a.status === 'skipped' && a.error === 'hold_not_extended'), JSON.stringify(attempts));
+ assert.strictEqual(billing.releases.length, 1, 'the first hold was released');
+ } finally { await h.stop(); await bad.close(); await good.close(); await billing.close(); }
+});
+
+t.test('an abort mid-stream settles the partial usage, never a release', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('slow');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const res = await fetch(`${h.base}/v1/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${producer}`, 'Content-Type': 'application/json' }, body: JSON.stringify({
+ stream: true, messages: [{ role: 'user', content: 'stream please' }] }) });
+ assert.strictEqual(res.status, 200);
+ const runId = res.headers.get('x-openvibe-run-id');
+ assert.match(String(runId), /^run_/);
+ const reader = res.body.getReader();
+ const { value } = await reader.read();
+ assert.ok(value && value.length > 0, 'a first chunk arrived');
+ // The cancel route takes first-party service tokens (ai.workflow.manage), not app tokens.
+ const cancelled = await request(h.base, 'POST', `/api/v1/runs/${runId}/cancel`, { tok: service });
+ assert.ok(cancelled.status < 300, cancelled.text);
+ await reader.cancel().catch(() => {});
+ let run = null;
+ for (let i = 0; i < 100; i++) {
+ const got = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: service });
+ if (['cancelled', 'failed', 'succeeded'].includes(got.body.run.status)) { run = got.body.run; break; }
+ await new Promise((r) => setTimeout(r, 30));
+ }
+ assert.ok(run, 'the run reached a terminal state');
+ assert.strictEqual(run.status, 'cancelled');
+ assert.strictEqual(billing.releases.length, 0, 'an abort settles; it does not release');
+ assert.strictEqual(billing.settlements.size, 1);
+ const settleCall = billing.calls.filter((c) => c.path.endsWith('/settle'))[0];
+ assert.ok(settleCall.body.usage.output_tokens > 0, `partial output was settled (${JSON.stringify(settleCall.body.usage)})`);
+ assert.ok(BigInt([...billing.settlements.values()][0].charge_nano_usd) > 0n);
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('one settle per request, even when the request is retried with the same request id', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const headers = { 'x-openvibe-request-id': 'req_credits_retry_1' };
+ const first = await chat(h, { headers });
+ const second = await chat(h, { headers });
+ assert.strictEqual(first.status, 200, first.text);
+ assert.strictEqual(second.status, 200, second.text);
+ const settles = billing.calls.filter((c) => c.path.endsWith('/settle'));
+ assert.strictEqual(settles.length, 2, 'each attempt asked Billing');
+ assert.strictEqual(billing.settlements.size, 1, '...and Billing settled once: the second is a replay');
+ const settled = [...billing.settlements.values()][0];
+ assert.ok(BigInt(settled.charge_nano_usd) > 0n);
+ assert.strictEqual(settles[0].body.attempts.length, 1, 'the single managed attempt is reported');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a provider failure before the first token releases the hold', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('fail');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ const r = await chat(h, {});
+ assert.ok(r.status >= 500, r.text);
+ assert.strictEqual(billing.releases.length, 1, 'the hold was released');
+ assert.strictEqual(billing.settlements.size, 0, 'nothing was charged');
+ assert.strictEqual(up.calls, 1);
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a settle Billing cannot take is queued and retried with the same key: exactly one settlement', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up);
+ billing.failSettle = 2;
+ const r = await chat(h, { headers: { 'x-openvibe-request-id': 'req_credits_queue_1' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const queued = await h.credits.pendingSettlements();
+ assert.strictEqual(queued.length, 1, 'the failed settle is kept');
+ assert.strictEqual(queued[0].status, 'queued');
+ assert.strictEqual(billing.settlements.size, 0, 'nothing was charged yet');
+ billing.failSettle = 0;
+ const out = await h.credits.drainSettlements({ force: true });
+ assert.strictEqual(out.sent, 1, JSON.stringify(out));
+ assert.strictEqual(billing.settlements.size, 1, 'exactly one settlement');
+ const after = await h.credits.pendingSettlements();
+ assert.strictEqual(after[0].status, 'sent');
+ const settles = billing.calls.filter((c) => c.path.endsWith('/settle'));
+ assert.strictEqual(settles[0].body.idempotency_key, settles[settles.length - 1].body.idempotency_key, 'the same key was retried');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('no payer: paid offers with a versioned card are skipped (payer_required) and a free offer answers', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up, { fallbacks: [{ provider: 'stub', model: 'stub-1' }] });
+ const r = await chat(h, { tok: service });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(up.calls, 0, 'the paid managed offer was not served for free');
+ assert.strictEqual(r.body.x_openvibe.provider, 'stub', 'the free local offer answered');
+ const run = await request(h.base, 'GET', `/api/v1/runs/${runIdOf(r)}`, { tok: service });
+ assert.ok(run.body.run.explain.attempts.some((a) => a.status === 'skipped' && a.error === 'payer_required'), JSON.stringify(run.body.run.explain.attempts));
+ assert.strictEqual(billing.settlements.size, 0, 'no Balance was spent');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('an exact id whose only offer has a versioned card fails 402 billing.payer_required for a no-payer run', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: {
+ key: 'up', kind: 'openai', base_url: up.url, auth_mode: 'none', default_model: 'gpt-up', capabilities: ['chat'],
+ provider_access: 'OWN_DEPLOYMENT', billing_profile: 'metered' } });
+ assert.ok(made.status < 300, made.text);
+ assert.ok((await request(h.base, 'POST', '/api/v1/models', { tok: service, body: {
+ provider_key: 'up', model_key: 'gpt-up', type: 'chat', cost: { in_per_mtok: 1, out_per_mtok: 2 },
+ metadata: { id: 'acme/only@r1', rate_version: 'retail-1' } } })).status < 300);
+ const r = await chat(h, { tok: service, body: { model: 'acme/only@r1' } });
+ assert.strictEqual(r.status, 402, r.text);
+ assert.strictEqual(r.body.error.code, 'billing.payer_required');
+ assert.strictEqual(up.calls, 0);
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a paid offer without a versioned card behaves as before for a no-payer run', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up, { rateVersion: null });
+ const r = await chat(h, { tok: service });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(up.calls, 1, 'no card: the offer is not a Balance offer and still runs');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('a sandbox app token never reaches paid supply, and is never charged (D10)', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing);
+ try {
+ await seed(h, up, { fallbacks: [{ provider: 'stub', model: 'stub-1' }] });
+ const paid = await chat(h, {});
+ assert.strictEqual(paid.status, 200, paid.text);
+ assert.strictEqual(up.calls, 1);
+ const r = await chat(h, { tok: sandbox });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.x_openvibe.provider, 'stub', 'the free local offer answered');
+ assert.strictEqual(up.calls, 1, 'paid supply was never called for a sandbox token');
+ assert.strictEqual(billing.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'a sandbox run resolves no payer');
+ assert.strictEqual(billing.calls.filter((c) => c.path === '/internal/credits/holds').length, 1, 'only the production run held Balance');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.test('with AI_CREDITS off, a request runs exactly as before and Billing is never called', async () => {
+ const billing = await fakeBilling();
+ const up = await upstream('ok');
+ const h = await bootWith(billing, { AI_CREDITS: '0' });
+ try {
+ await seed(h, up);
+ const r = await chat(h, {});
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.choices[0].message.content, 'an answer from upstream');
+ assert.strictEqual(up.calls, 1);
+ assert.strictEqual(billing.calls.length, 0, 'no hold, extend, settle or release');
+ } finally { await h.stop(); await up.close(); await billing.close(); }
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,345 @@
@@ -0,0 +1,345 @@
+'use strict';
+// AI Exchange F / P7 + D12: the Balance session (server/credits.js) against a fake Billing, without HTTP. Covers
+// the review's money rules and D12's payer: the worst-case hold at the retail card, a decimal-only max_cost_usd
+// that clamps the output, an app holding its project's Balance (subject app + project_id, no Network lookup), a
+// person holding their personal Balance (no project_id), a pricier managed fallback extending the hold, one settle
+// per request, the partial usage of an abort, release before a first token, BYOK never held, the streaming
+// extension, contract-valid request bodies, and AI_CREDITS off.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { suite } = require('./helpers');
+const { createCredits, retailOf, parseCostUsd, affordable } = require('../server/credits');
+
+const t = suite('credits');
+
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const APP = 'app_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';
+const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+const R1 = { version: 'retail-1', input_nano_usd_per_m: '1000000000', output_nano_usd_per_m: '2000000000', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', per_request_nano_usd: '0' };
+const R2 = { version: 'retail-2', input_nano_usd_per_m: '500000000', output_nano_usd_per_m: '4000000000', cached_read_nano_usd_per_m: '0', cache_write_nano_usd_per_m: '0', per_request_nano_usd: '0' };
+const FACTS = { resolved_model: 'org/model@r1', revision: 'r1', deployment: 'up', provider_access: 'OWN_DEPLOYMENT', rate_version: 'retail-1', policy: null, fallback_reason: null };
+const BYOK_FACTS = { ...FACTS, provider_access: 'CUSTOMER_BYOK' };
+
+/** A tiny Billing: holds/settlements like PR #187. Network is not part of D12's hold path. */
+function fakeBilling({ balance = '100000000000' } = {}) {
+ const state = { balance: BigInt(balance), held: 0n, holds: new Map(), settlements: new Map(), released: new Map(), calls: [], seq: 0 };
+ const available = () => state.balance - state.held;
+ const chargeOf = (usage, retail) => {
+ const perM = retail.nano_usd_per_m || {};
+ const part = (tokens, rate) => {
+ const n = BigInt(tokens || 0) * BigInt(rate || 0);
+ return n === 0n ? 0n : (n % 1000000n === 0n ? n / 1000000n : n / 1000000n + 1n);
+ };
+ return part(usage.input_tokens, perM.input) + part(usage.cached_read_tokens, perM.cached_read)
+ + part(usage.cache_write_tokens, perM.cache_write) + part(usage.output_tokens, perM.output) + BigInt(retail.per_request_nano_usd || 0);
+ };
+ async function fetchImpl(url, opts = {}) {
+ const path = new URL(url).pathname;
+ const body = opts.body ? JSON.parse(opts.body) : {};
+ state.calls.push({ path, body });
+ if (path === '/internal/credits/holds') {
+ const key = body.idempotency_key;
+ const prev = state.holds.get(key);
+ if (prev) {
+ if (prev.fingerprint !== body.request_fingerprint) return json(409, { code: 'billing.hold_key_reused', detail: 'different fingerprint' });
+ return json(201, { hold_id: prev.hold_id, held_nano_usd: prev.held_nano_usd, available_nano_usd: String(available() < 0n ? 0n : available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });
+ }
+ const amount = BigInt(String(body.max_nano_usd));
+ if (available() < amount) return json(402, { code: 'billing.insufficient_funds', detail: `need ${amount}, have ${available()}` });
+ const hold = { hold_id: `hld_${String(++state.seq).padStart(26, '0')}`, held_nano_usd: String(amount), fingerprint: body.request_fingerprint, status: 'active', rate_version: body.rate_version };
+ state.holds.set(key, hold);
+ state.held += amount;
+ return json(201, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });
+ }
+ const extend = /^\/internal\/credits\/holds\/([^/]+)\/extend$/.exec(path);
+ if (extend) {
+ const hold = [...state.holds.values()].find((h) => h.hold_id === decodeURIComponent(extend[1]));
+ if (!hold) return json(404, { code: 'billing.hold_not_found' });
+ const add = BigInt(String(body.add_nano_usd));
+ if (state.refuseExtend) return json(402, { code: 'billing.insufficient_funds', detail: 'cannot extend' });
+ if (available() < add) return json(402, { code: 'billing.insufficient_funds', detail: 'cannot extend' });
+ hold.held_nano_usd = String(BigInt(hold.held_nano_usd) + add);
+ hold.status = 'extended';
+ state.held += add;
+ return json(200, { hold_id: hold.hold_id, held_nano_usd: hold.held_nano_usd, available_nano_usd: String(available()), lease_expires_at: new Date(Date.now() + 300000).toISOString() });
+ }
+ const settle = /^\/internal\/credits\/holds\/([^/]+)\/settle$/.exec(path);
+ if (settle) {
+ const id = decodeURIComponent(settle[1]);
+ const prev = state.settlements.get(body.idempotency_key);
+ if (prev) return json(201, prev.result);
+ const hold = [...state.holds.values()].find((h) => h.hold_id === id);
+ if (!hold) return json(404, { code: 'billing.hold_not_found' });
+ const charge = body.byok ? 0n : chargeOf(body.usage, body.retail);
+ const held = BigInt(hold.held_nano_usd);
+ const result = { charge_nano_usd: String(charge), released_nano_usd: String(charge >= held ? 0n : held - charge), journal_id: `txn_${state.settlements.size + 1}`, settlement: { usage: body.usage, retail: body.retail, attempts: body.attempts } };
+ state.settlements.set(body.idempotency_key, { hold_id: id, result });
+ hold.status = 'settled';
+ state.held -= held;
+ state.balance -= charge;
+ return json(201, result);
+ }
+ const release = /^\/internal\/credits\/holds\/([^/]+)\/release$/.exec(path);
+ if (release) {
+ const id = decodeURIComponent(release[1]);
+ const hold = [...state.holds.values()].find((h) => h.hold_id === id);
+ if (hold && hold.status !== 'settled') {
+ state.released.set(id, { hold_id: id, released_nano_usd: hold.held_nano_usd });
+ state.held -= BigInt(hold.held_nano_usd);
+ hold.status = 'released';
+ }
+ return json(200, state.released.get(id) || { hold_id: id, released_nano_usd: '0' });
+ }
+ return json(404, { code: 'not_found', detail: path });
+ }
+ return { state, fetchImpl, available, chargeOf };
+}
+
+function managerFor(billing, overrides = {}) {
+ return createCredits({
+ config: { credits: { enabled: true, billingUrl: 'http://billing.test', audience: 'openvibe.billing', timeoutMs: 1000 }, console: { clientId: 'ai' } },
+ tokens: { getToken: async () => 'billing-token' },
+ fetchImpl: billing.fetchImpl, log: { warn() {}, log() {}, error() {} },
+ ...overrides,
+ });
+}
+
+function begin(manager, { requestId = 'req_credits_0001', input = {}, extra = {} } = {}) {
+ return manager.begin({ requestId, ctx: { runId: 'run_1', requesterType: 'app', requesterId: APP, appId: APP, projectId: PRJ, ...extra }, input: { messages: [{ role: 'user', content: 'hello' }], ...input } });
+}
+
+/** A person's own run: a user subject, and (deliberately) a project attribution the hold must not use. */
+function beginPerson(manager, { requestId = 'req_credits_person_0001', input = {}, attribution = { type: 'project', id: PRJ } } = {}) {
+ return manager.begin({ requestId, ctx: { runId: 'run_person', requesterType: 'service', requesterId: 'live' }, attribution, onBehalfOf: OWNER, input: { messages: [{ role: 'user', content: 'hello' }], ...input } });
+}
+
+const callsTo = (billing, suffix) => billing.state.calls.filter((c) => c.path.endsWith(suffix));
+
+t.test('max_cost_usd is an exact decimal: 0.29, 0.000000001, and 0.0000000011 rounding up to 2 nano', () => {
+ assert.strictEqual(parseCostUsd(0.29), 290000000n);
+ assert.strictEqual(parseCostUsd('0.000000001'), 1n);
+ assert.strictEqual(parseCostUsd('0.0000000011'), 2n);
+ assert.strictEqual(parseCostUsd('1.0000000004'), 1000000001n);
+ assert.strictEqual(parseCostUsd('0'), 0n);
+ assert.throws(() => parseCostUsd('1e-9'), (err) => err.status === 422 && err.code === 'input.invalid');
+ assert.throws(() => parseCostUsd('0.1e2'), (err) => err.status === 422);
+ assert.throws(() => parseCostUsd('-1'), (err) => err.status === 422);
+ assert.throws(() => parseCostUsd('abc'), (err) => err.status === 422);
+});
+
+t.test('max_cost_usd caps the worst case by clamping the affordable output tokens in BigInt', () => {
+ const retail = retailOf({ ...R1, input_nano_usd_per_m: '0' });
+ assert.deepStrictEqual(affordable(retail, { inputTokens: 0, maxTokens: 100, maxCostNano: 10000n }), { maxTokens: 5, amount: 10000n });
+ assert.deepStrictEqual(affordable(retail, { inputTokens: 0, maxTokens: 100, maxCostNano: 9999n }).maxTokens, 4);
+ assert.throws(() => affordable(retail, { inputTokens: 0, maxTokens: 100, maxCostNano: 1999n }), (err) => err.status === 402 && err.code === 'billing.insufficient_funds');
+ assert.deepStrictEqual(affordable(retail, { inputTokens: 0, maxTokens: 3 }), { maxTokens: 3, amount: 6000n });
+});
+
+t.test('an app\'s hold names the app and its project: no Network lookup, and nothing is cached', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ const routed = await session.onRoute(FACTS, { retail: R1, maxTokens: 1000 });
+ const call = billing.state.calls.find((c) => c.path === '/internal/credits/holds');
+ const expected = BigInt(session.inputTokens) * 1000n + 1000n * 2000n; // $1/M input, $2/M output, in nano
+ assert.strictEqual(call.body.max_nano_usd, String(expected));
+ assert.strictEqual(call.body.rate_version, 'retail-1');
+ assert.deepStrictEqual(call.body.subject, { type: 'app', id: APP }, 'an app is the payer, not its owner');
+ assert.strictEqual(call.body.project_id, PRJ, 'the hold spends the project\'s Balance');
+ assert.match(call.body.request_fingerprint, /^[0-9a-f]{64}$/);
+ assert.deepStrictEqual(routed, { maxTokens: 1000 });
+ // A second app request holds too; there is no owner to resolve or cache.
+ const second = begin(manager, { requestId: 'req_credits_0002' });
+ await second.onRoute(FACTS, { retail: R1, maxTokens: 10 });
+ assert.strictEqual(callsTo(billing, '/holds').length, 2);
+ assert.strictEqual(billing.state.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');
+});
+
+t.test('a person\'s own run holds their personal Balance: subject user, no project_id', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = beginPerson(manager);
+ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ const call = callsTo(billing, '/holds').pop();
+ assert.deepStrictEqual(call.body.subject, { type: 'user', id: OWNER });
+ assert.ok(!Object.prototype.hasOwnProperty.call(call.body, 'project_id'), 'a personal hold carries no project_id');
+ assert.strictEqual(callsTo(billing, '/holds').length, 1);
+ assert.strictEqual(billing.state.calls.filter((c) => c.path.includes('/internal/projects/')).length, 0, 'Network was never called');
+});
+
+t.test('an app without a project gets no session, so no unpaid paid offer can run', () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const raw = manager.begin({ requestId: 'req_credits_noproject', ctx: { requesterType: 'app', requesterId: APP, appId: APP }, input: { messages: [] } });
+ assert.strictEqual(raw, null);
+ assert.strictEqual(billing.state.calls.length, 0);
+});
+
+t.test('a 402 from Billing is AI\'s own 402, raised before the provider (onRoute never returns)', async () => {
+ const billing = fakeBilling({ balance: '0' });
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ await assert.rejects(session.onRoute(FACTS, { retail: R1, maxTokens: 100 }), (err) => err.status === 402 && err.code === 'billing.insufficient_funds' && err.detail === 'Not enough Balance');
+ assert.strictEqual(session.holdId, null, 'no hold exists to settle or release');
+ assert.strictEqual(billing.state.settlements.size, 0);
+});
+
+t.test('one settle per request: the second call is a no-op, and a retried request replays Billing\'s answer', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ const first = await session.settle({ usage: { input: 100, output: 10 }, supplyCostUsd: 0.000001, attempts: [{ deployment_id: 'up:gpt-up', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: null }] });
+ const again = await session.settle({ usage: { input: 100, output: 10 } });
+ assert.ok(first && first.charge_nano_usd);
+ assert.strictEqual(again, null, 'the same session settles once');
+ assert.strictEqual(billing.state.settlements.size, 1, 'one settlement');
+ // A retried request (same request id) replays the hold and the settlement: no second charge.
+ const retried = begin(manager);
+ await retried.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ const replay = await retried.settle({ usage: { input: 100, output: 10 }, attempts: [] });
+ assert.strictEqual(replay.charge_nano_usd, first.charge_nano_usd);
+ assert.strictEqual(billing.state.settlements.size, 1, 'still one settlement, replayed');
+ assert.strictEqual(billing.chargeOf({ input_tokens: 100, cached_read_tokens: 0, cache_write_tokens: 0, output_tokens: 10 }, retailOf(R1)).toString(), first.charge_nano_usd);
+});
+
+t.test('an abort mid-stream settles the partial usage it streamed', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ await session.onRoute(FACTS, { retail: R1, maxTokens: 1000 });
+ session.observe({ delta: { content: 'x'.repeat(80) } }); // 20 output tokens estimated
+ assert.strictEqual(session.streamedChars, 80);
+ await session.settle({ usage: { input: session.inputTokens, output: Math.ceil(session.streamedChars / 4) } });
+ const settlement = [...billing.state.settlements.values()][0];
+ assert.strictEqual(settlement.result.settlement.usage.output_tokens, 20);
+ assert.ok(BigInt(settlement.result.charge_nano_usd) > 0n, 'the partial answer was charged');
+});
+
+t.test('an error before the first token releases the hold with nothing charged', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ const out = await session.release();
+ assert.ok(out.released_nano_usd);
+ assert.strictEqual(billing.state.settlements.size, 0);
+ assert.strictEqual(await session.settle({ usage: { input: 10, output: 0 } }), null);
+});
+
+t.test('a pricier managed fallback extends the hold by the difference, and a refused extension skips it', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ const before = BigInt(session.heldNano);
+ const routed = await session.onRoute({ ...FACTS, resolved_model: 'org/two@r1', deployment: 'up2', rate_version: 'retail-2' }, { retail: R2, maxTokens: 100 });
+ const extend = callsTo(billing, '/extend').pop();
+ const after = BigInt(session.heldNano);
+ assert.ok(after > before, 'the hold grew');
+ assert.strictEqual(extend.body.add_nano_usd, String(after - before));
+ assert.deepStrictEqual(routed, { maxTokens: 100 });
+ const out = await session.settle({ usage: { input: 100, output: 20 }, attempts: [
+ { deployment_id: 'up:one', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: 'provider.error' },
+ { deployment_id: 'up2:two', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: null },
+ ] });
+ assert.strictEqual(billing.state.settlements.size, 1);
+ const saved = [...billing.state.settlements.values()][0];
+ assert.strictEqual(saved.result.settlement.retail.version, 'retail-2', 'the card of the model that answered');
+ assert.strictEqual(saved.result.settlement.attempts.length, 2);
+ assert.ok(out.charge_nano_usd);
+
+ // The same fallback, but Billing refuses the extension: the offer is skipped, never run.
+ const refused = fakeBilling();
+ refused.state.refuseExtend = true;
+ const m2 = managerFor(refused);
+ const s2 = begin(m2);
+ await s2.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ const skip = await s2.onRoute({ ...FACTS, resolved_model: 'org/two@r1', deployment: 'up2' }, { retail: R2, maxTokens: 100 });
+ assert.deepStrictEqual(skip, { skip: 'hold_not_extended' });
+});
+
+t.test('a stream near the hold is extended in a step (and never past max_cost_usd)', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ // A tiny hold: $1/M output, max_tokens 1 => 1000 nano-USD held for the output.
+ const cheap = { ...R1, input_nano_usd_per_m: '0', output_nano_usd_per_m: '1000000000' };
+ await session.onRoute(FACTS, { retail: cheap, maxTokens: 1 });
+ const hold = BigInt(session.heldNano);
+ assert.ok(hold > 0n);
+ // 40 chars => 10 output tokens => 10,000 nano, past 80% of the hold.
+ session.observe({ delta: { content: 'y'.repeat(40) } });
+ await new Promise((r) => setTimeout(r, 20));
+ const extend = billing.state.calls.find((c) => c.path.endsWith('/extend'));
+ assert.ok(extend, 'the hold was extended');
+ assert.ok(BigInt(extend.body.add_nano_usd) > 0n);
+
+ // max_cost_usd caps the total: a second session holds at most the cap and clamps its max_tokens.
+ const capped = begin(manager, { requestId: 'req_credits_cap', extra: { maxCostNano: '2000' } });
+ const routed = await capped.onRoute(FACTS, { retail: cheap, maxTokens: 16000 });
+ assert.deepStrictEqual(routed, { maxTokens: 2 });
+ assert.strictEqual(capped.heldNano, 2000n);
+});
+
+t.test('the outgoing app and personal hold, extend and settle bodies validate against v0.289.1', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ await session.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ await session.onRoute({ ...FACTS, resolved_model: 'org/two@r1', deployment: 'up2', rate_version: 'retail-2' }, { retail: R2, maxTokens: 100 });
+ await session.settle({ usage: { input: 100, output: 20 }, attempts: [
+ { deployment_id: 'up:one', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: 'provider.error' },
+ { deployment_id: 'up2:two', provider_access: 'OWN_DEPLOYMENT', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), error_code: null },
+ ] });
+ const person = beginPerson(manager);
+ await person.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ await person.settle({ usage: { input: 100, output: 20 }, attempts: [] });
+ const holds = callsTo(billing, '/holds').map((c) => c.body);
+ const extend = callsTo(billing, '/extend').pop().body;
+ const settle = callsTo(billing, '/settle')[0].body;
+ assert.strictEqual(holds.length, 2);
+ assert.strictEqual(holds[0].project_id, PRJ, 'the app hold names its project');
+ assert.ok(!('project_id' in holds[1]), 'the personal hold does not');
+ for (const hold of holds) {
+ const v = contracts.validate('billing.credit-hold-create-request@1', hold);
+ assert.ok(v.valid, `billing.credit-hold-create-request@1: ${JSON.stringify(v.errors && v.errors.slice(0, 3))}`);
+ }
+ for (const [schema, body] of [['billing.credit-hold-extend-request@1', extend], ['billing.credit-hold-settle-request@1', settle]]) {
+ const v = contracts.validate(schema, body);
+ assert.ok(v.valid, `${schema}: ${JSON.stringify(v.errors && v.errors.slice(0, 3))}`);
+ }
+ assert.deepStrictEqual(Object.keys(settle.attempts[0]).sort(), ['deployment_id', 'ended_at', 'error_code', 'provider_access', 'started_at'], 'the contract\'s exact attempt shape');
+});
+
+t.test('BYOK is never held, never charged and Billing is never called', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ const session = begin(manager);
+ await session.onRoute(BYOK_FACTS, { retail: R1, maxTokens: 100 }); // a person's own key: no meta, or CUSTOMER_BYOK
+ await session.onRoute(BYOK_FACTS);
+ assert.strictEqual(session.holdId, null);
+ assert.strictEqual(billing.state.calls.length, 0, 'Billing was never called');
+ assert.strictEqual(await session.settle({ usage: { input: 100, output: 20 } }), null);
+});
+
+t.test('no payer means no session; a person without a project does get one; no Billing refuses paid offers', async () => {
+ const billing = fakeBilling();
+ const manager = managerFor(billing);
+ assert.strictEqual(manager.begin({ requestId: 'req_1', ctx: { requesterType: 'service', requesterId: 'live' } }), null, 'a first-party service with no person has no payer');
+ const person = manager.begin({ requestId: 'req_2', ctx: { requesterType: 'service', requesterId: 'live' }, onBehalfOf: OWNER });
+ assert.ok(person, 'a person gets a personal session with no project');
+ const created = await person.onRoute(FACTS, { retail: R1, maxTokens: 100 });
+ assert.deepStrictEqual(created, { maxTokens: 100 });
+ const hold = callsTo(billing, '/holds')[0].body;
+ assert.deepStrictEqual(hold.subject, { type: 'user', id: OWNER });
+ assert.ok(!('project_id' in hold));
+ assert.strictEqual(createCredits({ config: { credits: { enabled: false } }, tokens: { getToken: async () => 't' }, fetchImpl: billing.fetchImpl }), null, 'AI_CREDITS off -> no manager, nothing changes');
+ // Billing on, Billing URL missing: a paid managed offer must refuse rather than run free.
+ const unconfigured = createCredits({ config: { credits: { enabled: true, billingUrl: '' }, console: { clientId: 'ai' } }, env: { OV_OAUTH_CLIENT_SECRET: 'test-secret' }, tokens: { getToken: async () => 't' }, fetchImpl: billing.fetchImpl, log: { warn() {}, log() {}, error() {} } });
+ const blocked = unconfigured.begin({ requestId: 'req_4', ctx: { requesterType: 'service', requesterId: 'live' }, onBehalfOf: OWNER });
+ await assert.rejects(blocked.onRoute(FACTS, { retail: R1, maxTokens: 100 }), (err) => err.status === 503 && err.code === 'billing.unavailable');
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,1055 @@
@@ -0,0 +1,1055 @@
+{
+ "protocol": "anthropic-messages",
+ "source": "Shapes transcribed from the public Anthropic Messages API reference and the official Anthropic SDK examples (request, non-streaming message, and the named SSE events message_start / content_block_* / message_delta / message_stop). No production call was made. The Messages API has no response_format; structured JSON is requested through a forced tool, which the json_structured scenario covers.",
+ "scenarios": {
+ "text": {
+ "request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "system": "Be concise.",
+ "messages": [
+ {
+ "role": "user",
+ "content": "Say hello."
+ }
+ ]
+ },
+ "stream_request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "system": "Be concise.",
+ "messages": [
+ {
+ "role": "user",
+ "content": "Say hello."
+ }
+ ],
+ "stream": true
+ },
+ "response": {
+ "id": "msg_up_text",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-text",
+ "content": [
+ {
+ "type": "text",
+ "text": "Hello there"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 18,
+ "output_tokens": 3
+ }
+ },
+ "stream": [
+ {
+ "event": "message_start",
+ "data": {
+ "type": "message_start",
+ "message": {
+ "id": "msg_up_text",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-text",
+ "content": [],
+ "stop_reason": null,
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 18,
+ "output_tokens": 0
+ }
+ }
+ }
+ },
+ {
+ "event": "content_block_start",
+ "data": {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {
+ "type": "text",
+ "text": ""
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": "Hello"
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": " there"
+ }
+ }
+ },
+ {
+ "event": "content_block_stop",
+ "data": {
+ "type": "content_block_stop",
+ "index": 0
+ }
+ },
+ {
+ "event": "message_delta",
+ "data": {
+ "type": "message_delta",
+ "delta": {
+ "stop_reason": "end_turn",
+ "stop_sequence": null
+ },
+ "usage": {
+ "output_tokens": 3
+ }
+ }
+ },
+ {
+ "event": "message_stop",
+ "data": {
+ "type": "message_stop"
+ }
+ }
+ ]
+ },
+ "tool_use": {
+ "request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "What is the weather in Lisbon?"
+ }
+ ],
+ "tools": [
+ {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "city"
+ ]
+ }
+ }
+ ],
+ "tool_choice": {
+ "type": "auto"
+ }
+ },
+ "stream_request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "What is the weather in Lisbon?"
+ }
+ ],
+ "tools": [
+ {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "city"
+ ]
+ }
+ }
+ ],
+ "tool_choice": {
+ "type": "auto"
+ },
+ "stream": true
+ },
+ "response": {
+ "id": "msg_up_tool",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-tool",
+ "content": [
+ {
+ "type": "text",
+ "text": "Let me check."
+ },
+ {
+ "type": "tool_use",
+ "id": "toolu_weather_1",
+ "name": "get_weather",
+ "input": {
+ "city": "Lisbon"
+ }
+ }
+ ],
+ "stop_reason": "tool_use",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 50,
+ "output_tokens": 12
+ }
+ },
+ "stream": [
+ {
+ "event": "message_start",
+ "data": {
+ "type": "message_start",
+ "message": {
+ "id": "msg_up_tool",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-tool",
+ "content": [],
+ "stop_reason": null,
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 50,
+ "output_tokens": 0
+ }
+ }
+ }
+ },
+ {
+ "event": "content_block_start",
+ "data": {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {
+ "type": "text",
+ "text": ""
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": "Let me check."
+ }
+ }
+ },
+ {
+ "event": "content_block_stop",
+ "data": {
+ "type": "content_block_stop",
+ "index": 0
+ }
+ },
+ {
+ "event": "content_block_start",
+ "data": {
+ "type": "content_block_start",
+ "index": 1,
+ "content_block": {
+ "type": "tool_use",
+ "id": "toolu_weather_1",
+ "name": "get_weather",
+ "input": {}
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 1,
+ "delta": {
+ "type": "input_json_delta",
+ "partial_json": "{\"city\":\"Lisbon\"}"
+ }
+ }
+ },
+ {
+ "event": "content_block_stop",
+ "data": {
+ "type": "content_block_stop",
+ "index": 1
+ }
+ },
+ {
+ "event": "message_delta",
+ "data": {
+ "type": "message_delta",
+ "delta": {
+ "stop_reason": "tool_use",
+ "stop_sequence": null
+ },
+ "usage": {
+ "output_tokens": 12
+ }
+ }
+ },
+ {
+ "event": "message_stop",
+ "data": {
+ "type": "message_stop"
+ }
+ }
+ ]
+ },
+ "tool_result": {
+ "request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "What is the weather in Lisbon?"
+ },
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "text",
+ "text": "Let me check."
+ },
+ {
+ "type": "tool_use",
+ "id": "toolu_weather_1",
+ "name": "get_weather",
+ "input": {
+ "city": "Lisbon"
+ }
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_weather_1",
+ "content": "19 C and sunny"
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "city"
+ ]
+ }
+ }
+ ]
+ },
+ "stream_request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "What is the weather in Lisbon?"
+ },
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "text",
+ "text": "Let me check."
+ },
+ {
+ "type": "tool_use",
+ "id": "toolu_weather_1",
+ "name": "get_weather",
+ "input": {
+ "city": "Lisbon"
+ }
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_weather_1",
+ "content": "19 C and sunny"
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "city"
+ ]
+ }
+ }
+ ],
+ "stream": true
+ },
+ "response": {
+ "id": "msg_up_tool_result",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-tool-result",
+ "content": [
+ {
+ "type": "text",
+ "text": "It is 19 C and sunny in Lisbon."
+ }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 68,
+ "output_tokens": 8
+ }
+ },
+ "stream": [
+ {
+ "event": "message_start",
+ "data": {
+ "type": "message_start",
+ "message": {
+ "id": "msg_up_tool_result",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-tool-result",
+ "content": [],
+ "stop_reason": null,
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 68,
+ "output_tokens": 0
+ }
+ }
+ }
+ },
+ {
+ "event": "content_block_start",
+ "data": {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {
+ "type": "text",
+ "text": ""
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": "It is 19 C"
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": " and sunny in Lisbon."
+ }
+ }
+ },
+ {
+ "event": "content_block_stop",
+ "data": {
+ "type": "content_block_stop",
+ "index": 0
+ }
+ },
+ {
+ "event": "message_delta",
+ "data": {
+ "type": "message_delta",
+ "delta": {
+ "stop_reason": "end_turn",
+ "stop_sequence": null
+ },
+ "usage": {
+ "output_tokens": 8
+ }
+ }
+ },
+ {
+ "event": "message_stop",
+ "data": {
+ "type": "message_stop"
+ }
+ }
+ ]
+ },
+ "json_structured": {
+ "request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Weather in Lisbon as structured JSON."
+ }
+ ],
+ "tools": [
+ {
+ "name": "weather",
+ "description": "Return the weather result.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string"
+ },
+ "temp_c": {
+ "type": "integer"
+ },
+ "conditions": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "city",
+ "temp_c",
+ "conditions"
+ ]
+ }
+ }
+ ],
+ "tool_choice": {
+ "type": "tool",
+ "name": "weather"
+ },
+ "system": "Return the answer through the tool."
+ },
+ "stream_request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Weather in Lisbon as structured JSON."
+ }
+ ],
+ "tools": [
+ {
+ "name": "weather",
+ "description": "Return the weather result.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string"
+ },
+ "temp_c": {
+ "type": "integer"
+ },
+ "conditions": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "city",
+ "temp_c",
+ "conditions"
+ ]
+ }
+ }
+ ],
+ "tool_choice": {
+ "type": "tool",
+ "name": "weather"
+ },
+ "stream": true,
+ "system": "Return the answer through the tool."
+ },
+ "response": {
+ "id": "msg_up_json",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-json",
+ "content": [
+ {
+ "type": "tool_use",
+ "id": "toolu_weather_json",
+ "name": "weather",
+ "input": {
+ "city": "Lisbon",
+ "temp_c": 19,
+ "conditions": "sunny"
+ }
+ }
+ ],
+ "stop_reason": "tool_use",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 39,
+ "output_tokens": 15
+ }
+ },
+ "stream": [
+ {
+ "event": "message_start",
+ "data": {
+ "type": "message_start",
+ "message": {
+ "id": "msg_up_json",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-json",
+ "content": [],
+ "stop_reason": null,
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 39,
+ "output_tokens": 0
+ }
+ }
+ }
+ },
+ {
+ "event": "content_block_start",
+ "data": {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {
+ "type": "tool_use",
+ "id": "toolu_weather_json",
+ "name": "weather",
+ "input": {}
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "input_json_delta",
+ "partial_json": "{\"city\":\"Lisbon\",\"temp_c\":19,"
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "input_json_delta",
+ "partial_json": "\"conditions\":\"sunny\"}"
+ }
+ }
+ },
+ {
+ "event": "content_block_stop",
+ "data": {
+ "type": "content_block_stop",
+ "index": 0
+ }
+ },
+ {
+ "event": "message_delta",
+ "data": {
+ "type": "message_delta",
+ "delta": {
+ "stop_reason": "tool_use",
+ "stop_sequence": null
+ },
+ "usage": {
+ "output_tokens": 15
+ }
+ }
+ },
+ {
+ "event": "message_stop",
+ "data": {
+ "type": "message_stop"
+ }
+ }
+ ]
+ },
+ "multi_turn": {
+ "request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "system": "You are a concise travel assistant.",
+ "messages": [
+ {
+ "role": "user",
+ "content": "I am going to Lisbon."
+ },
+ {
+ "role": "assistant",
+ "content": "Lisbon is a good choice."
+ },
+ {
+ "role": "user",
+ "content": "What should I pack?"
+ }
+ ]
+ },
+ "stream_request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "system": "You are a concise travel assistant.",
+ "messages": [
+ {
+ "role": "user",
+ "content": "I am going to Lisbon."
+ },
+ {
+ "role": "assistant",
+ "content": "Lisbon is a good choice."
+ },
+ {
+ "role": "user",
+ "content": "What should I pack?"
+ }
+ ],
+ "stream": true
+ },
+ "response": {
+ "id": "msg_up_multi",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-multi",
+ "content": [
+ {
+ "type": "text",
+ "text": "Pack a light jacket and sunscreen."
+ }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 51,
+ "output_tokens": 8
+ }
+ },
+ "stream": [
+ {
+ "event": "message_start",
+ "data": {
+ "type": "message_start",
+ "message": {
+ "id": "msg_up_multi",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-multi",
+ "content": [],
+ "stop_reason": null,
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 51,
+ "output_tokens": 0
+ }
+ }
+ }
+ },
+ {
+ "event": "content_block_start",
+ "data": {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {
+ "type": "text",
+ "text": ""
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": "Pack a light jacket"
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": " and sunscreen."
+ }
+ }
+ },
+ {
+ "event": "content_block_stop",
+ "data": {
+ "type": "content_block_stop",
+ "index": 0
+ }
+ },
+ {
+ "event": "message_delta",
+ "data": {
+ "type": "message_delta",
+ "delta": {
+ "stop_reason": "end_turn",
+ "stop_sequence": null
+ },
+ "usage": {
+ "output_tokens": 8
+ }
+ }
+ },
+ {
+ "event": "message_stop",
+ "data": {
+ "type": "message_stop"
+ }
+ }
+ ]
+ },
+ "exact_id": {
+ "request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Who answered this exact id?"
+ }
+ ]
+ },
+ "stream_request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 256,
+ "messages": [
+ {
+ "role": "user",
+ "content": "Who answered this exact id?"
+ }
+ ],
+ "stream": true
+ },
+ "response": {
+ "id": "msg_up_exact",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-exact",
+ "content": [
+ {
+ "type": "text",
+ "text": "the exact artifact answered"
+ }
+ ],
+ "stop_reason": "end_turn",
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 12,
+ "output_tokens": 5
+ }
+ },
+ "stream": [
+ {
+ "event": "message_start",
+ "data": {
+ "type": "message_start",
+ "message": {
+ "id": "msg_up_exact",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-up-exact",
+ "content": [],
+ "stop_reason": null,
+ "stop_sequence": null,
+ "usage": {
+ "input_tokens": 12,
+ "output_tokens": 0
+ }
+ }
+ }
+ },
+ {
+ "event": "content_block_start",
+ "data": {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {
+ "type": "text",
+ "text": ""
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": "the exact artifact"
+ }
+ }
+ },
+ {
+ "event": "content_block_delta",
+ "data": {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": " answered"
+ }
+ }
+ },
+ {
+ "event": "content_block_stop",
+ "data": {
+ "type": "content_block_stop",
+ "index": 0
+ }
+ },
+ {
+ "event": "message_delta",
+ "data": {
+ "type": "message_delta",
+ "delta": {
+ "stop_reason": "end_turn",
+ "stop_sequence": null
+ },
+ "usage": {
+ "output_tokens": 5
+ }
+ }
+ },
+ {
+ "event": "message_stop",
+ "data": {
+ "type": "message_stop"
+ }
+ }
+ ]
+ }
+ },
+ "refusals": {
+ "exact_missing": {
+ "request": {
+ "model": "anthropic/nope@rev0",
+ "max_tokens": 64,
+ "messages": [
+ {
+ "role": "user",
+ "content": "This artifact does not exist."
+ }
+ ]
+ },
+ "stream_request": {
+ "model": "anthropic/nope@rev0",
+ "max_tokens": 64,
+ "messages": [
+ {
+ "role": "user",
+ "content": "This artifact does not exist."
+ }
+ ],
+ "stream": true
+ },
+ "status": 404,
+ "code": "ai.model_not_found"
+ },
+ "exact_unavailable": {
+ "request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 64,
+ "messages": [
+ {
+ "role": "user",
+ "content": "The artifact has no active deployment."
+ }
+ ]
+ },
+ "stream_request": {
+ "model": "anthropic/claude-test@revA",
+ "max_tokens": 64,
+ "messages": [
+ {
+ "role": "user",
+ "content": "The artifact has no active deployment."
+ }
+ ],
+ "stream": true
+ },
+ "status": 400,
+ "code": "ai.model_unavailable"
+ }
+ },
+ "aliases": [
+ {
+ "model": "openvibe",
+ "policy": "auto"
+ },
+ {
+ "model": "openvibe/auto",
+ "policy": "auto"
+ },
+ {
+ "model": "openvibe/economy",
+ "policy": "cheapest"
+ },
+ {
+ "model": "openvibe/balanced",
+ "policy": "balanced"
+ },
+ {
+ "model": "openvibe/fast",
+ "policy": "fastest"
+ },
+ {
+ "model": "openvibe/private",
+ "policy": "private",
+ "deployment": "delta-local"
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,653 @@
@@ -0,0 +1,653 @@
+{
+ "protocol": "openai-chat-completions",
+ "source": "Shapes transcribed from the public OpenAI Chat Completions API reference and the official OpenAI SDK examples (request, non-streaming response, streamed chat.completion.chunk, [DONE]). No production call was made.",
+ "scenarios": {
+ "text": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "Be concise." },
+ { "role": "user", "content": "Say hello." }
+ ]
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "Be concise." },
+ { "role": "user", "content": "Say hello." }
+ ],
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "response": {
+ "id": "chatcmpl-up-text",
+ "object": "chat.completion",
+ "created": 1762300000,
+ "model": "gpt-up-text",
+ "choices": [
+ {
+ "index": 0,
+ "message": { "role": "assistant", "content": "Hello there" },
+ "finish_reason": "stop",
+ "logprobs": null
+ }
+ ],
+ "usage": { "prompt_tokens": 18, "completion_tokens": 3, "total_tokens": 21 },
+ "system_fingerprint": "fp_text"
+ },
+ "stream": [
+ {
+ "id": "chatcmpl-up-text",
+ "object": "chat.completion.chunk",
+ "created": 1762300000,
+ "model": "gpt-up-text",
+ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "Hello" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-text",
+ "object": "chat.completion.chunk",
+ "created": 1762300000,
+ "model": "gpt-up-text",
+ "choices": [{ "index": 0, "delta": { "content": " there" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-text",
+ "object": "chat.completion.chunk",
+ "created": 1762300000,
+ "model": "gpt-up-text",
+ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]
+ },
+ {
+ "id": "chatcmpl-up-text",
+ "object": "chat.completion.chunk",
+ "created": 1762300000,
+ "model": "gpt-up-text",
+ "choices": [],
+ "usage": { "prompt_tokens": 18, "completion_tokens": 3, "total_tokens": 21 }
+ }
+ ]
+ },
+ "tool_call": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [{ "role": "user", "content": "What is the weather in Lisbon?" }],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "parameters": {
+ "type": "object",
+ "properties": { "city": { "type": "string" } },
+ "required": ["city"],
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ }
+ ],
+ "tool_choice": "auto"
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [{ "role": "user", "content": "What is the weather in Lisbon?" }],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "parameters": {
+ "type": "object",
+ "properties": { "city": { "type": "string" } },
+ "required": ["city"],
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ }
+ ],
+ "tool_choice": "auto",
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "response": {
+ "id": "chatcmpl-up-tool",
+ "object": "chat.completion",
+ "created": 1762300001,
+ "model": "gpt-up-tool",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_weather_1",
+ "type": "function",
+ "function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\"}" }
+ }
+ ]
+ },
+ "finish_reason": "tool_calls",
+ "logprobs": null
+ }
+ ],
+ "usage": { "prompt_tokens": 42, "completion_tokens": 17, "total_tokens": 59 },
+ "system_fingerprint": "fp_tool"
+ },
+ "stream": [
+ {
+ "id": "chatcmpl-up-tool",
+ "object": "chat.completion.chunk",
+ "created": 1762300001,
+ "model": "gpt-up-tool",
+ "choices": [{
+ "index": 0,
+ "delta": {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [{
+ "index": 0,
+ "id": "call_weather_1",
+ "type": "function",
+ "function": { "name": "get_weather", "arguments": "" }
+ }]
+ },
+ "finish_reason": null
+ }]
+ },
+ {
+ "id": "chatcmpl-up-tool",
+ "object": "chat.completion.chunk",
+ "created": 1762300001,
+ "model": "gpt-up-tool",
+ "choices": [{
+ "index": 0,
+ "delta": { "tool_calls": [{ "index": 0, "function": { "arguments": "{\"city\":" } }] },
+ "finish_reason": null
+ }]
+ },
+ {
+ "id": "chatcmpl-up-tool",
+ "object": "chat.completion.chunk",
+ "created": 1762300001,
+ "model": "gpt-up-tool",
+ "choices": [{
+ "index": 0,
+ "delta": { "tool_calls": [{ "index": 0, "function": { "arguments": "\"Lisbon\"}" } }] },
+ "finish_reason": null
+ }]
+ },
+ {
+ "id": "chatcmpl-up-tool",
+ "object": "chat.completion.chunk",
+ "created": 1762300001,
+ "model": "gpt-up-tool",
+ "choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }]
+ },
+ {
+ "id": "chatcmpl-up-tool",
+ "object": "chat.completion.chunk",
+ "created": 1762300001,
+ "model": "gpt-up-tool",
+ "choices": [],
+ "usage": { "prompt_tokens": 42, "completion_tokens": 17, "total_tokens": 59 }
+ }
+ ]
+ },
+ "tool_result": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "user", "content": "What is the weather in Lisbon?" },
+ {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_weather_1",
+ "type": "function",
+ "function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\"}" }
+ }
+ ]
+ },
+ { "role": "tool", "tool_call_id": "call_weather_1", "content": "19 C and sunny" }
+ ],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "parameters": {
+ "type": "object",
+ "properties": { "city": { "type": "string" } },
+ "required": ["city"],
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ }
+ ]
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "user", "content": "What is the weather in Lisbon?" },
+ {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_weather_1",
+ "type": "function",
+ "function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\"}" }
+ }
+ ]
+ },
+ { "role": "tool", "tool_call_id": "call_weather_1", "content": "19 C and sunny" }
+ ],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": "Get the current weather for a city.",
+ "parameters": {
+ "type": "object",
+ "properties": { "city": { "type": "string" } },
+ "required": ["city"],
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ }
+ ],
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "response": {
+ "id": "chatcmpl-up-tool-result",
+ "object": "chat.completion",
+ "created": 1762300002,
+ "model": "gpt-up-tool-result",
+ "choices": [
+ {
+ "index": 0,
+ "message": { "role": "assistant", "content": "It is 19 C and sunny in Lisbon." },
+ "finish_reason": "stop",
+ "logprobs": null
+ }
+ ],
+ "usage": { "prompt_tokens": 58, "completion_tokens": 8, "total_tokens": 66 },
+ "system_fingerprint": "fp_tool_result"
+ },
+ "stream": [
+ {
+ "id": "chatcmpl-up-tool-result",
+ "object": "chat.completion.chunk",
+ "created": 1762300002,
+ "model": "gpt-up-tool-result",
+ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "It is " }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-tool-result",
+ "object": "chat.completion.chunk",
+ "created": 1762300002,
+ "model": "gpt-up-tool-result",
+ "choices": [{ "index": 0, "delta": { "content": "19 C and sunny in Lisbon." }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-tool-result",
+ "object": "chat.completion.chunk",
+ "created": 1762300002,
+ "model": "gpt-up-tool-result",
+ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]
+ },
+ {
+ "id": "chatcmpl-up-tool-result",
+ "object": "chat.completion.chunk",
+ "created": 1762300002,
+ "model": "gpt-up-tool-result",
+ "choices": [],
+ "usage": { "prompt_tokens": 58, "completion_tokens": 8, "total_tokens": 66 }
+ }
+ ]
+ },
+ "json_object": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "Answer with a JSON object." },
+ { "role": "user", "content": "Weather in Lisbon as JSON." }
+ ],
+ "response_format": { "type": "json_object" }
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "Answer with a JSON object." },
+ { "role": "user", "content": "Weather in Lisbon as JSON." }
+ ],
+ "response_format": { "type": "json_object" },
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "response": {
+ "id": "chatcmpl-up-json-object",
+ "object": "chat.completion",
+ "created": 1762300003,
+ "model": "gpt-up-json-object",
+ "choices": [
+ {
+ "index": 0,
+ "message": { "role": "assistant", "content": "{\"city\":\"Lisbon\",\"temp_c\":19}" },
+ "finish_reason": "stop",
+ "logprobs": null
+ }
+ ],
+ "usage": { "prompt_tokens": 31, "completion_tokens": 11, "total_tokens": 42 },
+ "system_fingerprint": "fp_json_object"
+ },
+ "stream": [
+ {
+ "id": "chatcmpl-up-json-object",
+ "object": "chat.completion.chunk",
+ "created": 1762300003,
+ "model": "gpt-up-json-object",
+ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "{\"city\":" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-json-object",
+ "object": "chat.completion.chunk",
+ "created": 1762300003,
+ "model": "gpt-up-json-object",
+ "choices": [{ "index": 0, "delta": { "content": "\"Lisbon\",\"temp_c\":19}" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-json-object",
+ "object": "chat.completion.chunk",
+ "created": 1762300003,
+ "model": "gpt-up-json-object",
+ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]
+ },
+ {
+ "id": "chatcmpl-up-json-object",
+ "object": "chat.completion.chunk",
+ "created": 1762300003,
+ "model": "gpt-up-json-object",
+ "choices": [],
+ "usage": { "prompt_tokens": 31, "completion_tokens": 11, "total_tokens": 42 }
+ }
+ ]
+ },
+ "json_schema": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "Answer with a JSON object that matches the schema." },
+ { "role": "user", "content": "Weather in Lisbon as JSON." }
+ ],
+ "response_format": {
+ "type": "json_schema",
+ "json_schema": {
+ "name": "weather",
+ "strict": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "city": { "type": "string" },
+ "temp_c": { "type": "integer" },
+ "conditions": { "type": "string" }
+ },
+ "required": ["city", "temp_c", "conditions"],
+ "additionalProperties": false
+ }
+ }
+ }
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "Answer with a JSON object that matches the schema." },
+ { "role": "user", "content": "Weather in Lisbon as JSON." }
+ ],
+ "response_format": {
+ "type": "json_schema",
+ "json_schema": {
+ "name": "weather",
+ "strict": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "city": { "type": "string" },
+ "temp_c": { "type": "integer" },
+ "conditions": { "type": "string" }
+ },
+ "required": ["city", "temp_c", "conditions"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "response": {
+ "id": "chatcmpl-up-json-schema",
+ "object": "chat.completion",
+ "created": 1762300004,
+ "model": "gpt-up-json-schema",
+ "choices": [
+ {
+ "index": 0,
+ "message": { "role": "assistant", "content": "{\"city\":\"Lisbon\",\"temp_c\":19,\"conditions\":\"sunny\"}" },
+ "finish_reason": "stop",
+ "logprobs": null
+ }
+ ],
+ "usage": { "prompt_tokens": 44, "completion_tokens": 14, "total_tokens": 58 },
+ "system_fingerprint": "fp_json_schema"
+ },
+ "stream": [
+ {
+ "id": "chatcmpl-up-json-schema",
+ "object": "chat.completion.chunk",
+ "created": 1762300004,
+ "model": "gpt-up-json-schema",
+ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "{\"city\":\"Lisbon\"," }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-json-schema",
+ "object": "chat.completion.chunk",
+ "created": 1762300004,
+ "model": "gpt-up-json-schema",
+ "choices": [{ "index": 0, "delta": { "content": "\"temp_c\":19,\"conditions\":\"sunny\"}" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-json-schema",
+ "object": "chat.completion.chunk",
+ "created": 1762300004,
+ "model": "gpt-up-json-schema",
+ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]
+ },
+ {
+ "id": "chatcmpl-up-json-schema",
+ "object": "chat.completion.chunk",
+ "created": 1762300004,
+ "model": "gpt-up-json-schema",
+ "choices": [],
+ "usage": { "prompt_tokens": 44, "completion_tokens": 14, "total_tokens": 58 }
+ }
+ ]
+ },
+ "multi_turn": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "You are a concise travel assistant." },
+ { "role": "user", "content": "I am going to Lisbon." },
+ { "role": "assistant", "content": "Lisbon is a good choice." },
+ { "role": "user", "content": "What should I pack?" }
+ ]
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [
+ { "role": "system", "content": "You are a concise travel assistant." },
+ { "role": "user", "content": "I am going to Lisbon." },
+ { "role": "assistant", "content": "Lisbon is a good choice." },
+ { "role": "user", "content": "What should I pack?" }
+ ],
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "response": {
+ "id": "chatcmpl-up-multi",
+ "object": "chat.completion",
+ "created": 1762300005,
+ "model": "gpt-up-multi",
+ "choices": [
+ {
+ "index": 0,
+ "message": { "role": "assistant", "content": "Pack a light jacket and sunscreen." },
+ "finish_reason": "stop",
+ "logprobs": null
+ }
+ ],
+ "usage": { "prompt_tokens": 51, "completion_tokens": 8, "total_tokens": 59 },
+ "system_fingerprint": "fp_multi"
+ },
+ "stream": [
+ {
+ "id": "chatcmpl-up-multi",
+ "object": "chat.completion.chunk",
+ "created": 1762300005,
+ "model": "gpt-up-multi",
+ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "Pack a light jacket" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-multi",
+ "object": "chat.completion.chunk",
+ "created": 1762300005,
+ "model": "gpt-up-multi",
+ "choices": [{ "index": 0, "delta": { "content": " and sunscreen." }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-multi",
+ "object": "chat.completion.chunk",
+ "created": 1762300005,
+ "model": "gpt-up-multi",
+ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]
+ },
+ {
+ "id": "chatcmpl-up-multi",
+ "object": "chat.completion.chunk",
+ "created": 1762300005,
+ "model": "gpt-up-multi",
+ "choices": [],
+ "usage": { "prompt_tokens": 51, "completion_tokens": 8, "total_tokens": 59 }
+ }
+ ]
+ },
+ "exact_id": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [{ "role": "user", "content": "Who answered this exact id?" }]
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [{ "role": "user", "content": "Who answered this exact id?" }],
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "response": {
+ "id": "chatcmpl-up-exact",
+ "object": "chat.completion",
+ "created": 1762300006,
+ "model": "gpt-up-exact",
+ "choices": [
+ {
+ "index": 0,
+ "message": { "role": "assistant", "content": "the exact artifact answered" },
+ "finish_reason": "stop",
+ "logprobs": null
+ }
+ ],
+ "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 },
+ "system_fingerprint": "fp_exact"
+ },
+ "stream": [
+ {
+ "id": "chatcmpl-up-exact",
+ "object": "chat.completion.chunk",
+ "created": 1762300006,
+ "model": "gpt-up-exact",
+ "choices": [{ "index": 0, "delta": { "role": "assistant", "content": "the exact artifact" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-exact",
+ "object": "chat.completion.chunk",
+ "created": 1762300006,
+ "model": "gpt-up-exact",
+ "choices": [{ "index": 0, "delta": { "content": " answered" }, "finish_reason": null }]
+ },
+ {
+ "id": "chatcmpl-up-exact",
+ "object": "chat.completion.chunk",
+ "created": 1762300006,
+ "model": "gpt-up-exact",
+ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }]
+ },
+ {
+ "id": "chatcmpl-up-exact",
+ "object": "chat.completion.chunk",
+ "created": 1762300006,
+ "model": "gpt-up-exact",
+ "choices": [],
+ "usage": { "prompt_tokens": 12, "completion_tokens": 5, "total_tokens": 17 }
+ }
+ ]
+ }
+ },
+ "refusals": {
+ "exact_missing": {
+ "request": {
+ "model": "acme/nope@rev0",
+ "messages": [{ "role": "user", "content": "This artifact does not exist." }]
+ },
+ "stream_request": {
+ "model": "acme/nope@rev0",
+ "messages": [{ "role": "user", "content": "This artifact does not exist." }],
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "status": 404,
+ "code": "ai.model_not_found"
+ },
+ "exact_unavailable": {
+ "request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [{ "role": "user", "content": "The artifact has no active deployment." }]
+ },
+ "stream_request": {
+ "model": "acme/m1:q4_k_m@rev1",
+ "messages": [{ "role": "user", "content": "The artifact has no active deployment." }],
+ "stream": true,
+ "stream_options": { "include_usage": true }
+ },
+ "status": 400,
+ "code": "ai.model_unavailable"
+ }
+ },
+ "aliases": [
+ { "model": "openvibe", "policy": "auto" },
+ { "model": "openvibe/auto", "policy": "auto" },
+ { "model": "openvibe/economy", "policy": "cheapest" },
+ { "model": "openvibe/balanced", "policy": "balanced" },
+ { "model": "openvibe/fast", "policy": "fastest" },
+ { "model": "openvibe/private", "policy": "private", "deployment": "local" }
+ ]
+}
@@ -45,7 +45,7 @@ function tmpDir() {
@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
const restartable = new Map();
-async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log } = {}) {
+async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence } = {}) {
const reuse = dir !== undefined;
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
if (dir === undefined) dir = tmpDir();
const fullEnv = {
const fullEnv = {
@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
@@ -59,7 +59,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
if (reuse) restartable.set(dir, testdb);
if (reuse) restartable.set(dir, testdb);
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels });
+ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,462 @@
@@ -0,0 +1,462 @@
+'use strict';
+// AI Exchange H: the official-client compatibility fixtures for both wire protocols, with AI_MODEL_CATALOG on.
+//
+// test/fixtures/protocol-compat/openai-chat-completions.json OpenAI Chat Completions request / response / SSE shapes
+// test/fixtures/protocol-compat/anthropic-messages.json Anthropic Messages request / message / named-SSE shapes
+//
+// Every fixture is replayed by a local fake upstream. The test then reads the answer exactly as an official client
+// would: OpenAI's chat.completion / chat.completion.chunk + [DONE], Anthropic's message / message_start……message_stop.
+// It asserts the protocol's own shape, usage, the x_openvibe disclosure (top-level and, on SSE, only the leading
+// comment and the final chunk), and that an exact id is answered by its artifact or refused 400/404, never substituted.
+// The aliases are exercised one by one in both protocols. The repo has no official openai / @anthropic-ai/sdk
+// devDependency, so the fixtures go over HTTP; OpenVibe.Examples #55 covers the SDKs themselves.
+const assert = require('assert');
+const http = require('http');
+const { boot, request, token, suite, ALL } = require('./helpers');
+const openaiFixture = require('./fixtures/protocol-compat/openai-chat-completions.json');
+const anthropicFixture = require('./fixtures/protocol-compat/anthropic-messages.json');
+
+const t = suite('protocol-compat');
+const service = token('live', ALL);
+const ANTHROPIC_HEADERS = { 'anthropic-version': '2023-06-01' };
+
+const ALPHA_ID = 'acme/m1:q4_k_m@rev1';
+const BETA_ID = 'acme/m2@rev2';
+const LOCAL_ID = 'acme/local@revL';
+const DELTA_ID = 'anthropic/claude-test@revA';
+const DELTA_LOCAL_ID = 'anthropic/claude-local@revB';
+const IDS = { alpha: ALPHA_ID, beta: BETA_ID, local: LOCAL_ID, delta: DELTA_ID, 'delta-local': DELTA_LOCAL_ID };
+
+let h;
+let alpha;
+let beta;
+let local;
+let delta;
+let deltaLocal;
+
+/* ── fake upstreams: replay one fixture scenario over the real wire protocol ───────────────── */
+async function fakeUpstream(protocol, fixture) {
+ const state = { calls: [], scenario: 'text', url: null };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ let body = {};
+ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
+ state.calls.push({ url: req.url, body });
+ const scenario = fixture.scenarios[state.scenario];
+ if (!scenario) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: `no fixture scenario ${state.scenario}` }));
+ }
+ if (body.stream) {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ if (protocol === 'openai') {
+ for (const chunk of scenario.stream) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
+ res.write('data: [DONE]\n\n');
+ } else {
+ for (const frame of scenario.stream) res.write(`event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`);
+ }
+ return res.end();
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify(scenario.response));
+ });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ state.url = `http://127.0.0.1:${server.address().port}`;
+ state.close = () => new Promise((resolve) => { server.closeAllConnections?.(); server.close(() => resolve()); });
+ return state;
+}
+
+const callCounts = () => [alpha.calls.length, beta.calls.length, local.calls.length, delta.calls.length, deltaLocal.calls.length];
+const scenario = (upstream, name) => { upstream.scenario = name; };
+
+/* ── reading the answer the way an official client does ───────────────────────────────────── */
+const blocks = (text) => text.split('\n\n').filter((b) => b.trim());
+const comment = (block) => {
+ assert.match(block, /^: x_openvibe /, 'the leading block is the disclosure comment');
+ return JSON.parse(block.slice(': x_openvibe '.length));
+};
+function parseOpenAiSse(text) {
+ const all = blocks(text);
+ const comments = all.filter((b) => b.startsWith(': '));
+ const data = all.filter((b) => b.startsWith('data: ')).map((b) => b.slice('data: '.length));
+ const done = data.length > 0 && data[data.length - 1] === '[DONE]';
+ const chunks = data.filter((d) => d !== '[DONE]').map((d) => JSON.parse(d));
+ return { all, comments, chunks, done };
+}
+function parseAnthropicSse(text) {
+ const all = blocks(text);
+ const comments = all.filter((b) => b.startsWith(': '));
+ const frames = [];
+ for (const block of all) {
+ const e = /^event: (.+)$/m.exec(block);
+ const d = /^data: (.*)$/m.exec(block);
+ if (!e && !d) continue;
+ assert.ok(e && d, `a complete named SSE frame: ${block}`);
+ frames.push({ event: e[1].trim(), data: JSON.parse(d[1]) });
+ }
+ return { all, comments, frames };
+}
+
+function assertRouteDisclosure(x) {
+ assert.ok(x && typeof x === 'object', 'the disclosure is an object');
+ for (const k of ['resolved_model', 'revision', 'deployment', 'provider_access', 'rate_version', 'policy', 'fallback_reason']) {
+ assert.ok(k in x, `x_openvibe.${k} is present`);
+ }
+}
+function assertDisclosure(x) {
+ assertRouteDisclosure(x);
+ for (const k of ['run_id', 'provider', 'fallback_used', 'cost_usd']) assert.ok(k in x, `x_openvibe.${k} is present`);
+ assert.match(String(x.run_id), /^run_/);
+}
+
+function assertOpenAiJson(body, expected) {
+ assert.strictEqual(body.object, 'chat.completion');
+ assert.match(body.id, /^chatcmpl-run_/);
+ assert.strictEqual(typeof body.created, 'number');
+ assert.strictEqual(body.model, expected.response.model);
+ assert.strictEqual(body.system_fingerprint, expected.response.system_fingerprint);
+ assert.deepStrictEqual(body.choices, expected.response.choices, 'the upstream choices are passed through untranslated');
+ assert.ok(body.usage, 'usage is present');
+ assert.deepStrictEqual(
+ [body.usage.prompt_tokens, body.usage.completion_tokens, body.usage.total_tokens],
+ [expected.response.usage.prompt_tokens, expected.response.usage.completion_tokens, expected.response.usage.total_tokens]
+ );
+ assertDisclosure(body.x_openvibe);
+ assert.strictEqual(body.x_openvibe.run_id, body.id.slice('chatcmpl-'.length));
+ assert.ok(!JSON.stringify(body.choices).includes('x_openvibe'), 'x_openvibe never rides inside choices');
+}
+
+function assertOpenAiStream(parsed, expected) {
+ assert.strictEqual(parsed.comments.length, 1, 'exactly one SSE comment');
+ assert.match(parsed.all[0], /^: x_openvibe /, 'the disclosure is the leading SSE block');
+ const head = comment(parsed.comments[0]);
+ assertRouteDisclosure(head);
+ assert.ok(parsed.done, '[DONE] is last');
+ assert.ok(!parsed.all.some((b) => /^event:/m.test(b)), 'OpenAI SSE carries no event names');
+ assert.ok(parsed.chunks.length >= 2, 'deltas, then the final chunk');
+ assert.ok(parsed.chunks.every((c) => c.object === 'chat.completion.chunk' && Array.isArray(c.choices)), 'chat.completion.chunk frames');
+ const last = parsed.chunks[parsed.chunks.length - 1];
+ assert.ok(last.x_openvibe, 'the final chunk carries x_openvibe');
+ assert.deepStrictEqual(last.choices, []);
+ assert.ok(parsed.chunks.slice(0, -1).every((c) => !('x_openvibe' in c)), 'only the final chunk carries x_openvibe');
+ assert.ok(!JSON.stringify(parsed.chunks.map((c) => c.choices)).includes('x_openvibe'), 'x_openvibe never rides inside choices');
+ assert.deepStrictEqual(
+ parsed.chunks.filter((c) => c.choices.length).map((c) => c.choices),
+ expected.stream.filter((c) => c.choices.length).map((c) => c.choices),
+ 'the upstream deltas are passed through in order'
+ );
+ const usage = parsed.chunks.find((c) => c.usage);
+ assert.ok(usage, 'usage is present when stream_options.include_usage is asked');
+ assert.deepStrictEqual(
+ [usage.usage.prompt_tokens, usage.usage.completion_tokens, usage.usage.total_tokens],
+ [expected.response.usage.prompt_tokens, expected.response.usage.completion_tokens, expected.response.usage.total_tokens]
+ );
+ assert.ok(parsed.chunks.indexOf(last) > parsed.chunks.indexOf(usage), 'x_openvibe is on the final chunk, after usage');
+ assertDisclosure(last.x_openvibe);
+ assert.deepStrictEqual(
+ [head.resolved_model, head.deployment, head.provider_access, head.policy],
+ [last.x_openvibe.resolved_model, last.x_openvibe.deployment, last.x_openvibe.provider_access, last.x_openvibe.policy],
+ 'the comment and the final chunk disclose the same route'
+ );
+}
+
+function assertAnthropicJson(body, expected) {
+ assert.deepStrictEqual([body.type, body.role], ['message', 'assistant']);
+ assert.strictEqual(body.id, expected.response.id);
+ assert.strictEqual(body.model, expected.response.model);
+ assert.deepStrictEqual(body.content, expected.response.content, 'the upstream content blocks are passed through untranslated');
+ assert.strictEqual(body.stop_reason, expected.response.stop_reason);
+ assert.strictEqual(body.stop_sequence, expected.response.stop_sequence);
+ assert.deepStrictEqual(
+ [body.usage.input_tokens, body.usage.output_tokens],
+ [expected.response.usage.input_tokens, expected.response.usage.output_tokens]
+ );
+ assertDisclosure(body.x_openvibe);
+ assert.ok(!JSON.stringify(body.content).includes('x_openvibe'), 'x_openvibe never rides inside content');
+}
+
+function assertAnthropicStream(parsed, expected) {
+ assert.strictEqual(parsed.comments.length, 1, 'exactly one SSE comment');
+ assert.match(parsed.all[0], /^: x_openvibe /, 'the disclosure is the leading SSE block');
+ const head = comment(parsed.comments[0]);
+ assertRouteDisclosure(head);
+ const last = parsed.frames[parsed.frames.length - 1];
+ assert.ok(parsed.frames.length >= 2, 'message_start through message_stop');
+ assert.deepStrictEqual(parsed.frames.map((f) => f.event), expected.stream.map((f) => f.event), 'the named events, in protocol order');
+ assert.deepStrictEqual(parsed.frames.slice(0, -1).map((f) => f.data), expected.stream.slice(0, -1).map((f) => f.data), 'every event before message_stop is passed through');
+ assert.ok(parsed.frames.every((f) => f.event === f.data.type), 'the event name matches the data type');
+ assert.deepStrictEqual([last.event, last.data.type], ['message_stop', 'message_stop']);
+ assert.ok(last.data.x_openvibe, 'message_stop carries x_openvibe');
+ assert.ok(parsed.frames.slice(0, -1).every((f) => !('x_openvibe' in f.data)), 'only message_stop carries x_openvibe');
+ assert.ok(!JSON.stringify(parsed.frames.map((f) => f.data.content_block || {})).includes('x_openvibe'), 'x_openvibe never rides inside a content block');
+ const start = parsed.frames.find((f) => f.event === 'message_start');
+ const stop = parsed.frames.find((f) => f.event === 'message_delta');
+ assert.strictEqual(start.data.message.usage.input_tokens, expected.response.usage.input_tokens, 'input usage is present on message_start');
+ assert.strictEqual(stop.data.usage.output_tokens, expected.response.usage.output_tokens, 'output usage is present on message_delta');
+ assert.strictEqual(stop.data.delta.stop_reason, expected.response.stop_reason);
+ assertDisclosure(last.data.x_openvibe);
+ assert.deepStrictEqual(
+ [head.resolved_model, head.deployment, head.provider_access, head.policy],
+ [last.data.x_openvibe.resolved_model, last.data.x_openvibe.deployment, last.data.x_openvibe.provider_access, last.data.x_openvibe.policy],
+ 'the comment and message_stop disclose the same route'
+ );
+}
+
+/* ── HTTP helpers ──────────────────────────────────────────────────────────────────────────── */
+const post = (path, body, headers = {}) => request(h.base, 'POST', path, { tok: service, body, headers });
+async function stream(path, body, headers = {}) {
+ const res = await fetch(`${h.base}${path}`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${service}`, 'Content-Type': 'application/json', ...headers },
+ body: JSON.stringify(body),
+ });
+ return { res, text: await res.text() };
+}
+
+/* ── boot ──────────────────────────────────────────────────────────────────────────────────── */
+t.test('boot: the catalog is on, with an exact artifact for each protocol and a local private one', async () => {
+ [alpha, beta, local] = await Promise.all([fakeUpstream('openai', openaiFixture), fakeUpstream('openai', openaiFixture), fakeUpstream('openai', openaiFixture)]);
+ [delta, deltaLocal] = await Promise.all([fakeUpstream('anthropic', anthropicFixture), fakeUpstream('anthropic', anthropicFixture)]);
+ h = await boot({ env: { AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ const providers = [
+ { key: 'alpha', kind: 'openai', base_url: `${alpha.url}/v1`, auth_mode: 'none', capabilities: ['chat', 'complete', 'tools', 'json'], default_model: 'alpha-1', provider_access: 'OWN_DEPLOYMENT' },
+ { key: 'beta', kind: 'openai', base_url: `${beta.url}/v1`, auth_mode: 'none', capabilities: ['chat', 'complete', 'tools', 'json'], default_model: 'beta-1', provider_access: 'AUTHORIZED_WHOLESALE' },
+ { key: 'local', kind: 'openai', base_url: `${local.url}/v1`, auth_mode: 'none', capabilities: ['chat', 'complete', 'tools', 'json'], default_model: 'local-1', provider_access: 'OWN_DEPLOYMENT', metadata: { local: true } },
+ { key: 'delta', kind: 'anthropic', base_url: delta.url, auth_mode: 'none', capabilities: ['chat', 'messages', 'tools', 'json'], default_model: 'delta-1', provider_access: 'OWN_DEPLOYMENT' },
+ { key: 'delta-local', kind: 'anthropic', base_url: deltaLocal.url, auth_mode: 'none', capabilities: ['chat', 'messages', 'tools', 'json'], default_model: 'delta-local-1', provider_access: 'OWN_DEPLOYMENT', metadata: { local: true } },
+ ];
+ for (const provider of providers) {
+ const made = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: provider });
+ assert.strictEqual(made.status, 201, made.text);
+ }
+ const cost = (input, output) => ({ in_per_mtok: input, out_per_mtok: output, cached_per_mtok: input / 10, source: 'https://example.com/pricing', verified_at: '2026-11-01' });
+ const models = [
+ { provider_key: 'alpha', model_key: 'alpha-1', metadata: { org: 'acme', model: 'm1', quant: 'q4_k_m', revision: 'rev1', family: 'm', variant: 'original', license: 'mit', rate_version: 'rr-alpha' }, cost: cost(1, 2) },
+ { provider_key: 'beta', model_key: 'beta-1', metadata: { org: 'acme', model: 'm2', revision: 'rev2', rate_version: 'rr-beta' }, cost: cost(5, 10) },
+ { provider_key: 'local', model_key: 'local-1', metadata: { org: 'acme', model: 'local', revision: 'revL', rate_version: 'rr-local' }, cost: cost(10, 20) },
+ { provider_key: 'delta', model_key: 'delta-1', metadata: { org: 'anthropic', model: 'claude-test', revision: 'revA', rate_version: 'rr-delta' }, cost: cost(3, 6) },
+ { provider_key: 'delta-local', model_key: 'delta-local-1', metadata: { org: 'anthropic', model: 'claude-local', revision: 'revB', rate_version: 'rr-delta-local' }, cost: cost(4, 8) },
+ ];
+ for (const model of models) {
+ const made = await request(h.base, 'POST', '/api/v1/models', {
+ tok: service,
+ body: { type: 'chat', status: 'active', context_window: 32768, max_output: 4096, supports: { json: true, tools: true, streaming: true, vision: false }, ...model },
+ });
+ assert.strictEqual(made.status, 201, made.text);
+ }
+ // The aliases resolve through the same catalog.
+ for (const id of [...Object.values(IDS)]) {
+ const found = await request(h.base, 'GET', `/v1/models/${encodeURIComponent(id)}`, { tok: service });
+ assert.strictEqual(found.status, 200, `${id}: ${found.text}`);
+ }
+});
+
+/* ── OpenAI: every fixture, non-streamed and streamed ─────────────────────────────────────── */
+t.test('OpenAI Chat Completions: text, tool call, tool result, json_object, json_schema, multi-turn, exact id — JSON and SSE', async () => {
+ const before = callCounts();
+ for (const name of ['text', 'tool_call', 'tool_result', 'json_object', 'json_schema', 'multi_turn', 'exact_id']) {
+ const expected = openaiFixture.scenarios[name];
+ scenario(alpha, name);
+
+ const plain = await post('/v1/chat/completions', expected.request);
+ assert.strictEqual(plain.status, 200, `${name}: ${plain.text}`);
+ assertOpenAiJson(plain.body, expected);
+ const sent = alpha.calls[alpha.calls.length - 1].body;
+ assert.strictEqual(sent.model, 'alpha-1', `${name}: the exact artifact's own model ran`);
+ assert.deepStrictEqual(sent.messages, expected.request.messages, `${name}: messages pass through as written`);
+ if (expected.request.tools) assert.deepStrictEqual(sent.tools, expected.request.tools, `${name}: tools pass through`);
+ if (expected.request.tool_choice !== undefined) assert.deepStrictEqual(sent.tool_choice, expected.request.tool_choice, `${name}: tool_choice passes through`);
+ if (expected.request.response_format) assert.deepStrictEqual(sent.response_format, expected.request.response_format, `${name}: response_format passes through`);
+
+ scenario(alpha, name);
+ const streamed = await stream('/v1/chat/completions', expected.stream_request);
+ assert.strictEqual(streamed.res.status, 200, `${name} stream: ${streamed.text}`);
+ assert.match(streamed.res.headers.get('content-type'), /^text\/event-stream/);
+ const parsed = parseOpenAiSse(streamed.text);
+ assertOpenAiStream(parsed, expected);
+ const sentStream = alpha.calls[alpha.calls.length - 1].body;
+ assert.deepStrictEqual([sentStream.stream, sentStream.stream_options], [true, { include_usage: true }], `${name}: the upstream is asked to stream its usage`);
+ assert.deepStrictEqual(sentStream.messages, expected.stream_request.messages);
+ if (expected.stream_request.response_format) assert.deepStrictEqual(sentStream.response_format, expected.stream_request.response_format);
+ }
+ assert.strictEqual(alpha.calls.length, before[0] + 14, 'alpha answered every OpenAI fixture');
+ assert.deepStrictEqual([beta.calls.length, local.calls.length, delta.calls.length, deltaLocal.calls.length], [before[1], before[2], before[3], before[4]], 'the exact id never substituted another provider');
+});
+
+/* ── OpenAI exact ids: 404, 400, no substitution ──────────────────────────────────────────── */
+t.test('OpenAI exact ids: an unknown id is 404, an unavailable one is 400, and neither falls back', async () => {
+ // Missing: every provider is up, so a substitution would be visible.
+ const beforeMissing = callCounts();
+ for (const body of [openaiFixture.refusals.exact_missing.request, openaiFixture.refusals.exact_missing.stream_request]) {
+ const res = await post('/v1/chat/completions', body);
+ assert.strictEqual(res.status, 404, res.text);
+ assert.strictEqual(res.body.error.code, 'ai.model_not_found');
+ assert.strictEqual(res.body.error.type, 'invalid_request_error');
+ assert.match(res.headers.get('content-type'), /application\/json/, 'a refused exact id is an HTTP error, not an SSE stream');
+ }
+ assert.deepStrictEqual(callCounts(), beforeMissing, 'the unknown artifact called no provider');
+
+ // Unavailable: the artifact exists, but its only deployment is disabled; the other providers stay up.
+ const beforeUnavailable = callCounts();
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/alpha/disable', { tok: service, body: {} })).status, 200);
+ try {
+ for (const body of [openaiFixture.refusals.exact_unavailable.request, openaiFixture.refusals.exact_unavailable.stream_request]) {
+ const res = await post('/v1/chat/completions', body);
+ assert.strictEqual(res.status, 400, res.text);
+ assert.strictEqual(res.body.error.code, 'ai.model_unavailable');
+ assert.strictEqual(res.body.error.type, 'invalid_request_error');
+ assert.match(res.headers.get('content-type'), /application\/json/, 'a refused exact id is an HTTP error, not an SSE stream');
+ }
+ } finally {
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/alpha/enable', { tok: service, body: {} })).status, 200);
+ }
+ assert.deepStrictEqual(callCounts(), beforeUnavailable, 'the unavailable artifact fell back to no provider');
+
+ scenario(alpha, 'exact_id');
+ const exact = await post('/v1/chat/completions', openaiFixture.scenarios.exact_id.request);
+ assert.strictEqual(exact.status, 200, exact.text);
+ assert.deepStrictEqual(
+ [exact.body.x_openvibe.resolved_model, exact.body.x_openvibe.deployment, exact.body.x_openvibe.policy],
+ [ALPHA_ID, 'alpha', 'exact'],
+ 'the exact artifact answered, and the disclosure says so'
+ );
+});
+
+/* ── OpenAI aliases: each one, JSON and SSE ───────────────────────────────────────────────── */
+t.test('OpenAI aliases: every dynamic alias answers, and x_openvibe names its policy', async () => {
+ for (const upstream of [alpha, beta, local]) scenario(upstream, 'text');
+ const base = openaiFixture.scenarios.text.request;
+ for (const alias of openaiFixture.aliases) {
+ const before = callCounts();
+ const body = { ...base, model: alias.model };
+ const plain = await post('/v1/chat/completions', body);
+ assert.strictEqual(plain.status, 200, `${alias.model}: ${plain.text}`);
+ assertOpenAiJson(plain.body, openaiFixture.scenarios.text);
+ assert.strictEqual(plain.body.x_openvibe.policy, alias.policy, `${alias.model}: the alias policy is disclosed`);
+ assert.strictEqual(plain.body.x_openvibe.resolved_model, IDS[plain.body.x_openvibe.deployment], `${alias.model}: resolved_model matches the deployment`);
+ const after = callCounts();
+ const increased = ['alpha', 'beta', 'local'].filter((key, i) => after[i] === before[i] + 1);
+ assert.strictEqual(increased.length, 1, `${alias.model}: exactly one provider answered`);
+ assert.strictEqual(increased[0], plain.body.x_openvibe.deployment, `${alias.model}: the disclosed deployment answered`);
+ if (alias.deployment) assert.strictEqual(plain.body.x_openvibe.deployment, alias.deployment, `${alias.model}: private stays local`);
+ assert.deepStrictEqual([after[3], after[4]], [before[3], before[4]], `${alias.model}: no Anthropic upstream was called`);
+
+ const streamed = await stream('/v1/chat/completions', { ...body, stream: true, stream_options: { include_usage: true } });
+ assert.strictEqual(streamed.res.status, 200, `${alias.model} stream: ${streamed.text}`);
+ const parsed = parseOpenAiSse(streamed.text);
+ assertOpenAiStream(parsed, openaiFixture.scenarios.text);
+ assert.strictEqual(comment(parsed.comments[0]).policy, alias.policy, `${alias.model}: the leading comment discloses the alias policy`);
+ assert.strictEqual(parsed.chunks[parsed.chunks.length - 1].x_openvibe.policy, alias.policy);
+ if (alias.deployment) assert.strictEqual(parsed.chunks[parsed.chunks.length - 1].x_openvibe.deployment, alias.deployment);
+ }
+});
+
+/* ── Anthropic: every fixture, non-streamed and streamed ──────────────────────────────────── */
+t.test('Anthropic Messages: text, tool_use, tool_result, structured JSON tool, multi-turn, exact id — message and named SSE', async () => {
+ const before = callCounts();
+ for (const name of ['text', 'tool_use', 'tool_result', 'json_structured', 'multi_turn', 'exact_id']) {
+ const expected = anthropicFixture.scenarios[name];
+ scenario(delta, name);
+
+ const plain = await post('/v1/messages', expected.request, ANTHROPIC_HEADERS);
+ assert.strictEqual(plain.status, 200, `${name}: ${plain.text}`);
+ assertAnthropicJson(plain.body, expected);
+ const sent = delta.calls[delta.calls.length - 1].body;
+ assert.strictEqual(sent.model, 'delta-1', `${name}: the exact artifact's own model ran`);
+ assert.deepStrictEqual(sent.messages, expected.request.messages, `${name}: messages pass through as written`);
+ if (expected.request.system !== undefined) assert.deepStrictEqual(sent.system, expected.request.system, `${name}: the system prompt passes through`);
+ if (expected.request.tools) assert.deepStrictEqual(sent.tools, expected.request.tools, `${name}: tools pass through`);
+ if (expected.request.tool_choice !== undefined) assert.deepStrictEqual(sent.tool_choice, expected.request.tool_choice, `${name}: tool_choice passes through`);
+
+ scenario(delta, name);
+ const streamed = await stream('/v1/messages', expected.stream_request, ANTHROPIC_HEADERS);
+ assert.strictEqual(streamed.res.status, 200, `${name} stream: ${streamed.text}`);
+ assert.match(streamed.res.headers.get('content-type'), /^text\/event-stream/);
+ const parsed = parseAnthropicSse(streamed.text);
+ assertAnthropicStream(parsed, expected);
+ const sentStream = delta.calls[delta.calls.length - 1].body;
+ assert.strictEqual(sentStream.stream, true);
+ assert.deepStrictEqual(sentStream.messages, expected.stream_request.messages);
+ }
+ assert.strictEqual(delta.calls.length, before[3] + 12, 'delta answered every Anthropic fixture');
+ assert.deepStrictEqual([alpha.calls.length, beta.calls.length, local.calls.length, deltaLocal.calls.length], [before[0], before[1], before[2], before[4]], 'the exact id never substituted another provider');
+});
+
+/* ── Anthropic exact ids: 404, 400, no substitution ───────────────────────────────────────── */
+t.test('Anthropic exact ids: an unknown id is 404, an unavailable one is 400, and neither falls back', async () => {
+ // Missing: every provider is up, so a substitution would be visible.
+ const beforeMissing = callCounts();
+ for (const body of [anthropicFixture.refusals.exact_missing.request, anthropicFixture.refusals.exact_missing.stream_request]) {
+ const res = await post('/v1/messages', body, ANTHROPIC_HEADERS);
+ assert.strictEqual(res.status, 404, res.text);
+ assert.strictEqual(res.body.type, 'error');
+ assert.deepStrictEqual([res.body.error.type, res.body.error.code], ['not_found_error', 'ai.model_not_found']);
+ assert.ok(!res.headers.get('content-type').startsWith('text/event-stream'), 'a refused exact id is an HTTP error, not an SSE stream');
+ }
+ assert.deepStrictEqual(callCounts(), beforeMissing, 'the unknown artifact called no provider');
+
+ // Unavailable: the artifact exists, but its only deployment is disabled; the other providers stay up.
+ const beforeUnavailable = callCounts();
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/delta/disable', { tok: service, body: {} })).status, 200);
+ try {
+ for (const body of [anthropicFixture.refusals.exact_unavailable.request, anthropicFixture.refusals.exact_unavailable.stream_request]) {
+ const res = await post('/v1/messages', body, ANTHROPIC_HEADERS);
+ assert.strictEqual(res.status, 400, res.text);
+ assert.strictEqual(res.body.type, 'error');
+ assert.deepStrictEqual([res.body.error.type, res.body.error.code], ['invalid_request_error', 'ai.model_unavailable']);
+ assert.ok(!res.headers.get('content-type').startsWith('text/event-stream'), 'a refused exact id is an HTTP error, not an SSE stream');
+ }
+ } finally {
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/delta/enable', { tok: service, body: {} })).status, 200);
+ }
+ assert.deepStrictEqual(callCounts(), beforeUnavailable, 'the unavailable artifact fell back to no provider');
+
+ scenario(delta, 'exact_id');
+ const exact = await post('/v1/messages', anthropicFixture.scenarios.exact_id.request, ANTHROPIC_HEADERS);
+ assert.strictEqual(exact.status, 200, exact.text);
+ assert.deepStrictEqual(
+ [exact.body.x_openvibe.resolved_model, exact.body.x_openvibe.deployment, exact.body.x_openvibe.policy],
+ [DELTA_ID, 'delta', 'exact'],
+ 'the exact artifact answered, and the disclosure says so'
+ );
+});
+
+/* ── Anthropic aliases: each one, message and SSE ─────────────────────────────────────────── */
+t.test('Anthropic aliases: every dynamic alias answers, and x_openvibe names its policy', async () => {
+ for (const upstream of [delta, deltaLocal]) scenario(upstream, 'text');
+ const base = anthropicFixture.scenarios.text.request;
+ for (const alias of anthropicFixture.aliases) {
+ const before = callCounts();
+ const body = { ...base, model: alias.model };
+ const plain = await post('/v1/messages', body, ANTHROPIC_HEADERS);
+ assert.strictEqual(plain.status, 200, `${alias.model}: ${plain.text}`);
+ assertAnthropicJson(plain.body, anthropicFixture.scenarios.text);
+ assert.strictEqual(plain.body.x_openvibe.policy, alias.policy, `${alias.model}: the alias policy is disclosed`);
+ assert.strictEqual(plain.body.x_openvibe.resolved_model, IDS[plain.body.x_openvibe.deployment], `${alias.model}: resolved_model matches the deployment`);
+ const after = callCounts();
+ const increased = ['delta', 'delta-local'].filter((key, i) => after[i + 3] === before[i + 3] + 1);
+ assert.strictEqual(increased.length, 1, `${alias.model}: exactly one provider answered`);
+ assert.strictEqual(increased[0], plain.body.x_openvibe.deployment, `${alias.model}: the disclosed deployment answered`);
+ if (alias.deployment) assert.strictEqual(plain.body.x_openvibe.deployment, alias.deployment, `${alias.model}: private stays local`);
+ assert.deepStrictEqual([after[0], after[1], after[2]], [before[0], before[1], before[2]], `${alias.model}: no OpenAI upstream was called`);
+
+ const streamed = await stream('/v1/messages', { ...body, stream: true }, ANTHROPIC_HEADERS);
+ assert.strictEqual(streamed.res.status, 200, `${alias.model} stream: ${streamed.text}`);
+ const parsed = parseAnthropicSse(streamed.text);
+ assertAnthropicStream(parsed, anthropicFixture.scenarios.text);
+ const last = parsed.frames[parsed.frames.length - 1];
+ assert.strictEqual(comment(parsed.comments[0]).policy, alias.policy, `${alias.model}: the leading comment discloses the alias policy`);
+ assert.strictEqual(last.data.x_openvibe.policy, alias.policy);
+ if (alias.deployment) assert.strictEqual(last.data.x_openvibe.deployment, alias.deployment);
+ }
+});
+
+t.test('stop', async () => {
+ await h.stop();
+ await Promise.all([alpha.close(), beta.close(), local.close(), delta.close(), deltaLocal.close()]);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,312 @@
@@ -0,0 +1,312 @@
+'use strict';
+/**
+ * AI Exchange P7: alias routing by measured evidence (AI_ROUTING_EVIDENCE).
+ *
+ * - the per-deployment EWMA updates from each attempt and survives a new reader (the table is the store);
+ * - price_after_cache is integer BigInt arithmetic on the retail card and the measured cache-hit ratio;
+ * - economy orders by expected cost per success, fast by p50 TTFT then decode tokens/s;
+ * - sparse Bench evidence is ignored and reported, never filled in;
+ * - hysteresis keeps the incumbent within the margin and switches after N consecutive challenger wins;
+ * - cache affinity prefers the deployment that served the same prompt prefix while it is within the margin;
+ * - exploration is 0 by default and never runs on an exact id, BYOK or an opted-out preference;
+ * - with the flag off nothing is written and the router's order is unchanged.
+ */
+const assert = require('assert');
+const { suite, boot, request, token, seamServer, ALL } = require('./helpers');
+const { testDb } = require('./db');
+const { createProviderMetrics, ewma } = require('../server/providers/metrics');
+const { createRoutingEvidence, priceAfterCacheNano, blendedInputRateNano, combineEvidence } = require('../server/providers/evidence');
+const { createVariantEvidence } = require('../server/providers/bench-variant');
+
+const t = suite('routing-evidence');
+const tok = token('live', ALL);
+const CLOCK = { now: () => Date.parse('2026-11-04T00:00:00.000Z') };
+const CONFIG = (over = {}) => ({
+ routingEvidence: {
+ enabled: true, alpha: 0.2, minSamples: 2, successFloor: 0.1, prior: 0.9,
+ hysteresisDecisions: 3, explorationShare: 0, affinityGain: 0.05, expectedOutputTokens: 512, ...over,
+ },
+ placement: { costWeight: 0.5, latencyWeight: 0.5 },
+});
+const RETAIL = (inM, cachedM, outM, fee = '0') => ({
+ input_nano_usd_per_m: String(inM), cached_read_nano_usd_per_m: String(cachedM),
+ cache_write_nano_usd_per_m: '0', output_nano_usd_per_m: String(outM), per_request_nano_usd: fee,
+});
+
+async function seed(metrics, provider, { n = 3, ok = true, ttft = null, latency = 1000, input = 1000, cached = 0, output = 500 } = {}) {
+ for (let i = 0; i < n; i++) await metrics.record({ provider, model: 'm', ok, ttftMs: ttft, latencyMs: latency, inputTokens: input, cachedTokens: cached, outputTokens: output });
+}
+
+async function harness({ prices = {}, config = CONFIG(), bench = null, metrics: givenMetrics = null } = {}) {
+ const db = await testDb();
+ const metrics = givenMetrics || createProviderMetrics({ db: db.db, clock: CLOCK });
+ const resolveOffer = async (c) => {
+ const p = prices[c.provider];
+ if (!p) return null;
+ return { id: `${c.provider}:${c.model}`, provider: c.provider, model: c.model, exactId: p.exactId || null, access: 'OWN_DEPLOYMENT', free: false, retail: p.retail || RETAIL(10, 1, 10) };
+ };
+ const ev = createRoutingEvidence({ db: db.db, clock: CLOCK, metrics, bench, resolveOffer, config });
+ return { db, metrics, ev, stop: () => db.close() };
+}
+
+t.test('the EWMA updates and the table keeps the measurements for a new reader', async () => {
+ const db = await testDb();
+ const first = createProviderMetrics({ db: db.db, clock: CLOCK });
+ await first.record({ provider: 'p', model: 'm', ok: true, ttftMs: 100, latencyMs: 1100, inputTokens: 1000, cachedTokens: 500, outputTokens: 1000 });
+ await first.record({ provider: 'p', model: 'm', ok: false, timeout: true, latencyMs: 400, inputTokens: 100, cachedTokens: 0, outputTokens: 0 });
+ const row = await first.get('p', 'm');
+ assert.strictEqual(row.sample_count, 2);
+ assert.strictEqual(row.ttft_samples, 1);
+ assert.strictEqual(row.decode_samples, 1);
+ assert.strictEqual(row.cache_samples, 2);
+ assert.strictEqual(row.success_rate, ewma(1, 0, 0.2), 'one failure after a success is an EWMA, not a reset');
+ assert.strictEqual(row.timeout_rate, 0.2);
+ assert.strictEqual(row.cache_hit_ratio, 0.2 * 0 + 0.8 * 0.5, 'the cache ratio follows its own observations');
+ const second = createProviderMetrics({ db: db.db, clock: CLOCK });
+ const again = await second.get('p', 'm');
+ assert.deepStrictEqual(again, row, 'a new reader (a restart) sees the persisted row');
+ await db.close();
+});
+
+t.test('price_after_cache is exact integer nano-USD weighted by the cache-hit ratio', () => {
+ const rates = RETAIL(3000000, 300000, 15000000, '5');
+ const allUncached = priceAfterCacheNano({ promptTokens: 1000000, outputTokens: 500000, cacheHitRatio: 0, rates });
+ const half = priceAfterCacheNano({ promptTokens: 1000000, outputTokens: 500000, cacheHitRatio: 0.5, rates });
+ const allCached = priceAfterCacheNano({ promptTokens: 1000000, outputTokens: 500000, cacheHitRatio: 1, rates });
+ assert.strictEqual(allUncached, BigInt(3000000 + 7500000 + 5));
+ assert.strictEqual(half, BigInt(1500000 + 150000 + 7500000 + 5));
+ assert.strictEqual(allCached, BigInt(300000 + 7500000 + 5));
+ assert.strictEqual(typeof half, 'bigint');
+ // A fractional component rounds up once (D1): 1 token at 1.5 nano per million is 2 nano, not 1.
+ assert.strictEqual(priceAfterCacheNano({ promptTokens: 1, outputTokens: 0, cacheHitRatio: 0, rates: RETAIL(1500000, 0, 0) }), 2n);
+ // The blended single rate used by the SDK planner prices the same prompt at the same total.
+ for (const ratio of [0, 0.1, 0.5, 0.9, 1]) {
+ const exact = BigInt(priceAfterCacheNano({ promptTokens: 12345, outputTokens: 0, cacheHitRatio: ratio, rates: RETAIL(3000000, 300000, 0) }));
+ const blended = blendedInputRateNano({ promptTokens: 12345, cacheHitRatio: ratio, rates: RETAIL(3000000, 300000, 0) });
+ assert.strictEqual(BigInt(priceAfterCacheNano({ promptTokens: 12345, outputTokens: 0, cacheHitRatio: 0, rates: RETAIL(blended, 0, 0) })), exact);
+ }
+});
+
+t.test('economy orders by expected cost per success', async () => {
+ const h = await harness({ prices: {
+ cheap: { retail: RETAIL(1000000, 100000, 1000000) },
+ pricey: { retail: RETAIL(9000000, 900000, 9000000) },
+ } });
+ await seed(h.metrics, 'cheap');
+ await seed(h.metrics, 'pricey');
+ const picked = await h.ev.choose({ routeKey: 'economy', policy: 'cheapest', list: [{ provider: 'pricey', model: 'm' }, { provider: 'cheap', model: 'm' }], req: { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 }, ctx: {} });
+ assert.strictEqual(picked.order[0].provider, 'cheap', picked.view.reason);
+ assert.strictEqual(picked.view.policy, 'cheapest');
+ assert.match(picked.view.reason, /lowest cost per success/);
+ const byProvider = Object.fromEntries(picked.view.candidates.map((c) => [c.provider, c]));
+ assert.ok(BigInt(byProvider.cheap.cost_per_success_nano_usd) < BigInt(byProvider.pricey.cost_per_success_nano_usd));
+ await h.stop();
+});
+
+t.test('fast orders by p50 TTFT, then decode tokens/s', async () => {
+ const h = await harness({ prices: {
+ quick: { retail: RETAIL(1000000, 100000, 1000000) },
+ slow: { retail: RETAIL(1000000, 100000, 1000000) },
+ equal: { retail: RETAIL(1000000, 100000, 1000000) },
+ } });
+ await seed(h.metrics, 'quick', { ttft: 100, latency: 600, output: 1000 });
+ await seed(h.metrics, 'slow', { ttft: 300, latency: 800, output: 1000 });
+ await seed(h.metrics, 'equal', { ttft: 100, latency: 500, output: 2000 });
+ const list = [{ provider: 'slow', model: 'm' }, { provider: 'quick', model: 'm' }, { provider: 'equal', model: 'm' }];
+ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };
+ const r = await h.ev.choose({ routeKey: 'fast', policy: 'fastest', list, req, ctx: {} });
+ assert.strictEqual(r.order[0].provider, 'equal', 'the equal-TTFT deployment with the higher decode rate wins the tie');
+ assert.strictEqual(r.view.candidates.find((c) => c.provider === 'equal').decode_tokens_per_s, 5000);
+ await h.stop();
+});
+
+t.test('balanced weighs the normalised cost per success and TTFT', async () => {
+ const h = await harness({ prices: {
+ cheapSlow: { retail: RETAIL(100000, 0, 100000) },
+ priceyFast: { retail: RETAIL(5000000, 0, 5000000) },
+ } });
+ await seed(h.metrics, 'cheapSlow', { ttft: 500, latency: 1000 });
+ await seed(h.metrics, 'priceyFast', { ttft: 100, latency: 300 });
+ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };
+ const r = await h.ev.choose({ routeKey: 'balanced', policy: 'balanced', list: [{ provider: 'cheapSlow', model: 'm' }, { provider: 'priceyFast', model: 'm' }], req, ctx: {} });
+ assert.ok(r.view.candidates.length === 2);
+ assert.ok(['cheapSlow', 'priceyFast'].includes(r.order[0].provider));
+ await h.stop();
+});
+
+t.test('a deployment with no measured successes is priced at the floor, not excluded', async () => {
+ const h = await harness({ prices: { bad: { retail: RETAIL(1000000, 0, 1000000) }, good: { retail: RETAIL(2000000, 0, 2000000) } } });
+ await seed(h.metrics, 'bad', { n: 3, ok: false });
+ await seed(h.metrics, 'good', { n: 3, ok: true });
+ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };
+ const r = await h.ev.choose({ routeKey: 'floor', policy: 'cheapest', list: [{ provider: 'bad', model: 'm' }, { provider: 'good', model: 'm' }], req, ctx: {} });
+ assert.strictEqual(r.order[0].provider, 'good');
+ const bad = r.view.candidates.find((c) => c.provider === 'bad');
+ assert.ok(bad, 'the failing deployment stays in the candidate list');
+ assert.ok(BigInt(bad.cost_per_success_nano_usd) > BigInt(r.view.candidates.find((c) => c.provider === 'good').cost_per_success_nano_usd));
+ await h.stop();
+});
+
+t.test('sparse Bench evidence is ignored and reported, never filled in', async () => {
+ const sparse = { get: async () => ({ status: 'sparse', samples: 5, success_rate: 0.1, ttft_p50_ms: 5, decode_tokens_per_s: 500, exact_id: 'org/m@r1' }) };
+ const h = await harness({ bench: sparse, prices: { a: { retail: RETAIL(1000000, 100000, 1000000), exactId: 'org/m@r1' } } });
+ await seed(h.metrics, 'a', { n: 1 });
+ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };
+ const r = await h.ev.choose({ routeKey: 'sparse', policy: 'cheapest', list: [{ provider: 'a', model: 'm' }], req, ctx: {} });
+ const c = r.view.candidates[0];
+ assert.strictEqual(r.view.bench.status, 'sparse');
+ assert.strictEqual(c.sparse_evidence, true);
+ assert.strictEqual(c.p_success, 0.9, 'sparse evidence keeps the neutral prior, never Bench’s 0.1');
+ assert.strictEqual(c.bench.status, 'sparse');
+ assert.strictEqual(c.samples, 1, 'the EWMA sample count is reported, not Bench’s 5');
+ const absent = { get: async () => ({ status: 'absent', samples: 0, exact_id: 'org/m@r1' }) };
+ assert.strictEqual(combineEvidence({ metric: null, bench: await absent.get('x'), minSamples: 2 }).sparse, true);
+ await h.stop();
+});
+
+t.test('non-sparse Bench evidence is an input', async () => {
+ const bench = { get: async () => ({ status: 'ok', samples: 30, success_rate: 0.5, ttft_p50_ms: 50, decode_tokens_per_s: 900, exact_id: 'org/m@r1' }) };
+ const h = await harness({ bench, prices: { a: { retail: RETAIL(1000000, 100000, 1000000), exactId: 'org/m@r1' } } });
+ await seed(h.metrics, 'a', { n: 30, ok: true });
+ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };
+ const r = await h.ev.choose({ routeKey: 'bench', policy: 'cheapest', list: [{ provider: 'a', model: 'm' }], req, ctx: {} });
+ const c = r.view.candidates[0];
+ assert.strictEqual(c.sparse_evidence, false);
+ assert.strictEqual(c.p_success, 0.75, 'the EWMA success and Bench’s success are sample-weighted');
+ assert.strictEqual(c.ttft_p50_ms, 50, 'Bench’s measured p50 is the latency when it is not sparse');
+ assert.strictEqual(c.ttft_source, 'bench_p50');
+ assert.strictEqual(r.view.bench.status, 'ok');
+ await h.stop();
+});
+
+t.test('hysteresis keeps the incumbent within the margin and switches after N', async () => {
+ let prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(1050000, 0, 1050000) } };
+ const db = await testDb();
+ const metrics = createProviderMetrics({ db: db.db, clock: CLOCK });
+ await seed(metrics, 'a');
+ await seed(metrics, 'b');
+ const ev = createRoutingEvidence({ db: db.db, clock: CLOCK, metrics, config: CONFIG({ minSamples: 2, hysteresisDecisions: 3 }), resolveOffer: async (c) => ({ id: `${c.provider}:m`, provider: c.provider, model: 'm', exactId: null, free: false, retail: prices[c.provider].retail }) });
+ const list = [{ provider: 'a', model: 'm' }, { provider: 'b', model: 'm' }];
+ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };
+ const first = await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} });
+ assert.strictEqual(first.order[0].provider, 'a');
+ // b is only 5% cheaper: planAi's minGain keeps the incumbent.
+ prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(950000, 0, 950000) } };
+ for (let i = 0; i < 3; i++) {
+ const r = await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} });
+ assert.strictEqual(r.order[0].provider, 'a', `within the margin, decision ${i + 1}`);
+ assert.ok(r.view.streak <= 0, 'a challenger within the margin never builds a streak');
+ }
+ // b is now 50% cheaper: the third consecutive decision switches, and the switch is persisted.
+ prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(500000, 0, 500000) } };
+ const calls = [];
+ for (let i = 0; i < 3; i++) calls.push(await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} }));
+ assert.deepStrictEqual(calls.map((c) => c.order[0].provider), ['a', 'a', 'b']);
+ assert.deepStrictEqual(calls.map((c) => c.view.streak), [1, 2, 0]);
+ assert.strictEqual(calls[2].view.switched, true);
+ const again = await ev.choose({ routeKey: 'h', policy: 'cheapest', list, req, ctx: {} });
+ assert.strictEqual(again.order[0].provider, 'b', 'the new incumbent is persisted');
+ assert.strictEqual(again.view.incumbent, 'b:m');
+ await db.close();
+});
+
+t.test('cache affinity prefers the warm deployment while it is within the margin', async () => {
+ const prices = { cold: { retail: RETAIL(1000000, 0, 1000000) }, warm: { retail: RETAIL(1000000, 0, 1000000) } };
+ const h = await harness({ prices });
+ await seed(h.metrics, 'cold', { ttft: 100, latency: 500, output: 1000 });
+ await seed(h.metrics, 'warm', { ttft: 103, latency: 503, output: 1000 });
+ const req = { messages: [{ role: 'user', content: 'the same prompt prefix' }], maxTokens: 100 };
+ const list = [{ provider: 'cold', model: 'm' }, { provider: 'warm', model: 'm' }];
+ const near = await h.ev.choose({ routeKey: 'affinity-near', policy: 'fastest', list, req, ctx: {}, warmDeployment: { provider: 'warm', model: 'm' } });
+ assert.strictEqual(near.order[0].provider, 'warm', '3% slower is within the 5% affinity margin');
+ assert.ok(near.view.affinity && near.view.affinity.within_margin);
+ await seed(h.metrics, 'warm', { ttft: 130, latency: 530, output: 1000 });
+ const far = await h.ev.choose({ routeKey: 'affinity-far', policy: 'fastest', list, req, ctx: {}, warmDeployment: { provider: 'warm', model: 'm' } });
+ assert.strictEqual(far.order[0].provider, 'cold', '30% slower is outside the margin');
+ assert.strictEqual(far.view.affinity, null);
+ await h.stop();
+});
+
+t.test('exploration is 0 by default and never on an exact id or an opted-out preference', async () => {
+ const prices = { a: { retail: RETAIL(1000000, 0, 1000000) }, b: { retail: RETAIL(900000, 0, 900000) } };
+ const req = { messages: [{ role: 'user', content: 'hello there' }], maxTokens: 100 };
+ const list = [{ provider: 'a', model: 'm' }, { provider: 'b', model: 'm' }];
+ const h = await harness({ prices });
+ await seed(h.metrics, 'a'); await seed(h.metrics, 'b');
+ const off = await h.ev.choose({ routeKey: 'explore-off', policy: 'cheapest', list, req, ctx: {} });
+ assert.strictEqual(off.view.exploration.used, false);
+ assert.strictEqual(off.order[0].provider, 'b', 'the policy order decides, with no exploration');
+ await h.stop();
+
+ const on = await harness({ prices, config: CONFIG({ explorationShare: 1 }) });
+ await seed(on.metrics, 'a'); await seed(on.metrics, 'b');
+ const explored = await on.ev.choose({ routeKey: 'explore-on', policy: 'cheapest', list, req, ctx: {} });
+ assert.strictEqual(explored.view.exploration.used, true);
+ assert.notStrictEqual(explored.order[0].provider, explored.view.incumbent.split(':')[0], 'the incumbent is not the exploration target');
+ for (const ctx of [{ exact: { id: 'org/m@r1' } }, { credential: { provider: 'openai' } }, { selfCredentials: [{ provider: 'openai' }] }, { exploration: false }]) {
+ const r = await on.ev.choose({ routeKey: 'explore-blocked-' + Object.keys(ctx)[0], policy: 'cheapest', list, req, ctx });
+ assert.strictEqual(r.view.exploration.used, false, `never explores with ${Object.keys(ctx)[0]}`);
+ }
+ for (const prefs of [{ only_my_keys: true }, { exploration: false }]) {
+ const r = await on.ev.choose({ routeKey: 'explore-prefs-' + Object.keys(prefs)[0], policy: 'cheapest', list, req, ctx: { selfPreferences: prefs } });
+ assert.strictEqual(r.view.exploration.used, false, `never explores when preferences opt out (${Object.keys(prefs)[0]})`);
+ }
+ await on.stop();
+});
+
+t.test('with the flag off nothing is written and the alias order is unchanged', async () => {
+ const one = await seamServer(() => ({ text: 'from one', model: 'm', usage: { input: 10, output: 5 } }));
+ const two = await seamServer(() => ({ text: 'from two', model: 'm', usage: { input: 10, output: 5 } }));
+ let h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ const mk = async (key, url, cost) => {
+ const p = await request(h.base, 'POST', '/api/v1/providers', { tok, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, key, base_url: url, capabilities: ['chat', 'generate'], default_model: 'm' } });
+ assert.strictEqual(p.status, 201, p.text);
+ const m = await request(h.base, 'POST', '/api/v1/models', { tok, body: { provider_key: key, model_key: 'm', type: 'chat', status: 'active', cost: { in_per_mtok: cost, out_per_mtok: cost }, supports: { json: true } } });
+ assert.strictEqual(m.status, 201, m.text);
+ };
+ await mk('one', one.url, 10);
+ await mk('two', two.url, 1);
+ const r = await request(h.base, 'POST', '/api/v1/routes/evidence.off/versions', { tok, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });
+ assert.strictEqual(r.status, 201, r.text);
+ const ctx = { routeKey: r.body.route.key, routeVersion: r.body.route.version, aliasPolicy: 'cheapest', policy: 'cheapest' };
+ const exec = await h.pool.execute(r.body.route, 'chat', { messages: [{ role: 'user', content: 'hello there' }], timeoutMs: 3000 }, ctx);
+ assert.strictEqual(exec.explain.evidence, undefined, 'no evidence decision');
+ assert.strictEqual(exec.explain.policy, 'cheapest', 'the alias policy still reaches the explain as before');
+ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM provider_metrics').get()).n, 0, 'no metrics are written with the flag off');
+ await h.stop();
+
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_ROUTING_EVIDENCE: '1', AI_ROUTING_EVIDENCE_MIN_SAMPLES: '1', AI_ROUTING_EVIDENCE_DECISIONS: '1' } });
+ await mk('one', one.url, 10);
+ await mk('two', two.url, 1);
+ const r2 = await request(h.base, 'POST', '/api/v1/routes/evidence.on/versions', { tok, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });
+ assert.strictEqual(r2.status, 201, r2.text);
+ const facts = [];
+ const exec2 = await h.pool.execute(r2.body.route, 'chat', { messages: [{ role: 'user', content: 'hello there' }], timeoutMs: 3000 }, { routeKey: r2.body.route.key, routeVersion: r2.body.route.version, aliasPolicy: 'cheapest', policy: 'cheapest', onRoute: async (f) => { facts.push(f); } });
+ assert.ok(exec2.explain.evidence, 'the alias request carries the evidence explanation');
+ assert.match(exec2.explain.evidence.reason, /lowest cost per success|no measured success/);
+ assert.ok(facts.some((f) => f.policy_reason && /lowest cost per success|no measured success/.test(f.policy_reason)), 'x_openvibe gains the short policy reason');
+ assert.ok((await h.db.prepare('SELECT COUNT(*) AS n FROM provider_metrics').get()).n >= 1, 'the attempt is measured');
+ await h.stop();
+ await one.close(); await two.close();
+});
+
+t.test('the Bench client marks a sparse or absent exact id and never throws', async () => {
+ const responses = [];
+ const fetchImpl = async (url) => {
+ responses.push(url);
+ if (String(url).includes('sparse%2Fm')) return { ok: true, status: 200, json: async () => ({ evidence: { exact_id: 'sparse/m@r1', sample_count: 5, success_rate: 1, metrics: { ttft_p50_ms: 100 }, sparse: true } }) };
+ if (String(url).includes('absent')) return { ok: false, status: 404, json: async () => ({}) };
+ return { ok: false, status: 503, json: async () => ({}) };
+ };
+ const client = createVariantEvidence({ config: { bench: { url: 'http://bench.invalid', timeoutMs: 1000 } }, fetchImpl, clock: CLOCK, log: { warn() {} } });
+ const sparse = await client.get('sparse/m@r1');
+ assert.strictEqual(sparse.status, 'sparse');
+ assert.strictEqual(sparse.samples, 5);
+ assert.strictEqual((await client.get('absent')).status, 'absent');
+ assert.strictEqual((await client.get('down')).status, 'unavailable');
+ const before = responses.length;
+ await client.get('sparse/m@r1');
+ assert.strictEqual(responses.length, before, 'a cached read does not call Bench again');
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+// GET /api/v1/usage/series: aggregate demand for Host's inference planner. Per UTC day and model only —
+// requests, successes and the four token totals — never a person, app, project, prompt or answer.
+// The table rows are inserted directly because the point is the query, not the run that wrote them.
+
+const assert = require('assert');
+const { boot, request, token, suite } = require('./helpers');
+
+const t = suite('usage-series');
+const clock = { now: () => Date.parse('2026-11-03T12:00:00Z') };
+const TODAY = '2026-11-03';
+const YESTERDAY = '2026-11-02';
+const OLD = '2026-10-20'; // inside 30 days
+const OUTSIDE = '2026-08-01'; // outside 30 days
+let h;
+
+const addStats = (day, provider, model, { requests, ok, errors = 0, tokensIn = 0, tokensOut = 0 }) =>
+ h.db.prepare(`INSERT INTO provider_stats_daily (day, provider, model, requests, ok, errors, tokens_in, tokens_out)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(day, provider, model, requests, ok, errors, tokensIn, tokensOut);
+
+const addUsage = (day, requester, attribution, workflow, provider, model, cached) =>
+ h.db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, 0)`).run(day, requester, attribution, workflow, provider, model, cached * 2, cached, cached);
+
+async function series(query = '') {
+ return await request(h.base, 'GET', `/api/v1/usage/series${query}`, { tok: token('planner', ['ai.usage.read']) });
+}
+
+t.test('boot with the aggregate demand feed on and two models, one with exact-id metadata', async () => {
+ h = await boot({ clock, env: { AI_USAGE_SERIES: 'on' } });
+ const now = new Date(clock.now()).toISOString();
+ await h.db.prepare(`INSERT INTO models (provider_key, model_key, type, status, metadata, created_at, updated_at)
+ VALUES (?, ?, 'chat', 'active', ?, ?, ?), (?, ?, 'chat', 'active', ?, ?, ?)`)
+ .run('openai', 'gpt-4o-mini', JSON.stringify({ revision: '3f2a1c9' }), now, now,
+ 'anthropic', 'claude-sonnet-4-5', '{}', now, now);
+ // Provider attempts once rolled up (a retry would be a second attempt): today and yesterday.
+ await addStats(TODAY, 'openai', 'gpt-4o-mini', { requests: 3, ok: 2, errors: 1, tokensIn: 1000, tokensOut: 200 });
+ await addStats(TODAY, 'anthropic', 'claude-sonnet-4-5', { requests: 1, ok: 1, tokensIn: 5000, tokensOut: 100 });
+ await addStats(YESTERDAY, 'openai', 'gpt-4o-mini', { requests: 4, ok: 4, tokensIn: 2000, tokensOut: 400 });
+ await addStats(OLD, 'openai', 'gpt-4o-mini', { requests: 1, ok: 1, tokensIn: 10, tokensOut: 1 });
+ await addStats(OUTSIDE, 'openai', 'gpt-4o-mini', { requests: 99, ok: 99, tokensIn: 1, tokensOut: 1 });
+ // Two callers, a service and an app, on the same model and day: their cache reads aggregate.
+ await addUsage(TODAY, 'service:live', 'live:user:42', 'live.translate', 'openai', 'gpt-4o-mini', 100);
+ await addUsage(TODAY, 'app:app_01J8', '', 'app.summarise', 'openai', 'gpt-4o-mini', 50);
+ await addUsage(TODAY, 'service:live', '', 'live.translate', 'anthropic', 'claude-sonnet-4-5', 500);
+ await addUsage(OUTSIDE, 'service:live', '', 'live.translate', 'openai', 'gpt-4o-mini', 777);
+});
+
+t.test('the shape: per day and model, four token totals, success count, and no caller or text', async () => {
+ const r = await series('?days=30');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.days, 30);
+ assert.strictEqual(r.body.to, TODAY);
+ assert.strictEqual(r.body.from, '2026-10-05');
+ assert.match(r.body.note, /cache_write/);
+ assert.ok(Array.isArray(r.body.series));
+
+ const today = r.body.series.filter((x) => x.day === TODAY);
+ const openai = today.find((x) => x.provider === 'openai');
+ assert.deepStrictEqual(openai, {
+ day: TODAY,
+ model: 'openai/gpt-4o-mini@3f2a1c9',
+ provider: 'openai',
+ model_key: 'gpt-4o-mini',
+ requests: 3,
+ successes: 2,
+ tokens: { input: 850, cached_read: 150, cache_write: 0, output: 200 },
+ });
+ const anthropic = today.find((x) => x.provider === 'anthropic');
+ assert.strictEqual(anthropic.model, 'anthropic/claude-sonnet-4-5', 'no artifact revision: provider/model');
+ assert.strictEqual(anthropic.requests, 1);
+ assert.strictEqual(anthropic.successes, 1);
+ assert.deepStrictEqual(anthropic.tokens, { input: 4500, cached_read: 500, cache_write: 0, output: 100 });
+ assert.strictEqual(r.body.series.find((x) => x.day === YESTERDAY).requests, 4);
+
+ // The caller rows (a service and an app) are one number here: no person, app, project or cost, and
+ // no prompt or answer text.
+ const text = JSON.stringify(r.body.series);
+ for (const forbidden of ['requester', 'attribution', 'project', 'prompt', 'answer', 'cost', 'live:user:42', 'app_01J8']) {
+ assert.ok(!text.includes(forbidden), `the aggregate carries no ${forbidden}`);
+ }
+ assert.ok(!r.body.series.some((x) => x.day === OUTSIDE), 'outside the requested window');
+});
+
+t.test('days is an integer from 1 to 90', async () => {
+ assert.strictEqual((await series()).body.days, 30, 'default 30');
+ assert.strictEqual((await series('?days=1')).body.days, 1);
+ assert.strictEqual((await series('?days=90')).body.days, 90);
+ for (const q of ['?days=0', '?days=91', '?days=abc', '?days=1.5', '?days=-1']) {
+ const r = await series(q);
+ assert.strictEqual(r.status, 422, q);
+ assert.strictEqual(r.body.code, 'ai.invalid', q);
+ }
+});
+
+t.test('the capability gate: ai.usage.read, or 401 without a token', async () => {
+ const denied = await request(h.base, 'GET', '/api/v1/usage/series?days=30', { tok: token('planner', ['ai.run.read']) });
+ assert.strictEqual(denied.status, 403);
+ assert.strictEqual(denied.body.code, 'capability.denied');
+ const missing = await request(h.base, 'GET', '/api/v1/usage/series?days=30');
+ assert.strictEqual(missing.status, 401);
+ assert.strictEqual(missing.body.code, 'token.missing');
+});
+
+t.test('AI_USAGE_SERIES off: the route is absent', async () => {
+ const off = await boot({ clock, env: { AI_USAGE_SERIES: 'off' } });
+ try {
+ const r = await request(off.base, 'GET', '/api/v1/usage/series?days=30', { tok: token('planner', ['ai.usage.read']) });
+ assert.strictEqual(r.status, 404);
+ assert.strictEqual(r.body.code, 'ai.not_found');
+ } finally { await off.close(); }
+});
+
+(async () => { await t.run(); })();
new file mode 100644
new file mode 100644
@@ -0,0 +1,295 @@
@@ -0,0 +1,295 @@
+'use strict';
+// AI Exchange P4 (CHECKPOINT D3/D4/D8), behind AI_MODEL_CATALOG:
+// - GET /v1/models and /v1/models/{id} in both wire protocols: the registry's real models as ai.model@1 entries
+// plus the dynamic policy aliases, marked dynamic;
+// - an exact id runs only its artifact (never a substitute); an alias resolves through the same placement;
+// - the route disclosure inside x_openvibe (D3') on JSON responses, as the first SSE comment and on the final chunk;
+// - paid managed routing refuses UNVERIFIED/RESTRICTED offers, while an own deployment and CUSTOMER_BYOK work;
+// - with the flag off, the legacy single `openvibe` model and no disclosure, exactly as before.
+const assert = require('assert');
+const http = require('http');
+const { boot, request, token, suite, ALL } = require('./helpers');
+
+const t = suite('v1-models');
+const service = token('live', ALL);
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP7';
+const CREDENTIAL_KEY = 'cd'.repeat(32);
+const ACTION = token('codes', ALL, { extra: { on_behalf_of: PERSON } });
+const MANAGER = token('codes', [...ALL, 'ai.credential.manage'], { extra: { on_behalf_of: PERSON } });
+const APP_D8 = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });
+const APP_LEGACY = token('app', ['ai.app.run'], { sub: `app:app_${PRJ.slice(4)}`, actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });
+
+const ALPHA_ID = 'acme/m1:q4_k_m@rev1';
+const BETA_ID = 'acme/m2@rev2';
+const GAMMA_ID = 'acme/m3@rev3';
+const DELTA_ID = 'anthropic/claude-test@revA';
+const WEATHER = { type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } };
+
+/* ── a fake OpenAI-compatible upstream (JSON and SSE) ─────────────────────── */
+function openAiUpstream() {
+ const state = { mode: 'ok', calls: [], url: null, close: null };
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ let body = {};
+ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
+ state.calls.push({ url: req.url, body });
+ if (state.mode === 'fail') {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: { message: 'the artifact is down' } }));
+ }
+ const chunk = (choices, extra = {}) => JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion.chunk', created: 1, model: body.model, choices, ...extra });
+ if (body.stream) {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ res.write(`data: ${chunk([{ index: 0, delta: { role: 'assistant', content: 'Hello' }, finish_reason: null }])}\n\n`);
+ res.write(`data: ${chunk([{ index: 0, delta: {}, finish_reason: 'stop' }])}\n\n`);
+ res.write(`data: ${chunk([], { usage: { prompt_tokens: 5, completion_tokens: 2 } })}\n\n`);
+ res.write('data: [DONE]\n\n');
+ return res.end();
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ id: 'chatcmpl-up', object: 'chat.completion', model: body.model,
+ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello there' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 5, completion_tokens: 2 } }));
+ });
+ });
+ server.listen(0, '127.0.0.1', () => { state.url = `http://127.0.0.1:${server.address().port}/v1`; });
+ state.ready = new Promise((resolve) => server.on('listening', resolve));
+ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });
+ return state;
+}
+
+/* ── a fake Anthropic Messages upstream (JSON and Anthropic's SSE frames) ──── */
+function anthropicUpstream() {
+ const state = { calls: [], url: null, close: null };
+ const FRAMES = [
+ ['message_start', { type: 'message_start', message: { id: 'msg_up', type: 'message', role: 'assistant', model: 'delta-1', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 5, output_tokens: 0 } } }],
+ ['content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }],
+ ['content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }],
+ ['content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' there' } }],
+ ['content_block_stop', { type: 'content_block_stop', index: 0 }],
+ ['message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 2 } }],
+ ['message_stop', { type: 'message_stop' }],
+ ];
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', async () => {
+ let body = {};
+ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
+ state.calls.push({ url: req.url, body });
+ if (body.stream) {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ for (const [event, data] of FRAMES) {
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
+ await new Promise((r) => setTimeout(r, 2));
+ }
+ return res.end();
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ id: 'msg_up', type: 'message', role: 'assistant', model: body.model,
+ content: [{ type: 'text', text: 'Hello there' }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 5, output_tokens: 2 } }));
+ });
+ });
+ server.listen(0, '127.0.0.1', () => { state.url = `http://127.0.0.1:${server.address().port}`; });
+ state.ready = new Promise((resolve) => server.on('listening', resolve));
+ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });
+ return state;
+}
+
+const alpha = openAiUpstream();
+const beta = openAiUpstream();
+const gamma = openAiUpstream();
+const delta = anthropicUpstream();
+const freecap = openAiUpstream();
+let h;
+let ownCalls = 0;
+const credentialFetch = async (url, init) => {
+ ownCalls++;
+ const body = JSON.parse(init.body);
+ return new Response(JSON.stringify({ id: 'msg_own', type: 'message', role: 'assistant', model: body.model,
+ content: [{ type: 'text', text: 'from your own key' }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 3, output_tokens: 2 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
+};
+const post = (path, body, tok = service, headers = {}) => fetch(`${h.base}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
+const chat = (model, extra = {}) => request(h.base, 'POST', '/v1/chat/completions', { tok: service, body: { model, messages: [{ role: 'user', content: 'Say hello' }], ...extra } });
+const mkProvider = (body) => request(h.base, 'POST', '/api/v1/providers', { tok: service, body });
+const mkModel = (body) => request(h.base, 'POST', '/api/v1/models', { tok: service, body });
+
+t.test('boot: the catalog is on, with own, wholesale and unverified offers', async () => {
+ await Promise.all([alpha.ready, beta.ready, gamma.ready, delta.ready, freecap.ready]);
+ h = await boot({ env: { AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_CREDENTIALS_KEY: CREDENTIAL_KEY }, credentialFetch });
+ const providers = [
+ { key: 'alpha', kind: 'openai', base_url: alpha.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'alpha-1', provider_access: 'OWN_DEPLOYMENT' },
+ { key: 'beta', kind: 'openai', base_url: beta.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'beta-1', provider_access: 'AUTHORIZED_WHOLESALE' },
+ { key: 'gamma', kind: 'openai', base_url: gamma.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'gamma-1' },
+ { key: 'delta', kind: 'anthropic', base_url: delta.url, auth_mode: 'none', capabilities: ['chat', 'json'], default_model: 'delta-1', provider_access: 'OWN_DEPLOYMENT' },
+ ];
+ for (const p of providers) assert.strictEqual((await mkProvider(p)).status, 201);
+ const cost = (input, output) => ({ in_per_mtok: input, out_per_mtok: output, cached_per_mtok: input / 10, source: 'https://example.com/pricing', verified_at: '2026-11-01' });
+ const model = (provider_key, model_key, metadata, inUsd, outUsd, supports = { json: true, tools: true, streaming: true, vision: false }) =>
+ mkModel({ provider_key, model_key, type: 'chat', status: 'active', context_window: 32768, max_output: 4096, supports, cost: cost(inUsd, outUsd), metadata });
+ assert.strictEqual((await model('alpha', 'alpha-1', { org: 'acme', model: 'm1', quant: 'q4_k_m', revision: 'rev1', family: 'm', variant: 'original', license: 'mit', sha256: 'a'.repeat(64), rate_version: 'rr-1' }, 1, 2)).status, 201);
+ assert.strictEqual((await model('beta', 'beta-1', { org: 'acme', model: 'm2', revision: 'rev2', rate_version: 'rr-2' }, 5, 10)).status, 201);
+ assert.strictEqual((await model('gamma', 'gamma-1', { org: 'acme', model: 'm3', revision: 'rev3' }, 0.1, 0.2)).status, 201);
+ assert.strictEqual((await model('delta', 'delta-1', { org: 'anthropic', model: 'claude-test', revision: 'revA' }, 3, 6)).status, 201);
+});
+
+t.test('both protocols list the real models and the dynamic aliases', async () => {
+ const openai = await request(h.base, 'GET', '/v1/models', { tok: service });
+ assert.strictEqual(openai.status, 200, openai.text);
+ assert.strictEqual(openai.body.object, 'list');
+ const byId = new Map(openai.body.data.map((m) => [m.id, m]));
+ for (const id of ['openvibe', 'openvibe/auto', 'openvibe/economy', 'openvibe/balanced', 'openvibe/fast', 'openvibe/private', ALPHA_ID, BETA_ID, GAMMA_ID, DELTA_ID]) assert.ok(byId.has(id), `lists ${id}`);
+ assert.deepStrictEqual([byId.get('openvibe/auto').dynamic, byId.get('openvibe/auto').policy], [true, 'auto']);
+ assert.deepStrictEqual([byId.get('openvibe/economy').dynamic, byId.get('openvibe/economy').policy], [true, 'cheapest']);
+ assert.strictEqual(byId.get(ALPHA_ID).dynamic, false);
+ assert.deepStrictEqual([byId.get(ALPHA_ID).family, byId.get(ALPHA_ID).revision, byId.get(ALPHA_ID).retail_rate.input_nano_usd_per_m],
+ ['m', 'rev1', '1000000000'], 'the ai.model@1 catalog facts, in integer nano-USD');
+
+ const detail = await request(h.base, 'GET', `/v1/models/${encodeURIComponent(ALPHA_ID)}`, { tok: service });
+ assert.strictEqual(detail.status, 200, detail.text);
+ assert.deepStrictEqual([detail.body.id, detail.body.variant, detail.body.quant], [ALPHA_ID, 'original', 'q4_k_m']);
+ const alias = await request(h.base, 'GET', '/v1/models/openvibe/private', { tok: service });
+ assert.deepStrictEqual([alias.status, alias.body.dynamic, alias.body.policy], [200, true, 'private']);
+ const missing = await request(h.base, 'GET', '/v1/models/acme/nope@rev0', { tok: service });
+ assert.deepStrictEqual([missing.status, missing.body.error.code], [404, 'ai.model_not_found']);
+
+ const anthropic = await request(h.base, 'GET', '/v1/models', { tok: service, headers: { 'anthropic-version': '2023-06-01' } });
+ assert.strictEqual(anthropic.status, 200, anthropic.text);
+ assert.strictEqual(anthropic.body.object, undefined);
+ assert.strictEqual(anthropic.body.has_more, false);
+ assert.strictEqual(anthropic.body.data.find((m) => m.id === 'openvibe/economy').dynamic, true);
+ assert.strictEqual(anthropic.body.data.find((m) => m.id === ALPHA_ID).type, 'model');
+ const one = await request(h.base, 'GET', `/v1/models/${encodeURIComponent(ALPHA_ID)}`, { tok: service, headers: { 'anthropic-version': '2023-06-01' } });
+ assert.deepStrictEqual([one.status, one.body.id, one.body.display_name], [200, ALPHA_ID, 'alpha-1']);
+});
+
+t.test('an exact id runs only that artifact; an alias is dynamic and says which model answered', async () => {
+ alpha.calls.length = 0; beta.calls.length = 0; gamma.calls.length = 0;
+ const exact = await chat(ALPHA_ID);
+ assert.strictEqual(exact.status, 200, exact.text);
+ const exactBody = exact.body;
+ assert.strictEqual(alpha.calls.length, 1, 'alpha ran');
+ assert.deepStrictEqual([beta.calls.length, gamma.calls.length], [0, 0], 'no other artifact was called');
+ assert.deepStrictEqual([exactBody.x_openvibe.resolved_model, exactBody.x_openvibe.deployment, exactBody.x_openvibe.provider_access, exactBody.x_openvibe.policy],
+ [ALPHA_ID, 'alpha', 'OWN_DEPLOYMENT', 'exact'], JSON.stringify(exactBody.x_openvibe));
+ assert.deepStrictEqual([exactBody.model, exactBody.x_openvibe.rate_version], ['alpha-1', 'rr-1']);
+
+ alpha.calls.length = 0; beta.calls.length = 0; gamma.calls.length = 0;
+ const alias = await chat('openvibe/economy');
+ assert.strictEqual(alias.status, 200, alias.text);
+ const aliasBody = alias.body;
+ assert.strictEqual(alpha.calls.length, 1, 'economy picked the cheapest eligible offer, alpha');
+ assert.deepStrictEqual([beta.calls.length, gamma.calls.length], [0, 0], 'beta costs more; gamma is UNVERIFIED');
+ assert.deepStrictEqual([aliasBody.x_openvibe.resolved_model, aliasBody.x_openvibe.deployment, aliasBody.x_openvibe.policy],
+ [ALPHA_ID, 'alpha', 'cheapest']);
+});
+
+t.test('an exact id whose artifact is down fails, and never substitutes another model', async () => {
+ await request(h.base, 'POST', '/api/v1/providers/alpha/disable', { tok: service, body: {} });
+ beta.calls.length = 0;
+ const down = await chat(ALPHA_ID);
+ assert.deepStrictEqual([down.status, down.body.error.code], [400, 'ai.model_unavailable'], down.text);
+ assert.strictEqual(beta.calls.length, 0, 'the other artifact was never called');
+
+ // The deployment is active again but its upstream fails: still an error, still no substitute.
+ await request(h.base, 'POST', '/api/v1/providers/alpha/enable', { tok: service, body: {} });
+ alpha.mode = 'fail';
+ beta.calls.length = 0;
+ const failed = await chat(ALPHA_ID);
+ assert.strictEqual(failed.status, 503, failed.text);
+ assert.strictEqual(beta.calls.length, 0, 'not substituted after the artifact failed');
+ alpha.mode = 'ok';
+});
+
+t.test('the UNVERIFIED gate: gamma is never resold, while own and wholesale offers work', async () => {
+ gamma.calls.length = 0;
+ const blocked = await chat(GAMMA_ID);
+ assert.deepStrictEqual([blocked.status, blocked.body.error.code], [400, 'ai.model_unavailable']);
+ assert.strictEqual(gamma.calls.length, 0, 'the UNVERIFIED offer was never called');
+ // Both allowed accesses still serve a paid managed request.
+ assert.strictEqual((await chat(BETA_ID)).status, 200);
+ assert.strictEqual((await chat(ALPHA_ID)).status, 200);
+});
+
+t.test('BYOK still works with the catalog on, disclosed as CUSTOMER_BYOK', async () => {
+ const put = await request(h.base, 'PUT', `/api/v1/credentials/${PERSON}/anthropic`, { tok: MANAGER, body: { key: 'sk-ant-test-person-0123456789', models: { chat: 'claude-test-1' } } });
+ assert.strictEqual(put.status, 200, put.text);
+ ownCalls = 0;
+ const before = delta.calls.length;
+ const res = await request(h.base, 'POST', '/v1/messages', { tok: ACTION, headers: { 'x-openvibe-credential': 'anthropic' }, body: { model: 'openvibe/auto', max_tokens: 64, messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(res.status, 200, res.text);
+ const body = res.body;
+ assert.strictEqual(body.x_openvibe.provider_access, 'CUSTOMER_BYOK');
+ assert.strictEqual(body.x_openvibe.policy, 'byok');
+ assert.strictEqual(ownCalls, 1, 'the person\'s own key answered');
+ assert.strictEqual(delta.calls.length, before, 'the managed Anthropic offer was not called');
+ await request(h.base, 'DELETE', `/api/v1/credentials/${PERSON}/anthropic`, { tok: MANAGER });
+});
+
+t.test('the SSE disclosure: a leading comment and a field on the final chunk, both protocols', async () => {
+ const openai = await post('/v1/chat/completions', { model: ALPHA_ID, stream: true, messages: [{ role: 'user', content: 'stream' }] });
+ assert.strictEqual(openai.status, 200);
+ const blocks = (await openai.text()).split('\n\n').filter(Boolean);
+ assert.match(blocks[0], /^: x_openvibe /, 'the first SSE block is the disclosure comment');
+ const head = JSON.parse(blocks[0].slice(': x_openvibe '.length));
+ assert.deepStrictEqual([head.resolved_model, head.deployment, head.provider_access], [ALPHA_ID, 'alpha', 'OWN_DEPLOYMENT']);
+ const chunks = blocks.filter((b) => b.startsWith('data: ') && !b.includes('[DONE]')).map((b) => JSON.parse(b.slice(6)));
+ assert.deepStrictEqual([chunks[chunks.length - 1].x_openvibe.resolved_model, chunks[chunks.length - 1].x_openvibe.policy], [ALPHA_ID, 'exact']);
+
+ delta.calls.length = 0;
+ const anthropic = await post('/v1/messages', { model: DELTA_ID, max_tokens: 32, stream: true, messages: [{ role: 'user', content: 'stream' }] }, service, { 'anthropic-version': '2023-06-01' });
+ assert.strictEqual(anthropic.status, 200);
+ const frames = (await anthropic.text()).split('\n\n').filter(Boolean);
+ assert.match(frames[0], /^: x_openvibe /, 'the first Anthropic SSE block is the disclosure comment');
+ const anthropicHead = JSON.parse(frames[0].slice(': x_openvibe '.length));
+ assert.deepStrictEqual([anthropicHead.resolved_model, anthropicHead.deployment, anthropicHead.policy], [DELTA_ID, 'delta', 'exact']);
+ const stop = frames.map((b) => { const d = /^data: (.*)$/m.exec(b); return d ? JSON.parse(d[1]) : null; }).filter((d) => d && d.type === 'message_stop');
+ assert.strictEqual(stop.length, 1);
+ assert.strictEqual(stop[0].x_openvibe.resolved_model, DELTA_ID, 'the final Anthropic chunk carries the disclosure');
+});
+
+t.test("D8': developer apps use ai.app.run on /v1/models and /v1/chat/completions", async () => {
+ const list = await request(h.base, 'GET', '/v1/models', { tok: APP_D8 });
+ assert.strictEqual(list.status, 200, list.text);
+ assert.ok(list.body.data.some((m) => m.id === ALPHA_ID));
+ assert.strictEqual((await request(h.base, 'GET', '/v1/models', { tok: APP_LEGACY })).status, 200, 'ai.app.run still works');
+ // A free own deployment lets an app's free-only run answer through the same guard.
+ assert.strictEqual((await mkProvider({ key: 'freecap', kind: 'openai', base_url: freecap.url, auth_mode: 'none', capabilities: ['chat', 'complete'], default_model: 'free-1', billing_profile: 'free', provider_access: 'OWN_DEPLOYMENT' })).status, 201);
+ assert.strictEqual((await mkModel({ provider_key: 'freecap', model_key: 'free-1', type: 'chat', status: 'active', supports: { json: true, streaming: true } })).status, 201);
+ const res = await chat('openvibe/auto', {});
+ assert.strictEqual(res.status, 200);
+ const appRes = await request(h.base, 'POST', '/v1/chat/completions', { tok: APP_D8, body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'app' }] } });
+ assert.strictEqual(appRes.status, 200, appRes.text);
+});
+
+t.test('flag off: the legacy single model and no disclosure, exactly as before', async () => {
+ const off = await boot();
+ try {
+ const list = await request(off.base, 'GET', '/v1/models', { tok: service });
+ assert.deepStrictEqual(list.body.data.map((m) => m.id), ['openvibe']);
+ const anthropic = await request(off.base, 'GET', '/v1/models', { tok: service, headers: { 'anthropic-version': '2023-06-01' } });
+ assert.deepStrictEqual(anthropic.body.data.map((m) => m.id), ['openvibe']);
+ const res = await request(off.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: 'not-a-catalog-id', messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual((res.body.x_openvibe || {}).resolved_model, undefined, 'no route disclosure with the flag off');
+ assert.strictEqual((await request(off.base, 'GET', '/v1/models', { tok: APP_D8 })).status, 200, 'an ai.app.run app lists the legacy model as before');
+ // An exact id is a pin: with the catalog off (a rollback of the flag) it is refused, never run on another model.
+ const pin = 'qwen/qwen3-8b:q4_k_m@3f2a1c9';
+ const chat = await request(off.base, 'POST', '/v1/chat/completions', { tok: service, body: { model: pin, messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(chat.status, 400, 'an exact id with the catalog off is refused');
+ assert.strictEqual(chat.body.error.code, 'ai.model_unavailable');
+ const msg = await request(off.base, 'POST', '/v1/messages', { tok: service, body: { model: pin, max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] } });
+ assert.strictEqual(msg.status, 400, 'and on Messages');
+ assert.match(msg.body.error.message, /ai\.model_unavailable/);
+ } finally { await off.stop(); }
+});
+
+t.test('stop', async () => {
+ await h.stop();
+ await Promise.all([alpha.close(), beta.close(), gamma.close(), delta.close(), freecap.close()]);
+});
+
+t.run();