openvibers/openvibe.actor Commits
D11 owner settings: actor.ai (#286)
Changes
@@@ -24,6 +24,12 @@ OV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback# ACTOR_AUDIENCE=openvibe.actor++# OpenVibe.AI (the model gateway a person's own keys live in; server/runtime/models.js). The catalog flag is off by++# default: with it on, Actor reads GET /v1/models with its service token and offers the aliases and exact ids where a++# person picks the model their tasks run on.++# OV_AI_URL=https://ai.openvibe.services++# ACTOR_AI_CATALOG=1++# OpenVibe.Events → Actor (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions# are created at boot (off when unset); ACTOR_EVENTS_SUBSCRIBE=0 turns that off.nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++-- The fingerprint key of each namespace. Never served, never logged.++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK++ created_at text COLLATE "C" NOT NULL++);@@@ -15,9 +15,9 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"devDependencies": {@@@ -1146,9 +1146,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.269.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",-- "integrity": "sha512-OCdHszaBv7sV+82JgGfJy6XeO6s2ZPpvLTZJoYihnfYghX6QFoy6PCu2efoWoBHYOdhAL9kJmSLo4kVTE9zQ9A==",++ "version": "0.288.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1190,9 +1190,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.109.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",++ "version": "4.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -28,9 +28,9 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * The `actor.ai` namespace (D11 owner settings): the switch that lets Actor's model pickers read OpenVibe.AI's++ * catalog. ACTOR_AI_CATALOG stays the pin: while it is set it wins, is read-only in the summary and a write++ * answers 409 config.pinned_in_env.++ *++ * /internal/config is openvibe-shared/config's adminRoutes, guarded by actor.config.manage (Network's service++ * token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write++ * (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.++ */++const express = require('express');++const shared = require('openvibe-shared/config');++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const SERVICE = 'actor';++const NAMESPACE = 'actor.ai';++const CAPABILITY = 'actor.config.manage';++++const SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'OpenVibe.AI in Actor',++ description: 'Read OpenVibe.AI\'s model catalog so the model picker offers its aliases and exact ids.',++ type: 'object',++ additionalProperties: false,++ required: ['catalog'],++ properties: {++ catalog: {++ type: 'boolean', title: 'AI model catalog', 'x-group': 'AI Exchange', 'x-env': 'ACTOR_AI_CATALOG',++ description: 'Read GET /v1/models with Actor\'s own service token and cache the answer about five minutes. Off, the model field is the plain one it was before.',++ },++ },++};++const DEFAULTS = Object.freeze({ catalog: false });++++function bearer(req) {++ const h = String((req.headers && req.headers.authorization) || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;++}++++function capabilityGuard({ keys, config, log }) {++ return async function guard(req, res, next) {++ try {++ const token = bearer(req);++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a Network service token is required', ctx: req.ov });++ const publicKey = typeof keys.pemForToken === 'function' ? await keys.pemForToken(token) : keys.get();++ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded yet', ctx: req.ov });++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ if (!r.ok) return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason || 'the token does not verify', ctx: req.ov });++ const sub = String((r.claims && r.claims.sub) || '');++ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Actor configuration`, ctx: req.ov });++ const decision = capabilities.check(r.claims, CAPABILITY);++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${CAPABILITY} not granted`, ctx: req.ov });++ req.configClaims = r.claims;++ return next();++ } catch (err) {++ log.error(`[config] actor.ai guard failed: ${(err && err.message) || err}`);++ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });++ }++ };++}++++function actor(req) {++ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();++ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;++}++++const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';++++async function create({ db, keys, config, env = process.env, log = console, onActivate = null }) {++ // ACTOR_AI_CATALOG has always accepted 1/true; the shared store's boolean pin reads true/1.++ const storeEnv = { ...env };++ if (['1', 'true'].includes(String(storeEnv.ACTOR_AI_CATALOG || '').trim().toLowerCase())) storeEnv.ACTOR_AI_CATALOG = '1';++ const store = await shared.createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,++ onActivate: async (values) => { if (onActivate) await onActivate(values); },++ onChange: ({ namespace, keys: changed, actor: who, revision }) => {++ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${changed.join(', ')}`);++ },++ });++ if (env.OV_CONFIG_KEKS) {++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }++ }++ const router = shared.adminRoutes([store], {++ basePath: '/internal/config', requireAdmin: capabilityGuard({ keys, config, log }), actor, requireFresh, log,++ });++ return {++ store, router,++ get: (key) => store.get(key),++ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },++ };++}++++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };@@@ -24,6 +24,7 @@ const { createSso } = require('./auth/sso');const catalog = require('./agents/catalog');const { createAdapters } = require('./agents/adapters');const { createModels } = require('./runtime/models');++const createAiCatalogConfig = require('./ai-catalog-config').create;const { createRegistry } = require('./runtime/tools');const { createLoop } = require('./runtime/loop');const { createStats } = require('./runtime/stats');@@@ -85,6 +86,9 @@ async function createApp(opts = {}) {const s = opts.store || await openStore(config, { now: opts.now, log });const keys = createKeyStore({ config, fetchImpl, log });++ // D11 owner settings (actor.ai): the catalog switch reads through the store, env pins winning.++ const aiCatalogConfig = await createAiCatalogConfig({ db: s.db, keys, config, env: opts.env || process.env, log });++ Object.defineProperty(config.ai, 'catalog', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });// A self-hosted Actor signs its one owner in itself (auth/local.js); otherwise OpenVibe.Network does (auth/sso.js).if (config.selfHosted.on) checkSelfHosted(config);const sso = config.selfHosted.on ? createLocalSignIn({ config, now: s.now }) : createSso({ config, keys, fetchImpl, now: s.now, log });@@@ -135,7 +139,7 @@ async function createApp(opts = {}) {createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });// Each personal agent's run that passed its check also goes to its owner's Vibe chat (agents/vibe-deliver.js).const vibeDelivery = require('./agents/vibe-deliver').createVibeDelivery({ s, config, chat: chatClient, log });-- const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };++ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;@@@ -150,6 +154,7 @@ async function createApp(opts = {}) {ctx.accountSend = accountSend;const app = express();++ aiCatalogConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.app.get('/openapi.json', (_req, res) => {@@@ -93,6 +93,10 @@ function load(env = process.env) {clientId: env.OV_OAUTH_CLIENT_ID || 'actor',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',timeoutMs: Math.max(5000, int(env.ACTOR_AI_TIMEOUT_MS, 180_000)),++ // The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer++ // its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is++ // the plain one it was before.++ catalog: env.ACTOR_AI_CATALOG === '1' || env.ACTOR_AI_CATALOG === 'true',},// The model providers behind the agent systems (server/agents/catalog.js). A provider without its key is// listed as unavailable, with the reason; nothing is invented. Keys are the operator's.@@@ -224,7 +224,7 @@ function createApi(ctx) {if (r.error) return problem(req, res, 422, 'actor.model.invalid', r.error);if (body.choice === undefined && body.model === undefined) return res.json({ ...modelPrefs.wire(await modelPrefs.get(s, req.principal.requester)), keys: null });}-- const out = await modelPrefs.put(s, req.principal.requester, body);++ const out = await modelPrefs.put(s, req.principal.requester, body, { exactIds: Boolean(config.ai && config.ai.catalog) });if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);res.json({ ...modelPrefs.wire(out.row), keys: null });});@@@ -31,6 +31,7 @@ const { sameOrigin } = require('./principal');const looks = require('../agents/looks');const inputs = require('../tasks/inputs');const modelPrefs = require('../runtime/model-prefs');++const aiCatalog = require('../runtime/ai-catalog');// The model a person's tasks run on (plan L55 C3; the owner, 11-01: "use your own API keys, or OpenVibe's"). Auto is// OpenVibe's own; "own:<provider>" is the person's key, sealed in OpenVibe.AI, which Actor never sees.@@@ -317,6 +318,31 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=</div></details>`;}++ /**++ * The model field of /chat/model (PLAN G-P9; ACTOR_AI_CATALOG, off by default). With OpenVibe.AI's catalog read,++ * the dynamic aliases come first (marked Dynamic) and then the exact ids, each with its retail output price and++ * availability in the option's data-desc/data-state for Shared's select; the chosen id is posted unchanged. With++ * no catalog (the flag off, a failed read, or AI's legacy single model) it is the plain input it always was.++ */++ function modelField(models, current) {++ if (!Array.isArray(models) || !models.length) {++ return html`<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${current || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>`;++ }++ const option = (e) => {++ const desc = e.output_nano_usd_per_m ? aiCatalog.priceDescription(e.output_nano_usd_per_m)++ : (e.dynamic ? 'Dynamic: the router picks the model per request; the price follows it' : 'Output price not listed');++ const label = e.dynamic ? `${e.id} · ${e.name}` : e.id;++ return html`<option value="${e.id}"${e.id === current ? raw(' selected') : ''}${e.dynamic ? raw(' data-badge="Dynamic"') : ''} data-desc="${desc}" data-state="${e.state}"${e.available ? '' : raw(' disabled')}>${label}</option>`;++ };++ const aliases = models.filter((e) => e.dynamic);++ const exact = models.filter((e) => !e.dynamic);++ const saved = current && !models.some((e) => e.id === current);++ return html`<label class="cm-modelid"><span>A model id, if you want one <small>(an alias lets OpenVibe.AI pick per request; an exact id pins one build)</small></span>++<select name="model" data-ov-title="A model for OpenVibe.AI">++<option value="">OpenVibe.AI picks the provider's best fit</option>${saved ? html`<option value="${current}" selected>${current}</option>` : ''}${aliases.length ? html`<optgroup label="Dynamic aliases">${aliases.map(option)}</optgroup>` : ''}${exact.length ? html`<optgroup label="Exact ids">${exact.map(option)}</optgroup>` : ''}++</select></label>`;++ }++const ATTACH_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>';const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null } = {}) {@@@ -389,6 +415,9 @@ ${modelChip(model)}if (!signedIn(req)) return res.redirect(303, loginTo(req));const model = modelPrefs.wire(await modelPrefs.get(s, requesterOf(req)).catch(() => null));const current = model.provider ? `own:${model.provider}` : 'auto';++ // OpenVibe.AI's aliases and exact ids (PLAN G-P9; ACTOR_AI_CATALOG, off by default). No catalog (the flag++ // off, a failed read, or AI's legacy `openvibe`) leaves the plain model input below exactly as it was.++ const aiModels = ctx.models && typeof ctx.models.aiCatalog === 'function' ? await ctx.models.aiCatalog().catch(() => null) : null;const saved = req.query.saved === '1';const runsSaved = req.query.saved === 'runs';const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';@@@ -406,7 +435,7 @@ ${radio('auto', 'Auto, OpenVibe\'s', 'Actor picks a model for each step, on Open${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&')}" target="_blank" rel="noopener">Add or allow the key</a>`))}<p class="cm-head">Your computer</p>${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}--<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>++${modelField(aiModels, model.model || '')}<button class="cm-save" type="submit">Save</button></form><h2 class="cm-h2" id="runs-on">Where it runs</h2>@@@ -425,7 +454,8 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs onif (!signedIn(req)) return res.redirect(303, loginTo(req));if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change this on openvibe.actor itself.');const body = req.body || {};-- const out = await modelPrefs.put(s, requesterOf(req), { choice: body.choice, model: body.model ? String(body.model) : null });++ const out = await modelPrefs.put(s, requesterOf(req), { choice: body.choice, model: body.model ? String(body.model) : null },++ { exactIds: Boolean(config.ai && config.ai.catalog) });if (out.error) return res.redirect(303, `/chat/model?error=${encodeURIComponent('That did not save: ' + out.error)}`);return res.redirect(303, '/chat/model?saved=1');});@@@ -124,8 +124,13 @@ const msText = (n) => {};// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };++// An exact AI Exchange id (org/model[:quant]@revision, D3) is shown as itself: splitting it would hide the org,++// the quant and the revision that the person or the run disclosure named.++const EXACT_MODEL_ID = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?@[A-Za-z0-9._-]+$/;function modelName(id) {-- const m = String(id || '').split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');++ const raw = String(id || '');++ if (EXACT_MODEL_ID.test(raw)) return raw;++ const m = raw.split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');if (!m) return 'another model';if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');@@@ -30,7 +30,7 @@ const subject = require('./subject');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage'];++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];function decodePayload(token) {const parts = String(token || '').split('.');nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++++/**++ * OpenVibe.AI's model catalog (PLAN G-P9), behind ACTOR_AI_CATALOG (off by default): the aliases a person may name++ * instead of a model, and the exact ids `org/model[:quant]@revision` that pin one artifact.++ *++ * GET /v1/models answers OpenAI's `{ object: 'list', data: [...] }`; every alias carries `dynamic: true` and every++ * registry model carries `id`, `availability` and `retail_rate.output_nano_usd_per_m` (integer nano-USD per million++ * tokens, D1). The list and the parser are AI Exchange P4 (OpenVibe.AI #289); with AI_MODEL_CATALOG off that route++ * answers only the legacy `openvibe` model, which means "fall back to Actor's usual model choice", never a one-row++ * picker. The same goes for an unreadable or empty answer: `parse()` returns null and the page keeps its plain field.++ *++ * Prices are converted from nano-USD to dollars with BigInt, so the description never rounds (D1: no floats).++ */++const CACHE_MS = 5 * 60_000;++++// The dynamic aliases of D3, in the order a person should see them. `openvibe` is the legacy name of `openvibe/auto`++// and is not offered twice.++const ALIASES = ['openvibe/auto', 'openvibe/economy', 'openvibe/balanced', 'openvibe/fast', 'openvibe/private'];++const EXACT = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?@[A-Za-z0-9._-]+$/;++const STATE = { available: 'ok', limited: 'warn', deprecated: 'warn', unavailable: 'off' };++++/** Integer nano-USD per million tokens → exact USD per million, no float: 2500000000 → "2.50". */++function nanoToUsd(nano) {++ const text = String(nano == null ? '' : nano).trim();++ if (!/^\d+$/.test(text)) return null;++ const n = BigInt(text);++ const whole = n / 1_000_000_000n;++ let frac = (n % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '');++ if (frac.length < 2) frac = (frac + '00').slice(0, 2);++ return `${whole}.${frac}`;++}++++/** The option's second line: the retail output price, or that the catalog does not state one. */++function priceDescription(nano) {++ const usd = nanoToUsd(nano);++ return usd == null ? 'Output price not listed' : `$${usd} per million output tokens`;++}++++/**++ * Parse GET /v1/models: aliases first (all dynamic ones, in D3's order), then the exact ids. Registry models without++ * an exact id are left out: only an id that pins one artifact is a choice this picker can post unchanged.++ * Null means "today's behaviour": the flag is off, the answer is the legacy single `openvibe`, or it is unusable.++ */++function parse(body) {++ const list = Array.isArray(body) ? body++ : body && typeof body === 'object' && (Array.isArray(body.data) ? body.data : Array.isArray(body.models) ? body.models : null);++ if (!list) return null;++ const entries = [];++ const seen = new Set();++ for (const m of list) {++ if (!m || typeof m !== 'object') continue;++ const id = typeof m.id === 'string' ? m.id.trim() : '';++ if (!id || id.length > 256 || id === 'openvibe' || seen.has(id)) continue;++ const dynamic = m.dynamic === true || ALIASES.includes(id);++ if (!dynamic && !EXACT.test(id)) continue;++ const availability = typeof m.availability === 'string' && m.availability++ ? m.availability++ : (dynamic ? 'available' : (m.status === 'active' ? 'available' : 'unavailable'));++ const output = m.retail_rate && typeof m.retail_rate === 'object' ? m.retail_rate.output_nano_usd_per_m : null;++ seen.add(id);++ entries.push({++ id,++ dynamic,++ name: String(m.display_name || m.name || id),++ description: typeof m.description === 'string' ? m.description : null,++ policy: typeof m.policy === 'string' ? m.policy : null,++ output_nano_usd_per_m: typeof output === 'string' && /^\d+$/.test(output) ? output : null,++ availability,++ state: STATE[availability] || (dynamic ? 'ok' : 'off'),++ // A deprecated artifact still runs (with a warning); one with no deployment is not a choice.++ available: availability !== 'unavailable',++ });++ }++ if (!entries.length) return null;++ const rank = (id) => { const i = ALIASES.indexOf(id); return i < 0 ? ALIASES.length : i; };++ const aliases = entries.filter((e) => e.dynamic).sort((a, b) => rank(a.id) - rank(b.id));++ return [...aliases, ...entries.filter((e) => !e.dynamic)];++}++++module.exports = { CACHE_MS, ALIASES, parse, nanoToUsd, priceDescription };@@@ -26,16 +26,21 @@ const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter'const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];// A model id: letters, digits and the marks providers use (gpt-5.1, claude-sonnet-4-5, deepseek/flash, org/model).const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/;++// An exact id (D3: org/model[:quant]@revision), accepted only while the AI catalog is on: `@` and `_` are marks an++// exact id carries and today's model names did not. 256 is AI's own limit for a requested model.++const EXACT_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/;/*** Read a choice from a request body. Returns { choice, provider, model } or { error } (never throws: a bad choice-- * is a 422, not a crash).++ * is a 422, not a crash). `exactIds` (ACTOR_AI_CATALOG) lets an exact id through unchanged.*/--function parse(body) {++function parse(body, { exactIds = false } = {}) {const raw = body && typeof body === 'object' ? body : {};const choice = String(raw.choice == null ? 'auto' : raw.choice).trim();const model = raw.model == null ? null : String(raw.model).trim();-- if (model && !MODEL_RE.test(model)) return { error: 'model: a model id (letters, digits, and . _ : - /)' };++ if (model && !(exactIds ? EXACT_MODEL_RE : MODEL_RE).test(model)) {++ return { error: exactIds ? 'model: a model id, including an exact id (letters, digits, and . _ : - / @)' : 'model: a model id (letters, digits, and . _ : - /)' };++ }if (CHOICES.includes(choice)) return { choice, provider: null, model: model || null };if (choice.startsWith('own:')) {const provider = choice.slice(4).trim().toLowerCase();@@@ -53,8 +58,8 @@ async function get(s, requester) {}/** Write the person's choice. Returns { row } or { error }. */--async function put(s, requester, body, now = () => new Date().toISOString()) {-- const parsed = parse(body);++async function put(s, requester, body, { now = () => new Date().toISOString(), exactIds = false } = {}) {++ const parsed = parse(body, { exactIds });if (parsed.error) return { error: parsed.error };if (!requester) return { error: 'no person' };const at = now();@@@ -25,13 +25,15 @@* cost_usd goes back to the caller, which meters it (the task's `meter`).** createModels({ config, rates, fetchImpl, log, now }) → {-- * warm(), chat(...), resolve(tier, { avoidFamily }), tierList(), modelList(), providerList(), tierConfig++ * warm(), chat(...), resolve(tier, { avoidFamily }), tierList(), modelList(), providerList(), tierConfig,++ * aiCatalog() OpenVibe.AI's aliases and exact ids (PLAN G-P9; ACTOR_AI_CATALOG, cached ~5 minutes)* }*/const { AsyncLocalStorage } = require('node:async_hooks');const { serviceAuth } = require('openvibe-contracts');const { chatClient, priceUsage, readStream, ProviderError } = require('../agents/providers');++const aiCatalogLib = require('./ai-catalog');class ModelError extends Error {constructor(message, { code = 'actor.model.unavailable' } = {}) { super(message); this.code = code; }@@@ -280,6 +282,40 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =} catch { return null; }}++ // ── OpenVibe.AI's model catalog (PLAN G-P9), behind ACTOR_AI_CATALOG ───────────────────────────++ // GET /v1/models, read with the same service token, for the aliases and exact ids a person may pick. Cached for++ // about five minutes, failures included: a failed read is today's behaviour for the next few minutes, never a++ // broken page or a retry storm.++ let aiCatalogCache = null; // { at, entries } — entries null when the flag is on but the read failed/legacy++ let aiCatalogInFlight = null;++ const aiCatalogOn = () => Boolean(ai.catalog); // D11 owner settings: read live++++ /** The catalog's entries (aliases first, then exact ids), or null: today's model field, unchanged. */++ async function aiCatalog() {++ if (!aiCatalogOn()) return null;++ const at = now();++ if (aiCatalogCache && at - aiCatalogCache.at < aiCatalogLib.CACHE_MS) return aiCatalogCache.entries;++ if (aiCatalogInFlight) return aiCatalogInFlight;++ aiCatalogInFlight = (async () => {++ let entries = null;++ const tokens = aiTokenClient();++ if (tokens && ai.url) {++ try {++ const res = await fetchImpl(trimSlash(ai.url) + '/v1/models', {++ headers: { accept: 'application/json', ...(await tokens.authHeaders()) },++ signal: AbortSignal.timeout(Math.max(500, cfg.listTimeoutMs || 5000)),++ });++ if (res.ok) entries = aiCatalogLib.parse(await res.json());++ } catch (err) {++ if (log && log.warn) log.warn('[Actor] OpenVibe.AI model catalog:', (err && err.message) || err);++ }++ }++ aiCatalogCache = { at: now(), entries };++ return entries;++ })().finally(() => { aiCatalogInFlight = null; });++ return aiCatalogInFlight;++ }++/** Run the rest of the work with this person's model choice in force. */function withChoice(choice, fn) {return choiceStore.run(choice || null, fn);@@@ -356,6 +392,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =const said = String(msg || '').trim();throw new ModelError(said ? said.charAt(0).toUpperCase() + said.slice(1) : 'This service may not use your key yet', { code: 'actor.model.not_allowed' });}++ // An exact id AI refuses (PLAN G-P9): shown as that refusal, never retried on another model. AI's own++ // message says why (no such artifact, or one that cannot run this request).++ if ((res.status === 404 && code === 'ai.model_not_found') || (res.status === 400 && code === 'ai.model_unavailable')) {++ throw new ModelError(clip(msg, 300), { code });++ }throw new ProviderError('HTTP ' + res.status + ': ' + clip(msg, 300), { status: res.status, code: res.status === 429 ? 'actor.provider.rate_limited' : 'actor.provider.error' });}if (onDelta) answer = await readStream(res, onDelta, { signal, who: 'OpenVibe.AI' });@@@ -364,13 +405,18 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =const message = first.message || { role: 'assistant', content: '' };const u = answer.usage || {};const usage = { input: u.prompt_tokens || 0, cached: 0, output: u.completion_tokens || 0 };-- const x = answer.x_openvibe || {};++ // The run disclosure (D3', PLAN G-P9): x_openvibe already carries the cost; resolved_model names the artifact++ // that answered, so this is the model every existing model/cost record and page shows. Absent (the catalog is++ // off on AI), the upstream model name is kept exactly as before.++ const x = answer.x_openvibe && typeof answer.x_openvibe === 'object' ? answer.x_openvibe : {};++ const resolved = typeof x.resolved_model === 'string' && x.resolved_model.trim() ? x.resolved_model.trim() : null;return {message,text: typeof message.content === 'string' ? message.content : '',toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : [],finish: first.finish_reason || null,-- model: answer.model || choice.model || 'openvibe',++ model: resolved || answer.model || choice.model || 'openvibe',++ resolved_model: resolved,usage,// AI prices the call (it knows the provider, and whether the person's own key paid for it).cost_usd: Number(x.cost_usd) || 0,@@@ -428,7 +474,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =}return {-- warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys,++ warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys, aiCatalog,tierConfig: cfg, PREFERRED, PREFERRED_CHECKED, TIERS,get warmed() { return warmed; },listErrors: () => ({ ...listErrors }),@@@ -17,13 +17,15 @@*/const KINDS = new Set(['model', 'tool', 'check', 'judge', 'delegate']);--const MAX_NAME = 120;++// 256 is OpenVibe.AI's own limit for a requested model, so an exact id (org/model[:quant]@revision) is kept whole.++const MAX_NAME = 256;const MAX_SHORT = 64;const clip = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? t.slice(0, n) : t; };// A name is an identifier (a model id or a tool name), never a sentence: anything that is not identifier-shaped becomes--// an underscore, so a provider echoing a prompt in its model id cannot put the person's words in a span.--const safeName = (s) => clip(String(s == null ? '' : s).replace(/[^a-zA-Z0-9._:/-]+/g, '_'), MAX_NAME);++// an underscore, so a provider echoing a prompt in its model id cannot put the person's words in a span. `@` is the++// revision mark of an exact AI Exchange id (D3), so it is kept as itself.++const safeName = (s) => clip(String(s == null ? '' : s).replace(/[^a-zA-Z0-9._:@/-]+/g, '_'), MAX_NAME);const short = (v, n = MAX_SHORT) => (typeof v === 'string' && /^[a-zA-Z0-9_.:-]+$/.test(v) ? v.slice(0, n) : null);const intOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : null));@@@ -397,7 +397,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =: err && err.code === 'actor.budget.exceeded' ? 'actor.budget.exceeded': err && err.code === 'actor.checkpoint.invalid' ? 'actor.checkpoint.invalid': err && err.code === 'actor.acting.unresolved' ? 'actor.acting.unresolved'-- : 'actor.agent.failed';++ // An exact AI Exchange id AI refused (PLAN G-P9): the person sees AI's own error.++ : err && (err.code === 'ai.model_not_found' || err.code === 'ai.model_unavailable') ? err.code++ : 'actor.agent.failed';return await setState(id, 'failed', { error_code: code, error_detail: detail.slice(0, 2000), finished_at: iso() });}try {@@@ -20,6 +20,7 @@ const { testDb } = require('./helpers/db');const EXEMPT = {account_data_events: 'the ledger of requests itself: which export and deletion Actor applied',actor_brain_outcomes: 'counts of outcomes by task kind, tier and tool: no person column and no task text',++ config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',};const A = 'usr_01JZ0000000000000000000AAA';nnew file mode 100644@@@ -0,0 +1,275 @@++'use strict';++/**++ * PLAN G-P9: Actor uses OpenVibe.AI's catalog through what it already has, behind ACTOR_AI_CATALOG (off).++ *++ * What is proved here:++ * the catalog parses aliases first and then exact ids, with the output price exact in nano-USD → dollars;++ * the flag off, a failed read, and AI's legacy single `openvibe` all leave today's model field/behaviour unchanged;++ * an exact id is sent unchanged as `model` (never rewritten or substituted);++ * x_openvibe.resolved_model and the cost are the model and cost the run records and shows;++ * an exact id AI refuses is that refusal, with one call and no retry on another model.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createModels } = require('../server/runtime/models');++const aiCatalog = require('../server/runtime/ai-catalog');++require('../server/tasks/engine'); // the end-to-end check below: an exact id AI refuses becomes the task's error++++const SAME = { 'sec-fetch-site': 'same-origin' };++const PERSON = 'usr_01JZ0000000000000000000AAA';++const EXACT = 'acme/m1:q4_k_m@rev1';++const LOG = { log() {}, warn() {}, error() {} };++const reply = (status, obj, headers = {}) => ({++ ok: status >= 200 && status < 300,++ status,++ headers: new Headers({ 'content-type': 'application/json', ...headers }),++ json: async () => obj,++ text: async () => JSON.stringify(obj),++});++++/** A config with OpenVibe.AI (and no operator providers). */++function aiConfig(catalog = true) {++ return {++ models: { tiers: {}, providerOrder: [], listTimeoutMs: 200 },++ providers: {},++ ai: { url: 'https://ai.test', audience: 'openvibe.ai', scope: 'ai.run.create', clientId: 'actor', clientSecret: 'actor-secret', timeoutMs: 5000, catalog },++ networkInternalUrl: 'https://network.test', networkUrl: 'https://openvibe.network',++ oauth: { clientId: 'actor', clientSecret: 'actor-secret' },++ };++}++++/** The token endpoint, then whatever the test wants for GET /v1/models and POST /v1/chat/completions. */++function fakeFetch({ models = null, chat = null } = {}) {++ const calls = [];++ const fetchImpl = async (url, opts = {}) => {++ const u = String(url);++ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_service_token', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });++ const call = { url: u, method: opts.method || 'GET', headers: opts.headers || {}, body: opts.body ? JSON.parse(opts.body) : null };++ calls.push(call);++ if (u.endsWith('/v1/models')) return models(call);++ return chat(call);++ };++ return { calls, fetchImpl };++}++++// The ai.model@1 catalog as AI serves it (OpenVibe.AI #289): aliases carry dynamic:true and no price; a registry++// model carries an exact id, its availability and integer nano-USD prices. The legacy `openvibe` is the alias name.++const CATALOG = {++ object: 'list',++ data: [++ { id: 'acme/m1:q4_k_m@rev1', object: 'model', dynamic: false, display_name: 'm1', availability: 'available', retail_rate: { input_nano_usd_per_m: '1000000000', output_nano_usd_per_m: '2500000000' }, capabilities: { tools: true } },++ { id: 'openvibe/fast', object: 'model', dynamic: true, policy: 'fastest', display_name: 'Fast', availability: 'available' },++ { id: 'acme/no-revision', object: 'model', dynamic: false, display_name: 'no exact id' },++ { id: 'openvibe', object: 'model', dynamic: true, display_name: 'OpenVibe AI' },++ { id: 'openvibe/auto', object: 'model', dynamic: true, policy: 'auto', display_name: 'Auto', availability: 'available' },++ { id: 'openvibe/private', object: 'model', dynamic: true, policy: 'private', display_name: 'Private', availability: 'available' },++ { id: 'acme/m2@rev2', object: 'model', dynamic: false, display_name: 'm2', availability: 'unavailable', retail_rate: { output_nano_usd_per_m: '123456789' } },++ ],++};++++/** The OpenAI chat answer AI gives, with the route disclosure in x_openvibe. */++function answer({ status = 200, resolved = EXACT, cost = 0.0012, body = null } = {}) {++ if (body) return reply(status, body);++ return reply(status, {++ model: 'm1',++ choices: [{ index: 0, message: { role: 'assistant', content: 'hello' }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 10, completion_tokens: 5 },++ x_openvibe: { resolved_model: resolved, revision: 'rev1', provider_access: 'OWN_DEPLOYMENT', rate_version: 'rr-1', policy: 'exact', fallback_reason: null, cost_usd: cost },++ });++}++++(async () => {++ let t = null;++ try {++ await check('the catalog parses aliases first, then exact ids, with the output price exact', async () => {++ const entries = aiCatalog.parse(CATALOG);++ assert.ok(entries, 'a catalog with aliases and exact ids is usable');++ assert.deepStrictEqual(entries.map((e) => e.id), ['openvibe/auto', 'openvibe/fast', 'openvibe/private', 'acme/m1:q4_k_m@rev1', 'acme/m2@rev2'], 'aliases in D3 order, then exact ids; legacy openvibe and a non-exact id are left out');++ assert.deepStrictEqual([entries[0].dynamic, entries[3].dynamic], [true, false]);++ assert.deepStrictEqual(entries.slice(0, 3).map((e) => e.output_nano_usd_per_m), [null, null, null], 'an alias has no price of its own');++ assert.strictEqual(aiCatalog.priceDescription(entries[3].output_nano_usd_per_m), '$2.50 per million output tokens');++ assert.strictEqual(aiCatalog.priceDescription(entries[4].output_nano_usd_per_m), '$0.123456789 per million output tokens', 'nano-USD to dollars without a float');++ assert.strictEqual(aiCatalog.priceDescription('0'), '$0.00 per million output tokens');++ assert.strictEqual(aiCatalog.priceDescription('1000000000000'), '$1000.00 per million output tokens');++ assert.strictEqual(aiCatalog.priceDescription(null), 'Output price not listed');++ assert.deepStrictEqual(entries.map((e) => e.state), ['ok', 'ok', 'ok', 'ok', 'off'], 'availability as the select state');++ assert.strictEqual(entries[4].available, false, 'an unavailable exact id is not a choice');++++ // The flag-off AI answer (one legacy model), an empty answer and rubbish are today's behaviour.++ assert.strictEqual(aiCatalog.parse({ object: 'list', data: [{ id: 'openvibe', object: 'model' }] }), null);++ assert.strictEqual(aiCatalog.parse({ object: 'list', data: [] }), null);++ assert.strictEqual(aiCatalog.parse(null), null);++ assert.strictEqual(aiCatalog.parse({ object: 'list', data: 'nope' }), null);++ });++++ await check('flag off: no catalog call, and the page keeps the plain model field', async () => {++ const offline = fakeFetch({ models: () => { throw new Error('the catalog must not be read'); } });++ const models = createModels({ config: aiConfig(false), fetchImpl: offline.fetchImpl, log: LOG });++ assert.strictEqual(await models.aiCatalog(), null);++ assert.strictEqual(offline.calls.length, 0, 'no GET /v1/models and no token while the flag is off');++++ t = await boot();++ const alice = t.network.addUser('catalog-off');++ const page = await t.get('/chat/model', { as: alice });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<input name="model" value="" maxlength="120"/);++ assert.ok(!page.text.includes('<select name="model"'), 'no catalog select without the flag');++ });++++ await check('flag on: GET /v1/models is read with Actor\'s service token and cached about five minutes', async () => {++ let clock = 1_000_000;++ const f = fakeFetch({ models: () => reply(200, CATALOG) });++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG, now: () => clock });++ const first = await models.aiCatalog();++ assert.deepStrictEqual(first.map((e) => e.id).slice(0, 3), ['openvibe/auto', 'openvibe/fast', 'openvibe/private']);++ assert.strictEqual(f.calls.length, 1);++ assert.strictEqual(f.calls[0].url, 'https://ai.test/v1/models');++ assert.strictEqual(f.calls[0].headers.Authorization, 'Bearer tok_service_token', 'the token Actor already uses');++ assert.ok(!JSON.stringify(f.calls[0]).includes('actor-secret'), 'the client secret never travels');++ const second = await models.aiCatalog();++ assert.deepStrictEqual(second, first);++ assert.strictEqual(f.calls.length, 1, 'a second read is cached');++ clock += aiCatalog.CACHE_MS + 1;++ await models.aiCatalog();++ assert.strictEqual(f.calls.length, 2, 'after five minutes it is read again');++ });++++ await check('flag on, AI catalog off or a failed read: null, and the next use is today\'s behaviour', async () => {++ const legacy = fakeFetch({ models: () => reply(200, { object: 'list', data: [{ id: 'openvibe', object: 'model' }] }) });++ const a = createModels({ config: aiConfig(true), fetchImpl: legacy.fetchImpl, log: LOG });++ assert.strictEqual(await a.aiCatalog(), null, 'the legacy single model means no picker');++++ const down = fakeFetch({ models: () => reply(503, { error: { message: 'down' } }) });++ const b = createModels({ config: aiConfig(true), fetchImpl: down.fetchImpl, log: LOG });++ assert.strictEqual(await b.aiCatalog(), null);++ assert.strictEqual(await b.aiCatalog(), null, 'a failure is cached too, not retried on every page');++ assert.strictEqual(down.calls.length, 1);++ });++++ await check('an exact id is sent unchanged as model, and its disclosure is the run\'s model and cost', async () => {++ const f = fakeFetch({ chat: () => answer() });++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });++ const r = await models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({++ tier: 'smart', messages: [{ role: 'user', content: 'hello' }],++ }));++ assert.strictEqual(f.calls.length, 1, 'one call to AI');++ assert.strictEqual(f.calls[0].body.model, EXACT, 'the exact id is sent unchanged');++ assert.strictEqual(f.calls[0].body.prefer.model, EXACT, 'the soft preference names the same id');++ assert.strictEqual(f.calls[0].headers['x-openvibe-credential'], 'openai:' + PERSON);++ assert.strictEqual(r.model, EXACT, 'the model the run records is x_openvibe.resolved_model');++ assert.strictEqual(r.resolved_model, EXACT);++ assert.strictEqual(r.cost_usd, 0.0012, 'the cost is x_openvibe.cost_usd');++ assert.strictEqual(r.text, 'hello');++ // The same id is what the pages show and the trace records, not split at the quant or sanitised.++ const { modelName } = require('../server/http/pages');++ const { safeName } = require('../server/runtime/trace');++ assert.strictEqual(modelName(EXACT), EXACT);++ assert.strictEqual(safeName(EXACT), EXACT);++++ // A dynamic alias is likewise sent unchanged.++ const g = fakeFetch({ chat: () => answer({ resolved: 'acme/m1@rev1' }) });++ const models2 = createModels({ config: aiConfig(true), fetchImpl: g.fetchImpl, log: LOG });++ const a = await models2.withChoice({ subject: PERSON, provider: 'openai', model: 'openvibe/economy' }, () => models2.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hi' }] }));++ assert.strictEqual(g.calls[0].body.model, 'openvibe/economy');++ assert.strictEqual(a.model, 'acme/m1@rev1', 'the alias says which model answered');++ });++++ await check('a streamed answer records the final x_openvibe too', async () => {++ const chunk = (o) => `data: ${JSON.stringify(o)}\n\n`;++ const sse = chunk({ model: 'm1', choices: [{ index: 0, delta: { role: 'assistant', content: 'hel' }, finish_reason: null }] })++ + chunk({ model: 'm1', choices: [{ index: 0, delta: { content: 'lo' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 5 } })++ + chunk({ model: 'm1', choices: [], x_openvibe: { resolved_model: EXACT, cost_usd: 0.003 } });++ const f = fakeFetch({ chat: () => new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) });++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });++ const r = await models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }], onDelta: () => {} }));++ assert.strictEqual(r.model, EXACT);++ assert.strictEqual(r.cost_usd, 0.003);++ });++++ await check('an exact id AI refuses is that error, one call, never another model', async () => {++ const cases = [++ [404, 'ai.model_not_found', 'no artifact ' + EXACT, 'No artifact'],++ [400, 'ai.model_unavailable', 'the artifact ' + EXACT + ' has no active deployment', 'no active deployment'],++ ];++ for (const [status, code, message] of cases) {++ const f = fakeFetch({ chat: () => reply(status, { error: { message, type: 'invalid_request_error', code, param: null } }) });++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });++ await assert.rejects(++ () => models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }] })),++ (err) => {++ assert.strictEqual(err.code, code, 'AI\'s own code is shown');++ assert.strictEqual(err.message, message, 'AI\'s own detail is shown');++ return true;++ },++ );++ assert.strictEqual(f.calls.length, 1, `${code}: no retry on another model`);++ }++ });++++ await check('a refused exact id fails the person\'s task with AI\'s error, never another model', async () => {++ const aiCalls = [];++ const f = fakeFetch({++ models: () => reply(200, { data: [{ id: 'gpt-5-mini' }] }),++ chat: (call) => {++ aiCalls.push(call);++ return reply(404, { error: { message: 'no artifact ' + EXACT, type: 'invalid_request_error', code: 'ai.model_not_found', param: null } });++ },++ });++ // The operator's own tier must resolve for the loop to start; this checks the person's AI choice does win.++ const cfg = aiConfig(true);++ cfg.providers = { openai: { apiKey: 'sk-fixture-operator-key', baseUrl: 'https://api.openai.test/v1', model: 'gpt-5-mini' } };++ const models = createModels({ config: cfg, fetchImpl: f.fetchImpl, log: LOG });++ const tb = await boot({ env: { ACTOR_AI_CATALOG: '1' }, models });++ try {++ const user = tb.network.addUser('catalog-refused');++ const put = await tb.get('/api/v1/me/model', { as: user, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });++ assert.strictEqual(put.status, 200, put.text);++ const read = await tb.get('/api/v1/me/model', { as: user });++ assert.deepStrictEqual([read.json().choice, read.json().model], ['own:openai', EXACT], read.text);++ const made = await tb.get('/api/v1/tasks', { as: user, json: { task: 'Say hello', mode: 'balanced' }, headers: SAME });++ assert.strictEqual(made.status, 201, made.text);++ const row = await tb.waitFor(made.json().id);++ assert.strictEqual(row.state, 'failed');++ assert.strictEqual(row.error_code, 'ai.model_not_found', `the task shows AI's own error code (${row.error_code}: ${row.error_detail})`);++ assert.strictEqual(row.error_detail, 'no artifact ' + EXACT, 'and AI\'s own detail');++ const chats = aiCalls.filter((c) => c.url.endsWith('/v1/chat/completions'));++ assert.ok(chats.length >= 1, 'the run asked OpenVibe.AI');++ assert.ok(chats.every((c) => c.body.model === EXACT), 'every call names the exact id; nothing is substituted');++ } finally { await tb.close(); }++ });++++ await check('the page offers aliases first, then exact ids, priced and available; a saved exact id is kept', async () => {++ const entries = aiCatalog.parse(CATALOG);++ const stub = { warm: async () => ({}), aiCatalog: async () => entries };++ const tb = await boot({ env: { ACTOR_AI_CATALOG: '1' }, models: stub });++ try {++ const alice = tb.network.addUser('catalog-on');++ const page = await tb.get('/chat/model', { as: alice });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<select name="model" data-ov-title="A model for OpenVibe.AI">/);++ assert.ok(page.text.indexOf('openvibe/auto') < page.text.indexOf(EXACT), 'aliases come before exact ids');++ assert.match(page.text, /<option value="openvibe\/auto"[^>]*data-badge="Dynamic"[^>]*data-desc="Dynamic: the router picks/);++ assert.match(page.text, new RegExp(`<option value="${EXACT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*data-desc="\\$2.50 per million output tokens"[^>]*data-state="ok"`));++ assert.match(page.text, /<option value="acme\/m2@rev2"[^>]*data-desc="\$0.123456789 per million output tokens"[^>]*data-state="off" disabled>/, 'unavailable is shown as off and cannot be chosen');++++ const put = await tb.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual(put.json().model, EXACT, 'the exact id is stored unchanged');++ const after = await tb.get('/chat/model', { as: alice });++ assert.match(after.text, new RegExp(`<option value="${EXACT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}" selected`));++ } finally { await tb.close(); }++ });++++ await check('flag off: an exact id is still refused by today\'s model rule', async () => {++ t = t || await boot();++ const alice = t.network.addUser('catalog-off-exact');++ const put = await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });++ assert.strictEqual(put.status, 422);++ assert.strictEqual(put.json().code, 'actor.model.invalid');++ });++ } finally {++ if (t) await t.close();++ }++++ done();++})();@@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');const configLib = require('../server/config');const { boot, check, done } = require('./helpers/boot');--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage'];++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads@@@ -97,6 +97,7 @@ function codeRoutes() {await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {const declared = new Set();for (const id of CAPS) {++ if (id === 'actor.config.manage') continue; // /internal/config, openvibe-shared/config's adminRoutesconst impl = contracts.capabilities.get(id).implementedBy;// A planned resource capability lists no routes; an active one lists exactly the index's two.const expected = id === 'actor.resource.read' ? INDEX_ROUTES : impl;@@@ -116,6 +117,7 @@ function codeRoutes() {await check('each guarded route requires the capability that lists it; the agent reads are public', () => {for (const id of CAPS) {++ if (id === 'actor.config.manage') continue; // /internal/config is guarded by capabilityGuard, not a route in this mapconst implemented = id === 'actor.resource.read' ? INDEX_ROUTES : contracts.capabilities.get(id).implementedBy;for (const r of implemented) {const route = routes.get(r);@@@ -134,7 +134,7 @@ function minimalState(task, calls = 0) {assert.strictEqual(parts[0].content[0].task_id, id);assert.strictEqual(parts[0].content[0].step, 1);assert.ok(!('state_enc' in parts[0].content[0]), 'the sealed state is not exported');-- assert.deepStrictEqual(await uncovered(d.db, { tables: accountData.TABLES, exempt: { account_data_events: 'the ledger of requests itself', actor_brain_outcomes: 'counts only' } }), [], 'the guard passes');++ assert.deepStrictEqual(await uncovered(d.db, { tables: accountData.TABLES, exempt: { account_data_events: 'the ledger of requests itself', actor_brain_outcomes: 'counts only', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data' } }), [], 'the guard passes');await s.db.query('DELETE FROM tasks WHERE id = $1', [id]);assert.strictEqual(await cp.count(id), 0, 'erased with the task');} finally { await d.close(); }@@@ -42,7 +42,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects, notifier: opts.notifier, models: opts.models, registry: opts.registry, loop: opts.loop, connectors: opts.connectors, guard: opts.guard });++ const built = await createApp({ config, env, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects, notifier: opts.notifier, models: opts.models, registry: opts.registry, loop: opts.loop, connectors: opts.connectors, guard: opts.guard });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * D11 owner settings: the actor.ai namespace and /internal/config.++ * Env pins win; actor.config.manage is required; an owner change applies live; no KEK still works;++ * a secret-class value never leaves a summary, snapshot or history.++ */++const assert = require('assert');++const { serviceAuth } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++const CAP = ['actor.config.manage'];++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const SECRET = 'fixture-actor-d11-not-a-credential';++++function mint(t, cap = CAP) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.actor'], cap,++ iat: now, exp: now + 300, jti: 'tok_actor_config',++ }, t.network.privatePem);++}++++(async () => {++ const t = await boot({ env: { ACTOR_AI_CATALOG: '1' } });++ try {++ await check('the summary: schema, the env pin, sealed:false without a KEK', async () => {++ const r = await t.get('/internal/config/actor.ai', { bearer: mint(t) });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().namespace, 'actor.ai');++ assert.strictEqual(r.json().sealed, false);++ assert.strictEqual(r.json().values.catalog, true);++ assert.deepStrictEqual(r.json().pins.catalog, { env: 'ACTOR_AI_CATALOG', pinned: true });++ assert.ok(!r.text.includes(SECRET));++ });++++ await check('a service token without actor.config.manage is refused', async () => {++ const r = await t.get('/internal/config/actor.ai', { bearer: mint(t, ['actor.task.read']) });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'capability.denied');++ });++++ await check('an env-pinned key is read-only', async () => {++ const r = await t.get('/internal/config/actor.ai', {++ method: 'POST', bearer: mint(t), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: false }, reason: 'off' },++ });++ assert.strictEqual(r.status, 409, r.text.slice(0, 300));++ assert.strictEqual(r.json().code, 'config.pinned_in_env');++ });++ } finally { await t.close(); }++++ const live = await boot();++ try {++ await check('an owner change applies live', async () => {++ assert.strictEqual(live.config.ai.catalog, false);++ const r = await live.get('/internal/config/actor.ai', {++ method: 'POST', bearer: mint(live), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: true }, reason: 'owner turned it on' },++ });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().revision, 2);++ assert.strictEqual(live.config.ai.catalog, true);++ assert.ok(live.logs().includes('by user:' + OWNER));++ assert.ok(!live.logs().includes(SECRET));++ });++++ await check('with a KEK the store is sealed and boot reseals', async () => {++ const sealed = await boot({ env: { OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });++ try {++ const r = await sealed.get('/internal/config/actor.ai', { bearer: mint(sealed) });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.strictEqual(r.json().sealed, true);++ assert.strictEqual(r.json().kek, 'k1');++ } finally { await sealed.close(); }++ });++++ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {++ const shared = require('openvibe-shared/config');++ const extra = await shared.createConfigStore({++ db: live.ctx.s.db, service: 'actor', namespace: 'actor.test_secrets',++ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },++ classify: () => 'secret',++ });++ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });++ assert.strictEqual(marker.redacted, true);++ assert.ok(!JSON.stringify(marker).includes(SECRET));++ const summary = await extra.summary();++ assert.strictEqual(summary.values.api_key.redacted, true);++ assert.ok(!JSON.stringify(summary).includes(SECRET));++ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));++ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));++ });++ } finally { await live.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';// 0017 on top of the running schema.const second = await db.migrate({ dir: MIGRATIONS });// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027']);++ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028']);await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
@@ -24,6 +24,12 @@ OV_OAUTH_CLIENT_SECRET=
@@ -24,6 +24,12 @@ OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback
# ACTOR_AUDIENCE=openvibe.actor
# ACTOR_AUDIENCE=openvibe.actor
+# OpenVibe.AI (the model gateway a person's own keys live in; server/runtime/models.js). The catalog flag is off by
+# default: with it on, Actor reads GET /v1/models with its service token and offers the aliases and exact ids where a
+# person picks the model their tasks run on.
+# OV_AI_URL=https://ai.openvibe.services
+# ACTOR_AI_CATALOG=1
+
# OpenVibe.Events → Actor (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for
# OpenVibe.Events → Actor (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for
# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions
# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions
# are created at boot (off when unset); ACTOR_EVENTS_SUBSCRIBE=0 turns that off.
# are created at boot (off when unset); ACTOR_EVENTS_SUBSCRIBE=0 turns that off.
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+-- The fingerprint key of each namespace. Never served, never logged.
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
+ created_at text COLLATE "C" NOT NULL
+);
@@ -15,9 +15,9 @@
@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1146,9 +1146,9 @@
@@ -1146,9 +1146,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.269.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
- "integrity": "sha512-OCdHszaBv7sV+82JgGfJy6XeO6s2ZPpvLTZJoYihnfYghX6QFoy6PCu2efoWoBHYOdhAL9kJmSLo4kVTE9zQ9A==",
+ "version": "0.288.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
+ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1190,9 +1190,9 @@
@@ -1190,9 +1190,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.109.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
+ "version": "4.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
+ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -28,9 +28,9 @@
@@ -28,9 +28,9 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * The `actor.ai` namespace (D11 owner settings): the switch that lets Actor's model pickers read OpenVibe.AI's
+ * catalog. ACTOR_AI_CATALOG stays the pin: while it is set it wins, is read-only in the summary and a write
+ * answers 409 config.pinned_in_env.
+ *
+ * /internal/config is openvibe-shared/config's adminRoutes, guarded by actor.config.manage (Network's service
+ * token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write
+ * (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.
+ */
+const express = require('express');
+const shared = require('openvibe-shared/config');
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const SERVICE = 'actor';
+const NAMESPACE = 'actor.ai';
+const CAPABILITY = 'actor.config.manage';
+
+const SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'OpenVibe.AI in Actor',
+ description: 'Read OpenVibe.AI\'s model catalog so the model picker offers its aliases and exact ids.',
+ type: 'object',
+ additionalProperties: false,
+ required: ['catalog'],
+ properties: {
+ catalog: {
+ type: 'boolean', title: 'AI model catalog', 'x-group': 'AI Exchange', 'x-env': 'ACTOR_AI_CATALOG',
+ description: 'Read GET /v1/models with Actor\'s own service token and cache the answer about five minutes. Off, the model field is the plain one it was before.',
+ },
+ },
+};
+const DEFAULTS = Object.freeze({ catalog: false });
+
+function bearer(req) {
+ const h = String((req.headers && req.headers.authorization) || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
+}
+
+function capabilityGuard({ keys, config, log }) {
+ return async function guard(req, res, next) {
+ try {
+ const token = bearer(req);
+ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a Network service token is required', ctx: req.ov });
+ const publicKey = typeof keys.pemForToken === 'function' ? await keys.pemForToken(token) : keys.get();
+ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded yet', ctx: req.ov });
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ if (!r.ok) return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason || 'the token does not verify', ctx: req.ov });
+ const sub = String((r.claims && r.claims.sub) || '');
+ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Actor configuration`, ctx: req.ov });
+ const decision = capabilities.check(r.claims, CAPABILITY);
+ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${CAPABILITY} not granted`, ctx: req.ov });
+ req.configClaims = r.claims;
+ return next();
+ } catch (err) {
+ log.error(`[config] actor.ai guard failed: ${(err && err.message) || err}`);
+ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });
+ }
+ };
+}
+
+function actor(req) {
+ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();
+ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;
+}
+
+const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
+
+async function create({ db, keys, config, env = process.env, log = console, onActivate = null }) {
+ // ACTOR_AI_CATALOG has always accepted 1/true; the shared store's boolean pin reads true/1.
+ const storeEnv = { ...env };
+ if (['1', 'true'].includes(String(storeEnv.ACTOR_AI_CATALOG || '').trim().toLowerCase())) storeEnv.ACTOR_AI_CATALOG = '1';
+ const store = await shared.createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,
+ onActivate: async (values) => { if (onActivate) await onActivate(values); },
+ onChange: ({ namespace, keys: changed, actor: who, revision }) => {
+ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${changed.join(', ')}`);
+ },
+ });
+ if (env.OV_CONFIG_KEKS) {
+ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
+ }
+ const router = shared.adminRoutes([store], {
+ basePath: '/internal/config', requireAdmin: capabilityGuard({ keys, config, log }), actor, requireFresh, log,
+ });
+ return {
+ store, router,
+ get: (key) => store.get(key),
+ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },
+ };
+}
+
+module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
@@ -24,6 +24,7 @@ const { createSso } = require('./auth/sso');
@@ -24,6 +24,7 @@ const { createSso } = require('./auth/sso');
const catalog = require('./agents/catalog');
const catalog = require('./agents/catalog');
const { createAdapters } = require('./agents/adapters');
const { createAdapters } = require('./agents/adapters');
const { createModels } = require('./runtime/models');
const { createModels } = require('./runtime/models');
+const createAiCatalogConfig = require('./ai-catalog-config').create;
const { createRegistry } = require('./runtime/tools');
const { createRegistry } = require('./runtime/tools');
const { createLoop } = require('./runtime/loop');
const { createLoop } = require('./runtime/loop');
const { createStats } = require('./runtime/stats');
const { createStats } = require('./runtime/stats');
@@ -85,6 +86,9 @@ async function createApp(opts = {}) {
@@ -85,6 +86,9 @@ async function createApp(opts = {}) {
const s = opts.store || await openStore(config, { now: opts.now, log });
const s = opts.store || await openStore(config, { now: opts.now, log });
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
+ // D11 owner settings (actor.ai): the catalog switch reads through the store, env pins winning.
+ const aiCatalogConfig = await createAiCatalogConfig({ db: s.db, keys, config, env: opts.env || process.env, log });
+ Object.defineProperty(config.ai, 'catalog', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });
// A self-hosted Actor signs its one owner in itself (auth/local.js); otherwise OpenVibe.Network does (auth/sso.js).
// A self-hosted Actor signs its one owner in itself (auth/local.js); otherwise OpenVibe.Network does (auth/sso.js).
if (config.selfHosted.on) checkSelfHosted(config);
if (config.selfHosted.on) checkSelfHosted(config);
const sso = config.selfHosted.on ? createLocalSignIn({ config, now: s.now }) : createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = config.selfHosted.on ? createLocalSignIn({ config, now: s.now }) : createSso({ config, keys, fetchImpl, now: s.now, log });
@@ -135,7 +139,7 @@ async function createApp(opts = {}) {
@@ -135,7 +139,7 @@ async function createApp(opts = {}) {
createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });
createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });
// Each personal agent's run that passed its check also goes to its owner's Vibe chat (agents/vibe-deliver.js).
// Each personal agent's run that passed its check also goes to its owner's Vibe chat (agents/vibe-deliver.js).
const vibeDelivery = require('./agents/vibe-deliver').createVibeDelivery({ s, config, chat: chatClient, log });
const vibeDelivery = require('./agents/vibe-deliver').createVibeDelivery({ s, config, chat: chatClient, log });
- const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };
+ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@ -150,6 +154,7 @@ async function createApp(opts = {}) {
@@ -150,6 +154,7 @@ async function createApp(opts = {}) {
ctx.accountSend = accountSend;
ctx.accountSend = accountSend;
const app = express();
const app = express();
+ aiCatalogConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
app.get('/openapi.json', (_req, res) => {
@@ -93,6 +93,10 @@ function load(env = process.env) {
@@ -93,6 +93,10 @@ function load(env = process.env) {
clientId: env.OV_OAUTH_CLIENT_ID || 'actor',
clientId: env.OV_OAUTH_CLIENT_ID || 'actor',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
timeoutMs: Math.max(5000, int(env.ACTOR_AI_TIMEOUT_MS, 180_000)),
timeoutMs: Math.max(5000, int(env.ACTOR_AI_TIMEOUT_MS, 180_000)),
+ // The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer
+ // its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is
+ // the plain one it was before.
+ catalog: env.ACTOR_AI_CATALOG === '1' || env.ACTOR_AI_CATALOG === 'true',
},
},
// The model providers behind the agent systems (server/agents/catalog.js). A provider without its key is
// The model providers behind the agent systems (server/agents/catalog.js). A provider without its key is
// listed as unavailable, with the reason; nothing is invented. Keys are the operator's.
// listed as unavailable, with the reason; nothing is invented. Keys are the operator's.
@@ -224,7 +224,7 @@ function createApi(ctx) {
@@ -224,7 +224,7 @@ function createApi(ctx) {
if (r.error) return problem(req, res, 422, 'actor.model.invalid', r.error);
if (r.error) return problem(req, res, 422, 'actor.model.invalid', r.error);
if (body.choice === undefined && body.model === undefined) return res.json({ ...modelPrefs.wire(await modelPrefs.get(s, req.principal.requester)), keys: null });
if (body.choice === undefined && body.model === undefined) return res.json({ ...modelPrefs.wire(await modelPrefs.get(s, req.principal.requester)), keys: null });
}
}
- const out = await modelPrefs.put(s, req.principal.requester, body);
+ const out = await modelPrefs.put(s, req.principal.requester, body, { exactIds: Boolean(config.ai && config.ai.catalog) });
if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);
if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);
res.json({ ...modelPrefs.wire(out.row), keys: null });
res.json({ ...modelPrefs.wire(out.row), keys: null });
});
});
@@ -31,6 +31,7 @@ const { sameOrigin } = require('./principal');
@@ -31,6 +31,7 @@ const { sameOrigin } = require('./principal');
const looks = require('../agents/looks');
const looks = require('../agents/looks');
const inputs = require('../tasks/inputs');
const inputs = require('../tasks/inputs');
const modelPrefs = require('../runtime/model-prefs');
const modelPrefs = require('../runtime/model-prefs');
+const aiCatalog = require('../runtime/ai-catalog');
// The model a person's tasks run on (plan L55 C3; the owner, 11-01: "use your own API keys, or OpenVibe's"). Auto is
// The model a person's tasks run on (plan L55 C3; the owner, 11-01: "use your own API keys, or OpenVibe's"). Auto is
// OpenVibe's own; "own:<provider>" is the person's key, sealed in OpenVibe.AI, which Actor never sees.
// OpenVibe's own; "own:<provider>" is the person's key, sealed in OpenVibe.AI, which Actor never sees.
@@ -317,6 +318,31 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -317,6 +318,31 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
</div></details>`;
</div></details>`;
}
}
+ /**
+ * The model field of /chat/model (PLAN G-P9; ACTOR_AI_CATALOG, off by default). With OpenVibe.AI's catalog read,
+ * the dynamic aliases come first (marked Dynamic) and then the exact ids, each with its retail output price and
+ * availability in the option's data-desc/data-state for Shared's select; the chosen id is posted unchanged. With
+ * no catalog (the flag off, a failed read, or AI's legacy single model) it is the plain input it always was.
+ */
+ function modelField(models, current) {
+ if (!Array.isArray(models) || !models.length) {
+ return html`<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${current || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>`;
+ }
+ const option = (e) => {
+ const desc = e.output_nano_usd_per_m ? aiCatalog.priceDescription(e.output_nano_usd_per_m)
+ : (e.dynamic ? 'Dynamic: the router picks the model per request; the price follows it' : 'Output price not listed');
+ const label = e.dynamic ? `${e.id} · ${e.name}` : e.id;
+ return html`<option value="${e.id}"${e.id === current ? raw(' selected') : ''}${e.dynamic ? raw(' data-badge="Dynamic"') : ''} data-desc="${desc}" data-state="${e.state}"${e.available ? '' : raw(' disabled')}>${label}</option>`;
+ };
+ const aliases = models.filter((e) => e.dynamic);
+ const exact = models.filter((e) => !e.dynamic);
+ const saved = current && !models.some((e) => e.id === current);
+ return html`<label class="cm-modelid"><span>A model id, if you want one <small>(an alias lets OpenVibe.AI pick per request; an exact id pins one build)</small></span>
+<select name="model" data-ov-title="A model for OpenVibe.AI">
+<option value="">OpenVibe.AI picks the provider's best fit</option>${saved ? html`<option value="${current}" selected>${current}</option>` : ''}${aliases.length ? html`<optgroup label="Dynamic aliases">${aliases.map(option)}</optgroup>` : ''}${exact.length ? html`<optgroup label="Exact ids">${exact.map(option)}</optgroup>` : ''}
+</select></label>`;
+ }
+
const ATTACH_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>';
const ATTACH_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>';
const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';
const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';
function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null } = {}) {
function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null } = {}) {
@@ -389,6 +415,9 @@ ${modelChip(model)}
@@ -389,6 +415,9 @@ ${modelChip(model)}
if (!signedIn(req)) return res.redirect(303, loginTo(req));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
const model = modelPrefs.wire(await modelPrefs.get(s, requesterOf(req)).catch(() => null));
const model = modelPrefs.wire(await modelPrefs.get(s, requesterOf(req)).catch(() => null));
const current = model.provider ? `own:${model.provider}` : 'auto';
const current = model.provider ? `own:${model.provider}` : 'auto';
+ // OpenVibe.AI's aliases and exact ids (PLAN G-P9; ACTOR_AI_CATALOG, off by default). No catalog (the flag
+ // off, a failed read, or AI's legacy `openvibe`) leaves the plain model input below exactly as it was.
+ const aiModels = ctx.models && typeof ctx.models.aiCatalog === 'function' ? await ctx.models.aiCatalog().catch(() => null) : null;
const saved = req.query.saved === '1';
const saved = req.query.saved === '1';
const runsSaved = req.query.saved === 'runs';
const runsSaved = req.query.saved === 'runs';
const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';
const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';
@@ -406,7 +435,7 @@ ${radio('auto', 'Auto, OpenVibe\'s', 'Actor picks a model for each step, on Open
@@ -406,7 +435,7 @@ ${radio('auto', 'Auto, OpenVibe\'s', 'Actor picks a model for each step, on Open
${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&')}" target="_blank" rel="noopener">Add or allow the key</a>`))}
${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&')}" target="_blank" rel="noopener">Add or allow the key</a>`))}
<p class="cm-head">Your computer</p>
<p class="cm-head">Your computer</p>
${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}
${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}
-<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>
+${modelField(aiModels, model.model || '')}
<button class="cm-save" type="submit">Save</button>
<button class="cm-save" type="submit">Save</button>
</form>
</form>
<h2 class="cm-h2" id="runs-on">Where it runs</h2>
<h2 class="cm-h2" id="runs-on">Where it runs</h2>
@@ -425,7 +454,8 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
@@ -425,7 +454,8 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
if (!signedIn(req)) return res.redirect(303, loginTo(req));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change this on openvibe.actor itself.');
if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change this on openvibe.actor itself.');
const body = req.body || {};
const body = req.body || {};
- const out = await modelPrefs.put(s, requesterOf(req), { choice: body.choice, model: body.model ? String(body.model) : null });
+ const out = await modelPrefs.put(s, requesterOf(req), { choice: body.choice, model: body.model ? String(body.model) : null },
+ { exactIds: Boolean(config.ai && config.ai.catalog) });
if (out.error) return res.redirect(303, `/chat/model?error=${encodeURIComponent('That did not save: ' + out.error)}`);
if (out.error) return res.redirect(303, `/chat/model?error=${encodeURIComponent('That did not save: ' + out.error)}`);
return res.redirect(303, '/chat/model?saved=1');
return res.redirect(303, '/chat/model?saved=1');
});
});
@@ -124,8 +124,13 @@ const msText = (n) => {
@@ -124,8 +124,13 @@ const msText = (n) => {
};
};
// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };
const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };
+// An exact AI Exchange id (org/model[:quant]@revision, D3) is shown as itself: splitting it would hide the org,
+// the quant and the revision that the person or the run disclosure named.
+const EXACT_MODEL_ID = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?@[A-Za-z0-9._-]+$/;
function modelName(id) {
function modelName(id) {
- const m = String(id || '').split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');
+ const raw = String(id || '');
+ if (EXACT_MODEL_ID.test(raw)) return raw;
+ const m = raw.split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');
if (!m) return 'another model';
if (!m) return 'another model';
if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;
if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;
return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
@@ -30,7 +30,7 @@ const subject = require('./subject');
@@ -30,7 +30,7 @@ const subject = require('./subject');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
-const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage'];
+const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+/**
+ * OpenVibe.AI's model catalog (PLAN G-P9), behind ACTOR_AI_CATALOG (off by default): the aliases a person may name
+ * instead of a model, and the exact ids `org/model[:quant]@revision` that pin one artifact.
+ *
+ * GET /v1/models answers OpenAI's `{ object: 'list', data: [...] }`; every alias carries `dynamic: true` and every
+ * registry model carries `id`, `availability` and `retail_rate.output_nano_usd_per_m` (integer nano-USD per million
+ * tokens, D1). The list and the parser are AI Exchange P4 (OpenVibe.AI #289); with AI_MODEL_CATALOG off that route
+ * answers only the legacy `openvibe` model, which means "fall back to Actor's usual model choice", never a one-row
+ * picker. The same goes for an unreadable or empty answer: `parse()` returns null and the page keeps its plain field.
+ *
+ * Prices are converted from nano-USD to dollars with BigInt, so the description never rounds (D1: no floats).
+ */
+const CACHE_MS = 5 * 60_000;
+
+// The dynamic aliases of D3, in the order a person should see them. `openvibe` is the legacy name of `openvibe/auto`
+// and is not offered twice.
+const ALIASES = ['openvibe/auto', 'openvibe/economy', 'openvibe/balanced', 'openvibe/fast', 'openvibe/private'];
+const EXACT = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?@[A-Za-z0-9._-]+$/;
+const STATE = { available: 'ok', limited: 'warn', deprecated: 'warn', unavailable: 'off' };
+
+/** Integer nano-USD per million tokens → exact USD per million, no float: 2500000000 → "2.50". */
+function nanoToUsd(nano) {
+ const text = String(nano == null ? '' : nano).trim();
+ if (!/^\d+$/.test(text)) return null;
+ const n = BigInt(text);
+ const whole = n / 1_000_000_000n;
+ let frac = (n % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '');
+ if (frac.length < 2) frac = (frac + '00').slice(0, 2);
+ return `${whole}.${frac}`;
+}
+
+/** The option's second line: the retail output price, or that the catalog does not state one. */
+function priceDescription(nano) {
+ const usd = nanoToUsd(nano);
+ return usd == null ? 'Output price not listed' : `$${usd} per million output tokens`;
+}
+
+/**
+ * Parse GET /v1/models: aliases first (all dynamic ones, in D3's order), then the exact ids. Registry models without
+ * an exact id are left out: only an id that pins one artifact is a choice this picker can post unchanged.
+ * Null means "today's behaviour": the flag is off, the answer is the legacy single `openvibe`, or it is unusable.
+ */
+function parse(body) {
+ const list = Array.isArray(body) ? body
+ : body && typeof body === 'object' && (Array.isArray(body.data) ? body.data : Array.isArray(body.models) ? body.models : null);
+ if (!list) return null;
+ const entries = [];
+ const seen = new Set();
+ for (const m of list) {
+ if (!m || typeof m !== 'object') continue;
+ const id = typeof m.id === 'string' ? m.id.trim() : '';
+ if (!id || id.length > 256 || id === 'openvibe' || seen.has(id)) continue;
+ const dynamic = m.dynamic === true || ALIASES.includes(id);
+ if (!dynamic && !EXACT.test(id)) continue;
+ const availability = typeof m.availability === 'string' && m.availability
+ ? m.availability
+ : (dynamic ? 'available' : (m.status === 'active' ? 'available' : 'unavailable'));
+ const output = m.retail_rate && typeof m.retail_rate === 'object' ? m.retail_rate.output_nano_usd_per_m : null;
+ seen.add(id);
+ entries.push({
+ id,
+ dynamic,
+ name: String(m.display_name || m.name || id),
+ description: typeof m.description === 'string' ? m.description : null,
+ policy: typeof m.policy === 'string' ? m.policy : null,
+ output_nano_usd_per_m: typeof output === 'string' && /^\d+$/.test(output) ? output : null,
+ availability,
+ state: STATE[availability] || (dynamic ? 'ok' : 'off'),
+ // A deprecated artifact still runs (with a warning); one with no deployment is not a choice.
+ available: availability !== 'unavailable',
+ });
+ }
+ if (!entries.length) return null;
+ const rank = (id) => { const i = ALIASES.indexOf(id); return i < 0 ? ALIASES.length : i; };
+ const aliases = entries.filter((e) => e.dynamic).sort((a, b) => rank(a.id) - rank(b.id));
+ return [...aliases, ...entries.filter((e) => !e.dynamic)];
+}
+
+module.exports = { CACHE_MS, ALIASES, parse, nanoToUsd, priceDescription };
@@ -26,16 +26,21 @@ const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter'
@@ -26,16 +26,21 @@ const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter'
const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];
const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];
// A model id: letters, digits and the marks providers use (gpt-5.1, claude-sonnet-4-5, deepseek/flash, org/model).
// A model id: letters, digits and the marks providers use (gpt-5.1, claude-sonnet-4-5, deepseek/flash, org/model).
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/;
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/;
+// An exact id (D3: org/model[:quant]@revision), accepted only while the AI catalog is on: `@` and `_` are marks an
+// exact id carries and today's model names did not. 256 is AI's own limit for a requested model.
+const EXACT_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/;
/**
/**
* Read a choice from a request body. Returns { choice, provider, model } or { error } (never throws: a bad choice
* Read a choice from a request body. Returns { choice, provider, model } or { error } (never throws: a bad choice
- * is a 422, not a crash).
+ * is a 422, not a crash). `exactIds` (ACTOR_AI_CATALOG) lets an exact id through unchanged.
*/
*/
-function parse(body) {
+function parse(body, { exactIds = false } = {}) {
const raw = body && typeof body === 'object' ? body : {};
const raw = body && typeof body === 'object' ? body : {};
const choice = String(raw.choice == null ? 'auto' : raw.choice).trim();
const choice = String(raw.choice == null ? 'auto' : raw.choice).trim();
const model = raw.model == null ? null : String(raw.model).trim();
const model = raw.model == null ? null : String(raw.model).trim();
- if (model && !MODEL_RE.test(model)) return { error: 'model: a model id (letters, digits, and . _ : - /)' };
+ if (model && !(exactIds ? EXACT_MODEL_RE : MODEL_RE).test(model)) {
+ return { error: exactIds ? 'model: a model id, including an exact id (letters, digits, and . _ : - / @)' : 'model: a model id (letters, digits, and . _ : - /)' };
+ }
if (CHOICES.includes(choice)) return { choice, provider: null, model: model || null };
if (CHOICES.includes(choice)) return { choice, provider: null, model: model || null };
if (choice.startsWith('own:')) {
if (choice.startsWith('own:')) {
const provider = choice.slice(4).trim().toLowerCase();
const provider = choice.slice(4).trim().toLowerCase();
@@ -53,8 +58,8 @@ async function get(s, requester) {
@@ -53,8 +58,8 @@ async function get(s, requester) {
}
}
/** Write the person's choice. Returns { row } or { error }. */
/** Write the person's choice. Returns { row } or { error }. */
-async function put(s, requester, body, now = () => new Date().toISOString()) {
- const parsed = parse(body);
+async function put(s, requester, body, { now = () => new Date().toISOString(), exactIds = false } = {}) {
+ const parsed = parse(body, { exactIds });
if (parsed.error) return { error: parsed.error };
if (parsed.error) return { error: parsed.error };
if (!requester) return { error: 'no person' };
if (!requester) return { error: 'no person' };
const at = now();
const at = now();
@@ -25,13 +25,15 @@
@@ -25,13 +25,15 @@
* cost_usd goes back to the caller, which meters it (the task's `meter`).
* cost_usd goes back to the caller, which meters it (the task's `meter`).
*
*
* createModels({ config, rates, fetchImpl, log, now }) → {
* createModels({ config, rates, fetchImpl, log, now }) → {
- * warm(), chat(...), resolve(tier, { avoidFamily }), tierList(), modelList(), providerList(), tierConfig
+ * warm(), chat(...), resolve(tier, { avoidFamily }), tierList(), modelList(), providerList(), tierConfig,
+ * aiCatalog() OpenVibe.AI's aliases and exact ids (PLAN G-P9; ACTOR_AI_CATALOG, cached ~5 minutes)
* }
* }
*/
*/
const { AsyncLocalStorage } = require('node:async_hooks');
const { AsyncLocalStorage } = require('node:async_hooks');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { chatClient, priceUsage, readStream, ProviderError } = require('../agents/providers');
const { chatClient, priceUsage, readStream, ProviderError } = require('../agents/providers');
+const aiCatalogLib = require('./ai-catalog');
class ModelError extends Error {
class ModelError extends Error {
constructor(message, { code = 'actor.model.unavailable' } = {}) { super(message); this.code = code; }
constructor(message, { code = 'actor.model.unavailable' } = {}) { super(message); this.code = code; }
@@ -280,6 +282,40 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -280,6 +282,40 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
} catch { return null; }
} catch { return null; }
}
}
+ // ── OpenVibe.AI's model catalog (PLAN G-P9), behind ACTOR_AI_CATALOG ───────────────────────────
+ // GET /v1/models, read with the same service token, for the aliases and exact ids a person may pick. Cached for
+ // about five minutes, failures included: a failed read is today's behaviour for the next few minutes, never a
+ // broken page or a retry storm.
+ let aiCatalogCache = null; // { at, entries } — entries null when the flag is on but the read failed/legacy
+ let aiCatalogInFlight = null;
+ const aiCatalogOn = () => Boolean(ai.catalog); // D11 owner settings: read live
+
+ /** The catalog's entries (aliases first, then exact ids), or null: today's model field, unchanged. */
+ async function aiCatalog() {
+ if (!aiCatalogOn()) return null;
+ const at = now();
+ if (aiCatalogCache && at - aiCatalogCache.at < aiCatalogLib.CACHE_MS) return aiCatalogCache.entries;
+ if (aiCatalogInFlight) return aiCatalogInFlight;
+ aiCatalogInFlight = (async () => {
+ let entries = null;
+ const tokens = aiTokenClient();
+ if (tokens && ai.url) {
+ try {
+ const res = await fetchImpl(trimSlash(ai.url) + '/v1/models', {
+ headers: { accept: 'application/json', ...(await tokens.authHeaders()) },
+ signal: AbortSignal.timeout(Math.max(500, cfg.listTimeoutMs || 5000)),
+ });
+ if (res.ok) entries = aiCatalogLib.parse(await res.json());
+ } catch (err) {
+ if (log && log.warn) log.warn('[Actor] OpenVibe.AI model catalog:', (err && err.message) || err);
+ }
+ }
+ aiCatalogCache = { at: now(), entries };
+ return entries;
+ })().finally(() => { aiCatalogInFlight = null; });
+ return aiCatalogInFlight;
+ }
+
/** Run the rest of the work with this person's model choice in force. */
/** Run the rest of the work with this person's model choice in force. */
function withChoice(choice, fn) {
function withChoice(choice, fn) {
return choiceStore.run(choice || null, fn);
return choiceStore.run(choice || null, fn);
@@ -356,6 +392,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -356,6 +392,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
const said = String(msg || '').trim();
const said = String(msg || '').trim();
throw new ModelError(said ? said.charAt(0).toUpperCase() + said.slice(1) : 'This service may not use your key yet', { code: 'actor.model.not_allowed' });
throw new ModelError(said ? said.charAt(0).toUpperCase() + said.slice(1) : 'This service may not use your key yet', { code: 'actor.model.not_allowed' });
}
}
+ // An exact id AI refuses (PLAN G-P9): shown as that refusal, never retried on another model. AI's own
+ // message says why (no such artifact, or one that cannot run this request).
+ if ((res.status === 404 && code === 'ai.model_not_found') || (res.status === 400 && code === 'ai.model_unavailable')) {
+ throw new ModelError(clip(msg, 300), { code });
+ }
throw new ProviderError('HTTP ' + res.status + ': ' + clip(msg, 300), { status: res.status, code: res.status === 429 ? 'actor.provider.rate_limited' : 'actor.provider.error' });
throw new ProviderError('HTTP ' + res.status + ': ' + clip(msg, 300), { status: res.status, code: res.status === 429 ? 'actor.provider.rate_limited' : 'actor.provider.error' });
}
}
if (onDelta) answer = await readStream(res, onDelta, { signal, who: 'OpenVibe.AI' });
if (onDelta) answer = await readStream(res, onDelta, { signal, who: 'OpenVibe.AI' });
@@ -364,13 +405,18 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -364,13 +405,18 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
const message = first.message || { role: 'assistant', content: '' };
const message = first.message || { role: 'assistant', content: '' };
const u = answer.usage || {};
const u = answer.usage || {};
const usage = { input: u.prompt_tokens || 0, cached: 0, output: u.completion_tokens || 0 };
const usage = { input: u.prompt_tokens || 0, cached: 0, output: u.completion_tokens || 0 };
- const x = answer.x_openvibe || {};
+ // The run disclosure (D3', PLAN G-P9): x_openvibe already carries the cost; resolved_model names the artifact
+ // that answered, so this is the model every existing model/cost record and page shows. Absent (the catalog is
+ // off on AI), the upstream model name is kept exactly as before.
+ const x = answer.x_openvibe && typeof answer.x_openvibe === 'object' ? answer.x_openvibe : {};
+ const resolved = typeof x.resolved_model === 'string' && x.resolved_model.trim() ? x.resolved_model.trim() : null;
return {
return {
message,
message,
text: typeof message.content === 'string' ? message.content : '',
text: typeof message.content === 'string' ? message.content : '',
toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : [],
toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : [],
finish: first.finish_reason || null,
finish: first.finish_reason || null,
- model: answer.model || choice.model || 'openvibe',
+ model: resolved || answer.model || choice.model || 'openvibe',
+ resolved_model: resolved,
usage,
usage,
// AI prices the call (it knows the provider, and whether the person's own key paid for it).
// AI prices the call (it knows the provider, and whether the person's own key paid for it).
cost_usd: Number(x.cost_usd) || 0,
cost_usd: Number(x.cost_usd) || 0,
@@ -428,7 +474,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -428,7 +474,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
}
}
return {
return {
- warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys,
+ warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys, aiCatalog,
tierConfig: cfg, PREFERRED, PREFERRED_CHECKED, TIERS,
tierConfig: cfg, PREFERRED, PREFERRED_CHECKED, TIERS,
get warmed() { return warmed; },
get warmed() { return warmed; },
listErrors: () => ({ ...listErrors }),
listErrors: () => ({ ...listErrors }),
@@ -17,13 +17,15 @@
@@ -17,13 +17,15 @@
*/
*/
const KINDS = new Set(['model', 'tool', 'check', 'judge', 'delegate']);
const KINDS = new Set(['model', 'tool', 'check', 'judge', 'delegate']);
-const MAX_NAME = 120;
+// 256 is OpenVibe.AI's own limit for a requested model, so an exact id (org/model[:quant]@revision) is kept whole.
+const MAX_NAME = 256;
const MAX_SHORT = 64;
const MAX_SHORT = 64;
const clip = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? t.slice(0, n) : t; };
const clip = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? t.slice(0, n) : t; };
// A name is an identifier (a model id or a tool name), never a sentence: anything that is not identifier-shaped becomes
// A name is an identifier (a model id or a tool name), never a sentence: anything that is not identifier-shaped becomes
-// an underscore, so a provider echoing a prompt in its model id cannot put the person's words in a span.
-const safeName = (s) => clip(String(s == null ? '' : s).replace(/[^a-zA-Z0-9._:/-]+/g, '_'), MAX_NAME);
+// an underscore, so a provider echoing a prompt in its model id cannot put the person's words in a span. `@` is the
+// revision mark of an exact AI Exchange id (D3), so it is kept as itself.
+const safeName = (s) => clip(String(s == null ? '' : s).replace(/[^a-zA-Z0-9._:@/-]+/g, '_'), MAX_NAME);
const short = (v, n = MAX_SHORT) => (typeof v === 'string' && /^[a-zA-Z0-9_.:-]+$/.test(v) ? v.slice(0, n) : null);
const short = (v, n = MAX_SHORT) => (typeof v === 'string' && /^[a-zA-Z0-9_.:-]+$/.test(v) ? v.slice(0, n) : null);
const intOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : null));
const intOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : null));
@@ -397,7 +397,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -397,7 +397,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
: err && err.code === 'actor.budget.exceeded' ? 'actor.budget.exceeded'
: err && err.code === 'actor.budget.exceeded' ? 'actor.budget.exceeded'
: err && err.code === 'actor.checkpoint.invalid' ? 'actor.checkpoint.invalid'
: err && err.code === 'actor.checkpoint.invalid' ? 'actor.checkpoint.invalid'
: err && err.code === 'actor.acting.unresolved' ? 'actor.acting.unresolved'
: err && err.code === 'actor.acting.unresolved' ? 'actor.acting.unresolved'
- : 'actor.agent.failed';
+ // An exact AI Exchange id AI refused (PLAN G-P9): the person sees AI's own error.
+ : err && (err.code === 'ai.model_not_found' || err.code === 'ai.model_unavailable') ? err.code
+ : 'actor.agent.failed';
return await setState(id, 'failed', { error_code: code, error_detail: detail.slice(0, 2000), finished_at: iso() });
return await setState(id, 'failed', { error_code: code, error_detail: detail.slice(0, 2000), finished_at: iso() });
}
}
try {
try {
@@ -20,6 +20,7 @@ const { testDb } = require('./helpers/db');
@@ -20,6 +20,7 @@ const { testDb } = require('./helpers/db');
const EXEMPT = {
const EXEMPT = {
account_data_events: 'the ledger of requests itself: which export and deletion Actor applied',
account_data_events: 'the ledger of requests itself: which export and deletion Actor applied',
actor_brain_outcomes: 'counts of outcomes by task kind, tier and tool: no person column and no task text',
actor_brain_outcomes: 'counts of outcomes by task kind, tier and tool: no person column and no task text',
+ config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',
};
};
const A = 'usr_01JZ0000000000000000000AAA';
const A = 'usr_01JZ0000000000000000000AAA';
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -0,0 +1,275 @@
+'use strict';
+/**
+ * PLAN G-P9: Actor uses OpenVibe.AI's catalog through what it already has, behind ACTOR_AI_CATALOG (off).
+ *
+ * What is proved here:
+ * the catalog parses aliases first and then exact ids, with the output price exact in nano-USD → dollars;
+ * the flag off, a failed read, and AI's legacy single `openvibe` all leave today's model field/behaviour unchanged;
+ * an exact id is sent unchanged as `model` (never rewritten or substituted);
+ * x_openvibe.resolved_model and the cost are the model and cost the run records and shows;
+ * an exact id AI refuses is that refusal, with one call and no retry on another model.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createModels } = require('../server/runtime/models');
+const aiCatalog = require('../server/runtime/ai-catalog');
+require('../server/tasks/engine'); // the end-to-end check below: an exact id AI refuses becomes the task's error
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const PERSON = 'usr_01JZ0000000000000000000AAA';
+const EXACT = 'acme/m1:q4_k_m@rev1';
+const LOG = { log() {}, warn() {}, error() {} };
+const reply = (status, obj, headers = {}) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ headers: new Headers({ 'content-type': 'application/json', ...headers }),
+ json: async () => obj,
+ text: async () => JSON.stringify(obj),
+});
+
+/** A config with OpenVibe.AI (and no operator providers). */
+function aiConfig(catalog = true) {
+ return {
+ models: { tiers: {}, providerOrder: [], listTimeoutMs: 200 },
+ providers: {},
+ ai: { url: 'https://ai.test', audience: 'openvibe.ai', scope: 'ai.run.create', clientId: 'actor', clientSecret: 'actor-secret', timeoutMs: 5000, catalog },
+ networkInternalUrl: 'https://network.test', networkUrl: 'https://openvibe.network',
+ oauth: { clientId: 'actor', clientSecret: 'actor-secret' },
+ };
+}
+
+/** The token endpoint, then whatever the test wants for GET /v1/models and POST /v1/chat/completions. */
+function fakeFetch({ models = null, chat = null } = {}) {
+ const calls = [];
+ const fetchImpl = async (url, opts = {}) => {
+ const u = String(url);
+ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_service_token', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });
+ const call = { url: u, method: opts.method || 'GET', headers: opts.headers || {}, body: opts.body ? JSON.parse(opts.body) : null };
+ calls.push(call);
+ if (u.endsWith('/v1/models')) return models(call);
+ return chat(call);
+ };
+ return { calls, fetchImpl };
+}
+
+// The ai.model@1 catalog as AI serves it (OpenVibe.AI #289): aliases carry dynamic:true and no price; a registry
+// model carries an exact id, its availability and integer nano-USD prices. The legacy `openvibe` is the alias name.
+const CATALOG = {
+ object: 'list',
+ data: [
+ { id: 'acme/m1:q4_k_m@rev1', object: 'model', dynamic: false, display_name: 'm1', availability: 'available', retail_rate: { input_nano_usd_per_m: '1000000000', output_nano_usd_per_m: '2500000000' }, capabilities: { tools: true } },
+ { id: 'openvibe/fast', object: 'model', dynamic: true, policy: 'fastest', display_name: 'Fast', availability: 'available' },
+ { id: 'acme/no-revision', object: 'model', dynamic: false, display_name: 'no exact id' },
+ { id: 'openvibe', object: 'model', dynamic: true, display_name: 'OpenVibe AI' },
+ { id: 'openvibe/auto', object: 'model', dynamic: true, policy: 'auto', display_name: 'Auto', availability: 'available' },
+ { id: 'openvibe/private', object: 'model', dynamic: true, policy: 'private', display_name: 'Private', availability: 'available' },
+ { id: 'acme/m2@rev2', object: 'model', dynamic: false, display_name: 'm2', availability: 'unavailable', retail_rate: { output_nano_usd_per_m: '123456789' } },
+ ],
+};
+
+/** The OpenAI chat answer AI gives, with the route disclosure in x_openvibe. */
+function answer({ status = 200, resolved = EXACT, cost = 0.0012, body = null } = {}) {
+ if (body) return reply(status, body);
+ return reply(status, {
+ model: 'm1',
+ choices: [{ index: 0, message: { role: 'assistant', content: 'hello' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
+ x_openvibe: { resolved_model: resolved, revision: 'rev1', provider_access: 'OWN_DEPLOYMENT', rate_version: 'rr-1', policy: 'exact', fallback_reason: null, cost_usd: cost },
+ });
+}
+
+(async () => {
+ let t = null;
+ try {
+ await check('the catalog parses aliases first, then exact ids, with the output price exact', async () => {
+ const entries = aiCatalog.parse(CATALOG);
+ assert.ok(entries, 'a catalog with aliases and exact ids is usable');
+ assert.deepStrictEqual(entries.map((e) => e.id), ['openvibe/auto', 'openvibe/fast', 'openvibe/private', 'acme/m1:q4_k_m@rev1', 'acme/m2@rev2'], 'aliases in D3 order, then exact ids; legacy openvibe and a non-exact id are left out');
+ assert.deepStrictEqual([entries[0].dynamic, entries[3].dynamic], [true, false]);
+ assert.deepStrictEqual(entries.slice(0, 3).map((e) => e.output_nano_usd_per_m), [null, null, null], 'an alias has no price of its own');
+ assert.strictEqual(aiCatalog.priceDescription(entries[3].output_nano_usd_per_m), '$2.50 per million output tokens');
+ assert.strictEqual(aiCatalog.priceDescription(entries[4].output_nano_usd_per_m), '$0.123456789 per million output tokens', 'nano-USD to dollars without a float');
+ assert.strictEqual(aiCatalog.priceDescription('0'), '$0.00 per million output tokens');
+ assert.strictEqual(aiCatalog.priceDescription('1000000000000'), '$1000.00 per million output tokens');
+ assert.strictEqual(aiCatalog.priceDescription(null), 'Output price not listed');
+ assert.deepStrictEqual(entries.map((e) => e.state), ['ok', 'ok', 'ok', 'ok', 'off'], 'availability as the select state');
+ assert.strictEqual(entries[4].available, false, 'an unavailable exact id is not a choice');
+
+ // The flag-off AI answer (one legacy model), an empty answer and rubbish are today's behaviour.
+ assert.strictEqual(aiCatalog.parse({ object: 'list', data: [{ id: 'openvibe', object: 'model' }] }), null);
+ assert.strictEqual(aiCatalog.parse({ object: 'list', data: [] }), null);
+ assert.strictEqual(aiCatalog.parse(null), null);
+ assert.strictEqual(aiCatalog.parse({ object: 'list', data: 'nope' }), null);
+ });
+
+ await check('flag off: no catalog call, and the page keeps the plain model field', async () => {
+ const offline = fakeFetch({ models: () => { throw new Error('the catalog must not be read'); } });
+ const models = createModels({ config: aiConfig(false), fetchImpl: offline.fetchImpl, log: LOG });
+ assert.strictEqual(await models.aiCatalog(), null);
+ assert.strictEqual(offline.calls.length, 0, 'no GET /v1/models and no token while the flag is off');
+
+ t = await boot();
+ const alice = t.network.addUser('catalog-off');
+ const page = await t.get('/chat/model', { as: alice });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<input name="model" value="" maxlength="120"/);
+ assert.ok(!page.text.includes('<select name="model"'), 'no catalog select without the flag');
+ });
+
+ await check('flag on: GET /v1/models is read with Actor\'s service token and cached about five minutes', async () => {
+ let clock = 1_000_000;
+ const f = fakeFetch({ models: () => reply(200, CATALOG) });
+ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG, now: () => clock });
+ const first = await models.aiCatalog();
+ assert.deepStrictEqual(first.map((e) => e.id).slice(0, 3), ['openvibe/auto', 'openvibe/fast', 'openvibe/private']);
+ assert.strictEqual(f.calls.length, 1);
+ assert.strictEqual(f.calls[0].url, 'https://ai.test/v1/models');
+ assert.strictEqual(f.calls[0].headers.Authorization, 'Bearer tok_service_token', 'the token Actor already uses');
+ assert.ok(!JSON.stringify(f.calls[0]).includes('actor-secret'), 'the client secret never travels');
+ const second = await models.aiCatalog();
+ assert.deepStrictEqual(second, first);
+ assert.strictEqual(f.calls.length, 1, 'a second read is cached');
+ clock += aiCatalog.CACHE_MS + 1;
+ await models.aiCatalog();
+ assert.strictEqual(f.calls.length, 2, 'after five minutes it is read again');
+ });
+
+ await check('flag on, AI catalog off or a failed read: null, and the next use is today\'s behaviour', async () => {
+ const legacy = fakeFetch({ models: () => reply(200, { object: 'list', data: [{ id: 'openvibe', object: 'model' }] }) });
+ const a = createModels({ config: aiConfig(true), fetchImpl: legacy.fetchImpl, log: LOG });
+ assert.strictEqual(await a.aiCatalog(), null, 'the legacy single model means no picker');
+
+ const down = fakeFetch({ models: () => reply(503, { error: { message: 'down' } }) });
+ const b = createModels({ config: aiConfig(true), fetchImpl: down.fetchImpl, log: LOG });
+ assert.strictEqual(await b.aiCatalog(), null);
+ assert.strictEqual(await b.aiCatalog(), null, 'a failure is cached too, not retried on every page');
+ assert.strictEqual(down.calls.length, 1);
+ });
+
+ await check('an exact id is sent unchanged as model, and its disclosure is the run\'s model and cost', async () => {
+ const f = fakeFetch({ chat: () => answer() });
+ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });
+ const r = await models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({
+ tier: 'smart', messages: [{ role: 'user', content: 'hello' }],
+ }));
+ assert.strictEqual(f.calls.length, 1, 'one call to AI');
+ assert.strictEqual(f.calls[0].body.model, EXACT, 'the exact id is sent unchanged');
+ assert.strictEqual(f.calls[0].body.prefer.model, EXACT, 'the soft preference names the same id');
+ assert.strictEqual(f.calls[0].headers['x-openvibe-credential'], 'openai:' + PERSON);
+ assert.strictEqual(r.model, EXACT, 'the model the run records is x_openvibe.resolved_model');
+ assert.strictEqual(r.resolved_model, EXACT);
+ assert.strictEqual(r.cost_usd, 0.0012, 'the cost is x_openvibe.cost_usd');
+ assert.strictEqual(r.text, 'hello');
+ // The same id is what the pages show and the trace records, not split at the quant or sanitised.
+ const { modelName } = require('../server/http/pages');
+ const { safeName } = require('../server/runtime/trace');
+ assert.strictEqual(modelName(EXACT), EXACT);
+ assert.strictEqual(safeName(EXACT), EXACT);
+
+ // A dynamic alias is likewise sent unchanged.
+ const g = fakeFetch({ chat: () => answer({ resolved: 'acme/m1@rev1' }) });
+ const models2 = createModels({ config: aiConfig(true), fetchImpl: g.fetchImpl, log: LOG });
+ const a = await models2.withChoice({ subject: PERSON, provider: 'openai', model: 'openvibe/economy' }, () => models2.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hi' }] }));
+ assert.strictEqual(g.calls[0].body.model, 'openvibe/economy');
+ assert.strictEqual(a.model, 'acme/m1@rev1', 'the alias says which model answered');
+ });
+
+ await check('a streamed answer records the final x_openvibe too', async () => {
+ const chunk = (o) => `data: ${JSON.stringify(o)}\n\n`;
+ const sse = chunk({ model: 'm1', choices: [{ index: 0, delta: { role: 'assistant', content: 'hel' }, finish_reason: null }] })
+ + chunk({ model: 'm1', choices: [{ index: 0, delta: { content: 'lo' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 5 } })
+ + chunk({ model: 'm1', choices: [], x_openvibe: { resolved_model: EXACT, cost_usd: 0.003 } });
+ const f = fakeFetch({ chat: () => new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) });
+ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });
+ const r = await models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }], onDelta: () => {} }));
+ assert.strictEqual(r.model, EXACT);
+ assert.strictEqual(r.cost_usd, 0.003);
+ });
+
+ await check('an exact id AI refuses is that error, one call, never another model', async () => {
+ const cases = [
+ [404, 'ai.model_not_found', 'no artifact ' + EXACT, 'No artifact'],
+ [400, 'ai.model_unavailable', 'the artifact ' + EXACT + ' has no active deployment', 'no active deployment'],
+ ];
+ for (const [status, code, message] of cases) {
+ const f = fakeFetch({ chat: () => reply(status, { error: { message, type: 'invalid_request_error', code, param: null } }) });
+ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });
+ await assert.rejects(
+ () => models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }] })),
+ (err) => {
+ assert.strictEqual(err.code, code, 'AI\'s own code is shown');
+ assert.strictEqual(err.message, message, 'AI\'s own detail is shown');
+ return true;
+ },
+ );
+ assert.strictEqual(f.calls.length, 1, `${code}: no retry on another model`);
+ }
+ });
+
+ await check('a refused exact id fails the person\'s task with AI\'s error, never another model', async () => {
+ const aiCalls = [];
+ const f = fakeFetch({
+ models: () => reply(200, { data: [{ id: 'gpt-5-mini' }] }),
+ chat: (call) => {
+ aiCalls.push(call);
+ return reply(404, { error: { message: 'no artifact ' + EXACT, type: 'invalid_request_error', code: 'ai.model_not_found', param: null } });
+ },
+ });
+ // The operator's own tier must resolve for the loop to start; this checks the person's AI choice does win.
+ const cfg = aiConfig(true);
+ cfg.providers = { openai: { apiKey: 'sk-fixture-operator-key', baseUrl: 'https://api.openai.test/v1', model: 'gpt-5-mini' } };
+ const models = createModels({ config: cfg, fetchImpl: f.fetchImpl, log: LOG });
+ const tb = await boot({ env: { ACTOR_AI_CATALOG: '1' }, models });
+ try {
+ const user = tb.network.addUser('catalog-refused');
+ const put = await tb.get('/api/v1/me/model', { as: user, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });
+ assert.strictEqual(put.status, 200, put.text);
+ const read = await tb.get('/api/v1/me/model', { as: user });
+ assert.deepStrictEqual([read.json().choice, read.json().model], ['own:openai', EXACT], read.text);
+ const made = await tb.get('/api/v1/tasks', { as: user, json: { task: 'Say hello', mode: 'balanced' }, headers: SAME });
+ assert.strictEqual(made.status, 201, made.text);
+ const row = await tb.waitFor(made.json().id);
+ assert.strictEqual(row.state, 'failed');
+ assert.strictEqual(row.error_code, 'ai.model_not_found', `the task shows AI's own error code (${row.error_code}: ${row.error_detail})`);
+ assert.strictEqual(row.error_detail, 'no artifact ' + EXACT, 'and AI\'s own detail');
+ const chats = aiCalls.filter((c) => c.url.endsWith('/v1/chat/completions'));
+ assert.ok(chats.length >= 1, 'the run asked OpenVibe.AI');
+ assert.ok(chats.every((c) => c.body.model === EXACT), 'every call names the exact id; nothing is substituted');
+ } finally { await tb.close(); }
+ });
+
+ await check('the page offers aliases first, then exact ids, priced and available; a saved exact id is kept', async () => {
+ const entries = aiCatalog.parse(CATALOG);
+ const stub = { warm: async () => ({}), aiCatalog: async () => entries };
+ const tb = await boot({ env: { ACTOR_AI_CATALOG: '1' }, models: stub });
+ try {
+ const alice = tb.network.addUser('catalog-on');
+ const page = await tb.get('/chat/model', { as: alice });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<select name="model" data-ov-title="A model for OpenVibe.AI">/);
+ assert.ok(page.text.indexOf('openvibe/auto') < page.text.indexOf(EXACT), 'aliases come before exact ids');
+ assert.match(page.text, /<option value="openvibe\/auto"[^>]*data-badge="Dynamic"[^>]*data-desc="Dynamic: the router picks/);
+ assert.match(page.text, new RegExp(`<option value="${EXACT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*data-desc="\\$2.50 per million output tokens"[^>]*data-state="ok"`));
+ assert.match(page.text, /<option value="acme\/m2@rev2"[^>]*data-desc="\$0.123456789 per million output tokens"[^>]*data-state="off" disabled>/, 'unavailable is shown as off and cannot be chosen');
+
+ const put = await tb.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual(put.json().model, EXACT, 'the exact id is stored unchanged');
+ const after = await tb.get('/chat/model', { as: alice });
+ assert.match(after.text, new RegExp(`<option value="${EXACT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}" selected`));
+ } finally { await tb.close(); }
+ });
+
+ await check('flag off: an exact id is still refused by today\'s model rule', async () => {
+ t = t || await boot();
+ const alice = t.network.addUser('catalog-off-exact');
+ const put = await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });
+ assert.strictEqual(put.status, 422);
+ assert.strictEqual(put.json().code, 'actor.model.invalid');
+ });
+ } finally {
+ if (t) await t.close();
+ }
+
+ done();
+})();
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
-const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage'];
+const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
@@ -97,6 +97,7 @@ function codeRoutes() {
@@ -97,6 +97,7 @@ function codeRoutes() {
await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
const declared = new Set();
const declared = new Set();
for (const id of CAPS) {
for (const id of CAPS) {
+ if (id === 'actor.config.manage') continue; // /internal/config, openvibe-shared/config's adminRoutes
const impl = contracts.capabilities.get(id).implementedBy;
const impl = contracts.capabilities.get(id).implementedBy;
// A planned resource capability lists no routes; an active one lists exactly the index's two.
// A planned resource capability lists no routes; an active one lists exactly the index's two.
const expected = id === 'actor.resource.read' ? INDEX_ROUTES : impl;
const expected = id === 'actor.resource.read' ? INDEX_ROUTES : impl;
@@ -116,6 +117,7 @@ function codeRoutes() {
@@ -116,6 +117,7 @@ function codeRoutes() {
await check('each guarded route requires the capability that lists it; the agent reads are public', () => {
await check('each guarded route requires the capability that lists it; the agent reads are public', () => {
for (const id of CAPS) {
for (const id of CAPS) {
+ if (id === 'actor.config.manage') continue; // /internal/config is guarded by capabilityGuard, not a route in this map
const implemented = id === 'actor.resource.read' ? INDEX_ROUTES : contracts.capabilities.get(id).implementedBy;
const implemented = id === 'actor.resource.read' ? INDEX_ROUTES : contracts.capabilities.get(id).implementedBy;
for (const r of implemented) {
for (const r of implemented) {
const route = routes.get(r);
const route = routes.get(r);
@@ -134,7 +134,7 @@ function minimalState(task, calls = 0) {
@@ -134,7 +134,7 @@ function minimalState(task, calls = 0) {
assert.strictEqual(parts[0].content[0].task_id, id);
assert.strictEqual(parts[0].content[0].task_id, id);
assert.strictEqual(parts[0].content[0].step, 1);
assert.strictEqual(parts[0].content[0].step, 1);
assert.ok(!('state_enc' in parts[0].content[0]), 'the sealed state is not exported');
assert.ok(!('state_enc' in parts[0].content[0]), 'the sealed state is not exported');
- assert.deepStrictEqual(await uncovered(d.db, { tables: accountData.TABLES, exempt: { account_data_events: 'the ledger of requests itself', actor_brain_outcomes: 'counts only' } }), [], 'the guard passes');
+ assert.deepStrictEqual(await uncovered(d.db, { tables: accountData.TABLES, exempt: { account_data_events: 'the ledger of requests itself', actor_brain_outcomes: 'counts only', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data' } }), [], 'the guard passes');
await s.db.query('DELETE FROM tasks WHERE id = $1', [id]);
await s.db.query('DELETE FROM tasks WHERE id = $1', [id]);
assert.strictEqual(await cp.count(id), 0, 'erased with the task');
assert.strictEqual(await cp.count(id), 0, 'erased with the task');
} finally { await d.close(); }
} finally { await d.close(); }
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects, notifier: opts.notifier, models: opts.models, registry: opts.registry, loop: opts.loop, connectors: opts.connectors, guard: opts.guard });
+ const built = await createApp({ config, env, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects, notifier: opts.notifier, models: opts.models, registry: opts.registry, loop: opts.loop, connectors: opts.connectors, guard: opts.guard });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * D11 owner settings: the actor.ai namespace and /internal/config.
+ * Env pins win; actor.config.manage is required; an owner change applies live; no KEK still works;
+ * a secret-class value never leaves a summary, snapshot or history.
+ */
+const assert = require('assert');
+const { serviceAuth } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+const CAP = ['actor.config.manage'];
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const SECRET = 'fixture-actor-d11-not-a-credential';
+
+function mint(t, cap = CAP) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.actor'], cap,
+ iat: now, exp: now + 300, jti: 'tok_actor_config',
+ }, t.network.privatePem);
+}
+
+(async () => {
+ const t = await boot({ env: { ACTOR_AI_CATALOG: '1' } });
+ try {
+ await check('the summary: schema, the env pin, sealed:false without a KEK', async () => {
+ const r = await t.get('/internal/config/actor.ai', { bearer: mint(t) });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().namespace, 'actor.ai');
+ assert.strictEqual(r.json().sealed, false);
+ assert.strictEqual(r.json().values.catalog, true);
+ assert.deepStrictEqual(r.json().pins.catalog, { env: 'ACTOR_AI_CATALOG', pinned: true });
+ assert.ok(!r.text.includes(SECRET));
+ });
+
+ await check('a service token without actor.config.manage is refused', async () => {
+ const r = await t.get('/internal/config/actor.ai', { bearer: mint(t, ['actor.task.read']) });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ });
+
+ await check('an env-pinned key is read-only', async () => {
+ const r = await t.get('/internal/config/actor.ai', {
+ method: 'POST', bearer: mint(t), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: false }, reason: 'off' },
+ });
+ assert.strictEqual(r.status, 409, r.text.slice(0, 300));
+ assert.strictEqual(r.json().code, 'config.pinned_in_env');
+ });
+ } finally { await t.close(); }
+
+ const live = await boot();
+ try {
+ await check('an owner change applies live', async () => {
+ assert.strictEqual(live.config.ai.catalog, false);
+ const r = await live.get('/internal/config/actor.ai', {
+ method: 'POST', bearer: mint(live), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: true }, reason: 'owner turned it on' },
+ });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().revision, 2);
+ assert.strictEqual(live.config.ai.catalog, true);
+ assert.ok(live.logs().includes('by user:' + OWNER));
+ assert.ok(!live.logs().includes(SECRET));
+ });
+
+ await check('with a KEK the store is sealed and boot reseals', async () => {
+ const sealed = await boot({ env: { OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });
+ try {
+ const r = await sealed.get('/internal/config/actor.ai', { bearer: mint(sealed) });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.strictEqual(r.json().sealed, true);
+ assert.strictEqual(r.json().kek, 'k1');
+ } finally { await sealed.close(); }
+ });
+
+ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {
+ const shared = require('openvibe-shared/config');
+ const extra = await shared.createConfigStore({
+ db: live.ctx.s.db, service: 'actor', namespace: 'actor.test_secrets',
+ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },
+ classify: () => 'secret',
+ });
+ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });
+ assert.strictEqual(marker.redacted, true);
+ assert.ok(!JSON.stringify(marker).includes(SECRET));
+ const summary = await extra.summary();
+ assert.strictEqual(summary.values.api_key.redacted, true);
+ assert.ok(!JSON.stringify(summary).includes(SECRET));
+ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));
+ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));
+ });
+ } finally { await live.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
const second = await db.migrate({ dir: MIGRATIONS });
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027']);
+ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028']);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);