openvibers/openvibe.ai Commits

Move Live's AI here: import script for its AI records, migration plan, and the Live patch that adds AI_SERVICE=remote

OP OpenVibers committed 23 Sept 2026 · 3d9e0fa0d3 · 4 files, +1047 −0

scripts/import-from-live.js reads a Live snapshot read-only and imports usage, spend caps, per-role
models, pricing and per-streamer AI-viewer budgets, holding what cannot be represented (secrets, BYO
keys, the text-less translation cache) with a reason; --dry-run writes nothing and re-runs import
nothing twice. docs/live-patch.diff applies to Live's main: with AI_SERVICE=remote Live's shared-key
AI calls become runs here (structured workflows for translation, paste/frame analysis, overviews,
recap and footer copy; passthrough runs for the rest) using its openvibe.ai service token; unset,
Live behaves exactly as before.

Changes

ddiff --git a/docs/live-patch.diff b/docs/live-patch.diff
nnew file mode 100644
iindex 0000000..97d5f09
---- /dev/null
++++ b/docs/live-patch.diff
@@@ -0,0 +1,596 @@
++diff --git a/.env.example b/.env.example
++index 31f53b7..6911d85 100644
++--- a/.env.example
+++++ b/.env.example
++@@ -16,6 +16,15 @@ 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
+++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
+++
++ # Server
++ PORT=3000
++ HOST=0.0.0.0
++diff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
++index 559a58a..70297da 100644
++--- a/server/ai/ai-analysis.js
+++++ b/server/ai/ai-analysis.js
++@@ -16,7 +16,10 @@ function b(k) { const v = db.getSetting(k); return v === true || v === 'true' ||
++ 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
++@@ -121,6 +124,12 @@ async function _toVisionJpeg(image, opts = {}) {
++
++ /** 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 });
++@@ -131,6 +140,11 @@ Reply ONLY with compact JSON: {"description":"1-2 sentence description of what t
++
++ /** 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' });
++@@ -139,6 +153,12 @@ async function analyzeTextPaste(content, title) {
++
++ /** 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
++ // 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>"}.`;
++@@ -160,6 +180,18 @@ async function summarizeStreamMemories(memories, streamId = null) {
++ // 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
++@@ -230,7 +262,14 @@ async function generateStreamerOverview(userId) {
++
++ 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}`;
++
++- const text = await _complete({ prompt, maxTokens: 550, kind: 'streamer_overview' });
+++ 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' });
++ if (!text) return null;
++ const overview = text.slice(0, 4000);
++ try {
++diff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
++new file mode 100644
++index 0000000..9b0ef43
++--- /dev/null
+++++ b/server/ai/ai-service.js
++@@ -0,0 +1,181 @@
+++/**
+++ * ai-service.js — Live as a CONSUMER of OpenVibe.AI (roadmap Wave 13).
+++ *
+++ * 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.
+++ *
+++ * 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)
+++ * 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. Streamer BYO providers are not routed here (llm.js keeps calling them directly).
+++ */
+++'use strict';
+++const principal = require('../net/network-principal');
+++
+++const AUDIENCE = 'openvibe.ai';
+++const TERMINAL = new Set(['succeeded', 'cached', 'failed', 'cancelled']);
+++
+++/** llm.complete() kind -> the OpenVibe.AI workflow that owns that feature (same table as the service). */
+++const KIND_TO_WORKFLOW = {
+++ ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
+++ chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
+++ moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',
+++ arena_persona: 'live.arena.persona', arena_quotes: 'live.arena.quotes', arena_scene: 'live.arena.scene', arena_beef_judge: 'live.arena.judge', arena_mic_judge: 'live.arena.judge', arena_headline: 'live.arena.headline',
+++ status_check: 'live.status_check',
+++};
+++const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
+++
+++function enabled() { return String(process.env.AI_SERVICE || '').trim().toLowerCase() === 'remote'; }
+++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'; }
+++
+++let _lastWarn = 0;
+++function warn(msg) {
+++ if (Date.now() - _lastWarn < 60_000) return;
+++ _lastWarn = Date.now();
+++ console.warn(`[AI service] ${msg}`);
+++}
+++
+++async function _fetchJson(method, path, body, timeoutMs, retried = false) {
+++ let headers;
+++ try { headers = await principal.serviceHeaders(AUDIENCE); } catch (e) { warn(`no service token for ${AUDIENCE}: ${e.message}`); return null; }
+++ let res;
+++ try {
+++ res = await fetch(`${baseUrl()}${path}`, {
+++ method,
+++ headers: { ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
+++ body: body ? JSON.stringify(body) : undefined,
+++ signal: AbortSignal.timeout(timeoutMs),
+++ });
+++ } catch (e) { warn(`${method} ${path} failed: ${e.message}`); return null; }
+++ const json = await res.json().catch(() => null);
+++ if (res.status === 401 && !retried) { // stale/rotated token: fetch a new one once
+++ principal.invalidate(AUDIENCE);
+++ return _fetchJson(method, path, body, timeoutMs, true);
+++ }
+++ if (res.status === 429) { warn(`quota: ${(json && json.detail) || 'refused'} (retry after ${res.headers.get('retry-after') || '?'}s)`); return null; }
+++ if (!res.ok && res.status !== 202) { warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`); return null; }
+++ return json;
+++}
+++
+++/**
+++ * Run a workflow and wait for it. Returns the run object (any terminal status) or null.
+++ * opts: { target, attribution, idempotencyKey, waitMs (default 60s), timeoutMs }
+++ */
+++async function run(workflow, input, { target, attribution, idempotencyKey, waitMs = 60000 } = {}) {
+++ if (!enabled()) return null;
+++ const wait = Math.max(0, Math.min(60000, waitMs));
+++ const created = await _fetchJson('POST', `/api/v1/runs?wait=${wait}`, {
+++ workflow, input, target: target || undefined, attribution: attribution || undefined, idempotency_key: idempotencyKey || undefined,
+++ }, wait + 15000);
+++ let r = created && created.run;
+++ if (!r) return null;
+++ // Still queued/running after the wait: poll briefly (the service caps a wait at 60 s).
+++ const deadline = Date.now() + wait;
+++ while (!TERMINAL.has(r.status) && Date.now() < deadline) {
+++ await new Promise(res => setTimeout(res, 1000));
+++ const again = await _fetchJson('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`, null, 15000);
+++ if (!again || !again.run) return null;
+++ r = again.run;
+++ }
+++ return r;
+++}
+++
+++/** A usable (real, finished) output or null. */
+++function usable(r) {
+++ if (!r || !['succeeded', 'cached'].includes(r.status)) return null;
+++ if (r.synthetic) { warn(`${r.workflow && r.workflow.key} answered from the stub provider; treating it as no answer`); return null; }
+++ return r.output || null;
+++}
+++
+++// Live's ai_usage table keeps metering remote runs too (per-streamer budgets, admin cost views);
+++// llm.js installs the recorder so this module stays free of the database.
+++let _recorder = null;
+++function setRecorder(fn) { _recorder = typeof fn === 'function' ? fn : null; }
+++function meter(r, m) {
+++ if (!_recorder || !m || !r || r.status !== 'succeeded' || r.synthetic) return;
+++ try { _recorder(r, m); } catch { /* metering is best-effort */ }
+++}
+++
+++/** opts: run() options plus meter: { kind, role, ownerUserId, source } for Live's ai_usage row. */
+++async function structured(workflow, input, opts = {}) {
+++ const r = await run(workflow, input, { ...opts, attribution: opts.attribution || ownerRef(opts.meter && opts.meter.ownerUserId) });
+++ meter(r, opts.meter);
+++ return usable(r);
+++}
+++
+++/** EntityRef for the Live user a spend is attributed to (Live's per-streamer budgets). */
+++function ownerRef(ownerUserId) {
+++ const id = parseInt(ownerUserId, 10);
+++ return id > 0 ? { service: 'live', type: 'user', id: String(id) } : undefined;
+++}
+++
+++/**
+++ * An image for a workflow input. OpenVibe.Media URLs stay URLs (the service fetches only
+++ * allow-listed OpenVibe hosts); files, buffers and any other URL are downscaled here, as before,
+++ * and sent inline.
+++ */
+++async function imageInput(image, { toVisionJpeg, maxWidth = 1024 } = {}) {
+++ if (!image) return null;
+++ if (typeof image === 'string' && /^https:\/\/openvibe\.media\//i.test(image)) return { url: image, max_width: maxWidth };
+++ const dataUrl = toVisionJpeg ? await toVisionJpeg(image, { maxWidth, quality: 78 }) : (typeof image === 'string' && image.startsWith('data:') ? image : null);
+++ return dataUrl ? { data_url: dataUrl, max_width: maxWidth } : null;
+++}
+++
+++/**
+++ * llm.complete() over the service. Same argument object; same result shape
+++ * ({ text, json, usage, model, provider, shared, latencyMs, cost }) or null.
+++ */
+++async function complete(o = {}, { toVisionJpeg } = {}) {
+++ const role = ROLES.includes(o.role) ? o.role : 'legacy';
+++ let image = null;
+++ if (o.image) {
+++ image = await imageInput(o.image, { toVisionJpeg, maxWidth: o.imageMaxWidth || 1024 });
+++ if (!image) return null;
+++ }
+++ const system = typeof o.system === 'string' ? o.system
+++ : Array.isArray(o.system) ? o.system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: Boolean(x.cache) })) : undefined;
+++ const input = {
+++ role, kind: o.kind ? String(o.kind).slice(0, 64) : undefined, source: o.source ? String(o.source).slice(0, 64) : undefined,
+++ system: system && system.length ? system : undefined,
+++ messages: Array.isArray(o.messages) && o.messages.length ? o.messages.filter(m => m && m.role).map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })) : undefined,
+++ user: o.user != null ? String(o.user) : undefined,
+++ image: image || undefined,
+++ json: o.json ? { name: o.json.name, schema: o.json.schema, strict: o.json.strict, description: o.json.description } : undefined,
+++ max_tokens: Math.max(1, Math.round(o.maxTokens || 400)),
+++ temperature: o.temperature == null ? undefined : o.temperature,
+++ timeout_ms: o.timeoutMs ? Math.max(1000, Math.min(120000, o.timeoutMs)) : undefined,
+++ cache_key: o.cacheKey ? String(o.cacheKey).slice(0, 128) : undefined,
+++ };
+++ if (!input.user && !input.messages) input.user = '';
+++ const started = Date.now();
+++ const r = await run(workflowFor(o.kind), input, { attribution: ownerRef(o.ownerUserId), waitMs: Math.max(30000, (o.timeoutMs || 30000) * 2) });
+++ const out = usable(r);
+++ if (!out) return null;
+++ const usage = { input: (r.usage && r.usage.tokens_in) || 0, output: (r.usage && r.usage.tokens_out) || 0, cached: 0 };
+++ return {
+++ text: typeof out.text === 'string' ? out.text : '',
+++ json: out.json || null,
+++ usage,
+++ model: (r.provenance && r.provenance.model) || null,
+++ provider: 'openvibe-ai',
+++ shared: true,
+++ latencyMs: Date.now() - started,
+++ cost: (r.usage && r.usage.cost_usd) || 0,
+++ runId: r.id,
+++ workflow: r.workflow && r.workflow.key,
+++ };
+++}
+++
+++module.exports = { enabled, run, structured, complete, usable, imageInput, ownerRef, workflowFor, setRecorder, KIND_TO_WORKFLOW, AUDIENCE };
++diff --git a/server/ai/llm.js b/server/ai/llm.js
++index 6824fed..67dc39c 100644
++--- a/server/ai/llm.js
+++++ b/server/ai/llm.js
++@@ -21,6 +21,7 @@
++ 'use strict';
++ const fs = require('fs');
++ const db = require('../db/database');
+++const aiService = require('./ai-service');
++
++ const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
++ const DEFAULT_TIMEOUT_MS = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
++@@ -30,7 +31,8 @@ function b(k) { const v = db.getSetting(k); return v === true || v === 'true' ||
++ function num(k, d) { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; }
++
++ // ── Gates ────────────────────────────────────────────────────
++-function isEnabled() { return b('ai_enabled') && !!s('ai_api_key'); }
+++// 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')); }
++ function withinBudget() {
++ const cap = num('ai_max_cost_usd_per_day', 0);
++ if (!cap || cap <= 0) return true;
++@@ -294,6 +296,22 @@ function parseJsonLoose(text) {
++ */
++ 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.
+++ 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;
++@@ -357,4 +375,11 @@ async function testProvider(override = null) {
++ } catch (e) { return { ok: false, error: e.message }; }
++ }
++
+++// Remote structured workflows (translate, paste/frame analysis, overviews, recap) meter here too.
+++aiService.setRecorder((r, m) => db.recordAiUsage({
+++ kind: m.kind || (r.workflow && r.workflow.key) || 'remote', model: (r.provenance && r.provenance.model) || null,
+++ input_tokens: (r.usage && r.usage.tokens_in) || 0, output_tokens: (r.usage && r.usage.tokens_out) || 0, cached_tokens: 0,
+++ 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 };
++diff --git a/server/ai/media-analysis.js b/server/ai/media-analysis.js
++index 3b4421c..a1da565 100644
++--- a/server/ai/media-analysis.js
+++++ b/server/ai/media-analysis.js
++@@ -297,7 +297,14 @@ async function analyzeMedia(src, { streamId = null, userId = null, numFrames = n
++ 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')}`;
++- overview = await ai.summarizeText(prompt, 400, 'media_overview');
+++ 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);
++ }
++ return { overview: overview || null, transcript: transcript || '', segments: segments || [], frames, duration };
++diff --git a/server/i18n/translate.js b/server/i18n/translate.js
++index 64209fd..86514ef 100644
++--- a/server/i18n/translate.js
+++++ b/server/i18n/translate.js
++@@ -23,6 +23,8 @@ const crypto = require('crypto');
++ const db = require('../db/database');
++ let llm = null;
++ try { llm = require('../ai/llm'); } catch { llm = null; }
+++let aiService = null;
+++try { aiService = require('../ai/ai-service'); } catch { aiService = null; }
++
++ const LANG_NAMES = {
++ en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', ar: 'Arabic',
++@@ -191,6 +193,16 @@ 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)'
++diff --git a/server/internal/routes.js b/server/internal/routes.js
++index 540fcb2..f5b4007 100644
++--- a/server/internal/routes.js
+++++ b/server/internal/routes.js
++@@ -32,6 +32,15 @@ router.post('/ai/site-copy', async (req, res) => {
++ const linkList = links.map(l => ({ id: clean(l.id, 60), name: clean(l.name, 60), about: clean(l.about, 120) }));
++ _siteCopyBusy = true;
++ try {
+++ const aiService = require('../ai/ai-service');
+++ if (aiService.enabled()) {
+++ // AI_SERVICE=remote: OpenVibe.AI owns this workflow (network.site_copy). Network should call it
+++ // directly; until it does, this endpoint answers from there instead of from Live's own key.
+++ const run = await aiService.run('network.site_copy', { sites: facts, links: linkList });
+++ const out = aiService.usable(run);
+++ if (!out || !Array.isArray(out.sites)) return res.status(503).json({ ok: false, error: 'AI unavailable or over budget' });
+++ return res.json({ ok: true, model: (run.provenance && run.provenance.model) || null, run_id: run.id, sites: out.sites });
+++ }
++ const llm = require('../ai/llm');
++ const out = await llm.complete({
++ role: 'summary', kind: 'site_copy', source: 'network-footer', maxTokens: 2200, temperature: 0.7, timeoutMs: 60000,
++diff --git a/server/recap/recap.js b/server/recap/recap.js
++index 9bafab8..bb131e6 100644
++--- a/server/recap/recap.js
+++++ b/server/recap/recap.js
++@@ -148,8 +148,15 @@ async function aiWriteup(g) {
++ };
++ 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;
++- 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; }
++- const out = r && (r.json || (r.text ? llm.parseJsonLoose(r.text) : 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));
+++ }
++ 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' };
++ }
++diff --git a/test/ai-service-client.test.js b/test/ai-service-client.test.js
++new file mode 100644
++index 0000000..024f740
++--- /dev/null
+++++ b/test/ai-service-client.test.js
++@@ -0,0 +1,146 @@
+++'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
+++// 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.
+++
+++const assert = require('assert');
+++const http = require('http');
+++const crypto = require('crypto');
+++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;
+++
+++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+++let tokenCalls = 0;
+++const calls = [];
+++const script = { next: [] }; // queued responses for the AI stub: { status, body } | function
+++
+++const network = http.createServer((req, res) => {
+++ let raw = '';
+++ req.on('data', (c) => { raw += c; });
+++ req.on('end', () => {
+++ tokenCalls++;
+++ 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_${tokenCalls}abcdefgh` }, keys.privateKey), expires_in: 300 }));
+++ });
+++});
+++
+++const ai = http.createServer((req, res) => {
+++ let raw = '';
+++ req.on('data', (c) => { raw += c; });
+++ req.on('end', () => {
+++ const auth = String(req.headers.authorization || '');
+++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, audience: 'openvibe.ai' });
+++ const body = raw ? JSON.parse(raw) : null;
+++ calls.push({ method: req.method, url: req.url, body, tokenOk: v.ok, jti: v.ok ? v.claims.jti : null });
+++ let out = script.next.shift() || { status: 500, body: { code: 'unscripted' } };
+++ if (typeof out === 'function') out = out(body, req);
+++ res.statusCode = out.status;
+++ res.setHeader('Content-Type', 'application/json');
+++ if (out.headers) for (const [k, val] of Object.entries(out.headers)) res.setHeader(k, val);
+++ res.end(JSON.stringify(out.body));
+++ });
+++});
+++
+++const run = (over = {}) => ({ id: 'run_01JAB2C3D4E5F6G7H8J9K0MNPA', status: 'succeeded', synthetic: false, workflow: { key: 'live.translate', version: 1 }, output: { text: 'hello everyone', unchanged: false }, provenance: { model: 'gpt-5-nano', provider: 'shared' }, usage: { tokens_in: 30, tokens_out: 5, cost_usd: 0.0001 }, ...over });
+++
+++(async () => {
+++ await new Promise((r) => network.listen(0, '127.0.0.1', r));
+++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
+++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${network.address().port}`;
+++ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${ai.address().port}`;
+++ const svc = require('../server/ai/ai-service');
+++ const metered = [];
+++ svc.setRecorder((r, m) => metered.push({ run: r.id, ...m }));
+++
+++ // 0. Off by default: nothing is sent.
+++ 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');
+++
+++ process.env.AI_SERVICE = 'remote';
+++ assert.strictEqual(svc.enabled(), true);
+++
+++ // 1. A structured workflow: token for openvibe.ai, the run's output back, usage metered for the streamer.
+++ script.next.push({ status: 201, body: { run: run() } });
+++ const out = await svc.structured('live.translate', { text: 'みなさん', from: 'ja', to: 'en' }, { meter: { kind: 'translate', ownerUserId: 42 } });
+++ assert.deepStrictEqual(out, { text: 'hello everyone', unchanged: false });
+++ const c1 = calls.pop();
+++ assert.ok(c1.tokenOk, 'Live calls with a service token for audience openvibe.ai');
+++ assert.strictEqual(c1.method, 'POST');
+++ assert.match(c1.url, /^\/api\/v1\/runs\?wait=\d+$/);
+++ assert.strictEqual(c1.body.workflow, 'live.translate');
+++ assert.deepStrictEqual(c1.body.attribution, { service: 'live', type: 'user', id: '42' });
+++ assert.deepStrictEqual(metered.pop(), { run: run().id, kind: 'translate', ownerUserId: 42 });
+++
+++ // 2. Synthetic (stub provider), failed runs, quota refusals and an unreachable service are "no answer".
+++ script.next.push({ status: 201, body: { run: run({ synthetic: true }) } });
+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }, { meter: { kind: 'translate' } }), null);
+++ assert.strictEqual(metered.length, 0, 'synthetic output is not metered');
+++ script.next.push({ status: 201, body: { run: run({ status: 'failed', output: null, error: { code: 'provider.unavailable' } }) } });
+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);
+++ script.next.push({ status: 429, headers: { 'Retry-After': '30' }, body: { code: 'quota.exceeded', detail: 'quota exceeded' } });
+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);
+++ const saved = process.env.OV_AI_INTERNAL_URL;
+++ process.env.OV_AI_INTERNAL_URL = 'http://127.0.0.1:1';
+++ assert.strictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }), null);
+++ process.env.OV_AI_INTERNAL_URL = saved;
+++
+++ // 3. Still running after the wait: poll the run until it finishes.
+++ script.next.push({ status: 202, body: { run: run({ status: 'running', output: null }) } });
+++ script.next.push({ status: 200, body: { run: run({ status: 'running', output: null }) } });
+++ script.next.push({ status: 200, body: { run: run() } });
+++ calls.length = 0;
+++ assert.deepStrictEqual(await svc.structured('live.translate', { text: 'a b', to: 'en' }, { waitMs: 5000 }), run().output);
+++ assert.deepStrictEqual(calls.map((c) => c.method), ['POST', 'GET', 'GET']);
+++ assert.strictEqual(calls[1].url, `/api/v1/runs/${run().id}`);
+++
+++ // 4. A rejected token is refreshed once and the call retried.
+++ const before = tokenCalls;
+++ script.next.push({ status: 401, body: { code: 'token.expired' } });
+++ script.next.push({ status: 201, body: { run: run() } });
+++ calls.length = 0;
+++ assert.ok(await svc.structured('live.translate', { text: 'a b', to: 'en' }));
+++ assert.strictEqual(tokenCalls, before + 1, 'a new token was fetched');
+++ assert.notStrictEqual(calls[0].jti, calls[1].jti);
+++
+++ // 5. llm.complete()-shaped calls: passthrough run of the owning workflow, llm.complete result shape back.
+++ script.next.push((body) => ({ status: 201, body: { run: run({ workflow: { key: body.workflow, version: 1 }, output: { text: '{"skip":true,"lines":[]}', json: { skip: true, lines: [] } }, provenance: { model: 'gpt-5' } }) } }));
+++ calls.length = 0;
+++ const r = await svc.complete({
+++ role: 'director', kind: 'ai_viewers_director', source: 'ai_viewers', ownerUserId: 7,
+++ system: [{ text: 'RULES', cache: true }, { text: '' }], user: 'plan now', json: { name: 'plan', schema: { type: 'object' }, strict: true }, maxTokens: 330, temperature: 0.9, cacheKey: 'chan:7',
+++ });
+++ const sent = calls[0].body;
+++ assert.strictEqual(sent.workflow, 'live.viewers.plan');
+++ assert.deepStrictEqual(sent.input.system, [{ text: 'RULES', cache: true }]);
+++ assert.strictEqual(sent.input.role, 'director');
+++ assert.strictEqual(sent.input.user, 'plan now');
+++ assert.strictEqual(sent.input.max_tokens, 330);
+++ assert.deepStrictEqual(sent.input.json.schema, { type: 'object' });
+++ assert.deepStrictEqual(sent.attribution, { service: 'live', type: 'user', id: '7' });
+++ assert.deepStrictEqual(r.json, { skip: true, lines: [] });
+++ assert.strictEqual(r.model, 'gpt-5');
+++ assert.deepStrictEqual(r.usage, { input: 30, output: 5, cached: 0 });
+++ assert.strictEqual(r.provider, 'openvibe-ai');
+++ assert.strictEqual(svc.workflowFor('something_new'), 'live.complete', 'unknown kinds still have a workflow');
+++
+++ // 6. Images: OpenVibe.Media URLs are passed as URLs; anything else is downscaled here and sent inline.
+++ assert.deepStrictEqual(await svc.imageInput('https://openvibe.media/t/9', {}), { url: 'https://openvibe.media/t/9', max_width: 1024 });
+++ const inline = await svc.imageInput('/tmp/frame.jpg', { toVisionJpeg: async () => 'data:image/jpeg;base64,AAAA', maxWidth: 768 });
+++ assert.deepStrictEqual(inline, { data_url: 'data:image/jpeg;base64,AAAA', max_width: 768 });
+++ assert.strictEqual(await svc.imageInput('https://evil.example/x.png', { toVisionJpeg: async () => null }), null, 'other URLs are not forwarded');
+++
+++ network.close();
+++ ai.close();
+++ console.log('ai service client: all checks passed');
+++ process.exit(0);
+++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/docs/migration.md b/docs/migration.md
nnew file mode 100644
iindex 0000000..ddf2373
---- /dev/null
++++ b/docs/migration.md
@@@ -0,0 +1,114 @@
++# Moving Live's AI into OpenVibe.AI
++
++OpenVibe.Live grew into the network's de facto AI service: every feature called model providers
++through `server/ai/llm.js` with the shared key held in Live's admin settings, and Network's footer
++copy called Live's `/internal/ai/site-copy`. This document is the cutover plan: what moves here,
++what stays in Live, how to import, and how to switch and roll back.
++
++## What moves and what stays
++
++### Records that move here
++
++| Live source | Becomes in OpenVibe.AI | How |
++|---|---|---|
++| `site_settings.ai_provider`, `ai_base_url`, `ai_model`, `ai_enabled`, `ai_input_cost_per_mtok`, `ai_output_cost_per_mtok` | the `shared` provider and its default model | **environment**, with the same names upper-cased (`AI_PROVIDER`, …). The import prints the values and records a hold |
++| `site_settings.ai_api_key` | `shared.secret_ref = env:AI_API_KEY` | **environment only**. The import records a `secret` hold and never prints the value |
++| `site_settings.ai_model_chat` / `_vision` / `_director` / `_summary` | a new version of route `live.<role>` with that model | import (`created_by = import`; the boot seed never overwrites it) |
++| `site_settings.ai_pricing_json` | `models` rows for provider `shared` with cost metadata | import. The `default` entry and prefix matching stay in `AI_PRICING_JSON` (held) |
++| `site_settings.ai_max_cost_usd_per_day` | quota `global` / `day` / `max_cost_usd` | import (also `AI_MAX_COST_USD_PER_DAY` at boot) |
++| `site_settings.ai_viewers_global_cap_usd_per_day` | quota `global` / `day` / `max_cost_usd`, `workflow_prefix live.viewers.` | import |
++| `ai_usage` | `usage_daily` (requester `service:live`, attribution `live:user:<owner>`, workflow from the Live kind, provider `shared` or `live-byo`) and today's quota windows | import, one ledger row per usage id |
++| `channel_ai_config.daily_budget_cents` (shared key) | quota `attribution` `live:user:<id>` / `day` / `max_cost_usd`, prefix `live.viewers.` | import |
++| `channel_ai_config` BYO key / base URL | — | **held** (`byo_provider`): per-actor provider secrets are not stored here yet, so BYO calls keep going straight to the streamer's provider from Live |
++| `translations` | — | **held** (`unrepresentable`): rows are keyed by `sha1(from\|to\|text)` without the source text, so they cannot become input-hashed cache entries. Live keeps them as its read-through cache; new translations are also cached here (per service) |
++| `ai_timeline_cache` | — | **held** (`stays_in_live`): an assembled Live read model with no model output |
++| `ai_chatbot_configs` | — | **held** (`superseded`): Live already migrated it into `channel_ai_config` |
++| Live's prompts | versioned templates and workflows (see below) | code (`server/workflows/*.js`), seeded at boot |
++
++### Records that stay in Live
++
++`stream_memories`, `stream_timeline_events`, `streamer_overviews`, `stream_recaps`, `vod_ai_state`,
++`clip_ai_state`, `chat_ai_summaries`, `channel_ai_bots`, `ai_viewer_threads`, `ai_viewer_log`,
++`channel_ai_config.settings_json`, and the `ai_*_enabled` feature switches. They are Live's product
++state: AI produces a draft/evidence package (a run), and Live decides what to store and show. Each
++run is attributable to a workflow, its version, a model and a run id — never to a person — and Live
++can keep that `run_id` next to what it stored.
++
++## Live features and their workflows
++
++Structured workflows own their prompt here (templates copied verbatim from Live):
++
++| Live code | Workflow |
++|---|---|
++| `i18n/translate.js` translate / translateChatMessage / translateLines | `live.translate` (cache per service) |
++| `ai-analysis.analyzeImagePaste` (paste_image, moment_frame) | `live.paste.describe_image` |
++| `ai-analysis.analyzeTextPaste` | `live.paste.summarize_text` |
++| `ai-analysis.analyzeStreamFrame` (stream memories, live pastes) | `live.stream.describe_frame` |
++| `ai-analysis.summarizeStreamMemories` (overview + inferred category) | `live.stream.summarize` |
++| `ai-analysis.generateStreamerOverview` | `live.streamer.overview` |
++| `media-analysis.analyzeMedia` (VOD/clip overview) | `live.media.overview` |
++| `recap/recap.js` aiWriteup | `live.stream.recap` |
++| `internal/routes.js` `/internal/ai/site-copy` (Network footer) | `network.site_copy` |
++| `transcribe.js` (whisper.cpp) | `live.media.transcribe` — available here; Live keeps transcribing locally for now (see below) |
++
++Passthrough workflows carry a prompt Live still renders itself (the run keeps only its hash):
++
++| Live `llm.complete()` kind | Workflow |
++|---|---|
++| `ai_viewers_director` / `ai_viewers_reply` / `ai_viewers` / `ai_viewers_fold` | `live.viewers.plan` / `.reply` / `.line` / `.fold` |
++| `chat_global`, `chat_user`, `chat_relay`, `chat_anon`, `combined_overview`, `session_titles` | `live.chat.insight` |
++| `moment_vod_rank`, `moment_pick` | `live.moments.rank`, `live.moments.pick` |
++| `auto_clip_confirm` | `live.clips.confirm` |
++| `hero_slogans`, `easter_egg`, `home_star` | `live.hero.slogans`, `live.easter_egg`, `live.home.star` |
++| `arena_persona`, `arena_quotes`, `arena_scene`, `arena_beef_judge`/`arena_mic_judge`, `arena_headline` | `live.arena.*` |
++| `status_check` | `live.status_check` |
++| anything else | `live.complete` |
++
++Moving a passthrough feature's prompt here later is a normal change: add a template and a
++structured workflow, point Live's call at it, and the passthrough key stays for old runs.
++
++## Cutover
++
++1. **Deploy OpenVibe.AI** (`/opt/openvibe.ai`, unit `deploy/systemd/openvibe-ai.service`, env
++ `/etc/openvibe/ai.env` from `.env.example`). Leave `AI_STUB_FALLBACK` off in production.
++2. **Dry-run the import** against a snapshot of Live's database:
++ ```bash
++ node scripts/import-from-live.js --live-db /path/live-snapshot.db --dry-run
++ ```
++ Copy the `environment` lines it prints into `/etc/openvibe/ai.env` (set `AI_API_KEY` from Live's
++ `ai_api_key` yourself — the script never prints it), restart, check `GET /api/ready`.
++3. **Import**: the same command without `--dry-run`. Re-running is safe: the ledger skips what is
++ already imported and applies changed settings once. Review `import_holds`.
++4. **Grant Live a token**: Network client `live` needs `ai.run.create` (and `ai.run.read`) for
++ audience `openvibe.ai`, namespaces `live.*` and `network.*` (the latter only while Live still
++ answers `/internal/ai/site-copy`).
++5. **Apply `docs/live-patch.diff`** to Live (`git apply`), deploy, then set in `/etc/openvibe/live.env`:
++ ```
++ AI_SERVICE=remote
++ OV_AI_INTERNAL_URL=http://127.0.0.1:4700
++ ```
++ and restart Live. Live keeps its `ai_enabled` switch and local daily cap, and keeps writing
++ `ai_usage` rows (provider `openvibe-ai`) so per-streamer AI-viewer budgets and the admin cost
++ page work unchanged.
++6. **Verify**: `GET /api/v1/runs?workflow=live.translate` (with an `ai.usage.read` token) shows runs
++ from `service:live`; Live's logs show no `[AI service]` warnings.
++7. **Network**: give Network's client `ai.run.create` with namespace `network.*` and call
++ `POST /api/v1/runs {"workflow":"network.site_copy", ...}` directly instead of Live's
++ `/internal/ai/site-copy` (same input and output shape). Until then Live's endpoint forwards to
++ the same workflow.
++
++**Rollback**: unset `AI_SERVICE` in `live.env` and restart Live. Nothing in Live's database changed
++shape; the shared key must still be in Live's settings for that path (keep it until the cutover
++has held for a while).
++
++## Not moved yet
++
++- **Streamer BYO providers** stay in Live (`llm.js` provider override): they need per-actor
++ provider records with secret references here first.
++- **Local transcription** (live timeline segments, VOD/clip transcripts, stream memories audio)
++ still runs whisper.cpp inside Live: live segments are local files with a latency budget, and the
++ VOD path resumes per 5-minute window. `live.media.transcribe` is ready here (fetches from
++ allow-listed OpenVibe hosts only) for when VOD transcription moves.
++- **YAMNet sound events** (`audio-events.js`) and frame capture (`stream-vision.js`) are Live
++ capture code, not model calls; only the vision call moved.
++- **Run events** (`ai.run.queued|succeeded|failed|cached`) are not published to OpenVibe.Events yet.
ddiff --git a/scripts/import-from-live.js b/scripts/import-from-live.js
nnew file mode 100644
iindex 0000000..ef383be
---- /dev/null
++++ b/scripts/import-from-live.js
@@@ -0,0 +1,224 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Import the AI records that belong to OpenVibe.AI from a snapshot of OpenVibe.Live's database.
++ *
++ * node scripts/import-from-live.js --live-db /path/to/live-snapshot.db [--ai-db ./data/ai.db] [--dry-run]
++ *
++ * Reads the Live snapshot READ-ONLY. Idempotent: every imported source row is recorded in
++ * import_ledger (source, key, content hash), so a second run imports nothing new and a changed
++ * setting is applied once. Rows this service cannot represent are recorded in import_holds with a
++ * reason — never silently dropped, never guessed. --dry-run prints the plan and writes nothing.
++ *
++ * What moves (see docs/migration.md for the full table):
++ * site_settings ai_model_<role> -> a new version of route live.<role> with that model (origin import)
++ * site_settings ai_pricing_json -> model rows with cost metadata for provider `shared`
++ * site_settings ai_max_cost_usd_per_day -> quota global/day max_cost_usd
++ * site_settings ai_viewers_global_cap_usd_per_day -> quota global/day max_cost_usd, workflow_prefix live.viewers.
++ * site_settings ai_provider / ai_base_url / ai_model / ai_api_key -> HELD: these are environment
++ * (AI_PROVIDER, AI_BASE_URL, AI_MODEL, AI_API_KEY); the script prints which names to set, never values of secrets
++ * ai_usage -> usage_daily (per day, requester service:live, attribution live:user:<owner>,
++ * workflow by Live kind), plus today's quota windows
++ * channel_ai_config -> attribution quota live:user:<id>/day for live.viewers.* (shared key budgets);
++ * BYO keys are HELD (per-actor provider secrets are not stored here yet)
++ * translations, ai_timeline_cache, ai_chatbot_configs -> HELD (one row each; reasons in the hold)
++ * Everything else AI-shaped in Live (stream_memories, streamer_overviews, stream_recaps, vod/clip_ai_state,
++ * chat_ai_summaries, channel_ai_bots, ai_viewer_*) is Live product state and stays in Live.
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const { load } = require('../server/config');
++const { openDb } = require('../server/db');
++const { createRegistry } = require('../server/registry');
++const { createQuotas } = require('../server/quota');
++const { seed } = require('../server/workflows/seed');
++const { KIND_TO_WORKFLOW } = require('../server/workflows/live');
++const { sha256, iso } = require('../server/util');
++
++/** Live ai_usage.kind -> the workflow that now does that job. */
++const KIND_MAP = {
++ ...KIND_TO_WORKFLOW,
++ translate: 'live.translate', paste_image: 'live.paste.describe_image', moment_frame: 'live.paste.describe_image', paste_text: 'live.paste.summarize_text',
++ stream_memory: 'live.stream.summarize', streamer_overview: 'live.streamer.overview', media_overview: 'live.media.overview', stream_recap: 'live.stream.recap',
++ site_copy: 'network.site_copy',
++};
++const ROLES = ['chat', 'vision', 'director', 'summary'];
++
++function parseArgs(argv) {
++ const out = { dryRun: false, liveDb: null, aiDb: null, quiet: false };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (a === '--dry-run') out.dryRun = true;
++ else if (a === '--quiet') out.quiet = true;
++ else if (a === '--live-db') out.liveDb = argv[++i];
++ else if (a === '--ai-db') out.aiDb = argv[++i];
++ else if (a === '-h' || a === '--help') out.help = true;
++ else throw new Error(`unknown argument ${a}`);
++ }
++ return out;
++}
++
++function hasTable(db, name) {
++ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
++}
++
++function run({ liveDb, aiDb, dryRun = false, env = process.env, log = console.log, now = Date.now() } = {}) {
++ if (!liveDb || !fs.existsSync(liveDb)) throw new Error('--live-db <snapshot> is required and must exist');
++ const live = new Database(liveDb, { readonly: true, fileMustExist: true });
++ const config = load({ ...env, AI_DB_PATH: aiDb || env.AI_DB_PATH || './data/ai.db' });
++ const aiPath = path.resolve(config.dbPath);
++
++ // Dry run: never create or write the AI database; read its ledger if it already exists.
++ let db = null;
++ let registry = null;
++ let quotas = null;
++ if (!dryRun) {
++ db = openDb(aiPath);
++ registry = createRegistry(db, { env });
++ quotas = createQuotas(db, { registry });
++ seed({ registry, quotas, config, env, db });
++ } else if (fs.existsSync(aiPath)) {
++ db = new Database(aiPath, { readonly: true, fileMustExist: true });
++ }
++ const ledgerGet = (source, key) => (db && hasTable(db, 'import_ledger') ? db.prepare('SELECT target FROM import_ledger WHERE source = ? AND source_key = ?').get(source, String(key)) : null);
++ const holdGet = (source, key) => (db && hasTable(db, 'import_holds') ? db.prepare('SELECT 1 FROM import_holds WHERE source = ? AND source_key = ?').get(source, String(key)) : null);
++ const stats = {};
++ const bump = (source, what, n = 1) => { stats[source] = stats[source] || { imported: 0, unchanged: 0, held: 0 }; stats[source][what] += n; };
++ const ledger = (source, key, target) => { if (!dryRun) db.prepare('INSERT OR REPLACE INTO import_ledger (source, source_key, target, imported_at) VALUES (?, ?, ?, ?)').run(source, String(key), target, iso(now)); };
++ const hold = (source, key, reason, detail = null) => {
++ if (holdGet(source, key)) return bump(source, 'unchanged');
++ if (!dryRun) db.prepare('INSERT OR REPLACE INTO import_holds (source, source_key, reason, detail, held_at) VALUES (?, ?, ?, ?, ?)').run(source, String(key), reason, detail, iso(now));
++ bump(source, 'held');
++ };
++ /** Apply once per (source, key, content): unchanged content is skipped on re-runs. */
++ const once = (source, key, content, apply) => {
++ const h = sha256(content);
++ const prev = ledgerGet(source, key);
++ if (prev && prev.target.endsWith(`#${h}`)) return bump(source, 'unchanged');
++ const target = dryRun ? 'dry-run' : apply();
++ ledger(source, key, `${target}#${h}`);
++ bump(source, 'imported');
++ };
++
++ // ── site_settings ──
++ const settings = hasTable(live, 'site_settings') ? Object.fromEntries(live.prepare("SELECT key, value FROM site_settings WHERE key LIKE 'ai_%'").all().map(r => [r.key, r.value])) : {};
++ const envAdvice = [];
++ for (const [k, envName] of [['ai_provider', 'AI_PROVIDER'], ['ai_base_url', 'AI_BASE_URL'], ['ai_model', 'AI_MODEL'], ['ai_enabled', 'AI_ENABLED'], ['ai_input_cost_per_mtok', 'AI_INPUT_COST_PER_MTOK'], ['ai_output_cost_per_mtok', 'AI_OUTPUT_COST_PER_MTOK']]) {
++ if (settings[k] != null && settings[k] !== '') { envAdvice.push(`${envName}=${settings[k]}`); hold('live.site_settings', k, 'environment', `set ${envName} in /etc/openvibe/ai.env`); }
++ }
++ if (settings.ai_api_key) {
++ envAdvice.push('AI_API_KEY=<copy the value of Live\'s ai_api_key setting; not printed>');
++ hold('live.site_settings', 'ai_api_key', 'secret', 'secrets are environment references (env:AI_API_KEY); set it in /etc/openvibe/ai.env');
++ }
++ for (const role of ROLES) {
++ const m = String(settings[`ai_model_${role}`] || '').trim();
++ if (!m) continue;
++ once('live.site_settings', `ai_model_${role}`, m, () => {
++ const cur = registry.getRoute(`live.${role}`);
++ const v = registry.createRouteVersion(`live.${role}`, { primary: { provider: 'shared', model: m }, fallbacks: cur ? cur.fallbacks : [] }, { actor: 'import' });
++ return `route:live.${role}@${v.version}`;
++ });
++ }
++ if (settings.ai_pricing_json) {
++ let table = null;
++ try { table = JSON.parse(settings.ai_pricing_json); } catch { table = null; }
++ if (!table || typeof table !== 'object') hold('live.site_settings', 'ai_pricing_json', 'unparseable', 'not JSON');
++ else {
++ for (const [model, p] of Object.entries(table)) {
++ if (model === 'default' || !p || typeof p !== 'object') { hold('live.site_settings', `ai_pricing_json.${model}`, 'environment', 'prefix/default pricing stays in AI_PRICING_JSON'); continue; }
++ once('live.site_settings', `ai_pricing_json.${model}`, p, () => {
++ registry.upsertModel({ provider_key: 'shared', model_key: model, type: 'chat', cost: { in_per_mtok: Number(p.in), out_per_mtok: Number(p.out), cached_per_mtok: p.cached != null ? Number(p.cached) : null }, metadata: { imported_from: 'live.ai_pricing_json' } }, { actor: 'import' });
++ return `model:shared/${model}`;
++ });
++ }
++ }
++ }
++ const cap = Number(settings.ai_max_cost_usd_per_day);
++ if (cap > 0) once('live.site_settings', 'ai_max_cost_usd_per_day', cap, () => `quota:${quotas.upsert({ scope_type: 'global', window: 'day', max_cost_usd: cap }, { actor: 'import', origin: 'import' }).id}`);
++ const vcap = Number(settings.ai_viewers_global_cap_usd_per_day);
++ if (vcap > 0) once('live.site_settings', 'ai_viewers_global_cap_usd_per_day', vcap, () => `quota:${quotas.upsert({ scope_type: 'global', window: 'day', max_cost_usd: vcap, workflow_prefix: 'live.viewers.' }, { actor: 'import', origin: 'import' }).id}`);
++
++ // ── ai_usage -> usage_daily (+ today's windows) ──
++ if (hasTable(live, 'ai_usage')) {
++ const cols = live.prepare('PRAGMA table_info(ai_usage)').all().map(c => c.name);
++ const has = (c) => cols.includes(c);
++ const rows = live.prepare(`SELECT id, kind, model, input_tokens, output_tokens, cost_usd, created_at,
++ ${has('owner_user_id') ? 'owner_user_id' : 'NULL AS owner_user_id'}, ${has('cached_tokens') ? 'cached_tokens' : '0 AS cached_tokens'}, ${has('provider') ? 'provider' : "'shared' AS provider"}
++ FROM ai_usage ORDER BY id`).all();
++ const today = iso(now).slice(0, 10);
++ const dayStart = Math.floor(now / 86400000) * 86400;
++ const insDaily = db && !dryRun ? db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)
++ VALUES (?, 'service:live', ?, ?, ?, ?, 1, ?, ?, ?, ?)
++ ON CONFLICT(day, requester, attribution, workflow_key, provider_key, model_key) DO UPDATE SET requests = requests + 1,
++ tokens_in = tokens_in + excluded.tokens_in, tokens_out = tokens_out + excluded.tokens_out, tokens_cached = tokens_cached + excluded.tokens_cached, cost_usd = cost_usd + excluded.cost_usd`) : null;
++ const insCounter = db && !dryRun ? db.prepare(`INSERT INTO usage_counters (scope_type, scope_id, window, window_start, workflow_prefix, requests, tokens, cost_usd) VALUES (?, ?, 'day', ?, '', 1, ?, ?)
++ ON CONFLICT(scope_type, scope_id, window, window_start, workflow_prefix) DO UPDATE SET requests = requests + 1, tokens = tokens + excluded.tokens, cost_usd = cost_usd + excluded.cost_usd`) : null;
++ const tx = (fn) => (db && !dryRun ? db.transaction(fn)() : fn());
++ tx(() => {
++ for (const r of rows) {
++ if (ledgerGet('live.ai_usage', r.id)) { bump('live.ai_usage', 'unchanged'); continue; }
++ const day = String(r.created_at || '').slice(0, 10);
++ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { hold('live.ai_usage', r.id, 'unrepresentable', 'no valid created_at'); continue; }
++ const wf = KIND_MAP[r.kind] || 'live.complete';
++ const attribution = r.owner_user_id ? `live:user:${r.owner_user_id}` : '';
++ const provider = r.provider === 'byo' ? 'live-byo' : 'shared';
++ const tokens = (Number(r.input_tokens) || 0) + (Number(r.output_tokens) || 0);
++ if (!dryRun) {
++ insDaily.run(day, attribution, wf, provider, r.model || '', Number(r.input_tokens) || 0, Number(r.output_tokens) || 0, Number(r.cached_tokens) || 0, Number(r.cost_usd) || 0);
++ if (day === today && provider === 'shared') {
++ insCounter.run('global', '*', dayStart, tokens, Number(r.cost_usd) || 0);
++ insCounter.run('service', 'live', dayStart, tokens, Number(r.cost_usd) || 0);
++ if (attribution) insCounter.run('attribution', attribution, dayStart, tokens, Number(r.cost_usd) || 0);
++ }
++ }
++ ledger('live.ai_usage', r.id, `usage_daily:${day}/${wf}#`);
++ bump('live.ai_usage', 'imported');
++ }
++ });
++ }
++
++ // ── channel_ai_config -> per-streamer AI-viewer budgets; BYO keys held ──
++ if (hasTable(live, 'channel_ai_config')) {
++ for (const c of live.prepare('SELECT user_id, use_shared_key, daily_budget_cents, byo_key, byo_base_url FROM channel_ai_config').all()) {
++ if (Number(c.use_shared_key) === 1 || c.use_shared_key == null) {
++ const usd = (Number(c.daily_budget_cents) || 0) / 100;
++ if (usd <= 0) { bump('live.channel_ai_config', 'unchanged'); continue; }
++ once('live.channel_ai_config', c.user_id, usd, () => `quota:${quotas.upsert({ scope_type: 'attribution', scope_id: `live:user:${c.user_id}`, window: 'day', max_cost_usd: usd, workflow_prefix: 'live.viewers.' }, { actor: 'import', origin: 'import' }).id}`);
++ } else {
++ let host = '';
++ try { host = c.byo_base_url ? new URL(c.byo_base_url).host : ''; } catch { host = ''; }
++ hold('live.channel_ai_config', c.user_id, 'byo_provider', `streamer ${c.user_id} uses their own ${c.byo_key ? 'key' : 'self-hosted endpoint'}${host ? ` (${host})` : ''}; BYO providers keep running in Live (llm.js provider override) until per-actor provider secrets exist here`);
++ }
++ }
++ }
++
++ // ── Whole-table holds ──
++ const tableHold = (table, reason, detail) => {
++ if (!hasTable(live, table)) return;
++ const n = live.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
++ hold(`live.${table}`, '*', reason, `${n} row(s): ${detail}`);
++ };
++ tableHold('translations', 'unrepresentable', 'keyed by sha1(from|to|text) without the source text, so they cannot become input-hashed cache entries; Live keeps them as its local read-through cache and new translations are cached here per service');
++ tableHold('ai_timeline_cache', 'stays_in_live', 'an assembled Live read model (no model output); Live keeps serving it');
++ tableHold('ai_chatbot_configs', 'superseded', 'Live already migrated these rows into channel_ai_config');
++
++ live.close();
++ if (db) db.close();
++ const report = { dry_run: dryRun, ai_db: aiPath, sources: stats, environment: envAdvice };
++ log(JSON.stringify(report, null, 2));
++ return report;
++}
++
++if (require.main === module) {
++ require('dotenv').config();
++ let args;
++ try { args = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
++ if (args.help || !args.liveDb) {
++ console.error('usage: node scripts/import-from-live.js --live-db <snapshot.db> [--ai-db <ai.db>] [--dry-run]');
++ process.exit(args.help ? 0 : 2);
++ }
++ try { run({ liveDb: args.liveDb, aiDb: args.aiDb, dryRun: args.dryRun }); } catch (e) { console.error(`[import] ${e.message}`); process.exit(1); }
++}
++
++module.exports = { run, KIND_MAP };
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..fccd357
---- /dev/null
++++ b/test/import.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++// scripts/import-from-live.js against a synthetic Live snapshot: --dry-run writes nothing; a real
++// run imports usage, budgets, per-role models and pricing, and holds what cannot be represented
++// (secrets, BYO keys, text-less translation cache); a second run imports nothing; a new Live row
++// is picked up alone. Secret values never appear in the report or the holds.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const { suite, tmpDir } = require('./helpers');
++const { run } = require('../scripts/import-from-live');
++
++const t = suite('import');
++const dir = tmpDir();
++const liveDb = path.join(dir, 'live-snapshot.db');
++const aiDb = path.join(dir, 'ai.db');
++const SECRET = 'sk-live-snapshot-secret-DO-NOT-PRINT';
++const env = { NODE_ENV: 'test', AI_QUOTA_SERVICE_RPM: '0', AI_QUOTA_SERVICE_RPD: '0' };
++const quiet = () => {};
++const today = new Date().toISOString().slice(0, 10);
++
++t.test('build a Live snapshot', () => {
++ const d = new Database(liveDb);
++ d.exec(`CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', description TEXT, type TEXT, updated_at DATETIME);
++ CREATE TABLE ai_usage (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, cost_usd REAL, owner_user_id INTEGER, source TEXT, created_at DATETIME, cached_tokens INTEGER, role TEXT, provider TEXT, latency_ms INTEGER);
++ CREATE TABLE channel_ai_config (user_id INTEGER PRIMARY KEY, enabled INTEGER, use_shared_key INTEGER, daily_budget_cents INTEGER, byo_key TEXT, byo_base_url TEXT, byo_model TEXT, settings_json TEXT);
++ CREATE TABLE translations (key TEXT PRIMARY KEY, src TEXT, dst TEXT, text TEXT NOT NULL, created_at DATETIME);
++ CREATE TABLE ai_timeline_cache (user_id INTEGER PRIMARY KEY, payload TEXT, generated_at DATETIME);
++ CREATE TABLE ai_chatbot_configs (user_id INTEGER PRIMARY KEY, api_token TEXT);`);
++ const set = d.prepare('INSERT INTO site_settings (key, value) VALUES (?, ?)');
++ for (const [k, v] of [['ai_enabled', 'true'], ['ai_provider', 'openai'], ['ai_api_key', SECRET], ['ai_model', 'gpt-5-mini'], ['ai_model_director', 'gpt-5'], ['ai_model_vision', ''],
++ ['ai_pricing_json', '{"gpt-5-nano":{"in":0.05,"out":0.4,"cached":0.005},"default":{"in":1,"out":2}}'], ['ai_max_cost_usd_per_day', '5'], ['ai_viewers_global_cap_usd_per_day', '2']]) set.run(k, v);
++ const use = d.prepare('INSERT INTO ai_usage (kind, model, input_tokens, output_tokens, cost_usd, owner_user_id, created_at, cached_tokens, provider) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
++ use.run('translate', 'gpt-5-nano', 100, 20, 0.01, null, `${today} 10:00:00`, 0, 'shared');
++ use.run('ai_viewers_director', 'gpt-5', 1000, 200, 0.2, 42, `${today} 10:01:00`, 500, 'shared');
++ use.run('ai_viewers', 'llama3', 50, 10, 0, 7, '2026-09-01 08:00:00', 0, 'byo');
++ use.run('weird_kind', 'gpt-5-mini', 10, 10, 0.001, null, '2026-09-02 08:00:00', 0, 'shared');
++ use.run('paste_text', 'gpt-5-mini', 10, 10, 0.001, null, 'garbage', 0, 'shared');
++ const ch = d.prepare('INSERT INTO channel_ai_config (user_id, use_shared_key, daily_budget_cents, byo_key, byo_base_url) VALUES (?, ?, ?, ?, ?)');
++ ch.run(42, 1, 20, '', '');
++ ch.run(7, 0, 20, 'sk-byo-streamer-secret', 'https://llm.example.org/v1');
++ d.prepare("INSERT INTO translations (key, src, dst, text) VALUES ('abc', 'ja', 'en', 'hello')").run();
++ d.close();
++});
++
++t.test('--dry-run reports the plan and writes nothing', () => {
++ const r = run({ liveDb, aiDb, dryRun: true, env, log: quiet });
++ assert.strictEqual(r.dry_run, true);
++ assert.strictEqual(fs.existsSync(aiDb), false, 'no AI database created');
++ assert.strictEqual(r.sources['live.ai_usage'].imported, 4);
++ assert.strictEqual(r.sources['live.ai_usage'].held, 1);
++ assert.ok(!JSON.stringify(r).includes(SECRET));
++});
++
++t.test('a real run imports and holds, without leaking secrets', () => {
++ const r = run({ liveDb, aiDb, env, log: quiet });
++ assert.ok(!JSON.stringify(r).includes(SECRET) && !JSON.stringify(r).includes('sk-byo'));
++ assert.ok(r.environment.some(l => l.startsWith('AI_API_KEY=<')), 'tells the operator which secret NAME to set');
++ const d = new Database(aiDb, { readonly: true });
++ const quotas = d.prepare('SELECT * FROM quotas WHERE origin = ?').all('import');
++ assert.ok(quotas.some(q => q.scope_type === 'global' && q.max_cost_usd === 5 && !q.workflow_prefix));
++ assert.ok(quotas.some(q => q.scope_type === 'global' && q.max_cost_usd === 2 && q.workflow_prefix === 'live.viewers.'));
++ assert.ok(quotas.some(q => q.scope_type === 'attribution' && q.scope_id === 'live:user:42' && q.max_cost_usd === 0.2));
++ const route = d.prepare("SELECT * FROM routes WHERE key = 'live.director' ORDER BY version DESC LIMIT 1").get();
++ assert.strictEqual(route.primary_model, 'gpt-5');
++ assert.strictEqual(route.created_by, 'import');
++ const model = d.prepare("SELECT * FROM models WHERE provider_key = 'shared' AND model_key = 'gpt-5-nano'").get();
++ assert.strictEqual(model.cost_in_per_mtok, 0.05);
++ const usage = d.prepare('SELECT * FROM usage_daily').all();
++ assert.ok(usage.some(u => u.workflow_key === 'live.translate' && u.day === today));
++ assert.ok(usage.some(u => u.workflow_key === 'live.viewers.plan' && u.attribution === 'live:user:42' && u.tokens_cached === 500));
++ assert.ok(usage.some(u => u.provider_key === 'live-byo'));
++ assert.ok(usage.some(u => u.workflow_key === 'live.complete'), 'unknown kinds land on live.complete');
++ const counter = d.prepare("SELECT * FROM usage_counters WHERE scope_type = 'global' AND window = 'day'").get();
++ assert.ok(Math.abs(counter.cost_usd - 0.21) < 1e-9, "today's spend counts toward today's cap");
++ const holds = d.prepare('SELECT * FROM import_holds').all();
++ const reasons = Object.fromEntries(holds.map(h => [`${h.source}:${h.source_key}`, h.reason]));
++ assert.strictEqual(reasons['live.site_settings:ai_api_key'], 'secret');
++ assert.strictEqual(reasons['live.channel_ai_config:7'], 'byo_provider');
++ assert.strictEqual(reasons['live.translations:*'], 'unrepresentable');
++ assert.strictEqual(reasons['live.ai_usage:5'], 'unrepresentable');
++ assert.ok(!JSON.stringify(holds).includes(SECRET) && !JSON.stringify(holds).includes('sk-byo'));
++ d.close();
++});
++
++t.test('a second run imports nothing and changes no totals', () => {
++ const before = new Database(aiDb, { readonly: true });
++ const sum = (x) => x.prepare('SELECT SUM(requests) r, SUM(cost_usd) c FROM usage_daily').get();
++ const s1 = sum(before);
++ before.close();
++ const r = run({ liveDb, aiDb, env, log: quiet });
++ for (const [src, s] of Object.entries(r.sources)) assert.strictEqual(s.imported + s.held, 0, `${src} imported again: ${JSON.stringify(s)}`);
++ const after = new Database(aiDb, { readonly: true });
++ assert.deepStrictEqual(sum(after), s1);
++ assert.strictEqual(after.prepare("SELECT COUNT(*) n FROM routes WHERE key = 'live.director'").get().n, 2, 'no extra route version');
++ after.close();
++});
++
++t.test('new Live rows and changed settings are picked up alone', () => {
++ const d = new Database(liveDb);
++ d.prepare("INSERT INTO ai_usage (kind, model, input_tokens, output_tokens, cost_usd, created_at, provider) VALUES ('hero_slogans', 'gpt-5-mini', 5, 5, 0.002, ?, 'shared')").run(`${today} 11:00:00`);
++ d.prepare("UPDATE site_settings SET value = '8' WHERE key = 'ai_max_cost_usd_per_day'").run();
++ d.close();
++ const r = run({ liveDb, aiDb, env, log: quiet });
++ assert.strictEqual(r.sources['live.ai_usage'].imported, 1);
++ assert.strictEqual(r.sources['live.site_settings'].imported, 1);
++ const a = new Database(aiDb, { readonly: true });
++ assert.strictEqual(a.prepare("SELECT max_cost_usd FROM quotas WHERE scope_type = 'global' AND workflow_prefix IS NULL").get().max_cost_usd, 8);
++ a.close();
++});
++
++t.run();