openvibers/openvibe.ai Commits
Your AI (L36): the person's own keys, preferences and router (#224)
Changes
@@@ -33,3 +33,15 @@ description = "Made-up provider key in test/messages-api.test.js (the person's ocondition = "AND"paths = ['''^test/messages-api\.test\.js$''']regexes = ['''^sk-ant-test-person-0123456789$''']++++[[allowlists]]++description = "Made-up provider keys in test/me-credentials.test.js (L36: the self vault, test-on-save, the AAD, and the 'a key is never in an answer' test; placeholders, not secrets)"++condition = "AND"++paths = ['''^test/me-credentials\.test\.js$''']++regexes = ['''sk-test-dana-0123456789WXYZ''', '''sk-ant-test-0123456789ABCD''', '''sk-test-custom-0123456789''', '''sk-test-gemini-0123456789''', '''sk-test-ivan-LEAKME-0123456789''']++++[[allowlists]]++description = "Made-up provider key in test/account-data.test.js (L36: the account export must never carry the key; a placeholder, not a secret)"++condition = "AND"++paths = ['''^test/account-data\.test\.js$''']++regexes = ['''sk-self-ada-0123456789LEAK''']@@@ -26,6 +26,13 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.- 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-streamerattribution quotas (`ai.quota.attribution.manage`)++- **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every console 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`)- a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their ownmachine (Ollama, LM Studio, llama.cpp through the OpenVibe Node), 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`);@@@ -58,7 +65,8 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`;[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,`ai.usage.read`, `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/*`) and `ai.app.run` (developer apps, ADR-014; public,++`/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). A service token's `ns`claim limits which workflow namespaces it may run; an app token is fixed to its project's.@@@ -114,6 +122,7 @@ idempotent replay):| `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` |@@@ -7,7 +7,7 @@"runtime": "node 22, express, postgresql","port": 4700,"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",-- "contracts": "openvibe-contracts v0.252.0",++ "contracts": "openvibe-contracts v0.252.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)","sdk": "openvibe-sdk v0.75.0","shared": "openvibe-shared v4.62.0","capabilities": [@@@ -17,7 +17,12 @@"ai.provider.manage","ai.usage.read","ai.credential.manage",-- "ai.quota.attribution.manage"++ "ai.quota.attribution.manage",++ "ai.actions.read",++ "ai.memory.read",++ "ai.memory.write",++ "ai.me.read",++ "ai.me.manage"],"capabilitiesRegistered": true,"providers": [@@@ -39,6 +44,7 @@"scoped cache","citations or explicit gaps on every run","people's own provider keys, encrypted at rest",++ "Your AI (L36): the person's own keys under owner self for every console provider, tested on save (models, or one token) with status/last_error/models_count; only_my_keys, free_first and a price ceiling per person; a run on_behalf_of a person tries their keys first and explain names paid_by and every attempt; a 401/403 marks the key failing and publishes ai.credential.failed","media.analyze: local-first recording analysis with windowed speech-to-text","ai.run.* events through the SDK outbox","ai.preferences and ai.usage_summary user modules",nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- L36 Y1/Y8: the health of a person's own provider key, and every provider the console knows.++-- Expand only: the new columns have defaults, so rows and releases before this migration keep working; the++-- provider CHECK is widened to a superset, so an older release's inserts still pass.++-- status ok | failing | untested: what the last save-time or run-time check found++-- checked_at when that check ran (ISO 8601 UTC)++-- last_error the redacted reason it failed (never the key)++-- models_count how many models the provider listed when it was last tested++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS status text COLLATE "C" NOT NULL DEFAULT 'untested';++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS checked_at text COLLATE "C";++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS last_error text COLLATE "C";++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS models_count integer NOT NULL DEFAULT 0;++ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;++ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check++ CHECK (provider IN ('openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom',++ 'gemini', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks'));nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- L36 Y3: one person's ai.me-preferences (only_my_keys, free_first, price_ceiling_usd_per_mtok and a default++-- model or `auto` per kind of work), stored per person. Expand only: a service without this table keeps running++-- (its routes simply answer the defaults), and the row holds JSON so more preferences add without a migration.++CREATE TABLE IF NOT EXISTS ai_person_preferences (++ subject text COLLATE "C" PRIMARY KEY,++ preferences text COLLATE "C" NOT NULL DEFAULT '{}',++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0","pg": "^8.23.0"@@@ -1540,9 +1540,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.252.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",-- "integrity": "sha512-+b51PEN0xTiPlHtkrBpLq0cGP4bCZD2Irg0lAqm7CxA6QdIe2NFSq/kGsF6MmYXlGxIrOoe5Nw0u72xUDjgQSg==",++ "version": "0.257.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",++ "integrity": "sha512-fzyXLBKCqrOjUvlUB6UKxZQgrwOMhXaQK8lyxmmpkIujcd8AaIRW0DVTvYGIRJGCf/1Z3RiYS5gK1XLG8rkXFA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0","pg": "^8.23.0"@@@ -3,8 +3,10 @@* Account export and deletion → AI (ADR-033; openvibe-sdk/account-data). What AI holds about a person:** - their console and person sessions (Console sign-in and the AI chat), erased;-- * - the provider credentials they brought (server/credentials.js): the encrypted key, its hint and the budget, erased-- * with them (the key itself is never exported, only its hint);++ * - the provider credentials they brought (server/credentials.js), including the keys they hold themselves (owner++ * `self`, L36): the encrypted key, its hint, status and the budget, erased with them (the key itself is never++ * exported, only its hint and health);++ * - their ai.me-preferences (server/person-preferences.js): how their runs route. Their own settings, so they go;* - their runs: the workflows AI ran for them (runs.on_behalf_of is their SubjectRef) and the requests, citations and* private cache entries under them. Those are the person's own content, so they go with the account (ADR-033);* - the free-allowance usage the allowance ledger counts (server/free-allowance.js) and the usage counters: kept,@@@ -27,7 +29,10 @@ const TABLES = [{ table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',-- columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },++ columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'status', 'checked_at', 'last_error', 'models_count', 'created_at', 'updated_at', 'last_used_at'] },++ // L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own++ // settings, so they go with the account; the row holds no key.++ { table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is// never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.{ table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },@@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');const home = require('./home');const pkg = require('../package.json');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {const app = express();app.disable('x-powered-by');app.set('trust proxy', 'loopback');@@@ -117,6 +117,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).const personSessions = createPersonSessions({ db, config, clock });++ // The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only++ // the person's Network token or their AI site session (same origin) reaches them; a service token is refused.++ if (credentials && preferences) {++ app.use(require('./me').meRouter({ credentials, preferences, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ }app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));app.use(runPageRouter({ runs, personSessions, log }));@@@ -58,6 +58,9 @@ function load(env = process.env) {issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),++ // Test-on-save for a person's own key (L36 Y1): a model list, else one token, within this budget. Ten seconds in++ // production; a test may shorten it.++ credentialTestTimeoutMs: Math.max(50, Math.min(60000, int(env.AI_CREDENTIAL_TEST_TIMEOUT_MS, 10000))),// The person's memory (server/memory/): AES-256-GCM with a key of its own, so it can rotate apart from credentials.memoryKey: String(env.AI_MEMORY_KEY || '').trim(),// The store's own maintenance (server/memory/jobs.js): owner by owner, with a per-run cap. Off in test mode@@@ -28,4 +28,25 @@ const TEXT_CAPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enriconst byId = (id) => PRESETS.find((p) => p.id === id) || null;--module.exports = { PRESETS, TEXT_CAPS, byId };++/**++ * The providers a person may store their own key for (L36 Y1). One table: each credential provider is a preset++ * above, so its base URL and wire format (OpenAI-compatible or Anthropic Messages) are the console's, never a++ * second list. DeepSeek and MiniMax keep their Anthropic-format endpoints (their keys speak Messages there, which is++ * what a person's stored model expects); `custom` is the preset with an empty base URL, and a person names it.++ * `node` is not here: it is the person's own machine, not a key (server/credentials.js keeps its old route).++ */++const CREDENTIAL_PRESET = {++ anthropic: 'anthropic', openai: 'openai', gemini: 'gemini', deepseek: 'deepseek-anthropic',++ mistral: 'mistral', xai: 'xai', groq: 'groq', cerebras: 'cerebras', together: 'together',++ fireworks: 'fireworks', openrouter: 'openrouter', minimax: 'minimax-anthropic', custom: 'custom',++};++const CREDENTIAL_PROVIDERS = Object.keys(CREDENTIAL_PRESET);++++/** The console preset behind a credential provider: { id, kind, base_url, model, keys } or null. */++function credentialProvider(id) {++ const preset = byId(CREDENTIAL_PRESET[id]);++ return preset ? { id, kind: preset.kind, base_url: preset.base_url, model: preset.model, keys: preset.keys } : null;++}++++module.exports = { PRESETS, TEXT_CAPS, byId, CREDENTIAL_PROVIDERS, CREDENTIAL_PRESET, credentialProvider };++@@@ -1,61 +1,174 @@'use strict';/**-- * A person's own provider key (roadmap WS-O task 2; Contracts ai.credential.manage, ai.credential-put@2 and-- * ai.credential@2 since Codes C3). The service that holds the person's consent (Live, for a streamer's AI viewers;-- * Codes, for the people it builds for) stores the key here; it never leaves OpenVibe.AI again. Runs that name the-- * credential (ai.run-request credential { subject, provider? }) call that provider with that key only-- * (server/providers/index.js executeWithCredential), never a shared key.++ * A person's own provider key (roadmap WS-O task 2; L36 Y1/Y8; Contracts ai.credential.manage,++ * ai.credential-put@2 and ai.credential@2 since Codes C3, and ai.me.* since L36).*-- * PUT /api/v1/credentials/:subject store or replace the DEFAULT provider's credential (the v1 route)-- * PUT /api/v1/credentials/:subject/:provider store or replace that provider's credential-- * GET /api/v1/credentials/:subject[/:provider] read it back without the key: a four-character hint, today's spend++ * A service that holds the person's consent (Live for a streamer's AI viewers, Codes for the people it builds for)++ * stores the key here and never sees it again. A run that names the credential (ai.run-request credential++ * { subject, provider? }) calls that provider with that key only (server/providers/index.js++ * executeWithCredential), never a shared key.++ *++ * PUT /api/v1/credentials/:subject store the DEFAULT provider's credential (the v1 route)++ * PUT /api/v1/credentials/:subject/:provider store that provider's credential++ * GET /api/v1/credentials/:subject[/:provider] read it back without the key* DELETE /api/v1/credentials/:subject[/:provider]*-- * A person may keep one credential per provider (anthropic, openai, deepseek, minimax, openrouter, custom, node), each-- * with its own daily budget; the provider-less routes read and write the default one, which is the row a pre-C3 caller-- * stored (migrations/0014).++ * The person's own routes (L36 Y1) store keys the person holds themselves under the reserved owner `self`:++ *++ * GET /api/v1/me/credentials++ * PUT /api/v1/me/credentials/:provider++ * DELETE /api/v1/me/credentials/:provider++ * POST /api/v1/me/credentials/:provider/test++ *++ * A person may keep one credential per provider (the console presets: anthropic, openai, gemini, deepseek, mistral,++ * xai, groq, cerebras, together, fireworks, openrouter, minimax and a custom endpoint; plus node for the old routes),++ * each with its own daily budget. The provider-less routes read and write the default one, which is the row a pre-C3++ * caller stored (migrations/0014).** At rest the key is AES-256-GCM encrypted with AI_CREDENTIALS_KEY (64 hex characters) and bound to its owner, subject-- * AND provider (the additional data), so a row copied to another owner, subject or provider does not decrypt. Rows-- * written before the provider dimension were sealed with owner|subject only (`v1:`); they are read with that AAD and-- * rewritten as `v2:` (owner|subject|provider) on first use. Without the key configured the routes answer 503 and-- * credentialed runs are refused. Spend is what the credential's requests cost today (provider_key byo:<owner>:<subject>-- * for a row stored before C3 or through the provider-less route, byo:<owner>:<subject>:<provider> for the rest),-- * checked against the optional budget_usd_per_day.++ * AND provider (the additional data), so a row copied to another owner, subject or provider does not decrypt. The++ * person's own rows use the reserved owner `self` and the same scheme (`self|subject|provider`). Rows written before++ * the provider dimension were sealed with owner|subject only (`v1:`); they are read with that AAD and rewritten as++ * `v2:` (owner|subject|provider) on first use. Without the key configured the routes answer 503 and credentialed runs++ * are refused. Spend is what the credential's requests cost today (provider_key byo:<owner>:<subject> for a row++ * stored before C3 or through the provider-less route, byo:<owner>:<subject>:<provider> for the rest), checked against++ * the optional budget_usd_per_day. A person's own key is never billed: its runs carry no network budget.++ *++ * Test on save (L36 Y1): list the provider's models and count them; if it lists none, one token to its cheapest model;++ * store status, checked_at, models_count and a redacted last_error. The key is never returned, never logged and never++ * put in an error, even when the provider echoes it.*-- * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing is-- * sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.++ * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing++ * is sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.*/const crypto = require('crypto');const express = require('express');--const { validate } = require('openvibe-contracts');++const contracts = require('openvibe-contracts');++const { validate } = contracts;++++/**++ * ai.me-* are added by openvibe-contracts 0.257.0. A service pinning an older release (until the owner tags it) has no++ * schema for them, so the route validates the essential shape itself; with 0.257.0 or later the contract is the rule.++ */++function validateMe(id, body) {++ try { return contracts.schema(id) ? contracts.validate(id, body) : null; } catch { return null; }++}++function manualMePut(provider, body) {++ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';++ if (body.provider !== provider) return `body.provider is ${body.provider}, the route names ${provider}`;++ if (body.key != null && (typeof body.key !== 'string' || body.key.length < 8 || body.key.length > 400)) return 'key must be 8-400 characters';++ if (body.base_url != null && !/^https:\/\/[^\s/?#@]+(\/[^\s?#]*)?$/.test(String(body.base_url))) return 'base_url must be an https URL without credentials, query or fragment';++ if (provider === 'custom' && !body.base_url) return 'a custom provider needs base_url';++ if (body.models != null && (typeof body.models !== 'object' || Array.isArray(body.models))) return 'models must be an object';++ if (body.budget_usd_per_day != null && !(Number(body.budget_usd_per_day) >= 0 && Number(body.budget_usd_per_day) <= 1000)) return 'budget_usd_per_day must be 0-1000';++ return null;++}const { AiError } = require('./util');++const { CREDENTIAL_PROVIDERS, credentialProvider } = require('./console/presets');const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];--/** Where each provider answers when the person stored no base_url. DeepSeek and MiniMax speak the Anthropic format at-- * their own /anthropic path; a custom provider always names its endpoint. */--const PROVIDER_BASE_URL = {-- anthropic: 'https://api.anthropic.com/v1', openai: 'https://api.openai.com/v1', deepseek: 'https://api.deepseek.com/anthropic',-- minimax: 'https://api.minimax.io/anthropic', openrouter: 'https://openrouter.ai/api/v1',++/** The reserved owner of a key the person holds themselves (L36 Y1). */++const SELF_OWNER = 'self';++const LEGACY_PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];++const PROVIDERS = [...new Set([...LEGACY_PROVIDERS, ...CREDENTIAL_PROVIDERS])];++/** Where each provider answers when the person stored no base_url: the ONE table in server/console/presets.js. */++const PROVIDER_BASE_URL = Object.fromEntries(CREDENTIAL_PROVIDERS++ .map((id) => [id, (credentialProvider(id) || {}).base_url || null])++ .filter(([, url]) => url));++const PROVIDER_KIND = Object.fromEntries(CREDENTIAL_PROVIDERS.map((id) => [id, (credentialProvider(id) || {}).kind || 'openai']));++/** The model a provider's test uses when it lists no models (and a saved key names none). */++const DEFAULT_MODEL = {++ anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', gemini: 'gemini-2.5-flash', deepseek: 'deepseek-chat',++ mistral: 'mistral-small-latest', xai: 'grok-4-fast', groq: 'openai/gpt-oss-20b', cerebras: 'llama-3.3-70b',++ together: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', fireworks: 'accounts/fireworks/models/llama-v3p3-70b-instruct',++ openrouter: 'openrouter/auto', minimax: 'MiniMax-M2', custom: null,};++/** Providers that report a balance somewhere public (L36 Y1 "its balance where the provider reports one"). */++const BALANCE_PROVIDERS = new Set(['openrouter', 'deepseek']);function providerKeyFor(owner, subject, provider = null) { return provider ? `byo:${owner}:${subject}:${provider}` : `byo:${owner}:${subject}`; }/** The AAD a key is sealed with: moving the row to another owner, subject or provider must not decrypt it. */function aadFor(owner, subject, provider) { return `${owner}|${subject}|${provider}`; }function legacyAadFor(owner, subject) { return `${owner}|${subject}`; }++const hintOf = (key) => `…${String(key).slice(-4)}`;++++/** Replace the key (and anything that looks like one) before an upstream error is stored, logged or answered. */++function scrub(text, key) {++ let t = String(text == null ? '' : text);++ if (key) t = t.split(String(key)).join('[redacted]');++ return t.replace(/\b(sk|key|token)[-_][A-Za-z0-9_-]{8,}\b/gi, '[redacted]').slice(0, 500);++}--function createCredentials({ db, config, clock = { now: () => Date.now() } }) {++/** A provider's auth headers, the same way its adapter sends them (server/providers/anthropic.js). */++function authHeaders(provider, base, key) {++ if (PROVIDER_KIND[provider] === 'anthropic') {++ const headers = { 'x-api-key': key, 'anthropic-version': '2023-06-01' };++ if (!/anthropic\.com/i.test(String(base || ''))) headers.Authorization = `Bearer ${key}`;++ return headers;++ }++ return { Authorization: `Bearer ${key}` };++}++++function withDeadline(promise, ms, message) {++ let timer;++ const timeout = new Promise((_, reject) => {++ timer = setTimeout(() => reject(Object.assign(new Error(message || `no response within ${Math.round(ms / 1000)}s`), { code: 'provider.timeout' })), ms);++ if (timer.unref) timer.unref();++ });++ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));++}++++async function bodyText(res) {++ try { return await res.text(); } catch { return ''; }++}++++function modelIds(json) {++ const list = Array.isArray(json) ? json++ : json && Array.isArray(json.data) ? json.data++ : json && Array.isArray(json.models) ? json.models : [];++ const out = [];++ for (const m of list) {++ const id = typeof m === 'string' ? m : m && (m.id || m.name);++ if (typeof id === 'string' && id && !out.includes(id)) out.push(id);++ }++ return out;++}++++/** { amount, currency } when the provider reports one, else null. A balance failure never fails the key test. */++async function balanceOf(provider, base, key, fetchImpl, timeoutMs) {++ if (!BALANCE_PROVIDERS.has(provider)) return null;++ try {++ const url = provider === 'openrouter'++ ? `${String(base).replace(/\/+$/, '')}/credits`++ : `${new URL(base).origin}/user/balance`;++ const res = await withDeadline(fetchImpl(url, { method: 'GET', headers: { Accept: 'application/json', ...authHeaders(provider, base, key) }, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);++ if (!res.ok) return null;++ const j = await res.json().catch(() => null);++ if (!j) return null;++ if (provider === 'openrouter') {++ const d = j.data || j;++ const amount = Number(d.total_credits) - Number(d.total_usage);++ return Number.isFinite(amount) ? { amount: Math.round(amount * 1e6) / 1e6, currency: 'USD' } : null;++ }++ const info = Array.isArray(j.balance_infos) ? j.balance_infos[0] : null;++ const amount = Number(info && info.total_balance);++ return Number.isFinite(amount) ? { amount, currency: String((info && info.currency) || 'USD').toUpperCase().slice(0, 3) } : null;++ } catch { return null; }++}++++function createCredentials({ db, config, clock = { now: () => Date.now() }, fetchImpl = null }) {// subject_credentials is in migrations/0001_initial.sql (ADR-035); 0011 added the node machine, 0014 the provider-- // dimension (is_default, spend_key, one row per provider).++ // dimension (is_default, spend_key, one row per provider), 0019 the health of a key.const iso = (ms) => new Date(ms).toISOString();++ const doFetch = fetchImpl || require('./providers/guarded-fetch').guardedFetch;++ const testTimeout = () => Number(config.credentialTestTimeoutMs) || 10000;function secretKey() {const hex = String(config.credentialsKey || '');if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'credentials.unavailable', 'person credentials are not configured on this service (AI_CREDENTIALS_KEY)');return Buffer.from(hex, 'hex');}-- /** v2: bound to owner, subject and provider (Codes C3). */++ /** v2: bound to owner, subject and provider (Codes C3; `self` for the person's own rows, L36). */function seal(plain, aad) {const iv = crypto.randomBytes(12);const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);@@@ -100,6 +213,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,};}++ /** The person's own view (ai.me-credential@1): no owner/subject/spend, no key, a four-character hint. */++ function selfView(row) {++ return {++ provider: row.provider, hint: String(row.key_hint || '').slice(-4) || '????',++ base_url: row.base_url || null,++ budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,++ status: row.status || 'untested', checked_at: row.checked_at || null, last_error: row.last_error || null,++ models_count: Number(row.models_count) || 0,++ created_at: row.created_at, updated_at: row.updated_at,++ };++ }const getRow = async (owner, subject, provider) => provider? await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(owner, subject, provider): await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND is_default').get(owner, subject);@@@ -142,7 +266,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = excluded.base_url, key_enc = excluded.key_enc,key_hint = excluded.key_hint, models = excluded.models, budget_usd_day = excluded.budget_usd_day, is_default = subject_credentials.is_default OR excluded.is_default, updated_at = excluded.updated_at`)-- .run(owner, subject, body.provider, body.base_url || null, seal(key, aadFor(owner, subject, body.provider)), `…${key.slice(-4)}`, JSON.stringify(body.models || {}),++ .run(owner, subject, body.provider, body.base_url || null, seal(key, aadFor(owner, subject, body.provider)), hintOf(key), JSON.stringify(body.models || {}),body.budget_usd_per_day ? Number(body.budget_usd_per_day) : null, makeDefault,(prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, body.provider)), prev ? prev.created_at : at, at);});@@@ -194,18 +318,182 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {}}-- /** The decrypted credential for one execution: { providerKey, provider, base_url, apiKey|machine, models }. */++ /** The decrypted credential for one execution: { providerKey, owner, provider, base_url, apiKey|machine, models, hint }. */async function forRun(owner, subject, provider = null) {if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');const r = await getRow(owner, subject, provider);if (!r) throw new AiError(404, 'credential.not_found', provider ? `the ${provider} credential was deleted` : 'the credential was deleted');await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);const providerKey = r.spend_key || providerKeyFor(owner, subject);-- if (r.provider === 'node') return { providerKey, provider: 'node', subject, machine: r.machine, models: JSON.parse(r.models || '{}') };-- return { providerKey, provider: r.provider, subject, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}') };++ const hint = String(r.key_hint || '').slice(-4);++ if (r.provider === 'node') return { providerKey, owner: r.owner, subject, provider: 'node', machine: r.machine, models: JSON.parse(r.models || '{}'), hint };++ return { providerKey, owner: r.owner, subject, provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint };++ }++++ // ── The person's own keys (owner `self`, L36 Y1) ───────────────────────++++ /** Every self row for one person, newest first. */++ async function selfRows(subject) {++ if (!SUBJECT_RE.test(String(subject))) return [];++ return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'node' ORDER BY provider").all(SELF_OWNER, subject);++ }++ async function listSelf(subject) {++ return (await selfRows(subject)).map(selfView);++ }++ async function hasSelf(subject) {++ return (await selfRows(subject)).length > 0;++ }++++ /** A person's own credential for one provider (no key returned to the caller; server-side use only). */++ async function selfForRun(subject, provider) {++ if (!CREDENTIAL_PROVIDERS.includes(provider)) return null;++ const r = await getRow(SELF_OWNER, subject, provider);++ if (!r) return null;++ await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), SELF_OWNER, subject, provider);++ return {++ providerKey: r.spend_key || providerKeyFor(SELF_OWNER, subject, provider), owner: SELF_OWNER, subject,++ provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null,++ apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint: String(r.key_hint || '').slice(-4), status: r.status,++ };++ }++ /** Every self credential a run may try first (L36 Y6). A row whose key cannot be opened is skipped, never fatal. */++ async function selfForAllRuns(subject) {++ const out = [];++ for (const r of await selfRows(subject)) {++ try {++ const cred = await selfForRun(subject, r.provider);++ if (cred) out.push(cred);++ } catch { /* a broken row must not take the person's other keys with it */ }++ }++ return out;++ }++++ /** The person's own PUT: store or replace one provider's key, then test it on save. */++ async function putSelf(subject, provider, body) {++ if (!SUBJECT_RE.test(String(subject))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');++ if (!CREDENTIAL_PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', `no credential is stored for ${provider}`);++ const v = validateMe('ai.me-credential-put@1', body);++ const manual = v ? null : manualMePut(provider, body);++ if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-credential-put@1', { errors: v ? v.errors : [{ path: '', message: manual }] });++ if (body.provider !== provider) throw new AiError(422, 'input.invalid', `body.provider is ${body.provider}, the route names ${provider}`);++ const prev = await getRow(SELF_OWNER, subject, provider);++ const at = iso(clock.now());++ const sameEndpoint = prev && (prev.base_url || null) === (body.base_url || null);++ if (body.key == null && !sameEndpoint) throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the endpoint' : 'key is required the first time');++ const key = body.key != null ? String(body.key) : await openRow(prev);++ await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, key_enc, key_hint, models, budget_usd_day, is_default, spend_key, status, checked_at, last_error, models_count, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, false, ?, 'untested', NULL, NULL, 0, ?, ?)++ ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = excluded.base_url, key_enc = excluded.key_enc,++ key_hint = excluded.key_hint, models = excluded.models, budget_usd_day = excluded.budget_usd_day,++ status = 'untested', checked_at = NULL, last_error = NULL, models_count = 0, updated_at = excluded.updated_at`)++ .run(SELF_OWNER, subject, provider, body.base_url || null, seal(key, aadFor(SELF_OWNER, subject, provider)), hintOf(key),++ JSON.stringify(body.models || {}), body.budget_usd_per_day ? Number(body.budget_usd_per_day) : null,++ providerKeyFor(SELF_OWNER, subject, provider), prev ? prev.created_at : at, at);++ await testSelf(subject, provider);++ return selfView(await getRow(SELF_OWNER, subject, provider));}-- return { put, get, remove, admit, forRun, spentToday, providerKeyFor };++ async function removeSelf(subject, provider) {++ if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;++ return (await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, provider)).changes > 0;++ }++++ /** Mark a key failing (a provider 401/403 in a run, L36 Y8). Never stores the key. */++ async function markFailed(owner, subject, provider, message) {++ if (!owner || !SUBJECT_RE.test(String(subject)) || !PROVIDERS.includes(provider)) return false;++ const at = iso(clock.now());++ return (await db.prepare('UPDATE subject_credentials SET status = ?, checked_at = ?, last_error = ? WHERE owner = ? AND subject = ? AND provider = ?')++ .run('failing', at, String(message || 'the provider refused the key').slice(0, 500), owner, subject, provider)).changes > 0;++ }++++ /**++ * Test one stored key (L36 Y1): the model list, else one token to its cheapest model, within testTimeout().++ * Stores status, checked_at, models_count and a redacted last_error, and returns the ai.me-credential-test@1 body.++ * For a custom (or overridden) endpoint the production fetch is the guarded fetcher, so the egress rules hold.++ */++ async function testSelf(subject, provider) {++ if (!CREDENTIAL_PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', `no credential is stored for ${provider}`);++ const row = await getRow(SELF_OWNER, subject, provider);++ if (!row) throw new AiError(404, 'credential.not_found', `no ${provider} credential stored`);++ const key = await openRow(row);++ const spec = credentialProvider(provider) || {};++ const base = String(row.base_url || PROVIDER_BASE_URL[provider] || '').replace(/\/+$/, '');++ const timeoutMs = testTimeout();++ const started = Date.now();++ let models = [];++ let status = 'ok';++ let error = null;++ let balance = null;++ const headers = { Accept: 'application/json', 'Content-Type': 'application/json', ...authHeaders(provider, base, key) };++ if (!base) {++ status = 'failing';++ error = { code: 'credential.endpoint_required', message: 'this provider needs a base URL' };++ } else {++ try {++ const res = await withDeadline(doFetch(`${base}/models`, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);++ if (res.ok) {++ models = modelIds(await res.json().catch(() => null));++ } else if (res.status === 401 || res.status === 403) {++ throw Object.assign(new Error(`the provider refused the key (${res.status})`), { status: res.status, code: 'provider.auth' });++ } else {++ // A provider with no /models endpoint is not a failed key: try the one-token call below.++ models = [];++ }++ } catch (err) {++ if (err && (err.status === 401 || err.status === 403)) {++ status = 'failing';++ error = { code: 'provider.auth', message: scrub(err.message, key) };++ } else if (err && err.code === 'provider.timeout') {++ status = 'failing';++ error = { code: 'provider.timeout', message: scrub(err.message, key) };++ } else if (err && err.code === 'EADDRNOTPUBLIC') {++ // The guarded fetcher's egress rule (https, public address, no credentials in the URL) refused the++ // endpoint: that is a bad endpoint, not a provider without a model list.++ status = 'failing';++ error = { code: 'credential.endpoint_refused', message: scrub(err.message, key) };++ } else {++ models = []; // no list: the one-token request decides++ }++ }++ if (!error && !models.length) {++ const model = (JSON.parse(row.models || '{}').chat) || spec.model || DEFAULT_MODEL[provider];++ try {++ if (!model) throw Object.assign(new Error('no model is known for this provider'), { code: 'credential.no_model' });++ const url = PROVIDER_KIND[provider] === 'anthropic' ? `${base}/messages` : `${base}/chat/completions`;++ const body = PROVIDER_KIND[provider] === 'anthropic'++ ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }++ : { model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] };++ const res = await withDeadline(doFetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);++ if (!res.ok) {++ const text = await bodyText(res);++ const auth = res.status === 401 || res.status === 403;++ throw Object.assign(new Error(`${auth ? 'the provider refused the key' : 'the provider did not answer the test request'} (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`), { status: res.status, code: auth ? 'provider.auth' : 'provider.http' });++ }++ } catch (err) {++ status = 'failing';++ error = {++ code: err.status === 401 || err.status === 403 ? 'provider.auth'++ : err.code === 'provider.timeout' ? 'provider.timeout'++ : err.code === 'EADDRNOTPUBLIC' ? 'credential.endpoint_refused' : 'provider.http',++ message: scrub(err.message, key),++ };++ }++ }++ if (!error) balance = await balanceOf(provider, base, key, doFetch, timeoutMs);++ }++ const checkedAt = iso(clock.now());++ await db.prepare('UPDATE subject_credentials SET status = ?, checked_at = ?, last_error = ?, models_count = ? WHERE owner = ? AND subject = ? AND provider = ?')++ .run(status, checkedAt, error ? error.message : null, models.length, SELF_OWNER, subject, provider);++ return {++ ok: status === 'ok', models_count: models.length, sample_models: models.slice(0, 5),++ latency_ms: Math.max(0, Date.now() - started), balance, error,++ };++ }++++ return {++ put, get, remove, admit, forRun, spentToday, providerKeyFor,++ listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed,++ };}/** PUT/GET/DELETE /api/v1/credentials/:subject[/:provider] (ai.credential.manage; a service token speaks for itself). */@@@ -258,4 +546,7 @@ function credentialsRouter({ credentials, auth, registry, sendError }) {return r;}--module.exports = { createCredentials, credentialsRouter, providerKeyFor, aadFor, PROVIDERS, PROVIDER_BASE_URL };++module.exports = {++ createCredentials, credentialsRouter, providerKeyFor, aadFor, PROVIDERS, PROVIDER_BASE_URL, PROVIDER_KIND,++ CREDENTIAL_PROVIDERS, SELF_OWNER, DEFAULT_MODEL, scrub, hintOf,++};@@@ -88,6 +88,25 @@ async function runChanged(row) {return env;}++/**++ * L36 Y8: a provider refused one of a person's own keys (401/403) during a run. The key's status is already++ * `failing` (server/credentials.js markFailed); this tells the products that may show it. The payload never carries++ * the key, only a redacted reason. No-op while publishing is off, like every other event.++ */++async function credentialFailed({ subject, provider, error, owner = null }) {++ if (!svc || !dbRef || !subject || !provider) return null;++ const env = await svc.emitIn(dbRef, {++ event_type: 'ai.credential.failed',++ actor: { type: 'service', id: 'ai' },++ on_behalf_of: { type: 'user', id: subject },++ subject: { type: 'credential', id: `${subject}:${provider}` },++ visibility: 'internal',++ priority: 'important',++ payload: { subject, provider, status: 'failing', checked_at: iso(Date.now()), error: String(error || 'the provider refused the key').slice(0, 500) },++ });++ dbRef.afterCommit(() => svc && svc.kick());++ return env;++}async function status() {if (!svc) return { enabled: false };const s = await svc.status();@@@ -106,4 +125,4 @@ async function stop() {}function _reset() { if (svc) svc.stop(); svc = null; queued = 0; }--module.exports = { init, runChanged, payloadOf, status, stop, _reset };++module.exports = { init, runChanged, credentialFailed, payloadOf, status, stop, _reset };@@@ -61,10 +61,14 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowrequire('./usage-samples').init(db, { ...config.billing, clientId: config.console.clientId, networkUrl: config.networkInternalUrl, log });// ai.preferences (read) and ai.usage_summary (written): Network user modules (server/user-modules.js).const userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });-- // A person's own provider keys (WS-O task 2): stored by the service holding their consent, used by their runs only.-- const credentials = require('./credentials').createCredentials({ db, config, clock });++ // A person's own provider keys (WS-O task 2; L36 Y1): stored by the service holding their consent, or by the++ // person themselves (owner `self`), used by their runs only. The test seam (a fake fetch) is for tests; production++ // reaches a custom endpoint through the guarded fetcher's egress rules.++ const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch });++ // A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.++ const preferences = require('./person-preferences').createPreferences({ db, clock });const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers, projectSecrets, pool, runBudgets });++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets });await seed({ registry, quotas, config, env, db });const interrupted = await runs.recoverInterrupted();if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);@@@ -86,7 +90,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets, memory });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, vault, benchEvents, runBudgets, memory });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++/**++ * The person's own AI routes (L36 Y1/Y3): their provider keys and how their runs route. Contracts++ * ai.me.read / ai.me.manage, ai.me-credential(-put|-test|-list)@1 and ai.me-preferences@1.++ *++ * GET /api/v1/me/credentials++ * PUT /api/v1/me/credentials/:provider++ * DELETE /api/v1/me/credentials/:provider++ * POST /api/v1/me/credentials/:provider/test++ * GET /api/v1/me/ai/preferences++ * PUT /api/v1/me/ai/preferences++ *++ * Only the person themselves reaches them: their Network user token, or the person session AI's own site already++ * issues for a run receipt, behind a same-origin check. A service token is refused — including any token carrying++ * `on_behalf_of`, whatever capabilities it holds (memory rule, AI #204/#205): a Codes run token holds ai.run.create,++ * so a capability check alone would admit an agent. `ai.me.*` are person capabilities, never a service's.++ *++ * Writes are stricter than reads. A user token must carry audience `openvibe.ai` explicitly: a token minted for++ * `openvibe.network` is every OpenVibe site's session, and a site's server holding it must not be able to point a++ * person's prompts at its own endpoint or delete their keys. Such a token may still read (hints and settings only).++ * AI's own person session must prove same-origin for a write: with neither Origin nor Sec-Fetch-Site the write is++ * refused, so a site cannot use the cookie with a bare request. Reads keep accepting that cookie as before.++ *++ * The key is never returned (a four-character hint only), never logged and never put in an error.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { verifyUserToken } = require('openvibe-sdk/auth');++const { AiError } = require('./util');++const { bearer } = require('./auth');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const NON_PERSON = /^(svc|app|mod|node):/;++++/** The audience a write needs: a token minted for AI itself, never a site's `openvibe.network` session. */++const WRITE_AUDIENCE = 'openvibe.ai';++++function audiencesOf(claims) {++ const aud = claims && claims.aud;++ return (Array.isArray(aud) ? aud : aud == null ? [] : [aud]).map(String);++}++++/**++ * The same-origin check for the person-session cookie (the console's csrfOk rule, without a form token).++ * A write with neither Origin nor Sec-Fetch-Site is refused: a site can make a bare request with the cookie, so the++ * absence of both is not proof of same-origin. A read may keep the old rule (it returns hints and settings only).++ */++function sameOrigin(req, config, { write = false } = {}) {++ const site = req.headers['sec-fetch-site'];++ const origin = req.headers.origin;++ if (!site && !origin) return !write;++ if (site && site !== 'same-origin' && site !== 'none') return false;++ if (origin) {++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ }++ return true;++}++++function meRouter({ credentials, preferences, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {++ const r = express.Router();++++ /** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */++ function guard(req, res, next) {++ return Promise.resolve().then(async () => {++ const ctx = req.ov;++ const write = req.method !== 'GET' && req.method !== 'HEAD';++ const token = bearer(req);++ if (token) {++ // A service, app, module or node token is a principal whatever capabilities it holds. Checked first so++ // the answer is explicit, never "invalid token".++ let service = null;++ try { service = await auth.verify(token); } catch { service = { ok: false }; }++ if (service && service.ok) return http.sendProblem(res, 403, 'capability.denied', { detail: "a service token is not accepted on the person's own route", ctx });++ let claims;++ try {++ claims = await verifyUserToken(token, {++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),++ issuer: config.issuer, audience: ['openvibe.network', 'openvibe.ai'], now: clock.now(), log,++ });++ } catch (err) {++ return http.sendProblem(res, err.status || 401, err.code || 'token.invalid', { detail: err.detail || 'invalid token', ctx });++ }++ if (NON_PERSON.test(String(claims.sub || '')) || ['service', 'app', 'mod', 'node'].includes(claims.actor_type)) {++ return http.sendProblem(res, 403, 'capability.denied', { detail: "a principal token is not accepted on the person's own route", ctx });++ }++ if (claims.on_behalf_of != null) return http.sendProblem(res, 403, 'capability.denied', { detail: 'a token acting on behalf of someone is not the person themselves', ctx });++ // A token minted for openvibe.network is every OpenVibe site's session: it may read hints and settings,++ // but a write needs a token whose aud explicitly names AI itself.++ if (write && !audiencesOf(claims).includes(WRITE_AUDIENCE)) {++ return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });++ }++ const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');++ if (!subject) return http.sendProblem(res, 401, 'token.invalid', { detail: 'the person token has no usr_ subject', ctx });++ req.principal = { kind: 'user', sub: claims.sub, subject: { type: 'user', id: subject }, claims };++ return next();++ }++ // The person session AI already issues for a run receipt. Cookie auth needs the same origin, so another++ // site cannot spend a person's keys with their cookie; a write also needs the origin signal to be present.++ if (!personSessions) return http.sendProblem(res, 401, 'token.missing', { detail: "the person's own token or session is required", ctx });++ if (!sameOrigin(req, config, { write })) return http.sendProblem(res, 403, 'capability.denied', { detail: 'the person session is only used from ' + new URL(config.baseUrl).origin + (write ? ', with an Origin or Sec-Fetch-Site header' : ''), ctx });++ const session = await personSessions.read(req);++ if (!session) return http.sendProblem(res, 401, 'token.missing', { detail: "the person's own token or session is required", ctx });++ if (!SUBJECT_RE.test(String(session.subject || ''))) return http.sendProblem(res, 401, 'token.invalid', { detail: 'the session has no usr_ subject', ctx });++ req.principal = { kind: 'user', subject: { type: 'user', id: session.subject }, username: session.username || null };++ return next();++ }).catch((err) => {++ log.error(`[me] ${req.method} ${req.path}: ${(err && err.stack) || err}`);++ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx: req.ov });++ });++ }++ const personOf = (req) => req.principal.subject.id;++ const noStore = (res) => res.set('Cache-Control', 'no-store');++ const run = (fn) => async (req, res) => {++ try { noStore(res).json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }++ };++ const audit = async (req, action, metadata) => {++ if (!registry) return;++ try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }++ };++++ r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: await credentials.listSelf(personOf(req)) })));++ r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {++ const out = await credentials.putSelf(personOf(req), req.params.provider, { ...(req.body || {}), ...(req.body && req.body.provider == null ? { provider: req.params.provider } : {}) });++ await audit(req, 'credential.self.put', { provider: out.provider, status: out.status, base_url: out.base_url });++ return out;++ }));++ r.delete('/api/v1/me/credentials/:provider', guard, async (req, res) => {++ try {++ const gone = await credentials.removeSelf(personOf(req), req.params.provider);++ if (gone) await audit(req, 'credential.self.delete', { provider: req.params.provider });++ res.status(gone ? 204 : 404).end();++ } catch (err) { sendError(res, err, req.ov); }++ });++ r.post('/api/v1/me/credentials/:provider/test', guard, run(async (req) => {++ const out = await credentials.testSelf(personOf(req), req.params.provider);++ await audit(req, 'credential.self.test', { provider: req.params.provider, ok: out.ok, models_count: out.models_count });++ return out;++ }));++ r.get('/api/v1/me/ai/preferences', guard, run(async (req) => await preferences.get(personOf(req))));++ r.put('/api/v1/me/ai/preferences', guard, run(async (req) => {++ const out = await preferences.put(personOf(req), req.body || {});++ await audit(req, 'ai.preferences.put', { only_my_keys: out.only_my_keys, free_first: out.free_first, price_ceiling_usd_per_mtok: out.price_ceiling_usd_per_mtok });++ return out;++ }));++ return r;++}++++module.exports = { meRouter, sameOrigin };@@@ -12,8 +12,10 @@** /api/health and /api/ready are probes. The operator/admin API and the attribution-quota API are* internal (staff/service tokens): they stay out. Credentials are deliberately not documented at all-- * — no credential, provider secret or secret reference field appears in this document. The-- * OpenAI-compatible /v1/chat/completions and /v1/models are outside /api and stay out of it too.++ * — no credential, provider secret or secret reference field appears in this document. The person's++ * own /api/v1/me/* routes (their provider keys, and the preferences that route them) are person-only++ * and carry a key on PUT, so they stay out for the same reason. The OpenAI-compatible++ * /v1/chat/completions and /v1/models are outside /api and stay out of it too.** Request fields are what server/api/runs.js and server/api/actions.js read; the run properties are* server/runs.js decode(). Nothing is inferred.nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++/**++ * A person's ai.preferences (L36 Y3; Contracts ai.me-preferences@1): only_my_keys, free_first, a price ceiling for++ * the network's paid fallback, and a default model (or `auto`) per kind of work. Stored per person in++ * ai_person_preferences (migrations/0020), JSON so another preference adds without a migration.++ *++ * A row that predates the table or a field it never saw reads the default, never undefined. There is no default key++ * and no network budget here: own keys are free to their owner.++ */++const contracts = require('openvibe-contracts');++const { AiError, iso } = require('./util');++++const DEFAULTS = Object.freeze({++ only_my_keys: false,++ free_first: true,++ price_ceiling_usd_per_mtok: null,++ defaults: Object.freeze({ text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }),++});++++/** The same rules ai.me-preferences@1 states, for a service whose contracts pin predates 0.257.0. */++function manualPreferences(body) {++ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';++ if (typeof body.only_my_keys !== 'boolean' || typeof body.free_first !== 'boolean') return 'only_my_keys and free_first are booleans';++ if (body.price_ceiling_usd_per_mtok != null && !(Number(body.price_ceiling_usd_per_mtok) >= 0 && Number(body.price_ceiling_usd_per_mtok) <= 1000)) return 'price_ceiling_usd_per_mtok must be 0-1000 or null';++ const d = body.defaults;++ if (!d || typeof d !== 'object' || Array.isArray(d)) return 'defaults is an object';++ for (const k of ['text', 'code', 'vision', 'image']) {++ if (typeof d[k] !== 'string' || !d[k].length || d[k].length > 120) return `defaults.${k} must be a model id or auto (1-120 characters)`;++ }++ return null;++}++++function createPreferences({ db, clock = { now: () => Date.now() } }) {++ const read = async (subject) => {++ const row = await db.prepare('SELECT preferences FROM ai_person_preferences WHERE subject = ?').get(String(subject));++ let stored = {};++ try { stored = row ? JSON.parse(row.preferences || '{}') : {}; } catch { stored = {}; }++ return {++ only_my_keys: stored.only_my_keys === true,++ free_first: stored.free_first == null ? DEFAULTS.free_first : stored.free_first === true,++ price_ceiling_usd_per_mtok: typeof stored.price_ceiling_usd_per_mtok === 'number' ? stored.price_ceiling_usd_per_mtok : null,++ defaults: { ...DEFAULTS.defaults, ...(stored.defaults && typeof stored.defaults === 'object' ? stored.defaults : {}) },++ updated_at: row ? row.updated_at : null,++ };++ };++++ async function get(subject) { return await read(subject); }++++ async function put(subject, body) {++ // ai.me-preferences@1 arrives with openvibe-contracts 0.257.0; before that pin the shape is checked here.++ let v = null;++ try { v = contracts.schema('ai.me-preferences@1') ? contracts.validate('ai.me-preferences@1', body) : null; } catch { v = null; }++ const manual = v ? null : manualPreferences(body);++ if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-preferences@1', { errors: v ? v.errors : [{ path: '', message: manual }] });++ const value = {++ only_my_keys: body.only_my_keys === true,++ free_first: body.free_first === true,++ price_ceiling_usd_per_mtok: body.price_ceiling_usd_per_mtok == null ? null : Number(body.price_ceiling_usd_per_mtok),++ defaults: {++ text: String(body.defaults.text), code: String(body.defaults.code),++ vision: String(body.defaults.vision), image: String(body.defaults.image),++ },++ };++ const at = iso(clock.now());++ const prev = await db.prepare('SELECT created_at FROM ai_person_preferences WHERE subject = ?').get(String(subject));++ await db.prepare(`INSERT INTO ai_person_preferences (subject, preferences, created_at, updated_at)++ VALUES (?, ?, ?, ?)++ ON CONFLICT(subject) DO UPDATE SET preferences = excluded.preferences, updated_at = excluded.updated_at`)++ .run(String(subject), JSON.stringify(value), prev ? prev.created_at : at, at);++ return await read(subject);++ }++++ return { get, put, DEFAULTS };++}++++module.exports = { createPreferences, DEFAULTS };@@@ -1,9 +1,11 @@'use strict';/*** Person sessions for the run receipt page (GET /runs/:id): a random 256-bit cookie id, stored as its-- * SHA-256; like the console's sessions, the cookie itself is never written down. A person session is-- * read-only and only opens runs whose on_behalf_of is its subject; it grants no capability and never-- * opens the operator console.++ * SHA-256; like the console's sessions, the cookie itself is never written down. In the run pages a++ * person session only opens runs whose on_behalf_of is its subject. It is the person's own sign-in on++ * AI's site, so behind a same-origin check it also reaches their own /api/v1/me/* settings++ * (server/me.js: their provider keys and routing preferences) — never a service route, and it grants++ * no capability and never opens the operator console.** cookie `__Host-ovai_person` (plain `ovai_person` over http in development): host-only, Path=/,* HttpOnly, SameSite=Lax, Secure when BASE_URL is https, Max-Age = AI_PERSON_SESSION_TTL_MIN.@@@ -44,7 +44,39 @@ function rateLimited(err) {}/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */--const CREDENTIAL_DEFAULT_MODEL = { anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };++const { CREDENTIAL_PROVIDERS, PROVIDER_KIND, DEFAULT_MODEL } = require('../credentials');++const CREDENTIAL_DEFAULT_MODEL = { ...DEFAULT_MODEL, anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };++++/** The key as explain names it, never the key itself (L36 Y1/Y7). */++const paidByOf = (cred) => `your ${cred.provider} key …${String(cred.hint || '').slice(-4) || '????'}`;++/** The kind of work a run does, for the person's ai.me-preferences.defaults. */++function workKind(operation, req, ctx = {}) {++ if (operation === 'image') return 'image';++ if (req && req.image) return 'vision';++ const hint = `${ctx.workflowKey || ''} ${ctx.routeKey || ''}`;++ return /\b(code|coding|codes|build|design|program|developer)\b/i.test(hint) ? 'code' : 'text';++}++/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */++function credentialCanServe(provider, operation, req) {++ if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;++ const text = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];++ if (text.includes(operation)) return true;++ if (operation === 'messages') return PROVIDER_KIND[provider] === 'anthropic' || provider === 'custom';++ if (operation === 'complete') return PROVIDER_KIND[provider] === 'openai' || provider === 'custom';++ return false;++}++/** The model a self key should use for this run: the person's default for the kind, else their stored model. */++function selfModelFor(cred, operation, req, ctx, prefs) {++ const kind = workKind(operation, req, ctx);++ const choice = prefs && prefs.defaults ? prefs.defaults[kind] : 'auto';++ const stored = cred.models || {};++ const role = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;++ const model = (choice && choice !== 'auto') ? choice++ : (req.image && stored.vision) || (role && stored[role]) || stored[kind] || stored.chat++ || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini';++ const why = choice && choice !== 'auto' ? `your default ${kind} model` : (stored[kind] || stored.chat ? 'the model you stored for this key' : "the provider's default");++ return { model, kind, why };++}function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {// A person's own machine as their model (credential provider node, server/machines.js).@@@ -411,8 +443,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (ctx.logRequest) ctx.logRequest({ ...base, model_key: result.model, status: 'ok', output_hash: sha256(result.text || ''), tokens_in: usage.input, tokens_out: usage.output, tokens_cached: 0, tokens_estimated: 0, cost_usd: 0, latency_ms: latency });return { result: { text: result.text, json: null, model: result.model, usage }, provider: cred.providerKey, providerKind: 'node', model: result.model, fallbackUsed: false, usage, cost: 0, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,explain: { objective: 'own-machine', reasons: [cred.machine === 'any' ? 'the fastest of the person\'s own machines that answered, by their choice' : 'the person\'s own machine, by their choice'],++ paid_by: 'your machine',selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },-- candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })) } };++ candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),++ attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }] } };} catch (err) {if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');@@@ -426,11 +460,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.// The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter// speaks that format too, so a person's OpenRouter key answers it as well).-- const completing = operation === 'complete' && ['openai', 'openrouter'].includes(cred.provider);++ const completing = operation === 'complete' && (PROVIDER_KIND[cred.provider] === 'openai' || cred.provider === 'custom');// Codes C3: POST /v1/messages on the person's own key. Only a provider that speaks Anthropic's Messages API serves// it — Anthropic itself, DeepSeek and MiniMax (their own Anthropic-compatible endpoints) and a custom endpoint.const messaging = operation === 'messages';-- const anthropicFormat = ['anthropic', 'deepseek', 'minimax', 'custom'].includes(cred.provider);++ const anthropicFormat = PROVIDER_KIND[cred.provider] === 'anthropic' || cred.provider === 'anthropic' || cred.provider === 'custom';if (messaging && !anthropicFormat) throw new AiError(422, 'credential.unsupported', `the ${cred.provider} credential does not speak the Messages API`);if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;@@@ -439,9 +473,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst role = wanted && cred.models[wanted] ? wanted : null;// On the person's Anthropic key the model the caller asked for is theirs to choose; elsewhere the model they stored// is the one their provider serves (a Claude Code model name does not exist at DeepSeek).-- const model = messaging++ const model = ctx.defaultModel || (messaging? ((cred.provider === 'anthropic' || cred.provider === 'custom') && req.model) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'claude-haiku-4-5-20251001'-- : (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini';++ : (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini');const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: cred.capabilities || ['chat', 'json', 'vision'], timeout_ms: 60000 };const a = anthropicFormat ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };@@@ -451,6 +485,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst t = String(text == null ? '' : text);return cred.apiKey ? t.split(String(cred.apiKey)).join('[redacted]') : t;};++ const paidBy = paidByOf(cred);++ const explain = cred.explain || {++ objective: 'own-key', reasons: ['your own key: no shared provider is called'],++ paid_by: paidBy, selected: `${cred.provider}/${model}`, candidates: [],++ attempts: [{ key: paidBy, provider: cred.provider, model, why: 'your own key', status: 'ok', error: null }],++ };const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));const call = messaging ? a.messages : completing ? a.complete : a.chat;let streamed = false;@@@ -468,12 +508,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });}-- return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,-- ...(cred.explain ? { explain: cred.explain } : {}) };++ return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [],++ startedAt: t0, explain: { ...explain, selected: `${cred.provider}/${result.model || model}` } };} catch (err) {lastErr = err;if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: scrub(err && err.message || err).slice(0, 500) });++ // L36 Y8: the provider refused the key (401/403). The key becomes failing and the event says so.++ if (err && (err.status === 401 || err.status === 403) && typeof ctx.onCredentialFailed === 'function') {++ await ctx.onCredentialFailed({ owner: cred.owner || null, subject: cred.subject || null, provider: cred.provider, providerKey: cred.providerKey, error: scrub(err.message) });++ }if (err && err.code === 'EADDRNOTPUBLIC') throw new AiError(422, 'credential.endpoint_refused', scrub(err.message));if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${scrub(err && err.message || err).slice(0, 200)}`);if (attempt < config.providerRetries && retryable(err)) { await sleep(config.retryDelayMs, ctx.signal).catch(() => {}); continue; }@@@ -511,6 +555,30 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noreturn explain.bench || null;}++ /** The providers that could serve this request, for the ai.needs_key answer (L36 Y1). */++ async function servingProviders(route, features, operation, req, selfList = []) {++ const out = new Set();++ const add = (key) => { if (CREDENTIAL_PROVIDERS.includes(key)) out.add(key); };++ const scope = route.constraints && route.constraints.provider_scope;++ if (route.primary && route.primary.provider) {++ for (const c of [route.primary, ...(route.fallbacks || [])]) if (c && c.provider) add(c.provider);++ } else {++ for (const p of await registry.listProviders()) {++ if (p.key === 'stub' || !appOffered({}, p)) continue;++ if (scope === 'local' && !isLocalProvider(p)) continue;++ if (scope === 'paid' && isFreeProvider(p)) continue;++ const caps = await offeredCapabilities(p);++ if (features.some(f => !caps.includes(f))) continue;++ add(p.key);++ }++ }++ for (const c of selfList) if (credentialCanServe(c.provider, operation, req)) add(c.provider);++ // A route whose network providers are all unknown to the credential table still names the console's providers:++ // that is what the person can add under Your AI.++ if (!out.size) for (const id of CREDENTIAL_PROVIDERS) if (credentialCanServe(id, operation, req)) out.add(id);++ return [...out].sort();++ }++async function execute(route, operation, req, ctx = {}) {if (ctx.credential) return await executeWithCredential(ctx.credential, operation, req, ctx);const features = [operation];@@@ -523,7 +591,62 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// after that is final, never retried or handed to a fallback whose words would follow the first one's.let streamed = false;const onDelta = typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;++ // L36 Y1: a run on behalf of a person tries the person's OWN keys first, for the providers that can serve the++ // request. With only_my_keys it never falls back; otherwise the network is the fallback and the explain says so.++ const prefs = ctx.selfPreferences || {};++ const selfAttempts = [];++ const selfList = Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : [];++ if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {++ const order = [...selfList].sort((a, b) => {++ const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);++ return rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider));++ });++ for (const cred of order) {++ if (!credentialCanServe(cred.provider, operation, req)) {++ selfAttempts.push({ key: paidByOf(cred), provider: cred.provider, model: null, why: 'your own key', status: 'skipped', error: `this key cannot serve ${operation}` });++ continue;++ }++ const pick = selfModelFor(cred, operation, req, ctx, prefs);++ const key = paidByOf(cred);++ try {++ const out = await executeWithCredential(cred, operation, req, { ...ctx, defaultModel: pick.model });++ const attempts = [...selfAttempts, { key, provider: cred.provider, model: pick.model, why: pick.why, status: 'ok', error: null }];++ return {++ ...out, fallbackUsed: false,++ explain: {++ objective: 'own-keys', reasons: ['your own keys are tried before the network\'s providers'],++ paid_by: key, selected: `${cred.provider}/${pick.model}`, candidates: [], attempts,++ },++ };++ } catch (err) {++ const detail = String(err && (err.detail || err.message) || err).slice(0, 300);++ selfAttempts.push({ key, provider: cred.provider, model: pick.model, why: pick.why, status: 'failed', error: detail });++ }++ }++ if (prefs.only_my_keys) {++ const providers = await servingProviders(route, features, operation, req, selfList);++ const names = providers.length ? providers.join(', ') : 'any provider that can serve this request';++ throw new AiError(422, 'ai.needs_key',++ `only_my_keys is on and none of your keys could serve this request; add a key for ${names} under Your AI`,++ { providers, paid_by: null, attempts: selfAttempts, explain: { objective: 'own-keys', reasons: ['only_my_keys: no network fallback'], paid_by: null, selected: null, candidates: [], attempts: selfAttempts } });++ }++ }let { order: list, explain } = await candidates(route, features, operation, ctx);++ // L36 Y3: free_first puts the free tiers ahead of metered ones for this person's fallback. Own-key attempts above++ // are already free to them, so this only reorders the network.++ if (prefs.free_first && ctx.selfSubject) {++ const free = new Map();++ for (const c of list) {++ if (c.provider === 'stub') { free.set(c.provider, true); continue; }++ const p = await registry.getProvider(c.provider);++ free.set(c.provider, !p || isFreeProvider(p));++ }++ list = [...list].sort((a, b) => Number(free.get(b.provider)) - Number(free.get(a.provider)));++ }++ const attempts = [...selfAttempts];++ explain.paid_by = 'openvibe';++ explain.attempts = attempts;++ explain.reasons = [...(explain.reasons || []), ...(selfAttempts.length ? ['your own keys did not answer; the network is the fallback'] : [])];// A first-party service's generate run may name one offer from GET /api/v1/routes. The run then// tries that offer alone: no fallback to another model, so the caller gets the model it asked for// or an explicit failure. server/runs.js validates the pin against this same offer list first.@@@ -544,10 +667,24 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst model = c.model || (p && p.default_model) || null;const fallback = i > 0;const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };++ // L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).++ if (prefs.price_ceiling_usd_per_mtok != null) {++ const cards = await rateCardsFor(c.provider, model);++ const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;++ if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {++ const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;++ statFor(c.provider).skipped++;++ tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: whyPrice });++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'over_price_ceiling' });++ continue;++ }++ }const why = await skipReason(p, a, features);if (why) {statFor(c.provider).skipped++;tried.push({ provider: c.provider, skipped: why });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: why });if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });continue;}@@@ -588,7 +725,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no});}explain.selected = c.provider;-- return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback, usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };++ explain.paid_by = 'openvibe';++ attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: 'ok', error: null });++ explain.attempts = attempts;++ return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };} catch (err) {lastErr = err;if (ctx.signal && ctx.signal.aborted) {@@@ -609,9 +749,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;if (!callerFault(lastErr) && !rateLimited(lastErr)) await recordFailure(c.provider, lastErr);tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });++ attempts.push({ key: 'openvibe', provider: c.provider, model: lastErr && lastErr.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: lastErr && lastErr.code === 'provider.timeout' ? 'timeout' : 'failed', error: String(lastErr && lastErr.message || lastErr).slice(0, 300) });log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);}explain.selected = null;++ explain.paid_by = 'openvibe';++ explain.attempts = attempts;// The media itself could not be read: no provider will do better, and the caller should hear why (and not// retry as if a provider were down).if (unreadable) {@@@ -99,7 +99,7 @@ function routePinRequest(value, principal, wf) {return { provider, model };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }const queue = []; // run ids waiting for the workerlet active = 0;@@@ -247,6 +247,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');if (!projectSecrets || !projectSecrets.available()) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');}++ // L36 Y1: a run on behalf of a person tries that person's OWN keys first (owner `self`). When they hold one,++ // the run is uncached and no network budget or tier holds it: their own key is free to them, and a $0 balance++ // must never block it. If it falls back to the network, the usage is still recorded after the fact.++ const personSubject = body.on_behalf_of && body.on_behalf_of.type === 'user' && typeof body.on_behalf_of.id === 'string' ? body.on_behalf_of.id : null;++ const selfKeys = Boolean(credentials && personSubject && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(personSubject));// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.if (runBudgets && principal.jti) {@@@ -274,23 +279,25 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowsource_service: requester.type === 'service' ? requester.id : null,target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,-- options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned, debug: Boolean(options.debug), input_retained: kept.full,++ options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),++ ...(selfKeys ? { self_subject: personSubject } : {}),...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}) }), created_at: now,};base.input = stableStringify(kept.value);// Cache (scoped; never crosses requester/actor/target/attribution)// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,-- // and another model's answer under the same prompt would silently break the pin.-- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned ? null : cache.scopeFor(wf.cache_mode, ctx);++ // and another model's answer under the same prompt would silently break the pin. A run that may use the++ // person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.++ const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys ? null : cache.scopeFor(wf.cache_mode, ctx);let cacheKey = null;if (scope) {cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });const hit = await cache.get(cacheKey, scope);if (hit) {await db.tx(async () => {-- const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], selected: null, candidates: [] };++ const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], paid_by: 'cache', selected: null, candidates: [], attempts: [] };await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,source_service, target, target_key, input, input_hash, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, explain, created_at, started_at, finished_at, citations_count)VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'cached', @requester_type, @requester_id, @on_behalf_of, @attribution,@@@ -315,10 +322,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowadmit(caller);// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().-- const hold = credentialSubject !== null || projectProvider || !tiers ? null : await tiers.admit(ctx, id);++ // L36 Y1: a run that may use the person's own key holds neither — their key is free to them, and a $0 balance++ // anywhere must never block it. If it falls back to the network, the usage is recorded after the run.++ const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);let reserved;try {-- reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);++ reserved = credentialSubject !== null || selfKeys ? null : await quotas.reserve(ctx);} catch (err) { if (hold) await tiers.release(hold); throw err; }try {@@@ -375,7 +384,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowreturn {providerKey: `project:${ctx.projectId}`, provider: 'openai', base_url: spec.base_url, apiKey: value, models: { chat: spec.model },capabilities: ['chat', 'json', 'vision', 'tools'],-- explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], selected: `project:${ctx.projectId}`, candidates: [] },++ explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], paid_by: 'project', selected: `project:${ctx.projectId}`, candidates: [], attempts: [] },};}@@@ -409,6 +418,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now}const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject, opts.credential_provider || null): opts.project_provider ? await projectCredential(opts.project_provider, ctx) : null;++ // L36 Y1/Y8: the person's own keys, and what their preferences say about the network fallback. A provider++ // refusing one (401/403) marks it failing and publishes ai.credential.failed, never the key.++ const selfSubject = opts.self_subject || null;++ const selfCredentials = selfSubject && credentials ? await credentials.selfForAllRuns(selfSubject) : null;++ const selfPreferences = selfSubject && personPreferences ? await personPreferences.get(selfSubject) : null;++ const onCredentialFailed = credentials ? async (failure) => {++ if (!failure || !failure.owner || !failure.subject || !failure.provider) return;++ const changed = await credentials.markFailed(failure.owner, failure.subject, failure.provider, failure.error);++ if (changed) await events.credentialFailed({ subject: failure.subject, provider: failure.provider, error: failure.error, owner: failure.owner });++ } : undefined;const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is// limited to free and local capacity when it is a sandbox run or when no tier budget governs its@@@ -417,6 +436,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// Who asked, for a step that must not call the caller back (research: Search's own web ladder).requesterType: ctx.requesterType, requesterId: ctx.requesterId,...(ctx.routePin ? { routePin: ctx.routePin } : {}),++ ...(onCredentialFailed ? { onCredentialFailed } : {}),++ ...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}) });if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');@@@ -432,7 +453,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowreturn d;});if (!done.changes) return; // cancelled while finishing-- if (r.provider && reserved) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });++ // A self-key run has no reservation and no tier hold; if it fell back to the network, its usage is still++ // recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.++ const usedOwnKey = String(r.provider || '').startsWith('byo:self:');++ if (r.provider && (reserved || (opts.self_subject && !usedOwnKey))) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });// What the run spent goes on the budget of its run token (Codes C3): the same tokens and cost the// quota accounting counts, so the next request of a run over its budget is refused.if (runBudgets && ctx.runJti) await runBudgets.add(ctx.runJti, { tokens: r.usage.input + r.usage.output, cost: r.cost });@@@ -137,3 +137,37 @@ t.test('their runs, quota and module marker go; the spend ledger and the audit tassert.ok(confirmation.body.retained.usage_counters >= 1, 'the kept ledger is counted as retained');} finally { await h.stop(); await stub.close(); }});++++t.test('L36: the person\'s self credentials export without the key, and deletion erases them with their preferences', async () => {++ const stub = await startNetworkStub();++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });++ try {++ const SECRET_KEY = 'sk-self-ada-0123456789LEAK';++ const sealed = `v2:${Buffer.alloc(12).toString('base64')}:${Buffer.alloc(16).toString('base64')}:${Buffer.from(SECRET_KEY).toString('base64')}`;++ await h.db.exec(`INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, status, checked_at, last_error, models_count, created_at, updated_at)++ VALUES ('self', $1, 'anthropic', $2, '…LEAK', '{"chat":"m"}', 'ok', '2026-10-31T09:00:00.000Z', NULL, 3, '2026-10-01', '2026-10-02')`, [A, sealed]);++ await h.db.exec(`INSERT INTO ai_person_preferences (subject, preferences, created_at, updated_at)++ VALUES ($1, '{"only_my_keys":true,"free_first":false,"price_ceiling_usd_per_mtok":5,"defaults":{"text":"auto","code":"auto","vision":"auto","image":"auto"}}', '2026-10-01', '2026-10-02')`, [A]);++ const deliver = async (event) => {++ const body = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++++ const r = await deliver(ev('evt_01JZ0000000000000000000E03', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX3', subject: A }));++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX3/parts');++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ const cred = files['provider-credentials.json'].find((c) => c.owner === 'self');++ assert.deepStrictEqual([cred.provider, cred.key_hint, cred.status, cred.models_count, cred.checked_at], ['anthropic', '…LEAK', 'ok', 3, '2026-10-31T09:00:00.000Z']);++ assert.ok(!('key_enc' in cred) && !JSON.stringify(part.body).includes(SECRET_KEY), 'the key is never exported');++ assert.strictEqual(files['ai-preferences.json'][0].preferences.includes('only_my_keys'), true, 'the preferences export');++++ const d = await deliver(ev('evt_01JZ0000000000000000000D03', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DX3', subject: A, aliases: [] }));++ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);++ assert.strictEqual(Number(await h.db.value("SELECT count(*)::int FROM subject_credentials WHERE owner = 'self' AND subject = $1", [A])), 0, 'the self key is erased');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM ai_person_preferences WHERE subject = $1', [A])), 0, 'the preferences are erased');++ } finally { await h.stop(); await stub.close(); }++});++++t.run();@@@ -102,6 +102,21 @@ t.test('a failing outbox prune is caught, not left as an unhandled rejection', a}});++t.test('a refused own key queues ai.credential.failed, never the key (L36 Y8)', async () => {++ const events = require('../server/events');++ const subject = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const env = await events.credentialFailed({ subject, provider: 'anthropic', error: 'the provider refused the key (401)' });++ assert.ok(env && env.event_id, 'the event was queued');++ const ev = (await queued()).find((e) => e.event_id === env.event_id);++ assert.strictEqual(ev.event_type, 'ai.credential.failed');++ assert.deepStrictEqual(ev.on_behalf_of, { type: 'user', id: subject });++ assert.deepStrictEqual([ev.payload.subject, ev.payload.provider, ev.payload.status], [subject, 'anthropic', 'failing']);++ let known = true;++ try { contracts.resolve('ai.credential.failed@1'); } catch { known = false; }++ if (known) valid(ev); // the schema arrives with openvibe-contracts 0.257.0++ assert.ok(!JSON.stringify(ev).includes('sk-'), 'no key');++});++t.test('close() stops the ai.run.* relay (unsent rows stay in the outbox)', async () => {const events = require('../server/events');const pending = (await events.status()).pending;nnew file mode 100644@@@ -0,0 +1,218 @@++'use strict';++// L36 Y1/Y3/Y7/Y8: the person's own provider keys and how their runs route.++// - GET/PUT/DELETE/POST /api/v1/me/credentials and GET/PUT /api/v1/me/ai/preferences are person-only: a service++// token (with or without on_behalf_of) is refused, so is another site's cookie session;++// - a token minted for openvibe.network (every site's session) may read but never write; a write needs aud++// openvibe.ai, and a cookie write needs Origin or Sec-Fetch-Site;++// - a key is sealed with the AAD self|subject|provider and never returned, logged or put in an error;++// - test on save lists models, falls back to a one-token request, records 401 and a timeout;++// - a run on_behalf_of a person tries their self key first, only_my_keys never falls back, and the explain names++// paid_by and every attempt.++const assert = require('assert');++const crypto = require('crypto');++const { validate, schema } = require('openvibe-contracts');++const HAS_ME_CONTRACT = (() => { try { return Boolean(schema('ai.me-credential@1')); } catch { return false; } })();++const { boot, request, token, suite, ALL } = require('./helpers');++const { createCredentials, aadFor, PROVIDERS, SELF_OWNER } = require('../server/credentials');++++const t = suite('me-credentials');++const KEY = 'cd'.repeat(32);++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';++const API_KEY = 'sk-test-dana-0123456789WXYZ';++const ANTHROPIC_KEY = 'sk-ant-test-0123456789ABCD';++const person = token('dana', ALL, { sub: DANA, actorType: 'user' });++const siteSession = token('dana-site', ALL, { sub: DANA, actorType: 'user', extra: { aud: ['openvibe.network', 'openvibe.design'] } });++const service = token('live', [...ALL, 'ai.me.read', 'ai.me.manage']);++const serviceOnBehalf = token('live', [...ALL, 'ai.me.read', 'ai.me.manage'], { extra: { on_behalf_of: DANA } });++const calls = [];++const logs = [];++const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++let mode = 'ok';++let h;++++const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++const credentialFetch = async (url, init = {}) => {++ const auth = (init.headers && (init.headers.Authorization || init.headers['x-api-key'])) || null;++ calls.push({ url, auth, body: init.body ? JSON.parse(init.body) : null });++ if (mode === 'timeout') return await new Promise(() => {});++ if (mode === 'auth') return json({ error: { message: `invalid key ${auth}` } }, 401);++ if (String(url).endsWith('/models')) {++ if (mode === 'empty') return json({ data: [] });++ return json({ data: [{ id: 'm-one' }, { id: 'm-two' }, { id: 'm-three' }] });++ }++ if (String(url).endsWith('/messages')) {++ return json({ model: 'm-one', content: [{ type: 'text', text: 'hello from dana\'s key' }], usage: { input_tokens: 4, output_tokens: 2 } });++ }++ if (String(url).endsWith('/chat/completions')) {++ return json({ model: 'm-one', choices: [{ message: { role: 'assistant', content: 'hello from dana\'s key' } }], usage: { prompt_tokens: 4, completion_tokens: 2 } });++ }++ return json({});++};++++t.test('boot', async () => {++ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, log: capture });++});++++t.test('the person routes refuse a service token, an on_behalf_of token, and another site\'s session', async () => {++ for (const tok of [service, serviceOnBehalf]) {++ const r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${r.status} ${r.text}`);++ }++ // A person session cookie on AI's own site works, with the same-origin signal.++ const raw = crypto.randomBytes(32).toString('base64url');++ const hash = crypto.createHash('sha256').update(raw).digest('hex');++ await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, 'dana', '2026-10-01', '2030-10-01')", [hash, DANA]);++ const prefsBody = { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } };++ let r = await request(h.base, 'GET', '/api/v1/me/credentials', { headers: { Cookie: `ovai_person=${raw}` } });++ assert.strictEqual(r.status, 200, 'a read with no Origin or Sec-Fetch-Site still works');++ // A write with the cookie but neither header is not proof of same-origin: refused.++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { headers: { Cookie: `ovai_person=${raw}` }, body: prefsBody });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'a write needs an origin signal');++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { headers: { Cookie: `ovai_person=${raw}`, 'Sec-Fetch-Site': 'same-origin' }, body: prefsBody });++ assert.strictEqual(r.status, 200, 'a same-origin cookie write works');++ r = await request(h.base, 'GET', '/api/v1/me/credentials', { headers: { Cookie: `ovai_person=${raw}`, Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' } });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot spend the session');++});++++t.test('a site session token may read but never write; a token for openvibe.ai may write', async () => {++ let r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok: siteSession });++ assert.strictEqual(r.status, 200, 'a site token reads the hints');++ r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: siteSession });++ assert.strictEqual(r.status, 200, 'a site token reads the settings');++ const prefsBody = { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } };++ const writes = [++ ['PUT', '/api/v1/me/credentials/anthropic', { provider: 'anthropic', key: API_KEY }],++ ['DELETE', '/api/v1/me/credentials/anthropic', undefined],++ ['POST', '/api/v1/me/credentials/anthropic/test', undefined],++ ['PUT', '/api/v1/me/ai/preferences', prefsBody],++ ];++ for (const [method, path, body] of writes) {++ r = await request(h.base, method, path, { tok: siteSession, body });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${method} ${path} refused a site session token: ${r.text}`);++ }++ // A token whose aud explicitly names openvibe.ai reaches the writes.++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefsBody });++ assert.strictEqual(r.status, 200, r.text);++});++++t.test('store a key: sealed with self|subject|provider, a four-character hint, never the key', async () => {++ mode = 'ok';++ let r = await request(h.base, 'PUT', `/api/v1/me/credentials/anthropic`, { tok: person, body: { provider: 'anthropic', key: ANTHROPIC_KEY, budget_usd_per_day: 4, models: { chat: 'm-one' } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.provider, r.body.hint, r.body.status, r.body.models_count], ['anthropic', 'ABCD', 'ok', 3]);++ if (HAS_ME_CONTRACT) assert.ok(validate('ai.me-credential@1', r.body).valid, JSON.stringify(validate('ai.me-credential@1', r.body).errors));++ assert.ok(!r.text.includes(ANTHROPIC_KEY), 'the key never comes back');++ const row = await h.db.prepare('SELECT key_enc, key_hint, owner FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');++ assert.strictEqual(row.owner, 'self');++ assert.ok(row.key_enc.startsWith('v2:') && !row.key_enc.includes(ANTHROPIC_KEY), 'encrypted at rest');++ assert.ok(!logs.join('\n').includes(ANTHROPIC_KEY), 'not in a log line');++ const creds = createCredentials({ db: h.db, config: h.config });++ const cred = await creds.forRun('self', DANA, 'anthropic');++ assert.strictEqual(cred.apiKey, ANTHROPIC_KEY, 'opens with self|subject|provider');++ assert.strictEqual(aadFor('self', DANA, 'anthropic'), `self|${DANA}|anthropic`);++ // The AAD binds the provider: a row moved elsewhere does not decrypt.++ await h.db.prepare('UPDATE subject_credentials SET provider = ? WHERE owner = ? AND subject = ? AND provider = ?').run('openai', SELF_OWNER, DANA, 'anthropic');++ await assert.rejects(creds.forRun('self', DANA, 'openai'), /unable to authenticate|bad decrypt|Unsupported state/i);++ await h.db.prepare('UPDATE subject_credentials SET provider = ? WHERE owner = ? AND subject = ? AND provider = ?').run('anthropic', SELF_OWNER, DANA, 'openai');++});++++t.test('test on save: success, an empty model list, a 401 and a timeout', async () => {++ // success already covered above (3 models). An empty list makes one one-token request.++ mode = 'empty';++ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY, models: { chat: 'm-chat' } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.status, r.body.models_count, r.body.last_error], ['ok', 0, null]);++ assert.ok(calls.some((c) => c.url.endsWith('/chat/completions') && c.body.max_tokens === 1), 'one token');++ // A 401 is failing, and the key is redacted from the error the provider echoed.++ mode = 'auth';++ r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.status, 'failing');++ assert.ok(r.body.last_error && /refused/i.test(r.body.last_error), r.body.last_error);++ assert.ok(!r.body.last_error.includes(API_KEY), 'the key is not in the stored error');++ assert.ok(!r.text.includes(API_KEY), 'the key never comes back');++ // A timeout is failing after the (test-shortened) 10 s deadline.++ mode = 'timeout';++ r = await request(h.base, 'PUT', '/api/v1/me/credentials/gemini', { tok: person, body: { provider: 'gemini', key: 'sk-test-gemini-0123456789' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.status, r.body.last_error], ['failing', 'no response within 0s']);++ mode = 'ok';++ // Every console provider is storable, not only today's list.++ for (const provider of ['mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks']) {++ assert.ok(PROVIDERS.includes(provider), `${provider} is a provider`);++ }++});++++t.test('a custom endpoint goes through the guarded fetcher\'s egress rules', async () => {++ const real = createCredentials({ db: h.db, config: h.config }); // no test fetch: the production guarded fetcher++ const out = await real.putSelf(DANA, 'custom', { provider: 'custom', key: 'sk-test-custom-0123456789', base_url: 'https://127.0.0.1/v1' });++ assert.strictEqual(out.status, 'failing');++ assert.ok(/IP-literal|non-public|refused|https/i.test(out.last_error), out.last_error);++ await real.removeSelf(DANA, 'custom');++});++++t.test('preferences are per person, with defaults and a model per kind', async () => {++ let r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: person });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.only_my_keys, r.body.free_first, r.body.defaults.text], [false, true, 'auto']);++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: true, free_first: false, price_ceiling_usd_per_mtok: 5, defaults: { text: 'm-default', code: 'auto', vision: 'auto', image: 'auto' } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual([r.body.only_my_keys, r.body.free_first, r.body.price_ceiling_usd_per_mtok, r.body.defaults.text], [true, false, 5, 'm-default']);++ const other = token('bob', ALL, { sub: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY', actorType: 'user' });++ r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: other });++ assert.strictEqual(r.body.only_my_keys, false, 'preferences are per person');++});++++t.test('a run on behalf of the person tries their key first; only_my_keys never falls back', async () => {++ // Dana already has an anthropic key; give her an OpenAI key that works (empty list, one-token) and a stored model.++ mode = 'ok';++ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY, models: { chat: 'm-chat' } } });++ assert.strictEqual(r.status, 200, r.text);++ const run = async () => await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: { workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' }, on_behalf_of: { type: 'user', id: DANA } } });++ r = await run();++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));++ assert.strictEqual(r.body.run.output.text, "hello from dana's key", JSON.stringify(r.body.run).slice(0, 1200));++ assert.ok(r.body.run.explain && r.body.run.explain.paid_by === `your anthropic key …${ANTHROPIC_KEY.slice(-4)}`, JSON.stringify(r.body.run.explain));++ assert.ok(r.body.run.explain.attempts.some((a) => a.provider === 'anthropic' && a.status === 'ok' && a.key === r.body.run.explain.paid_by));++ // only_my_keys: a refused key is an explicit ai.needs_key, never a network fallback.++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: true, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } } });++ assert.strictEqual(r.status, 200, r.text);++ mode = 'auth';++ r = await run();++ assert.strictEqual(r.body.run.status, 'failed', JSON.stringify(r.body.run).slice(0, 600));++ assert.strictEqual(r.body.run.error.code, 'ai.needs_key');++ assert.ok(/anthropic|openai/.test(String(r.body.run.error.detail)), `the error names the providers: ${r.body.run.error.detail}`);++ assert.ok(!r.text.includes(API_KEY), 'the key is never in the answer');++ // Without only_my_keys the network is the fallback, and explain says which key paid and what failed.++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } } });++ assert.strictEqual(r.status, 200, r.text);++ r = await run();++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));++ assert.strictEqual(r.body.run.explain.paid_by, 'openvibe');++ assert.ok(r.body.run.explain.attempts.some((a) => a.key.startsWith('your ') && a.status === 'failed'));++ const status = await h.db.prepare('SELECT status, last_error FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'openai');++ assert.strictEqual(status.status, 'failing', 'a 401 marks the key failing');++ assert.ok(status.last_error && !status.last_error.includes(API_KEY));++ await request(h.base, 'DELETE', '/api/v1/me/credentials/openai', { tok: person });++});++++t.test('a key never appears in a response, a log line or an error', async () => {++ const IVAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ';++ const ivan = token('ivan', ALL, { sub: IVAN, actorType: 'user' });++ const key = 'sk-test-ivan-LEAKME-0123456789';++ mode = 'auth';++ const r = await request(h.base, 'PUT', `/api/v1/me/credentials/openai`, { tok: ivan, body: { provider: 'openai', key, models: { chat: 'm-chat' } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(key), 'not in the response');++ assert.ok(!logs.join('\n').includes(key), 'not in a log line');++ assert.ok(!String(r.body.last_error || '').includes(key), 'not in the error');++ const row = await h.db.prepare('SELECT last_error FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, IVAN, 'openai');++ assert.ok(!String(row.last_error).includes(key), 'not in the stored error');++ mode = 'ok';++ await request(h.base, 'DELETE', `/api/v1/me/credentials/openai`, { tok: ivan });++});++++t.test('shutdown', async () => { await h.stop(); });++++t.run();@@@ -44,13 +44,14 @@ function mounted(app) {/*** Not public API surface: probes and redirect, the operator/admin API, the attribution-quota API and-- * the credential routes (which must never appear in a public document).++ * the credential routes (which must never appear in a public document). The person's own /api/v1/me/*++ * routes are person-only and carry a key on PUT, so they stay out for the same reason.*/const LEFT_OUT = ['/api/health', '/api/ready', '/api/v1/openapi.json','/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows','/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',-- '/api/v1/credentials', '/api/v1/attribution-quotas',++ '/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',];const excluded = (key) => {const p = key.split(' ')[1];
@@ -33,3 +33,15 @@ description = "Made-up provider key in test/messages-api.test.js (the person's o
@@ -33,3 +33,15 @@ description = "Made-up provider key in test/messages-api.test.js (the person's o
condition = "AND"
condition = "AND"
paths = ['''^test/messages-api\.test\.js$''']
paths = ['''^test/messages-api\.test\.js$''']
regexes = ['''^sk-ant-test-person-0123456789$''']
regexes = ['''^sk-ant-test-person-0123456789$''']
+
+[[allowlists]]
+description = "Made-up provider keys in test/me-credentials.test.js (L36: the self vault, test-on-save, the AAD, and the 'a key is never in an answer' test; placeholders, not secrets)"
+condition = "AND"
+paths = ['''^test/me-credentials\.test\.js$''']
+regexes = ['''sk-test-dana-0123456789WXYZ''', '''sk-ant-test-0123456789ABCD''', '''sk-test-custom-0123456789''', '''sk-test-gemini-0123456789''', '''sk-test-ivan-LEAKME-0123456789''']
+
+[[allowlists]]
+description = "Made-up provider key in test/account-data.test.js (L36: the account export must never carry the key; a placeholder, not a secret)"
+condition = "AND"
+paths = ['''^test/account-data\.test\.js$''']
+regexes = ['''sk-self-ada-0123456789LEAK''']
@@ -26,6 +26,13 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -26,6 +26,13 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- people's own provider keys (`ai.credential.manage`), encrypted at rest, one per provider (Codes C3: `anthropic`,
- 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
`openai`, `deepseek`, `minimax`, `openrouter` or a `custom` endpoint), and per-streamer
attribution quotas (`ai.quota.attribution.manage`)
attribution quotas (`ai.quota.attribution.manage`)
+- **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every console 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`)
- a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their own
- 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 Node), reached through OpenVibe.Bot with `bot.machine.ai`
machine (Ollama, LM Studio, llama.cpp through the OpenVibe Node), 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`);
for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`);
@@ -58,7 +65,8 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`;
@@ -58,7 +65,8 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`;
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject[/:provider]`),
`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject[/:provider]`),
`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,
`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,
-`/api/v1/memory/*`) and `ai.app.run` (developer apps, ADR-014; public,
+`/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). A service token's `ns`
registered since openvibe-contracts 0.109.0). A service token's `ns`
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
@@ -114,6 +122,7 @@ idempotent replay):
@@ -114,6 +122,7 @@ idempotent replay):
| `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) |
| `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) |
| `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 /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/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` |
| `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 /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"runtime": "node 22, express, postgresql",
"port": 4700,
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
- "contracts": "openvibe-contracts v0.252.0",
+ "contracts": "openvibe-contracts v0.252.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)",
"sdk": "openvibe-sdk v0.75.0",
"sdk": "openvibe-sdk v0.75.0",
"shared": "openvibe-shared v4.62.0",
"shared": "openvibe-shared v4.62.0",
"capabilities": [
"capabilities": [
@@ -17,7 +17,12 @@
@@ -17,7 +17,12 @@
"ai.provider.manage",
"ai.provider.manage",
"ai.usage.read",
"ai.usage.read",
"ai.credential.manage",
"ai.credential.manage",
- "ai.quota.attribution.manage"
+ "ai.quota.attribution.manage",
+ "ai.actions.read",
+ "ai.memory.read",
+ "ai.memory.write",
+ "ai.me.read",
+ "ai.me.manage"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"providers": [
"providers": [
@@ -39,6 +44,7 @@
@@ -39,6 +44,7 @@
"scoped cache",
"scoped cache",
"citations or explicit gaps on every run",
"citations or explicit gaps on every run",
"people's own provider keys, encrypted at rest",
"people's own provider keys, encrypted at rest",
+ "Your AI (L36): the person's own keys under owner self for every console provider, tested on save (models, or one token) with status/last_error/models_count; only_my_keys, free_first and a price ceiling per person; a run on_behalf_of a person tries their keys first and explain names paid_by and every attempt; a 401/403 marks the key failing and publishes ai.credential.failed",
"media.analyze: local-first recording analysis with windowed speech-to-text",
"media.analyze: local-first recording analysis with windowed speech-to-text",
"ai.run.* events through the SDK outbox",
"ai.run.* events through the SDK outbox",
"ai.preferences and ai.usage_summary user modules",
"ai.preferences and ai.usage_summary user modules",
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- L36 Y1/Y8: the health of a person's own provider key, and every provider the console knows.
+-- Expand only: the new columns have defaults, so rows and releases before this migration keep working; the
+-- provider CHECK is widened to a superset, so an older release's inserts still pass.
+-- status ok | failing | untested: what the last save-time or run-time check found
+-- checked_at when that check ran (ISO 8601 UTC)
+-- last_error the redacted reason it failed (never the key)
+-- models_count how many models the provider listed when it was last tested
+ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS status text COLLATE "C" NOT NULL DEFAULT 'untested';
+ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS checked_at text COLLATE "C";
+ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS last_error text COLLATE "C";
+ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS models_count integer NOT NULL DEFAULT 0;
+ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;
+ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check
+ CHECK (provider IN ('openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom',
+ 'gemini', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- L36 Y3: one person's ai.me-preferences (only_my_keys, free_first, price_ceiling_usd_per_mtok and a default
+-- model or `auto` per kind of work), stored per person. Expand only: a service without this table keeps running
+-- (its routes simply answer the defaults), and the row holds JSON so more preferences add without a migration.
+CREATE TABLE IF NOT EXISTS ai_person_preferences (
+ subject text COLLATE "C" PRIMARY KEY,
+ preferences text COLLATE "C" NOT NULL DEFAULT '{}',
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1540,9 +1540,9 @@
@@ -1540,9 +1540,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.252.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
- "integrity": "sha512-+b51PEN0xTiPlHtkrBpLq0cGP4bCZD2Irg0lAqm7CxA6QdIe2NFSq/kGsF6MmYXlGxIrOoe5Nw0u72xUDjgQSg==",
+ "version": "0.257.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",
+ "integrity": "sha512-fzyXLBKCqrOjUvlUB6UKxZQgrwOMhXaQK8lyxmmpkIujcd8AaIRW0DVTvYGIRJGCf/1Z3RiYS5gK1XLG8rkXFA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -3,8 +3,10 @@
@@ -3,8 +3,10 @@
* Account export and deletion → AI (ADR-033; openvibe-sdk/account-data). What AI holds about a person:
* Account export and deletion → AI (ADR-033; openvibe-sdk/account-data). What AI holds about a person:
*
*
* - their console and person sessions (Console sign-in and the AI chat), erased;
* - their console and person sessions (Console sign-in and the AI chat), erased;
- * - the provider credentials they brought (server/credentials.js): the encrypted key, its hint and the budget, erased
- * with them (the key itself is never exported, only its hint);
+ * - the provider credentials they brought (server/credentials.js), including the keys they hold themselves (owner
+ * `self`, L36): the encrypted key, its hint, status and the budget, erased with them (the key itself is never
+ * exported, only its hint and health);
+ * - their ai.me-preferences (server/person-preferences.js): how their runs route. Their own settings, so they go;
* - their runs: the workflows AI ran for them (runs.on_behalf_of is their SubjectRef) and the requests, citations and
* - their runs: the workflows AI ran for them (runs.on_behalf_of is their SubjectRef) and the requests, citations and
* private cache entries under them. Those are the person's own content, so they go with the account (ADR-033);
* private cache entries under them. Those are the person's own content, so they go with the account (ADR-033);
* - the free-allowance usage the allowance ledger counts (server/free-allowance.js) and the usage counters: kept,
* - the free-allowance usage the allowance ledger counts (server/free-allowance.js) and the usage counters: kept,
@@ -27,7 +29,10 @@ const TABLES = [
@@ -27,7 +29,10 @@ const TABLES = [
{ table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
- columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },
+ columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'status', 'checked_at', 'last_error', 'models_count', 'created_at', 'updated_at', 'last_used_at'] },
+ // L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
+ // settings, so they go with the account; the row holds no key.
+ { table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
// never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.
// never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.
{ table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');
const home = require('./home');
const home = require('./home');
const pkg = require('../package.json');
const pkg = require('../package.json');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -117,6 +117,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -117,6 +117,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
const personSessions = createPersonSessions({ db, config, clock });
const personSessions = createPersonSessions({ db, config, clock });
+ // The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
+ // the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
+ if (credentials && preferences) {
+ app.use(require('./me').meRouter({ credentials, preferences, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ }
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
app.use(runPageRouter({ runs, personSessions, log }));
@@ -58,6 +58,9 @@ function load(env = process.env) {
@@ -58,6 +58,9 @@ function load(env = process.env) {
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.
// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
+ // Test-on-save for a person's own key (L36 Y1): a model list, else one token, within this budget. Ten seconds in
+ // production; a test may shorten it.
+ credentialTestTimeoutMs: Math.max(50, Math.min(60000, int(env.AI_CREDENTIAL_TEST_TIMEOUT_MS, 10000))),
// The person's memory (server/memory/): AES-256-GCM with a key of its own, so it can rotate apart from credentials.
// The person's memory (server/memory/): AES-256-GCM with a key of its own, so it can rotate apart from credentials.
memoryKey: String(env.AI_MEMORY_KEY || '').trim(),
memoryKey: String(env.AI_MEMORY_KEY || '').trim(),
// The store's own maintenance (server/memory/jobs.js): owner by owner, with a per-run cap. Off in test mode
// The store's own maintenance (server/memory/jobs.js): owner by owner, with a per-run cap. Off in test mode
@@ -28,4 +28,25 @@ const TEXT_CAPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enri
@@ -28,4 +28,25 @@ const TEXT_CAPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enri
const byId = (id) => PRESETS.find((p) => p.id === id) || null;
const byId = (id) => PRESETS.find((p) => p.id === id) || null;
-module.exports = { PRESETS, TEXT_CAPS, byId };
+/**
+ * The providers a person may store their own key for (L36 Y1). One table: each credential provider is a preset
+ * above, so its base URL and wire format (OpenAI-compatible or Anthropic Messages) are the console's, never a
+ * second list. DeepSeek and MiniMax keep their Anthropic-format endpoints (their keys speak Messages there, which is
+ * what a person's stored model expects); `custom` is the preset with an empty base URL, and a person names it.
+ * `node` is not here: it is the person's own machine, not a key (server/credentials.js keeps its old route).
+ */
+const CREDENTIAL_PRESET = {
+ anthropic: 'anthropic', openai: 'openai', gemini: 'gemini', deepseek: 'deepseek-anthropic',
+ mistral: 'mistral', xai: 'xai', groq: 'groq', cerebras: 'cerebras', together: 'together',
+ fireworks: 'fireworks', openrouter: 'openrouter', minimax: 'minimax-anthropic', custom: 'custom',
+};
+const CREDENTIAL_PROVIDERS = Object.keys(CREDENTIAL_PRESET);
+
+/** The console preset behind a credential provider: { id, kind, base_url, model, keys } or null. */
+function credentialProvider(id) {
+ const preset = byId(CREDENTIAL_PRESET[id]);
+ return preset ? { id, kind: preset.kind, base_url: preset.base_url, model: preset.model, keys: preset.keys } : null;
+}
+
+module.exports = { PRESETS, TEXT_CAPS, byId, CREDENTIAL_PROVIDERS, CREDENTIAL_PRESET, credentialProvider };
+
@@ -1,61 +1,174 @@
@@ -1,61 +1,174 @@
'use strict';
'use strict';
/**
/**
- * A person's own provider key (roadmap WS-O task 2; Contracts ai.credential.manage, ai.credential-put@2 and
- * ai.credential@2 since Codes C3). The service that holds the person's consent (Live, for a streamer's AI viewers;
- * Codes, for the people it builds for) stores the key here; it never leaves OpenVibe.AI again. Runs that name the
- * credential (ai.run-request credential { subject, provider? }) call that provider with that key only
- * (server/providers/index.js executeWithCredential), never a shared key.
+ * A person's own provider key (roadmap WS-O task 2; L36 Y1/Y8; Contracts ai.credential.manage,
+ * ai.credential-put@2 and ai.credential@2 since Codes C3, and ai.me.* since L36).
*
*
- * PUT /api/v1/credentials/:subject store or replace the DEFAULT provider's credential (the v1 route)
- * PUT /api/v1/credentials/:subject/:provider store or replace that provider's credential
- * GET /api/v1/credentials/:subject[/:provider] read it back without the key: a four-character hint, today's spend
+ * A service that holds the person's consent (Live for a streamer's AI viewers, Codes for the people it builds for)
+ * stores the key here and never sees it again. A run that names the credential (ai.run-request credential
+ * { subject, provider? }) calls that provider with that key only (server/providers/index.js
+ * executeWithCredential), never a shared key.
+ *
+ * PUT /api/v1/credentials/:subject store the DEFAULT provider's credential (the v1 route)
+ * PUT /api/v1/credentials/:subject/:provider store that provider's credential
+ * GET /api/v1/credentials/:subject[/:provider] read it back without the key
* DELETE /api/v1/credentials/:subject[/:provider]
* DELETE /api/v1/credentials/:subject[/:provider]
*
*
- * A person may keep one credential per provider (anthropic, openai, deepseek, minimax, openrouter, custom, node), each
- * with its own daily budget; the provider-less routes read and write the default one, which is the row a pre-C3 caller
- * stored (migrations/0014).
+ * The person's own routes (L36 Y1) store keys the person holds themselves under the reserved owner `self`:
+ *
+ * GET /api/v1/me/credentials
+ * PUT /api/v1/me/credentials/:provider
+ * DELETE /api/v1/me/credentials/:provider
+ * POST /api/v1/me/credentials/:provider/test
+ *
+ * A person may keep one credential per provider (the console presets: anthropic, openai, gemini, deepseek, mistral,
+ * xai, groq, cerebras, together, fireworks, openrouter, minimax and a custom endpoint; plus node for the old routes),
+ * each with its own daily budget. The provider-less routes read and write the default one, which is the row a pre-C3
+ * caller stored (migrations/0014).
*
*
* At rest the key is AES-256-GCM encrypted with AI_CREDENTIALS_KEY (64 hex characters) and bound to its owner, subject
* At rest the key is AES-256-GCM encrypted with AI_CREDENTIALS_KEY (64 hex characters) and bound to its owner, subject
- * AND provider (the additional data), so a row copied to another owner, subject or provider does not decrypt. Rows
- * written before the provider dimension were sealed with owner|subject only (`v1:`); they are read with that AAD and
- * rewritten as `v2:` (owner|subject|provider) on first use. Without the key configured the routes answer 503 and
- * credentialed runs are refused. Spend is what the credential's requests cost today (provider_key byo:<owner>:<subject>
- * for a row stored before C3 or through the provider-less route, byo:<owner>:<subject>:<provider> for the rest),
- * checked against the optional budget_usd_per_day.
+ * AND provider (the additional data), so a row copied to another owner, subject or provider does not decrypt. The
+ * person's own rows use the reserved owner `self` and the same scheme (`self|subject|provider`). Rows written before
+ * the provider dimension were sealed with owner|subject only (`v1:`); they are read with that AAD and rewritten as
+ * `v2:` (owner|subject|provider) on first use. Without the key configured the routes answer 503 and credentialed runs
+ * are refused. Spend is what the credential's requests cost today (provider_key byo:<owner>:<subject> for a row
+ * stored before C3 or through the provider-less route, byo:<owner>:<subject>:<provider> for the rest), checked against
+ * the optional budget_usd_per_day. A person's own key is never billed: its runs carry no network budget.
+ *
+ * Test on save (L36 Y1): list the provider's models and count them; if it lists none, one token to its cheapest model;
+ * store status, checked_at, models_count and a redacted last_error. The key is never returned, never logged and never
+ * put in an error, even when the provider echoes it.
*
*
- * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing is
- * sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.
+ * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing
+ * is sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { validate } = require('openvibe-contracts');
+const contracts = require('openvibe-contracts');
+const { validate } = contracts;
+
+/**
+ * ai.me-* are added by openvibe-contracts 0.257.0. A service pinning an older release (until the owner tags it) has no
+ * schema for them, so the route validates the essential shape itself; with 0.257.0 or later the contract is the rule.
+ */
+function validateMe(id, body) {
+ try { return contracts.schema(id) ? contracts.validate(id, body) : null; } catch { return null; }
+}
+function manualMePut(provider, body) {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
+ if (body.provider !== provider) return `body.provider is ${body.provider}, the route names ${provider}`;
+ if (body.key != null && (typeof body.key !== 'string' || body.key.length < 8 || body.key.length > 400)) return 'key must be 8-400 characters';
+ if (body.base_url != null && !/^https:\/\/[^\s/?#@]+(\/[^\s?#]*)?$/.test(String(body.base_url))) return 'base_url must be an https URL without credentials, query or fragment';
+ if (provider === 'custom' && !body.base_url) return 'a custom provider needs base_url';
+ if (body.models != null && (typeof body.models !== 'object' || Array.isArray(body.models))) return 'models must be an object';
+ if (body.budget_usd_per_day != null && !(Number(body.budget_usd_per_day) >= 0 && Number(body.budget_usd_per_day) <= 1000)) return 'budget_usd_per_day must be 0-1000';
+ return null;
+}
const { AiError } = require('./util');
const { AiError } = require('./util');
+const { CREDENTIAL_PROVIDERS, credentialProvider } = require('./console/presets');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];
-/** Where each provider answers when the person stored no base_url. DeepSeek and MiniMax speak the Anthropic format at
- * their own /anthropic path; a custom provider always names its endpoint. */
-const PROVIDER_BASE_URL = {
- anthropic: 'https://api.anthropic.com/v1', openai: 'https://api.openai.com/v1', deepseek: 'https://api.deepseek.com/anthropic',
- minimax: 'https://api.minimax.io/anthropic', openrouter: 'https://openrouter.ai/api/v1',
+/** The reserved owner of a key the person holds themselves (L36 Y1). */
+const SELF_OWNER = 'self';
+const LEGACY_PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];
+const PROVIDERS = [...new Set([...LEGACY_PROVIDERS, ...CREDENTIAL_PROVIDERS])];
+/** Where each provider answers when the person stored no base_url: the ONE table in server/console/presets.js. */
+const PROVIDER_BASE_URL = Object.fromEntries(CREDENTIAL_PROVIDERS
+ .map((id) => [id, (credentialProvider(id) || {}).base_url || null])
+ .filter(([, url]) => url));
+const PROVIDER_KIND = Object.fromEntries(CREDENTIAL_PROVIDERS.map((id) => [id, (credentialProvider(id) || {}).kind || 'openai']));
+/** The model a provider's test uses when it lists no models (and a saved key names none). */
+const DEFAULT_MODEL = {
+ anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', gemini: 'gemini-2.5-flash', deepseek: 'deepseek-chat',
+ mistral: 'mistral-small-latest', xai: 'grok-4-fast', groq: 'openai/gpt-oss-20b', cerebras: 'llama-3.3-70b',
+ together: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', fireworks: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
+ openrouter: 'openrouter/auto', minimax: 'MiniMax-M2', custom: null,
};
};
+/** Providers that report a balance somewhere public (L36 Y1 "its balance where the provider reports one"). */
+const BALANCE_PROVIDERS = new Set(['openrouter', 'deepseek']);
function providerKeyFor(owner, subject, provider = null) { return provider ? `byo:${owner}:${subject}:${provider}` : `byo:${owner}:${subject}`; }
function providerKeyFor(owner, subject, provider = null) { return provider ? `byo:${owner}:${subject}:${provider}` : `byo:${owner}:${subject}`; }
/** The AAD a key is sealed with: moving the row to another owner, subject or provider must not decrypt it. */
/** The AAD a key is sealed with: moving the row to another owner, subject or provider must not decrypt it. */
function aadFor(owner, subject, provider) { return `${owner}|${subject}|${provider}`; }
function aadFor(owner, subject, provider) { return `${owner}|${subject}|${provider}`; }
function legacyAadFor(owner, subject) { return `${owner}|${subject}`; }
function legacyAadFor(owner, subject) { return `${owner}|${subject}`; }
+const hintOf = (key) => `…${String(key).slice(-4)}`;
+
+/** Replace the key (and anything that looks like one) before an upstream error is stored, logged or answered. */
+function scrub(text, key) {
+ let t = String(text == null ? '' : text);
+ if (key) t = t.split(String(key)).join('[redacted]');
+ return t.replace(/\b(sk|key|token)[-_][A-Za-z0-9_-]{8,}\b/gi, '[redacted]').slice(0, 500);
+}
-function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
+/** A provider's auth headers, the same way its adapter sends them (server/providers/anthropic.js). */
+function authHeaders(provider, base, key) {
+ if (PROVIDER_KIND[provider] === 'anthropic') {
+ const headers = { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
+ if (!/anthropic\.com/i.test(String(base || ''))) headers.Authorization = `Bearer ${key}`;
+ return headers;
+ }
+ return { Authorization: `Bearer ${key}` };
+}
+
+function withDeadline(promise, ms, message) {
+ let timer;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(Object.assign(new Error(message || `no response within ${Math.round(ms / 1000)}s`), { code: 'provider.timeout' })), ms);
+ if (timer.unref) timer.unref();
+ });
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+}
+
+async function bodyText(res) {
+ try { return await res.text(); } catch { return ''; }
+}
+
+function modelIds(json) {
+ const list = Array.isArray(json) ? json
+ : json && Array.isArray(json.data) ? json.data
+ : json && Array.isArray(json.models) ? json.models : [];
+ const out = [];
+ for (const m of list) {
+ const id = typeof m === 'string' ? m : m && (m.id || m.name);
+ if (typeof id === 'string' && id && !out.includes(id)) out.push(id);
+ }
+ return out;
+}
+
+/** { amount, currency } when the provider reports one, else null. A balance failure never fails the key test. */
+async function balanceOf(provider, base, key, fetchImpl, timeoutMs) {
+ if (!BALANCE_PROVIDERS.has(provider)) return null;
+ try {
+ const url = provider === 'openrouter'
+ ? `${String(base).replace(/\/+$/, '')}/credits`
+ : `${new URL(base).origin}/user/balance`;
+ const res = await withDeadline(fetchImpl(url, { method: 'GET', headers: { Accept: 'application/json', ...authHeaders(provider, base, key) }, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);
+ if (!res.ok) return null;
+ const j = await res.json().catch(() => null);
+ if (!j) return null;
+ if (provider === 'openrouter') {
+ const d = j.data || j;
+ const amount = Number(d.total_credits) - Number(d.total_usage);
+ return Number.isFinite(amount) ? { amount: Math.round(amount * 1e6) / 1e6, currency: 'USD' } : null;
+ }
+ const info = Array.isArray(j.balance_infos) ? j.balance_infos[0] : null;
+ const amount = Number(info && info.total_balance);
+ return Number.isFinite(amount) ? { amount, currency: String((info && info.currency) || 'USD').toUpperCase().slice(0, 3) } : null;
+ } catch { return null; }
+}
+
+function createCredentials({ db, config, clock = { now: () => Date.now() }, fetchImpl = null }) {
// subject_credentials is in migrations/0001_initial.sql (ADR-035); 0011 added the node machine, 0014 the provider
// subject_credentials is in migrations/0001_initial.sql (ADR-035); 0011 added the node machine, 0014 the provider
- // dimension (is_default, spend_key, one row per provider).
+ // dimension (is_default, spend_key, one row per provider), 0019 the health of a key.
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
+ const doFetch = fetchImpl || require('./providers/guarded-fetch').guardedFetch;
+ const testTimeout = () => Number(config.credentialTestTimeoutMs) || 10000;
function secretKey() {
function secretKey() {
const hex = String(config.credentialsKey || '');
const hex = String(config.credentialsKey || '');
if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'credentials.unavailable', 'person credentials are not configured on this service (AI_CREDENTIALS_KEY)');
if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'credentials.unavailable', 'person credentials are not configured on this service (AI_CREDENTIALS_KEY)');
return Buffer.from(hex, 'hex');
return Buffer.from(hex, 'hex');
}
}
- /** v2: bound to owner, subject and provider (Codes C3). */
+ /** v2: bound to owner, subject and provider (Codes C3; `self` for the person's own rows, L36). */
function seal(plain, aad) {
function seal(plain, aad) {
const iv = crypto.randomBytes(12);
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);
const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);
@@ -100,6 +213,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -100,6 +213,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,
created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,
};
};
}
}
+ /** The person's own view (ai.me-credential@1): no owner/subject/spend, no key, a four-character hint. */
+ function selfView(row) {
+ return {
+ provider: row.provider, hint: String(row.key_hint || '').slice(-4) || '????',
+ base_url: row.base_url || null,
+ budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,
+ status: row.status || 'untested', checked_at: row.checked_at || null, last_error: row.last_error || null,
+ models_count: Number(row.models_count) || 0,
+ created_at: row.created_at, updated_at: row.updated_at,
+ };
+ }
const getRow = async (owner, subject, provider) => provider
const getRow = async (owner, subject, provider) => provider
? await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(owner, subject, provider)
? await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(owner, subject, provider)
: await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND is_default').get(owner, subject);
: await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND is_default').get(owner, subject);
@@ -142,7 +266,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -142,7 +266,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = excluded.base_url, key_enc = excluded.key_enc,
ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = excluded.base_url, key_enc = excluded.key_enc,
key_hint = excluded.key_hint, models = excluded.models, budget_usd_day = excluded.budget_usd_day, is_default = subject_credentials.is_default OR excluded.is_default, updated_at = excluded.updated_at`)
key_hint = excluded.key_hint, models = excluded.models, budget_usd_day = excluded.budget_usd_day, is_default = subject_credentials.is_default OR excluded.is_default, updated_at = excluded.updated_at`)
- .run(owner, subject, body.provider, body.base_url || null, seal(key, aadFor(owner, subject, body.provider)), `…${key.slice(-4)}`, JSON.stringify(body.models || {}),
+ .run(owner, subject, body.provider, body.base_url || null, seal(key, aadFor(owner, subject, body.provider)), hintOf(key), JSON.stringify(body.models || {}),
body.budget_usd_per_day ? Number(body.budget_usd_per_day) : null, makeDefault,
body.budget_usd_per_day ? Number(body.budget_usd_per_day) : null, makeDefault,
(prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, body.provider)), prev ? prev.created_at : at, at);
(prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, body.provider)), prev ? prev.created_at : at, at);
});
});
@@ -194,18 +318,182 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -194,18 +318,182 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
}
}
}
}
- /** The decrypted credential for one execution: { providerKey, provider, base_url, apiKey|machine, models }. */
+ /** The decrypted credential for one execution: { providerKey, owner, provider, base_url, apiKey|machine, models, hint }. */
async function forRun(owner, subject, provider = null) {
async function forRun(owner, subject, provider = null) {
if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');
if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');
const r = await getRow(owner, subject, provider);
const r = await getRow(owner, subject, provider);
if (!r) throw new AiError(404, 'credential.not_found', provider ? `the ${provider} credential was deleted` : 'the credential was deleted');
if (!r) throw new AiError(404, 'credential.not_found', provider ? `the ${provider} credential was deleted` : 'the credential was deleted');
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);
const providerKey = r.spend_key || providerKeyFor(owner, subject);
const providerKey = r.spend_key || providerKeyFor(owner, subject);
- if (r.provider === 'node') return { providerKey, provider: 'node', subject, machine: r.machine, models: JSON.parse(r.models || '{}') };
- return { providerKey, provider: r.provider, subject, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}') };
+ const hint = String(r.key_hint || '').slice(-4);
+ if (r.provider === 'node') return { providerKey, owner: r.owner, subject, provider: 'node', machine: r.machine, models: JSON.parse(r.models || '{}'), hint };
+ return { providerKey, owner: r.owner, subject, provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint };
+ }
+
+ // ── The person's own keys (owner `self`, L36 Y1) ───────────────────────
+
+ /** Every self row for one person, newest first. */
+ async function selfRows(subject) {
+ if (!SUBJECT_RE.test(String(subject))) return [];
+ return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'node' ORDER BY provider").all(SELF_OWNER, subject);
+ }
+ async function listSelf(subject) {
+ return (await selfRows(subject)).map(selfView);
+ }
+ async function hasSelf(subject) {
+ return (await selfRows(subject)).length > 0;
+ }
+
+ /** A person's own credential for one provider (no key returned to the caller; server-side use only). */
+ async function selfForRun(subject, provider) {
+ if (!CREDENTIAL_PROVIDERS.includes(provider)) return null;
+ const r = await getRow(SELF_OWNER, subject, provider);
+ if (!r) return null;
+ await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), SELF_OWNER, subject, provider);
+ return {
+ providerKey: r.spend_key || providerKeyFor(SELF_OWNER, subject, provider), owner: SELF_OWNER, subject,
+ provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null,
+ apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint: String(r.key_hint || '').slice(-4), status: r.status,
+ };
+ }
+ /** Every self credential a run may try first (L36 Y6). A row whose key cannot be opened is skipped, never fatal. */
+ async function selfForAllRuns(subject) {
+ const out = [];
+ for (const r of await selfRows(subject)) {
+ try {
+ const cred = await selfForRun(subject, r.provider);
+ if (cred) out.push(cred);
+ } catch { /* a broken row must not take the person's other keys with it */ }
+ }
+ return out;
+ }
+
+ /** The person's own PUT: store or replace one provider's key, then test it on save. */
+ async function putSelf(subject, provider, body) {
+ if (!SUBJECT_RE.test(String(subject))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');
+ if (!CREDENTIAL_PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', `no credential is stored for ${provider}`);
+ const v = validateMe('ai.me-credential-put@1', body);
+ const manual = v ? null : manualMePut(provider, body);
+ if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-credential-put@1', { errors: v ? v.errors : [{ path: '', message: manual }] });
+ if (body.provider !== provider) throw new AiError(422, 'input.invalid', `body.provider is ${body.provider}, the route names ${provider}`);
+ const prev = await getRow(SELF_OWNER, subject, provider);
+ const at = iso(clock.now());
+ const sameEndpoint = prev && (prev.base_url || null) === (body.base_url || null);
+ if (body.key == null && !sameEndpoint) throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the endpoint' : 'key is required the first time');
+ const key = body.key != null ? String(body.key) : await openRow(prev);
+ await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, key_enc, key_hint, models, budget_usd_day, is_default, spend_key, status, checked_at, last_error, models_count, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, false, ?, 'untested', NULL, NULL, 0, ?, ?)
+ ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = excluded.base_url, key_enc = excluded.key_enc,
+ key_hint = excluded.key_hint, models = excluded.models, budget_usd_day = excluded.budget_usd_day,
+ status = 'untested', checked_at = NULL, last_error = NULL, models_count = 0, updated_at = excluded.updated_at`)
+ .run(SELF_OWNER, subject, provider, body.base_url || null, seal(key, aadFor(SELF_OWNER, subject, provider)), hintOf(key),
+ JSON.stringify(body.models || {}), body.budget_usd_per_day ? Number(body.budget_usd_per_day) : null,
+ providerKeyFor(SELF_OWNER, subject, provider), prev ? prev.created_at : at, at);
+ await testSelf(subject, provider);
+ return selfView(await getRow(SELF_OWNER, subject, provider));
}
}
- return { put, get, remove, admit, forRun, spentToday, providerKeyFor };
+ async function removeSelf(subject, provider) {
+ if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
+ return (await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, provider)).changes > 0;
+ }
+
+ /** Mark a key failing (a provider 401/403 in a run, L36 Y8). Never stores the key. */
+ async function markFailed(owner, subject, provider, message) {
+ if (!owner || !SUBJECT_RE.test(String(subject)) || !PROVIDERS.includes(provider)) return false;
+ const at = iso(clock.now());
+ return (await db.prepare('UPDATE subject_credentials SET status = ?, checked_at = ?, last_error = ? WHERE owner = ? AND subject = ? AND provider = ?')
+ .run('failing', at, String(message || 'the provider refused the key').slice(0, 500), owner, subject, provider)).changes > 0;
+ }
+
+ /**
+ * Test one stored key (L36 Y1): the model list, else one token to its cheapest model, within testTimeout().
+ * Stores status, checked_at, models_count and a redacted last_error, and returns the ai.me-credential-test@1 body.
+ * For a custom (or overridden) endpoint the production fetch is the guarded fetcher, so the egress rules hold.
+ */
+ async function testSelf(subject, provider) {
+ if (!CREDENTIAL_PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', `no credential is stored for ${provider}`);
+ const row = await getRow(SELF_OWNER, subject, provider);
+ if (!row) throw new AiError(404, 'credential.not_found', `no ${provider} credential stored`);
+ const key = await openRow(row);
+ const spec = credentialProvider(provider) || {};
+ const base = String(row.base_url || PROVIDER_BASE_URL[provider] || '').replace(/\/+$/, '');
+ const timeoutMs = testTimeout();
+ const started = Date.now();
+ let models = [];
+ let status = 'ok';
+ let error = null;
+ let balance = null;
+ const headers = { Accept: 'application/json', 'Content-Type': 'application/json', ...authHeaders(provider, base, key) };
+ if (!base) {
+ status = 'failing';
+ error = { code: 'credential.endpoint_required', message: 'this provider needs a base URL' };
+ } else {
+ try {
+ const res = await withDeadline(doFetch(`${base}/models`, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);
+ if (res.ok) {
+ models = modelIds(await res.json().catch(() => null));
+ } else if (res.status === 401 || res.status === 403) {
+ throw Object.assign(new Error(`the provider refused the key (${res.status})`), { status: res.status, code: 'provider.auth' });
+ } else {
+ // A provider with no /models endpoint is not a failed key: try the one-token call below.
+ models = [];
+ }
+ } catch (err) {
+ if (err && (err.status === 401 || err.status === 403)) {
+ status = 'failing';
+ error = { code: 'provider.auth', message: scrub(err.message, key) };
+ } else if (err && err.code === 'provider.timeout') {
+ status = 'failing';
+ error = { code: 'provider.timeout', message: scrub(err.message, key) };
+ } else if (err && err.code === 'EADDRNOTPUBLIC') {
+ // The guarded fetcher's egress rule (https, public address, no credentials in the URL) refused the
+ // endpoint: that is a bad endpoint, not a provider without a model list.
+ status = 'failing';
+ error = { code: 'credential.endpoint_refused', message: scrub(err.message, key) };
+ } else {
+ models = []; // no list: the one-token request decides
+ }
+ }
+ if (!error && !models.length) {
+ const model = (JSON.parse(row.models || '{}').chat) || spec.model || DEFAULT_MODEL[provider];
+ try {
+ if (!model) throw Object.assign(new Error('no model is known for this provider'), { code: 'credential.no_model' });
+ const url = PROVIDER_KIND[provider] === 'anthropic' ? `${base}/messages` : `${base}/chat/completions`;
+ const body = PROVIDER_KIND[provider] === 'anthropic'
+ ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }
+ : { model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] };
+ const res = await withDeadline(doFetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);
+ if (!res.ok) {
+ const text = await bodyText(res);
+ const auth = res.status === 401 || res.status === 403;
+ throw Object.assign(new Error(`${auth ? 'the provider refused the key' : 'the provider did not answer the test request'} (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`), { status: res.status, code: auth ? 'provider.auth' : 'provider.http' });
+ }
+ } catch (err) {
+ status = 'failing';
+ error = {
+ code: err.status === 401 || err.status === 403 ? 'provider.auth'
+ : err.code === 'provider.timeout' ? 'provider.timeout'
+ : err.code === 'EADDRNOTPUBLIC' ? 'credential.endpoint_refused' : 'provider.http',
+ message: scrub(err.message, key),
+ };
+ }
+ }
+ if (!error) balance = await balanceOf(provider, base, key, doFetch, timeoutMs);
+ }
+ const checkedAt = iso(clock.now());
+ await db.prepare('UPDATE subject_credentials SET status = ?, checked_at = ?, last_error = ?, models_count = ? WHERE owner = ? AND subject = ? AND provider = ?')
+ .run(status, checkedAt, error ? error.message : null, models.length, SELF_OWNER, subject, provider);
+ return {
+ ok: status === 'ok', models_count: models.length, sample_models: models.slice(0, 5),
+ latency_ms: Math.max(0, Date.now() - started), balance, error,
+ };
+ }
+
+ return {
+ put, get, remove, admit, forRun, spentToday, providerKeyFor,
+ listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed,
+ };
}
}
/** PUT/GET/DELETE /api/v1/credentials/:subject[/:provider] (ai.credential.manage; a service token speaks for itself). */
/** PUT/GET/DELETE /api/v1/credentials/:subject[/:provider] (ai.credential.manage; a service token speaks for itself). */
@@ -258,4 +546,7 @@ function credentialsRouter({ credentials, auth, registry, sendError }) {
@@ -258,4 +546,7 @@ function credentialsRouter({ credentials, auth, registry, sendError }) {
return r;
return r;
}
}
-module.exports = { createCredentials, credentialsRouter, providerKeyFor, aadFor, PROVIDERS, PROVIDER_BASE_URL };
+module.exports = {
+ createCredentials, credentialsRouter, providerKeyFor, aadFor, PROVIDERS, PROVIDER_BASE_URL, PROVIDER_KIND,
+ CREDENTIAL_PROVIDERS, SELF_OWNER, DEFAULT_MODEL, scrub, hintOf,
+};
@@ -88,6 +88,25 @@ async function runChanged(row) {
@@ -88,6 +88,25 @@ async function runChanged(row) {
return env;
return env;
}
}
+/**
+ * L36 Y8: a provider refused one of a person's own keys (401/403) during a run. The key's status is already
+ * `failing` (server/credentials.js markFailed); this tells the products that may show it. The payload never carries
+ * the key, only a redacted reason. No-op while publishing is off, like every other event.
+ */
+async function credentialFailed({ subject, provider, error, owner = null }) {
+ if (!svc || !dbRef || !subject || !provider) return null;
+ const env = await svc.emitIn(dbRef, {
+ event_type: 'ai.credential.failed',
+ actor: { type: 'service', id: 'ai' },
+ on_behalf_of: { type: 'user', id: subject },
+ subject: { type: 'credential', id: `${subject}:${provider}` },
+ visibility: 'internal',
+ priority: 'important',
+ payload: { subject, provider, status: 'failing', checked_at: iso(Date.now()), error: String(error || 'the provider refused the key').slice(0, 500) },
+ });
+ dbRef.afterCommit(() => svc && svc.kick());
+ return env;
+}
async function status() {
async function status() {
if (!svc) return { enabled: false };
if (!svc) return { enabled: false };
const s = await svc.status();
const s = await svc.status();
@@ -106,4 +125,4 @@ async function stop() {
@@ -106,4 +125,4 @@ async function stop() {
}
}
function _reset() { if (svc) svc.stop(); svc = null; queued = 0; }
function _reset() { if (svc) svc.stop(); svc = null; queued = 0; }
-module.exports = { init, runChanged, payloadOf, status, stop, _reset };
+module.exports = { init, runChanged, credentialFailed, payloadOf, status, stop, _reset };
@@ -61,10 +61,14 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -61,10 +61,14 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
require('./usage-samples').init(db, { ...config.billing, clientId: config.console.clientId, networkUrl: config.networkInternalUrl, log });
require('./usage-samples').init(db, { ...config.billing, clientId: config.console.clientId, networkUrl: config.networkInternalUrl, log });
// ai.preferences (read) and ai.usage_summary (written): Network user modules (server/user-modules.js).
// ai.preferences (read) and ai.usage_summary (written): Network user modules (server/user-modules.js).
const userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });
const userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });
- // A person's own provider keys (WS-O task 2): stored by the service holding their consent, used by their runs only.
- const credentials = require('./credentials').createCredentials({ db, config, clock });
+ // A person's own provider keys (WS-O task 2; L36 Y1): stored by the service holding their consent, or by the
+ // person themselves (owner `self`), used by their runs only. The test seam (a fake fetch) is for tests; production
+ // reaches a custom endpoint through the guarded fetcher's egress rules.
+ const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch });
+ // A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.
+ const preferences = require('./person-preferences').createPreferences({ db, clock });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers, projectSecrets, pool, runBudgets });
+ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets });
await seed({ registry, quotas, config, env, db });
await seed({ registry, quotas, config, env, db });
const interrupted = await runs.recoverInterrupted();
const interrupted = await runs.recoverInterrupted();
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
@@ -86,7 +90,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -86,7 +90,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets, memory });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, vault, benchEvents, runBudgets, memory });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+/**
+ * The person's own AI routes (L36 Y1/Y3): their provider keys and how their runs route. Contracts
+ * ai.me.read / ai.me.manage, ai.me-credential(-put|-test|-list)@1 and ai.me-preferences@1.
+ *
+ * GET /api/v1/me/credentials
+ * PUT /api/v1/me/credentials/:provider
+ * DELETE /api/v1/me/credentials/:provider
+ * POST /api/v1/me/credentials/:provider/test
+ * GET /api/v1/me/ai/preferences
+ * PUT /api/v1/me/ai/preferences
+ *
+ * Only the person themselves reaches them: their Network user token, or the person session AI's own site already
+ * issues for a run receipt, behind a same-origin check. A service token is refused — including any token carrying
+ * `on_behalf_of`, whatever capabilities it holds (memory rule, AI #204/#205): a Codes run token holds ai.run.create,
+ * so a capability check alone would admit an agent. `ai.me.*` are person capabilities, never a service's.
+ *
+ * Writes are stricter than reads. A user token must carry audience `openvibe.ai` explicitly: a token minted for
+ * `openvibe.network` is every OpenVibe site's session, and a site's server holding it must not be able to point a
+ * person's prompts at its own endpoint or delete their keys. Such a token may still read (hints and settings only).
+ * AI's own person session must prove same-origin for a write: with neither Origin nor Sec-Fetch-Site the write is
+ * refused, so a site cannot use the cookie with a bare request. Reads keep accepting that cookie as before.
+ *
+ * The key is never returned (a four-character hint only), never logged and never put in an error.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { verifyUserToken } = require('openvibe-sdk/auth');
+const { AiError } = require('./util');
+const { bearer } = require('./auth');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const NON_PERSON = /^(svc|app|mod|node):/;
+
+/** The audience a write needs: a token minted for AI itself, never a site's `openvibe.network` session. */
+const WRITE_AUDIENCE = 'openvibe.ai';
+
+function audiencesOf(claims) {
+ const aud = claims && claims.aud;
+ return (Array.isArray(aud) ? aud : aud == null ? [] : [aud]).map(String);
+}
+
+/**
+ * The same-origin check for the person-session cookie (the console's csrfOk rule, without a form token).
+ * A write with neither Origin nor Sec-Fetch-Site is refused: a site can make a bare request with the cookie, so the
+ * absence of both is not proof of same-origin. A read may keep the old rule (it returns hints and settings only).
+ */
+function sameOrigin(req, config, { write = false } = {}) {
+ const site = req.headers['sec-fetch-site'];
+ const origin = req.headers.origin;
+ if (!site && !origin) return !write;
+ if (site && site !== 'same-origin' && site !== 'none') return false;
+ if (origin) {
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ }
+ return true;
+}
+
+function meRouter({ credentials, preferences, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {
+ const r = express.Router();
+
+ /** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
+ function guard(req, res, next) {
+ return Promise.resolve().then(async () => {
+ const ctx = req.ov;
+ const write = req.method !== 'GET' && req.method !== 'HEAD';
+ const token = bearer(req);
+ if (token) {
+ // A service, app, module or node token is a principal whatever capabilities it holds. Checked first so
+ // the answer is explicit, never "invalid token".
+ let service = null;
+ try { service = await auth.verify(token); } catch { service = { ok: false }; }
+ if (service && service.ok) return http.sendProblem(res, 403, 'capability.denied', { detail: "a service token is not accepted on the person's own route", ctx });
+ let claims;
+ try {
+ claims = await verifyUserToken(token, {
+ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
+ issuer: config.issuer, audience: ['openvibe.network', 'openvibe.ai'], now: clock.now(), log,
+ });
+ } catch (err) {
+ return http.sendProblem(res, err.status || 401, err.code || 'token.invalid', { detail: err.detail || 'invalid token', ctx });
+ }
+ if (NON_PERSON.test(String(claims.sub || '')) || ['service', 'app', 'mod', 'node'].includes(claims.actor_type)) {
+ return http.sendProblem(res, 403, 'capability.denied', { detail: "a principal token is not accepted on the person's own route", ctx });
+ }
+ if (claims.on_behalf_of != null) return http.sendProblem(res, 403, 'capability.denied', { detail: 'a token acting on behalf of someone is not the person themselves', ctx });
+ // A token minted for openvibe.network is every OpenVibe site's session: it may read hints and settings,
+ // but a write needs a token whose aud explicitly names AI itself.
+ if (write && !audiencesOf(claims).includes(WRITE_AUDIENCE)) {
+ return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });
+ }
+ const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');
+ if (!subject) return http.sendProblem(res, 401, 'token.invalid', { detail: 'the person token has no usr_ subject', ctx });
+ req.principal = { kind: 'user', sub: claims.sub, subject: { type: 'user', id: subject }, claims };
+ return next();
+ }
+ // The person session AI already issues for a run receipt. Cookie auth needs the same origin, so another
+ // site cannot spend a person's keys with their cookie; a write also needs the origin signal to be present.
+ if (!personSessions) return http.sendProblem(res, 401, 'token.missing', { detail: "the person's own token or session is required", ctx });
+ if (!sameOrigin(req, config, { write })) return http.sendProblem(res, 403, 'capability.denied', { detail: 'the person session is only used from ' + new URL(config.baseUrl).origin + (write ? ', with an Origin or Sec-Fetch-Site header' : ''), ctx });
+ const session = await personSessions.read(req);
+ if (!session) return http.sendProblem(res, 401, 'token.missing', { detail: "the person's own token or session is required", ctx });
+ if (!SUBJECT_RE.test(String(session.subject || ''))) return http.sendProblem(res, 401, 'token.invalid', { detail: 'the session has no usr_ subject', ctx });
+ req.principal = { kind: 'user', subject: { type: 'user', id: session.subject }, username: session.username || null };
+ return next();
+ }).catch((err) => {
+ log.error(`[me] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
+ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx: req.ov });
+ });
+ }
+ const personOf = (req) => req.principal.subject.id;
+ const noStore = (res) => res.set('Cache-Control', 'no-store');
+ const run = (fn) => async (req, res) => {
+ try { noStore(res).json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }
+ };
+ const audit = async (req, action, metadata) => {
+ if (!registry) return;
+ try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }
+ };
+
+ r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: await credentials.listSelf(personOf(req)) })));
+ r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {
+ const out = await credentials.putSelf(personOf(req), req.params.provider, { ...(req.body || {}), ...(req.body && req.body.provider == null ? { provider: req.params.provider } : {}) });
+ await audit(req, 'credential.self.put', { provider: out.provider, status: out.status, base_url: out.base_url });
+ return out;
+ }));
+ r.delete('/api/v1/me/credentials/:provider', guard, async (req, res) => {
+ try {
+ const gone = await credentials.removeSelf(personOf(req), req.params.provider);
+ if (gone) await audit(req, 'credential.self.delete', { provider: req.params.provider });
+ res.status(gone ? 204 : 404).end();
+ } catch (err) { sendError(res, err, req.ov); }
+ });
+ r.post('/api/v1/me/credentials/:provider/test', guard, run(async (req) => {
+ const out = await credentials.testSelf(personOf(req), req.params.provider);
+ await audit(req, 'credential.self.test', { provider: req.params.provider, ok: out.ok, models_count: out.models_count });
+ return out;
+ }));
+ r.get('/api/v1/me/ai/preferences', guard, run(async (req) => await preferences.get(personOf(req))));
+ r.put('/api/v1/me/ai/preferences', guard, run(async (req) => {
+ const out = await preferences.put(personOf(req), req.body || {});
+ await audit(req, 'ai.preferences.put', { only_my_keys: out.only_my_keys, free_first: out.free_first, price_ceiling_usd_per_mtok: out.price_ceiling_usd_per_mtok });
+ return out;
+ }));
+ return r;
+}
+
+module.exports = { meRouter, sameOrigin };
@@ -12,8 +12,10 @@
@@ -12,8 +12,10 @@
*
*
* /api/health and /api/ready are probes. The operator/admin API and the attribution-quota API are
* /api/health and /api/ready are probes. The operator/admin API and the attribution-quota API are
* internal (staff/service tokens): they stay out. Credentials are deliberately not documented at all
* internal (staff/service tokens): they stay out. Credentials are deliberately not documented at all
- * — no credential, provider secret or secret reference field appears in this document. The
- * OpenAI-compatible /v1/chat/completions and /v1/models are outside /api and stay out of it too.
+ * — no credential, provider secret or secret reference field appears in this document. The person's
+ * own /api/v1/me/* routes (their provider keys, and the preferences that route them) are person-only
+ * and carry a key on PUT, so they stay out for the same reason. The OpenAI-compatible
+ * /v1/chat/completions and /v1/models are outside /api and stay out of it too.
*
*
* Request fields are what server/api/runs.js and server/api/actions.js read; the run properties are
* Request fields are what server/api/runs.js and server/api/actions.js read; the run properties are
* server/runs.js decode(). Nothing is inferred.
* server/runs.js decode(). Nothing is inferred.
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * A person's ai.preferences (L36 Y3; Contracts ai.me-preferences@1): only_my_keys, free_first, a price ceiling for
+ * the network's paid fallback, and a default model (or `auto`) per kind of work. Stored per person in
+ * ai_person_preferences (migrations/0020), JSON so another preference adds without a migration.
+ *
+ * A row that predates the table or a field it never saw reads the default, never undefined. There is no default key
+ * and no network budget here: own keys are free to their owner.
+ */
+const contracts = require('openvibe-contracts');
+const { AiError, iso } = require('./util');
+
+const DEFAULTS = Object.freeze({
+ only_my_keys: false,
+ free_first: true,
+ price_ceiling_usd_per_mtok: null,
+ defaults: Object.freeze({ text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }),
+});
+
+/** The same rules ai.me-preferences@1 states, for a service whose contracts pin predates 0.257.0. */
+function manualPreferences(body) {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
+ if (typeof body.only_my_keys !== 'boolean' || typeof body.free_first !== 'boolean') return 'only_my_keys and free_first are booleans';
+ if (body.price_ceiling_usd_per_mtok != null && !(Number(body.price_ceiling_usd_per_mtok) >= 0 && Number(body.price_ceiling_usd_per_mtok) <= 1000)) return 'price_ceiling_usd_per_mtok must be 0-1000 or null';
+ const d = body.defaults;
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return 'defaults is an object';
+ for (const k of ['text', 'code', 'vision', 'image']) {
+ if (typeof d[k] !== 'string' || !d[k].length || d[k].length > 120) return `defaults.${k} must be a model id or auto (1-120 characters)`;
+ }
+ return null;
+}
+
+function createPreferences({ db, clock = { now: () => Date.now() } }) {
+ const read = async (subject) => {
+ const row = await db.prepare('SELECT preferences FROM ai_person_preferences WHERE subject = ?').get(String(subject));
+ let stored = {};
+ try { stored = row ? JSON.parse(row.preferences || '{}') : {}; } catch { stored = {}; }
+ return {
+ only_my_keys: stored.only_my_keys === true,
+ free_first: stored.free_first == null ? DEFAULTS.free_first : stored.free_first === true,
+ price_ceiling_usd_per_mtok: typeof stored.price_ceiling_usd_per_mtok === 'number' ? stored.price_ceiling_usd_per_mtok : null,
+ defaults: { ...DEFAULTS.defaults, ...(stored.defaults && typeof stored.defaults === 'object' ? stored.defaults : {}) },
+ updated_at: row ? row.updated_at : null,
+ };
+ };
+
+ async function get(subject) { return await read(subject); }
+
+ async function put(subject, body) {
+ // ai.me-preferences@1 arrives with openvibe-contracts 0.257.0; before that pin the shape is checked here.
+ let v = null;
+ try { v = contracts.schema('ai.me-preferences@1') ? contracts.validate('ai.me-preferences@1', body) : null; } catch { v = null; }
+ const manual = v ? null : manualPreferences(body);
+ if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-preferences@1', { errors: v ? v.errors : [{ path: '', message: manual }] });
+ const value = {
+ only_my_keys: body.only_my_keys === true,
+ free_first: body.free_first === true,
+ price_ceiling_usd_per_mtok: body.price_ceiling_usd_per_mtok == null ? null : Number(body.price_ceiling_usd_per_mtok),
+ defaults: {
+ text: String(body.defaults.text), code: String(body.defaults.code),
+ vision: String(body.defaults.vision), image: String(body.defaults.image),
+ },
+ };
+ const at = iso(clock.now());
+ const prev = await db.prepare('SELECT created_at FROM ai_person_preferences WHERE subject = ?').get(String(subject));
+ await db.prepare(`INSERT INTO ai_person_preferences (subject, preferences, created_at, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(subject) DO UPDATE SET preferences = excluded.preferences, updated_at = excluded.updated_at`)
+ .run(String(subject), JSON.stringify(value), prev ? prev.created_at : at, at);
+ return await read(subject);
+ }
+
+ return { get, put, DEFAULTS };
+}
+
+module.exports = { createPreferences, DEFAULTS };
@@ -1,9 +1,11 @@
@@ -1,9 +1,11 @@
'use strict';
'use strict';
/**
/**
* Person sessions for the run receipt page (GET /runs/:id): a random 256-bit cookie id, stored as its
* Person sessions for the run receipt page (GET /runs/:id): a random 256-bit cookie id, stored as its
- * SHA-256; like the console's sessions, the cookie itself is never written down. A person session is
- * read-only and only opens runs whose on_behalf_of is its subject; it grants no capability and never
- * opens the operator console.
+ * SHA-256; like the console's sessions, the cookie itself is never written down. In the run pages a
+ * person session only opens runs whose on_behalf_of is its subject. It is the person's own sign-in on
+ * AI's site, so behind a same-origin check it also reaches their own /api/v1/me/* settings
+ * (server/me.js: their provider keys and routing preferences) — never a service route, and it grants
+ * no capability and never opens the operator console.
*
*
* cookie `__Host-ovai_person` (plain `ovai_person` over http in development): host-only, Path=/,
* cookie `__Host-ovai_person` (plain `ovai_person` over http in development): host-only, Path=/,
* HttpOnly, SameSite=Lax, Secure when BASE_URL is https, Max-Age = AI_PERSON_SESSION_TTL_MIN.
* HttpOnly, SameSite=Lax, Secure when BASE_URL is https, Max-Age = AI_PERSON_SESSION_TTL_MIN.
@@ -44,7 +44,39 @@ function rateLimited(err) {
@@ -44,7 +44,39 @@ function rateLimited(err) {
}
}
/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */
/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */
-const CREDENTIAL_DEFAULT_MODEL = { anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };
+const { CREDENTIAL_PROVIDERS, PROVIDER_KIND, DEFAULT_MODEL } = require('../credentials');
+const CREDENTIAL_DEFAULT_MODEL = { ...DEFAULT_MODEL, anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };
+
+/** The key as explain names it, never the key itself (L36 Y1/Y7). */
+const paidByOf = (cred) => `your ${cred.provider} key …${String(cred.hint || '').slice(-4) || '????'}`;
+/** The kind of work a run does, for the person's ai.me-preferences.defaults. */
+function workKind(operation, req, ctx = {}) {
+ if (operation === 'image') return 'image';
+ if (req && req.image) return 'vision';
+ const hint = `${ctx.workflowKey || ''} ${ctx.routeKey || ''}`;
+ return /\b(code|coding|codes|build|design|program|developer)\b/i.test(hint) ? 'code' : 'text';
+}
+/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */
+function credentialCanServe(provider, operation, req) {
+ if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
+ const text = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
+ if (text.includes(operation)) return true;
+ if (operation === 'messages') return PROVIDER_KIND[provider] === 'anthropic' || provider === 'custom';
+ if (operation === 'complete') return PROVIDER_KIND[provider] === 'openai' || provider === 'custom';
+ return false;
+}
+/** The model a self key should use for this run: the person's default for the kind, else their stored model. */
+function selfModelFor(cred, operation, req, ctx, prefs) {
+ const kind = workKind(operation, req, ctx);
+ const choice = prefs && prefs.defaults ? prefs.defaults[kind] : 'auto';
+ const stored = cred.models || {};
+ const role = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
+ const model = (choice && choice !== 'auto') ? choice
+ : (req.image && stored.vision) || (role && stored[role]) || stored[kind] || stored.chat
+ || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini';
+ const why = choice && choice !== 'auto' ? `your default ${kind} model` : (stored[kind] || stored.chat ? 'the model you stored for this key' : "the provider's default");
+ return { model, kind, why };
+}
function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {
function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
// A person's own machine as their model (credential provider node, server/machines.js).
@@ -411,8 +443,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -411,8 +443,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (ctx.logRequest) ctx.logRequest({ ...base, model_key: result.model, status: 'ok', output_hash: sha256(result.text || ''), tokens_in: usage.input, tokens_out: usage.output, tokens_cached: 0, tokens_estimated: 0, cost_usd: 0, latency_ms: latency });
if (ctx.logRequest) ctx.logRequest({ ...base, model_key: result.model, status: 'ok', output_hash: sha256(result.text || ''), tokens_in: usage.input, tokens_out: usage.output, tokens_cached: 0, tokens_estimated: 0, cost_usd: 0, latency_ms: latency });
return { result: { text: result.text, json: null, model: result.model, usage }, provider: cred.providerKey, providerKind: 'node', model: result.model, fallbackUsed: false, usage, cost: 0, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,
return { result: { text: result.text, json: null, model: result.model, usage }, provider: cred.providerKey, providerKind: 'node', model: result.model, fallbackUsed: false, usage, cost: 0, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,
explain: { objective: 'own-machine', reasons: [cred.machine === 'any' ? 'the fastest of the person\'s own machines that answered, by their choice' : 'the person\'s own machine, by their choice'],
explain: { objective: 'own-machine', reasons: [cred.machine === 'any' ? 'the fastest of the person\'s own machines that answered, by their choice' : 'the person\'s own machine, by their choice'],
+ paid_by: 'your machine',
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
- candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })) } };
+ candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),
+ attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }] } };
} catch (err) {
} catch (err) {
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
@@ -426,11 +460,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -426,11 +460,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
// The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter
// The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter
// speaks that format too, so a person's OpenRouter key answers it as well).
// speaks that format too, so a person's OpenRouter key answers it as well).
- const completing = operation === 'complete' && ['openai', 'openrouter'].includes(cred.provider);
+ const completing = operation === 'complete' && (PROVIDER_KIND[cred.provider] === 'openai' || cred.provider === 'custom');
// Codes C3: POST /v1/messages on the person's own key. Only a provider that speaks Anthropic's Messages API serves
// Codes C3: POST /v1/messages on the person's own key. Only a provider that speaks Anthropic's Messages API serves
// it — Anthropic itself, DeepSeek and MiniMax (their own Anthropic-compatible endpoints) and a custom endpoint.
// it — Anthropic itself, DeepSeek and MiniMax (their own Anthropic-compatible endpoints) and a custom endpoint.
const messaging = operation === 'messages';
const messaging = operation === 'messages';
- const anthropicFormat = ['anthropic', 'deepseek', 'minimax', 'custom'].includes(cred.provider);
+ const anthropicFormat = PROVIDER_KIND[cred.provider] === 'anthropic' || cred.provider === 'anthropic' || cred.provider === 'custom';
if (messaging && !anthropicFormat) throw new AiError(422, 'credential.unsupported', `the ${cred.provider} credential does not speak the Messages API`);
if (messaging && !anthropicFormat) throw new AiError(422, 'credential.unsupported', `the ${cred.provider} credential does not speak the Messages API`);
if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
@@ -439,9 +473,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -439,9 +473,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const role = wanted && cred.models[wanted] ? wanted : null;
const role = wanted && cred.models[wanted] ? wanted : null;
// On the person's Anthropic key the model the caller asked for is theirs to choose; elsewhere the model they stored
// On the person's Anthropic key the model the caller asked for is theirs to choose; elsewhere the model they stored
// is the one their provider serves (a Claude Code model name does not exist at DeepSeek).
// is the one their provider serves (a Claude Code model name does not exist at DeepSeek).
- const model = messaging
+ const model = ctx.defaultModel || (messaging
? ((cred.provider === 'anthropic' || cred.provider === 'custom') && req.model) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'claude-haiku-4-5-20251001'
? ((cred.provider === 'anthropic' || cred.provider === 'custom') && req.model) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'claude-haiku-4-5-20251001'
- : (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini';
+ : (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini');
const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: cred.capabilities || ['chat', 'json', 'vision'], timeout_ms: 60000 };
const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: cred.capabilities || ['chat', 'json', 'vision'], timeout_ms: 60000 };
const a = anthropicFormat ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });
const a = anthropicFormat ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });
const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
@@ -451,6 +485,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -451,6 +485,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const t = String(text == null ? '' : text);
const t = String(text == null ? '' : text);
return cred.apiKey ? t.split(String(cred.apiKey)).join('[redacted]') : t;
return cred.apiKey ? t.split(String(cred.apiKey)).join('[redacted]') : t;
};
};
+ const paidBy = paidByOf(cred);
+ const explain = cred.explain || {
+ objective: 'own-key', reasons: ['your own key: no shared provider is called'],
+ paid_by: paidBy, selected: `${cred.provider}/${model}`, candidates: [],
+ attempts: [{ key: paidBy, provider: cred.provider, model, why: 'your own key', status: 'ok', error: null }],
+ };
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
const call = messaging ? a.messages : completing ? a.complete : a.chat;
const call = messaging ? a.messages : completing ? a.complete : a.chat;
let streamed = false;
let streamed = false;
@@ -468,12 +508,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -468,12 +508,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
ctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,
ctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,
tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });
tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });
}
}
- return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,
- ...(cred.explain ? { explain: cred.explain } : {}) };
+ return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [],
+ startedAt: t0, explain: { ...explain, selected: `${cred.provider}/${result.model || model}` } };
} catch (err) {
} catch (err) {
lastErr = err;
lastErr = err;
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: scrub(err && err.message || err).slice(0, 500) });
if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: scrub(err && err.message || err).slice(0, 500) });
+ // L36 Y8: the provider refused the key (401/403). The key becomes failing and the event says so.
+ if (err && (err.status === 401 || err.status === 403) && typeof ctx.onCredentialFailed === 'function') {
+ await ctx.onCredentialFailed({ owner: cred.owner || null, subject: cred.subject || null, provider: cred.provider, providerKey: cred.providerKey, error: scrub(err.message) });
+ }
if (err && err.code === 'EADDRNOTPUBLIC') throw new AiError(422, 'credential.endpoint_refused', scrub(err.message));
if (err && err.code === 'EADDRNOTPUBLIC') throw new AiError(422, 'credential.endpoint_refused', scrub(err.message));
if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${scrub(err && err.message || err).slice(0, 200)}`);
if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${scrub(err && err.message || err).slice(0, 200)}`);
if (attempt < config.providerRetries && retryable(err)) { await sleep(config.retryDelayMs, ctx.signal).catch(() => {}); continue; }
if (attempt < config.providerRetries && retryable(err)) { await sleep(config.retryDelayMs, ctx.signal).catch(() => {}); continue; }
@@ -511,6 +555,30 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -511,6 +555,30 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return explain.bench || null;
return explain.bench || null;
}
}
+ /** The providers that could serve this request, for the ai.needs_key answer (L36 Y1). */
+ async function servingProviders(route, features, operation, req, selfList = []) {
+ const out = new Set();
+ const add = (key) => { if (CREDENTIAL_PROVIDERS.includes(key)) out.add(key); };
+ const scope = route.constraints && route.constraints.provider_scope;
+ if (route.primary && route.primary.provider) {
+ for (const c of [route.primary, ...(route.fallbacks || [])]) if (c && c.provider) add(c.provider);
+ } else {
+ for (const p of await registry.listProviders()) {
+ if (p.key === 'stub' || !appOffered({}, p)) continue;
+ if (scope === 'local' && !isLocalProvider(p)) continue;
+ if (scope === 'paid' && isFreeProvider(p)) continue;
+ const caps = await offeredCapabilities(p);
+ if (features.some(f => !caps.includes(f))) continue;
+ add(p.key);
+ }
+ }
+ for (const c of selfList) if (credentialCanServe(c.provider, operation, req)) add(c.provider);
+ // A route whose network providers are all unknown to the credential table still names the console's providers:
+ // that is what the person can add under Your AI.
+ if (!out.size) for (const id of CREDENTIAL_PROVIDERS) if (credentialCanServe(id, operation, req)) out.add(id);
+ return [...out].sort();
+ }
+
async function execute(route, operation, req, ctx = {}) {
async function execute(route, operation, req, ctx = {}) {
if (ctx.credential) return await executeWithCredential(ctx.credential, operation, req, ctx);
if (ctx.credential) return await executeWithCredential(ctx.credential, operation, req, ctx);
const features = [operation];
const features = [operation];
@@ -523,7 +591,62 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -523,7 +591,62 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// after that is final, never retried or handed to a fallback whose words would follow the first one's.
// after that is final, never retried or handed to a fallback whose words would follow the first one's.
let streamed = false;
let streamed = false;
const onDelta = typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;
const onDelta = typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;
+ // L36 Y1: a run on behalf of a person tries the person's OWN keys first, for the providers that can serve the
+ // request. With only_my_keys it never falls back; otherwise the network is the fallback and the explain says so.
+ const prefs = ctx.selfPreferences || {};
+ const selfAttempts = [];
+ const selfList = Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : [];
+ if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {
+ const order = [...selfList].sort((a, b) => {
+ const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);
+ return rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider));
+ });
+ for (const cred of order) {
+ if (!credentialCanServe(cred.provider, operation, req)) {
+ selfAttempts.push({ key: paidByOf(cred), provider: cred.provider, model: null, why: 'your own key', status: 'skipped', error: `this key cannot serve ${operation}` });
+ continue;
+ }
+ const pick = selfModelFor(cred, operation, req, ctx, prefs);
+ const key = paidByOf(cred);
+ try {
+ const out = await executeWithCredential(cred, operation, req, { ...ctx, defaultModel: pick.model });
+ const attempts = [...selfAttempts, { key, provider: cred.provider, model: pick.model, why: pick.why, status: 'ok', error: null }];
+ return {
+ ...out, fallbackUsed: false,
+ explain: {
+ objective: 'own-keys', reasons: ['your own keys are tried before the network\'s providers'],
+ paid_by: key, selected: `${cred.provider}/${pick.model}`, candidates: [], attempts,
+ },
+ };
+ } catch (err) {
+ const detail = String(err && (err.detail || err.message) || err).slice(0, 300);
+ selfAttempts.push({ key, provider: cred.provider, model: pick.model, why: pick.why, status: 'failed', error: detail });
+ }
+ }
+ if (prefs.only_my_keys) {
+ const providers = await servingProviders(route, features, operation, req, selfList);
+ const names = providers.length ? providers.join(', ') : 'any provider that can serve this request';
+ throw new AiError(422, 'ai.needs_key',
+ `only_my_keys is on and none of your keys could serve this request; add a key for ${names} under Your AI`,
+ { providers, paid_by: null, attempts: selfAttempts, explain: { objective: 'own-keys', reasons: ['only_my_keys: no network fallback'], paid_by: null, selected: null, candidates: [], attempts: selfAttempts } });
+ }
+ }
let { order: list, explain } = await candidates(route, features, operation, ctx);
let { order: list, explain } = await candidates(route, features, operation, ctx);
+ // L36 Y3: free_first puts the free tiers ahead of metered ones for this person's fallback. Own-key attempts above
+ // are already free to them, so this only reorders the network.
+ if (prefs.free_first && ctx.selfSubject) {
+ const free = new Map();
+ for (const c of list) {
+ if (c.provider === 'stub') { free.set(c.provider, true); continue; }
+ const p = await registry.getProvider(c.provider);
+ free.set(c.provider, !p || isFreeProvider(p));
+ }
+ list = [...list].sort((a, b) => Number(free.get(b.provider)) - Number(free.get(a.provider)));
+ }
+ const attempts = [...selfAttempts];
+ explain.paid_by = 'openvibe';
+ explain.attempts = attempts;
+ explain.reasons = [...(explain.reasons || []), ...(selfAttempts.length ? ['your own keys did not answer; the network is the fallback'] : [])];
// A first-party service's generate run may name one offer from GET /api/v1/routes. The run then
// A first-party service's generate run may name one offer from GET /api/v1/routes. The run then
// tries that offer alone: no fallback to another model, so the caller gets the model it asked for
// tries that offer alone: no fallback to another model, so the caller gets the model it asked for
// or an explicit failure. server/runs.js validates the pin against this same offer list first.
// or an explicit failure. server/runs.js validates the pin against this same offer list first.
@@ -544,10 +667,24 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -544,10 +667,24 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const model = c.model || (p && p.default_model) || null;
const model = c.model || (p && p.default_model) || null;
const fallback = i > 0;
const fallback = i > 0;
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
+ // L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).
+ if (prefs.price_ceiling_usd_per_mtok != null) {
+ const cards = await rateCardsFor(c.provider, model);
+ const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;
+ if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {
+ const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;
+ statFor(c.provider).skipped++;
+ tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: whyPrice });
+ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'over_price_ceiling' });
+ continue;
+ }
+ }
const why = await skipReason(p, a, features);
const why = await skipReason(p, a, features);
if (why) {
if (why) {
statFor(c.provider).skipped++;
statFor(c.provider).skipped++;
tried.push({ provider: c.provider, skipped: why });
tried.push({ provider: c.provider, skipped: why });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: why });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
continue;
continue;
}
}
@@ -588,7 +725,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -588,7 +725,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
});
});
}
}
explain.selected = c.provider;
explain.selected = c.provider;
- return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback, usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
+ explain.paid_by = 'openvibe';
+ attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: 'ok', error: null });
+ explain.attempts = attempts;
+ return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
} catch (err) {
} catch (err) {
lastErr = err;
lastErr = err;
if (ctx.signal && ctx.signal.aborted) {
if (ctx.signal && ctx.signal.aborted) {
@@ -609,9 +749,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -609,9 +749,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;
if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;
if (!callerFault(lastErr) && !rateLimited(lastErr)) await recordFailure(c.provider, lastErr);
if (!callerFault(lastErr) && !rateLimited(lastErr)) await recordFailure(c.provider, lastErr);
tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });
tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });
+ attempts.push({ key: 'openvibe', provider: c.provider, model: lastErr && lastErr.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: lastErr && lastErr.code === 'provider.timeout' ? 'timeout' : 'failed', error: String(lastErr && lastErr.message || lastErr).slice(0, 300) });
log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);
log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);
}
}
explain.selected = null;
explain.selected = null;
+ explain.paid_by = 'openvibe';
+ explain.attempts = attempts;
// The media itself could not be read: no provider will do better, and the caller should hear why (and not
// The media itself could not be read: no provider will do better, and the caller should hear why (and not
// retry as if a provider were down).
// retry as if a provider were down).
if (unreadable) {
if (unreadable) {
@@ -99,7 +99,7 @@ function routePinRequest(value, principal, wf) {
@@ -99,7 +99,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
return { provider, model };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {
+function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
const queue = []; // run ids waiting for the worker
let active = 0;
let active = 0;
@@ -247,6 +247,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -247,6 +247,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');
if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');
if (!projectSecrets || !projectSecrets.available()) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');
if (!projectSecrets || !projectSecrets.available()) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');
}
}
+ // L36 Y1: a run on behalf of a person tries that person's OWN keys first (owner `self`). When they hold one,
+ // the run is uncached and no network budget or tier holds it: their own key is free to them, and a $0 balance
+ // must never block it. If it falls back to the network, the usage is still recorded after the fact.
+ const personSubject = body.on_behalf_of && body.on_behalf_of.type === 'user' && typeof body.on_behalf_of.id === 'string' ? body.on_behalf_of.id : null;
+ const selfKeys = Boolean(credentials && personSubject && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(personSubject));
// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before
// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before
// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.
// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.
if (runBudgets && principal.jti) {
if (runBudgets && principal.jti) {
@@ -274,23 +279,25 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -274,23 +279,25 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
source_service: requester.type === 'service' ? requester.id : null,
source_service: requester.type === 'service' ? requester.id : null,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
- options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned, debug: Boolean(options.debug), input_retained: kept.full,
+ options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
+ ...(selfKeys ? { self_subject: personSubject } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}) }), created_at: now,
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}) }), created_at: now,
};
};
base.input = stableStringify(kept.value);
base.input = stableStringify(kept.value);
// Cache (scoped; never crosses requester/actor/target/attribution)
// Cache (scoped; never crosses requester/actor/target/attribution)
// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,
// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,
- // and another model's answer under the same prompt would silently break the pin.
- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned ? null : cache.scopeFor(wf.cache_mode, ctx);
+ // and another model's answer under the same prompt would silently break the pin. A run that may use the
+ // person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
+ const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
let cacheKey = null;
if (scope) {
if (scope) {
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });
const hit = await cache.get(cacheKey, scope);
const hit = await cache.get(cacheKey, scope);
if (hit) {
if (hit) {
await db.tx(async () => {
await db.tx(async () => {
- const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], selected: null, candidates: [] };
+ const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], paid_by: 'cache', selected: null, candidates: [], attempts: [] };
await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
source_service, target, target_key, input, input_hash, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, explain, created_at, started_at, finished_at, citations_count)
source_service, target, target_key, input, input_hash, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, explain, created_at, started_at, finished_at, citations_count)
VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'cached', @requester_type, @requester_id, @on_behalf_of, @attribution,
VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'cached', @requester_type, @requester_id, @on_behalf_of, @attribution,
@@ -315,10 +322,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -315,10 +322,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
admit(caller);
admit(caller);
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
- const hold = credentialSubject !== null || projectProvider || !tiers ? null : await tiers.admit(ctx, id);
+ // L36 Y1: a run that may use the person's own key holds neither — their key is free to them, and a $0 balance
+ // anywhere must never block it. If it falls back to the network, the usage is recorded after the run.
+ const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);
let reserved;
let reserved;
try {
try {
- reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);
+ reserved = credentialSubject !== null || selfKeys ? null : await quotas.reserve(ctx);
} catch (err) { if (hold) await tiers.release(hold); throw err; }
} catch (err) { if (hold) await tiers.release(hold); throw err; }
try {
try {
@@ -375,7 +384,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -375,7 +384,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
return {
return {
providerKey: `project:${ctx.projectId}`, provider: 'openai', base_url: spec.base_url, apiKey: value, models: { chat: spec.model },
providerKey: `project:${ctx.projectId}`, provider: 'openai', base_url: spec.base_url, apiKey: value, models: { chat: spec.model },
capabilities: ['chat', 'json', 'vision', 'tools'],
capabilities: ['chat', 'json', 'vision', 'tools'],
- explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], selected: `project:${ctx.projectId}`, candidates: [] },
+ explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], paid_by: 'project', selected: `project:${ctx.projectId}`, candidates: [], attempts: [] },
};
};
}
}
@@ -409,6 +418,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -409,6 +418,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject, opts.credential_provider || null)
const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject, opts.credential_provider || null)
: opts.project_provider ? await projectCredential(opts.project_provider, ctx) : null;
: opts.project_provider ? await projectCredential(opts.project_provider, ctx) : null;
+ // L36 Y1/Y8: the person's own keys, and what their preferences say about the network fallback. A provider
+ // refusing one (401/403) marks it failing and publishes ai.credential.failed, never the key.
+ const selfSubject = opts.self_subject || null;
+ const selfCredentials = selfSubject && credentials ? await credentials.selfForAllRuns(selfSubject) : null;
+ const selfPreferences = selfSubject && personPreferences ? await personPreferences.get(selfSubject) : null;
+ const onCredentialFailed = credentials ? async (failure) => {
+ if (!failure || !failure.owner || !failure.subject || !failure.provider) return;
+ const changed = await credentials.markFailed(failure.owner, failure.subject, failure.provider, failure.error);
+ if (changed) await events.credentialFailed({ subject: failure.subject, provider: failure.provider, error: failure.error, owner: failure.owner });
+ } : undefined;
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
@@ -417,6 +436,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -417,6 +436,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// Who asked, for a step that must not call the caller back (research: Search's own web ladder).
// Who asked, for a step that must not call the caller back (research: Search's own web ladder).
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
+ ...(onCredentialFailed ? { onCredentialFailed } : {}),
+ ...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}) });
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}) });
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
@@ -432,7 +453,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -432,7 +453,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
return d;
return d;
});
});
if (!done.changes) return; // cancelled while finishing
if (!done.changes) return; // cancelled while finishing
- if (r.provider && reserved) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });
+ // A self-key run has no reservation and no tier hold; if it fell back to the network, its usage is still
+ // recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.
+ const usedOwnKey = String(r.provider || '').startsWith('byo:self:');
+ if (r.provider && (reserved || (opts.self_subject && !usedOwnKey))) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });
// What the run spent goes on the budget of its run token (Codes C3): the same tokens and cost the
// What the run spent goes on the budget of its run token (Codes C3): the same tokens and cost the
// quota accounting counts, so the next request of a run over its budget is refused.
// quota accounting counts, so the next request of a run over its budget is refused.
if (runBudgets && ctx.runJti) await runBudgets.add(ctx.runJti, { tokens: r.usage.input + r.usage.output, cost: r.cost });
if (runBudgets && ctx.runJti) await runBudgets.add(ctx.runJti, { tokens: r.usage.input + r.usage.output, cost: r.cost });
@@ -137,3 +137,37 @@ t.test('their runs, quota and module marker go; the spend ledger and the audit t
@@ -137,3 +137,37 @@ t.test('their runs, quota and module marker go; the spend ledger and the audit t
assert.ok(confirmation.body.retained.usage_counters >= 1, 'the kept ledger is counted as retained');
assert.ok(confirmation.body.retained.usage_counters >= 1, 'the kept ledger is counted as retained');
} finally { await h.stop(); await stub.close(); }
} finally { await h.stop(); await stub.close(); }
});
});
+
+t.test('L36: the person\'s self credentials export without the key, and deletion erases them with their preferences', async () => {
+ const stub = await startNetworkStub();
+ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
+ try {
+ const SECRET_KEY = 'sk-self-ada-0123456789LEAK';
+ const sealed = `v2:${Buffer.alloc(12).toString('base64')}:${Buffer.alloc(16).toString('base64')}:${Buffer.from(SECRET_KEY).toString('base64')}`;
+ await h.db.exec(`INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, status, checked_at, last_error, models_count, created_at, updated_at)
+ VALUES ('self', $1, 'anthropic', $2, '…LEAK', '{"chat":"m"}', 'ok', '2026-10-31T09:00:00.000Z', NULL, 3, '2026-10-01', '2026-10-02')`, [A, sealed]);
+ await h.db.exec(`INSERT INTO ai_person_preferences (subject, preferences, created_at, updated_at)
+ VALUES ($1, '{"only_my_keys":true,"free_first":false,"price_ceiling_usd_per_mtok":5,"defaults":{"text":"auto","code":"auto","vision":"auto","image":"auto"}}', '2026-10-01', '2026-10-02')`, [A]);
+ const deliver = async (event) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+
+ const r = await deliver(ev('evt_01JZ0000000000000000000E03', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX3', subject: A }));
+ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
+ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX3/parts');
+ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ const cred = files['provider-credentials.json'].find((c) => c.owner === 'self');
+ assert.deepStrictEqual([cred.provider, cred.key_hint, cred.status, cred.models_count, cred.checked_at], ['anthropic', '…LEAK', 'ok', 3, '2026-10-31T09:00:00.000Z']);
+ assert.ok(!('key_enc' in cred) && !JSON.stringify(part.body).includes(SECRET_KEY), 'the key is never exported');
+ assert.strictEqual(files['ai-preferences.json'][0].preferences.includes('only_my_keys'), true, 'the preferences export');
+
+ const d = await deliver(ev('evt_01JZ0000000000000000000D03', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DX3', subject: A, aliases: [] }));
+ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
+ assert.strictEqual(Number(await h.db.value("SELECT count(*)::int FROM subject_credentials WHERE owner = 'self' AND subject = $1", [A])), 0, 'the self key is erased');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM ai_person_preferences WHERE subject = $1', [A])), 0, 'the preferences are erased');
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.run();
@@ -102,6 +102,21 @@ t.test('a failing outbox prune is caught, not left as an unhandled rejection', a
@@ -102,6 +102,21 @@ t.test('a failing outbox prune is caught, not left as an unhandled rejection', a
}
}
});
});
+t.test('a refused own key queues ai.credential.failed, never the key (L36 Y8)', async () => {
+ const events = require('../server/events');
+ const subject = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const env = await events.credentialFailed({ subject, provider: 'anthropic', error: 'the provider refused the key (401)' });
+ assert.ok(env && env.event_id, 'the event was queued');
+ const ev = (await queued()).find((e) => e.event_id === env.event_id);
+ assert.strictEqual(ev.event_type, 'ai.credential.failed');
+ assert.deepStrictEqual(ev.on_behalf_of, { type: 'user', id: subject });
+ assert.deepStrictEqual([ev.payload.subject, ev.payload.provider, ev.payload.status], [subject, 'anthropic', 'failing']);
+ let known = true;
+ try { contracts.resolve('ai.credential.failed@1'); } catch { known = false; }
+ if (known) valid(ev); // the schema arrives with openvibe-contracts 0.257.0
+ assert.ok(!JSON.stringify(ev).includes('sk-'), 'no key');
+});
+
t.test('close() stops the ai.run.* relay (unsent rows stay in the outbox)', async () => {
t.test('close() stops the ai.run.* relay (unsent rows stay in the outbox)', async () => {
const events = require('../server/events');
const events = require('../server/events');
const pending = (await events.status()).pending;
const pending = (await events.status()).pending;
new file mode 100644
new file mode 100644
@@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
+'use strict';
+// L36 Y1/Y3/Y7/Y8: the person's own provider keys and how their runs route.
+// - GET/PUT/DELETE/POST /api/v1/me/credentials and GET/PUT /api/v1/me/ai/preferences are person-only: a service
+// token (with or without on_behalf_of) is refused, so is another site's cookie session;
+// - a token minted for openvibe.network (every site's session) may read but never write; a write needs aud
+// openvibe.ai, and a cookie write needs Origin or Sec-Fetch-Site;
+// - a key is sealed with the AAD self|subject|provider and never returned, logged or put in an error;
+// - test on save lists models, falls back to a one-token request, records 401 and a timeout;
+// - a run on_behalf_of a person tries their self key first, only_my_keys never falls back, and the explain names
+// paid_by and every attempt.
+const assert = require('assert');
+const crypto = require('crypto');
+const { validate, schema } = require('openvibe-contracts');
+const HAS_ME_CONTRACT = (() => { try { return Boolean(schema('ai.me-credential@1')); } catch { return false; } })();
+const { boot, request, token, suite, ALL } = require('./helpers');
+const { createCredentials, aadFor, PROVIDERS, SELF_OWNER } = require('../server/credentials');
+
+const t = suite('me-credentials');
+const KEY = 'cd'.repeat(32);
+const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';
+const API_KEY = 'sk-test-dana-0123456789WXYZ';
+const ANTHROPIC_KEY = 'sk-ant-test-0123456789ABCD';
+const person = token('dana', ALL, { sub: DANA, actorType: 'user' });
+const siteSession = token('dana-site', ALL, { sub: DANA, actorType: 'user', extra: { aud: ['openvibe.network', 'openvibe.design'] } });
+const service = token('live', [...ALL, 'ai.me.read', 'ai.me.manage']);
+const serviceOnBehalf = token('live', [...ALL, 'ai.me.read', 'ai.me.manage'], { extra: { on_behalf_of: DANA } });
+const calls = [];
+const logs = [];
+const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+let mode = 'ok';
+let h;
+
+const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+const credentialFetch = async (url, init = {}) => {
+ const auth = (init.headers && (init.headers.Authorization || init.headers['x-api-key'])) || null;
+ calls.push({ url, auth, body: init.body ? JSON.parse(init.body) : null });
+ if (mode === 'timeout') return await new Promise(() => {});
+ if (mode === 'auth') return json({ error: { message: `invalid key ${auth}` } }, 401);
+ if (String(url).endsWith('/models')) {
+ if (mode === 'empty') return json({ data: [] });
+ return json({ data: [{ id: 'm-one' }, { id: 'm-two' }, { id: 'm-three' }] });
+ }
+ if (String(url).endsWith('/messages')) {
+ return json({ model: 'm-one', content: [{ type: 'text', text: 'hello from dana\'s key' }], usage: { input_tokens: 4, output_tokens: 2 } });
+ }
+ if (String(url).endsWith('/chat/completions')) {
+ return json({ model: 'm-one', choices: [{ message: { role: 'assistant', content: 'hello from dana\'s key' } }], usage: { prompt_tokens: 4, completion_tokens: 2 } });
+ }
+ return json({});
+};
+
+t.test('boot', async () => {
+ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, log: capture });
+});
+
+t.test('the person routes refuse a service token, an on_behalf_of token, and another site\'s session', async () => {
+ for (const tok of [service, serviceOnBehalf]) {
+ const r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${r.status} ${r.text}`);
+ }
+ // A person session cookie on AI's own site works, with the same-origin signal.
+ const raw = crypto.randomBytes(32).toString('base64url');
+ const hash = crypto.createHash('sha256').update(raw).digest('hex');
+ await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, 'dana', '2026-10-01', '2030-10-01')", [hash, DANA]);
+ const prefsBody = { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } };
+ let r = await request(h.base, 'GET', '/api/v1/me/credentials', { headers: { Cookie: `ovai_person=${raw}` } });
+ assert.strictEqual(r.status, 200, 'a read with no Origin or Sec-Fetch-Site still works');
+ // A write with the cookie but neither header is not proof of same-origin: refused.
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { headers: { Cookie: `ovai_person=${raw}` }, body: prefsBody });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'a write needs an origin signal');
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { headers: { Cookie: `ovai_person=${raw}`, 'Sec-Fetch-Site': 'same-origin' }, body: prefsBody });
+ assert.strictEqual(r.status, 200, 'a same-origin cookie write works');
+ r = await request(h.base, 'GET', '/api/v1/me/credentials', { headers: { Cookie: `ovai_person=${raw}`, Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' } });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot spend the session');
+});
+
+t.test('a site session token may read but never write; a token for openvibe.ai may write', async () => {
+ let r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok: siteSession });
+ assert.strictEqual(r.status, 200, 'a site token reads the hints');
+ r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: siteSession });
+ assert.strictEqual(r.status, 200, 'a site token reads the settings');
+ const prefsBody = { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } };
+ const writes = [
+ ['PUT', '/api/v1/me/credentials/anthropic', { provider: 'anthropic', key: API_KEY }],
+ ['DELETE', '/api/v1/me/credentials/anthropic', undefined],
+ ['POST', '/api/v1/me/credentials/anthropic/test', undefined],
+ ['PUT', '/api/v1/me/ai/preferences', prefsBody],
+ ];
+ for (const [method, path, body] of writes) {
+ r = await request(h.base, method, path, { tok: siteSession, body });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${method} ${path} refused a site session token: ${r.text}`);
+ }
+ // A token whose aud explicitly names openvibe.ai reaches the writes.
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefsBody });
+ assert.strictEqual(r.status, 200, r.text);
+});
+
+t.test('store a key: sealed with self|subject|provider, a four-character hint, never the key', async () => {
+ mode = 'ok';
+ let r = await request(h.base, 'PUT', `/api/v1/me/credentials/anthropic`, { tok: person, body: { provider: 'anthropic', key: ANTHROPIC_KEY, budget_usd_per_day: 4, models: { chat: 'm-one' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.provider, r.body.hint, r.body.status, r.body.models_count], ['anthropic', 'ABCD', 'ok', 3]);
+ if (HAS_ME_CONTRACT) assert.ok(validate('ai.me-credential@1', r.body).valid, JSON.stringify(validate('ai.me-credential@1', r.body).errors));
+ assert.ok(!r.text.includes(ANTHROPIC_KEY), 'the key never comes back');
+ const row = await h.db.prepare('SELECT key_enc, key_hint, owner FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');
+ assert.strictEqual(row.owner, 'self');
+ assert.ok(row.key_enc.startsWith('v2:') && !row.key_enc.includes(ANTHROPIC_KEY), 'encrypted at rest');
+ assert.ok(!logs.join('\n').includes(ANTHROPIC_KEY), 'not in a log line');
+ const creds = createCredentials({ db: h.db, config: h.config });
+ const cred = await creds.forRun('self', DANA, 'anthropic');
+ assert.strictEqual(cred.apiKey, ANTHROPIC_KEY, 'opens with self|subject|provider');
+ assert.strictEqual(aadFor('self', DANA, 'anthropic'), `self|${DANA}|anthropic`);
+ // The AAD binds the provider: a row moved elsewhere does not decrypt.
+ await h.db.prepare('UPDATE subject_credentials SET provider = ? WHERE owner = ? AND subject = ? AND provider = ?').run('openai', SELF_OWNER, DANA, 'anthropic');
+ await assert.rejects(creds.forRun('self', DANA, 'openai'), /unable to authenticate|bad decrypt|Unsupported state/i);
+ await h.db.prepare('UPDATE subject_credentials SET provider = ? WHERE owner = ? AND subject = ? AND provider = ?').run('anthropic', SELF_OWNER, DANA, 'openai');
+});
+
+t.test('test on save: success, an empty model list, a 401 and a timeout', async () => {
+ // success already covered above (3 models). An empty list makes one one-token request.
+ mode = 'empty';
+ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY, models: { chat: 'm-chat' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.status, r.body.models_count, r.body.last_error], ['ok', 0, null]);
+ assert.ok(calls.some((c) => c.url.endsWith('/chat/completions') && c.body.max_tokens === 1), 'one token');
+ // A 401 is failing, and the key is redacted from the error the provider echoed.
+ mode = 'auth';
+ r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.status, 'failing');
+ assert.ok(r.body.last_error && /refused/i.test(r.body.last_error), r.body.last_error);
+ assert.ok(!r.body.last_error.includes(API_KEY), 'the key is not in the stored error');
+ assert.ok(!r.text.includes(API_KEY), 'the key never comes back');
+ // A timeout is failing after the (test-shortened) 10 s deadline.
+ mode = 'timeout';
+ r = await request(h.base, 'PUT', '/api/v1/me/credentials/gemini', { tok: person, body: { provider: 'gemini', key: 'sk-test-gemini-0123456789' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.status, r.body.last_error], ['failing', 'no response within 0s']);
+ mode = 'ok';
+ // Every console provider is storable, not only today's list.
+ for (const provider of ['mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks']) {
+ assert.ok(PROVIDERS.includes(provider), `${provider} is a provider`);
+ }
+});
+
+t.test('a custom endpoint goes through the guarded fetcher\'s egress rules', async () => {
+ const real = createCredentials({ db: h.db, config: h.config }); // no test fetch: the production guarded fetcher
+ const out = await real.putSelf(DANA, 'custom', { provider: 'custom', key: 'sk-test-custom-0123456789', base_url: 'https://127.0.0.1/v1' });
+ assert.strictEqual(out.status, 'failing');
+ assert.ok(/IP-literal|non-public|refused|https/i.test(out.last_error), out.last_error);
+ await real.removeSelf(DANA, 'custom');
+});
+
+t.test('preferences are per person, with defaults and a model per kind', async () => {
+ let r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: person });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.only_my_keys, r.body.free_first, r.body.defaults.text], [false, true, 'auto']);
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: true, free_first: false, price_ceiling_usd_per_mtok: 5, defaults: { text: 'm-default', code: 'auto', vision: 'auto', image: 'auto' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual([r.body.only_my_keys, r.body.free_first, r.body.price_ceiling_usd_per_mtok, r.body.defaults.text], [true, false, 5, 'm-default']);
+ const other = token('bob', ALL, { sub: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY', actorType: 'user' });
+ r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: other });
+ assert.strictEqual(r.body.only_my_keys, false, 'preferences are per person');
+});
+
+t.test('a run on behalf of the person tries their key first; only_my_keys never falls back', async () => {
+ // Dana already has an anthropic key; give her an OpenAI key that works (empty list, one-token) and a stored model.
+ mode = 'ok';
+ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY, models: { chat: 'm-chat' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ const run = async () => await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: { workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' }, on_behalf_of: { type: 'user', id: DANA } } });
+ r = await run();
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));
+ assert.strictEqual(r.body.run.output.text, "hello from dana's key", JSON.stringify(r.body.run).slice(0, 1200));
+ assert.ok(r.body.run.explain && r.body.run.explain.paid_by === `your anthropic key …${ANTHROPIC_KEY.slice(-4)}`, JSON.stringify(r.body.run.explain));
+ assert.ok(r.body.run.explain.attempts.some((a) => a.provider === 'anthropic' && a.status === 'ok' && a.key === r.body.run.explain.paid_by));
+ // only_my_keys: a refused key is an explicit ai.needs_key, never a network fallback.
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: true, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ mode = 'auth';
+ r = await run();
+ assert.strictEqual(r.body.run.status, 'failed', JSON.stringify(r.body.run).slice(0, 600));
+ assert.strictEqual(r.body.run.error.code, 'ai.needs_key');
+ assert.ok(/anthropic|openai/.test(String(r.body.run.error.detail)), `the error names the providers: ${r.body.run.error.detail}`);
+ assert.ok(!r.text.includes(API_KEY), 'the key is never in the answer');
+ // Without only_my_keys the network is the fallback, and explain says which key paid and what failed.
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await run();
+ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));
+ assert.strictEqual(r.body.run.explain.paid_by, 'openvibe');
+ assert.ok(r.body.run.explain.attempts.some((a) => a.key.startsWith('your ') && a.status === 'failed'));
+ const status = await h.db.prepare('SELECT status, last_error FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'openai');
+ assert.strictEqual(status.status, 'failing', 'a 401 marks the key failing');
+ assert.ok(status.last_error && !status.last_error.includes(API_KEY));
+ await request(h.base, 'DELETE', '/api/v1/me/credentials/openai', { tok: person });
+});
+
+t.test('a key never appears in a response, a log line or an error', async () => {
+ const IVAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ';
+ const ivan = token('ivan', ALL, { sub: IVAN, actorType: 'user' });
+ const key = 'sk-test-ivan-LEAKME-0123456789';
+ mode = 'auth';
+ const r = await request(h.base, 'PUT', `/api/v1/me/credentials/openai`, { tok: ivan, body: { provider: 'openai', key, models: { chat: 'm-chat' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(key), 'not in the response');
+ assert.ok(!logs.join('\n').includes(key), 'not in a log line');
+ assert.ok(!String(r.body.last_error || '').includes(key), 'not in the error');
+ const row = await h.db.prepare('SELECT last_error FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, IVAN, 'openai');
+ assert.ok(!String(row.last_error).includes(key), 'not in the stored error');
+ mode = 'ok';
+ await request(h.base, 'DELETE', `/api/v1/me/credentials/openai`, { tok: ivan });
+});
+
+t.test('shutdown', async () => { await h.stop(); });
+
+t.run();
@@ -44,13 +44,14 @@ function mounted(app) {
@@ -44,13 +44,14 @@ function mounted(app) {
/**
/**
* Not public API surface: probes and redirect, the operator/admin API, the attribution-quota API and
* Not public API surface: probes and redirect, the operator/admin API, the attribution-quota API and
- * the credential routes (which must never appear in a public document).
+ * the credential routes (which must never appear in a public document). The person's own /api/v1/me/*
+ * routes are person-only and carry a key on PUT, so they stay out for the same reason.
*/
*/
const LEFT_OUT = [
const LEFT_OUT = [
'/api/health', '/api/ready', '/api/v1/openapi.json',
'/api/health', '/api/ready', '/api/v1/openapi.json',
'/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',
'/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',
'/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
'/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
- '/api/v1/credentials', '/api/v1/attribution-quotas',
+ '/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',
];
];
const excluded = (key) => {
const excluded = (key) => {
const p = key.split(' ')[1];
const p = key.split(' ')[1];