openvibers/openvibe.ai Commits
AI: serve llms-full.txt with every public page and the API (SEO-33) (#193)
Changes
@@@ -10,7 +10,7 @@## The Node service (port 4700, systemd unit openvibe-ai) serves:## / the product home for a browser (server/home.js), the text API index for anything else## /stats public price and latency per provider and model (server-rendered, no sign-in)--## /shared/* the home's OpenVibe Frame files (content-addressed); /robots.txt, /sitemap.xml, /llms.txt++## /shared/* the home's OpenVibe Frame files (content-addressed); /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt## /api/health, /api/ready## /api/v1/{chat,generate,summarize,classify,extract,embed} (POST) and /api/v1/runs/:id (GET)## developer apps (ai.app.run, app tokens from OpenVibe.Network): open here, rate-limited@@@ -154,6 +154,7 @@ server {location = /stats { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }location = /sitemap.xml { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }location = /llms.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }++ location = /llms-full.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }location = /release.json { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }# Open tabs report how their update went (openvibe-shared release-watch → POST /release-metrics, counted in /metrics).# The catch-all below passes GET and HEAD only, so every visitor's report was refused with a 403 (2026-10-24).@@@ -163,7 +163,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysconst from = new Date(clock.now() - STATS_DAYS * 86400000).toISOString().slice(0, 10);const stats = await db.prepare('SELECT MAX(day) AS lastmod FROM provider_stats_daily WHERE day >= ?').get(from);const statsLastmod = [stats && stats.lastmod ? new Date(stats.lastmod).toISOString() : null, releaseLastmod].filter(Boolean).sort().pop();-- const entries = [['/', releaseLastmod], ['/stats', statsLastmod], ['/updates', releaseLastmod]];++ // The paths come from home.PUBLIC_PAGES, the same catalogue /llms-full.txt describes.++ const lastmods = { '/': releaseLastmod, '/stats': statsLastmod, '/updates': releaseLastmod };++ const entries = home.PUBLIC_PAGES.map((p) => [p.path, lastmods[p.path]]);res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map(([p, lastmod]) => ` <url><loc>${config.baseUrl}${p}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ''}</url>`).join('\n')}\n</urlset>\n`);} catch (e) { next(e); }});@@@ -171,6 +173,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysres.setHeader('Cache-Control', 'public, max-age=3600');res.type('text/plain').send(home.llmsTxt({ siteUrl: config.baseUrl }));});++ // The full AI map: the same page catalogue as the sitemap plus the developer API, in plain text.++ app.get('/llms-full.txt', (_req, res) => {++ res.setHeader('Cache-Control', 'public, max-age=3600');++ res.type('text/plain').send(home.llmsFullTxt({ siteUrl: config.baseUrl }));++ });// GET /: the home page for a browser (server/home.js), the API index for curl and API clients (`*/*`, no// Accept, or any non-HTML Accept). Vary: Accept so a shared cache never serves one to the other; the home is@@@ -265,6 +265,37 @@ ${sections({ siteUrl, stats })}});}++/**++ * The public page catalogue: /sitemap.xml lists these paths and /llms-full.txt describes them, so the++ * two artifacts cannot drift apart. The API reference is a section body rather than a page: it is++ * generated from the contracts at openvibe.services/docs/api, not served from this origin.++ */++const PUBLIC_PAGES = [++ { path: '/', title: 'OpenVibe.AI home', text: 'One API for chat, generation, summaries, classification, extraction and embeddings across many models, OpenAI-compatible. Every run says which model answered, why it was chosen and what it cost; developer apps, tokens and the sandbox are explained here.' },++ { path: '/stats', title: 'Prices and measured latency', text: 'Rate cards per provider and model and the measured p50 and p95 latency from the last seven days, with the number of calls each model answered. Nothing per caller.' },++ { path: '/updates', title: 'What shipped on OpenVibe.AI', text: 'The shared changelog for OpenVibe.AI, newest first.' },++];++++function llmsFullTxt({ siteUrl }) {++ return seo.llmsFull({++ site: SITE_NAME,++ summary: SUMMARY,++ base: siteUrl,++ maxBytes: 256 * 1024,++ sections: [++ { title: 'Pages', pages: PUBLIC_PAGES.map((p) => ({ url: `${siteUrl}${p.path}`, title: p.title, text: p.text })) },++ { title: 'Developer API', body: [++ `OpenAPI reference (generated from the published contracts): ${API_DOCS_URL}`,++ `Developer guide (tokens, limits, billing, sandbox): ${GUIDE}`,++ `Create a project and an app: ${PROJECTS_URL}`,++ `POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed`,++ `POST ${siteUrl}/v1/chat/completions and GET ${siteUrl}/v1/models (OpenAI-compatible)`,++ `GET ${siteUrl}/api/v1/runs/:id for your own runs`,++ ].join('\n') },++ ],++ });++}++function llmsTxt({ siteUrl }) {return [`# ${SITE_NAME}`,@@@ -298,4 +329,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, FAQ, DESCRIPTION };++module.exports = { renderHome, renderUpdates, llmsTxt, llmsFullTxt, PUBLIC_PAGES, HOME_CSP, SITE_NAME, renderNotFound, FAQ, DESCRIPTION };@@@ -139,13 +139,20 @@ t.test('robots.txt, sitemap.xml and llms.txt name the public origin', async () =assert.match(llms.headers.get('content-type'), /^text\/plain/);assert.ok(llms.text.startsWith('# OpenVibe.AI'));assert.ok(llms.text.includes(`${SITE}/stats`) && llms.text.includes(`POST ${SITE}/api/v1/chat`));++ const full = await request(h.base, 'GET', '/llms-full.txt');++ assert.strictEqual(full.status, 200);++ assert.match(full.headers.get('content-type'), /^text\/plain/);++ assert.ok(full.text.startsWith('# OpenVibe.AI'));++ assert.ok(full.text.length >= 500, `real content, not a stub (${full.text.length} characters)`);++ for (const loc of [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1])) assert.ok(full.text.includes(loc), `llms-full.txt names ${loc}`);++ assert.ok(full.text.includes('OpenAPI reference'), 'the generated API reference is named');});const active = (file) => fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', file), 'utf8').split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');t.test('the public vhost proxies the home, its files and exactly the developer-app API routes', () => {const conf = active('ai.openvibe.services.conf');-- for (const loc of ['location = / {', 'location = /stats', 'location = /updates', 'location = /sitemap.xml', 'location = /llms.txt', 'location /shared/']) assert.ok(conf.includes(loc), loc);++ for (const loc of ['location = / {', 'location = /stats', 'location = /updates', 'location = /sitemap.xml', 'location = /llms.txt', 'location = /llms-full.txt', 'location /shared/']) assert.ok(conf.includes(loc), loc);const ops = conf.indexOf('location ~ ^/api/v1/(chat|generate|summarize|classify|extract|embed)$ {');const run = conf.indexOf('location ~ ^/api/v1/runs/[A-Za-z0-9_-]+$ {');const completions = conf.indexOf('location = /v1/chat/completions {');
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
## The Node service (port 4700, systemd unit openvibe-ai) serves:
## The Node service (port 4700, systemd unit openvibe-ai) serves:
## / the product home for a browser (server/home.js), the text API index for anything else
## / the product home for a browser (server/home.js), the text API index for anything else
## /stats public price and latency per provider and model (server-rendered, no sign-in)
## /stats public price and latency per provider and model (server-rendered, no sign-in)
-## /shared/* the home's OpenVibe Frame files (content-addressed); /robots.txt, /sitemap.xml, /llms.txt
+## /shared/* the home's OpenVibe Frame files (content-addressed); /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt
## /api/health, /api/ready
## /api/health, /api/ready
## /api/v1/{chat,generate,summarize,classify,extract,embed} (POST) and /api/v1/runs/:id (GET)
## /api/v1/{chat,generate,summarize,classify,extract,embed} (POST) and /api/v1/runs/:id (GET)
## developer apps (ai.app.run, app tokens from OpenVibe.Network): open here, rate-limited
## developer apps (ai.app.run, app tokens from OpenVibe.Network): open here, rate-limited
@@ -154,6 +154,7 @@ server {
@@ -154,6 +154,7 @@ server {
location = /stats { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /stats { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /sitemap.xml { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /sitemap.xml { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /llms.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /llms.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
+ location = /llms-full.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /release.json { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /release.json { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
# Open tabs report how their update went (openvibe-shared release-watch → POST /release-metrics, counted in /metrics).
# Open tabs report how their update went (openvibe-shared release-watch → POST /release-metrics, counted in /metrics).
# The catch-all below passes GET and HEAD only, so every visitor's report was refused with a 403 (2026-10-24).
# The catch-all below passes GET and HEAD only, so every visitor's report was refused with a 403 (2026-10-24).
@@ -163,7 +163,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -163,7 +163,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
const from = new Date(clock.now() - STATS_DAYS * 86400000).toISOString().slice(0, 10);
const from = new Date(clock.now() - STATS_DAYS * 86400000).toISOString().slice(0, 10);
const stats = await db.prepare('SELECT MAX(day) AS lastmod FROM provider_stats_daily WHERE day >= ?').get(from);
const stats = await db.prepare('SELECT MAX(day) AS lastmod FROM provider_stats_daily WHERE day >= ?').get(from);
const statsLastmod = [stats && stats.lastmod ? new Date(stats.lastmod).toISOString() : null, releaseLastmod].filter(Boolean).sort().pop();
const statsLastmod = [stats && stats.lastmod ? new Date(stats.lastmod).toISOString() : null, releaseLastmod].filter(Boolean).sort().pop();
- const entries = [['/', releaseLastmod], ['/stats', statsLastmod], ['/updates', releaseLastmod]];
+ // The paths come from home.PUBLIC_PAGES, the same catalogue /llms-full.txt describes.
+ const lastmods = { '/': releaseLastmod, '/stats': statsLastmod, '/updates': releaseLastmod };
+ const entries = home.PUBLIC_PAGES.map((p) => [p.path, lastmods[p.path]]);
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map(([p, lastmod]) => ` <url><loc>${config.baseUrl}${p}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ''}</url>`).join('\n')}\n</urlset>\n`);
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map(([p, lastmod]) => ` <url><loc>${config.baseUrl}${p}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ''}</url>`).join('\n')}\n</urlset>\n`);
} catch (e) { next(e); }
} catch (e) { next(e); }
});
});
@@ -171,6 +173,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -171,6 +173,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.type('text/plain').send(home.llmsTxt({ siteUrl: config.baseUrl }));
res.type('text/plain').send(home.llmsTxt({ siteUrl: config.baseUrl }));
});
});
+ // The full AI map: the same page catalogue as the sitemap plus the developer API, in plain text.
+ app.get('/llms-full.txt', (_req, res) => {
+ res.setHeader('Cache-Control', 'public, max-age=3600');
+ res.type('text/plain').send(home.llmsFullTxt({ siteUrl: config.baseUrl }));
+ });
// GET /: the home page for a browser (server/home.js), the API index for curl and API clients (`*/*`, no
// GET /: the home page for a browser (server/home.js), the API index for curl and API clients (`*/*`, no
// Accept, or any non-HTML Accept). Vary: Accept so a shared cache never serves one to the other; the home is
// Accept, or any non-HTML Accept). Vary: Accept so a shared cache never serves one to the other; the home is
@@ -265,6 +265,37 @@ ${sections({ siteUrl, stats })}
@@ -265,6 +265,37 @@ ${sections({ siteUrl, stats })}
});
});
}
}
+/**
+ * The public page catalogue: /sitemap.xml lists these paths and /llms-full.txt describes them, so the
+ * two artifacts cannot drift apart. The API reference is a section body rather than a page: it is
+ * generated from the contracts at openvibe.services/docs/api, not served from this origin.
+ */
+const PUBLIC_PAGES = [
+ { path: '/', title: 'OpenVibe.AI home', text: 'One API for chat, generation, summaries, classification, extraction and embeddings across many models, OpenAI-compatible. Every run says which model answered, why it was chosen and what it cost; developer apps, tokens and the sandbox are explained here.' },
+ { path: '/stats', title: 'Prices and measured latency', text: 'Rate cards per provider and model and the measured p50 and p95 latency from the last seven days, with the number of calls each model answered. Nothing per caller.' },
+ { path: '/updates', title: 'What shipped on OpenVibe.AI', text: 'The shared changelog for OpenVibe.AI, newest first.' },
+];
+
+function llmsFullTxt({ siteUrl }) {
+ return seo.llmsFull({
+ site: SITE_NAME,
+ summary: SUMMARY,
+ base: siteUrl,
+ maxBytes: 256 * 1024,
+ sections: [
+ { title: 'Pages', pages: PUBLIC_PAGES.map((p) => ({ url: `${siteUrl}${p.path}`, title: p.title, text: p.text })) },
+ { title: 'Developer API', body: [
+ `OpenAPI reference (generated from the published contracts): ${API_DOCS_URL}`,
+ `Developer guide (tokens, limits, billing, sandbox): ${GUIDE}`,
+ `Create a project and an app: ${PROJECTS_URL}`,
+ `POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed`,
+ `POST ${siteUrl}/v1/chat/completions and GET ${siteUrl}/v1/models (OpenAI-compatible)`,
+ `GET ${siteUrl}/api/v1/runs/:id for your own runs`,
+ ].join('\n') },
+ ],
+ });
+}
+
function llmsTxt({ siteUrl }) {
function llmsTxt({ siteUrl }) {
return [
return [
`# ${SITE_NAME}`,
`# ${SITE_NAME}`,
@@ -298,4 +329,4 @@ function renderNotFound() {
@@ -298,4 +329,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');
'<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, FAQ, DESCRIPTION };
+module.exports = { renderHome, renderUpdates, llmsTxt, llmsFullTxt, PUBLIC_PAGES, HOME_CSP, SITE_NAME, renderNotFound, FAQ, DESCRIPTION };
@@ -139,13 +139,20 @@ t.test('robots.txt, sitemap.xml and llms.txt name the public origin', async () =
@@ -139,13 +139,20 @@ t.test('robots.txt, sitemap.xml and llms.txt name the public origin', async () =
assert.match(llms.headers.get('content-type'), /^text\/plain/);
assert.match(llms.headers.get('content-type'), /^text\/plain/);
assert.ok(llms.text.startsWith('# OpenVibe.AI'));
assert.ok(llms.text.startsWith('# OpenVibe.AI'));
assert.ok(llms.text.includes(`${SITE}/stats`) && llms.text.includes(`POST ${SITE}/api/v1/chat`));
assert.ok(llms.text.includes(`${SITE}/stats`) && llms.text.includes(`POST ${SITE}/api/v1/chat`));
+ const full = await request(h.base, 'GET', '/llms-full.txt');
+ assert.strictEqual(full.status, 200);
+ assert.match(full.headers.get('content-type'), /^text\/plain/);
+ assert.ok(full.text.startsWith('# OpenVibe.AI'));
+ assert.ok(full.text.length >= 500, `real content, not a stub (${full.text.length} characters)`);
+ for (const loc of [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1])) assert.ok(full.text.includes(loc), `llms-full.txt names ${loc}`);
+ assert.ok(full.text.includes('OpenAPI reference'), 'the generated API reference is named');
});
});
const active = (file) => fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', file), 'utf8').split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
const active = (file) => fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', file), 'utf8').split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
t.test('the public vhost proxies the home, its files and exactly the developer-app API routes', () => {
t.test('the public vhost proxies the home, its files and exactly the developer-app API routes', () => {
const conf = active('ai.openvibe.services.conf');
const conf = active('ai.openvibe.services.conf');
- for (const loc of ['location = / {', 'location = /stats', 'location = /updates', 'location = /sitemap.xml', 'location = /llms.txt', 'location /shared/']) assert.ok(conf.includes(loc), loc);
+ for (const loc of ['location = / {', 'location = /stats', 'location = /updates', 'location = /sitemap.xml', 'location = /llms.txt', 'location = /llms-full.txt', 'location /shared/']) assert.ok(conf.includes(loc), loc);
const ops = conf.indexOf('location ~ ^/api/v1/(chat|generate|summarize|classify|extract|embed)$ {');
const ops = conf.indexOf('location ~ ^/api/v1/(chat|generate|summarize|classify|extract|embed)$ {');
const run = conf.indexOf('location ~ ^/api/v1/runs/[A-Za-z0-9_-]+$ {');
const run = conf.indexOf('location ~ ^/api/v1/runs/[A-Za-z0-9_-]+$ {');
const completions = conf.indexOf('location = /v1/chat/completions {');
const completions = conf.indexOf('location = /v1/chat/completions {');