openvibers/openvibe.ai Commits
Capability proposals (ai.run.create, ai.run.read, ai.workflow.manage, ai.provider.manage, ai.usage.read) and the ai service manifest; README, .env.example, systemd unit, nginx vhost, CI on Node 22.22.1, STATUS alpha W13
Changes
nnew file mode 100644@@@ -0,0 +1,95 @@++# ═══════════════════════════════════════════════════════════════++# ai.openvibe.network — OpenVibe.AI Environment Configuration++# Copy to .env and fill in values. In production this lives at /etc/openvibe/ai.env (0600).++# Secrets are read by NAME only (providers store 'env:NAME' references); nothing logs or returns them.++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4700++HOST=127.0.0.1++NODE_ENV=development++BASE_URL=http://localhost:4700++++# SQLite (production unit: /var/lib/openvibe-ai/ai.db via StateDirectory=openvibe-ai)++AI_DB_PATH=./data/ai.db++++# Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).++# The public key is fetched from <Network>/api/.well-known/jwks; OV_NETWORK_PUBLIC_KEY pins a PEM instead.++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++#OV_NETWORK_ISSUER=https://openvibe.network++#OV_NETWORK_PUBLIC_KEY=++++# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──++# (ai_enabled, ai_provider, ai_base_url, ai_api_key, ai_model, ai_model_<role>, ai_pricing_json,++# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day). scripts/import-from-live.js prints the++# values Live had (never the key).++#AI_ENABLED=true++# anthropic | openai | openrouter | groq | ollama | (blank = OpenAI-shaped). Anthropic only when++# AI_BASE_URL is blank or anthropic.com; any other base URL is treated as OpenAI-compatible.++AI_PROVIDER=++#AI_BASE_URL=https://api.openai.com/v1++AI_API_KEY=++# Blank = claude-sonnet-5 for anthropic, gpt-4o-mini otherwise (Live's defaults)++AI_MODEL=++# Per-role models for Live's workflows (routes live.chat / live.vision / live.director / live.summary)++#AI_MODEL_CHAT=gpt-5-nano++#AI_MODEL_VISION=gpt-5-mini++#AI_MODEL_DIRECTOR=gpt-5-mini++#AI_MODEL_SUMMARY=gpt-5-mini++#AI_EMBEDDING_MODEL=text-embedding-3-small++#AI_PROVIDER_TIMEOUT_MS=30000++# USD per million tokens, longest model-prefix match: {"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005}}++#AI_PRICING_JSON=++#AI_INPUT_COST_PER_MTOK=3++#AI_OUTPUT_COST_PER_MTOK=15++++# Optional second provider used as every route's fallback (OpenAI-compatible unless =anthropic)++#AI_FALLBACK_PROVIDER=++#AI_FALLBACK_BASE_URL=++#AI_FALLBACK_API_KEY=++#AI_FALLBACK_MODEL=++# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})++#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete++# Let routes fall back to the deterministic stub when every real provider fails. Default: on outside++# production, off in production (synthetic output must never stand in for a real answer there).++#AI_STUB_FALLBACK=false++++# ── Local speech-to-text: whisper.cpp, the same names Live uses ──++#WHISPER_BIN=/home/ubuntu/whisper.cpp/build/bin/whisper-cli++#WHISPER_MODEL=/home/ubuntu/whisper.cpp/models/ggml-base.en.bin++#WHISPER_MODEL_LIVE=++#WHISPER_MODEL_MULTI=/home/ubuntu/whisper.cpp/models/ggml-small.bin++#WHISPER_VAD_MODEL=++#WHISPER_VAD=1++#WHISPER_THREADS=4++#WHISPER_BEAM=1++#WHISPER_MAX_CONCURRENT=1++++# ── Quotas seeded at boot (admin API edits them afterwards; 0 = none) ──++# Live's ai_max_cost_usd_per_day: one daily spend cap across every caller++AI_MAX_COST_USD_PER_DAY=0++# Requests per minute / per day for EACH calling service++#AI_QUOTA_SERVICE_RPM=600++#AI_QUOTA_SERVICE_RPD=50000++++# ── Runs, cache, logs ──++#AI_MAX_WAIT_MS=60000++#AI_MAX_CONCURRENT_RUNS=4++#AI_CACHE_TTL_SEC=604800++#AI_MAX_INPUT_BYTES=6291456++#AI_RUN_RETENTION_DAYS=30++# Raw prompt/response logging: opt-in debugging only, and only for runs whose caller sets options.debug++#AI_DEBUG_RAW_LOG=false++#AI_BREAKER_FAILURES=3++#AI_BREAKER_COOLDOWN_MS=60000++#AI_PROVIDER_RETRIES=1++#AI_PROVIDER_RETRY_DELAY_MS=800++++# ── Media inputs: only these https hosts are fetched (plus the Media internal origin) ──++OV_MEDIA_URL=https://openvibe.media++#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100++#AI_FETCH_ALLOW_HOSTS=openvibe.media,openvibe.live,openvibe.network,*.openvibe.network,openvibe.community,openvibe.tools,openvibe.games++#AI_FETCH_MAX_BYTES=536870912++#AI_FETCH_MAX_IMAGE_BYTES=12582912++#AI_FETCH_TIMEOUT_MS=120000nnew file mode 100644@@@ -0,0 +1,22 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ # openvibe-contracts still carries the charter-only `ai` manifest (range ^0.1.0, no++ # capabilities); the replacement is proposed in docs/capabilities-proposal/. Until a contracts++ # release ships it this check reports that range, so it may not fail the build yet.++ - name: contract check (openvibe-contracts)++ continue-on-error: true++ run: npx openvibe-contracts-check --service ai --src server@@@ -2,57 +2,168 @@> Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `ai.openvibe.network`--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §34.++**Status:** alpha (roadmap Wave 13). The service runs and is tested; it is **not deployed**, and the++domain keeps its placeholder page until the launch rule below is met.++**Domain:** `ai.openvibe.network` · **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 invoke registered, versioned workflows that produce draft/evidence packages; AI never owns publication truth.----## Owns----- providers, models, routing profiles, prompt templates, workflow definitions, runs/jobs, request/completion metadata logs, source documents/citations, cache, quotas/usage, audit (the 11 model groups)--- deterministic no-key stub provider, local HTTP seam, bounded external adapters----## Does not own----- publication decisions (Wiki/Blog/News/… own their revisions)--- source registry policy (shared Sources contract; adapters registered here)----## Planned surfaces----- status/admin, provider/model/route/template/workflow CRUD, run create/list/detail/cancel/retry--- `chat|generate|summarize|classify|extract|enrich|embed` conveniences--- seed workflows: `wiki.generate_*`, `blog.draft_post`, `news.summarize_story|compare_perspectives`, `reviews.summarize_entity`, `deals.enrich_deal`, `coupons.extract_coupon`, `trade.summarize_market_context`, `codes.generate_docs`, `games.generate_lore`, `moderation.classify`----## Data (authority tables / families)----- see above----## Capabilities and events----- `ai.run.create|get|cancel`, `ai.workflow.invoke`, `ai.template.*`----Events: ``ai.run.queued|succeeded|failed|cached``----## Depends on----- OpenVibe.Contracts--- OpenVibe.Events--- OpenVibe.Network (quotas per actor)----## Acceptance (must be true before "done")----- every workflow has versioned input/output schemas and citations--- source failures are explicit states, never fabricated content--- private cache never crosses actor/resource scope--- a degraded primary provider falls back and records that it did----## Bootstrap / extraction source----Live's AI subsystem (chat AI, moments, captions/translation, slogans, SQL/analysis jobs) migrated as workflow adapters; Network's generated-copy dependency on Live is reversed.++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.++++## Run it++++```bash++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++```++++Node 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++++```http++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):++++```json++{ "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/citations` | `ai.run.create` |++| `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}` — each a run of workflow `ai.<op>` | `ai.run.create` |++| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |++| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |++| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |++| `POST/PATCH /api/v1/providers…`, `…/disable\|enable\|reset`, `POST/PATCH /api/v1/models…`, `POST /api/v1/quotas`, `DELETE /api/v1/cache` | `ai.provider.manage` |++| `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |++| `GET /api/health`, `GET /api/ready` | public |++++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`), `capability.denied`, `capability.namespace_denied`, `token.*`. A run that cannot++produce a real answer ends `failed` with an explicit code — `provider.unavailable`,++`route.unavailable`, `fetch.refused`, `source.unavailable`, `input.insufficient`, `output.empty`,++`output.invalid`, `run.interrupted` — and never with filler content.++++The capability ids are proposed for openvibe-contracts in `docs/capabilities-proposal/` (with the++replacement `ai` service manifest). Until a contracts release defines them, `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.*`, …).++++## The eleven record groups++++| Group | Table(s) |++|---|---|++| Providers | `providers` (+ `provider_health`: circuit breaker) — 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. Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL`, `AI_MODEL_<ROLE>` — Live's setting names |++| `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 | `WHISPER_*` — Live's names |++++Routing tries the route's primary, then each fallback; 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 fallback that answers is++recorded on the run (`fallback_used`), in the request log (`fallback = 1`) and in the audit log. The++stub joins every route as a last resort outside production only (`AI_STUB_FALLBACK`).++++## Workflows++++- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `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`. 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`.++- **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`.++- **Direct operations** — `ai.chat|generate|summarize|classify|extract|enrich|embed`.++++## Guarantees and where they are tested++++| 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` |++| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |++| The cache never crosses requester, actor, target or attribution scope | `test/cache.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 redirects re-checked; size caps | `test/ssrf.test.js` |++| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |++| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |++| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |++| Capability proposals are valid contracts documents matching what is enforced | `test/proposals.test.js` |++++## Live and Network++++`docs/live-patch.diff` (applies to Live's `main` with `git apply`) adds `AI_SERVICE=remote`: Live's++shared-key AI calls become runs here, authenticated with `serviceHeaders('openvibe.ai')` from Live's++`server/net/network-principal.js`. Default behaviour is unchanged. `scripts/import-from-live.js`++moves the AI records that belong here (`--dry-run` first). Network's footer copy becomes the++`network.site_copy` workflow — the dependency on Live's internal endpoint is reversed. The full++plan, holds and rollback are in `docs/migration.md`.++++## Deploy (when launched)++++`/opt/openvibe.ai`, env `/etc/openvibe/ai.env` (0600), unit `deploy/systemd/openvibe-ai.service`++(`StateDirectory=openvibe-ai`, database `/var/lib/openvibe-ai/ai.db`), nginx++`deploy/nginx/ai.openvibe.network.conf` (only health/ready public; the API is host-local).++++## Not done yet++++- Contracts: the capability ids and service manifest are proposals; the CI contract check reports++ the old charter manifest until a contracts release ships them.++- `ai.run.*` events to OpenVibe.Events; per-actor (BYO) provider secrets; moving Live's local++ transcription and the passthrough prompts; Network's direct call to `network.site_copy`; a++ server-rendered status page.## Launch rule@@@ -1,7 +1,17 @@{"repository": "OpenVibers/OpenVibe.AI",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "wave": "W13","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "runtime": "node 22, express, better-sqlite3",++ "port": 4700,++ "domain": "ai.openvibe.network",++ "contracts": "openvibe-contracts v0.6.0",++ "capabilities": ["ai.run.create", "ai.run.read", "ai.workflow.manage", "ai.provider.manage", "ai.usage.read"],++ "capabilitiesRegistered": false,++ "providers": ["stub", "openai-compatible", "anthropic", "http-seam", "whisper.cpp"],++ "livePatch": "docs/live-patch.diff (AI_SERVICE=remote, not applied)",++ "deployed": false,++ "updated": "2026-09-23"}nnew file mode 100644@@@ -0,0 +1,67 @@++## ═══════════════════════════════════════════════════════════════++## ai.openvibe.network — Nginx Configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/ai.openvibe.network.conf — keep them in sync, and reload++## nginx after changing it. The domain keeps its OpenVibe.Sites placeholder until the launch rule in++## README.md is met; this vhost is what replaces it then.++##++## The Node service (port 4700, systemd unit openvibe-ai) serves:++## /api/health, /api/ready++## /api/v1/* service-to-service API (Network service tokens, audience openvibe.ai). Services on++## this host call 127.0.0.1:4700 directly, so the public vhost refuses it; open the++## location below only when a service on another host needs it (it still needs a token).++## ═══════════════════════════════════════════════════════════════++++server {++ listen 80;++ listen [::]:80;++ server_name ai.openvibe.network;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://ai.openvibe.network$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name ai.openvibe.network;++++ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/ai.openvibe.network.access.log;++ error_log /var/log/nginx/ai.openvibe.network.error.log;++++ # Inline images arrive as data URLs inside run inputs.++ client_max_body_size 12m;++++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ location = /api/health { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; }++ location = /api/ready { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; }++++ # ── Service API: host-local only (see header) ───────────++ location /api/ {++ return 403;++ # To expose it (token-guarded) to off-host services instead:++ # proxy_pass http://127.0.0.1:4700;++ # proxy_http_version 1.1;++ # proxy_set_header Host $host;++ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ # proxy_set_header X-Forwarded-Proto $scheme;++ # proxy_read_timeout 90s; # ?wait= holds a request up to 60 s++ }++++ location = / {++ proxy_pass http://127.0.0.1:4700;++ proxy_set_header Host $host;++ }++++ location / {++ return 404;++ }++}nnew file mode 100644@@@ -0,0 +1,34 @@++[Unit]++Description=OpenVibe.AI — providers, routing, versioned templates and workflows, runs, citations, cache, quotas and audit++# Network (openvibe-network) signs the service tokens callers present. The AI service starts without++# it and keeps retrying the JWKS fetch; /api/ready stays 503 until the key has loaded.++After=network.target++Wants=openvibe-network.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.ai++EnvironmentFile=/etc/openvibe/ai.env++Environment=NODE_ENV=production++Environment=PORT=4700++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.ai/server/index.js++Restart=always++RestartSec=5++# SIGTERM: stop accepting; runs still in flight end as failed/run.interrupted (callers can retry).++KillSignal=SIGTERM++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=read-only++# whisper.cpp (live.media.transcribe) runs as a child process; it reads its binary and models from++# WHISPER_BIN / WHISPER_MODEL* (defaults under ~/whisper.cpp, readable with ProtectHome=read-only).++# The code tree stays read-only; the database lives in /var/lib/openvibe-ai.++StateDirectory=openvibe-ai++Environment=AI_DB_PATH=/var/lib/openvibe-ai/ai.db++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,27 @@++{++ "id": "ai.provider.manage",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "internal",++ "description": "Register, update, enable and disable providers and models (secrets only as env references, never values), reset circuit breakers, set quotas and purge the cache.",++ "permissions": [++ "ai:admin"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "ai-admin",++ "events": [],++ "implementedBy": [++ "POST /api/v1/providers",++ "PATCH /api/v1/providers/:key",++ "POST /api/v1/providers/:key/disable",++ "POST /api/v1/providers/:key/enable",++ "POST /api/v1/providers/:key/reset",++ "POST /api/v1/models",++ "PATCH /api/v1/models/:provider/:model",++ "POST /api/v1/quotas",++ "DELETE /api/v1/cache"++ ]++}nnew file mode 100644@@@ -0,0 +1,30 @@++{++ "id": "ai.run.create",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "first-party",++ "description": "Create workflow runs (POST /api/v1/runs and the direct chat/generate/summarize/classify/extract/enrich/embed operations), cancel or retry the caller's own runs and attach citations to them. A token's `ns` claim limits which workflow namespaces it may run (live.*, wiki.*, ...). Quotas are checked before any provider call.",++ "permissions": [++ "ai:run"++ ],++ "resourceConstraints": [++ "namespace",++ "owner"++ ],++ "quotaClass": "ai-run",++ "events": [],++ "implementedBy": [++ "POST /api/v1/runs",++ "POST /api/v1/runs/:id/cancel",++ "POST /api/v1/runs/:id/retry",++ "POST /api/v1/runs/:id/citations",++ "POST /api/v1/chat",++ "POST /api/v1/generate",++ "POST /api/v1/summarize",++ "POST /api/v1/classify",++ "POST /api/v1/extract",++ "POST /api/v1/enrich",++ "POST /api/v1/embed"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "ai.run.read",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "first-party",++ "description": "Read the caller's own runs, their citations and request-log metadata (hashes, tokens, latency; never raw prompts).",++ "permissions": [++ "ai:read"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "ai-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/runs",++ "GET /api/v1/runs/:id",++ "GET /api/v1/runs/:id/citations"++ ]++}nnew file mode 100644@@@ -0,0 +1,26 @@++{++ "id": "ai.usage.read",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "internal",++ "description": "Read usage, quota windows, request logs, the audit log, cache statistics, the admin summary and any caller's runs.",++ "permissions": [++ "ai:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "ai-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/status",++ "GET /api/v1/usage",++ "GET /api/v1/quotas",++ "GET /api/v1/requests",++ "GET /api/v1/audit",++ "GET /api/v1/cache",++ "GET /api/v1/providers",++ "GET /api/v1/models"++ ]++}nnew file mode 100644@@@ -0,0 +1,24 @@++{++ "id": "ai.workflow.manage",++ "version": "1.0",++ "owner": "ai",++ "status": "active",++ "visibility": "internal",++ "description": "Create new versions of prompt templates, workflow definitions and routing profiles, and change their lifecycle status (draft, active, deprecated, archived; routes active/disabled). Every change is audited; edits never overwrite a version.",++ "permissions": [++ "ai:admin"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "ai-admin",++ "events": [],++ "implementedBy": [++ "POST /api/v1/templates/:key/versions",++ "POST /api/v1/templates/:key/versions/:version/status",++ "POST /api/v1/workflows/:key/versions",++ "POST /api/v1/workflows/:key/versions/:version/status",++ "POST /api/v1/routes/:key/versions",++ "POST /api/v1/routes/:key/versions/:version/status"++ ]++}nnew file mode 100644@@@ -0,0 +1,27 @@++{++ "version": "0.1.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.6.0 <1.0.0"++ },++ "id": "ai",++ "name": "OpenVibe.AI",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.AI",++ "domains": [++ "ai.openvibe.network"++ ],++ "publicOrigin": "https://ai.openvibe.network",++ "health": "/api/health",++ "ready": "/api/ready",++ "capabilities": [++ "ai.run.create",++ "ai.run.read",++ "ai.workflow.manage",++ "ai.provider.manage",++ "ai.usage.read"++ ],++ "eventsProduced": [],++ "eventsConsumed": [],++ "namespacesOwned": [],++ "notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached events follow once the Events client is wired in."++}nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++// The capability manifests proposed for openvibe-contracts (docs/capabilities-proposal/) are valid++// contracts documents, use 3+ segment ids owned by 'ai', and are exactly the ids this service enforces.++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const { CAPS } = require('../server/auth');++const { suite } = require('./helpers');++++const t = suite('proposals');++const dir = path.join(__dirname, '..', 'docs', 'capabilities-proposal');++++t.test('every proposal validates against the contracts schemas', () => {++ for (const f of fs.readdirSync(dir)) {++ const doc = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));++ const v = contracts.validate(f.startsWith('service-manifest') ? 'registry.service-manifest' : 'capabilities.capability', doc);++ assert.ok(v.valid, `${f}: ${JSON.stringify(v.errors)}`);++ }++});++++t.test('capability ids: owner ai, 3+ segments, the same set the service enforces and the manifest lists', () => {++ const caps = fs.readdirSync(dir).filter(f => !f.startsWith('service-manifest')).map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));++ for (const c of caps) {++ assert.strictEqual(c.owner, 'ai');++ assert.ok(c.id.split('.').length >= 3, c.id);++ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'ai', `${c.id} is owned elsewhere in contracts`);++ }++ const ids = caps.map(c => c.id).sort();++ assert.deepStrictEqual(ids, Object.values(CAPS).sort());++ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'service-manifest.ai.json'), 'utf8'));++ assert.deepStrictEqual([...manifest.capabilities].sort(), ids);++});++++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+# ═══════════════════════════════════════════════════════════════
+# ai.openvibe.network — OpenVibe.AI Environment Configuration
+# Copy to .env and fill in values. In production this lives at /etc/openvibe/ai.env (0600).
+# Secrets are read by NAME only (providers store 'env:NAME' references); nothing logs or returns them.
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4700
+HOST=127.0.0.1
+NODE_ENV=development
+BASE_URL=http://localhost:4700
+
+# SQLite (production unit: /var/lib/openvibe-ai/ai.db via StateDirectory=openvibe-ai)
+AI_DB_PATH=./data/ai.db
+
+# Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
+# The public key is fetched from <Network>/api/.well-known/jwks; OV_NETWORK_PUBLIC_KEY pins a PEM instead.
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+#OV_NETWORK_ISSUER=https://openvibe.network
+#OV_NETWORK_PUBLIC_KEY=
+
+# ── The shared provider — the same names as OpenVibe.Live's admin AI settings ──
+# (ai_enabled, ai_provider, ai_base_url, ai_api_key, ai_model, ai_model_<role>, ai_pricing_json,
+# ai_input/output_cost_per_mtok, ai_max_cost_usd_per_day). scripts/import-from-live.js prints the
+# values Live had (never the key).
+#AI_ENABLED=true
+# anthropic | openai | openrouter | groq | ollama | (blank = OpenAI-shaped). Anthropic only when
+# AI_BASE_URL is blank or anthropic.com; any other base URL is treated as OpenAI-compatible.
+AI_PROVIDER=
+#AI_BASE_URL=https://api.openai.com/v1
+AI_API_KEY=
+# Blank = claude-sonnet-5 for anthropic, gpt-4o-mini otherwise (Live's defaults)
+AI_MODEL=
+# Per-role models for Live's workflows (routes live.chat / live.vision / live.director / live.summary)
+#AI_MODEL_CHAT=gpt-5-nano
+#AI_MODEL_VISION=gpt-5-mini
+#AI_MODEL_DIRECTOR=gpt-5-mini
+#AI_MODEL_SUMMARY=gpt-5-mini
+#AI_EMBEDDING_MODEL=text-embedding-3-small
+#AI_PROVIDER_TIMEOUT_MS=30000
+# USD per million tokens, longest model-prefix match: {"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005}}
+#AI_PRICING_JSON=
+#AI_INPUT_COST_PER_MTOK=3
+#AI_OUTPUT_COST_PER_MTOK=15
+
+# Optional second provider used as every route's fallback (OpenAI-compatible unless =anthropic)
+#AI_FALLBACK_PROVIDER=
+#AI_FALLBACK_BASE_URL=
+#AI_FALLBACK_API_KEY=
+#AI_FALLBACK_MODEL=
+# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})
+#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete
+# Let routes fall back to the deterministic stub when every real provider fails. Default: on outside
+# production, off in production (synthetic output must never stand in for a real answer there).
+#AI_STUB_FALLBACK=false
+
+# ── Local speech-to-text: whisper.cpp, the same names Live uses ──
+#WHISPER_BIN=/home/ubuntu/whisper.cpp/build/bin/whisper-cli
+#WHISPER_MODEL=/home/ubuntu/whisper.cpp/models/ggml-base.en.bin
+#WHISPER_MODEL_LIVE=
+#WHISPER_MODEL_MULTI=/home/ubuntu/whisper.cpp/models/ggml-small.bin
+#WHISPER_VAD_MODEL=
+#WHISPER_VAD=1
+#WHISPER_THREADS=4
+#WHISPER_BEAM=1
+#WHISPER_MAX_CONCURRENT=1
+
+# ── Quotas seeded at boot (admin API edits them afterwards; 0 = none) ──
+# Live's ai_max_cost_usd_per_day: one daily spend cap across every caller
+AI_MAX_COST_USD_PER_DAY=0
+# Requests per minute / per day for EACH calling service
+#AI_QUOTA_SERVICE_RPM=600
+#AI_QUOTA_SERVICE_RPD=50000
+
+# ── Runs, cache, logs ──
+#AI_MAX_WAIT_MS=60000
+#AI_MAX_CONCURRENT_RUNS=4
+#AI_CACHE_TTL_SEC=604800
+#AI_MAX_INPUT_BYTES=6291456
+#AI_RUN_RETENTION_DAYS=30
+# Raw prompt/response logging: opt-in debugging only, and only for runs whose caller sets options.debug
+#AI_DEBUG_RAW_LOG=false
+#AI_BREAKER_FAILURES=3
+#AI_BREAKER_COOLDOWN_MS=60000
+#AI_PROVIDER_RETRIES=1
+#AI_PROVIDER_RETRY_DELAY_MS=800
+
+# ── Media inputs: only these https hosts are fetched (plus the Media internal origin) ──
+OV_MEDIA_URL=https://openvibe.media
+#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+#AI_FETCH_ALLOW_HOSTS=openvibe.media,openvibe.live,openvibe.network,*.openvibe.network,openvibe.community,openvibe.tools,openvibe.games
+#AI_FETCH_MAX_BYTES=536870912
+#AI_FETCH_MAX_IMAGE_BYTES=12582912
+#AI_FETCH_TIMEOUT_MS=120000
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ # openvibe-contracts still carries the charter-only `ai` manifest (range ^0.1.0, no
+ # capabilities); the replacement is proposed in docs/capabilities-proposal/. Until a contracts
+ # release ships it this check reports that range, so it may not fail the build yet.
+ - name: contract check (openvibe-contracts)
+ continue-on-error: true
+ run: npx openvibe-contracts-check --service ai --src server
@@ -2,57 +2,168 @@
@@ -2,57 +2,168 @@
> Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product.
> Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `ai.openvibe.network`
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §34.
+**Status:** alpha (roadmap Wave 13). The service runs and is tested; it is **not deployed**, and the
+domain keeps its placeholder page until the launch rule below is met.
+**Domain:** `ai.openvibe.network` · **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).
**License:** AGPL-3.0 (same as every OpenVibe service).
-## Purpose
-
-The reusable AI authority extracted from OpenVibe.Live. Products invoke registered, versioned workflows that produce draft/evidence packages; AI never owns publication truth.
-
-## Owns
-
-- providers, models, routing profiles, prompt templates, workflow definitions, runs/jobs, request/completion metadata logs, source documents/citations, cache, quotas/usage, audit (the 11 model groups)
-- deterministic no-key stub provider, local HTTP seam, bounded external adapters
-
-## Does not own
-
-- publication decisions (Wiki/Blog/News/… own their revisions)
-- source registry policy (shared Sources contract; adapters registered here)
-
-## Planned surfaces
-
-- status/admin, provider/model/route/template/workflow CRUD, run create/list/detail/cancel/retry
-- `chat|generate|summarize|classify|extract|enrich|embed` conveniences
-- seed workflows: `wiki.generate_*`, `blog.draft_post`, `news.summarize_story|compare_perspectives`, `reviews.summarize_entity`, `deals.enrich_deal`, `coupons.extract_coupon`, `trade.summarize_market_context`, `codes.generate_docs`, `games.generate_lore`, `moderation.classify`
-
-## Data (authority tables / families)
-
-- see above
-
-## Capabilities and events
-
-- `ai.run.create|get|cancel`, `ai.workflow.invoke`, `ai.template.*`
-
-Events: ``ai.run.queued|succeeded|failed|cached``
-
-## Depends on
-
-- OpenVibe.Contracts
-- OpenVibe.Events
-- OpenVibe.Network (quotas per actor)
-
-## Acceptance (must be true before "done")
-
-- every workflow has versioned input/output schemas and citations
-- source failures are explicit states, never fabricated content
-- private cache never crosses actor/resource scope
-- a degraded primary provider falls back and records that it did
-
-## Bootstrap / extraction source
-
-Live's AI subsystem (chat AI, moments, captions/translation, slogans, SQL/analysis jobs) migrated as workflow adapters; Network's generated-copy dependency on Live is reversed.
+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.
+
+## Run it
+
+```bash
+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
+```
+
+Node 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
+
+```http
+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):
+
+```json
+{ "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/citations` | `ai.run.create` |
+| `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}` — each a run of workflow `ai.<op>` | `ai.run.create` |
+| `GET /api/v1/runs`, `GET /runs/:id` (+ citations, request-log metadata), `GET /runs/:id/citations` | `ai.run.read` (own runs) |
+| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
+| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
+| `POST/PATCH /api/v1/providers…`, `…/disable\|enable\|reset`, `POST/PATCH /api/v1/models…`, `POST /api/v1/quotas`, `DELETE /api/v1/cache` | `ai.provider.manage` |
+| `GET /api/v1/status\|usage\|quotas\|requests\|audit\|cache\|providers\|models` | `ai.usage.read` |
+| `GET /api/health`, `GET /api/ready` | public |
+
+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`), `capability.denied`, `capability.namespace_denied`, `token.*`. A run that cannot
+produce a real answer ends `failed` with an explicit code — `provider.unavailable`,
+`route.unavailable`, `fetch.refused`, `source.unavailable`, `input.insufficient`, `output.empty`,
+`output.invalid`, `run.interrupted` — and never with filler content.
+
+The capability ids are proposed for openvibe-contracts in `docs/capabilities-proposal/` (with the
+replacement `ai` service manifest). Until a contracts release defines them, `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.*`, …).
+
+## The eleven record groups
+
+| Group | Table(s) |
+|---|---|
+| Providers | `providers` (+ `provider_health`: circuit breaker) — 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. Ported from Live's `llm.js` / `ai-provider.js`. | `AI_PROVIDER`, `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL`, `AI_MODEL_<ROLE>` — Live's setting names |
+| `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 | `WHISPER_*` — Live's names |
+
+Routing tries the route's primary, then each fallback; 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 fallback that answers is
+recorded on the run (`fallback_used`), in the request log (`fallback = 1`) and in the audit log. The
+stub joins every route as a last resort outside production only (`AI_STUB_FALLBACK`).
+
+## Workflows
+
+- **Seed product workflows** — `wiki.generate_space`, `wiki.generate_page`, `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`. 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`.
+- **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`.
+- **Direct operations** — `ai.chat|generate|summarize|classify|extract|enrich|embed`.
+
+## Guarantees and where they are tested
+
+| 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` |
+| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
+| The cache never crosses requester, actor, target or attribution scope | `test/cache.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 redirects re-checked; size caps | `test/ssrf.test.js` |
+| Token and capability denial, namespaces, no secret values in any response, runs private to the requester | `test/auth.test.js` |
+| Ported adapters against fake OpenAI/Anthropic servers, stub determinism, whisper filter, templates | `test/providers.test.js` |
+| Import from a Live snapshot: dry run, holds, idempotent re-run | `test/import.test.js` |
+| Capability proposals are valid contracts documents matching what is enforced | `test/proposals.test.js` |
+
+## Live and Network
+
+`docs/live-patch.diff` (applies to Live's `main` with `git apply`) adds `AI_SERVICE=remote`: Live's
+shared-key AI calls become runs here, authenticated with `serviceHeaders('openvibe.ai')` from Live's
+`server/net/network-principal.js`. Default behaviour is unchanged. `scripts/import-from-live.js`
+moves the AI records that belong here (`--dry-run` first). Network's footer copy becomes the
+`network.site_copy` workflow — the dependency on Live's internal endpoint is reversed. The full
+plan, holds and rollback are in `docs/migration.md`.
+
+## Deploy (when launched)
+
+`/opt/openvibe.ai`, env `/etc/openvibe/ai.env` (0600), unit `deploy/systemd/openvibe-ai.service`
+(`StateDirectory=openvibe-ai`, database `/var/lib/openvibe-ai/ai.db`), nginx
+`deploy/nginx/ai.openvibe.network.conf` (only health/ready public; the API is host-local).
+
+## Not done yet
+
+- Contracts: the capability ids and service manifest are proposals; the CI contract check reports
+ the old charter manifest until a contracts release ships them.
+- `ai.run.*` events to OpenVibe.Events; per-actor (BYO) provider secrets; moving Live's local
+ transcription and the passthrough prompts; Network's direct call to `network.site_copy`; a
+ server-rendered status page.
## Launch rule
## Launch rule
@@ -1,7 +1,17 @@
@@ -1,7 +1,17 @@
{
{
"repository": "OpenVibers/OpenVibe.AI",
"repository": "OpenVibers/OpenVibe.AI",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "wave": "W13",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "runtime": "node 22, express, better-sqlite3",
+ "port": 4700,
+ "domain": "ai.openvibe.network",
+ "contracts": "openvibe-contracts v0.6.0",
+ "capabilities": ["ai.run.create", "ai.run.read", "ai.workflow.manage", "ai.provider.manage", "ai.usage.read"],
+ "capabilitiesRegistered": false,
+ "providers": ["stub", "openai-compatible", "anthropic", "http-seam", "whisper.cpp"],
+ "livePatch": "docs/live-patch.diff (AI_SERVICE=remote, not applied)",
+ "deployed": false,
+ "updated": "2026-09-23"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+## ═══════════════════════════════════════════════════════════════
+## ai.openvibe.network — Nginx Configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/ai.openvibe.network.conf — keep them in sync, and reload
+## nginx after changing it. The domain keeps its OpenVibe.Sites placeholder until the launch rule in
+## README.md is met; this vhost is what replaces it then.
+##
+## The Node service (port 4700, systemd unit openvibe-ai) serves:
+## /api/health, /api/ready
+## /api/v1/* service-to-service API (Network service tokens, audience openvibe.ai). Services on
+## this host call 127.0.0.1:4700 directly, so the public vhost refuses it; open the
+## location below only when a service on another host needs it (it still needs a token).
+## ═══════════════════════════════════════════════════════════════
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name ai.openvibe.network;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://ai.openvibe.network$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name ai.openvibe.network;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/ai.openvibe.network.access.log;
+ error_log /var/log/nginx/ai.openvibe.network.error.log;
+
+ # Inline images arrive as data URLs inside run inputs.
+ client_max_body_size 12m;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ location = /api/health { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; }
+ location = /api/ready { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; }
+
+ # ── Service API: host-local only (see header) ───────────
+ location /api/ {
+ return 403;
+ # To expose it (token-guarded) to off-host services instead:
+ # proxy_pass http://127.0.0.1:4700;
+ # proxy_http_version 1.1;
+ # proxy_set_header Host $host;
+ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # proxy_set_header X-Forwarded-Proto $scheme;
+ # proxy_read_timeout 90s; # ?wait= holds a request up to 60 s
+ }
+
+ location = / {
+ proxy_pass http://127.0.0.1:4700;
+ proxy_set_header Host $host;
+ }
+
+ location / {
+ return 404;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+[Unit]
+Description=OpenVibe.AI — providers, routing, versioned templates and workflows, runs, citations, cache, quotas and audit
+# Network (openvibe-network) signs the service tokens callers present. The AI service starts without
+# it and keeps retrying the JWKS fetch; /api/ready stays 503 until the key has loaded.
+After=network.target
+Wants=openvibe-network.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.ai
+EnvironmentFile=/etc/openvibe/ai.env
+Environment=NODE_ENV=production
+Environment=PORT=4700
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.ai/server/index.js
+Restart=always
+RestartSec=5
+# SIGTERM: stop accepting; runs still in flight end as failed/run.interrupted (callers can retry).
+KillSignal=SIGTERM
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=read-only
+# whisper.cpp (live.media.transcribe) runs as a child process; it reads its binary and models from
+# WHISPER_BIN / WHISPER_MODEL* (defaults under ~/whisper.cpp, readable with ProtectHome=read-only).
+# The code tree stays read-only; the database lives in /var/lib/openvibe-ai.
+StateDirectory=openvibe-ai
+Environment=AI_DB_PATH=/var/lib/openvibe-ai/ai.db
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+{
+ "id": "ai.provider.manage",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Register, update, enable and disable providers and models (secrets only as env references, never values), reset circuit breakers, set quotas and purge the cache.",
+ "permissions": [
+ "ai:admin"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "ai-admin",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/providers",
+ "PATCH /api/v1/providers/:key",
+ "POST /api/v1/providers/:key/disable",
+ "POST /api/v1/providers/:key/enable",
+ "POST /api/v1/providers/:key/reset",
+ "POST /api/v1/models",
+ "PATCH /api/v1/models/:provider/:model",
+ "POST /api/v1/quotas",
+ "DELETE /api/v1/cache"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+{
+ "id": "ai.run.create",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create workflow runs (POST /api/v1/runs and the direct chat/generate/summarize/classify/extract/enrich/embed operations), cancel or retry the caller's own runs and attach citations to them. A token's `ns` claim limits which workflow namespaces it may run (live.*, wiki.*, ...). Quotas are checked before any provider call.",
+ "permissions": [
+ "ai:run"
+ ],
+ "resourceConstraints": [
+ "namespace",
+ "owner"
+ ],
+ "quotaClass": "ai-run",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/runs",
+ "POST /api/v1/runs/:id/cancel",
+ "POST /api/v1/runs/:id/retry",
+ "POST /api/v1/runs/:id/citations",
+ "POST /api/v1/chat",
+ "POST /api/v1/generate",
+ "POST /api/v1/summarize",
+ "POST /api/v1/classify",
+ "POST /api/v1/extract",
+ "POST /api/v1/enrich",
+ "POST /api/v1/embed"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "ai.run.read",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read the caller's own runs, their citations and request-log metadata (hashes, tokens, latency; never raw prompts).",
+ "permissions": [
+ "ai:read"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "ai-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/runs",
+ "GET /api/v1/runs/:id",
+ "GET /api/v1/runs/:id/citations"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+{
+ "id": "ai.usage.read",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Read usage, quota windows, request logs, the audit log, cache statistics, the admin summary and any caller's runs.",
+ "permissions": [
+ "ai:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "ai-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/status",
+ "GET /api/v1/usage",
+ "GET /api/v1/quotas",
+ "GET /api/v1/requests",
+ "GET /api/v1/audit",
+ "GET /api/v1/cache",
+ "GET /api/v1/providers",
+ "GET /api/v1/models"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "id": "ai.workflow.manage",
+ "version": "1.0",
+ "owner": "ai",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Create new versions of prompt templates, workflow definitions and routing profiles, and change their lifecycle status (draft, active, deprecated, archived; routes active/disabled). Every change is audited; edits never overwrite a version.",
+ "permissions": [
+ "ai:admin"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "ai-admin",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/templates/:key/versions",
+ "POST /api/v1/templates/:key/versions/:version/status",
+ "POST /api/v1/workflows/:key/versions",
+ "POST /api/v1/workflows/:key/versions/:version/status",
+ "POST /api/v1/routes/:key/versions",
+ "POST /api/v1/routes/:key/versions/:version/status"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+{
+ "version": "0.1.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.6.0 <1.0.0"
+ },
+ "id": "ai",
+ "name": "OpenVibe.AI",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.AI",
+ "domains": [
+ "ai.openvibe.network"
+ ],
+ "publicOrigin": "https://ai.openvibe.network",
+ "health": "/api/health",
+ "ready": "/api/ready",
+ "capabilities": [
+ "ai.run.create",
+ "ai.run.read",
+ "ai.workflow.manage",
+ "ai.provider.manage",
+ "ai.usage.read"
+ ],
+ "eventsProduced": [],
+ "eventsConsumed": [],
+ "namespacesOwned": [],
+ "notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached events follow once the Events client is wired in."
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+// The capability manifests proposed for openvibe-contracts (docs/capabilities-proposal/) are valid
+// contracts documents, use 3+ segment ids owned by 'ai', and are exactly the ids this service enforces.
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const { CAPS } = require('../server/auth');
+const { suite } = require('./helpers');
+
+const t = suite('proposals');
+const dir = path.join(__dirname, '..', 'docs', 'capabilities-proposal');
+
+t.test('every proposal validates against the contracts schemas', () => {
+ for (const f of fs.readdirSync(dir)) {
+ const doc = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
+ const v = contracts.validate(f.startsWith('service-manifest') ? 'registry.service-manifest' : 'capabilities.capability', doc);
+ assert.ok(v.valid, `${f}: ${JSON.stringify(v.errors)}`);
+ }
+});
+
+t.test('capability ids: owner ai, 3+ segments, the same set the service enforces and the manifest lists', () => {
+ const caps = fs.readdirSync(dir).filter(f => !f.startsWith('service-manifest')).map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
+ for (const c of caps) {
+ assert.strictEqual(c.owner, 'ai');
+ assert.ok(c.id.split('.').length >= 3, c.id);
+ assert.ok(!contracts.capabilities.get(c.id) || contracts.capabilities.get(c.id).owner === 'ai', `${c.id} is owned elsewhere in contracts`);
+ }
+ const ids = caps.map(c => c.id).sort();
+ assert.deepStrictEqual(ids, Object.values(CAPS).sort());
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'service-manifest.ai.json'), 'utf8'));
+ assert.deepStrictEqual([...manifest.capabilities].sort(), ids);
+});
+
+t.run();