openvibers/openvibe.exchange Commits

Carry the AI site onto Exchange: start building, why this model, the merged questions (#107)

OP OpenVibers committed 6 days ago · df34fbd7cb · 16 files, +458 −13

The old ai.openvibe.services home, prices, changelog and FAQ content moves here on Exchange's own shell. /start is the guide (endpoint, four steps, OpenAI/Anthropic/Node samples, AI's six operations, sandbox table, links to the session pages /you and /runs/:id); /why is the routing explainer with a signed-in one-question demo that prints a real run's explain; /faq merges AI's questions with Exchange's, with FAQPage JSON-LD. Sitemap, llms.txt, llms-full.txt, the Markdown twins and IndexNow carry the new pages; /api and /start link to AI's own provider-keys page.

Changes

ddiff --git a/.env.example b/.env.example
iindex 592dd23..ef301d6 100644
---- a/.env.example
++++ b/.env.example
@@@ -67,3 +67,7 @@ EXCHANGE_EVENTS_URL=
# optional: defaults to true in production
# COOKIE_SECURE=true
++
++# IndexNow (openvibe-shared/indexnow): a key lets a new page or a changed sitemap be submitted for recrawl.
++# Unset → off; nothing is mounted and nothing is sent. The key file is served at /<key>.txt.
++#INDEXNOW_KEY=
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 803fb14..639d888 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ What changed in OpenVibe.Exchange, newest first.
## Unreleased
++- **The public pages of ai.openvibe.services are here now (2026-11-07).** The old site's home, prices and latency,
++ changelog, legal pages and discovery files answer 301 on an exact page map; every API route there is unchanged.
++ Three pages carry the content: `/start` (the endpoint, four steps, OpenAI/Anthropic/Node samples, AI's six
++ operations and app-token path, the sandbox and production table, and the links to the session pages `/you` and
++ `/runs/:id`), `/why` (the router's own explanation, with a signed-in one-question demo that prints a real run's
++ `explain`: objective, selected, every candidate's cost and latency, and why each was passed over) and `/faq` (AI's
++ six questions merged with Exchange's, with FAQPage JSON-LD). Sitemap, llms.txt, llms-full.txt and the Markdown
++ twins (`/start.md`, `/why.md`, `/faq.md`) carry them; `openvibe-shared/indexnow` pings the new pages on deploy when
++ `INDEXNOW_KEY` is set. `/api` and `/start` link to AI's own provider-keys page (`/you`) because it needs the
++ person's session.
- **Pooled machines (`/sell`).** Under the single-machine card, a signed-in person with two or more of their
machines sees "Pool your machines": the four steps of the design as stacked cards. **Choose the machines** lists
each machine with its GPU, free VRAM and RAM and Node's measured link in words ("2 ms, 940 Mb/s: good"; a failed
ddiff --git a/README.md b/README.md
iindex 77a47ff..71d261a 100644
---- a/README.md
++++ b/README.md
@@@ -18,7 +18,10 @@ for a person — and keeps only two things of its own: a person's chats and thei
| `/` | "Every AI model, one account": search, popular now, cheapest per million, uncensored, community machines, the endpoint, AI in OpenVibe. Every number is live. |
| `/models`, `/m/<org>/<model>` | Every model and every offer that serves it: price in, out and cached per million, measured p50/p95, uptime, data policy, a **Try it** box, curl and the SDK line. |
| `/chat` | One model, or up to three side by side; system prompt, temperature, history in the account. The shared conversation UI (`openvibe-shared/thread`, `ov-thread.js`). |
--| `/api` | The endpoint, model ids (`deepseek/deepseek-chat`, `openvibe/auto`), keys from Network, aggregate usage. |
++| `/api` | The endpoint, model ids (`deepseek/deepseek-chat`, `openvibe/auto`), keys from Network, aggregate usage, and the link to AI's own provider-keys page (`/you`, a session page). |
++| `/start` | Start building: one key, one endpoint, the OpenAI/Anthropic samples, four steps, AI's six operations, and the sandbox and production limits. |
++| `/why` | Why this model: the routing rule and a signed-in demo that shows a real run's explain — the objective, the selected offer, and every candidate with its cost, latency and reason. |
++| `/faq` | The merged questions: the endpoint, SDKs, models, prices, privacy, keys, selling compute, the uncensored tier and the move from ai.openvibe.services. |
| `/sell` | OpenVibe Node's "Offer to the network", "Pool your machines" for a signed-in person (the four steps: choose the machines, what fits, approve on each, offer it), the dashboard, and what is **not** sold here (personal plans, personal API keys), said plainly. |
| `/uncensored` | The 18+ gate on the account, the abliterated models and the hard floor. |
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex ce8a7d2..8e229fc 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -634,3 +634,16 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
.ex-pool-approval { align-items: flex-start; }
.ex-pool .ex-field select { width: 100%; }
}
++
++/* Why this model, Start building and the merged FAQ (moved from ai.openvibe.services, 2026-11-07). */
++.ex-why-answer { margin: 16px 0; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); min-width: 0; }
++.ex-answer-body { margin: 8px 0 12px; }
++.ex-answer-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.65; }
++.ex-why-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 14px; margin: 12px 0; font-size: .9rem; }
++.ex-why-facts dt { font-weight: 800; color: var(--text-muted, #7386a3); }
++.ex-why-facts dd { margin: 0; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.ex-why-form { display: grid; gap: 10px; }
++.ex-why-form textarea { width: 100%; min-height: 96px; }
++.ex-why-gate { margin: 8px 0; }
++.ex-faq { display: grid; gap: 10px; }
++.ex-faq .ovk-fold { margin: 0; }
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 20c8a3d..77f01c4 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -43,6 +43,17 @@ function createAgentPages(ctx) {
const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
return page(p, { index: true, title: 'The API', description: 'One OpenAI- and Anthropic-compatible endpoint for every model.', body: raw(views.apiPage({ catalog, usage })) });
}
++ if (p === '/start') {
++ const catalog = await ai.catalog();
++ return page(p, { index: true, title: 'Start building', description: 'One key and one OpenAI- and Anthropic-compatible endpoint; every answer says which model ran and why.', body: raw(views.startPage({ catalog })) });
++ }
++ if (p === '/why') {
++ const catalog = await ai.catalog();
++ return page(p, { index: true, title: 'Why this model', description: 'Why the router chose the model that answered: the objective, the candidates and the reason each was passed over.', body: raw(views.whyPage({ catalog, viewer: { kind: 'anonymous' } })) });
++ }
++ if (p === '/faq') {
++ return page(p, { index: true, title: 'Questions', description: 'The endpoint, the SDKs, keys, prices, privacy, the uncensored tier and the move from ai.openvibe.services.', body: raw(views.faqPage()) });
++ }
if (p === '/sell') {
const [catalog, usage] = await Promise.all([ai.catalog(), ai.usage(30)]);
return page(p, { index: true, title: 'Sell your compute', description: 'Turn a GPU or a spare machine into credits with OpenVibe Node.', body: raw(views.sellPage({ catalog, usage })) });
ddiff --git a/server/ai.js b/server/ai.js
iindex 1e86f3b..7140f36 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -173,6 +173,9 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
privacy: privacy ? privacy.label || null : null,
privacy_detail: privacy,
preset: explain && explain.preset && explain.preset.name ? String(explain.preset.name) : null,
++ // The router's whole explanation, as AI recorded it on the run (objective, selected, reasons, every
++ // candidate with its cost, latency and excluded reason). /why draws it; nothing else re-derives it.
++ explain: explain && typeof explain === 'object' ? explain : null,
};
}
ddiff --git a/server/app.js b/server/app.js
iindex 3d34a67..4096c97 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -67,6 +67,11 @@ async function createApp(opts = {}) {
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const s = opts.store || await openStore(config, { now: opts.now, log });
++ // IndexNow (openvibe-shared/indexnow): created once from INDEXNOW_KEY; unset → off, nothing mounted,
++ // nothing sent. The new pages ping it after a deploy (server/index.js), and the key file is served here.
++ const indexnow = opts.indexnow !== undefined ? opts.indexnow : require('openvibe-shared/indexnow').createIndexNow({
++ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: fetchImpl } : {}), log,
++ });
// The sharing system's own DDL (openvibe-sdk/share). The migration role creates it in production; the runtime
@@@ -109,7 +114,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
-- const ctx = { config, s, keys, sso, principal, ai, network, bench, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, log };
++ const ctx = { config, s, keys, sso, principal, ai, network, bench, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, log };
const app = express();
app.disable('x-powered-by');
@@@ -151,6 +156,8 @@ async function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));
++ // IndexNow's key file (/<key>.txt), when a key is configured.
++ if (indexnow.enabled) app.use(indexnow.keyFile);
release.mount(app, { registry: metrics.registry });
const readiness = createServiceReadiness({ s, config, release: release.release, valkey, sso });
app.get('/api/ready', readiness.handler);
ddiff --git a/server/config.js b/server/config.js
iindex fafcce6..d470de2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -132,6 +132,10 @@ function load(env = process.env) {
models: Math.max(1, Math.min(3, int(env.EXCHANGE_CHAT_MODELS, 3))),
maxChars: Math.max(200, int(env.EXCHANGE_CHAT_MAX_CHARS, 8000)),
},
++
++ // IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page that appeared or
++ // changed. Unset → off: nothing mounted, nothing sent.
++ indexnow: { key: String(env.INDEXNOW_KEY || '').trim() },
};
}
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex c29ed1d..816f1ff 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -17,8 +17,11 @@ const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/chat', '/listing
const PUBLIC_PAGES = [
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Every AI model, one account: live prices, speed and uptime, chat, and one endpoint.' },
-- { path: '/models', changefreq: 'daily', priority: 0.9, title: 'Models', text: 'Every model with every offer that serves it: price in and out per million tokens, speed, uptime and data policy.' },
++ { path: '/models', changefreq: 'daily', priority: 0.9, title: 'Models', text: 'Every model with every offer that serves it: price in, out and cached per million tokens, measured p50 and p95 speed, uptime and data policy.' },
{ path: '/api', changefreq: 'weekly', priority: 0.7, title: 'The API', text: 'One OpenAI- and Anthropic-compatible endpoint, model ids, keys from Network and aggregate usage.' },
++ { path: '/start', changefreq: 'weekly', priority: 0.8, title: 'Start building', text: 'One key and one endpoint: the OpenAI and Anthropic SDK samples, four steps to a first answer, OpenVibe.AI’s six operations, and the sandbox and production limits.' },
++ { path: '/why', changefreq: 'weekly', priority: 0.7, title: 'Why this model', text: 'Every answer carries its receipt: what the router optimised, the offer that answered, every other candidate’s cost and latency, and why each was passed over.' },
++ { path: '/faq', changefreq: 'weekly', priority: 0.6, title: 'Questions', text: 'The endpoint, the SDKs, keys, prices, privacy, the uncensored tier, selling compute, and the move of OpenVibe.AI’s public pages here.' },
{ path: '/sell', changefreq: 'weekly', priority: 0.6, title: 'Sell your compute', text: 'OpenVibe Node offers a GPU or a spare machine to the network for credits; what is not sold here, said plainly.' },
{ path: '/own', changefreq: 'weekly', priority: 0.6, title: 'Your own endpoint', text: 'A private GPU for one model or your own Hugging Face weights; only you reach it, it scales to zero when idle, and your Balance pays for the seconds it runs.' },
{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
@@@ -103,9 +106,12 @@ function createDiscoveryRoutes(ctx) {
sections: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
-- { title: 'Models', url: abs('/models') },
++ { title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
{ title: 'The API', url: abs('/api') },
++ { title: 'Start building', url: abs('/start') },
++ { title: 'Why this model', url: abs('/why') },
++ { title: 'Questions', url: abs('/faq') },
{ title: 'Sell your compute', url: abs('/sell') },
{ title: 'Uncensored (18+)', url: abs('/uncensored') },
{ title: 'AI in OpenVibe', url: abs('/ai') },
@@@ -120,7 +126,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
-- { title: 'OpenVibe.AI', url: 'https://ai.openvibe.services', note: 'the router and the run API every product uses' },
++ { title: 'OpenVibe.AI', url: 'https://ai.openvibe.services', note: 'the router, the six operations, a run’s receipt and the operator console; its public pages are here now' },
{ title: 'OpenVibe.Node', url: 'https://node.openvibe.network', note: 'offer a machine to the network' },
],
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a8338d9..a5cbe83 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -630,6 +630,60 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
});
});
++ // ── Start building, why this model, and the questions ────
++ // Moved from ai.openvibe.services when its public pages merged here. The API stays on that address; these
++ // routes carry its guide, its routing explainer and its FAQ onto Exchange's own shell.
++ r.get('/start', async (req, res) => {
++ const catalog = await catalogOf();
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
++ title: 'Start building',
++ description: 'Start with one key and one OpenAI- and Anthropic-compatible endpoint; every answer says which model ran and why.',
++ body: raw(views.startPage({ catalog })),
++ });
++ });
++
++ r.get('/why', async (req, res) => {
++ const catalog = await catalogOf();
++ return page(req, res, {
++ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE, agentLayer: true,
++ title: 'Why this model',
++ description: 'Why the router chose the model that answered: the objective, the candidates, their estimated cost and latency, and the reason each was passed over.',
++ body: raw(views.whyPage({ catalog, viewer: req.viewer })),
++ });
++ });
++
++ r.post('/why', async (req, res) => {
++ if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to ask one question', text: 'The demo is one real run on your own allowance.', next: '/why' });
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const text = String((req.body && req.body.text) || '').slice(0, Math.min(2000, config.chat.maxChars));
++ const catalog = await catalogOf();
++ if (!text.trim()) {
++ return page(req, res, { index: false, title: 'Why this model', body: raw(views.whyPage({ catalog, viewer: req.viewer, problem: 'Write something to send.', text })) }, 422);
++ }
++ try {
++ const result = await ai.chat({ model: 'openvibe/auto', messages: [{ role: 'user', content: text }], subject: owner(req), maxTokens: 512 });
++ return page(req, res, {
++ index: false, title: 'Why this model',
++ description: 'One real run, with the router’s own explanation of the offer it chose.',
++ body: raw(views.whyPage({ catalog, viewer: req.viewer, result, text })),
++ });
++ } catch (err) {
++ return page(req, res, {
++ index: false, title: 'Why this model',
++ body: raw(views.whyPage({ catalog, viewer: req.viewer, problem: err.message || 'The model did not answer.', text })),
++ }, err.status === 429 ? 429 : 502);
++ }
++ });
++
++ r.get('/faq', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, agentLayer: true,
++ title: 'Questions',
++ description: 'The questions developers and people ask about OpenVibe.Exchange: the endpoint, SDKs, keys, prices, privacy, the uncensored tier and the move from ai.openvibe.services.',
++ jsonLd: [seo.jsonLd.faq(views.FAQ.map((p) => ({ q: p.q, a: p.a })))],
++ body: raw(views.faqPage()),
++ }));
++
// ── Sell ─────────────────────────────────────────────────
// The person's machines and pools come from Network with their own token (never a service token); the live
// community offers come from AI's public read. Exchange measures nothing and keeps no pool of its own.
ddiff --git a/server/index.js b/server/index.js
iindex 239cdc7..0030f85 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -38,6 +38,9 @@ async function start() {
// Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded
// only because the first JWKS fetch had not happened.
ctx.keys.ensure().catch(() => {});
++ // IndexNow: the pages that moved here from ai.openvibe.services, once, on the deploy that serves them.
++ // pingSoon batches and never throws; with no key configured it does nothing.
++ if (ctx.indexnow && ctx.indexnow.enabled) ctx.indexnow.pingSoon(['/start', '/why', '/faq', '/sitemap.xml', '/llms.txt']);
});
server.keepAliveTimeout = 65_000;
ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 1e36b6a..ff0a61f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -25,10 +25,13 @@ const NAV = [
{ label: 'Arena', href: '/arena' },
{ label: 'Rankings', href: '/rankings' },
{ label: 'API', href: '/api' },
++ { label: 'Start', href: '/start' },
++ { label: 'Why this model', href: '/why' },
{ label: 'Sell compute', href: '/sell' },
{ label: 'Your endpoint', href: '/own' },
{ label: 'Uncensored', href: '/uncensored' },
{ label: 'AI in OpenVibe', href: '/ai' },
++ { label: 'Questions', href: '/faq' },
{ label: 'Status', href: '/status' },
];
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 17fd7ac..032762f 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -183,9 +183,9 @@ function home({ catalog, usage, viewer, q = '', config = null }) {
<div class="ex-hero-copy">
<p class="ex-kicker">OpenVibe.Exchange</p>
<h1>Every AI model, one account.</h1>
--<p class="ex-lead">${families.length ? `${esc(listSaid(families.slice(0, 6)))} now, more as their providers join: the` : 'The'} live price, speed and uptime of every offer, one OpenAI-compatible endpoint, and a chat that puts two or three models side by side.</p>
++<p class="ex-lead">${families.length ? `${esc(listSaid(families.slice(0, 6)))} now, more as their providers join: the` : 'The'} live price, speed and uptime of every offer, one OpenAI-compatible endpoint, and a chat that puts two or three models side by side. Every answer says which model ran and why it was chosen.</p>
${kit.find({ action: '/models', name: 'q', placeholder: families.length ? `Find a model: ${families.slice(0, 3).join(', ')}…` : 'Find a model or a provider', value: q, icon: 'search' })}
--<p class="ex-hero-actions">${kit.button({ href: '/models', label: 'All models' })} ${kit.button({ href: '/chat', label: 'Open chat', kind: 'quiet' })} ${kit.button({ href: '/arena', label: 'Enter the Arena', kind: 'ghost' })}</p>
++<p class="ex-hero-actions">${kit.button({ href: '/models', label: 'All models' })} ${kit.button({ href: '/start', label: 'Start building', kind: 'quiet' })} ${kit.button({ href: '/chat', label: 'Open chat', kind: 'quiet' })} ${kit.button({ href: '/arena', label: 'Enter the Arena', kind: 'ghost' })}</p>
</div>
${preview.length === 2 ? previewCard(preview) : ''}
</section>
@@@ -200,6 +200,8 @@ ${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} mod
${lineCard({ href: '/arena', name: 'The Arena', text: 'One prompt, two unnamed models, one vote: then both names and their receipts.', mark: mark('DeepSeek') })}
${lineCard({ href: '/rankings', name: 'Rankings by task', text: 'What people prefer for code, writing, chat and reasoning, beside the price, speed and uptime. Worth it, measured.', mark: mark('Qwen') })}
${lineCard({ href: '/uncensored', name: 'Uncensored (18+)', text: 'Abliterated models for adults, with the floor that holds on every request.', mark: mark('Llama') })}
++${lineCard({ href: '/why', name: 'Why this model', text: 'Every answer carries its receipt: the objective, the candidates, their cost and latency, and why the others did not answer.', mark: mark('DeepSeek') })}
++${lineCard({ href: '/start', name: 'Start building', text: 'One key, one endpoint and the SDK you already use; four steps from a key to a receipt.', mark: mark('Qwen') })}
${lineCard({ href: '/sell', name: 'Run on people’s machines', text: 'Community GPUs through OpenVibe Node, labelled on every answer.', mark: mark('Qwen') })}
${lineCard({ href: '/api', name: 'Bring your own key, 0% fee', text: 'Your provider key pays the provider directly; the router never adds a fee on it.', mark: mark('GLM') })}
</div>
@@@ -207,7 +209,7 @@ ${panel({
title: 'Use it from code', note: 'One endpoint, compatible with OpenAI and with Anthropic.', id: 'code', body: `
<p class="ex-prose">Point an OpenAI client at <code>https://api.openvibe.exchange/v1</code>, or Claude Code at its <code>/v1/messages</code>. Model ids are <code>deepseek/deepseek-chat</code>, or <code>openvibe/auto</code> to let the router pick by your floor, ceiling and policy.</p>
${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"deepseek/deepseek-chat","messages":[{"role":"user","content":"Hello"}]}'`, id: 'home-curl' })}
--<p class="ex-more"><a href="/api">Keys, usage and the full reference →</a></p>`,
++<p class="ex-more"><a href="/start">Start building in four steps →</a> · <a href="/api">Keys, usage and the full reference →</a></p>`,
})}
${panel({
title: 'AI in OpenVibe', note: 'Which products run which models, and what each costs a day.', id: 'products',
@@@ -472,8 +474,8 @@ ${panel({ title: 'One endpoint', body: `
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`, id: 'api-curl' })}
<h3 class="ex-h3">Keys</h3>
--<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here.</p>
--<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })}</p>
++<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here. Your own provider keys (OpenAI, Anthropic and the rest) are different: they stay sealed on <a href="https://ai.openvibe.services/you">OpenVibe.AI’s own provider-keys page</a>, which needs your sign-in, and are never copied here.</p>
++<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })} ${kit.button({ href: 'https://ai.openvibe.services/you', label: 'Your own provider keys', kind: 'quiet' })}</p>
<h3 class="ex-h3" id="strict">Strict privacy</h3>
${switchBox}
<h3 class="ex-h3">Model ids</h3>
@@@ -972,4 +974,184 @@ ${panel({ title: 'Your endpoints', id: 'mine', body: signedIn ? list : kit.empty
</div>`;
}
--module.exports = { audienceDefaults, home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, ownPage, statusPage, retired, sealedChat, privacyOf, answerPrivacy, providerLabel, familyLabel, chatModels, ENDPOINT };
++// ── Start building, why this model, and the questions (moved from ai.openvibe.services) ──
++/**
++ * The AI site's start-building guide, on Exchange: one endpoint and a key, the SDKs people already use, the
++ * four steps, and the OpenVibe.AI developer-app path that stays on ai.openvibe.services for the six operations.
++ * Every URL and limit here is one the products serve; nothing is invented.
++ */
++function startPage({ catalog = null } = {}) {
++ const models = chatModels(catalog || {});
++ const python = [
++ '# pip install openai',
++ 'import os',
++ 'from openai import OpenAI',
++ '',
++ "client = OpenAI(base_url='https://api.openvibe.exchange/v1',",
++ " api_key=os.environ['OPENVIBE_KEY'])",
++ 'reply = client.chat.completions.create(',
++ " model='openvibe/auto', # the router picks; reply.model names the one that ran",
++ " messages=[{'role': 'user', 'content': 'Name three uses for a paperclip.'}],",
++ ')',
++ 'print(reply.model, reply.choices[0].message.content)',
++ ].join('\n');
++ const node = [
++ '// npm i openai',
++ "import OpenAI from 'openai';",
++ '',
++ "const client = new OpenAI({ baseURL: 'https://api.openvibe.exchange/v1', apiKey: process.env.OPENVIBE_KEY });",
++ 'const stream = await client.chat.completions.create({',
++ " model: 'openvibe/auto', stream: true,",
++ " messages: [{ role: 'user', content: 'Name three uses for a paperclip.' }],",
++ '});',
++ "for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? '');",
++ ].join('\n');
++ const anthropic = [
++ 'export ANTHROPIC_BASE_URL=https://api.openvibe.exchange',
++ 'export ANTHROPIC_AUTH_TOKEN=$OPENVIBE_KEY',
++ '# Claude Code, or any Anthropic client, now speaks to /v1/messages',
++ ].join('\n');
++ const token = [
++ 'TOKEN=$(curl -s -X POST https://openvibe.network/oauth/token \\',
++ ' -d grant_type=client_credentials -d client_id=$APP -d client_secret=$SECRET \\',
++ ' -d audience=openvibe.ai | jq -r .access_token)',
++ '# a five-minute token for an app (app_<ULID>), scoped to its project',
++ ].join('\n');
++ const chat = [
++ 'curl -s -X POST https://ai.openvibe.services/api/v1/chat \\',
++ ' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',
++ ' -d \'{ "messages": [ { "role": "user", "content": "Name three uses for a paperclip." } ] }\'',
++ '# → { "run": { "id": "run_…", "status": "succeeded", "output": …, "provenance": …, "explain": … } }',
++ ].join('\n');
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Start building', sub: 'One endpoint, a key, and every answer says which model ran and why.', actions: [kit.button({ href: '/api', label: 'Keys and the API', kind: 'quiet' })] })}
++${panel({ title: 'The endpoint', id: 'endpoint', body: `<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions, and <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API. Point an OpenAI client at <code>https://api.openvibe.exchange/v1</code>; the model's page has its own curl and SDK sample.</p>
++${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"openvibe/auto","messages":[{"role":"user","content":"Hello"}]}'`, id: 'start-curl' })}
++<p class="ex-prose">A key is an OpenVibe.Network developer key: make one, name its scope, revoke it any time. It is shown once and never stored here.</p>
++<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })} ${kit.button({ href: '/own', label: 'Your own endpoint', kind: 'quiet' })}</p>` })}
++${panel({ title: 'Four steps', id: 'steps', body: `<ol class="ex-list">
++<li><strong>Make a key</strong> at <a href="https://openvibe.network/developers">openvibe.network/developers</a>. It is a developer key for your project, and it is shown once.</li>
++<li><strong>Point a client at the endpoint</strong>: <code>https://api.openvibe.exchange/v1</code> for OpenAI, or <code>https://api.openvibe.exchange</code> for Anthropic's <code>/v1/messages</code>.</li>
++<li><strong>Send one request</strong> with <code>model: "openvibe/auto"</code>, or an exact model id from <a href="/models">Models</a> to pin one artifact.</li>
++<li><strong>Read the receipt</strong>: every answer names the model that ran, its tokens, its exact cost, and why the router chose it. <a href="/why">Why this model</a> shows how to read it; a run's own page is <code>https://ai.openvibe.services/runs/&lt;id&gt;</code> after you sign in with OpenVibe.</li>
++</ol>` })}
++${panel({ title: 'Use the OpenAI SDK you already have', id: 'sdk', body: `<p class="ex-prose">The same request in Python, in Node with streaming, or from an Anthropic client. The answer's <code>model</code> names the model that ran.</p>
++${kit.snippet({ code: python, id: 'start-python' })}
++${kit.snippet({ code: node, id: 'start-node' })}
++${kit.snippet({ code: anthropic, id: 'start-anthropic' })}` })}
++${panel({ title: 'The six operations, for OpenVibe.AI apps', id: 'ai-apps', body: `<p class="ex-prose">OpenVibe.AI's own operations stay at <code>https://ai.openvibe.services</code> for developer apps: chat, generate, summarize, classify, extract and embed, with JSON output on a schema. They authenticate with an app token from OpenVibe.Network for the audience <code>openvibe.ai</code>.</p>
++${kit.snippet({ code: token, id: 'start-token' })}
++${kit.snippet({ code: chat, id: 'start-chat' })}
++<p class="ex-prose">The same router answers both: Exchange's endpoint and AI's six operations use one catalog, one set of offers and one explanation. <a href="https://openvibe.services/projects">Create a project and an app</a> · <a href="https://openvibe.services/docs/api">API reference</a> · <a href="https://github.com/OpenVibers/OpenVibe.AI#developer-apps">Developer guide</a>.</p>` })}
++${panel({ title: 'Sandbox and production', id: 'sandbox', body: `<p class="ex-prose">OpenVibe.AI developer apps run in one of two environments; the token says which one an app is in.</p>
++${kit.table({ columns: [{ key: 'limit', label: 'Limit', primary: true }, { key: 'sandbox', label: 'Sandbox' }, { key: 'production', label: 'Production' }], rows: [
++ { limit: 'Access to ai.app.run', sandbox: 'Every sandbox app, without asking', production: 'Once OpenVibe staff add it to the project' },
++ { limit: 'Models', sandbox: 'Free and local capacity only', production: 'Free and local capacity; metered models open with project budgets' },
++ { limit: 'Your project’s own provider', sandbox: 'On /v1/chat/completions, keyed by a project secret granted to ai', production: 'The same' },
++ { limit: 'Free allowance', sandbox: 'Per project, per provider', production: 'Per project, per provider' },
++ { limit: 'Who can read a run', sandbox: 'The project that made it', production: 'The project that made it' },
++ { limit: 'A person’s own provider key', sandbox: 'Never used', production: 'Never used' },
++ ], label: 'Sandbox and production', stack: true })}` })}
++${panel({ title: 'Keys, endpoints and questions', id: 'more', body: `<p class="ex-prose">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.${models.length ? ` ${models.length} model${models.length === 1 ? '' : 's'} are on sale here right now.` : ''}</p>
++<p>${kit.button({ href: '/api', label: 'Keys and the API' })} ${kit.button({ href: '/own', label: 'Your own endpoint', kind: 'quiet' })} ${kit.button({ href: 'https://ai.openvibe.services/you', label: 'Your own provider keys', kind: 'quiet' })} ${kit.button({ href: '/faq', label: 'Questions', kind: 'quiet' })}</p>` })}
++</div>`;
++}
++
++const EXPLAIN_WHY = [
++ 'The router starts from the offers that can do the job and are healthy right now.',
++ 'It ranks them by the objective you asked for: cheapest, fastest, balanced, or private.',
++ 'Your floor, price ceiling and privacy policy remove the offers that do not fit; a hard cap refuses instead of spending over it.',
++ 'A provider that is down, out of balance or too slow is skipped and written down; a fallback that answers is named on the run.',
++];
++
++/** One run's full explain, as AI recorded it: objective, reasons, the answer, and every candidate with its reason. */
++function explainPanel(result) {
++ const x = (result && result.explain) || {};
++ const candidates = Array.isArray(x.candidates) ? x.candidates : [];
++ const reasons = Array.isArray(x.reasons) ? x.reasons : [];
++ const selected = x.selected || (result && result.provider ? `${result.provider}${result.model ? `/${result.model}` : ''}` : null);
++ const rows = candidates.map((c) => ({
++ offer: `<code>${esc(c.provider || '—')}${c.model ? `/${esc(c.model)}` : ''}</code>`,
++ eligible: c.eligible === false ? kit.badge('not used', 'bad') : kit.badge('candidate', 'ok'),
++ cost: c.cost == null ? '—' : `$${Number(c.cost) < 0.01 ? Number(c.cost).toFixed(6) : Number(c.cost).toFixed(4)}`,
++ latency: c.latency == null ? '—' : ms(c.latency),
++ why: c.eligible === false ? esc(c.excluded_reason || 'excluded by the route') : '—',
++ }));
++ return `<article class="ex-why-answer">
++<header class="ex-answer-head"><b>${esc(result.model || selected || 'The answer')}</b><span class="ex-answer-cost">${result.cost_usd == null ? '—' : Number(result.cost_usd) === 0 ? 'free' : `$${Number(result.cost_usd).toFixed(6)}`}${result.latency_ms == null ? '' : ` · ${ms(result.latency_ms)}`}</span></header>
++${result.content ? `<div class="ex-answer-body"><p class="ex-answer-text">${esc(result.content)}</p></div>` : ''}
++<dl class="ex-why-facts">
++<dt>Chosen</dt><dd>${esc(selected || '—')}${result.provider ? ` · ${esc(providerLabel({ provider: result.provider, provider_name: result.provider }))}` : ''}</dd>
++<dt>Objective</dt><dd>${esc(x.objective || 'balanced')}</dd>
++<dt>Tokens</dt><dd>${count(result.tokens_in)} in · ${count(result.tokens_out)} out</dd>
++<dt>Privacy</dt><dd>${esc(result.privacy || '—')}</dd>
++</dl>
++${reasons.length ? `<h3 class="ex-h3">Why</h3><ul class="ex-list">${reasons.map((r) => `<li>${esc(r)}</li>`).join('')}</ul>` : ''}
++${rows.length ? `<h3 class="ex-h3">Every candidate</h3>${kit.table({ columns: [{ key: 'offer', label: 'Offer', primary: true, html: true }, { key: 'eligible', label: 'State', html: true }, { key: 'cost', label: 'Estimated cost' }, { key: 'latency', label: 'Estimated latency' }, { key: 'why', label: 'Why not', html: true }], rows, label: 'Every candidate the router considered', stack: true })}` : ''}
++<p class="ex-small ex-muted">${result.run_id ? `Run <code>${esc(result.run_id)}</code> · ` : ''}The full receipt is on OpenVibe.AI after you sign in with OpenVibe: <a href="https://ai.openvibe.services/runs/${esc(result.run_id || '')}">ai.openvibe.services/runs/${esc(result.run_id || '<id>')}</a>.</p>
++</article>`;
++}
++
++/**
++ * /why: the "why this model" explainer. Signed out, it explains the rule and shows the live offers. A signed-in
++ * person can ask one question; the answer is a real AI run, and the page prints the run's own explain. It never
++ * invents a reason: every line is from the run or from the catalog.
++ */
++function whyPage({ catalog = null, viewer = null, result = null, problem = null, text = '' }) {
++ const signedIn = viewer && viewer.kind === 'user' && viewer.subject;
++ const offers = (catalog && catalog.models ? chatModels(catalog) : []).flatMap((m) => (m.offers || []).map((o) => ({ m, o })));
++ const sample = offers.filter((x) => x.o.price).sort((a, b) => blended(a.o.price) - blended(b.o.price)).slice(0, 6);
++ const sampleRows = sample.map(({ m, o }) => ({
++ model: `<a href="${hrefOf(m)}">${esc(m.name)}</a>`,
++ offer: esc(providerLabel(o)),
++ price: `${money(o.price.in)} in / ${money(o.price.out)} out`,
++ speed: `${ms(o.speed.p50_ms)} p50 · ${ms(o.speed.p95_ms)} p95`,
++ uptime: o.uptime == null ? '—' : pct(o.uptime),
++ }));
++ const form = signedIn
++ ? `<form method="post" action="/why" class="ovk-form ex-why-form">
++<label class="ex-label" for="why-text">One question</label>
++<textarea id="why-text" name="text" rows="3" maxlength="2000" placeholder="Ask anything…">${esc(text)}</textarea>
++<p class="ex-small ex-muted">One run on your free allowance, or your Balance when it is spent. The router chooses the offer; the page shows its receipt.</p>
++${kit.button({ label: 'Ask and see why', type: 'submit' })}</form>`
++ : `<div class="ex-why-gate">${kit.empty({ icon: 'key', title: 'Sign in to ask one question', text: 'The demo is one real run on your own allowance. Signed out, the rule and the live offers are below.', action: { href: '/auth/login?next=%2Fwhy', label: 'Sign in with OpenVibe' } })}</div>`;
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Why this model', sub: 'Every answer carries its receipt: what the router optimised, who answered, and why the others did not.', actions: [kit.button({ href: '/models', label: 'Models', kind: 'quiet' })] })}
++${panel({ title: 'The rule', id: 'rule', body: `<ol class="ex-list">${EXPLAIN_WHY.map((x) => `<li>${esc(x)}</li>`).join('')}</ol>
++<p class="ex-prose">It is the same router behind every Chat answer, every model page and every call to the endpoint; the receipt on a run is the router's own explanation, not a copy written for the page.</p>` })}
++${problem ? kit.notice({ kind: 'warn', text: esc(problem) }) : ''}
++${result ? explainPanel(result) : ''}
++${panel({ title: result ? 'Ask another' : 'Try it', id: 'try', body: form })}
++${panel({ title: 'The live offers it is choosing between', id: 'offers', note: 'The cheapest priced offers in the catalog right now, with their measured speed and uptime.', body: sampleRows.length ? kit.table({ columns: [{ key: 'model', label: 'Model', primary: true, html: true }, { key: 'offer', label: 'Offer' }, { key: 'price', label: 'In / out per M' }, { key: 'speed', label: 'Speed' }, { key: 'uptime', label: 'Uptime' }], rows: sampleRows, label: 'Live offers', stack: true }) : kit.empty({ icon: 'spark', title: 'No priced offer right now', text: 'When an offer is live, it appears here with its price, speed and uptime.' }) })}
++${panel({ title: 'Read it in the API', id: 'api', body: `<p class="ex-prose">Every run response carries <code>explain</code>: the objective, the selected offer, the reasons, and every candidate with its estimated cost, latency and excluded reason. <code>GET /api/v1/runs/:id</code> on OpenVibe.AI returns the same object for a run you own.</p>
++${kit.snippet({ code: `curl -s https://ai.openvibe.services/api/v1/runs/$RUN \\\n -H "Authorization: Bearer $TOKEN" | jq .run.explain`, id: 'why-curl' })}
++<p class="ex-more">Keys and the endpoint: <a href="/api">the API</a> · Start building: <a href="/start">four steps</a>.</p>` })}
++</div>`;
++}
++
++// The questions developers ask, merged: OpenVibe.AI's own six (word for word where the fact is unchanged) and the
++// questions this Exchange gets. Every answer is a fact the code keeps; a change here changes the FAQPage JSON-LD too.
++const FAQ = [
++ { q: 'What is OpenVibe.Exchange for?', a: 'Every AI model, one account: the live price, speed and uptime of every offer, chat with one model or three side by side, and one OpenAI- and Anthropic-compatible endpoint. The public pages of OpenVibe.AI live here now; its API stays at ai.openvibe.services.' },
++ { q: 'Can I use the OpenAI SDK?', a: 'Yes. Set the client’s base URL to https://api.openvibe.exchange/v1 and pass your OpenVibe.Network developer key as the API key. Chat Completions with tools and streaming work, and each answer names the model that ran.' },
++ { q: 'Can I use Claude Code or Anthropic’s SDK?', a: 'Yes. Point ANTHROPIC_BASE_URL at https://api.openvibe.exchange and use the same key as ANTHROPIC_AUTH_TOKEN; the host speaks Anthropic’s Messages API at /v1/messages.' },
++ { q: 'Which models can my app use?', a: 'Every model on the Models page. openvibe/auto lets the router pick by your floor, ceiling and policy; an exact id pins one artifact; a preset name such as @preset/private fixes the rule. Every answer says which offer ran and why.' },
++ { q: 'How is AI paid for?', a: 'Each offer’s own price per million tokens, and every answer carries the exact cost of that run. A small free daily allowance comes with every account. Bring your own provider key and those requests pay the provider directly, at no fee from us; paid use, when the owner turns money on, is at cost plus a margin and shown before the request.' },
++ { q: 'Does it keep my prompts?', a: 'That depends on the offer. Each offer shows the provider’s data policy (training, retention, region) and a privacy label. Turn on strict privacy and only an offer that keeps nothing and trains on nothing may answer; when none can, the router refuses instead of falling back to one that keeps or trains on the prompt.' },
++ { q: 'Who can read a run?', a: 'Only the account that made it. A run’s receipt is on OpenVibe.AI at /runs/<id> after an OpenVibe sign-in, and only the person the run was made for can read it. Exchange keeps a chat and the 18+ confirmation; it does not store prompts or answers for the public pages.' },
++ { q: 'Where are my keys?', a: 'Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time at openvibe.network/developers. They are shown once and never stored here.' },
++ { q: 'Can I sell compute?', a: 'Yes, through OpenVibe.Node: offer a GPU or a spare machine to the network for credits. Machines and pools are what is sold; personal subscription plans and personal API keys are not. Every offer is labelled on the answer.' },
++ { q: 'What is the uncensored tier?', a: 'Abliterated models for adults. The 18+ confirmation sits on your account, and the same floor applies to every request on the tier: no minors, no non-consensual content, no help with mass harm. A breach ends the account.' },
++ { q: 'Is ai.openvibe.services still a site of its own?', a: 'No. Its public pages redirect here, and its page list, price page and update log are gone from that address. The API stays there unchanged for OpenVibe’s services and developer apps, and AI’s own run pages and operator console stay for the people who need their session.' },
++ { q: 'Where did AI’s prices and latency page go?', a: 'The live numbers are on Models and each model’s page: price in, out and cached per million tokens, measured p50 and p95 speed, uptime and the free allowance. The status page keeps the per-offer uptime and the model changelog.' },
++];
++
++function faqPage() {
++ return `<div class="ex-wrap ex-page">
++${kit.head({ title: 'Questions', sub: 'The answers developers and people ask, with where the fact lives.', actions: [kit.button({ href: '/start', label: 'Start building', kind: 'quiet' })] })}
++${panel({ title: 'Questions developers ask', id: 'faq', body: `<div class="ex-faq">${FAQ.map((p) => kit.fold({ title: p.q, body: `<p class="ex-prose">${esc(p.a)}</p>` })).join('')}</div>` })}
++${panel({ title: 'Still building', id: 'next', body: `<p>${kit.button({ href: '/start', label: 'Start building' })} ${kit.button({ href: '/api', label: 'Keys and the API', kind: 'quiet' })} ${kit.button({ href: 'https://openvibe.help/tickets/new', label: 'Get help', kind: 'quiet' })}</p>` })}
++</div>`;
++}
++
++module.exports = { audienceDefaults, home, modelsPage, modelPage, apiPage, startPage, whyPage, faqPage, FAQ, sellPage, uncensoredPage, aiPage, ownPage, statusPage, retired, sealedChat, privacyOf, answerPrivacy, providerLabel, familyLabel, chatModels, ENDPOINT };
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
iindex fa2d877..ddba160 100644
---- a/test/helpers/ai.js
++++ b/test/helpers/ai.js
@@@ -78,7 +78,20 @@ function createFakeAi({ chat = null, community = [] } = {}) {
async chat(args) {
calls.push(args);
if (chat) return chat(args);
-- return { content: `An answer from ${args.model}.`, model: args.model, provider: 'deepseek', run_id: 'run_TEST', cost_usd: 0.00012, usage: { tokens_in: 10, tokens_out: 20 }, tokens_in: 10, tokens_out: 20, latency_ms: 420, why: ['cheapest healthy offer within your ceiling'] };
++ return {
++ content: `An answer from ${args.model}.`, model: args.model, provider: 'deepseek', run_id: 'run_TEST', cost_usd: 0.00012,
++ usage: { tokens_in: 10, tokens_out: 20 }, tokens_in: 10, tokens_out: 20, latency_ms: 420,
++ why: ['cheapest healthy offer within your ceiling'],
++ explain: {
++ objective: 'balanced',
++ reasons: ['cheapest healthy offer within your ceiling', 'the provider answered and the latency is measured'],
++ selected: 'deepseek/deepseek-chat',
++ candidates: [
++ { provider: 'deepseek', model: 'deepseek-chat', eligible: true, excluded_reason: null, cost: 0.00012, latency: 420 },
++ { provider: 'deepinfra', model: 'deepseek-chat', eligible: false, excluded_reason: 'over your price ceiling', cost: 0.0003, latency: 700 },
++ ],
++ },
++ };
},
catalogData: catalog,
usageData: usage,
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex a132f94..13866f3 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
// The M2 memory client goes out with this fetch. A test that does not stand one up gets an empty store, so
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl, openapiFetch: opts.openapiFetch, fetchImpl: opts.fetchImpl });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/start-why-faq.test.js b/test/start-why-faq.test.js
nnew file mode 100644
iindex 0000000..b9be56e
---- /dev/null
++++ b/test/start-why-faq.test.js
@@@ -0,0 +1,129 @@
++'use strict';
++/**
++ * The pages that moved from ai.openvibe.services: /start (the guide), /why (the router's own explain, on a real
++ * run) and /faq (the merged questions), plus their Markdown twins, sitemap/llms entries and IndexNow's key file.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++
++(async () => {
++ const ai = createFakeAi();
++ const t = await boot({ ai });
++ try {
++ await check('/start: the endpoint, the four steps, both SDKs, the six AI operations and the sandbox table', async () => {
++ const r = await t.get('/start');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('https://api.openvibe.exchange/v1/chat/completions'), 'the endpoint');
++ assert.ok(r.text.includes('/v1/messages') && r.text.includes('ANTHROPIC_BASE_URL'), 'Anthropic compatibility');
++ assert.ok(r.text.includes('Four steps') && r.text.includes('Make a key') && r.text.includes('Read the receipt'));
++ assert.ok(r.text.includes('import OpenAI from') && r.text.includes('openai') && r.text.includes('client.chat.completions.create'), 'the SDK samples');
++ assert.ok(r.text.includes('chat, generate, summarize, classify, extract and embed'), 'AI’s six operations stay described');
++ assert.ok(r.text.includes('Sandbox') && r.text.includes('Production') && r.text.includes('Never used'), 'the sandbox and production limits');
++ assert.ok(r.text.includes('openvibe.network/developers') && r.text.includes('/own') && r.text.includes('/faq'), 'keys, own endpoints and the questions');
++ assert.ok(r.text.includes('https://ai.openvibe.services/you'), 'own provider keys stay on AI, with the person session');
++ });
++
++ await check('/why signed out: the rule, the live offers and a sign-in gate, never a fake answer', async () => {
++ const r = await t.get('/why');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Why this model') && r.text.includes('The rule'));
++ assert.ok(r.text.includes('Sign in to ask one question') && r.text.includes('/auth/login?next=%2Fwhy'));
++ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('$0.20 in'), 'the live offers');
++ assert.ok(r.text.includes('every candidate with its estimated cost'), 'what explain carries');
++ assert.ok(!r.text.includes('An answer from'), 'no invented run');
++ });
++
++ await check('POST /why signed out answers the sign-in page, and does not call AI', async () => {
++ const before = ai.calls.length;
++ const r = await t.get('/why', { form: { text: 'Why you?' } });
++ assert.strictEqual(r.status, 401);
++ assert.ok(r.text.includes('Sign in with OpenVibe'));
++ assert.strictEqual(ai.calls.length, before, 'no run was made');
++ });
++
++ await check('POST /why signed in: one auto run, the answer, the chosen offer and every candidate with its reason', async () => {
++ const user = t.network.addUser('why-user');
++ const r = await t.get('/why', { as: user, form: { text: 'Which model should answer this?' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('An answer from openvibe/auto.'), 'the answer');
++ assert.ok(r.text.includes('Chosen') && r.text.includes('deepseek/deepseek-chat'), 'the selected offer');
++ assert.ok(r.text.includes('cheapest healthy offer within your ceiling'), 'the router’s reason');
++ assert.ok(r.text.includes('deepinfra') && r.text.includes('over your price ceiling'), 'an excluded candidate and why');
++ assert.ok(r.text.includes('run_TEST') && r.text.includes('https://ai.openvibe.services/runs/run_TEST'), 'the run receipt stays on AI, with the session');
++ const call = ai.calls[ai.calls.length - 1];
++ assert.strictEqual(call.model, 'openvibe/auto');
++ assert.strictEqual(call.messages[0].content, 'Which model should answer this?');
++ assert.ok(call.subject, 'the run is made on the person, so it is their allowance');
++ });
++
++ await check('POST /why with no question: 422, and no run', async () => {
++ const user = t.network.addUser('why-empty');
++ const before = ai.calls.length;
++ const r = await t.get('/why', { as: user, form: { text: ' ' } });
++ assert.strictEqual(r.status, 422);
++ assert.ok(r.text.includes('Write something to send.'));
++ assert.strictEqual(ai.calls.length, before);
++ });
++
++ await check('/faq: the merged questions and their FAQPage JSON-LD', async () => {
++ const r = await t.get('/faq');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('What is OpenVibe.Exchange for?'));
++ assert.ok(r.text.includes('Can I use the OpenAI SDK?'));
++ assert.ok(r.text.includes('Is ai.openvibe.services still a site of its own?'), 'the move is answered');
++ assert.ok(r.text.includes('Where did AI’s prices and latency page go?'));
++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const faq = blocks.find((b) => b['@type'] === 'FAQPage');
++ assert.ok(faq && faq.mainEntity.length >= 10, 'FAQPage JSON-LD');
++ assert.ok(r.text.includes('The public pages of OpenVibe.AI live here now'), 'the moved answer is on the page');
++ });
++
++ await check('the Markdown twins of the new pages', async () => {
++ for (const [path, needle] of [['/start.md', 'Start building'], ['/why.md', 'Why this model'], ['/faq.md', 'What is OpenVibe.Exchange for?']]) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 200, `${path}: ${r.status}`);
++ assert.match(r.headers.get('content-type'), /text\/markdown/);
++ assert.ok(r.text.startsWith('---\ntitle:'), path);
++ assert.ok(r.text.includes(needle), `${path} has ${needle}`);
++ }
++ });
++
++ await check('sitemap and llms.txt carry the new pages', async () => {
++ const map = await t.get('/sitemap.xml');
++ assert.strictEqual(map.status, 200);
++ for (const p of ['/start', '/why', '/faq']) assert.ok(map.text.includes(`https://openvibe.exchange${p}</loc>`), `sitemap ${p}`);
++ const llms = await t.get('/llms.txt');
++ assert.ok(llms.text.includes('/start.md') && llms.text.includes('/why.md') && llms.text.includes('/faq.md'), 'llms.txt links the twins');
++ const full = await t.get('/llms-full.txt');
++ for (const p of ['/start', '/why', '/faq']) assert.ok(full.text.includes(`https://openvibe.exchange${p}`), `llms-full ${p}`);
++ });
++ } finally {
++ await t.close();
++ }
++
++ const KEY = 'abc12345';
++ const on = await boot({ ai: createFakeAi(), env: { INDEXNOW_KEY: KEY } });
++ try {
++ await check('IndexNow: the key file is served when INDEXNOW_KEY is set', async () => {
++ assert.strictEqual(on.ctx.indexnow.enabled, true);
++ const r = await on.get(`/${KEY}.txt`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.text.trim(), KEY);
++ });
++ } finally {
++ await on.close();
++ }
++
++ const off = await boot({ ai: createFakeAi(), env: { INDEXNOW_KEY: '' } });
++ try {
++ await check('IndexNow: off without a key', async () => {
++ assert.strictEqual(off.ctx.indexnow.enabled, false);
++ const r = await off.get(`/${KEY}.txt`);
++ assert.strictEqual(r.status, 404);
++ });
++ } finally {
++ await off.close();
++ done();
++ }
++})();