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.

OP OpenVibers committed 28 Sept 2026 · 04cd59fd54 · 19 files, +511 −948

AI_SERVICE: ai-service.enabled() is true unless AI_SERVICE is off (any case), so a run on OpenVibe.AI is the only mode; the old value remote still means on.

Local fallbacks deleted: analyzeImagePaste, analyzeTextPaste, analyzeStreamFrame, summarizeStreamMemories and generateStreamerOverview (ai-analysis.js), the recap write-up, the media overview and translation are now only their workflow runs, and answer null when AI is off. Their prompts and the JSON extraction helpers went with them.

llm.js: complete() is either a run with credential { subject } (a streamer's key in AI, metered byo) or a run of the workflow that owns the feature (metered openvibe-ai). The direct Anthropic/OpenAI calls, resolveProvider, per-role models and pricing are gone. A raw key/base URL override answers null with one warning and never falls back to the site's AI. testProvider() keeps the one direct call left: the dashboard's test of a key typed but not saved yet, through egress.postJson, answering only ok or an HTTP status. isEnabled() is ai_enabled plus ai-service.enabled(). ai-provider.js (unused direct client) is deleted.

Settings: ai_provider, ai_api_key, ai_base_url, ai_model, ai_model_*, ai_pricing_json and ai_input/output_cost_per_mtok are no longer seeded, and an idempotent inline DELETE removes them (and the Arena's unseeded ai_image_* keys) from site_settings; the Network admin page lists only rows that exist. /api/admin/ai/status drops provider, model, has_key and base_url and reports service (openvibe-ai or off).

Consequences:
- Arena portraits: the image provider was called with the shared key and OpenVibe.AI has no image workflow, so imageGenAvailable() is false and portraits drawn earlier stay.
- AI viewers: an own key counts as usable only when it is stored in AI (byo_in_ai). /byo/test tests a typed key even when one is already in AI, and answers when the channel has no Network subject instead of testing the site's AI.
- The global AI-viewers cap counted only provider 'shared' rows, so it stopped counting when runs were recorded as openvibe-ai on 2026-09-23; it now counts every row but byo.

C-21 (/internal/ai/site-copy) was already retired in 0a115f6, and Network's frame service no longer calls it.

test/ai-remote-only.test.js checks, with every outbound connection recorded, that with AI off (either switch) every entry point answers null and nothing goes out, that with AI on llm.complete talks only to OpenVibe.AI, and that no server file builds a provider request outside testProvider. ai-service-client, ai-derivation-optout, security-ssrf and security-secrets follow. npm test 145/145.

Changes

ddiff --git a/.env.example b/.env.example
iindex 6b4875e9..20fbe34f 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/docs/arena.md b/docs/arena.md
iindex 3d0026a0..73158833 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -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.
--**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.
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 5c883033..3fabf576 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -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
"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
ddiff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
iindex 5b318985..97b5285c 100644
---- a/server/ai/ai-analysis.js
++++ b/server/ai/ai-analysis.js
@@@ -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, temperat
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') {
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, maxTokens
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 _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,
};
ddiff --git a/server/ai/ai-provider.js b/server/ai/ai-provider.js
ddeleted file mode 100644
iindex dc8ca068..00000000
---- a/server/ai/ai-provider.js
++++ /dev/null
@@@ -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 };
ddiff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
iindex a2322ec9..c6f5f4fd 100644
---- a/server/ai/ai-service.js
++++ b/server/ai/ai-service.js
@@@ -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'; }
ddiff --git a/server/ai/llm.js b/server/ai/llm.js
iindex 8fc7e4fc..84a9e16c 100644
---- a/server/ai/llm.js
++++ b/server/ai/llm.js
@@@ -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 };
ddiff --git a/server/ai/media-analysis.js b/server/ai/media-analysis.js
iindex a1da5653..a0546a39 100644
---- a/server/ai/media-analysis.js
++++ b/server/ai/media-analysis.js
@@@ -292,20 +292,10 @@ async function analyzeMedia(src, { streamId = null, userId = null, numFrames = n
const { 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 };
}
ddiff --git a/server/ai/viewers/budget.js b/server/ai/viewers/budget.js
iindex 990c04e1..5945a43f 100644
---- a/server/ai/viewers/budget.js
++++ b/server/ai/viewers/budget.js
@@@ -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, maxToken
if (!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; }
}
/**
ddiff --git a/server/ai/viewers/routes.js b/server/ai/viewers/routes.js
iindex 492489fb..f15248cc 100644
---- a/server/ai/viewers/routes.js
++++ b/server/ai/viewers/routes.js
@@@ -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 }); }
ddiff --git a/server/arena/arena-service.js b/server/arena/arena-service.js
iindex 3da4dea5..8f24cd8f 100644
---- a/server/arena/arena-service.js
++++ b/server/arena/arena-service.js
@@@ -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,
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 204477ba..534ac52b 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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
ddiff --git a/server/i18n/translate.js b/server/i18n/translate.js
iindex 86514ef5..88b7221c 100644
---- a/server/i18n/translate.js
++++ b/server/i18n/translate.js
@@@ -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', max
const 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;
ddiff --git a/server/recap/recap.js b/server/recap/recap.js
iindex 824c243c..72eb4ca4 100644
---- a/server/recap/recap.js
++++ b/server/recap/recap.js
@@@ -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' };
}
ddiff --git a/test/ai-derivation-optout.test.js b/test/ai-derivation-optout.test.js
iindex bef4e744..1d5f9fb0 100644
---- a/test/ai-derivation-optout.test.js
++++ b/test/ai-derivation-optout.test.js
@@@ -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');
ddiff --git a/test/ai-remote-only.test.js b/test/ai-remote-only.test.js
nnew file mode 100644
iindex 00000000..8727939e
---- /dev/null
++++ b/test/ai-remote-only.test.js
@@@ -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); });
ddiff --git a/test/ai-service-client.test.js b/test/ai-service-client.test.js
iindex 024f7401..67583ac5 100644
---- a/test/ai-service-client.test.js
++++ b/test/ai-service-client.test.js
@@@ -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: 'suc
const 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);
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 9512d561..fe7df4ff 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -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',
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 2675dd2f..c712b1a0 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)',