openvibers/openvibe.chat Commits

Your AI key may be a model on your own computer (prefilled from openvibe.bot) (#207)

OP OpenVibers committed 18 days ago · 7ea9b872a3 · 4 files, +80 −18

* Your AI key may be a model on your own computer (provider node: the machine, no key, nothing spent); the settings form comes prefilled from openvibe.bot

* A room's AI member on someone's own computer reads "<model> on Fran's computer", never "your own computer" or a key; the member form says "Your own computer: <model>"

Changes

ddiff --git a/server/ai/credentials.js b/server/ai/credentials.js
iindex 3e936d8..623d0ca 100644
---- a/server/ai/credentials.js
++++ b/server/ai/credentials.js
@@@ -11,12 +11,17 @@
* label(cred) → "openai · gpt-4o-mini" (what a room shows next to the member)
*
* Needs Network's grant [chat, ai.credential.manage, openvibe.ai]. Refusals come back as CredentialError (status, code).
++ *
++ * Provider node (contracts 0.220.0) is a model on the person's own computer instead: `machine` names it (a dev_ id on
++ * openvibe.bot/machines), there is no key and nothing is spent; OpenVibe.AI reaches it through OpenVibe.Bot, and only
++ * while the person lets their AI use that computer's models there.
*/
const config = require('../config');
const aiClient = require('./client');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--const PROVIDERS = { openai: 'OpenAI (or any OpenAI-compatible endpoint)', anthropic: 'Anthropic' };
++const PROVIDERS = { openai: 'OpenAI (or any OpenAI-compatible endpoint)', anthropic: 'Anthropic', node: 'A model on your own computer' };
++const MACHINE_RE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
class CredentialError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
const fail = (status, code, message) => { throw new CredentialError(status, code, message); };
@@@ -31,7 +36,7 @@ function call(method, subject, json) {
}
const view = (c) => (c ? {
-- provider: c.provider, base_url: c.base_url || null, key_hint: c.key_hint || null, model: (c.models && c.models.chat) || null,
++ provider: c.provider, base_url: c.base_url || null, machine: c.machine || null, key_hint: c.key_hint || null, model: (c.models && c.models.chat) || null,
budget_usd_per_day: c.budget_usd_per_day == null ? null : Number(c.budget_usd_per_day),
spent_usd_today: Number(c.spent_usd_today) || 0, last_used_at: c.last_used_at || null,
} : null);
@@@ -44,10 +49,22 @@ async function get(subject) {
}
}
--async function put(subject, { provider, base_url, api_key, model, budget_usd_per_day } = {}) {
-- if (!PROVIDERS[provider]) fail(422, 'credentials.provider', 'Choose OpenAI (or an OpenAI-compatible endpoint) or Anthropic.');
++async function put(subject, { provider, base_url, api_key, model, budget_usd_per_day, machine } = {}) {
++ if (!PROVIDERS[provider]) fail(422, 'credentials.provider', 'Choose OpenAI (or an OpenAI-compatible endpoint), Anthropic, or a model on your own computer.');
const m = String(model || '').trim();
if (!m || m.length > 120 || /\s/.test(m)) fail(422, 'credentials.model', 'Name the model, for example gpt-4o-mini or claude-haiku-4-5.');
++ if (provider === 'node') {
++ // A model on the person's own computer: the machine, no key, no budget (nothing is spent).
++ const id = String(machine || '').trim();
++ if (!MACHINE_RE.test(id)) fail(422, 'credentials.machine', 'Name your computer: its id (dev_…) from openvibe.bot/machines.');
++ try {
++ return view(await call('PUT', subject, { provider: 'node', machine: id, models: { chat: m } }));
++ } catch (err) {
++ if (err instanceof CredentialError) throw err;
++ if (err && err.status >= 400 && err.status < 500) throw new CredentialError(err.status, 'credentials.refused', (err.detail || err.message || 'OpenVibe.AI refused it.').slice(0, 200));
++ throw new CredentialError(502, 'credentials.unreachable', 'OpenVibe.AI did not answer. Try again in a minute.');
++ }
++ }
const url = base_url ? String(base_url).trim().replace(/\/+$/, '') : '';
if (url && (provider !== 'openai' || !/^https:\/\/[^\s/?#@]+(\/[^\s?#]*)?$/.test(url) || url.length > 300)) fail(422, 'credentials.base_url', 'An endpoint is an https:// address, for an OpenAI-compatible provider.');
const key = api_key == null ? '' : String(api_key).trim();
@@@ -75,6 +92,12 @@ async function remove(subject) {
}
}
--const label = (c) => (c ? `${c.provider}${c.base_url ? ` (${(() => { try { return new URL(c.base_url).hostname; } catch { return 'custom'; } })()})` : ''} · ${c.model || 'default model'}` : null);
++const label = (c) => (c && c.provider === 'node' ? `your own computer · ${c.model || 'its model'}` : c ? `${c.provider}${c.base_url ? ` (${(() => { try { return new URL(c.base_url).hostname; } catch { return 'custom'; } })()})` : ''} · ${c.model || 'default model'}` : null);
++
++/** What a room's members see an AI member think with: "<model> on Fran's computer", or "openai · gpt-4o-mini, Fran's key". */
++const memberLabel = (c, name) => {
++ const who = String(name || 'a member').slice(0, 40);
++ return c && c.provider === 'node' ? `${c.model || 'a model'} on ${who}'s computer` : `${label(c)}, ${who}'s key`;
++};
--module.exports = { get, put, remove, label, CredentialError, PROVIDERS };
++module.exports = { get, put, remove, label, memberLabel, CredentialError, PROVIDERS };
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex 47abf13..e1c99f8 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -91,7 +91,7 @@ async function engineOf(input, user) {
let cred;
try { cred = await credentials.get(subject); } catch (err) { fail(err.status || 502, err.code || 'credentials.unreachable', err.message); }
if (!cred) fail(409, 'rooms.ai_no_key', 'Add your own AI key in Settings first');
-- return { engine: 'own', engine_subject: subject, engine_label: `${credentials.label(cred)}, ${(user.display_name || user.username || 'a member').slice(0, 40)}'s key` };
++ return { engine: 'own', engine_subject: subject, engine_label: credentials.memberLabel(cred, user.display_name || user.username) };
}
/** Add one. `can` is rooms.access() for `user`: only a room's managers add, edit or remove AI members. */
@@@ -141,7 +141,7 @@ async function dropKey(subject) {
async function relabelKey(subject, cred, user) {
if (!subject || !cred) return;
await db.run("UPDATE room_ai_members SET engine_label = ?, updated_at = ov_now() WHERE engine = 'own' AND engine_subject = ?",
-- [`${credentials.label(cred)}, ${((user && (user.display_name || user.username)) || 'a member').slice(0, 40)}'s key`, String(subject)]);
++ [credentials.memberLabel(cred, user && (user.display_name || user.username)), String(subject)]);
}
/** The first enabled member a message names (@handle, as a word), or null. */
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex aa88070..b21debd 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -489,26 +489,34 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
<h2>Your chat history</h2>
<p>Download every message you have sent that is still visible, in Live chat and here.</p>
<p><a class="oc-button" href="/api/chat/me/export" download>Download (JSON)</a> <a class="oc-button" href="/api/chat/me/export?format=csv" download>Download (CSV)</a></p>
--${await aiKeySection(actor)}`,
++${await aiKeySection(actor, req.query || {})}`,
});
});
// ── Your own AI key (server/ai/credentials.js): AI members you set up can think with your provider and model ──
const credentials = require('../ai/credentials');
-- async function aiKeySection(actor) {
++ async function aiKeySection(actor, q = {}) {
if (!require('../rooms/ai-members').enabled()) return '';
let key = null, unreachable = false;
try { key = await credentials.get(actor.user.subject_id); } catch { unreachable = true; }
const opt = (v, label, cur) => `<option value="${v}"${v === cur ? ' selected' : ''}>${label}</option>`;
const spent = key ? `$${key.spent_usd_today.toFixed(2)} of ${key.budget_usd_per_day ? `$${key.budget_usd_per_day.toFixed(2)}` : 'no cap'} used today` : '';
++ // From openvibe.bot/machines, "Use these models in OpenVibe.Chat": a model on your own computer, filled in.
++ const offered = q.own === 'node' && /^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(q.machine || '')) ? { machine: String(q.machine), model: /^[A-Za-z0-9._:/-]{1,120}$/.test(String(q.model || '')) ? String(q.model) : '' } : null;
++ const provider = offered ? 'node' : key ? key.provider : 'openai';
++ const machineValue = offered ? offered.machine : (key && key.machine) || '';
++ const modelValue = offered && offered.model ? offered.model : (key && key.model) || '';
++ const keyLine = key && key.provider === 'node' ? `on your computer <code>${esc(key.machine || '')}</code> · nothing spent` : key ? `key ending ${esc(key.key_hint || '····')} · ${esc(spent)}` : '';
return `<h2 id="oc-ai-key">Your AI key</h2>
--<p class="oc-muted">AI members you add to a room can think with your own provider and model instead of OpenVibe's: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter, Groq, Together, your own server). The key is kept encrypted in OpenVibe.AI and never shown again; the room's messages go to your provider when one of those members answers, and its replies count against your daily budget, not the room's limit.</p>
--${unreachable ? notice('error', 'OpenVibe.AI did not answer, so your key cannot be shown or changed right now.') : `${key ? `<p><strong>${esc(credentials.label(key))}</strong> <span class="oc-muted">· key ending ${esc(key.key_hint || '····')} · ${esc(spent)}</span></p>` : '<p class="oc-muted">No key yet.</p>'}
++<p class="oc-muted">AI members you add to a room, your Vibe among them, can think with your own provider and model instead of OpenVibe's: OpenAI, Anthropic, any OpenAI-compatible endpoint (OpenRouter, Groq, Together, your own server), or <strong>a model on your own computer</strong> (Ollama, LM Studio or llama.cpp through the OpenVibe Node, free, with no port opened: turn on "Let my AI use this computer's models" for it on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a>). A key is kept encrypted in OpenVibe.AI and never shown again; the room's messages go to your provider or your computer when one of those members answers, and a key's replies count against your daily budget, not the room's limit.</p>
++${offered ? notice('ok', `Save to let your AI members think with ${offered.model || 'a model'} on your own computer.`) : ''}
++${unreachable ? notice('error', 'OpenVibe.AI did not answer, so your key cannot be shown or changed right now.') : `${key ? `<p><strong>${esc(credentials.label(key))}</strong> <span class="oc-muted">· ${keyLine}</span></p>` : '<p class="oc-muted">No key yet.</p>'}
<form class="oc-form" method="post" action="/settings/ai-key">
--<label>Provider <select name="provider">${opt('openai', 'OpenAI or OpenAI-compatible', key ? key.provider : 'openai')}${opt('anthropic', 'Anthropic', key ? key.provider : '')}</select></label>
++<label>Provider <select name="provider">${opt('openai', 'OpenAI or OpenAI-compatible', provider)}${opt('anthropic', 'Anthropic', provider)}${opt('node', 'A model on my own computer', provider)}</select></label>
++<label>Computer <input name="machine" maxlength="40" pattern="dev_[0-9A-Z]{26}" placeholder="dev_… (a model on your own computer only)" value="${esc(machineValue)}" autocomplete="off" spellcheck="false"></label>
<label>Endpoint <input name="base_url" type="url" maxlength="300" inputmode="url" placeholder="https://openrouter.ai/api/v1 (leave empty for OpenAI itself)" value="${esc((key && key.base_url) || '')}"></label>
--<label>Model <input name="model" required maxlength="120" placeholder="gpt-4o-mini" value="${esc((key && key.model) || '')}" autocomplete="off" spellcheck="false"></label>
--<label>API key <input name="api_key" type="password" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${key ? 'Leave empty to keep the current key' : 'sk-…'}"${key ? '' : ' required'}></label>
++<label>Model <input name="model" required maxlength="120" placeholder="gpt-4o-mini, or llama3.1:8b on your computer" value="${esc(modelValue)}" autocomplete="off" spellcheck="false"></label>
++<label>API key <input name="api_key" type="password" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${key ? 'Leave empty to keep the current key' : 'sk-… (none for your own computer)'}"></label>
<label>Daily budget, US dollars <input name="budget_usd_per_day" type="number" min="0" max="1000" step="0.5" value="${key && key.budget_usd_per_day != null ? key.budget_usd_per_day : 1}"> <span class="oc-muted">0 means no cap</span></label>
<p><button type="submit">${key ? 'Save' : 'Add key'}</button></p>
</form>
@@@ -518,7 +526,7 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
try {
-- const key = await credentials.put(actor.user.subject_id, { provider: b.provider, base_url: b.base_url, api_key: b.api_key, model: b.model, budget_usd_per_day: b.budget_usd_per_day });
++ const key = await credentials.put(actor.user.subject_id, { provider: b.provider, base_url: b.base_url, api_key: b.api_key, model: b.model, budget_usd_per_day: b.budget_usd_per_day, machine: b.machine });
await require('../rooms/ai-members').relabelKey(actor.user.subject_id, key, actor.user);
return res.redirect(303, '/settings?saved=1#oc-ai-key');
} catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save your key right now')}#oc-ai-key`); }
@@@ -810,9 +818,9 @@ ${composer}${tools}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : '
if (can.manage && room.kind !== 'system' && aiMembers.enabled()) { try { myKey = await credentials.get(actor.user.subject_id); } catch { myKey = null; } }
const engineField = (a) => {
const cur = a ? (a.engine || 'openvibe') : 'openvibe';
-- const ownLabel = a && cur === 'own' ? aiMembers.modelOf(a) : myKey ? `Your own key: ${credentials.label(myKey)}` : 'Your own key (add one in your chat settings first)';
++ const ownLabel = a && cur === 'own' ? aiMembers.modelOf(a) : myKey && myKey.provider === 'node' ? `Your own computer: ${myKey.model || 'its model'}` : myKey ? `Your own key: ${credentials.label(myKey)}` : 'Your own key (add one in your chat settings first)';
return `<label>Thinks with <select name="engine"><option value="openvibe"${cur === 'openvibe' ? ' selected' : ''}>OpenVibe's model</option><option value="own"${cur === 'own' ? ' selected' : ''}${cur === 'own' || myKey ? '' : ' disabled'}>${esc(ownLabel)}</option></select></label>
--${myKey || cur === 'own' ? '' : '<p class="oc-muted">To give it a different model (yours, from OpenAI, Anthropic or an OpenAI-compatible provider), <a href="/settings#oc-ai-key">add your AI key</a>.</p>'}`;
++${myKey || cur === 'own' ? '' : '<p class="oc-muted">To give it a different model (yours, from OpenAI, Anthropic or an OpenAI-compatible provider, or a model on your own computer), <a href="/settings#oc-ai-key">add your AI key</a>.</p>'}`;
};
const aiForm = (a) => `<form class="oc-form oc-ai-form" method="post" action="/r/${esc(room.slug)}/ai-members${a ? `/${Number(a.id)}` : ''}">
${a ? '' : '<h3>Add an AI member</h3>'}
ddiff --git a/test/room-ai-models.test.js b/test/room-ai-models.test.js
iindex f4f09c3..6a7557c 100644
---- a/test/room-ai-models.test.js
++++ b/test/room-ai-models.test.js
@@@ -35,6 +35,11 @@ const fakeHttp = {
if (method === 'GET') { if (!store.has(subject)) throw problem(404, 'credential.not_found'); return store.get(subject); }
if (method === 'DELETE') { if (!store.delete(subject)) throw problem(404, 'credential.not_found'); return null; }
const prev = store.get(subject);
++ if (json.provider === 'node') { // a model on the person's own computer: no key (contracts 0.220.0)
++ const row = { owner: 'chat', subject, provider: 'node', machine: json.machine, base_url: null, models: json.models, budget_usd_per_day: null, spent_usd_today: 0 };
++ store.set(subject, row);
++ return row;
++ }
if (!json.api_key && (!prev || prev.provider !== json.provider || (prev.base_url || null) !== (json.base_url || null))) throw problem(400, 'credential.key_required');
const row = { owner: 'chat', subject, provider: json.provider, base_url: json.base_url || null, key_hint: json.api_key ? json.api_key.slice(-4) : prev.key_hint, models: json.models, budget_usd_per_day: json.budget_usd_per_day, spent_usd_today: 0.25 };
store.set(subject, row);
@@@ -149,6 +154,32 @@ t('a new model relabels its members; removing the key sends them back to OpenVib
assert.strictEqual((await api('POST', '/rooms/model-night/ai-members', ann, { name: 'Echo', engine: 'own' })).body.code, 'rooms.ai_no_key', 'no key, no own engine');
});
++t('a model on your own computer: the machine and no key; the settings form comes prefilled from openvibe.bot', async () => {
++ const MACHINE = 'dev_01M5YGM39VC2RRGDQPRD6D4SHD';
++ const cy = h.addUser('cy', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(cy.id));
++ assert.strictEqual((await api('PUT', '/me/ai-key', cy, { provider: 'node', model: 'llama3.1:8b' })).status, 422, 'it names the computer');
++ assert.strictEqual((await api('PUT', '/me/ai-key', cy, { provider: 'node', machine: 'dev_nope', model: 'llama3.1:8b' })).status, 422);
++ const put = await api('PUT', '/me/ai-key', cy, { provider: 'node', machine: MACHINE, model: 'llama3.1:8b' });
++ assert.strictEqual(put.status, 200, JSON.stringify(put.body));
++ assert.deepStrictEqual([put.body.key.provider, put.body.key.machine, put.body.key.model], ['node', MACHINE, 'llama3.1:8b']);
++ const { label, memberLabel } = require('../server/ai/credentials');
++ assert.strictEqual(label(put.body.key), 'your own computer · llama3.1:8b');
++ assert.strictEqual(memberLabel(put.body.key, 'Cy'), 'llama3.1:8b on Cy\'s computer', 'what a room sees: whose computer, never "your own"');
++ assert.strictEqual(memberLabel({ provider: 'openai', model: 'gpt-4o-mini' }, 'Cy'), 'openai · gpt-4o-mini, Cy\'s key');
++ // From openvibe.bot/machines ("Use these models in OpenVibe.Chat"): the form is filled in, nothing is saved yet.
++ const page = await web('GET', `/settings?own=node&machine=${MACHINE}&model=qwen2.5:7b`, cy);
++ const sect = page.text.slice(page.text.indexOf('id="oc-ai-key"'));
++ assert.ok(sect.includes('<option value="node" selected>A model on my own computer</option>'), 'own computer chosen');
++ assert.ok(sect.includes(`name="machine" maxlength="40"`) && sect.includes(`value="${MACHINE}"`));
++ assert.ok(sect.includes('value="qwen2.5:7b"'), 'the model from the link');
++ assert.ok(sect.includes('Save to let your AI members think with qwen2.5:7b on your own computer.'));
++ assert.ok(sect.includes('your own computer · llama3.1:8b') && sect.includes('nothing spent'), 'what is saved now');
++ const bad = await web('GET', '/settings?own=node&machine=../x&model=<b>', cy);
++ assert.ok(!bad.text.includes('Save to let your AI members'), 'a bad link fills nothing');
++ assert.strictEqual((await api('DELETE', '/me/ai-key', cy)).status, 200);
++});
++
t('a server\'s admins give a text channel AI members; others are sent to the server', async () => {
assert.strictEqual((await api('POST', '/servers', ann, { name: 'Model Guild' })).status, 201);
assert.strictEqual((await api('POST', '/servers/model-guild/join', bob)).status, 200);