Repositories openvibers openvibe.ai official

openvibers/openvibe.ai

Open source AI gateway for the OpenVibe network: providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas through one API.

Get it

Get this repository

Clone over HTTPS

A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.

git clone https://git.openvibe.codes/openvibers/openvibe.ai.git
Clone to my computer

OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.

Clone to my computer
Open in Codes

A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.

Open in Codes
Files at main
.env.exampleRelay AI usage samples to local Billing by default (#470)2 days ago21.2 KB
.githubopenvibe-shared v4.223.0 (was v4.214.0) (#499)19 h ago
.gitignoreAI service: providers, routing with fallback and circuit breaker, versioned templates/workflows/routes, runs, request log, citations, scoped cache, quotas and audit23 Sept 202693 B
.gitleaks.tomlPropose verified provider prices as pending price-book changes (#447)2 days ago2.8 KB
AGENTS.mdAGENTS.md: the context an agent starts from (#497)1 days ago11.3 KB
CITATION.cffREADME first screen and CITATION.cff (#417)3 days ago520 B
CLAUDE.mdAGENTS.md: the context an agent starts from (#497)1 days ago11 B
LICENSECharter: OpenVibe.AI as defined in the OpenVibe realignment plan (placeholder, no code yet)21 Sept 202633.7 KB
README.mdopenvibe-shared v4.234.0 (was v4.233.0) (#526)1 h ago92.7 KB
SECURITY.mdSECURITY.md: how to report a vulnerability ([email protected], 7-day reply, scope, supported versions)24 Sept 2026986 B
STATUS.jsonopenvibe-shared v4.234.0 (was v4.233.0) (#526)1 h ago6.4 KB
binExchange for builders: signed webhooks, the MCP server and a personal usage read (#334)6 days ago
deployAI vhost: POST /api/v1/decisions reaches the app (#513)7 h ago
docsAI: meter every call with the 1.1.0 fields and accept usage reports (#517)4 h ago
migrationsAI: meter every call with the 1.1.0 fields and accept usage reports (#517)4 h ago
package-lock.jsonopenvibe-shared v4.234.0 (was v4.233.0) (#526)1 h ago80.4 KB
package.jsonopenvibe-shared v4.234.0 (was v4.233.0) (#526)1 h ago1.6 KB
scriptsOwner controls, rented GPU capacity and pending confirmed prices (#454)2 days ago
serverRouting: an empty answer is a failed attempt, and the next offer answers (#527)1 h ago
testRouting: an empty answer is a failed attempt, and the next offer answers (#527)1 h ago

README.md

View file

OpenVibe.AI

Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product.

Live site: https://ai.openvibe.services · API docs: https://openvibe.services · For AI: https://ai.openvibe.services/llms.txt
Run it: npm ci && npm run dev (Node 20 or newer) · Tests: npm test

Status: alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on 127.0.0.1:4700 and in
production use: OpenVibe.Live runs with AI_SERVICE=remote and Network's footer copy runs
network.site_copy here. Public since 2026-10-07 at ai.openvibe.services:
the API (ai.app.run, the OpenAI-compatible /v1) and a run's receipt (/runs/:id). The operator console and
personal credential settings live on OpenVibe.Exchange.
The public site moved to openvibe.exchange on 2026-11-07: the home, prices
and latency, the changelog, the legal pages and the discovery files answer 301 there on an exact page map
(server/app.js), while every API route on this origin is unchanged. The content and the map are in
/home/workstation/openvibe/plan-state-2026-10-22/ai-site-inventory-2026-11-07.md.
Domain: ai.openvibe.services · Port: 4700 · Unit: openvibe-ai.service
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §15.14, §33, §34.
License: AGPL-3.0 (same as every OpenVibe service).

Purpose

The reusable AI authority extracted from OpenVibe.Live. Products run registered, versioned
workflows; every run returns a draft/evidence package (structured output + citations +
provenance) attributed to a workflow, its version, a model and a run id — never to a person. AI never
owns publication truth: Wiki, Blog, News, Live, … decide what to publish.

Owns

  • the eleven record groups (below): providers, models, routing profiles,
    prompt templates, workflow definitions, runs, the request log (hashes, not prompts), citations, the
    cache, quotas and usage, and the audit log, in AI's own PostgreSQL database (ov_ai, ADR-035; schema in
    [migrations/](migrations/))
  • people's own provider keys (ai.credential.manage), encrypted at rest, one per provider (Codes C3: anthropic,
    openai, deepseek, minimax, openrouter or a custom endpoint), and per-streamer
    attribution quotas (ai.quota.attribution.manage)
  • "Your AI" (L36 Y1/Y3/Y8): the person's own keys under the reserved owner self for every supported provider
    (Anthropic, OpenAI, Gemini, DeepSeek, Mistral, xAI, Groq, Cerebras, Together, Fireworks, OpenRouter, MiniMax and a
    custom endpoint), tested on save (its models, or one token) with status, models_count and a redacted last_error;
    their only_my_keys, free_first, price ceiling and default model per kind of work; a run on_behalf_of a person
    tries their keys first, explain.paid_by and the attempts name who paid and why each failed, and a 401/403 marks the
    key failing and publishes ai.credential.failed (/api/v1/me/*, server/me.js; only the person's own token or
    AI-site session, never a service token or on_behalf_of). The person's settings UI is on
    OpenVibe.Exchange.
  • a person's own computer as their provider (credential provider node, contracts 0.220.0): a model on their own
    machine (Ollama, LM Studio, llama.cpp through the OpenVibe Link), reached through OpenVibe.Bot with bot.machine.ai
    for that person only, free and with no key (server/machines.js; Bot on this host by default, OV_BOT_INTERNAL_URL);
    machine any (contracts 0.221.0) uses the fastest of their machines that is on with the model, then the next
  • the ai.run.queued|succeeded|failed|cached events and the ai.preferences / ai.usage_summary
    user modules' AI side

Does not own

  • publication: every run is a draft/evidence package; Wiki, Blog, News, Live and the rest decide what is
    published
  • the recordings it analyses (OpenVibe.Media), identity (OpenVibe.Network), the products' prompts'
    meaning (each workflow's owner)

Depends on

  • PostgreSQL 18 (OpenVibe.Host roles/data/, ADR-035): every read and write is async through openvibe-sdk/db
  • OpenVibe.Network (JWKS, service tokens, SSO for the operator console, user modules)
  • OpenVibe.Events (the ai.run.* outbox relay, and the signed bench.rating.changed subscription; off without EVENTS_URL)
  • OpenVibe.Bench (bench.rating.changed and GET /api/v1/boards/:kind?type=elo|cost, for opted-in routes)
  • OpenVibe.Media (recordings media.analyze reads, over allow-listed https URLs or signed URLs)
  • provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp
    and the local model server openvibe-llm.service on this host
  • openvibe-contracts v0.421.0, openvibe-sdk v0.82.0 (service tokens, events outbox, redact),
    openvibe-shared v4.109.2, pinned by release tarball

Capabilities

Implemented here (the service manifest's capabilities, audience openvibe.ai; routes under
API): ai.run.create, ai.run.read, ai.workflow.manage, ai.provider.manage,
ai.usage.read, ai.usage.report (POST /api/v1/usage/calls: another product reports the AI calls it made
without AI, ai.call-report@1, and AI prices, dedupes and publishes each as ai.call.recorded@1),
ai.credential.manage (/api/v1/credentials/:subject[/:provider]),
ai.quota.attribution.manage (per-streamer quotas), ai.memory.read and ai.memory.write (Memory M2a,
/api/v1/memory/*), ai.me.read and ai.me.manage ("Your AI", /api/v1/me/*: a person's own keys and
routing preferences; only the person's own token or AI-site session) and ai.app.run (developer apps, ADR-014; public,
registered since openvibe-contracts 0.109.0), and ai.layout.suggest (POST /api/v1/ui/layout/suggest: a surface catalog,
the layout shown now and the person's words in, a validated ui.layout@1 with a one-sentence summary or an honest
refusal out; one retry with the validator's reasons, on the cheap structured-output route, as the person named in
X-OV-Subject). A service token's ns
claim limits which workflow namespaces it may run; an app token is fixed to its project's.

A first-party service may also open a batch of the network's own with the ai.batches.create grant
(see Batches): the batch is billed to the network's account, there is no Balance
session for it, and the run's attribution names the service. The grant is checked locally by the
batch service; the Contracts capability entry comes later (BA3).

Called elsewhere, as the service principal ai: events.event.publish (Events, the outbox relay) and
events.subscription.manage (Events, creating AI's own bench.rating.changed subscription), and
network.modules.read / network.modules.write for ai.preferences and ai.usage_summary
(Network). Other services grant ai their own draft capabilities (for example
blog.post.create, news.story.revise, reviews.summary.propose) to receive drafts.

Run it

npm install
cp .env.example .env        # no key needed: without one every route falls back to the stub provider
npm run dev                 # http://localhost:4700
npm test                    # stub provider, temp databases, local servers only — no network

Link 22 (CI pins 22.22.1). Callers need an OpenVibe.Network service token with audience
openvibe.ai; the Network public key is fetched from /api/.well-known/jwks (or pinned with
OV_NETWORK_PUBLIC_KEY).

API

POST /api/v1/runs?wait=10000
Authorization: Bearer <service token, aud openvibe.ai, cap ai.run.create>
Idempotency-Key: live-translate-8812

{ "workflow": "live.translate",
  "input": { "text": "みなさん、こんにちは", "from": "ja", "to": "en", "context": "chat" },
  "target": { "service": "live", "type": "chat_message", "id": "8812" },
  "attribution": { "service": "live", "type": "user", "id": "42" } }

answers 201 with the finished run (or 202 + Location while it is still running; 200 for an
idempotent replay):

{ "run": { "id": "run_01…", "status": "succeeded", "output": { "text": "Hello everyone", "unchanged": false },
  "synthetic": false, "provenance": { "origin": "ai", "workflow": "live.translate", "workflow_version": 1,
  "template_version": 1, "route": "live.chat", "route_version": 1, "provider": "shared", "model": "gpt-5-nano",
  "fallback_used": false, "run_id": "run_01…" }, "citations_count": 0, "…": "…" } }

| Route | Capability |
|---|---|
| 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 |
| POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed,image} — each a run of workflow ai.<op> | ai.run.create |
| POST /api/v1/research — a run of research.gather (MegaSource) | ai.run.create (namespace research.*) or ai.app.run (free sources only) |
| 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) |
| GET /api/v1/runs, GET /runs/:id (+ citations, request-log metadata), GET /runs/:id/citations | ai.run.read (own runs) |
| 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) |
| 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) |
| 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 |
| 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 |
| GET /api/v1/workflows\|templates\|routes[/:key] | ai.run.create, ai.workflow.manage or ai.usage.read |
| GET /api/v1/routes?operation=generate | the same read: every route's offers ({provider, model}), plus the operation's deduplicated offers |
| POST /api/v1/{templates,workflows,routes}/:key/versions, …/versions/:v/status | ai.workflow.manage |
| POST/PATCH /api/v1/providers…, …/disable\|enable\|reset, POST/PATCH /api/v1/models…, POST /api/v1/quotas, DELETE /api/v1/cache | ai.provider.manage |
| GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models | ai.usage.read |
| 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 |
| GET /api/v1/usage/series?days=N (N 1–90) — aggregate demand per UTC day and model: requests, successes and the four token totals, never a caller id or text (AI_USAGE_SERIES=on) | ai.usage.read |
| GET /api/health, GET /api/ready, GET /release.json | public |
| GET /metrics | direct loopback callers only (a request carrying X-Forwarded-For gets 404) |
| GET /console… | 301 to the same path on OpenVibe.Exchange |
| 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) |
| GET /auth/person/login\|callback | any Network person, for a run receipt |
| GET /robots.txt | public: disallows /console, /runs, /auth/, /api/ |
| 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) |

Browser reads of GET /stats and GET /release.json allow exact origins from AI_CORS_ORIGINS
(comma-separated; default https://ai.openvibe.services,https://openvibe.services). Their OPTIONS
preflight allows GET. Responses never allow credentials; /api/v1/*, /console and /auth/*
have no CORS access. Service callers do not need CORS.

/api/ready (openvibe-shared/ready) is 503 when the database, the active workflows or the Network
key fail; provider configuration (an active provider without its credentials, or with an open
circuit) degrades it. /metrics (openvibe-shared/metrics) carries golden signals by route template,
ai_runs{state="queued"|"running"}, ai_provider_circuit{provider,state} and the measured
ai_provider_circuit_open{provider,model,class} and ai_provider_failures_total{provider,class}.

Each HTTP request is aggregated by route template, method and status class, never by raw URL; /shared
files, static assets, health, readiness and /metrics are not counted. Once a minute one http.request
sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this
service's own telemetry_samples table (openvibe-sdk 0.46 telemetry, the platform.telemetry-sample@1
shape); rows older than 14 days are deleted daily. server/app.js mounts openvibe-sdk's
telemetryMiddleware (a no-op until init) and server/index.js calls telemetry.init() at production
start, runs the 14-day retention and flushes on stop. The table is created by migration
migrations/0009_telemetry.sql.

Errors are application/problem+json (openvibe-contracts http.problem): input.invalid (with the
schema errors), workflow.not_found, idempotency.conflict, quota.exceeded (429 +
Retry-After), govern.exceeded (429 + Retry-After: a tier budget) and govern.unavailable (503: Valkey
unreachable for a paid or staff run), queue.full (429 + Retry-After: the run would wait behind AI_MAX_QUEUED_RUNS
runs, or AI_MAX_QUEUED_RUNS_PER_CALLER of this caller's), capability.denied, capability.namespace_denied, token.*. A run that cannot
produce a real answer ends failed with an explicit code — ai.resting (503: every offer is out of balance,
paused or busy; the detail is the sentence products show, "AI is resting for a moment", with Retry-After),
provider.unavailable, route.unavailable, fetch.refused, source.unavailable, input.insufficient,
output.empty, output.invalid, run.interrupted — and never with filler content.

Each active provider with a balance API (DeepSeek's GET /user/balance) is asked hourly; the last reading is
ai_provider_balance_usd{provider} on /metrics and OpenVibe.Host's OpenVibeAiProviderBalanceLow alert turns a
reading below $2 into an operator ticket. The key is only sent in the request's Authorization header: it is never
logged, returned or stored by the probe. AI_BALANCE_PROBE=0 (or a restore drill, AI_DRILL=1) turns it off.

The capability ids and the ai service manifest are released in openvibe-contracts v0.76.0 (the
drafts stay in docs/capabilities-proposal/); server/auth.js checks them with the contracts grant
rule (exact id or family.*). A token's ns claim limits which
workflow namespaces it may run (live.*, wiki.*, …), and namespaces fail closed: a token with
no ns runs nothing. Every caller's Network grant names its namespaces (Live: live.*,
network.site_copy, media.analyze). The fallback for service tokens without ns (AI_NS_FALLBACK)
and its rollback lever (AI_NS_REQUIRED) were retired on 2026-09-28 (shims C-22 and C-23).

Batches

Many prompts in, the answers later. A batch is half the retail price for the customer, and it runs
where the money works out: on OpenVibe's own capacity and free providers, on a community offer that
asks at most half, on a paid provider inside a price-book off-peak window, or on a provider's own
batch API
when that provider's real batch price is at most half the retail card. The customer
batch therefore runs on a paid model at any hour, not only in an off-peak window.

The providers whose own batch API this service speaks, with the discount, the window and the limits
read on each provider's own documentation page (a provider whose batch API could not be read is not
listed and is never called):

| provider | adapter | batch price | window | limits |
|---|---|---|---|---|
| shared (OpenAI) | OpenAI-compatible | half the list price | 24 h | 50 000 requests, 200 MB, one model per file |
| qwen (DashScope) | OpenAI-compatible | half the real-time price | 24 h | 50 000 requests, 500 MB, one model per file |
| kimi (Moonshot) | OpenAI-compatible | 60% of the list price | 24 h | 10 000 requests, 100 MB, one model per file |
| deepinfra | OpenAI-compatible | 80% of the list price | 24 h | 10 000 requests, 100 MB, one model per file |
| anthropic | Message Batches | half the list price | 24 h | 100 000 requests, 256 MB (adapter written, not enabled) |
| 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) |

Only the providers whose batch price is at most half the retail card (shared, qwen) may carry a
customer batch: the customer is charged half, so the network's real cost has to fit inside that
half. Kimi and DeepInfra are below half and are therefore not eligible for a customer batch, though
the network's own batches may still use them.

How a native batch runs: an item whose run has latency_class = 'batch' waits in the queue
(batch_queue), grouped by provider and model. A group goes to the provider when it is full
(AI_BATCH_NATIVE_MAX_ITEMS, default 1000) or old (AI_BATCH_NATIVE_MAX_AGE_MS, default 5
minutes). One worker polls the batches the provider is working on (the claim is the database's and
expires on its own, so several workers are safe), writes every answer onto its own run (the status,
the output, the tokens and the cost at the provider's batch price) and finishes the run on the
ordinary path: the same settle, the same receipt, the same events. A deadline the provider cannot
meet re-routes the item to the ordinary (background or standard) path, and the item says so.

The money: the cost written on a run is what the provider really charged for the tier it really ran
on (its own list price times its own batch discount), computed in nano-USD. The customer is charged
half the retail card through Billing like every other request; this queue is a queue and a status,
never a second ledger.

The balance is checked when the batch is submitted, and the money is taken when the answer
arrives.
A run whose estimate does not fit the customer's Balance (or the project's budget) is
refused before the provider is given the work, so the network never owes a provider for a run nobody
can pay for; the item carries the reason. The hold itself is taken and settled when the answer comes
back, so a batch that waits hours does not hold the customer's Balance in the meantime.

A waiting batch run is durable. It waits in the database, not in the service's memory: the
service is deployed several times a day, and a restart leaves the waiting runs queued (the provider
is still working on them) instead of failing them. The batcher finishes them when the answer is
there, with the ordinary settle, the ordinary receipt and the ordinary events.

Service batches. A first-party service principal whose token carries the ai.batches.create
grant may open a batch of the network's own: it is billed to the network's account (there is no
Balance session for it) and the run's attribution names the service. The grant is checked locally by
the batch service (server/batch/service.js), because the pinned openvibe-contracts build does
not know that capability id yet: the Contracts capability entry comes later (BA3). Until then the
route's own guard still asks for ai.run.create (or an app's ai.app.run) as well.

Model catalog, exact ids and the disclosure

Behind AI_MODEL_CATALOG (off by default; with it off both GET /v1/models endpoints answer the legacy virtual
openvibe model and no disclosure is added; an exact id is still a pin, so it is refused with 400
ai.model_unavailable rather than run on another model). It applies to both POST /v1/chat/completions and
POST /v1/messages.

  • GET /v1/models / GET /v1/models/{id}: the registry's real models as ai.model@1 catalog entries
    (provider_key, model_key, id, family, variant, quant, revision, sha256, license, context, capabilities,
    modalities, retail rate in integer nano-USD, availability) plus the dynamic policy aliases openvibe/auto,
    openvibe/economy, openvibe/balanced, openvibe/fast, openvibe/private and legacy openvibe (= auto). An
    alias carries dynamic: true and its policy. Every entry also carries the response shape of the protocol it was
    asked in: OpenAI's object/created/owned_by, or Anthropic's type/display_name/created_at. An Anthropic
    client identifies itself with its anthropic-version header (or ?format=anthropic).
  • Exact ids: model may be <org>/<model>[:<quant>]@<revision> (for example
    qwen/qwen3-9b-abliterated:q4_k_m@3f2a1c9). It runs only that artifact. An artifact that does not exist is 404
    ai.model_not_found; one that exists but cannot run the request (no active deployment, unmet capability, down
    provider, or an offer that may not be resold) is 400 ai.model_unavailable. It is never substituted with another
    model, in fallback or otherwise. A row without artifact metadata has no exact id; it is still listed and reachable
    by its legacy provider/model id.
  • Aliases: the dynamic names resolve through the same provider placement. economy ranks price first, fast
    measured latency first, balanced weighs both, private never leaves local/own capacity, and auto keeps the
    existing task-aware router. User preferences (only_my_keys, free_first, own keys and machines) still apply.
  • The disclosure, inside x_openvibe: resolved_model, revision, deployment, provider_access,
    rate_version, policy and fallback_reason join the x_openvibe object responses already carry (run, provider,
    cost). There is no second object.
  • On a JSON response it is in the top-level x_openvibe.
  • On SSE it is the first line, as an SSE comment (: x_openvibe {...}), and it is on the final chunk
    (message_stop for Messages).
  • Commercial access: provider_access is a provider field (POST/PATCH /api/v1/providers, default UNVERIFIED).
    Paid managed routing uses only OWN_DEPLOYMENT and AUTHORIZED_WHOLESALE; RESTRICTED, UNVERIFIED and
    CUSTOMER_BYOK offers stay out of the shared pool. A person's own key or project provider is disclosed as
    CUSTOMER_BYOK and is unaffected.
  • Tokens: developer apps call with the public ai.app.run they already hold (ADR-014). The separate
    ai.inference.create once planned for this was retired (Contracts 0.285.0). ai.run.create remains the
    first-party service capability.

OpenAI-compatible endpoint

POST /v1/chat/completions and GET /v1/models (server/api/openai.js), so a client built for OpenAI's API (the SDKs,
coding agents, Codes' harnesses) points its base URL at https://ai.openvibe.services/v1.

  • Tokens: the same as the direct operations: an app's Network token (ai.app.run; its project pays and owns the
    run) or a first-party service's (ai.run.create). Network tokens last five minutes: a client refreshes it with the
    client-credentials grant (the SDKs take a token function).
  • Request: Chat Completions, as written: messages (system, developer, user, assistant with tool_calls, and tool
    results), tools, tool_choice, parallel_tool_calls, response_format, stop, seed, temperature, top_p,
    max_tokens or max_completion_tokens, stream. n must be 1. model may be anything: AI's router picks the
    upstream for default.chat by price and health, and a request with tools goes only to a provider that declares
    tools. The answer's model names the model that ran.
  • Answer: chat.completion with the upstream's choices and usage, plus x_openvibe (run_id, provider,
    fallback_used, cost_usd, the privacy label and the receipt id) and the X-OpenVibe-Run-Id header. The
    answer's receipt is in x-openvibe-receipt (compact JSON) and x-openvibe-receipt-id.
  • Streaming: stream: true answers server-sent events in the chunk format as the upstream streams them.
  • An OpenAI-format upstream (the router's, or the project's own) is asked with stream: true and
    stream_options.include_usage, and each delta goes out when it arrives: text, and a tool call's id, name and
    argument pieces.
  • Then come the finish reason, usage (with stream_options.include_usage), x_openvibe, and [DONE].
  • The run itself ends with the whole answer put back together, the upstream's usage (estimated when it sends none)
    and its cost, as without streaming.
  • Once a delta has gone out, a failure is final: an error event, then [DONE]. It is never retried or handed to a
    fallback whose words would follow the first one's.
  • An answer that came back in one piece (a replayed idempotent run) is sent as one delta.
  • Each request is a run of workflow ai.complete (step kind complete): the run's quota, free allowance, cost,
    audit and explain apply, and the prompt is not kept. Errors are OpenAI's shape ({ error: { message, type, code } }).
  • Upstreams: OpenAI-format providers (kind: openai) pass the request through (complete(); a reasoning model
    takes max_completion_tokens and its default sampling). Anthropic is not used for it yet.
  • An app's own provider (x_openvibe.provider, ADR-056). An app may name its project's own OpenAI-format API
    instead of the router: { "base_url": "https://api.openai.com/v1", "model": "gpt-5-mini", "secret": "openai-key" }.
  • secret is the name of a project secret (OpenVibe.Services → project → Secrets) that the project granted to ai
    for reveal to that base URL's origin.
  • OpenVibe.Network reveals the key for that one run, from the app's environment (sandbox or production). The key is
    sent to that origin only, through the public-address guard, and is never stored, cached or logged. The run keeps
    the secret's name.
  • The project pays its provider directly, so no tier budget holds the run (the app's quotas still count it). Tools,
    tool results and every other field pass through.
  • Refusals: a secret that does not exist or is not granted answers 403 project_secret.refused, a base URL that is
    not https answers 400, and a first-party service token naming a provider answers 403.
  • Production app runs that use the router are limited to free and local capacity (see Developer apps). Today that is
    a small local model without tools, so a coding agent's loop needs its project's own provider.

Presets, receipts, BYOK and the privacy label

The Exchange's routing profiles and the receipt of every answer (the plan of 2026-11-06, "Product lines
and features", item 1). server/exchange/ holds the four parts: presets.js, privacy.js,
constraints.js and receipts.js; server/api/exchange.js serves them and migrations/0031_exchange_presets_receipts.sql
holds them.

Presets

A preset is a named set of routing constraints. Five are built in — cheapest, fastest, private,
uncensored, eu-only — and they are code, not rows: nobody can overwrite or delete one (409
ai.preset_builtin). The others belong to an account (the person a token acts for) or to a Network
project (an app's project).

  • GET /api/v1/presets lists the presets the caller may name: the built-in ones and their own.
  • POST /api/v1/presets stores (or replaces) one of their own: { "name": "…", "policy": { … } }.
  • DELETE /api/v1/presets/{name} removes one of their own.

The policy's knobs: objective (cheapest, fastest, balanced, private), floor (fast,
balanced, strong, frontier), ceiling and max_price (US dollars per million tokens),
quantization, region, data_policy (private, anonymized, community), community (may a
community machine answer) and adult (the uncensored tier: the 18+ gate is the pool's own, never the
preset's — the preset only names the tier the request asks for). A knob nobody knows is dropped, never
stored, so a typo cannot come back as a rule nobody reads.

A request names a preset as the model "@preset/cheapest" or beside it as "preset": "cheapest"; it
may also write its profile inline ("preset": { "policy": { … } }) and cap the price on its own
("max_price": 0.42). A name nobody has is a 404 (ai.preset_not_found), never a silent default.

The rules filter the pool before the call: an offer that breaks one is out, and the receipt says
which rule removed it. A hard max_price that no offer fits is refused before anything is called, with
a problem document (ai.max_price_exceeded) that carries the cap, the lowest price on offer and the
offers that were excluded.

Receipts

Every answered request carries a receipt: the offer that answered, the price it charged, the tokens in
and out, the cache reads and writes, the latency, why the router chose it, the fallbacks it would have
used, who paid and the privacy label. It never carries the prompt or the answer.

  • In the answer: the header x-openvibe-receipt-id and x-openvibe-receipt (the same facts, compact
    JSON). A streamed answer names its receipt in the headers and carries the whole one in the last chunk.
  • On its own: GET /v1/receipts/{id} (and the documented GET /api/v1/receipts/{id}), readable by the
    caller that made the run, by the person it was for, and by whoever may read the usage
    (ai.usage.read). Anyone else gets a 404, not a refusal that says there is something there.
  • Kept with the usage: the receipt goes with its run (runs.receipt_id points at it) and is pruned
    with the runs' retention window.
BYOK and the privacy label

A run on the person's own key (BYOK) is metered at 0 %: the receipt says paid_by: own_key (or
own_machine), byok: true and fee_usd: 0. The OpenVibe key pays the list price plus the fee.

Every answer carries a privacy label — private, anonymized or community — read from the offer's
declared data policy: the person's own machine is private, an offer that keeps nothing and trains on
nothing is private, a community offer is community, and anything else is anonymized. The label is on the
receipt and in the answer's x_openvibe.

Memory

Memory M2a (spec research-2026-10-30/memory-m2-spec.md): a person's own notes, sealed with AI_MEMORY_KEY
(AES-256-GCM, AAD memory|<owner>|<id>|<scope>, m1:), deduped by an HMAC of the normalized text (HKDF-SHA256, info
memory-mac), and scoped by user:<usr_…> on every query. The server redacts every item again with
openvibe-sdk/redact (the Link's shapes and corpus) and refuses an item that is only a secret; what an agent writes is
a suggestion, and every item a service writes passes noLooks. An agent's search is active-only, at most 8 items, in
the scopes its token's grant names (user always; project:* when the grant lists it), and leaves a memory_reads
row with ids, never text. The profile cache rebuilds only when its items_hash moves, at most once an hour, in-process
on the service's own chat route (no run row, no person allowance). The account export decrypts the person's items for
their own export; a deletion removes the rows. Unset AI_MEMORY_KEY turns every memory route into 503
memory.unavailable; no default key exists.

Anthropic-compatible endpoint

POST /v1/messages and GET /v1/models (server/api/messages.js), so a client built for Anthropic's Messages API
(Claude Code, an SDK) points ANTHROPIC_BASE_URL at https://ai.openvibe.services and works (Codes C3).

  • Tokens: the same Network tokens as POST /v1/chat/completions: an app's (ai.app.run; its project pays and owns the
    run) or a first-party service's (ai.run.create).
  • Request: Anthropic's Messages API as written: messages, system, tools, tool_choice, stop_sequences,
    temperature, top_p, top_k, max_tokens, metadata, stream.
  • The person's own key: x-openvibe-credential: <provider> uses that person's credential for that provider
    (anthropic, deepseek, minimax, openrouter or a custom endpoint) instead of the router. The person is
    <provider>:<subject>, or the token's on_behalf_of (an agent acting for them). Without the header the router picks an
    upstream that speaks Anthropic's format (Anthropic, DeepSeek's and MiniMax's Anthropic-compatible endpoints); an
    OpenAI-format provider is never converted.
  • Answer: a message (id, role, model, content, stop_reason, usage), and the run id in
    X-OpenVibe-Run-Id. The answer's receipt is in x-openvibe-receipt (compact JSON) and
    x-openvibe-receipt-id.
  • Streaming: stream: true answers Anthropic's own server-sent events as they arrive (message_start,
    content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, ping); the run
    still ends with the whole message.
  • Each request is a run of workflow ai.messages (step kind messages): the run's quota, free allowance, cost,
    audit and explain apply, and the prompt is not kept. Errors are Anthropic's shape
    ({ "type": "error", "error": { "type", "message" } }).

Developer apps

Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (POST /oauth/token,
grant_type=client_credentials with its app_<ULID> client, audience=openvibe.ai): sub app:app_<ULID>, project_id prj_<ULID>, env sandbox|production, ns [project_id, app.<project_id>.*], five minutes. AI accepts one capability for it, ai.app.run (public). A
sandbox app holds it without asking (Network's default sandbox allowance); a production app holds it
once OpenVibe staff add it to the project's allowance. Create the project and its app at
openvibe.services/projects:

| Route | What |
|---|---|
| POST /api/v1/{chat,generate,summarize,classify,extract,embed,image} | the direct operations; enrich is first-party only |
| GET /api/v1/runs/:id | the app's own runs (404 for another project's) |

Every other route refuses an app token (403 capability.denied), whatever its grant carries. An
app's runs are its project's: the run's requester is project:prj_<ULID> and its attribution is
the project EntityRef {service: 'network', type: 'project', id: 'prj_<ULID>'}, so two apps of one
project share their runs, and the project is the budget unit — the free allowance's subject is the
attribution (network:project:prj_<ULID>), the tier budgets (Providers) name the
project (project:prj_<ULID>), and a quota can cap it on the service scope id project:prj_<ULID>.

Billing. Every run's platform.usage-sample@1 readings carry the app's project
(prj_<ULID>) with subject project:prj_<ULID>, so Billing bills the project's use: the project's
bounded free allowance is claimed first (free_allowance_used on the reading), the rest is priced
under the project's tier budget (AI_GOVERN_TIERS, keyed project:prj_<ULID>). Free and local
capacity only
(the stub, the local model, a free server or a pooled subscription — never a metered
provider) for every sandbox run, and for every app run while no tier budget governs its project
(AI_GOVERN_TIERS off): metered capacity is only ever spent under a project budget.

Workflows. An app runs the six direct operations (the fixed ai.* workflows) and nothing else:
POST /api/v1/runs, which takes a workflow by key, stays first-party, and the workflow check refuses
anything outside ai.* and the app's own app.<project_key>.* (project_key = p + the project's
ULID in lowercase, Events' rule for app.<project_key>.<name> event types). An app never reads another
project's runs and never uses a person's BYO provider key: the byok provider profile is never
offered to an app run, and credential on its request is refused.

AI=https://ai.openvibe.services
APP=app_01JAB…                      # the app's client id; SECRET is its client secret
TOKEN=$(curl -s -X POST https://openvibe.network/oauth/token \
  -d grant_type=client_credentials -d client_id=$APP -d client_secret=$SECRET \
  -d audience=openvibe.ai | jq -r .access_token)
curl -s -X POST "$AI/api/v1/chat" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{ "messages": [ { "role": "user", "content": "hello" } ] }' | jq .run.provenance
curl -s "$AI/api/v1/runs/$RUN" -H "Authorization: Bearer $TOKEN" | jq .run.status

A sandbox token is issued only for an audience that opted in (Network's DEV_SANDBOX_AUDIENCES);
AI takes one on these routes alone, and every other route refuses env: sandbox with 401
token.sandbox_refused, as before.

Operator console

The owner-only AI operator console is on OpenVibe.Exchange. 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.

A person's provider keys and supported browser session cookies are managed at Your keys and accounts. AI's /api/v1/me/credentials endpoints remain person-authenticated JSON APIs; list and balance responses contain metadata only, never secret values.

Run receipts

GET /runs/:id is the person-facing page the Action Center's AI item links to (plan 2026-10-28
§3.10). It shows one run's status, workflow, model, cost, created/started/finished times and the
output or the error — never the stored input, the request log, the trace or another person's run.
It is server-rendered with the console's classes and CSP (no script, no form, no external resource),
noindex, no-store, Referrer-Policy: no-referrer.

A person signs in through the same Network OAuth client and callback as the console, but
GET /auth/person/login marks the flow as a person flow: the callback stores a read-only session
(__Host-ovai_person, host-only, HttpOnly, Secure, SameSite=Lax, AI_PERSON_SESSION_TTL_MIN = 24 h;
only its hash in person_sessions) and redirects only to the exact /runs/run_<ULID> page asked
for. It grants no capability and never opens /console. The page shows a run only when the run's
on_behalf_of is that session's usr_… subject; any other run is a 404. GET /api/v1/actions for
the Action Center is separate: a first-party service token with ai.actions.read names the person.

Provider keys added from the console (vault:)

A provider the owner adds from the console keeps its key in the ai.provider_keys namespace of the configuration
store (D11, server/settings.js, migrations/0028_config.sql). For each provider <p>.origin is the origin of its
base URL and <p>.key is a write-only secret sealed under OV_CONFIG_KEKS and x-bind'ed to <p>.origin; changing the
base URL locks the key until it is entered again. The provider's secret_ref is vault:<provider key>, which
util.resolveSecret reads from this process's cache (refreshed by the store's onActivate; providers resolve secrets
synchronously). Only a four-character hint is ever shown, and audit rows say a key was replaced, never what it is.
Without a KEK the store still works and reports "sealed": false. At boot, when AI_CREDENTIALS_KEY and
OV_CONFIG_KEKS are both set, the old provider_secrets rows (migration 0010_provider_secrets.sql) are opened,
written as sealed <p>.key values bound to their current origin, read back and only then deleted; counts are logged,
never a value.

The eleven record groups

| Group | Table(s) |
|---|---|
| Providers | providers, provider_circuits (per provider+model breaker), provider_failure_counts, provider_health (roll-up) — secrets only as env:NAME references |
| Models | models (type, limits, cost per Mtok, JSON/tools/streaming/vision support) |
| Routing profiles | routes — versioned; primary + fallbacks, options, output limit, format, timeout, explicit aliases |
| Prompt templates | templates — versioned; input/output schema, system/user prompt, default route, owner, visibility, draft\|active\|deprecated\|archived |
| Workflow definitions | workflows — versioned; namespace, input/output schema, steps, default route, cache mode |
| 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 |
| 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 |
| Sources/citations | citations |
| Cache | cache_entries — scoped (below) |
| Quotas/usage | quotas, usage_counters, usage_daily |
| Audit | audit_log — every run create/cancel/retry, every provider/model/route/template/workflow/quota change, circuit changes, fallbacks |

Edits to templates, workflows and routes always create a new version; old versions stay readable
and every run records the versions it used. Boot seeding adds a new version when the code changes a
definition, but never over an admin's version.

Providers

| Kind | What | Configured by |
|---|---|---|
| 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 |
| 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 |
| 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 |
| 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) |
| anthropic | Messages API with cached system blocks and forced-tool JSON. Ported from Live's llm.js. | same, with AI_PROVIDER=anthropic |
| http | The local HTTP seam: POST {operation, …} → {text, json, usage} | AI_HTTP_SEAM_URL or the admin API |
| 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 |
| 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) |
| 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 |

Routing: a route either names a capability and the router builds its pool from every provider that
can serve it, ordered by openvibe-sdk/placement (objective, price, measured latency, health, with
hysteresis through the route's current placement), or pins a [primary, ...fallbacks] list. A
provider that is disabled, missing credentials, circuit-open or unable to do the operation is
skipped and logged; every call has a strict timeout, cancellation and one retry on a transient
failure. A failure is classified as balance (402, insufficient_quota, "credit balance is too low",
OpenRouter's 402 credits), auth (401/403), rate_limit (429), transient (timeout, 5xx, broken
stream) or request (400/413/422 and context too long). The circuit is per provider+model
(provider_circuits, migrations/0060, survives a restart): balance and auth open it at once and are
never retried; a run of transient failures opens it for 30 s doubling to 10 min; rate limits and
malformed requests never open it. A balance/auth circuit may probe the next real request after
AI_BREAKER_EXHAUSTED_COOLDOWN_MS (15 minutes) and closes on a success or when the provider's key
changes. The catalogue reports up, degraded (over 5% of at least 10 requests in the last hour) or
down, with status_reason, status_since and status_class. When nothing can serve, the run answers ai.resting; a fallback that answers is
recorded on the run (fallback_used), in the request log
(fallback = 1) and in the audit log. Every run response and GET /api/v1/runs/:id carry explain
(objective, reasons, selected, and every candidate with its estimated cost, latency and excluded
reason).

Bench evidence (BENCH-11, plan §4.12 and §4.7)

AI consumes OpenVibe.Bench's bench.rating.changed (through the signed /internal/events webhook, subscribed
at boot with events.subscription.manage) and keeps a small bench_evidence table: per kind and category, the
model, provider, Elo rating, confidence interval, games and Bench's cost per solved. A sparse event (Bench's
current outbox names only subjects) is filled from GET /api/v1/boards/:kind?type=elo|cost.

A route learns from that only when it opts in — learn_from_bench: true on the route, default off; bench_kind
names the board (when absent AI infers it from the route key or operation, e.g. chat.reply), and
bench_category defaults to all. Among the route's eligible offers, placement then prefers the cheapest
offer whose rating interval overlaps the leader's (the highest non-provisional rating): statistically tied with
the best, so price decides. A provisional rating (under 10 games) never decides. With no evidence the order is
exactly the one placement produced. GET /api/v1/routes/:key carries a bench object with the evidence, the
leader, the tied offers, the selected offer and the reason; the operator console shows the same read-only.

GET /api/v1/routes?operation=generate lists each route's eligible offers as { provider, model }.
A first-party service holding ai.run.create may pin one on POST /api/v1/generate (and on
POST /api/v1/runs for ai.generate) with "route": { "provider": …, "model": … }. The run tries
that offer alone, never a fallback; a provider/model the routing does not list for generate is
422 input.invalid in words, and the caller's ns is still checked first (a workflow outside it is
403 capability.namespace_denied). Developer-app tokens (ai.app.run) cannot pin. Quotas, tier budgets and Billing readings are exactly as for an unpinned
run, and a pinned run is never served from the shared cache.

GET /api/v1/runs/:id adds explain.usage_readings when the run has a Billing reading: per
reading its idempotency_key, state (queued until Billing accepts it, sent, or failed when Billing
refused it), attempts, queued_at, sent_at, rejected_at, next_attempt_at, last_error (the
outbox's last send error, also while a queued reading is retried) and free_allowance_used when some
Balance (AI Exchange F; AI_CREDITS, off). A request on a paid managed route — the model catalog's
provider_access is OWN_DEPLOYMENT or AUTHORIZED_WHOLESALE and the offer is metered — reserves its
worst case in OpenVibe.Billing before any provider is called: the input estimate plus max_tokens of output
at the model's retail card (ai.model@1 retail_rate). Who pays is the payer's own Balance (D12): an app's
run holds against its project's Balance, with the hold's subject {type: "app", id} and its project_id, so
no Network owner lookup is made; a person's own run holds against their personal Balance, subject
{type: "user", id} and no project_id. A first-party service with no payer (no person and no app) is never
served a paid offer that carries a versioned card (payer_required); offers without a card behave as before.
An exact id whose only offer is skipped that way is 402 billing.payer_required.

max_cost_usd is parsed as an exact decimal (up to 9 fractional digits, finer digits round up; exponent forms
are 422) into integer nano-USD. When it binds, the affordable output is computed in BigInt from nano-USD per
million tokens and the provider receives that clamped max_tokens; fewer than one affordable token is 402
before the provider. A pricier managed fallback extends the hold by the difference (clamped the same way); if
Billing refuses the extension the offer is skipped as hold_not_extended. A stream extends the hold in steps
when the output nears it.

The hold's idempotency key is the request id; its fingerprint covers the resolved model, the token caps, the
rate version, the payer and (for an app) the project. Billing's 402 billing.insufficient_funds becomes AI's
own 402 Not enough Balance; any other Billing failure refuses the run with 503 billing.unavailable rather
than serving unpaid capacity. At the end one settle carries the four token counts, the answering model's retail
card version, the supply cost and every attempt in billing.credit-hold-settle-request@1's exact shape
(deployment_id, provider_access, start/end, error_code); a retried request replays the same settlement
instead of charging twice. A settle Billing cannot take is kept in credit_settle_queue (migration 0025) and
retried with backoff and the same key; a 409 or 422 marks it review, never a retry. A failure before the
first token releases the hold, an abort settles the partial usage. BYOK — a person's key, their machine, a project's own provider — is never held and
never charged; Billing is not called at all. A sandbox app token is never offered paid supply (D10), so it
spends no Balance and never opens a hold. Requires AI_MODEL_CATALOG for the access labels; with either
flag off nothing changes. Code: server/credits.js, server/credit-settlements.js; the fake-Billing tests are
test/credits.test.js and test/credits-run.test.js.

Speech and embeddings use placement pools of registered stt and embedding models; until
a second upstream exists, an unavailable sole provider answers 503 provider.unavailable. Media
uses separate local and paid pools so paid work still requires its explicit budget. The synthetic
stub can join general text routes as a last resort outside production only (AI_STUB_FALLBACK).

Free allowance (§2.1.8): a rate card's free_allowance (tokens per metric, from a models row or
AI_PRICING_JSON) is free provider capacity for each subject (the run's attribution, else its
actor, else its requester) per provider, metric and reset_period (day and month reset at the UTC
boundary; none never resets for the card's terms). An accounted run claims it first, atomically, in
the same transaction as its usage (free_allowance_usage, server/free-allowance.js); the free tokens
are the reading's free_allowance_used on its platform.usage-sample@1, and its cost_estimate leaves
their price out. It is pricing, never admission: quotas, request counting and the run's own cost are
unchanged, and no call is ever refused because the allowance is used up. Every card defaults to 0, so
nothing is free and the field is absent until an operator configures an allowance.

Free-use sources and price ceiling (AI_FREE_PROVIDERS, default deepseek,local,community;
AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, default 1.5): a run that nobody pays for — the free allowance
(a first-party service naming a person, without that person's own token) or the Exchange's anonymous Try
it — may use only offers from those sources and at or below this output price per million tokens. A
source is a provider key; local covers every local model server (local, local2, whisper.cpp) and
community the community machines. The design funds free use from the owner's DeepSeek key, local models
and volunteers, never from Live's shared OpenAI key. The router skips the rest and answers
402 billing.payer_required when every capable offer is outside those rules. A person's own token (and so
their Balance, AI #311), their own provider key, an app's project and a service's own run-token budget are
not capped this way. The check is admission, not pricing: it never changes a price or a Balance reading.
The Exchange catalog marks each offer free_tier by the same two rules (distinct from free, which means
prepaid capacity with no per-token price).

Abuse prevention for the free tier (plan E5; server/abuse/, docs/abuse-prevention.md, privacy note
docs/privacy.md): the daily free allowance is AI_FREE_DAILY_TOKENS (default 20,000) scaled by trust — ¼ for a
new or unvouched account, ½ after 7 days, full when Network says a passkey, a linked account or 30 days with
activity verifies it, plus up to one extra full for a volunteer's kudos. A hosting/data-centre ASN is always
the new tier and a lower rate; the address is looked up in a local monthly iptoasn.com table and only the ASN and
an HMAC of its /24–/48 are kept, for 24 h. A signed HttpOnly device cookie counts accounts per device, and
more than three accounts on one device share one allowance at the lowest tier. The anonymous Try it needs one
in-house hashcash proof of work (POST /api/v1/exchange/tryit/challenge). Every finished free run feeds a
per-minute velocity check per ASN, device and input bucket against the last week's baseline; above it the run is
429 ai.abuse_throttled and an abuse.throttled row goes to /console/abuse and the
ai_abuse_throttles gauge (OpenVibe.Host alerts on it). No raw IP, device id or prompt is stored.

Tier budgets (T6 step 8, decision D5; server/govern.js, off unless AI_GOVERN_TIERS=1): the SaaS tiers
free, paid and staff on openvibe-sdk/govern, counted in Valkey. Before a run touches a provider
(and before reserve() counts it), it holds an estimate of the units ai-token and ai-usd against two
budgets: its subject (attribution, else actor, else requester) inside its project (the calling
product, e.g. service:live), and the project as a whole. Each hold is one atomic step, so two runs
never both take the last unit; a hold that does not fit refuses the run with govern.exceeded (429 +
Retry-After). When the run is accounted the hold becomes its real tokens and its priced cost (the free
allowance's share left out, so a fully free run spends no ai-usd); a run that spent nothing gives it back.
Like the quotas, a run that spends more than its hold bounds the next run, not itself. A subject's tier is
its own AI_GOVERN_TIER_MAP entry, else its project's, else AI_GOVERN_DEFAULT_TIER. With the tiers on,
the free allowance's counter lives in the same Valkey (same numbers, one atomic claim per metric);
free_allowance_usage keeps its total for the console and is the floor a lost key restarts from. Each
platform.usage-sample@1 comes from the onUsage record of an in-process govern meter, with the same
idempotency key (ai:<run>:tokens), whether the tiers are on or off. Valkey unreachable: a run with
any budget on paid or staff is refused with govern.unavailable (503, fail closed); an all-free run
is admitted on the PostgreSQL quotas alone (today's behaviour) and its tokens are priced in full (nothing is
free while the counter cannot be read). Reads never touch the tiers. With the flag off nothing here runs and
the service boots without Valkey; with it on, production refuses to boot without VALKEY_URL.

| Setting | Default | Meaning |
|---|---|---|
| AI_GOVERN_TIERS | 0 | 1 turns the tier budgets on |
| 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 |
| AI_GOVERN_TIER_MAP | empty | subject=tier pairs, comma separated (service:live=paid,user:usr_1=staff) |
| AI_GOVERN_DEFAULT_TIER | free | the tier of a subject or project not in the map |
| AI_GOVERN_HOLD_TOKENS, AI_GOVERN_HOLD_USD | 4000, 0.02 | the estimate a run holds before its provider call |
| AI_GOVERN_TIMEOUT_MS | 1000 | longest wait for Valkey before it counts as unreachable |
| VALKEY_URL, VALKEY_PREFIX | empty | the shared Valkey (ADR-035) the counters live in |

The living price book

The price the router charges and the Exchange shows is read from the provider that sells it, and from nowhere
else. A price nobody publishes stays unknown: it is never guessed, never averaged, never taken from an
aggregator.

Once a week (plan E1, 2026-10-30 §8) a job reads each catalogued provider's own model and price listing —
DeepSeek's price page, Alibaba Model Studio's price table, Kimi's pricing page, Z.ai's and MiniMax's price
pages, DeepInfra's, Novita's, Featherless', Refuseless' and Abliteration.ai's listings — through the same egress
guard the rest of the platform uses: https only, a public host, never an IP literal, a timeout and a size cap.
OpenRouter's public model list is read as well, but only as a second opinion; no price ever comes from it.

A price never changes silently. Every change lands in the price_changes log with the old price, the new
price, the listing it was read from and the day it was read. A change of more than 20% waits there for the
owner, who approves or rejects it on the operator console (price book, ai.provider.manage); a
smaller one the job applies at once, and the model row carries the new price with the day it was checked. A
listing that cannot be read leaves every price alone: an unreadable page is not evidence of a change. A price a
person typed by hand is never touched.

A reviewed reading can also be put in front of the owner without waiting for the weekly job:
POST /api/v1/admin/console/pricebook/proposals (owner-only, ai.provider.manage) takes the same readings
scripts/propose-pricebook.js reads from a JSON file. Both write pending changes only — the source listing and
the day it was read travel with each row — and a model the registry does not know is created switched off with no
price, so there is a row to approve against. Nothing is applied until the owner approves it.

The book is due-based, not timer-based: AI is deployed several times a day, so a timer in the process
would be reset every time and the week would never come round. The last finished run is a row in the database
(price_book_state, migration 0037) and the service asks once an hour whether the interval has passed since
then; the first ask is a few minutes after a boot, so the boot path stays free. AI runs several workers, so a
run is claimed before it starts: only one of them reads the listings at a time, and the claim expires on its own
if a worker dies.

The catalog says when a price was checked (price_checked_at, price_checked), and
GET /api/v1/exchange/price-changes is the changelog Exchange renders: what a price was, what it became, the
listing it was read from and the day it was read.

| Setting | Default | Meaning |
|---|---|---|
| AI_PRICE_BOOK | on (off in test mode, off under AI_DRILL=1) | the weekly job |
| AI_PRICE_BOOK_INTERVAL_MS | 604800000 (a week) | how long between two runs |
| 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 |
| AI_PRICE_BOOK_CLAIM_TTL_MS | 900000 | how long a claimed run holds the claim |
| AI_PRICE_BOOK_THRESHOLD_PCT | 20 | a change of more than this waits for the owner |
| AI_PRICE_BOOK_TIMEOUT_MS, AI_PRICE_BOOK_MAX_BYTES | 20000, 8 MiB | the guard's timeout and size cap |

The tests never touch the network: the listings are recorded (test/fixtures/pricebook/, written by
scripts/record-price-book.js) and replayed through the reader's transport seam.

Webhooks and the MCP server

The Exchange's developer tools (the plan of 2026-11-06, "For builders", item 7): openvibe-sdk/exchange has
verified webhooks since 0.91.0; server/exchange/webhooks.js is the sender, and server/mcp/exchange.js the MCP
server.

Webhooks

A person's token or a developer app registers an https receiver on the Exchange API:

| Route | What |
|---|---|
| POST /api/v1/exchange/webhooks | {url, events?, description?} → 201 {webhook}; the signing secret (whsec_…) is in this answer only |
| GET /api/v1/exchange/webhooks | the caller's receivers (never a secret) |
| DELETE /api/v1/exchange/webhooks/{id} | remove one and its delivery history |
| GET /api/v1/exchange/webhooks/{id}/deliveries | the delivery log: event, attempts, status, the receiver's answer and the payload |

Three events: usage (an answered run: the tokens, the money and who paid), earnings (a community offer earned
credits or kudos) and offer.down (an endpoint offer was removed). Every delivery carries
X-OpenVibe-Signature-V2: t=<unix seconds>, v2=<hex HMAC-SHA256 of "<t>.<raw body>"> — exactly what
require('openvibe-sdk/exchange').verifyWebhook(rawBody, header, secret) checks — plus X-OpenVibe-Event,
X-OpenVibe-Delivery and X-OpenVibe-Webhook-Id. A payload never carries a prompt or an answer.

The receiver URL goes through the same egress guard as a community endpoint: https, a public host name, never an IP
literal, port 443 or 8443, no query or fragment, and the DNS answer is judged inside the connection. A delivery is a
row, not a call in the request that caused it: a 408/425/429/5xx or a network failure is retried with an exponential
backoff (AI_WEBHOOK_BASE_DELAY_MS, up to AI_WEBHOOK_MAX_DELAY_MS, AI_WEBHOOK_MAX_ATTEMPTS); any other non-2xx
fails it at once. The worker is off in test mode and in NODE_ENV=test.

The MCP server

bin/openvibe-exchange-mcp.js speaks MCP over stdio in the repo that owns the Exchange API:

{
  "mcpServers": {
    "openvibe-exchange": {
      "command": "openvibe-exchange-mcp",
      "env": { "OPENVIBE_EXCHANGE_TOKEN": "your Network developer key" }
    }
  }
}

The tools are exchange_models (the live catalog: price in/out per million, measured p50/p95, uptime, health and the
data policy), exchange_chat (one completion on the person's allowance, or their own stored provider key when
credential names a provider) and exchange_usage (the person's own requests, tokens and spend by day and model,
from GET /api/v1/exchange/me/usage). exchange_models is public; the other two refuse a call without a token
before any request, and a 401 from the Exchange is refused back as the tool's error. The token is never printed,
logged or stored.

The CLI

openvibe-link exchange serve --model qwen3:32b --public-url https://…/v1 (OpenVibe Link) serves the local model
through an OpenAI-compatible listener and registers it on the existing endpoint-seller API with the person's
OPENVIBE_DEVELOPER_KEY; Ctrl-C removes the offer. See that repo's README.

Workflows

  • Seed product workflows — wiki.generate_space, wiki.generate_page (both on route wiki.write, see MegaSource), blog.draft_post,
    news.summarize_story, news.compare_perspectives, reviews.summarize_entity,
    deals.enrich_deal, coupons.extract_coupon, trade.summarize_market_context,
    codes.generate_docs, games.generate_lore, moderation.classify, food.suggest_recipes,
    rent.screen_listing (a rental listing's scam signs for Rent's staff: allow or review, quoting the words). They take numbered
    sources, return citations (indices the engine bounds to the sources given) and gaps, and
    never invent facts: Reviews has no rating field, a Deals price is null unless cited, a Coupons
    expiry stays unknown unless stated, Trade carries its informational-only disclaimer. The
    historical route keys (wiki.generate, news.summarize, …) are explicit aliases of default.json.
    blog.write_digest writes an OpenVibe.Blog desk's digest from its sources' items, every section cited.
  • Satire — blog.write_satire (Blog's "Is This Real?" desk) is invented on purpose and takes no sources: absurd
    mock-explainers that never name or quote a real person or put words in a real organisation's mouth, never joke about
    tragedies, minors or groups of people, and never give steps for anything dangerous. Blog labels each post as satire
    written by AI.
  • Live's features — live.translate, live.paste.describe_image, live.paste.summarize_text,
    live.stream.describe_frame, live.stream.summarize, live.streamer.overview,
    live.media.overview, live.stream.recap, live.media.transcribe, network.site_copy
    (prompts moved verbatim), plus passthrough workflows for the features whose prompts Live still
    renders (live.viewers.*, live.chat.insight, live.moments.*, live.hero.slogans,
    live.arena.*, …). See docs/migration.md.
  • Media — media.analyze (roadmap WS-O task 5), local-first analysis of a VOD, clip or audio file by media_url
    or MediaRef, fetched like any media input (allow-listed hosts) into media-tmp beside the database
    (AI_MEDIA_ANALYSIS_DIR; never /tmp, which is memory on the production host). The file cap is
    AI_MEDIA_ANALYSIS_MAX_BYTES (4 GB), also bounded by that disk's free space minus
    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:
    1. Signals, one FFmpeg pass with video decoded at keyframes only: scene changes (scdet, threshold
    AI_MEDIA_SCENE_THRESHOLD), black and frozen picture, silence, and loudness. Loudness has the integrated level
    and range, the typical level (the median audible second) and loud moments at least 6 LU above it.
    2. Scenes from the scene changes (at most 300).
    3. Speech: whisper.cpp through live.stt, with another registered STT model eligible for fallback.
    4. Highlights: windows scored by loud, scene changes and speech, each with its reasons and transcript excerpt.
    5. An overview from a local model (media.local), else extractive from the measured facts. A paid provider
    (media.paid, including the shared provider) writes it only with allow_paid: true and an active quota with
    workflow_prefix media.paid and max_cost_usd > 0, while today's media.* spend is under it. That quota
    gates only this call, never whole runs.
  • Chat's AI members ([server/workflows/chat.js](server/workflows/chat.js)): chat.member.reply (namespace chat, route
    chat.member, an alias of default.chat, text out). A room on OpenVibe.Chat adds an AI member with a name and how
    it should be; when someone names it, Chat sends the recent messages, the room's notes and any shared context a linked
    service gave the room (Food: a household's pantry and plan), and posts the reply. The prompt forbids inventing facts
    about the people or their things and claiming actions beyond writing in the chat.
    chat.member.turn is the same member with tools: a Chat Completions request (Chat's system message, the transcript,
    the tools Chat runs) passed through like ai.complete (the prompt is not kept) on route chat.member, so Chat's grant
    and quota apply and a person's own key reaches only their provider; the choices come back with any tool calls, and
    Chat sends the results in its next turn.
    chat.vibe.listen is the Dock's voice on browsers without speech recognition: Chat sends a short recording
    (audio/webm, audio/ogg or audio/mp4, at most 1 MB) inline as a data URL, the existing transcribe step runs it on
    route live.stt (whisper.cpp/Parakeet), and the text comes back. The input is never retained (retain_input: false); the engine writes it to a private temp file and deletes it in the same call.
  • Space's spaces ([server/workflows/space.js](server/workflows/space.js), namespace space): space.app.build
    is the AI builder of OpenVibe.Space. From what a person asks for (and a space's current files when changing one),
    it returns a working space: name, description, the instructions its AI follows, the SDK features it uses, and up to
    six files with an index.html. Paths are relative and safe, and an answer without a real page is no answer. It runs
    on route space.build (the creative model, 180 s, 16,000 output tokens), on behalf of the builder. space.app.ask
    is a space's own AI (OV.ai.ask in the space SDK): the maker's instructions under the platform's rules, on route
    space.ask (an alias of default.chat), on behalf of the visitor, so it counts against their daily allowance.

The builder's prompt can carry OpenVibe.Design's public design.component@1 registry in a components input
(Space reads and caches it): the definitions are then the interface vocabulary and the answer marks each major
part with data-ov-component. overview.source says which (local_model, paid, extractive), and gaps
lists what is missing (no local model, no speech-to-text, no budget). The output is not cached: callers keep it.
Live's grant names media.analyze.

  • Direct operations — ai.chat|generate|summarize|classify|extract|enrich|embed|image.

MegaSource research

research.gather (namespace research, server/research/, server/workflows/research.js) answers one question from
many sources at once and returns one ranked, de-duplicated evidence pack the product workflows cite by number. No model
is called; a run is recorded like a provider call (provider research, model megasource), so the web route's per-call
price counts against the cost quotas and the daily usage.

| Source | What it gives | Terms |
|---|---|---|
| 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 |
| 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 |
| Wikidata | the top article's key statements as labelled facts, and its picture | CC0 |
| 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 |
| 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 |
| 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 |

Input: query (required), topic (the subject it belongs to: finds the topic's wiki), focus (one subject inside the
topic), lang, max_sources (≤ 20), max_images (≤ 24), content_chars (≤ 12,000), providers (a subset). Output:
sources (each with url, title, content, published_at, retrieved_at and provenance.provider|site|via|license),
images (each with license, free, author, source_url, provider), facts, topic_wiki, providers (per route:
ok, ms, count, cost, or why it was skipped or failed) and usage (requests, DeepSeek calls, fabric calls and the fabric's
search_fabric_cost_usd; cost_usd is this run's own DeepSeek calls, so a paid fabric step is not billed twice —
Search and its nested AI run pay it). Cached for a day per calling service.

Web search asks the Search Fabric first, as AI's own service principal (audience openvibe.search). Search's viewer
requires search.query.delegate of a service token, so Network must grant ai that capability on openvibe.search
(the route's search.fabric.query is public). The token is requested with exactly that scope; without
OV_OAUTH_CLIENT_SECRET or with AI_RESEARCH_SEARCH=0 the fabric is skipped. When Search's own web ladder calls this
endpoint for DeepSeek or Wikipedia, research keeps DeepSeek direct, so Search is never asked back in a loop. DeepSeek is
used only when its key resolves (AI_RESEARCH_DEEPSEEK_KEY_REF, default env:DEEPSEEK_API_KEY;
AI_RESEARCH_DEEPSEEK=0 turns it off), only for first-party callers (a developer app's run reads the free sources),
within AI_RESEARCH_DEEPSEEK_DAILY calls a day (1500), and it stands down on its own answers: 402 (balance used up) for
30 minutes, 401 for an hour, 429 for a minute. Tests read a canned web (test/fixtures/research-fetch.js); none reaches
the internet.

OpenVibe.Wiki's "vibe a wiki" uses it with wiki.generate_space (plans the pages: main page first, each with a kind,
an outline and a search phrase) and wiki.generate_page (writes one page: lead, sections, infobox, [[links]] to the
planned pages, and up to three of the numbered pictures by heading), both on route wiki.write (the creative model,
7,000 output tokens, 170 s, past the provider's chat timeout).

Image generation

Off unless AI_IMAGE_MODEL names the shared provider's image model (dall-e-3, gpt-image-1; OpenAI-shaped
providers only). Then boot gives shared the image capability, registers the model as type image and seeds
route default.image (a capability pool of registered image models, objective correctness,
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
503 route.unavailable, like any operation whose route is not configured.

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;
size defaults to AI_IMAGE_SIZE, 1024x1024) and answers output: { images: [{ b64, url }], count }
(dall-e-* is asked for b64_json; gpt-image-1 answers base64 already). A missing prompt is
422 input.invalid, a provider that returns no image 502 output.invalid, no provider 503 provider.unavailable.
Outside production the stub answers a 1×1 PNG as the last resort (AI_STUB_FALLBACK).

Images are metered per image, never per token: the request log's operation is image and tokens_out the
image count, and each attempt is one platform.usage-sample@1 reading (resource output-images:<model>,
unit image, quantity the images returned), queued like every other reading. No rate card prices an image
yet (an owner/T5 item, never AI-written), so an image costs 0 in AI's usage and cost quotas; Billing stores the
reading unrated until a per-image card exists and its unitFits() accepts image.

Local model

deploy/systemd/openvibe-llm.service runs llama.cpp's llama-server on 127.0.0.1:8090 (OpenAI API, no key),
which AI reaches as the provider local and the route media.local (AI_LOCAL_LLM_URL=http://127.0.0.1:8090/v1,
AI_LOCAL_LLM_MODEL). On the production host since 2026-09-27: llama.cpp b11222 (the CPU build, checked against
the release's sha256) in /opt/llama.cpp/b11222 with /opt/llama.cpp/current, and Qwen2.5-1.5B-Instruct Q4_K_M
(Apache-2.0, checked against Hugging Face's sha256) in /var/lib/openvibe-llm/models. It is held back so live
streams always win: 2 threads, at most 1.5 CPUs, nice 19, 3 GB. It uses about 0.9 GB and generates about
28 tokens/s. Without it, media.analyze writes an extractive overview and says so. To swap the model, put the
file beside it, change --model/--alias and AI_LOCAL_LLM_MODEL, and restart both units.
Both servers run two slots (--parallel 2, 2026-11-07): llama.cpp batches the slots into each step, so a second
request no longer waits for the first; the 8192-token context is split, 4096 a slot. Install a changed unit by hand:
sudo install -m 0644 deploy/systemd/openvibe-llm*.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl restart openvibe-llm openvibe-llm2 (AI falls over to shared for the seconds they restart).

deploy/systemd/openvibe-llm2.service is the optional second chat server: the same llama.cpp build and models
directory on 127.0.0.1:8092, a different model family, and the provider local2
(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,
nice 19 and 2 GB, and it never advertises json or embed. Games' AI-vs-AI job pins one local offer per side,
so the two models must be different families and both aliases must be the model in its GAMES_AI_MATCHES_OFFERS
pair. On the production host since 2026-11-06: Llama-3.2-1B-Instruct Q4_K_M (Llama 3.2 Community License,
Hugging Face sha256 6f85a640a97cf2bf5b8e764087b1e83da0fdb51d7c9fab7d0fece9385611df83, 807,694,464 bytes),
checked against that hash before install; it uses about 0.8 GB.

The local model is a last offer for short text (summaries, titles, moderation hints, a chat line), never
for code or long reasoning: routes with max_output_tokens over 800 or outlast_provider_timeout (an app or a
wiki page) never offer it. It answers with AI_LOCAL_LLM_MAX_TOKENS (512) at most, and one request at a time
(AI_LOCAL_LLM_CONCURRENCY, 1) across local and local2, so a paid outage cannot starve the streams sharing
the host. A run that it answered names local/local2 as its provider and marks the receipt local.

Local embeddings are off unless AI_LOCAL_EMBED_MODEL names an embedding model the server at AI_LOCAL_LLM_URL
answers at /embeddings. Then local advertises embed, the model is registered as an embedding model, and
default.embedding (a capability pool) offers it beside the shared provider's AI_EMBEDDING_MODEL; local is
billing profile free, so its readings carry no cost. llama.cpp's llama-server answers /embeddings only when
started with --embedding and a GGUF that supports embeddings (a chat-only model answers 400, and every embedding
call then fails over to the shared provider with an error in the request log). That flag restricts llama-server
to embeddings, so the Qwen chat unit above runs without it: embeddings need a server that serves both on one URL
(Ollama's /v1, for example), or AI_LOCAL_LLM_URL given over to an embedding-only server. Vector dimensions
differ per upstream
: a run says which provider and model made its vectors, and a consumer must never compare or
mix vectors from different providers (re-embed when the provider changes).

Acceptance: guarantees and where they are tested

npm test runs every test/*.test.js with the stub provider, temp databases and local servers only.

| Guarantee | Test |
|---|---|
| Every workflow has versioned, compiling input/output schemas, runs on the stub and validates | test/workflows.test.js |
| Edits are new versions; runs pin versions; lifecycle; admin versions survive reseeding | test/versioning.test.js |
| A degraded primary falls back and records it; breaker; skips; timeouts; explicit provider.unavailable | test/fallback.test.js |
| 402/insufficient-quota opens the long exhausted circuit, never retries, fails over, then ai.resting | test/provider-exhausted.test.js |
| 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 |
| Hourly DeepSeek balance gauge, no key in logs, off under a drill | test/provider-balance.test.js |
| Quotas refuse with 429 + Retry-After before any provider call; per-service, attribution, cost caps | test/quota.test.js |
| 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 |
| 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 |
| 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 |
| 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 |
| The cache never crosses requester, actor, target or attribution scope | test/cache.test.js |
| Queue caps: one caller cannot fill the run queue (per-caller and global 429 queue.full + Retry-After) | test/queue.test.js |
| Idempotency, cancel (running and queued), retry, async polling, audit rows, restart recovery, no raw prompts or inline images kept | test/runs.test.js |
| 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 |
| The shared compiled-schema cache (caller-supplied schemas) is an LRU bounded by count and bytes, in Ajv too | test/schemas.test.js |
| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | test/auth.test.js |
| 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 |
| 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 |
| Public price/latency page: 200 without auth, rows from seeded stats and rate cards, the empty state, no caller ids, own-key providers, URLs or secrets | test/stats-page.test.js |
| The OpenAI Responses adapter (kind: responses) with a stubbed fetch: request shape, output and usage parsing, text.format, error mapping, the key never in an error | test/provider-responses.test.js |
| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | test/providers.test.js |
| OpenAI Chat Completions and Anthropic Messages pass the official-client fixtures (tool call + tool result, JSON response_format / structured tool, multi-turn system prompt, exact id, every alias) in JSON and SSE; usage is present and x_openvibe is top-level only | test/protocol-compat.test.js |
| Parakeet: engine by language, 30 s pieces past 45 s, fallback to whisper, ASR_ENGINE=whisper | test/parakeet.test.js |
| Word timing (live.media.transcribe with words: true): whisper's -ojf tokens and Parakeet's token lines become each segment's words with their times; punctuation joins a word without moving its end; one unit per token in Japanese; fillers and control tokens are not words | test/word-timing.test.js |
| media.analyze on a real 28 s fixture: signals, scenes and loud moments measured, highlights with evidence, the local model's overview from the facts only; the local model down → extractive and said; paid only with allow_paid and a media.paid budget, never past it; too little disk refused before downloading | test/media-analysis.test.js |
| Import from a Live snapshot: dry run, holds, idempotent re-run | test/import.test.js |
| Routing presets: the five built-in ones, a caller's own stored per account and per project, each knob applied (floor, ceiling, max_price, quantization, region, data policy, community), a hard max_price refused before the call with a problem document, every answer's receipt (offer, price, tokens, cache hits, latency, why, fallbacks, privacy label, BYOK at 0 %) in the headers and at GET /v1/receipts/{id}, and the Anthropic endpoint and the stream carrying the same receipt | test/exchange-presets-receipts.test.js |
| Capability proposals are valid contracts documents matching what is enforced | test/proposals.test.js |
| 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 |

Security

Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:

  • Auth. Every API route needs a Network service token for audience openvibe.ai, with the route's
    capability and, for runs, a namespace its ns claim allows. Runs are private to their requester.
    A developer-app token is accepted only on ai.app.run's routes (Developer apps),

where it acts as its project; every other route refuses it. The owner-only operator console is on OpenVibe.Exchange;
AI's admin JSON endpoints still require their named capabilities.

  • Secrets. Shared provider keys are env:NAME references, never values; a person's own key is
    AES-256-GCM encrypted with AI_CREDENTIALS_KEY, bound to its owner and subject, and never returned
    (a four-character hint only). No response, log or console page carries a secret value.
  • Memory. Item text is sealed with AI_MEMORY_KEY and bound to its owner, id and scope; plaintext exists only
    inside a request, and the profile builder calls the provider in-process so the notes are not written to a run.
  • Private data. The request log keeps hashes, not prompts; raw prompts and inline images are not
    kept; the cache never crosses requester, actor, target or attribution; history: false keeps
    neither input nor cache entry.
  • Egress. Provider calls go only to configured providers. Media is fetched only from allow-listed
    https OpenVibe hosts, with every DNS answer and redirect hop re-checked and size caps
    (test/ssrf.test.js); ffmpeg reads through a loopback proxy that applies the same rules.
  • Abuse. Quotas refuse with 429 before any provider call; queue caps per caller and overall.
    /metrics answers direct loopback callers only.

Live and Network

docs/live-patch.diff added AI_SERVICE=remote to Live (deployed as Live fa22de5; production runs
with AI_SERVICE=remote since 2026-09-23): Live's shared-key AI calls become runs here, authenticated
with serviceHeaders('openvibe.ai') from Live's server/net/network-principal.js. Without the flag
Live's behaviour is unchanged. The one-time scripts/import-from-live.js moved the AI records that belong here;
it ran on production on 2026-09-23 at 18:29 UTC (79,657 ledger rows, 11 holds recorded with their reasons, kept in
import_ledger and import_holds) and was retired with the move to PostgreSQL (it is in git history).
Network's footer copy calls the network.site_copy workflow (Network 422f8e9), but the deployed Network still
falls back to Live's /internal/ai/site-copy, and Live still serves that route. The full plan, holds
and rollback are in docs/migration.md.

Deploy

Production deploys with sudo ovhost deploy ai on the host (strategy git-checkout: fetch,
fast-forward /opt/openvibe.ai, install on a lockfile change, restart, wait for /api/ready).
The unit is openvibe-ai.service on 127.0.0.1:4700, the env file /etc/openvibe/ai.env. The database is
ov_ai on the host's data role (sudo /opt/openvibe.host/roles/data/add-service.sh ai writes its settings); the
release migrates it at boot. The local model
runs as openvibe-llm.service on 127.0.0.1:8090 (Local model).
Rollback: ovhost puts the previous sha back by itself when /api/ready does not answer 2xx after the
restart; afterwards sudo ovhost rollback ai --to <sha>. Migrations only add tables and columns.

Backups and drills: ov_ai is in the host's nightly backup (pgBackRest to B2, plus a weekly logical dump), and a
restore drill (sudo ovhost drill ai) restored a fresh dump and passed on 2026-10-15: models, routes, templates,
workflows, quotas and citations counted equal, and /api/health matched production. /metrics is scraped by the
host's Prometheus on 127.0.0.1:4700 and refused on the public vhost.

Deployed: /opt/openvibe.ai, env /etc/openvibe/ai.env (0600; BASE_URL=https://ai.openvibe.services), unit
deploy/systemd/openvibe-ai.service (StateDirectory=openvibe-ai; database ov_ai on the host's data role),
principal ai. The vhost deploy/nginx/ai.openvibe.services.conf (on the *.openvibe.services certificate)
serves the home, /stats, /shared/*, /robots.txt, /sitemap.xml, /llms.txt, health/ready, the operator
console /console with its sign-in /auth/, and exactly the developer-app routes (the six operations by POST,
/api/v1/runs/:id by GET, rate-limited per address); every other /api/ path and /metrics stay host-local.
The old ai.openvibe.network address was retired on 2026-10-10. The console needs
AI_CONSOLE_SESSION_SECRET (32+ random characters) and OV_OAUTH_CLIENT_SECRET in the env file (without them it
answers 503 in production); Network client ai lists https://ai.openvibe.services/auth/callback.

Sitemap entries use the release commit for home and the newest recorded stats day for /stats when available.

Not done yet

  • Live's VOD and clip overviews calling media.analyze instead of extracting frames and transcribing
    themselves; moving the remaining passthrough prompts into AI templates (most live.* workflows are
    versioned templates since 2026-09-27);
    removing Network's fallback to Live's /internal/ai/site-copy; a public server-rendered status page.
  • Metered models for developer apps: production app runs use free and local capacity until a tier budget
    governs their project (AI_GOVERN_TIERS is off in production) and OpenVibe.Billing charges project subjects.
  • Import holds from the 2026-09-23 run: a streamer's own provider key stays in Live, and 3,021 Live
    translations cannot become cache entries (they have no source text) and stay in Live.
  • Text routes now run a capability pool (shared plus a local model when one is configured) instead
    of a declared fallback, but speech and embeddings still have a single upstream, so an outage there
    fails that feature until O9 adds a second provider. On 2026-09-23 live.* and network.site_copy
    outputs carried no citations or gaps
    (0 citation rows); runs record grounding (cited sources and named gaps) since 2026-09-25.

Launch

Launched 2026-10-07 under the launch rule (plan §12.12): the runtime with health, readiness and metrics; Network
subjects and scoped service principals; server-rendered public routes useful without JavaScript (the home,
/stats); PostgreSQL persistence and end-to-end workflows; capabilities and events registered in
OpenVibe.Contracts (ai.app.run since 0.109.0, the service live since 0.110.0); migrations at boot,
robots/sitemap/llms.txt; acceptance tests (above, and
test/home.test.js for the public routes and vhosts). The same release removed ai.openvibe.services and the
ai.openvibe.network notice from OpenVibe.Sites.


Part of the OpenVibe network. Built in the open by OpenVibers.

<!-- versions:start -->

  • openvibe-contracts: v0.421.0
  • openvibe-sdk: v0.152.0
  • openvibe-shared: v4.234.0

<!-- versions:end -->

Exchange search engines

The web, image, icon 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 all four search/fetch endpoints.