openvibers/openvibe.actor Commits
A person chooses the model their tasks run on: their own key or OpenVibe's (plan L55 C3) (#231)
Changes
@@@ -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 -->@@@ -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"],nnew file mode 100644@@@ -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++);@@@ -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",@@@ -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"@@@ -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; }@@@ -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 = []; }@@@ -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: {@@@ -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)');@@@ -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 },@@@ -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/${workconst 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.moconst 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, '&')}" 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));@@@ -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('.');@@@ -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 tokennnew file mode 100644@@@ -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 };@@@ -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 - 1function 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 };@@@ -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) {@@@ -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 },nnew file mode 100644@@@ -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&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);++ }++})();@@@ -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');}});@@@ -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', 'registfunction 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 });@@@ -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]);nnew file mode 100644@@@ -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();++})();
@@ -254,7 +254,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.254.0
+- openvibe-contracts: v0.269.0
- openvibe-sdk: v0.74.0
- openvibe-sdk: v0.74.0
- openvibe-shared: v4.73.0
- openvibe-shared: v4.73.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"port": 4950,
"domain": "openvibe.actor",
"domain": "openvibe.actor",
- "contracts": "openvibe-contracts v0.254.0",
+ "contracts": "openvibe-contracts v0.269.0",
"sdk": "openvibe-sdk v0.74.0",
"sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.73.0",
"shared": "openvibe-shared v4.73.0",
"packages": [
"packages": [
- "openvibe-contracts v0.254.0",
+ "openvibe-contracts v0.269.0",
"openvibe-sdk v0.74.0",
"openvibe-sdk v0.74.0",
"openvibe-shared v4.73.0"
"openvibe-shared v4.73.0"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.73.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1146,9 +1146,9 @@
@@ -1146,9 +1146,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.73.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -242,3 +242,51 @@ html[data-ov-motion=reduced] .ct-answer.is-streaming .sw { opacity: 1 !important
@@ -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 span { display: grid; line-height: 1.25; }
.ct-file b { font-size: 13.5px; font-weight: 600; }
.ct-file b { font-size: 13.5px; font-weight: 600; }
.ct-file small { font-size: 12px; color: var(--text-muted, #7386a3); }
.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; }
@@ -449,6 +449,66 @@
@@ -449,6 +449,66 @@
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(str).then(ok, function () {});
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(str).then(ok, function () {});
}
}
decorate(chat);
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 = []; }
function stop() { streams.forEach(function (s) { try { s.close(); } catch (e) { /* */ } }); streams = []; }
@@ -82,6 +82,18 @@ function load(env = process.env) {
@@ -82,6 +82,18 @@ function load(env = process.env) {
},
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
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
// 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.
providers: {
providers: {
@@ -30,6 +30,7 @@ const personal = require('../agents/personal');
@@ -30,6 +30,7 @@ const personal = require('../agents/personal');
const memoryStore = require('../memory');
const memoryStore = require('../memory');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
const items = require('../actions/items');
const items = require('../actions/items');
+const modelPrefs = require('../runtime/model-prefs');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -191,6 +192,35 @@ function createApi(ctx) {
@@ -191,6 +192,35 @@ function createApi(ctx) {
res.status(204).end();
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) => {
r.post('/route', limits.budget('actor.route'), (req, res) => {
const b = req.body || {};
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)');
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)');
@@ -30,6 +30,8 @@ const BUDGETS = {
@@ -30,6 +30,8 @@ const BUDGETS = {
'actor.task.create': { minute: 6, hour: 60 },
'actor.task.create': { minute: 6, hour: 60 },
'actor.agent.run': { minute: 6, hour: 60 },
'actor.agent.run': { minute: 6, hour: 60 },
'actor.agent.manage': { minute: 30, hour: 600 },
'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.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
@@ -30,6 +30,14 @@ const taskEvents = require('../../public/js/task-events');
@@ -30,6 +30,14 @@ const taskEvents = require('../../public/js/task-events');
const { sameOrigin } = require('./principal');
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');
+
+// 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 glyphs = require('openvibe-shared/glyphs');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
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=
@@ -280,7 +288,24 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
</aside>`;
</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') : ''}>
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>` : ''}
${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}
${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
@@ -290,6 +315,7 @@ ${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>
<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">
<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">
<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>
<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>
<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>`}
${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
@@ -321,6 +347,7 @@ ${working ? html`<button class="cc-stop" type="submit" formaction="/tasks/${work
const lastTurn = turns[turns.length - 1] || null;
const lastTurn = turns[turns.length - 1] || null;
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
const fileMap = await inputs.list(s, turns.map((t) => t.id));
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 turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [] })));
const title = first ? clip(first.task, 60) : 'New chat';
const title = first ? clip(first.task, 60) : 'New chat';
return send(res, status, {
return send(res, status, {
@@ -337,7 +364,7 @@ ${sidebar(chats, first ? first.id : null)}
@@ -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>
${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>`}
<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>
</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>
</section>
</div>`,
</div>`,
});
});
@@ -345,6 +372,41 @@ ${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mo
@@ -345,6 +372,41 @@ ${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mo
const loginTo = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
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, '&')}" 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) => {
r.get('/chat', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
@@ -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'];
+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) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -62,6 +62,11 @@ const TABLES = [
@@ -62,6 +62,11 @@ const TABLES = [
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
{ 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.
// 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' },
{ 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.
// 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' },
{ 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
// The connections they added (stage V2-11): the servers and agents, and which of their tools are on. The token
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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 };
@@ -29,6 +29,8 @@
@@ -29,6 +29,8 @@
* }
* }
*/
*/
+const { AsyncLocalStorage } = require('node:async_hooks');
+const { serviceAuth } = require('openvibe-contracts');
const { chatClient, priceUsage, ProviderError } = require('../agents/providers');
const { chatClient, priceUsage, ProviderError } = require('../agents/providers');
class ModelError extends Error {
class ModelError extends Error {
@@ -57,6 +59,10 @@ const SMALL = { test: (m) => !FULL.has(String(m)) && SMALL_RE.test(String(m)) };
@@ -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 TIERS = ['fast', 'smart', 'best', 'check', 'vision'];
const PROVIDERS = ['openai', 'deepseek', 'runtime', 'compatible', 'local'];
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. */
/** Parse a pinned tier value: "openai:gpt-5.1", or a bare model on the first configured provider. */
function parsePin(value) {
function parsePin(value) {
@@ -73,6 +79,7 @@ const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1
@@ -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() } = {}) {
function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() } = {}) {
const cfg = config.models || { tiers: {}, providerOrder: [], listTimeoutMs: 5000 };
const cfg = config.models || { tiers: {}, providerOrder: [], listTimeoutMs: 5000 };
const p = config.providers || {};
const p = config.providers || {};
+ const ai = config.ai || {};
const order = (cfg.providerOrder && cfg.providerOrder.length ? cfg.providerOrder : PROVIDERS).filter((k) => PROVIDERS.includes(k));
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);
for (const k of PROVIDERS) if (!order.includes(k)) order.push(k);
@@ -83,6 +90,8 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -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' },
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' },
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' },
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 listed = {}; // provider → the provider's model ids (as read at boot)
const listErrors = {}; // provider → why its list could not be read
const listErrors = {}; // provider → why its list could not be read
@@ -107,6 +116,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -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 (!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 === '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 === '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 };
return { available: true };
}
}
@@ -231,8 +245,139 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -231,8 +245,139 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
return resolveNow(tier, opts);
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. */
/** 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();
await warm();
if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });
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 });
const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });
@@ -277,11 +422,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -277,11 +422,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
}
}
return {
return {
- warm, chat, resolve, tierList, modelList, resolveAll,
+ warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys,
tierConfig: cfg, PREFERRED, PREFERRED_CHECKED, TIERS,
tierConfig: cfg, PREFERRED, PREFERRED_CHECKED, TIERS,
get warmed() { return warmed; },
get warmed() { return warmed; },
listErrors: () => ({ ...listErrors }),
listErrors: () => ({ ...listErrors }),
};
};
}
}
-module.exports = { createModels, ModelError, parsePin, PREFERRED, PREFERRED_CHECKED, TIERS, PROVIDERS };
+module.exports = { createModels, ModelError, parsePin, PREFERRED, PREFERRED_CHECKED, TIERS, PROVIDERS, AI_PROVIDER };
@@ -19,6 +19,10 @@ const PERSON_WAIT_MAX_MS = 15 * 60_000;
@@ -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.
* At most config.maxRunning tasks run at once; the rest wait queued in order.
*/
*/
const store = require('./store');
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 catalog = require('../agents/catalog');
const { classify, heuristic } = require('../agents/classify');
const { classify, heuristic } = require('../agents/classify');
const { route, whyNone } = require('../agents/router');
const { route, whyNone } = require('../agents/router');
@@ -346,8 +350,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -346,8 +350,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
controls.set(id, control);
controls.set(id, control);
// The files attached to this message in the chat (plan L55 C2): text as data; images described first.
// 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 []; });
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 {
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,
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.
// 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) : [],
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 = () =
@@ -356,7 +363,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
personText, agentId: ownAgent, notifyTask,
personText, agentId: ownAgent, notifyTask,
askPerson, control, resume, fork, spent: () => spent,
askPerson, control, resume, fork, spent: () => spent,
waits, waitEvent: wakePayload, attachments,
waits, waitEvent: wakePayload, attachments,
- });
+ }));
// The run parked (plan A2): the wait is open, so the task is not finished. It stays open
// 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
// ('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.
// 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 = () =
@@ -383,11 +390,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
}
}
try {
try {
await setState(id, 'verifying', { agentForEvent: agentId });
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,
task: row.task, answer: current.text, evidence: current.evidence, models,
writerFamily: current.family || null, signal, now: () => now(),
writerFamily: current.family || null, signal, now: () => now(),
trace: current.state && current.state.ctx && current.state.ctx.trace,
trace: current.state && current.state.ctx && current.state.ctx.trace,
- });
+ }));
let verdict = await verify(out);
let verdict = await verify(out);
await meter(verdict.cost_usd);
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 });
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 = () =
@@ -430,7 +437,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
for (const c of verdict.contested.slice(0, 2)) {
for (const c of verdict.contested.slice(0, 2)) {
if (budgetLeft() <= 0) break;
if (budgetLeft() <= 0) break;
try {
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 }); }
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) {
} catch (err) {
if (signal && signal.aborted) throw err;
if (signal && signal.aborted) throw err;
@@ -446,14 +453,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -446,14 +453,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (Array.isArray(verdict.claims) && verdict.claims.length) {
if (Array.isArray(verdict.claims) && verdict.claims.length) {
const outward = !!(out.state && out.state.brain && out.state.brain.understanding && out.state.brain.understanding.outward);
const outward = !!(out.state && out.state.brain && out.state.brain.understanding && out.state.brain.understanding.outward);
try {
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,
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,
outward, signal, trace: out.state && out.state.ctx && out.state.ctx.trace,
budgetLeft, writerFamily: out.family || null, now: () => now(),
budgetLeft, writerFamily: out.family || null, now: () => now(),
search: async (query) => (registry
search: async (query) => (registry
? registry.run('search_web', { query }, { config, log, signal, requester: row.requester, now })
? registry.run('search_web', { query }, { config, log, signal, requester: row.requester, now })
: { text: 'no search is configured', isError: true }),
: { text: 'no search is configured', isError: true }),
- });
+ }));
await meter(review.cost_usd);
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) });
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) {
} catch (err) {
@@ -25,6 +25,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -25,6 +25,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'actor.task.create': { minute: 6, hour: 60 },
'actor.task.create': { minute: 6, hour: 60 },
'actor.agent.run': { minute: 6, hour: 60 },
'actor.agent.run': { minute: 6, hour: 60 },
'actor.agent.manage': { minute: 30, hour: 600 },
'actor.agent.manage': { minute: 30, hour: 600 },
+ 'actor.model.manage': { minute: 30, hour: 600 },
'actor.route': { minute: 30, hour: 600 },
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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&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);
+ }
+})();
@@ -72,7 +72,7 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -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 page = await t.get(`/chat/${at}`, { as: kim });
const a = page.text.indexOf(`id="t-${first}"`), b = page.text.indexOf(`id="t-${second}"`);
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.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');
assert.match(page.text, new RegExp(`<a href="/chat/${first}" aria-current="page"`), 'the list names a chat by its first turn');
}
}
});
});
@@ -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'];
+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'];
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
@@ -34,7 +34,7 @@ const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'regist
@@ -34,7 +34,7 @@ const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'regist
function codeRoutes() {
function codeRoutes() {
const out = new Map();
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 [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 guard = m[3].match(/requireCapability\('([a-z0-9.]+)'\)/);
const read = m[3].match(/limits\.reads\('([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 });
out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
@@ -42,7 +42,8 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
@@ -42,7 +42,8 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
[agentMemoryId, agentId, owner, oldStyleText, 'agent', now]);
[agentMemoryId, agentId, owner, oldStyleText, 'agent', now]);
// 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 });
- 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 () => {
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]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -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();
+})();