openvibers/openvibe.actor Commits

Your tasks can run on your own computer's model (L55 C3) (#267)

OP OpenVibers committed 11 days ago · 28db7c5369 · 7 files, +51 −10

A new choice, own:node: Actor calls OpenVibe.AI with x-openvibe-credential
node:<person>, and AI runs the loop's chat completions, tools included, on
the model the person added under Your computer at ai.openvibe.services/you,
on whichever of their computers is on with it (OpenVibe Node v0.36.0, through
Bot). No key, nothing billed, and only after they allow Actor there. The
chip and /chat/model show it as its own group with the set-up link;
migration 0026 widens the choice list (expand: old rows stay valid, old code
never writes it).

Changes

ddiff --git a/migrations/0026_actor_model_prefs_node.sql b/migrations/0026_actor_model_prefs_node.sql
nnew file mode 100644
iindex 0000000..90936c1
---- /dev/null
++++ b/migrations/0026_actor_model_prefs_node.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- Plan L55 C3: a person may run their tasks on a model on their own computer ('own:node': OpenVibe.AI's "Your
++-- computer", through their OpenVibe Node; no key). The choice list widens by one; every row that was valid stays
++-- valid, and the code before this one never writes the new value.
++ALTER TABLE actor_model_prefs DROP CONSTRAINT IF EXISTS actor_model_prefs_choice_check;
++ALTER TABLE actor_model_prefs ADD CONSTRAINT actor_model_prefs_choice_check
++ CHECK (choice IN ('auto', 'openvibe', 'own:openai', 'own:anthropic', 'own:deepseek', 'own:google', 'own:openrouter', 'own:mistral', 'own:node'));
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 699aa5f..a2f9a1d 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -451,11 +451,11 @@
});
var p = j && j.provider;
var name = p ? (b.querySelector('b') || {}).textContent : '';
-- if (label) label.textContent = p ? 'Your ' + name + ' key' : 'Auto';
++ if (label) label.textContent = p === 'node' ? 'Your computer' : 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: ');
++ fine.append(p === 'node' ? 'Your next task runs on your computer once you add its model and allow Actor: ' : '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';
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 143396d..e6376bc 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -34,10 +34,10 @@ 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 PROVIDER_NAMES = { openai: 'OpenAI', anthropic: 'Anthropic', deepseek: 'DeepSeek', google: 'Google Gemini', openrouter: 'OpenRouter', mistral: 'Mistral', node: 'computer' };
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 modelLabel = (m) => (m && m.provider === 'node' ? 'Your computer' : m && m.provider ? `Your ${PROVIDER_NAMES[m.provider] || m.provider} key` : 'Auto');
const glyphs = require('openvibe-shared/glyphs');
// One conversation UI for the network (plan L55 C4): the sidebar, the turns, the work, the answer and the composer.
const thread = require('openvibe-shared/thread');
@@@ -290,7 +290,9 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
<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>
++<ul class="ccm-list">${modelPrefs.KEY_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-head">Your computer</p>
++<ul class="ccm-list"><li class="ccm-own" data-ccm-provider="node">${item('own:node', 'Your computer', 'A model you run at home, through OpenVibe Node. Nothing billed.')}<a class="ccm-setup" href="${allowUrl('node')}" 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>`;
}
@@@ -375,12 +377,14 @@ ${modelChip(model)}
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>
++<p class="cm-lede">Actor can run on OpenVibe's keys, on your own key for a provider you use, or on a model you run on your own computer. Your key stays sealed in OpenVibe.AI: Actor never sees it, and only uses it (or your computer) after you allow Actor there.</p>
${saved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on ${modelLabel(model)}.</p>` : ''}${error ? html`<p class="cm-err" role="alert">${error}</p>` : ''}
<form class="cm-form" method="post" action="/chat/model">
${radio('auto', 'Auto, OpenVibe\'s', 'Actor picks a model for each step, on OpenVibe\'s keys. Free within the daily allowance.')}
<p class="cm-head">Your own key</p>
--${modelPrefs.OWN_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&amp;')}" target="_blank" rel="noopener">Add or allow the key</a>`))}
++${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&amp;')}" target="_blank" rel="noopener">Add or allow the key</a>`))}
++<p class="cm-head">Your computer</p>
++${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&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></main>`,
ddiff --git a/server/runtime/model-prefs.js b/server/runtime/model-prefs.js
iindex dadbc18..5c2aec7 100644
---- a/server/runtime/model-prefs.js
++++ b/server/runtime/model-prefs.js
@@@ -7,6 +7,8 @@
* 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
++ * own:node a model on the person's own computer (OpenVibe.AI's "Your computer": their OpenVibe Node, through
++ * OpenVibe.Bot; no key, nothing billed), used only after they allow Actor there
*
* 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.
@@@ -16,7 +18,9 @@
* (server/identity/account-data.js).
*/
const CHOICES = ['auto', 'openvibe'];
--const OWN_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'];
++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'];
// 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}$/;
@@@ -82,4 +86,4 @@ function wire(row) {
};
}
--module.exports = { CHOICES, OWN_PROVIDERS, parse, get, put, choiceFor, wire };
++module.exports = { CHOICES, OWN_PROVIDERS, KEY_PROVIDERS, parse, get, put, choiceFor, wire };
ddiff --git a/test/chat-model.test.js b/test/chat-model.test.js
iindex 7013db6..c4c3359 100644
---- a/test/chat-model.test.js
++++ b/test/chat-model.test.js
@@@ -26,6 +26,10 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.ok(page.text.includes(`https://ai.openvibe.services/you?allow=actor&amp;provider=${p}`), `${p}: set up at OpenVibe.AI`);
});
assert.ok(page.text.includes('href="/chat/model"'), 'the plain form, for no script');
++ // Their own computer (L55 C3): its own group, set up and allowed at OpenVibe.AI.
++ assert.ok(page.text.includes('<p class="ccm-head">Your computer</p>'));
++ assert.ok(page.text.includes('data-ccm-choice="own:node" aria-pressed="false"'));
++ assert.ok(page.text.includes('https://ai.openvibe.services/you?allow=actor&amp;provider=node'));
});
await check('a tap saves through the API from the page; the chip says the person\'s own key after', async () => {
@@@ -53,6 +57,12 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
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');
++ const pc = await t.get('/chat/model', { as: kim, form: { choice: 'own:node' }, headers: SAME });
++ assert.strictEqual(pc.status, 303);
++ const pcPage = await t.get('/chat/model?saved=1', { as: kim });
++ assert.match(pcPage.text, /Saved\. Your next task runs on Your computer\./);
++ assert.match(pcPage.text, /<input type="radio" name="choice" value="own:node" checked>/);
++ assert.match((await t.get('/chat', { as: kim })).text, /<span data-ccm-label>Your computer<\/span>/);
});
await check('the script: the menu asks which keys are ready, saves in one request, closes on Escape', async () => {
ddiff --git a/test/memory-migration.test.js b/test/memory-migration.test.js
iindex 65d17e0..02dcdf0 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']);
++ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026']);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
ddiff --git a/test/model-prefs.test.js b/test/model-prefs.test.js
iindex 2759d5e..dd3c89b 100644
---- a/test/model-prefs.test.js
++++ b/test/model-prefs.test.js
@@@ -84,6 +84,22 @@ function fakeFetch(calls, chatAnswer) {
assert.ok(!JSON.stringify(map.columns).includes('key'), 'no key material is exported');
});
++ await check('your computer (L55 C3): own:node runs through OpenVibe.AI on the person\'s own machine, with the tools', async () => {
++ const put = await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:node' }, headers: SAME });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.deepStrictEqual([put.json().choice, put.json().provider], ['own:node', 'node']);
++ const calls = [];
++ const models = createModels({ config: aiConfig(), fetchImpl: fakeFetch(calls), log: { log() {}, warn() {}, error() {} } });
++ await models.withChoice({ subject: PERSON, provider: 'node', model: null }, () => models.chat({
++ tier: 'smart', messages: [{ role: 'user', content: 'hello' }],
++ tools: [{ type: 'function', function: { name: 'noop', parameters: { type: 'object', properties: {} } } }],
++ }));
++ assert.strictEqual(calls.length, 1);
++ assert.strictEqual(calls[0].headers['x-openvibe-credential'], 'node:' + PERSON, 'AI\'s name for their computer');
++ assert.ok(Array.isArray(calls[0].body.tools), 'the loop\'s tools go with it');
++ await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'auto' }, headers: SAME });
++ });
++
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() {} } });