openvibers/openvibe.ai README.md
README.md
| 1 | # OpenVibe.AI |
| 2 | |
| 3 | > Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product. |
| 4 | |
| 5 | **Live site:** https://ai.openvibe.services · **API docs:** https://openvibe.services · **For AI:** https://ai.openvibe.services/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 20 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700` and in |
| 9 | production use: OpenVibe.Live runs with `AI_SERVICE=remote` and Network's footer copy runs |
| 10 | `network.site_copy` here. Public since 2026-10-07 at [ai.openvibe.services](https://ai.openvibe.services): |
| 11 | the API (`ai.app.run`, the OpenAI-compatible `/v1`) and a run's receipt (`/runs/:id`). The operator console and |
| 12 | personal credential settings live on OpenVibe.Exchange. |
| 13 | **The public site moved to [openvibe.exchange](https://openvibe.exchange) on 2026-11-07:** the home, prices |
| 14 | and latency, the changelog, the legal pages and the discovery files answer 301 there on an exact page map |
| 15 | (`server/app.js`), while every API route on this origin is unchanged. The content and the map are in |
| 16 | `/home/workstation/openvibe/plan-state-2026-10-22/ai-site-inventory-2026-11-07.md`. |
| 17 | **Domain:** `ai.openvibe.services` · **Port:** 4700 · **Unit:** `openvibe-ai.service` |
| 18 | **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §15.14, §33, §34. |
| 19 | **License:** AGPL-3.0 (same as every OpenVibe service). |
| 20 | |
| 21 | ## Purpose |
| 22 | |
| 23 | The reusable AI authority extracted from OpenVibe.Live. Products run registered, versioned |
| 24 | workflows; every run returns a **draft/evidence package** (structured output + citations + |
| 25 | provenance) attributed to a workflow, its version, a model and a run id — never to a person. AI never |
| 26 | owns publication truth: Wiki, Blog, News, Live, … decide what to publish. |
| 27 | |
| 28 | ## Owns |
| 29 | |
| 30 | - the eleven record groups ([below](#the-eleven-record-groups)): providers, models, routing profiles, |
| 31 | prompt templates, workflow definitions, runs, the request log (hashes, not prompts), citations, the |
| 32 | cache, quotas and usage, and the audit log, in AI's own PostgreSQL database (`ov_ai`, ADR-035; schema in |
| 33 | [migrations/](migrations/)) |
| 34 | - people's own provider keys (`ai.credential.manage`), encrypted at rest, one per provider (Codes C3: `anthropic`, |
| 35 | `openai`, `deepseek`, `minimax`, `openrouter` or a `custom` endpoint), and per-streamer |
| 36 | attribution quotas (`ai.quota.attribution.manage`) |
| 37 | - **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every supported provider |
| 38 | (Anthropic, OpenAI, Gemini, DeepSeek, Mistral, xAI, Groq, Cerebras, Together, Fireworks, OpenRouter, MiniMax and a |
| 39 | custom endpoint), tested on save (its models, or one token) with status, `models_count` and a redacted `last_error`; |
| 40 | their `only_my_keys`, `free_first`, price ceiling and default model per kind of work; a run `on_behalf_of` a person |
| 41 | tries their keys first, `explain.paid_by` and the attempts name who paid and why each failed, and a 401/403 marks the |
| 42 | key failing and publishes `ai.credential.failed` (`/api/v1/me/*`, `server/me.js`; only the person's own token or |
| 43 | AI-site session, never a service token or `on_behalf_of`). The person's settings UI is on |
| 44 | [OpenVibe.Exchange](https://openvibe.exchange/privacy/keys). |
| 45 | - a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their own |
| 46 | machine (Ollama, LM Studio, llama.cpp through the OpenVibe Link), reached through OpenVibe.Bot with `bot.machine.ai` |
| 47 | for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`); |
| 48 | machine `any` (contracts 0.221.0) uses the fastest of their machines that is on with the model, then the next |
| 49 | - the `ai.run.queued|succeeded|failed|cached` events and the `ai.preferences` / `ai.usage_summary` |
| 50 | user modules' AI side |
| 51 | |
| 52 | ## Does not own |
| 53 | |
| 54 | - publication: every run is a draft/evidence package; Wiki, Blog, News, Live and the rest decide what is |
| 55 | published |
| 56 | - the recordings it analyses (OpenVibe.Media), identity (OpenVibe.Network), the products' prompts' |
| 57 | meaning (each workflow's owner) |
| 58 | |
| 59 | ## Depends on |
| 60 | |
| 61 | - PostgreSQL 18 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db` |
| 62 | - OpenVibe.Network (JWKS, service tokens, SSO for the operator console, user modules) |
| 63 | - OpenVibe.Events (the `ai.run.*` outbox relay, and the signed `bench.rating.changed` subscription; off without `EVENTS_URL`) |
| 64 | - OpenVibe.Bench (`bench.rating.changed` and `GET /api/v1/boards/:kind?type=elo|cost`, for opted-in routes) |
| 65 | - OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs) |
| 66 | - provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp |
| 67 | and the local model server `openvibe-llm.service` on this host |
| 68 | - `openvibe-contracts` v0.421.0, `openvibe-sdk` v0.82.0 (service tokens, events outbox, redact), |
| 69 | `openvibe-shared` v4.109.2, pinned by release tarball |
| 70 | |
| 71 | ## Capabilities |
| 72 | |
| 73 | Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under |
| 74 | [API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`, |
| 75 | `ai.usage.read`, `ai.usage.report` (`POST /api/v1/usage/calls`: another product reports the AI calls it made |
| 76 | without AI, `ai.call-report@1`, and AI prices, dedupes and publishes each as `ai.call.recorded@1`), |
| 77 | `ai.credential.manage` (`/api/v1/credentials/:subject[/:provider]`), |
| 78 | `ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a, |
| 79 | `/api/v1/memory/*`), `ai.me.read` and `ai.me.manage` ("Your AI", `/api/v1/me/*`: a person's own keys and |
| 80 | routing preferences; only the person's own token or AI-site session) and `ai.app.run` (developer apps, ADR-014; public, |
| 81 | registered since openvibe-contracts 0.109.0), and `ai.layout.suggest` (`POST /api/v1/ui/layout/suggest`: a surface catalog, |
| 82 | the layout shown now and the person's words in, a validated `ui.layout@1` with a one-sentence summary or an honest |
| 83 | refusal out; one retry with the validator's reasons, on the cheap structured-output route, as the person named in |
| 84 | `X-OV-Subject`). A service token's `ns` |
| 85 | claim limits which workflow namespaces it may run; an app token is fixed to its project's. |
| 86 | |
| 87 | A first-party service may also open a batch of the network's own with the `ai.batches.create` grant |
| 88 | (see [Batches](#batches)): the batch is billed to the network's account, there is no Balance |
| 89 | session for it, and the run's attribution names the service. The grant is checked locally by the |
| 90 | batch service; the Contracts capability entry comes later (BA3). |
| 91 | |
| 92 | Called elsewhere, as the service principal `ai`: `events.event.publish` (Events, the outbox relay) and |
| 93 | `events.subscription.manage` (Events, creating AI's own `bench.rating.changed` subscription), and |
| 94 | `network.modules.read` / `network.modules.write` for `ai.preferences` and `ai.usage_summary` |
| 95 | (Network). Other services grant `ai` their own draft capabilities (for example |
| 96 | `blog.post.create`, `news.story.revise`, `reviews.summary.propose`) to receive drafts. |
| 97 | |
| 98 | ## Run it |
| 99 | |
| 100 | ```bash |
| 101 | npm install |
| 102 | cp .env.example .env # no key needed: without one every route falls back to the stub provider |
| 103 | npm run dev # http://localhost:4700 |
| 104 | npm test # stub provider, temp databases, local servers only — no network |
| 105 | ``` |
| 106 | |
| 107 | Link 22 (CI pins 22.22.1). Callers need an OpenVibe.Network service token with audience |
| 108 | `openvibe.ai`; the Network public key is fetched from `/api/.well-known/jwks` (or pinned with |
| 109 | `OV_NETWORK_PUBLIC_KEY`). |
| 110 | |
| 111 | ## API |
| 112 | |
| 113 | ```http |
| 114 | POST /api/v1/runs?wait=10000 |
| 115 | Authorization: Bearer <service token, aud openvibe.ai, cap ai.run.create> |
| 116 | Idempotency-Key: live-translate-8812 |
| 117 | |
| 118 | { "workflow": "live.translate", |
| 119 | "input": { "text": "みなさん、こんにちは", "from": "ja", "to": "en", "context": "chat" }, |
| 120 | "target": { "service": "live", "type": "chat_message", "id": "8812" }, |
| 121 | "attribution": { "service": "live", "type": "user", "id": "42" } } |
| 122 | ``` |
| 123 | |
| 124 | answers `201` with the finished run (or `202` + `Location` while it is still running; `200` for an |
| 125 | idempotent replay): |
| 126 | |
| 127 | ```json |
| 128 | { "run": { "id": "run_01…", "status": "succeeded", "output": { "text": "Hello everyone", "unchanged": false }, |
| 129 | "synthetic": false, "provenance": { "origin": "ai", "workflow": "live.translate", "workflow_version": 1, |
| 130 | "template_version": 1, "route": "live.chat", "route_version": 1, "provider": "shared", "model": "gpt-5-nano", |
| 131 | "fallback_used": false, "run_id": "run_01…" }, "citations_count": 0, "…": "…" } } |
| 132 | ``` |
| 133 | |
| 134 | | Route | Capability | |
| 135 | |---|---| |
| 136 | | `POST /api/v1/runs` (`?wait=ms`, `Idempotency-Key`), `POST /runs/:id/cancel`, `POST /runs/:id/retry`, `POST /runs/:id/advice` (an advisor call on a run that asked for one), `POST /runs/:id/citations` | `ai.run.create` | |
| 137 | | `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed,image}` — each a run of workflow `ai.<op>` | `ai.run.create` | |
| 138 | | `POST /api/v1/research` — a run of `research.gather` ([MegaSource](#megasource-research)) | `ai.run.create` (namespace `research.*`) or `ai.app.run` (free sources only) | |
| 139 | | `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) | |
| 140 | | `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) | |
| 141 | | `GET /api/v1/actions?subject=usr_…` — one person's Action Center items (`common.action-list-result@1`, runs whose `on_behalf_of` is that person) | `ai.actions.read` (first-party service) | |
| 142 | | `POST /api/v1/memory/items:batch`, `GET/PATCH/DELETE /api/v1/memory/items[/:id]`, `DELETE /api/v1/memory/items?source=`, `GET /api/v1/memory/profile` | `ai.memory.read` / `ai.memory.write` (the person's own token, or a service acting `on_behalf_of` them) | |
| 143 | | `GET /api/v1/memory/search?q=&scopes=` — active items only, at most 8, in the grant's scopes, audited | `ai.memory.read` plus `on_behalf_of` | |
| 144 | | `GET/PUT/DELETE /api/v1/me/credentials[/:provider]`, `POST /api/v1/me/credentials/:provider/test`, `GET/PUT /api/v1/me/ai/preferences` | `ai.me.read` / `ai.me.manage` — the person's own token or AI-site session only; a service token or `on_behalf_of` is refused. A site session token (audience `openvibe.network`) may read these hints and settings but never write them: a write needs a token whose `aud` includes `openvibe.ai`, or AI's own same-origin session | |
| 145 | | `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` | |
| 146 | | `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` | |
| 147 | | `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` | |
| 148 | | `POST/PATCH /api/v1/providers…`, `…/disable\|enable\|reset`, `POST/PATCH /api/v1/models…`, `POST /api/v1/quotas`, `DELETE /api/v1/cache` | `ai.provider.manage` | |
| 149 | | `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` | |
| 150 | | `POST /api/v1/usage/calls` — up to 100 calls a product made outside AI (`ai.call-report@1`, 202 `{ accepted, duplicates, rejected }`) | `ai.usage.report` | |
| 151 | | `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` | |
| 152 | | `GET /api/health`, `GET /api/ready`, `GET /release.json` | public | |
| 153 | | `GET /metrics` | direct loopback callers only (a request carrying X-Forwarded-For gets 404) | |
| 154 | | `GET /console…` | 301 to the same path on [OpenVibe.Exchange](https://openvibe.exchange/console) | |
| 155 | | `GET /runs/:id` — one run's receipt (status, workflow, model, cost, times, output or error; never the input) | the run's person, signed in (see [Run receipts](#run-receipts)) | |
| 156 | | `GET /auth/person/login\|callback` | any Network person, for a run receipt | |
| 157 | | `GET /robots.txt` | public: disallows `/console`, `/runs`, `/auth/`, `/api/` | |
| 158 | | `GET /stats` | public: p50/p95 latency, success rate and rate-card price per registered provider and model over the last 7 days (server-rendered, `openvibe-shared/cache-policy` HTML headers, nothing per caller) | |
| 159 | |
| 160 | Browser reads of `GET /stats` and `GET /release.json` allow exact origins from `AI_CORS_ORIGINS` |
| 161 | (comma-separated; default `https://ai.openvibe.services,https://openvibe.services`). Their `OPTIONS` |
| 162 | preflight allows GET. Responses never allow credentials; `/api/v1/*`, `/console` and `/auth/*` |
| 163 | have no CORS access. Service callers do not need CORS. |
| 164 | |
| 165 | `/api/ready` (openvibe-shared/ready) is 503 when the database, the active workflows or the Network |
| 166 | key fail; provider configuration (an active provider without its credentials, or with an open |
| 167 | circuit) degrades it. `/metrics` (openvibe-shared/metrics) carries golden signals by route template, |
| 168 | `ai_runs{state="queued"|"running"}`, `ai_provider_circuit{provider,state}` and the measured |
| 169 | `ai_provider_circuit_open{provider,model,class}` and `ai_provider_failures_total{provider,class}`. |
| 170 | |
| 171 | Each HTTP request is aggregated by route template, method and status class, never by raw URL; `/shared` |
| 172 | files, static assets, health, readiness and `/metrics` are not counted. Once a minute one `http.request` |
| 173 | sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this |
| 174 | service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` |
| 175 | shape); rows older than 14 days are deleted daily. `server/app.js` mounts openvibe-sdk's |
| 176 | `telemetryMiddleware` (a no-op until init) and `server/index.js` calls `telemetry.init()` at production |
| 177 | start, runs the 14-day retention and flushes on stop. The table is created by migration |
| 178 | `migrations/0009_telemetry.sql`. |
| 179 | |
| 180 | Errors are `application/problem+json` (openvibe-contracts `http.problem`): `input.invalid` (with the |
| 181 | schema errors), `workflow.not_found`, `idempotency.conflict`, `quota.exceeded` (429 + |
| 182 | `Retry-After`), `govern.exceeded` (429 + `Retry-After`: a tier budget) and `govern.unavailable` (503: Valkey |
| 183 | unreachable for a paid or staff run), `queue.full` (429 + `Retry-After`: the run would wait behind `AI_MAX_QUEUED_RUNS` |
| 184 | runs, or `AI_MAX_QUEUED_RUNS_PER_CALLER` of this caller's), `capability.denied`, `capability.namespace_denied`, `token.*`. A run that cannot |
| 185 | produce a real answer ends `failed` with an explicit code — `ai.resting` (503: every offer is out of balance, |
| 186 | paused or busy; the detail is the sentence products show, "AI is resting for a moment", with `Retry-After`), |
| 187 | `provider.unavailable`, `route.unavailable`, `fetch.refused`, `source.unavailable`, `input.insufficient`, |
| 188 | `output.empty`, `output.invalid`, `run.interrupted` — and never with filler content. |
| 189 | |
| 190 | Each active provider with a balance API (DeepSeek's `GET /user/balance`) is asked hourly; the last reading is |
| 191 | `ai_provider_balance_usd{provider}` on `/metrics` and OpenVibe.Host's `OpenVibeAiProviderBalanceLow` alert turns a |
| 192 | reading below $2 into an operator ticket. The key is only sent in the request's `Authorization` header: it is never |
| 193 | logged, returned or stored by the probe. `AI_BALANCE_PROBE=0` (or a restore drill, `AI_DRILL=1`) turns it off. |
| 194 | |
| 195 | The capability ids and the `ai` service manifest are released in openvibe-contracts v0.76.0 (the |
| 196 | drafts stay in `docs/capabilities-proposal/`); `server/auth.js` checks them with the contracts grant |
| 197 | rule (exact id or `family.*`). A token's `ns` claim limits which |
| 198 | workflow namespaces it may run (`live.*`, `wiki.*`, …), and namespaces fail closed: a token with |
| 199 | no `ns` runs nothing. Every caller's Network grant names its namespaces (Live: `live.*`, |
| 200 | `network.site_copy`, `media.analyze`). The fallback for service tokens without `ns` (`AI_NS_FALLBACK`) |
| 201 | and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22 and C-23). |
| 202 | |
| 203 | ## Batches |
| 204 | |
| 205 | Many prompts in, the answers later. A batch is half the retail price for the customer, and it runs |
| 206 | where the money works out: on OpenVibe's own capacity and free providers, on a community offer that |
| 207 | asks at most half, on a paid provider inside a price-book off-peak window, or on **a provider's own |
| 208 | batch API** when that provider's real batch price is at most half the retail card. The customer |
| 209 | batch therefore runs on a paid model at any hour, not only in an off-peak window. |
| 210 | |
| 211 | The providers whose own batch API this service speaks, with the discount, the window and the limits |
| 212 | read on each provider's own documentation page (a provider whose batch API could not be read is not |
| 213 | listed and is never called): |
| 214 | |
| 215 | | provider | adapter | batch price | window | limits | |
| 216 | |---|---|---|---|---| |
| 217 | | `shared` (OpenAI) | OpenAI-compatible | half the list price | 24 h | 50 000 requests, 200 MB, one model per file | |
| 218 | | `qwen` (DashScope) | OpenAI-compatible | half the real-time price | 24 h | 50 000 requests, 500 MB, one model per file | |
| 219 | | `kimi` (Moonshot) | OpenAI-compatible | 60% of the list price | 24 h | 10 000 requests, 100 MB, one model per file | |
| 220 | | `deepinfra` | OpenAI-compatible | 80% of the list price | 24 h | 10 000 requests, 100 MB, one model per file | |
| 221 | | `anthropic` | Message Batches | half the list price | 24 h | 100 000 requests, 256 MB (adapter written, not enabled) | |
| 222 | | `gemini` | Gemini Batch API | half the list price | 24 h | 10 000 requests (adapter written, **not enabled**: the endpoint could not be read on the provider's own page) | |
| 223 | |
| 224 | Only the providers whose batch price is at most half the retail card (`shared`, `qwen`) may carry a |
| 225 | **customer** batch: the customer is charged half, so the network's real cost has to fit inside that |
| 226 | half. Kimi and DeepInfra are below half and are therefore not eligible for a customer batch, though |
| 227 | the network's own batches may still use them. |
| 228 | |
| 229 | How a native batch runs: an item whose run has `latency_class = 'batch'` waits in the queue |
| 230 | (`batch_queue`), grouped by provider and model. A group goes to the provider when it is full |
| 231 | (`AI_BATCH_NATIVE_MAX_ITEMS`, default 1000) or old (`AI_BATCH_NATIVE_MAX_AGE_MS`, default 5 |
| 232 | minutes). One worker polls the batches the provider is working on (the claim is the database's and |
| 233 | expires on its own, so several workers are safe), writes every answer onto its own run (the status, |
| 234 | the output, the tokens and the cost **at the provider's batch price**) and finishes the run on the |
| 235 | ordinary path: the same settle, the same receipt, the same events. A deadline the provider cannot |
| 236 | meet re-routes the item to the ordinary (background or standard) path, and the item says so. |
| 237 | |
| 238 | The money: the cost written on a run is what the provider really charged for the tier it really ran |
| 239 | on (its own list price times its own batch discount), computed in nano-USD. The customer is charged |
| 240 | half the retail card through Billing like every other request; this queue is a queue and a status, |
| 241 | never a second ledger. |
| 242 | |
| 243 | **The balance is checked when the batch is submitted, and the money is taken when the answer |
| 244 | arrives.** A run whose estimate does not fit the customer's Balance (or the project's budget) is |
| 245 | refused before the provider is given the work, so the network never owes a provider for a run nobody |
| 246 | can pay for; the item carries the reason. The hold itself is taken and settled when the answer comes |
| 247 | back, so a batch that waits hours does not hold the customer's Balance in the meantime. |
| 248 | |
| 249 | **A waiting batch run is durable.** It waits in the database, not in the service's memory: the |
| 250 | service is deployed several times a day, and a restart leaves the waiting runs queued (the provider |
| 251 | is still working on them) instead of failing them. The batcher finishes them when the answer is |
| 252 | there, with the ordinary settle, the ordinary receipt and the ordinary events. |
| 253 | |
| 254 | **Service batches.** A first-party service principal whose token carries the `ai.batches.create` |
| 255 | grant may open a batch of the network's own: it is billed to the network's account (there is no |
| 256 | Balance session for it) and the run's attribution names the service. The grant is checked locally by |
| 257 | the batch service (`server/batch/service.js`), because the pinned `openvibe-contracts` build does |
| 258 | not know that capability id yet: the Contracts capability entry comes later (BA3). Until then the |
| 259 | route's own guard still asks for `ai.run.create` (or an app's `ai.app.run`) as well. |
| 260 | |
| 261 | |
| 262 | ## Model catalog, exact ids and the disclosure |
| 263 | |
| 264 | Behind `AI_MODEL_CATALOG` (off by default; with it off both `GET /v1/models` endpoints answer the legacy virtual |
| 265 | `openvibe` model and no disclosure is added; an exact id is still a pin, so it is refused with 400 |
| 266 | `ai.model_unavailable` rather than run on another model). It applies to both `POST /v1/chat/completions` and |
| 267 | `POST /v1/messages`. |
| 268 | |
| 269 | - **`GET /v1/models` / `GET /v1/models/{id}`:** the registry's real models as `ai.model@1` catalog entries |
| 270 | (`provider_key`, `model_key`, `id`, family, variant, quant, revision, sha256, license, context, capabilities, |
| 271 | modalities, retail rate in integer nano-USD, availability) plus the dynamic policy aliases `openvibe/auto`, |
| 272 | `openvibe/economy`, `openvibe/balanced`, `openvibe/fast`, `openvibe/private` and legacy `openvibe` (= auto). An |
| 273 | alias carries `dynamic: true` and its `policy`. Every entry also carries the response shape of the protocol it was |
| 274 | asked in: OpenAI's `object`/`created`/`owned_by`, or Anthropic's `type`/`display_name`/`created_at`. An Anthropic |
| 275 | client identifies itself with its `anthropic-version` header (or `?format=anthropic`). |
| 276 | - **Exact ids:** `model` may be `<org>/<model>[:<quant>]@<revision>` (for example |
| 277 | `qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9`). It runs only that artifact. An artifact that does not exist is 404 |
| 278 | `ai.model_not_found`; one that exists but cannot run the request (no active deployment, unmet capability, down |
| 279 | provider, or an offer that may not be resold) is 400 `ai.model_unavailable`. It is never substituted with another |
| 280 | model, in fallback or otherwise. A row without artifact metadata has no exact id; it is still listed and reachable |
| 281 | by its legacy `provider/model` id. |
| 282 | - **Aliases:** the dynamic names resolve through the same provider placement. `economy` ranks price first, `fast` |
| 283 | measured latency first, `balanced` weighs both, `private` never leaves local/own capacity, and `auto` keeps the |
| 284 | existing task-aware router. User preferences (`only_my_keys`, `free_first`, own keys and machines) still apply. |
| 285 | - **The disclosure, inside `x_openvibe`:** `resolved_model`, `revision`, `deployment`, `provider_access`, |
| 286 | `rate_version`, `policy` and `fallback_reason` join the `x_openvibe` object responses already carry (run, provider, |
| 287 | cost). There is no second object. |
| 288 | - On a JSON response it is in the top-level `x_openvibe`. |
| 289 | - On SSE it is the first line, as an SSE comment (`: x_openvibe {...}`), and it is on the final chunk |
| 290 | (`message_stop` for Messages). |
| 291 | - **Commercial access:** `provider_access` is a provider field (`POST/PATCH /api/v1/providers`, default `UNVERIFIED`). |
| 292 | Paid managed routing uses only `OWN_DEPLOYMENT` and `AUTHORIZED_WHOLESALE`; `RESTRICTED`, `UNVERIFIED` and |
| 293 | `CUSTOMER_BYOK` offers stay out of the shared pool. A person's own key or project provider is disclosed as |
| 294 | `CUSTOMER_BYOK` and is unaffected. |
| 295 | - **Tokens:** developer apps call with the public `ai.app.run` they already hold (ADR-014). The separate |
| 296 | `ai.inference.create` once planned for this was retired (Contracts 0.285.0). `ai.run.create` remains the |
| 297 | first-party service capability. |
| 298 | |
| 299 | ## OpenAI-compatible endpoint |
| 300 | |
| 301 | `POST /v1/chat/completions` and `GET /v1/models` (`server/api/openai.js`), so a client built for OpenAI's API (the SDKs, |
| 302 | coding agents, Codes' harnesses) points its base URL at `https://ai.openvibe.services/v1`. |
| 303 | |
| 304 | - **Tokens:** the same as the direct operations: an app's Network token (`ai.app.run`; its project pays and owns the |
| 305 | run) or a first-party service's (`ai.run.create`). Network tokens last five minutes: a client refreshes it with the |
| 306 | client-credentials grant (the SDKs take a token function). |
| 307 | - **Request:** Chat Completions, as written: `messages` (system, developer, user, assistant with `tool_calls`, and `tool` |
| 308 | results), `tools`, `tool_choice`, `parallel_tool_calls`, `response_format`, `stop`, `seed`, `temperature`, `top_p`, |
| 309 | `max_tokens` or `max_completion_tokens`, `stream`. `n` must be 1. `model` may be anything: AI's router picks the |
| 310 | upstream for `default.chat` by price and health, and a request with `tools` goes only to a provider that declares |
| 311 | `tools`. The answer's `model` names the model that ran. |
| 312 | - **Answer:** `chat.completion` with the upstream's `choices` and `usage`, plus `x_openvibe` (`run_id`, `provider`, |
| 313 | `fallback_used`, `cost_usd`, the privacy label and the receipt id) and the `X-OpenVibe-Run-Id` header. The |
| 314 | answer's receipt is in `x-openvibe-receipt` (compact JSON) and `x-openvibe-receipt-id`. |
| 315 | - **Streaming:** `stream: true` answers server-sent events in the chunk format **as the upstream streams them**. |
| 316 | - An OpenAI-format upstream (the router's, or the project's own) is asked with `stream: true` and |
| 317 | `stream_options.include_usage`, and each delta goes out when it arrives: text, and a tool call's id, name and |
| 318 | argument pieces. |
| 319 | - Then come the finish reason, `usage` (with `stream_options.include_usage`), `x_openvibe`, and `[DONE]`. |
| 320 | - The run itself ends with the whole answer put back together, the upstream's usage (estimated when it sends none) |
| 321 | and its cost, as without streaming. |
| 322 | - Once a delta has gone out, a failure is final: an `error` event, then `[DONE]`. It is never retried or handed to a |
| 323 | fallback whose words would follow the first one's. |
| 324 | - An answer that came back in one piece (a replayed idempotent run) is sent as one delta. |
| 325 | - **Each request is a run** of workflow `ai.complete` (step kind `complete`): the run's quota, free allowance, cost, |
| 326 | audit and explain apply, and the prompt is not kept. Errors are OpenAI's shape (`{ error: { message, type, code } }`). |
| 327 | - **Upstreams:** OpenAI-format providers (`kind: openai`) pass the request through (`complete()`; a reasoning model |
| 328 | takes `max_completion_tokens` and its default sampling). Anthropic is not used for it yet. |
| 329 | - **An app's own provider** (`x_openvibe.provider`, ADR-056). An app may name its project's own OpenAI-format API |
| 330 | instead of the router: `{ "base_url": "https://api.openai.com/v1", "model": "gpt-5-mini", "secret": "openai-key" }`. |
| 331 | - `secret` is the name of a project secret (OpenVibe.Services → project → Secrets) that the project granted to `ai` |
| 332 | for reveal to that base URL's origin. |
| 333 | - OpenVibe.Network reveals the key for that one run, from the app's environment (sandbox or production). The key is |
| 334 | sent to that origin only, through the public-address guard, and is never stored, cached or logged. The run keeps |
| 335 | the secret's name. |
| 336 | - The project pays its provider directly, so no tier budget holds the run (the app's quotas still count it). Tools, |
| 337 | tool results and every other field pass through. |
| 338 | - Refusals: a secret that does not exist or is not granted answers 403 `project_secret.refused`, a base URL that is |
| 339 | not https answers 400, and a first-party service token naming a provider answers 403. |
| 340 | - Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that is |
| 341 | a small local model without tools, so a coding agent's loop needs its project's own provider. |
| 342 | |
| 343 | ## Presets, receipts, BYOK and the privacy label |
| 344 | |
| 345 | The Exchange's routing profiles and the receipt of every answer (the plan of 2026-11-06, "Product lines |
| 346 | and features", item 1). `server/exchange/` holds the four parts: `presets.js`, `privacy.js`, |
| 347 | `constraints.js` and `receipts.js`; `server/api/exchange.js` serves them and `migrations/0031_exchange_presets_receipts.sql` |
| 348 | holds them. |
| 349 | |
| 350 | ### Presets |
| 351 | |
| 352 | A preset is a named set of routing constraints. Five are built in — `cheapest`, `fastest`, `private`, |
| 353 | `uncensored`, `eu-only` — and they are code, not rows: nobody can overwrite or delete one (409 |
| 354 | `ai.preset_builtin`). The others belong to an account (the person a token acts for) or to a Network |
| 355 | project (an app's project). |
| 356 | |
| 357 | - `GET /api/v1/presets` lists the presets the caller may name: the built-in ones and their own. |
| 358 | - `POST /api/v1/presets` stores (or replaces) one of their own: `{ "name": "…", "policy": { … } }`. |
| 359 | - `DELETE /api/v1/presets/{name}` removes one of their own. |
| 360 | |
| 361 | The policy's knobs: `objective` (`cheapest`, `fastest`, `balanced`, `private`), `floor` (`fast`, |
| 362 | `balanced`, `strong`, `frontier`), `ceiling` and `max_price` (US dollars per million tokens), |
| 363 | `quantization`, `region`, `data_policy` (`private`, `anonymized`, `community`), `community` (may a |
| 364 | community machine answer) and `adult` (the uncensored tier: the 18+ gate is the pool's own, never the |
| 365 | preset's — the preset only names the tier the request asks for). A knob nobody knows is dropped, never |
| 366 | stored, so a typo cannot come back as a rule nobody reads. |
| 367 | |
| 368 | A request names a preset as the model `"@preset/cheapest"` or beside it as `"preset": "cheapest"`; it |
| 369 | may also write its profile inline (`"preset": { "policy": { … } }`) and cap the price on its own |
| 370 | (`"max_price": 0.42`). A name nobody has is a 404 (`ai.preset_not_found`), never a silent default. |
| 371 | |
| 372 | The rules filter the pool **before** the call: an offer that breaks one is out, and the receipt says |
| 373 | which rule removed it. A hard `max_price` that no offer fits is refused before anything is called, with |
| 374 | a problem document (`ai.max_price_exceeded`) that carries the cap, the lowest price on offer and the |
| 375 | offers that were excluded. |
| 376 | |
| 377 | ### Receipts |
| 378 | |
| 379 | Every answered request carries a receipt: the offer that answered, the price it charged, the tokens in |
| 380 | and out, the cache reads and writes, the latency, why the router chose it, the fallbacks it would have |
| 381 | used, who paid and the privacy label. It never carries the prompt or the answer. |
| 382 | |
| 383 | - **In the answer:** the header `x-openvibe-receipt-id` and `x-openvibe-receipt` (the same facts, compact |
| 384 | JSON). A streamed answer names its receipt in the headers and carries the whole one in the last chunk. |
| 385 | - **On its own:** `GET /v1/receipts/{id}` (and the documented `GET /api/v1/receipts/{id}`), readable by the |
| 386 | caller that made the run, by the person it was for, and by whoever may read the usage |
| 387 | (`ai.usage.read`). Anyone else gets a 404, not a refusal that says there is something there. |
| 388 | - **Kept with the usage:** the receipt goes with its run (`runs.receipt_id` points at it) and is pruned |
| 389 | with the runs' retention window. |
| 390 | |
| 391 | ### BYOK and the privacy label |
| 392 | |
| 393 | A run on the person's own key (BYOK) is metered at 0 %: the receipt says `paid_by: own_key` (or |
| 394 | `own_machine`), `byok: true` and `fee_usd: 0`. The OpenVibe key pays the list price plus the fee. |
| 395 | |
| 396 | Every answer carries a privacy label — `private`, `anonymized` or `community` — read from the offer's |
| 397 | declared data policy: the person's own machine is private, an offer that keeps nothing and trains on |
| 398 | nothing is private, a community offer is community, and anything else is anonymized. The label is on the |
| 399 | receipt and in the answer's `x_openvibe`. |
| 400 | |
| 401 | ## Memory |
| 402 | |
| 403 | Memory M2a (spec `research-2026-10-30/memory-m2-spec.md`): a person's own notes, sealed with `AI_MEMORY_KEY` |
| 404 | (AES-256-GCM, AAD `memory|<owner>|<id>|<scope>`, `m1:`), deduped by an HMAC of the normalized text (HKDF-SHA256, info |
| 405 | `memory-mac`), and scoped by `user:<usr_…>` on every query. The server redacts every item again with |
| 406 | `openvibe-sdk/redact` (the Link's shapes and corpus) and refuses an item that is only a secret; what an agent writes is |
| 407 | a suggestion, and every item a service writes passes `noLooks`. An agent's search is active-only, at most 8 items, in |
| 408 | the scopes its token's grant names (`user` always; `project:*` when the grant lists it), and leaves a `memory_reads` |
| 409 | row with ids, never text. The profile cache rebuilds only when its `items_hash` moves, at most once an hour, in-process |
| 410 | on the service's own chat route (no run row, no person allowance). The account export decrypts the person's items for |
| 411 | their own export; a deletion removes the rows. Unset `AI_MEMORY_KEY` turns every memory route into 503 |
| 412 | `memory.unavailable`; no default key exists. |
| 413 | |
| 414 | ## Anthropic-compatible endpoint |
| 415 | |
| 416 | `POST /v1/messages` and `GET /v1/models` (`server/api/messages.js`), so a client built for Anthropic's Messages API |
| 417 | (Claude Code, an SDK) points `ANTHROPIC_BASE_URL` at `https://ai.openvibe.services` and works (Codes C3). |
| 418 | |
| 419 | - **Tokens:** the same Network tokens as `POST /v1/chat/completions`: an app's (`ai.app.run`; its project pays and owns the |
| 420 | run) or a first-party service's (`ai.run.create`). |
| 421 | - **Request:** Anthropic's Messages API as written: `messages`, `system`, `tools`, `tool_choice`, `stop_sequences`, |
| 422 | `temperature`, `top_p`, `top_k`, `max_tokens`, `metadata`, `stream`. |
| 423 | - **The person's own key:** `x-openvibe-credential: <provider>` uses that person's credential for that provider |
| 424 | (`anthropic`, `deepseek`, `minimax`, `openrouter` or a `custom` endpoint) instead of the router. The person is |
| 425 | `<provider>:<subject>`, or the token's `on_behalf_of` (an agent acting for them). Without the header the router picks an |
| 426 | upstream that speaks Anthropic's format (Anthropic, DeepSeek's and MiniMax's Anthropic-compatible endpoints); an |
| 427 | OpenAI-format provider is never converted. |
| 428 | - **Answer:** a `message` (`id`, `role`, `model`, `content`, `stop_reason`, `usage`), and the run id in |
| 429 | `X-OpenVibe-Run-Id`. The answer's receipt is in `x-openvibe-receipt` (compact JSON) and |
| 430 | `x-openvibe-receipt-id`. |
| 431 | - **Streaming:** `stream: true` answers Anthropic's own server-sent events as they arrive (`message_start`, |
| 432 | `content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta`, `message_stop`, `ping`); the run |
| 433 | still ends with the whole message. |
| 434 | - **Each request is a run** of workflow `ai.messages` (step kind `messages`): the run's quota, free allowance, cost, |
| 435 | audit and explain apply, and the prompt is not kept. Errors are Anthropic's shape |
| 436 | (`{ "type": "error", "error": { "type", "message" } }`). |
| 437 | |
| 438 | ## Developer apps |
| 439 | |
| 440 | Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`, |
| 441 | `grant_type=client_credentials` with its `app_<ULID>` client, `audience=openvibe.ai`): `sub |
| 442 | app:app_<ULID>`, `project_id prj_<ULID>`, `env sandbox|production`, ns `[project_id, |
| 443 | app.<project_id>.*]`, five minutes. AI accepts one capability for it, `ai.app.run` (public). A |
| 444 | sandbox app holds it without asking (Network's default sandbox allowance); a production app holds it |
| 445 | once OpenVibe staff add it to the project's allowance. Create the project and its app at |
| 446 | [openvibe.services/projects](https://openvibe.services/projects): |
| 447 | |
| 448 | | Route | What | |
| 449 | |---|---| |
| 450 | | `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` | the direct operations; `enrich` is first-party only | |
| 451 | | `GET /api/v1/runs/:id` | the app's own runs (404 for another project's) | |
| 452 | |
| 453 | Every other route refuses an app token (403 `capability.denied`), whatever its grant carries. An |
| 454 | app's runs are its **project's**: the run's requester is `project:prj_<ULID>` and its attribution is |
| 455 | the project EntityRef `{service: 'network', type: 'project', id: 'prj_<ULID>'}`, so two apps of one |
| 456 | project share their runs, and the project is the budget unit — the free allowance's subject is the |
| 457 | attribution (`network:project:prj_<ULID>`), the tier budgets ([Providers](#providers)) name the |
| 458 | project (`project:prj_<ULID>`), and a quota can cap it on the service scope id `project:prj_<ULID>`. |
| 459 | |
| 460 | **Billing.** Every run's `platform.usage-sample@1` readings carry the app's `project` |
| 461 | (`prj_<ULID>`) with subject `project:prj_<ULID>`, so Billing bills the project's use: the project's |
| 462 | bounded free allowance is claimed first (`free_allowance_used` on the reading), the rest is priced |
| 463 | under the project's tier budget (`AI_GOVERN_TIERS`, keyed `project:prj_<ULID>`). **Free and local |
| 464 | capacity only** (the stub, the local model, a free server or a pooled subscription — never a metered |
| 465 | provider) for every sandbox run, and for every app run while no tier budget governs its project |
| 466 | (`AI_GOVERN_TIERS` off): metered capacity is only ever spent under a project budget. |
| 467 | |
| 468 | **Workflows.** An app runs the six direct operations (the fixed `ai.*` workflows) and nothing else: |
| 469 | `POST /api/v1/runs`, which takes a workflow by key, stays first-party, and the workflow check refuses |
| 470 | anything outside `ai.*` and the app's own `app.<project_key>.*` (`project_key` = `p` + the project's |
| 471 | ULID in lowercase, Events' rule for `app.<project_key>.<name>` event types). An app never reads another |
| 472 | project's runs and never uses a person's BYO provider key: the `byok` provider profile is never |
| 473 | offered to an app run, and `credential` on its request is refused. |
| 474 | |
| 475 | ```bash |
| 476 | AI=https://ai.openvibe.services |
| 477 | APP=app_01JAB… # the app's client id; SECRET is its client secret |
| 478 | TOKEN=$(curl -s -X POST https://openvibe.network/oauth/token \ |
| 479 | -d grant_type=client_credentials -d client_id=$APP -d client_secret=$SECRET \ |
| 480 | -d audience=openvibe.ai | jq -r .access_token) |
| 481 | curl -s -X POST "$AI/api/v1/chat" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ |
| 482 | -d '{ "messages": [ { "role": "user", "content": "hello" } ] }' | jq .run.provenance |
| 483 | curl -s "$AI/api/v1/runs/$RUN" -H "Authorization: Bearer $TOKEN" | jq .run.status |
| 484 | ``` |
| 485 | |
| 486 | A sandbox token is issued only for an audience that opted in (Network's `DEV_SANDBOX_AUDIENCES`); |
| 487 | AI takes one on these routes alone, and every other route refuses `env: sandbox` with 401 |
| 488 | `token.sandbox_refused`, as before. |
| 489 | |
| 490 | ## Operator console |
| 491 | |
| 492 | The owner-only AI operator console is on [OpenVibe.Exchange](https://openvibe.exchange/console). It uses Exchange sign-in and Exchange's page shell; Exchange reads and writes AI's JSON admin API with the `exchange` service grant. The old `ai.openvibe.services/console` paths return 301 to the matching Exchange path. AI keeps its JSON admin APIs and run receipts, not operator-console HTML. |
| 493 | |
| 494 | A person's provider keys and supported browser session cookies are managed at [Your keys and accounts](https://openvibe.exchange/privacy/keys). AI's `/api/v1/me/credentials` endpoints remain person-authenticated JSON APIs; list and balance responses contain metadata only, never secret values. |
| 495 | |
| 496 | ## Run receipts |
| 497 | |
| 498 | `GET /runs/:id` is the person-facing page the Action Center's AI item links to (plan 2026-10-28 |
| 499 | §3.10). It shows one run's status, workflow, model, cost, created/started/finished times and the |
| 500 | output or the error — never the stored input, the request log, the trace or another person's run. |
| 501 | It is server-rendered with the console's classes and CSP (no script, no form, no external resource), |
| 502 | `noindex`, `no-store`, `Referrer-Policy: no-referrer`. |
| 503 | |
| 504 | A person signs in through the same Network OAuth client and callback as the console, but |
| 505 | `GET /auth/person/login` marks the flow as a person flow: the callback stores a read-only session |
| 506 | (`__Host-ovai_person`, host-only, HttpOnly, Secure, SameSite=Lax, `AI_PERSON_SESSION_TTL_MIN` = 24 h; |
| 507 | only its hash in `person_sessions`) and redirects only to the exact `/runs/run_<ULID>` page asked |
| 508 | for. It grants no capability and never opens `/console`. The page shows a run only when the run's |
| 509 | `on_behalf_of` is that session's `usr_…` subject; any other run is a 404. `GET /api/v1/actions` for |
| 510 | the Action Center is separate: a first-party service token with `ai.actions.read` names the person. |
| 511 | |
| 512 | ### Provider keys added from the console (`vault:`) |
| 513 | |
| 514 | A provider the owner adds from the console keeps its key in the `ai.provider_keys` namespace of the configuration |
| 515 | store (D11, `server/settings.js`, `migrations/0028_config.sql`). For each provider `<p>.origin` is the origin of its |
| 516 | base URL and `<p>.key` is a write-only secret sealed under `OV_CONFIG_KEKS` and x-bind'ed to `<p>.origin`; changing the |
| 517 | base URL locks the key until it is entered again. The provider's `secret_ref` is `vault:<provider key>`, which |
| 518 | `util.resolveSecret` reads from this process's cache (refreshed by the store's `onActivate`; providers resolve secrets |
| 519 | synchronously). Only a four-character hint is ever shown, and audit rows say a key was replaced, never what it is. |
| 520 | Without a KEK the store still works and reports `"sealed": false`. At boot, when `AI_CREDENTIALS_KEY` and |
| 521 | `OV_CONFIG_KEKS` are both set, the old `provider_secrets` rows (migration `0010_provider_secrets.sql`) are opened, |
| 522 | written as sealed `<p>.key` values bound to their current origin, read back and only then deleted; counts are logged, |
| 523 | never a value. |
| 524 | |
| 525 | ## The eleven record groups |
| 526 | |
| 527 | | Group | Table(s) | |
| 528 | |---|---| |
| 529 | | Providers | `providers`, `provider_circuits` (per provider+model breaker), `provider_failure_counts`, `provider_health` (roll-up) — secrets only as `env:NAME` references | |
| 530 | | Models | `models` (type, limits, cost per Mtok, JSON/tools/streaming/vision support) | |
| 531 | | Routing profiles | `routes` — versioned; primary + fallbacks, options, output limit, format, timeout, explicit aliases | |
| 532 | | Prompt templates | `templates` — versioned; input/output schema, system/user prompt, default route, owner, visibility, `draft\|active\|deprecated\|archived` | |
| 533 | | Workflow definitions | `workflows` — versioned; namespace, input/output schema, steps, default route, cache mode | |
| 534 | | Workflow runs | `runs` — `queued\|running\|succeeded\|failed\|cancelled\|cached`, requester, on-behalf-of subject, attribution, source service, target EntityRef, idempotency key, trace id, pinned versions | |
| 535 | | Request/completion log | `requests` — provider, model, route, status, skip reason, fallback flag, prompt/input/output **hashes**, tokens, cost, latency; raw prompts only with `AI_DEBUG_RAW_LOG` and a caller opt-in | |
| 536 | | Sources/citations | `citations` | |
| 537 | | Cache | `cache_entries` — scoped (below) | |
| 538 | | Quotas/usage | `quotas`, `usage_counters`, `usage_daily` | |
| 539 | | Audit | `audit_log` — every run create/cancel/retry, every provider/model/route/template/workflow/quota change, circuit changes, fallbacks | |
| 540 | |
| 541 | Edits to templates, workflows and routes always create a new version; old versions stay readable |
| 542 | and every run records the versions it used. Boot seeding adds a new version when the code changes a |
| 543 | definition, but never over an admin's version. |
| 544 | |
| 545 | ## Providers |
| 546 | |
| 547 | | Kind | What | Configured by | |
| 548 | |---|---|---| |
| 549 | | `stub` | Deterministic, no key, realistically shaped output (schema-shaped JSON, sentences, unit-vector embeddings, timed transcript segments), clearly marked synthetic (`(synthetic)` text, `synthetic: true` on the run). Never cached. | always present | |
| 550 | | `openai` | Any OpenAI-compatible API (OpenAI, OpenRouter, Groq, Together, Ollama, LM Studio, llama.cpp): chat with structured-output step-down, embeddings, Whisper-style transcription, image generation (`POST {base}/images/generations`). Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL` — Live's setting names | |
| 551 | | `openai`, key `deepseek` | DeepSeek's own API, registered when its key is configured: `deepseek-flash` and `deepseek-v4-pro`, priced from DeepSeek's price page (peak rates; off-peak is half). A missing key leaves the provider disabled, so none of its models is an offer. | `DEEPSEEK_API_KEY` (or `AI_DEEPSEEK_KEY_REF`), `AI_DEEPSEEK_BASE_URL` | |
| 552 | | `responses` | The OpenAI Responses API (`POST {base}/responses`) for GPT-family models that only speak it, at any OpenAI-compatible `base_url` (default `https://api.openai.com/v1`): chat and every text operation, structured output through `text.format` with the same step-down, vision; no embeddings or transcription. | the admin API (`kind: "responses"`, `base_url`, `secret_ref`) | |
| 553 | | `anthropic` | Messages API with cached system blocks and forced-tool JSON. Ported from Live's `llm.js`. | same, with `AI_PROVIDER=anthropic` | |
| 554 | | `http` | The local HTTP seam: `POST {operation, …}` → `{text, json, usage}` | `AI_HTTP_SEAM_URL` or the admin API | |
| 555 | | `whisper` | whisper.cpp on this host, with Live's VAD, hallucination filter, multilingual model and live/batch lanes; NVIDIA Parakeet TDT 0.6B v3 (`parakeet-cli`) for English and 24 European languages when `PARAKEET_MODEL` is set, falling back to whisper | `WHISPER_*`, `PARAKEET_*`, `ASR_ENGINE` — Live's names | |
| 556 | | `openai`, key `local` | A model server on this host speaking the OpenAI API (llama.cpp's `llama-server`, Ollama's `/v1`): no key, never paid; route `media.local`; last offer for short text, one request at a time | `AI_LOCAL_LLM_URL`, `AI_LOCAL_LLM_MODEL`, `AI_LOCAL_LLM_TIMEOUT_MS`, `AI_LOCAL_LLM_MAX_TOKENS` (512), `AI_LOCAL_LLM_CONCURRENCY` (1) | |
| 557 | | `openai`, key `local2` | A second local chat server, another llama.cpp process and model family: no key, never paid, no JSON or embeddings. Two local offers let a caller pin a match between them (Games' AI-vs-AI job) | `AI_LOCAL_LLM2_URL`, `AI_LOCAL_LLM2_MODEL`, `AI_LOCAL_LLM2_TIMEOUT_MS` | |
| 558 | |
| 559 | Routing: a route either names a `capability` and the router builds its pool from every provider that |
| 560 | can serve it, ordered by `openvibe-sdk/placement` (objective, price, measured latency, health, with |
| 561 | hysteresis through the route's current placement), or pins a `[primary, ...fallbacks]` list. A |
| 562 | provider that is disabled, missing credentials, circuit-open or unable to do the operation is |
| 563 | **skipped and logged**; every call has a strict timeout, cancellation and one retry on a transient |
| 564 | failure. A failure is classified as `balance` (402, `insufficient_quota`, "credit balance is too low", |
| 565 | OpenRouter's 402 credits), `auth` (401/403), `rate_limit` (429), `transient` (timeout, 5xx, broken |
| 566 | stream) or `request` (400/413/422 and context too long). The circuit is per provider+model |
| 567 | (`provider_circuits`, migrations/0060, survives a restart): balance and auth open it at once and are |
| 568 | never retried; a run of transient failures opens it for 30 s doubling to 10 min; rate limits and |
| 569 | malformed requests never open it. A balance/auth circuit may probe the next real request after |
| 570 | `AI_BREAKER_EXHAUSTED_COOLDOWN_MS` (15 minutes) and closes on a success or when the provider's key |
| 571 | changes. The catalogue reports `up`, `degraded` (over 5% of at least 10 requests in the last hour) or |
| 572 | `down`, with `status_reason`, `status_since` and `status_class`. When nothing can serve, the run answers `ai.resting`; a fallback that answers is |
| 573 | recorded on the run (`fallback_used`), in the request log |
| 574 | (`fallback = 1`) and in the audit log. Every run response and `GET /api/v1/runs/:id` carry `explain` |
| 575 | (objective, reasons, selected, and every candidate with its estimated cost, latency and excluded |
| 576 | reason). |
| 577 | |
| 578 | ### Bench evidence (BENCH-11, plan §4.12 and §4.7) |
| 579 | |
| 580 | AI consumes OpenVibe.Bench's `bench.rating.changed` (through the signed `/internal/events` webhook, subscribed |
| 581 | at boot with `events.subscription.manage`) and keeps a small `bench_evidence` table: per kind and category, the |
| 582 | model, provider, Elo rating, confidence interval, games and Bench's cost per solved. A sparse event (Bench's |
| 583 | current outbox names only `subjects`) is filled from `GET /api/v1/boards/:kind?type=elo|cost`. |
| 584 | |
| 585 | A route learns from that only when it opts in — `learn_from_bench: true` on the route, default off; `bench_kind` |
| 586 | names the board (when absent AI infers it from the route key or operation, e.g. `chat.reply`), and |
| 587 | `bench_category` defaults to `all`. Among the route's eligible offers, placement then prefers the **cheapest** |
| 588 | offer whose rating interval overlaps the leader's (the highest non-provisional rating): statistically tied with |
| 589 | the best, so price decides. A provisional rating (under 10 games) never decides. With no evidence the order is |
| 590 | exactly the one placement produced. `GET /api/v1/routes/:key` carries a `bench` object with the evidence, the |
| 591 | leader, the tied offers, the selected offer and the reason; the operator console shows the same read-only. |
| 592 | |
| 593 | `GET /api/v1/routes?operation=generate` lists each route's eligible offers as `{ provider, model }`. |
| 594 | A first-party service holding `ai.run.create` may pin one on `POST /api/v1/generate` (and on |
| 595 | `POST /api/v1/runs` for `ai.generate`) with `"route": { "provider": …, "model": … }`. The run tries |
| 596 | that offer alone, never a fallback; a provider/model the routing does not list for `generate` is |
| 597 | `422 input.invalid` in words, and the caller's `ns` is still checked first (a workflow outside it is |
| 598 | `403 capability.namespace_denied`). Developer-app tokens (`ai.app.run`) cannot pin. Quotas, tier budgets and Billing readings are exactly as for an unpinned |
| 599 | run, and a pinned run is never served from the shared cache. |
| 600 | |
| 601 | `GET /api/v1/runs/:id` adds `explain.usage_readings` when the run has a Billing reading: per |
| 602 | reading its `idempotency_key`, `state` (`queued` until Billing accepts it, `sent`, or `failed` when Billing |
| 603 | refused it), `attempts`, `queued_at`, `sent_at`, `rejected_at`, `next_attempt_at`, `last_error` (the |
| 604 | outbox's last send error, also while a queued reading is retried) and `free_allowance_used` when some |
| 605 | **Balance (AI Exchange F; `AI_CREDITS`, off).** A request on a paid managed route — the model catalog's |
| 606 | `provider_access` is `OWN_DEPLOYMENT` or `AUTHORIZED_WHOLESALE` and the offer is metered — reserves its |
| 607 | worst case in OpenVibe.Billing before any provider is called: the input estimate plus `max_tokens` of output |
| 608 | at the model's retail card (`ai.model@1` `retail_rate`). Who pays is the payer's own Balance (D12): an app's |
| 609 | run holds against its project's Balance, with the hold's subject `{type: "app", id}` and its `project_id`, so |
| 610 | no Network owner lookup is made; a person's own run holds against their personal Balance, subject |
| 611 | `{type: "user", id}` and no `project_id`. A first-party service with no payer (no person and no app) is never |
| 612 | served a paid offer that carries a versioned card (`payer_required`); offers without a card behave as before. |
| 613 | An exact id whose only offer is skipped that way is `402 billing.payer_required`. |
| 614 | |
| 615 | `max_cost_usd` is parsed as an exact decimal (up to 9 fractional digits, finer digits round up; exponent forms |
| 616 | are 422) into integer nano-USD. When it binds, the affordable output is computed in BigInt from nano-USD per |
| 617 | million tokens and the provider receives that clamped `max_tokens`; fewer than one affordable token is 402 |
| 618 | before the provider. A pricier managed fallback extends the hold by the difference (clamped the same way); if |
| 619 | Billing refuses the extension the offer is skipped as `hold_not_extended`. A stream extends the hold in steps |
| 620 | when the output nears it. |
| 621 | |
| 622 | The hold's idempotency key is the request id; its fingerprint covers the resolved model, the token caps, the |
| 623 | rate version, the payer and (for an app) the project. Billing's `402 billing.insufficient_funds` becomes AI's |
| 624 | own 402 `Not enough Balance`; any other Billing failure refuses the run with `503 billing.unavailable` rather |
| 625 | than serving unpaid capacity. At the end one settle carries the four token counts, the answering model's retail |
| 626 | card version, the supply cost and every attempt in `billing.credit-hold-settle-request@1`'s exact shape |
| 627 | (`deployment_id`, `provider_access`, start/end, `error_code`); a retried request replays the same settlement |
| 628 | instead of charging twice. A settle Billing cannot take is kept in `credit_settle_queue` (migration 0025) and |
| 629 | retried with backoff and the same key; a 409 or 422 marks it `review`, never a retry. A failure before the |
| 630 | 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 |
| 631 | never charged; Billing is not called at all. A sandbox app token is never offered paid supply (D10), so it |
| 632 | spends no Balance and never opens a hold. Requires `AI_MODEL_CATALOG` for the access labels; with either |
| 633 | flag off nothing changes. Code: `server/credits.js`, `server/credit-settlements.js`; the fake-Billing tests are |
| 634 | `test/credits.test.js` and `test/credits-run.test.js`. |
| 635 | |
| 636 | Speech and embeddings use placement pools of registered `stt` and `embedding` models; until |
| 637 | a second upstream exists, an unavailable sole provider answers `503 provider.unavailable`. Media |
| 638 | uses separate local and paid pools so paid work still requires its explicit budget. The synthetic |
| 639 | stub can join general text routes as a last resort outside production only (`AI_STUB_FALLBACK`). |
| 640 | |
| 641 | Free allowance (§2.1.8): a rate card's `free_allowance` (tokens per metric, from a models row or |
| 642 | `AI_PRICING_JSON`) is free provider capacity for **each subject** (the run's attribution, else its |
| 643 | actor, else its requester) per provider, metric and `reset_period` (`day` and `month` reset at the UTC |
| 644 | boundary; `none` never resets for the card's terms). An accounted run claims it first, atomically, in |
| 645 | the same transaction as its usage (`free_allowance_usage`, `server/free-allowance.js`); the free tokens |
| 646 | are the reading's `free_allowance_used` on its `platform.usage-sample@1`, and its `cost_estimate` leaves |
| 647 | their price out. It is pricing, never admission: quotas, request counting and the run's own cost are |
| 648 | unchanged, and no call is ever refused because the allowance is used up. Every card defaults to 0, so |
| 649 | nothing is free and the field is absent until an operator configures an allowance. |
| 650 | |
| 651 | Free-use sources and price ceiling (`AI_FREE_PROVIDERS`, default `deepseek,local,community`; |
| 652 | `AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK`, default 1.5): a run that nobody pays for — the free allowance |
| 653 | (a first-party service naming a person, without that person's own token) or the Exchange's anonymous Try |
| 654 | it — may use only offers from those sources and at or below this output price per million tokens. A |
| 655 | source is a provider key; `local` covers every local model server (`local`, `local2`, whisper.cpp) and |
| 656 | `community` the community machines. The design funds free use from the owner's DeepSeek key, local models |
| 657 | and volunteers, never from Live's shared OpenAI key. The router skips the rest and answers |
| 658 | `402 billing.payer_required` when every capable offer is outside those rules. A person's own token (and so |
| 659 | their Balance, AI #311), their own provider key, an app's project and a service's own run-token budget are |
| 660 | not capped this way. The check is admission, not pricing: it never changes a price or a Balance reading. |
| 661 | The Exchange catalog marks each offer `free_tier` by the same two rules (distinct from `free`, which means |
| 662 | prepaid capacity with no per-token price). |
| 663 | |
| 664 | Abuse prevention for the free tier (plan E5; `server/abuse/`, `docs/abuse-prevention.md`, privacy note |
| 665 | `docs/privacy.md`): the daily free allowance is `AI_FREE_DAILY_TOKENS` (default 20,000) scaled by trust — ¼ for a |
| 666 | new or unvouched account, ½ after 7 days, full when Network says a passkey, a linked account or 30 days with |
| 667 | activity verifies it, plus up to one extra full for a volunteer's kudos. A **hosting/data-centre ASN** is always |
| 668 | the new tier and a lower rate; the address is looked up in a local monthly iptoasn.com table and only the ASN and |
| 669 | an HMAC of its `/24`–`/48` are kept, for 24 h. A signed `HttpOnly` device cookie counts accounts per device, and |
| 670 | more than three accounts on one device share one allowance at the lowest tier. The anonymous Try it needs one |
| 671 | in-house hashcash proof of work (`POST /api/v1/exchange/tryit/challenge`). Every finished free run feeds a |
| 672 | per-minute velocity check per ASN, device and input bucket against the last week's baseline; above it the run is |
| 673 | `429 ai.abuse_throttled` and an `abuse.throttled` row goes to `/console/abuse` and the |
| 674 | `ai_abuse_throttles` gauge (OpenVibe.Host alerts on it). No raw IP, device id or prompt is stored. |
| 675 | |
| 676 | Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers |
| 677 | `free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider |
| 678 | (and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two |
| 679 | budgets: its **subject** (attribution, else actor, else requester) inside its **project** (the calling |
| 680 | product, e.g. `service:live`), and the **project** as a whole. Each hold is one atomic step, so two runs |
| 681 | never both take the last unit; a hold that does not fit refuses the run with `govern.exceeded` (429 + |
| 682 | `Retry-After`). When the run is accounted the hold becomes its real tokens and its priced cost (the free |
| 683 | allowance's share left out, so a fully free run spends no `ai-usd`); a run that spent nothing gives it back. |
| 684 | Like the quotas, a run that spends more than its hold bounds the next run, not itself. A subject's tier is |
| 685 | its own `AI_GOVERN_TIER_MAP` entry, else its project's, else `AI_GOVERN_DEFAULT_TIER`. With the tiers on, |
| 686 | the free allowance's counter lives in the same Valkey (same numbers, one atomic claim per metric); |
| 687 | `free_allowance_usage` keeps its total for the console and is the floor a lost key restarts from. Each |
| 688 | `platform.usage-sample@1` comes from the `onUsage` record of an in-process govern meter, with the same |
| 689 | idempotency key (`ai:<run>:tokens`), whether the tiers are on or off. **Valkey unreachable:** a run with |
| 690 | any budget on `paid` or `staff` is refused with `govern.unavailable` (503, fail closed); an all-`free` run |
| 691 | is admitted on the PostgreSQL quotas alone (today's behaviour) and its tokens are priced in full (nothing is |
| 692 | free while the counter cannot be read). Reads never touch the tiers. With the flag off nothing here runs and |
| 693 | the service boots without Valkey; with it on, production refuses to boot without `VALKEY_URL`. |
| 694 | |
| 695 | | Setting | Default | Meaning | |
| 696 | |---|---|---| |
| 697 | | `AI_GOVERN_TIERS` | `0` | `1` turns the tier budgets on | |
| 698 | | `AI_GOVERN_POLICY_JSON` | free: 200,000 `ai-token`/day and 2,000,000/month, $0.50 `ai-usd`/day and $5/month; paid: 5,000,000 `ai-token`/day, $50/day; staff: unlimited | govern policy `{ unit: { tier: { minute?, hour?, day?, month? } } }` for `ai-token` and `ai-usd`; a missing tier or window is unlimited, 0 refuses | |
| 699 | | `AI_GOVERN_TIER_MAP` | empty | `subject=tier` pairs, comma separated (`service:live=paid,user:usr_1=staff`) | |
| 700 | | `AI_GOVERN_DEFAULT_TIER` | `free` | the tier of a subject or project not in the map | |
| 701 | | `AI_GOVERN_HOLD_TOKENS`, `AI_GOVERN_HOLD_USD` | `4000`, `0.02` | the estimate a run holds before its provider call | |
| 702 | | `AI_GOVERN_TIMEOUT_MS` | `1000` | longest wait for Valkey before it counts as unreachable | |
| 703 | | `VALKEY_URL`, `VALKEY_PREFIX` | empty | the shared Valkey (ADR-035) the counters live in | |
| 704 | |
| 705 | ## The living price book |
| 706 | |
| 707 | The price the router charges and the Exchange shows is read from the provider that sells it, and from nowhere |
| 708 | else. A price nobody publishes stays unknown: it is never guessed, never averaged, never taken from an |
| 709 | aggregator. |
| 710 | |
| 711 | Once a week (plan E1, 2026-10-30 §8) a job reads each catalogued provider's own model and price listing — |
| 712 | DeepSeek's price page, Alibaba Model Studio's price table, Kimi's pricing page, Z.ai's and MiniMax's price |
| 713 | pages, DeepInfra's, Novita's, Featherless', Refuseless' and Abliteration.ai's listings — through the same egress |
| 714 | guard the rest of the platform uses: https only, a public host, never an IP literal, a timeout and a size cap. |
| 715 | OpenRouter's public model list is read as well, but only as a second opinion; no price ever comes from it. |
| 716 | |
| 717 | A price never changes silently. Every change lands in the `price_changes` log with the old price, the new |
| 718 | price, the listing it was read from and the day it was read. A change of **more than 20%** waits there for the |
| 719 | owner, who approves or rejects it on the operator console ([price book](https://openvibe.exchange/console/pricebook), `ai.provider.manage`); a |
| 720 | smaller one the job applies at once, and the model row carries the new price with the day it was checked. A |
| 721 | listing that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price a |
| 722 | person typed by hand is never touched. |
| 723 | |
| 724 | A reviewed reading can also be put in front of the owner without waiting for the weekly job: |
| 725 | `POST /api/v1/admin/console/pricebook/proposals` (owner-only, `ai.provider.manage`) takes the same readings |
| 726 | `scripts/propose-pricebook.js` reads from a JSON file. Both write **pending** changes only — the source listing and |
| 727 | the day it was read travel with each row — and a model the registry does not know is created switched off with no |
| 728 | price, so there is a row to approve against. Nothing is applied until the owner approves it. |
| 729 | |
| 730 | The book is **due-based**, not timer-based: AI is deployed several times a day, so a timer in the process |
| 731 | would be reset every time and the week would never come round. The last finished run is a row in the database |
| 732 | (`price_book_state`, migration 0037) and the service asks once an hour whether the interval has passed since |
| 733 | then; the first ask is a few minutes after a boot, so the boot path stays free. AI runs several workers, so a |
| 734 | run is claimed before it starts: only one of them reads the listings at a time, and the claim expires on its own |
| 735 | if a worker dies. |
| 736 | |
| 737 | The catalog says when a price was checked (`price_checked_at`, `price_checked`), and |
| 738 | `GET /api/v1/exchange/price-changes` is the changelog Exchange renders: what a price was, what it became, the |
| 739 | listing it was read from and the day it was read. |
| 740 | |
| 741 | | Setting | Default | Meaning | |
| 742 | |---|---|---| |
| 743 | | `AI_PRICE_BOOK` | on (off in test mode, off under `AI_DRILL=1`) | the weekly job | |
| 744 | | `AI_PRICE_BOOK_INTERVAL_MS` | 604800000 (a week) | how long between two runs | |
| 745 | | `AI_PRICE_BOOK_TICK_MS`, `AI_PRICE_BOOK_FIRST_TICK_MS` | 3600000, 300000 | how often the service asks whether the book is due, and how long after a boot the first ask is | |
| 746 | | `AI_PRICE_BOOK_CLAIM_TTL_MS` | 900000 | how long a claimed run holds the claim | |
| 747 | | `AI_PRICE_BOOK_THRESHOLD_PCT` | 20 | a change of more than this waits for the owner | |
| 748 | | `AI_PRICE_BOOK_TIMEOUT_MS`, `AI_PRICE_BOOK_MAX_BYTES` | 20000, 8 MiB | the guard's timeout and size cap | |
| 749 | |
| 750 | The tests never touch the network: the listings are recorded (`test/fixtures/pricebook/`, written by |
| 751 | `scripts/record-price-book.js`) and replayed through the reader's transport seam. |
| 752 | |
| 753 | ## Webhooks and the MCP server |
| 754 | |
| 755 | The Exchange's developer tools (the plan of 2026-11-06, "For builders", item 7): `openvibe-sdk/exchange` has |
| 756 | verified webhooks since 0.91.0; `server/exchange/webhooks.js` is the sender, and `server/mcp/exchange.js` the MCP |
| 757 | server. |
| 758 | |
| 759 | ### Webhooks |
| 760 | |
| 761 | A person's token or a developer app registers an https receiver on the Exchange API: |
| 762 | |
| 763 | | Route | What | |
| 764 | |---|---| |
| 765 | | `POST /api/v1/exchange/webhooks` | `{url, events?, description?}` → `201 {webhook}`; the signing `secret` (`whsec_…`) is in this answer only | |
| 766 | | `GET /api/v1/exchange/webhooks` | the caller's receivers (never a secret) | |
| 767 | | `DELETE /api/v1/exchange/webhooks/{id}` | remove one and its delivery history | |
| 768 | | `GET /api/v1/exchange/webhooks/{id}/deliveries` | the delivery log: event, attempts, status, the receiver's answer and the payload | |
| 769 | |
| 770 | Three events: `usage` (an answered run: the tokens, the money and who paid), `earnings` (a community offer earned |
| 771 | credits or kudos) and `offer.down` (an endpoint offer was removed). Every delivery carries |
| 772 | `X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>">` — exactly what |
| 773 | `require('openvibe-sdk/exchange').verifyWebhook(rawBody, header, secret)` checks — plus `X-OpenVibe-Event`, |
| 774 | `X-OpenVibe-Delivery` and `X-OpenVibe-Webhook-Id`. A payload never carries a prompt or an answer. |
| 775 | |
| 776 | The receiver URL goes through the same egress guard as a community endpoint: https, a public host name, never an IP |
| 777 | literal, port 443 or 8443, no query or fragment, and the DNS answer is judged inside the connection. A delivery is a |
| 778 | row, not a call in the request that caused it: a 408/425/429/5xx or a network failure is retried with an exponential |
| 779 | backoff (`AI_WEBHOOK_BASE_DELAY_MS`, up to `AI_WEBHOOK_MAX_DELAY_MS`, `AI_WEBHOOK_MAX_ATTEMPTS`); any other non-2xx |
| 780 | fails it at once. The worker is off in test mode and in `NODE_ENV=test`. |
| 781 | |
| 782 | ### The MCP server |
| 783 | |
| 784 | `bin/openvibe-exchange-mcp.js` speaks MCP over stdio in the repo that owns the Exchange API: |
| 785 | |
| 786 | ```json |
| 787 | { |
| 788 | "mcpServers": { |
| 789 | "openvibe-exchange": { |
| 790 | "command": "openvibe-exchange-mcp", |
| 791 | "env": { "OPENVIBE_EXCHANGE_TOKEN": "your Network developer key" } |
| 792 | } |
| 793 | } |
| 794 | } |
| 795 | ``` |
| 796 | |
| 797 | The tools are `exchange_models` (the live catalog: price in/out per million, measured p50/p95, uptime, health and the |
| 798 | data policy), `exchange_chat` (one completion on the person's allowance, or their own stored provider key when |
| 799 | `credential` names a provider) and `exchange_usage` (the person's own requests, tokens and spend by day and model, |
| 800 | from `GET /api/v1/exchange/me/usage`). `exchange_models` is public; the other two refuse a call without a token |
| 801 | before any request, and a 401 from the Exchange is refused back as the tool's error. The token is never printed, |
| 802 | logged or stored. |
| 803 | |
| 804 | ### The CLI |
| 805 | |
| 806 | `openvibe-link exchange serve --model qwen3:32b --public-url https://…/v1` (OpenVibe Link) serves the local model |
| 807 | through an OpenAI-compatible listener and registers it on the existing endpoint-seller API with the person's |
| 808 | `OPENVIBE_DEVELOPER_KEY`; Ctrl-C removes the offer. See that repo's README. |
| 809 | |
| 810 | ## Workflows |
| 811 | |
| 812 | - **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page` (both on route `wiki.write`, see [MegaSource](#megasource-research)), `blog.draft_post`, |
| 813 | `news.summarize_story`, `news.compare_perspectives`, `reviews.summarize_entity`, |
| 814 | `deals.enrich_deal`, `coupons.extract_coupon`, `trade.summarize_market_context`, |
| 815 | `codes.generate_docs`, `games.generate_lore`, `moderation.classify`, `food.suggest_recipes`, |
| 816 | `rent.screen_listing` (a rental listing's scam signs for Rent's staff: `allow` or `review`, quoting the words). They take numbered |
| 817 | `sources`, return `citations` (indices the engine bounds to the sources given) and `gaps`, and |
| 818 | never invent facts: Reviews has no rating field, a Deals price is null unless cited, a Coupons |
| 819 | expiry stays unknown unless stated, Trade carries its informational-only disclaimer. The |
| 820 | historical route keys (`wiki.generate`, `news.summarize`, …) are explicit aliases of `default.json`. |
| 821 | `blog.write_digest` writes an OpenVibe.Blog desk's digest from its sources' items, every section cited. |
| 822 | - **Satire** — `blog.write_satire` (Blog's "Is This Real?" desk) is invented on purpose and takes no sources: absurd |
| 823 | mock-explainers that never name or quote a real person or put words in a real organisation's mouth, never joke about |
| 824 | tragedies, minors or groups of people, and never give steps for anything dangerous. Blog labels each post as satire |
| 825 | written by AI. |
| 826 | - **Live's features** — `live.translate`, `live.paste.describe_image`, `live.paste.summarize_text`, |
| 827 | `live.stream.describe_frame`, `live.stream.summarize`, `live.streamer.overview`, |
| 828 | `live.media.overview`, `live.stream.recap`, `live.media.transcribe`, `network.site_copy` |
| 829 | (prompts moved verbatim), plus passthrough workflows for the features whose prompts Live still |
| 830 | renders (`live.viewers.*`, `live.chat.insight`, `live.moments.*`, `live.hero.slogans`, |
| 831 | `live.arena.*`, …). See `docs/migration.md`. |
| 832 | - **Media** — `media.analyze` (roadmap WS-O task 5), local-first analysis of a VOD, clip or audio file by `media_url` |
| 833 | or MediaRef, fetched like any media input (allow-listed hosts) into `media-tmp` beside the database |
| 834 | (`AI_MEDIA_ANALYSIS_DIR`; never `/tmp`, which is memory on the production host). The file cap is |
| 835 | `AI_MEDIA_ANALYSIS_MAX_BYTES` (4 GB), also bounded by that disk's free space minus |
| 836 | `AI_MEDIA_ANALYSIS_DISK_RESERVE_BYTES` (3 GB). Otherwise the run is refused with `media.no_space` before downloading. Everything runs on this host, one analysis at a time, at nice 15: |
| 837 | 1. **Signals**, one FFmpeg pass with video decoded at keyframes only: scene changes (`scdet`, threshold |
| 838 | `AI_MEDIA_SCENE_THRESHOLD`), black and frozen picture, silence, and loudness. Loudness has the integrated level |
| 839 | and range, the typical level (the median audible second) and loud moments at least 6 LU above it. |
| 840 | 2. **Scenes** from the scene changes (at most 300). |
| 841 | 3. **Speech**: whisper.cpp through `live.stt`, with another registered STT model eligible for fallback. |
| 842 | 4. **Highlights**: windows scored by loud, scene changes and speech, each with its reasons and transcript excerpt. |
| 843 | 5. **An overview** from a local model (`media.local`), else extractive from the measured facts. A paid provider |
| 844 | (`media.paid`, including the shared provider) writes it only with `allow_paid: true` **and** an active quota with |
| 845 | `workflow_prefix` `media.paid` and `max_cost_usd` > 0, while today's `media.*` spend is under it. That quota |
| 846 | gates only this call, never whole runs. |
| 847 | |
| 848 | - **Chat's AI members** ([server/workflows/chat.js](server/workflows/chat.js)): `chat.member.reply` (namespace `chat`, route |
| 849 | `chat.member`, an alias of `default.chat`, text out). A room on OpenVibe.Chat adds an AI member with a name and how |
| 850 | it should be; when someone names it, Chat sends the recent messages, the room's notes and any shared context a linked |
| 851 | service gave the room (Food: a household's pantry and plan), and posts the reply. The prompt forbids inventing facts |
| 852 | about the people or their things and claiming actions beyond writing in the chat. |
| 853 | `chat.member.turn` is the same member with tools: a Chat Completions request (Chat's system message, the transcript, |
| 854 | the tools Chat runs) passed through like `ai.complete` (the prompt is not kept) on route `chat.member`, so Chat's grant |
| 855 | and quota apply and a person's own key reaches only their provider; the choices come back with any tool calls, and |
| 856 | Chat sends the results in its next turn. |
| 857 | `chat.vibe.listen` is the Dock's voice on browsers without speech recognition: Chat sends a short recording |
| 858 | (audio/webm, audio/ogg or audio/mp4, at most 1 MB) inline as a data URL, the existing `transcribe` step runs it on |
| 859 | route `live.stt` (whisper.cpp/Parakeet), and the text comes back. The input is never retained (`retain_input: |
| 860 | false`); the engine writes it to a private temp file and deletes it in the same call. |
| 861 | - **Space's spaces** ([server/workflows/space.js](server/workflows/space.js), namespace `space`): `space.app.build` |
| 862 | is the AI builder of OpenVibe.Space. From what a person asks for (and a space's current files when changing one), |
| 863 | it returns a working space: name, description, the instructions its AI follows, the SDK features it uses, and up to |
| 864 | six files with an `index.html`. Paths are relative and safe, and an answer without a real page is no answer. It runs |
| 865 | on route `space.build` (the creative model, 180 s, 16,000 output tokens), on behalf of the builder. `space.app.ask` |
| 866 | is a space's own AI (`OV.ai.ask` in the space SDK): the maker's instructions under the platform's rules, on route |
| 867 | `space.ask` (an alias of `default.chat`), on behalf of the visitor, so it counts against their daily allowance. |
| 868 | |
| 869 | The builder's prompt can carry OpenVibe.Design's public `design.component@1` registry in a `components` input |
| 870 | (Space reads and caches it): the definitions are then the interface vocabulary and the answer marks each major |
| 871 | part with `data-ov-component`. `overview.source` says which (`local_model`, `paid`, `extractive`), and `gaps` |
| 872 | lists what is missing (no local model, no speech-to-text, no budget). The output is not cached: callers keep it. |
| 873 | Live's grant names `media.analyze`. |
| 874 | - **Direct operations** — `ai.chat|generate|summarize|classify|extract|enrich|embed|image`. |
| 875 | |
| 876 | ## MegaSource research |
| 877 | |
| 878 | `research.gather` (namespace `research`, `server/research/`, `server/workflows/research.js`) answers one question from |
| 879 | many sources at once and returns one ranked, de-duplicated evidence pack the product workflows cite by number. No model |
| 880 | is called; a run is recorded like a provider call (provider `research`, model `megasource`), so the web route's per-call |
| 881 | price counts against the cost quotas and the daily usage. |
| 882 | |
| 883 | | Source | What it gives | Terms | |
| 884 | |---|---|---| |
| 885 | | Wikipedia | the best articles' full text, their pictures (each with its licence; an article's own non-free files are marked) and the Wikidata id | CC BY-SA 4.0 | |
| 886 | | Fandom | the topic's own community wiki when it has one (from the web results' hosts, else the topic as a subdomain, confirmed by the wiki's name): pages read through its MediaWiki API, infobox rows kept as "Label: value" | CC BY-SA 3.0; pictures marked not free | |
| 887 | | Wikidata | the top article's key statements as labelled facts, and its picture | CC0 | |
| 888 | | Search Fabric | the web through OpenVibe.Search's web scope (`POST /api/v1/query`, capability `search.fabric.query`) first: the open index, then the providers Search routes to; every page keeps its provider, URL and dates | per provider Search used | |
| 889 | | DeepSeek | the fallback web search when the fabric is off, refuses or answers nothing: page text (Search API) and full text for short results (Fetch API), fetched by DeepSeek, never from this host | $0.75 per 1,000 successful calls each | |
| 890 | | Commons, Openverse | freely licensed pictures (CC0, public domain, CC BY, CC BY-SA; NonCommercial and NoDerivatives are not counted as free), kept only when their title or description names the subject | per picture | |
| 891 | |
| 892 | Input: `query` (required), `topic` (the subject it belongs to: finds the topic's wiki), `focus` (one subject inside the |
| 893 | topic), `lang`, `max_sources` (≤ 20), `max_images` (≤ 24), `content_chars` (≤ 12,000), `providers` (a subset). Output: |
| 894 | `sources` (each with `url`, `title`, `content`, `published_at`, `retrieved_at` and `provenance.provider|site|via|license`), |
| 895 | `images` (each with `license`, `free`, `author`, `source_url`, `provider`), `facts`, `topic_wiki`, `providers` (per route: |
| 896 | ok, ms, count, cost, or why it was skipped or failed) and `usage` (requests, DeepSeek calls, fabric calls and the fabric's |
| 897 | `search_fabric_cost_usd`; `cost_usd` is this run's own DeepSeek calls, so a paid fabric step is not billed twice — |
| 898 | Search and its nested AI run pay it). Cached for a day per calling service. |
| 899 | |
| 900 | Web search asks the Search Fabric first, as AI's own service principal (audience `openvibe.search`). Search's viewer |
| 901 | requires `search.query.delegate` of a service token, so Network must grant `ai` that capability on `openvibe.search` |
| 902 | (the route's `search.fabric.query` is public). The token is requested with exactly that scope; without |
| 903 | `OV_OAUTH_CLIENT_SECRET` or with `AI_RESEARCH_SEARCH=0` the fabric is skipped. When Search's own web ladder calls this |
| 904 | endpoint for DeepSeek or Wikipedia, research keeps DeepSeek direct, so Search is never asked back in a loop. DeepSeek is |
| 905 | used only when its key resolves (`AI_RESEARCH_DEEPSEEK_KEY_REF`, default `env:DEEPSEEK_API_KEY`; |
| 906 | `AI_RESEARCH_DEEPSEEK=0` turns it off), only for first-party callers (a developer app's run reads the free sources), |
| 907 | within `AI_RESEARCH_DEEPSEEK_DAILY` calls a day (1500), and it stands down on its own answers: 402 (balance used up) for |
| 908 | 30 minutes, 401 for an hour, 429 for a minute. Tests read a canned web (`test/fixtures/research-fetch.js`); none reaches |
| 909 | the internet. |
| 910 | |
| 911 | OpenVibe.Wiki's "vibe a wiki" uses it with `wiki.generate_space` (plans the pages: main page first, each with a kind, |
| 912 | an outline and a search phrase) and `wiki.generate_page` (writes one page: lead, sections, infobox, `[[links]]` to the |
| 913 | planned pages, and up to three of the numbered pictures by heading), both on route `wiki.write` (the creative model, |
| 914 | 7,000 output tokens, 170 s, past the provider's chat timeout). |
| 915 | |
| 916 | ## Image generation |
| 917 | |
| 918 | Off unless `AI_IMAGE_MODEL` names the shared provider's image model (`dall-e-3`, `gpt-image-1`; OpenAI-shaped |
| 919 | providers only). Then boot gives `shared` the `image` capability, registers the model as type `image` and seeds |
| 920 | route `default.image` (a capability pool of registered `image` models, objective `correctness`, |
| 921 | `provider_scope: paid`, timeout 120 s, above `AI_PROVIDER_TIMEOUT_MS`; a timed-out image is not retried, so it is never paid for twice). Without it no route is seeded and `POST /api/v1/image` answers |
| 922 | `503 route.unavailable`, like any operation whose route is not configured. |
| 923 | |
| 924 | `POST /api/v1/image` (`ai.run.create`, or `ai.app.run` for an app) takes `{ prompt, size?, n? }` (`n` 1–4, 1 for `dall-e-3`; |
| 925 | `size` defaults to `AI_IMAGE_SIZE`, `1024x1024`) and answers `output: { images: [{ b64, url }], count }` |
| 926 | (`dall-e-*` is asked for `b64_json`; `gpt-image-1` answers base64 already). A missing prompt is |
| 927 | `422 input.invalid`, a provider that returns no image `502 output.invalid`, no provider `503 provider.unavailable`. |
| 928 | Outside production the stub answers a 1×1 PNG as the last resort (`AI_STUB_FALLBACK`). |
| 929 | |
| 930 | Images are metered per image, never per token: the request log's `operation` is `image` and `tokens_out` the |
| 931 | image count, and each attempt is one `platform.usage-sample@1` reading (`resource` `output-images:<model>`, |
| 932 | `unit` `image`, `quantity` the images returned), queued like every other reading. No rate card prices an image |
| 933 | yet (an owner/T5 item, never AI-written), so an image costs 0 in AI's usage and cost quotas; Billing stores the |
| 934 | reading unrated until a per-image card exists and its `unitFits()` accepts `image`. |
| 935 | |
| 936 | ## Local model |
| 937 | |
| 938 | `deploy/systemd/openvibe-llm.service` runs llama.cpp's `llama-server` on `127.0.0.1:8090` (OpenAI API, no key), |
| 939 | which AI reaches as the provider `local` and the route `media.local` (`AI_LOCAL_LLM_URL=http://127.0.0.1:8090/v1`, |
| 940 | `AI_LOCAL_LLM_MODEL`). On the production host since 2026-09-27: llama.cpp b11222 (the CPU build, checked against |
| 941 | the release's sha256) in `/opt/llama.cpp/b11222` with `/opt/llama.cpp/current`, and Qwen2.5-1.5B-Instruct Q4_K_M |
| 942 | (Apache-2.0, checked against Hugging Face's sha256) in `/var/lib/openvibe-llm/models`. It is held back so live |
| 943 | streams always win: 2 threads, at most 1.5 CPUs, nice 19, 3 GB. It uses about 0.9 GB and generates about |
| 944 | 28 tokens/s. Without it, `media.analyze` writes an extractive overview and says so. To swap the model, put the |
| 945 | file beside it, change `--model`/`--alias` and `AI_LOCAL_LLM_MODEL`, and restart both units. |
| 946 | Both servers run two slots (`--parallel 2`, 2026-11-07): llama.cpp batches the slots into each step, so a second |
| 947 | request no longer waits for the first; the 8192-token context is split, 4096 a slot. Install a changed unit by hand: |
| 948 | `sudo install -m 0644 deploy/systemd/openvibe-llm*.service /etc/systemd/system/ && sudo systemctl daemon-reload && |
| 949 | sudo systemctl restart openvibe-llm openvibe-llm2` (AI falls over to `shared` for the seconds they restart). |
| 950 | |
| 951 | `deploy/systemd/openvibe-llm2.service` is the optional second chat server: the same llama.cpp build and models |
| 952 | directory on `127.0.0.1:8092`, a different model family, and the provider `local2` |
| 953 | (`AI_LOCAL_LLM2_URL=http://127.0.0.1:8092/v1`, `AI_LOCAL_LLM2_MODEL`). It is held to 2 threads, at most 1 CPU, |
| 954 | nice 19 and 2 GB, and it never advertises `json` or `embed`. Games' AI-vs-AI job pins one local offer per side, |
| 955 | so the two models must be different families and both aliases must be the `model` in its `GAMES_AI_MATCHES_OFFERS` |
| 956 | pair. On the production host since 2026-11-06: Llama-3.2-1B-Instruct Q4_K_M (Llama 3.2 Community License, |
| 957 | Hugging Face sha256 `6f85a640a97cf2bf5b8e764087b1e83da0fdb51d7c9fab7d0fece9385611df83`, 807,694,464 bytes), |
| 958 | checked against that hash before install; it uses about 0.8 GB. |
| 959 | |
| 960 | The local model is a **last offer for short text** (summaries, titles, moderation hints, a chat line), never |
| 961 | for code or long reasoning: routes with `max_output_tokens` over 800 or `outlast_provider_timeout` (an app or a |
| 962 | wiki page) never offer it. It answers with `AI_LOCAL_LLM_MAX_TOKENS` (512) at most, and one request at a time |
| 963 | (`AI_LOCAL_LLM_CONCURRENCY`, 1) across `local` and `local2`, so a paid outage cannot starve the streams sharing |
| 964 | the host. A run that it answered names `local`/`local2` as its provider and marks the receipt local. |
| 965 | |
| 966 | Local embeddings are off unless `AI_LOCAL_EMBED_MODEL` names an embedding model the server at `AI_LOCAL_LLM_URL` |
| 967 | answers at `/embeddings`. Then `local` advertises `embed`, the model is registered as an `embedding` model, and |
| 968 | `default.embedding` (a capability pool) offers it beside the shared provider's `AI_EMBEDDING_MODEL`; `local` is |
| 969 | billing profile `free`, so its readings carry no cost. llama.cpp's `llama-server` answers `/embeddings` only when |
| 970 | started with `--embedding` and a GGUF that supports embeddings (a chat-only model answers 400, and every embedding |
| 971 | call then fails over to the shared provider with an error in the request log). That flag restricts llama-server |
| 972 | to embeddings, so the Qwen chat unit above runs without it: embeddings need a server that serves both on one URL |
| 973 | (Ollama's `/v1`, for example), or `AI_LOCAL_LLM_URL` given over to an embedding-only server. **Vector dimensions |
| 974 | differ per upstream**: a run says which provider and model made its vectors, and a consumer must never compare or |
| 975 | mix vectors from different providers (re-embed when the provider changes). |
| 976 | |
| 977 | ## Acceptance: guarantees and where they are tested |
| 978 | |
| 979 | `npm test` runs every `test/*.test.js` with the stub provider, temp databases and local servers only. |
| 980 | |
| 981 | | Guarantee | Test | |
| 982 | |---|---| |
| 983 | | Every workflow has versioned, compiling input/output schemas, runs on the stub and validates | `test/workflows.test.js` | |
| 984 | | Edits are new versions; runs pin versions; lifecycle; admin versions survive reseeding | `test/versioning.test.js` | |
| 985 | | A degraded primary falls back and records it; breaker; skips; timeouts; explicit `provider.unavailable` | `test/fallback.test.js` | |
| 986 | | 402/insufficient-quota opens the long exhausted circuit, never retries, fails over, then `ai.resting` | `test/provider-exhausted.test.js` | |
| 987 | | Every provider error shape maps to balance/auth/rate_limit/transient/request; per-offer circuits, 30 s doubling backoff, persistence, key reset, catalogue status and the owner alert | `test/provider-circuits.test.js` | |
| 988 | | Hourly DeepSeek balance gauge, no key in logs, off under a drill | `test/provider-balance.test.js` | |
| 989 | | Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` | |
| 990 | | Free allowance: free up to the card's allowance then fully priced, reset per day/month (never for `none`), per subject, never past the cap under concurrent claims, absent at 0 | `test/free-allowance.test.js` | |
| 991 | | Abuse prevention (plan E5): allowance by trust tier, kudos bonus, hosting override, `/24`–`/48` hashes, ASN table, signed device cookie, >3 accounts share one allowance, rate limit, per-minute velocity against the week's baseline, single-use proof of work, operator events and prune | `test/abuse-trust.test.js`, `test/abuse.test.js`, `test/abuse-pow.test.js` | |
| 992 | | The run path refuses a spent daily allowance (402) and the anonymous Try it without/with a used proof of work (403) | `test/abuse-runs.test.js` | |
| 993 | | Tier budgets: enforced per subject and per project and reset per window, two concurrent runs never both take the last unit, the free allowance's numbers unchanged on the Valkey store, one reading per accounted run from govern's `onUsage`, Valkey unreachable (paid 503, free admitted), off by default | `test/govern-tiers.test.js` | |
| 994 | | The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` | |
| 995 | | Queue caps: one caller cannot fill the run queue (per-caller and global 429 `queue.full` + Retry-After) | `test/queue.test.js` | |
| 996 | | Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | `test/runs.test.js` | |
| 997 | | Media fetched only from allow-listed https OpenVibe hosts; DNS answers and every redirect hop re-checked (a public hop never reaches internal Media); size caps | `test/ssrf.test.js` | |
| 998 | | The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | `test/schemas.test.js` | |
| 999 | | Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` | |
| 1000 | | Developer apps: `ai.app.run` on the six direct operations and `GET /runs/:id`, 403 on every other route, project-scoped runs, readings and free allowance per project, govern tiers keyed by the project, sandbox and ungoverned runs free/local capacity only, a byok profile and `credential` never selected | `test/app-access.test.js` | |
| 1001 | | Operator console: owner-only pages and controls on Exchange; AI keeps JSON admin APIs and redirects the old paths | Exchange `test/console-merge.test.js`, AI `test/home.test.js` | |
| 1002 | | 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` | |
| 1003 | | 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` | |
| 1004 | | Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` | |
| 1005 | | 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` | |
| 1006 | | Parakeet: engine by language, 30 s pieces past 45 s, fallback to whisper, `ASR_ENGINE=whisper` | `test/parakeet.test.js` | |
| 1007 | | 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` | |
| 1008 | | `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` | |
| 1009 | | Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` | |
| 1010 | | Routing presets: the five built-in ones, a caller's own stored per account and per project, each knob applied (floor, ceiling, max_price, quantization, region, data policy, community), a hard max_price refused before the call with a problem document, every answer's receipt (offer, price, tokens, cache hits, latency, why, fallbacks, privacy label, BYOK at 0 %) in the headers and at `GET /v1/receipts/{id}`, and the Anthropic endpoint and the stream carrying the same receipt | `test/exchange-presets-receipts.test.js` | |
| 1011 | | Capability proposals are valid contracts documents matching what is enforced | `test/proposals.test.js` | |
| 1012 | | The living price book: every listing read in its own shape, the guard refusing an IP literal, a change of 20% or less applied at once, one of more than 20% waiting for the owner and applied only on approval, the change log, the catalog's "price checked" date, the public changelog, the job off under a drill, the book due-based (a restart before the week does not run it, a restart after it does) and two instances at the same moment never both running | `test/price-book.test.js` | |
| 1013 | |
| 1014 | ## Security |
| 1015 | |
| 1016 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps: |
| 1017 | |
| 1018 | - **Auth.** Every API route needs a Network service token for audience `openvibe.ai`, with the route's |
| 1019 | capability and, for runs, a namespace its `ns` claim allows. Runs are private to their requester. |
| 1020 | A developer-app token is accepted only on `ai.app.run`'s routes ([Developer apps](#developer-apps)), |
| 1021 | where it acts as its project; every other route refuses it. The owner-only operator console is on OpenVibe.Exchange; |
| 1022 | AI's admin JSON endpoints still require their named capabilities. |
| 1023 | - **Secrets.** Shared provider keys are `env:NAME` references, never values; a person's own key is |
| 1024 | AES-256-GCM encrypted with `AI_CREDENTIALS_KEY`, bound to its owner and subject, and never returned |
| 1025 | (a four-character hint only). No response, log or console page carries a secret value. |
| 1026 | - **Memory.** Item text is sealed with `AI_MEMORY_KEY` and bound to its owner, id and scope; plaintext exists only |
| 1027 | inside a request, and the profile builder calls the provider in-process so the notes are not written to a run. |
| 1028 | - **Private data.** The request log keeps hashes, not prompts; raw prompts and inline images are not |
| 1029 | kept; the cache never crosses requester, actor, target or attribution; `history: false` keeps |
| 1030 | neither input nor cache entry. |
| 1031 | - **Egress.** Provider calls go only to configured providers. Media is fetched only from allow-listed |
| 1032 | https OpenVibe hosts, with every DNS answer and redirect hop re-checked and size caps |
| 1033 | (`test/ssrf.test.js`); ffmpeg reads through a loopback proxy that applies the same rules. |
| 1034 | - **Abuse.** Quotas refuse with 429 before any provider call; queue caps per caller and overall. |
| 1035 | `/metrics` answers direct loopback callers only. |
| 1036 | |
| 1037 | ## Live and Network |
| 1038 | |
| 1039 | `docs/live-patch.diff` added `AI_SERVICE=remote` to Live (deployed as Live `fa22de5`; production runs |
| 1040 | with `AI_SERVICE=remote` since 2026-09-23): Live's shared-key AI calls become runs here, authenticated |
| 1041 | with `serviceHeaders('openvibe.ai')` from Live's `server/net/network-principal.js`. Without the flag |
| 1042 | Live's behaviour is unchanged. The one-time `scripts/import-from-live.js` moved the AI records that belong here; |
| 1043 | it ran on production on 2026-09-23 at 18:29 UTC (79,657 ledger rows, 11 holds recorded with their reasons, kept in |
| 1044 | `import_ledger` and `import_holds`) and was retired with the move to PostgreSQL (it is in git history). |
| 1045 | Network's footer copy calls the `network.site_copy` workflow (Network `422f8e9`), but the deployed Network still |
| 1046 | falls back to Live's `/internal/ai/site-copy`, and Live still serves that route. The full plan, holds |
| 1047 | and rollback are in `docs/migration.md`. |
| 1048 | |
| 1049 | ## Deploy |
| 1050 | |
| 1051 | Production deploys with `sudo ovhost deploy ai` on the host (strategy `git-checkout`: fetch, |
| 1052 | fast-forward `/opt/openvibe.ai`, install on a lockfile change, restart, wait for `/api/ready`). |
| 1053 | The unit is `openvibe-ai.service` on `127.0.0.1:4700`, the env file `/etc/openvibe/ai.env`. The database is |
| 1054 | `ov_ai` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh ai` writes its settings); the |
| 1055 | release migrates it at boot. The local model |
| 1056 | runs as `openvibe-llm.service` on 127.0.0.1:8090 ([Local model](#local-model)). |
| 1057 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 1058 | restart; afterwards `sudo ovhost rollback ai --to <sha>`. Migrations only add tables and columns. |
| 1059 | |
| 1060 | Backups and drills: `ov_ai` is in the host's nightly backup (pgBackRest to B2, plus a weekly logical dump), and a |
| 1061 | restore drill (`sudo ovhost drill ai`) restored a fresh dump and passed on 2026-10-15: models, routes, templates, |
| 1062 | workflows, quotas and citations counted equal, and `/api/health` matched production. `/metrics` is scraped by the |
| 1063 | host's Prometheus on 127.0.0.1:4700 and refused on the public vhost. |
| 1064 | |
| 1065 | Deployed: `/opt/openvibe.ai`, env `/etc/openvibe/ai.env` (0600; `BASE_URL=https://ai.openvibe.services`), unit |
| 1066 | `deploy/systemd/openvibe-ai.service` (`StateDirectory=openvibe-ai`; database `ov_ai` on the host's data role), |
| 1067 | principal `ai`. The vhost `deploy/nginx/ai.openvibe.services.conf` (on the `*.openvibe.services` certificate) |
| 1068 | serves the home, `/stats`, `/shared/*`, `/robots.txt`, `/sitemap.xml`, `/llms.txt`, health/ready, the operator |
| 1069 | console `/console` with its sign-in `/auth/`, and exactly the developer-app routes (the six operations by POST, |
| 1070 | `/api/v1/runs/:id` by GET, rate-limited per address); every other `/api/` path and `/metrics` stay host-local. |
| 1071 | The old `ai.openvibe.network` address was retired on 2026-10-10. The console needs |
| 1072 | `AI_CONSOLE_SESSION_SECRET` (32+ random characters) and `OV_OAUTH_CLIENT_SECRET` in the env file (without them it |
| 1073 | answers 503 in production); Network client `ai` lists `https://ai.openvibe.services/auth/callback`. |
| 1074 | |
| 1075 | Sitemap entries use the release commit for home and the newest recorded stats day for `/stats` when available. |
| 1076 | |
| 1077 | ## Not done yet |
| 1078 | |
| 1079 | - Live's VOD and clip overviews calling `media.analyze` instead of extracting frames and transcribing |
| 1080 | themselves; moving the remaining passthrough prompts into AI templates (most `live.*` workflows are |
| 1081 | versioned templates since 2026-09-27); |
| 1082 | removing Network's fallback to Live's `/internal/ai/site-copy`; a public server-rendered status page. |
| 1083 | - Metered models for developer apps: production app runs use free and local capacity until a tier budget |
| 1084 | governs their project (`AI_GOVERN_TIERS` is off in production) and OpenVibe.Billing charges project subjects. |
| 1085 | - Import holds from the 2026-09-23 run: a streamer's own provider key stays in Live, and 3,021 Live |
| 1086 | translations cannot become cache entries (they have no source text) and stay in Live. |
| 1087 | - Text routes now run a capability pool (`shared` plus a local model when one is configured) instead |
| 1088 | of a declared fallback, but speech and embeddings still have a single upstream, so an outage there |
| 1089 | fails that feature until O9 adds a second provider. On 2026-09-23 `live.*` and `network.site_copy` |
| 1090 | outputs carried no citations or gaps |
| 1091 | (0 citation rows); runs record grounding (cited sources and named gaps) since 2026-09-25. |
| 1092 | |
| 1093 | ## Launch |
| 1094 | |
| 1095 | Launched 2026-10-07 under the launch rule (plan §12.12): the runtime with health, readiness and metrics; Network |
| 1096 | subjects and scoped service principals; server-rendered public routes useful without JavaScript (the home, |
| 1097 | `/stats`); PostgreSQL persistence and end-to-end workflows; capabilities and events registered in |
| 1098 | `OpenVibe.Contracts` (`ai.app.run` since 0.109.0, the service live since 0.110.0); migrations at boot, |
| 1099 | robots/sitemap/llms.txt; acceptance tests ([above](#acceptance-guarantees-and-where-they-are-tested), and |
| 1100 | `test/home.test.js` for the public routes and vhosts). The same release removed ai.openvibe.services and the |
| 1101 | ai.openvibe.network notice from OpenVibe.Sites. |
| 1102 | |
| 1103 | --- |
| 1104 | |
| 1105 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 1106 | |
| 1107 | <!-- versions:start --> |
| 1108 | - openvibe-contracts: v0.421.0 |
| 1109 | - openvibe-sdk: v0.166.0 |
| 1110 | - openvibe-shared: v4.241.0 |
| 1111 | <!-- versions:end --> |
| 1112 | |
| 1113 | ### Exchange search engines |
| 1114 | |
| 1115 | The web, image, icon, reverse-image, Unsplash download-event and fetch APIs, provider configuration, verified tariffs, personal keys and receipts are documented in [docs/search-engines.md](docs/search-engines.md). `GET /api/v1/exchange/search/engines` publishes the current adapter catalog; `/openapi.json` describes every search/fetch endpoint. |
| 1116 |