openvibers/openvibe.chat Commits
Chat: AI members with their own models (a person's provider key, kept in OpenVibe.AI) (#108)
Changes
@@@ -308,6 +308,19 @@ only; members see names and handles, managers the set-up); the settings page manmember can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting anaccount keeps the AI members it added (they belong to the room) without its subject on them, and exports them.++**Their own models** (`migrations/0014_ai_member_models.sql`, `server/ai/credentials.js`; the owner's multi-model group++chat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter,++Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays).++The key goes to OpenVibe.AI (`PUT|GET|DELETE /api/v1/credentials/:subject`, Chat as its owner, Network grant++`chat ai.credential.manage`) and is never shown again, only a four-character hint and today's spend. A room's manager++sets a member to think with their own key (`engine: own`; `openvibe` is OpenVibe's model): its answers are runs that++name `credential { subject }`, so they reach that provider only, count against the key's budget and not the room's++daily cap. The room page and the API (`model`) say which model each member uses ("openai (openrouter.ai) · gpt-4o-mini,++Ann's key"). Removing the key (or the account) sends its members back to OpenVibe's model; a new model relabels them.++API: `GET|PUT|DELETE /api/chat/me/ai-key` (own sign-in only, not API tokens); pages: `/settings#oc-ai-key` and the++"Thinks with" choice on each AI member. A server's admins give a text channel AI members at `/r/<channel room>/settings`,++linked from the channel on the server's settings page.++**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a textnnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- A model of their own for AI members (server/rooms/ai-members.js; the owner's "multi model group chat"): an AI member++-- thinks with OpenVibe's model (engine 'openvibe', the default) or with the provider key a person keeps in OpenVibe.AI++-- through Chat (engine 'own': OpenAI, Anthropic or any OpenAI-compatible endpoint, their model, their daily budget).++-- engine_subject is whose key; engine_label what the room shows ("openai · gpt-4o-mini").++ALTER TABLE room_ai_members ADD COLUMN IF NOT EXISTS engine text COLLATE "C" NOT NULL DEFAULT 'openvibe';++ALTER TABLE room_ai_members ADD COLUMN IF NOT EXISTS engine_subject text COLLATE "C";++ALTER TABLE room_ai_members ADD COLUMN IF NOT EXISTS engine_label text COLLATE "C";++ALTER TABLE room_ai_members ADD CONSTRAINT room_ai_members_engine CHECK (engine IN ('openvibe', 'own'));@@@ -11,6 +11,10 @@const config = require('../config');let _ai = null;++let _http = null;++++/** The HTTP client behind ai() (Chat's token for openvibe.ai), for calls the SDK's AI client has no method for. */++function http() { ai(); return _http; }function ai() {if (_ai) return _ai;@@@ -25,8 +29,8 @@ function ai() {audience: 'openvibe.ai',scope: config.ai.scope,});-- const http = createClient({ tokenProvider: tokenClient, timeoutMs: config.ai.waitMs + 10000 });-- _ai = createAiClient(http, { baseUrl: config.ai.internalUrl });++ _http = createClient({ tokenProvider: tokenClient, timeoutMs: config.ai.waitMs + 10000 });++ _ai = createAiClient(_http, { baseUrl: config.ai.internalUrl });return _ai;}@@@ -44,11 +48,20 @@ async function structured(workflow, input, opts = {}) {if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;try {const { runs } = ai();-- const body = await runs.create(workflow, input, {-- wait: config.ai.waitMs,-- ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),-- ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),-- });++ // A person's own provider key (ai.run-request `credential`): the SDK's runs.create does not carry it, so such a++ // run is posted through the same client (and token).++ const body = opts.credential++ ? await http().json({++ service: 'ai', baseUrl: config.ai.internalUrl, audience: 'openvibe.ai', method: 'POST', path: '/api/v1/runs',++ query: { wait: Math.max(0, Math.floor(config.ai.waitMs)) },++ json: { workflow, input, credential: opts.credential, ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}) },++ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : { idempotencyKey: false }),++ })++ : await runs.create(workflow, input, {++ wait: config.ai.waitMs,++ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),++ ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),++ });const run = body && (body.run || body);if (!run || run.status !== 'succeeded' || run.synthetic) return null;return run.output || null;@@@ -58,4 +71,4 @@ async function structured(workflow, input, opts = {}) {}}--module.exports = { structured, enabled, _setClient(c) { _ai = c; }, _reset() { _ai = null; } };++module.exports = { structured, enabled, http, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _reset() { _ai = null; _http = null; } };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * A person's own AI provider key, for the AI members they set up in rooms (server/rooms/ai-members.js). The key lives in++ * OpenVibe.AI (PUT/GET/DELETE /api/v1/credentials/:subject, ai.credential-put@1 / ai.credential@1), encrypted there with++ * Chat as its owner; Chat never stores or shows it again (only AI's four-character hint). A run that names the++ * credential calls that provider with that key only, never a shared one, inside the person's own daily budget.++ *++ * get(subject) → { provider, base_url, key_hint, model, budget_usd_per_day, spent_usd_today } | null++ * put(subject, { provider, base_url?, api_key?, model, budget_usd_per_day? })++ * remove(subject)++ * 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).++ */++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' };++++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); };++++function call(method, subject, json) {++ if (!config.oauth.clientSecret) fail(503, 'credentials.off', 'Your own AI key cannot be stored here right now.');++ if (!SUBJECT_RE.test(String(subject || ''))) fail(400, 'credentials.subject', 'An OpenVibe account is needed for your own AI key.');++ return aiClient.http().json({++ service: 'ai', baseUrl: config.ai.internalUrl, audience: 'openvibe.ai', method, path: `/api/v1/credentials/${encodeURIComponent(subject)}`,++ ...(json ? { json } : {}), idempotencyKey: false,++ });++}++++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,++ 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);++++async function get(subject) {++ try { return view(await call('GET', subject)); } catch (err) {++ if (err instanceof CredentialError) throw err;++ if (err && err.status === 404) return null;++ throw new CredentialError(502, 'credentials.unreachable', 'OpenVibe.AI did not answer. Try again in a minute.');++ }++}++++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.');++ 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.');++ 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();++ if (key && (key.length < 8 || key.length > 400)) fail(422, 'credentials.key', 'That does not look like an API key.');++ // Left out, the budget stays what it is ($1 a day the first time).++ const budget = budget_usd_per_day == null || budget_usd_per_day === ''++ ? (await get(subject).then((c) => (c && c.budget_usd_per_day != null ? c.budget_usd_per_day : 1), () => 1))++ : Number(budget_usd_per_day);++ if (!Number.isFinite(budget) || budget < 0 || budget > 1000) fail(422, 'credentials.budget', 'A daily budget is 0 to 1000 US dollars (0 means no cap).');++ try {++ return view(await call('PUT', subject, { provider, ...(url ? { base_url: url } : {}), ...(key ? { api_key: key } : {}), models: { chat: m }, budget_usd_per_day: budget }));++ } catch (err) {++ if (err instanceof CredentialError) throw err;++ if (err && err.code === 'credential.key_required') throw new CredentialError(400, 'credentials.key', 'Enter the key: it is needed the first time, and again for a new provider or endpoint.');++ if (err && err.status >= 400 && err.status < 500) throw new CredentialError(err.status, 'credentials.refused', (err.detail || err.message || 'OpenVibe.AI refused the key.').slice(0, 200));++ throw new CredentialError(502, 'credentials.unreachable', 'OpenVibe.AI did not answer. Try again in a minute.');++ }++}++++async function remove(subject) {++ try { await call('DELETE', subject); } catch (err) {++ if (err instanceof CredentialError) throw err;++ if (err && err.status === 404) return;++ throw new CredentialError(502, 'credentials.unreachable', 'OpenVibe.AI did not answer. Try again in a minute.');++ }++}++++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);++++module.exports = { get, put, remove, label, CredentialError, PROVIDERS };@@@ -99,6 +99,8 @@ const ERASE_AFTER_ROOMS = [['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],++ // Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.++ [null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],];// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.const ERASE_CHANNEL = [@@@ -916,4 +916,33 @@ router.get('/me/export', requireAuth, exportLimit, async (req, res) => {}});++// ── Your own AI key, for the AI members you set up in rooms (server/ai/credentials.js) ─────────────────++// Kept in OpenVibe.AI, never shown again (only a four-character hint). With your own sign-in only, not an API token.++const aiKeyLimit = limits('chat.ai_key', { minute: 10, hour: 60 });++const aiKeyPersonOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Manage your AI key with your own sign-in, not an API token', code: 'credentials.person_only' }) : next());++function aiKeySend(res, err) {++ const credentials = require('../ai/credentials');++ if (err instanceof credentials.CredentialError) return res.status(err.status).json({ error: err.message, code: err.code });++ console.error('[Chat] AI key:', err && err.message);++ return res.status(500).json({ error: 'Could not reach your AI key right now' });++}++router.get('/me/ai-key', requireAuth, aiKeyPersonOnly, aiKeyLimit, async (req, res) => {++ try { res.set('Cache-Control', 'private, no-store').json({ key: await require('../ai/credentials').get(req.user.subject_id) }); } catch (err) { aiKeySend(res, err); }++});++router.put('/me/ai-key', requireAuth, aiKeyPersonOnly, aiKeyLimit, async (req, res) => {++ try {++ const credentials = require('../ai/credentials');++ const key = await credentials.put(req.user.subject_id, req.body || {});++ await require('../rooms/ai-members').relabelKey(req.user.subject_id, key, req.user);++ res.set('Cache-Control', 'private, no-store').json({ key });++ } catch (err) { aiKeySend(res, err); }++});++router.delete('/me/ai-key', requireAuth, aiKeyPersonOnly, aiKeyLimit, async (req, res) => {++ try {++ await require('../ai/credentials').remove(req.user.subject_id);++ const switched = await require('../rooms/ai-members').dropKey(req.user.subject_id);++ res.json({ ok: true, members_switched: switched });++ } catch (err) { aiKeySend(res, err); }++});++module.exports = router;@@@ -187,7 +187,7 @@ module.exports = {ai: {enabled: bool(process.env.CHAT_AI_ENABLED, false),internalUrl: strip(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),-- scope: 'ai.run.create ai.run.read',++ scope: 'ai.run.create ai.run.read ai.credential.manage',waitMs: Math.max(0, int(process.env.CHAT_AI_WAIT_MS, 60000)),tickMs: Math.max(1000, int(process.env.CHAT_AI_TICK_MS, 45000)),// A room's AI members (server/rooms/ai-members.js): on unless CHAT_AI_MEMBERS=0, independent of the insight job@@@ -13,6 +13,11 @@* an AI member never answers an AI member.* - Limits: one answer at a time per room, CHAT_AI_MEMBER_REPLIES_PER_DAY a room (100), and 20 an hour from one* person. Past them, or with AI unavailable, the member says so once instead of answering.++ * - A model of its own (migrations/0014_ai_member_models.sql): engine 'openvibe' thinks with OpenVibe's model; engine++ * 'own' with the provider key the manager who set it keeps in OpenVibe.AI through Chat (server/ai/credentials.js:++ * OpenAI, Anthropic or an OpenAI-compatible endpoint, their model, their daily budget). The room sees which++ * (engine_label). Such a member is outside the room's daily cap (the key's own budget limits it); if the key is++ * removed, its members go back to OpenVibe's model (dropKey).* - Off with CHAT_AI_MEMBERS=0, and without the OAuth client secret (no token to call AI with).** Room messages written by an AI member have user_id 0 and the member's name (rooms.js MSG_SELECT joins it).@@@ -20,6 +25,7 @@const db = require('../db/database');const config = require('../config');const aiClient = require('../ai/client');++const credentials = require('../ai/credentials');const MAX_PER_ROOM = 3;const TRANSCRIPT = 30;@@@ -59,6 +65,22 @@ function validate({ name, persona, memory } = {}, { partial = false } = {}) {return out;}++/**++ * The engine an add or an update asks for: 'openvibe', or 'own' (the acting person's own key, which must be stored).++ * Returns the columns to write, or null when the input does not name an engine.++ */++async function engineOf(input, user) {++ if (input.engine === undefined || input.engine === null || input.engine === '') return null;++ if (input.engine === 'openvibe') return { engine: 'openvibe', engine_subject: null, engine_label: null };++ if (input.engine !== 'own') fail(422, 'rooms.ai_engine', 'An AI member thinks with OpenVibe\'s model or with your own key');++ const subject = user && user.subject_id;++ if (!subject) fail(403, 'rooms.ai_engine', 'Sign in with an OpenVibe account to use your own AI key');++ 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` };++}++/** Add one. `can` is rooms.access() for `user`: only a room's managers add, edit or remove AI members. */async function add(room, user, can, input = {}) {if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');@@@ -67,21 +89,26 @@ async function add(room, user, can, input = {}) {if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);const link = input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;++ const e = (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };const r = await db.run(-- 'INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',-- [room.id, v.name, v.handle, v.persona || '', v.memory || '', link ? link.service : null, link ? link.resource : null, (user && user.subject_id) || null]);++ `INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject, engine, engine_subject, engine_label)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,++ [room.id, v.name, v.handle, v.persona || '', v.memory || '', link ? link.service : null, link ? link.resource : null, (user && user.subject_id) || null, e.engine, e.engine_subject, e.engine_label]);return await byId(room, r.lastInsertRowid);}--async function update(room, can, id, input = {}) {++async function update(room, can, id, input = {}, user = null) {if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');const m = await byId(room, id);if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');const v = validate(input, { partial: true });if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);-- await db.run('UPDATE room_ai_members SET name = ?, handle = ?, persona = ?, memory = ?, enabled = ?, updated_at = ov_now() WHERE id = ?',-- [v.name || m.name, v.handle || m.handle, v.persona !== undefined ? v.persona : m.persona, v.memory !== undefined ? v.memory : m.memory, enabledFlag, m.id]);++ // Keeping 'own' as it is needs no key of the editor's: only a change to it does (someone else's key stays theirs).++ const e = input.engine === 'own' && m.engine === 'own' ? null : await engineOf(input, user);++ const eng = e || { engine: m.engine || 'openvibe', engine_subject: m.engine_subject || null, engine_label: m.engine_label || null };++ await db.run('UPDATE room_ai_members SET name = ?, handle = ?, persona = ?, memory = ?, enabled = ?, engine = ?, engine_subject = ?, engine_label = ?, updated_at = ov_now() WHERE id = ?',++ [v.name || m.name, v.handle || m.handle, v.persona !== undefined ? v.persona : m.persona, v.memory !== undefined ? v.memory : m.memory, enabledFlag, eng.engine, eng.engine_subject, eng.engine_label, m.id]);return await byId(room, m.id);}@@@ -90,6 +117,20 @@ async function remove(room, can, id) {await db.run('DELETE FROM room_ai_members WHERE room_id = ? AND id = ?', [room.id, Number(id) || 0]);}++/** A person's key is gone (they removed it, or their account was erased): their members go back to OpenVibe's model. */++async function dropKey(subject) {++ if (!subject) return 0;++ const r = await db.run("UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL, updated_at = ov_now() WHERE engine_subject = ?", [String(subject)]);++ return r.changes || 0;++}++++/** Keeps every member that uses this person's key showing the key's current provider and model. */++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)]);++}++/** The first enabled member a message names (@handle, as a word), or null. */function mentioned(members, text) {const t = String(text || '').toLowerCase();@@@ -158,7 +199,8 @@ async function afterPost(room, message, user, deps = {}) {};if (inFlight.has(room.id)) return null;if (!personOk(room.id, user.id, Date.now())) return null;-- if (await repliesToday(room) >= config.ai.memberRepliesPerDay) {++ const own = member.engine === 'own' && member.engine_subject;++ if (!own && await repliesToday(room) >= config.ai.memberRepliesPerDay) {return await say(`I have answered ${config.ai.memberRepliesPerDay} times in this room today, which is my limit. Ask me again tomorrow.`);}inFlight.add(room.id);@@@ -171,10 +213,11 @@ async function afterPost(room, message, user, deps = {}) {memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',transcript: transcriptOf(history) || '(no earlier messages)',asked_by: (user.display_name || user.username || 'someone').slice(0, 80), message: String(message.message).slice(0, 2000),-- }, { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}` });++ }, { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,++ ...(own ? { credential: { subject: member.engine_subject } } : {}) });const text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';-- if (!text) return await say('I could not answer just now. Try again in a minute.');-- await countReply(room);++ if (!text) return await say(own ? 'I could not reach my model just now (its key may be out of today\'s budget, or the provider did not answer). Try again later, or the room\'s owner can switch me to OpenVibe\'s model.' : 'I could not answer just now. Try again in a minute.');++ if (!own) await countReply(room);return await say(text);} catch (err) {console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);@@@ -184,11 +227,15 @@ async function afterPost(room, message, user, deps = {}) {}}++/** What the room shows a member thinks with: "OpenVibe", or the own key's provider, model and whose key it is. */++const modelOf = (m) => (m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');++const toWire = (m) => ({id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),-- link: m.link_service ? { service: m.link_service, resource: m.link_resource } : null, created_at: m.created_at, updated_at: m.updated_at,++ link: m.link_service ? { service: m.link_service, resource: m.link_resource } : null,++ engine: m.engine || 'openvibe', model: modelOf(m), created_at: m.created_at, updated_at: m.updated_at,});function _reset() { inFlight.clear(); personHour.clear(); }--module.exports = { MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, mentioned, handleOf, afterPost, transcriptOf, toWire, _reset };++module.exports = { MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, transcriptOf, toWire, _reset };@@@ -235,7 +235,7 @@ function aiSend(res, err) {router.get('/:slug/ai-members', optionalAuth, async (req, res) => {const room = await readable(req, res); if (!room) return;const can = await rooms.access(room, req.user || null);-- const list = (await aiMembers.list(room)).map(aiMembers.toWire).map((m) => (can.manage ? m : { id: m.id, name: m.name, handle: m.handle, enabled: m.enabled }));++ const list = (await aiMembers.list(room)).map(aiMembers.toWire).map((m) => (can.manage ? m : { id: m.id, name: m.name, handle: m.handle, enabled: m.enabled, model: m.model }));res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ ai_members: list, enabled: aiMembers.enabled(), max: aiMembers.MAX_PER_ROOM });});router.post('/:slug/ai-members', requireAuth, personOnly, manageLimit, async (req, res) => {@@@ -247,7 +247,7 @@ router.post('/:slug/ai-members', requireAuth, personOnly, manageLimit, async (re});router.patch('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {const room = await readable(req, res); if (!room) return;-- try { res.json(aiMembers.toWire(await aiMembers.update(room, await rooms.access(room, req.user), req.params.id, req.body || {}))); } catch (err) { aiSend(res, err); }++ try { res.json(aiMembers.toWire(await aiMembers.update(room, await rooms.access(room, req.user), req.params.id, req.body || {}, req.user))); } catch (err) { aiSend(res, err); }});router.delete('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {const room = await readable(req, res); if (!room) return;@@@ -388,10 +388,50 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}</form><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>`,++<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)}`,});});++ // ── 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) {++ 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` : '';++ 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>'}++<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>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>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>++${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete"><button type="submit" class="oc-link">Remove my key</button> <span class="oc-muted">(AI members that use it go back to OpenVibe's model)</span></form>` : ''}`}`;++ }++ router.post('/settings/ai-key', formLimit, sameSite, async (req, res) => {++ 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 });++ 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`); }++ });++ router.post('/settings/ai-key/delete', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ try {++ await credentials.remove(actor.user.subject_id);++ await require('../rooms/ai-members').dropKey(actor.user.subject_id);++ return res.redirect(303, '/settings?saved=1#oc-ai-key');++ } catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not remove your key right now')}#oc-ai-key`); }++ });++router.post('/settings', formLimit, sameSite, async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const b = req.body || {};@@@ -547,7 +587,7 @@ ${people}const isCall = room.kind === 'call';const callReady = isCall && !!require('../config').calls.enabled;const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];-- const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';++ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';await page(req, res, 200, {actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,@@@ -622,7 +662,8 @@ ${composer}${tools}`,router.get('/r/:slug/settings', async (req, res) => {const x = await loadRoom(req, res); if (!x) return;const { actor, room, can } = x;-- if (room.server_id) return res.redirect(302, await channelUrl(room, '/settings'));++ // A server's channel is set up on the server's settings page; only its AI members are set here (server admins).++ if (room.server_id && !can.manage) return res.redirect(302, await channelUrl(room, '/settings'));if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);const saved = req.query.saved ? notice('ok', 'Saved.') : '';const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';@@@ -637,18 +678,34 @@ ${composer}${tools}`,const addRole = rooms.joinRoleOf(room);const attachments = can.manage ? await rooms.attachments(room) : [];const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];++ let 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 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)';++ 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>'}`;++ };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>'}<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label><label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label><label>Notes it always knows <textarea name="memory" rows="3" maxlength="2000" placeholder="Lee is vegetarian. We shop on Saturdays. Budget: $80 a week.">${a ? esc(a.memory) : ''}</textarea></label>++${engineField(a)}${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label>` : ''}<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;const aiSection = can.manage && room.kind !== 'system' ? `<h2 id="oc-ai">AI members</h2>${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}--<p class="oc-muted">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI. It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>--${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-link">Remove ${esc(a.name)}</button></form></section>`).join('')}++<p class="oc-muted">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI, with OpenVibe's model or with your own key's (several members with different models make a multi-model group chat). It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>++${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)} · ${esc(aiMembers.modelOf(a))}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-link">Remove ${esc(a.name)}</button></form></section>`).join('')}${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';++ if (room.server_id) {++ return await page(req, res, 200, {++ actor, title: `#${room.name}: AI members`, path: `/r/${room.slug}/settings`, active: 'servers', robots: 'noindex, nofollow',++ body: `<p><a href="${esc(await channelUrl(room, '/settings'))}">← Server settings</a> · <a href="${esc(await channelUrl(room))}">#${esc(room.name)}</a></p><h1>AI members in #${esc(room.name)}</h1>${saved}${error}++${aiSection}`,++ });++ }const communityUrl = config.web.communityUrl;const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';await page(req, res, 200, {@@@ -716,8 +773,8 @@ ${attachList}`,}res.redirect(303, `/r/${room.slug}/settings?saved=1`);});-- router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory }); }));-- router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1' }); }));++ router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine }); }));++ router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1', engine: b.engine }, actor.user); }));router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {@@@ -119,7 +119,7 @@ ${create}`,else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;else composer = notice('error', 'You cannot write here right now.');const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];-- const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';++ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(require('../rooms/ai-members').modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';const byRank = {};for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;@@@ -221,7 +221,8 @@ ${composer}<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label><label>Topic <input name="topic" maxlength="200" value="${esc(c.topic || '')}"></label>${c.kind === 'voice' ? `<label>Members in the call <select name="join_role"><option value="speaker"${c.join_role === 'speaker' ? ' selected' : ''}>can talk</option><option value="participant"${c.join_role === 'participant' ? ' selected' : ''}>listen and write (moderators talk)</option></select></label>` : ''}--<p><button type="submit">Save</button> <button type="submit" name="delete" value="1" class="oc-link oc-danger-link">Delete this channel</button></p></form></details></li>`;++<p><button type="submit">Save</button> <button type="submit" name="delete" value="1" class="oc-link oc-danger-link">Delete this channel</button></p></form>++${can.manage && c.kind !== 'voice' && require('../rooms/ai-members').enabled() ? `<p><a href="/r/${esc(c.room)}/settings#oc-ai">AI members in #${esc(c.name)}</a> <span class="oc-muted">(they answer when someone writes their @name, with OpenVibe's model or your own key's)</span></p>` : ''}</details></li>`;const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span><form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;await page(req, res, 200, {@@@ -56,7 +56,7 @@ t('only the room\'s owner adds, changes and removes AI members; names are checkeassert.deepStrictEqual([r.body.name, r.body.handle, r.body.memory], ['Pantry', 'pantry', 'Bob is vegetarian']);assert.strictEqual((await api('POST', '/our-kitchen/ai-members', ann, { name: 'pantry' })).status, 409, 'one @pantry per room');const seen = await api('GET', '/our-kitchen/ai-members', bob);-- assert.deepStrictEqual(Object.keys(seen.body.ai_members[0]).sort(), ['enabled', 'handle', 'id', 'name'], 'members see who it is, not its set-up');++ assert.deepStrictEqual(Object.keys(seen.body.ai_members[0]).sort(), ['enabled', 'handle', 'id', 'model', 'name'], 'members see who it is and its model, not its set-up');assert.strictEqual((await api('GET', '/our-kitchen/ai-members', ann)).body.ai_members[0].persona, 'A cheerful cook');assert.strictEqual((await api('PATCH', `/our-kitchen/ai-members/${r.body.id}`, bob, { memory: 'x' })).status, 403);});nnew file mode 100644@@@ -0,0 +1,182 @@++'use strict';++/**++ * A model of their own for AI members (migrations/0014_ai_member_models.sql, server/ai/credentials.js): a person keeps++ * their provider key in OpenVibe.AI through Chat (/api/chat/me/ai-key and the /settings form; the key is never shown++ * again), a room's manager sets a member to think with it (engine 'own'), its answers are runs that name the++ * credential, outside the room's daily cap, and the room shows which model each member uses. Removing the key, or the++ * account, sends those members back to OpenVibe's model. A server's channels get AI members from its admins.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++let aiClient, aiMembers, config;++++const t = suite('room AI members: their own models');++const SITE = 'https://openvibe.chat';++let h, ann, bob;++const web = async (method, path, u, fields) => {++ const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };++ const r = await fetch(`${h.base}${path}`, { method, headers, body: fields ? new URLSearchParams(fields).toString() : undefined, redirect: 'manual' });++ return { status: r.status, text: await r.text(), location: r.headers.get('location') };++};++const api = (method, path, u, body) => h.http(method, `/api/chat${path}`, { token: u && u.token, body });++const settle = (ms = 150) => new Promise((r) => setTimeout(r, ms));++++// OpenVibe.AI, as Chat's HTTP client sees it: the credential store and credentialed runs.++const store = new Map(); // subject → stored credential (as AI answers it)++const runs = []; // credentialed runs++const shared = []; // runs with OpenVibe's model++const problem = (status, code) => Object.assign(new Error(code), { status, code });++const fakeHttp = {++ async json({ method, path, json }) {++ const cred = /^\/api\/v1\/credentials\/(.+)$/.exec(path);++ if (cred) {++ const subject = decodeURIComponent(cred[1]);++ 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.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);++ return row;++ }++ if (method === 'POST' && path === '/api/v1/runs') {++ runs.push(json);++ return { run: { status: 'succeeded', output: { text: `From ${store.get(json.credential.subject).models.chat}: hello ${json.input.asked_by}` } } };++ }++ throw problem(404, 'not_found');++ },++};++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ aiClient = require('../server/ai/client');++ aiMembers = require('../server/rooms/ai-members');++ config = require('../server/config');++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));++ aiClient._setClient({ runs: { create: async (workflow, input) => { shared.push({ workflow, input }); return { run: { status: 'succeeded', output: { text: 'OpenVibe here' } } }; } } });++ aiClient._setHttp(fakeHttp);++ assert.strictEqual((await api('POST', '/rooms', ann, { name: 'Model Night', visibility: 'private' })).status, 201);++ assert.strictEqual((await api('POST', '/rooms/model-night/members', ann, { username: 'bob' })).status, 200);++});++++t('your AI key: stored in OpenVibe.AI, never shown back; checked first', async () => {++ assert.deepStrictEqual((await api('GET', '/me/ai-key', ann)).body, { key: null });++ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'gemini', model: 'x', api_key: 'sk-test-1234' })).status, 422, 'a provider AI knows');++ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'openai', model: 'two words', api_key: 'sk-test-1234' })).status, 422, 'a model name');++ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'anthropic', base_url: 'https://x.example', model: 'm', api_key: 'sk-test-1234' })).status, 422, 'an endpoint is for OpenAI-compatible providers');++ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'http://insecure.example', model: 'm', api_key: 'sk-test-1234' })).status, 422, 'https only');++ const first = await api('PUT', '/me/ai-key', ann, { provider: 'openai', model: 'gpt-4o-mini' });++ assert.deepStrictEqual([first.status, first.body.code], [400, 'credentials.key'], 'the key is needed the first time');++ const put = await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'https://openrouter.ai/api/v1/', model: 'gpt-4o-mini', api_key: 'sk-test-abcd', budget_usd_per_day: 2 });++ assert.strictEqual(put.status, 200, put.text);++ assert.deepStrictEqual([put.body.key.provider, put.body.key.base_url, put.body.key.model, put.body.key.key_hint, put.body.key.budget_usd_per_day], ['openai', 'https://openrouter.ai/api/v1', 'gpt-4o-mini', 'abcd', 2]);++ assert.ok(!JSON.stringify(put.body).includes('sk-test-abcd'), 'the key itself never comes back');++ assert.strictEqual(store.get(h.live.users.get(ann.id).subject_id).owner, 'chat');++ const keep = await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'https://openrouter.ai/api/v1', model: 'gpt-4.1-mini' });++ assert.strictEqual(keep.status, 200, 'changing only the model keeps the key');++});++++t('a member thinks with its manager\'s own key; the room sees which model', async () => {++ assert.strictEqual((await api('POST', '/rooms/model-night/ai-members', bob, { name: 'Sage', engine: 'own' })).status, 403, 'managers only');++ assert.strictEqual((await api('POST', '/rooms/model-night/ai-members', ann, { name: 'Sage', engine: 'gpt' })).status, 422);++ const own = await api('POST', '/rooms/model-night/ai-members', ann, { name: 'Sage', persona: 'Thoughtful', engine: 'own' });++ assert.strictEqual(own.status, 201, own.text);++ assert.deepStrictEqual([own.body.engine, own.body.model], ['own', 'openai (openrouter.ai) · gpt-4.1-mini, Ann\'s key']);++ const plain = await api('POST', '/rooms/model-night/ai-members', ann, { name: 'Pip' });++ assert.deepStrictEqual([plain.body.engine, plain.body.model], ['openvibe', 'OpenVibe']);++ const seen = (await api('GET', '/rooms/model-night/ai-members', bob)).body.ai_members;++ assert.deepStrictEqual(seen.map((m) => m.model), ['openai (openrouter.ai) · gpt-4.1-mini, Ann\'s key', 'OpenVibe'], 'members see each model, not the set-up');++ const page = await web('GET', '/r/model-night', bob);++ assert.ok(page.text.includes('<strong>Sage</strong> <span class="oc-muted oc-ai-model">(openai (openrouter.ai) · gpt-4.1-mini, Ann's key)</span>') || page.text.includes('(openai (openrouter.ai) · gpt-4.1-mini, Ann's key)'), 'the room page names the model');++});++++t('@sage answers through the key (a credentialed run); outside the room\'s daily cap; @pip with OpenVibe\'s', async () => {++ const keep = config.ai.memberRepliesPerDay;++ config.ai.memberRepliesPerDay = 0;++ try {++ await api('POST', '/rooms/model-night/messages', bob, { message: '@sage hi' });++ await settle();++ const run = runs[runs.length - 1];++ assert.ok(run, 'a run went out');++ assert.deepStrictEqual([run.workflow, run.credential], ['chat.member.reply', { subject: h.live.users.get(ann.id).subject_id }]);++ assert.strictEqual(run.attribution.type, 'room');++ let last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages.pop();++ assert.deepStrictEqual([last.display_name, last.message], ['Sage', 'From gpt-4.1-mini: hello Bob']);++ await api('POST', '/rooms/model-night/messages', bob, { message: '@pip hi' });++ await settle();++ last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages.pop();++ assert.match(last.message, /my limit/, 'OpenVibe\'s model keeps the room\'s cap');++ assert.strictEqual(shared.length, 0);++ } finally { config.ai.memberRepliesPerDay = keep; }++ await api('POST', '/rooms/model-night/messages', bob, { message: '@pip and now?' });++ await settle();++ assert.strictEqual(shared.length, 1, 'with room under the cap, Pip answers with OpenVibe\'s model');++ assert.ok(!('credential' in (runs[runs.length - 1] || {})) || runs.length === 1, 'and not through the key');++});++++t('the settings pages: your key (hint, budget), the room\'s "thinks with" choice', async () => {++ const settings = await web('GET', '/settings', ann);++ const sect = settings.text.slice(settings.text.indexOf('id="oc-ai-key"'), settings.text.indexOf('id="oc-ai-key"') + 1500);++ assert.ok(sect.includes('key ending abcd') && sect.includes('$0.25 of $2.00 used today'), sect);++ assert.ok(!settings.text.includes('sk-test-abcd'));++ const bobs = await web('GET', '/settings', bob);++ assert.ok(bobs.text.includes('No key yet.'));++ const saved = await web('POST', '/settings/ai-key', bob, { provider: 'anthropic', model: 'claude-haiku-4-5', api_key: 'sk-ant-test-wxyz', budget_usd_per_day: '0' });++ assert.deepStrictEqual([saved.status, saved.location], [303, '/settings?saved=1#oc-ai-key']);++ assert.strictEqual(store.get(h.live.users.get(bob.id).subject_id).budget_usd_per_day, 0, '0 = no cap');++ const room = await web('GET', '/r/model-night/settings', ann);++ assert.ok(room.text.includes('Thinks with <select name="engine">') && room.text.includes('Your own key: openai (openrouter.ai) · gpt-4.1-mini'));++ const sage = (await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage');++ const back = await web('POST', `/r/model-night/ai-members/${sage.id}`, ann, { name: 'Sage', persona: 'Thoughtful', memory: '', enabled: '1', engine: 'openvibe' });++ assert.strictEqual(back.status, 303);++ assert.strictEqual((await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage').engine, 'openvibe', 'switched back from the form');++ await web('POST', `/r/model-night/ai-members/${sage.id}`, ann, { name: 'Sage', persona: 'Thoughtful', memory: '', enabled: '1', engine: 'own' });++ assert.strictEqual((await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage').engine, 'own');++});++++t('a new model relabels its members; removing the key sends them back to OpenVibe\'s model', async () => {++ await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'https://openrouter.ai/api/v1', model: 'gpt-5-mini' });++ let sage = (await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage');++ assert.strictEqual(sage.model, 'openai (openrouter.ai) · gpt-5-mini, Ann\'s key');++ const del = await api('DELETE', '/me/ai-key', ann);++ assert.deepStrictEqual([del.status, del.body.members_switched], [200, 1]);++ sage = (await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage');++ assert.deepStrictEqual([sage.engine, sage.model], ['openvibe', 'OpenVibe']);++ 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 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);++ const general = (await api('GET', '/servers/model-guild', ann)).body.channels[0].room;++ const settings = await web('GET', '/s/model-guild/settings', ann);++ assert.ok(settings.text.includes(`<a href="/r/${general}/settings#oc-ai">AI members in #general</a>`), 'linked from the channel');++ const page = await web('GET', `/r/${general}/settings`, ann);++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('AI members in #general') && page.text.includes('Add an AI member') && !page.text.includes('Who can read'), 'only the AI members part');++ const add = await web('POST', `/r/${general}/ai-members`, ann, { name: 'Oracle', persona: '', memory: '', engine: 'openvibe' });++ assert.deepStrictEqual([add.status, add.location], [303, `/r/${general}/settings?saved=1`]);++ assert.deepStrictEqual([(await web('GET', `/r/${general}/settings`, bob)).status, (await web('GET', `/r/${general}/settings`, bob)).location], [302, '/s/model-guild/settings']);++ const channel = await web('GET', '/s/model-guild/general', bob);++ assert.ok(channel.text.includes('<strong>Oracle</strong> <span class="oc-muted oc-ai-model">(OpenVibe)</span>'), 'the channel names its AI members and their model');++});++++t('an erased account\'s members go back to OpenVibe\'s model', async () => {++ assert.strictEqual((await api('POST', '/rooms', bob, { name: 'Bob Den', visibility: 'private' })).status, 201);++ assert.strictEqual((await api('POST', '/rooms/bob-den/members', bob, { username: 'ann' })).status, 200, 'someone else is in it, so it outlives Bob');++ const m = await api('POST', '/rooms/bob-den/ai-members', bob, { name: 'Haiku', engine: 'own' });++ assert.deepStrictEqual([m.body.engine, m.body.model], ['own', 'anthropic · claude-haiku-4-5, Bob\'s key'], m.text);++ const sid = h.live.users.get(bob.id).subject_id;++ const del = { event_id: ids.newId('event'), event_type: 'network.account.deleted', version: 1, source: 'network', actor: { type: 'user', id: sid },++ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: sid },++ payload: { deletion_id: `del_${ids.ulid()}`, subject: sid, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() } };++ assert.strictEqual(await require('../server/chat/account-data').apply(del, { send: async () => ({ ok: true, status: 200 }) }), 'erased');++ const row = await require('../server/db/database').get('SELECT engine, engine_subject, engine_label FROM room_ai_members WHERE handle = ?', ['haiku']);++ assert.ok(row, 'the member stays (rooms outlive their owner)');++ assert.deepStrictEqual([row.engine, row.engine_subject, row.engine_label], ['openvibe', null, null]);++});++++t.run(async () => { if (aiClient) { aiClient._setClient(null); aiClient._setHttp(null); } if (h && h.close) await h.close(); });
@@ -308,6 +308,19 @@ only; members see names and handles, managers the set-up); the settings page man
@@ -308,6 +308,19 @@ only; members see names and handles, managers the set-up); the settings page man
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
+**Their own models** (`migrations/0014_ai_member_models.sql`, `server/ai/credentials.js`; the owner's multi-model group
+chat). A person keeps an AI provider key through Chat: OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter,
+Groq, Together, their own server), a model and a daily budget in US dollars (0 = no cap; left out on a change, it stays).
+The key goes to OpenVibe.AI (`PUT|GET|DELETE /api/v1/credentials/:subject`, Chat as its owner, Network grant
+`chat ai.credential.manage`) and is never shown again, only a four-character hint and today's spend. A room's manager
+sets a member to think with their own key (`engine: own`; `openvibe` is OpenVibe's model): its answers are runs that
+name `credential { subject }`, so they reach that provider only, count against the key's budget and not the room's
+daily cap. The room page and the API (`model`) say which model each member uses ("openai (openrouter.ai) · gpt-4o-mini,
+Ann's key"). Removing the key (or the account) sends its members back to OpenVibe's model; a new model relabels them.
+API: `GET|PUT|DELETE /api/chat/me/ai-key` (own sign-in only, not API tokens); pages: `/settings#oc-ai-key` and the
+"Thinks with" choice on each AI member. A server's admins give a text channel AI members at `/r/<channel room>/settings`,
+linked from the channel on the server's settings page.
+
**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,
**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,
`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,
`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,
channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text
channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- A model of their own for AI members (server/rooms/ai-members.js; the owner's "multi model group chat"): an AI member
+-- thinks with OpenVibe's model (engine 'openvibe', the default) or with the provider key a person keeps in OpenVibe.AI
+-- through Chat (engine 'own': OpenAI, Anthropic or any OpenAI-compatible endpoint, their model, their daily budget).
+-- engine_subject is whose key; engine_label what the room shows ("openai · gpt-4o-mini").
+ALTER TABLE room_ai_members ADD COLUMN IF NOT EXISTS engine text COLLATE "C" NOT NULL DEFAULT 'openvibe';
+ALTER TABLE room_ai_members ADD COLUMN IF NOT EXISTS engine_subject text COLLATE "C";
+ALTER TABLE room_ai_members ADD COLUMN IF NOT EXISTS engine_label text COLLATE "C";
+ALTER TABLE room_ai_members ADD CONSTRAINT room_ai_members_engine CHECK (engine IN ('openvibe', 'own'));
@@ -11,6 +11,10 @@
@@ -11,6 +11,10 @@
const config = require('../config');
const config = require('../config');
let _ai = null;
let _ai = null;
+let _http = null;
+
+/** The HTTP client behind ai() (Chat's token for openvibe.ai), for calls the SDK's AI client has no method for. */
+function http() { ai(); return _http; }
function ai() {
function ai() {
if (_ai) return _ai;
if (_ai) return _ai;
@@ -25,8 +29,8 @@ function ai() {
@@ -25,8 +29,8 @@ function ai() {
audience: 'openvibe.ai',
audience: 'openvibe.ai',
scope: config.ai.scope,
scope: config.ai.scope,
});
});
- const http = createClient({ tokenProvider: tokenClient, timeoutMs: config.ai.waitMs + 10000 });
- _ai = createAiClient(http, { baseUrl: config.ai.internalUrl });
+ _http = createClient({ tokenProvider: tokenClient, timeoutMs: config.ai.waitMs + 10000 });
+ _ai = createAiClient(_http, { baseUrl: config.ai.internalUrl });
return _ai;
return _ai;
}
}
@@ -44,11 +48,20 @@ async function structured(workflow, input, opts = {}) {
@@ -44,11 +48,20 @@ async function structured(workflow, input, opts = {}) {
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
try {
try {
const { runs } = ai();
const { runs } = ai();
- const body = await runs.create(workflow, input, {
- wait: config.ai.waitMs,
- ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
- ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),
- });
+ // A person's own provider key (ai.run-request `credential`): the SDK's runs.create does not carry it, so such a
+ // run is posted through the same client (and token).
+ const body = opts.credential
+ ? await http().json({
+ service: 'ai', baseUrl: config.ai.internalUrl, audience: 'openvibe.ai', method: 'POST', path: '/api/v1/runs',
+ query: { wait: Math.max(0, Math.floor(config.ai.waitMs)) },
+ json: { workflow, input, credential: opts.credential, ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}) },
+ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : { idempotencyKey: false }),
+ })
+ : await runs.create(workflow, input, {
+ wait: config.ai.waitMs,
+ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
+ ...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),
+ });
const run = body && (body.run || body);
const run = body && (body.run || body);
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
if (!run || run.status !== 'succeeded' || run.synthetic) return null;
return run.output || null;
return run.output || null;
@@ -58,4 +71,4 @@ async function structured(workflow, input, opts = {}) {
@@ -58,4 +71,4 @@ async function structured(workflow, input, opts = {}) {
}
}
}
}
-module.exports = { structured, enabled, _setClient(c) { _ai = c; }, _reset() { _ai = null; } };
+module.exports = { structured, enabled, http, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _reset() { _ai = null; _http = null; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * A person's own AI provider key, for the AI members they set up in rooms (server/rooms/ai-members.js). The key lives in
+ * OpenVibe.AI (PUT/GET/DELETE /api/v1/credentials/:subject, ai.credential-put@1 / ai.credential@1), encrypted there with
+ * Chat as its owner; Chat never stores or shows it again (only AI's four-character hint). A run that names the
+ * credential calls that provider with that key only, never a shared one, inside the person's own daily budget.
+ *
+ * get(subject) → { provider, base_url, key_hint, model, budget_usd_per_day, spent_usd_today } | null
+ * put(subject, { provider, base_url?, api_key?, model, budget_usd_per_day? })
+ * remove(subject)
+ * 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).
+ */
+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' };
+
+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); };
+
+function call(method, subject, json) {
+ if (!config.oauth.clientSecret) fail(503, 'credentials.off', 'Your own AI key cannot be stored here right now.');
+ if (!SUBJECT_RE.test(String(subject || ''))) fail(400, 'credentials.subject', 'An OpenVibe account is needed for your own AI key.');
+ return aiClient.http().json({
+ service: 'ai', baseUrl: config.ai.internalUrl, audience: 'openvibe.ai', method, path: `/api/v1/credentials/${encodeURIComponent(subject)}`,
+ ...(json ? { json } : {}), idempotencyKey: false,
+ });
+}
+
+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,
+ 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);
+
+async function get(subject) {
+ try { return view(await call('GET', subject)); } catch (err) {
+ if (err instanceof CredentialError) throw err;
+ if (err && err.status === 404) return null;
+ throw new CredentialError(502, 'credentials.unreachable', 'OpenVibe.AI did not answer. Try again in a minute.');
+ }
+}
+
+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.');
+ 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.');
+ 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();
+ if (key && (key.length < 8 || key.length > 400)) fail(422, 'credentials.key', 'That does not look like an API key.');
+ // Left out, the budget stays what it is ($1 a day the first time).
+ const budget = budget_usd_per_day == null || budget_usd_per_day === ''
+ ? (await get(subject).then((c) => (c && c.budget_usd_per_day != null ? c.budget_usd_per_day : 1), () => 1))
+ : Number(budget_usd_per_day);
+ if (!Number.isFinite(budget) || budget < 0 || budget > 1000) fail(422, 'credentials.budget', 'A daily budget is 0 to 1000 US dollars (0 means no cap).');
+ try {
+ return view(await call('PUT', subject, { provider, ...(url ? { base_url: url } : {}), ...(key ? { api_key: key } : {}), models: { chat: m }, budget_usd_per_day: budget }));
+ } catch (err) {
+ if (err instanceof CredentialError) throw err;
+ if (err && err.code === 'credential.key_required') throw new CredentialError(400, 'credentials.key', 'Enter the key: it is needed the first time, and again for a new provider or endpoint.');
+ if (err && err.status >= 400 && err.status < 500) throw new CredentialError(err.status, 'credentials.refused', (err.detail || err.message || 'OpenVibe.AI refused the key.').slice(0, 200));
+ throw new CredentialError(502, 'credentials.unreachable', 'OpenVibe.AI did not answer. Try again in a minute.');
+ }
+}
+
+async function remove(subject) {
+ try { await call('DELETE', subject); } catch (err) {
+ if (err instanceof CredentialError) throw err;
+ if (err && err.status === 404) return;
+ throw new CredentialError(502, 'credentials.unreachable', 'OpenVibe.AI did not answer. Try again in a minute.');
+ }
+}
+
+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);
+
+module.exports = { get, put, remove, label, CredentialError, PROVIDERS };
@@ -99,6 +99,8 @@ const ERASE_AFTER_ROOMS = [
@@ -99,6 +99,8 @@ const ERASE_AFTER_ROOMS = [
['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],
['pending_messages', 'DELETE FROM pending_ip_messages WHERE user_id = ANY(@ids)'],
// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
// An AI member belongs to its room, not to whoever added it: it stays, without their name on it.
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
[null, 'UPDATE room_ai_members SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)'],
+ // Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
+ [null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],
];
];
// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.
// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.
const ERASE_CHANNEL = [
const ERASE_CHANNEL = [
@@ -916,4 +916,33 @@ router.get('/me/export', requireAuth, exportLimit, async (req, res) => {
@@ -916,4 +916,33 @@ router.get('/me/export', requireAuth, exportLimit, async (req, res) => {
}
}
});
});
+// ── Your own AI key, for the AI members you set up in rooms (server/ai/credentials.js) ─────────────────
+// Kept in OpenVibe.AI, never shown again (only a four-character hint). With your own sign-in only, not an API token.
+const aiKeyLimit = limits('chat.ai_key', { minute: 10, hour: 60 });
+const aiKeyPersonOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Manage your AI key with your own sign-in, not an API token', code: 'credentials.person_only' }) : next());
+function aiKeySend(res, err) {
+ const credentials = require('../ai/credentials');
+ if (err instanceof credentials.CredentialError) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.error('[Chat] AI key:', err && err.message);
+ return res.status(500).json({ error: 'Could not reach your AI key right now' });
+}
+router.get('/me/ai-key', requireAuth, aiKeyPersonOnly, aiKeyLimit, async (req, res) => {
+ try { res.set('Cache-Control', 'private, no-store').json({ key: await require('../ai/credentials').get(req.user.subject_id) }); } catch (err) { aiKeySend(res, err); }
+});
+router.put('/me/ai-key', requireAuth, aiKeyPersonOnly, aiKeyLimit, async (req, res) => {
+ try {
+ const credentials = require('../ai/credentials');
+ const key = await credentials.put(req.user.subject_id, req.body || {});
+ await require('../rooms/ai-members').relabelKey(req.user.subject_id, key, req.user);
+ res.set('Cache-Control', 'private, no-store').json({ key });
+ } catch (err) { aiKeySend(res, err); }
+});
+router.delete('/me/ai-key', requireAuth, aiKeyPersonOnly, aiKeyLimit, async (req, res) => {
+ try {
+ await require('../ai/credentials').remove(req.user.subject_id);
+ const switched = await require('../rooms/ai-members').dropKey(req.user.subject_id);
+ res.json({ ok: true, members_switched: switched });
+ } catch (err) { aiKeySend(res, err); }
+});
+
module.exports = router;
module.exports = router;
@@ -187,7 +187,7 @@ module.exports = {
@@ -187,7 +187,7 @@ module.exports = {
ai: {
ai: {
enabled: bool(process.env.CHAT_AI_ENABLED, false),
enabled: bool(process.env.CHAT_AI_ENABLED, false),
internalUrl: strip(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),
internalUrl: strip(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),
- scope: 'ai.run.create ai.run.read',
+ scope: 'ai.run.create ai.run.read ai.credential.manage',
waitMs: Math.max(0, int(process.env.CHAT_AI_WAIT_MS, 60000)),
waitMs: Math.max(0, int(process.env.CHAT_AI_WAIT_MS, 60000)),
tickMs: Math.max(1000, int(process.env.CHAT_AI_TICK_MS, 45000)),
tickMs: Math.max(1000, int(process.env.CHAT_AI_TICK_MS, 45000)),
// A room's AI members (server/rooms/ai-members.js): on unless CHAT_AI_MEMBERS=0, independent of the insight job
// A room's AI members (server/rooms/ai-members.js): on unless CHAT_AI_MEMBERS=0, independent of the insight job
@@ -13,6 +13,11 @@
@@ -13,6 +13,11 @@
* an AI member never answers an AI member.
* an AI member never answers an AI member.
* - Limits: one answer at a time per room, CHAT_AI_MEMBER_REPLIES_PER_DAY a room (100), and 20 an hour from one
* - Limits: one answer at a time per room, CHAT_AI_MEMBER_REPLIES_PER_DAY a room (100), and 20 an hour from one
* person. Past them, or with AI unavailable, the member says so once instead of answering.
* person. Past them, or with AI unavailable, the member says so once instead of answering.
+ * - A model of its own (migrations/0014_ai_member_models.sql): engine 'openvibe' thinks with OpenVibe's model; engine
+ * 'own' with the provider key the manager who set it keeps in OpenVibe.AI through Chat (server/ai/credentials.js:
+ * OpenAI, Anthropic or an OpenAI-compatible endpoint, their model, their daily budget). The room sees which
+ * (engine_label). Such a member is outside the room's daily cap (the key's own budget limits it); if the key is
+ * removed, its members go back to OpenVibe's model (dropKey).
* - Off with CHAT_AI_MEMBERS=0, and without the OAuth client secret (no token to call AI with).
* - Off with CHAT_AI_MEMBERS=0, and without the OAuth client secret (no token to call AI with).
*
*
* Room messages written by an AI member have user_id 0 and the member's name (rooms.js MSG_SELECT joins it).
* Room messages written by an AI member have user_id 0 and the member's name (rooms.js MSG_SELECT joins it).
@@ -20,6 +25,7 @@
@@ -20,6 +25,7 @@
const db = require('../db/database');
const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const aiClient = require('../ai/client');
const aiClient = require('../ai/client');
+const credentials = require('../ai/credentials');
const MAX_PER_ROOM = 3;
const MAX_PER_ROOM = 3;
const TRANSCRIPT = 30;
const TRANSCRIPT = 30;
@@ -59,6 +65,22 @@ function validate({ name, persona, memory } = {}, { partial = false } = {}) {
@@ -59,6 +65,22 @@ function validate({ name, persona, memory } = {}, { partial = false } = {}) {
return out;
return out;
}
}
+/**
+ * The engine an add or an update asks for: 'openvibe', or 'own' (the acting person's own key, which must be stored).
+ * Returns the columns to write, or null when the input does not name an engine.
+ */
+async function engineOf(input, user) {
+ if (input.engine === undefined || input.engine === null || input.engine === '') return null;
+ if (input.engine === 'openvibe') return { engine: 'openvibe', engine_subject: null, engine_label: null };
+ if (input.engine !== 'own') fail(422, 'rooms.ai_engine', 'An AI member thinks with OpenVibe\'s model or with your own key');
+ const subject = user && user.subject_id;
+ if (!subject) fail(403, 'rooms.ai_engine', 'Sign in with an OpenVibe account to use your own AI key');
+ 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` };
+}
+
/** 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. */
async function add(room, user, can, input = {}) {
async function add(room, user, can, input = {}) {
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner adds AI members');
@@ -67,21 +89,26 @@ async function add(room, user, can, input = {}) {
@@ -67,21 +89,26 @@ async function add(room, user, can, input = {}) {
if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);
if ((await list(room)).length >= MAX_PER_ROOM) fail(409, 'rooms.ai_limit', `A room has at most ${MAX_PER_ROOM} AI members`);
if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
if (await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ?', [room.id, v.handle])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
const link = input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;
const link = input.link_service === 'food' && /^[A-Za-z0-9_-]{4,64}$/.test(String(input.link_resource || '')) ? { service: 'food', resource: String(input.link_resource) } : null;
+ const e = (await engineOf(input, user)) || { engine: 'openvibe', engine_subject: null, engine_label: null };
const r = await db.run(
const r = await db.run(
- 'INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- [room.id, v.name, v.handle, v.persona || '', v.memory || '', link ? link.service : null, link ? link.resource : null, (user && user.subject_id) || null]);
+ `INSERT INTO room_ai_members (room_id, name, handle, persona, memory, link_service, link_resource, created_by_subject, engine, engine_subject, engine_label)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
+ [room.id, v.name, v.handle, v.persona || '', v.memory || '', link ? link.service : null, link ? link.resource : null, (user && user.subject_id) || null, e.engine, e.engine_subject, e.engine_label]);
return await byId(room, r.lastInsertRowid);
return await byId(room, r.lastInsertRowid);
}
}
-async function update(room, can, id, input = {}) {
+async function update(room, can, id, input = {}, user = null) {
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');
if (!can || !can.manage) fail(403, 'rooms.forbidden', 'Only the room\'s owner changes AI members');
const m = await byId(room, id);
const m = await byId(room, id);
if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');
if (!m) fail(404, 'rooms.ai_not_found', 'No such AI member in this room');
const v = validate(input, { partial: true });
const v = validate(input, { partial: true });
if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
if (v.handle && v.handle !== m.handle && await db.get('SELECT 1 FROM room_ai_members WHERE room_id = ? AND handle = ? AND id <> ?', [room.id, v.handle, m.id])) fail(409, 'rooms.ai_taken', `This room already has an AI member called @${v.handle}`);
const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);
const enabledFlag = input.enabled === undefined ? m.enabled : (input.enabled === false || input.enabled === '0' || input.enabled === 0 ? 0 : 1);
- await db.run('UPDATE room_ai_members SET name = ?, handle = ?, persona = ?, memory = ?, enabled = ?, updated_at = ov_now() WHERE id = ?',
- [v.name || m.name, v.handle || m.handle, v.persona !== undefined ? v.persona : m.persona, v.memory !== undefined ? v.memory : m.memory, enabledFlag, m.id]);
+ // Keeping 'own' as it is needs no key of the editor's: only a change to it does (someone else's key stays theirs).
+ const e = input.engine === 'own' && m.engine === 'own' ? null : await engineOf(input, user);
+ const eng = e || { engine: m.engine || 'openvibe', engine_subject: m.engine_subject || null, engine_label: m.engine_label || null };
+ await db.run('UPDATE room_ai_members SET name = ?, handle = ?, persona = ?, memory = ?, enabled = ?, engine = ?, engine_subject = ?, engine_label = ?, updated_at = ov_now() WHERE id = ?',
+ [v.name || m.name, v.handle || m.handle, v.persona !== undefined ? v.persona : m.persona, v.memory !== undefined ? v.memory : m.memory, enabledFlag, eng.engine, eng.engine_subject, eng.engine_label, m.id]);
return await byId(room, m.id);
return await byId(room, m.id);
}
}
@@ -90,6 +117,20 @@ async function remove(room, can, id) {
@@ -90,6 +117,20 @@ async function remove(room, can, id) {
await db.run('DELETE FROM room_ai_members WHERE room_id = ? AND id = ?', [room.id, Number(id) || 0]);
await db.run('DELETE FROM room_ai_members WHERE room_id = ? AND id = ?', [room.id, Number(id) || 0]);
}
}
+/** A person's key is gone (they removed it, or their account was erased): their members go back to OpenVibe's model. */
+async function dropKey(subject) {
+ if (!subject) return 0;
+ const r = await db.run("UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL, updated_at = ov_now() WHERE engine_subject = ?", [String(subject)]);
+ return r.changes || 0;
+}
+
+/** Keeps every member that uses this person's key showing the key's current provider and model. */
+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)]);
+}
+
/** 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. */
function mentioned(members, text) {
function mentioned(members, text) {
const t = String(text || '').toLowerCase();
const t = String(text || '').toLowerCase();
@@ -158,7 +199,8 @@ async function afterPost(room, message, user, deps = {}) {
@@ -158,7 +199,8 @@ async function afterPost(room, message, user, deps = {}) {
};
};
if (inFlight.has(room.id)) return null;
if (inFlight.has(room.id)) return null;
if (!personOk(room.id, user.id, Date.now())) return null;
if (!personOk(room.id, user.id, Date.now())) return null;
- if (await repliesToday(room) >= config.ai.memberRepliesPerDay) {
+ const own = member.engine === 'own' && member.engine_subject;
+ if (!own && await repliesToday(room) >= config.ai.memberRepliesPerDay) {
return await say(`I have answered ${config.ai.memberRepliesPerDay} times in this room today, which is my limit. Ask me again tomorrow.`);
return await say(`I have answered ${config.ai.memberRepliesPerDay} times in this room today, which is my limit. Ask me again tomorrow.`);
}
}
inFlight.add(room.id);
inFlight.add(room.id);
@@ -171,10 +213,11 @@ async function afterPost(room, message, user, deps = {}) {
@@ -171,10 +213,11 @@ async function afterPost(room, message, user, deps = {}) {
memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',
memory: member.memory || '', context: context ? context.text.slice(0, 4000) : '', context_source: context ? context.source.slice(0, 80) : '',
transcript: transcriptOf(history) || '(no earlier messages)',
transcript: transcriptOf(history) || '(no earlier messages)',
asked_by: (user.display_name || user.username || 'someone').slice(0, 80), message: String(message.message).slice(0, 2000),
asked_by: (user.display_name || user.username || 'someone').slice(0, 80), message: String(message.message).slice(0, 2000),
- }, { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}` });
+ }, { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,
+ ...(own ? { credential: { subject: member.engine_subject } } : {}) });
const text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';
const text = out && typeof out.text === 'string' ? clean(out.text, 2000) : '';
- if (!text) return await say('I could not answer just now. Try again in a minute.');
- await countReply(room);
+ if (!text) return await say(own ? 'I could not reach my model just now (its key may be out of today\'s budget, or the provider did not answer). Try again later, or the room\'s owner can switch me to OpenVibe\'s model.' : 'I could not answer just now. Try again in a minute.');
+ if (!own) await countReply(room);
return await say(text);
return await say(text);
} catch (err) {
} catch (err) {
console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);
console.warn(`[Chat AI members] room ${room.id}: ${(err && err.message) || err}`);
@@ -184,11 +227,15 @@ async function afterPost(room, message, user, deps = {}) {
@@ -184,11 +227,15 @@ async function afterPost(room, message, user, deps = {}) {
}
}
}
}
+/** What the room shows a member thinks with: "OpenVibe", or the own key's provider, model and whose key it is. */
+const modelOf = (m) => (m.engine === 'own' ? (m.engine_label || 'someone\'s own key') : 'OpenVibe');
+
const toWire = (m) => ({
const toWire = (m) => ({
id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),
id: Number(m.id), name: m.name, handle: m.handle, persona: m.persona, memory: m.memory, enabled: Boolean(Number(m.enabled)),
- link: m.link_service ? { service: m.link_service, resource: m.link_resource } : null, created_at: m.created_at, updated_at: m.updated_at,
+ link: m.link_service ? { service: m.link_service, resource: m.link_resource } : null,
+ engine: m.engine || 'openvibe', model: modelOf(m), created_at: m.created_at, updated_at: m.updated_at,
});
});
function _reset() { inFlight.clear(); personHour.clear(); }
function _reset() { inFlight.clear(); personHour.clear(); }
-module.exports = { MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, mentioned, handleOf, afterPost, transcriptOf, toWire, _reset };
+module.exports = { MAX_PER_ROOM, LIMITS, AiMemberError, enabled, list, byId, add, update, remove, dropKey, relabelKey, modelOf, mentioned, handleOf, afterPost, transcriptOf, toWire, _reset };
@@ -235,7 +235,7 @@ function aiSend(res, err) {
@@ -235,7 +235,7 @@ function aiSend(res, err) {
router.get('/:slug/ai-members', optionalAuth, async (req, res) => {
router.get('/:slug/ai-members', optionalAuth, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
const can = await rooms.access(room, req.user || null);
const can = await rooms.access(room, req.user || null);
- const list = (await aiMembers.list(room)).map(aiMembers.toWire).map((m) => (can.manage ? m : { id: m.id, name: m.name, handle: m.handle, enabled: m.enabled }));
+ const list = (await aiMembers.list(room)).map(aiMembers.toWire).map((m) => (can.manage ? m : { id: m.id, name: m.name, handle: m.handle, enabled: m.enabled, model: m.model }));
res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ ai_members: list, enabled: aiMembers.enabled(), max: aiMembers.MAX_PER_ROOM });
res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ ai_members: list, enabled: aiMembers.enabled(), max: aiMembers.MAX_PER_ROOM });
});
});
router.post('/:slug/ai-members', requireAuth, personOnly, manageLimit, async (req, res) => {
router.post('/:slug/ai-members', requireAuth, personOnly, manageLimit, async (req, res) => {
@@ -247,7 +247,7 @@ router.post('/:slug/ai-members', requireAuth, personOnly, manageLimit, async (re
@@ -247,7 +247,7 @@ router.post('/:slug/ai-members', requireAuth, personOnly, manageLimit, async (re
});
});
router.patch('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {
router.patch('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
- try { res.json(aiMembers.toWire(await aiMembers.update(room, await rooms.access(room, req.user), req.params.id, req.body || {}))); } catch (err) { aiSend(res, err); }
+ try { res.json(aiMembers.toWire(await aiMembers.update(room, await rooms.access(room, req.user), req.params.id, req.body || {}, req.user))); } catch (err) { aiSend(res, err); }
});
});
router.delete('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {
router.delete('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
@@ -388,10 +388,50 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
@@ -388,10 +388,50 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
</form>
</form>
<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)}`,
});
});
});
});
+ // ── 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) {
+ 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` : '';
+ 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>'}
+<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>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>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>
+${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete"><button type="submit" class="oc-link">Remove my key</button> <span class="oc-muted">(AI members that use it go back to OpenVibe's model)</span></form>` : ''}`}`;
+ }
+ router.post('/settings/ai-key', formLimit, sameSite, async (req, res) => {
+ 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 });
+ 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`); }
+ });
+ router.post('/settings/ai-key/delete', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ try {
+ await credentials.remove(actor.user.subject_id);
+ await require('../rooms/ai-members').dropKey(actor.user.subject_id);
+ return res.redirect(303, '/settings?saved=1#oc-ai-key');
+ } catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not remove your key right now')}#oc-ai-key`); }
+ });
+
router.post('/settings', formLimit, sameSite, async (req, res) => {
router.post('/settings', formLimit, sameSite, async (req, res) => {
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 || {};
@@ -547,7 +587,7 @@ ${people}
@@ -547,7 +587,7 @@ ${people}
const isCall = room.kind === 'call';
const isCall = room.kind === 'call';
const callReady = isCall && !!require('../config').calls.enabled;
const callReady = isCall && !!require('../config').calls.enabled;
const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];
const ais = aiMembers.enabled() ? (await aiMembers.list(room)).filter((a) => Number(a.enabled)) : [];
- const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';
+ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
@@ -622,7 +662,8 @@ ${composer}${tools}`,
@@ -622,7 +662,8 @@ ${composer}${tools}`,
router.get('/r/:slug/settings', async (req, res) => {
router.get('/r/:slug/settings', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, room, can } = x;
const { actor, room, can } = x;
- if (room.server_id) return res.redirect(302, await channelUrl(room, '/settings'));
+ // A server's channel is set up on the server's settings page; only its AI members are set here (server admins).
+ if (room.server_id && !can.manage) return res.redirect(302, await channelUrl(room, '/settings'));
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
@@ -637,18 +678,34 @@ ${composer}${tools}`,
@@ -637,18 +678,34 @@ ${composer}${tools}`,
const addRole = rooms.joinRoleOf(room);
const addRole = rooms.joinRoleOf(room);
const attachments = can.manage ? await rooms.attachments(room) : [];
const attachments = can.manage ? await rooms.attachments(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
const ais = can.manage && room.kind !== 'system' ? await aiMembers.list(room) : [];
+ let 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 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)';
+ 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>'}`;
+ };
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>'}
<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label>
<label>Name <input name="name" required minlength="2" maxlength="24" value="${a ? esc(a.name) : ''}" placeholder="Pantry"></label>
<label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>
<label>How it should be <textarea name="persona" rows="2" maxlength="1000" placeholder="A cheerful cook who keeps meals cheap and quick">${a ? esc(a.persona) : ''}</textarea></label>
<label>Notes it always knows <textarea name="memory" rows="3" maxlength="2000" placeholder="Lee is vegetarian. We shop on Saturdays. Budget: $80 a week.">${a ? esc(a.memory) : ''}</textarea></label>
<label>Notes it always knows <textarea name="memory" rows="3" maxlength="2000" placeholder="Lee is vegetarian. We shop on Saturdays. Budget: $80 a week.">${a ? esc(a.memory) : ''}</textarea></label>
+${engineField(a)}
${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label>` : ''}
${a ? `<label class="oc-check"><input type="checkbox" name="enabled" value="1"${Number(a.enabled) ? ' checked' : ''}> Answers when someone writes @${esc(a.handle)}</label>` : ''}
<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;
<p><button type="submit">${a ? 'Save' : 'Add'}</button></p></form>`;
const aiSection = can.manage && room.kind !== 'system' ? `<h2 id="oc-ai">AI members</h2>
const aiSection = can.manage && room.kind !== 'system' ? `<h2 id="oc-ai">AI members</h2>
${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}
${aiMembers.enabled() ? '' : notice('error', 'AI members are switched off on this server right now.')}
-<p class="oc-muted">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI. It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>
-${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-link">Remove ${esc(a.name)}</button></form></section>`).join('')}
+<p class="oc-muted">An AI member answers when someone writes its name with an @ (for example @${esc(ais[0] ? ais[0].handle : 'pantry')}). It reads the room's recent messages and the notes you give it, through OpenVibe.AI, with OpenVibe's model or with your own key's (several members with different models make a multi-model group chat). It cannot do anything outside the chat. At most ${aiMembers.MAX_PER_ROOM}.</p>
+${ais.map((a) => `<section class="oc-ai-member"><h3><span class="oc-badge oc-ai">AI</span> ${esc(a.name)} <span class="oc-muted">@${esc(a.handle)} · ${esc(aiMembers.modelOf(a))}</span>${a.link_service === 'food' ? ' <span class="oc-badge">knows the household\'s pantry (OpenVibe.Food)</span>' : ''}</h3>${aiForm(a)}<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/ai-members/${Number(a.id)}/delete"><button type="submit" class="oc-link">Remove ${esc(a.name)}</button></form></section>`).join('')}
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
+ if (room.server_id) {
+ return await page(req, res, 200, {
+ actor, title: `#${room.name}: AI members`, path: `/r/${room.slug}/settings`, active: 'servers', robots: 'noindex, nofollow',
+ body: `<p><a href="${esc(await channelUrl(room, '/settings'))}">← Server settings</a> · <a href="${esc(await channelUrl(room))}">#${esc(room.name)}</a></p><h1>AI members in #${esc(room.name)}</h1>${saved}${error}
+${aiSection}`,
+ });
+ }
const communityUrl = config.web.communityUrl;
const communityUrl = config.web.communityUrl;
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
await page(req, res, 200, {
@@ -716,8 +773,8 @@ ${attachList}`,
@@ -716,8 +773,8 @@ ${attachList}`,
}
}
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
});
});
- router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory }); }));
- router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1' }); }));
+ router.post('/r/:slug/ai-members', formLimit, sameSite, aiMemberForm(async (room, actor, can, b) => { await aiMembers.add(room, actor.user, can, { name: b.name, persona: b.persona, memory: b.memory, engine: b.engine }); }));
+ router.post('/r/:slug/ai-members/:id', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.update(room, can, req.params.id, { name: b.name, persona: b.persona, memory: b.memory, enabled: b.enabled === '1', engine: b.engine }, actor.user); }));
router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));
router.post('/r/:slug/ai-members/:id/delete', formLimit, sameSite, aiMemberForm(async (room, actor, can, b, req) => { await aiMembers.remove(room, can, req.params.id); }));
router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/attachments/:service/:resource/detach', formLimit, sameSite, roomsOrError(async (req, res) => {
@@ -119,7 +119,7 @@ ${create}`,
@@ -119,7 +119,7 @@ ${create}`,
else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
else composer = notice('error', 'You cannot write here right now.');
else composer = notice('error', 'You cannot write here right now.');
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
- const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
+ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(require('../rooms/ai-members').modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
const byRank = {};
const byRank = {};
for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);
for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);
const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;
const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;
@@ -221,7 +221,8 @@ ${composer}
@@ -221,7 +221,8 @@ ${composer}
<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(c.topic || '')}"></label>
<label>Topic <input name="topic" maxlength="200" value="${esc(c.topic || '')}"></label>
${c.kind === 'voice' ? `<label>Members in the call <select name="join_role"><option value="speaker"${c.join_role === 'speaker' ? ' selected' : ''}>can talk</option><option value="participant"${c.join_role === 'participant' ? ' selected' : ''}>listen and write (moderators talk)</option></select></label>` : ''}
${c.kind === 'voice' ? `<label>Members in the call <select name="join_role"><option value="speaker"${c.join_role === 'speaker' ? ' selected' : ''}>can talk</option><option value="participant"${c.join_role === 'participant' ? ' selected' : ''}>listen and write (moderators talk)</option></select></label>` : ''}
-<p><button type="submit">Save</button> <button type="submit" name="delete" value="1" class="oc-link oc-danger-link">Delete this channel</button></p></form></details></li>`;
+<p><button type="submit">Save</button> <button type="submit" name="delete" value="1" class="oc-link oc-danger-link">Delete this channel</button></p></form>
+${can.manage && c.kind !== 'voice' && require('../rooms/ai-members').enabled() ? `<p><a href="/r/${esc(c.room)}/settings#oc-ai">AI members in #${esc(c.name)}</a> <span class="oc-muted">(they answer when someone writes their @name, with OpenVibe's model or your own key's)</span></p>` : ''}</details></li>`;
const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>
const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>
<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;
<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;
await page(req, res, 200, {
await page(req, res, 200, {
@@ -56,7 +56,7 @@ t('only the room\'s owner adds, changes and removes AI members; names are checke
@@ -56,7 +56,7 @@ t('only the room\'s owner adds, changes and removes AI members; names are checke
assert.deepStrictEqual([r.body.name, r.body.handle, r.body.memory], ['Pantry', 'pantry', 'Bob is vegetarian']);
assert.deepStrictEqual([r.body.name, r.body.handle, r.body.memory], ['Pantry', 'pantry', 'Bob is vegetarian']);
assert.strictEqual((await api('POST', '/our-kitchen/ai-members', ann, { name: 'pantry' })).status, 409, 'one @pantry per room');
assert.strictEqual((await api('POST', '/our-kitchen/ai-members', ann, { name: 'pantry' })).status, 409, 'one @pantry per room');
const seen = await api('GET', '/our-kitchen/ai-members', bob);
const seen = await api('GET', '/our-kitchen/ai-members', bob);
- assert.deepStrictEqual(Object.keys(seen.body.ai_members[0]).sort(), ['enabled', 'handle', 'id', 'name'], 'members see who it is, not its set-up');
+ assert.deepStrictEqual(Object.keys(seen.body.ai_members[0]).sort(), ['enabled', 'handle', 'id', 'model', 'name'], 'members see who it is and its model, not its set-up');
assert.strictEqual((await api('GET', '/our-kitchen/ai-members', ann)).body.ai_members[0].persona, 'A cheerful cook');
assert.strictEqual((await api('GET', '/our-kitchen/ai-members', ann)).body.ai_members[0].persona, 'A cheerful cook');
assert.strictEqual((await api('PATCH', `/our-kitchen/ai-members/${r.body.id}`, bob, { memory: 'x' })).status, 403);
assert.strictEqual((await api('PATCH', `/our-kitchen/ai-members/${r.body.id}`, bob, { memory: 'x' })).status, 403);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+'use strict';
+/**
+ * A model of their own for AI members (migrations/0014_ai_member_models.sql, server/ai/credentials.js): a person keeps
+ * their provider key in OpenVibe.AI through Chat (/api/chat/me/ai-key and the /settings form; the key is never shown
+ * again), a room's manager sets a member to think with it (engine 'own'), its answers are runs that name the
+ * credential, outside the room's daily cap, and the room shows which model each member uses. Removing the key, or the
+ * account, sends those members back to OpenVibe's model. A server's channels get AI members from its admins.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+let aiClient, aiMembers, config;
+
+const t = suite('room AI members: their own models');
+const SITE = 'https://openvibe.chat';
+let h, ann, bob;
+const web = async (method, path, u, fields) => {
+ const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };
+ const r = await fetch(`${h.base}${path}`, { method, headers, body: fields ? new URLSearchParams(fields).toString() : undefined, redirect: 'manual' });
+ return { status: r.status, text: await r.text(), location: r.headers.get('location') };
+};
+const api = (method, path, u, body) => h.http(method, `/api/chat${path}`, { token: u && u.token, body });
+const settle = (ms = 150) => new Promise((r) => setTimeout(r, ms));
+
+// OpenVibe.AI, as Chat's HTTP client sees it: the credential store and credentialed runs.
+const store = new Map(); // subject → stored credential (as AI answers it)
+const runs = []; // credentialed runs
+const shared = []; // runs with OpenVibe's model
+const problem = (status, code) => Object.assign(new Error(code), { status, code });
+const fakeHttp = {
+ async json({ method, path, json }) {
+ const cred = /^\/api\/v1\/credentials\/(.+)$/.exec(path);
+ if (cred) {
+ const subject = decodeURIComponent(cred[1]);
+ 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.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);
+ return row;
+ }
+ if (method === 'POST' && path === '/api/v1/runs') {
+ runs.push(json);
+ return { run: { status: 'succeeded', output: { text: `From ${store.get(json.credential.subject).models.chat}: hello ${json.input.asked_by}` } } };
+ }
+ throw problem(404, 'not_found');
+ },
+};
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ aiClient = require('../server/ai/client');
+ aiMembers = require('../server/rooms/ai-members');
+ config = require('../server/config');
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ aiClient._setClient({ runs: { create: async (workflow, input) => { shared.push({ workflow, input }); return { run: { status: 'succeeded', output: { text: 'OpenVibe here' } } }; } } });
+ aiClient._setHttp(fakeHttp);
+ assert.strictEqual((await api('POST', '/rooms', ann, { name: 'Model Night', visibility: 'private' })).status, 201);
+ assert.strictEqual((await api('POST', '/rooms/model-night/members', ann, { username: 'bob' })).status, 200);
+});
+
+t('your AI key: stored in OpenVibe.AI, never shown back; checked first', async () => {
+ assert.deepStrictEqual((await api('GET', '/me/ai-key', ann)).body, { key: null });
+ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'gemini', model: 'x', api_key: 'sk-test-1234' })).status, 422, 'a provider AI knows');
+ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'openai', model: 'two words', api_key: 'sk-test-1234' })).status, 422, 'a model name');
+ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'anthropic', base_url: 'https://x.example', model: 'm', api_key: 'sk-test-1234' })).status, 422, 'an endpoint is for OpenAI-compatible providers');
+ assert.strictEqual((await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'http://insecure.example', model: 'm', api_key: 'sk-test-1234' })).status, 422, 'https only');
+ const first = await api('PUT', '/me/ai-key', ann, { provider: 'openai', model: 'gpt-4o-mini' });
+ assert.deepStrictEqual([first.status, first.body.code], [400, 'credentials.key'], 'the key is needed the first time');
+ const put = await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'https://openrouter.ai/api/v1/', model: 'gpt-4o-mini', api_key: 'sk-test-abcd', budget_usd_per_day: 2 });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.deepStrictEqual([put.body.key.provider, put.body.key.base_url, put.body.key.model, put.body.key.key_hint, put.body.key.budget_usd_per_day], ['openai', 'https://openrouter.ai/api/v1', 'gpt-4o-mini', 'abcd', 2]);
+ assert.ok(!JSON.stringify(put.body).includes('sk-test-abcd'), 'the key itself never comes back');
+ assert.strictEqual(store.get(h.live.users.get(ann.id).subject_id).owner, 'chat');
+ const keep = await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'https://openrouter.ai/api/v1', model: 'gpt-4.1-mini' });
+ assert.strictEqual(keep.status, 200, 'changing only the model keeps the key');
+});
+
+t('a member thinks with its manager\'s own key; the room sees which model', async () => {
+ assert.strictEqual((await api('POST', '/rooms/model-night/ai-members', bob, { name: 'Sage', engine: 'own' })).status, 403, 'managers only');
+ assert.strictEqual((await api('POST', '/rooms/model-night/ai-members', ann, { name: 'Sage', engine: 'gpt' })).status, 422);
+ const own = await api('POST', '/rooms/model-night/ai-members', ann, { name: 'Sage', persona: 'Thoughtful', engine: 'own' });
+ assert.strictEqual(own.status, 201, own.text);
+ assert.deepStrictEqual([own.body.engine, own.body.model], ['own', 'openai (openrouter.ai) · gpt-4.1-mini, Ann\'s key']);
+ const plain = await api('POST', '/rooms/model-night/ai-members', ann, { name: 'Pip' });
+ assert.deepStrictEqual([plain.body.engine, plain.body.model], ['openvibe', 'OpenVibe']);
+ const seen = (await api('GET', '/rooms/model-night/ai-members', bob)).body.ai_members;
+ assert.deepStrictEqual(seen.map((m) => m.model), ['openai (openrouter.ai) · gpt-4.1-mini, Ann\'s key', 'OpenVibe'], 'members see each model, not the set-up');
+ const page = await web('GET', '/r/model-night', bob);
+ assert.ok(page.text.includes('<strong>Sage</strong> <span class="oc-muted oc-ai-model">(openai (openrouter.ai) · gpt-4.1-mini, Ann's key)</span>') || page.text.includes('(openai (openrouter.ai) · gpt-4.1-mini, Ann's key)'), 'the room page names the model');
+});
+
+t('@sage answers through the key (a credentialed run); outside the room\'s daily cap; @pip with OpenVibe\'s', async () => {
+ const keep = config.ai.memberRepliesPerDay;
+ config.ai.memberRepliesPerDay = 0;
+ try {
+ await api('POST', '/rooms/model-night/messages', bob, { message: '@sage hi' });
+ await settle();
+ const run = runs[runs.length - 1];
+ assert.ok(run, 'a run went out');
+ assert.deepStrictEqual([run.workflow, run.credential], ['chat.member.reply', { subject: h.live.users.get(ann.id).subject_id }]);
+ assert.strictEqual(run.attribution.type, 'room');
+ let last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages.pop();
+ assert.deepStrictEqual([last.display_name, last.message], ['Sage', 'From gpt-4.1-mini: hello Bob']);
+ await api('POST', '/rooms/model-night/messages', bob, { message: '@pip hi' });
+ await settle();
+ last = (await api('GET', '/rooms/model-night/messages', bob)).body.messages.pop();
+ assert.match(last.message, /my limit/, 'OpenVibe\'s model keeps the room\'s cap');
+ assert.strictEqual(shared.length, 0);
+ } finally { config.ai.memberRepliesPerDay = keep; }
+ await api('POST', '/rooms/model-night/messages', bob, { message: '@pip and now?' });
+ await settle();
+ assert.strictEqual(shared.length, 1, 'with room under the cap, Pip answers with OpenVibe\'s model');
+ assert.ok(!('credential' in (runs[runs.length - 1] || {})) || runs.length === 1, 'and not through the key');
+});
+
+t('the settings pages: your key (hint, budget), the room\'s "thinks with" choice', async () => {
+ const settings = await web('GET', '/settings', ann);
+ const sect = settings.text.slice(settings.text.indexOf('id="oc-ai-key"'), settings.text.indexOf('id="oc-ai-key"') + 1500);
+ assert.ok(sect.includes('key ending abcd') && sect.includes('$0.25 of $2.00 used today'), sect);
+ assert.ok(!settings.text.includes('sk-test-abcd'));
+ const bobs = await web('GET', '/settings', bob);
+ assert.ok(bobs.text.includes('No key yet.'));
+ const saved = await web('POST', '/settings/ai-key', bob, { provider: 'anthropic', model: 'claude-haiku-4-5', api_key: 'sk-ant-test-wxyz', budget_usd_per_day: '0' });
+ assert.deepStrictEqual([saved.status, saved.location], [303, '/settings?saved=1#oc-ai-key']);
+ assert.strictEqual(store.get(h.live.users.get(bob.id).subject_id).budget_usd_per_day, 0, '0 = no cap');
+ const room = await web('GET', '/r/model-night/settings', ann);
+ assert.ok(room.text.includes('Thinks with <select name="engine">') && room.text.includes('Your own key: openai (openrouter.ai) · gpt-4.1-mini'));
+ const sage = (await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage');
+ const back = await web('POST', `/r/model-night/ai-members/${sage.id}`, ann, { name: 'Sage', persona: 'Thoughtful', memory: '', enabled: '1', engine: 'openvibe' });
+ assert.strictEqual(back.status, 303);
+ assert.strictEqual((await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage').engine, 'openvibe', 'switched back from the form');
+ await web('POST', `/r/model-night/ai-members/${sage.id}`, ann, { name: 'Sage', persona: 'Thoughtful', memory: '', enabled: '1', engine: 'own' });
+ assert.strictEqual((await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage').engine, 'own');
+});
+
+t('a new model relabels its members; removing the key sends them back to OpenVibe\'s model', async () => {
+ await api('PUT', '/me/ai-key', ann, { provider: 'openai', base_url: 'https://openrouter.ai/api/v1', model: 'gpt-5-mini' });
+ let sage = (await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage');
+ assert.strictEqual(sage.model, 'openai (openrouter.ai) · gpt-5-mini, Ann\'s key');
+ const del = await api('DELETE', '/me/ai-key', ann);
+ assert.deepStrictEqual([del.status, del.body.members_switched], [200, 1]);
+ sage = (await api('GET', '/rooms/model-night/ai-members', ann)).body.ai_members.find((m) => m.handle === 'sage');
+ assert.deepStrictEqual([sage.engine, sage.model], ['openvibe', 'OpenVibe']);
+ 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 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);
+ const general = (await api('GET', '/servers/model-guild', ann)).body.channels[0].room;
+ const settings = await web('GET', '/s/model-guild/settings', ann);
+ assert.ok(settings.text.includes(`<a href="/r/${general}/settings#oc-ai">AI members in #general</a>`), 'linked from the channel');
+ const page = await web('GET', `/r/${general}/settings`, ann);
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('AI members in #general') && page.text.includes('Add an AI member') && !page.text.includes('Who can read'), 'only the AI members part');
+ const add = await web('POST', `/r/${general}/ai-members`, ann, { name: 'Oracle', persona: '', memory: '', engine: 'openvibe' });
+ assert.deepStrictEqual([add.status, add.location], [303, `/r/${general}/settings?saved=1`]);
+ assert.deepStrictEqual([(await web('GET', `/r/${general}/settings`, bob)).status, (await web('GET', `/r/${general}/settings`, bob)).location], [302, '/s/model-guild/settings']);
+ const channel = await web('GET', '/s/model-guild/general', bob);
+ assert.ok(channel.text.includes('<strong>Oracle</strong> <span class="oc-muted oc-ai-model">(OpenVibe)</span>'), 'the channel names its AI members and their model');
+});
+
+t('an erased account\'s members go back to OpenVibe\'s model', async () => {
+ assert.strictEqual((await api('POST', '/rooms', bob, { name: 'Bob Den', visibility: 'private' })).status, 201);
+ assert.strictEqual((await api('POST', '/rooms/bob-den/members', bob, { username: 'ann' })).status, 200, 'someone else is in it, so it outlives Bob');
+ const m = await api('POST', '/rooms/bob-den/ai-members', bob, { name: 'Haiku', engine: 'own' });
+ assert.deepStrictEqual([m.body.engine, m.body.model], ['own', 'anthropic · claude-haiku-4-5, Bob\'s key'], m.text);
+ const sid = h.live.users.get(bob.id).subject_id;
+ const del = { event_id: ids.newId('event'), event_type: 'network.account.deleted', version: 1, source: 'network', actor: { type: 'user', id: sid },
+ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: sid },
+ payload: { deletion_id: `del_${ids.ulid()}`, subject: sid, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() } };
+ assert.strictEqual(await require('../server/chat/account-data').apply(del, { send: async () => ({ ok: true, status: 200 }) }), 'erased');
+ const row = await require('../server/db/database').get('SELECT engine, engine_subject, engine_label FROM room_ai_members WHERE handle = ?', ['haiku']);
+ assert.ok(row, 'the member stays (rooms outlive their owner)');
+ assert.deepStrictEqual([row.engine, row.engine_subject, row.engine_label], ['openvibe', null, null]);
+});
+
+t.run(async () => { if (aiClient) { aiClient._setClient(null); aiClient._setHttp(null); } if (h && h.close) await h.close(); });