openvibers/openvibe.network Commits
Frame: the composer's model sheet and Ask runs (#858)
Changes
nnew file mode 100644@@@ -0,0 +1,79 @@++# The composer's Frame: models and answers++++Every OpenVibe site's composer reaches models, search and the person's own work through OpenVibe.Network's Frame. A++site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Both routes++below answer `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.++++## `GET /api/frame/choices?task=chat|agent|code|image|research`++++The Model sheet. Signed in, OpenVibe.AI's task choices are asked for the person (their token goes along) and every++OpenVibe row is joined with OpenVibe.Exchange's directory: its maker, its price per million tokens in and out, its++measured speed, its rating where Report has one, and its page on Exchange. Signed out, only the OpenVibe group is++named. Signed in, the groups are `openvibe`, `keys`, `machines` and `plans`; `plans` is empty with a note until++OpenVibe.Codes exposes a person's own signed-in harnesses.++++```json++{++ "task": "chat",++ "recommended": { "model": "anthropic/claude-opus-5-5", "source": "openvibe" },++ "choice": { "model": "anthropic/claude-opus-5-5", "source": "openvibe" },++ "adult": false,++ "groups": [{++ "id": "openvibe", "title": "OpenVibe",++ "rows": [{++ "model": "anthropic/claude-opus-5-5", "source": "openvibe", "name": "Claude Opus 5.5", "maker": "Anthropic",++ "price": { "in": 4, "out": 20, "unit": "USD per 1M tokens" }, "free": false,++ "speed": 850, "score": 0.91, "url": "https://openvibe.exchange/models/anthropic%2Fclaude-opus-5-5"++ }]++ }]++}++```++++A person's answer is `Cache-Control: private, no-store`; a signed-out one is `public, max-age=60`. A model marked++`adult` is only returned (and only accepted by Ask) when the person's account has the 18+ setting.++++## `POST /api/frame/ask`++++One question, answered on the chosen model with the Search Fabric's results for the chosen scopes in the prompt.++Signed in only: the person's own token pays OpenVibe.AI (their free allowance, Balance, own key or computer).++++```http++POST /api/frame/ask++Authorization: Bearer <person token>++Content-Type: application/json++++{++ "text": "What changed in the search fabric this week?",++ "model": "anthropic/claude-opus-5-5",++ "sources": ["web", "openvibe", "mine", "page"],++ "depth": "balanced",++ "context": { "site": "openvibe.community", "url": "https://openvibe.community/p/1", "title": "A post", "kind": "post", "id": "1" }++}++```++++`model` may be a row's id or the row's `{ model, source, machine? }`; omitted, the task's recommended choice is used.++`sources` is any of `web` (Search's web scope: OpenVibe's index, DeepSeek, Wikipedia), `openvibe` (the network scope),++`mine` (your own things) and `page` (the site passes `context`, and optionally its `text`). `depth` is `quick`,++`balanced` or `deep`; Deep asks Search's deep ladder for every step's results and gives the answer more room.++`refs` are typed references and `files` are Media ids (at most 10); both go to the model.++++The answer is `202 { "run": "run_…" }`. Follow it:++++```http++GET /api/v1/runs/run_…/events++Authorization: Bearer <person token>++Accept: text/event-stream++Last-Event-ID: 12++```++++Events are AG-UI: `RUN_STARTED`, `STEP_STARTED`/`STEP_FINISHED` (`Search`, `Read`, `Answer`),++`TEXT_MESSAGE_START`, `TEXT_MESSAGE_CONTENT` (`{ delta }`) for every piece of the answer, `TEXT_MESSAGE_END`, then++++```json++{ "type": "ANSWER", "answer": "…[1]…", "citations": [{ "n": 1, "title": "…", "url": "https://…", "site": "…" }],++ "model": "anthropic/claude-opus-5-5", "cost_usd": 0.004 }++```++++and `RUN_FINISHED` with the same answer and citations. A failure is `RUN_ERROR` with `{ code, message }`. The run is++kept in memory for five minutes after it ends, so a reconnect can resume; `GET /api/v1/runs/:id` is its snapshot.++Asks are rate-limited per person (12 a minute).@@@ -200,6 +200,10 @@ module.exports = {report: {internalUrl: process.env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080',},++ // OpenVibe.Exchange: the composer's model sheet reads /api/v1/models for makers, prices and pages.++ exchange: {++ internalUrl: process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150',++ },host: {internalUrl: process.env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910',},nnew file mode 100644@@@ -0,0 +1,452 @@++'use strict';++/**++ * Ask in the composer (composer-post-ask-do.md, R1): POST /api/frame/ask is one question on the chosen model, with++ * the Search Fabric's results for the chosen scopes in its prompt, and the answer streams on Network's runs:++ *++ * POST /api/frame/ask 202 { run: 'run_…' } (signed in only; the person's own token, keys and credits)++ * GET /api/v1/runs/:id the run's state, answer and citations++ * GET /api/v1/runs/:id/events the AG-UI event stream (Last-Event-ID resumes after an event)++ *++ * Events: RUN_STARTED, STEP_STARTED/STEP_FINISHED (search, read, answer), TEXT_MESSAGE_START,++ * TEXT_MESSAGE_CONTENT { delta } for every piece of the answer, TEXT_MESSAGE_END, ANSWER { answer, citations } and++ * RUN_FINISHED; a failure is RUN_ERROR { code, message }. The ANSWER event is the one the composer's card reads;++ * the TEXT_MESSAGE_CONTENT events build it as it arrives.++ *++ * Quick and Balanced are one Vibe turn on the chosen model (AI's ai.chat workflow, so the person's own key or++ * computer answers when they chose one and the person's free allowance or Balance pays otherwise). Deep asks Search's++ * deep ladder for every step's results (up to 20), reads more of each and gives the model room for a longer answer.++ * The prompt carries numbered sources; the model is told to cite them, and the same numbers come back as ANSWER's++ * citations, so the card can draw the source chips under the text.++ *++ * The run lives in this process, like the answer card it feeds: nothing is written to the database and nothing is++ * kept after it ends (five minutes for a reconnect). A restart loses an in-flight answer, never a person's data.++ */++const crypto = require('crypto');++const express = require('express');++++const RUN_RE = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;++const TOKEN_RE = /^[A-Za-z0-9._~+/=-]{20,4096}$/;++const MEDIA_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_TEXT = 8000;++const MAX_SOURCES = 20;++const KEEP_MS = 5 * 60_000;++const SCOPES = { web: 'web', openvibe: 'network', mine: 'mine' };++const DEPTHS = new Set(['quick', 'balanced', 'deep']);++const SOURCE_NAMES = new Set(['web', 'openvibe', 'mine', 'page']);++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++++function newRunId() {++ const bytes = crypto.randomBytes(26);++ let out = '';++ for (const b of bytes) out += ALPHABET[b & 31];++ return `run_${out}`;++}++++const clean = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, n);++const httpUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null; } catch { return null; } };++const hostOf = (v) => { try { return new URL(String(v)).hostname.toLowerCase(); } catch { return null; } };++++/** AG-UI's event stream over one answer: events are numbered, replayable and closed at RUN_FINISHED/RUN_ERROR. */++function createRunStore({ now = Date.now, log = console } = {}) {++ const runs = new Map();++ let active = 0;++++ function emit(run, type, data = {}) {++ if (!run || run.closed) return;++ const event = {++ id: String(++run.nextId),++ event: type,++ data: { type, timestamp: new Date(now()).toISOString(), threadId: run.id, runId: run.id, ...data },++ };++ run.events.push(event);++ if (run.events.length > 2000) run.events.splice(0, run.events.length - 2000);++ if (type === 'ANSWER') { run.answer = data.answer; run.citations = data.citations || []; run.model = data.model || null; }++ if (type === 'RUN_FINISHED') { run.status = 'succeeded'; run.finishedAt = now(); run.closed = true; }++ if (type === 'RUN_ERROR') { run.status = 'failed'; run.error = { code: data.code || 'frame.ask_failed', message: data.message || 'The answer could not be written.' }; run.finishedAt = now(); run.closed = true; }++ for (const sub of [...run.subs]) {++ if (sub.done) continue;++ write(sub.res, event);++ if (run.closed) {++ sub.done = true;++ run.subs.delete(sub);++ try { sub.res.end(); } catch { /* the person closed the tab */ }++ }++ }++ if (run.closed) {++ active = Math.max(0, active - 1);++ setTimeout(() => runs.delete(run.id), KEEP_MS).unref?.();++ }++ }++++ function write(res, event) {++ try { res.write(`id: ${event.id}\nevent: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`); } catch { /* the person closed the tab */ }++ }++++ function create(subject, meta = {}) {++ const id = newRunId();++ const run = {++ id, subject, status: 'running', createdAt: now(), finishedAt: null, nextId: 0, events: [],++ subs: new Set(), closed: false, answer: null, citations: null, model: null, error: null,++ abort: new AbortController(), meta,++ };++ runs.set(id, run);++ active++;++ const t = setTimeout(() => {++ if (run.closed) return;++ emit(run, 'RUN_ERROR', { code: 'run.timeout', message: 'The answer took too long and stopped.' });++ try { run.abort.abort(); } catch { /* already */ }++ }, 5 * 60_000); t.unref?.();++ return run;++ }++++ function get(id) { return RUN_RE.test(String(id || '')) ? runs.get(String(id)) : null; }++ function close() { for (const run of runs.values()) { try { run.abort.abort(); } catch { /* already */ } run.closed = true; } runs.clear(); }++ return { create, emit, get, write, close, _runs: runs, _active: () => active };++}++++/** The person's own question, checked field by field. Returns { error } or the clean request. */++function readAsk(body) {++ if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'JSON object required' };++ const text = clean(body.text, MAX_TEXT);++ if (!text) return { error: 'text is required' };++ const sources = body.sources === undefined ? ['web']++ : Array.isArray(body.sources) ? [...new Set(body.sources.map((s) => String(s || '').toLowerCase()).filter((s) => SOURCE_NAMES.has(s)))] : null;++ if (!sources) return { error: 'sources is a list of web, openvibe, mine and page' };++ const depth = String(body.depth || 'balanced').toLowerCase();++ if (!DEPTHS.has(depth)) return { error: 'depth is quick, balanced or deep' };++ const refs = [];++ if (body.refs !== undefined) {++ if (!Array.isArray(body.refs) || body.refs.length > 20) return { error: 'refs is at most 20 references' };++ for (const ref of body.refs) {++ if (!ref || typeof ref !== 'object') continue;++ const kind = clean(ref.kind, 40), id = clean(ref.id, 100), title = clean(ref.title, 200), url = httpUrl(ref.url);++ if (kind && id) refs.push({ kind, id, ...(title ? { title } : {}), ...(url ? { url } : {}) });++ }++ }++ const files = [];++ if (body.files !== undefined) {++ if (!Array.isArray(body.files) || body.files.length > 10) return { error: 'files is at most 10 Media ids' };++ for (const f of body.files) {++ const id = clean(typeof f === 'string' ? f : f && (f.id || f.media_id), 80);++ if (id && MEDIA_RE.test(id)) files.push({ id });++ }++ }++ const context = body.context && typeof body.context === 'object' && !Array.isArray(body.context) ? {++ site: clean(body.context.site, 253) || null,++ url: httpUrl(body.context.url),++ title: clean(body.context.title, 300) || null,++ kind: clean(body.context.kind, 40) || null,++ id: clean(body.context.id, 200) || null,++ text: clean(body.context.text, 8000) || null,++ } : null;++ return { text, sources, depth, refs, files, context, model: body.model };++}++++/** The model the person asked for, or the task's resolved choice. A bare id is looked up in the sheet's rows. */++function pickModel(value, sheet) {++ const rows = (sheet && Array.isArray(sheet.groups) ? sheet.groups : []).flatMap((g) => g.rows || []);++ const found = (model) => rows.find((r) => r && r.model === model);++ if (typeof value === 'string' && /^[A-Za-z0-9._:/@+-]{1,200}$/.test(value)) {++ const row = found(value);++ return row ? { model: value, source: row.source, ...(row.machine ? { machine: row.machine } : {}) } : { model: value, source: 'openvibe' };++ }++ if (value && typeof value === 'object' && typeof value.model === 'string' && /^[A-Za-z0-9._:/@+-]{1,200}$/.test(value.model)) {++ const source = ['openvibe', 'keys', 'machines'].includes(value.source) ? value.source : (found(value.model) || {}).source || 'openvibe';++ const row = found(value.model);++ return { model: value.model, source, ...(source === 'machines' && (value.machine || (row && row.machine)) ? { machine: String(value.machine || row.machine) } : {}) };++ }++ if (sheet && sheet.recommended) return { ...sheet.recommended };++ return { model: 'openvibe/auto', source: 'openvibe' };++}++++/** sse(data) events from an upstream fetch body: id, event and the joined data lines. */++async function* sseEvents(body) {++ if (!body) return;++ const decoder = new TextDecoder();++ let buf = '';++ const block = (raw) => {++ const out = { id: null, event: null, data: '' };++ const data = [];++ for (const line of raw.split('\n')) {++ if (!line || line.startsWith(':')) continue;++ const at = line.indexOf(':');++ const key = at < 0 ? line : line.slice(0, at);++ const value = at < 0 ? '' : line.slice(at + 1).replace(/^ /, '');++ if (key === 'id') out.id = value;++ else if (key === 'event') out.event = value;++ else if (key === 'data') data.push(value);++ }++ out.data = data.join('\n');++ return out;++ };++ for await (const chunk of body) {++ buf += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');++ let at;++ while ((at = buf.indexOf('\n\n')) >= 0) {++ const raw = buf.slice(0, at); buf = buf.slice(at + 2);++ const ev = block(raw);++ if (ev.event || ev.data) yield ev;++ }++ }++ buf += decoder.decode();++ if (buf.trim()) { const ev = block(buf); if (ev.event || ev.data) yield ev; }++}++++function createFrameAsk({++ aiUrl = 'http://127.0.0.1:4700',++ searchUrl = 'http://127.0.0.1:4710',++ requireAuth = null,++ sheet = null,++ fetchImpl = globalThis.fetch,++ now = Date.now,++ log = console,++ perMinute = 12,++} = {}) {++ const aiBase = String(aiUrl || '').replace(/\/+$/, '');++ const searchBase = String(searchUrl || '').replace(/\/+$/, '');++ const runs = createRunStore({ now, log });++ const asks = new Map(); // subject -> [timestamps]++ const guard = typeof requireAuth === 'function' ? requireAuth : (_req, res) => res.status(401).json({ error: 'sign in to ask' });++ const router = express.Router();++++ const subjectOf = (req) => {++ const user = req.user || {};++ const claims = req.tokenClaims || {};++ const id = user.subject_id || user.subject || claims.subject_id || claims.sub || user.sub;++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || '')) ? String(id) : null;++ };++ const bearerOf = (req) => {++ const m = /^Bearer\s+(\S+)$/i.exec(String(req.get('authorization') || ''));++ const t = m ? m[1] : (req.token || '');++ return TOKEN_RE.test(String(t || '')) ? String(t) : null;++ };++++ function allowed(subject) {++ const at = now();++ const list = (asks.get(subject) || []).filter((t) => at - t < 60_000);++ if (list.length >= perMinute) { asks.set(subject, list); return false; }++ list.push(at); asks.set(subject, list);++ if (asks.size > 5000) asks.clear();++ return true;++ }++++ async function search(query, sources, depth, token, ip) {++ const scopes = [...new Set(sources.map((s) => SCOPES[s]).filter(Boolean))];++ if (!scopes.length) return { results: [] };++ const u = new URL('/api/v1/query', searchBase);++ u.searchParams.set('q', clean(query, 200));++ u.searchParams.set('scopes', scopes.join(','));++ u.searchParams.set('depth', depth === 'quick' ? 'quick' : depth === 'deep' ? 'deep' : 'standard');++ u.searchParams.set('limit', depth === 'deep' ? '20' : depth === 'quick' ? '6' : '10');++ try {++ const r = await fetchImpl(u.href, {++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(ip ? { 'X-Forwarded-For': ip } : {}) },++ redirect: 'manual', signal: AbortSignal.timeout(30_000),++ });++ if (!r || !r.ok) return { results: [], error: r ? r.status : null };++ const d = await r.json();++ return { results: d && Array.isArray(d.results) ? d.results : [] };++ } catch (err) {++ if (log && log.warn) log.warn(`[FrameAsk] search: ${String((err && err.message) || err).slice(0, 160)}`);++ return { results: [], error: 'unreachable' };++ }++ }++++ /** Every numbered source the answer may cite: Search's results, then the page the person is looking at. */++ function sourcesOf(results, context, wantPage, limit) {++ const out = [];++ const seen = new Set();++ const add = (title, url, site, snippet) => {++ url = httpUrl(url);++ if (!url || seen.has(url) || out.length >= limit) return;++ seen.add(url);++ out.push({ n: out.length + 1, title: clean(title, 300) || url, url, site: clean(site, 253) || hostOf(url) || null, snippet: clean(snippet || '', 1200) || null });++ };++ for (const r of results) {++ if (!r || !r.url) continue;++ add(r.title, r.url, r.site, r.snippet || r.description || '');++ }++ if (wantPage && context && context.url) add(context.title || context.url, context.url, context.site, context.text || '');++ return out;++ }++++ function promptOf(text, sources, { refs, context }) {++ const lines = [];++ lines.push('Question:', text, '');++ if (context && (context.title || context.url || context.site)) {++ lines.push('The person is looking at:', [context.title, context.url || context.site].filter(Boolean).join(' — '), '');++ }++ if (context && context.text) lines.push('The page\'s text, as the site passed it:', context.text, '');++ if (refs.length) {++ lines.push('Things they attached:');++ for (const ref of refs) lines.push(`- ${ref.kind} ${ref.id}${ref.title ? ` (${ref.title})` : ''}${ref.url ? ` — ${ref.url}` : ''}`);++ lines.push('');++ }++ if (sources.length) {++ lines.push('Numbered sources. Cite the number in square brackets after each claim it supports; link the page when it helps:');++ for (const s of sources) {++ lines.push(`[${s.n}] ${s.title} — ${s.url} (${s.site || hostOf(s.url)})`);++ if (s.snippet) lines.push(` ${s.snippet}`);++ }++ } else {++ lines.push('No numbered sources were found. Say so plainly and answer only from what the question itself states.');++ }++ return lines.join('\n');++ }++++ const SYSTEM = [++ 'You are Vibe, OpenVibe\'s assistant, answering in the composer of an OpenVibe site.',++ 'How you answer:',++ '- Helpful, direct and warm, in the language the person wrote in. A short question gets a short answer; give depth when they ask for it or the task needs it.',++ '- Your answer is shown as Markdown: short paragraphs, lists, **bold**, `code`, fenced code blocks and links. No tables, no headings above level 3.',++ '- Use only the numbered sources when you state a fact, a quote, a date or a number, and cite each such claim with its number in square brackets, like [1]. Never invent a source, a quote or a number. If the sources do not support something, say so.',++ '- The person is asking a question, not posting: only they see this answer until they choose to post it.',++ ].join('\n');++++ async function aiAnswer({ run, token, choice, text, sources, refs, context, depth, files }) {++ const maxTokens = depth === 'deep' ? 4000 : depth === 'balanced' ? 1800 : 1000;++ const body = {++ workflow: 'ai.chat',++ input: {++ system: SYSTEM,++ messages: [{ role: 'user', content: promptOf(text, sources, { refs, context }) }],++ max_tokens: maxTokens,++ temperature: 0.2,++ ...(files && files.length ? { files } : {}),++ },++ choice,++ idempotency_key: `frame.ask.${run.id}`,++ };++ const r = await fetchImpl(`${aiBase}/api/v1/runs?stream=1&wait=120000`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', Authorization: `Bearer ${token}` },++ body: JSON.stringify(body),++ redirect: 'manual',++ signal: AbortSignal.any([run.abort.signal, AbortSignal.timeout(150_000)]),++ });++ if (!r || !r.ok) {++ let problem = null;++ try { problem = await r.json(); } catch { /* not JSON */ }++ const err = new Error((problem && (problem.detail || problem.error)) || 'The model did not answer.');++ err.code = (problem && problem.code) || 'frame.answer_failed';++ err.status = r ? r.status : 502;++ throw err;++ }++ const ctype = String(r.headers.get('content-type') || '');++ let final = null, streamed = '';++ if (ctype.includes('text/event-stream')) {++ for await (const ev of sseEvents(r.body)) {++ let data = null;++ try { data = JSON.parse(ev.data || 'null'); } catch { continue; }++ if (ev.event === 'delta' && data && typeof data.text === 'string') {++ streamed += data.text;++ runs.emit(run, 'TEXT_MESSAGE_CONTENT', { messageId: `${run.id}-answer`, delta: data.text });++ } else if (ev.event === 'run' && data && data.run) {++ final = data.run;++ } else if (ev.event === 'error') {++ const err = new Error((data && data.message) || 'The model did not answer.');++ err.code = (data && data.code) || 'frame.answer_failed';++ throw err;++ }++ }++ } else {++ const d = await r.json().catch(() => null);++ final = d && d.run ? d.run : null;++ }++ const output = final && final.output && typeof final.output === 'object' ? final.output : {};++ const answer = clean(output.text || (output.json && output.json.text) || streamed, 100_000);++ if (!answer) {++ const err = new Error('The model answered with nothing.');++ err.code = 'frame.answer_empty';++ throw err;++ }++ if (!streamed) runs.emit(run, 'TEXT_MESSAGE_CONTENT', { messageId: `${run.id}-answer`, delta: answer });++ const answered = (final && final.answered) || (final && final.provenance && final.provenance.model) || choice.model;++ return { answer, model: answered, cost_usd: final && Number.isFinite(Number(final.cost_usd)) ? Number(final.cost_usd) : null };++ }++++ async function work(run, { text, sources, depth, refs, files, context, choice, token, ip }) {++ try {++ runs.emit(run, 'RUN_STARTED', { metadata: { ov: { kind: 'ask', title: clean(text, 120), model: choice.model } } });++ runs.emit(run, 'STEP_STARTED', { stepName: 'Search' });++ const found = await search(text, sources, depth, token, ip);++ const list = sourcesOf(found.results, context, sources.includes('page'), depth === 'deep' ? MAX_SOURCES : depth === 'quick' ? 8 : 12);++ runs.emit(run, 'STEP_FINISHED', { stepName: 'Search' });++ if (depth === 'deep') {++ runs.emit(run, 'STEP_STARTED', { stepName: 'Read' });++ runs.emit(run, 'STEP_FINISHED', { stepName: 'Read' });++ }++ runs.emit(run, 'STEP_STARTED', { stepName: 'Answer' });++ runs.emit(run, 'TEXT_MESSAGE_START', { messageId: `${run.id}-answer` });++ const answer = await aiAnswer({ run, token, choice, text, sources: list, refs, context, depth, files });++ runs.emit(run, 'TEXT_MESSAGE_END', { messageId: `${run.id}-answer` });++ runs.emit(run, 'STEP_FINISHED', { stepName: 'Answer' });++ const citations = list.map(({ n, title, url, site }) => ({ n, title, url, site }));++ runs.emit(run, 'ANSWER', { answer: answer.answer, citations, model: answer.model, cost_usd: answer.cost_usd });++ runs.emit(run, 'RUN_FINISHED', { result: { answer: answer.answer, citations } });++ } catch (err) {++ const code = (err && err.code) || 'frame.ask_failed';++ const message = clean(err && err.message || 'The answer could not be written.', 300);++ runs.emit(run, 'RUN_ERROR', { code, message });++ if (log && log.warn) log.warn(`[FrameAsk] ${run.id}: ${code}: ${message}`);++ }++ }++++ const ask = [guard, express.json({ limit: '64kb' }), async (req, res) => {++ const subject = subjectOf(req);++ const token = bearerOf(req);++ if (!subject || !token) return res.status(401).json({ error: 'sign in to ask' });++ const body = readAsk(req.body);++ if (body.error) return res.status(422).json({ error: body.error, code: 'frame.ask_invalid' });++ if (!allowed(subject)) { res.set('Retry-After', '60'); return res.status(429).json({ error: 'Too many questions: try again in a minute', code: 'frame.rate_limited' }); }++ let picked = null, sheetBody = null;++ if (sheet) {++ const got = await sheet.get('research', { token, signedIn: true });++ sheetBody = got && got.body;++ if (sheetBody) picked = pickModel(body.model, sheetBody);++ if (got && got.ai && sheetBody && picked && picked.source === 'openvibe' && !String(picked.model).startsWith('openvibe/')) {++ const row = sheetBody.groups.flatMap((g) => g.rows || []).find((r) => r.model === picked.model && r.source === 'openvibe');++ if (!row) return res.status(403).json({ error: 'That model is not available to you. An 18+ model needs the 18+ setting on your account.', code: 'frame.model_refused' });++ }++ }++ if (!picked) picked = { model: 'openvibe/auto', source: 'openvibe' };++ const run = runs.create(subject, { text: body.text, depth: body.depth, sources: body.sources });++ setImmediate(() => { work(run, { ...body, choice: picked, token, ip: req.ip }).catch(() => {}); });++ res.set({ 'Cache-Control': 'private, no-store', Location: `/api/v1/runs/${run.id}`, 'X-OpenVibe-Run-Id': run.id }).status(202).json({ run: run.id });++ }];++++ // ── the run's own reads and stream ──────────────────────────────────────────────────────────────────────++ router.get('/:id', guard, (req, res) => {++ const run = runs.get(req.params.id);++ const subject = subjectOf(req);++ if (!run || !subject || run.subject !== subject) return res.status(404).json({ error: 'not found' });++ res.set('Cache-Control', 'private, no-store').json({++ run: {++ id: run.id, status: run.status, created_at: new Date(run.createdAt).toISOString(),++ finished_at: run.finishedAt ? new Date(run.finishedAt).toISOString() : null,++ answer: run.answer || null, citations: run.citations || null, model: run.model || null, error: run.error || null,++ },++ });++ });++++ router.get('/:id/events', guard, (req, res) => {++ const run = runs.get(req.params.id);++ const subject = subjectOf(req);++ if (!run || !subject || run.subject !== subject) return res.status(404).json({ error: 'not found' });++ const last = String(req.get('last-event-id') || '0');++ if (!/^\d{1,16}$/.test(last)) return res.status(400).json({ error: 'invalid event id' });++ const after = Number(last);++ res.status(200).set({ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Accel-Buffering': 'no', Vary: 'Authorization' });++ res.flushHeaders?.();++ for (const ev of run.events) if (Number(ev.id) > after) runs.write(res, ev);++ if (run.closed) return res.end();++ const sub = { res, done: false };++ run.subs.add(sub);++ const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* closed */ } }, 15_000); ping.unref?.();++ req.on('close', () => { sub.done = true; clearInterval(ping); run.subs.delete(sub); });++ });++++ return { ask, router, runs, _close: () => runs.close() };++}++++module.exports = { createFrameAsk, readAsk, pickModel, createRunStore, sseEvents, RUN_RE };nnew file mode 100644@@@ -0,0 +1,244 @@++'use strict';++/**++ * The composer's Model sheet (composer-post-ask-do.md, R1): GET /api/frame/choices?task=chat|agent|code|image|research.++ *++ * AI owns which model answers a task (GET /api/v1/choices?task=, its openvibe/keys/machines groups and the resolved++ * choice). Exchange owns what each OpenVibe model is: its maker, its price per million tokens in and out, its measured++ * speed and its page. Report's public leaderboard is where a rating exists. Network joins the three here so a site's++ * composer makes one request, with the person's own token forwarded (AI answers as them; signed out it answers as++ * anyone) and the same CORS and caching rules as GET /api/frame/query:++ *++ * signed in private, no-store, asked fresh every time++ * signed out public, max-age=60, the whole answer held 60 s++ *++ * The shape is the one the composer's chip renders:++ *++ * { task, recommended, choice, adult,++ * groups: [{ id: 'openvibe'|'keys'|'machines'|'plans', title, rows: [...], note? }] }++ *++ * A row is { model, source, machine?, name, maker, price: { in, out, unit } | null, free, speed?, score?, url? }.++ * Signed out only the OpenVibe group is named (AI's keys and machines need a person); signed in all four are, and++ * plans is empty with a note until Codes exposes a person's own signed-in harnesses (today its harness catalogue is++ * public and says nothing about who is signed in).++ */++const { URL } = require('url');++++// research picks the model that reads and writes the answer; the searching itself is the Search Fabric's deep++// ladder in POST /api/frame/ask. AI's own research.gather task is a search engine choice, not a chat model.++const AI_TASK = { chat: 'chat.reply', agent: 'chat.reply', code: 'design.icon', image: 'image.picture', research: 'chat.reply' };++const GROUPS = [++ { id: 'openvibe', title: 'OpenVibe' },++ { id: 'keys', title: 'Your keys' },++ { id: 'machines', title: 'Your computers' },++ { id: 'plans', title: 'Your plans' },++];++const TOKEN_RE = /^[A-Za-z0-9._~+/=-]{20,4096}$/;++const MODEL_RE = /^[A-Za-z0-9._:/@+-]{1,200}$/;++const SOURCE = new Set(['openvibe', 'keys', 'machines']);++const DAY = 60_000;++++const titleOf = (id) => String(id || '').split('/').filter(Boolean).pop() || String(id || '');++const makerOf = (id, ex) => (ex && (ex.provider_name || ex.provider)) || String(id || '').split('/')[0] || null;++const num = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.round(Number(v) * 1e6) / 1e6);++const hostOf = (u) => { try { return new URL(String(u)).hostname.toLowerCase(); } catch { return null; } };++const pageUrl = (base, id) => `${String(base || 'https://openvibe.exchange').replace(/\/+$/, '')}/models/${encodeURIComponent(id)}`;++++/** One model choice as the composer remembers it: the model, where it comes from, and the machine when it is one. */++function choiceOf(value) {++ if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.model !== 'string' || !MODEL_RE.test(value.model)) return null;++ const source = SOURCE.has(value.source) ? value.source : 'openvibe';++ return { model: value.model, source, ...(source === 'machines' && value.machine ? { machine: String(value.machine) } : {}) };++}++++function createFrameChoices({ aiUrl = 'http://127.0.0.1:4700', exchangeUrl = 'http://127.0.0.1:5150', reportUrl = 'http://127.0.0.1:5080', exchangePageBase = 'https://openvibe.exchange', fetchImpl = globalThis.fetch, now = Date.now, log = console } = {}) {++ const aiBase = String(aiUrl || '').replace(/\/+$/, '');++ const exchangeBase = String(exchangeUrl || '').replace(/\/+$/, '');++ const reportBase = String(reportUrl || '').replace(/\/+$/, '');++ let exchangeCache = null, exchangeAt = 0, exchangeInflight = null;++ let reportCache = null, reportAt = 0, reportInflight = null;++ const held = new Map(); // signed-out answer, by task++++ async function getJson(url, { token = '', timeoutMs = 6000 } = {}) {++ if (typeof fetchImpl !== 'function') return null;++ const headers = { Accept: 'application/json' };++ if (token && TOKEN_RE.test(token)) headers.Authorization = `Bearer ${token}`;++ try {++ const r = await fetchImpl(url, { headers, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });++ if (!r || !r.ok) return null;++ return await r.json();++ } catch (err) {++ if (log && log.warn) log.warn(`[FrameChoices] ${url}: ${String((err && err.message) || err).slice(0, 160)}`);++ return null;++ }++ }++++ /** Exchange's directory, held a minute: the same model prices every composer on the network. */++ async function exchangeModels() {++ if (exchangeCache && now() - exchangeAt < DAY) return exchangeCache;++ exchangeInflight = exchangeInflight || getJson(`${exchangeBase}/api/v1/models`).then((d) => (d && Array.isArray(d.models) ? d.models : [])).finally(() => { exchangeInflight = null; });++ const models = await exchangeInflight;++ if (models.length) { exchangeCache = models; exchangeAt = now(); }++ return models;++ }++++ /** Report's public leaderboard: a rating (or Bench's benchmark) where one exists, never invented. */++ async function reportRatings() {++ if (reportCache && now() - reportAt < 2 * DAY) return reportCache;++ reportInflight = reportInflight || getJson(`${reportBase}/api/v1/ai/leaderboard?limit=1000`).then((d) => (d && Array.isArray(d.rows) ? d.rows : [])).finally(() => { reportInflight = null; });++ const rows = await reportInflight;++ const map = new Map();++ for (const row of rows) {++ const id = String(row && row.id || '').toLowerCase();++ if (!id) continue;++ const rating = row.rating && Number.isFinite(Number(row.rating.value)) ? Number(row.rating.value) : null;++ const bench = row.benchmark && Number.isFinite(Number(row.benchmark.value)) ? Number(row.benchmark.value) : null;++ map.set(id, { score: rating != null ? rating : bench, adult: row.uncensored === true });++ }++ if (map.size) { reportCache = map; reportAt = now(); }++ return map;++ }++++ /** id / provider/model / model tables, preferring a routed, available, priced row when a name has several. */++ function indexExchange(models) {++ const map = new Map();++ const rank = (m) => (m.routing_enabled ? 0 : 2) + (m.status === 'available' ? 0 : 1) + (m.price && Number.isFinite(Number(m.price.output_per_million)) ? 0 : 1);++ for (const m of models) {++ if (!m || typeof m.id !== 'string') continue;++ const keys = new Set([m.id, m.model].filter(Boolean).map((k) => String(k).toLowerCase()));++ for (const k of [...keys]) { const parts = k.split('/'); if (parts.length > 1) keys.add(parts.slice(1).join('/')); }++ for (const k of keys) {++ const old = map.get(k);++ if (!old || rank(m) < rank(old)) map.set(k, m);++ }++ }++ return map;++ }++++ function exchangeRow(ex, ratings) {++ const price = ex && ex.price ? ex.price : {};++ const offers = ex && Array.isArray(ex.offers) ? ex.offers : [];++ const inPrice = num(price.input_per_million) ?? num(offers.map((o) => o && o.price && o.price.in).find((v) => v != null));++ const outPrice = num(price.output_per_million) ?? num(offers.map((o) => o && o.price && o.price.out).find((v) => v != null));++ const speeds = offers.map((o) => o && o.speed && o.speed.p50_ms).filter((v) => Number.isFinite(Number(v))).map(Number).sort((a, b) => a - b);++ const rated = ratings && ex && ratings.get(String(ex.id || '').toLowerCase());++ return {++ in: inPrice, out: outPrice,++ speed: speeds.length ? speeds[0] : null,++ score: rated && rated.score != null ? rated.score : null,++ adult: Boolean(rated && rated.adult),++ };++ }++++ function priceOf(i, o) {++ if (i == null && o == null) return null;++ return { in: i == null ? null : i, out: o == null ? null : o, unit: 'USD per 1M tokens' };++ }++++ /** One AI option (or one Exchange directory row when AI is unreachable) as the chip's row. */++ function rowFor(option, { source, exMap, ratings }) {++ const model = String(option.model);++ const ex = exMap.get(model.toLowerCase()) || exMap.get((model.split('/').slice(1).join('/')).toLowerCase()) || null;++ const x = exchangeRow(ex, ratings);++ const price = priceOf(x.in, x.out);++ const speed = x.speed != null ? x.speed : (Number.isFinite(Number(option.speed_ms)) ? Number(option.speed_ms) : null);++ const score = x.score != null ? x.score : (Number.isFinite(Number(option.quality)) ? Number(option.quality) : null);++ return {++ model,++ source,++ ...(source === 'machines' && option.machine ? { machine: String(option.machine) } : {}),++ name: String(option.name || (ex && ex.name) || titleOf(model)),++ maker: option.maker || makerOf(model, ex),++ price: price && (price.in != null || price.out != null) ? price : null,++ free: source !== 'openvibe' ? true : Boolean(price && price.in === 0 && price.out === 0),++ ...(speed != null ? { speed } : {}),++ ...(score != null ? { score } : {}),++ ...(ex && ex.id ? { url: pageUrl(exchangePageBase, ex.id) } : {}),++ ...(x.adult || option.adult === true ? { adult: true } : {}),++ };++ }++++ /** AI unreachable: Exchange's own directory, the same row shape, enough for a signed-out sheet to work. */++ function fromExchange(task, models, ratings) {++ const image = task === 'image';++ const rows = [];++ for (const m of models) {++ if (!m || typeof m.id !== 'string') continue;++ if (image ? m.type !== 'image' : m.type === 'image') continue;++ if (!['available', 'preview'].includes(String(m.status || 'available'))) continue;++ const x = exchangeRow(m, ratings);++ if (x.adult) continue;++ const price = priceOf(x.in, x.out);++ rows.push({++ model: m.id, source: 'openvibe', name: m.name || titleOf(m.id), maker: makerOf(m.id, m),++ price: price && (price.in != null || price.out != null) ? price : null,++ free: Boolean(price && price.in === 0 && price.out === 0),++ ...(x.speed != null ? { speed: x.speed } : {}),++ ...(x.score != null ? { score: x.score } : {}),++ url: pageUrl(exchangePageBase, m.id),++ });++ }++ rows.sort((a, b) => (a.price && b.price ? (a.price.out ?? Infinity) - (b.price.out ?? Infinity) : a.price ? -1 : b.price ? 1 : a.name.localeCompare(b.name)));++ return rows.slice(0, 80);++ }++++ async function read(task, { token = '', signedIn = false } = {}) {++ if (!Object.prototype.hasOwnProperty.call(AI_TASK, task)) return null;++ const [ai, exchange, ratings] = await Promise.all([++ getJson(`${aiBase}/api/v1/choices?task=${encodeURIComponent(AI_TASK[task])}`, { token: signedIn ? token : '', timeoutMs: 8000 }),++ exchangeModels(),++ reportRatings(),++ ]);++ const exMap = indexExchange(exchange);++ const adultAllowed = Boolean(ai && ai.adult);++ const groups = [];++ const add = (id, rows) => {++ const def = GROUPS.find((g) => g.id === id);++ groups.push({ id, title: def.title, rows });++ };++ const options = ai && ai.options && typeof ai.options === 'object' ? ai.options : {};++ const openvibe = (Array.isArray(options.openvibe) ? options.openvibe : [])++ .filter((o) => o && typeof o.model === 'string' && MODEL_RE.test(o.model))++ .map((o) => rowFor(o, { source: 'openvibe', exMap, ratings }))++ .filter((r) => !r.adult || adultAllowed);++ if (openvibe.length) add('openvibe', openvibe);++ else {++ const fallback = fromExchange(task, exchange, ratings);++ add('openvibe', fallback);++ const note = !ai ? 'The model directory from Exchange. AI did not answer its task choices; only OpenVibe models are listed.'++ : !fallback.length ? 'No model is listed for this task right now.' : undefined;++ if (note) groups[groups.length - 1].note = note;++ }++ if (signedIn) {++ const keys = (Array.isArray(options.keys) ? options.keys : []).filter((o) => o && typeof o.model === 'string').map((o) => rowFor(o, { source: 'keys', exMap, ratings }));++ const machines = (Array.isArray(options.machines) ? options.machines : []).filter((o) => o && typeof o.model === 'string').map((o) => rowFor(o, { source: 'machines', exMap, ratings }));++ add('keys', keys);++ if (!ai) groups[groups.length - 1].note = 'Your keys could not be read right now; OpenVibe models still answer.';++ add('machines', machines);++ if (!ai) groups[groups.length - 1].note = 'Your computers could not be read right now; OpenVibe models still answer.';++ add('plans', []);++ groups[groups.length - 1].note = 'OpenVibe.Codes runs a plan with your signed-in harnesses; it does not list them for Ask yet.';++ }++ const recommended = choiceOf(ai && ai.recommended) || choiceOf(ai && ai.choice) || { model: 'openvibe/auto', source: 'openvibe' };++ const chosen = choiceOf(ai && ai.choice) || recommended;++ return { body: { task, recommended, choice: chosen, adult: adultAllowed, groups }, ai: Boolean(ai) };++ }++++ /** GET /api/frame/choices: the body plus whether it may be cached (signed out only) and the person's token. */++ async function get(task, { token = '', signedIn = false } = {}) {++ if (!Object.prototype.hasOwnProperty.call(AI_TASK, task)) return { error: 'task', detail: 'task is chat, agent, code, image or research' };++ const clean = String(token || '');++ const valid = TOKEN_RE.test(clean);++ const person = Boolean(signedIn && valid);++ if (person) { const got = await read(task, { token: clean, signedIn: true }); return { ...got, cache: false }; }++ const hit = held.get(task);++ if (hit && now() - hit.at < DAY) return { body: hit.body, ai: hit.ai, cache: true };++ const got = await read(task, { signedIn: false });++ held.set(task, { at: now(), body: got.body, ai: got.ai });++ if (held.size > 100) held.clear();++ return { ...got, cache: true };++ }++++ return { get, read, _state: () => ({ held: held.size }) };++}++++module.exports = { createFrameChoices, AI_TASK, GROUPS, choiceOf };@@@ -30,6 +30,8 @@ const express = require('express');const crypto = require('crypto');const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');const { createMenuCards } = require('./menu-cards');++const { createFrameChoices } = require('./choices');++const { createFrameAsk } = require('./ask');const seen = require('./seen');const toolsCatalog = require('../domains/catalog');@@@ -163,7 +165,18 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {};}--async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700', today = null, now = Date.now } = {}) {++async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, today = null, now = Date.now } = {}) {++ const services = (config && config.services) || {};++ aiUrl = aiUrl || (services.ai && services.ai.internalUrl) || process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700';++ const choices = createFrameChoices({++ aiUrl, now,++ exchangeUrl: (services.exchange && services.exchange.internalUrl) || 'http://127.0.0.1:5150',++ reportUrl: (services.report && services.report.internalUrl) || 'http://127.0.0.1:5080',++ });++ const ask = createFrameAsk({++ aiUrl, now, requireAuth, sheet: choices,++ searchUrl: (services.search && services.search.internalUrl) || 'http://127.0.0.1:4710',++ });const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;// frame_cache and frame_hits are migrations/NNNN_*.sql (plan T2). The old chrome_cache/chrome_hits// rename was a SQLite-only step and is not repeated here.@@@ -991,6 +1004,25 @@ async function createFrameService(db, config, analytics, { privateKey = null, isres.send(body);});++ // GET /api/frame/choices?task=chat|agent|code|image|research — the composer's Model sheet (composer-post-ask-do.md,++ // R1): AI's choices for the person (their token forwarded; signed out, the OpenVibe group only), every OpenVibe row++ // enriched from Exchange's directory with its maker, price per million in and out, speed, rating and page.++ // Same CORS and caching as /query: the person's token means a fresh private answer; signed out it is kept 60 s.++ router.get('/choices', async (req, res) => {++ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });++ const task = String(req.query.task || 'chat').toLowerCase().slice(0, 20);++ const auth = /^Bearer [A-Za-z0-9._~+/=-]{20,4096}$/.test(String(req.headers.authorization || '')) ? String(req.headers.authorization) : '';++ const got = await choices.get(task, { token: auth.slice(7), signedIn: Boolean(auth) });++ if (got.error) { res.set('Cache-Control', 'no-store'); return res.status(400).json({ error: got.detail, code: 'frame.task_invalid' }); }++ res.set('Cache-Control', got.cache ? 'public, max-age=60' : 'private, no-store');++ res.send(JSON.stringify(got.body));++ });++++ // POST /api/frame/ask — one question, answered on the chosen model with the Search Fabric's results for the++ // chosen scopes, streamed on Network's runs. Signed in only; the person's own token pays. The run's events are at++ // /api/v1/runs/:id/events (mounted in server/index.js), replayable from Last-Event-ID.++ router.post('/ask', ...ask.ask);++// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing@@@ -1108,14 +1140,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, istimers = [t1, i1, t2, i2, t3, i3];}/** Graceful stop: no further rank or copy refreshes. */-- function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; menuCards.stop(); }++ function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; menuCards.stop(); try { ask._close(); } catch { /* already closed */ } }/** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */function ranking() {return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };}-- return { router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, online: onlineFor, _state: () => ({ rank, copy, cards: menuCards._state() }) };++ return { router, runsRouter: ask.router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, online: onlineFor, _state: () => ({ rank, copy, cards: menuCards._state() }) };}module.exports = { createFrameService, BANNED, stripOf, openvibeLink };@@@ -569,7 +569,7 @@ const ACCOUNT_ID_LINK = {const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';// A path with a variable is a route to call, not a page to fetch: it is written as code in llms.txt, never as a// Markdown link, so an agent (or a checker doing a GET) does not ask for the literal @handle address.--const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places.';++const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes).';function llmsTxt() {const { catalog } = toolsCatalog.peek();@@@ -589,6 +589,7 @@ function llmsTxt() {{ title: 'Machine-readable', links: [{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },{ title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },++ { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages) and POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events' },{ title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },@@@ -63,6 +63,8 @@ app.get('/openapi.json', (_req, res) => {}res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });});++app.get(['/docs/frame', '/docs/frame.md'], (_req, res) => res.type('text/markdown').set('Cache-Control', 'public, max-age=300')++ .send(fs.readFileSync(path.join(__dirname, '..', 'docs', 'frame.md'), 'utf8')));app.get(['/docs/trust-signals', '/docs/trust-signals.md'], (_req, res) => {res.type('text/markdown').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(fs.readFileSync(path.join(__dirname, '..', 'docs', 'trust-signals.md'), 'utf8'));@@@ -1038,11 +1040,13 @@ app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.// The menu's day counts (Report's daily activity): the strip's "today" counts reuse the same cached read.const frameToday = require('./frame/today').createToday({ reportUrl: config.services.report.internalUrl });--const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken, today: () => frameToday.get() });++const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken, requireAuth, today: () => frameToday.get() });app.use('/api/v1/frame/today', rateLimit({ windowMs: 60_000, max: 240 }), frameToday.router);const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });app.use('/api/frame', frameLimit, frameService.router);app.use('/api/chrome', frameLimit, frameService.router);++// The composer's Ask runs: the answer streams here (AG-UI events), resumable from Last-Event-ID. A person's own run.++app.use('/api/v1/runs', rateLimit({ windowMs: 60_000, max: 240 }), frameService.runsRouter);frameService.start();// /api/v1/registry/featured follows the navigation's usage ranking.ecosystem.setRanking(() => frameService.ranking());nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++// POST /api/frame/ask: one question on the chosen model, Search's results in the prompt, the answer streamed on++// /api/v1/runs/:id/events with the final ANSWER carrying numbered citations. Signed in only; the person's token pays++// on Search and AI, an 18+ model the person has not opted into is refused, and the stream resumes from Last-Event-ID.++const assert = require('assert');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++++const TOKEN = `Bearer ${'a'.repeat(40)}.${'b'.repeat(40)}.${'c'.repeat(20)}`;++const RAW = TOKEN.slice(7);++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++++function fakeAuth(req, res, next) {++ const m = /^Bearer\s+(\S+)$/.exec(String(req.get('authorization') || ''));++ if (!m) return res.status(401).json({ error: 'sign in' });++ req.token = m[1];++ req.user = { subject_id: USR };++ req.tokenClaims = { sub: USR, subject_id: USR };++ next();++}++++function blocks(text) {++ return text.split('\n\n').filter((b) => b.trim()).map((b) => {++ const out = { id: null, event: null, data: null };++ for (const line of b.split('\n')) {++ if (line.startsWith('id: ')) out.id = line.slice(4);++ else if (line.startsWith('event: ')) out.event = line.slice(7);++ else if (line.startsWith('data: ')) { try { out.data = JSON.parse(line.slice(6)); } catch { out.data = null; } }++ }++ return out;++ });++}++++(async () => {++ const asked = [];++ const ai = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const u = new URL(req.url, 'http://x');++ asked.push({ method: req.method, path: u.pathname, query: u.search, auth: req.headers.authorization || '', body: Buffer.concat(chunks).toString('utf8') });++ if (u.pathname === '/api/v1/choices') {++ res.setHeader('content-type', 'application/json');++ return res.end(JSON.stringify({++ task: { name: 'chat.reply', kind: 'text' }, choice: { model: 'openai/gpt-5-mini', source: 'openvibe' },++ recommended: { model: 'openai/gpt-5-mini', source: 'openvibe' }, adult: false,++ options: { openvibe: [{ model: 'openai/gpt-5-mini', source: 'openvibe', name: 'GPT-5 Mini', maker: 'openai', adult: false }], keys: [], machines: [] },++ }));++ }++ if (u.pathname === '/api/v1/runs' && u.searchParams.get('stream') === '1') {++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ res.write('event: delta\ndata: {"text":"The answer "}\n\n');++ res.write('event: delta\ndata: {"text":"is [1]."}\n\n');++ res.end(`event: run\ndata: ${JSON.stringify({ run: { id: 'run_ai', status: 'succeeded', cost_usd: 0.001, output: { text: 'The answer is [1].' }, provenance: { model: 'openai/gpt-5-mini' } } })}\n\n`);++ return;++ }++ res.statusCode = 404; res.end('{}');++ });++ });++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));++ const search = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ asked.push({ method: req.method, path: u.pathname, query: u.search, auth: req.headers.authorization || '', body: '' });++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({++ results: [++ { scope: 'web', kind: 'page', title: 'Example source', url: 'https://example.org/a', site: 'example.org', snippet: 'The source says five.', provider: 'wikipedia' },++ { scope: 'network', kind: 'document', title: 'A network post', url: 'https://openvibe.community/p/1', site: 'openvibe.community', snippet: 'A post about it.' },++ ], groups: { web: 1, network: 1 }, citations: [], route: { explain: [], cost_usd: 0 }, next_cursor: null,++ }));++ });++ await new Promise((r) => search.listen(0, '127.0.0.1', r));++++ const svc = await createFrameService(getDb(), { services: {++ ai: { internalUrl: `http://127.0.0.1:${ai.address().port}` },++ search: { internalUrl: `http://127.0.0.1:${search.address().port}` },++ } }, null, { requireAuth: fakeAuth, aiUrl: `http://127.0.0.1:${ai.address().port}` });++ const app = express();++ app.set('trust proxy', 'loopback');++ app.use('/api/frame', svc.router);++ app.use('/api/v1/runs', svc.runsRouter);++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const post = async (body, headers = { Authorization: TOKEN }) => {++ const r = await fetch(`${base}/api/frame/ask`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });++ return { r, d: await r.json().catch(() => null) };++ };++++ try {++ // One asked question: the answer comes back as AG-UI events, with the numbered sources in ANSWER.++ let { r, d } = await post({++ text: 'What is example?', model: 'openai/gpt-5-mini', sources: ['web', 'openvibe', 'page'], depth: 'balanced',++ files: ['med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'],++ context: { site: 'openvibe.community', url: 'https://openvibe.community/thread/1', title: 'The thread', kind: 'thread', id: '1', text: 'The page says five too.' },++ });++ assert.strictEqual(r.status, 202);++ assert.match(d.run, /^run_[0-9A-HJKMNP-TV-Z]{26}$/);++ const runId = d.run;++ const eventsRes = await fetch(`${base}/api/v1/runs/${runId}/events`, { headers: { Authorization: TOKEN } });++ assert.strictEqual(eventsRes.status, 200);++ assert.match(eventsRes.headers.get('content-type'), /text\/event-stream/);++ const events = blocks(await eventsRes.text());++ const types = events.map((e) => e.event);++ assert.ok(types.includes('RUN_STARTED'));++ assert.ok(types.filter((t) => t === 'STEP_STARTED').length >= 2, 'Search and Answer are steps');++ assert.strictEqual(types.at(-1), 'RUN_FINISHED');++ assert.strictEqual(types.at(-2), 'ANSWER');++ assert.match(events.filter((e) => e.event === 'TEXT_MESSAGE_CONTENT').map((e) => e.data.delta).join(''), /^The answer is \[1\]\.$/);++ const answer = events.find((e) => e.event === 'ANSWER').data;++ assert.strictEqual(answer.answer, 'The answer is [1].');++ assert.strictEqual(answer.model, 'openai/gpt-5-mini');++ assert.deepStrictEqual(answer.citations.map((c) => [c.n, c.title, c.site]), [[1, 'Example source', 'example.org'], [2, 'A network post', 'openvibe.community'], [3, 'The thread', 'openvibe.community']]);++ const eventIds = events.map((e) => Number(e.id));++ assert.deepStrictEqual(eventIds, eventIds.map((_, i) => i + 1), 'events are numbered from 1');++++ // What went upstream: Search scopes and the person's token; AI's chat with the numbered sources and the choice.++ const searchHit = asked.find((x) => x.path === '/api/v1/query');++ const su = new URL(searchHit.query, 'http://x');++ assert.strictEqual(su.searchParams.get('scopes'), 'web,network', 'page is context, not a Search scope');++ assert.strictEqual(su.searchParams.get('depth'), 'standard');++ assert.strictEqual(searchHit.auth, TOKEN);++ const chatHit = asked.find((x) => x.path === '/api/v1/runs' && x.query.includes('stream=1'));++ assert.strictEqual(chatHit.auth, TOKEN, "the person's token pays for the model");++ const chat = JSON.parse(chatHit.body);++ assert.strictEqual(chat.workflow, 'ai.chat');++ assert.deepStrictEqual(chat.choice, { model: 'openai/gpt-5-mini', source: 'openvibe' });++ assert.match(chat.input.messages[0].content, /\[1\] Example source — https:\/\/example\.org\/a/);++ assert.match(chat.input.messages[0].content, /\[3\] The thread/);++ assert.match(chat.input.messages[0].content, /The page's text/);++ assert.deepStrictEqual(chat.input.files, [{ id: 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' }], 'a Media id rides on the turn');++++ // The run's snapshot has the same answer.++ let snap = await fetch(`${base}/api/v1/runs/${runId}`, { headers: { Authorization: TOKEN } }).then((x) => x.json());++ assert.deepStrictEqual([snap.run.status, snap.run.answer, snap.run.citations.length], ['succeeded', 'The answer is [1].', 3]);++++ // Resuming from Last-Event-ID sends only what came after it.++ const again = blocks(await fetch(`${base}/api/v1/runs/${runId}/events`, { headers: { Authorization: TOKEN, 'Last-Event-ID': '2' } }).then((x) => x.text()));++ assert.ok(again.length > 0 && Number(again[0].id) === 3, `resumed at ${again[0] && again[0].id}`);++ assert.strictEqual(again.at(-1).event, 'RUN_FINISHED');++++ // Deep: Search's deep ladder with twenty sources, and room for a longer answer.++ asked.length = 0;++ ({ r, d } = await post({ text: 'A deep question', model: 'openai/gpt-5-mini', sources: ['web'], depth: 'deep' }));++ assert.strictEqual(r.status, 202);++ await fetch(`${base}/api/v1/runs/${d.run}/events`, { headers: { Authorization: TOKEN } }).then((x) => x.text());++ const deep = new URL(asked.find((x) => x.path === '/api/v1/query').query, 'http://x');++ assert.deepStrictEqual([deep.searchParams.get('depth'), deep.searchParams.get('limit')], ['deep', '20']);++ assert.strictEqual(JSON.parse(asked.find((x) => x.path === '/api/v1/runs' && x.query.includes('stream=1')).body).input.max_tokens, 4000);++++ // Signed out: 401 and nothing asked of AI or Search.++ let n = asked.length;++ ({ r } = await post({ text: 'hello' }, {}));++ assert.strictEqual(r.status, 401);++ assert.strictEqual(asked.length, n, 'nothing went upstream');++++ // An 18+ model the person has not opted into is refused before a run exists.++ n = asked.length;++ ({ r, d } = await post({ text: 'hello', model: 'abliteratedmodels/uncensored' }));++ assert.strictEqual(r.status, 403);++ assert.strictEqual(d.code, 'frame.model_refused');++ assert.strictEqual(asked.slice(n).filter((x) => x.path === '/api/v1/runs').length, 0, 'no model call for a refused model');++++ // A body without text is a 422.++ ({ r } = await post({ model: 'openai/gpt-5-mini' }));++ assert.strictEqual(r.status, 422);++++ // The run belongs to the person whose token made it.++ const other = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++ assert.notStrictEqual(other, USR);++ const notMine = await fetch(`${base}/api/v1/runs/${runId}/events`, { headers: { Authorization: TOKEN } });++ assert.strictEqual(notMine.status, 200, 'still the owner');++ assert.strictEqual((await fetch(`${base}/api/v1/runs/nope/events`, { headers: { Authorization: TOKEN } })).status, 404);++++ console.log(' ok frame ask: Search + AI chat streamed as AG-UI, ANSWER citations, deep ladder and refusals\n');++ } finally {++ server.close(); svc.stop(); ai.close(); search.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++// GET /api/frame/choices: AI's task choices for the person, joined with Exchange's model directory (maker, price per++// million, speed, rating and page) and the plans group. Signed in the token goes to AI and the answer is never kept;++// signed out only the OpenVibe group is named and the whole answer is kept 60 s.++const assert = require('assert');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++++const TOKEN = `Bearer ${'a'.repeat(40)}.${'b'.repeat(40)}.${'c'.repeat(20)}`;++++(async () => {++ const asked = [];++ let aiDown = false;++ const ai = http.createServer((req, res) => {++ asked.push({ path: req.url, auth: req.headers.authorization || '' });++ if (aiDown) { res.statusCode = 503; return res.end('{}'); }++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({++ task: { name: 'chat.reply', kind: 'text' },++ choice: { model: 'openai/gpt-5-mini', source: 'openvibe' },++ recommended: { model: 'openai/gpt-5-mini', source: 'openvibe' },++ options: {++ openvibe: [++ { model: 'openai/gpt-5-mini', source: 'openvibe', name: 'GPT-5 Mini', maker: 'openai', price_estimate_usd: 0.003, speed_ms: 900, quality: 0.8, adult: false },++ { model: 'abliteratedmodels/uncensored', source: 'openvibe', name: 'Uncensored', maker: 'abliteratedmodels', adult: true },++ ],++ keys: [{ model: 'openai/gpt-5-mini', source: 'keys', name: 'GPT-5 Mini', maker: 'openai', price_estimate_usd: 0 }],++ machines: [{ model: 'llama3.1:8b', source: 'machines', machine: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', name: 'llama3.1:8b', maker: null }],++ },++ adult: false,++ }));++ });++ await new Promise((r) => ai.listen(0, '127.0.0.1', r));++ const exchange = http.createServer((req, res) => {++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ count: 1, models: [{++ id: 'openai/gpt-5-mini', name: 'OpenAI: GPT-5 Mini', provider: 'openai', provider_name: 'OpenAI',++ status: 'available', routing_enabled: true, type: 'chat',++ price: { input_per_million: 0.25, output_per_million: 2 }, offers: [{ price: { in: 0.25, out: 2 }, speed: { p50_ms: 750 } }],++ }] }));++ });++ await new Promise((r) => exchange.listen(0, '127.0.0.1', r));++ const report = http.createServer((req, res) => {++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ rows: [{ id: 'openai/gpt-5-mini', rating: { value: 0.82 }, benchmark: { value: null }, uncensored: false }] }));++ });++ await new Promise((r) => report.listen(0, '127.0.0.1', r));++++ const svc = await createFrameService(getDb(), { services: {++ ai: { internalUrl: `http://127.0.0.1:${ai.address().port}` },++ exchange: { internalUrl: `http://127.0.0.1:${exchange.address().port}` },++ report: { internalUrl: `http://127.0.0.1:${report.address().port}` },++ } }, null);++ const app = express();++ app.set('trust proxy', 'loopback');++ app.use('/api/frame', svc.router);++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/frame/choices`;++ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };++++ try {++ // Signed out: only OpenVibe, enriched from Exchange and Report, held 60 s.++ let { r, d } = await get('?task=chat');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.deepStrictEqual(d.groups.map((g) => g.id), ['openvibe'], 'keys and machines need a person');++ assert.deepStrictEqual(d.recommended, { model: 'openai/gpt-5-mini', source: 'openvibe' });++ const row = d.groups[0].rows[0];++ assert.deepStrictEqual([row.model, row.source, row.name, row.maker], ['openai/gpt-5-mini', 'openvibe', 'GPT-5 Mini', 'openai']);++ assert.deepStrictEqual(row.price, { in: 0.25, out: 2, unit: 'USD per 1M tokens' });++ assert.strictEqual(row.free, false);++ assert.strictEqual(row.speed, 750);++ assert.strictEqual(row.score, 0.82);++ assert.equal(row.url, 'https://openvibe.exchange/models/openai%2Fgpt-5-mini');++ assert.ok(!d.groups[0].rows.some((x) => x.adult), 'an 18+ row is not offered without the 18+ setting');++ assert.strictEqual(asked[0].auth, '', 'an anonymous sheet asks as anyone');++ const firstUrls = asked.map((x) => x.path);++ await get('?task=chat');++ assert.deepStrictEqual(asked.map((x) => x.path), firstUrls, 'the anonymous sheet is answered from memory');++++ // Signed in: the person's token goes to AI, all four groups are named, and nothing is kept.++ ({ r, d } = await get('?task=chat', { Authorization: TOKEN }));++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.deepStrictEqual(d.groups.map((g) => g.id), ['openvibe', 'keys', 'machines', 'plans']);++ assert.strictEqual(asked[asked.length - 1].auth, TOKEN, "the person's token goes along");++ assert.strictEqual(d.groups.find((g) => g.id === 'keys').rows[0].source, 'keys');++ assert.strictEqual(d.groups.find((g) => g.id === 'keys').rows[0].price.in, 0.25, 'a key row still shows the model price');++ assert.strictEqual(d.groups.find((g) => g.id === 'keys').rows[0].free, true, 'no OpenVibe fee on your own key');++ const machine = d.groups.find((g) => g.id === 'machines').rows[0];++ assert.deepStrictEqual([machine.model, machine.source, machine.machine], ['llama3.1:8b', 'machines', 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3']);++ assert.strictEqual(d.groups.find((g) => g.id === 'plans').rows.length, 0);++ assert.match(d.groups.find((g) => g.id === 'plans').note, /Codes/);++ await get('?task=chat', { Authorization: TOKEN });++ assert.strictEqual(asked.filter((x) => x.auth === TOKEN).length, 2, 'a person is always asked fresh');++++ // The task maps to AI's task for it; a malformed token is a signed-out ask, never a person's.++ await get('?task=code', { Authorization: 'Bearer short' });++ assert.strictEqual(asked[asked.length - 1].auth, '', 'a malformed token is not passed on');++ assert.match(asked[asked.length - 1].path, /task=design\.icon$/, 'code maps to AI\'s code task');++++ // AI down: Exchange's directory still fills the OpenVibe group, with a note.++ aiDown = true;++ ({ r, d } = await get('?task=agent'));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(d.groups[0].rows[0].model, 'openai/gpt-5-mini');++ assert.match(d.groups[0].note, /Exchange/);++ aiDown = false;++++ // A task not in the list is a 400, never an upstream ask.++ const n = asked.length;++ ({ r, d } = await get('?task=unreal'));++ assert.strictEqual(r.status, 400);++ assert.strictEqual(d.code, 'frame.task_invalid');++ assert.strictEqual(asked.length, n);++++ console.log(' ok frame choices: AI choices, Exchange enrichment, signed-out cache and the plans note\n');++ } finally {++ server.close(); svc.stop(); ai.close(); exchange.close(); report.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });@@@ -118,6 +118,8 @@ const REVIEWED = {'server/home/popular.js fetch': [2, 'Report public popularity and co-usage: the operator-configured first-party Report URL (OV_REPORT_INTERNAL_URL), a fixed loopback origin, never a request value'],'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],++ 'server/frame/choices.js fetch': [1, 'the composer\'s Model sheet: AI (OV_AI_INTERNAL_URL), Exchange (OV_EXCHANGE_INTERNAL_URL / config.services.exchange) and Report (OV_REPORT_INTERNAL_URL) internal URLs from operator config, fixed /api/v1/choices, /api/v1/models and /api/v1/ai/leaderboard paths, the task percent-encoded and a checked person Bearer only; no request value names a host, redirects refused, 3-8 s timeouts'],++ 'server/frame/ask.js fetch': [2, 'the composer\'s Ask: Search (OV_SEARCH_INTERNAL_URL) fixed /api/v1/query path with checked scopes and the person\'s Bearer, and AI (OV_AI_INTERNAL_URL) fixed /api/v1/runs?stream=1 path with the person\'s Bearer; both operator-configured internal URLs, no request value names a host, redirects refused, 30-150 s timeouts'],'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+# The composer's Frame: models and answers
+
+Every OpenVibe site's composer reaches models, search and the person's own work through OpenVibe.Network's Frame. A
+site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Both routes
+below answer `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.
+
+## `GET /api/frame/choices?task=chat|agent|code|image|research`
+
+The Model sheet. Signed in, OpenVibe.AI's task choices are asked for the person (their token goes along) and every
+OpenVibe row is joined with OpenVibe.Exchange's directory: its maker, its price per million tokens in and out, its
+measured speed, its rating where Report has one, and its page on Exchange. Signed out, only the OpenVibe group is
+named. Signed in, the groups are `openvibe`, `keys`, `machines` and `plans`; `plans` is empty with a note until
+OpenVibe.Codes exposes a person's own signed-in harnesses.
+
+```json
+{
+ "task": "chat",
+ "recommended": { "model": "anthropic/claude-opus-5-5", "source": "openvibe" },
+ "choice": { "model": "anthropic/claude-opus-5-5", "source": "openvibe" },
+ "adult": false,
+ "groups": [{
+ "id": "openvibe", "title": "OpenVibe",
+ "rows": [{
+ "model": "anthropic/claude-opus-5-5", "source": "openvibe", "name": "Claude Opus 5.5", "maker": "Anthropic",
+ "price": { "in": 4, "out": 20, "unit": "USD per 1M tokens" }, "free": false,
+ "speed": 850, "score": 0.91, "url": "https://openvibe.exchange/models/anthropic%2Fclaude-opus-5-5"
+ }]
+ }]
+}
+```
+
+A person's answer is `Cache-Control: private, no-store`; a signed-out one is `public, max-age=60`. A model marked
+`adult` is only returned (and only accepted by Ask) when the person's account has the 18+ setting.
+
+## `POST /api/frame/ask`
+
+One question, answered on the chosen model with the Search Fabric's results for the chosen scopes in the prompt.
+Signed in only: the person's own token pays OpenVibe.AI (their free allowance, Balance, own key or computer).
+
+```http
+POST /api/frame/ask
+Authorization: Bearer <person token>
+Content-Type: application/json
+
+{
+ "text": "What changed in the search fabric this week?",
+ "model": "anthropic/claude-opus-5-5",
+ "sources": ["web", "openvibe", "mine", "page"],
+ "depth": "balanced",
+ "context": { "site": "openvibe.community", "url": "https://openvibe.community/p/1", "title": "A post", "kind": "post", "id": "1" }
+}
+```
+
+`model` may be a row's id or the row's `{ model, source, machine? }`; omitted, the task's recommended choice is used.
+`sources` is any of `web` (Search's web scope: OpenVibe's index, DeepSeek, Wikipedia), `openvibe` (the network scope),
+`mine` (your own things) and `page` (the site passes `context`, and optionally its `text`). `depth` is `quick`,
+`balanced` or `deep`; Deep asks Search's deep ladder for every step's results and gives the answer more room.
+`refs` are typed references and `files` are Media ids (at most 10); both go to the model.
+
+The answer is `202 { "run": "run_…" }`. Follow it:
+
+```http
+GET /api/v1/runs/run_…/events
+Authorization: Bearer <person token>
+Accept: text/event-stream
+Last-Event-ID: 12
+```
+
+Events are AG-UI: `RUN_STARTED`, `STEP_STARTED`/`STEP_FINISHED` (`Search`, `Read`, `Answer`),
+`TEXT_MESSAGE_START`, `TEXT_MESSAGE_CONTENT` (`{ delta }`) for every piece of the answer, `TEXT_MESSAGE_END`, then
+
+```json
+{ "type": "ANSWER", "answer": "…[1]…", "citations": [{ "n": 1, "title": "…", "url": "https://…", "site": "…" }],
+ "model": "anthropic/claude-opus-5-5", "cost_usd": 0.004 }
+```
+
+and `RUN_FINISHED` with the same answer and citations. A failure is `RUN_ERROR` with `{ code, message }`. The run is
+kept in memory for five minutes after it ends, so a reconnect can resume; `GET /api/v1/runs/:id` is its snapshot.
+Asks are rate-limited per person (12 a minute).
@@ -200,6 +200,10 @@ module.exports = {
@@ -200,6 +200,10 @@ module.exports = {
report: {
report: {
internalUrl: process.env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080',
internalUrl: process.env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080',
},
},
+ // OpenVibe.Exchange: the composer's model sheet reads /api/v1/models for makers, prices and pages.
+ exchange: {
+ internalUrl: process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150',
+ },
host: {
host: {
internalUrl: process.env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910',
internalUrl: process.env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910',
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,452 @@
@@ -0,0 +1,452 @@
+'use strict';
+/**
+ * Ask in the composer (composer-post-ask-do.md, R1): POST /api/frame/ask is one question on the chosen model, with
+ * the Search Fabric's results for the chosen scopes in its prompt, and the answer streams on Network's runs:
+ *
+ * POST /api/frame/ask 202 { run: 'run_…' } (signed in only; the person's own token, keys and credits)
+ * GET /api/v1/runs/:id the run's state, answer and citations
+ * GET /api/v1/runs/:id/events the AG-UI event stream (Last-Event-ID resumes after an event)
+ *
+ * Events: RUN_STARTED, STEP_STARTED/STEP_FINISHED (search, read, answer), TEXT_MESSAGE_START,
+ * TEXT_MESSAGE_CONTENT { delta } for every piece of the answer, TEXT_MESSAGE_END, ANSWER { answer, citations } and
+ * RUN_FINISHED; a failure is RUN_ERROR { code, message }. The ANSWER event is the one the composer's card reads;
+ * the TEXT_MESSAGE_CONTENT events build it as it arrives.
+ *
+ * Quick and Balanced are one Vibe turn on the chosen model (AI's ai.chat workflow, so the person's own key or
+ * computer answers when they chose one and the person's free allowance or Balance pays otherwise). Deep asks Search's
+ * deep ladder for every step's results (up to 20), reads more of each and gives the model room for a longer answer.
+ * The prompt carries numbered sources; the model is told to cite them, and the same numbers come back as ANSWER's
+ * citations, so the card can draw the source chips under the text.
+ *
+ * The run lives in this process, like the answer card it feeds: nothing is written to the database and nothing is
+ * kept after it ends (five minutes for a reconnect). A restart loses an in-flight answer, never a person's data.
+ */
+const crypto = require('crypto');
+const express = require('express');
+
+const RUN_RE = /^run_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TOKEN_RE = /^[A-Za-z0-9._~+/=-]{20,4096}$/;
+const MEDIA_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_TEXT = 8000;
+const MAX_SOURCES = 20;
+const KEEP_MS = 5 * 60_000;
+const SCOPES = { web: 'web', openvibe: 'network', mine: 'mine' };
+const DEPTHS = new Set(['quick', 'balanced', 'deep']);
+const SOURCE_NAMES = new Set(['web', 'openvibe', 'mine', 'page']);
+const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+function newRunId() {
+ const bytes = crypto.randomBytes(26);
+ let out = '';
+ for (const b of bytes) out += ALPHABET[b & 31];
+ return `run_${out}`;
+}
+
+const clean = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, n);
+const httpUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null; } catch { return null; } };
+const hostOf = (v) => { try { return new URL(String(v)).hostname.toLowerCase(); } catch { return null; } };
+
+/** AG-UI's event stream over one answer: events are numbered, replayable and closed at RUN_FINISHED/RUN_ERROR. */
+function createRunStore({ now = Date.now, log = console } = {}) {
+ const runs = new Map();
+ let active = 0;
+
+ function emit(run, type, data = {}) {
+ if (!run || run.closed) return;
+ const event = {
+ id: String(++run.nextId),
+ event: type,
+ data: { type, timestamp: new Date(now()).toISOString(), threadId: run.id, runId: run.id, ...data },
+ };
+ run.events.push(event);
+ if (run.events.length > 2000) run.events.splice(0, run.events.length - 2000);
+ if (type === 'ANSWER') { run.answer = data.answer; run.citations = data.citations || []; run.model = data.model || null; }
+ if (type === 'RUN_FINISHED') { run.status = 'succeeded'; run.finishedAt = now(); run.closed = true; }
+ if (type === 'RUN_ERROR') { run.status = 'failed'; run.error = { code: data.code || 'frame.ask_failed', message: data.message || 'The answer could not be written.' }; run.finishedAt = now(); run.closed = true; }
+ for (const sub of [...run.subs]) {
+ if (sub.done) continue;
+ write(sub.res, event);
+ if (run.closed) {
+ sub.done = true;
+ run.subs.delete(sub);
+ try { sub.res.end(); } catch { /* the person closed the tab */ }
+ }
+ }
+ if (run.closed) {
+ active = Math.max(0, active - 1);
+ setTimeout(() => runs.delete(run.id), KEEP_MS).unref?.();
+ }
+ }
+
+ function write(res, event) {
+ try { res.write(`id: ${event.id}\nevent: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`); } catch { /* the person closed the tab */ }
+ }
+
+ function create(subject, meta = {}) {
+ const id = newRunId();
+ const run = {
+ id, subject, status: 'running', createdAt: now(), finishedAt: null, nextId: 0, events: [],
+ subs: new Set(), closed: false, answer: null, citations: null, model: null, error: null,
+ abort: new AbortController(), meta,
+ };
+ runs.set(id, run);
+ active++;
+ const t = setTimeout(() => {
+ if (run.closed) return;
+ emit(run, 'RUN_ERROR', { code: 'run.timeout', message: 'The answer took too long and stopped.' });
+ try { run.abort.abort(); } catch { /* already */ }
+ }, 5 * 60_000); t.unref?.();
+ return run;
+ }
+
+ function get(id) { return RUN_RE.test(String(id || '')) ? runs.get(String(id)) : null; }
+ function close() { for (const run of runs.values()) { try { run.abort.abort(); } catch { /* already */ } run.closed = true; } runs.clear(); }
+ return { create, emit, get, write, close, _runs: runs, _active: () => active };
+}
+
+/** The person's own question, checked field by field. Returns { error } or the clean request. */
+function readAsk(body) {
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'JSON object required' };
+ const text = clean(body.text, MAX_TEXT);
+ if (!text) return { error: 'text is required' };
+ const sources = body.sources === undefined ? ['web']
+ : Array.isArray(body.sources) ? [...new Set(body.sources.map((s) => String(s || '').toLowerCase()).filter((s) => SOURCE_NAMES.has(s)))] : null;
+ if (!sources) return { error: 'sources is a list of web, openvibe, mine and page' };
+ const depth = String(body.depth || 'balanced').toLowerCase();
+ if (!DEPTHS.has(depth)) return { error: 'depth is quick, balanced or deep' };
+ const refs = [];
+ if (body.refs !== undefined) {
+ if (!Array.isArray(body.refs) || body.refs.length > 20) return { error: 'refs is at most 20 references' };
+ for (const ref of body.refs) {
+ if (!ref || typeof ref !== 'object') continue;
+ const kind = clean(ref.kind, 40), id = clean(ref.id, 100), title = clean(ref.title, 200), url = httpUrl(ref.url);
+ if (kind && id) refs.push({ kind, id, ...(title ? { title } : {}), ...(url ? { url } : {}) });
+ }
+ }
+ const files = [];
+ if (body.files !== undefined) {
+ if (!Array.isArray(body.files) || body.files.length > 10) return { error: 'files is at most 10 Media ids' };
+ for (const f of body.files) {
+ const id = clean(typeof f === 'string' ? f : f && (f.id || f.media_id), 80);
+ if (id && MEDIA_RE.test(id)) files.push({ id });
+ }
+ }
+ const context = body.context && typeof body.context === 'object' && !Array.isArray(body.context) ? {
+ site: clean(body.context.site, 253) || null,
+ url: httpUrl(body.context.url),
+ title: clean(body.context.title, 300) || null,
+ kind: clean(body.context.kind, 40) || null,
+ id: clean(body.context.id, 200) || null,
+ text: clean(body.context.text, 8000) || null,
+ } : null;
+ return { text, sources, depth, refs, files, context, model: body.model };
+}
+
+/** The model the person asked for, or the task's resolved choice. A bare id is looked up in the sheet's rows. */
+function pickModel(value, sheet) {
+ const rows = (sheet && Array.isArray(sheet.groups) ? sheet.groups : []).flatMap((g) => g.rows || []);
+ const found = (model) => rows.find((r) => r && r.model === model);
+ if (typeof value === 'string' && /^[A-Za-z0-9._:/@+-]{1,200}$/.test(value)) {
+ const row = found(value);
+ return row ? { model: value, source: row.source, ...(row.machine ? { machine: row.machine } : {}) } : { model: value, source: 'openvibe' };
+ }
+ if (value && typeof value === 'object' && typeof value.model === 'string' && /^[A-Za-z0-9._:/@+-]{1,200}$/.test(value.model)) {
+ const source = ['openvibe', 'keys', 'machines'].includes(value.source) ? value.source : (found(value.model) || {}).source || 'openvibe';
+ const row = found(value.model);
+ return { model: value.model, source, ...(source === 'machines' && (value.machine || (row && row.machine)) ? { machine: String(value.machine || row.machine) } : {}) };
+ }
+ if (sheet && sheet.recommended) return { ...sheet.recommended };
+ return { model: 'openvibe/auto', source: 'openvibe' };
+}
+
+/** sse(data) events from an upstream fetch body: id, event and the joined data lines. */
+async function* sseEvents(body) {
+ if (!body) return;
+ const decoder = new TextDecoder();
+ let buf = '';
+ const block = (raw) => {
+ const out = { id: null, event: null, data: '' };
+ const data = [];
+ for (const line of raw.split('\n')) {
+ if (!line || line.startsWith(':')) continue;
+ const at = line.indexOf(':');
+ const key = at < 0 ? line : line.slice(0, at);
+ const value = at < 0 ? '' : line.slice(at + 1).replace(/^ /, '');
+ if (key === 'id') out.id = value;
+ else if (key === 'event') out.event = value;
+ else if (key === 'data') data.push(value);
+ }
+ out.data = data.join('\n');
+ return out;
+ };
+ for await (const chunk of body) {
+ buf += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
+ let at;
+ while ((at = buf.indexOf('\n\n')) >= 0) {
+ const raw = buf.slice(0, at); buf = buf.slice(at + 2);
+ const ev = block(raw);
+ if (ev.event || ev.data) yield ev;
+ }
+ }
+ buf += decoder.decode();
+ if (buf.trim()) { const ev = block(buf); if (ev.event || ev.data) yield ev; }
+}
+
+function createFrameAsk({
+ aiUrl = 'http://127.0.0.1:4700',
+ searchUrl = 'http://127.0.0.1:4710',
+ requireAuth = null,
+ sheet = null,
+ fetchImpl = globalThis.fetch,
+ now = Date.now,
+ log = console,
+ perMinute = 12,
+} = {}) {
+ const aiBase = String(aiUrl || '').replace(/\/+$/, '');
+ const searchBase = String(searchUrl || '').replace(/\/+$/, '');
+ const runs = createRunStore({ now, log });
+ const asks = new Map(); // subject -> [timestamps]
+ const guard = typeof requireAuth === 'function' ? requireAuth : (_req, res) => res.status(401).json({ error: 'sign in to ask' });
+ const router = express.Router();
+
+ const subjectOf = (req) => {
+ const user = req.user || {};
+ const claims = req.tokenClaims || {};
+ const id = user.subject_id || user.subject || claims.subject_id || claims.sub || user.sub;
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || '')) ? String(id) : null;
+ };
+ const bearerOf = (req) => {
+ const m = /^Bearer\s+(\S+)$/i.exec(String(req.get('authorization') || ''));
+ const t = m ? m[1] : (req.token || '');
+ return TOKEN_RE.test(String(t || '')) ? String(t) : null;
+ };
+
+ function allowed(subject) {
+ const at = now();
+ const list = (asks.get(subject) || []).filter((t) => at - t < 60_000);
+ if (list.length >= perMinute) { asks.set(subject, list); return false; }
+ list.push(at); asks.set(subject, list);
+ if (asks.size > 5000) asks.clear();
+ return true;
+ }
+
+ async function search(query, sources, depth, token, ip) {
+ const scopes = [...new Set(sources.map((s) => SCOPES[s]).filter(Boolean))];
+ if (!scopes.length) return { results: [] };
+ const u = new URL('/api/v1/query', searchBase);
+ u.searchParams.set('q', clean(query, 200));
+ u.searchParams.set('scopes', scopes.join(','));
+ u.searchParams.set('depth', depth === 'quick' ? 'quick' : depth === 'deep' ? 'deep' : 'standard');
+ u.searchParams.set('limit', depth === 'deep' ? '20' : depth === 'quick' ? '6' : '10');
+ try {
+ const r = await fetchImpl(u.href, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(ip ? { 'X-Forwarded-For': ip } : {}) },
+ redirect: 'manual', signal: AbortSignal.timeout(30_000),
+ });
+ if (!r || !r.ok) return { results: [], error: r ? r.status : null };
+ const d = await r.json();
+ return { results: d && Array.isArray(d.results) ? d.results : [] };
+ } catch (err) {
+ if (log && log.warn) log.warn(`[FrameAsk] search: ${String((err && err.message) || err).slice(0, 160)}`);
+ return { results: [], error: 'unreachable' };
+ }
+ }
+
+ /** Every numbered source the answer may cite: Search's results, then the page the person is looking at. */
+ function sourcesOf(results, context, wantPage, limit) {
+ const out = [];
+ const seen = new Set();
+ const add = (title, url, site, snippet) => {
+ url = httpUrl(url);
+ if (!url || seen.has(url) || out.length >= limit) return;
+ seen.add(url);
+ out.push({ n: out.length + 1, title: clean(title, 300) || url, url, site: clean(site, 253) || hostOf(url) || null, snippet: clean(snippet || '', 1200) || null });
+ };
+ for (const r of results) {
+ if (!r || !r.url) continue;
+ add(r.title, r.url, r.site, r.snippet || r.description || '');
+ }
+ if (wantPage && context && context.url) add(context.title || context.url, context.url, context.site, context.text || '');
+ return out;
+ }
+
+ function promptOf(text, sources, { refs, context }) {
+ const lines = [];
+ lines.push('Question:', text, '');
+ if (context && (context.title || context.url || context.site)) {
+ lines.push('The person is looking at:', [context.title, context.url || context.site].filter(Boolean).join(' — '), '');
+ }
+ if (context && context.text) lines.push('The page\'s text, as the site passed it:', context.text, '');
+ if (refs.length) {
+ lines.push('Things they attached:');
+ for (const ref of refs) lines.push(`- ${ref.kind} ${ref.id}${ref.title ? ` (${ref.title})` : ''}${ref.url ? ` — ${ref.url}` : ''}`);
+ lines.push('');
+ }
+ if (sources.length) {
+ lines.push('Numbered sources. Cite the number in square brackets after each claim it supports; link the page when it helps:');
+ for (const s of sources) {
+ lines.push(`[${s.n}] ${s.title} — ${s.url} (${s.site || hostOf(s.url)})`);
+ if (s.snippet) lines.push(` ${s.snippet}`);
+ }
+ } else {
+ lines.push('No numbered sources were found. Say so plainly and answer only from what the question itself states.');
+ }
+ return lines.join('\n');
+ }
+
+ const SYSTEM = [
+ 'You are Vibe, OpenVibe\'s assistant, answering in the composer of an OpenVibe site.',
+ 'How you answer:',
+ '- Helpful, direct and warm, in the language the person wrote in. A short question gets a short answer; give depth when they ask for it or the task needs it.',
+ '- Your answer is shown as Markdown: short paragraphs, lists, **bold**, `code`, fenced code blocks and links. No tables, no headings above level 3.',
+ '- Use only the numbered sources when you state a fact, a quote, a date or a number, and cite each such claim with its number in square brackets, like [1]. Never invent a source, a quote or a number. If the sources do not support something, say so.',
+ '- The person is asking a question, not posting: only they see this answer until they choose to post it.',
+ ].join('\n');
+
+ async function aiAnswer({ run, token, choice, text, sources, refs, context, depth, files }) {
+ const maxTokens = depth === 'deep' ? 4000 : depth === 'balanced' ? 1800 : 1000;
+ const body = {
+ workflow: 'ai.chat',
+ input: {
+ system: SYSTEM,
+ messages: [{ role: 'user', content: promptOf(text, sources, { refs, context }) }],
+ max_tokens: maxTokens,
+ temperature: 0.2,
+ ...(files && files.length ? { files } : {}),
+ },
+ choice,
+ idempotency_key: `frame.ask.${run.id}`,
+ };
+ const r = await fetchImpl(`${aiBase}/api/v1/runs?stream=1&wait=120000`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', Authorization: `Bearer ${token}` },
+ body: JSON.stringify(body),
+ redirect: 'manual',
+ signal: AbortSignal.any([run.abort.signal, AbortSignal.timeout(150_000)]),
+ });
+ if (!r || !r.ok) {
+ let problem = null;
+ try { problem = await r.json(); } catch { /* not JSON */ }
+ const err = new Error((problem && (problem.detail || problem.error)) || 'The model did not answer.');
+ err.code = (problem && problem.code) || 'frame.answer_failed';
+ err.status = r ? r.status : 502;
+ throw err;
+ }
+ const ctype = String(r.headers.get('content-type') || '');
+ let final = null, streamed = '';
+ if (ctype.includes('text/event-stream')) {
+ for await (const ev of sseEvents(r.body)) {
+ let data = null;
+ try { data = JSON.parse(ev.data || 'null'); } catch { continue; }
+ if (ev.event === 'delta' && data && typeof data.text === 'string') {
+ streamed += data.text;
+ runs.emit(run, 'TEXT_MESSAGE_CONTENT', { messageId: `${run.id}-answer`, delta: data.text });
+ } else if (ev.event === 'run' && data && data.run) {
+ final = data.run;
+ } else if (ev.event === 'error') {
+ const err = new Error((data && data.message) || 'The model did not answer.');
+ err.code = (data && data.code) || 'frame.answer_failed';
+ throw err;
+ }
+ }
+ } else {
+ const d = await r.json().catch(() => null);
+ final = d && d.run ? d.run : null;
+ }
+ const output = final && final.output && typeof final.output === 'object' ? final.output : {};
+ const answer = clean(output.text || (output.json && output.json.text) || streamed, 100_000);
+ if (!answer) {
+ const err = new Error('The model answered with nothing.');
+ err.code = 'frame.answer_empty';
+ throw err;
+ }
+ if (!streamed) runs.emit(run, 'TEXT_MESSAGE_CONTENT', { messageId: `${run.id}-answer`, delta: answer });
+ const answered = (final && final.answered) || (final && final.provenance && final.provenance.model) || choice.model;
+ return { answer, model: answered, cost_usd: final && Number.isFinite(Number(final.cost_usd)) ? Number(final.cost_usd) : null };
+ }
+
+ async function work(run, { text, sources, depth, refs, files, context, choice, token, ip }) {
+ try {
+ runs.emit(run, 'RUN_STARTED', { metadata: { ov: { kind: 'ask', title: clean(text, 120), model: choice.model } } });
+ runs.emit(run, 'STEP_STARTED', { stepName: 'Search' });
+ const found = await search(text, sources, depth, token, ip);
+ const list = sourcesOf(found.results, context, sources.includes('page'), depth === 'deep' ? MAX_SOURCES : depth === 'quick' ? 8 : 12);
+ runs.emit(run, 'STEP_FINISHED', { stepName: 'Search' });
+ if (depth === 'deep') {
+ runs.emit(run, 'STEP_STARTED', { stepName: 'Read' });
+ runs.emit(run, 'STEP_FINISHED', { stepName: 'Read' });
+ }
+ runs.emit(run, 'STEP_STARTED', { stepName: 'Answer' });
+ runs.emit(run, 'TEXT_MESSAGE_START', { messageId: `${run.id}-answer` });
+ const answer = await aiAnswer({ run, token, choice, text, sources: list, refs, context, depth, files });
+ runs.emit(run, 'TEXT_MESSAGE_END', { messageId: `${run.id}-answer` });
+ runs.emit(run, 'STEP_FINISHED', { stepName: 'Answer' });
+ const citations = list.map(({ n, title, url, site }) => ({ n, title, url, site }));
+ runs.emit(run, 'ANSWER', { answer: answer.answer, citations, model: answer.model, cost_usd: answer.cost_usd });
+ runs.emit(run, 'RUN_FINISHED', { result: { answer: answer.answer, citations } });
+ } catch (err) {
+ const code = (err && err.code) || 'frame.ask_failed';
+ const message = clean(err && err.message || 'The answer could not be written.', 300);
+ runs.emit(run, 'RUN_ERROR', { code, message });
+ if (log && log.warn) log.warn(`[FrameAsk] ${run.id}: ${code}: ${message}`);
+ }
+ }
+
+ const ask = [guard, express.json({ limit: '64kb' }), async (req, res) => {
+ const subject = subjectOf(req);
+ const token = bearerOf(req);
+ if (!subject || !token) return res.status(401).json({ error: 'sign in to ask' });
+ const body = readAsk(req.body);
+ if (body.error) return res.status(422).json({ error: body.error, code: 'frame.ask_invalid' });
+ if (!allowed(subject)) { res.set('Retry-After', '60'); return res.status(429).json({ error: 'Too many questions: try again in a minute', code: 'frame.rate_limited' }); }
+ let picked = null, sheetBody = null;
+ if (sheet) {
+ const got = await sheet.get('research', { token, signedIn: true });
+ sheetBody = got && got.body;
+ if (sheetBody) picked = pickModel(body.model, sheetBody);
+ if (got && got.ai && sheetBody && picked && picked.source === 'openvibe' && !String(picked.model).startsWith('openvibe/')) {
+ const row = sheetBody.groups.flatMap((g) => g.rows || []).find((r) => r.model === picked.model && r.source === 'openvibe');
+ if (!row) return res.status(403).json({ error: 'That model is not available to you. An 18+ model needs the 18+ setting on your account.', code: 'frame.model_refused' });
+ }
+ }
+ if (!picked) picked = { model: 'openvibe/auto', source: 'openvibe' };
+ const run = runs.create(subject, { text: body.text, depth: body.depth, sources: body.sources });
+ setImmediate(() => { work(run, { ...body, choice: picked, token, ip: req.ip }).catch(() => {}); });
+ res.set({ 'Cache-Control': 'private, no-store', Location: `/api/v1/runs/${run.id}`, 'X-OpenVibe-Run-Id': run.id }).status(202).json({ run: run.id });
+ }];
+
+ // ── the run's own reads and stream ──────────────────────────────────────────────────────────────────────
+ router.get('/:id', guard, (req, res) => {
+ const run = runs.get(req.params.id);
+ const subject = subjectOf(req);
+ if (!run || !subject || run.subject !== subject) return res.status(404).json({ error: 'not found' });
+ res.set('Cache-Control', 'private, no-store').json({
+ run: {
+ id: run.id, status: run.status, created_at: new Date(run.createdAt).toISOString(),
+ finished_at: run.finishedAt ? new Date(run.finishedAt).toISOString() : null,
+ answer: run.answer || null, citations: run.citations || null, model: run.model || null, error: run.error || null,
+ },
+ });
+ });
+
+ router.get('/:id/events', guard, (req, res) => {
+ const run = runs.get(req.params.id);
+ const subject = subjectOf(req);
+ if (!run || !subject || run.subject !== subject) return res.status(404).json({ error: 'not found' });
+ const last = String(req.get('last-event-id') || '0');
+ if (!/^\d{1,16}$/.test(last)) return res.status(400).json({ error: 'invalid event id' });
+ const after = Number(last);
+ res.status(200).set({ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Accel-Buffering': 'no', Vary: 'Authorization' });
+ res.flushHeaders?.();
+ for (const ev of run.events) if (Number(ev.id) > after) runs.write(res, ev);
+ if (run.closed) return res.end();
+ const sub = { res, done: false };
+ run.subs.add(sub);
+ const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* closed */ } }, 15_000); ping.unref?.();
+ req.on('close', () => { sub.done = true; clearInterval(ping); run.subs.delete(sub); });
+ });
+
+ return { ask, router, runs, _close: () => runs.close() };
+}
+
+module.exports = { createFrameAsk, readAsk, pickModel, createRunStore, sseEvents, RUN_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,244 @@
@@ -0,0 +1,244 @@
+'use strict';
+/**
+ * The composer's Model sheet (composer-post-ask-do.md, R1): GET /api/frame/choices?task=chat|agent|code|image|research.
+ *
+ * AI owns which model answers a task (GET /api/v1/choices?task=, its openvibe/keys/machines groups and the resolved
+ * choice). Exchange owns what each OpenVibe model is: its maker, its price per million tokens in and out, its measured
+ * speed and its page. Report's public leaderboard is where a rating exists. Network joins the three here so a site's
+ * composer makes one request, with the person's own token forwarded (AI answers as them; signed out it answers as
+ * anyone) and the same CORS and caching rules as GET /api/frame/query:
+ *
+ * signed in private, no-store, asked fresh every time
+ * signed out public, max-age=60, the whole answer held 60 s
+ *
+ * The shape is the one the composer's chip renders:
+ *
+ * { task, recommended, choice, adult,
+ * groups: [{ id: 'openvibe'|'keys'|'machines'|'plans', title, rows: [...], note? }] }
+ *
+ * A row is { model, source, machine?, name, maker, price: { in, out, unit } | null, free, speed?, score?, url? }.
+ * Signed out only the OpenVibe group is named (AI's keys and machines need a person); signed in all four are, and
+ * plans is empty with a note until Codes exposes a person's own signed-in harnesses (today its harness catalogue is
+ * public and says nothing about who is signed in).
+ */
+const { URL } = require('url');
+
+// research picks the model that reads and writes the answer; the searching itself is the Search Fabric's deep
+// ladder in POST /api/frame/ask. AI's own research.gather task is a search engine choice, not a chat model.
+const AI_TASK = { chat: 'chat.reply', agent: 'chat.reply', code: 'design.icon', image: 'image.picture', research: 'chat.reply' };
+const GROUPS = [
+ { id: 'openvibe', title: 'OpenVibe' },
+ { id: 'keys', title: 'Your keys' },
+ { id: 'machines', title: 'Your computers' },
+ { id: 'plans', title: 'Your plans' },
+];
+const TOKEN_RE = /^[A-Za-z0-9._~+/=-]{20,4096}$/;
+const MODEL_RE = /^[A-Za-z0-9._:/@+-]{1,200}$/;
+const SOURCE = new Set(['openvibe', 'keys', 'machines']);
+const DAY = 60_000;
+
+const titleOf = (id) => String(id || '').split('/').filter(Boolean).pop() || String(id || '');
+const makerOf = (id, ex) => (ex && (ex.provider_name || ex.provider)) || String(id || '').split('/')[0] || null;
+const num = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.round(Number(v) * 1e6) / 1e6);
+const hostOf = (u) => { try { return new URL(String(u)).hostname.toLowerCase(); } catch { return null; } };
+const pageUrl = (base, id) => `${String(base || 'https://openvibe.exchange').replace(/\/+$/, '')}/models/${encodeURIComponent(id)}`;
+
+/** One model choice as the composer remembers it: the model, where it comes from, and the machine when it is one. */
+function choiceOf(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.model !== 'string' || !MODEL_RE.test(value.model)) return null;
+ const source = SOURCE.has(value.source) ? value.source : 'openvibe';
+ return { model: value.model, source, ...(source === 'machines' && value.machine ? { machine: String(value.machine) } : {}) };
+}
+
+function createFrameChoices({ aiUrl = 'http://127.0.0.1:4700', exchangeUrl = 'http://127.0.0.1:5150', reportUrl = 'http://127.0.0.1:5080', exchangePageBase = 'https://openvibe.exchange', fetchImpl = globalThis.fetch, now = Date.now, log = console } = {}) {
+ const aiBase = String(aiUrl || '').replace(/\/+$/, '');
+ const exchangeBase = String(exchangeUrl || '').replace(/\/+$/, '');
+ const reportBase = String(reportUrl || '').replace(/\/+$/, '');
+ let exchangeCache = null, exchangeAt = 0, exchangeInflight = null;
+ let reportCache = null, reportAt = 0, reportInflight = null;
+ const held = new Map(); // signed-out answer, by task
+
+ async function getJson(url, { token = '', timeoutMs = 6000 } = {}) {
+ if (typeof fetchImpl !== 'function') return null;
+ const headers = { Accept: 'application/json' };
+ if (token && TOKEN_RE.test(token)) headers.Authorization = `Bearer ${token}`;
+ try {
+ const r = await fetchImpl(url, { headers, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
+ if (!r || !r.ok) return null;
+ return await r.json();
+ } catch (err) {
+ if (log && log.warn) log.warn(`[FrameChoices] ${url}: ${String((err && err.message) || err).slice(0, 160)}`);
+ return null;
+ }
+ }
+
+ /** Exchange's directory, held a minute: the same model prices every composer on the network. */
+ async function exchangeModels() {
+ if (exchangeCache && now() - exchangeAt < DAY) return exchangeCache;
+ exchangeInflight = exchangeInflight || getJson(`${exchangeBase}/api/v1/models`).then((d) => (d && Array.isArray(d.models) ? d.models : [])).finally(() => { exchangeInflight = null; });
+ const models = await exchangeInflight;
+ if (models.length) { exchangeCache = models; exchangeAt = now(); }
+ return models;
+ }
+
+ /** Report's public leaderboard: a rating (or Bench's benchmark) where one exists, never invented. */
+ async function reportRatings() {
+ if (reportCache && now() - reportAt < 2 * DAY) return reportCache;
+ reportInflight = reportInflight || getJson(`${reportBase}/api/v1/ai/leaderboard?limit=1000`).then((d) => (d && Array.isArray(d.rows) ? d.rows : [])).finally(() => { reportInflight = null; });
+ const rows = await reportInflight;
+ const map = new Map();
+ for (const row of rows) {
+ const id = String(row && row.id || '').toLowerCase();
+ if (!id) continue;
+ const rating = row.rating && Number.isFinite(Number(row.rating.value)) ? Number(row.rating.value) : null;
+ const bench = row.benchmark && Number.isFinite(Number(row.benchmark.value)) ? Number(row.benchmark.value) : null;
+ map.set(id, { score: rating != null ? rating : bench, adult: row.uncensored === true });
+ }
+ if (map.size) { reportCache = map; reportAt = now(); }
+ return map;
+ }
+
+ /** id / provider/model / model tables, preferring a routed, available, priced row when a name has several. */
+ function indexExchange(models) {
+ const map = new Map();
+ const rank = (m) => (m.routing_enabled ? 0 : 2) + (m.status === 'available' ? 0 : 1) + (m.price && Number.isFinite(Number(m.price.output_per_million)) ? 0 : 1);
+ for (const m of models) {
+ if (!m || typeof m.id !== 'string') continue;
+ const keys = new Set([m.id, m.model].filter(Boolean).map((k) => String(k).toLowerCase()));
+ for (const k of [...keys]) { const parts = k.split('/'); if (parts.length > 1) keys.add(parts.slice(1).join('/')); }
+ for (const k of keys) {
+ const old = map.get(k);
+ if (!old || rank(m) < rank(old)) map.set(k, m);
+ }
+ }
+ return map;
+ }
+
+ function exchangeRow(ex, ratings) {
+ const price = ex && ex.price ? ex.price : {};
+ const offers = ex && Array.isArray(ex.offers) ? ex.offers : [];
+ const inPrice = num(price.input_per_million) ?? num(offers.map((o) => o && o.price && o.price.in).find((v) => v != null));
+ const outPrice = num(price.output_per_million) ?? num(offers.map((o) => o && o.price && o.price.out).find((v) => v != null));
+ const speeds = offers.map((o) => o && o.speed && o.speed.p50_ms).filter((v) => Number.isFinite(Number(v))).map(Number).sort((a, b) => a - b);
+ const rated = ratings && ex && ratings.get(String(ex.id || '').toLowerCase());
+ return {
+ in: inPrice, out: outPrice,
+ speed: speeds.length ? speeds[0] : null,
+ score: rated && rated.score != null ? rated.score : null,
+ adult: Boolean(rated && rated.adult),
+ };
+ }
+
+ function priceOf(i, o) {
+ if (i == null && o == null) return null;
+ return { in: i == null ? null : i, out: o == null ? null : o, unit: 'USD per 1M tokens' };
+ }
+
+ /** One AI option (or one Exchange directory row when AI is unreachable) as the chip's row. */
+ function rowFor(option, { source, exMap, ratings }) {
+ const model = String(option.model);
+ const ex = exMap.get(model.toLowerCase()) || exMap.get((model.split('/').slice(1).join('/')).toLowerCase()) || null;
+ const x = exchangeRow(ex, ratings);
+ const price = priceOf(x.in, x.out);
+ const speed = x.speed != null ? x.speed : (Number.isFinite(Number(option.speed_ms)) ? Number(option.speed_ms) : null);
+ const score = x.score != null ? x.score : (Number.isFinite(Number(option.quality)) ? Number(option.quality) : null);
+ return {
+ model,
+ source,
+ ...(source === 'machines' && option.machine ? { machine: String(option.machine) } : {}),
+ name: String(option.name || (ex && ex.name) || titleOf(model)),
+ maker: option.maker || makerOf(model, ex),
+ price: price && (price.in != null || price.out != null) ? price : null,
+ free: source !== 'openvibe' ? true : Boolean(price && price.in === 0 && price.out === 0),
+ ...(speed != null ? { speed } : {}),
+ ...(score != null ? { score } : {}),
+ ...(ex && ex.id ? { url: pageUrl(exchangePageBase, ex.id) } : {}),
+ ...(x.adult || option.adult === true ? { adult: true } : {}),
+ };
+ }
+
+ /** AI unreachable: Exchange's own directory, the same row shape, enough for a signed-out sheet to work. */
+ function fromExchange(task, models, ratings) {
+ const image = task === 'image';
+ const rows = [];
+ for (const m of models) {
+ if (!m || typeof m.id !== 'string') continue;
+ if (image ? m.type !== 'image' : m.type === 'image') continue;
+ if (!['available', 'preview'].includes(String(m.status || 'available'))) continue;
+ const x = exchangeRow(m, ratings);
+ if (x.adult) continue;
+ const price = priceOf(x.in, x.out);
+ rows.push({
+ model: m.id, source: 'openvibe', name: m.name || titleOf(m.id), maker: makerOf(m.id, m),
+ price: price && (price.in != null || price.out != null) ? price : null,
+ free: Boolean(price && price.in === 0 && price.out === 0),
+ ...(x.speed != null ? { speed: x.speed } : {}),
+ ...(x.score != null ? { score: x.score } : {}),
+ url: pageUrl(exchangePageBase, m.id),
+ });
+ }
+ rows.sort((a, b) => (a.price && b.price ? (a.price.out ?? Infinity) - (b.price.out ?? Infinity) : a.price ? -1 : b.price ? 1 : a.name.localeCompare(b.name)));
+ return rows.slice(0, 80);
+ }
+
+ async function read(task, { token = '', signedIn = false } = {}) {
+ if (!Object.prototype.hasOwnProperty.call(AI_TASK, task)) return null;
+ const [ai, exchange, ratings] = await Promise.all([
+ getJson(`${aiBase}/api/v1/choices?task=${encodeURIComponent(AI_TASK[task])}`, { token: signedIn ? token : '', timeoutMs: 8000 }),
+ exchangeModels(),
+ reportRatings(),
+ ]);
+ const exMap = indexExchange(exchange);
+ const adultAllowed = Boolean(ai && ai.adult);
+ const groups = [];
+ const add = (id, rows) => {
+ const def = GROUPS.find((g) => g.id === id);
+ groups.push({ id, title: def.title, rows });
+ };
+ const options = ai && ai.options && typeof ai.options === 'object' ? ai.options : {};
+ const openvibe = (Array.isArray(options.openvibe) ? options.openvibe : [])
+ .filter((o) => o && typeof o.model === 'string' && MODEL_RE.test(o.model))
+ .map((o) => rowFor(o, { source: 'openvibe', exMap, ratings }))
+ .filter((r) => !r.adult || adultAllowed);
+ if (openvibe.length) add('openvibe', openvibe);
+ else {
+ const fallback = fromExchange(task, exchange, ratings);
+ add('openvibe', fallback);
+ const note = !ai ? 'The model directory from Exchange. AI did not answer its task choices; only OpenVibe models are listed.'
+ : !fallback.length ? 'No model is listed for this task right now.' : undefined;
+ if (note) groups[groups.length - 1].note = note;
+ }
+ if (signedIn) {
+ const keys = (Array.isArray(options.keys) ? options.keys : []).filter((o) => o && typeof o.model === 'string').map((o) => rowFor(o, { source: 'keys', exMap, ratings }));
+ const machines = (Array.isArray(options.machines) ? options.machines : []).filter((o) => o && typeof o.model === 'string').map((o) => rowFor(o, { source: 'machines', exMap, ratings }));
+ add('keys', keys);
+ if (!ai) groups[groups.length - 1].note = 'Your keys could not be read right now; OpenVibe models still answer.';
+ add('machines', machines);
+ if (!ai) groups[groups.length - 1].note = 'Your computers could not be read right now; OpenVibe models still answer.';
+ add('plans', []);
+ groups[groups.length - 1].note = 'OpenVibe.Codes runs a plan with your signed-in harnesses; it does not list them for Ask yet.';
+ }
+ const recommended = choiceOf(ai && ai.recommended) || choiceOf(ai && ai.choice) || { model: 'openvibe/auto', source: 'openvibe' };
+ const chosen = choiceOf(ai && ai.choice) || recommended;
+ return { body: { task, recommended, choice: chosen, adult: adultAllowed, groups }, ai: Boolean(ai) };
+ }
+
+ /** GET /api/frame/choices: the body plus whether it may be cached (signed out only) and the person's token. */
+ async function get(task, { token = '', signedIn = false } = {}) {
+ if (!Object.prototype.hasOwnProperty.call(AI_TASK, task)) return { error: 'task', detail: 'task is chat, agent, code, image or research' };
+ const clean = String(token || '');
+ const valid = TOKEN_RE.test(clean);
+ const person = Boolean(signedIn && valid);
+ if (person) { const got = await read(task, { token: clean, signedIn: true }); return { ...got, cache: false }; }
+ const hit = held.get(task);
+ if (hit && now() - hit.at < DAY) return { body: hit.body, ai: hit.ai, cache: true };
+ const got = await read(task, { signedIn: false });
+ held.set(task, { at: now(), body: got.body, ai: got.ai });
+ if (held.size > 100) held.clear();
+ return { ...got, cache: true };
+ }
+
+ return { get, read, _state: () => ({ held: held.size }) };
+}
+
+module.exports = { createFrameChoices, AI_TASK, GROUPS, choiceOf };
@@ -30,6 +30,8 @@ const express = require('express');
@@ -30,6 +30,8 @@ const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { createMenuCards } = require('./menu-cards');
const { createMenuCards } = require('./menu-cards');
+const { createFrameChoices } = require('./choices');
+const { createFrameAsk } = require('./ask');
const seen = require('./seen');
const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
const toolsCatalog = require('../domains/catalog');
@@ -163,7 +165,18 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
@@ -163,7 +165,18 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
};
};
}
}
-async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700', today = null, now = Date.now } = {}) {
+async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, today = null, now = Date.now } = {}) {
+ const services = (config && config.services) || {};
+ aiUrl = aiUrl || (services.ai && services.ai.internalUrl) || process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700';
+ const choices = createFrameChoices({
+ aiUrl, now,
+ exchangeUrl: (services.exchange && services.exchange.internalUrl) || 'http://127.0.0.1:5150',
+ reportUrl: (services.report && services.report.internalUrl) || 'http://127.0.0.1:5080',
+ });
+ const ask = createFrameAsk({
+ aiUrl, now, requireAuth, sheet: choices,
+ searchUrl: (services.search && services.search.internalUrl) || 'http://127.0.0.1:4710',
+ });
const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
// frame_cache and frame_hits are migrations/NNNN_*.sql (plan T2). The old chrome_cache/chrome_hits
// frame_cache and frame_hits are migrations/NNNN_*.sql (plan T2). The old chrome_cache/chrome_hits
// rename was a SQLite-only step and is not repeated here.
// rename was a SQLite-only step and is not repeated here.
@@ -991,6 +1004,25 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -991,6 +1004,25 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(body);
res.send(body);
});
});
+ // GET /api/frame/choices?task=chat|agent|code|image|research — the composer's Model sheet (composer-post-ask-do.md,
+ // R1): AI's choices for the person (their token forwarded; signed out, the OpenVibe group only), every OpenVibe row
+ // enriched from Exchange's directory with its maker, price per million in and out, speed, rating and page.
+ // Same CORS and caching as /query: the person's token means a fresh private answer; signed out it is kept 60 s.
+ router.get('/choices', async (req, res) => {
+ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });
+ const task = String(req.query.task || 'chat').toLowerCase().slice(0, 20);
+ const auth = /^Bearer [A-Za-z0-9._~+/=-]{20,4096}$/.test(String(req.headers.authorization || '')) ? String(req.headers.authorization) : '';
+ const got = await choices.get(task, { token: auth.slice(7), signedIn: Boolean(auth) });
+ if (got.error) { res.set('Cache-Control', 'no-store'); return res.status(400).json({ error: got.detail, code: 'frame.task_invalid' }); }
+ res.set('Cache-Control', got.cache ? 'public, max-age=60' : 'private, no-store');
+ res.send(JSON.stringify(got.body));
+ });
+
+ // POST /api/frame/ask — one question, answered on the chosen model with the Search Fabric's results for the
+ // chosen scopes, streamed on Network's runs. Signed in only; the person's own token pays. The run's events are at
+ // /api/v1/runs/:id/events (mounted in server/index.js), replayable from Last-Event-ID.
+ router.post('/ask', ...ask.ask);
+
// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the
// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the
// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
@@ -1108,14 +1140,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -1108,14 +1140,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
timers = [t1, i1, t2, i2, t3, i3];
timers = [t1, i1, t2, i2, t3, i3];
}
}
/** Graceful stop: no further rank or copy refreshes. */
/** Graceful stop: no further rank or copy refreshes. */
- function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; menuCards.stop(); }
+ function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; menuCards.stop(); try { ask._close(); } catch { /* already closed */ } }
/** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */
/** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */
function ranking() {
function ranking() {
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
}
}
- return { router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, online: onlineFor, _state: () => ({ rank, copy, cards: menuCards._state() }) };
+ return { router, runsRouter: ask.router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, online: onlineFor, _state: () => ({ rank, copy, cards: menuCards._state() }) };
}
}
module.exports = { createFrameService, BANNED, stripOf, openvibeLink };
module.exports = { createFrameService, BANNED, stripOf, openvibeLink };
@@ -569,7 +569,7 @@ const ACCOUNT_ID_LINK = {
@@ -569,7 +569,7 @@ const ACCOUNT_ID_LINK = {
const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';
const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';
// A path with a variable is a route to call, not a page to fetch: it is written as code in llms.txt, never as a
// A path with a variable is a route to call, not a page to fetch: it is written as code in llms.txt, never as a
// Markdown link, so an agent (or a checker doing a GET) does not ask for the literal @handle address.
// Markdown link, so an agent (or a checker doing a GET) does not ask for the literal @handle address.
-const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places.';
+const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes).';
function llmsTxt() {
function llmsTxt() {
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
@@ -589,6 +589,7 @@ function llmsTxt() {
@@ -589,6 +589,7 @@ function llmsTxt() {
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },
{ title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },
+ { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages) and POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events' },
{ title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },
{ title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
@@ -63,6 +63,8 @@ app.get('/openapi.json', (_req, res) => {
@@ -63,6 +63,8 @@ app.get('/openapi.json', (_req, res) => {
}
}
res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
});
});
+app.get(['/docs/frame', '/docs/frame.md'], (_req, res) => res.type('text/markdown').set('Cache-Control', 'public, max-age=300')
+ .send(fs.readFileSync(path.join(__dirname, '..', 'docs', 'frame.md'), 'utf8')));
app.get(['/docs/trust-signals', '/docs/trust-signals.md'], (_req, res) => {
app.get(['/docs/trust-signals', '/docs/trust-signals.md'], (_req, res) => {
res.type('text/markdown').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
res.type('text/markdown').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(fs.readFileSync(path.join(__dirname, '..', 'docs', 'trust-signals.md'), 'utf8'));
.send(fs.readFileSync(path.join(__dirname, '..', 'docs', 'trust-signals.md'), 'utf8'));
@@ -1038,11 +1040,13 @@ app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles
@@ -1038,11 +1040,13 @@ app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
// The menu's day counts (Report's daily activity): the strip's "today" counts reuse the same cached read.
// The menu's day counts (Report's daily activity): the strip's "today" counts reuse the same cached read.
const frameToday = require('./frame/today').createToday({ reportUrl: config.services.report.internalUrl });
const frameToday = require('./frame/today').createToday({ reportUrl: config.services.report.internalUrl });
-const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken, today: () => frameToday.get() });
+const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken, requireAuth, today: () => frameToday.get() });
app.use('/api/v1/frame/today', rateLimit({ windowMs: 60_000, max: 240 }), frameToday.router);
app.use('/api/v1/frame/today', rateLimit({ windowMs: 60_000, max: 240 }), frameToday.router);
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
+// The composer's Ask runs: the answer streams here (AG-UI events), resumable from Last-Event-ID. A person's own run.
+app.use('/api/v1/runs', rateLimit({ windowMs: 60_000, max: 240 }), frameService.runsRouter);
frameService.start();
frameService.start();
// /api/v1/registry/featured follows the navigation's usage ranking.
// /api/v1/registry/featured follows the navigation's usage ranking.
ecosystem.setRanking(() => frameService.ranking());
ecosystem.setRanking(() => frameService.ranking());
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+// POST /api/frame/ask: one question on the chosen model, Search's results in the prompt, the answer streamed on
+// /api/v1/runs/:id/events with the final ANSWER carrying numbered citations. Signed in only; the person's token pays
+// on Search and AI, an 18+ model the person has not opted into is refused, and the stream resumes from Last-Event-ID.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+
+const TOKEN = `Bearer ${'a'.repeat(40)}.${'b'.repeat(40)}.${'c'.repeat(20)}`;
+const RAW = TOKEN.slice(7);
+const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+
+function fakeAuth(req, res, next) {
+ const m = /^Bearer\s+(\S+)$/.exec(String(req.get('authorization') || ''));
+ if (!m) return res.status(401).json({ error: 'sign in' });
+ req.token = m[1];
+ req.user = { subject_id: USR };
+ req.tokenClaims = { sub: USR, subject_id: USR };
+ next();
+}
+
+function blocks(text) {
+ return text.split('\n\n').filter((b) => b.trim()).map((b) => {
+ const out = { id: null, event: null, data: null };
+ for (const line of b.split('\n')) {
+ if (line.startsWith('id: ')) out.id = line.slice(4);
+ else if (line.startsWith('event: ')) out.event = line.slice(7);
+ else if (line.startsWith('data: ')) { try { out.data = JSON.parse(line.slice(6)); } catch { out.data = null; } }
+ }
+ return out;
+ });
+}
+
+(async () => {
+ const asked = [];
+ const ai = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const u = new URL(req.url, 'http://x');
+ asked.push({ method: req.method, path: u.pathname, query: u.search, auth: req.headers.authorization || '', body: Buffer.concat(chunks).toString('utf8') });
+ if (u.pathname === '/api/v1/choices') {
+ res.setHeader('content-type', 'application/json');
+ return res.end(JSON.stringify({
+ task: { name: 'chat.reply', kind: 'text' }, choice: { model: 'openai/gpt-5-mini', source: 'openvibe' },
+ recommended: { model: 'openai/gpt-5-mini', source: 'openvibe' }, adult: false,
+ options: { openvibe: [{ model: 'openai/gpt-5-mini', source: 'openvibe', name: 'GPT-5 Mini', maker: 'openai', adult: false }], keys: [], machines: [] },
+ }));
+ }
+ if (u.pathname === '/api/v1/runs' && u.searchParams.get('stream') === '1') {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ res.write('event: delta\ndata: {"text":"The answer "}\n\n');
+ res.write('event: delta\ndata: {"text":"is [1]."}\n\n');
+ res.end(`event: run\ndata: ${JSON.stringify({ run: { id: 'run_ai', status: 'succeeded', cost_usd: 0.001, output: { text: 'The answer is [1].' }, provenance: { model: 'openai/gpt-5-mini' } } })}\n\n`);
+ return;
+ }
+ res.statusCode = 404; res.end('{}');
+ });
+ });
+ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
+ const search = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ asked.push({ method: req.method, path: u.pathname, query: u.search, auth: req.headers.authorization || '', body: '' });
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({
+ results: [
+ { scope: 'web', kind: 'page', title: 'Example source', url: 'https://example.org/a', site: 'example.org', snippet: 'The source says five.', provider: 'wikipedia' },
+ { scope: 'network', kind: 'document', title: 'A network post', url: 'https://openvibe.community/p/1', site: 'openvibe.community', snippet: 'A post about it.' },
+ ], groups: { web: 1, network: 1 }, citations: [], route: { explain: [], cost_usd: 0 }, next_cursor: null,
+ }));
+ });
+ await new Promise((r) => search.listen(0, '127.0.0.1', r));
+
+ const svc = await createFrameService(getDb(), { services: {
+ ai: { internalUrl: `http://127.0.0.1:${ai.address().port}` },
+ search: { internalUrl: `http://127.0.0.1:${search.address().port}` },
+ } }, null, { requireAuth: fakeAuth, aiUrl: `http://127.0.0.1:${ai.address().port}` });
+ const app = express();
+ app.set('trust proxy', 'loopback');
+ app.use('/api/frame', svc.router);
+ app.use('/api/v1/runs', svc.runsRouter);
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const post = async (body, headers = { Authorization: TOKEN }) => {
+ const r = await fetch(`${base}/api/frame/ask`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
+ return { r, d: await r.json().catch(() => null) };
+ };
+
+ try {
+ // One asked question: the answer comes back as AG-UI events, with the numbered sources in ANSWER.
+ let { r, d } = await post({
+ text: 'What is example?', model: 'openai/gpt-5-mini', sources: ['web', 'openvibe', 'page'], depth: 'balanced',
+ files: ['med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'],
+ context: { site: 'openvibe.community', url: 'https://openvibe.community/thread/1', title: 'The thread', kind: 'thread', id: '1', text: 'The page says five too.' },
+ });
+ assert.strictEqual(r.status, 202);
+ assert.match(d.run, /^run_[0-9A-HJKMNP-TV-Z]{26}$/);
+ const runId = d.run;
+ const eventsRes = await fetch(`${base}/api/v1/runs/${runId}/events`, { headers: { Authorization: TOKEN } });
+ assert.strictEqual(eventsRes.status, 200);
+ assert.match(eventsRes.headers.get('content-type'), /text\/event-stream/);
+ const events = blocks(await eventsRes.text());
+ const types = events.map((e) => e.event);
+ assert.ok(types.includes('RUN_STARTED'));
+ assert.ok(types.filter((t) => t === 'STEP_STARTED').length >= 2, 'Search and Answer are steps');
+ assert.strictEqual(types.at(-1), 'RUN_FINISHED');
+ assert.strictEqual(types.at(-2), 'ANSWER');
+ assert.match(events.filter((e) => e.event === 'TEXT_MESSAGE_CONTENT').map((e) => e.data.delta).join(''), /^The answer is \[1\]\.$/);
+ const answer = events.find((e) => e.event === 'ANSWER').data;
+ assert.strictEqual(answer.answer, 'The answer is [1].');
+ assert.strictEqual(answer.model, 'openai/gpt-5-mini');
+ assert.deepStrictEqual(answer.citations.map((c) => [c.n, c.title, c.site]), [[1, 'Example source', 'example.org'], [2, 'A network post', 'openvibe.community'], [3, 'The thread', 'openvibe.community']]);
+ const eventIds = events.map((e) => Number(e.id));
+ assert.deepStrictEqual(eventIds, eventIds.map((_, i) => i + 1), 'events are numbered from 1');
+
+ // What went upstream: Search scopes and the person's token; AI's chat with the numbered sources and the choice.
+ const searchHit = asked.find((x) => x.path === '/api/v1/query');
+ const su = new URL(searchHit.query, 'http://x');
+ assert.strictEqual(su.searchParams.get('scopes'), 'web,network', 'page is context, not a Search scope');
+ assert.strictEqual(su.searchParams.get('depth'), 'standard');
+ assert.strictEqual(searchHit.auth, TOKEN);
+ const chatHit = asked.find((x) => x.path === '/api/v1/runs' && x.query.includes('stream=1'));
+ assert.strictEqual(chatHit.auth, TOKEN, "the person's token pays for the model");
+ const chat = JSON.parse(chatHit.body);
+ assert.strictEqual(chat.workflow, 'ai.chat');
+ assert.deepStrictEqual(chat.choice, { model: 'openai/gpt-5-mini', source: 'openvibe' });
+ assert.match(chat.input.messages[0].content, /\[1\] Example source — https:\/\/example\.org\/a/);
+ assert.match(chat.input.messages[0].content, /\[3\] The thread/);
+ assert.match(chat.input.messages[0].content, /The page's text/);
+ assert.deepStrictEqual(chat.input.files, [{ id: 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' }], 'a Media id rides on the turn');
+
+ // The run's snapshot has the same answer.
+ let snap = await fetch(`${base}/api/v1/runs/${runId}`, { headers: { Authorization: TOKEN } }).then((x) => x.json());
+ assert.deepStrictEqual([snap.run.status, snap.run.answer, snap.run.citations.length], ['succeeded', 'The answer is [1].', 3]);
+
+ // Resuming from Last-Event-ID sends only what came after it.
+ const again = blocks(await fetch(`${base}/api/v1/runs/${runId}/events`, { headers: { Authorization: TOKEN, 'Last-Event-ID': '2' } }).then((x) => x.text()));
+ assert.ok(again.length > 0 && Number(again[0].id) === 3, `resumed at ${again[0] && again[0].id}`);
+ assert.strictEqual(again.at(-1).event, 'RUN_FINISHED');
+
+ // Deep: Search's deep ladder with twenty sources, and room for a longer answer.
+ asked.length = 0;
+ ({ r, d } = await post({ text: 'A deep question', model: 'openai/gpt-5-mini', sources: ['web'], depth: 'deep' }));
+ assert.strictEqual(r.status, 202);
+ await fetch(`${base}/api/v1/runs/${d.run}/events`, { headers: { Authorization: TOKEN } }).then((x) => x.text());
+ const deep = new URL(asked.find((x) => x.path === '/api/v1/query').query, 'http://x');
+ assert.deepStrictEqual([deep.searchParams.get('depth'), deep.searchParams.get('limit')], ['deep', '20']);
+ assert.strictEqual(JSON.parse(asked.find((x) => x.path === '/api/v1/runs' && x.query.includes('stream=1')).body).input.max_tokens, 4000);
+
+ // Signed out: 401 and nothing asked of AI or Search.
+ let n = asked.length;
+ ({ r } = await post({ text: 'hello' }, {}));
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(asked.length, n, 'nothing went upstream');
+
+ // An 18+ model the person has not opted into is refused before a run exists.
+ n = asked.length;
+ ({ r, d } = await post({ text: 'hello', model: 'abliteratedmodels/uncensored' }));
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(d.code, 'frame.model_refused');
+ assert.strictEqual(asked.slice(n).filter((x) => x.path === '/api/v1/runs').length, 0, 'no model call for a refused model');
+
+ // A body without text is a 422.
+ ({ r } = await post({ model: 'openai/gpt-5-mini' }));
+ assert.strictEqual(r.status, 422);
+
+ // The run belongs to the person whose token made it.
+ const other = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+ assert.notStrictEqual(other, USR);
+ const notMine = await fetch(`${base}/api/v1/runs/${runId}/events`, { headers: { Authorization: TOKEN } });
+ assert.strictEqual(notMine.status, 200, 'still the owner');
+ assert.strictEqual((await fetch(`${base}/api/v1/runs/nope/events`, { headers: { Authorization: TOKEN } })).status, 404);
+
+ console.log(' ok frame ask: Search + AI chat streamed as AG-UI, ANSWER citations, deep ladder and refusals\n');
+ } finally {
+ server.close(); svc.stop(); ai.close(); search.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+// GET /api/frame/choices: AI's task choices for the person, joined with Exchange's model directory (maker, price per
+// million, speed, rating and page) and the plans group. Signed in the token goes to AI and the answer is never kept;
+// signed out only the OpenVibe group is named and the whole answer is kept 60 s.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+
+const TOKEN = `Bearer ${'a'.repeat(40)}.${'b'.repeat(40)}.${'c'.repeat(20)}`;
+
+(async () => {
+ const asked = [];
+ let aiDown = false;
+ const ai = http.createServer((req, res) => {
+ asked.push({ path: req.url, auth: req.headers.authorization || '' });
+ if (aiDown) { res.statusCode = 503; return res.end('{}'); }
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({
+ task: { name: 'chat.reply', kind: 'text' },
+ choice: { model: 'openai/gpt-5-mini', source: 'openvibe' },
+ recommended: { model: 'openai/gpt-5-mini', source: 'openvibe' },
+ options: {
+ openvibe: [
+ { model: 'openai/gpt-5-mini', source: 'openvibe', name: 'GPT-5 Mini', maker: 'openai', price_estimate_usd: 0.003, speed_ms: 900, quality: 0.8, adult: false },
+ { model: 'abliteratedmodels/uncensored', source: 'openvibe', name: 'Uncensored', maker: 'abliteratedmodels', adult: true },
+ ],
+ keys: [{ model: 'openai/gpt-5-mini', source: 'keys', name: 'GPT-5 Mini', maker: 'openai', price_estimate_usd: 0 }],
+ machines: [{ model: 'llama3.1:8b', source: 'machines', machine: 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', name: 'llama3.1:8b', maker: null }],
+ },
+ adult: false,
+ }));
+ });
+ await new Promise((r) => ai.listen(0, '127.0.0.1', r));
+ const exchange = http.createServer((req, res) => {
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ count: 1, models: [{
+ id: 'openai/gpt-5-mini', name: 'OpenAI: GPT-5 Mini', provider: 'openai', provider_name: 'OpenAI',
+ status: 'available', routing_enabled: true, type: 'chat',
+ price: { input_per_million: 0.25, output_per_million: 2 }, offers: [{ price: { in: 0.25, out: 2 }, speed: { p50_ms: 750 } }],
+ }] }));
+ });
+ await new Promise((r) => exchange.listen(0, '127.0.0.1', r));
+ const report = http.createServer((req, res) => {
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ rows: [{ id: 'openai/gpt-5-mini', rating: { value: 0.82 }, benchmark: { value: null }, uncensored: false }] }));
+ });
+ await new Promise((r) => report.listen(0, '127.0.0.1', r));
+
+ const svc = await createFrameService(getDb(), { services: {
+ ai: { internalUrl: `http://127.0.0.1:${ai.address().port}` },
+ exchange: { internalUrl: `http://127.0.0.1:${exchange.address().port}` },
+ report: { internalUrl: `http://127.0.0.1:${report.address().port}` },
+ } }, null);
+ const app = express();
+ app.set('trust proxy', 'loopback');
+ app.use('/api/frame', svc.router);
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/frame/choices`;
+ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };
+
+ try {
+ // Signed out: only OpenVibe, enriched from Exchange and Report, held 60 s.
+ let { r, d } = await get('?task=chat');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.deepStrictEqual(d.groups.map((g) => g.id), ['openvibe'], 'keys and machines need a person');
+ assert.deepStrictEqual(d.recommended, { model: 'openai/gpt-5-mini', source: 'openvibe' });
+ const row = d.groups[0].rows[0];
+ assert.deepStrictEqual([row.model, row.source, row.name, row.maker], ['openai/gpt-5-mini', 'openvibe', 'GPT-5 Mini', 'openai']);
+ assert.deepStrictEqual(row.price, { in: 0.25, out: 2, unit: 'USD per 1M tokens' });
+ assert.strictEqual(row.free, false);
+ assert.strictEqual(row.speed, 750);
+ assert.strictEqual(row.score, 0.82);
+ assert.equal(row.url, 'https://openvibe.exchange/models/openai%2Fgpt-5-mini');
+ assert.ok(!d.groups[0].rows.some((x) => x.adult), 'an 18+ row is not offered without the 18+ setting');
+ assert.strictEqual(asked[0].auth, '', 'an anonymous sheet asks as anyone');
+ const firstUrls = asked.map((x) => x.path);
+ await get('?task=chat');
+ assert.deepStrictEqual(asked.map((x) => x.path), firstUrls, 'the anonymous sheet is answered from memory');
+
+ // Signed in: the person's token goes to AI, all four groups are named, and nothing is kept.
+ ({ r, d } = await get('?task=chat', { Authorization: TOKEN }));
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.deepStrictEqual(d.groups.map((g) => g.id), ['openvibe', 'keys', 'machines', 'plans']);
+ assert.strictEqual(asked[asked.length - 1].auth, TOKEN, "the person's token goes along");
+ assert.strictEqual(d.groups.find((g) => g.id === 'keys').rows[0].source, 'keys');
+ assert.strictEqual(d.groups.find((g) => g.id === 'keys').rows[0].price.in, 0.25, 'a key row still shows the model price');
+ assert.strictEqual(d.groups.find((g) => g.id === 'keys').rows[0].free, true, 'no OpenVibe fee on your own key');
+ const machine = d.groups.find((g) => g.id === 'machines').rows[0];
+ assert.deepStrictEqual([machine.model, machine.source, machine.machine], ['llama3.1:8b', 'machines', 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3']);
+ assert.strictEqual(d.groups.find((g) => g.id === 'plans').rows.length, 0);
+ assert.match(d.groups.find((g) => g.id === 'plans').note, /Codes/);
+ await get('?task=chat', { Authorization: TOKEN });
+ assert.strictEqual(asked.filter((x) => x.auth === TOKEN).length, 2, 'a person is always asked fresh');
+
+ // The task maps to AI's task for it; a malformed token is a signed-out ask, never a person's.
+ await get('?task=code', { Authorization: 'Bearer short' });
+ assert.strictEqual(asked[asked.length - 1].auth, '', 'a malformed token is not passed on');
+ assert.match(asked[asked.length - 1].path, /task=design\.icon$/, 'code maps to AI\'s code task');
+
+ // AI down: Exchange's directory still fills the OpenVibe group, with a note.
+ aiDown = true;
+ ({ r, d } = await get('?task=agent'));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(d.groups[0].rows[0].model, 'openai/gpt-5-mini');
+ assert.match(d.groups[0].note, /Exchange/);
+ aiDown = false;
+
+ // A task not in the list is a 400, never an upstream ask.
+ const n = asked.length;
+ ({ r, d } = await get('?task=unreal'));
+ assert.strictEqual(r.status, 400);
+ assert.strictEqual(d.code, 'frame.task_invalid');
+ assert.strictEqual(asked.length, n);
+
+ console.log(' ok frame choices: AI choices, Exchange enrichment, signed-out cache and the plans note\n');
+ } finally {
+ server.close(); svc.stop(); ai.close(); exchange.close(); report.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -118,6 +118,8 @@ const REVIEWED = {
@@ -118,6 +118,8 @@ const REVIEWED = {
'server/home/popular.js fetch': [2, 'Report public popularity and co-usage: the operator-configured first-party Report URL (OV_REPORT_INTERNAL_URL), a fixed loopback origin, never a request value'],
'server/home/popular.js fetch': [2, 'Report public popularity and co-usage: the operator-configured first-party Report URL (OV_REPORT_INTERNAL_URL), a fixed loopback origin, never a request value'],
'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],
'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
+ 'server/frame/choices.js fetch': [1, 'the composer\'s Model sheet: AI (OV_AI_INTERNAL_URL), Exchange (OV_EXCHANGE_INTERNAL_URL / config.services.exchange) and Report (OV_REPORT_INTERNAL_URL) internal URLs from operator config, fixed /api/v1/choices, /api/v1/models and /api/v1/ai/leaderboard paths, the task percent-encoded and a checked person Bearer only; no request value names a host, redirects refused, 3-8 s timeouts'],
+ 'server/frame/ask.js fetch': [2, 'the composer\'s Ask: Search (OV_SEARCH_INTERNAL_URL) fixed /api/v1/query path with checked scopes and the person\'s Bearer, and AI (OV_AI_INTERNAL_URL) fixed /api/v1/runs?stream=1 path with the person\'s Bearer; both operator-configured internal URLs, no request value names a host, redirects refused, 30-150 s timeouts'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],
'server/admin/routes.js fetch': [1, 'registry refresh: POSTs to the service refresh targets resolved from the registry (admin-set service URLs, staff only)'],