openvibers/openvibe.chat Commits
Your AI key may be a model on your own computer (prefilled from openvibe.bot) (#207)
Changes
@@@ -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 };@@@ -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. */@@@ -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>'}@@@ -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 OpenVibassert.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);
@@ -11,12 +11,17 @@
@@ -11,12 +11,17 @@
* label(cred) → "openai · gpt-4o-mini" (what a room shows next to the member)
* 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).
* 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 config = require('../config');
const aiClient = require('./client');
const aiClient = require('./client');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
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; } }
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); };
const fail = (status, code, message) => { throw new CredentialError(status, code, message); };
@@ -31,7 +36,7 @@ function call(method, subject, json) {
@@ -31,7 +36,7 @@ function call(method, subject, json) {
}
}
const view = (c) => (c ? {
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),
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,
spent_usd_today: Number(c.spent_usd_today) || 0, last_used_at: c.last_used_at || null,
} : null);
} : null);
@@ -44,10 +49,22 @@ async function get(subject) {
@@ -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();
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 (!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(/\/+$/, '') : '';
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.');
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();
const key = api_key == null ? '' : String(api_key).trim();
@@ -75,6 +92,12 @@ async function remove(subject) {
@@ -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 };
@@ -91,7 +91,7 @@ async function engineOf(input, user) {
@@ -91,7 +91,7 @@ async function engineOf(input, user) {
let cred;
let cred;
try { cred = await credentials.get(subject); } catch (err) { fail(err.status || 502, err.code || 'credentials.unreachable', err.message); }
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');
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. */
/** 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) {
@@ -141,7 +141,7 @@ async function dropKey(subject) {
async function relabelKey(subject, cred, user) {
async function relabelKey(subject, cred, user) {
if (!subject || !cred) return;
if (!subject || !cred) return;
await db.run("UPDATE room_ai_members SET engine_label = ?, updated_at = ov_now() WHERE engine = 'own' AND engine_subject = ?",
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. */
/** The first enabled member a message names (@handle, as a word), or null. */
@@ -489,26 +489,34 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
@@ -489,26 +489,34 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
<h2>Your chat history</h2>
<h2>Your chat history</h2>
<p>Download every message you have sent that is still visible, in Live chat and here.</p>
<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>
<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 ──
// ── 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');
const credentials = require('../ai/credentials');
- async function aiKeySection(actor) {
+ async function aiKeySection(actor, q = {}) {
if (!require('../rooms/ai-members').enabled()) return '';
if (!require('../rooms/ai-members').enabled()) return '';
let key = null, unreachable = false;
let key = null, unreachable = false;
try { key = await credentials.get(actor.user.subject_id); } catch { unreachable = true; }
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 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` : '';
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>
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">
<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>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>
<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>
<p><button type="submit">${key ? 'Save' : 'Add key'}</button></p>
</form>
</form>
@@ -518,7 +526,7 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
@@ -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 actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
const b = req.body || {};
try {
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);
await require('../rooms/ai-members').relabelKey(actor.user.subject_id, key, actor.user);
return res.redirect(303, '/settings?saved=1#oc-ai-key');
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`); }
} 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>` : '
@@ -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; } }
if (can.manage && room.kind !== 'system' && aiMembers.enabled()) { try { myKey = await credentials.get(actor.user.subject_id); } catch { myKey = null; } }
const engineField = (a) => {
const engineField = (a) => {
const cur = a ? (a.engine || 'openvibe') : 'openvibe';
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>
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)}` : ''}">
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>'}
${a ? '' : '<h3>Add an AI member</h3>'}
@@ -35,6 +35,11 @@ const fakeHttp = {
@@ -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 === '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; }
if (method === 'DELETE') { if (!store.delete(subject)) throw problem(404, 'credential.not_found'); return null; }
const prev = store.get(subject);
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');
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 };
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);
store.set(subject, row);
@@ -149,6 +154,32 @@ t('a new model relabels its members; removing the key sends them back to OpenVib
@@ -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');
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 () => {
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', ann, { name: 'Model Guild' })).status, 201);
assert.strictEqual((await api('POST', '/servers/model-guild/join', bob)).status, 200);
assert.strictEqual((await api('POST', '/servers/model-guild/join', bob)).status, 200);