openvibers/openvibe.network Commits

Frame: the composer's model sheet and Ask runs (#858)

OP OpenVibers committed 1 days ago · 52c93b5898 · 10 files, +1127 −5

GET /api/frame/choices?task= asks AI's task choices for the person (their token forwarded; signed out, the
OpenVibe group only), joins every OpenVibe row with Exchange's directory (maker, price per million in and out,
speed, Report's rating, the model's page) and names the keys, computers and plans groups. Same CORS and caching
as /api/frame/query: private and fresh signed in, public for 60 s signed out.

POST /api/frame/ask is one question on the chosen model: Search's fabric for the chosen scopes in the prompt,
numbered citations, and the answer streamed on Network's new runs (GET /api/v1/runs/:id/events, AG-UI,
Last-Event-ID resumes). Quick and Balanced are a Vibe turn on AI's ai.chat; Deep asks Search's deep ladder and
gives the answer more room. Signed in only; the person's token, free allowance, Balance, key or computer pays. An
18+ model the person has not opted into is refused before a run exists. The runs live in memory for five minutes
after they end.

Docs: /docs/frame and llms.txt name both routes. Tests: frame-choices, frame-ask (the stream, citations, deep,
refusals and replay), the SSRF ratchet, and the full suite.

Changes

ddiff --git a/docs/frame.md b/docs/frame.md
nnew file mode 100644
iindex 00000000..0c7b0e22
---- /dev/null
++++ b/docs/frame.md
@@@ -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).
ddiff --git a/server/config.js b/server/config.js
iindex b6de5d64..b6bed3e5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
},
ddiff --git a/server/frame/ask.js b/server/frame/ask.js
nnew file mode 100644
iindex 00000000..1ddbd762
---- /dev/null
++++ b/server/frame/ask.js
@@@ -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 };
ddiff --git a/server/frame/choices.js b/server/frame/choices.js
nnew file mode 100644
iindex 00000000..6a9c8e39
---- /dev/null
++++ b/server/frame/choices.js
@@@ -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 };
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 890d3247..e43cd259 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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, is
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
// 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, is
timers = [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 };
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 8460c14a..3ddd7887 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -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' },
ddiff --git a/server/index.js b/server/index.js
iindex 6cf4eece..e9199212 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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());
ddiff --git a/test/frame-ask.test.js b/test/frame-ask.test.js
nnew file mode 100644
iindex 00000000..eb82bc1d
---- /dev/null
++++ b/test/frame-ask.test.js
@@@ -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); });
ddiff --git a/test/frame-choices.test.js b/test/frame-choices.test.js
nnew file mode 100644
iindex 00000000..a0e56040
---- /dev/null
++++ b/test/frame-choices.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 6a8301ee..e3670dc1 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)'],