openvibers/openvibe.actor Commits

A person chooses the model their tasks run on: their own key or OpenVibe's (plan L55 C3) (#231)

OP OpenVibers committed 12 days ago · 4440de6ba2 · 22 files, +702 −25

* A person chooses the model their tasks run on: their own key or OpenVibe's (plan L55 C3).

Actor holds no provider key for this. The choice is the person's own row (migration 0025, exported and erased with them), read at GET/PUT /api/v1/me/model, and the run carries it: every model call of that run (the loop's, the plan's, the check's) goes to OpenVibe.AI with x-openvibe-credential: <provider>:<usr_…>, and AI answers with the person's own key or refuses, which becomes a plain error saying where to add the key. 'auto' keeps today's behaviour.

* The person's own model choice, on its own capability, and the keys it may use (plan L55 C3).

GET/PUT /api/v1/me/model now sits on actor.model.manage (OpenVibe.Contracts PR #278, v0.269.0: the pin follows its release), not on actor.agent.manage. The answer fills keys with the providers the person allowed this service to use their own key for, and that they hold a key for: names only, from OpenVibe.AI. A run whose person did not allow this service now fails with the plain message OpenVibe.AI sends, which names the page where they allow it. The run's choice rides with the models layer only when it is the real one, so a stand-in models object keeps today's behaviour.

* openvibe-contracts v0.269.0 (was v0.254.0): actor.model.manage

The pin this PR waited for. The contracts test reads PUT routes too and lists the new capability; the caller
budgets pin actor.model.manage (a setting: 30 a minute, 600 an hour).

* Chat: the model chip, and /chat/model

Plan L55 C3, the UI (the owner, 11-01: "use your own API keys, or OpenVibe's"). The composer has a chip saying what
the person's tasks run on (Auto, OpenVibe's, or Your <provider> key); its menu saves in one tap (PUT
/api/v1/me/model), shows which of their keys are ready (names only, from OpenVibe.AI), and links each other provider
to OpenVibe.AI to add the key and allow Actor. Chosen but not allowed yet, it says what is left instead of letting the
next task fail. /chat/model is the same as a plain form, with an optional model id, saved from openvibe.actor only.

* test: the chat list counts chats, not the model chip's settings link

Changes

ddiff --git a/README.md b/README.md
iindex e497046..55643f0 100644
---- a/README.md
++++ b/README.md
@@@ -254,7 +254,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.254.0
++- openvibe-contracts: v0.269.0
- openvibe-sdk: v0.74.0
- openvibe-shared: v4.73.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3b555ff..fd3a6dd 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"domain": "openvibe.actor",
-- "contracts": "openvibe-contracts v0.254.0",
++ "contracts": "openvibe-contracts v0.269.0",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.73.0",
"packages": [
-- "openvibe-contracts v0.254.0",
++ "openvibe-contracts v0.269.0",
"openvibe-sdk v0.74.0",
"openvibe-shared v4.73.0"
],
ddiff --git a/migrations/0025_actor_model_prefs.sql b/migrations/0025_actor_model_prefs.sql
nnew file mode 100644
iindex 0000000..8e0596c
---- /dev/null
++++ b/migrations/0025_actor_model_prefs.sql
@@@ -0,0 +1,15 @@
++-- phase: expand
++-- Plan L55 C3: the model a person chose for their tasks (the owner, 2026-11-01: "use your own API keys, or
++-- OpenVibe's… choosing the model per chat"). server/runtime/model-prefs.js.
++--
++-- requester is the person the choice belongs to (user:usr_...), as tasks.requester, so the row is exported and
++-- deleted with the person (server/identity/account-data.js). choice is 'auto' (Actor decides, as today), 'openvibe'
++-- (OpenVibe's own keys, through OpenVibe.AI) or 'own:<provider>' (the person's own key for that provider, which
++-- lives sealed in OpenVibe.AI's vault and never in Actor). model is the model id they picked, or nothing:
++-- OpenVibe.AI then routes the call. Nothing here is a secret: no key, token or credential is stored.
++CREATE TABLE actor_model_prefs (
++ requester text COLLATE "C" PRIMARY KEY,
++ choice text COLLATE "C" NOT NULL CHECK (choice IN ('auto', 'openvibe', 'own:openai', 'own:anthropic', 'own:deepseek', 'own:google', 'own:openrouter', 'own:mistral')),
++ model text COLLATE "C",
++ updated_at text COLLATE "C" NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 1b8d2d0..f628d74 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"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.254.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.73.0",
"pg": "^8.23.0"
@@@ -1146,9 +1146,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.254.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.254.0",
-- "integrity": "sha512-C+OWG9+CrUQ6lcYvY+cWSIu+SI0THIUpA+7zM4xq+S44RG1J3sS5U4apUViFElfKlTV4nFrPqaA4DVBBs7I3Wg==",
++ "version": "0.269.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
++ "integrity": "sha512-OCdHszaBv7sV+82JgGfJy6XeO6s2ZPpvLTZJoYihnfYghX6QFoy6PCu2efoWoBHYOdhAL9kJmSLo4kVTE9zQ9A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 4a0235f..9fb7c03 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"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.254.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.73.0",
"pg": "^8.23.0"
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 1b2ae6f..3adc4ba 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -242,3 +242,51 @@ html[data-ov-motion=reduced] .ct-answer.is-streaming .sw { opacity: 1 !important
.ct-file span { display: grid; line-height: 1.25; }
.ct-file b { font-size: 13.5px; font-weight: 600; }
.ct-file small { font-size: 12px; color: var(--text-muted, #7386a3); }
++
++/* The model chip (plan L55 C3): what your tasks run on, beside the mode. Its menu opens above the composer. */
++.cc-model { position: relative; }
++.cc-model > summary { list-style: none; }
++.cc-model > summary::-webkit-details-marker { display: none; }
++.cc-model-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--text-secondary, #a8b3c7); font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; max-width: 46vw; }
++.cc-model-chip span { overflow: hidden; text-overflow: ellipsis; }
++.cc-model-chip svg { flex: none; }
++.cc-model[open] > .cc-model-chip, .cc-model-chip:hover { color: var(--text-primary, #eef2f8); border-color: color-mix(in srgb, var(--accent, #4f7cff) 45%, var(--line)); }
++.cc-model-chip:focus-visible { outline: 2px solid var(--accent, #4f7cff); outline-offset: 2px; }
++.ccm-panel { position: absolute; left: 0; bottom: calc(100% + 10px); z-index: 30; width: min(330px, calc(100vw - 32px)); max-height: min(70vh, 520px); overflow-y: auto; overscroll-behavior: contain; padding: 8px; border-radius: 16px; border: 1px solid var(--line); background: var(--bg-elevated, #121826); box-shadow: 0 18px 48px rgba(0, 0, 0, .45); }
++.ccm-head { margin: 10px 8px 4px; color: var(--text-muted, #7386a3); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
++.ccm-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
++.ccm-own { display: flex; align-items: center; gap: 6px; }
++.ccm-own > .ccm-item { flex: 1; min-width: 0; }
++.ccm-item { width: 100%; display: grid; gap: 2px; text-align: left; padding: 9px 10px 9px 34px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary, #eef2f8); font: inherit; cursor: pointer; position: relative; }
++.ccm-item b { font-size: 14px; font-weight: 650; }
++.ccm-item small { color: var(--text-muted, #7386a3); font-size: 12px; line-height: 1.35; }
++.ccm-item::before { content: ''; position: absolute; left: 11px; top: 12px; width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--line); box-sizing: border-box; }
++.ccm-item.is-on::before { border-color: var(--accent, #4f7cff); background: radial-gradient(circle, var(--accent, #4f7cff) 0 3px, transparent 3.5px); }
++.ccm-item:hover, .ccm-item:focus-visible { background: color-mix(in srgb, var(--text-primary, #eef2f8) 6%, transparent); outline: none; }
++.ccm-own.is-ready .ccm-item small::after { content: ' · ready'; color: #22c55e; font-weight: 650; }
++.ccm-setup { flex: none; padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; color: var(--accent, #4f7cff); text-decoration: none; border: 1px solid color-mix(in srgb, var(--accent, #4f7cff) 40%, transparent); }
++.ccm-setup:hover { background: color-mix(in srgb, var(--accent, #4f7cff) 12%, transparent); }
++.ccm-fine { margin: 8px 8px 4px; color: var(--text-muted, #7386a3); font-size: 12px; line-height: 1.45; }
++.ccm-fine a { color: var(--accent, #4f7cff); }
++@media (max-width: 560px) { .ccm-panel { position: fixed; left: 12px; right: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 160px); width: auto; } }
++
++/* /chat/model: the plain settings page (no script needed). */
++.cm { max-width: 640px; margin: 0 auto; padding: 24px 16px 96px; }
++.cm-back { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted, #7386a3); text-decoration: none; font-size: 14px; }
++.cm h1 { margin: 14px 0 8px; font-size: clamp(24px, 4vw, 32px); line-height: 1.15; text-wrap: balance; }
++.cm-lede { margin: 0 0 18px; color: var(--text-secondary, #a8b3c7); line-height: 1.55; }
++.cm-ok, .cm-err { padding: 10px 14px; border-radius: 12px; font-size: 14px; }
++.cm-ok { background: color-mix(in srgb, #22c55e 14%, transparent); }
++.cm-err { background: color-mix(in srgb, #ef4444 14%, transparent); }
++.cm-form { display: grid; gap: 6px; }
++.cm-head { margin: 14px 0 2px; color: var(--text-muted, #7386a3); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
++.cm-opt { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); cursor: pointer; }
++.cm-opt:has(input:checked) { border-color: color-mix(in srgb, var(--accent, #4f7cff) 55%, var(--line)); background: color-mix(in srgb, var(--accent, #4f7cff) 7%, transparent); }
++.cm-opt input { accent-color: var(--accent, #4f7cff); width: 18px; height: 18px; }
++.cm-opt > span { flex: 1; min-width: 180px; display: grid; gap: 2px; }
++.cm-opt small { color: var(--text-muted, #7386a3); font-size: 12.5px; }
++.cm-setup { font-size: 13px; font-weight: 700; color: var(--accent, #4f7cff); }
++.cm-modelid { display: grid; gap: 6px; margin-top: 12px; font-size: 14px; }
++.cm-modelid small { color: var(--text-muted, #7386a3); }
++.cm-modelid input { min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: transparent; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
++.cm-save { justify-self: start; margin-top: 14px; min-height: 42px; padding: 0 22px; border-radius: 999px; border: 0; background: var(--accent, #4f7cff); color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex df22cf5..ea59070 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -449,6 +449,66 @@
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(str).then(ok, function () {});
}
decorate(chat);
++ modelChip();
++ }
++
++ // ── The model chip (plan L55 C3): what this person's tasks run on. Opening it asks which of their own keys Actor
++ // may use (names only, from OpenVibe.AI); a choice saves at once. Escape or a click elsewhere closes it.
++ function modelChip() {
++ var box = document.querySelector('[data-cc-model]');
++ if (!box || box.dataset.wired) return;
++ box.dataset.wired = '1';
++ var label = box.querySelector('[data-ccm-label]');
++ var fine = box.querySelector('[data-ccm-fine]');
++ var ready = null;
++ function mark() {
++ box.querySelectorAll('[data-ccm-provider]').forEach(function (li) {
++ var ok = !!(ready && ready.indexOf(li.getAttribute('data-ccm-provider')) !== -1);
++ li.classList.toggle('is-ready', ok);
++ var setup = li.querySelector('[data-ccm-setup]');
++ if (setup) setup.hidden = ok;
++ });
++ }
++ box.addEventListener('toggle', function () {
++ if (!box.open) return;
++ fetch('/api/v1/me/model', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
++ .then(function (r) { return r.ok ? r.json() : null; })
++ .then(function (j) { if (j) { ready = Array.isArray(j.keys) ? j.keys : []; mark(); } })
++ .catch(function () {});
++ });
++ box.addEventListener('click', function (e) {
++ var b = e.target.closest('[data-ccm-choice]');
++ if (!b) return;
++ var choice = b.getAttribute('data-ccm-choice');
++ b.disabled = true;
++ fetch('/api/v1/me/model', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ choice: choice }) })
++ .then(function (r) { return r.ok ? r.json() : r.json().then(function (j) { throw new Error((j && j.detail) || 'not saved'); }, function () { throw new Error('not saved'); }); })
++ .then(function (j) {
++ box.querySelectorAll('[data-ccm-choice]').forEach(function (x) {
++ var on = x.getAttribute('data-ccm-choice') === choice;
++ x.classList.toggle('is-on', on);
++ x.setAttribute('aria-pressed', on ? 'true' : 'false');
++ });
++ var p = j && j.provider;
++ var name = p ? (b.querySelector('b') || {}).textContent : '';
++ if (label) label.textContent = p ? 'Your ' + name + ' key' : 'Auto';
++ // Chosen but not allowed yet: say what is left to do instead of letting the next task fail.
++ if (p && ready && ready.indexOf(p) === -1 && fine) {
++ fine.textContent = '';
++ fine.append('Your next task runs on your ' + name + ' key once you allow Actor to use it: ');
++ var a = document.createElement('a');
++ a.href = 'https://ai.openvibe.services/you?allow=actor&provider=' + encodeURIComponent(p);
++ a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'allow it at OpenVibe.AI';
++ fine.append(a, '.');
++ } else {
++ box.open = false;
++ }
++ })
++ .catch(function (err) { if (fine) fine.textContent = 'That did not save: ' + err.message; })
++ .then(function () { b.disabled = false; });
++ });
++ document.addEventListener('click', function (e) { if (box.open && !box.contains(e.target)) box.open = false; });
++ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.open) { box.open = false; box.querySelector('summary').focus(); } });
}
function stop() { streams.forEach(function (s) { try { s.close(); } catch (e) { /* */ } }); streams = []; }
ddiff --git a/server/config.js b/server/config.js
iindex eec9360..e5200fc 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -82,6 +82,18 @@ function load(env = process.env) {
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ // OpenVibe.AI (plan L55 C3): the model gateway a person's own keys live in. Actor holds NO provider key of
++ // its own for this: it asks AI to run the call, and AI answers with the key the person chose (their own,
++ // sealed in AI's vault) or with OpenVibe's own keys. The token is Actor's service token for audience
++ // openvibe.ai (the grant: ai.run.create, namespace ai.complete); the person is named in the request.
++ ai: {
++ url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
++ audience: 'openvibe.ai',
++ scope: 'ai.run.create',
++ 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 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.
providers: {
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 4372a86..3b84376 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -30,6 +30,7 @@ const personal = require('../agents/personal');
const memoryStore = require('../memory');
const approvals = require('../agents/approvals');
const items = require('../actions/items');
++const modelPrefs = require('../runtime/model-prefs');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -191,6 +192,35 @@ function createApi(ctx) {
res.status(204).end();
});
++ // ── The person's model choice (plan L55 C3) ─────────────────────────────────
++ // Which model their tasks run on: 'auto' (Actor decides, as today), 'openvibe' (OpenVibe's own keys, through
++ // OpenVibe.AI) or 'own:<provider>' (their OWN key for that provider, sealed in OpenVibe.AI's vault — a key never
++ // reaches Actor, and Actor never stores one).
++ //
++ // The person's own session or token, never a service: what a person spends their own key on is theirs to choose.
++ // (A service token may read it — it must know which provider to ask for — but not change it.)
++ const personOnly = (req, res, next) => {
++ const p = req.principal;
++ if (!p || p.kind !== 'user' || !p.requester || !p.requester.startsWith('user:')) {
++ return problem(req, res, 404, 'actor.model.not_found', 'No such setting.');
++ }
++ return next();
++ };
++
++ r.get('/me/model', principal.requireCapability('actor.model.manage'), personOnly, limits.reads('actor.model.manage'), async (req, res) => {
++ const row = await modelPrefs.get(s, req.principal.requester);
++ // The providers the person allowed THIS service to use their own key for, and that they hold a key for:
++ // names only, never key material. Null when OpenVibe.AI does not say (the person allowed us nothing yet).
++ const subject = String(req.principal.requester || '').replace(/^user:/, '');
++ const keys = ctx.models && typeof ctx.models.allowedKeys === 'function' ? await ctx.models.allowedKeys(subject) : null;
++ res.json({ ...modelPrefs.wire(row), keys });
++ });
++
++ r.put('/me/model', principal.requireCapability('actor.model.manage'), personOnly, limits.budget('actor.model.manage'), async (req, res) => {
++ const out = await modelPrefs.put(s, req.principal.requester, req.body || {});
++ if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);
++ res.json({ ...modelPrefs.wire(out.row), keys: null });
++ });
r.post('/route', limits.budget('actor.route'), (req, res) => {
const b = req.body || {};
if (typeof b.task !== 'string' || !b.task.trim() || b.task.length > 20000) return problem(req, res, 422, 'actor.task.invalid', 'task: the instruction in plain words (1 to 20000 characters)');
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex ef6a7ca..62b0abc 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -30,6 +30,8 @@ const BUDGETS = {
'actor.task.create': { minute: 6, hour: 60 },
'actor.agent.run': { minute: 6, hour: 60 },
'actor.agent.manage': { minute: 30, hour: 600 },
++ // The person's own model choice (plan L55 C3): a setting, not a task.
++ 'actor.model.manage': { minute: 30, hour: 600 },
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 518b58a..8111172 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -30,6 +30,14 @@ const taskEvents = require('../../public/js/task-events');
const { sameOrigin } = require('./principal');
const looks = require('../agents/looks');
const inputs = require('../tasks/inputs');
++const modelPrefs = require('../runtime/model-prefs');
++
++// 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.
++const PROVIDER_NAMES = { openai: 'OpenAI', anthropic: 'Anthropic', deepseek: 'DeepSeek', google: 'Google Gemini', openrouter: 'OpenRouter', mistral: 'Mistral' };
++const AI_SITE = 'https://ai.openvibe.services';
++const allowUrl = (provider) => `${AI_SITE}/you?allow=actor&provider=${encodeURIComponent(provider)}`;
++const modelLabel = (m) => (m && m.provider ? `Your ${PROVIDER_NAMES[m.provider] || m.provider} key` : 'Auto');
const glyphs = require('openvibe-shared/glyphs');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -280,7 +288,24 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
</aside>`;
}
-- function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '' } = {}) {
++ /**
++ * The model chip: what this person's tasks run on, and a menu to change it. The buttons save with one request
++ * (js/chat.js, PUT /api/v1/me/model); without the script the menu's link opens /chat/model, a plain form.
++ */
++ function modelChip(model) {
++ const current = model && model.provider ? `own:${model.provider}` : 'auto';
++ const item = (choice, title, sub) => html`<button type="button" class="ccm-item${choice === current ? ' is-on' : ''}" data-ccm-choice="${choice}" aria-pressed="${choice === current ? 'true' : 'false'}"><b>${title}</b><small>${sub}</small></button>`;
++ return html`<details class="cc-model" data-cc-model>
++<summary class="cc-model-chip" title="The model your tasks run on">${raw(glyphs.svg('chip', { size: 16 }))}<span data-ccm-label>${modelLabel(model)}</span></summary>
++<div class="ccm-panel" role="group" aria-label="The model your tasks run on">
++<ul class="ccm-list"><li>${item('auto', 'Auto, OpenVibe\'s', 'Actor picks a model for each step, on OpenVibe\'s keys.')}</li></ul>
++<p class="ccm-head">Your own key</p>
++<ul class="ccm-list">${modelPrefs.OWN_PROVIDERS.map((p) => html`<li class="ccm-own" data-ccm-provider="${p}">${item(`own:${p}`, PROVIDER_NAMES[p] || p, 'Your key, kept in OpenVibe.AI')}<a class="ccm-setup" href="${allowUrl(p)}" target="_blank" rel="noopener" data-ccm-setup>Set up</a></li>`)}</ul>
++<p class="ccm-fine" data-ccm-fine>A key you add stays sealed in OpenVibe.AI: Actor never sees it, and only uses it once you allow Actor there. <a href="/chat/model">All settings</a></p>
++</div></details>`;
++ }
++
++ function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null } = {}) {
return html`<form class="cc" method="post" action="${working ? `/chat/${working}/steer` : '/chat'}" data-composer${busy ? raw(' data-busy') : ''}>
${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}
${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
@@@ -290,6 +315,7 @@ ${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
<textarea id="cc-text" name="task" rows="1" maxlength="20000" required placeholder="${working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something'}" data-cc-text>${text}</textarea>
<div class="cc-row">
<button type="button" class="cc-attach" data-cc-attach aria-label="Attach files" title="Attach text, code, CSV, JSON or images" hidden>${raw('<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>')}</button><input type="file" multiple hidden data-cc-files 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/*,image/png,image/jpeg,image/webp,image/gif">
++${modelChip(model)}
<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>
<span class="cc-note" data-cc-note></span>
${working ? html`<button class="cc-stop" type="submit" formaction="/tasks/${working}/cancel" formnovalidate aria-label="Stop" data-cc-stop>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>')}</button>` : html`<button class="cc-stop" type="button" aria-label="Stop" data-cc-stop hidden>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>')}</button>`}
@@@ -321,6 +347,7 @@ ${working ? html`<button class="cc-stop" type="submit" formaction="/tasks/${work
const lastTurn = turns[turns.length - 1] || null;
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
const fileMap = await inputs.list(s, turns.map((t) => t.id));
++ const model = modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));
const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [] })));
const title = first ? clip(first.task, 60) : 'New chat';
return send(res, status, {
@@@ -337,7 +364,7 @@ ${sidebar(chats, first ? first.id : null)}
${turns.length ? html`<ol class="ct-thread" data-thread>${turnHtmls}</ol>` : html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`}
</div>
--${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text })}
++${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model })}
</section>
</div>`,
});
@@@ -345,6 +372,41 @@ ${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mo
const loginTo = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
++ r.get('/chat/model', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ 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';
++ const saved = req.query.saved === '1';
++ const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';
++ const radio = (choice, title, sub, extra = '') => html`<label class="cm-opt"><input type="radio" name="choice" value="${choice}"${choice === current ? raw(' checked') : ''}><span><b>${title}</b><small>${sub}</small></span>${raw(extra)}</label>`;
++ return send(res, 200, {
++ viewer: req.viewer, config, path: req.originalUrl, title: 'Your model', app: true, noReferrer: true,
++ css: ['css/chat.css'], scripts: [],
++ body: html`<main class="cm"><a class="cm-back" href="/chat">${raw(glyphs.svg('back', { size: 18 }))} Chat</a>
++<h1>The model your tasks run on</h1>
++<p class="cm-lede">Actor can run on OpenVibe's keys, or on your own key for a provider you use. Your key stays sealed in OpenVibe.AI: Actor never sees it, and only uses it after you allow Actor there.</p>
++${saved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on ${modelLabel(model)}.</p>` : ''}${error ? html`<p class="cm-err" role="alert">${error}</p>` : ''}
++<form class="cm-form" method="post" action="/chat/model">
++${radio('auto', 'Auto, OpenVibe\'s', 'Actor picks a model for each step, on OpenVibe\'s keys. Free within the daily allowance.')}
++<p class="cm-head">Your own key</p>
++${modelPrefs.OWN_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, '&amp;')}" target="_blank" rel="noopener">Add or allow the key</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>
++<button class="cm-save" type="submit">Save</button>
++</form></main>`,
++ });
++ });
++
++ r.post('/chat/model', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ 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.');
++ const body = req.body || {};
++ const out = await modelPrefs.put(s, requesterOf(req), { choice: body.choice, model: body.model ? String(body.model) : null });
++ 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');
++ });
++
r.get('/chat', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 9e8e4c5..cd23d37 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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'];
++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'];
function decodePayload(token) {
const parts = String(token || '').split('.');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 5b4a4eb..c89cae9 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -62,6 +62,11 @@ const TABLES = [
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
++ // The model they chose for their tasks (plan L55 C3): which provider their own key is for, and the model id
++ // they picked. Never a key: the key itself is sealed in OpenVibe.AI's vault, and this row only names the
++ // provider. It goes with the person.
++ { table: 'actor_model_prefs', subject: 'requester', value: (usr) => `user:${usr}`, file: 'model-prefs.json',
++ columns: ['requester', 'choice', 'model', 'updated_at'] },
// What they asked Actor in chat (chat/asks.js): the message it answered, the room, the task it became.
{ table: 'actor_chat_asks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'chat-asks.json' },
// The connections they added (stage V2-11): the servers and agents, and which of their tools are on. The token
ddiff --git a/server/runtime/model-prefs.js b/server/runtime/model-prefs.js
nnew file mode 100644
iindex 0000000..dadbc18
---- /dev/null
++++ b/server/runtime/model-prefs.js
@@@ -0,0 +1,85 @@
++'use strict';
++
++/**
++ * The person's model choice (plan L55 C3; the owner, 2026-11-01: "use your own API keys, or OpenVibe's… all the
++ * features"). One row per person:
++ *
++ * auto Actor decides, as today (the operator's tiers and keys)
++ * openvibe OpenVibe's own keys, through OpenVibe.AI (the gateway: its routing, quota and cost)
++ * own:<provider> the person's OWN key for that provider, sealed in OpenVibe.AI's vault
++ *
++ * The provider is one of the six OpenVibe.AI knows a person's key for. The model id is optional: without one,
++ * OpenVibe.AI routes the call (L37's soft preference), and the person's choice of provider still holds.
++ *
++ * No key material is ever written here: this table says which provider the person's key is for, nothing else.
++ * The row belongs to the person (requester = user:usr_…), so it is exported and erased with them
++ * (server/identity/account-data.js).
++ */
++const CHOICES = ['auto', 'openvibe'];
++const OWN_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'];
++// 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}$/;
++
++/**
++ * Read a choice from a request body. Returns { choice, provider, model } or { error } (never throws: a bad choice
++ * is a 422, not a crash).
++ */
++function parse(body) {
++ 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 (CHOICES.includes(choice)) return { choice, provider: null, model: model || null };
++ if (choice.startsWith('own:')) {
++ const provider = choice.slice(4).trim().toLowerCase();
++ if (!OWN_PROVIDERS.includes(provider)) return { error: 'choice: own:<provider> needs a provider of ' + OWN_PROVIDERS.join(', ') };
++ return { choice: 'own:' + provider, provider, model: model || null };
++ }
++ return { error: 'choice: one of ' + CHOICES.join(', ') + ', own:<provider>' };
++}
++
++/** The row for a person, or null (no row: the default is 'auto'). */
++async function get(s, requester) {
++ if (!requester) return null;
++ const row = await s.db.maybe('SELECT requester, choice, model, updated_at FROM actor_model_prefs WHERE requester = $1', [requester]);
++ return row || null;
++}
++
++/** Write the person's choice. Returns { row } or { error }. */
++async function put(s, requester, body, now = () => new Date().toISOString()) {
++ const parsed = parse(body);
++ if (parsed.error) return { error: parsed.error };
++ if (!requester) return { error: 'no person' };
++ const at = now();
++ await s.db.query(
++ 'INSERT INTO actor_model_prefs (requester, choice, model, updated_at) VALUES ($1, $2, $3, $4)'
++ + ' ON CONFLICT (requester) DO UPDATE SET choice = excluded.choice, model = excluded.model, updated_at = excluded.updated_at',
++ [requester, parsed.choice, parsed.model, at],
++ );
++ return { row: { requester, choice: parsed.choice, provider: parsed.provider, model: parsed.model, updated_at: at } };
++}
++
++/**
++ * The choice as the run needs it: { subject, provider, model } or null (today's behaviour). The subject is the bare
++ * usr_… id OpenVibe.AI expects in x-openvibe-credential — never "user:usr_…".
++ */
++async function choiceFor(s, requester) {
++ const row = await get(s, requester);
++ if (!row || !String(row.choice || '').startsWith('own:')) return null;
++ const subject = String(requester || '').startsWith('user:') ? requester.slice(5) : null;
++ if (!subject) return null;
++ return { subject, provider: row.choice.slice(4), model: row.model || null };
++}
++
++/** The choice as the API answers it. */
++function wire(row) {
++ if (!row) return { choice: 'auto', provider: null, model: null, updated_at: null };
++ return {
++ choice: row.choice,
++ provider: String(row.choice || '').startsWith('own:') ? row.choice.slice(4) : null,
++ model: row.model || null,
++ updated_at: row.updated_at || null,
++ };
++}
++
++module.exports = { CHOICES, OWN_PROVIDERS, parse, get, put, choiceFor, wire };
ddiff --git a/server/runtime/models.js b/server/runtime/models.js
iindex e52e200..d483082 100644
---- a/server/runtime/models.js
++++ b/server/runtime/models.js
@@@ -29,6 +29,8 @@
* }
*/
++const { AsyncLocalStorage } = require('node:async_hooks');
++const { serviceAuth } = require('openvibe-contracts');
const { chatClient, priceUsage, ProviderError } = require('../agents/providers');
class ModelError extends Error {
@@@ -57,6 +59,10 @@ const SMALL = { test: (m) => !FULL.has(String(m)) && SMALL_RE.test(String(m)) };
const TIERS = ['fast', 'smart', 'best', 'check', 'vision'];
const PROVIDERS = ['openai', 'deepseek', 'runtime', 'compatible', 'local'];
++// OpenVibe.AI (plan L55 C3): the gateway, NOT one of the operator's providers. It is never resolved as a tier (a
++// person who did not choose it keeps today's behaviour), and it holds no key of its own: the call goes to AI, and AI
++// answers with the person's own key (sealed in AI's vault) or with OpenVibe's.
++const AI_PROVIDER = 'openvibe-ai';
/** Parse a pinned tier value: "openai:gpt-5.1", or a bare model on the first configured provider. */
function parsePin(value) {
@@@ -73,6 +79,7 @@ const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1
function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() } = {}) {
const cfg = config.models || { tiers: {}, providerOrder: [], listTimeoutMs: 5000 };
const p = config.providers || {};
++ const ai = config.ai || {};
const order = (cfg.providerOrder && cfg.providerOrder.length ? cfg.providerOrder : PROVIDERS).filter((k) => PROVIDERS.includes(k));
for (const k of PROVIDERS) if (!order.includes(k)) order.push(k);
@@@ -83,6 +90,8 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
runtime: { baseUrl: trimSlash(p.runtime && p.runtime.url), apiKey: (p.runtime && p.runtime.apiKey) || '', model: (p.runtime && p.runtime.model) || '', family: 'runtime' },
compatible: { baseUrl: trimSlash(p.compatible && p.compatible.baseUrl), apiKey: (p.compatible && p.compatible.apiKey) || '', model: (p.compatible && p.compatible.model) || '', family: 'compatible' },
local: { baseUrl: trimSlash(p.local && p.local.url), apiKey: '', model: (p.local && p.local.model) || '', family: 'local' },
++ // OpenVibe.AI: no api key (Actor holds none), no model of its own (AI routes), one family of its own.
++ 'openvibe-ai': { baseUrl: trimSlash(ai.url), apiKey: '', model: '', family: 'openvibe-ai' },
};
const listed = {}; // provider → the provider's model ids (as read at boot)
const listErrors = {}; // provider → why its list could not be read
@@@ -107,6 +116,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
if (!e.baseUrl) return { available: false, reason: `no ${key} endpoint is configured on this server` };
if ((key === 'openai' || key === 'deepseek') && !e.apiKey) return { available: false, reason: `no ${key} key is configured on this server` };
if ((key === 'runtime' || key === 'local') && !e.model) return { available: false, reason: `no model is configured for the ${key} endpoint` };
++ if (key === AI_PROVIDER) {
++ if (!e.baseUrl) return { available: false, reason: 'no OpenVibe.AI endpoint is configured on this server' };
++ if (!ai.clientSecret) return { available: false, reason: 'Actor has no Network client secret for OpenVibe.AI' };
++ return { available: true };
++ }
return { available: true };
}
@@@ -231,8 +245,139 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
return resolveNow(tier, opts);
}
++ // ── OpenVibe.AI (plan L55 C3): the person's own key ────────────────────────────────────────────
++ // The model choice of the run in flight. The store is per async context, so every model call of that run —
++ // the loop's own, the plan's, the check's — reads it, and no other run's does.
++ const choiceStore = new AsyncLocalStorage();
++ let aiTokens = null;
++
++ /** Actor's service token for OpenVibe.AI (audience openvibe.ai). Never a provider key. */
++ function aiTokenClient() {
++ if (aiTokens) return aiTokens;
++ if (!ai.clientSecret) return null;
++ aiTokens = serviceAuth.createTokenClient({
++ tokenUrl: (config.networkInternalUrl || config.networkUrl) + '/oauth/token',
++ clientId: ai.clientId, clientSecret: ai.clientSecret,
++ audience: ai.audience, scope: ai.scope, fetchImpl,
++ });
++ return aiTokens;
++ }
++
++ /**
++ * The providers the person allowed THIS service to use their own key for, and that they hold a key for: names
++ * only, never key material. Null when AI does not say (or does not know) — the person may not have allowed us.
++ */
++ async function allowedKeys(subject) {
++ const tokens = aiTokenClient();
++ if (!tokens || !subject) return null;
++ try {
++ const res = await fetchImpl(trimSlash(ai.url) + '/api/v1/allowed-keys/' + encodeURIComponent(subject), {
++ headers: { accept: 'application/json', ...(await tokens.authHeaders()) },
++ });
++ if (!res.ok) return null;
++ const j = await res.json();
++ return Array.isArray(j.providers) ? j.providers : null;
++ } catch { return null; }
++ }
++
++ /** Run the rest of the work with this person's model choice in force. */
++ function withChoice(choice, fn) {
++ return choiceStore.run(choice || null, fn);
++ }
++
++ /** The choice of the run in flight, or null (today's behaviour). */
++ function currentChoice() {
++ return choiceStore.getStore() || null;
++ }
++
++ /**
++ * One call through OpenVibe.AI, on the person's own key (plan L55 C3).
++ *
++ * Actor sends no key: AI holds it, sealed, and the request names the provider and the person
++ * (x-openvibe-credential: <provider>:<subject>, the bare usr_… id AI expects — never "user:usr_…"). AI answers
++ * with that key, or refuses: the person has no key for that provider yet, and the message says where to add it.
++ */
++ async function chatViaAi({ choice, messages, tools, json, maxTokens, signal }) {
++ const tokens = aiTokenClient();
++ if (!tokens) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });
++ const base = trimSlash(ai.url);
++ let host = base;
++ try { host = new URL(base).host; } catch { /* a plain host is fine */ }
++ const provider = choice.provider;
++ const body = {
++ model: choice.model || 'openvibe',
++ messages,
++ ...(tools && tools.length ? { tools, tool_choice: 'auto' } : {}),
++ ...(json ? { response_format: { type: 'json_object' } } : {}),
++ max_tokens: Math.max(1, Math.min(Number(maxTokens) || 1500, 16000)),
++ temperature: 0.2,
++ // L37: a wish, not a pin. The provider is the one the person's own key is for, so the wish holds.
++ prefer: { provider, ...(choice.model ? { model: choice.model } : {}) },
++ };
++ const timeout = AbortSignal.timeout(ai.timeoutMs || 180_000);
++ const sig = signal ? AbortSignal.any([signal, timeout]) : timeout;
++ let res;
++ try {
++ res = await fetchImpl(base + '/v1/chat/completions', {
++ method: 'POST',
++ headers: {
++ 'content-type': 'application/json',
++ accept: 'application/json',
++ ...(await tokens.authHeaders()),
++ // The person's own key for this provider, and who the person is (the bare usr_… id).
++ 'x-openvibe-credential': provider + ':' + choice.subject,
++ },
++ body: JSON.stringify(body),
++ signal: sig,
++ });
++ } catch (err) {
++ if (signal && signal.aborted) throw new ProviderError('cancelled', { code: 'actor.task.cancelled' });
++ throw new ProviderError(err && err.name === 'TimeoutError' ? 'OpenVibe.AI did not answer in time' : 'OpenVibe.AI could not be reached', { code: 'actor.provider.unreachable' });
++ }
++ const raw = await res.text();
++ let answer = null;
++ try { answer = JSON.parse(raw); } catch { /* not JSON */ }
++ if (!res.ok) {
++ const err = (answer && answer.error) || {};
++ const code = String(err.code || '');
++ const msg = String(err.message || clip(raw, 200) || ('HTTP ' + res.status));
++ // No key for that provider yet: the person adds it at AI's own site, where it is sealed.
++ if (res.status === 404 && code === 'credential.not_found') {
++ throw new ModelError('Add your ' + provider + ' key at ' + host + ' first', { code: 'actor.model.no_own_key' });
++ }
++ // The person holds the key but has not allowed this service to use it (AI, plan L55 C3): AI's own detail
++ // names the page where they allow it, so the person can act on it.
++ if (res.status === 403 && code === 'ai.credential_not_allowed') {
++ 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 ProviderError('HTTP ' + res.status + ': ' + clip(msg, 300), { status: res.status, code: res.status === 429 ? 'actor.provider.rate_limited' : 'actor.provider.error' });
++ }
++ if (!answer) throw new ProviderError('OpenVibe.AI answered with something that is not JSON');
++ const first = (answer.choices && answer.choices[0]) || {};
++ 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 || {};
++ 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',
++ 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,
++ };
++ }
/** One model call. The caller checks the budget first and meters cost_usd after; this layer only prices it. */
-- async function chat({ tier = 'smart', messages, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null } = {}) {
++ async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null } = {}) {
++ // The person's own key (plan L55 C3): every model call of this run goes to OpenVibe.AI with it.
++ const choice = currentChoice();
++ if (choice && choice.provider) {
++ const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal });
++ return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };
++ }
await warm();
if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });
const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });
@@@ -277,11 +422,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
}
return {
-- warm, chat, resolve, tierList, modelList, resolveAll,
++ warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys,
tierConfig: cfg, PREFERRED, PREFERRED_CHECKED, TIERS,
get warmed() { return warmed; },
listErrors: () => ({ ...listErrors }),
};
}
--module.exports = { createModels, ModelError, parsePin, PREFERRED, PREFERRED_CHECKED, TIERS, PROVIDERS };
++module.exports = { createModels, ModelError, parsePin, PREFERRED, PREFERRED_CHECKED, TIERS, PROVIDERS, AI_PROVIDER };
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex c130d39..b79f4bf 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -19,6 +19,10 @@ const PERSON_WAIT_MAX_MS = 15 * 60_000;
* At most config.maxRunning tasks run at once; the rest wait queued in order.
*/
const store = require('./store');
++const modelPrefs = require('../runtime/model-prefs');
++// The person's own model choice rides with the models layer (plan L55 C3). A stand-in models object (a test's)
++// does not carry it, and a run then keeps today's behaviour.
++const withChoice = (models, choice, fn) => (models && typeof models.withChoice === 'function' ? models.withChoice(choice, fn) : fn());
const catalog = require('../agents/catalog');
const { classify, heuristic } = require('../agents/classify');
const { route, whyNone } = require('../agents/router');
@@@ -346,8 +350,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
controls.set(id, control);
// The files attached to this message in the chat (plan L55 C2): text as data; images described first.
const attachments = await inputs.forRun(s, id, { models, meter, signal, log }).catch((err) => { log.warn('[Actor] attachments:', err && err.message); return []; });
++ // The person's own model choice for this run (plan L55 C3): their own key, through OpenVibe.AI.
++ // Every model call of the run — the loop's, the plan's, the check's — reads it, and no other run's does.
++ const choice = await modelPrefs.choiceFor(s, row.requester).catch(() => null);
try {
-- out = await loop.run({
++ out = await withChoice(models, choice, async () => loop.run({
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
// A fork carries its parent's state in the checkpoint, not as a follow-up conversation.
context: row.parent_id && row.fork_of_step == null && !resume ? await store.followContext(s, row) : [],
@@@ -356,7 +363,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
personText, agentId: ownAgent, notifyTask,
askPerson, control, resume, fork, spent: () => spent,
waits, waitEvent: wakePayload, attachments,
-- });
++ }));
// The run parked (plan A2): the wait is open, so the task is not finished. It stays open
// ('running' is the closest state there is today), its wall clock waits with it, and its
// budget clock stops because no model runs while it waits. The desk is closed with the run.
@@@ -383,11 +390,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
}
try {
await setState(id, 'verifying', { agentForEvent: agentId });
-- const verify = async (current) => checkClaims({
++ const verify = async (current) => withChoice(models, choice, () => checkClaims({
task: row.task, answer: current.text, evidence: current.evidence, models,
writerFamily: current.family || null, signal, now: () => now(),
trace: current.state && current.state.ctx && current.state.ctx.trace,
-- });
++ }));
let verdict = await verify(out);
await meter(verdict.cost_usd);
const showCheck = (v) => emit(id, { kind: 'output', step: 'check', agent: agentId, text: `${v.ok ? 'Checked' : 'Check failed'}${v.by ? ` by ${v.by}${v.cross ? '' : ' (same family)'}` : ''}: ${v.reason || ''}`.slice(0, 2000), is_error: !v.ok });
@@@ -430,7 +437,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
for (const c of verdict.contested.slice(0, 2)) {
if (budgetLeft() <= 0) break;
try {
-- const j = await adjudicate({ claim: c, evidence: out.evidence, task: row.task, models, writerFamily: out.family || null, signal, now: () => now(), trace: out.state && out.state.ctx && out.state.ctx.trace });
++ const j = await withChoice(models, choice, () => adjudicate({ claim: c, evidence: out.evidence, task: row.task, models, writerFamily: out.family || null, signal, now: () => now(), trace: out.state && out.state.ctx && out.state.ctx.trace }));
if (j) { await meter(j.cost_usd); if (!j.failed) contested.push({ claim: j.claim, sides: j.sides, pick: j.pick, reason: j.reason, by: j.by }); }
} catch (err) {
if (signal && signal.aborted) throw err;
@@@ -446,14 +453,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (Array.isArray(verdict.claims) && verdict.claims.length) {
const outward = !!(out.state && out.state.brain && out.state.brain.understanding && out.state.brain.understanding.outward);
try {
-- review = await confidence.review({
++ review = await withChoice(models, choice, () => confidence.review({
claims: verdict.claims, evidence: out.evidence, task: row.task, mode: row.mode, models,
outward, signal, trace: out.state && out.state.ctx && out.state.ctx.trace,
budgetLeft, writerFamily: out.family || null, now: () => now(),
search: async (query) => (registry
? registry.run('search_web', { query }, { config, log, signal, requester: row.requester, now })
: { text: 'no search is configured', isError: true }),
-- });
++ }));
await meter(review.cost_usd);
if (review.challenged) await emit(id, { kind: 'output', step: 'check', agent: agentId, text: `Challenged ${review.challenged} high-stakes claim${review.challenged === 1 ? '' : 's'} with a counter-evidence search; ${review.unsure} could not be confirmed.`.slice(0, 2000) });
} catch (err) {
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 71e6b3b..b6ead01 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -25,6 +25,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'actor.task.create': { minute: 6, hour: 60 },
'actor.agent.run': { minute: 6, hour: 60 },
'actor.agent.manage': { minute: 30, hour: 600 },
++ 'actor.model.manage': { minute: 30, hour: 600 },
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
ddiff --git a/test/chat-model.test.js b/test/chat-model.test.js
nnew file mode 100644
iindex 0000000..7013db6
---- /dev/null
++++ b/test/chat-model.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++// The model chip (plan L55 C3; the owner, 11-01: "use your own API keys, or OpenVibe's"): the chat's composer says
++// what this person's tasks run on and changes it in one tap; /chat/model is the same as a plain form. The key itself
++// never reaches Actor: the menu links to OpenVibe.AI to add it and allow Actor.
++// node test/chat-model.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const bo = t.network.addUser('bo');
++ try {
++ await check('the chip says Auto by default, and its menu offers OpenVibe\'s and each provider\'s own key', async () => {
++ const page = await t.get('/chat', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<details class="cc-model" data-cc-model>/);
++ assert.match(page.text, /<span data-ccm-label>Auto<\/span>/);
++ assert.match(page.text, /data-ccm-choice="auto" aria-pressed="true"/);
++ ['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'].forEach((p) => {
++ assert.ok(page.text.includes(`data-ccm-choice="own:${p}" aria-pressed="false"`), p);
++ assert.ok(page.text.includes(`https://ai.openvibe.services/you?allow=actor&amp;provider=${p}`), `${p}: set up at OpenVibe.AI`);
++ });
++ assert.ok(page.text.includes('href="/chat/model"'), 'the plain form, for no script');
++ });
++
++ await check('a tap saves through the API from the page; the chip says the person\'s own key after', async () => {
++ const put = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:openai' }, headers: SAME });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().provider, 'openai');
++ const page = await t.get('/chat', { as: kim });
++ assert.match(page.text, /<span data-ccm-label>Your OpenAI key<\/span>/);
++ assert.match(page.text, /data-ccm-choice="own:openai" aria-pressed="true"/);
++ assert.match((await t.get('/chat', { as: bo })).text, /<span data-ccm-label>Auto<\/span>/, 'another person\'s choice is theirs');
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:nope' }, headers: SAME })).status, 422);
++ });
++
++ await check('/chat/model: the plain form saves from openvibe.actor only, and says so', async () => {
++ const page = await t.get('/chat/model', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<input type="radio" name="choice" value="own:openai" checked>/);
++ const saved = await t.get('/chat/model', { as: kim, form: { choice: 'own:anthropic', model: 'claude-sonnet-4-5' }, headers: SAME });
++ assert.strictEqual(saved.status, 303);
++ assert.strictEqual(saved.headers.get('location'), '/chat/model?saved=1');
++ const after = await t.get('/chat/model?saved=1', { as: kim });
++ assert.match(after.text, /Saved\. Your next task runs on Your Anthropic key\./);
++ assert.match(after.text, /name="model" value="claude-sonnet-4-5"/);
++ assert.strictEqual((await t.get('/chat/model', { as: kim, form: { choice: 'auto' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
++ const bad = await t.get('/chat/model', { as: kim, form: { choice: 'own:openai', model: 'bad model!' }, headers: SAME });
++ assert.match(bad.headers.get('location'), /\/chat\/model\?error=/);
++ assert.strictEqual((await t.get('/chat/model')).status, 303, 'signed out: sign in first');
++ });
++
++ await check('the script: the menu asks which keys are ready, saves in one request, closes on Escape', async () => {
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
++ assert.match(js, /fetch\('\/api\/v1\/me\/model', \{ credentials: 'same-origin'/);
++ assert.match(js, /fetch\('\/api\/v1\/me\/model', \{ method: 'PUT', credentials: 'same-origin'/);
++ assert.match(js, /e\.key === 'Escape' && box\.open/);
++ assert.match(js, /allow it at OpenVibe\.AI/, 'chosen but not allowed: it says what is left to do');
++ });
++ } finally {
++ await done(t);
++ }
++})();
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 8b70760..46c5c29 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -72,7 +72,7 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
const page = await t.get(`/chat/${at}`, { as: kim });
const a = page.text.indexOf(`id="t-${first}"`), b = page.text.indexOf(`id="t-${second}"`);
assert.ok(a > 0 && b > a, `the chat through ${at} shows both turns, oldest first`);
-- assert.strictEqual((page.text.match(/<a href="\/chat\//g) || []).length, 1, 'one chat in the list');
++ assert.strictEqual((page.text.match(/<a href="\/chat\/tsk_/g) || []).length, 1, 'one chat in the list');
assert.match(page.text, new RegExp(`<a href="/chat/${first}" aria-current="page"`), 'the list names a chat by its first turn');
}
});
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 01051e3..8bb8ddc 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -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'];
++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 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
@@@ -34,7 +34,7 @@ const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'regist
function codeRoutes() {
const out = new Map();
for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources'], [CONTROL_SRC, '/api/v1/resources'], [A2A_SRC, '']]) {
-- for (const m of source.matchAll(/^\s*r\.(get|post|patch|delete)\('([^']+)'(.*)$/gm)) {
++ for (const m of source.matchAll(/^\s*r\.(get|post|put|patch|delete)\('([^']+)'(.*)$/gm)) {
const guard = m[3].match(/requireCapability\('([a-z0-9.]+)'\)/);
const read = m[3].match(/limits\.reads\('([a-z0-9.]+)'\)/);
out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
ddiff --git a/test/memory-migration.test.js b/test/memory-migration.test.js
iindex 618a198..65d17e0 100644
---- a/test/memory-migration.test.js
++++ b/test/memory-migration.test.js
@@@ -42,7 +42,8 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
[agentMemoryId, agentId, owner, oldStyleText, 'agent', now]);
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024']);
++ // 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']);
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]);
ddiff --git a/test/model-prefs.test.js b/test/model-prefs.test.js
nnew file mode 100644
iindex 0000000..2759d5e
---- /dev/null
++++ b/test/model-prefs.test.js
@@@ -0,0 +1,136 @@
++'use strict';
++/**
++ * Plan L55 C3: the person chooses the model their tasks run on — with their OWN provider key (sealed in OpenVibe.AI's
++ * vault, never in Actor) or with OpenVibe's.
++ *
++ * What is proved here:
++ * the choice is the person's own (GET/PUT /api/v1/me/model), and a bad one is refused, not stored;
++ * a run whose person chose their own key calls OpenVibe.AI with x-openvibe-credential: <provider>:<usr_…>;
++ * NO key material ever reaches Actor: the request carries Actor's service token and the provider's name, nothing
++ * else — the key stays in AI's vault;
++ * AI's refusal (no key for that provider) becomes a plain error that says where to add the key;
++ * 'auto' keeps today's behaviour: the operator's own providers, and no call to AI at all.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createModels } = require('../server/runtime/models');
++const prefs = require('../server/runtime/model-prefs');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const PERSON = 'usr_01JZ0000000000000000000AAA';
++
++const reply = (status, obj) => ({ ok: status >= 200 && status < 300, status, json: async () => obj, text: async () => JSON.stringify(obj) });
++
++/** A config with one operator provider and OpenVibe.AI, and no real network. */
++function aiConfig() {
++ return {
++ models: { tiers: {}, providerOrder: ['openai'], listTimeoutMs: 200 },
++ providers: { openai: { apiKey: 'sk-fixture-operator-key', baseUrl: 'https://api.openai.test/v1', model: 'gpt-5-mini' } },
++ ai: { url: 'https://ai.openvibe.services', audience: 'openvibe.ai', scope: 'ai.run.create', clientId: 'actor', clientSecret: 'actor-secret', timeoutMs: 5000 },
++ networkInternalUrl: 'https://network.test', networkUrl: 'https://openvibe.network',
++ oauth: { clientId: 'actor', clientSecret: 'actor-secret' },
++ };
++}
++
++/** The fetch every model call in this file goes through: the token endpoint, a model list and a chat call. */
++function fakeFetch(calls, chatAnswer) {
++ return 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('/models')) return reply(200, { data: [{ id: 'gpt-5.1' }, { id: 'gpt-5-mini' }] });
++ return chatAnswer ? chatAnswer(call) : reply(200, { choices: [{ message: { role: 'assistant', content: 'from the operator key' }, finish_reason: 'stop' }], model: 'gpt-5.1', usage: { prompt_tokens: 10, completion_tokens: 5 }, x_openvibe: { cost_usd: 0.001 } });
++ };
++}
++
++(async () => {
++ const t = await boot();
++ try {
++ const alice = t.network.addUser('model-alice');
++
++ await check('the choice is the person\'s own, and a bad one is refused', async () => {
++ const before = await t.get('/api/v1/me/model', { as: alice });
++ assert.strictEqual(before.status, 200);
++ assert.strictEqual(before.json().choice, 'auto', 'the default is today\'s behaviour');
++ assert.strictEqual(before.json().keys, null, 'AI has no route that lists a person\'s providers to a service');
++
++ const put = await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: 'gpt-5.1' }, headers: SAME });
++ assert.strictEqual(put.status, 200);
++ assert.strictEqual(put.json().choice, 'own:openai');
++ assert.strictEqual(put.json().provider, 'openai');
++ assert.strictEqual(put.json().model, 'gpt-5.1');
++
++ const read = await t.get('/api/v1/me/model', { as: alice });
++ assert.strictEqual(read.json().choice, 'own:openai');
++
++ // A provider AI does not know a person's key for, and a model id that is not one.
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:not-a-provider' }, headers: SAME })).status, 422);
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: 'a model id with spaces' }, headers: SAME })).status, 422);
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'whatever' }, headers: SAME })).status, 422);
++ // The bad ones never landed.
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: alice })).json().choice, 'own:openai');
++
++ // A signed-in write from another site is refused (the session rule), and a service never reaches it.
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'auto' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
++ });
++
++ await check('the row is the person\'s, and it goes with them (account data)', async () => {
++ const { TABLES } = require('../server/identity/account-data');
++ const map = TABLES.find((x) => x.table === 'actor_model_prefs');
++ assert.ok(map, 'the table is in the account-data map');
++ assert.strictEqual(map.subject, 'requester');
++ assert.strictEqual(map.value(PERSON), 'user:' + PERSON);
++ assert.ok(!JSON.stringify(map.columns).includes('key'), 'no key material is exported');
++ });
++
++ await check('a run with the person\'s own key goes to OpenVibe.AI, and no key reaches Actor', async () => {
++ const calls = [];
++ const models = createModels({ config: aiConfig(), fetchImpl: fakeFetch(calls), log: { log() {}, warn() {}, error() {} } });
++ const r = await models.withChoice({ subject: PERSON, provider: 'openai', model: 'gpt-5.1' }, () => models.chat({
++ tier: 'smart', messages: [{ role: 'user', content: 'hello' }],
++ tools: [{ type: 'function', function: { name: 'noop', parameters: { type: 'object', properties: {} } } }],
++ }));
++ assert.strictEqual(calls.length, 1, 'one call, to AI');
++ const call = calls[0];
++ assert.strictEqual(call.url, 'https://ai.openvibe.services/v1/chat/completions');
++ assert.strictEqual(call.headers['x-openvibe-credential'], 'openai:' + PERSON, 'the provider and the bare usr_… id');
++ assert.ok(call.body.messages && call.body.messages.length, 'the same OpenAI chat format');
++ assert.ok(Array.isArray(call.body.tools), 'tools go with it');
++ assert.strictEqual(call.body.prefer.provider, 'openai');
++ // NO key material: the only credential in the request is Actor's own service token, and the operator's
++ // key never travels with a person's run.
++ const authHeader = Object.entries(call.headers).find(([k]) => k.toLowerCase() === 'authorization');
++ assert.strictEqual(authHeader && authHeader[1], 'Bearer tok_service_token', 'Actor\'s own service token, nothing else');
++ assert.ok(!JSON.stringify(call).includes('sk-'), 'no provider key is in the request');
++ assert.ok(!JSON.stringify(call).includes('fixture'), 'not the operator\'s key either');
++ assert.strictEqual(r.provider, 'openvibe-ai');
++ assert.strictEqual(r.text, 'from the operator key');
++ });
++
++ await check('AI\'s refusal becomes a plain error that says where to add the key', async () => {
++ const calls = [];
++ const models = createModels({ config: aiConfig(), fetchImpl: fakeFetch(calls, () => reply(404, { error: { message: 'no credential', type: 'invalid_request_error', code: 'credential.not_found' } })), log: { log() {}, warn() {}, error() {} } });
++ await assert.rejects(
++ () => models.withChoice({ subject: PERSON, provider: 'openai', model: null }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }] })),
++ (err) => {
++ assert.strictEqual(err.code, 'actor.model.no_own_key');
++ assert.strictEqual(err.message, 'Add your openai key at ai.openvibe.services first');
++ return true;
++ },
++ );
++ });
++
++ await check('\'auto\' keeps today\'s behaviour: the operator\'s provider, and no call to AI', async () => {
++ const calls = [];
++ const models = createModels({ config: aiConfig(), fetchImpl: fakeFetch(calls), log: { log() {}, warn() {}, error() {} } });
++ const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }] });
++ assert.ok(calls.every((c) => !c.url.includes('ai.openvibe.services')), 'AI is not called');
++ assert.ok(calls.some((c) => c.url.includes('api.openai.test')), 'the operator\'s provider answers');
++ assert.strictEqual(r.provider, 'openai');
++ });
++
++ } finally { await t.close(); }
++
++ done();
++})();