openvibers/openvibe.live Commits
A streamer's own provider key lives in OpenVibe.AI, not in Live's database (roadmap WS-O task 2; AI fca04c7, 0b9f7aa; Network d37ee87 grants live ai.credential.manage).
Changes
nnew file mode 100644@@@ -0,0 +1,25 @@++#!/usr/bin/env node++'use strict';++/**++ * Moves every streamer's own provider key still stored in Live's database to OpenVibe.AI (roadmap WS-O task 2,++ * server/ai/byo-credentials.js). Each key is sent once (PUT /api/v1/credentials/:subject with Live's service token,++ * grant live ai.credential.manage on openvibe.ai); on success Live's copy is erased and byo_in_ai set. Idempotent:++ * a moved channel has no key left to move. Prints one line per channel, never a key.++ *++ * sudo -u <live service user> sh -c 'set -a; . /etc/openvibe/live.env; set +a; \++ * cd /opt/openvibe.live/current && node scripts/move-byo-keys-to-ai.js'++ */++const db = require('../server/db/database');++const byo = require('../server/ai/byo-credentials');++++(async () => {++ const rows = db.getDb().prepare("SELECT user_id FROM channel_ai_config WHERE byo_key IS NOT NULL AND TRIM(byo_key) != '' ORDER BY user_id").all();++ let moved = 0; let failed = 0;++ for (const { user_id: userId } of rows) {++ const out = await byo.moveLocal(userId);++ if (out === 'moved') moved++; else if (out !== 'none') failed++;++ console.log(`channel ${userId}: ${out}`);++ }++ console.log(`${rows.length} key(s) in Live: ${moved} moved to OpenVibe.AI, ${failed} not moved`);++ process.exit(failed ? 1 : 0);++})().catch((e) => { console.error(e.message); process.exit(2); });@@@ -18,7 +18,9 @@* null means "no AI answer" exactly like llm.complete() returning null today (AI off, over* budget, provider down): quota refusals (429), failed runs, unreachable service, and SYNTHETIC* answers (the AI service's stub provider) all come back as null — synthetic text never reaches-- * a Live page. Streamer BYO providers are not routed here (llm.js keeps calling them directly).++ * a Live page. A streamer's own key stored in OpenVibe.AI (byo-credentials.js) runs here too, as++ * complete({ credentialSubject }): the run carries credential { subject }; an older key still in++ * Live's database is called directly by llm.js until it is moved.*/'use strict';const principal = require('../net/network-principal');@@@ -73,11 +75,12 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {* Run a workflow and wait for it. Returns the run object (any terminal status) or null.* opts: { target, attribution, idempotencyKey, waitMs (default 60s), timeoutMs }*/--async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000 } = {}) {++async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000, credentialSubject = null } = {}) {if (!enabled()) return null;const wait = Math.max(0, Math.min(60000, waitMs));const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,++ credential: credentialSubject ? { subject: credentialSubject } : undefined,}, wait + 15000);let r = created && created.run;if (!r) return null;@@@ -160,7 +163,7 @@ async function complete(o = {}, { toVisionJpeg } = {}) {};if (!input.user && !input.messages) input.user = '';const started = Date.now();-- const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2) });++ const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2), credentialSubject: o.credentialSubject || null });const out = usable(r);if (!out) return null;const usage = { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 };@@@ -169,8 +172,8 @@ async function complete(o = {}, { toVisionJpeg } = {}) {json: out.json || null,usage,model: (r.provenance && r.provenance.model) || null,-- provider: 'openvibe-ai',-- shared: true,++ provider: o.credentialSubject ? 'byo' : 'openvibe-ai',++ shared: !o.credentialSubject,latencyMs: Date.now() - started,cost: (r.usage && r.usage.cost_usd) || 0,runId: r.id,nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++/**++ * A streamer's own provider key lives in OpenVibe.AI (roadmap WS-O task 2; Contracts 0.74.0 ai.credential.manage).++ *++ * When a streamer saves a key for their AI viewers, Live forwards it once to OpenVibe.AI++ * (PUT /api/v1/credentials/:subject, with Live's service token for audience openvibe.ai) and keeps only++ * channel_ai_config.byo_in_ai = 1; the key column is emptied. Their viewers then run as workflow runs on AI with++ * credential { subject } (llm.complete → ai-service.complete), so the key is used only by AI, only for them, and++ * only within the daily budget they set. A change of model keeps the stored key; a new provider address needs the++ * key again (AI answers 400 credential.key_required).++ *++ * moveLocal(userId) moves a key still stored in Live the same way (scripts/move-byo-keys-to-ai.js runs it for every++ * channel once). The key is never logged.++ */++const db = require('../db/database');++++function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }++++function subjectOf(userId) {++ const r = db.getDb().prepare("SELECT subject_id FROM linked_accounts WHERE user_id = ? AND service = 'network' AND subject_id IS NOT NULL ORDER BY id LIMIT 1").get(userId);++ return r ? r.subject_id : null;++}++++/** openai | anthropic, from the streamer's settings (settings_json.byo.provider) or the address. */++function providerOf(cfg) {++ let extra = {};++ try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }++ if (extra.provider === 'anthropic' || /anthropic\.com/i.test(String(cfg.byo_base_url || extra.base_url || ''))) return 'anthropic';++ return 'openai';++}++++/** Models per role from the streamer's settings, the chosen model as the default. */++function modelsOf(cfg) {++ let extra = {};++ try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }++ const models = { chat: String(cfg.byo_model || extra.model || 'gpt-4o-mini').slice(0, 120) };++ for (const role of ['vision', 'director', 'summary']) {++ const m = extra[`model_${role}`] || (extra.models && extra.models[role]);++ if (m) models[role] = String(m).slice(0, 120);++ }++ return models;++}++++/** The https address to send with the key (the provider's own when none was set). */++function addressOf(cfg, provider) {++ const raw = String(cfg.byo_base_url || '').trim().replace(/\/+$/, '');++ if (raw) return raw;++ return provider === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1';++}++++async function call(method, subject, body) {++ const principal = require('../net/network-principal');++ for (let attempt = 0; attempt < 2; attempt++) {++ let headers;++ try { headers = await principal.serviceHeaders('openvibe.ai'); } catch (e) { return { ok: false, status: 503, body: { code: 'credentials.unavailable', detail: `no service token for openvibe.ai: ${e.message}` } }; }++ let res;++ try {++ res = await fetch(`${baseUrl()}/api/v1/credentials/${encodeURIComponent(subject)}`, {++ method, headers: { ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },++ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000),++ });++ } catch (e) { return { ok: false, status: 503, body: { code: 'credentials.unavailable', detail: 'OpenVibe.AI did not answer' } }; }++ if (res.status === 401 && attempt === 0) { principal.invalidate('openvibe.ai'); continue; }++ let data = {};++ try { data = res.status === 204 ? {} : await res.json(); } catch { data = {}; }++ return { ok: res.ok, status: res.status, body: data };++ }++ return { ok: false, status: 503, body: { code: 'credentials.unavailable' } };++}++++/**++ * Store (or update) the streamer's credential in AI. apiKey may be omitted to keep the stored key (a model or++ * budget change). → { ok, status, body } (body: ai.credential@1, or a problem with `code`).++ */++async function store(userId, cfg, { apiKey = null } = {}) {++ const subject = subjectOf(userId);++ if (!subject) return { ok: false, status: 409, body: { code: 'credentials.no_subject', detail: 'this channel has no Network account linked' } };++ const provider = providerOf(cfg);++ const put = { provider, base_url: addressOf(cfg, provider), models: modelsOf(cfg) };++ if (apiKey) put.api_key = String(apiKey);++ return call('PUT', subject, put);++}++++async function remove(userId) {++ const subject = subjectOf(userId);++ if (!subject) return { ok: true, status: 204, body: {} };++ return call('DELETE', subject);++}++++/**++ * A saved AI-viewer config, before Live writes its own columns. byoKey: a new key (string), null to remove the stored++ * one, undefined to keep it. A new key, or a new address or model while the key is in AI, goes to AI; `fields` then++ * gets byo_key '' and byo_in_ai. → null when Live may save, else { status, error, code } to answer with.++ */++async function applyConfig(userId, before, byoKey, fields) {++ const newKey = typeof byoKey === 'string' && byoKey.trim() !== '' ? byoKey.trim() : null;++ if (byoKey === null && (before.byo_in_ai || before.byo_key)) {++ const r = await remove(userId);++ if (!r.ok && r.status !== 404) return { status: 502, error: 'Could not remove your key from OpenVibe.AI; try again', code: 'credentials.unavailable' };++ fields.byo_key = ''; fields.byo_in_ai = 0;++ return null;++ }++ if (!newKey && !(before.byo_in_ai && (fields.byo_base_url !== undefined || fields.byo_model !== undefined))) return null;++ const r = await store(userId, { ...before, ...fields }, { apiKey: newKey });++ if (!r.ok) {++ const code = (r.body && (r.body.code || r.body.error)) || null;++ if (code === 'credential.key_required') return { status: 400, error: 'Enter your key again to change the provider address', code };++ return { status: r.status >= 500 ? 502 : 400, error: (r.body && r.body.detail) || 'Could not store your key in OpenVibe.AI', code };++ }++ fields.byo_key = ''; fields.byo_in_ai = 1;++ return null;++}++++/** Move a key still stored in Live to AI; on success the local key is erased. → 'moved' | 'none' | '<code>' */++async function moveLocal(userId) {++ const cfg = db.getChannelAiConfig(userId);++ const key = String(cfg.byo_key || '').trim();++ if (!key) return 'none';++ const r = await store(userId, cfg, { apiKey: key });++ if (!r.ok) return (r.body && (r.body.code || r.body.error)) || `http_${r.status}`;++ db.upsertChannelAiConfig(userId, { byo_key: '', byo_in_ai: 1 });++ return 'moved';++}++++module.exports = { store, remove, moveLocal, applyConfig, subjectOf, providerOf, modelsOf, addressOf };@@@ -311,6 +311,21 @@ async function complete(o = {}) {// AI_SERVICE=remote: shared-key calls become workflow runs on OpenVibe.AI (a streamer's BYO// provider override still goes straight to that provider, below). Usage is still recorded here// so per-streamer budgets and the admin cost views keep working unchanged.++ // A streamer's own key stored in OpenVibe.AI (WS-O task 2, byo-credentials.js): a run with credential { subject };++ // AI calls their provider with it, within their daily budget. Metered here as 'byo', like the direct path below.++ if (o.provider && o.provider.credentialSubject) {++ if (!aiService.enabled()) return null;++ const r = await aiService.complete({ ...o, role, credentialSubject: o.provider.credentialSubject }, { toVisionJpeg });++ if (!r) return null;++ if (o.json && !r.json) r.json = parseJsonLoose(r.text);++ try {++ db.recordAiUsage({++ kind: o.kind || role, model: r.model, input_tokens: r.usage.input, output_tokens: r.usage.output, cached_tokens: r.usage.cached || 0,++ cost_usd: r.cost || 0, owner_user_id: o.ownerUserId || null, source: o.source || null, role, provider: 'byo', latency_ms: r.latencyMs,++ });++ } catch { /* metering is best-effort */ }++ return r;++ }if (aiService.enabled() && !(o.provider && (o.provider.apiKey || o.provider.baseUrl))) {if (!isEnabled() || !withinBudget()) return null;const r = await aiService.complete({ ...o, role }, { toVisionJpeg });@@@ -22,6 +22,7 @@ const SOURCE = 'ai_viewers';function approxTokens(str) { return Math.ceil((str || '').length / 4); }function byoUsable(cfg) {++ if (cfg.byo_in_ai) return true;if (cfg.byo_key && String(cfg.byo_key).trim()) return true;try { return aiProvider.isSelfHostedBaseUrl(cfg.byo_base_url); } catch { return false; }}@@@ -66,16 +67,26 @@ async function generate(userId, { system = '', user = '', image = null, maxToken// BYO key — same llm.js path as the shared key (real system role, caching, timeouts),// metered from the provider's usage report (estimated only when the server omits it).++ // Never fall through to the shared key: no usable own key means the streamer's bots stay quiet.++ const provider = byoProvider(st.cfg);++ if (!provider) return null;const r = await ai.llm.complete({role: 'chat', system, user, image, imageMaxWidth: 768, maxTokens, temperature,kind: 'ai_viewers', source: SOURCE, ownerUserId: userId,-- provider: byoProvider(st.cfg),++ provider,});return r && r.text ? r.text.trim() || null : null;}--/** llm.js provider override for a streamer's BYO settings (column fields + settings_json.byo). */++/**++ * llm.js provider override for a streamer's BYO settings (column fields + settings_json.byo). A key stored in++ * OpenVibe.AI (byo_in_ai, WS-O task 2) is named by the streamer's subject; the key itself never comes back here.++ */function byoProvider(cfg) {++ if (cfg.byo_in_ai) {++ const subject = require('../byo-credentials').subjectOf(cfg.user_id);++ return subject ? { credentialSubject: subject } : null;++ }let extra = {};try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }const models = {};@@@ -39,8 +39,9 @@ function sanitizeConfig(cfg) {daily_budget_cents: cfg.daily_budget_cents ?? 20,byo_base_url: cfg.byo_base_url || '',byo_model: cfg.byo_model || 'gpt-4o-mini',-- has_byo_key: !!key,-- byo_key_masked: key ? KEY_SENTINEL : '',++ has_byo_key: !!key || !!cfg.byo_in_ai,++ byo_key_masked: key || cfg.byo_in_ai ? KEY_SENTINEL : '',++ byo_key_in_ai: !!cfg.byo_in_ai,};}@@@ -125,13 +126,20 @@ router.post('/byo/test', requireAuth, async (req, res) => {try {const cfg = db.getChannelAiConfig(req.user.id);const body = req.body || {};++ // A stored key lives in OpenVibe.AI: test it there (a one-word run with credential { subject }).++ const typedKey = body.byo_key && body.byo_key !== KEY_SENTINEL;++ if (cfg.byo_in_ai && !typedKey) {++ const started = Date.now();++ const r = await ai.llm.complete({ role: 'chat', user: 'Reply with the single word: ok', maxTokens: 5, temperature: 0, timeoutMs: 15000, retries: 0, kind: 'status_check', ownerUserId: req.user.id, provider: budget.byoProvider(cfg) });++ return res.json(r && r.text ? { ok: true, model: r.model, latency_ms: Date.now() - started, via: 'openvibe-ai' } : { ok: false, error: 'your provider did not answer through OpenVibe.AI' });++ }const override = budget.byoProvider({ ...cfg, byo_key: (body.byo_key && body.byo_key !== KEY_SENTINEL) ? body.byo_key : cfg.byo_key, byo_base_url: body.byo_base_url ?? cfg.byo_base_url, byo_model: body.byo_model ?? cfg.byo_model });const r = await ai.llm.testProvider(override);res.json(r);} catch (e) { res.status(500).json({ ok: false, error: e.message }); }});--router.put('/config', requireAuth, (req, res) => {++router.put('/config', requireAuth, async (req, res) => {try {const body = req.body || {};const fields = {};@@@ -143,10 +151,11 @@ router.put('/config', requireAuth, (req, res) => {if (body.daily_budget_cents !== undefined) fields.daily_budget_cents = body.daily_budget_cents;if (body.byo_base_url !== undefined) fields.byo_base_url = body.byo_base_url;if (body.byo_model !== undefined) fields.byo_model = body.byo_model;-- // Preserve the stored key unless a real new one is supplied (not blank / not the mask).-- if (body.byo_key !== undefined && body.byo_key !== '' && body.byo_key !== KEY_SENTINEL) {-- fields.byo_key = body.byo_key;-- }++ // The key goes to OpenVibe.AI, never into Live's database (WS-O task 2, byo-credentials.js). A real new key++ // (not blank, not the mask) is sent with the address and models; a change of model alone keeps AI's stored key;++ // byo_key: null removes it.++ const refused = await require('../byo-credentials').applyConfig(req.user.id, db.getChannelAiConfig(req.user.id), body.byo_key === KEY_SENTINEL ? undefined : body.byo_key, fields);++ if (refused) return res.status(refused.status).json({ error: refused.error, code: refused.code });db.upsertChannelAiConfig(req.user.id, fields);// v3 settings blob (partial merge, validated + clamped in settings.js).if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);@@@ -1874,6 +1874,8 @@ function initDb() {try {const cols = database.prepare('PRAGMA table_info(channel_ai_config)').all().map(c => c.name);if (!cols.includes('settings_json')) database.exec("ALTER TABLE channel_ai_config ADD COLUMN settings_json TEXT DEFAULT '{}'");++ // The streamer's own key lives in OpenVibe.AI (WS-O task 2, server/ai/byo-credentials.js); Live keeps only this flag.++ if (!cols.includes('byo_in_ai')) database.exec('ALTER TABLE channel_ai_config ADD COLUMN byo_in_ai INTEGER DEFAULT 0');database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_threads (id INTEGER PRIMARY KEY AUTOINCREMENT,channel_user_id INTEGER NOT NULL,@@@ -5974,6 +5976,7 @@ function upsertChannelAiConfig(userId, fields) {byo_key: (v) => String(v || '').trim().slice(0, 400),byo_base_url: (v) => String(v || '').trim().slice(0, 500),byo_model: (v) => String(v || '').trim().slice(0, 120) || 'gpt-4o-mini',++ byo_in_ai: (v) => (v ? 1 : 0),// Validated/clamped by ai/viewers/settings.js before it gets here; just bound the size.settings_json: (v) => { const t = typeof v === 'string' ? v : JSON.stringify(v || {}); return t.length > 40000 ? '{}' : t; },};nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++// A streamer's own provider key lives in OpenVibe.AI (roadmap WS-O task 2), against a stub Network (token++// endpoint) and a stub AI service:++// - moveLocal sends a key still in Live's database to AI once (provider, address, models) and erases Live's copy;++// - the streamer's AI viewers then run on AI with credential { subject }, metered here as 'byo', and never fall++// back to the shared key (no subject → quiet);++// - a model change keeps AI's stored key, a new address without the key is refused, byo_key null removes it.++// node test/byo-credentials.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-byo-'));++process.env.DB_PATH = path.join(tmp, 'live.db');++process.env.NODE_ENV = 'test';++process.env.OV_OAUTH_CLIENT_ID = 'live';++process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';++process.env.AI_SERVICE = 'remote';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const LOCAL_KEY = 'sk-local-EXAMPLE-0000';++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++const calls = [];++const stored = new Map();++const network = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const f = new URLSearchParams(raw);++ const now = Math.floor(Date.now() / 1000);++ res.setHeader('Content-Type', 'application/json');++ res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create', 'ai.credential.manage'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));++ });++});++const ai = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const body = raw ? JSON.parse(raw) : null;++ calls.push({ method: req.method, url: req.url, body });++ res.setHeader('Content-Type', 'application/json');++ const m = /^\/api\/v1\/credentials\/(usr_[0-9A-Z]+)$/.exec(req.url);++ if (m && req.method === 'PUT') {++ const prev = stored.get(m[1]);++ if (!body.api_key && (!prev || prev.base_url !== body.base_url)) { res.statusCode = 400; return res.end(JSON.stringify({ code: 'credential.key_required', detail: 'enter the key again' })); }++ stored.set(m[1], { ...body, api_key: body.api_key || prev.api_key });++ return res.end(JSON.stringify({ subject: m[1], owner: 'live', provider: body.provider, key_hint: '…0000' }));++ }++ if (m && req.method === 'DELETE') { stored.delete(m[1]); res.statusCode = 204; return res.end(); }++ if (req.url.startsWith('/api/v1/runs')) {++ res.statusCode = 201;++ return res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: 'live.viewers.line' }, output: { text: 'hi from the streamer key', json: null }, usage: { tokens_in: 10, tokens_out: 5, cost_usd: 0.0001 }, provenance: { model: 'gpt-4o-mini' } } }));++ }++ res.statusCode = 404; res.end('{}');++ });++});++++(async () => {++ await new Promise((r) => network.listen(0, '127.0.0.1', r));++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;++ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;++ const db = require('../server/db/database');++ db.initDb();++ const byo = require('../server/ai/byo-credentials');++ const budget = require('../server/ai/viewers/budget');++ const d = db.getDb();++ const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';++ d.prepare("INSERT INTO users (id, username, password_hash) VALUES (30, 'dana', '$sso$'), (31, 'nolink', '$sso$')").run();++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (30, 'network', '300', 'dana', ?)").run(SUBJECT);++ db.setSetting('ai_enabled', 'true');++ db.upsertChannelAiConfig(30, { enabled: 1, use_shared_key: 0, byo_key: LOCAL_KEY, byo_base_url: 'https://openrouter.ai/api/v1', byo_model: 'gpt-4o-mini' });++ db.upsertChannelAiConfig(31, { enabled: 1, use_shared_key: 0, byo_key: LOCAL_KEY });++ try {++ // ── The move ──++ assert.strictEqual(await byo.moveLocal(30), 'moved');++ const put = calls.find((c) => c.method === 'PUT');++ assert.deepStrictEqual([put.url, put.body.provider, put.body.base_url, put.body.api_key, put.body.models], [`/api/v1/credentials/${SUBJECT}`, 'openai', 'https://openrouter.ai/api/v1', LOCAL_KEY, { chat: 'gpt-4o-mini' }]);++ const cfg = db.getChannelAiConfig(30);++ assert.deepStrictEqual([cfg.byo_key, cfg.byo_in_ai], ['', 1], "Live's copy is erased");++ assert.strictEqual(await byo.moveLocal(30), 'none', 'moving again changes nothing');++ assert.strictEqual(await byo.moveLocal(31), 'credentials.no_subject', 'no Network account: the key stays until one is linked');++++ // ── The viewers run on AI with the credential ──++ const n = calls.length;++ const text = await budget.generate(30, { system: 'be a viewer', user: 'say hi', maxTokens: 20 });++ assert.strictEqual(text, 'hi from the streamer key');++ const runCall = calls.slice(n).find((c) => c.url.startsWith('/api/v1/runs'));++ assert.deepStrictEqual([runCall.body.workflow, runCall.body.credential], ['live.viewers.line', { subject: SUBJECT }]);++ assert.ok(!JSON.stringify(runCall.body).includes(LOCAL_KEY), 'the key is not in the run');++ const usage = d.prepare("SELECT provider, owner_user_id FROM ai_usage WHERE source = 'ai_viewers' ORDER BY id DESC LIMIT 1").get();++ assert.deepStrictEqual([usage.provider, usage.owner_user_id], ['byo', 30], 'metered as the streamer\'s own');++ // A key in AI without a subject never falls back to the shared key.++ db.upsertChannelAiConfig(31, { byo_key: '', byo_in_ai: 1 });++ const m = calls.length;++ assert.strictEqual(await budget.generate(31, { user: 'x' }), null);++ assert.strictEqual(calls.length, m, 'nothing was called');++++ // ── Saving the config ──++ let fields = { byo_model: 'gpt-4o' };++ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), undefined, fields), null);++ const modelPut = calls[calls.length - 1];++ assert.deepStrictEqual([modelPut.method, modelPut.body.api_key, modelPut.body.models.chat], ['PUT', undefined, 'gpt-4o'], 'a model change keeps the stored key');++ fields = { byo_base_url: 'https://attacker.example/v1' };++ const refused = await byo.applyConfig(30, db.getChannelAiConfig(30), undefined, fields);++ assert.deepStrictEqual([refused.status, refused.code], [400, 'credential.key_required'], 'a new address needs the key again');++ fields = {};++ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), 'sk-new-EXAMPLE-1111', fields), null);++ assert.deepStrictEqual([fields.byo_key, fields.byo_in_ai], ['', 1], 'a new key is never saved in Live');++ fields = {};++ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), null, fields), null);++ assert.deepStrictEqual([calls[calls.length - 1].method, fields.byo_in_ai], ['DELETE', 0]);++ assert.ok(!stored.has(SUBJECT));++ } finally {++ network.close(); ai.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('byo credentials: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -354,6 +354,7 @@ async function check(name, fn) {'server/utils/notify.js': 'OpenVibe.Network (configured)','server/auth/identity-sync.js': 'OpenVibe.Network (configured)','server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',++ 'server/ai/byo-credentials.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s own key stored there','server/monetization/billing-client.js': 'OpenVibe.Billing (configured)','server/monetization/wallet-client.js': 'OpenVibe.Network wallet (configured)','server/monetization/payments.js': 'PayPal (fixed host)',
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Moves every streamer's own provider key still stored in Live's database to OpenVibe.AI (roadmap WS-O task 2,
+ * server/ai/byo-credentials.js). Each key is sent once (PUT /api/v1/credentials/:subject with Live's service token,
+ * grant live ai.credential.manage on openvibe.ai); on success Live's copy is erased and byo_in_ai set. Idempotent:
+ * a moved channel has no key left to move. Prints one line per channel, never a key.
+ *
+ * sudo -u <live service user> sh -c 'set -a; . /etc/openvibe/live.env; set +a; \
+ * cd /opt/openvibe.live/current && node scripts/move-byo-keys-to-ai.js'
+ */
+const db = require('../server/db/database');
+const byo = require('../server/ai/byo-credentials');
+
+(async () => {
+ const rows = db.getDb().prepare("SELECT user_id FROM channel_ai_config WHERE byo_key IS NOT NULL AND TRIM(byo_key) != '' ORDER BY user_id").all();
+ let moved = 0; let failed = 0;
+ for (const { user_id: userId } of rows) {
+ const out = await byo.moveLocal(userId);
+ if (out === 'moved') moved++; else if (out !== 'none') failed++;
+ console.log(`channel ${userId}: ${out}`);
+ }
+ console.log(`${rows.length} key(s) in Live: ${moved} moved to OpenVibe.AI, ${failed} not moved`);
+ process.exit(failed ? 1 : 0);
+})().catch((e) => { console.error(e.message); process.exit(2); });
@@ -18,7 +18,9 @@
@@ -18,7 +18,9 @@
* null means "no AI answer" exactly like llm.complete() returning null today (AI off, over
* null means "no AI answer" exactly like llm.complete() returning null today (AI off, over
* budget, provider down): quota refusals (429), failed runs, unreachable service, and SYNTHETIC
* budget, provider down): quota refusals (429), failed runs, unreachable service, and SYNTHETIC
* answers (the AI service's stub provider) all come back as null — synthetic text never reaches
* answers (the AI service's stub provider) all come back as null — synthetic text never reaches
- * a Live page. Streamer BYO providers are not routed here (llm.js keeps calling them directly).
+ * a Live page. A streamer's own key stored in OpenVibe.AI (byo-credentials.js) runs here too, as
+ * complete({ credentialSubject }): the run carries credential { subject }; an older key still in
+ * Live's database is called directly by llm.js until it is moved.
*/
*/
'use strict';
'use strict';
const principal = require('../net/network-principal');
const principal = require('../net/network-principal');
@@ -73,11 +75,12 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
@@ -73,11 +75,12 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
* Run a workflow and wait for it. Returns the run object (any terminal status) or null.
* Run a workflow and wait for it. Returns the run object (any terminal status) or null.
* opts: { target, attribution, idempotencyKey, waitMs (default 60s), timeoutMs }
* opts: { target, attribution, idempotencyKey, waitMs (default 60s), timeoutMs }
*/
*/
-async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000 } = {}) {
+async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000, credentialSubject = null } = {}) {
if (!enabled()) return null;
if (!enabled()) return null;
const wait = Math.max(0, Math.min(60000, waitMs));
const wait = Math.max(0, Math.min(60000, waitMs));
const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,
workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,
+ credential: credentialSubject ? { subject: credentialSubject } : undefined,
}, wait + 15000);
}, wait + 15000);
let r = created && created.run;
let r = created && created.run;
if (!r) return null;
if (!r) return null;
@@ -160,7 +163,7 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
@@ -160,7 +163,7 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
};
};
if (!input.user && !input.messages) input.user = '';
if (!input.user && !input.messages) input.user = '';
const started = Date.now();
const started = Date.now();
- const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2) });
+ const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2), credentialSubject: o.credentialSubject || null });
const out = usable(r);
const out = usable(r);
if (!out) return null;
if (!out) return null;
const usage = { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 };
const usage = { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 };
@@ -169,8 +172,8 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
@@ -169,8 +172,8 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
json: out.json || null,
json: out.json || null,
usage,
usage,
model: (r.provenance && r.provenance.model) || null,
model: (r.provenance && r.provenance.model) || null,
- provider: 'openvibe-ai',
- shared: true,
+ provider: o.credentialSubject ? 'byo' : 'openvibe-ai',
+ shared: !o.credentialSubject,
latencyMs: Date.now() - started,
latencyMs: Date.now() - started,
cost: (r.usage && r.usage.cost_usd) || 0,
cost: (r.usage && r.usage.cost_usd) || 0,
runId: r.id,
runId: r.id,
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+/**
+ * A streamer's own provider key lives in OpenVibe.AI (roadmap WS-O task 2; Contracts 0.74.0 ai.credential.manage).
+ *
+ * When a streamer saves a key for their AI viewers, Live forwards it once to OpenVibe.AI
+ * (PUT /api/v1/credentials/:subject, with Live's service token for audience openvibe.ai) and keeps only
+ * channel_ai_config.byo_in_ai = 1; the key column is emptied. Their viewers then run as workflow runs on AI with
+ * credential { subject } (llm.complete → ai-service.complete), so the key is used only by AI, only for them, and
+ * only within the daily budget they set. A change of model keeps the stored key; a new provider address needs the
+ * key again (AI answers 400 credential.key_required).
+ *
+ * moveLocal(userId) moves a key still stored in Live the same way (scripts/move-byo-keys-to-ai.js runs it for every
+ * channel once). The key is never logged.
+ */
+const db = require('../db/database');
+
+function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }
+
+function subjectOf(userId) {
+ const r = db.getDb().prepare("SELECT subject_id FROM linked_accounts WHERE user_id = ? AND service = 'network' AND subject_id IS NOT NULL ORDER BY id LIMIT 1").get(userId);
+ return r ? r.subject_id : null;
+}
+
+/** openai | anthropic, from the streamer's settings (settings_json.byo.provider) or the address. */
+function providerOf(cfg) {
+ let extra = {};
+ try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
+ if (extra.provider === 'anthropic' || /anthropic\.com/i.test(String(cfg.byo_base_url || extra.base_url || ''))) return 'anthropic';
+ return 'openai';
+}
+
+/** Models per role from the streamer's settings, the chosen model as the default. */
+function modelsOf(cfg) {
+ let extra = {};
+ try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
+ const models = { chat: String(cfg.byo_model || extra.model || 'gpt-4o-mini').slice(0, 120) };
+ for (const role of ['vision', 'director', 'summary']) {
+ const m = extra[`model_${role}`] || (extra.models && extra.models[role]);
+ if (m) models[role] = String(m).slice(0, 120);
+ }
+ return models;
+}
+
+/** The https address to send with the key (the provider's own when none was set). */
+function addressOf(cfg, provider) {
+ const raw = String(cfg.byo_base_url || '').trim().replace(/\/+$/, '');
+ if (raw) return raw;
+ return provider === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1';
+}
+
+async function call(method, subject, body) {
+ const principal = require('../net/network-principal');
+ for (let attempt = 0; attempt < 2; attempt++) {
+ let headers;
+ try { headers = await principal.serviceHeaders('openvibe.ai'); } catch (e) { return { ok: false, status: 503, body: { code: 'credentials.unavailable', detail: `no service token for openvibe.ai: ${e.message}` } }; }
+ let res;
+ try {
+ res = await fetch(`${baseUrl()}/api/v1/credentials/${encodeURIComponent(subject)}`, {
+ method, headers: { ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000),
+ });
+ } catch (e) { return { ok: false, status: 503, body: { code: 'credentials.unavailable', detail: 'OpenVibe.AI did not answer' } }; }
+ if (res.status === 401 && attempt === 0) { principal.invalidate('openvibe.ai'); continue; }
+ let data = {};
+ try { data = res.status === 204 ? {} : await res.json(); } catch { data = {}; }
+ return { ok: res.ok, status: res.status, body: data };
+ }
+ return { ok: false, status: 503, body: { code: 'credentials.unavailable' } };
+}
+
+/**
+ * Store (or update) the streamer's credential in AI. apiKey may be omitted to keep the stored key (a model or
+ * budget change). → { ok, status, body } (body: ai.credential@1, or a problem with `code`).
+ */
+async function store(userId, cfg, { apiKey = null } = {}) {
+ const subject = subjectOf(userId);
+ if (!subject) return { ok: false, status: 409, body: { code: 'credentials.no_subject', detail: 'this channel has no Network account linked' } };
+ const provider = providerOf(cfg);
+ const put = { provider, base_url: addressOf(cfg, provider), models: modelsOf(cfg) };
+ if (apiKey) put.api_key = String(apiKey);
+ return call('PUT', subject, put);
+}
+
+async function remove(userId) {
+ const subject = subjectOf(userId);
+ if (!subject) return { ok: true, status: 204, body: {} };
+ return call('DELETE', subject);
+}
+
+/**
+ * A saved AI-viewer config, before Live writes its own columns. byoKey: a new key (string), null to remove the stored
+ * one, undefined to keep it. A new key, or a new address or model while the key is in AI, goes to AI; `fields` then
+ * gets byo_key '' and byo_in_ai. → null when Live may save, else { status, error, code } to answer with.
+ */
+async function applyConfig(userId, before, byoKey, fields) {
+ const newKey = typeof byoKey === 'string' && byoKey.trim() !== '' ? byoKey.trim() : null;
+ if (byoKey === null && (before.byo_in_ai || before.byo_key)) {
+ const r = await remove(userId);
+ if (!r.ok && r.status !== 404) return { status: 502, error: 'Could not remove your key from OpenVibe.AI; try again', code: 'credentials.unavailable' };
+ fields.byo_key = ''; fields.byo_in_ai = 0;
+ return null;
+ }
+ if (!newKey && !(before.byo_in_ai && (fields.byo_base_url !== undefined || fields.byo_model !== undefined))) return null;
+ const r = await store(userId, { ...before, ...fields }, { apiKey: newKey });
+ if (!r.ok) {
+ const code = (r.body && (r.body.code || r.body.error)) || null;
+ if (code === 'credential.key_required') return { status: 400, error: 'Enter your key again to change the provider address', code };
+ return { status: r.status >= 500 ? 502 : 400, error: (r.body && r.body.detail) || 'Could not store your key in OpenVibe.AI', code };
+ }
+ fields.byo_key = ''; fields.byo_in_ai = 1;
+ return null;
+}
+
+/** Move a key still stored in Live to AI; on success the local key is erased. → 'moved' | 'none' | '<code>' */
+async function moveLocal(userId) {
+ const cfg = db.getChannelAiConfig(userId);
+ const key = String(cfg.byo_key || '').trim();
+ if (!key) return 'none';
+ const r = await store(userId, cfg, { apiKey: key });
+ if (!r.ok) return (r.body && (r.body.code || r.body.error)) || `http_${r.status}`;
+ db.upsertChannelAiConfig(userId, { byo_key: '', byo_in_ai: 1 });
+ return 'moved';
+}
+
+module.exports = { store, remove, moveLocal, applyConfig, subjectOf, providerOf, modelsOf, addressOf };
@@ -311,6 +311,21 @@ async function complete(o = {}) {
@@ -311,6 +311,21 @@ async function complete(o = {}) {
// AI_SERVICE=remote: shared-key calls become workflow runs on OpenVibe.AI (a streamer's BYO
// AI_SERVICE=remote: shared-key calls become workflow runs on OpenVibe.AI (a streamer's BYO
// provider override still goes straight to that provider, below). Usage is still recorded here
// provider override still goes straight to that provider, below). Usage is still recorded here
// so per-streamer budgets and the admin cost views keep working unchanged.
// so per-streamer budgets and the admin cost views keep working unchanged.
+ // A streamer's own key stored in OpenVibe.AI (WS-O task 2, byo-credentials.js): a run with credential { subject };
+ // AI calls their provider with it, within their daily budget. Metered here as 'byo', like the direct path below.
+ if (o.provider && o.provider.credentialSubject) {
+ if (!aiService.enabled()) return null;
+ const r = await aiService.complete({ ...o, role, credentialSubject: o.provider.credentialSubject }, { toVisionJpeg });
+ if (!r) return null;
+ if (o.json && !r.json) r.json = parseJsonLoose(r.text);
+ try {
+ db.recordAiUsage({
+ kind: o.kind || role, model: r.model, input_tokens: r.usage.input, output_tokens: r.usage.output, cached_tokens: r.usage.cached || 0,
+ cost_usd: r.cost || 0, owner_user_id: o.ownerUserId || null, source: o.source || null, role, provider: 'byo', latency_ms: r.latencyMs,
+ });
+ } catch { /* metering is best-effort */ }
+ return r;
+ }
if (aiService.enabled() && !(o.provider && (o.provider.apiKey || o.provider.baseUrl))) {
if (aiService.enabled() && !(o.provider && (o.provider.apiKey || o.provider.baseUrl))) {
if (!isEnabled() || !withinBudget()) return null;
if (!isEnabled() || !withinBudget()) return null;
const r = await aiService.complete({ ...o, role }, { toVisionJpeg });
const r = await aiService.complete({ ...o, role }, { toVisionJpeg });
@@ -22,6 +22,7 @@ const SOURCE = 'ai_viewers';
@@ -22,6 +22,7 @@ const SOURCE = 'ai_viewers';
function approxTokens(str) { return Math.ceil((str || '').length / 4); }
function approxTokens(str) { return Math.ceil((str || '').length / 4); }
function byoUsable(cfg) {
function byoUsable(cfg) {
+ if (cfg.byo_in_ai) return true;
if (cfg.byo_key && String(cfg.byo_key).trim()) return true;
if (cfg.byo_key && String(cfg.byo_key).trim()) return true;
try { return aiProvider.isSelfHostedBaseUrl(cfg.byo_base_url); } catch { return false; }
try { return aiProvider.isSelfHostedBaseUrl(cfg.byo_base_url); } catch { return false; }
}
}
@@ -66,16 +67,26 @@ async function generate(userId, { system = '', user = '', image = null, maxToken
@@ -66,16 +67,26 @@ async function generate(userId, { system = '', user = '', image = null, maxToken
// BYO key — same llm.js path as the shared key (real system role, caching, timeouts),
// BYO key — same llm.js path as the shared key (real system role, caching, timeouts),
// metered from the provider's usage report (estimated only when the server omits it).
// metered from the provider's usage report (estimated only when the server omits it).
+ // Never fall through to the shared key: no usable own key means the streamer's bots stay quiet.
+ const provider = byoProvider(st.cfg);
+ if (!provider) return null;
const r = await ai.llm.complete({
const r = await ai.llm.complete({
role: 'chat', system, user, image, imageMaxWidth: 768, maxTokens, temperature,
role: 'chat', system, user, image, imageMaxWidth: 768, maxTokens, temperature,
kind: 'ai_viewers', source: SOURCE, ownerUserId: userId,
kind: 'ai_viewers', source: SOURCE, ownerUserId: userId,
- provider: byoProvider(st.cfg),
+ provider,
});
});
return r && r.text ? r.text.trim() || null : null;
return r && r.text ? r.text.trim() || null : null;
}
}
-/** llm.js provider override for a streamer's BYO settings (column fields + settings_json.byo). */
+/**
+ * llm.js provider override for a streamer's BYO settings (column fields + settings_json.byo). A key stored in
+ * OpenVibe.AI (byo_in_ai, WS-O task 2) is named by the streamer's subject; the key itself never comes back here.
+ */
function byoProvider(cfg) {
function byoProvider(cfg) {
+ if (cfg.byo_in_ai) {
+ const subject = require('../byo-credentials').subjectOf(cfg.user_id);
+ return subject ? { credentialSubject: subject } : null;
+ }
let extra = {};
let extra = {};
try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
try { extra = (JSON.parse(cfg.settings_json || '{}') || {}).byo || {}; } catch { extra = {}; }
const models = {};
const models = {};
@@ -39,8 +39,9 @@ function sanitizeConfig(cfg) {
@@ -39,8 +39,9 @@ function sanitizeConfig(cfg) {
daily_budget_cents: cfg.daily_budget_cents ?? 20,
daily_budget_cents: cfg.daily_budget_cents ?? 20,
byo_base_url: cfg.byo_base_url || '',
byo_base_url: cfg.byo_base_url || '',
byo_model: cfg.byo_model || 'gpt-4o-mini',
byo_model: cfg.byo_model || 'gpt-4o-mini',
- has_byo_key: !!key,
- byo_key_masked: key ? KEY_SENTINEL : '',
+ has_byo_key: !!key || !!cfg.byo_in_ai,
+ byo_key_masked: key || cfg.byo_in_ai ? KEY_SENTINEL : '',
+ byo_key_in_ai: !!cfg.byo_in_ai,
};
};
}
}
@@ -125,13 +126,20 @@ router.post('/byo/test', requireAuth, async (req, res) => {
@@ -125,13 +126,20 @@ router.post('/byo/test', requireAuth, async (req, res) => {
try {
try {
const cfg = db.getChannelAiConfig(req.user.id);
const cfg = db.getChannelAiConfig(req.user.id);
const body = req.body || {};
const body = req.body || {};
+ // A stored key lives in OpenVibe.AI: test it there (a one-word run with credential { subject }).
+ const typedKey = body.byo_key && body.byo_key !== KEY_SENTINEL;
+ if (cfg.byo_in_ai && !typedKey) {
+ const started = Date.now();
+ const r = await ai.llm.complete({ role: 'chat', user: 'Reply with the single word: ok', maxTokens: 5, temperature: 0, timeoutMs: 15000, retries: 0, kind: 'status_check', ownerUserId: req.user.id, provider: budget.byoProvider(cfg) });
+ return res.json(r && r.text ? { ok: true, model: r.model, latency_ms: Date.now() - started, via: 'openvibe-ai' } : { ok: false, error: 'your provider did not answer through OpenVibe.AI' });
+ }
const override = budget.byoProvider({ ...cfg, byo_key: (body.byo_key && body.byo_key !== KEY_SENTINEL) ? body.byo_key : cfg.byo_key, byo_base_url: body.byo_base_url ?? cfg.byo_base_url, byo_model: body.byo_model ?? cfg.byo_model });
const override = budget.byoProvider({ ...cfg, byo_key: (body.byo_key && body.byo_key !== KEY_SENTINEL) ? body.byo_key : cfg.byo_key, byo_base_url: body.byo_base_url ?? cfg.byo_base_url, byo_model: body.byo_model ?? cfg.byo_model });
const r = await ai.llm.testProvider(override);
const r = await ai.llm.testProvider(override);
res.json(r);
res.json(r);
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
} catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
});
-router.put('/config', requireAuth, (req, res) => {
+router.put('/config', requireAuth, async (req, res) => {
try {
try {
const body = req.body || {};
const body = req.body || {};
const fields = {};
const fields = {};
@@ -143,10 +151,11 @@ router.put('/config', requireAuth, (req, res) => {
@@ -143,10 +151,11 @@ router.put('/config', requireAuth, (req, res) => {
if (body.daily_budget_cents !== undefined) fields.daily_budget_cents = body.daily_budget_cents;
if (body.daily_budget_cents !== undefined) fields.daily_budget_cents = body.daily_budget_cents;
if (body.byo_base_url !== undefined) fields.byo_base_url = body.byo_base_url;
if (body.byo_base_url !== undefined) fields.byo_base_url = body.byo_base_url;
if (body.byo_model !== undefined) fields.byo_model = body.byo_model;
if (body.byo_model !== undefined) fields.byo_model = body.byo_model;
- // Preserve the stored key unless a real new one is supplied (not blank / not the mask).
- if (body.byo_key !== undefined && body.byo_key !== '' && body.byo_key !== KEY_SENTINEL) {
- fields.byo_key = body.byo_key;
- }
+ // The key goes to OpenVibe.AI, never into Live's database (WS-O task 2, byo-credentials.js). A real new key
+ // (not blank, not the mask) is sent with the address and models; a change of model alone keeps AI's stored key;
+ // byo_key: null removes it.
+ const refused = await require('../byo-credentials').applyConfig(req.user.id, db.getChannelAiConfig(req.user.id), body.byo_key === KEY_SENTINEL ? undefined : body.byo_key, fields);
+ if (refused) return res.status(refused.status).json({ error: refused.error, code: refused.code });
db.upsertChannelAiConfig(req.user.id, fields);
db.upsertChannelAiConfig(req.user.id, fields);
// v3 settings blob (partial merge, validated + clamped in settings.js).
// v3 settings blob (partial merge, validated + clamped in settings.js).
if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);
if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);
@@ -1874,6 +1874,8 @@ function initDb() {
@@ -1874,6 +1874,8 @@ function initDb() {
try {
try {
const cols = database.prepare('PRAGMA table_info(channel_ai_config)').all().map(c => c.name);
const cols = database.prepare('PRAGMA table_info(channel_ai_config)').all().map(c => c.name);
if (!cols.includes('settings_json')) database.exec("ALTER TABLE channel_ai_config ADD COLUMN settings_json TEXT DEFAULT '{}'");
if (!cols.includes('settings_json')) database.exec("ALTER TABLE channel_ai_config ADD COLUMN settings_json TEXT DEFAULT '{}'");
+ // The streamer's own key lives in OpenVibe.AI (WS-O task 2, server/ai/byo-credentials.js); Live keeps only this flag.
+ if (!cols.includes('byo_in_ai')) database.exec('ALTER TABLE channel_ai_config ADD COLUMN byo_in_ai INTEGER DEFAULT 0');
database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_threads (
database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_user_id INTEGER NOT NULL,
channel_user_id INTEGER NOT NULL,
@@ -5974,6 +5976,7 @@ function upsertChannelAiConfig(userId, fields) {
@@ -5974,6 +5976,7 @@ function upsertChannelAiConfig(userId, fields) {
byo_key: (v) => String(v || '').trim().slice(0, 400),
byo_key: (v) => String(v || '').trim().slice(0, 400),
byo_base_url: (v) => String(v || '').trim().slice(0, 500),
byo_base_url: (v) => String(v || '').trim().slice(0, 500),
byo_model: (v) => String(v || '').trim().slice(0, 120) || 'gpt-4o-mini',
byo_model: (v) => String(v || '').trim().slice(0, 120) || 'gpt-4o-mini',
+ byo_in_ai: (v) => (v ? 1 : 0),
// Validated/clamped by ai/viewers/settings.js before it gets here; just bound the size.
// Validated/clamped by ai/viewers/settings.js before it gets here; just bound the size.
settings_json: (v) => { const t = typeof v === 'string' ? v : JSON.stringify(v || {}); return t.length > 40000 ? '{}' : t; },
settings_json: (v) => { const t = typeof v === 'string' ? v : JSON.stringify(v || {}); return t.length > 40000 ? '{}' : t; },
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+// A streamer's own provider key lives in OpenVibe.AI (roadmap WS-O task 2), against a stub Network (token
+// endpoint) and a stub AI service:
+// - moveLocal sends a key still in Live's database to AI once (provider, address, models) and erases Live's copy;
+// - the streamer's AI viewers then run on AI with credential { subject }, metered here as 'byo', and never fall
+// back to the shared key (no subject → quiet);
+// - a model change keeps AI's stored key, a new address without the key is refused, byo_key null removes it.
+// node test/byo-credentials.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-byo-'));
+process.env.DB_PATH = path.join(tmp, 'live.db');
+process.env.NODE_ENV = 'test';
+process.env.OV_OAUTH_CLIENT_ID = 'live';
+process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
+process.env.AI_SERVICE = 'remote';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const LOCAL_KEY = 'sk-local-EXAMPLE-0000';
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const calls = [];
+const stored = new Map();
+const network = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const f = new URLSearchParams(raw);
+ const now = Math.floor(Date.now() / 1000);
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ access_token: serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: [f.get('audience')], cap: ['ai.run.create', 'ai.credential.manage'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));
+ });
+});
+const ai = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const body = raw ? JSON.parse(raw) : null;
+ calls.push({ method: req.method, url: req.url, body });
+ res.setHeader('Content-Type', 'application/json');
+ const m = /^\/api\/v1\/credentials\/(usr_[0-9A-Z]+)$/.exec(req.url);
+ if (m && req.method === 'PUT') {
+ const prev = stored.get(m[1]);
+ if (!body.api_key && (!prev || prev.base_url !== body.base_url)) { res.statusCode = 400; return res.end(JSON.stringify({ code: 'credential.key_required', detail: 'enter the key again' })); }
+ stored.set(m[1], { ...body, api_key: body.api_key || prev.api_key });
+ return res.end(JSON.stringify({ subject: m[1], owner: 'live', provider: body.provider, key_hint: '…0000' }));
+ }
+ if (m && req.method === 'DELETE') { stored.delete(m[1]); res.statusCode = 204; return res.end(); }
+ if (req.url.startsWith('/api/v1/runs')) {
+ res.statusCode = 201;
+ return res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: 'live.viewers.line' }, output: { text: 'hi from the streamer key', json: null }, usage: { tokens_in: 10, tokens_out: 5, cost_usd: 0.0001 }, provenance: { model: 'gpt-4o-mini' } } }));
+ }
+ res.statusCode = 404; res.end('{}');
+ });
+});
+
+(async () => {
+ await new Promise((r) => network.listen(0, '127.0.0.1', r));
+ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
+ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;
+ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;
+ const db = require('../server/db/database');
+ db.initDb();
+ const byo = require('../server/ai/byo-credentials');
+ const budget = require('../server/ai/viewers/budget');
+ const d = db.getDb();
+ const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
+ d.prepare("INSERT INTO users (id, username, password_hash) VALUES (30, 'dana', '$sso$'), (31, 'nolink', '$sso$')").run();
+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (30, 'network', '300', 'dana', ?)").run(SUBJECT);
+ db.setSetting('ai_enabled', 'true');
+ db.upsertChannelAiConfig(30, { enabled: 1, use_shared_key: 0, byo_key: LOCAL_KEY, byo_base_url: 'https://openrouter.ai/api/v1', byo_model: 'gpt-4o-mini' });
+ db.upsertChannelAiConfig(31, { enabled: 1, use_shared_key: 0, byo_key: LOCAL_KEY });
+ try {
+ // ── The move ──
+ assert.strictEqual(await byo.moveLocal(30), 'moved');
+ const put = calls.find((c) => c.method === 'PUT');
+ assert.deepStrictEqual([put.url, put.body.provider, put.body.base_url, put.body.api_key, put.body.models], [`/api/v1/credentials/${SUBJECT}`, 'openai', 'https://openrouter.ai/api/v1', LOCAL_KEY, { chat: 'gpt-4o-mini' }]);
+ const cfg = db.getChannelAiConfig(30);
+ assert.deepStrictEqual([cfg.byo_key, cfg.byo_in_ai], ['', 1], "Live's copy is erased");
+ assert.strictEqual(await byo.moveLocal(30), 'none', 'moving again changes nothing');
+ assert.strictEqual(await byo.moveLocal(31), 'credentials.no_subject', 'no Network account: the key stays until one is linked');
+
+ // ── The viewers run on AI with the credential ──
+ const n = calls.length;
+ const text = await budget.generate(30, { system: 'be a viewer', user: 'say hi', maxTokens: 20 });
+ assert.strictEqual(text, 'hi from the streamer key');
+ const runCall = calls.slice(n).find((c) => c.url.startsWith('/api/v1/runs'));
+ assert.deepStrictEqual([runCall.body.workflow, runCall.body.credential], ['live.viewers.line', { subject: SUBJECT }]);
+ assert.ok(!JSON.stringify(runCall.body).includes(LOCAL_KEY), 'the key is not in the run');
+ const usage = d.prepare("SELECT provider, owner_user_id FROM ai_usage WHERE source = 'ai_viewers' ORDER BY id DESC LIMIT 1").get();
+ assert.deepStrictEqual([usage.provider, usage.owner_user_id], ['byo', 30], 'metered as the streamer\'s own');
+ // A key in AI without a subject never falls back to the shared key.
+ db.upsertChannelAiConfig(31, { byo_key: '', byo_in_ai: 1 });
+ const m = calls.length;
+ assert.strictEqual(await budget.generate(31, { user: 'x' }), null);
+ assert.strictEqual(calls.length, m, 'nothing was called');
+
+ // ── Saving the config ──
+ let fields = { byo_model: 'gpt-4o' };
+ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), undefined, fields), null);
+ const modelPut = calls[calls.length - 1];
+ assert.deepStrictEqual([modelPut.method, modelPut.body.api_key, modelPut.body.models.chat], ['PUT', undefined, 'gpt-4o'], 'a model change keeps the stored key');
+ fields = { byo_base_url: 'https://attacker.example/v1' };
+ const refused = await byo.applyConfig(30, db.getChannelAiConfig(30), undefined, fields);
+ assert.deepStrictEqual([refused.status, refused.code], [400, 'credential.key_required'], 'a new address needs the key again');
+ fields = {};
+ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), 'sk-new-EXAMPLE-1111', fields), null);
+ assert.deepStrictEqual([fields.byo_key, fields.byo_in_ai], ['', 1], 'a new key is never saved in Live');
+ fields = {};
+ assert.strictEqual(await byo.applyConfig(30, db.getChannelAiConfig(30), null, fields), null);
+ assert.deepStrictEqual([calls[calls.length - 1].method, fields.byo_in_ai], ['DELETE', 0]);
+ assert.ok(!stored.has(SUBJECT));
+ } finally {
+ network.close(); ai.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('byo credentials: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -354,6 +354,7 @@ async function check(name, fn) {
@@ -354,6 +354,7 @@ async function check(name, fn) {
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.js': 'OpenVibe.Network (configured)',
'server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',
'server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',
+ 'server/ai/byo-credentials.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s own key stored there',
'server/monetization/billing-client.js': 'OpenVibe.Billing (configured)',
'server/monetization/billing-client.js': 'OpenVibe.Billing (configured)',
'server/monetization/wallet-client.js': 'OpenVibe.Network wallet (configured)',
'server/monetization/wallet-client.js': 'OpenVibe.Network wallet (configured)',
'server/monetization/payments.js': 'PayPal (fixed host)',
'server/monetization/payments.js': 'PayPal (fixed host)',