openvibers/openvibe.ai Commits

The ai.openvibe.services home shows the models that answered this week with their prices and latency, the week in numbers, OpenAI SDK samples and a FAQ; apps and the API reference are named as OpenVibe.Services (#135)

OP OpenVibers committed 22 days ago · 298d9d409c · 3 files, +179 −29

Changes

ddiff --git a/server/app.js b/server/app.js
iindex afb21d8..1844f2c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -99,20 +99,31 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// nothing per caller. Only registered providers are listed: a person's own key records its runs as
// byo:<owner>:<subject>, which must never reach a public page.
const STATS_DAYS = 7;
++ async function statsRows() {
++ const rows = [];
++ for (const s of await quotas.statsFor({ days: STATS_DAYS })) {
++ const p = await registry.getProvider(s.provider);
++ if (!p) continue;
++ const m = s.model ? await registry.getModel(s.provider, s.model) : null;
++ rows.push({
++ provider: s.provider, model: s.model, capability: (m && m.type) || p.capabilities.join(', '),
++ requests: Number(s.requests) || 0, ok: Number(s.ok) || 0, p50: s.latency_p50_ms, p95: s.latency_p95_ms,
++ cards: await pool.rateCardsFor(s.provider, s.model || null),
++ });
++ }
++ return rows;
++ }
++ // The home's numbers and model table are the same rows, read at most once a minute (the home is cached for five).
++ let homeStats = { at: 0, rows: null };
++ async function homeRows() {
++ if (homeStats.rows && clock.now() - homeStats.at < 60_000) return homeStats.rows;
++ try { homeStats = { at: clock.now(), rows: await statsRows() }; } catch (e) { log.warn(`[home] price and latency rows unavailable: ${e.message}`); }
++ return homeStats.rows || [];
++ }
app.get('/stats', async (req, res, next) => {
try {
const now = clock.now();
-- const rows = [];
-- for (const s of await quotas.statsFor({ days: STATS_DAYS })) {
-- const p = await registry.getProvider(s.provider);
-- if (!p) continue;
-- const m = s.model ? await registry.getModel(s.provider, s.model) : null;
-- rows.push({
-- provider: s.provider, model: s.model, capability: (m && m.type) || p.capabilities.join(', '),
-- requests: Number(s.requests) || 0, ok: Number(s.ok) || 0, p50: s.latency_p50_ms, p95: s.latency_p95_ms,
-- cards: await pool.rateCardsFor(s.provider, s.model || null),
-- });
-- }
++ const rows = await statsRows();
res.setHeader('Cache-Control', cachePolicy.htmlHeaders());
res.setHeader('Content-Security-Policy', views.CSP);
res.type('html').send(views.pages.stats({
@@@ -150,13 +161,14 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
.send(home.renderUpdates({ siteUrl: config.baseUrl }));
});
-- app.get('/', (req, res) => {
++ app.get('/', async (req, res) => {
res.vary('Accept');
if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
++ const rows = await homeRows();
return res.type('html')
.set('Content-Security-Policy', home.HOME_CSP)
.set('Cache-Control', 'private, max-age=300')
-- .send(home.renderHome({ siteUrl: config.baseUrl }));
++ .send(home.renderHome({ siteUrl: config.baseUrl, stats: { days: STATS_DAYS, rows } }));
}
res.type('text/plain').send([
'OpenVibe.AI: providers, models, routing, versioned prompt templates and workflows, runs, citations, scoped cache, quotas and audit.',
ddiff --git a/server/home.js b/server/home.js
iindex 9dac594..a9ee653 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -3,10 +3,10 @@
/**
* The product home of OpenVibe.AI (ai.openvibe.services, plan T6: AI as a developer product). The OpenVibe Frame
* (openvibe-shared/shell) around openvibe-shared/showcase sections for developers. Every claim restates the README
-- * (API, Developer apps, Providers, Local model) and the code; nothing here reads data. Prices and measured latency
-- * are the /stats page, which reads them.
++ * (API, Developer apps, OpenAI-compatible endpoint, Providers, Local model) and the code. The numbers and the model
++ * table are the /stats rows (provider_stats_daily and the rate cards, nothing per caller), passed in by the route.
*
-- * renderHome({ siteUrl }) the whole document (siteUrl: the public origin, config.baseUrl)
++ * renderHome({ siteUrl, stats }) the whole document (siteUrl: the public origin, config.baseUrl; stats: { days, rows })
* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
* llmsTxt({ siteUrl }) /llms.txt, the same facts as plain text
*/
@@@ -15,6 +15,7 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
const appIcon = require('openvibe-shared/app-icon');
++const seo = require('openvibe-shared/seo');
const SITE_NAME = 'OpenVibe.AI';
const NETWORK_URL = 'https://openvibe.network';
@@@ -22,7 +23,9 @@ const PROJECTS_URL = 'https://openvibe.services/projects';
const API_DOCS_URL = 'https://openvibe.services/docs/api';
const SOURCE = 'https://github.com/OpenVibers/OpenVibe.AI';
const GUIDE = `${SOURCE}#developer-apps`;
--const DESCRIPTION = 'One API for chat, generation, summaries, classification, extraction and embeddings across many models. '
++const DESCRIPTION = 'One API for chat, summaries, extraction and embeddings across many models, OpenAI-compatible. Every run says which model answered, why and what it cost.';
++// The longer line for AI readers (the page summary): the same facts, with the sandbox.
++const SUMMARY = 'One API for chat, generation, summaries, classification, extraction and embeddings across many models. '
+ 'Every run says which model answered, why it was chosen and what it cost, and sandbox apps run on free and local capacity.';
const HOME_CSP = [
@@@ -41,7 +44,51 @@ const HOME_CSP = [
"form-action 'self' https://openvibe.network",
].join('; ');
--function sections({ siteUrl }) {
++const n = (v) => Number(v || 0).toLocaleString('en-US');
++const usd = (v) => { const x = Number(v); return `$${x >= 0.01 || x === 0 ? x.toFixed(2) : x.toPrecision(2)}`; };
++const esc = showcase.esc;
++
++/** The week in numbers, from the /stats rows: runs answered, models that answered, the share of calls that succeeded. */
++function numbers(stats) {
++ const rows = (stats && stats.rows) || [];
++ const ok = rows.reduce((a, r) => a + (Number(r.ok) || 0), 0);
++ const calls = rows.reduce((a, r) => a + (Number(r.requests) || 0), 0);
++ if (!ok) return '';
++ const models = rows.filter((r) => Number(r.ok) > 0).length;
++ const pct = (100 * ok) / calls;
++ const items = [
++ [n(ok), `calls answered in the last ${stats.days} days`],
++ [n(models), models === 1 ? 'model answered them' : 'models answered them'],
++ [`${pct >= 99.95 ? '100' : pct.toFixed(1)}%`, 'of provider calls succeeded'],
++ ];
++ return `<section class="sc-sec ai-num" aria-label="The last ${esc(stats.days)} days in numbers"><ul>${items.map(([v, label]) => `<li><a href="/stats"><b>${esc(v)}</b><span>${esc(label)}</span></a></li>`).join('')}</ul></section>`;
++}
++
++/** Every model that answered in the window, most used first: what it does, typical latency and its price card. */
++function models(stats) {
++ const rows = ((stats && stats.rows) || []).filter((r) => Number(r.ok) > 0).sort((a, b) => b.ok - a.ok);
++ if (!rows.length) return '';
++ const price = (cards) => (cards && cards.in && cards.out ? `${usd(cards.in.unit_price_usd)} in · ${usd(cards.out.unit_price_usd)} out per ${cards.in.unit_size === 1e6 ? '1M' : n(cards.in.unit_size)} tokens` : 'not billed');
++ const caps = (c) => { const list = String(c || '').split(/,\s*/).filter(Boolean); return list.length > 5 ? `${list.slice(0, 5).join(', ')} and ${list.length - 5} more` : list.join(', '); };
++ return `<section class="sc-sec" id="models" aria-labelledby="h-models"><h2 id="h-models">Models that answered this week</h2>
++<p class="sc-lede">Every model that answered a call in the last ${esc(stats.days)} days, for OpenVibe's own sites and for apps, with what it does, how fast it usually answered and what it costs. Apps on the router get free and local capacity today (a small local model, without tools); name your project's own provider for anything bigger.</p>
++<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="h-models"><table class="sc-table"><thead><tr><th scope="col">Model</th><th scope="col">Provider</th><th scope="col">Does</th><th scope="col">Answered</th><th scope="col">Typical latency</th><th scope="col">Price</th></tr></thead><tbody>
++${rows.map((r) => `<tr><th scope="row"><code>${esc(r.model || '—')}</code></th><td><code>${esc(r.provider)}</code></td><td>${esc(caps(r.capability))}</td><td>${esc(n(r.ok))}</td><td>${r.p50 == null ? '—' : `under ${esc(r.p50 >= 1000 ? `${(r.p50 / 1000).toLocaleString('en-US', { maximumFractionDigits: 1 })} s` : `${n(r.p50)} ms`)}`}</td><td>${esc(price(r.cards))}</td></tr>`).join('\n')}
++</tbody></table></div><p class="sc-note">Latency is the upper bound of a fixed bucket on the slowest day. Every number here, with p95, success rates and free allowances, is on <a href="/stats">Prices and latency</a>.</p></section>`;
++}
++
++// The questions developers ask, answered from the README; the FAQPage JSON-LD repeats them word for word.
++const FAQ = [
++ { q: 'What is OpenVibe.AI for?', a: 'One API for chat, generation, summaries, classification, extraction and embeddings across model providers, used by every OpenVibe site and open to developers.' },
++ { q: 'Can I use the OpenAI SDK?', a: 'Yes. Set the client\'s base URL to https://ai.openvibe.services/v1 and pass your app\'s OpenVibe token as the API key. Chat Completions with tools and streaming work, and each answer names the model that ran.' },
++ { q: 'Which models can my app use?', a: 'Through the router, free and local capacity: today a small local model without tools. For anything bigger, name your project\'s own OpenAI-format provider; its key stays a project secret, revealed for one run and never stored.' },
++ { q: 'How is AI paid for?', a: 'At cost: each project has quotas and budgets, and every run records its model, usage and cost. Sandbox apps use only free and local capacity, so trying it costs nothing.' },
++ { q: 'Does it keep my prompts?', a: 'Not on /v1/chat/completions: each request is a run whose usage, cost and explanation are kept, and the prompt is not.' },
++ { q: 'Who can read a run?', a: 'Only the project that made it. Runs, usage and the free allowance are per project, and a person\'s own provider key is never used for an app.' },
++];
++const faq = () => `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions developers ask</h2><div class="ai-faq">${FAQ.map((p) => `<div><h3>${esc(p.q)}</h3><p>${esc(p.a)}</p></div>`).join('')}</div></section>`;
++
++function sections({ siteUrl, stats }) {
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 \\',
@@@ -59,6 +106,34 @@ function sections({ siteUrl }) {
'# objective, the provider and model selected, and every candidate with its',
'# estimated cost, latency and the reason it was passed over',
].join('\n');
++ const python = [
++ '# pip install openai requests',
++ 'import os, requests',
++ 'from openai import OpenAI',
++ '',
++ 'token = requests.post("https://openvibe.network/oauth/token", data={',
++ ' "grant_type": "client_credentials", "audience": "openvibe.ai",',
++ ' "client_id": os.environ["OV_CLIENT_ID"], "client_secret": os.environ["OV_CLIENT_SECRET"],',
++ '}).json()["access_token"] # lasts five minutes',
++ '',
++ `client = OpenAI(base_url="${siteUrl}/v1", api_key=token)`,
++ 'reply = client.chat.completions.create(',
++ ' model="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: '${siteUrl}/v1', apiKey: token }); // token: as in the Python sample`,
++ 'const stream = await client.chat.completions.create({',
++ " model: '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');
return showcase.hero({
eyebrow: 'OpenVibe.AI · alpha',
title: 'Many models, one API.', accent: 'Every answer explained.',
@@@ -67,7 +142,7 @@ function sections({ siteUrl }) {
+ 'why it was chosen and what it cost.',
actions: [{ label: 'Start building', href: PROJECTS_URL, primary: true }, { label: 'See prices and latency', href: '/stats' }],
note: 'Alpha. OpenVibe.Live and OpenVibe.Network already run their AI here in production; developer apps start in the sandbox, on free and local capacity.',
-- }) + showcase.features({
++ }) + numbers(stats) + showcase.features({
title: 'What you get',
items: [
{ icon: 'ov:text', title: 'Six operations, and OpenAI\'s API', text: 'Chat, generate, summarize, classify, extract and embed, with JSON output on a schema. Any OpenAI client works too: point it at /v1 for Chat Completions with tools.' },
@@@ -77,10 +152,18 @@ function sections({ siteUrl }) {
{ icon: 'ov:account', title: 'Your project\'s runs', text: 'Runs belong to your project: another project can never read them. Usage is metered per project, with a free allowance first.' },
{ icon: 'ov:code', title: 'Open source', text: 'AGPL-3.0, built in the open. Every guarantee on this page has a test in the repository.' },
],
++ }) + models(stats) + showcase.code({
++ id: 'openai',
++ title: 'Use the OpenAI SDK you already have',
++ lede: `Point its base URL at ${siteUrl}/v1 and use your app's OpenVibe token as the key. Streaming and tools work; the answer's model names the model that ran.`,
++ samples: [
++ { label: 'Python', lang: 'python', code: python },
++ { label: 'Node, streaming', lang: 'js', code: node },
++ ],
}) + showcase.code({
id: 'examples',
title: 'Token, chat, explain',
-- lede: 'With an app from OpenVibe.Codes: get a token from OpenVibe.Network, call an operation, then read why that model answered.',
++ lede: 'With an app from OpenVibe.Services: get a token from OpenVibe.Network, call an operation, then read why that model answered.',
samples: [
{ label: 'Token', lang: 'bash', code: token },
{ label: 'Chat', lang: 'bash', code: chat },
@@@ -89,7 +172,7 @@ function sections({ siteUrl }) {
}) + showcase.steps({
title: 'Start in four steps',
items: [
-- { title: 'Create a project', text: 'Sign in at openvibe.services/projects with your OpenVibe account and create a project.' },
++ { title: 'Create a project', text: 'Sign in at openvibe.services/projects with your OpenVibe account and create a project.', href: PROJECTS_URL },
{ title: 'Add an app', text: 'Add an app to it. A confidential app gets a client id and a secret, shown once.' },
{ title: 'Get a token', text: 'Exchange them at OpenVibe.Network for a five-minute token with the audience openvibe.ai.' },
{ title: 'Call an operation', text: 'POST to /api/v1/chat or any of the other five, and keep the run id to read it later.' },
@@@ -106,16 +189,28 @@ function sections({ siteUrl }) {
{ label: 'Who can read a run', values: ['The project that made it', 'The project that made it'] },
{ label: 'A person\'s own provider key', values: ['Never used', 'Never used'] },
],
-- }) + showcase.nextSteps({
++ }) + faq() + showcase.nextSteps({
items: [
{ what: 'Prices and latency', text: 'Measured per provider and model over the last seven days.', href: '/stats' },
-- { what: 'Create a project and an app', text: 'Sign in with your OpenVibe account; the sandbox is open to every project.', href: PROJECTS_URL, to: 'codes' },
-- { what: 'API reference', text: 'The OpenAPI document generated from the published contracts.', href: API_DOCS_URL, to: 'codes' },
++ { what: 'Create a project and an app', text: 'Sign in with your OpenVibe account; the sandbox is open to every project.', href: PROJECTS_URL, to: 'services' },
++ { what: 'API reference', text: 'The OpenAPI document generated from the published contracts.', href: API_DOCS_URL, to: 'services' },
{ what: 'Developer guide', text: 'Tokens, limits, billing and the sandbox, in full.', href: GUIDE },
],
});
}
++// What the kit does not draw: the numbers row, the FAQ cards, and code samples one under another (their lines are long).
++const HOME_STYLE = `<style>
++.ai-num{margin-top:28px}.ai-num ul{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(190px,100%),1fr))}
++.ai-num a{display:flex;flex-direction:column;gap:2px;height:100%;padding:14px 16px;border-radius:14px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828);color:inherit;text-decoration:none}
++.ai-num a:hover,.ai-num a:focus-visible{border-color:var(--accent,#3b82f6)}.ai-num b{font-size:26px;font-weight:800;letter-spacing:-.5px;line-height:1.15}.ai-num span{font-size:13px;line-height:1.35;color:var(--text-secondary,#96a7c2)}
++#openai .sc-code,#examples .sc-code{grid-template-columns:minmax(0,1fr)}
++#models table{min-width:860px}#models td:nth-child(3){min-width:190px}#models td,#models th{vertical-align:top}#models th code{white-space:nowrap}#models td:nth-child(4),#models td:nth-child(5){white-space:nowrap}
++.ai-faq{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr))}
++.ai-faq>div{padding:16px 18px;border-radius:16px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
++.ai-faq h3{margin:0 0 6px;font-size:15.5px;line-height:1.35}.ai-faq p{margin:0;font-size:14px;line-height:1.55;color:var(--text-secondary,#96a7c2)}
++</style>`;
++
const NAV_LINKS = [{ label: 'Prices', href: '/stats' }, { label: 'Guide', href: GUIDE }];
const FOOTER = { service: 'ai', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
@@@ -139,26 +234,33 @@ ${frame.updatesBody({ service: 'ai', siteName: SITE_NAME })}
});
}
--function renderHome({ siteUrl }) {
++function renderHome({ siteUrl, stats = null }) {
const navLinks = NAV_LINKS;
const nav = { service: 'ai', apiBase: NETWORK_URL, links: navLinks, history: { type: 'page', title: SITE_NAME } };
const footer = FOOTER;
++ const site = String(siteUrl).replace(/\/+$/, '');
return shell.page({
name: SITE_NAME, service: 'ai', lang: 'en',
title: `${SITE_NAME}: many models, one API, every answer explained`,
siteName: SITE_NAME,
description: DESCRIPTION,
-- summary: DESCRIPTION,
++ summary: SUMMARY,
canonical: `${siteUrl}/`,
robots: 'index, follow',
++ jsonLd: [
++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai api, llm router, openai compatible api, model routing, ai gateway, open source' }),
++ seo.jsonLd.faq(FAQ),
++ ],
navbar: nav, footer, home: '/', navLinks,
head: [
appIcon.headTags({ site: 'ai' }),
`<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
++ HOME_STYLE,
].join('\n'),
body: `<div id="navbar-mount"></div>
<main id="main" class="page">
--${sections({ siteUrl })}
++${sections({ siteUrl, stats })}
</main>`,
});
}
@@@ -196,4 +298,4 @@ function renderNotFound() {
'<h1>Not found</h1>', `<p>Nothing is here. <a href="/">${SITE_NAME}</a> · <a href="/stats">Prices and latency</a> · <a href="${NETWORK_URL}">OpenVibe</a></p>`, '</body>', '</html>'].join('\n');
}
--module.exports = { renderHome, renderUpdates, llmsTxt, HOME_CSP, SITE_NAME, renderNotFound };
++module.exports = { renderHome, renderUpdates, llmsTxt, HOME_CSP, SITE_NAME, renderNotFound, FAQ, DESCRIPTION };
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 9ac4d07..45e5288 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -18,7 +18,7 @@ function assertSitemapLastmods(xml, tag = 'url') {
const fs = require('fs');
const path = require('path');
const { boot, request, suite } = require('./helpers');
--const { HOME_CSP } = require('../server/home');
++const { HOME_CSP, renderHome, FAQ, DESCRIPTION } = require('../server/home');
const SITE = 'https://ai.openvibe.services';
const BROWSER = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
@@@ -47,6 +47,42 @@ t.test('GET / as a browser: the home page, its CSP and a private cache', async (
assert.ok(!/ai\.openvibe\.network/.test(r.text), 'never the old name');
});
++t.test('the home: numbers and the model table come from the /stats rows; nothing is shown without them', async () => {
++ const card = (i, o) => ({ in: { unit_price_usd: i, unit_size: 1e6 }, cached: { unit_price_usd: i / 10, unit_size: 1e6 }, out: { unit_price_usd: o, unit_size: 1e6 } });
++ const rows = [
++ { provider: 'local', model: 'qwen2.5-1.5b-instruct', capability: 'chat, generate', requests: 10, ok: 10, p50: 25600, p95: 25600, cards: null },
++ { provider: 'shared', model: 'gpt-5-nano', capability: 'chat, generate, summarize, classify, extract, enrich, json', requests: 1003, ok: 1000, p50: 3200, p95: 25600, cards: card(0.05, 0.4) },
++ { provider: 'shared', model: 'never-answered', capability: 'chat', requests: 3, ok: 0, p50: 25600, p95: 25600, cards: card(0.25, 2) },
++ ];
++ const html = renderHome({ siteUrl: SITE, stats: { days: 7, rows } });
++ assert.match(html, /<b>1,010<\/b><span>calls answered in the last 7 days<\/span>/);
++ assert.match(html, /<b>2<\/b><span>models answered them<\/span>/);
++ assert.match(html, /<b>99\.4%<\/b><span>of provider calls succeeded<\/span>/);
++ assert.ok(html.indexOf('gpt-5-nano') < html.indexOf('qwen2.5-1.5b-instruct'), 'most used first');
++ assert.ok(!html.includes('never-answered'), 'a model with no answer is left out');
++ assert.ok(html.includes('$0.05 in · $0.40 out per 1M tokens') && html.includes('not billed'), 'prices from the rate cards');
++ assert.ok(html.includes('under 3.2 s'), 'latency as the bucket bound');
++ assert.ok(html.includes('chat, generate, summarize, classify, extract and 2 more'), 'long capability lists are cut');
++ assert.ok(html.includes('a small local model, without tools'), 'what an app gets on the router, as the README says');
++ const empty = renderHome({ siteUrl: SITE, stats: { days: 7, rows: [] } });
++ assert.ok(!/calls answered|id="models"/.test(empty), 'no rows: no numbers and no table');
++});
++
++t.test('the home: OpenAI SDK samples, the FAQ and its JSON-LD, a description of at most 160 characters', async () => {
++ const html = renderHome({ siteUrl: SITE, stats: { days: 7, rows: [] } });
++ assert.ok(html.includes(`base_url=&quot;${SITE}/v1&quot;`) || html.includes(`base_url="${SITE}/v1"`), 'the Python sample points at /v1');
++ assert.ok(html.includes(`baseURL: &#39;${SITE}/v1&#39;`) || html.includes(`baseURL: '${SITE}/v1'`), 'the Node sample points at /v1');
++ assert.ok(DESCRIPTION.length <= 160, `description ${DESCRIPTION.length} chars`);
++ assert.ok(html.includes(`<meta name="description" content="${DESCRIPTION}">`), 'the whole description, not clipped');
++ const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const types = blocks.map((b) => b['@type']);
++ for (const t of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(types.includes(t), `JSON-LD ${t}: ${types}`);
++ const faq = blocks.find((b) => b['@type'] === 'FAQPage');
++ assert.strictEqual(faq.mainEntity.length, FAQ.length);
++ faq.mainEntity.forEach((q) => assert.ok(html.includes(`<h3>${q.name.replace(/'/g, '&#39;')}</h3>`), `the page shows "${q.name}"`));
++ assert.ok(!/OpenVibe\.Codes/.test(html), 'apps and the API reference are on OpenVibe.Services, never named as Codes');
++});
++
t.test('GET / for curl and API clients: the text index, unchanged in kind', async () => {
for (const accept of [undefined, '*/*', 'application/json', 'text/plain']) {
const r = await request(h.base, 'GET', '/', { headers: accept ? { Accept: accept } : {} });