openvibers/openvibe.actor Commits
Your tasks can run on your own computer's model (L55 C3) (#267)
Changes
nnew file mode 100644@@@ -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'));@@@ -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';@@@ -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, '&')}" target="_blank" rel="noopener">Add or allow the key</a>`))}++${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&')}" target="_blank" rel="noopener">Add or allow the key</a>`))}++<p class="cm-head">Your computer</p>++${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label><button class="cm-save" type="submit">Save</button></form></main>`,@@@ -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 };@@@ -26,6 +26,10 @@ const SAME = { 'sec-fetch-site': 'same-origin' };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');++ // 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&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 () => {@@@ -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]);@@@ -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() {} } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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'));
@@ -451,11 +451,11 @@
@@ -451,11 +451,11 @@
});
});
var p = j && j.provider;
var p = j && j.provider;
var name = p ? (b.querySelector('b') || {}).textContent : '';
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.
// 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) {
if (p && ready && ready.indexOf(p) === -1 && fine) {
fine.textContent = '';
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');
var a = document.createElement('a');
a.href = 'https://ai.openvibe.services/you?allow=actor&provider=' + encodeURIComponent(p);
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';
a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'allow it at OpenVibe.AI';
@@ -34,10 +34,10 @@ const modelPrefs = require('../runtime/model-prefs');
@@ -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
// The model a person's tasks run on (plan L55 C3; the owner, 11-01: "use your own API keys, or OpenVibe's"). Auto is
// OpenVibe's own; "own:<provider>" is the person's key, sealed in OpenVibe.AI, which Actor never sees.
// OpenVibe's own; "own:<provider>" is the person's key, sealed in OpenVibe.AI, which Actor never sees.
-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 AI_SITE = 'https://ai.openvibe.services';
const allowUrl = (provider) => `${AI_SITE}/you?allow=actor&provider=${encodeURIComponent(provider)}`;
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');
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.
// 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');
const thread = require('openvibe-shared/thread');
@@ -290,7 +290,9 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -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">
<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>
<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>
<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>
<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>`;
</div></details>`;
}
}
@@ -375,12 +377,14 @@ ${modelChip(model)}
@@ -375,12 +377,14 @@ ${modelChip(model)}
css: ['css/chat.css'], scripts: [],
css: ['css/chat.css'], scripts: [],
body: html`<main class="cm"><a class="cm-back" href="/chat">${raw(glyphs.svg('back', { size: 18 }))} Chat</a>
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>
<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>` : ''}
${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">
<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.')}
${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>
<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>`))}
+${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&')}" target="_blank" rel="noopener">Add or allow the key</a>`))}
+<p class="cm-head">Your computer</p>
+${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}
<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>
<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>
<button class="cm-save" type="submit">Save</button>
</form></main>`,
</form></main>`,
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
* auto Actor decides, as today (the operator's tiers and keys)
* 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)
* 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:<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,
* 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.
* OpenVibe.AI routes the call (L37's soft preference), and the person's choice of provider still holds.
@@ -16,7 +18,9 @@
@@ -16,7 +18,9 @@
* (server/identity/account-data.js).
* (server/identity/account-data.js).
*/
*/
const CHOICES = ['auto', 'openvibe'];
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).
// A model id: letters, digits and the marks providers use (gpt-5.1, claude-sonnet-4-5, deepseek/flash, org/model).
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/;
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/;
@@ -82,4 +86,4 @@ function wire(row) {
@@ -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 };
@@ -26,6 +26,10 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -26,6 +26,10 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
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(`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');
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&provider=node'));
});
});
await check('a tap saves through the API from the page; the chip says the person\'s own key after', async () => {
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' };
@@ -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 });
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.match(bad.headers.get('location'), /\/chat\/model\?error=/);
assert.strictEqual((await t.get('/chat/model')).status, 303, 'signed out: sign in first');
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 () => {
await check('the script: the menu asks which keys are ready, saves in one request, closes on Escape', async () => {
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
const second = await db.migrate({ dir: MIGRATIONS });
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025']);
+ 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 () => {
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]);
@@ -84,6 +84,22 @@ function fakeFetch(calls, chatAnswer) {
@@ -84,6 +84,22 @@ function fakeFetch(calls, chatAnswer) {
assert.ok(!JSON.stringify(map.columns).includes('key'), 'no key material is exported');
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 () => {
await check('a run with the person\'s own key goes to OpenVibe.AI, and no key reaches Actor', async () => {
const calls = [];
const calls = [];
const models = createModels({ config: aiConfig(), fetchImpl: fakeFetch(calls), log: { log() {}, warn() {}, error() {} } });
const models = createModels({ config: aiConfig(), fetchImpl: fakeFetch(calls), log: { log() {}, warn() {}, error() {} } });