openvibers/openvibe.live Commits
Live calls no model provider itself and keeps no shared provider key (roadmap WS-O task 2, shims C-20 and C-21). Production has run AI_SERVICE=remote since 2026-09-23 and the local provider path has had no calls since; streamers' own keys moved to OpenVibe.AI in 4d758fa.
Changes
@@@ -16,13 +16,14 @@ ADMIN_PASSWORD=changeme123# channels keep using the English model and live captions stay hidden.# WHISPER_MODEL_MULTI=/home/openvibe/whisper.cpp/models/ggml-small.bin--# OpenVibe.AI (roadmap Wave 13). AI_SERVICE=remote sends Live's shared-key AI calls (chat AI, moments,--# paste/frame analysis, overviews, translation, recaps, slogans, arena, footer copy) to the AI service as--# workflow runs, with a service token for audience openvibe.ai (grant ai.run.create for client `live`).--# The admin ai_enabled switch and the local daily cap still apply; the provider key lives in the AI--# service. Streamer BYO keys and local whisper.cpp transcription stay here. Unset = Live calls--# providers itself, exactly as before.--#AI_SERVICE=remote++# OpenVibe.AI (roadmap Wave 13, WS-O task 2). Every AI call Live makes (chat AI, moments, paste/frame++# analysis, overviews, translation, recaps, slogans, arena, AI viewers) is a workflow run on the AI++# service, with a service token for audience openvibe.ai (grants ai.run.create, ai.run.read and++# ai.credential.manage for client `live`). Provider keys, a streamer's own included, live in the AI++# service; Live calls a provider only to test a key a streamer typed and has not saved yet. The admin++# ai_enabled switch and the local daily cap still apply; local whisper.cpp transcription stays here.++# AI_SERVICE=off turns AI off entirely.++#AI_SERVICE=off#OV_AI_INTERNAL_URL=http://127.0.0.1:4700# Server@@@ -32,7 +32,7 @@ The roster is **whoever has been heard**: every streamer with transcribed speechPOWER = weighted sum + a **mouth bonus** (≤ +12: recent XP ÷ 25 + 3 per beef win in 7 days). Audience numbers (viewers, chat volume, followers, clips, tips) do not exist anywhere in the Arena.--**Persona** (`chat` role, 24 h TTL): fighter name, title, class, element, moves, weakness, `taunt` + three `taunts` (ragebait in *their speaking voice*), `typing_style` (how they talk), `spoken_as` (nicknames/mishearings, fed to name detection), six `custom_stats` unique to them and `stat_quips` — written from their transcript lines, their best judged shit talk, who they call out, who calls them out and what they rant at. **Quotes**: AI-picked lines from the transcripts, VOD-linked. **Portraits** (optional, `ai_image_enabled`): drawn from their own stream frames.++**Persona** (`chat` role, 24 h TTL): fighter name, title, class, element, moves, weakness, `taunt` + three `taunts` (ragebait in *their speaking voice*), `typing_style` (how they talk), `spoken_as` (nicknames/mishearings, fed to name detection), six `custom_stats` unique to them and `stat_quips` — written from their transcript lines, their best judged shit talk, who they call out, who calls them out and what they rant at. **Quotes**: AI-picked lines from the transcripts, VOD-linked. **Portraits**: the ones drawn earlier from their own stream frames stay; no new ones are drawn (the image provider was called with the shared key, and Live calls no model provider itself since roadmap WS-O task 2).## The mic ledger (`server/arena/mic.js`)@@@ -86,7 +86,7 @@ Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" not|---|---|---|| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster (`stats`, `stat_meta`, per-fighter `mic`, `last_line`), card (+ `beefs`, `rivalries`, `moments`, `best_lines`, `mic`), drill-down, live cams (`ears`, `last_moment`, `open_beefs`) || GET | `/feed?limit&since` | the shit-talk feed |--| POST | `/fighters/:user/refresh` | admin — regenerate persona (+ portrait) |++| POST | `/fighters/:user/refresh` | admin — regenerate persona || GET | `/console/:user` | the ears: listener state, hot mic, level, mic stats, open beefs, recent moments || GET | `/beefs` · `/beefs/:id` · POST `/beefs/:id/hype {side}` | one hype per person per side; anonymous by hashed IP || GET | `/levels` | Trash Level ladder |@@@ -99,7 +99,6 @@ Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" not|---|---|---|| `arena_enabled` | `true` | `false` → API 404s || `ai_timeline_enabled` | `false` | the audio transcription the whole Arena runs on — must be on |--| `ai_image_enabled` / `ai_image_model` / `ai_image_quality` / `ai_image_cost_usd` | `false` / `gpt-image-1` / `low` / `0.011` | portraits || `arena_vote_salt` | `JWT_SECRET` | salt for anonymous hype hashing |Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · beef clocks every 60 s.@@@ -140,7 +140,7 @@ language detected from any non-English line in their bio or name (`server/i18n/tand the chat `auth` frame carries `channel_language` so the composer can show the"Auto-translated ↔ Japanese" pill.--When AI is enabled (`ai_enabled` + a key; the `chat_translate_enabled` site setting defaults to on),++When AI is enabled (`ai_enabled`, and `AI_SERVICE` not `off`; the `chat_translate_enabled` site setting defaults to on),each chat line is translated a beat after it is broadcast and delivered to the same rooms as```json@@@ -2,24 +2,19 @@* OpenVibe.Live — AI analysis** Vision + text analysis used for: paste description/tags, paste text overviews,-- * and periodic live-stream "memories". Provider (Anthropic Claude by default, or-- * any OpenAI-compatible endpoint) + key + model are configured in-- * openvibe.network/admin → AI. Everything is gated by the `ai_enabled` master switch and-- * an optional daily USD budget cap. Token usage + estimated cost are recorded to-- * `ai_usage` for the admin cost breakdown.++ * and periodic live-stream "memories". Every call is a workflow run on OpenVibe.AI++ * (ai-service.js), which holds the provider key, the models and the prompts of the analyses++ * here. Everything is gated by the `ai_enabled` master switch and an optional daily USD budget++ * cap. Token usage + cost are recorded to `ai_usage` for the admin cost breakdown.*/--const fs = require('fs');const db = require('../db/database');--function s(k) { return (db.getSetting(k) || '').toString().trim(); }function b(k) { const v = db.getSetting(k); return v === true || v === 'true' || v === 1 || v === '1'; }function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }const llm = require('./llm');const aiService = require('./ai-service');function isEnabled() { return llm.isEnabled(); }--// AI_SERVICE=remote: the analyses below whose prompt moved to OpenVibe.AI run as its workflows.--const remote = () => aiService.enabled() && isEnabled() && withinBudget();function pasteAnalysisEnabled() { return isEnabled() && b('ai_paste_analysis_enabled'); }function streamMemoryEnabled() { return isEnabled() && b('ai_stream_memory_enabled'); }// Local whisper.cpp transcription (default on when installed). Independent of the@@@ -30,16 +25,10 @@ function transcriptionEnabled() {try { return on && require('./transcribe').available(); } catch { return false; }}function captureIntervalSec() { return Math.max(30, num('ai_stream_capture_interval_sec', 120)); }--function model() { return llm.defaultModel(); }function withinBudget() { return llm.withinBudget(); }--// Kept for callers that estimate before/without a real usage object (BYO fallbacks).--function estimateCost(inTok, outTok, cachedTok = 0, modelId = null) {-- return llm.estimateCost(modelId || model(), { input: inTok, output: outTok, cached: cachedTok });--}--// Provider transport now lives in ./llm (real system/user roles, prompt caching, per-role--// models, structured JSON, timeouts + retry, uniform metering). These wrappers keep the--// existing call shapes for the analysis features below.++// The transport lives in ./llm (a run on OpenVibe.AI, uniform metering). These wrappers keep the++// existing call shapes for the features that still render their own prompt./** Core call: legacy single-prompt shape. Returns text or null. */async function _complete({ prompt, image = null, maxTokens = 400, kind, temperature = null, ownerUserId = null, source = null, role = 'legacy', imageMaxWidth = 1280 }) {@@@ -47,13 +36,13 @@ async function _complete({ prompt, image = null, maxTokens = 400, kind, temperatreturn r && r.text ? r.text : null;}--/** Generic text completion (used by media analysis to synthesize overviews). */++/** Generic text completion (chat insights, moments, slogans, …): a passthrough run of the workflow that owns `kind`. */async function summarizeText(prompt, maxTokens = 350, kind = 'media_overview') {return _complete({ prompt, maxTokens, kind });}/**-- * Metered completion for the AI Chat Viewers engine on the SHARED (admin) key.++ * Metered completion for the AI Chat Viewers engine on the site's AI (not a streamer's own key).* Attributes the spend to a streamer (owner_user_id) under source='ai_viewers' so* per-streamer daily budgets work. Returns text, or null if the admin AI is* disabled / over the global budget. Supports an optional image (vision) input.@@@ -63,111 +52,34 @@ async function viewerComplete({ system = '', user = '', image = null, maxTokensreturn r && r.text ? r.text : null;}--/** Is the shared admin AI key usable right now (enabled + within global budget)? */++/** Is Live's AI usable right now (enabled + within the global budget)? viewers/budget.js asks under this name. */function sharedKeyReady() { return isEnabled() && withinBudget(); }--function _parseJson(text) {-- if (!text) return null;-- const m = text.match(/\{[\s\S]*\}/);-- if (!m) return null;-- let raw = m[0];-- try { return JSON.parse(raw); } catch { /* try to repair below */ }-- // Models often emit slightly-broken JSON (a missing closing "]" before "}",-- // a trailing comma, smart quotes). Repair the common cases before giving up.-- try {-- let s = raw-- .replace(/[“”]/g, '"') // smart double quotes → "-- .replace(/[‘’]/g, "'") // smart single quotes → '-- .replace(/,\s*([}\]])/g, '$1'); // trailing commas-- // Balance brackets: an unclosed array right before the object close is the common-- // case (`..."fight"}` → `..."fight"]}`). Strip a trailing "}", add the missing "]",-- // then re-add the needed "}" so bracket counts line up.-- const opensq = (s.match(/\[/g) || []).length, closesq = (s.match(/\]/g) || []).length;-- if (opensq > closesq) {-- s = s.replace(/\}\s*$/, '');-- s += ']'.repeat(opensq - closesq);-- }-- const opencb = (s.match(/\{/g) || []).length, closecb = (s.match(/\}/g) || []).length;-- if (opencb > closecb) s += '}'.repeat(opencb - closecb);-- return JSON.parse(s);-- } catch { return null; }--}----// Robustly pull a plain-text description out of a model reply that was SUPPOSED to be--// JSON but may be malformed — so we never store a raw `{"description":...}` blob as text.--function _extractDescription(text, maxLen) {-- if (!text) return '';-- const j = _parseJson(text);-- if (j && j.description) return String(j.description).slice(0, maxLen);-- // Broken JSON: lift just the description string field.-- const dm = text.match(/"description"\s*:\s*"((?:[^"\\]|\\.)*)"/i);-- if (dm) { try { return JSON.parse(`"${dm[1]}"`).slice(0, maxLen); } catch { return dm[1].slice(0, maxLen); } }-- // Not JSON at all → use as-is; if it merely looks like a JSON blob we couldn't parse, drop it.-- const t = text.trim();-- return /^[{[]/.test(t) ? '' : t.slice(0, maxLen);--}--function _extractTags(text, maxTags) {-- const j = _parseJson(text);-- if (j && Array.isArray(j.tags)) return j.tags.slice(0, maxTags).map(String);-- const tm = text && text.match(/"tags"\s*:\s*\[([^\]]*)/i);-- if (tm) return tm[1].split(',').map(s => s.replace(/["'\s]/g, '')).filter(Boolean).slice(0, maxTags);-- return [];--}--// ── Public analysis functions ──--/** Re-encode any image (path/data) to a vision-friendly JPEG data URL. Handles-- * avif/gif/webp/huge images that the vision API otherwise rejects. */--async function _toVisionJpeg(image, opts = {}) {-- return (await llm.toVisionJpeg(image, { maxWidth: 1280, quality: 82, ...opts })) || image;--}--/** Describe an image paste → { description, tags }. */async function analyzeImagePaste(image, title, kind = 'paste_image') {-- if (aiService.enabled()) {-- if (!remote()) return null;-- const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 1280 });-- const o = img ? await aiService.structured('live.paste.describe_image', { title: String(title || '').slice(0, 500), image: img }, { meter: { kind, role: 'vision' } }) : null;-- return o && o.description ? { description: o.description, tags: o.tags || [] } : null;-- }-- const prompt = `You are describing an uploaded image/screenshot for a paste titled "${(title || '').slice(0, 120)}".--Reply ONLY with compact JSON: {"description":"1-2 sentence description of what the image shows","tags":["3-6","short","lowercase","tags"]}.`;-- const text = await _complete({ prompt, image, maxTokens: 300, kind, role: 'vision', imageMaxWidth: 1280 });-- if (!text) return null;-- const description = _extractDescription(text, 600);-- return description ? { description, tags: _extractTags(text, 8) } : null;++ if (!sharedKeyReady()) return null;++ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 1280 });++ const o = img ? await aiService.structured('live.paste.describe_image', { title: String(title || '').slice(0, 500), image: img }, { meter: { kind, role: 'vision' } }) : null;++ return o && o.description ? { description: o.description, tags: o.tags || [] } : null;}/** Summarize a text paste → { description }. */async function analyzeTextPaste(content, title) {-- if (aiService.enabled()) {-- if (!remote() || !String(content || '').trim()) return null;-- const o = await aiService.structured('live.paste.summarize_text', { title: String(title || '').slice(0, 500), content: String(content || '').slice(0, 200000) }, { meter: { kind: 'paste_text', role: 'legacy' } });-- return o && o.description ? { description: o.description, tags: [] } : null;-- }-- const snippet = String(content || '').slice(0, 6000);-- const prompt = `Summarize what this pasted text is about in one short sentence (max 200 chars), plainly. Title: "${(title || '').slice(0, 120)}".\n\n---\n${snippet}`;-- const text = await _complete({ prompt, maxTokens: 120, kind: 'paste_text' });-- return text ? { description: text.slice(0, 300), tags: [] } : null;++ if (!sharedKeyReady() || !String(content || '').trim()) return null;++ const o = await aiService.structured('live.paste.summarize_text', { title: String(title || '').slice(0, 500), content: String(content || '').slice(0, 200000) }, { meter: { kind: 'paste_text', role: 'legacy' } });++ return o && o.description ? { description: o.description, tags: [] } : null;}/** Analyze a live-stream frame → { description, tags }. */async function analyzeStreamFrame(image) {-- if (aiService.enabled()) {-- if (!remote()) return null;-- const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 768 });-- const o = img ? await aiService.structured('live.stream.describe_frame', { image: img }, { meter: { kind: 'stream_memory', role: 'vision' } }) : null;-- return o && o.description ? { description: o.description, tags: o.tags || [], worthy: o.worthy === true, title: o.worthy === true ? String(o.title || '').slice(0, 80) : '' } : null;-- }-- // One vision call does three jobs: the memory description, the tags, and a screenshot-worthiness++ // One vision run does three jobs: the memory description, the tags, and a screenshot-worthiness// verdict + caption (so live pastes need no extra call).-- const prompt = `This is a frame from a live stream. Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a face/reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption for it, else empty>"}.`;-- const text = await _complete({ prompt, image, maxTokens: 240, kind: 'stream_memory', role: 'vision', imageMaxWidth: 768 });-- if (!text) return null;-- const description = _extractDescription(text, 400);-- if (!description) return null;-- const j = _parseJson(text) || {};-- return { description, tags: _extractTags(text, 6), worthy: j.worthy === true, title: j.worthy === true ? String(j.title || '').replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80) : '' };++ if (!sharedKeyReady()) return null;++ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 768 });++ const o = img ? await aiService.structured('live.stream.describe_frame', { image: img }, { meter: { kind: 'stream_memory', role: 'vision' } }) : null;++ return o && o.description ? { description: o.description, tags: o.tags || [], worthy: o.worthy === true, title: o.worthy === true ? String(o.title || '').slice(0, 80) : '' } : null;}/** Fixed category taxonomy the AI picks from (matches the go-live selector). */@@@ -178,56 +90,21 @@ function normalizeCategory(c) { const t = String(c || '').toLowerCase().trim();async function summarizeStreamMemories(memories, streamId = null) {// Use observations from across the whole session (capped for token budget) so the// overview reflects the entire stream since it started, not just the latest frame.-- const lines = (memories || []).slice(-80).map(m => `- ${m.description}`).join('\n');-- if (!lines) return null;-- if (aiService.enabled()) {-- if (!remote()) return null;-- const input = { observations: (memories || []).slice(-80).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean) };-- if (streamId) {-- try {-- input.speech = (db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || []).slice(-200).map(r => ({ start_sec: Number(r.start_sec) || 0, text: String(r.text || '').slice(0, 2000) }));-- input.sounds = (db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || []).slice(-60).map(r => ({ start_sec: Number(r.start_sec) || 0, label: String(r.label || '').slice(0, 120), confidence: Number(r.confidence || 0) }));-- } catch { /* timeline optional */ }-- }-- const o = await aiService.structured('live.stream.summarize', input, { target: streamId ? { service: 'live', type: 'stream', id: String(streamId) } : undefined, meter: { kind: 'stream_memory', role: 'legacy' } });-- return o && o.overview ? { overview: o.overview, category: normalizeCategory(o.category), tags: Array.isArray(o.tags) ? o.tags.map(String).slice(0, 6) : [] } : null;-- }---- // Fold in the audio timeline when one exists. Previously the transcript reached this-- // prompt only as a 500-char slice embedded inside a memory's description string, with-- // its timestamps stripped — so the model saw fragments of speech with no idea when-- // they happened or what they lined up with. Sending the timeline directly gives it-- // what was SAID and what was HEARD, both anchored in time.-- let audioBlock = '';++ const observations = (memories || []).slice(-80).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean);++ if (!observations.length || !sharedKeyReady()) return null;++ // The audio timeline, when one exists, goes along with its timestamps: what was SAID and what++ // was HEARD, both anchored in time.++ const input = { observations };if (streamId) {try {-- const _mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;-- const speech = db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || [];-- const sounds = db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || [];-- if (speech.length) {-- const spoken = speech.slice(-200)-- .map(r => `[${_mmss(r.start_sec)}] ${String(r.text || '').replace(/\s+/g, ' ').trim().slice(0, 200)}`)-- .filter(l => l.length > 8).join('\n');-- if (spoken) audioBlock += `\n\nWHAT THE STREAMER SAID (timestamped):\n${spoken}`;-- }-- if (sounds.length) {-- const heard = sounds.slice(-60)-- .map(r => `[${_mmss(r.start_sec)}] ${r.label} (${Number(r.confidence || 0).toFixed(2)})`)-- .join('\n');-- if (heard) audioBlock += `\n\nNOTABLE SOUNDS HEARD:\n${heard}`;-- }++ input.speech = (db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || []).slice(-200).map(r => ({ start_sec: Number(r.start_sec) || 0, text: String(r.text || '').slice(0, 2000) }));++ input.sounds = (db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || []).slice(-60).map(r => ({ start_sec: Number(r.start_sec) || 0, label: String(r.label || '').slice(0, 120), confidence: Number(r.confidence || 0) }));} catch { /* timeline optional */ }}---- // The same call also classifies the stream — category is inferred from what is actually on-- // screen / said, never from the streamer's go-live selector (which used to default to "irl"-- // and taught every downstream AI that everyone is an IRL streamer).-- const prompt = `These are timestamped observations from a live stream, in order since it started. Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: ${CATEGORIES.join(', ')} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n${lines}${audioBlock}`;-- const text = await _complete({ prompt, maxTokens: 560, kind: 'stream_memory' });-- if (!text) return null;-- const j = _parseJson(text);-- const overview = j && j.overview ? String(j.overview).slice(0, 2000) : (_extractDescription(text, 2000) || text.slice(0, 2000));-- return { overview, category: normalizeCategory(j && j.category), tags: j && Array.isArray(j.tags) ? j.tags.map(String).slice(0, 6) : [] };++ // The same run also classifies the stream: the category is inferred from what is actually on++ // screen / said, never from the streamer's go-live selector.++ const o = await aiService.structured('live.stream.summarize', input, { target: streamId ? { service: 'live', type: 'stream', id: String(streamId) } : undefined, meter: { kind: 'stream_memory', role: 'legacy' } });++ return o && o.overview ? { overview: o.overview, category: normalizeCategory(o.category), tags: Array.isArray(o.tags) ? o.tags.map(String).slice(0, 6) : [] } : null;}/**@@@ -235,50 +112,36 @@ async function summarizeStreamMemories(memories, streamId = null) {* their streams (memories), VODs, and pastes. Returns the overview text or null.*/async function generateStreamerOverview(userId) {-- if (!isEnabled() || !withinBudget()) return null;++ if (!sharedKeyReady()) return null;const user = db.getUserById(userId);if (!user) return null;const channel = (typeof db.getChannelByUserId === 'function') ? db.getChannelByUserId(userId) : null;const memories = (db.getStreamMemoriesByUser ? db.getStreamMemoriesByUser(userId, 60) : []) || [];-- const memLines = memories.slice(0, 40).map(m => `- ${m.description}`).filter(l => l.length > 2);++ const memLines = memories.slice(0, 40).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean);// VODs from OpenVibe.Media (public ones) and pastes from OpenVibe.Community (any visibility:// this overview is for site staff), both through media-proxy/lookups.js.const lookups = require('../media-proxy/lookups');const [vods, pastes] = await Promise.all([lookups.userVods(userId, { limit: 20 }), lookups.userPastesForAi(user, 25)]);-- const vodLines = vods.map(v => `- ${v.title || 'Untitled VOD'}${(v.ai_category || v.category) ? ` [${v.ai_category || v.category}]` : ''}`);---- const pasteLines = pastes.filter(p => p.ai_summary).map(p => `- "${p.title || 'paste'}": ${p.ai_summary}`);---- if (!memLines.length && !vodLines.length && !pasteLines.length) return null;---- const ctx = [-- `Streamer: ${user.display_name || user.username} (@${user.username})`,-- (channel?.bio || user.bio) ? `Bio: ${(channel?.bio || user.bio).slice(0, 400)}` : '',-- (channel?.ai_category || channel?.category) ? `Usual category (${channel?.ai_category ? 'inferred by AI from their streams' : 'self-selected'}): ${channel.ai_category || channel.category}` : '',-- memLines.length ? `\nLive-stream observations (across sessions):\n${memLines.join('\n')}` : '',-- vodLines.length ? `\nRecent VODs:\n${vodLines.join('\n')}` : '',-- pasteLines.length ? `\nPaste summaries:\n${pasteLines.join('\n')}` : '',-- ].filter(Boolean).join('\n');++ const summarized = pastes.filter(p => p.ai_summary);-- const prompt = `You are building an internal profile of a livestreamer for site staff, using aggregated signals across their streams, VODs, and pastes. Write a concise overview (4-8 sentences) covering: what they stream / their content niche, recurring themes or activities, tone/vibe, and anything notable for moderation. Be factual and neutral; do NOT invent specifics that aren't supported by the signals below.\n\n${ctx}`;++ if (!memLines.length && !vods.length && !summarized.length) return null;-- const text = aiService.enabled()-- ? ((await aiService.structured('live.streamer.overview', {-- streamer: { username: String(user.username), display_name: String(user.display_name || user.username), bio: String(channel?.bio || user.bio || '').slice(0, 2000), category: (channel?.ai_category || channel?.category) ? String(channel.ai_category || channel.category) : undefined, category_inferred: Boolean(channel?.ai_category) },-- memories: memories.slice(0, 40).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean),-- vods: vods.map(v => ({ title: String(v.title || 'Untitled VOD').slice(0, 300), category: (v.ai_category || v.category) ? String(v.ai_category || v.category) : undefined })),-- pastes: pastes.filter(p => p.ai_summary).map(p => ({ title: String(p.title || 'paste').slice(0, 300), summary: String(p.ai_summary).slice(0, 600) })),-- }, { target: { service: 'live', type: 'user', id: String(userId) }, meter: { kind: 'streamer_overview', role: 'legacy' } })) || {}).overview || null-- : await _complete({ prompt, maxTokens: 550, kind: 'streamer_overview' });++ const o = await aiService.structured('live.streamer.overview', {++ streamer: { username: String(user.username), display_name: String(user.display_name || user.username), bio: String(channel?.bio || user.bio || '').slice(0, 2000), category: (channel?.ai_category || channel?.category) ? String(channel.ai_category || channel.category) : undefined, category_inferred: Boolean(channel?.ai_category) },++ memories: memLines,++ vods: vods.map(v => ({ title: String(v.title || 'Untitled VOD').slice(0, 300), category: (v.ai_category || v.category) ? String(v.ai_category || v.category) : undefined })),++ pastes: summarized.map(p => ({ title: String(p.title || 'paste').slice(0, 300), summary: String(p.ai_summary).slice(0, 600) })),++ }, { target: { service: 'live', type: 'user', id: String(userId) }, meter: { kind: 'streamer_overview', role: 'legacy' } });++ const text = o && o.overview;if (!text) return null;-- const overview = text.slice(0, 4000);++ const overview = String(text).slice(0, 4000);try {db.upsertStreamerOverview(userId, {overview,-- model: model(),-- sources: JSON.stringify({ memories: memories.length, vods: vods.length, pastes: pasteLines.length }),++ model: 'openvibe-ai',++ sources: JSON.stringify({ memories: memories.length, vods: vods.length, pastes: summarized.length }),});} catch (e) { console.warn('[AI] overview store failed:', e.message); }return overview;@@@ -499,25 +362,22 @@ async function generateClipOverview(clip) {return { overview: r ? r.overview : null, transcript };}--/** Report AI config + optionally live-probe the provider. */++/** Report AI config + optionally probe OpenVibe.AI with a one-word run. */async function testStatus({ probe = true } = {}) {const cfg = {enabled: isEnabled(),-- provider: s('ai_provider') || 'anthropic',-- model: model(),-- has_key: !!s('ai_api_key'),-- base_url: s('ai_base_url') || null,++ service: aiService.enabled() ? 'openvibe-ai' : 'off',paste_analysis: pasteAnalysisEnabled(),stream_memory: streamMemoryEnabled(),budget_cap_usd_per_day: num('ai_max_cost_usd_per_day', 0),within_budget: withinBudget(),};try { cfg.cost_today = db.getAiCostToday(); } catch { cfg.cost_today = null; }-- if (!cfg.enabled) return { ...cfg, ok: false, error: cfg.has_key ? 'AI is disabled (ai_enabled=false)' : 'No API key set' };++ if (!cfg.enabled) return { ...cfg, ok: false, error: aiService.enabled() ? 'AI is disabled (ai_enabled=false)' : 'AI is off (AI_SERVICE=off)' };if (!probe) return { ...cfg, ok: true, probed: false };const started = Date.now();const reply = await _complete({ prompt: 'Reply with exactly: OK', maxTokens: 8, kind: 'status_check' });-- return { ...cfg, ok: !!reply, probed: true, reply: reply || null, latency_ms: Date.now() - started, error: reply ? null : 'Provider returned no response (check key/model/base URL)' };++ return { ...cfg, ok: !!reply, probed: true, reply: reply || null, latency_ms: Date.now() - started, error: reply ? null : 'OpenVibe.AI returned no answer (over budget, or the service or its provider is down)' };}module.exports = {@@@ -526,6 +386,6 @@ module.exports = {analyzeImagePaste, analyzeTextPaste, analyzeStreamFrame, summarizeStreamMemories,generateStreamerOverview, generateVodOverview, generateClipOverview, ensureVodTimeline,generateVodTranscript, generateClipTranscript, summarizeText, testStatus,-- viewerComplete, sharedKeyReady, estimateCost,++ viewerComplete, sharedKeyReady,complete: llm.complete, llm,};ddeleted file mode 100644@@@ -1,165 +0,0 @@--/**-- * ai-provider.js — Minimal OpenAI-compatible AI client.-- *-- * Works with any provider exposing the OpenAI REST shape:-- * - Chat: POST {baseUrl}/chat/completions-- * - Transcription: POST {baseUrl}/audio/transcriptions (Whisper-style)-- *-- * That covers OpenAI, OpenRouter, Groq, Together, local llama.cpp / LM Studio /-- * Ollama (OpenAI-compat mode), etc. Callers supply baseUrl + apiKey + model.-- * No SDK dependency — uses global fetch/FormData/Blob (Node 18+).-- */--'use strict';--const fs = require('fs');--const path = require('path');----const DEFAULT_BASE_URL = 'https://api.openai.com/v1';----function normalizeBaseUrl(baseUrl) {-- let u = String(baseUrl || DEFAULT_BASE_URL).trim();-- if (!u) u = DEFAULT_BASE_URL;-- return u.replace(/\/+$/, '');--}----// A non-OpenAI base URL — local servers (Ollama / LM Studio / llama.cpp) are OpenAI---// compatible but need NO API key, so we don't require one for these.--function isSelfHostedBaseUrl(baseUrl) {-- const u = normalizeBaseUrl(baseUrl).toLowerCase();-- return !!u && !u.includes('api.openai.com');--}----// True if the URL points at a local/private address the PUBLIC server can't reach.--function isLocalOrPrivateHost(baseUrl) {-- try {-- const h = new URL(normalizeBaseUrl(baseUrl)).hostname.toLowerCase().replace(/^\[|\]$/g, '');-- return h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')-- || /^127\./.test(h) || /^10\./.test(h) || /^192\.168\./.test(h)-- || /^172\.(1[6-9]|2\d|3[01])\./.test(h) || /^169\.254\./.test(h);-- } catch { return false; }--}----// Translate a low-level fetch failure into an actionable message. undici throws a terse--// "fetch failed" (surfaced in browsers as "Failed to fetch") for any network-level problem--// — and the #1 cause here is a self-hosted endpoint that isn't reachable from our servers.--function _describeFetchError(err, url, baseUrl, timeoutMs) {-- if (err && err.name === 'AbortError') {-- return new Error(`The AI server at ${url} didn't respond within ${Math.round(timeoutMs / 1000)}s. Is it running and reachable?`);-- }-- if (isLocalOrPrivateHost(baseUrl)) {-- return new Error(`Can't reach ${url}. The AI chat viewers run on OpenVibe.Live's servers, so a self-hosted API must be reachable from the public internet — a local address like localhost/127.0.0.1 or a home/LAN IP won't work. Expose it with a public URL or a tunnel (e.g. ngrok, cloudflared) and use that as the Base URL.`);-- }-- return new Error(`Couldn't connect to the AI server at ${url}. Check the Base URL and that the server is online and reachable from the internet.`);--}----/**-- * Chat completion. Returns the assistant message text (string).-- * @param {{baseUrl?:string, apiKey:string, model:string, messages:Array, temperature?:number, maxTokens?:number, timeoutMs?:number}} opts-- */--async function chatCompletion(opts) {-- const {-- baseUrl, apiKey, model, messages,-- temperature = 1.0, maxTokens = 160, timeoutMs = 20000,-- } = opts;-- if (!model) throw new Error('AI model not configured');-- // API key is OPTIONAL: self-hosted OpenAI-compatible servers (Ollama, LM Studio,-- // llama.cpp) accept requests with no Authorization header. Only send one when present.-- const headers = { 'Content-Type': 'application/json' };-- if (apiKey) headers.Authorization = `Bearer ${apiKey}`;---- const url = `${normalizeBaseUrl(baseUrl)}/chat/completions`;-- const controller = new AbortController();-- const timer = setTimeout(() => controller.abort(), timeoutMs);-- try {-- let res;-- try {-- res = await fetch(url, {-- method: 'POST',-- headers,-- body: JSON.stringify({ model, messages, temperature, max_tokens: maxTokens }),-- signal: controller.signal,-- });-- } catch (netErr) {-- throw _describeFetchError(netErr, url, baseUrl, timeoutMs);-- }-- if (!res.ok) {-- const body = await res.text().catch(() => '');-- if (res.status === 401 || res.status === 403) {-- throw new Error(`The AI server rejected the request (HTTP ${res.status}). It needs a valid API key${apiKey ? '' : ' — none was provided'}.`);-- }-- throw new Error(`AI HTTP ${res.status}: ${body.slice(0, 300)}`);-- }-- const data = await res.json();-- const text = data?.choices?.[0]?.message?.content;-- return typeof text === 'string' ? text.trim() : '';-- } finally {-- clearTimeout(timer);-- }--}----/**-- * Transcribe an audio file. Returns the transcript text (string, possibly '').-- * @param {{baseUrl?:string, apiKey:string, model?:string, filePath:string, language?:string, timeoutMs?:number}} opts-- */--async function transcribe(opts) {-- const {-- baseUrl, apiKey, model = 'whisper-1', filePath,-- language, timeoutMs = 30000,-- } = opts;-- if (!filePath || !fs.existsSync(filePath)) throw new Error('Transcription audio file missing');---- const url = `${normalizeBaseUrl(baseUrl)}/audio/transcriptions`;-- const buf = fs.readFileSync(filePath);-- const form = new FormData();-- form.append('file', new Blob([buf], { type: 'audio/wav' }), path.basename(filePath));-- form.append('model', model);-- form.append('response_format', 'text');-- if (language) form.append('language', language);---- const headers = {};-- if (apiKey) headers.Authorization = `Bearer ${apiKey}`;-- const controller = new AbortController();-- const timer = setTimeout(() => controller.abort(), timeoutMs);-- try {-- let res;-- try {-- res = await fetch(url, { method: 'POST', headers, body: form, signal: controller.signal });-- } catch (netErr) {-- throw _describeFetchError(netErr, url, baseUrl, timeoutMs);-- }-- if (!res.ok) {-- const body = await res.text().catch(() => '');-- throw new Error(`Transcribe HTTP ${res.status}: ${body.slice(0, 300)}`);-- }-- // response_format=text returns raw text; some providers still return JSON.-- const raw = await res.text();-- try {-- const j = JSON.parse(raw);-- return String(j.text || j.transcript || '').trim();-- } catch {-- return String(raw || '').trim();-- }-- } finally {-- clearTimeout(timer);-- }--}----/**-- * Lightweight connectivity/credential check — a 1-token chat call.-- * Returns { ok:true } or { ok:false, error }.-- */--async function testConnection(opts) {-- try {-- await chatCompletion({-- ...opts,-- messages: [{ role: 'user', content: 'Reply with the single word: ok' }],-- maxTokens: 5,-- temperature: 0,-- timeoutMs: 15000,-- });-- return { ok: true };-- } catch (err) {-- return { ok: false, error: err.message };-- }--}----module.exports = { chatCompletion, transcribe, testConnection, normalizeBaseUrl, isSelfHostedBaseUrl, isLocalOrPrivateHost, DEFAULT_BASE_URL };@@@ -1,26 +1,25 @@/**-- * ai-service.js — Live as a CONSUMER of OpenVibe.AI (roadmap Wave 13).++ * ai-service.js — Live as a CONSUMER of OpenVibe.AI (roadmap Wave 13; the only mode since WS-O task 2).*-- * With AI_SERVICE=remote, Live stops calling model providers itself: every shared-key AI call-- * becomes a workflow run on OpenVibe.AI (ai.openvibe.network, internal OV_AI_INTERNAL_URL),-- * authenticated with Live's Network service token for audience openvibe.ai (network-principal).-- * Unset (the default), nothing here is used and Live behaves exactly as before.++ * Live calls no model provider itself and keeps no shared provider key: every AI call is a workflow++ * run on OpenVibe.AI (ai.openvibe.network, internal OV_AI_INTERNAL_URL), authenticated with Live's++ * Network service token for audience openvibe.ai (network-principal). AI_SERVICE=off turns all of it++ * off (every call answers null); any other value, or none, is on. The old AI_SERVICE=remote still++ * means on.** structured(workflow, input, opts) -> the run's output object, or null-- * for workflows whose prompt now lives in OpenVibe.AI (live.translate, live.paste.*,-- * live.stream.*, live.streamer.overview, live.media.overview, live.stream.recap,-- * network.site_copy)++ * for workflows whose prompt lives in OpenVibe.AI (live.translate, live.paste.*,++ * live.stream.*, live.streamer.overview, live.media.overview, live.stream.recap)* complete(o, { toVisionJpeg }) -> the llm.complete() result shape, or null* for every other llm.complete() call: the prompt Live rendered is sent as a passthrough run of* the workflow that owns that feature (KIND_TO_WORKFLOW), so each run is still attributable* to one workflow + model + run*-- * 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. 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.++ * null means "no AI answer" (AI off, over budget, provider down): quota refusals (429), failed runs,++ * an unreachable service, and SYNTHETIC answers (the AI service's stub provider) all come back as++ * null — synthetic text never reaches 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 }.*/'use strict';const principal = require('../net/network-principal');@@@ -38,7 +37,7 @@ const KIND_TO_WORKFLOW = {};const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];--function enabled() { return String(process.env.AI_SERVICE || '').trim().toLowerCase() === 'remote'; }++function enabled() { return String(process.env.AI_SERVICE || '').trim().toLowerCase() !== 'off'; }function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }function workflowFor(kind) { return KIND_TO_WORKFLOW[kind] || 'live.complete'; }@@@ -1,22 +1,19 @@/*** llm.js — the ONE chat-completion entry point for OpenVibe.Live.*-- * Every feature (AI viewers, overviews, chat insights, vision, paste analysis) calls-- * complete(); it is the single place that:-- * - gates on the admin master switch + global daily budget (shared key), or routes-- * to a streamer's BYO provider (uniformly metered either way);-- * - picks the model per ROLE (chat / vision / director / summary) with per-role admin-- * overrides and BYO equivalents, falling back to the default model;-- * - talks to Anthropic or any OpenAI-compatible endpoint with real system/user roles-- * (the old code flattened everything into one user message, which made prompt-- * caching impossible);-- * - uses prompt caching: Anthropic cache_control on the stable system blocks, OpenAI-- * automatic prefix caching + prompt_cache_key;-- * - supports structured JSON output (OpenAI json_schema, Anthropic forced tool), with a-- * prose-JSON repair fallback for gateways that lack it;-- * - always downscales images before sending them;-- * - applies a timeout and ONE retry on transient failures;-- * - records usage (incl. cached tokens, role, provider, latency) with per-model pricing.++ * Every feature (AI viewers, overviews, chat insights, vision, paste analysis) calls complete().++ * Live calls no model provider itself and keeps no shared provider key (roadmap WS-O task 2):++ * complete() is a workflow run on OpenVibe.AI (ai-service.js), one of two kinds:++ * - a streamer's own key stored in OpenVibe.AI (o.provider.credentialSubject, byo-credentials.js):++ * a run with credential { subject }, within the budget the streamer set there;++ * - every other call: gated on the admin master switch (ai_enabled) + the global daily budget,++ * then a run of the workflow that owns the feature (ai-service KIND_TO_WORKFLOW).++ * Both are recorded in ai_usage (role, provider, latency, the run's cost), so per-streamer budgets++ * and the admin cost views keep working. A raw key / base URL override (the old direct "bring your++ * own" path) is refused: every stored key moved to OpenVibe.AI.++ *++ * testProvider() is the one direct provider call left (see there). toVisionJpeg() downscales any++ * image before it is sent; parseJsonLoose() repairs the JSON models commonly get slightly wrong.*/'use strict';const fs = require('fs');@@@ -25,89 +22,18 @@ const aiService = require('./ai-service');const egress = require('../net/egress');const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];--const DEFAULT_TIMEOUT_MS = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };--function s(k) { return (db.getSetting(k) || '').toString().trim(); }function b(k) { const v = db.getSetting(k); return v === true || v === 'true' || v === 1 || v === '1'; }function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }// ── Gates ────────────────────────────────────────────────────--// AI_SERVICE=remote: the provider key lives in OpenVibe.AI, so the admin master switch alone decides.--function isEnabled() { return b('ai_enabled') && (aiService.enabled() || !!s('ai_api_key')); }++// The provider key lives in OpenVibe.AI, so the admin master switch and AI_SERVICE (not off) decide.++function isEnabled() { return b('ai_enabled') && aiService.enabled(); }function withinBudget() {const cap = num('ai_max_cost_usd_per_day', 0);if (!cap || cap <= 0) return true;try { return db.getAiCostToday() < cap; } catch { return true; }}--function defaultModel() { return s('ai_model') || (s('ai_provider') === 'anthropic' ? 'claude-sonnet-5' : 'gpt-4o-mini'); }----/** Model for a role: admin per-role override → default model. */--function modelForRole(role) {-- const r = ROLES.includes(role) ? role : 'legacy';-- if (r !== 'legacy') { const m = s(`ai_model_${r}`); if (m) return m; }-- return defaultModel();--}----/**-- * Resolve the provider for a call.-- * override = { kind?: 'openai'|'anthropic', baseUrl?, apiKey?, model?, models?: {chat,vision,...} }-- */--function resolveProvider(role, override = null) {-- if (override && (override.apiKey || override.baseUrl)) {-- const baseUrl = String(override.baseUrl || '').trim().replace(/\/+$/, '');-- const kind = override.kind || (/anthropic\.com/i.test(baseUrl) ? 'anthropic' : 'openai');-- const model = (override.models && override.models[role]) || override.model || 'gpt-4o-mini';-- return { kind, baseUrl: baseUrl || (kind === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1'), apiKey: override.apiKey || '', model, shared: false };-- }-- const baseUrl = (s('ai_base_url') || '').replace(/\/+$/, '');-- // Anthropic only when explicitly chosen (and not pointed at an OpenAI-compatible-- // gateway); everything else — openai, openrouter, groq, ollama, blank — is OpenAI-shaped.-- const kind = s('ai_provider') === 'anthropic' && (!baseUrl || /anthropic\.com/i.test(baseUrl)) ? 'anthropic' : 'openai';-- return {-- kind,-- baseUrl: baseUrl || (kind === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1'),-- apiKey: s('ai_api_key'),-- model: modelForRole(role),-- shared: true,-- };--}----// ── Pricing ──────────────────────────────────────────────────--// site_settings.ai_pricing_json = { "gpt-5-nano": {"in":0.05,"out":0.4,"cached":0.005}, "default": {...} }--// (USD per million tokens). Longest key-prefix match on the model id; falls back to the--// flat ai_input/output_cost_per_mtok settings, cached input = 10% of input.--let _pricingCache = { at: 0, table: null };--function pricingTable() {-- if (Date.now() - _pricingCache.at < 30000 && _pricingCache.table) return _pricingCache.table;-- let table = {};-- try {-- const raw = db.getSetting('ai_pricing_json'); // type 'json' → already parsed-- table = (raw && typeof raw === 'object') ? raw : (raw ? JSON.parse(String(raw)) : {}) || {};-- } catch { table = {}; }-- _pricingCache = { at: Date.now(), table };-- return table;--}--function priceFor(model) {-- const table = pricingTable();-- const m = String(model || '').toLowerCase();-- let best = null, bestLen = -1;-- for (const [k, v] of Object.entries(table)) {-- const key = String(k).toLowerCase();-- if (key === 'default') continue;-- if (m.startsWith(key) && key.length > bestLen && v && typeof v === 'object') { best = v; bestLen = key.length; }-- }-- if (!best && table.default && typeof table.default === 'object') best = table.default;-- const inRate = best && Number.isFinite(Number(best.in)) ? Number(best.in) : num('ai_input_cost_per_mtok', 3);-- const outRate = best && Number.isFinite(Number(best.out)) ? Number(best.out) : num('ai_output_cost_per_mtok', 15);-- const cachedRate = best && Number.isFinite(Number(best.cached)) ? Number(best.cached) : inRate * 0.1;-- return { in: inRate, out: outRate, cached: cachedRate };--}--/** usage = { input (total prompt tokens incl. cached), output, cached } */--function estimateCost(model, usage) {-- const p = priceFor(model);-- const input = Math.max(0, (usage.input || 0) - (usage.cached || 0));-- return (input / 1e6) * p.in + ((usage.cached || 0) / 1e6) * p.cached + ((usage.output || 0) / 1e6) * p.out;--}// ── Images ───────────────────────────────────────────────────async function _loadImage(image) {@@@ -147,138 +73,6 @@ async function toVisionJpeg(image, { maxWidth = 1280, quality = 82 } = {}) {return `data:image/jpeg;base64,${buf.toString('base64')}`;}}--function _splitDataUrl(dataUrl) {-- const m = String(dataUrl || '').match(/^data:([^;]+);base64,(.*)$/);-- return m ? { mediaType: m[1], base64: m[2] } : null;--}----// ── Message shaping ──────────────────────────────────────────--function _systemBlocks(system) {-- if (!system) return [];-- if (typeof system === 'string') return system.trim() ? [{ text: system, cache: false }] : [];-- return system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: !!x.cache }));--}--function _normMessages(messages, user) {-- const out = [];-- for (const m of (messages || [])) {-- if (!m || !m.role) continue;-- out.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content });-- }-- if (user) out.push({ role: 'user', content: user });-- if (!out.length) out.push({ role: 'user', content: '' });-- return out;--}----function _isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }----function _openaiBody({ p, system, messages, img, json, maxTokens, temperature, cacheKey, jsonMode }) {-- const msgs = [];-- const sysText = system.map(x => x.text).join('\n\n');-- if (sysText) msgs.push({ role: 'system', content: sysText });-- messages.forEach((m, i) => {-- const last = i === messages.length - 1;-- if (last && img && m.role === 'user') {-- const parts = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content || '') }];-- msgs.push({ role: 'user', content: [...parts, { type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${img.base64}` } }] });-- } else msgs.push({ role: m.role, content: Array.isArray(m.content) ? m.content : String(m.content || '') });-- });-- const body = { model: p.model, messages: msgs };-- if (_isReasoningModel(p.model)) {-- body.max_completion_tokens = Math.max(maxTokens, 256) + 512;-- body.reasoning_effort = /^gpt-5/i.test(p.model) ? 'minimal' : 'low';-- } else {-- body.max_tokens = maxTokens;-- if (temperature != null) body.temperature = temperature;-- }-- if (json && jsonMode === 'schema') body.response_format = { type: 'json_schema', json_schema: { name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };-- else if (json && jsonMode === 'object') body.response_format = { type: 'json_object' };-- if (cacheKey && /api\.openai\.com/i.test(p.baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);-- return body;--}--function _anthropicBody({ p, system, messages, img, json, maxTokens, temperature }) {-- const body = { model: p.model, max_tokens: Math.max(1, maxTokens) };-- if (system.length) {-- body.system = system.map(x => x.cache ? { type: 'text', text: x.text, cache_control: { type: 'ephemeral' } } : { type: 'text', text: x.text });-- }-- body.messages = messages.map((m, i) => {-- const last = i === messages.length - 1;-- const content = Array.isArray(m.content)-- ? m.content.map(c => (c.type === 'text' ? { type: 'text', text: c.text } : c))-- : [{ type: 'text', text: String(m.content || '') }];-- if (last && img && m.role === 'user') content.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.base64 } });-- return { role: m.role, content };-- });-- if (temperature != null) body.temperature = temperature;-- if (json) {-- body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];-- body.tool_choice = { type: 'tool', name: json.name || 'result' };-- }-- return body;--}----// ── HTTP ─────────────────────────────────────────────────────--class LlmHttpError extends Error { constructor(status, message, body) { super(message); this.status = status; this.body = body; } }----// A streamer's own provider (the AI viewers' BYO base URL, p.shared false) is a URL a user typed, so it--// goes out through the egress guard like every other user-chosen URL (server/net/egress.js): it--// could otherwise point at Live's own port or another internal service, and the "test connection"--// button would hand the answer back. The site's provider (ai_base_url, set by the owner) may be--// internal and is fetched as before.--async function _post(url, headers, body, timeoutMs, { userChosen = false } = {}) {-- let status, text;-- if (userChosen) {-- const r = await egress.postJson(url, body, { headers, timeoutMs, maxBytes: 4 * 1024 * 1024 });-- status = r.status; text = r.text;-- } else {-- const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });-- status = res.status; text = await res.text();-- }-- let j = {}; try { j = text ? JSON.parse(text) : {}; } catch { j = { raw: text }; }-- if (!(status >= 200 && status < 300)) throw new LlmHttpError(status, (j.error && (j.error.message || j.error)) || `HTTP ${status}: ${text.slice(0, 200)}`, j);-- return j;--}----async function _callOpenAI(p, args, timeoutMs) {-- const headers = p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {};-- let jsonMode = args.json ? 'schema' : null;-- for (let attempt = 0; attempt < 3; attempt++) {-- const body = _openaiBody({ ...args, p, jsonMode });-- try {-- const j = await _post(`${p.baseUrl}/chat/completions`, headers, body, timeoutMs, { userChosen: !p.shared });-- const msg = j.choices && j.choices[0] && j.choices[0].message;-- const text = (msg && typeof msg.content === 'string' ? msg.content : '').trim();-- const u = j.usage || {};-- return { text, usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 } };-- } catch (e) {-- // Gateways without structured-output support: step down schema → json_object → prose.-- if (e instanceof LlmHttpError && e.status === 400 && jsonMode && /response_format|json_schema|schema|strict/i.test(e.message)) {-- jsonMode = jsonMode === 'schema' ? 'object' : null;-- continue;-- }-- throw e;-- }-- }-- throw new Error('structured output unsupported');--}--async function _callAnthropic(p, args, timeoutMs) {-- const body = _anthropicBody({ ...args, p });-- const j = await _post(`${p.baseUrl}/messages`, { 'x-api-key': p.apiKey, 'anthropic-version': '2023-06-01' }, body, timeoutMs, { userChosen: !p.shared });-- const content = j.content || [];-- let text = content.filter(c => c.type === 'text').map(c => c.text).join('').trim();-- let jsonOut = null;-- const tool = content.find(c => c.type === 'tool_use');-- if (tool && tool.input) { jsonOut = tool.input; if (!text) text = JSON.stringify(tool.input); }-- const u = j.usage || {};-- const cached = u.cache_read_input_tokens || 0;-- return { text, json: jsonOut, usage: { input: (u.input_tokens || 0) + cached + (u.cache_creation_input_tokens || 0), output: u.output_tokens || 0, cached } };--}----function _retryable(e) {-- if (!e) return false;-- if (e.name === 'TimeoutError' || e.name === 'AbortError') return true;-- if (e instanceof LlmHttpError) return e.status === 429 || e.status >= 500;-- return /fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket/i.test(e.message || '');--}function parseJsonLoose(text) {if (!text) return null;@@@ -297,108 +91,90 @@ function parseJsonLoose(text) {} catch { return null; }}++function _meter(o, role, r, provider) {++ 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, latency_ms: r.latencyMs,++ });++ } catch { /* metering is best-effort */ }++}++++let _overrideWarned = false;++/*** The call.* @param {object} o* role, system (string | [{text, cache}]), messages ([{role, content}]), user (shortcut: appended user msg),* image (data URL | http url | path | Buffer), imageMaxWidth (default 1024), json ({name, schema, strict?}),-- * maxTokens, temperature, timeoutMs, retries (default 1), cacheKey,-- * kind (ai_usage.kind), source, ownerUserId, provider (BYO override), skipGate (internal)++ * maxTokens, temperature, timeoutMs, cacheKey, kind (ai_usage.kind), source, ownerUserId,++ * provider ({ credentialSubject }: a streamer's own key stored in OpenVibe.AI)* @returns {Promise<null|{text:string, json:object|null, usage:{input,output,cached}, model:string, provider:string, latencyMs:number}>}*/async function complete(o = {}) {const role = ROLES.includes(o.role) ? o.role : 'legacy';-- // 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 });-- if (!r) { console.warn(`[AI] ${role}/${o.kind || role} via OpenVibe.AI returned no answer`); 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: 'openvibe-ai', latency_ms: r.latencyMs,-- });-- } catch { /* metering is best-effort */ }-- return r;-- }-- const p = resolveProvider(role, o.provider || null);-- if (p.shared) { if (!isEnabled() || !withinBudget()) return null; }-- else if (!p.apiKey && !/localhost|127\.0\.0\.1|:\d+$/.test(p.baseUrl) && /api\.openai\.com|anthropic\.com/i.test(p.baseUrl)) return null;---- let img = null;-- if (o.image) {-- const dataUrl = await toVisionJpeg(o.image, { maxWidth: o.imageMaxWidth || 1024, quality: o.imageQuality || 78 });-- img = dataUrl ? _splitDataUrl(dataUrl) : null;-- if (!img) return null;-- }-- const args = {-- system: _systemBlocks(o.system),-- messages: _normMessages(o.messages, o.user),-- img,-- json: o.json || null,-- maxTokens: Math.max(1, Math.round(o.maxTokens || 400)),-- temperature: o.temperature == null ? null : o.temperature,-- cacheKey: o.cacheKey || null,-- };-- const timeoutMs = o.timeoutMs || DEFAULT_TIMEOUT_MS[role] || 25000;-- const retries = o.retries == null ? 1 : Math.max(0, o.retries);-- const started = Date.now();-- let r = null, lastErr = null;-- for (let attempt = 0; attempt <= retries; attempt++) {-- try {-- r = p.kind === 'anthropic' ? await _callAnthropic(p, args, timeoutMs) : await _callOpenAI(p, args, timeoutMs);-- break;-- } catch (e) {-- lastErr = e;-- if (attempt < retries && _retryable(e)) { await new Promise(res => setTimeout(res, 800 + Math.random() * 700)); continue; }-- break;++ if (o.provider) {++ // A streamer's own key stored in OpenVibe.AI (byo-credentials.js): a run with credential { subject };++ // AI calls their provider with it, within their daily budget. Metered here as 'byo'.++ if (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);++ _meter(o, role, r, 'byo');++ return r;}++ // A raw key or base URL: Live no longer calls a provider with one, and never falls back to the shared AI.++ if (!_overrideWarned) {++ _overrideWarned = true;++ console.warn(`[AI] ${role}/${o.kind || role}: a raw provider key/base URL is no longer called from Live (keys live in OpenVibe.AI); no answer`);++ }++ return null;}-- if (!r) { console.warn(`[AI] ${role}/${p.model} failed:`, lastErr && lastErr.message); return null; }-- const latencyMs = Date.now() - started;-- const usage = r.usage || { input: 0, output: 0, cached: 0 };-- if (!usage.input && !usage.output) {-- // Self-hosted servers often omit usage — estimate so budgets still mean something.-- const approx = (t) => Math.ceil(String(t || '').length / 4);-- usage.input = approx(args.system.map(x => x.text).join('')) + args.messages.reduce((n, m) => n + approx(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)), 0) + (img ? 800 : 0);-- usage.output = approx(r.text);-- usage.estimated = true;-- }-- const cost = estimateCost(p.model, usage);-- try {-- db.recordAiUsage({-- kind: o.kind || role, model: p.model, input_tokens: usage.input, output_tokens: usage.output, cached_tokens: usage.cached || 0,-- cost_usd: cost, owner_user_id: o.ownerUserId || null, source: o.source || null, role, provider: p.shared ? 'shared' : 'byo', latency_ms: latencyMs,-- });-- } catch { /* metering is best-effort */ }-- let json = r.json || null;-- if (o.json && !json) json = parseJsonLoose(r.text);-- return { text: r.text || '', json, usage, model: p.model, provider: p.kind, shared: p.shared, latencyMs, cost };++ if (!isEnabled() || !withinBudget()) return null;++ const r = await aiService.complete({ ...o, role }, { toVisionJpeg });++ if (!r) { console.warn(`[AI] ${role}/${o.kind || role} via OpenVibe.AI returned no answer`); return null; }++ if (o.json && !r.json) r.json = parseJsonLoose(r.text);++ _meter(o, role, r, 'openvibe-ai');++ return r;}--/** 1-token connectivity probe for a provider override (BYO test) or the shared key. */++/**++ * "Test connection" for a key a streamer has just typed for their AI viewers (POST /api/ai-viewers/byo/test),++ * before it is saved: a one-word request to their provider. This is the ONE direct provider call left in Live,++ * because the key is not in OpenVibe.AI yet (it goes there only when the streamer saves it), so there is nothing++ * AI could run it with. The address is the streamer's choice, so the request goes out through the egress guard++ * (server/net/egress.js): never to Live's own port or any private address, redirects not followed. Nothing is++ * metered and only ok / an HTTP status comes back, never the provider's answer.++ * override = { kind?: 'openai'|'anthropic', baseUrl?, apiKey?, model?, models?: { chat } }++ */async function testProvider(override = null) {++ if (!override || override.credentialSubject) return { ok: false, error: 'No key to test' };++ const typed = String(override.baseUrl || '').trim().replace(/\/+$/, '');++ const kind = override.kind === 'anthropic' || /anthropic\.com/i.test(typed) ? 'anthropic' : 'openai';++ const baseUrl = typed || (kind === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1');++ const model = String((override.models && override.models.chat) || override.model || 'gpt-4o-mini');++ const apiKey = String(override.apiKey || '');++ if (!apiKey && /api\.openai\.com|anthropic\.com/i.test(baseUrl)) return { ok: false, error: 'Enter your API key to test it' };++ const prompt = [{ role: 'user', content: 'Reply with the single word: ok' }];++ let url, headers, body;++ if (kind === 'anthropic') {++ url = `${baseUrl}/messages`;++ headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };++ body = { model, max_tokens: 5, temperature: 0, messages: prompt };++ } else {++ url = `${baseUrl}/chat/completions`;++ headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};++ body = { model, messages: prompt };++ // Reasoning models (gpt-5, o-series) take max_completion_tokens and think before they answer.++ if (/^(gpt-5|o\d)/i.test(model)) { body.max_completion_tokens = 768; body.reasoning_effort = /^gpt-5/i.test(model) ? 'minimal' : 'low'; }++ else { body.max_tokens = 5; body.temperature = 0; }++ }++ const started = Date.now();try {-- const r = await complete({ role: 'chat', user: 'Reply with the single word: ok', maxTokens: 5, temperature: 0, timeoutMs: 15000, retries: 0, kind: 'status_check', provider: override, skipGate: true });-- return r ? { ok: true, model: r.model, latencyMs: r.latencyMs } : { ok: false, error: 'AI disabled, over budget, or the provider rejected the request' };++ const r = await egress.postJson(url, body, { headers, timeoutMs: 15000, maxBytes: 256 * 1024 });++ if (r.status >= 200 && r.status < 300) return { ok: true, model, latencyMs: Date.now() - started };++ return { ok: false, error: `The provider answered HTTP ${r.status}` };} catch (e) { return { ok: false, error: e.message }; }}@@@ -409,4 +185,4 @@ aiService.setRecorder((r, m) => db.recordAiUsage({cost_usd: (r.usage && r.usage.cost_usd) || 0, owner_user_id: m.ownerUserId || null, source: m.source || null, role: m.role || null, provider: 'openvibe-ai', latency_ms: null,}));--module.exports = { complete, testProvider, resolveProvider, modelForRole, priceFor, estimateCost, toVisionJpeg, parseJsonLoose, isEnabled, withinBudget, defaultModel, ROLES };++module.exports = { complete, testProvider, toVisionJpeg, parseJsonLoose, isEnabled, withinBudget, ROLES };@@@ -292,20 +292,10 @@ async function analyzeMedia(src, { streamId = null, userId = null, numFrames = nconst { text: transcript, segments } = await _transcribeSpan(src, duration);let overview = null;-- if (frames.length || transcript) {-- const parts = [];-- if (frames.length) parts.push('Visual observations across the video (in order):\n' + frames.map(f => `- ${f.description}`).join('\n'));-- if (transcript) parts.push('Audio transcript (sampled from the recording):\n"' + transcript.slice(0, 4000) + '"');-- const prompt = `You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don't invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n${parts.join('\n\n')}`;-- const aiService = require('./ai-service');-- if (aiService.enabled()) {-- // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.media.overview).-- const o = ai.isEnabled() && ai.withinBudget()-- ? await aiService.structured('live.media.overview', { frames: frames.map(f => String(f.description || '').slice(0, 1000)).filter(Boolean).slice(0, 60), transcript: String(transcript || '').slice(0, 200000) }, { meter: { kind: 'media_overview', role: 'legacy', ownerUserId: userId } })-- : null;-- overview = o && o.overview ? o.overview : null;-- } else overview = await ai.summarizeText(prompt, 400, 'media_overview');-- if (overview) overview = overview.slice(0, 2000);++ if ((frames.length || transcript) && ai.isEnabled() && ai.withinBudget()) {++ // The prompt lives in OpenVibe.AI (workflow live.media.overview).++ const o = await require('./ai-service').structured('live.media.overview', { frames: frames.map(f => String(f.description || '').slice(0, 1000)).filter(Boolean).slice(0, 60), transcript: String(transcript || '').slice(0, 200000) }, { meter: { kind: 'media_overview', role: 'legacy', ownerUserId: userId } });++ overview = o && o.overview ? String(o.overview).slice(0, 2000) : null;}return { overview: overview || null, transcript: transcript || '', segments: segments || [], frames, duration };}@@@ -1,31 +1,25 @@/*** AI Chat Viewers — budget + cost routing.*-- * Every bot LLM call goes through generate(), which routes to either:-- * - the SHARED OpenVibe.Live key (default), metered + capped per streamer at++ * Every bot LLM call goes through generate(), a run on OpenVibe.AI either way:++ * - the site's AI (default), metered + capped per streamer at* channel_ai_config.daily_budget_cents (default 20¢/day). Over the cap → bots* go quiet (returns null). Also respects the admin master switch + global cap.-- * - the streamer's BYO key (uncapped; their provider bills them). Usage is still-- * recorded (estimated) for display.++ * - the streamer's own key, stored in OpenVibe.AI (byo_in_ai; their provider bills++ * them, within the budget they set there). Usage is still recorded for display.++ * A key that never moved there is not used: Live calls no provider itself.** Spend is attributed to the streamer via ai_usage.owner_user_id with* source='ai_viewers', which is the foundation for per-streamer budgets + billing.*/const db = require('../../db/database');const ai = require('../ai-analysis');--const aiProvider = require('../ai-provider');const SOURCE = 'ai_viewers';--// Rough token estimate from text length (~4 chars/token) — used only for the BYO--// path (whose provider doesn't report usage) so the dashboard meter has a number.--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; }--}++// Only a key stored in OpenVibe.AI runs; a key (or keyless self-hosted address) left in Live's++// database would need Live to call the provider itself, which it no longer does.++function byoUsable(cfg) { return !!cfg.byo_in_ai; }/*** Snapshot of a streamer's AI-viewer budget state.@@@ -61,15 +55,14 @@ async function generate(userId, { system = '', user = '', image = null, maxTokenif (!st.active) return null;if (st.useShared) {-- // Shared admin key: metered + attributed inside ai-analysis.viewerComplete.++ // The site's AI: metered + attributed inside ai-analysis.viewerComplete.return ai.viewerComplete({ system, user, image, maxTokens, temperature, ownerUserId: userId });}-- // 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.++ // Their own key in OpenVibe.AI — a run with credential { subject }, metered as 'byo' in llm.js.++ // Never fall through to the site's AI: no usable own key means the streamer's bots stay quiet.const provider = byoProvider(st.cfg);-- if (!provider) return null;++ if (!provider || !provider.credentialSubject) return null;const r = await ai.llm.complete({role: 'chat', system, user, image, imageMaxWidth: 768, maxTokens, temperature,kind: 'ai_viewers', source: SOURCE, ownerUserId: userId,@@@ -79,8 +72,10 @@ async function generate(userId, { system = '', user = '', image = null, maxToken}/**-- * 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.++ * The provider for a streamer's own-key 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, for llm.complete; the key itself never comes back++ * here. Otherwise the typed key, address and models, which only llm.testProvider uses ("test connection" before++ * the key is saved to OpenVibe.AI).*/function byoProvider(cfg) {if (cfg.byo_in_ai) {@@@ -95,7 +90,7 @@ function byoProvider(cfg) {if (m) models[role] = String(m);}return {-- baseUrl: cfg.byo_base_url || extra.base_url || aiProvider.DEFAULT_BASE_URL,++ baseUrl: cfg.byo_base_url || extra.base_url || '',apiKey: cfg.byo_key || '',model: cfg.byo_model || extra.model || 'gpt-4o-mini',models,@@@ -103,9 +98,13 @@ function byoProvider(cfg) {};}--/** Today's shared-key spend on AI viewers across ALL channels (global viewers cap). */++/**++ * Today's spend of the site's AI on AI viewers across ALL channels (global viewers cap): every row but a streamer's++ * own key. Runs on OpenVibe.AI are recorded as 'openvibe-ai' (the old direct calls were 'shared'), so counting only++ * 'shared' stopped counting anything once AI_SERVICE=remote went live.++ */function globalViewerSpendToday() {-- try { return db.get("SELECT COALESCE(SUM(cost_usd),0) AS c FROM ai_usage WHERE source = ? AND COALESCE(provider,'shared') = 'shared' AND created_at >= date('now')", [SOURCE])?.c || 0; } catch { return 0; }++ try { return db.get("SELECT COALESCE(SUM(cost_usd),0) AS c FROM ai_usage WHERE source = ? AND COALESCE(provider,'shared') <> 'byo' AND created_at >= date('now')", [SOURCE])?.c || 0; } catch { return 0; }}/**@@@ -129,11 +129,14 @@ router.post('/byo/test', requireAuth, async (req, res) => {// 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 provider = budget.byoProvider(cfg);++ if (!provider) return res.json({ ok: false, error: 'Link a Network account to use your own key' });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 r = await ai.llm.complete({ role: 'chat', user: 'Reply with the single word: ok', maxTokens: 5, temperature: 0, timeoutMs: 15000, kind: 'status_check', ownerUserId: req.user.id, provider });++ return res.json(r && r.text ? { ok: true, model: r.model, latencyMs: 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 });++ // A key typed but not saved yet (so not in OpenVibe.AI): llm.testProvider, the one direct provider call left.++ const override = budget.byoProvider({ ...cfg, byo_in_ai: 0, byo_key: typedKey ? 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 }); }@@@ -15,7 +15,7 @@* (recent XP + beef wins).* persona → AI "character select" bio written from their TRANSCRIPTS, cached 24 h.* quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.-- * image → optional AI character portrait drawn from their own stream frames.++ * image → a character portrait drawn earlier from their own stream frames (no new ones).** Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).*/@@@ -33,7 +33,6 @@ const ACTIVE_DAYS = 45;const STATS_WINDOW_DAYS = 90;const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;--const IMAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;const RATING_MIN = 40;const MIN_QUOTE_LINES = 20;const TALK_BONUS_MAX = 12;@@@ -113,10 +112,7 @@ function boolSetting(key, fallback = false) {}function arenaEnabled() { return boolSetting('arena_enabled', true); }function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }--function imageGenAvailable() {-- if (!aiOn() || !boolSetting('ai_image_enabled', false)) return false;-- try { return llm.resolveProvider('vision').kind === 'openai'; } catch { return false; }--}++function imageGenAvailable() { return false; } // see "Image" below// ── Raw stats ────────────────────────────────────────────────@@@ -388,140 +384,19 @@ async function generateQuotes(userId, { force = false } = {}) {return result;}--// ── Image (AI, optional) ─────────────────────────────────────++// ── Image ────────────────────────────────────────────────────++// Portraits drawn earlier stay on the cards; no new ones are drawn. They came from the site's image++// provider called with the shared key, and Live calls no model provider itself any more (roadmap++// WS-O task 2); OpenVibe.AI has no image workflow yet.--function imageIsFresh(row) {-- if (!row || !row.image_path || !row.image_generated_at) return false;-- if (!fs.existsSync(path.join(ARENA_DIR, path.basename(row.image_path)))) return false;-- return Date.now() - Date.parse(row.image_generated_at + 'Z') < IMAGE_TTL_MS;--}function imageUrlFor(row) {if (!row || !row.image_path) return null;const base = path.basename(row.image_path);return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;}--async function latestThumbnailFor(userId) { const r = await referenceImagesFor(userId); return r.length ? r[0] : null; }--/**-- * Up to REF_MAX real frames of THIS streamer's streams, newest first: the live thumbnail, AI-moment-- * frames the vision job persisted (data/ai-moments/<streamId>/<offset>.jpg), VOD thumbnails. Local-- * paths or URLs. These are what the portrait is drawn FROM, so every fighter looks like their own-- * stream — their setup, their lighting, their gear, their face — not a generic hero.-- */--const REF_MAX = 3;--async function referenceImagesFor(userId) {-- const out = [];-- const push = (p) => { if (p && !out.includes(p) && out.length < REF_MAX) out.push(p); };-- try {-- const live = db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]);-- if (live) {-- const thumbs = require('../media-proxy/live-thumbs');-- const url = thumbs.getCurrentLiveThumbnailUrl(live.id);-- if (url) { const local = paths.data('live-thumbs', path.basename(url)); if (fs.existsSync(local)) push(local); else if (url.startsWith('http')) push(url); }-- }-- } catch { /* */ }-- try {-- const momentsDir = paths.dir('AI_MOMENTS_PATH', 'ai-moments');-- const streams = db.all(`SELECT id FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 6`, [userId]);-- for (const s of streams) {-- const dir = path.join(momentsDir, String(s.id));-- if (!fs.existsSync(dir)) continue;-- // Prefer frames the vision model described as showing a person/face/reaction (they make better portraits).-- const memRows = db.all(`SELECT offset_seconds, description, thumbnail_url FROM stream_memories WHERE stream_id = ? ORDER BY CASE WHEN LOWER(description) LIKE '%person%' OR LOWER(description) LIKE '%man %' OR LOWER(description) LIKE '%woman%' OR LOWER(description) LIKE '%face%' OR LOWER(description) LIKE '%wearing%' OR LOWER(description) LIKE '%headphones%' THEN 0 ELSE 1 END, id DESC LIMIT 4`, [s.id]);-- for (const m of memRows) { const f = m.thumbnail_url && m.thumbnail_url.includes('/data/ai-moments/') ? path.join(momentsDir, ...m.thumbnail_url.split('/data/ai-moments/')[1].split('/')) : path.join(dir, `${m.offset_seconds}.jpg`); if (fs.existsSync(f)) push(f); }-- if (out.length >= REF_MAX) break;-- }-- } catch { /* */ }-- // Media-hosted thumbnails of their streams (what prod actually has: https://openvibe.media/t/vod-….jpg).-- try { for (const r of db.all(`SELECT thumbnail_url FROM streams WHERE user_id = ? AND thumbnail_url LIKE 'http%' ORDER BY started_at DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }-- try { for (const r of db.all(`SELECT m.thumbnail_url FROM stream_memories m WHERE m.user_id = ? AND m.thumbnail_url LIKE 'http%' AND (LOWER(m.description) LIKE '%person%' OR LOWER(m.description) LIKE '%face%' OR LOWER(m.description) LIKE '%wearing%' OR LOWER(m.description) LIKE '%headphones%') ORDER BY m.id DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }-- // Their newest public VODs' thumbnails, from OpenVibe.Media.-- if (out.length < REF_MAX) {-- try {-- const media = require('../media-client');-- for (const v of await require('../media-proxy/lookups').userVods(userId, { limit: 3 })) {-- const url = media.publicUrl(v.thumbnail_url);-- if (url && /^https?:\/\//i.test(url)) push(url);-- }-- } catch { /* */ }-- }-- return out;--}--async function loadImageBuffer(src) {-- try {-- if (/^https?:\/\//i.test(src)) { const r = await fetch(src, { signal: AbortSignal.timeout(15000) }); if (!r.ok) return null; return Buffer.from(await r.arrayBuffer()); }-- return fs.readFileSync(src);-- } catch { return null; }--}--const SCENE_SYSTEM = 'Describe this stream thumbnail as a SCENE for an illustrator in ≤ 60 words: setting, objects, lighting, colours, mood, what activity is happening. Do NOT describe any person\'s face, body, skin, hair, age, gender or identity — refer to a person only as "the host" if at all. Plain text only.';--const _imageInFlight = new Map();--async function generateImage(userId, { force = false } = {}) {++async function generateImage(userId) {ensureTables();-- const row = profileRow(userId);-- if (!force && imageIsFresh(row)) return imageUrlFor(row);-- if (!imageGenAvailable()) return imageUrlFor(row);-- if (_imageInFlight.has(userId)) return _imageInFlight.get(userId);-- const task = (async () => {-- const entry = loadRoster().byId[userId];-- const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);-- if (!persona) return null;-- let scene = '';-- const refSources = await referenceImagesFor(userId);-- const thumb = refSources.length ? null : await latestThumbnailFor(userId); // scene text only for the no-frames fallback-- if (thumb) { try { const d = await llm.complete({ role: 'vision', kind: 'arena_scene', source: 'arena', ownerUserId: userId, system: SCENE_SYSTEM, user: 'Describe the scene.', image: thumb, maxTokens: 120, temperature: 0.4, timeoutMs: 30000 }); scene = (d && d.text || '').trim(); } catch { scene = ''; } }-- const color = entry?.user?.profile_color || '#8b5cf6';-- const cs = Array.isArray(persona.custom_stats) ? persona.custom_stats.slice(0, 4).map(x => `${x.name} ${x.value}`).join(', ') : '';-- // Reference frames: the portrait is drawn FROM the streamer's own stream (image edit) whenever we-- // have frames; the text-only generation is the fallback for streamers with no frames yet.-- const refs = [];-- for (const src of refSources) { const buf = await loadImageBuffer(src); if (buf && buf.length > 4000) refs.push({ src, buf }); }-- const prompt = [-- refs.length ? `Turn the attached frames from this streamer's live stream into ONE fighting-game character-select portrait of them as "${persona.fighter_name}" — ${persona.title}. Keep what makes their stream recognisable: their setup, room, gear, lighting, clothing, silhouette, hair, headphones, camera angle, the vibe of the scene — exaggerated into a stylised caricature-hero (like a Street Fighter select screen), never a photo.` : `Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,-- `Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).${cs ? ` Their stats: ${cs}.` : ''}`,-- entry?.raw?.category ? `Costume and props inspired by ${String(entry.raw.category).replace(/[-_]/g, ' ')} streaming.` : '',-- !refs.length && scene ? `Background inspired by this scene: ${scene}` : (refs.length ? 'Background: their actual streaming environment from the frames, pushed into a dramatic arena lighting.' : 'Background: dark neon arena.'),-- `Colour palette led by ${color}. Bold comic-book line art, dramatic rim light, dynamic pose, three-quarter view, waist up.`,-- 'No text, no letters, no logos, no watermark, no UI overlays.',-- ].filter(Boolean).join(' ');-- const p = llm.resolveProvider('vision');-- const model = String(setting('ai_image_model', 'gpt-image-1'));-- const quality = String(setting('ai_image_quality', 'low'));-- const started = Date.now();-- let b64 = null;-- try {-- let res;-- if (refs.length && !/^dall-e/i.test(model)) {-- // images/edits: the model sees the real frames and restyles them.-- const fd = new FormData();-- fd.append('model', model); fd.append('prompt', prompt); fd.append('n', '1'); fd.append('size', '1024x1024'); fd.append('quality', quality);-- for (const r of refs) fd.append('image[]', new Blob([r.buf], { type: 'image/jpeg' }), path.basename(String(r.src)).replace(/[^a-z0-9._-]/gi, '_') || 'frame.jpg');-- res = await fetch(`${p.baseUrl}/images/edits`, { method: 'POST', headers: { ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) }, body: fd, signal: AbortSignal.timeout(180000) });-- } else {-- const body = { model, prompt, n: 1, size: '1024x1024' };-- if (/^dall-e/i.test(model)) body.response_format = 'b64_json'; else body.quality = quality;-- res = await fetch(`${p.baseUrl}/images/generations`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });-- }-- const j = await res.json().catch(() => ({}));-- if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);-- const item = j.data && j.data[0];-- if (item?.b64_json) b64 = item.b64_json;-- else if (item?.url) { const r2 = await fetch(item.url, { signal: AbortSignal.timeout(60000) }); b64 = Buffer.from(await r2.arrayBuffer()).toString('base64'); }-- if (!b64) throw new Error('no image in response');-- } catch (err) {-- console.warn(`[Arena] image generation failed for user ${userId}:`, err.message);-- db.run('INSERT INTO arena_profiles (user_id, image_error, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET image_error = excluded.image_error, updated_at = CURRENT_TIMESTAMP', [userId, err.message.slice(0, 300)]);-- return imageUrlFor(row);-- }-- const file = `u${userId}-${Date.now().toString(36)}.png`;-- fs.writeFileSync(path.join(ARENA_DIR, file), Buffer.from(b64, 'base64'));-- if (row?.image_path) { try { fs.unlinkSync(path.join(ARENA_DIR, path.basename(row.image_path))); } catch { /* */ } }-- db.run(`INSERT INTO arena_profiles (user_id, image_path, image_prompt, image_model, image_generated_at, image_error, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP)-- ON CONFLICT(user_id) DO UPDATE SET image_path = excluded.image_path, image_prompt = excluded.image_prompt, image_model = excluded.image_model, image_generated_at = CURRENT_TIMESTAMP, image_error = NULL, updated_at = CURRENT_TIMESTAMP`, [userId, file, prompt, model]);-- try { db.recordAiUsage({ kind: 'arena_image', model, cost_usd: Number(setting('ai_image_cost_usd', 0.011)) || 0, owner_user_id: userId, source: 'arena', role: 'image', provider: 'shared', latency_ms: Date.now() - started }); } catch { /* */ }-- console.log(`[Arena] portrait generated for user ${userId} (${model}, ${refs.length ? `from ${refs.length} of their own frames` : 'text only'}, ${Date.now() - started} ms)`);-- return `/data/arena/${file}`;-- })().finally(() => _imageInFlight.delete(userId));-- _imageInFlight.set(userId, task);-- return task;++ return imageUrlFor(profileRow(userId));}// ── Fighter cards ────────────────────────────────────────────@@@ -560,7 +435,7 @@ async function getFighter(usernameOrId, { generate = true } = {}) {try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); }}const card = cardFor(user.id, roster, { includeQuotes: true });-- card.image_pending = !card.image_url && _imageInFlight.has(user.id);++ card.image_pending = false;card.image_generation = imageGenAvailable() ? 'ai' : 'off';try { card.beefs = require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }return card;@@@ -659,7 +534,7 @@ function status() {let beefs = {}, moments = 0;try { beefs = db.get(`SELECT SUM(status = 'open') AS open, SUM(status = 'resolved') AS resolved FROM arena_beefs`) || {}; moments = db.get(`SELECT COUNT(*) AS n FROM arena_mic_moments WHERE created_at >= datetime('now', '-1 day')`)?.n || 0; } catch { /* */ }return {-- mode: 'battle-cam', enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,++ mode: 'battle-cam', enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: null,roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, mic_moments_24h: moments, live_fighters: liveFighters().length,@@@ -1098,38 +1098,33 @@ function initDb() {// could delete, which would have multiplied every balance by 100 again on the next boot.// AI analysis subsystem (configured in openvibe.network/admin → AI). Master switch-- // OFF by default so no API calls (or cost) happen until an admin enables it.++ // OFF by default so no AI runs (or cost) happen until an admin enables it. Every call is a++ // run on OpenVibe.AI, which holds the provider key, the models and their prices.const aiSeeds = [['ai_enabled', 'false', 'Master switch: enable AI analysis (pastes + stream memories)', 'boolean'],-- ['ai_provider', 'anthropic', 'AI provider: anthropic | openai', 'string'],-- ['ai_api_key', '', 'AI API key (Anthropic or OpenAI-compatible)', 'string'],-- ['ai_base_url', '', 'Optional custom base URL (OpenAI-compatible gateway). Blank = provider default', 'string'],-- ['ai_model', 'claude-sonnet-5', 'Vision-capable model id (e.g. claude-sonnet-5)', 'string'],['ai_paste_analysis_enabled', 'true', 'Analyze image + text pastes (when AI is enabled)', 'boolean'],['ai_stream_memory_enabled', 'false', 'Periodically analyze live-stream thumbnails into timestamped memories', 'boolean'],['ai_stream_capture_interval_sec', '120', 'Seconds between live-stream AI memory captures', 'number'],['ai_transcription_enabled', 'true', 'Transcribe live-stream/clip/VOD audio into memories — FREE, runs locally via whisper.cpp (no API/cost). Requires whisper.cpp installed on the server', 'boolean'],['ai_timeline_enabled', 'false', 'CONTINUOUS audio timeline — transcribes the WHOLE live stream (not a 12s sample every 2min) and detects non-speech sounds, into a searchable timestamped timeline. FREE/local, but uses noticeably more CPU than sampling', 'boolean'],['ai_max_cost_usd_per_day', '0', 'Daily AI spend cap in USD (0 = no cap)', 'number'],-- ['ai_input_cost_per_mtok', '3.0', 'Estimated input cost per million tokens (for cost breakdown)', 'number'],-- ['ai_output_cost_per_mtok', '15.0', 'Estimated output cost per million tokens (for cost breakdown)', 'number'],-- // Per-role model routing (blank = use ai_model). chat = short bot lines, vision = frame/image-- // analysis (must be vision-capable), director = the AI-viewers planner (structured JSON),-- // summary = memories/overviews/insight folds.-- ['ai_model_chat', '', 'Model for short chat-style generations (AI viewers lines). Blank = ai_model', 'string'],-- ['ai_model_vision', '', 'Model for image/frame analysis (must support vision). Blank = ai_model', 'string'],-- ['ai_model_director', '', 'Model for the AI-viewers director (plans several lines per call, JSON output). Blank = ai_model', 'string'],-- ['ai_model_summary', '', 'Model for summaries/overviews/memory folds. Blank = ai_model', 'string'],-- ['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"gpt-5-mini":{"in":0.25,"out":2,"cached":0.025},"gpt-5":{"in":1.25,"out":10,"cached":0.125},"gpt-4o-mini":{"in":0.15,"out":0.6,"cached":0.075},"gpt-4.1-mini":{"in":0.4,"out":1.6,"cached":0.1},"claude-haiku":{"in":0.8,"out":4,"cached":0.08},"claude-sonnet":{"in":3,"out":15,"cached":0.3}}', 'USD per 1M tokens by model-id prefix: {"<model>":{"in","out","cached"}}. Longest prefix wins; missing → ai_input/output_cost_per_mtok', 'json'],['ai_viewers_enabled', 'true', 'Kill switch for the AI chat viewers feature (all channels)', 'boolean'],['ai_viewers_max_roster', '12', 'Max AI viewers per channel', 'number'],['ai_viewers_max_lines_per_min', '12', 'Hard ceiling on bot lines per minute per channel', 'number'],-- ['ai_viewers_global_cap_usd_per_day', '0', 'Daily USD cap for ALL AI-viewer spend on the shared key (0 = none)', 'number'],++ ['ai_viewers_global_cap_usd_per_day', '0', 'Daily USD cap for ALL AI-viewer spend on the site’s AI, not streamers’ own keys (0 = none)', 'number'],['ai_viewers_default_settings_json', '{}', 'Admin defaults for per-channel AI viewer settings (overrides built-in defaults)', 'json'],];const seedAi = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");for (const [k, v, d, t] of aiSeeds) seedAi.run(k, v, d, t);try { database.exec("DELETE FROM site_settings WHERE key = 'ai_viewers_engine'"); } catch { /* */ }++ // Live's own provider path is gone (roadmap WS-O task 2): drop the shared key and the settings only it read++ // (provider, base URL, models per role, prices, the Arena's image provider). The admin page lists only rows++ // that exist, so they leave it too.++ try {++ database.exec(`DELETE FROM site_settings WHERE key IN ('ai_api_key', 'ai_provider', 'ai_base_url', 'ai_model', 'ai_model_chat', 'ai_model_vision',++ 'ai_model_director', 'ai_model_summary', 'ai_pricing_json', 'ai_input_cost_per_mtok', 'ai_output_cost_per_mtok',++ 'ai_image_enabled', 'ai_image_model', 'ai_image_quality', 'ai_image_cost_usd')`);++ } catch { /* */ }// PowerChat monetization (donations/tips). App-level OAuth client + webhook secret// are configured here by the owner; each streamer then connects their own PowerChat@@@ -15,8 +15,9 @@* - an English message in a non-English channel → the channel's language* (so the streamer can read his own chat without anyone typing Japanese)*-- * Every call is best-effort and metered through ai/llm.js (shared key, daily budget); when-- * AI is off it all silently returns null and the site behaves exactly as before.++ * Every translation is a run of OpenVibe.AI's live.translate workflow (ai/ai-service.js), gated on++ * ai/llm.js's master switch; when AI is off it all silently returns null and the site behaves++ * exactly as before.*/'use strict';const crypto = require('crypto');@@@ -138,7 +139,7 @@ function siteEnabled() {return !(String(v) === 'false' || String(v) === '0');} catch { return true; }}--function available() { return !!(llm && llm.isEnabled && llm.isEnabled() && siteEnabled()); }++function available() { return !!(llm && aiService && llm.isEnabled && llm.isEnabled() && siteEnabled()); }let _tableReady = false;function _ensureTable() {@@@ -193,37 +194,11 @@ async function translate(text, { from = 'auto', to = 'en', context = 'chat', maxconst ok = await _slot();if (!ok) return null;try {-- if (aiService && aiService.enabled()) {-- // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.translate).-- const o = await aiService.structured('live.translate', { text: String(text).trim(), from, to, context, max_tokens: maxTokens || undefined });-- if (o && o.unchanged) { _memSet(key, ''); return null; }-- const remote = o && typeof o.text === 'string' ? o.text.trim() : '';-- if (!remote) return null;-- _memSet(key, remote);-- try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, remote]); } catch { /* */ }-- return remote;-- }-- const src = from === 'auto' ? 'the source language' : langName(from);-- const kind = context === 'bio' ? 'a streamer\'s profile bio'-- : context === 'speech' ? 'lines of live-stream speech (a casual gamer talking to his chat)'-- : 'a live-stream chat message';-- const system = [-- `You translate ${kind} from ${src} to ${langName(to)}.`,-- 'Rules: keep the tone, slang, jokes, profanity and emoji as they are; keep names, URLs, :emotes:, !commands and @mentions unchanged;',-- 'never add explanations, notes, quotes or brackets; if it is already in the target language, return it unchanged.',-- context === 'speech' ? 'Input may contain several lines separated by newlines — return exactly the same number of lines, in order.' : '',-- 'Output ONLY the translation.',-- ].filter(Boolean).join(' ');-- const r = await llm.complete({-- role: 'chat', kind: 'translate', source: `translate:${context}`,-- system, user: String(text).trim(),-- maxTokens: Math.max(40, Math.min(maxTokens || 600, Math.round(String(text).length * 2.2) + 60)),-- temperature: 0.2, timeoutMs: 15000, retries: 0,-- });-- const out = r && typeof r.text === 'string' ? r.text.trim().replace(/^["“]|["”]$/g, '') : '';++ // The prompt lives in OpenVibe.AI (workflow live.translate).++ const o = await aiService.structured('live.translate', { text: String(text).trim(), from, to, context, max_tokens: maxTokens || undefined });++ if (o && o.unchanged) { _memSet(key, ''); return null; }++ const out = o && typeof o.text === 'string' ? o.text.trim() : '';if (!out) return null;-- // A translation identical to the input means it was already in the target language.-- if (out === String(text).trim()) { _memSet(key, ''); return null; }_memSet(key, out);try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, out]); } catch { /* */ }return out;@@@ -113,14 +113,6 @@ async function gather(streamId) {}// ── The write-up ─────────────────────────────────────────────--const SCHEMA = { name: 'stream_recap', schema: { type: 'object', additionalProperties: false, required: ['headline', 'summary', 'moment', 'tags', 'grade'], properties: {-- headline: { type: 'string', description: '≤ 70 chars, like a sports-page headline about this stream, no quotes, no emojis' },-- summary: { type: 'string', description: '2–3 sentences, ≤ 420 chars, what happened and how it went, concrete, warm, second person is fine' },-- moment: { type: 'string', description: '≤ 160 chars, the single moment of the night (from the mic lines, chat spike, a clip title or the transcript) — or empty string' },-- tags: { type: 'array', items: { type: 'string' }, description: '3 short vibe tags, 1–2 words each, lowercase' },-- grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },--} } };--function templateWriteup(g) {const s = g.stream, name = g.streamer.display_name;const bits = [];@@@ -146,17 +138,8 @@ async function aiWriteup(g) {mic_lines: g.mic.map(m => m.text), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,what_was_said: g.speech.sample || '(no transcript)',};-- const system = `You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.`;-- let r = null;-- let out = null;-- const aiService = require('../ai/ai-service');-- if (aiService.enabled()) {-- // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.stream.recap).-- out = await aiService.structured('live.stream.recap', { facts }, { target: { service: 'live', type: 'stream', id: String(g.stream.id) }, meter: { kind: 'stream_recap', role: 'summary', source: 'recap', ownerUserId: g.streamer.id } });-- } else {-- try { r = await llm.complete({ role: 'summary', kind: 'stream_recap', source: 'recap', ownerUserId: g.streamer.id, system, user: JSON.stringify(facts), json: SCHEMA, maxTokens: 500, temperature: 0.8, timeoutMs: 40000 }); } catch (e) { console.warn('[Recap] model call failed:', e.message); return null; }-- out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : null));-- }++ // The prompt lives in OpenVibe.AI (workflow live.stream.recap).++ const out = await require('../ai/ai-service').structured('live.stream.recap', { facts }, { target: { service: 'live', type: 'stream', id: String(g.stream.id) }, meter: { kind: 'stream_recap', role: 'summary', source: 'recap', ownerUserId: g.streamer.id } });if (!out || !out.headline || !out.summary) return null;return { headline: String(out.headline).trim().slice(0, 90), summary: String(out.summary).trim().slice(0, 500), moment: String(out.moment || '').trim().slice(0, 200), tags: (Array.isArray(out.tags) ? out.tags : []).map(t => String(t).trim().toLowerCase()).filter(Boolean).slice(0, 4), grade: ['S', 'A', 'B', 'C'].includes(out.grade) ? out.grade : 'B' };}@@@ -22,7 +22,7 @@ const vm = require('vm');const tmp = path.join(os.tmpdir(), `ov-ai-optout-${process.pid}.db`);process.env.DB_PATH = tmp;process.env.NODE_ENV = 'test';--delete process.env.AI_SERVICE;++delete process.env.AI_SERVICE; // on (every AI call is a run on OpenVibe.AI, stubbed below)const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };console.warn = () => {};@@@ -75,7 +75,9 @@ const llm = require('../server/ai/llm');const modelCalls = [];llm.isEnabled = () => true;llm.withinBudget = () => true;--llm.complete = async (o) => { modelCalls.push(o); return { json: { headline: 'What a night', summary: 'Chat was loud.', moment: '', tags: ['loud'], grade: 'A' } }; };++llm.complete = async (o) => { modelCalls.push(o); return null; };++// The after-show report is the live.stream.recap workflow on OpenVibe.AI.++require('../server/ai/ai-service').structured = async (workflow, input) => { modelCalls.push({ workflow, input }); return { headline: 'What a night', summary: 'Chat was loud.', moment: '', tags: ['loud'], grade: 'A' }; };const autoClip = require('../server/ai/auto-clip-job');const moments = require('../server/ai/ai-moments-job');nnew file mode 100644@@@ -0,0 +1,267 @@++'use strict';++/**++ * Live calls no model provider itself and keeps no shared provider key (roadmap WS-O task 2, shims++ * C-20 and C-21). Against a stub Network (token endpoint), a stub OpenVibe.AI and a stub "provider"++ * that must never be reached, with every outbound connection and fetch recorded:++ * - AI off (AI_SERVICE=off, or the admin switch ai_enabled off): every AI entry point answers null++ * (or its template / "no AI" value) and nothing at all goes out;++ * - AI on: llm.complete only ever talks to OpenVibe.AI, for the site's AI and for a streamer's key++ * stored there; a raw key / base URL override answers null with one warning and its address is++ * never contacted; a streamer's key typed but not saved is tested through the egress guard;++ * - the shared-key settings are not seeded, and a leftover ai_api_key row is deleted at boot;++ * - no file in server/ builds a provider request except llm.testProvider.++ *++ * node test/ai-remote-only.test.js++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const net = require('net');++const crypto = require('crypto');++const { spawnSync } = require('child_process');++const { serviceAuth } = require('openvibe-contracts');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-ai-remote-'));++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 = 'off';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++const warnings = [];++console.warn = (...a) => { warnings.push(a.join(' ')); };++++// ── Every connection and fetch this process makes ──++const outbound = [];++const realConnect = net.Socket.prototype.connect;++net.Socket.prototype.connect = function (...args) {++ const o = Array.isArray(args[0]) ? args[0][0] : args[0];++ outbound.push(o && typeof o === 'object' ? `${o.host || o.path || '?'}:${o.port || ''}` : String(o));++ return realConnect.apply(this, args);++};++const realFetch = globalThis.fetch;++globalThis.fetch = (url, opts) => { outbound.push(`fetch ${url}`); return realFetch(url, opts); };++++// ── Stubs: Network (service tokens), OpenVibe.AI (runs), a provider nobody may call ──++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++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.run.read'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));++ });++});++const aiRuns = [];++const ai = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const body = raw ? JSON.parse(raw) : null;++ aiRuns.push(body);++ const output = body.workflow === 'live.translate' ? { text: 'hello everyone', unchanged: false }++ : body.workflow === 'live.paste.summarize_text' ? { description: 'A shopping list.' }++ : { text: 'ok from AI', json: null };++ res.statusCode = 201;++ res.setHeader('Content-Type', 'application/json');++ res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: body.workflow }, output, usage: { tokens_in: 12, tokens_out: 3, cost_usd: 0.0002 }, provenance: { model: 'gpt-5-nano' } } }));++ });++});++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4'; // the streamer's Network subject++let providerHits = 0;++const provider = http.createServer((req, res) => { providerHits++; res.setHeader('Content-Type', 'application/json'); res.end('{"choices":[{"message":{"content":"direct"}}]}'); });++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 4).join('\n ')); }++}++++(async () => {++ for (const s of [network, ai, provider]) await new Promise((r) => s.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 providerUrl = `http://127.0.0.1:${provider.address().port}/v1`;++++ const db = require('../server/db/database');++ db.initDb();++ const d = db.getDb();++ const aiService = require('../server/ai/ai-service');++ const llm = require('../server/ai/llm');++ const analysis = require('../server/ai/ai-analysis');++ const translate = require('../server/i18n/translate');++ const recap = require('../server/recap/recap');++ const budget = require('../server/ai/viewers/budget');++ const arena = require('../server/arena/arena-service');++ const media = require('../server/media-client');++ media.listVods = async () => ({ vods: [] });++ media.listClips = async () => ({ clips: [] });++++ d.prepare("INSERT INTO users (id, username, display_name, password_hash) VALUES (40, 'kai', 'Kai', '$sso$')").run();++ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (40, 'network', '400', 'kai', ?)").run(SUBJECT);++ db.ensureChannel(40);++ const ch = db.getChannelByUserId(40);++ const sid = Number(db.createStream({ user_id: 40, channel_id: ch.id, title: 'Soldering at night', protocol: 'webrtc' }).lastInsertRowid);++ db.addStreamMemory({ stream_id: sid, user_id: 40, offset_seconds: 60, description: 'Kai solders a board' });++ db.endStream(sid);++ d.prepare('UPDATE streams SET duration_seconds = 3600 WHERE id = ?').run(sid);++ const frame = `data:image/png;base64,${'iVBORw0KGgo'.padEnd(400, 'A')}`;++++ quiet('AI is remote only');++++ await check('the shared-key settings are not seeded, and a leftover ai_api_key row is deleted at boot', () => {++ const keysNow = d.prepare("SELECT key FROM site_settings WHERE key LIKE 'ai\\_%' ESCAPE '\\'").all().map((r) => r.key);++ for (const k of ['ai_api_key', 'ai_provider', 'ai_base_url', 'ai_model', 'ai_model_chat', 'ai_pricing_json', 'ai_input_cost_per_mtok']) assert.ok(!keysNow.includes(k), `${k} is seeded`);++ assert.ok(keysNow.includes('ai_enabled') && keysNow.includes('ai_max_cost_usd_per_day'));++ db.setSetting('ai_api_key', 'sentinel-not-a-secret-leftover');++ db.initDb();++ assert.strictEqual(db.getSettingRow('ai_api_key'), undefined);++ });++++ // Both switches: AI_SERVICE=off with the admin switch on, then the admin switch off with AI_SERVICE on.++ for (const [label, setup] of [++ ['AI_SERVICE=off', () => { process.env.AI_SERVICE = 'OFF'; db.setSetting('ai_enabled', 'true'); }],++ ['ai_enabled off', () => { delete process.env.AI_SERVICE; db.setSetting('ai_enabled', 'false'); }],++ ]) {++ await check(`${label}: every AI entry point answers null (or no AI) and nothing goes out`, async () => {++ setup();++ db.upsertChannelAiConfig(40, { enabled: 1, use_shared_key: 1 });++ outbound.length = 0;++ assert.strictEqual(llm.isEnabled(), false);++ assert.strictEqual(await llm.complete({ role: 'chat', kind: 'chat_global', user: 'hi' }), null);++ assert.strictEqual(await analysis.analyzeImagePaste(frame, 'a screenshot'), null);++ assert.strictEqual(await analysis.analyzeTextPaste('eggs, milk, bread', 'list'), null);++ assert.strictEqual(await analysis.analyzeStreamFrame(frame), null);++ assert.strictEqual(await analysis.summarizeStreamMemories([{ description: 'Kai solders a board' }], sid), null);++ assert.strictEqual(await analysis.generateStreamerOverview(40), null);++ assert.strictEqual(await analysis.summarizeText('say something', 50, 'chat_global'), null);++ assert.strictEqual(await analysis.viewerComplete({ user: 'hi', ownerUserId: 40 }), null);++ assert.strictEqual(await analysis.generateVodOverview({ id: 1, stream_id: sid }), null);++ assert.strictEqual(await analysis.generateClipOverview({ id: 1, stream_id: sid }), null);++ assert.strictEqual((await analysis.testStatus({ probe: true })).ok, false);++ assert.strictEqual(await translate.translate('みなさんこんにちは', { from: 'ja', to: 'en' }), null);++ assert.deepStrictEqual(await translate.translateLines(['みなさん'], { from: 'ja' }), [null]);++ assert.strictEqual(await budget.generate(40, { user: 'hi' }), null);++ const r = await recap.buildRecap(sid);++ assert.ok(r && r.write && r.write.headline, 'the template report still ships');++ assert.strictEqual(r.ai, false);++ assert.strictEqual(arena.imageGenAvailable(), false);++ assert.strictEqual(await arena.generateImage(40), null, 'no portrait is drawn');++ if (label === 'AI_SERVICE=off') {++ // A streamer's key in OpenVibe.AI is not used either while the service is off.++ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { credentialSubject: SUBJECT } }), null);++ assert.strictEqual(await aiService.structured('live.translate', { text: 'x', to: 'en' }), null);++ }++ assert.deepStrictEqual(outbound, [], 'nothing went out');++ });++ }++++ await check('media analysis: a recording with speech gets no overview while AI is off, and nothing goes out', async () => {++ const wav = path.join(tmp, 'speech.wav');++ const made = spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'anullsrc=r=16000:cl=mono', '-t', '1', wav], { stdio: 'ignore' });++ if (made.status !== 0 || !fs.existsSync(wav)) { quiet(' (ffmpeg unavailable: skipped)'); return; }++ const transcribe = require('../server/ai/transcribe');++ transcribe.available = () => true;++ transcribe.transcribeMediaDetailed = async () => ({ text: 'welcome back everyone', segments: [{ start: 0, end: 1, text: 'welcome back everyone' }], ok: true });++ process.env.AI_SERVICE = 'off';++ db.setSetting('ai_enabled', 'true');++ outbound.length = 0;++ const r = await require('../server/ai/media-analysis').analyzeMedia(wav, { userId: 40 });++ assert.strictEqual(r.transcript, 'welcome back everyone', 'the local transcript is kept');++ assert.strictEqual(r.overview, null);++ assert.deepStrictEqual(outbound, []);++ });++++ await check('AI on: llm.complete is a run on OpenVibe.AI, metered as openvibe-ai; structured analyses and translation too', async () => {++ delete process.env.AI_SERVICE;++ db.setSetting('ai_enabled', 'true');++ assert.strictEqual(aiService.enabled(), true, 'on unless AI_SERVICE=off');++ process.env.AI_SERVICE = 'remote';++ assert.strictEqual(aiService.enabled(), true, 'the old value still means on');++ outbound.length = 0; aiRuns.length = 0;++ const r = await llm.complete({ role: 'chat', kind: 'chat_global', source: 'chat_ai', user: 'sum up chat', ownerUserId: 40 });++ assert.strictEqual(r.text, 'ok from AI');++ assert.strictEqual(r.provider, 'openvibe-ai');++ assert.strictEqual(aiRuns[0].workflow, 'live.chat.insight');++ const row = d.prepare("SELECT provider, owner_user_id, kind FROM ai_usage ORDER BY id DESC LIMIT 1").get();++ assert.deepStrictEqual([row.provider, row.owner_user_id, row.kind], ['openvibe-ai', 40, 'chat_global']);++ assert.deepStrictEqual(await analysis.analyzeTextPaste('eggs, milk, bread', 'list'), { description: 'A shopping list.', tags: [] });++ assert.strictEqual(await translate.translate('みなさんこんばんは', { from: 'ja', to: 'en' }), 'hello everyone');++ assert.deepStrictEqual(aiRuns.map((x) => x.workflow), ['live.chat.insight', 'live.paste.summarize_text', 'live.translate']);++ const aiPorts = [String(ai.address().port), String(network.address().port)];++ assert.ok(outbound.some((o) => o.includes(`:${ai.address().port}`)), 'the recorder sees the calls to OpenVibe.AI');++ const elsewhere = outbound.filter((o) => !aiPorts.some((p) => o.endsWith(`:${p}`) || o.includes(`:${p}/`)));++ assert.deepStrictEqual(elsewhere, [], 'only OpenVibe.AI and the Network token endpoint were contacted');++ });++++ await check('AI on: a streamer\'s key stored in OpenVibe.AI runs there with credential { subject }, metered as byo', async () => {++ aiRuns.length = 0;++ const r = await llm.complete({ role: 'chat', kind: 'ai_viewers', source: 'ai_viewers', user: 'hi', ownerUserId: 40, provider: { credentialSubject: SUBJECT } });++ assert.strictEqual(r.provider, 'byo');++ assert.deepStrictEqual(aiRuns[0].credential, { subject: SUBJECT });++ assert.strictEqual(d.prepare('SELECT provider FROM ai_usage ORDER BY id DESC LIMIT 1').get().provider, 'byo');++ });++++ await check('AI on: a raw key / base URL override is never called and never falls back to the site\'s AI (one warning)', async () => {++ outbound.length = 0; aiRuns.length = 0; warnings.length = 0;++ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { apiKey: 'sk-EXAMPLE-0000', baseUrl: providerUrl, model: 'm' } }), null);++ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { baseUrl: providerUrl } }), null);++ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { apiKey: 'sk-EXAMPLE-0000' } }), null);++ assert.strictEqual(providerHits, 0);++ assert.deepStrictEqual(outbound, [], 'not the provider, not OpenVibe.AI');++ assert.strictEqual(aiRuns.length, 0);++ assert.strictEqual(warnings.filter((w) => /raw provider key/.test(w)).length, 1, 'warned once');++ // Viewers of a channel whose key never moved to OpenVibe.AI stay quiet.++ db.upsertChannelAiConfig(40, { enabled: 1, use_shared_key: 0, byo_key: 'sk-EXAMPLE-0000', byo_base_url: providerUrl, byo_in_ai: 0 });++ assert.strictEqual(budget.budgetStatus(40).reason, 'no_byo_key');++ assert.strictEqual(await budget.generate(40, { user: 'hi' }), null);++ assert.strictEqual(providerHits, 0);++ });++++ await check('a key typed but not saved is tested through the egress guard: an internal address is refused', async () => {++ const r = await llm.testProvider({ apiKey: 'sk-EXAMPLE-0000', baseUrl: providerUrl, model: 'gpt-4o-mini' });++ assert.strictEqual(r.ok, false);++ assert.strictEqual(providerHits, 0);++ assert.deepStrictEqual(await llm.testProvider({ baseUrl: '', apiKey: '' }), { ok: false, error: 'Enter your API key to test it' });++ assert.strictEqual((await llm.testProvider({ credentialSubject: 'usr_x' })).ok, false);++ });++++ await check('no file in server/ builds a provider request except llm.testProvider', () => {++ const PROVIDER_CALL = /anthropic-version|chat\/completions|images\/(generations|edits)|audio\/transcriptions/;++ const root = path.join(__dirname, '..', 'server');++ const hits = [];++ const walk = (dir) => {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const f = path.join(dir, e.name);++ if (e.isDirectory()) walk(f);++ else if (e.name.endsWith('.js')) {++ let src = fs.readFileSync(f, 'utf8');++ if (f.endsWith(path.join('ai', 'llm.js'))) {++ const start = src.indexOf('async function testProvider(');++ const end = src.indexOf('\n}\n', start);++ assert.ok(start > 0 && end > start, 'llm.testProvider exists');++ src = src.slice(0, start) + src.slice(end);++ }++ if (PROVIDER_CALL.test(src)) hits.push(path.relative(root, f));++ }++ }++ };++ walk(root);++ assert.deepStrictEqual(hits, []);++ const exported = Object.keys(llm).sort();++ for (const gone of ['resolveProvider', 'modelForRole', 'priceFor', 'estimateCost', 'defaultModel']) assert.ok(!exported.includes(gone), `llm.${gone} is gone`);++ assert.ok(!fs.existsSync(path.join(root, 'ai', 'ai-provider.js')), 'the unused direct client is gone');++ });++++ for (const s of [network, ai, provider]) s.close();++ globalThis.fetch = realFetch;++ net.Socket.prototype.connect = realConnect;++ fs.rmSync(tmp, { recursive: true, force: true });++ quiet(failures ? `\n${failures} check(s) failed` : '\nai remote only: all checks passed');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -1,8 +1,8 @@'use strict';--// AI_SERVICE=remote (roadmap Wave 13): Live's AI goes to OpenVibe.AI as workflow runs with a service--// token for audience openvibe.ai. Against a stub Network (token endpoint) and a stub AI service:--// off by default; structured workflows return their output; llm.complete()-shaped calls become++// Live's AI goes to OpenVibe.AI as workflow runs with a service token for audience openvibe.ai (roadmap++// Wave 13; the only mode since WS-O task 2). Against a stub Network (token endpoint) and a stub AI service:++// off only with AI_SERVICE=off; structured workflows return their output; llm.complete()-shaped calls become// passthrough runs of the workflow that owns the feature, attributed to the streamer; synthetic// (stub-provider) answers, failed runs, quota refusals and an unreachable service are all "no// answer" (null); a still-running run is polled; a rejected token is refreshed once.@@@ -14,7 +14,7 @@ const { serviceAuth } = require('openvibe-contracts');process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--delete process.env.AI_SERVICE;++process.env.AI_SERVICE = 'off';const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });let tokenCalls = 0;@@@ -61,11 +61,18 @@ const run = (over = {}) => ({ id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succonst metered = [];svc.setRecorder((r, m) => metered.push({ run: r.id, ...m }));-- // 0. Off by default: nothing is sent.++ // 0. AI_SERVICE=off (any case): nothing is sent, not even a token request. Unset, or the old "remote", is on.assert.strictEqual(svc.enabled(), false);assert.strictEqual(await svc.structured('live.translate', { text: 'x', to: 'en' }), null);-- assert.strictEqual(calls.length, 0, 'default behaviour unchanged: no calls to OpenVibe.AI');++ assert.strictEqual(await svc.complete({ role: 'chat', user: 'x' }), null);++ process.env.AI_SERVICE = ' Off ';++ assert.strictEqual(svc.enabled(), false);++ assert.strictEqual(await svc.run('live.translate', { text: 'x' }), null);++ assert.strictEqual(calls.length, 0, 'AI off: no calls to OpenVibe.AI');++ assert.strictEqual(tokenCalls, 0, 'AI off: no token asked for');++ delete process.env.AI_SERVICE;++ assert.strictEqual(svc.enabled(), true, 'on unless AI_SERVICE=off');process.env.AI_SERVICE = 'remote';assert.strictEqual(svc.enabled(), true);@@@ -66,7 +66,6 @@ const SETTINGS = {kick_client_secret: 'sentinel-not-a-secret-kick',youtube_api_key: 'sentinel-not-a-secret-youtube',soundboard_101_api_key: 'sentinel-not-a-secret-soundboard',-- ai_api_key: 'sentinel-not-a-secret-ai',discord_webhook_url: 'https://discord.example.test/api/webhooks/1/sentinel-not-a-secret-discord',ops_alert_webhook_url: 'https://hooks.example.test/sentinel-not-a-secret-ops-setting',};@@@ -295,7 +295,7 @@ async function check(name, fn) {await call('POST', '/api/ai-viewers/byo/test', { byo_base_url: 'http://public.example.test/v1', byo_key: 'byo-test-key', byo_model: 'm' });assert.ok(publicHops.slice(before).includes('POST /v1/chat/completions'), 'a public BYO endpoint is still called, through the guard');});-- await check('AI viewers: a saved internal base URL is refused when the bots run, too (same call path)', async () => {++ await check('AI viewers: a saved base URL is never called when the bots run (llm.complete refuses a raw key/base URL)', async () => {await call('PUT', '/api/ai-viewers/config', { byo_base_url: `http://127.0.0.1:${p}/v1`, byo_key: 'byo-test-key', use_shared_key: 0 });const llm = require('../server/ai/llm');const budget = require('../server/ai/viewers/budget');@@@ -361,9 +361,7 @@ async function check(name, fn) {'server/monetization/cosmetics.js': 'legacy quest API (env)','server/openre/openre-client.js': 'OpenRe.Stream (configured)','server/ai/ai-service.js': 'OpenVibe.AI (configured)',-- 'server/ai/llm.js': 'the site\'s AI provider (owner setting); a streamer\'s BYO base URL goes through egress.postJson; images are Live\'s own frames',-- 'server/ai/ai-provider.js': 'unused network helpers; kept for its URL helpers',-- 'server/arena/arena-service.js': 'the site\'s image provider (owner setting) and Live\'s own frame URLs',++ 'server/ai/llm.js': 'images are Live\'s own frames; a streamer\'s typed provider address (testProvider) goes through egress.postJson','server/chat/routes.js': 'GIF providers (fixed hosts)','server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)','server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
@@ -16,13 +16,14 @@ ADMIN_PASSWORD=changeme123
@@ -16,13 +16,14 @@ ADMIN_PASSWORD=changeme123
# channels keep using the English model and live captions stay hidden.
# channels keep using the English model and live captions stay hidden.
# WHISPER_MODEL_MULTI=/home/openvibe/whisper.cpp/models/ggml-small.bin
# WHISPER_MODEL_MULTI=/home/openvibe/whisper.cpp/models/ggml-small.bin
-# OpenVibe.AI (roadmap Wave 13). AI_SERVICE=remote sends Live's shared-key AI calls (chat AI, moments,
-# paste/frame analysis, overviews, translation, recaps, slogans, arena, footer copy) to the AI service as
-# workflow runs, with a service token for audience openvibe.ai (grant ai.run.create for client `live`).
-# The admin ai_enabled switch and the local daily cap still apply; the provider key lives in the AI
-# service. Streamer BYO keys and local whisper.cpp transcription stay here. Unset = Live calls
-# providers itself, exactly as before.
-#AI_SERVICE=remote
+# OpenVibe.AI (roadmap Wave 13, WS-O task 2). Every AI call Live makes (chat AI, moments, paste/frame
+# analysis, overviews, translation, recaps, slogans, arena, AI viewers) is a workflow run on the AI
+# service, with a service token for audience openvibe.ai (grants ai.run.create, ai.run.read and
+# ai.credential.manage for client `live`). Provider keys, a streamer's own included, live in the AI
+# service; Live calls a provider only to test a key a streamer typed and has not saved yet. The admin
+# ai_enabled switch and the local daily cap still apply; local whisper.cpp transcription stays here.
+# AI_SERVICE=off turns AI off entirely.
+#AI_SERVICE=off
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
# Server
# Server
@@ -32,7 +32,7 @@ The roster is **whoever has been heard**: every streamer with transcribed speech
@@ -32,7 +32,7 @@ The roster is **whoever has been heard**: every streamer with transcribed speech
POWER = weighted sum + a **mouth bonus** (≤ +12: recent XP ÷ 25 + 3 per beef win in 7 days). Audience numbers (viewers, chat volume, followers, clips, tips) do not exist anywhere in the Arena.
POWER = weighted sum + a **mouth bonus** (≤ +12: recent XP ÷ 25 + 3 per beef win in 7 days). Audience numbers (viewers, chat volume, followers, clips, tips) do not exist anywhere in the Arena.
-**Persona** (`chat` role, 24 h TTL): fighter name, title, class, element, moves, weakness, `taunt` + three `taunts` (ragebait in *their speaking voice*), `typing_style` (how they talk), `spoken_as` (nicknames/mishearings, fed to name detection), six `custom_stats` unique to them and `stat_quips` — written from their transcript lines, their best judged shit talk, who they call out, who calls them out and what they rant at. **Quotes**: AI-picked lines from the transcripts, VOD-linked. **Portraits** (optional, `ai_image_enabled`): drawn from their own stream frames.
+**Persona** (`chat` role, 24 h TTL): fighter name, title, class, element, moves, weakness, `taunt` + three `taunts` (ragebait in *their speaking voice*), `typing_style` (how they talk), `spoken_as` (nicknames/mishearings, fed to name detection), six `custom_stats` unique to them and `stat_quips` — written from their transcript lines, their best judged shit talk, who they call out, who calls them out and what they rant at. **Quotes**: AI-picked lines from the transcripts, VOD-linked. **Portraits**: the ones drawn earlier from their own stream frames stay; no new ones are drawn (the image provider was called with the shared key, and Live calls no model provider itself since roadmap WS-O task 2).
## The mic ledger (`server/arena/mic.js`)
## The mic ledger (`server/arena/mic.js`)
@@ -86,7 +86,7 @@ Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" not
@@ -86,7 +86,7 @@ Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" not
|---|---|---|
|---|---|---|
| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster (`stats`, `stat_meta`, per-fighter `mic`, `last_line`), card (+ `beefs`, `rivalries`, `moments`, `best_lines`, `mic`), drill-down, live cams (`ears`, `last_moment`, `open_beefs`) |
| GET | `/status` · `/fighters` · `/fighters/:user` · `/fighters/:user/stat/:stat` · `/live` | roster (`stats`, `stat_meta`, per-fighter `mic`, `last_line`), card (+ `beefs`, `rivalries`, `moments`, `best_lines`, `mic`), drill-down, live cams (`ears`, `last_moment`, `open_beefs`) |
| GET | `/feed?limit&since` | the shit-talk feed |
| GET | `/feed?limit&since` | the shit-talk feed |
-| POST | `/fighters/:user/refresh` | admin — regenerate persona (+ portrait) |
+| POST | `/fighters/:user/refresh` | admin — regenerate persona |
| GET | `/console/:user` | the ears: listener state, hot mic, level, mic stats, open beefs, recent moments |
| GET | `/console/:user` | the ears: listener state, hot mic, level, mic stats, open beefs, recent moments |
| GET | `/beefs` · `/beefs/:id` · POST `/beefs/:id/hype {side}` | one hype per person per side; anonymous by hashed IP |
| GET | `/beefs` · `/beefs/:id` · POST `/beefs/:id/hype {side}` | one hype per person per side; anonymous by hashed IP |
| GET | `/levels` | Trash Level ladder |
| GET | `/levels` | Trash Level ladder |
@@ -99,7 +99,6 @@ Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" not
@@ -99,7 +99,6 @@ Old `/arena/topic/*` and `/arena/chatter/*` links show a "this part is gone" not
|---|---|---|
|---|---|---|
| `arena_enabled` | `true` | `false` → API 404s |
| `arena_enabled` | `true` | `false` → API 404s |
| `ai_timeline_enabled` | `false` | the audio transcription the whole Arena runs on — must be on |
| `ai_timeline_enabled` | `false` | the audio transcription the whole Arena runs on — must be on |
-| `ai_image_enabled` / `ai_image_model` / `ai_image_quality` / `ai_image_cost_usd` | `false` / `gpt-image-1` / `low` / `0.011` | portraits |
| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous hype hashing |
| `arena_vote_salt` | `JWT_SECRET` | salt for anonymous hype hashing |
Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · beef clocks every 60 s.
Background job (`server/arena/arena-job.js`): personas/portraits every 20 min (bounded, budget-aware) · listener every 15 s · beef clocks every 60 s.
@@ -140,7 +140,7 @@ language detected from any non-English line in their bio or name (`server/i18n/t
@@ -140,7 +140,7 @@ language detected from any non-English line in their bio or name (`server/i18n/t
and the chat `auth` frame carries `channel_language` so the composer can show the
and the chat `auth` frame carries `channel_language` so the composer can show the
"Auto-translated ↔ Japanese" pill.
"Auto-translated ↔ Japanese" pill.
-When AI is enabled (`ai_enabled` + a key; the `chat_translate_enabled` site setting defaults to on),
+When AI is enabled (`ai_enabled`, and `AI_SERVICE` not `off`; the `chat_translate_enabled` site setting defaults to on),
each chat line is translated a beat after it is broadcast and delivered to the same rooms as
each chat line is translated a beat after it is broadcast and delivered to the same rooms as
```json
```json
@@ -2,24 +2,19 @@
@@ -2,24 +2,19 @@
* OpenVibe.Live — AI analysis
* OpenVibe.Live — AI analysis
*
*
* Vision + text analysis used for: paste description/tags, paste text overviews,
* Vision + text analysis used for: paste description/tags, paste text overviews,
- * and periodic live-stream "memories". Provider (Anthropic Claude by default, or
- * any OpenAI-compatible endpoint) + key + model are configured in
- * openvibe.network/admin → AI. Everything is gated by the `ai_enabled` master switch and
- * an optional daily USD budget cap. Token usage + estimated cost are recorded to
- * `ai_usage` for the admin cost breakdown.
+ * and periodic live-stream "memories". Every call is a workflow run on OpenVibe.AI
+ * (ai-service.js), which holds the provider key, the models and the prompts of the analyses
+ * here. Everything is gated by the `ai_enabled` master switch and an optional daily USD budget
+ * cap. Token usage + cost are recorded to `ai_usage` for the admin cost breakdown.
*/
*/
-const fs = require('fs');
const db = require('../db/database');
const db = require('../db/database');
-function s(k) { return (db.getSetting(k) || '').toString().trim(); }
function b(k) { const v = db.getSetting(k); return v === true || v === 'true' || v === 1 || v === '1'; }
function b(k) { const v = db.getSetting(k); return v === true || v === 'true' || v === 1 || v === '1'; }
function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }
function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }
const llm = require('./llm');
const llm = require('./llm');
const aiService = require('./ai-service');
const aiService = require('./ai-service');
function isEnabled() { return llm.isEnabled(); }
function isEnabled() { return llm.isEnabled(); }
-// AI_SERVICE=remote: the analyses below whose prompt moved to OpenVibe.AI run as its workflows.
-const remote = () => aiService.enabled() && isEnabled() && withinBudget();
function pasteAnalysisEnabled() { return isEnabled() && b('ai_paste_analysis_enabled'); }
function pasteAnalysisEnabled() { return isEnabled() && b('ai_paste_analysis_enabled'); }
function streamMemoryEnabled() { return isEnabled() && b('ai_stream_memory_enabled'); }
function streamMemoryEnabled() { return isEnabled() && b('ai_stream_memory_enabled'); }
// Local whisper.cpp transcription (default on when installed). Independent of the
// Local whisper.cpp transcription (default on when installed). Independent of the
@@ -30,16 +25,10 @@ function transcriptionEnabled() {
@@ -30,16 +25,10 @@ function transcriptionEnabled() {
try { return on && require('./transcribe').available(); } catch { return false; }
try { return on && require('./transcribe').available(); } catch { return false; }
}
}
function captureIntervalSec() { return Math.max(30, num('ai_stream_capture_interval_sec', 120)); }
function captureIntervalSec() { return Math.max(30, num('ai_stream_capture_interval_sec', 120)); }
-function model() { return llm.defaultModel(); }
function withinBudget() { return llm.withinBudget(); }
function withinBudget() { return llm.withinBudget(); }
-// Kept for callers that estimate before/without a real usage object (BYO fallbacks).
-function estimateCost(inTok, outTok, cachedTok = 0, modelId = null) {
- return llm.estimateCost(modelId || model(), { input: inTok, output: outTok, cached: cachedTok });
-}
-// Provider transport now lives in ./llm (real system/user roles, prompt caching, per-role
-// models, structured JSON, timeouts + retry, uniform metering). These wrappers keep the
-// existing call shapes for the analysis features below.
+// The transport lives in ./llm (a run on OpenVibe.AI, uniform metering). These wrappers keep the
+// existing call shapes for the features that still render their own prompt.
/** Core call: legacy single-prompt shape. Returns text or null. */
/** Core call: legacy single-prompt shape. Returns text or null. */
async function _complete({ prompt, image = null, maxTokens = 400, kind, temperature = null, ownerUserId = null, source = null, role = 'legacy', imageMaxWidth = 1280 }) {
async function _complete({ prompt, image = null, maxTokens = 400, kind, temperature = null, ownerUserId = null, source = null, role = 'legacy', imageMaxWidth = 1280 }) {
@@ -47,13 +36,13 @@ async function _complete({ prompt, image = null, maxTokens = 400, kind, temperat
@@ -47,13 +36,13 @@ async function _complete({ prompt, image = null, maxTokens = 400, kind, temperat
return r && r.text ? r.text : null;
return r && r.text ? r.text : null;
}
}
-/** Generic text completion (used by media analysis to synthesize overviews). */
+/** Generic text completion (chat insights, moments, slogans, …): a passthrough run of the workflow that owns `kind`. */
async function summarizeText(prompt, maxTokens = 350, kind = 'media_overview') {
async function summarizeText(prompt, maxTokens = 350, kind = 'media_overview') {
return _complete({ prompt, maxTokens, kind });
return _complete({ prompt, maxTokens, kind });
}
}
/**
/**
- * Metered completion for the AI Chat Viewers engine on the SHARED (admin) key.
+ * Metered completion for the AI Chat Viewers engine on the site's AI (not a streamer's own key).
* Attributes the spend to a streamer (owner_user_id) under source='ai_viewers' so
* Attributes the spend to a streamer (owner_user_id) under source='ai_viewers' so
* per-streamer daily budgets work. Returns text, or null if the admin AI is
* per-streamer daily budgets work. Returns text, or null if the admin AI is
* disabled / over the global budget. Supports an optional image (vision) input.
* disabled / over the global budget. Supports an optional image (vision) input.
@@ -63,111 +52,34 @@ async function viewerComplete({ system = '', user = '', image = null, maxTokens
@@ -63,111 +52,34 @@ async function viewerComplete({ system = '', user = '', image = null, maxTokens
return r && r.text ? r.text : null;
return r && r.text ? r.text : null;
}
}
-/** Is the shared admin AI key usable right now (enabled + within global budget)? */
+/** Is Live's AI usable right now (enabled + within the global budget)? viewers/budget.js asks under this name. */
function sharedKeyReady() { return isEnabled() && withinBudget(); }
function sharedKeyReady() { return isEnabled() && withinBudget(); }
-function _parseJson(text) {
- if (!text) return null;
- const m = text.match(/\{[\s\S]*\}/);
- if (!m) return null;
- let raw = m[0];
- try { return JSON.parse(raw); } catch { /* try to repair below */ }
- // Models often emit slightly-broken JSON (a missing closing "]" before "}",
- // a trailing comma, smart quotes). Repair the common cases before giving up.
- try {
- let s = raw
- .replace(/[“”]/g, '"') // smart double quotes → "
- .replace(/[‘’]/g, "'") // smart single quotes → '
- .replace(/,\s*([}\]])/g, '$1'); // trailing commas
- // Balance brackets: an unclosed array right before the object close is the common
- // case (`..."fight"}` → `..."fight"]}`). Strip a trailing "}", add the missing "]",
- // then re-add the needed "}" so bracket counts line up.
- const opensq = (s.match(/\[/g) || []).length, closesq = (s.match(/\]/g) || []).length;
- if (opensq > closesq) {
- s = s.replace(/\}\s*$/, '');
- s += ']'.repeat(opensq - closesq);
- }
- const opencb = (s.match(/\{/g) || []).length, closecb = (s.match(/\}/g) || []).length;
- if (opencb > closecb) s += '}'.repeat(opencb - closecb);
- return JSON.parse(s);
- } catch { return null; }
-}
-
-// Robustly pull a plain-text description out of a model reply that was SUPPOSED to be
-// JSON but may be malformed — so we never store a raw `{"description":...}` blob as text.
-function _extractDescription(text, maxLen) {
- if (!text) return '';
- const j = _parseJson(text);
- if (j && j.description) return String(j.description).slice(0, maxLen);
- // Broken JSON: lift just the description string field.
- const dm = text.match(/"description"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
- if (dm) { try { return JSON.parse(`"${dm[1]}"`).slice(0, maxLen); } catch { return dm[1].slice(0, maxLen); } }
- // Not JSON at all → use as-is; if it merely looks like a JSON blob we couldn't parse, drop it.
- const t = text.trim();
- return /^[{[]/.test(t) ? '' : t.slice(0, maxLen);
-}
-function _extractTags(text, maxTags) {
- const j = _parseJson(text);
- if (j && Array.isArray(j.tags)) return j.tags.slice(0, maxTags).map(String);
- const tm = text && text.match(/"tags"\s*:\s*\[([^\]]*)/i);
- if (tm) return tm[1].split(',').map(s => s.replace(/["'\s]/g, '')).filter(Boolean).slice(0, maxTags);
- return [];
-}
-
// ── Public analysis functions ──
// ── Public analysis functions ──
-/** Re-encode any image (path/data) to a vision-friendly JPEG data URL. Handles
- * avif/gif/webp/huge images that the vision API otherwise rejects. */
-async function _toVisionJpeg(image, opts = {}) {
- return (await llm.toVisionJpeg(image, { maxWidth: 1280, quality: 82, ...opts })) || image;
-}
-
/** Describe an image paste → { description, tags }. */
/** Describe an image paste → { description, tags }. */
async function analyzeImagePaste(image, title, kind = 'paste_image') {
async function analyzeImagePaste(image, title, kind = 'paste_image') {
- if (aiService.enabled()) {
- if (!remote()) return null;
- const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 1280 });
- const o = img ? await aiService.structured('live.paste.describe_image', { title: String(title || '').slice(0, 500), image: img }, { meter: { kind, role: 'vision' } }) : null;
- return o && o.description ? { description: o.description, tags: o.tags || [] } : null;
- }
- const prompt = `You are describing an uploaded image/screenshot for a paste titled "${(title || '').slice(0, 120)}".
-Reply ONLY with compact JSON: {"description":"1-2 sentence description of what the image shows","tags":["3-6","short","lowercase","tags"]}.`;
- const text = await _complete({ prompt, image, maxTokens: 300, kind, role: 'vision', imageMaxWidth: 1280 });
- if (!text) return null;
- const description = _extractDescription(text, 600);
- return description ? { description, tags: _extractTags(text, 8) } : null;
+ if (!sharedKeyReady()) return null;
+ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 1280 });
+ const o = img ? await aiService.structured('live.paste.describe_image', { title: String(title || '').slice(0, 500), image: img }, { meter: { kind, role: 'vision' } }) : null;
+ return o && o.description ? { description: o.description, tags: o.tags || [] } : null;
}
}
/** Summarize a text paste → { description }. */
/** Summarize a text paste → { description }. */
async function analyzeTextPaste(content, title) {
async function analyzeTextPaste(content, title) {
- if (aiService.enabled()) {
- if (!remote() || !String(content || '').trim()) return null;
- const o = await aiService.structured('live.paste.summarize_text', { title: String(title || '').slice(0, 500), content: String(content || '').slice(0, 200000) }, { meter: { kind: 'paste_text', role: 'legacy' } });
- return o && o.description ? { description: o.description, tags: [] } : null;
- }
- const snippet = String(content || '').slice(0, 6000);
- const prompt = `Summarize what this pasted text is about in one short sentence (max 200 chars), plainly. Title: "${(title || '').slice(0, 120)}".\n\n---\n${snippet}`;
- const text = await _complete({ prompt, maxTokens: 120, kind: 'paste_text' });
- return text ? { description: text.slice(0, 300), tags: [] } : null;
+ if (!sharedKeyReady() || !String(content || '').trim()) return null;
+ const o = await aiService.structured('live.paste.summarize_text', { title: String(title || '').slice(0, 500), content: String(content || '').slice(0, 200000) }, { meter: { kind: 'paste_text', role: 'legacy' } });
+ return o && o.description ? { description: o.description, tags: [] } : null;
}
}
/** Analyze a live-stream frame → { description, tags }. */
/** Analyze a live-stream frame → { description, tags }. */
async function analyzeStreamFrame(image) {
async function analyzeStreamFrame(image) {
- if (aiService.enabled()) {
- if (!remote()) return null;
- const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 768 });
- const o = img ? await aiService.structured('live.stream.describe_frame', { image: img }, { meter: { kind: 'stream_memory', role: 'vision' } }) : null;
- return o && o.description ? { description: o.description, tags: o.tags || [], worthy: o.worthy === true, title: o.worthy === true ? String(o.title || '').slice(0, 80) : '' } : null;
- }
- // One vision call does three jobs: the memory description, the tags, and a screenshot-worthiness
+ // One vision run does three jobs: the memory description, the tags, and a screenshot-worthiness
// verdict + caption (so live pastes need no extra call).
// verdict + caption (so live pastes need no extra call).
- const prompt = `This is a frame from a live stream. Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a face/reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption for it, else empty>"}.`;
- const text = await _complete({ prompt, image, maxTokens: 240, kind: 'stream_memory', role: 'vision', imageMaxWidth: 768 });
- if (!text) return null;
- const description = _extractDescription(text, 400);
- if (!description) return null;
- const j = _parseJson(text) || {};
- return { description, tags: _extractTags(text, 6), worthy: j.worthy === true, title: j.worthy === true ? String(j.title || '').replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80) : '' };
+ if (!sharedKeyReady()) return null;
+ const img = await aiService.imageInput(image, { toVisionJpeg: llm.toVisionJpeg, maxWidth: 768 });
+ const o = img ? await aiService.structured('live.stream.describe_frame', { image: img }, { meter: { kind: 'stream_memory', role: 'vision' } }) : null;
+ return o && o.description ? { description: o.description, tags: o.tags || [], worthy: o.worthy === true, title: o.worthy === true ? String(o.title || '').slice(0, 80) : '' } : null;
}
}
/** Fixed category taxonomy the AI picks from (matches the go-live selector). */
/** Fixed category taxonomy the AI picks from (matches the go-live selector). */
@@ -178,56 +90,21 @@ function normalizeCategory(c) { const t = String(c || '').toLowerCase().trim();
@@ -178,56 +90,21 @@ function normalizeCategory(c) { const t = String(c || '').toLowerCase().trim();
async function summarizeStreamMemories(memories, streamId = null) {
async function summarizeStreamMemories(memories, streamId = null) {
// Use observations from across the whole session (capped for token budget) so the
// Use observations from across the whole session (capped for token budget) so the
// overview reflects the entire stream since it started, not just the latest frame.
// overview reflects the entire stream since it started, not just the latest frame.
- const lines = (memories || []).slice(-80).map(m => `- ${m.description}`).join('\n');
- if (!lines) return null;
- if (aiService.enabled()) {
- if (!remote()) return null;
- const input = { observations: (memories || []).slice(-80).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean) };
- if (streamId) {
- try {
- input.speech = (db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || []).slice(-200).map(r => ({ start_sec: Number(r.start_sec) || 0, text: String(r.text || '').slice(0, 2000) }));
- input.sounds = (db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || []).slice(-60).map(r => ({ start_sec: Number(r.start_sec) || 0, label: String(r.label || '').slice(0, 120), confidence: Number(r.confidence || 0) }));
- } catch { /* timeline optional */ }
- }
- const o = await aiService.structured('live.stream.summarize', input, { target: streamId ? { service: 'live', type: 'stream', id: String(streamId) } : undefined, meter: { kind: 'stream_memory', role: 'legacy' } });
- return o && o.overview ? { overview: o.overview, category: normalizeCategory(o.category), tags: Array.isArray(o.tags) ? o.tags.map(String).slice(0, 6) : [] } : null;
- }
-
- // Fold in the audio timeline when one exists. Previously the transcript reached this
- // prompt only as a 500-char slice embedded inside a memory's description string, with
- // its timestamps stripped — so the model saw fragments of speech with no idea when
- // they happened or what they lined up with. Sending the timeline directly gives it
- // what was SAID and what was HEARD, both anchored in time.
- let audioBlock = '';
+ const observations = (memories || []).slice(-80).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean);
+ if (!observations.length || !sharedKeyReady()) return null;
+ // The audio timeline, when one exists, goes along with its timestamps: what was SAID and what
+ // was HEARD, both anchored in time.
+ const input = { observations };
if (streamId) {
if (streamId) {
try {
try {
- const _mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
- const speech = db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || [];
- const sounds = db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || [];
- if (speech.length) {
- const spoken = speech.slice(-200)
- .map(r => `[${_mmss(r.start_sec)}] ${String(r.text || '').replace(/\s+/g, ' ').trim().slice(0, 200)}`)
- .filter(l => l.length > 8).join('\n');
- if (spoken) audioBlock += `\n\nWHAT THE STREAMER SAID (timestamped):\n${spoken}`;
- }
- if (sounds.length) {
- const heard = sounds.slice(-60)
- .map(r => `[${_mmss(r.start_sec)}] ${r.label} (${Number(r.confidence || 0).toFixed(2)})`)
- .join('\n');
- if (heard) audioBlock += `\n\nNOTABLE SOUNDS HEARD:\n${heard}`;
- }
+ input.speech = (db.getTimeline(streamId, { kind: 'speech', limit: 400 }) || []).slice(-200).map(r => ({ start_sec: Number(r.start_sec) || 0, text: String(r.text || '').slice(0, 2000) }));
+ input.sounds = (db.getTimeline(streamId, { kind: 'sound', limit: 120 }) || []).slice(-60).map(r => ({ start_sec: Number(r.start_sec) || 0, label: String(r.label || '').slice(0, 120), confidence: Number(r.confidence || 0) }));
} catch { /* timeline optional */ }
} catch { /* timeline optional */ }
}
}
-
- // The same call also classifies the stream — category is inferred from what is actually on
- // screen / said, never from the streamer's go-live selector (which used to default to "irl"
- // and taught every downstream AI that everyone is an IRL streamer).
- const prompt = `These are timestamped observations from a live stream, in order since it started. Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: ${CATEGORIES.join(', ')} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n${lines}${audioBlock}`;
- const text = await _complete({ prompt, maxTokens: 560, kind: 'stream_memory' });
- if (!text) return null;
- const j = _parseJson(text);
- const overview = j && j.overview ? String(j.overview).slice(0, 2000) : (_extractDescription(text, 2000) || text.slice(0, 2000));
- return { overview, category: normalizeCategory(j && j.category), tags: j && Array.isArray(j.tags) ? j.tags.map(String).slice(0, 6) : [] };
+ // The same run also classifies the stream: the category is inferred from what is actually on
+ // screen / said, never from the streamer's go-live selector.
+ const o = await aiService.structured('live.stream.summarize', input, { target: streamId ? { service: 'live', type: 'stream', id: String(streamId) } : undefined, meter: { kind: 'stream_memory', role: 'legacy' } });
+ return o && o.overview ? { overview: o.overview, category: normalizeCategory(o.category), tags: Array.isArray(o.tags) ? o.tags.map(String).slice(0, 6) : [] } : null;
}
}
/**
/**
@@ -235,50 +112,36 @@ async function summarizeStreamMemories(memories, streamId = null) {
@@ -235,50 +112,36 @@ async function summarizeStreamMemories(memories, streamId = null) {
* their streams (memories), VODs, and pastes. Returns the overview text or null.
* their streams (memories), VODs, and pastes. Returns the overview text or null.
*/
*/
async function generateStreamerOverview(userId) {
async function generateStreamerOverview(userId) {
- if (!isEnabled() || !withinBudget()) return null;
+ if (!sharedKeyReady()) return null;
const user = db.getUserById(userId);
const user = db.getUserById(userId);
if (!user) return null;
if (!user) return null;
const channel = (typeof db.getChannelByUserId === 'function') ? db.getChannelByUserId(userId) : null;
const channel = (typeof db.getChannelByUserId === 'function') ? db.getChannelByUserId(userId) : null;
const memories = (db.getStreamMemoriesByUser ? db.getStreamMemoriesByUser(userId, 60) : []) || [];
const memories = (db.getStreamMemoriesByUser ? db.getStreamMemoriesByUser(userId, 60) : []) || [];
- const memLines = memories.slice(0, 40).map(m => `- ${m.description}`).filter(l => l.length > 2);
+ const memLines = memories.slice(0, 40).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean);
// VODs from OpenVibe.Media (public ones) and pastes from OpenVibe.Community (any visibility:
// VODs from OpenVibe.Media (public ones) and pastes from OpenVibe.Community (any visibility:
// this overview is for site staff), both through media-proxy/lookups.js.
// this overview is for site staff), both through media-proxy/lookups.js.
const lookups = require('../media-proxy/lookups');
const lookups = require('../media-proxy/lookups');
const [vods, pastes] = await Promise.all([lookups.userVods(userId, { limit: 20 }), lookups.userPastesForAi(user, 25)]);
const [vods, pastes] = await Promise.all([lookups.userVods(userId, { limit: 20 }), lookups.userPastesForAi(user, 25)]);
- const vodLines = vods.map(v => `- ${v.title || 'Untitled VOD'}${(v.ai_category || v.category) ? ` [${v.ai_category || v.category}]` : ''}`);
-
- const pasteLines = pastes.filter(p => p.ai_summary).map(p => `- "${p.title || 'paste'}": ${p.ai_summary}`);
-
- if (!memLines.length && !vodLines.length && !pasteLines.length) return null;
-
- const ctx = [
- `Streamer: ${user.display_name || user.username} (@${user.username})`,
- (channel?.bio || user.bio) ? `Bio: ${(channel?.bio || user.bio).slice(0, 400)}` : '',
- (channel?.ai_category || channel?.category) ? `Usual category (${channel?.ai_category ? 'inferred by AI from their streams' : 'self-selected'}): ${channel.ai_category || channel.category}` : '',
- memLines.length ? `\nLive-stream observations (across sessions):\n${memLines.join('\n')}` : '',
- vodLines.length ? `\nRecent VODs:\n${vodLines.join('\n')}` : '',
- pasteLines.length ? `\nPaste summaries:\n${pasteLines.join('\n')}` : '',
- ].filter(Boolean).join('\n');
+ const summarized = pastes.filter(p => p.ai_summary);
- const prompt = `You are building an internal profile of a livestreamer for site staff, using aggregated signals across their streams, VODs, and pastes. Write a concise overview (4-8 sentences) covering: what they stream / their content niche, recurring themes or activities, tone/vibe, and anything notable for moderation. Be factual and neutral; do NOT invent specifics that aren't supported by the signals below.\n\n${ctx}`;
+ if (!memLines.length && !vods.length && !summarized.length) return null;
- const text = aiService.enabled()
- ? ((await aiService.structured('live.streamer.overview', {
- streamer: { username: String(user.username), display_name: String(user.display_name || user.username), bio: String(channel?.bio || user.bio || '').slice(0, 2000), category: (channel?.ai_category || channel?.category) ? String(channel.ai_category || channel.category) : undefined, category_inferred: Boolean(channel?.ai_category) },
- memories: memories.slice(0, 40).map(m => String(m.description || '').slice(0, 1000)).filter(Boolean),
- vods: vods.map(v => ({ title: String(v.title || 'Untitled VOD').slice(0, 300), category: (v.ai_category || v.category) ? String(v.ai_category || v.category) : undefined })),
- pastes: pastes.filter(p => p.ai_summary).map(p => ({ title: String(p.title || 'paste').slice(0, 300), summary: String(p.ai_summary).slice(0, 600) })),
- }, { target: { service: 'live', type: 'user', id: String(userId) }, meter: { kind: 'streamer_overview', role: 'legacy' } })) || {}).overview || null
- : await _complete({ prompt, maxTokens: 550, kind: 'streamer_overview' });
+ const o = await aiService.structured('live.streamer.overview', {
+ streamer: { username: String(user.username), display_name: String(user.display_name || user.username), bio: String(channel?.bio || user.bio || '').slice(0, 2000), category: (channel?.ai_category || channel?.category) ? String(channel.ai_category || channel.category) : undefined, category_inferred: Boolean(channel?.ai_category) },
+ memories: memLines,
+ vods: vods.map(v => ({ title: String(v.title || 'Untitled VOD').slice(0, 300), category: (v.ai_category || v.category) ? String(v.ai_category || v.category) : undefined })),
+ pastes: summarized.map(p => ({ title: String(p.title || 'paste').slice(0, 300), summary: String(p.ai_summary).slice(0, 600) })),
+ }, { target: { service: 'live', type: 'user', id: String(userId) }, meter: { kind: 'streamer_overview', role: 'legacy' } });
+ const text = o && o.overview;
if (!text) return null;
if (!text) return null;
- const overview = text.slice(0, 4000);
+ const overview = String(text).slice(0, 4000);
try {
try {
db.upsertStreamerOverview(userId, {
db.upsertStreamerOverview(userId, {
overview,
overview,
- model: model(),
- sources: JSON.stringify({ memories: memories.length, vods: vods.length, pastes: pasteLines.length }),
+ model: 'openvibe-ai',
+ sources: JSON.stringify({ memories: memories.length, vods: vods.length, pastes: summarized.length }),
});
});
} catch (e) { console.warn('[AI] overview store failed:', e.message); }
} catch (e) { console.warn('[AI] overview store failed:', e.message); }
return overview;
return overview;
@@ -499,25 +362,22 @@ async function generateClipOverview(clip) {
@@ -499,25 +362,22 @@ async function generateClipOverview(clip) {
return { overview: r ? r.overview : null, transcript };
return { overview: r ? r.overview : null, transcript };
}
}
-/** Report AI config + optionally live-probe the provider. */
+/** Report AI config + optionally probe OpenVibe.AI with a one-word run. */
async function testStatus({ probe = true } = {}) {
async function testStatus({ probe = true } = {}) {
const cfg = {
const cfg = {
enabled: isEnabled(),
enabled: isEnabled(),
- provider: s('ai_provider') || 'anthropic',
- model: model(),
- has_key: !!s('ai_api_key'),
- base_url: s('ai_base_url') || null,
+ service: aiService.enabled() ? 'openvibe-ai' : 'off',
paste_analysis: pasteAnalysisEnabled(),
paste_analysis: pasteAnalysisEnabled(),
stream_memory: streamMemoryEnabled(),
stream_memory: streamMemoryEnabled(),
budget_cap_usd_per_day: num('ai_max_cost_usd_per_day', 0),
budget_cap_usd_per_day: num('ai_max_cost_usd_per_day', 0),
within_budget: withinBudget(),
within_budget: withinBudget(),
};
};
try { cfg.cost_today = db.getAiCostToday(); } catch { cfg.cost_today = null; }
try { cfg.cost_today = db.getAiCostToday(); } catch { cfg.cost_today = null; }
- if (!cfg.enabled) return { ...cfg, ok: false, error: cfg.has_key ? 'AI is disabled (ai_enabled=false)' : 'No API key set' };
+ if (!cfg.enabled) return { ...cfg, ok: false, error: aiService.enabled() ? 'AI is disabled (ai_enabled=false)' : 'AI is off (AI_SERVICE=off)' };
if (!probe) return { ...cfg, ok: true, probed: false };
if (!probe) return { ...cfg, ok: true, probed: false };
const started = Date.now();
const started = Date.now();
const reply = await _complete({ prompt: 'Reply with exactly: OK', maxTokens: 8, kind: 'status_check' });
const reply = await _complete({ prompt: 'Reply with exactly: OK', maxTokens: 8, kind: 'status_check' });
- return { ...cfg, ok: !!reply, probed: true, reply: reply || null, latency_ms: Date.now() - started, error: reply ? null : 'Provider returned no response (check key/model/base URL)' };
+ return { ...cfg, ok: !!reply, probed: true, reply: reply || null, latency_ms: Date.now() - started, error: reply ? null : 'OpenVibe.AI returned no answer (over budget, or the service or its provider is down)' };
}
}
module.exports = {
module.exports = {
@@ -526,6 +386,6 @@ module.exports = {
@@ -526,6 +386,6 @@ module.exports = {
analyzeImagePaste, analyzeTextPaste, analyzeStreamFrame, summarizeStreamMemories,
analyzeImagePaste, analyzeTextPaste, analyzeStreamFrame, summarizeStreamMemories,
generateStreamerOverview, generateVodOverview, generateClipOverview, ensureVodTimeline,
generateStreamerOverview, generateVodOverview, generateClipOverview, ensureVodTimeline,
generateVodTranscript, generateClipTranscript, summarizeText, testStatus,
generateVodTranscript, generateClipTranscript, summarizeText, testStatus,
- viewerComplete, sharedKeyReady, estimateCost,
+ viewerComplete, sharedKeyReady,
complete: llm.complete, llm,
complete: llm.complete, llm,
};
};
deleted file mode 100644
deleted file mode 100644
@@ -1,165 +0,0 @@
@@ -1,165 +0,0 @@
-/**
- * ai-provider.js — Minimal OpenAI-compatible AI client.
- *
- * Works with any provider exposing the OpenAI REST shape:
- * - Chat: POST {baseUrl}/chat/completions
- * - Transcription: POST {baseUrl}/audio/transcriptions (Whisper-style)
- *
- * That covers OpenAI, OpenRouter, Groq, Together, local llama.cpp / LM Studio /
- * Ollama (OpenAI-compat mode), etc. Callers supply baseUrl + apiKey + model.
- * No SDK dependency — uses global fetch/FormData/Blob (Node 18+).
- */
-'use strict';
-const fs = require('fs');
-const path = require('path');
-
-const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
-
-function normalizeBaseUrl(baseUrl) {
- let u = String(baseUrl || DEFAULT_BASE_URL).trim();
- if (!u) u = DEFAULT_BASE_URL;
- return u.replace(/\/+$/, '');
-}
-
-// A non-OpenAI base URL — local servers (Ollama / LM Studio / llama.cpp) are OpenAI-
-// compatible but need NO API key, so we don't require one for these.
-function isSelfHostedBaseUrl(baseUrl) {
- const u = normalizeBaseUrl(baseUrl).toLowerCase();
- return !!u && !u.includes('api.openai.com');
-}
-
-// True if the URL points at a local/private address the PUBLIC server can't reach.
-function isLocalOrPrivateHost(baseUrl) {
- try {
- const h = new URL(normalizeBaseUrl(baseUrl)).hostname.toLowerCase().replace(/^\[|\]$/g, '');
- return h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')
- || /^127\./.test(h) || /^10\./.test(h) || /^192\.168\./.test(h)
- || /^172\.(1[6-9]|2\d|3[01])\./.test(h) || /^169\.254\./.test(h);
- } catch { return false; }
-}
-
-// Translate a low-level fetch failure into an actionable message. undici throws a terse
-// "fetch failed" (surfaced in browsers as "Failed to fetch") for any network-level problem
-// — and the #1 cause here is a self-hosted endpoint that isn't reachable from our servers.
-function _describeFetchError(err, url, baseUrl, timeoutMs) {
- if (err && err.name === 'AbortError') {
- return new Error(`The AI server at ${url} didn't respond within ${Math.round(timeoutMs / 1000)}s. Is it running and reachable?`);
- }
- if (isLocalOrPrivateHost(baseUrl)) {
- return new Error(`Can't reach ${url}. The AI chat viewers run on OpenVibe.Live's servers, so a self-hosted API must be reachable from the public internet — a local address like localhost/127.0.0.1 or a home/LAN IP won't work. Expose it with a public URL or a tunnel (e.g. ngrok, cloudflared) and use that as the Base URL.`);
- }
- return new Error(`Couldn't connect to the AI server at ${url}. Check the Base URL and that the server is online and reachable from the internet.`);
-}
-
-/**
- * Chat completion. Returns the assistant message text (string).
- * @param {{baseUrl?:string, apiKey:string, model:string, messages:Array, temperature?:number, maxTokens?:number, timeoutMs?:number}} opts
- */
-async function chatCompletion(opts) {
- const {
- baseUrl, apiKey, model, messages,
- temperature = 1.0, maxTokens = 160, timeoutMs = 20000,
- } = opts;
- if (!model) throw new Error('AI model not configured');
- // API key is OPTIONAL: self-hosted OpenAI-compatible servers (Ollama, LM Studio,
- // llama.cpp) accept requests with no Authorization header. Only send one when present.
- const headers = { 'Content-Type': 'application/json' };
- if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
-
- const url = `${normalizeBaseUrl(baseUrl)}/chat/completions`;
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeoutMs);
- try {
- let res;
- try {
- res = await fetch(url, {
- method: 'POST',
- headers,
- body: JSON.stringify({ model, messages, temperature, max_tokens: maxTokens }),
- signal: controller.signal,
- });
- } catch (netErr) {
- throw _describeFetchError(netErr, url, baseUrl, timeoutMs);
- }
- if (!res.ok) {
- const body = await res.text().catch(() => '');
- if (res.status === 401 || res.status === 403) {
- throw new Error(`The AI server rejected the request (HTTP ${res.status}). It needs a valid API key${apiKey ? '' : ' — none was provided'}.`);
- }
- throw new Error(`AI HTTP ${res.status}: ${body.slice(0, 300)}`);
- }
- const data = await res.json();
- const text = data?.choices?.[0]?.message?.content;
- return typeof text === 'string' ? text.trim() : '';
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Transcribe an audio file. Returns the transcript text (string, possibly '').
- * @param {{baseUrl?:string, apiKey:string, model?:string, filePath:string, language?:string, timeoutMs?:number}} opts
- */
-async function transcribe(opts) {
- const {
- baseUrl, apiKey, model = 'whisper-1', filePath,
- language, timeoutMs = 30000,
- } = opts;
- if (!filePath || !fs.existsSync(filePath)) throw new Error('Transcription audio file missing');
-
- const url = `${normalizeBaseUrl(baseUrl)}/audio/transcriptions`;
- const buf = fs.readFileSync(filePath);
- const form = new FormData();
- form.append('file', new Blob([buf], { type: 'audio/wav' }), path.basename(filePath));
- form.append('model', model);
- form.append('response_format', 'text');
- if (language) form.append('language', language);
-
- const headers = {};
- if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeoutMs);
- try {
- let res;
- try {
- res = await fetch(url, { method: 'POST', headers, body: form, signal: controller.signal });
- } catch (netErr) {
- throw _describeFetchError(netErr, url, baseUrl, timeoutMs);
- }
- if (!res.ok) {
- const body = await res.text().catch(() => '');
- throw new Error(`Transcribe HTTP ${res.status}: ${body.slice(0, 300)}`);
- }
- // response_format=text returns raw text; some providers still return JSON.
- const raw = await res.text();
- try {
- const j = JSON.parse(raw);
- return String(j.text || j.transcript || '').trim();
- } catch {
- return String(raw || '').trim();
- }
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Lightweight connectivity/credential check — a 1-token chat call.
- * Returns { ok:true } or { ok:false, error }.
- */
-async function testConnection(opts) {
- try {
- await chatCompletion({
- ...opts,
- messages: [{ role: 'user', content: 'Reply with the single word: ok' }],
- maxTokens: 5,
- temperature: 0,
- timeoutMs: 15000,
- });
- return { ok: true };
- } catch (err) {
- return { ok: false, error: err.message };
- }
-}
-
-module.exports = { chatCompletion, transcribe, testConnection, normalizeBaseUrl, isSelfHostedBaseUrl, isLocalOrPrivateHost, DEFAULT_BASE_URL };
@@ -1,26 +1,25 @@
@@ -1,26 +1,25 @@
/**
/**
- * ai-service.js — Live as a CONSUMER of OpenVibe.AI (roadmap Wave 13).
+ * ai-service.js — Live as a CONSUMER of OpenVibe.AI (roadmap Wave 13; the only mode since WS-O task 2).
*
*
- * With AI_SERVICE=remote, Live stops calling model providers itself: every shared-key AI call
- * becomes a workflow run on OpenVibe.AI (ai.openvibe.network, internal OV_AI_INTERNAL_URL),
- * authenticated with Live's Network service token for audience openvibe.ai (network-principal).
- * Unset (the default), nothing here is used and Live behaves exactly as before.
+ * Live calls no model provider itself and keeps no shared provider key: every AI call is a workflow
+ * run on OpenVibe.AI (ai.openvibe.network, internal OV_AI_INTERNAL_URL), authenticated with Live's
+ * Network service token for audience openvibe.ai (network-principal). AI_SERVICE=off turns all of it
+ * off (every call answers null); any other value, or none, is on. The old AI_SERVICE=remote still
+ * means on.
*
*
* structured(workflow, input, opts) -> the run's output object, or null
* structured(workflow, input, opts) -> the run's output object, or null
- * for workflows whose prompt now lives in OpenVibe.AI (live.translate, live.paste.*,
- * live.stream.*, live.streamer.overview, live.media.overview, live.stream.recap,
- * network.site_copy)
+ * for workflows whose prompt lives in OpenVibe.AI (live.translate, live.paste.*,
+ * live.stream.*, live.streamer.overview, live.media.overview, live.stream.recap)
* complete(o, { toVisionJpeg }) -> the llm.complete() result shape, or null
* complete(o, { toVisionJpeg }) -> the llm.complete() result shape, or null
* for every other llm.complete() call: the prompt Live rendered is sent as a passthrough run of
* for every other llm.complete() call: the prompt Live rendered is sent as a passthrough run of
* the workflow that owns that feature (KIND_TO_WORKFLOW), so each run is still attributable
* the workflow that owns that feature (KIND_TO_WORKFLOW), so each run is still attributable
* to one workflow + model + run
* to one workflow + model + run
*
*
- * 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. 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.
+ * null means "no AI answer" (AI off, over budget, provider down): quota refusals (429), failed runs,
+ * an unreachable service, and SYNTHETIC answers (the AI service's stub provider) all come back as
+ * null — synthetic text never reaches 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 }.
*/
*/
'use strict';
'use strict';
const principal = require('../net/network-principal');
const principal = require('../net/network-principal');
@@ -38,7 +37,7 @@ const KIND_TO_WORKFLOW = {
@@ -38,7 +37,7 @@ const KIND_TO_WORKFLOW = {
};
};
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
-function enabled() { return String(process.env.AI_SERVICE || '').trim().toLowerCase() === 'remote'; }
+function enabled() { return String(process.env.AI_SERVICE || '').trim().toLowerCase() !== 'off'; }
function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }
function baseUrl() { return String(process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/+$/, ''); }
function workflowFor(kind) { return KIND_TO_WORKFLOW[kind] || 'live.complete'; }
function workflowFor(kind) { return KIND_TO_WORKFLOW[kind] || 'live.complete'; }
@@ -1,22 +1,19 @@
@@ -1,22 +1,19 @@
/**
/**
* llm.js — the ONE chat-completion entry point for OpenVibe.Live.
* llm.js — the ONE chat-completion entry point for OpenVibe.Live.
*
*
- * Every feature (AI viewers, overviews, chat insights, vision, paste analysis) calls
- * complete(); it is the single place that:
- * - gates on the admin master switch + global daily budget (shared key), or routes
- * to a streamer's BYO provider (uniformly metered either way);
- * - picks the model per ROLE (chat / vision / director / summary) with per-role admin
- * overrides and BYO equivalents, falling back to the default model;
- * - talks to Anthropic or any OpenAI-compatible endpoint with real system/user roles
- * (the old code flattened everything into one user message, which made prompt
- * caching impossible);
- * - uses prompt caching: Anthropic cache_control on the stable system blocks, OpenAI
- * automatic prefix caching + prompt_cache_key;
- * - supports structured JSON output (OpenAI json_schema, Anthropic forced tool), with a
- * prose-JSON repair fallback for gateways that lack it;
- * - always downscales images before sending them;
- * - applies a timeout and ONE retry on transient failures;
- * - records usage (incl. cached tokens, role, provider, latency) with per-model pricing.
+ * Every feature (AI viewers, overviews, chat insights, vision, paste analysis) calls complete().
+ * Live calls no model provider itself and keeps no shared provider key (roadmap WS-O task 2):
+ * complete() is a workflow run on OpenVibe.AI (ai-service.js), one of two kinds:
+ * - a streamer's own key stored in OpenVibe.AI (o.provider.credentialSubject, byo-credentials.js):
+ * a run with credential { subject }, within the budget the streamer set there;
+ * - every other call: gated on the admin master switch (ai_enabled) + the global daily budget,
+ * then a run of the workflow that owns the feature (ai-service KIND_TO_WORKFLOW).
+ * Both are recorded in ai_usage (role, provider, latency, the run's cost), so per-streamer budgets
+ * and the admin cost views keep working. A raw key / base URL override (the old direct "bring your
+ * own" path) is refused: every stored key moved to OpenVibe.AI.
+ *
+ * testProvider() is the one direct provider call left (see there). toVisionJpeg() downscales any
+ * image before it is sent; parseJsonLoose() repairs the JSON models commonly get slightly wrong.
*/
*/
'use strict';
'use strict';
const fs = require('fs');
const fs = require('fs');
@@ -25,89 +22,18 @@ const aiService = require('./ai-service');
@@ -25,89 +22,18 @@ const aiService = require('./ai-service');
const egress = require('../net/egress');
const egress = require('../net/egress');
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
-const DEFAULT_TIMEOUT_MS = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
-function s(k) { return (db.getSetting(k) || '').toString().trim(); }
function b(k) { const v = db.getSetting(k); return v === true || v === 'true' || v === 1 || v === '1'; }
function b(k) { const v = db.getSetting(k); return v === true || v === 'true' || v === 1 || v === '1'; }
function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }
function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }
// ── Gates ────────────────────────────────────────────────────
// ── Gates ────────────────────────────────────────────────────
-// AI_SERVICE=remote: the provider key lives in OpenVibe.AI, so the admin master switch alone decides.
-function isEnabled() { return b('ai_enabled') && (aiService.enabled() || !!s('ai_api_key')); }
+// The provider key lives in OpenVibe.AI, so the admin master switch and AI_SERVICE (not off) decide.
+function isEnabled() { return b('ai_enabled') && aiService.enabled(); }
function withinBudget() {
function withinBudget() {
const cap = num('ai_max_cost_usd_per_day', 0);
const cap = num('ai_max_cost_usd_per_day', 0);
if (!cap || cap <= 0) return true;
if (!cap || cap <= 0) return true;
try { return db.getAiCostToday() < cap; } catch { return true; }
try { return db.getAiCostToday() < cap; } catch { return true; }
}
}
-function defaultModel() { return s('ai_model') || (s('ai_provider') === 'anthropic' ? 'claude-sonnet-5' : 'gpt-4o-mini'); }
-
-/** Model for a role: admin per-role override → default model. */
-function modelForRole(role) {
- const r = ROLES.includes(role) ? role : 'legacy';
- if (r !== 'legacy') { const m = s(`ai_model_${r}`); if (m) return m; }
- return defaultModel();
-}
-
-/**
- * Resolve the provider for a call.
- * override = { kind?: 'openai'|'anthropic', baseUrl?, apiKey?, model?, models?: {chat,vision,...} }
- */
-function resolveProvider(role, override = null) {
- if (override && (override.apiKey || override.baseUrl)) {
- const baseUrl = String(override.baseUrl || '').trim().replace(/\/+$/, '');
- const kind = override.kind || (/anthropic\.com/i.test(baseUrl) ? 'anthropic' : 'openai');
- const model = (override.models && override.models[role]) || override.model || 'gpt-4o-mini';
- return { kind, baseUrl: baseUrl || (kind === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1'), apiKey: override.apiKey || '', model, shared: false };
- }
- const baseUrl = (s('ai_base_url') || '').replace(/\/+$/, '');
- // Anthropic only when explicitly chosen (and not pointed at an OpenAI-compatible
- // gateway); everything else — openai, openrouter, groq, ollama, blank — is OpenAI-shaped.
- const kind = s('ai_provider') === 'anthropic' && (!baseUrl || /anthropic\.com/i.test(baseUrl)) ? 'anthropic' : 'openai';
- return {
- kind,
- baseUrl: baseUrl || (kind === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1'),
- apiKey: s('ai_api_key'),
- model: modelForRole(role),
- shared: true,
- };
-}
-
-// ── Pricing ──────────────────────────────────────────────────
-// site_settings.ai_pricing_json = { "gpt-5-nano": {"in":0.05,"out":0.4,"cached":0.005}, "default": {...} }
-// (USD per million tokens). Longest key-prefix match on the model id; falls back to the
-// flat ai_input/output_cost_per_mtok settings, cached input = 10% of input.
-let _pricingCache = { at: 0, table: null };
-function pricingTable() {
- if (Date.now() - _pricingCache.at < 30000 && _pricingCache.table) return _pricingCache.table;
- let table = {};
- try {
- const raw = db.getSetting('ai_pricing_json'); // type 'json' → already parsed
- table = (raw && typeof raw === 'object') ? raw : (raw ? JSON.parse(String(raw)) : {}) || {};
- } catch { table = {}; }
- _pricingCache = { at: Date.now(), table };
- return table;
-}
-function priceFor(model) {
- const table = pricingTable();
- const m = String(model || '').toLowerCase();
- let best = null, bestLen = -1;
- for (const [k, v] of Object.entries(table)) {
- const key = String(k).toLowerCase();
- if (key === 'default') continue;
- if (m.startsWith(key) && key.length > bestLen && v && typeof v === 'object') { best = v; bestLen = key.length; }
- }
- if (!best && table.default && typeof table.default === 'object') best = table.default;
- const inRate = best && Number.isFinite(Number(best.in)) ? Number(best.in) : num('ai_input_cost_per_mtok', 3);
- const outRate = best && Number.isFinite(Number(best.out)) ? Number(best.out) : num('ai_output_cost_per_mtok', 15);
- const cachedRate = best && Number.isFinite(Number(best.cached)) ? Number(best.cached) : inRate * 0.1;
- return { in: inRate, out: outRate, cached: cachedRate };
-}
-/** usage = { input (total prompt tokens incl. cached), output, cached } */
-function estimateCost(model, usage) {
- const p = priceFor(model);
- const input = Math.max(0, (usage.input || 0) - (usage.cached || 0));
- return (input / 1e6) * p.in + ((usage.cached || 0) / 1e6) * p.cached + ((usage.output || 0) / 1e6) * p.out;
-}
// ── Images ───────────────────────────────────────────────────
// ── Images ───────────────────────────────────────────────────
async function _loadImage(image) {
async function _loadImage(image) {
@@ -147,138 +73,6 @@ async function toVisionJpeg(image, { maxWidth = 1280, quality = 82 } = {}) {
@@ -147,138 +73,6 @@ async function toVisionJpeg(image, { maxWidth = 1280, quality = 82 } = {}) {
return `data:image/jpeg;base64,${buf.toString('base64')}`;
return `data:image/jpeg;base64,${buf.toString('base64')}`;
}
}
}
}
-function _splitDataUrl(dataUrl) {
- const m = String(dataUrl || '').match(/^data:([^;]+);base64,(.*)$/);
- return m ? { mediaType: m[1], base64: m[2] } : null;
-}
-
-// ── Message shaping ──────────────────────────────────────────
-function _systemBlocks(system) {
- if (!system) return [];
- if (typeof system === 'string') return system.trim() ? [{ text: system, cache: false }] : [];
- return system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: !!x.cache }));
-}
-function _normMessages(messages, user) {
- const out = [];
- for (const m of (messages || [])) {
- if (!m || !m.role) continue;
- out.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content });
- }
- if (user) out.push({ role: 'user', content: user });
- if (!out.length) out.push({ role: 'user', content: '' });
- return out;
-}
-
-function _isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }
-
-function _openaiBody({ p, system, messages, img, json, maxTokens, temperature, cacheKey, jsonMode }) {
- const msgs = [];
- const sysText = system.map(x => x.text).join('\n\n');
- if (sysText) msgs.push({ role: 'system', content: sysText });
- messages.forEach((m, i) => {
- const last = i === messages.length - 1;
- if (last && img && m.role === 'user') {
- const parts = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content || '') }];
- msgs.push({ role: 'user', content: [...parts, { type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${img.base64}` } }] });
- } else msgs.push({ role: m.role, content: Array.isArray(m.content) ? m.content : String(m.content || '') });
- });
- const body = { model: p.model, messages: msgs };
- if (_isReasoningModel(p.model)) {
- body.max_completion_tokens = Math.max(maxTokens, 256) + 512;
- body.reasoning_effort = /^gpt-5/i.test(p.model) ? 'minimal' : 'low';
- } else {
- body.max_tokens = maxTokens;
- if (temperature != null) body.temperature = temperature;
- }
- if (json && jsonMode === 'schema') body.response_format = { type: 'json_schema', json_schema: { name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };
- else if (json && jsonMode === 'object') body.response_format = { type: 'json_object' };
- if (cacheKey && /api\.openai\.com/i.test(p.baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);
- return body;
-}
-function _anthropicBody({ p, system, messages, img, json, maxTokens, temperature }) {
- const body = { model: p.model, max_tokens: Math.max(1, maxTokens) };
- if (system.length) {
- body.system = system.map(x => x.cache ? { type: 'text', text: x.text, cache_control: { type: 'ephemeral' } } : { type: 'text', text: x.text });
- }
- body.messages = messages.map((m, i) => {
- const last = i === messages.length - 1;
- const content = Array.isArray(m.content)
- ? m.content.map(c => (c.type === 'text' ? { type: 'text', text: c.text } : c))
- : [{ type: 'text', text: String(m.content || '') }];
- if (last && img && m.role === 'user') content.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.base64 } });
- return { role: m.role, content };
- });
- if (temperature != null) body.temperature = temperature;
- if (json) {
- body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];
- body.tool_choice = { type: 'tool', name: json.name || 'result' };
- }
- return body;
-}
-
-// ── HTTP ─────────────────────────────────────────────────────
-class LlmHttpError extends Error { constructor(status, message, body) { super(message); this.status = status; this.body = body; } }
-
-// A streamer's own provider (the AI viewers' BYO base URL, p.shared false) is a URL a user typed, so it
-// goes out through the egress guard like every other user-chosen URL (server/net/egress.js): it
-// could otherwise point at Live's own port or another internal service, and the "test connection"
-// button would hand the answer back. The site's provider (ai_base_url, set by the owner) may be
-// internal and is fetched as before.
-async function _post(url, headers, body, timeoutMs, { userChosen = false } = {}) {
- let status, text;
- if (userChosen) {
- const r = await egress.postJson(url, body, { headers, timeoutMs, maxBytes: 4 * 1024 * 1024 });
- status = r.status; text = r.text;
- } else {
- const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
- status = res.status; text = await res.text();
- }
- let j = {}; try { j = text ? JSON.parse(text) : {}; } catch { j = { raw: text }; }
- if (!(status >= 200 && status < 300)) throw new LlmHttpError(status, (j.error && (j.error.message || j.error)) || `HTTP ${status}: ${text.slice(0, 200)}`, j);
- return j;
-}
-
-async function _callOpenAI(p, args, timeoutMs) {
- const headers = p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {};
- let jsonMode = args.json ? 'schema' : null;
- for (let attempt = 0; attempt < 3; attempt++) {
- const body = _openaiBody({ ...args, p, jsonMode });
- try {
- const j = await _post(`${p.baseUrl}/chat/completions`, headers, body, timeoutMs, { userChosen: !p.shared });
- const msg = j.choices && j.choices[0] && j.choices[0].message;
- const text = (msg && typeof msg.content === 'string' ? msg.content : '').trim();
- const u = j.usage || {};
- return { text, usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 } };
- } catch (e) {
- // Gateways without structured-output support: step down schema → json_object → prose.
- if (e instanceof LlmHttpError && e.status === 400 && jsonMode && /response_format|json_schema|schema|strict/i.test(e.message)) {
- jsonMode = jsonMode === 'schema' ? 'object' : null;
- continue;
- }
- throw e;
- }
- }
- throw new Error('structured output unsupported');
-}
-async function _callAnthropic(p, args, timeoutMs) {
- const body = _anthropicBody({ ...args, p });
- const j = await _post(`${p.baseUrl}/messages`, { 'x-api-key': p.apiKey, 'anthropic-version': '2023-06-01' }, body, timeoutMs, { userChosen: !p.shared });
- const content = j.content || [];
- let text = content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
- let jsonOut = null;
- const tool = content.find(c => c.type === 'tool_use');
- if (tool && tool.input) { jsonOut = tool.input; if (!text) text = JSON.stringify(tool.input); }
- const u = j.usage || {};
- const cached = u.cache_read_input_tokens || 0;
- return { text, json: jsonOut, usage: { input: (u.input_tokens || 0) + cached + (u.cache_creation_input_tokens || 0), output: u.output_tokens || 0, cached } };
-}
-
-function _retryable(e) {
- if (!e) return false;
- if (e.name === 'TimeoutError' || e.name === 'AbortError') return true;
- if (e instanceof LlmHttpError) return e.status === 429 || e.status >= 500;
- return /fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket/i.test(e.message || '');
-}
function parseJsonLoose(text) {
function parseJsonLoose(text) {
if (!text) return null;
if (!text) return null;
@@ -297,108 +91,90 @@ function parseJsonLoose(text) {
@@ -297,108 +91,90 @@ function parseJsonLoose(text) {
} catch { return null; }
} catch { return null; }
}
}
+function _meter(o, role, r, provider) {
+ 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, latency_ms: r.latencyMs,
+ });
+ } catch { /* metering is best-effort */ }
+}
+
+let _overrideWarned = false;
+
/**
/**
* The call.
* The call.
* @param {object} o
* @param {object} o
* role, system (string | [{text, cache}]), messages ([{role, content}]), user (shortcut: appended user msg),
* role, system (string | [{text, cache}]), messages ([{role, content}]), user (shortcut: appended user msg),
* image (data URL | http url | path | Buffer), imageMaxWidth (default 1024), json ({name, schema, strict?}),
* image (data URL | http url | path | Buffer), imageMaxWidth (default 1024), json ({name, schema, strict?}),
- * maxTokens, temperature, timeoutMs, retries (default 1), cacheKey,
- * kind (ai_usage.kind), source, ownerUserId, provider (BYO override), skipGate (internal)
+ * maxTokens, temperature, timeoutMs, cacheKey, kind (ai_usage.kind), source, ownerUserId,
+ * provider ({ credentialSubject }: a streamer's own key stored in OpenVibe.AI)
* @returns {Promise<null|{text:string, json:object|null, usage:{input,output,cached}, model:string, provider:string, latencyMs:number}>}
* @returns {Promise<null|{text:string, json:object|null, usage:{input,output,cached}, model:string, provider:string, latencyMs:number}>}
*/
*/
async function complete(o = {}) {
async function complete(o = {}) {
const role = ROLES.includes(o.role) ? o.role : 'legacy';
const role = ROLES.includes(o.role) ? o.role : 'legacy';
- // 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 });
- if (!r) { console.warn(`[AI] ${role}/${o.kind || role} via OpenVibe.AI returned no answer`); 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: 'openvibe-ai', latency_ms: r.latencyMs,
- });
- } catch { /* metering is best-effort */ }
- return r;
- }
- const p = resolveProvider(role, o.provider || null);
- if (p.shared) { if (!isEnabled() || !withinBudget()) return null; }
- else if (!p.apiKey && !/localhost|127\.0\.0\.1|:\d+$/.test(p.baseUrl) && /api\.openai\.com|anthropic\.com/i.test(p.baseUrl)) return null;
-
- let img = null;
- if (o.image) {
- const dataUrl = await toVisionJpeg(o.image, { maxWidth: o.imageMaxWidth || 1024, quality: o.imageQuality || 78 });
- img = dataUrl ? _splitDataUrl(dataUrl) : null;
- if (!img) return null;
- }
- const args = {
- system: _systemBlocks(o.system),
- messages: _normMessages(o.messages, o.user),
- img,
- json: o.json || null,
- maxTokens: Math.max(1, Math.round(o.maxTokens || 400)),
- temperature: o.temperature == null ? null : o.temperature,
- cacheKey: o.cacheKey || null,
- };
- const timeoutMs = o.timeoutMs || DEFAULT_TIMEOUT_MS[role] || 25000;
- const retries = o.retries == null ? 1 : Math.max(0, o.retries);
- const started = Date.now();
- let r = null, lastErr = null;
- for (let attempt = 0; attempt <= retries; attempt++) {
- try {
- r = p.kind === 'anthropic' ? await _callAnthropic(p, args, timeoutMs) : await _callOpenAI(p, args, timeoutMs);
- break;
- } catch (e) {
- lastErr = e;
- if (attempt < retries && _retryable(e)) { await new Promise(res => setTimeout(res, 800 + Math.random() * 700)); continue; }
- break;
+ if (o.provider) {
+ // A streamer's own key stored in OpenVibe.AI (byo-credentials.js): a run with credential { subject };
+ // AI calls their provider with it, within their daily budget. Metered here as 'byo'.
+ if (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);
+ _meter(o, role, r, 'byo');
+ return r;
}
}
+ // A raw key or base URL: Live no longer calls a provider with one, and never falls back to the shared AI.
+ if (!_overrideWarned) {
+ _overrideWarned = true;
+ console.warn(`[AI] ${role}/${o.kind || role}: a raw provider key/base URL is no longer called from Live (keys live in OpenVibe.AI); no answer`);
+ }
+ return null;
}
}
- if (!r) { console.warn(`[AI] ${role}/${p.model} failed:`, lastErr && lastErr.message); return null; }
- const latencyMs = Date.now() - started;
- const usage = r.usage || { input: 0, output: 0, cached: 0 };
- if (!usage.input && !usage.output) {
- // Self-hosted servers often omit usage — estimate so budgets still mean something.
- const approx = (t) => Math.ceil(String(t || '').length / 4);
- usage.input = approx(args.system.map(x => x.text).join('')) + args.messages.reduce((n, m) => n + approx(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)), 0) + (img ? 800 : 0);
- usage.output = approx(r.text);
- usage.estimated = true;
- }
- const cost = estimateCost(p.model, usage);
- try {
- db.recordAiUsage({
- kind: o.kind || role, model: p.model, input_tokens: usage.input, output_tokens: usage.output, cached_tokens: usage.cached || 0,
- cost_usd: cost, owner_user_id: o.ownerUserId || null, source: o.source || null, role, provider: p.shared ? 'shared' : 'byo', latency_ms: latencyMs,
- });
- } catch { /* metering is best-effort */ }
- let json = r.json || null;
- if (o.json && !json) json = parseJsonLoose(r.text);
- return { text: r.text || '', json, usage, model: p.model, provider: p.kind, shared: p.shared, latencyMs, cost };
+ if (!isEnabled() || !withinBudget()) return null;
+ const r = await aiService.complete({ ...o, role }, { toVisionJpeg });
+ if (!r) { console.warn(`[AI] ${role}/${o.kind || role} via OpenVibe.AI returned no answer`); return null; }
+ if (o.json && !r.json) r.json = parseJsonLoose(r.text);
+ _meter(o, role, r, 'openvibe-ai');
+ return r;
}
}
-/** 1-token connectivity probe for a provider override (BYO test) or the shared key. */
+/**
+ * "Test connection" for a key a streamer has just typed for their AI viewers (POST /api/ai-viewers/byo/test),
+ * before it is saved: a one-word request to their provider. This is the ONE direct provider call left in Live,
+ * because the key is not in OpenVibe.AI yet (it goes there only when the streamer saves it), so there is nothing
+ * AI could run it with. The address is the streamer's choice, so the request goes out through the egress guard
+ * (server/net/egress.js): never to Live's own port or any private address, redirects not followed. Nothing is
+ * metered and only ok / an HTTP status comes back, never the provider's answer.
+ * override = { kind?: 'openai'|'anthropic', baseUrl?, apiKey?, model?, models?: { chat } }
+ */
async function testProvider(override = null) {
async function testProvider(override = null) {
+ if (!override || override.credentialSubject) return { ok: false, error: 'No key to test' };
+ const typed = String(override.baseUrl || '').trim().replace(/\/+$/, '');
+ const kind = override.kind === 'anthropic' || /anthropic\.com/i.test(typed) ? 'anthropic' : 'openai';
+ const baseUrl = typed || (kind === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1');
+ const model = String((override.models && override.models.chat) || override.model || 'gpt-4o-mini');
+ const apiKey = String(override.apiKey || '');
+ if (!apiKey && /api\.openai\.com|anthropic\.com/i.test(baseUrl)) return { ok: false, error: 'Enter your API key to test it' };
+ const prompt = [{ role: 'user', content: 'Reply with the single word: ok' }];
+ let url, headers, body;
+ if (kind === 'anthropic') {
+ url = `${baseUrl}/messages`;
+ headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
+ body = { model, max_tokens: 5, temperature: 0, messages: prompt };
+ } else {
+ url = `${baseUrl}/chat/completions`;
+ headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
+ body = { model, messages: prompt };
+ // Reasoning models (gpt-5, o-series) take max_completion_tokens and think before they answer.
+ if (/^(gpt-5|o\d)/i.test(model)) { body.max_completion_tokens = 768; body.reasoning_effort = /^gpt-5/i.test(model) ? 'minimal' : 'low'; }
+ else { body.max_tokens = 5; body.temperature = 0; }
+ }
+ const started = Date.now();
try {
try {
- const r = await complete({ role: 'chat', user: 'Reply with the single word: ok', maxTokens: 5, temperature: 0, timeoutMs: 15000, retries: 0, kind: 'status_check', provider: override, skipGate: true });
- return r ? { ok: true, model: r.model, latencyMs: r.latencyMs } : { ok: false, error: 'AI disabled, over budget, or the provider rejected the request' };
+ const r = await egress.postJson(url, body, { headers, timeoutMs: 15000, maxBytes: 256 * 1024 });
+ if (r.status >= 200 && r.status < 300) return { ok: true, model, latencyMs: Date.now() - started };
+ return { ok: false, error: `The provider answered HTTP ${r.status}` };
} catch (e) { return { ok: false, error: e.message }; }
} catch (e) { return { ok: false, error: e.message }; }
}
}
@@ -409,4 +185,4 @@ aiService.setRecorder((r, m) => db.recordAiUsage({
@@ -409,4 +185,4 @@ aiService.setRecorder((r, m) => db.recordAiUsage({
cost_usd: (r.usage && r.usage.cost_usd) || 0, owner_user_id: m.ownerUserId || null, source: m.source || null, role: m.role || null, provider: 'openvibe-ai', latency_ms: null,
cost_usd: (r.usage && r.usage.cost_usd) || 0, owner_user_id: m.ownerUserId || null, source: m.source || null, role: m.role || null, provider: 'openvibe-ai', latency_ms: null,
}));
}));
-module.exports = { complete, testProvider, resolveProvider, modelForRole, priceFor, estimateCost, toVisionJpeg, parseJsonLoose, isEnabled, withinBudget, defaultModel, ROLES };
+module.exports = { complete, testProvider, toVisionJpeg, parseJsonLoose, isEnabled, withinBudget, ROLES };
@@ -292,20 +292,10 @@ async function analyzeMedia(src, { streamId = null, userId = null, numFrames = n
@@ -292,20 +292,10 @@ async function analyzeMedia(src, { streamId = null, userId = null, numFrames = n
const { text: transcript, segments } = await _transcribeSpan(src, duration);
const { text: transcript, segments } = await _transcribeSpan(src, duration);
let overview = null;
let overview = null;
- if (frames.length || transcript) {
- const parts = [];
- if (frames.length) parts.push('Visual observations across the video (in order):\n' + frames.map(f => `- ${f.description}`).join('\n'));
- if (transcript) parts.push('Audio transcript (sampled from the recording):\n"' + transcript.slice(0, 4000) + '"');
- const prompt = `You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don't invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n${parts.join('\n\n')}`;
- const aiService = require('./ai-service');
- if (aiService.enabled()) {
- // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.media.overview).
- const o = ai.isEnabled() && ai.withinBudget()
- ? await aiService.structured('live.media.overview', { frames: frames.map(f => String(f.description || '').slice(0, 1000)).filter(Boolean).slice(0, 60), transcript: String(transcript || '').slice(0, 200000) }, { meter: { kind: 'media_overview', role: 'legacy', ownerUserId: userId } })
- : null;
- overview = o && o.overview ? o.overview : null;
- } else overview = await ai.summarizeText(prompt, 400, 'media_overview');
- if (overview) overview = overview.slice(0, 2000);
+ if ((frames.length || transcript) && ai.isEnabled() && ai.withinBudget()) {
+ // The prompt lives in OpenVibe.AI (workflow live.media.overview).
+ const o = await require('./ai-service').structured('live.media.overview', { frames: frames.map(f => String(f.description || '').slice(0, 1000)).filter(Boolean).slice(0, 60), transcript: String(transcript || '').slice(0, 200000) }, { meter: { kind: 'media_overview', role: 'legacy', ownerUserId: userId } });
+ overview = o && o.overview ? String(o.overview).slice(0, 2000) : null;
}
}
return { overview: overview || null, transcript: transcript || '', segments: segments || [], frames, duration };
return { overview: overview || null, transcript: transcript || '', segments: segments || [], frames, duration };
}
}
@@ -1,31 +1,25 @@
@@ -1,31 +1,25 @@
/**
/**
* AI Chat Viewers — budget + cost routing.
* AI Chat Viewers — budget + cost routing.
*
*
- * Every bot LLM call goes through generate(), which routes to either:
- * - the SHARED OpenVibe.Live key (default), metered + capped per streamer at
+ * Every bot LLM call goes through generate(), a run on OpenVibe.AI either way:
+ * - the site's AI (default), metered + capped per streamer at
* channel_ai_config.daily_budget_cents (default 20¢/day). Over the cap → bots
* channel_ai_config.daily_budget_cents (default 20¢/day). Over the cap → bots
* go quiet (returns null). Also respects the admin master switch + global cap.
* go quiet (returns null). Also respects the admin master switch + global cap.
- * - the streamer's BYO key (uncapped; their provider bills them). Usage is still
- * recorded (estimated) for display.
+ * - the streamer's own key, stored in OpenVibe.AI (byo_in_ai; their provider bills
+ * them, within the budget they set there). Usage is still recorded for display.
+ * A key that never moved there is not used: Live calls no provider itself.
*
*
* Spend is attributed to the streamer via ai_usage.owner_user_id with
* Spend is attributed to the streamer via ai_usage.owner_user_id with
* source='ai_viewers', which is the foundation for per-streamer budgets + billing.
* source='ai_viewers', which is the foundation for per-streamer budgets + billing.
*/
*/
const db = require('../../db/database');
const db = require('../../db/database');
const ai = require('../ai-analysis');
const ai = require('../ai-analysis');
-const aiProvider = require('../ai-provider');
const SOURCE = 'ai_viewers';
const SOURCE = 'ai_viewers';
-// Rough token estimate from text length (~4 chars/token) — used only for the BYO
-// path (whose provider doesn't report usage) so the dashboard meter has a number.
-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; }
-}
+// Only a key stored in OpenVibe.AI runs; a key (or keyless self-hosted address) left in Live's
+// database would need Live to call the provider itself, which it no longer does.
+function byoUsable(cfg) { return !!cfg.byo_in_ai; }
/**
/**
* Snapshot of a streamer's AI-viewer budget state.
* Snapshot of a streamer's AI-viewer budget state.
@@ -61,15 +55,14 @@ async function generate(userId, { system = '', user = '', image = null, maxToken
@@ -61,15 +55,14 @@ async function generate(userId, { system = '', user = '', image = null, maxToken
if (!st.active) return null;
if (!st.active) return null;
if (st.useShared) {
if (st.useShared) {
- // Shared admin key: metered + attributed inside ai-analysis.viewerComplete.
+ // The site's AI: metered + attributed inside ai-analysis.viewerComplete.
return ai.viewerComplete({ system, user, image, maxTokens, temperature, ownerUserId: userId });
return ai.viewerComplete({ system, user, image, maxTokens, temperature, ownerUserId: userId });
}
}
- // 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.
+ // Their own key in OpenVibe.AI — a run with credential { subject }, metered as 'byo' in llm.js.
+ // Never fall through to the site's AI: no usable own key means the streamer's bots stay quiet.
const provider = byoProvider(st.cfg);
const provider = byoProvider(st.cfg);
- if (!provider) return null;
+ if (!provider || !provider.credentialSubject) 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,
@@ -79,8 +72,10 @@ async function generate(userId, { system = '', user = '', image = null, maxToken
@@ -79,8 +72,10 @@ async function generate(userId, { system = '', user = '', image = null, maxToken
}
}
/**
/**
- * 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.
+ * The provider for a streamer's own-key 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, for llm.complete; the key itself never comes back
+ * here. Otherwise the typed key, address and models, which only llm.testProvider uses ("test connection" before
+ * the key is saved to OpenVibe.AI).
*/
*/
function byoProvider(cfg) {
function byoProvider(cfg) {
if (cfg.byo_in_ai) {
if (cfg.byo_in_ai) {
@@ -95,7 +90,7 @@ function byoProvider(cfg) {
@@ -95,7 +90,7 @@ function byoProvider(cfg) {
if (m) models[role] = String(m);
if (m) models[role] = String(m);
}
}
return {
return {
- baseUrl: cfg.byo_base_url || extra.base_url || aiProvider.DEFAULT_BASE_URL,
+ baseUrl: cfg.byo_base_url || extra.base_url || '',
apiKey: cfg.byo_key || '',
apiKey: cfg.byo_key || '',
model: cfg.byo_model || extra.model || 'gpt-4o-mini',
model: cfg.byo_model || extra.model || 'gpt-4o-mini',
models,
models,
@@ -103,9 +98,13 @@ function byoProvider(cfg) {
@@ -103,9 +98,13 @@ function byoProvider(cfg) {
};
};
}
}
-/** Today's shared-key spend on AI viewers across ALL channels (global viewers cap). */
+/**
+ * Today's spend of the site's AI on AI viewers across ALL channels (global viewers cap): every row but a streamer's
+ * own key. Runs on OpenVibe.AI are recorded as 'openvibe-ai' (the old direct calls were 'shared'), so counting only
+ * 'shared' stopped counting anything once AI_SERVICE=remote went live.
+ */
function globalViewerSpendToday() {
function globalViewerSpendToday() {
- try { return db.get("SELECT COALESCE(SUM(cost_usd),0) AS c FROM ai_usage WHERE source = ? AND COALESCE(provider,'shared') = 'shared' AND created_at >= date('now')", [SOURCE])?.c || 0; } catch { return 0; }
+ try { return db.get("SELECT COALESCE(SUM(cost_usd),0) AS c FROM ai_usage WHERE source = ? AND COALESCE(provider,'shared') <> 'byo' AND created_at >= date('now')", [SOURCE])?.c || 0; } catch { return 0; }
}
}
/**
/**
@@ -129,11 +129,14 @@ router.post('/byo/test', requireAuth, async (req, res) => {
@@ -129,11 +129,14 @@ router.post('/byo/test', requireAuth, async (req, res) => {
// A stored key lives in OpenVibe.AI: test it there (a one-word run with credential { subject }).
// 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;
const typedKey = body.byo_key && body.byo_key !== KEY_SENTINEL;
if (cfg.byo_in_ai && !typedKey) {
if (cfg.byo_in_ai && !typedKey) {
+ const provider = budget.byoProvider(cfg);
+ if (!provider) return res.json({ ok: false, error: 'Link a Network account to use your own key' });
const started = Date.now();
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 r = await ai.llm.complete({ role: 'chat', user: 'Reply with the single word: ok', maxTokens: 5, temperature: 0, timeoutMs: 15000, kind: 'status_check', ownerUserId: req.user.id, provider });
+ return res.json(r && r.text ? { ok: true, model: r.model, latencyMs: 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 });
+ // A key typed but not saved yet (so not in OpenVibe.AI): llm.testProvider, the one direct provider call left.
+ const override = budget.byoProvider({ ...cfg, byo_in_ai: 0, byo_key: typedKey ? 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 }); }
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
* (recent XP + beef wins).
* (recent XP + beef wins).
* persona → AI "character select" bio written from their TRANSCRIPTS, cached 24 h.
* persona → AI "character select" bio written from their TRANSCRIPTS, cached 24 h.
* quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.
* quotes → AI-picked "things they actually said" from the transcripts, VOD-linked.
- * image → optional AI character portrait drawn from their own stream frames.
+ * image → a character portrait drawn earlier from their own stream frames (no new ones).
*
*
* Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).
* Nothing here touches the AI unless AI is enabled and within budget (server/ai/llm.js).
*/
*/
@@ -33,7 +33,6 @@ const ACTIVE_DAYS = 45;
@@ -33,7 +33,6 @@ const ACTIVE_DAYS = 45;
const STATS_WINDOW_DAYS = 90;
const STATS_WINDOW_DAYS = 90;
const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;
const PERSONA_TTL_MS = 24 * 60 * 60 * 1000;
const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;
const QUOTES_TTL_MS = 24 * 60 * 60 * 1000;
-const IMAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const RATING_MIN = 40;
const RATING_MIN = 40;
const MIN_QUOTE_LINES = 20;
const MIN_QUOTE_LINES = 20;
const TALK_BONUS_MAX = 12;
const TALK_BONUS_MAX = 12;
@@ -113,10 +112,7 @@ function boolSetting(key, fallback = false) {
@@ -113,10 +112,7 @@ function boolSetting(key, fallback = false) {
}
}
function arenaEnabled() { return boolSetting('arena_enabled', true); }
function arenaEnabled() { return boolSetting('arena_enabled', true); }
function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
function aiOn() { try { return llm.isEnabled() && llm.withinBudget(); } catch { return false; } }
-function imageGenAvailable() {
- if (!aiOn() || !boolSetting('ai_image_enabled', false)) return false;
- try { return llm.resolveProvider('vision').kind === 'openai'; } catch { return false; }
-}
+function imageGenAvailable() { return false; } // see "Image" below
// ── Raw stats ────────────────────────────────────────────────
// ── Raw stats ────────────────────────────────────────────────
@@ -388,140 +384,19 @@ async function generateQuotes(userId, { force = false } = {}) {
@@ -388,140 +384,19 @@ async function generateQuotes(userId, { force = false } = {}) {
return result;
return result;
}
}
-// ── Image (AI, optional) ─────────────────────────────────────
+// ── Image ────────────────────────────────────────────────────
+// Portraits drawn earlier stay on the cards; no new ones are drawn. They came from the site's image
+// provider called with the shared key, and Live calls no model provider itself any more (roadmap
+// WS-O task 2); OpenVibe.AI has no image workflow yet.
-function imageIsFresh(row) {
- if (!row || !row.image_path || !row.image_generated_at) return false;
- if (!fs.existsSync(path.join(ARENA_DIR, path.basename(row.image_path)))) return false;
- return Date.now() - Date.parse(row.image_generated_at + 'Z') < IMAGE_TTL_MS;
-}
function imageUrlFor(row) {
function imageUrlFor(row) {
if (!row || !row.image_path) return null;
if (!row || !row.image_path) return null;
const base = path.basename(row.image_path);
const base = path.basename(row.image_path);
return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
return fs.existsSync(path.join(ARENA_DIR, base)) ? `/data/arena/${base}` : null;
}
}
-async function latestThumbnailFor(userId) { const r = await referenceImagesFor(userId); return r.length ? r[0] : null; }
-/**
- * Up to REF_MAX real frames of THIS streamer's streams, newest first: the live thumbnail, AI-moment
- * frames the vision job persisted (data/ai-moments/<streamId>/<offset>.jpg), VOD thumbnails. Local
- * paths or URLs. These are what the portrait is drawn FROM, so every fighter looks like their own
- * stream — their setup, their lighting, their gear, their face — not a generic hero.
- */
-const REF_MAX = 3;
-async function referenceImagesFor(userId) {
- const out = [];
- const push = (p) => { if (p && !out.includes(p) && out.length < REF_MAX) out.push(p); };
- try {
- const live = db.get('SELECT id FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1', [userId]);
- if (live) {
- const thumbs = require('../media-proxy/live-thumbs');
- const url = thumbs.getCurrentLiveThumbnailUrl(live.id);
- if (url) { const local = paths.data('live-thumbs', path.basename(url)); if (fs.existsSync(local)) push(local); else if (url.startsWith('http')) push(url); }
- }
- } catch { /* */ }
- try {
- const momentsDir = paths.dir('AI_MOMENTS_PATH', 'ai-moments');
- const streams = db.all(`SELECT id FROM streams WHERE user_id = ? AND duration_seconds > 0 ORDER BY started_at DESC LIMIT 6`, [userId]);
- for (const s of streams) {
- const dir = path.join(momentsDir, String(s.id));
- if (!fs.existsSync(dir)) continue;
- // Prefer frames the vision model described as showing a person/face/reaction (they make better portraits).
- const memRows = db.all(`SELECT offset_seconds, description, thumbnail_url FROM stream_memories WHERE stream_id = ? ORDER BY CASE WHEN LOWER(description) LIKE '%person%' OR LOWER(description) LIKE '%man %' OR LOWER(description) LIKE '%woman%' OR LOWER(description) LIKE '%face%' OR LOWER(description) LIKE '%wearing%' OR LOWER(description) LIKE '%headphones%' THEN 0 ELSE 1 END, id DESC LIMIT 4`, [s.id]);
- for (const m of memRows) { const f = m.thumbnail_url && m.thumbnail_url.includes('/data/ai-moments/') ? path.join(momentsDir, ...m.thumbnail_url.split('/data/ai-moments/')[1].split('/')) : path.join(dir, `${m.offset_seconds}.jpg`); if (fs.existsSync(f)) push(f); }
- if (out.length >= REF_MAX) break;
- }
- } catch { /* */ }
- // Media-hosted thumbnails of their streams (what prod actually has: https://openvibe.media/t/vod-….jpg).
- try { for (const r of db.all(`SELECT thumbnail_url FROM streams WHERE user_id = ? AND thumbnail_url LIKE 'http%' ORDER BY started_at DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }
- try { for (const r of db.all(`SELECT m.thumbnail_url FROM stream_memories m WHERE m.user_id = ? AND m.thumbnail_url LIKE 'http%' AND (LOWER(m.description) LIKE '%person%' OR LOWER(m.description) LIKE '%face%' OR LOWER(m.description) LIKE '%wearing%' OR LOWER(m.description) LIKE '%headphones%') ORDER BY m.id DESC LIMIT 3`, [userId])) push(r.thumbnail_url); } catch { /* */ }
- // Their newest public VODs' thumbnails, from OpenVibe.Media.
- if (out.length < REF_MAX) {
- try {
- const media = require('../media-client');
- for (const v of await require('../media-proxy/lookups').userVods(userId, { limit: 3 })) {
- const url = media.publicUrl(v.thumbnail_url);
- if (url && /^https?:\/\//i.test(url)) push(url);
- }
- } catch { /* */ }
- }
- return out;
-}
-async function loadImageBuffer(src) {
- try {
- if (/^https?:\/\//i.test(src)) { const r = await fetch(src, { signal: AbortSignal.timeout(15000) }); if (!r.ok) return null; return Buffer.from(await r.arrayBuffer()); }
- return fs.readFileSync(src);
- } catch { return null; }
-}
-const SCENE_SYSTEM = 'Describe this stream thumbnail as a SCENE for an illustrator in ≤ 60 words: setting, objects, lighting, colours, mood, what activity is happening. Do NOT describe any person\'s face, body, skin, hair, age, gender or identity — refer to a person only as "the host" if at all. Plain text only.';
-const _imageInFlight = new Map();
-async function generateImage(userId, { force = false } = {}) {
+async function generateImage(userId) {
ensureTables();
ensureTables();
- const row = profileRow(userId);
- if (!force && imageIsFresh(row)) return imageUrlFor(row);
- if (!imageGenAvailable()) return imageUrlFor(row);
- if (_imageInFlight.has(userId)) return _imageInFlight.get(userId);
- const task = (async () => {
- const entry = loadRoster().byId[userId];
- const persona = parseJson(row?.persona_json) || (entry ? fallbackPersona(entry) : null);
- if (!persona) return null;
- let scene = '';
- const refSources = await referenceImagesFor(userId);
- const thumb = refSources.length ? null : await latestThumbnailFor(userId); // scene text only for the no-frames fallback
- if (thumb) { try { const d = await llm.complete({ role: 'vision', kind: 'arena_scene', source: 'arena', ownerUserId: userId, system: SCENE_SYSTEM, user: 'Describe the scene.', image: thumb, maxTokens: 120, temperature: 0.4, timeoutMs: 30000 }); scene = (d && d.text || '').trim(); } catch { scene = ''; } }
- const color = entry?.user?.profile_color || '#8b5cf6';
- const cs = Array.isArray(persona.custom_stats) ? persona.custom_stats.slice(0, 4).map(x => `${x.name} ${x.value}`).join(', ') : '';
- // Reference frames: the portrait is drawn FROM the streamer's own stream (image edit) whenever we
- // have frames; the text-only generation is the fallback for streamers with no frames yet.
- const refs = [];
- for (const src of refSources) { const buf = await loadImageBuffer(src); if (buf && buf.length > 4000) refs.push({ src, buf }); }
- const prompt = [
- refs.length ? `Turn the attached frames from this streamer's live stream into ONE fighting-game character-select portrait of them as "${persona.fighter_name}" — ${persona.title}. Keep what makes their stream recognisable: their setup, room, gear, lighting, clothing, silhouette, hair, headphones, camera angle, the vibe of the scene — exaggerated into a stylised caricature-hero (like a Street Fighter select screen), never a photo.` : `Fighting-game character-select portrait of an original stylised hero called "${persona.fighter_name}" — ${persona.title}.`,
- `Class: ${persona.class}. Element: ${persona.element}. Signature move: ${persona.signature_move?.name} (${persona.signature_move?.description}).${cs ? ` Their stats: ${cs}.` : ''}`,
- entry?.raw?.category ? `Costume and props inspired by ${String(entry.raw.category).replace(/[-_]/g, ' ')} streaming.` : '',
- !refs.length && scene ? `Background inspired by this scene: ${scene}` : (refs.length ? 'Background: their actual streaming environment from the frames, pushed into a dramatic arena lighting.' : 'Background: dark neon arena.'),
- `Colour palette led by ${color}. Bold comic-book line art, dramatic rim light, dynamic pose, three-quarter view, waist up.`,
- 'No text, no letters, no logos, no watermark, no UI overlays.',
- ].filter(Boolean).join(' ');
- const p = llm.resolveProvider('vision');
- const model = String(setting('ai_image_model', 'gpt-image-1'));
- const quality = String(setting('ai_image_quality', 'low'));
- const started = Date.now();
- let b64 = null;
- try {
- let res;
- if (refs.length && !/^dall-e/i.test(model)) {
- // images/edits: the model sees the real frames and restyles them.
- const fd = new FormData();
- fd.append('model', model); fd.append('prompt', prompt); fd.append('n', '1'); fd.append('size', '1024x1024'); fd.append('quality', quality);
- for (const r of refs) fd.append('image[]', new Blob([r.buf], { type: 'image/jpeg' }), path.basename(String(r.src)).replace(/[^a-z0-9._-]/gi, '_') || 'frame.jpg');
- res = await fetch(`${p.baseUrl}/images/edits`, { method: 'POST', headers: { ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) }, body: fd, signal: AbortSignal.timeout(180000) });
- } else {
- const body = { model, prompt, n: 1, size: '1024x1024' };
- if (/^dall-e/i.test(model)) body.response_format = 'b64_json'; else body.quality = quality;
- res = await fetch(`${p.baseUrl}/images/generations`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(p.apiKey ? { Authorization: `Bearer ${p.apiKey}` } : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });
- }
- const j = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error((j.error && (j.error.message || j.error)) || `HTTP ${res.status}`);
- const item = j.data && j.data[0];
- if (item?.b64_json) b64 = item.b64_json;
- else if (item?.url) { const r2 = await fetch(item.url, { signal: AbortSignal.timeout(60000) }); b64 = Buffer.from(await r2.arrayBuffer()).toString('base64'); }
- if (!b64) throw new Error('no image in response');
- } catch (err) {
- console.warn(`[Arena] image generation failed for user ${userId}:`, err.message);
- db.run('INSERT INTO arena_profiles (user_id, image_error, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET image_error = excluded.image_error, updated_at = CURRENT_TIMESTAMP', [userId, err.message.slice(0, 300)]);
- return imageUrlFor(row);
- }
- const file = `u${userId}-${Date.now().toString(36)}.png`;
- fs.writeFileSync(path.join(ARENA_DIR, file), Buffer.from(b64, 'base64'));
- if (row?.image_path) { try { fs.unlinkSync(path.join(ARENA_DIR, path.basename(row.image_path))); } catch { /* */ } }
- db.run(`INSERT INTO arena_profiles (user_id, image_path, image_prompt, image_model, image_generated_at, image_error, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP)
- ON CONFLICT(user_id) DO UPDATE SET image_path = excluded.image_path, image_prompt = excluded.image_prompt, image_model = excluded.image_model, image_generated_at = CURRENT_TIMESTAMP, image_error = NULL, updated_at = CURRENT_TIMESTAMP`, [userId, file, prompt, model]);
- try { db.recordAiUsage({ kind: 'arena_image', model, cost_usd: Number(setting('ai_image_cost_usd', 0.011)) || 0, owner_user_id: userId, source: 'arena', role: 'image', provider: 'shared', latency_ms: Date.now() - started }); } catch { /* */ }
- console.log(`[Arena] portrait generated for user ${userId} (${model}, ${refs.length ? `from ${refs.length} of their own frames` : 'text only'}, ${Date.now() - started} ms)`);
- return `/data/arena/${file}`;
- })().finally(() => _imageInFlight.delete(userId));
- _imageInFlight.set(userId, task);
- return task;
+ return imageUrlFor(profileRow(userId));
}
}
// ── Fighter cards ────────────────────────────────────────────
// ── Fighter cards ────────────────────────────────────────────
@@ -560,7 +435,7 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
@@ -560,7 +435,7 @@ async function getFighter(usernameOrId, { generate = true } = {}) {
try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); }
try { await generatePersona(user.id); } catch (e) { console.warn('[Arena] persona:', e.message); }
}
}
const card = cardFor(user.id, roster, { includeQuotes: true });
const card = cardFor(user.id, roster, { includeQuotes: true });
- card.image_pending = !card.image_url && _imageInFlight.has(user.id);
+ card.image_pending = false;
card.image_generation = imageGenAvailable() ? 'ai' : 'off';
card.image_generation = imageGenAvailable() ? 'ai' : 'off';
try { card.beefs = require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }
try { card.beefs = require('./beef').forUser(user.id, 8); } catch { card.beefs = []; }
return card;
return card;
@@ -659,7 +534,7 @@ function status() {
@@ -659,7 +534,7 @@ function status() {
let beefs = {}, moments = 0;
let beefs = {}, moments = 0;
try { beefs = db.get(`SELECT SUM(status = 'open') AS open, SUM(status = 'resolved') AS resolved FROM arena_beefs`) || {}; moments = db.get(`SELECT COUNT(*) AS n FROM arena_mic_moments WHERE created_at >= datetime('now', '-1 day')`)?.n || 0; } catch { /* */ }
try { beefs = db.get(`SELECT SUM(status = 'open') AS open, SUM(status = 'resolved') AS resolved FROM arena_beefs`) || {}; moments = db.get(`SELECT COUNT(*) AS n FROM arena_mic_moments WHERE created_at >= datetime('now', '-1 day')`)?.n || 0; } catch { /* */ }
return {
return {
- mode: 'battle-cam', enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: imageGenAvailable() ? String(setting('ai_image_model', 'gpt-image-1')) : null,
+ mode: 'battle-cam', enabled: arenaEnabled(), ai: aiOn(), image_generation: imageGenAvailable(), image_model: null,
roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,
roster: roster.order.length, with_voice_data: roster.order.filter(id => roster.byId[id].raw.voice.has_data).length,
personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,
personas: counts.personas || 0, quotes: counts.quotes || 0, images: counts.images || 0,
beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, mic_moments_24h: moments, live_fighters: liveFighters().length,
beefs_open: beefs.open || 0, beefs_resolved: beefs.resolved || 0, mic_moments_24h: moments, live_fighters: liveFighters().length,
@@ -1098,38 +1098,33 @@ function initDb() {
@@ -1098,38 +1098,33 @@ function initDb() {
// could delete, which would have multiplied every balance by 100 again on the next boot.
// could delete, which would have multiplied every balance by 100 again on the next boot.
// AI analysis subsystem (configured in openvibe.network/admin → AI). Master switch
// AI analysis subsystem (configured in openvibe.network/admin → AI). Master switch
- // OFF by default so no API calls (or cost) happen until an admin enables it.
+ // OFF by default so no AI runs (or cost) happen until an admin enables it. Every call is a
+ // run on OpenVibe.AI, which holds the provider key, the models and their prices.
const aiSeeds = [
const aiSeeds = [
['ai_enabled', 'false', 'Master switch: enable AI analysis (pastes + stream memories)', 'boolean'],
['ai_enabled', 'false', 'Master switch: enable AI analysis (pastes + stream memories)', 'boolean'],
- ['ai_provider', 'anthropic', 'AI provider: anthropic | openai', 'string'],
- ['ai_api_key', '', 'AI API key (Anthropic or OpenAI-compatible)', 'string'],
- ['ai_base_url', '', 'Optional custom base URL (OpenAI-compatible gateway). Blank = provider default', 'string'],
- ['ai_model', 'claude-sonnet-5', 'Vision-capable model id (e.g. claude-sonnet-5)', 'string'],
['ai_paste_analysis_enabled', 'true', 'Analyze image + text pastes (when AI is enabled)', 'boolean'],
['ai_paste_analysis_enabled', 'true', 'Analyze image + text pastes (when AI is enabled)', 'boolean'],
['ai_stream_memory_enabled', 'false', 'Periodically analyze live-stream thumbnails into timestamped memories', 'boolean'],
['ai_stream_memory_enabled', 'false', 'Periodically analyze live-stream thumbnails into timestamped memories', 'boolean'],
['ai_stream_capture_interval_sec', '120', 'Seconds between live-stream AI memory captures', 'number'],
['ai_stream_capture_interval_sec', '120', 'Seconds between live-stream AI memory captures', 'number'],
['ai_transcription_enabled', 'true', 'Transcribe live-stream/clip/VOD audio into memories — FREE, runs locally via whisper.cpp (no API/cost). Requires whisper.cpp installed on the server', 'boolean'],
['ai_transcription_enabled', 'true', 'Transcribe live-stream/clip/VOD audio into memories — FREE, runs locally via whisper.cpp (no API/cost). Requires whisper.cpp installed on the server', 'boolean'],
['ai_timeline_enabled', 'false', 'CONTINUOUS audio timeline — transcribes the WHOLE live stream (not a 12s sample every 2min) and detects non-speech sounds, into a searchable timestamped timeline. FREE/local, but uses noticeably more CPU than sampling', 'boolean'],
['ai_timeline_enabled', 'false', 'CONTINUOUS audio timeline — transcribes the WHOLE live stream (not a 12s sample every 2min) and detects non-speech sounds, into a searchable timestamped timeline. FREE/local, but uses noticeably more CPU than sampling', 'boolean'],
['ai_max_cost_usd_per_day', '0', 'Daily AI spend cap in USD (0 = no cap)', 'number'],
['ai_max_cost_usd_per_day', '0', 'Daily AI spend cap in USD (0 = no cap)', 'number'],
- ['ai_input_cost_per_mtok', '3.0', 'Estimated input cost per million tokens (for cost breakdown)', 'number'],
- ['ai_output_cost_per_mtok', '15.0', 'Estimated output cost per million tokens (for cost breakdown)', 'number'],
- // Per-role model routing (blank = use ai_model). chat = short bot lines, vision = frame/image
- // analysis (must be vision-capable), director = the AI-viewers planner (structured JSON),
- // summary = memories/overviews/insight folds.
- ['ai_model_chat', '', 'Model for short chat-style generations (AI viewers lines). Blank = ai_model', 'string'],
- ['ai_model_vision', '', 'Model for image/frame analysis (must support vision). Blank = ai_model', 'string'],
- ['ai_model_director', '', 'Model for the AI-viewers director (plans several lines per call, JSON output). Blank = ai_model', 'string'],
- ['ai_model_summary', '', 'Model for summaries/overviews/memory folds. Blank = ai_model', 'string'],
- ['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"gpt-5-mini":{"in":0.25,"out":2,"cached":0.025},"gpt-5":{"in":1.25,"out":10,"cached":0.125},"gpt-4o-mini":{"in":0.15,"out":0.6,"cached":0.075},"gpt-4.1-mini":{"in":0.4,"out":1.6,"cached":0.1},"claude-haiku":{"in":0.8,"out":4,"cached":0.08},"claude-sonnet":{"in":3,"out":15,"cached":0.3}}', 'USD per 1M tokens by model-id prefix: {"<model>":{"in","out","cached"}}. Longest prefix wins; missing → ai_input/output_cost_per_mtok', 'json'],
['ai_viewers_enabled', 'true', 'Kill switch for the AI chat viewers feature (all channels)', 'boolean'],
['ai_viewers_enabled', 'true', 'Kill switch for the AI chat viewers feature (all channels)', 'boolean'],
['ai_viewers_max_roster', '12', 'Max AI viewers per channel', 'number'],
['ai_viewers_max_roster', '12', 'Max AI viewers per channel', 'number'],
['ai_viewers_max_lines_per_min', '12', 'Hard ceiling on bot lines per minute per channel', 'number'],
['ai_viewers_max_lines_per_min', '12', 'Hard ceiling on bot lines per minute per channel', 'number'],
- ['ai_viewers_global_cap_usd_per_day', '0', 'Daily USD cap for ALL AI-viewer spend on the shared key (0 = none)', 'number'],
+ ['ai_viewers_global_cap_usd_per_day', '0', 'Daily USD cap for ALL AI-viewer spend on the site’s AI, not streamers’ own keys (0 = none)', 'number'],
['ai_viewers_default_settings_json', '{}', 'Admin defaults for per-channel AI viewer settings (overrides built-in defaults)', 'json'],
['ai_viewers_default_settings_json', '{}', 'Admin defaults for per-channel AI viewer settings (overrides built-in defaults)', 'json'],
];
];
const seedAi = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
const seedAi = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
for (const [k, v, d, t] of aiSeeds) seedAi.run(k, v, d, t);
for (const [k, v, d, t] of aiSeeds) seedAi.run(k, v, d, t);
try { database.exec("DELETE FROM site_settings WHERE key = 'ai_viewers_engine'"); } catch { /* */ }
try { database.exec("DELETE FROM site_settings WHERE key = 'ai_viewers_engine'"); } catch { /* */ }
+ // Live's own provider path is gone (roadmap WS-O task 2): drop the shared key and the settings only it read
+ // (provider, base URL, models per role, prices, the Arena's image provider). The admin page lists only rows
+ // that exist, so they leave it too.
+ try {
+ database.exec(`DELETE FROM site_settings WHERE key IN ('ai_api_key', 'ai_provider', 'ai_base_url', 'ai_model', 'ai_model_chat', 'ai_model_vision',
+ 'ai_model_director', 'ai_model_summary', 'ai_pricing_json', 'ai_input_cost_per_mtok', 'ai_output_cost_per_mtok',
+ 'ai_image_enabled', 'ai_image_model', 'ai_image_quality', 'ai_image_cost_usd')`);
+ } catch { /* */ }
// PowerChat monetization (donations/tips). App-level OAuth client + webhook secret
// PowerChat monetization (donations/tips). App-level OAuth client + webhook secret
// are configured here by the owner; each streamer then connects their own PowerChat
// are configured here by the owner; each streamer then connects their own PowerChat
@@ -15,8 +15,9 @@
@@ -15,8 +15,9 @@
* - an English message in a non-English channel → the channel's language
* - an English message in a non-English channel → the channel's language
* (so the streamer can read his own chat without anyone typing Japanese)
* (so the streamer can read his own chat without anyone typing Japanese)
*
*
- * Every call is best-effort and metered through ai/llm.js (shared key, daily budget); when
- * AI is off it all silently returns null and the site behaves exactly as before.
+ * Every translation is a run of OpenVibe.AI's live.translate workflow (ai/ai-service.js), gated on
+ * ai/llm.js's master switch; when AI is off it all silently returns null and the site behaves
+ * exactly as before.
*/
*/
'use strict';
'use strict';
const crypto = require('crypto');
const crypto = require('crypto');
@@ -138,7 +139,7 @@ function siteEnabled() {
@@ -138,7 +139,7 @@ function siteEnabled() {
return !(String(v) === 'false' || String(v) === '0');
return !(String(v) === 'false' || String(v) === '0');
} catch { return true; }
} catch { return true; }
}
}
-function available() { return !!(llm && llm.isEnabled && llm.isEnabled() && siteEnabled()); }
+function available() { return !!(llm && aiService && llm.isEnabled && llm.isEnabled() && siteEnabled()); }
let _tableReady = false;
let _tableReady = false;
function _ensureTable() {
function _ensureTable() {
@@ -193,37 +194,11 @@ async function translate(text, { from = 'auto', to = 'en', context = 'chat', max
@@ -193,37 +194,11 @@ async function translate(text, { from = 'auto', to = 'en', context = 'chat', max
const ok = await _slot();
const ok = await _slot();
if (!ok) return null;
if (!ok) return null;
try {
try {
- if (aiService && aiService.enabled()) {
- // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.translate).
- const o = await aiService.structured('live.translate', { text: String(text).trim(), from, to, context, max_tokens: maxTokens || undefined });
- if (o && o.unchanged) { _memSet(key, ''); return null; }
- const remote = o && typeof o.text === 'string' ? o.text.trim() : '';
- if (!remote) return null;
- _memSet(key, remote);
- try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, remote]); } catch { /* */ }
- return remote;
- }
- const src = from === 'auto' ? 'the source language' : langName(from);
- const kind = context === 'bio' ? 'a streamer\'s profile bio'
- : context === 'speech' ? 'lines of live-stream speech (a casual gamer talking to his chat)'
- : 'a live-stream chat message';
- const system = [
- `You translate ${kind} from ${src} to ${langName(to)}.`,
- 'Rules: keep the tone, slang, jokes, profanity and emoji as they are; keep names, URLs, :emotes:, !commands and @mentions unchanged;',
- 'never add explanations, notes, quotes or brackets; if it is already in the target language, return it unchanged.',
- context === 'speech' ? 'Input may contain several lines separated by newlines — return exactly the same number of lines, in order.' : '',
- 'Output ONLY the translation.',
- ].filter(Boolean).join(' ');
- const r = await llm.complete({
- role: 'chat', kind: 'translate', source: `translate:${context}`,
- system, user: String(text).trim(),
- maxTokens: Math.max(40, Math.min(maxTokens || 600, Math.round(String(text).length * 2.2) + 60)),
- temperature: 0.2, timeoutMs: 15000, retries: 0,
- });
- const out = r && typeof r.text === 'string' ? r.text.trim().replace(/^["“]|["”]$/g, '') : '';
+ // The prompt lives in OpenVibe.AI (workflow live.translate).
+ const o = await aiService.structured('live.translate', { text: String(text).trim(), from, to, context, max_tokens: maxTokens || undefined });
+ if (o && o.unchanged) { _memSet(key, ''); return null; }
+ const out = o && typeof o.text === 'string' ? o.text.trim() : '';
if (!out) return null;
if (!out) return null;
- // A translation identical to the input means it was already in the target language.
- if (out === String(text).trim()) { _memSet(key, ''); return null; }
_memSet(key, out);
_memSet(key, out);
try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, out]); } catch { /* */ }
try { db.run('INSERT OR REPLACE INTO translations (key, src, dst, text) VALUES (?, ?, ?, ?)', [key, from, to, out]); } catch { /* */ }
return out;
return out;
@@ -113,14 +113,6 @@ async function gather(streamId) {
@@ -113,14 +113,6 @@ async function gather(streamId) {
}
}
// ── The write-up ─────────────────────────────────────────────
// ── The write-up ─────────────────────────────────────────────
-const SCHEMA = { name: 'stream_recap', schema: { type: 'object', additionalProperties: false, required: ['headline', 'summary', 'moment', 'tags', 'grade'], properties: {
- headline: { type: 'string', description: '≤ 70 chars, like a sports-page headline about this stream, no quotes, no emojis' },
- summary: { type: 'string', description: '2–3 sentences, ≤ 420 chars, what happened and how it went, concrete, warm, second person is fine' },
- moment: { type: 'string', description: '≤ 160 chars, the single moment of the night (from the mic lines, chat spike, a clip title or the transcript) — or empty string' },
- tags: { type: 'array', items: { type: 'string' }, description: '3 short vibe tags, 1–2 words each, lowercase' },
- grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },
-} } };
-
function templateWriteup(g) {
function templateWriteup(g) {
const s = g.stream, name = g.streamer.display_name;
const s = g.stream, name = g.streamer.display_name;
const bits = [];
const bits = [];
@@ -146,17 +138,8 @@ async function aiWriteup(g) {
@@ -146,17 +138,8 @@ async function aiWriteup(g) {
mic_lines: g.mic.map(m => m.text), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,
mic_lines: g.mic.map(m => m.text), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,
what_was_said: g.speech.sample || '(no transcript)',
what_was_said: g.speech.sample || '(no transcript)',
};
};
- const system = `You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.`;
- let r = null;
- let out = null;
- const aiService = require('../ai/ai-service');
- if (aiService.enabled()) {
- // AI_SERVICE=remote: the prompt lives in OpenVibe.AI (workflow live.stream.recap).
- out = await aiService.structured('live.stream.recap', { facts }, { target: { service: 'live', type: 'stream', id: String(g.stream.id) }, meter: { kind: 'stream_recap', role: 'summary', source: 'recap', ownerUserId: g.streamer.id } });
- } else {
- try { r = await llm.complete({ role: 'summary', kind: 'stream_recap', source: 'recap', ownerUserId: g.streamer.id, system, user: JSON.stringify(facts), json: SCHEMA, maxTokens: 500, temperature: 0.8, timeoutMs: 40000 }); } catch (e) { console.warn('[Recap] model call failed:', e.message); return null; }
- out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : null));
- }
+ // The prompt lives in OpenVibe.AI (workflow live.stream.recap).
+ const out = await require('../ai/ai-service').structured('live.stream.recap', { facts }, { target: { service: 'live', type: 'stream', id: String(g.stream.id) }, meter: { kind: 'stream_recap', role: 'summary', source: 'recap', ownerUserId: g.streamer.id } });
if (!out || !out.headline || !out.summary) return null;
if (!out || !out.headline || !out.summary) return null;
return { headline: String(out.headline).trim().slice(0, 90), summary: String(out.summary).trim().slice(0, 500), moment: String(out.moment || '').trim().slice(0, 200), tags: (Array.isArray(out.tags) ? out.tags : []).map(t => String(t).trim().toLowerCase()).filter(Boolean).slice(0, 4), grade: ['S', 'A', 'B', 'C'].includes(out.grade) ? out.grade : 'B' };
return { headline: String(out.headline).trim().slice(0, 90), summary: String(out.summary).trim().slice(0, 500), moment: String(out.moment || '').trim().slice(0, 200), tags: (Array.isArray(out.tags) ? out.tags : []).map(t => String(t).trim().toLowerCase()).filter(Boolean).slice(0, 4), grade: ['S', 'A', 'B', 'C'].includes(out.grade) ? out.grade : 'B' };
}
}
@@ -22,7 +22,7 @@ const vm = require('vm');
@@ -22,7 +22,7 @@ const vm = require('vm');
const tmp = path.join(os.tmpdir(), `ov-ai-optout-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-ai-optout-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-delete process.env.AI_SERVICE;
+delete process.env.AI_SERVICE; // on (every AI call is a run on OpenVibe.AI, stubbed below)
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.warn = () => {};
console.warn = () => {};
@@ -75,7 +75,9 @@ const llm = require('../server/ai/llm');
@@ -75,7 +75,9 @@ const llm = require('../server/ai/llm');
const modelCalls = [];
const modelCalls = [];
llm.isEnabled = () => true;
llm.isEnabled = () => true;
llm.withinBudget = () => true;
llm.withinBudget = () => true;
-llm.complete = async (o) => { modelCalls.push(o); return { json: { headline: 'What a night', summary: 'Chat was loud.', moment: '', tags: ['loud'], grade: 'A' } }; };
+llm.complete = async (o) => { modelCalls.push(o); return null; };
+// The after-show report is the live.stream.recap workflow on OpenVibe.AI.
+require('../server/ai/ai-service').structured = async (workflow, input) => { modelCalls.push({ workflow, input }); return { headline: 'What a night', summary: 'Chat was loud.', moment: '', tags: ['loud'], grade: 'A' }; };
const autoClip = require('../server/ai/auto-clip-job');
const autoClip = require('../server/ai/auto-clip-job');
const moments = require('../server/ai/ai-moments-job');
const moments = require('../server/ai/ai-moments-job');
new file mode 100644
new file mode 100644
@@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
+'use strict';
+/**
+ * Live calls no model provider itself and keeps no shared provider key (roadmap WS-O task 2, shims
+ * C-20 and C-21). Against a stub Network (token endpoint), a stub OpenVibe.AI and a stub "provider"
+ * that must never be reached, with every outbound connection and fetch recorded:
+ * - AI off (AI_SERVICE=off, or the admin switch ai_enabled off): every AI entry point answers null
+ * (or its template / "no AI" value) and nothing at all goes out;
+ * - AI on: llm.complete only ever talks to OpenVibe.AI, for the site's AI and for a streamer's key
+ * stored there; a raw key / base URL override answers null with one warning and its address is
+ * never contacted; a streamer's key typed but not saved is tested through the egress guard;
+ * - the shared-key settings are not seeded, and a leftover ai_api_key row is deleted at boot;
+ * - no file in server/ builds a provider request except llm.testProvider.
+ *
+ * node test/ai-remote-only.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const net = require('net');
+const crypto = require('crypto');
+const { spawnSync } = require('child_process');
+const { serviceAuth } = require('openvibe-contracts');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-ai-remote-'));
+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 = 'off';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+const warnings = [];
+console.warn = (...a) => { warnings.push(a.join(' ')); };
+
+// ── Every connection and fetch this process makes ──
+const outbound = [];
+const realConnect = net.Socket.prototype.connect;
+net.Socket.prototype.connect = function (...args) {
+ const o = Array.isArray(args[0]) ? args[0][0] : args[0];
+ outbound.push(o && typeof o === 'object' ? `${o.host || o.path || '?'}:${o.port || ''}` : String(o));
+ return realConnect.apply(this, args);
+};
+const realFetch = globalThis.fetch;
+globalThis.fetch = (url, opts) => { outbound.push(`fetch ${url}`); return realFetch(url, opts); };
+
+// ── Stubs: Network (service tokens), OpenVibe.AI (runs), a provider nobody may call ──
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+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.run.read'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey), token_type: 'Bearer', expires_in: 300 }));
+ });
+});
+const aiRuns = [];
+const ai = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const body = raw ? JSON.parse(raw) : null;
+ aiRuns.push(body);
+ const output = body.workflow === 'live.translate' ? { text: 'hello everyone', unchanged: false }
+ : body.workflow === 'live.paste.summarize_text' ? { description: 'A shopping list.' }
+ : { text: 'ok from AI', json: null };
+ res.statusCode = 201;
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ run: { id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: body.workflow }, output, usage: { tokens_in: 12, tokens_out: 3, cost_usd: 0.0002 }, provenance: { model: 'gpt-5-nano' } } }));
+ });
+});
+const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP4'; // the streamer's Network subject
+let providerHits = 0;
+const provider = http.createServer((req, res) => { providerHits++; res.setHeader('Content-Type', 'application/json'); res.end('{"choices":[{"message":{"content":"direct"}}]}'); });
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 4).join('\n ')); }
+}
+
+(async () => {
+ for (const s of [network, ai, provider]) await new Promise((r) => s.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 providerUrl = `http://127.0.0.1:${provider.address().port}/v1`;
+
+ const db = require('../server/db/database');
+ db.initDb();
+ const d = db.getDb();
+ const aiService = require('../server/ai/ai-service');
+ const llm = require('../server/ai/llm');
+ const analysis = require('../server/ai/ai-analysis');
+ const translate = require('../server/i18n/translate');
+ const recap = require('../server/recap/recap');
+ const budget = require('../server/ai/viewers/budget');
+ const arena = require('../server/arena/arena-service');
+ const media = require('../server/media-client');
+ media.listVods = async () => ({ vods: [] });
+ media.listClips = async () => ({ clips: [] });
+
+ d.prepare("INSERT INTO users (id, username, display_name, password_hash) VALUES (40, 'kai', 'Kai', '$sso$')").run();
+ d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, subject_id) VALUES (40, 'network', '400', 'kai', ?)").run(SUBJECT);
+ db.ensureChannel(40);
+ const ch = db.getChannelByUserId(40);
+ const sid = Number(db.createStream({ user_id: 40, channel_id: ch.id, title: 'Soldering at night', protocol: 'webrtc' }).lastInsertRowid);
+ db.addStreamMemory({ stream_id: sid, user_id: 40, offset_seconds: 60, description: 'Kai solders a board' });
+ db.endStream(sid);
+ d.prepare('UPDATE streams SET duration_seconds = 3600 WHERE id = ?').run(sid);
+ const frame = `data:image/png;base64,${'iVBORw0KGgo'.padEnd(400, 'A')}`;
+
+ quiet('AI is remote only');
+
+ await check('the shared-key settings are not seeded, and a leftover ai_api_key row is deleted at boot', () => {
+ const keysNow = d.prepare("SELECT key FROM site_settings WHERE key LIKE 'ai\\_%' ESCAPE '\\'").all().map((r) => r.key);
+ for (const k of ['ai_api_key', 'ai_provider', 'ai_base_url', 'ai_model', 'ai_model_chat', 'ai_pricing_json', 'ai_input_cost_per_mtok']) assert.ok(!keysNow.includes(k), `${k} is seeded`);
+ assert.ok(keysNow.includes('ai_enabled') && keysNow.includes('ai_max_cost_usd_per_day'));
+ db.setSetting('ai_api_key', 'sentinel-not-a-secret-leftover');
+ db.initDb();
+ assert.strictEqual(db.getSettingRow('ai_api_key'), undefined);
+ });
+
+ // Both switches: AI_SERVICE=off with the admin switch on, then the admin switch off with AI_SERVICE on.
+ for (const [label, setup] of [
+ ['AI_SERVICE=off', () => { process.env.AI_SERVICE = 'OFF'; db.setSetting('ai_enabled', 'true'); }],
+ ['ai_enabled off', () => { delete process.env.AI_SERVICE; db.setSetting('ai_enabled', 'false'); }],
+ ]) {
+ await check(`${label}: every AI entry point answers null (or no AI) and nothing goes out`, async () => {
+ setup();
+ db.upsertChannelAiConfig(40, { enabled: 1, use_shared_key: 1 });
+ outbound.length = 0;
+ assert.strictEqual(llm.isEnabled(), false);
+ assert.strictEqual(await llm.complete({ role: 'chat', kind: 'chat_global', user: 'hi' }), null);
+ assert.strictEqual(await analysis.analyzeImagePaste(frame, 'a screenshot'), null);
+ assert.strictEqual(await analysis.analyzeTextPaste('eggs, milk, bread', 'list'), null);
+ assert.strictEqual(await analysis.analyzeStreamFrame(frame), null);
+ assert.strictEqual(await analysis.summarizeStreamMemories([{ description: 'Kai solders a board' }], sid), null);
+ assert.strictEqual(await analysis.generateStreamerOverview(40), null);
+ assert.strictEqual(await analysis.summarizeText('say something', 50, 'chat_global'), null);
+ assert.strictEqual(await analysis.viewerComplete({ user: 'hi', ownerUserId: 40 }), null);
+ assert.strictEqual(await analysis.generateVodOverview({ id: 1, stream_id: sid }), null);
+ assert.strictEqual(await analysis.generateClipOverview({ id: 1, stream_id: sid }), null);
+ assert.strictEqual((await analysis.testStatus({ probe: true })).ok, false);
+ assert.strictEqual(await translate.translate('みなさんこんにちは', { from: 'ja', to: 'en' }), null);
+ assert.deepStrictEqual(await translate.translateLines(['みなさん'], { from: 'ja' }), [null]);
+ assert.strictEqual(await budget.generate(40, { user: 'hi' }), null);
+ const r = await recap.buildRecap(sid);
+ assert.ok(r && r.write && r.write.headline, 'the template report still ships');
+ assert.strictEqual(r.ai, false);
+ assert.strictEqual(arena.imageGenAvailable(), false);
+ assert.strictEqual(await arena.generateImage(40), null, 'no portrait is drawn');
+ if (label === 'AI_SERVICE=off') {
+ // A streamer's key in OpenVibe.AI is not used either while the service is off.
+ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { credentialSubject: SUBJECT } }), null);
+ assert.strictEqual(await aiService.structured('live.translate', { text: 'x', to: 'en' }), null);
+ }
+ assert.deepStrictEqual(outbound, [], 'nothing went out');
+ });
+ }
+
+ await check('media analysis: a recording with speech gets no overview while AI is off, and nothing goes out', async () => {
+ const wav = path.join(tmp, 'speech.wav');
+ const made = spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'anullsrc=r=16000:cl=mono', '-t', '1', wav], { stdio: 'ignore' });
+ if (made.status !== 0 || !fs.existsSync(wav)) { quiet(' (ffmpeg unavailable: skipped)'); return; }
+ const transcribe = require('../server/ai/transcribe');
+ transcribe.available = () => true;
+ transcribe.transcribeMediaDetailed = async () => ({ text: 'welcome back everyone', segments: [{ start: 0, end: 1, text: 'welcome back everyone' }], ok: true });
+ process.env.AI_SERVICE = 'off';
+ db.setSetting('ai_enabled', 'true');
+ outbound.length = 0;
+ const r = await require('../server/ai/media-analysis').analyzeMedia(wav, { userId: 40 });
+ assert.strictEqual(r.transcript, 'welcome back everyone', 'the local transcript is kept');
+ assert.strictEqual(r.overview, null);
+ assert.deepStrictEqual(outbound, []);
+ });
+
+ await check('AI on: llm.complete is a run on OpenVibe.AI, metered as openvibe-ai; structured analyses and translation too', async () => {
+ delete process.env.AI_SERVICE;
+ db.setSetting('ai_enabled', 'true');
+ assert.strictEqual(aiService.enabled(), true, 'on unless AI_SERVICE=off');
+ process.env.AI_SERVICE = 'remote';
+ assert.strictEqual(aiService.enabled(), true, 'the old value still means on');
+ outbound.length = 0; aiRuns.length = 0;
+ const r = await llm.complete({ role: 'chat', kind: 'chat_global', source: 'chat_ai', user: 'sum up chat', ownerUserId: 40 });
+ assert.strictEqual(r.text, 'ok from AI');
+ assert.strictEqual(r.provider, 'openvibe-ai');
+ assert.strictEqual(aiRuns[0].workflow, 'live.chat.insight');
+ const row = d.prepare("SELECT provider, owner_user_id, kind FROM ai_usage ORDER BY id DESC LIMIT 1").get();
+ assert.deepStrictEqual([row.provider, row.owner_user_id, row.kind], ['openvibe-ai', 40, 'chat_global']);
+ assert.deepStrictEqual(await analysis.analyzeTextPaste('eggs, milk, bread', 'list'), { description: 'A shopping list.', tags: [] });
+ assert.strictEqual(await translate.translate('みなさんこんばんは', { from: 'ja', to: 'en' }), 'hello everyone');
+ assert.deepStrictEqual(aiRuns.map((x) => x.workflow), ['live.chat.insight', 'live.paste.summarize_text', 'live.translate']);
+ const aiPorts = [String(ai.address().port), String(network.address().port)];
+ assert.ok(outbound.some((o) => o.includes(`:${ai.address().port}`)), 'the recorder sees the calls to OpenVibe.AI');
+ const elsewhere = outbound.filter((o) => !aiPorts.some((p) => o.endsWith(`:${p}`) || o.includes(`:${p}/`)));
+ assert.deepStrictEqual(elsewhere, [], 'only OpenVibe.AI and the Network token endpoint were contacted');
+ });
+
+ await check('AI on: a streamer\'s key stored in OpenVibe.AI runs there with credential { subject }, metered as byo', async () => {
+ aiRuns.length = 0;
+ const r = await llm.complete({ role: 'chat', kind: 'ai_viewers', source: 'ai_viewers', user: 'hi', ownerUserId: 40, provider: { credentialSubject: SUBJECT } });
+ assert.strictEqual(r.provider, 'byo');
+ assert.deepStrictEqual(aiRuns[0].credential, { subject: SUBJECT });
+ assert.strictEqual(d.prepare('SELECT provider FROM ai_usage ORDER BY id DESC LIMIT 1').get().provider, 'byo');
+ });
+
+ await check('AI on: a raw key / base URL override is never called and never falls back to the site\'s AI (one warning)', async () => {
+ outbound.length = 0; aiRuns.length = 0; warnings.length = 0;
+ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { apiKey: 'sk-EXAMPLE-0000', baseUrl: providerUrl, model: 'm' } }), null);
+ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { baseUrl: providerUrl } }), null);
+ assert.strictEqual(await llm.complete({ role: 'chat', user: 'hi', provider: { apiKey: 'sk-EXAMPLE-0000' } }), null);
+ assert.strictEqual(providerHits, 0);
+ assert.deepStrictEqual(outbound, [], 'not the provider, not OpenVibe.AI');
+ assert.strictEqual(aiRuns.length, 0);
+ assert.strictEqual(warnings.filter((w) => /raw provider key/.test(w)).length, 1, 'warned once');
+ // Viewers of a channel whose key never moved to OpenVibe.AI stay quiet.
+ db.upsertChannelAiConfig(40, { enabled: 1, use_shared_key: 0, byo_key: 'sk-EXAMPLE-0000', byo_base_url: providerUrl, byo_in_ai: 0 });
+ assert.strictEqual(budget.budgetStatus(40).reason, 'no_byo_key');
+ assert.strictEqual(await budget.generate(40, { user: 'hi' }), null);
+ assert.strictEqual(providerHits, 0);
+ });
+
+ await check('a key typed but not saved is tested through the egress guard: an internal address is refused', async () => {
+ const r = await llm.testProvider({ apiKey: 'sk-EXAMPLE-0000', baseUrl: providerUrl, model: 'gpt-4o-mini' });
+ assert.strictEqual(r.ok, false);
+ assert.strictEqual(providerHits, 0);
+ assert.deepStrictEqual(await llm.testProvider({ baseUrl: '', apiKey: '' }), { ok: false, error: 'Enter your API key to test it' });
+ assert.strictEqual((await llm.testProvider({ credentialSubject: 'usr_x' })).ok, false);
+ });
+
+ await check('no file in server/ builds a provider request except llm.testProvider', () => {
+ const PROVIDER_CALL = /anthropic-version|chat\/completions|images\/(generations|edits)|audio\/transcriptions/;
+ const root = path.join(__dirname, '..', 'server');
+ const hits = [];
+ const walk = (dir) => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const f = path.join(dir, e.name);
+ if (e.isDirectory()) walk(f);
+ else if (e.name.endsWith('.js')) {
+ let src = fs.readFileSync(f, 'utf8');
+ if (f.endsWith(path.join('ai', 'llm.js'))) {
+ const start = src.indexOf('async function testProvider(');
+ const end = src.indexOf('\n}\n', start);
+ assert.ok(start > 0 && end > start, 'llm.testProvider exists');
+ src = src.slice(0, start) + src.slice(end);
+ }
+ if (PROVIDER_CALL.test(src)) hits.push(path.relative(root, f));
+ }
+ }
+ };
+ walk(root);
+ assert.deepStrictEqual(hits, []);
+ const exported = Object.keys(llm).sort();
+ for (const gone of ['resolveProvider', 'modelForRole', 'priceFor', 'estimateCost', 'defaultModel']) assert.ok(!exported.includes(gone), `llm.${gone} is gone`);
+ assert.ok(!fs.existsSync(path.join(root, 'ai', 'ai-provider.js')), 'the unused direct client is gone');
+ });
+
+ for (const s of [network, ai, provider]) s.close();
+ globalThis.fetch = realFetch;
+ net.Socket.prototype.connect = realConnect;
+ fs.rmSync(tmp, { recursive: true, force: true });
+ quiet(failures ? `\n${failures} check(s) failed` : '\nai remote only: all checks passed');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
-// AI_SERVICE=remote (roadmap Wave 13): Live's AI goes to OpenVibe.AI as workflow runs with a service
-// token for audience openvibe.ai. Against a stub Network (token endpoint) and a stub AI service:
-// off by default; structured workflows return their output; llm.complete()-shaped calls become
+// Live's AI goes to OpenVibe.AI as workflow runs with a service token for audience openvibe.ai (roadmap
+// Wave 13; the only mode since WS-O task 2). Against a stub Network (token endpoint) and a stub AI service:
+// off only with AI_SERVICE=off; structured workflows return their output; llm.complete()-shaped calls become
// passthrough runs of the workflow that owns the feature, attributed to the streamer; synthetic
// passthrough runs of the workflow that owns the feature, attributed to the streamer; synthetic
// (stub-provider) answers, failed runs, quota refusals and an unreachable service are all "no
// (stub-provider) answers, failed runs, quota refusals and an unreachable service are all "no
// answer" (null); a still-running run is polled; a rejected token is refreshed once.
// answer" (null); a still-running run is polled; a rejected token is refreshed once.
@@ -14,7 +14,7 @@ const { serviceAuth } = require('openvibe-contracts');
@@ -14,7 +14,7 @@ const { serviceAuth } = require('openvibe-contracts');
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-delete process.env.AI_SERVICE;
+process.env.AI_SERVICE = 'off';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
let tokenCalls = 0;
let tokenCalls = 0;
@@ -61,11 +61,18 @@ const run = (over = {}) => ({ id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'suc
@@ -61,11 +61,18 @@ const run = (over = {}) => ({ id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'suc
const metered = [];
const metered = [];
svc.setRecorder((r, m) => metered.push({ run: r.id, ...m }));
svc.setRecorder((r, m) => metered.push({ run: r.id, ...m }));
- // 0. Off by default: nothing is sent.
+ // 0. AI_SERVICE=off (any case): nothing is sent, not even a token request. Unset, or the old "remote", is on.
assert.strictEqual(svc.enabled(), false);
assert.strictEqual(svc.enabled(), false);
assert.strictEqual(await svc.structured('live.translate', { text: 'x', to: 'en' }), null);
assert.strictEqual(await svc.structured('live.translate', { text: 'x', to: 'en' }), null);
- assert.strictEqual(calls.length, 0, 'default behaviour unchanged: no calls to OpenVibe.AI');
+ assert.strictEqual(await svc.complete({ role: 'chat', user: 'x' }), null);
+ process.env.AI_SERVICE = ' Off ';
+ assert.strictEqual(svc.enabled(), false);
+ assert.strictEqual(await svc.run('live.translate', { text: 'x' }), null);
+ assert.strictEqual(calls.length, 0, 'AI off: no calls to OpenVibe.AI');
+ assert.strictEqual(tokenCalls, 0, 'AI off: no token asked for');
+ delete process.env.AI_SERVICE;
+ assert.strictEqual(svc.enabled(), true, 'on unless AI_SERVICE=off');
process.env.AI_SERVICE = 'remote';
process.env.AI_SERVICE = 'remote';
assert.strictEqual(svc.enabled(), true);
assert.strictEqual(svc.enabled(), true);
@@ -66,7 +66,6 @@ const SETTINGS = {
@@ -66,7 +66,6 @@ const SETTINGS = {
kick_client_secret: 'sentinel-not-a-secret-kick',
kick_client_secret: 'sentinel-not-a-secret-kick',
youtube_api_key: 'sentinel-not-a-secret-youtube',
youtube_api_key: 'sentinel-not-a-secret-youtube',
soundboard_101_api_key: 'sentinel-not-a-secret-soundboard',
soundboard_101_api_key: 'sentinel-not-a-secret-soundboard',
- ai_api_key: 'sentinel-not-a-secret-ai',
discord_webhook_url: 'https://discord.example.test/api/webhooks/1/sentinel-not-a-secret-discord',
discord_webhook_url: 'https://discord.example.test/api/webhooks/1/sentinel-not-a-secret-discord',
ops_alert_webhook_url: 'https://hooks.example.test/sentinel-not-a-secret-ops-setting',
ops_alert_webhook_url: 'https://hooks.example.test/sentinel-not-a-secret-ops-setting',
};
};
@@ -295,7 +295,7 @@ async function check(name, fn) {
@@ -295,7 +295,7 @@ async function check(name, fn) {
await call('POST', '/api/ai-viewers/byo/test', { byo_base_url: 'http://public.example.test/v1', byo_key: 'byo-test-key', byo_model: 'm' });
await call('POST', '/api/ai-viewers/byo/test', { byo_base_url: 'http://public.example.test/v1', byo_key: 'byo-test-key', byo_model: 'm' });
assert.ok(publicHops.slice(before).includes('POST /v1/chat/completions'), 'a public BYO endpoint is still called, through the guard');
assert.ok(publicHops.slice(before).includes('POST /v1/chat/completions'), 'a public BYO endpoint is still called, through the guard');
});
});
- await check('AI viewers: a saved internal base URL is refused when the bots run, too (same call path)', async () => {
+ await check('AI viewers: a saved base URL is never called when the bots run (llm.complete refuses a raw key/base URL)', async () => {
await call('PUT', '/api/ai-viewers/config', { byo_base_url: `http://127.0.0.1:${p}/v1`, byo_key: 'byo-test-key', use_shared_key: 0 });
await call('PUT', '/api/ai-viewers/config', { byo_base_url: `http://127.0.0.1:${p}/v1`, byo_key: 'byo-test-key', use_shared_key: 0 });
const llm = require('../server/ai/llm');
const llm = require('../server/ai/llm');
const budget = require('../server/ai/viewers/budget');
const budget = require('../server/ai/viewers/budget');
@@ -361,9 +361,7 @@ async function check(name, fn) {
@@ -361,9 +361,7 @@ async function check(name, fn) {
'server/monetization/cosmetics.js': 'legacy quest API (env)',
'server/monetization/cosmetics.js': 'legacy quest API (env)',
'server/openre/openre-client.js': 'OpenRe.Stream (configured)',
'server/openre/openre-client.js': 'OpenRe.Stream (configured)',
'server/ai/ai-service.js': 'OpenVibe.AI (configured)',
'server/ai/ai-service.js': 'OpenVibe.AI (configured)',
- 'server/ai/llm.js': 'the site\'s AI provider (owner setting); a streamer\'s BYO base URL goes through egress.postJson; images are Live\'s own frames',
- 'server/ai/ai-provider.js': 'unused network helpers; kept for its URL helpers',
- 'server/arena/arena-service.js': 'the site\'s image provider (owner setting) and Live\'s own frame URLs',
+ 'server/ai/llm.js': 'images are Live\'s own frames; a streamer\'s typed provider address (testProvider) goes through egress.postJson',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',