openvibers/openvibe.actor Commits

Runs on: my computer (plan L55 C3) (#272)

OP OpenVibers committed 11 days ago · 384b67d04f · 9 files, +214 −10

A person chooses where their tasks run, beside the model they chose: OpenVibe, or their own computer.
On their computer, every model call of the agent loop (the run's and its check's) goes to the model they
run at home through OpenVibe.AI's node credential, whatever key they chose above it, and a coding task is
pinned to Codes on their machine. Private mode and a public chat's ask stay on OpenVibe.

- migration 0027: actor_model_prefs.runs_on ('openvibe' default, or 'computer'), expand-only
- model-prefs: RUNS_ON, runsOnFor, setRunsOn (keeps the model choice), computerChoice; wire says runs_on
- PUT /api/v1/me/model takes runs_on alone or with the choice
- /chat/model: a 'Where it runs' section and POST /chat/runs-on (same origin only)
- the explanation says the loop thought on the person's computer
- account data exports runs_on

Changes

ddiff --git a/migrations/0027_actor_runs_on.sql b/migrations/0027_actor_runs_on.sql
nnew file mode 100644
iindex 0000000..7205c20
---- /dev/null
++++ b/migrations/0027_actor_runs_on.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Plan L55 C3, "Runs on: my computer": where a person's tasks run. 'openvibe' (the default, today's behaviour) or
++-- 'computer' (every turn goes to OpenVibe.Codes on the person's own paired machine; robot and computer-control tasks
++-- keep their own agents). A new column with a default: every row stays valid, and the code before this one never reads
++-- or writes it.
++ALTER TABLE actor_model_prefs ADD COLUMN IF NOT EXISTS runs_on text NOT NULL DEFAULT 'openvibe';
++ALTER TABLE actor_model_prefs DROP CONSTRAINT IF EXISTS actor_model_prefs_runs_on_check;
++ALTER TABLE actor_model_prefs ADD CONSTRAINT actor_model_prefs_runs_on_check CHECK (runs_on IN ('openvibe', 'computer'));
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 5ca7657..6142e44 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -112,6 +112,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.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-h2 { margin: 40px 0 8px; padding-top: 24px; border-top: 1px solid var(--line); font-size: clamp(19px, 3vw, 22px); line-height: 1.2; scroll-margin-top: 72px; }
.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); }
@@@ -124,6 +125,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.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-setups { display: flex; flex-wrap: wrap; gap: 6px 16px; }
.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; }
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 60366c8..5e965f1 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -217,7 +217,14 @@ function createApi(ctx) {
});
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 || {});
++ const body = req.body || {};
++ // Where it runs (plan L55 C3) may come alone or with the model choice.
++ if (body.runs_on !== undefined) {
++ const r = await modelPrefs.setRunsOn(s, req.principal.requester, body.runs_on);
++ if (r.error) return problem(req, res, 422, 'actor.model.invalid', r.error);
++ if (body.choice === undefined && body.model === undefined) return res.json({ ...modelPrefs.wire(await modelPrefs.get(s, req.principal.requester)), keys: null });
++ }
++ const out = await modelPrefs.put(s, req.principal.requester, body);
if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);
res.json({ ...modelPrefs.wire(out.row), keys: null });
});
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 75e6ef9..73474b9 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -390,6 +390,7 @@ ${modelChip(model)}
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 runsSaved = req.query.saved === 'runs';
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, {
@@@ -407,6 +408,14 @@ ${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p,
${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&amp;')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}
<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>
<button class="cm-save" type="submit">Save</button>
++</form>
++<h2 class="cm-h2" id="runs-on">Where it runs</h2>
++<p class="cm-lede">On your computer, the model you run at home does all of Actor's thinking, whatever you picked above, and a coding task runs entirely on your machine through OpenVibe.Codes. Searching and reading the web still happen on OpenVibe. Private mode always stays on OpenVibe's own open model.</p>
++${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on ${model.runs_on === 'computer' ? 'your computer' : 'OpenVibe'}.</p>` : ''}
++<form class="cm-form" method="post" action="/chat/runs-on">
++<label class="cm-opt"><input type="radio" name="runs_on" value="openvibe"${model.runs_on !== 'computer' ? raw(' checked') : ''}><span><b>OpenVibe</b><small>Quick, from anywhere, on the model you chose above.</small></span></label>
++<label class="cm-opt"><input type="radio" name="runs_on" value="computer"${model.runs_on === 'computer' ? raw(' checked') : ''}><span><b>My computer</b><small>Nothing is billed for the model, and it answers only while your computer is on. Needs OpenVibe Node v0.36.0 or newer with a local model (Ollama, LM Studio or llama.cpp). Usually slower.</small></span><span class="cm-setups"><a class="cm-setup" href="${allowUrl('node')}" target="_blank" rel="noopener">Add your computer's model</a><a class="cm-setup" href="https://openvibe.bot/machines" target="_blank" rel="noopener">Add your machine for coding</a></span></label>
++<button class="cm-save" type="submit">Save</button>
</form></main>`,
});
});
@@@ -421,6 +430,15 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio
return res.redirect(303, '/chat/model?saved=1');
});
++ r.post('/chat/runs-on', express.urlencoded({ extended: false, limit: '1kb' }), 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 out = await modelPrefs.setRunsOn(s, requesterOf(req), (req.body || {}).runs_on);
++ if (out.error) return res.redirect(303, `/chat/model?error=${encodeURIComponent('That did not save: ' + out.error)}#runs-on`);
++ return res.redirect(303, '/chat/model?saved=runs#runs-on');
++ });
++
r.get('/chat', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex c89cae9..21d975c 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -66,7 +66,7 @@ const TABLES = [
// 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'] },
++ columns: ['requester', 'choice', 'model', 'runs_on', 'updated_at'] },
// What they asked Actor in chat (chat/asks.js): the message it answered, the room, the task it became.
{ table: 'actor_chat_asks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'chat-asks.json' },
// The connections they added (stage V2-11): the servers and agents, and which of their tools are on. The token
ddiff --git a/server/runtime/model-prefs.js b/server/runtime/model-prefs.js
iindex 5c2aec7..09dfc71 100644
---- a/server/runtime/model-prefs.js
++++ b/server/runtime/model-prefs.js
@@@ -18,6 +18,9 @@
* (server/identity/account-data.js).
*/
const CHOICES = ['auto', 'openvibe'];
++// Where the tasks run (plan L55 C3, migration 0027): OpenVibe's cloud, or the person's own paired machine through
++// OpenVibe.Codes (the codes agent), for every turn but robot and computer-control tasks.
++const RUNS_ON = ['openvibe', 'computer'];
const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'];
// Plus their own computer (plan L55 C3): node is OpenVibe.AI's name for it.
const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];
@@@ -45,7 +48,7 @@ function parse(body) {
/** 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]);
++ const row = await s.db.maybe('SELECT requester, choice, model, runs_on, updated_at FROM actor_model_prefs WHERE requester = $1', [requester]);
return row || null;
}
@@@ -75,15 +78,41 @@ async function choiceFor(s, requester) {
return { subject, provider: row.choice.slice(4), model: row.model || null };
}
++/** Where the person's tasks run: 'openvibe' (no row, or the default) or 'computer'. */
++async function runsOnFor(s, requester) {
++ const row = await get(s, requester);
++ return row && RUNS_ON.includes(row.runs_on) ? row.runs_on : 'openvibe';
++}
++
++/** The model choice "Runs on: my computer" means: the person's own computer, through OpenVibe.AI's node credential. */
++function computerChoice(requester) {
++ const subject = String(requester || '').startsWith('user:') ? requester.slice(5) : null;
++ return subject ? { subject, provider: 'node', model: null } : null;
++}
++
++/** Set where the person's tasks run. Returns { runs_on } or { error }; the model choice is left as it is. */
++async function setRunsOn(s, requester, value, now = () => new Date().toISOString()) {
++ const v = String(value == null ? '' : value).trim();
++ if (!RUNS_ON.includes(v)) return { error: 'runs_on: one of ' + RUNS_ON.join(', ') };
++ if (!requester) return { error: 'no person' };
++ await s.db.query(
++ "INSERT INTO actor_model_prefs (requester, choice, model, runs_on, updated_at) VALUES ($1, 'auto', NULL, $2, $3)"
++ + ' ON CONFLICT (requester) DO UPDATE SET runs_on = excluded.runs_on, updated_at = excluded.updated_at',
++ [requester, v, now()],
++ );
++ return { runs_on: v };
++}
++
/** The choice as the API answers it. */
function wire(row) {
-- if (!row) return { choice: 'auto', provider: null, model: null, updated_at: null };
++ if (!row) return { choice: 'auto', provider: null, model: null, runs_on: 'openvibe', updated_at: null };
return {
choice: row.choice,
provider: String(row.choice || '').startsWith('own:') ? row.choice.slice(4) : null,
model: row.model || null,
++ runs_on: RUNS_ON.includes(row.runs_on) ? row.runs_on : 'openvibe',
updated_at: row.updated_at || null,
};
}
--module.exports = { CHOICES, OWN_PROVIDERS, KEY_PROVIDERS, parse, get, put, choiceFor, wire };
++module.exports = { CHOICES, RUNS_ON, OWN_PROVIDERS, KEY_PROVIDERS, parse, get, put, choiceFor, runsOnFor, setRunsOn, computerChoice, wire };
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 833a181..32dd625 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -316,6 +316,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (fromChat && !PUBLIC_CLASSES.includes(cls)) {
return await setState(id, 'failed', { error_code: 'actor.task.not_public', error_detail: 'Asked in a public chat, Actor uses public tools only: ask on openvibe.actor to use your own computers, robots or code.', finished_at: iso() });
}
++ // Where the person's tasks run (plan L55 C3, /chat/model): 'computer' sends every model call of the loop to
++ // the model on their own computer, and a coding task to Codes on their machine. Never in private mode (the
++ // open model on OpenVibe's server is the point) nor for a public chat's ask.
++ const runsOn = row.mode === 'private' || fromChat || !String(row.requester || '').startsWith('user:')
++ ? 'openvibe' : await modelPrefs.runsOnFor(s, row.requester).catch(() => 'openvibe');
// Actor v2: every non-private task that is not code-only runs through the one loop. Private mode keeps
// the local model with no network tools, and a coding task keeps the Codes agent (which the loop can also
@@@ -330,7 +335,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const notifyTask = (kind, detail) => tellPerson(row, kind, detail || {});
const why = resume ? `the one agent loop resumed after a restart (step ${resume.step})`
: fork ? `the one agent loop forked from step ${fork.step}`
-- : `the one agent loop (${row.mode === 'best' ? 'best' : 'smart'} tier)`;
++ : runsOn === 'computer' ? 'the one agent loop, thinking on the model on your computer (Runs on: my computer)'
++ : `the one agent loop (${row.mode === 'best' ? 'best' : 'smart'} tier)`;
explanation.push({ selected: agentId, objective: row.mode === 'best' ? 'correctness' : 'balanced', reasons: [why], candidates: [], decided_at: iso() });
await store.updateTask(s, id, { explanation });
if (!resume && !fork) await emit(id, { kind: 'output', step: 'plan', agent: agentId, text: 'The agent starts with a plan, then works through it.' });
@@@ -352,7 +358,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
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);
++ let choice = await modelPrefs.choiceFor(s, row.requester).catch(() => null);
++ // "Runs on: my computer": the model on the person's computer, whatever else they chose above it (a
++ // model id they gave for their computer is kept).
++ if (runsOn === 'computer' && !(choice && choice.provider === 'node')) choice = modelPrefs.computerChoice(row.requester);
try {
out = await withChoice(models, choice, async () => loop.run({
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
@@@ -530,13 +539,18 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
}
}
++ // "Runs on: my computer" (plan L55 C3): a coding task goes to the codes agent, OpenVibe.Codes on the person's
++ // own paired machine, unless the task already names its agent.
++ const pinnedAgent = row.agent || (runsOn === 'computer' && cls === 'code' ? 'codes' : null);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
-- const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });
++ const placement = route({ cls, mode: row.mode, agent: pinnedAgent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });
explanation.push(placement);
await store.updateTask(s, id, { explanation });
if (!placement.selected) {
if (attempt > 1) break;
-- const why = whyNone(placement, { cls, mode: row.mode, budgetUsd: budgetLeft() });
++ const why = pinnedAgent === 'codes' && !row.agent
++ ? { code: 'actor.no_agent', detail: 'Your coding tasks run on your own machine (Runs on: my computer) through OpenVibe.Codes, and it is not connected here. Add your machine on https://openvibe.bot/machines, or switch Runs on back to OpenVibe at https://openvibe.actor/chat/model.' }
++ : whyNone(placement, { cls, mode: row.mode, budgetUsd: budgetLeft() });
return await setState(id, 'failed', { error_code: why.code, error_detail: why.detail, finished_at: iso() });
}
const agentId = placement.selected;
ddiff --git a/test/memory-migration.test.js b/test/memory-migration.test.js
iindex 02dcdf0..7c1cfbe 100644
---- a/test/memory-migration.test.js
++++ b/test/memory-migration.test.js
@@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026']);
++ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027']);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
ddiff --git a/test/runs-on.test.js b/test/runs-on.test.js
nnew file mode 100644
iindex 0000000..68dc86e
---- /dev/null
++++ b/test/runs-on.test.js
@@@ -0,0 +1,126 @@
++'use strict';
++// "Runs on: my computer" (plan L55 C3; the owner, 11-01: "a remote session, or a local agent session"): a person
++// chooses where their tasks run, beside the model they chose. On their computer, every model call of the agent loop
++// goes to the model on their own computer (OpenVibe.AI's node credential, whatever key they chose above it), and a
++// coding task is pinned to Codes on their machine. Private mode and a public chat's ask stay on OpenVibe.
++// node test/runs-on.test.js
++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 quiet = { log() {}, warn() {}, error() {}, info() {} };
++const reply = (status, obj) => ({ ok: status >= 200 && status < 300, status, json: async () => obj, text: async () => JSON.stringify(obj) });
++
++/** Models that reach a fake OpenVibe.AI, and record every call. */
++function aiModels(calls) {
++ const config = {
++ 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' },
++ };
++ const fetchImpl = async (url, opts = {}) => {
++ const u = String(url);
++ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_service_token', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });
++ calls.push({ url: u, headers: opts.headers || {} });
++ if (u.endsWith('/models')) return reply(200, { data: [{ id: 'gpt-5-mini' }] });
++ // The answer's check asks for a verdict as JSON; the run itself gets the answer.
++ const checking = String(opts.body || '').includes('\\"claims\\"');
++ const content = checking ? JSON.stringify({ claims: [{ claim: 'Two plus two is four.', kind: 'general', evidence: [], supported: true }], ok: true, reason: 'general knowledge' }) : 'Four.';
++ return reply(200, { choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }], model: 'llama3', usage: { prompt_tokens: 4, completion_tokens: 1 }, x_openvibe: { cost_usd: 0 } });
++ };
++ return createModels({ config, fetchImpl, log: quiet });
++}
++
++(async () => {
++ const calls = [];
++ const models = aiModels(calls);
++ // The loop, reduced to one model call: what matters here is which model the run's calls go to.
++ const loop = {
++ run: async ({ task }) => { const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: task }] }); return { text: r.text, state: {} }; },
++ close: async () => {}, planText: () => '', escalate: async () => null, revise: async () => null,
++ };
++ const t = await boot({ models, loop });
++ const kim = t.network.addUser('runs-kim');
++ try {
++ await check('the default is OpenVibe, and the API sets it alone without touching the model choice', async () => {
++ const before = await t.get('/api/v1/me/model', { as: kim });
++ assert.strictEqual(before.json().runs_on, 'openvibe');
++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:openai' }, headers: SAME });
++ const put = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'computer' }, headers: SAME });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.deepStrictEqual([put.json().runs_on, put.json().choice], ['computer', 'own:openai'], 'the model choice is kept');
++ const bad = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'the-moon' }, headers: SAME });
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer', 'the bad one never landed');
++ // A choice change keeps where it runs.
++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'auto' }, headers: SAME });
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer');
++ });
++
++ await check('on their computer, the run\'s model calls go to the model on their own computer', async () => {
++ calls.length = 0;
++ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?' }, headers: SAME });
++ assert.strictEqual(made.status, 201, made.text);
++ const row = await t.waitFor(made.json().id);
++ assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));
++ const loopCalls = calls.filter((c) => c.url.endsWith('/chat/completions'));
++ assert.ok(loopCalls.length >= 1, 'the loop called a model');
++ assert.ok(loopCalls.every((c) => c.headers['x-openvibe-credential'] === `node:${kim.subject || kim.id}`), JSON.stringify(loopCalls.map((c) => c.headers['x-openvibe-credential'])));
++ const why = JSON.stringify(row.explanation);
++ assert.ok(why.includes('on the model on your computer'), 'the explanation says where it ran');
++ });
++
++ await check('back on OpenVibe, the same person\'s run uses the model they chose (auto: no call to AI)', async () => {
++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'openvibe' }, headers: SAME });
++ calls.length = 0;
++ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?' }, headers: SAME });
++ const row = await t.waitFor(made.json().id);
++ assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));
++ assert.ok(calls.every((c) => !c.headers['x-openvibe-credential']), 'no person credential');
++ });
++
++ await check('private mode stays on OpenVibe\'s own open model even when it is set to the computer', async () => {
++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'computer' }, headers: SAME });
++ calls.length = 0;
++ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?', mode: 'private' }, headers: SAME });
++ await t.waitFor(made.json().id);
++ assert.ok(calls.every((c) => !String(c.headers['x-openvibe-credential'] || '').startsWith('node:')), 'never their computer');
++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'openvibe' }, headers: SAME });
++ });
++
++ await check('/chat/model: the "Where it runs" form saves from openvibe.actor only', async () => {
++ const page = await t.get('/chat/model', { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('id="runs-on"'));
++ assert.ok(page.text.includes('name="runs_on" value="openvibe" checked'));
++ assert.ok(page.text.includes('https://ai.openvibe.services/you?allow=actor&amp;provider=node'), 'set up the computer\'s model at OpenVibe.AI');
++ assert.ok(page.text.includes('https://openvibe.bot/machines'), 'add the machine for coding');
++ const saved = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'computer' }, headers: SAME });
++ assert.strictEqual(saved.status, 303);
++ assert.strictEqual(saved.headers.get('location'), '/chat/model?saved=runs#runs-on');
++ const after = await t.get('/chat/model?saved=runs', { as: kim });
++ assert.ok(after.text.includes('name="runs_on" value="computer" checked'));
++ assert.ok(after.text.includes('Your next task runs on your computer.'));
++ const cross = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'openvibe' }, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 403);
++ const bad = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'nope' }, headers: SAME });
++ assert.match(bad.headers.get('location'), /^\/chat\/model\?error=/);
++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer');
++ const anon = await t.get('/chat/runs-on', { form: { runs_on: 'computer' }, headers: SAME });
++ assert.strictEqual(anon.status, 303);
++ assert.match(anon.headers.get('location'), /^\/auth\/login/);
++ });
++
++ await check('the choice "Runs on: my computer" means: AI\'s node credential, for a person only', async () => {
++ assert.deepStrictEqual(prefs.computerChoice('user:usr_1'), { subject: 'usr_1', provider: 'node', model: null });
++ assert.strictEqual(prefs.computerChoice('ip:1.2.3.4'), null);
++ assert.deepStrictEqual(prefs.RUNS_ON, ['openvibe', 'computer']);
++ assert.deepStrictEqual(prefs.wire(null).runs_on, 'openvibe');
++ });
++ } finally { await t.close(); }
++ done();
++})();