openvibers/openvibe.exchange Commits

The home names only the model families on sale (#87)

OP OpenVibers committed 7 days ago · f4c559275a · 11 files, +88 −24

* The home names only the model families on sale

The hero, the search hint and the descriptions named Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds while
only GPT and DeepSeek had providers. The lead now lists the families in the live catalog, most requested first
("GPT and DeepSeek now, more as their providers join"), the search hint takes the first three, and the static
descriptions say "every offer". Two models of one family no longer show identical example answers in the preview.

* The companions' floor says what it enforces: non-consensual sexual content, fiction included, and incest

The rule's label, the card page, /uncensored and the system prompt every character chat sends the model said
"non-consensual sexual content of real people", while the published floor (and the rule's own pattern) refuses it
in fiction too. The model was told fiction was fine, and the output check would then end the person's chat for it.

* Sell compute: no @name placeholder, "verified" only when it is, and house servers are not machines on sale

The sample card printed a literal "@name's own machine" and "verified" on every offer. "Machines on the network now"
listed OpenVibe's own local model server (provider local, on the production host) as a volunteer community machine,
because AI gives it community trust so the router treats its privacy conservatively; only Node machines are listed now.

* A button inside an empty state or prose keeps its own text colour (the chat page's sign-in button was blue on blue)

* Model rows leave out unmeasured stats and OpenAI's models are named as OpenAI says them (GPT-5 nano)

* The API page's tables stay tables on a phone: model id and context (the family is the id's prefix), and usage with short token counts

Changes

ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex f6862a7..c64ce7a 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -14,7 +14,7 @@
.ex-narrow { max-width: 760px; }
.ex-page { padding-top: 10px; }
.ex-muted { color: var(--ovk-muted); }
--.ex-prose a, .ex-list a, .ex-more a, .ex-empty a, .ovk-empty a { color: var(--ovk-accent); text-underline-offset: 2px; }
++.ex-prose a:not(.ovk-btn), .ex-list a:not(.ovk-btn), .ex-more a:not(.ovk-btn), .ex-empty a:not(.ovk-btn), .ovk-empty a:not(.ovk-btn) { color: var(--ovk-accent); text-underline-offset: 2px; }
/* A sticky head inside the column still spans the window; its title sits on the content line. */
.ex-page > .ovk-head {
ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
iindex 184cb31..102ac42 100644
---- a/server/agent-descriptor.js
++++ b/server/agent-descriptor.js
@@@ -4,7 +4,7 @@
module.exports = {
id: 'exchange',
name: 'OpenVibe.Exchange',
-- purpose: 'Every AI model, one account: live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.',
++ purpose: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.',
home: 'https://openvibe.exchange',
site: 'openvibe.exchange',
api: 'https://api.openvibe.exchange/v1',
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 6f1ba8e..f128dac 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -7,7 +7,7 @@ const { raw } = require('./render/html');
const { renderPage } = require('./render/layout');
const { homeJsonLd } = require('./http/discovery');
--const HOME_DESCRIPTION = 'OpenVibe.Exchange: every AI model, one account. Live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.';
++const HOME_DESCRIPTION = 'OpenVibe.Exchange: every AI model, one account. Live prices, speed and uptime of every offer, from the families on sale today and more as their providers join; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.';
function createAgentPages(ctx) {
const { config, ai } = ctx;
ddiff --git a/server/ai.js b/server/ai.js
iindex 25f23ba..5697643 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -51,7 +51,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
async function catalog() {
if (catalogHit.value && now() - catalogHit.at < CATALOG_MS) return catalogHit.value;
try {
-- const body = await get('/api/v1/exchange/catalog', 'catalog');
++ const body = withDisplayNames(await get('/api/v1/exchange/catalog', 'catalog'));
catalogHit = { at: now(), value: body };
return body;
} catch (err) {
@@@ -151,4 +151,20 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
return { catalog, usage, chat, configured: Boolean(tokens) };
}
--module.exports = { createAi };
++/**
++ * OpenAI's models arrive named by their API ids ("gpt-5-nano") while every other family has a display name
++ * ("DeepSeek V4 Pro"). Say them the way OpenAI does ("GPT-5 nano", "GPT-4.1 mini", "GPT-4o mini"); the id stays the id.
++ */
++function displayName(name) {
++ const m = /^gpt-([0-9][0-9a-z.]*)((?:-[a-z0-9]+)*)$/i.exec(String(name || ''));
++ if (!m) return name;
++ const rest = m[2] ? ' ' + m[2].slice(1).split('-').join(' ') : '';
++ return `GPT-${m[1]}${rest}`;
++}
++
++function withDisplayNames(body) {
++ if (!body || !Array.isArray(body.models)) return body;
++ return { ...body, models: body.models.map((m) => (m && typeof m.name === 'string' ? { ...m, name: displayName(m.name) } : m)) };
++}
++
++module.exports = { createAi, displayName };
ddiff --git a/server/companions/floor.js b/server/companions/floor.js
iindex fbcebec..60df372 100644
---- a/server/companions/floor.js
++++ b/server/companions/floor.js
@@@ -51,7 +51,7 @@ const ATTACK_INTENT = /\b(?:(?:how (?:do|can|would) (?:i|we|you|one))\s+[^.?!]{0
/** The three rules, in the order they are checked. */
const RULES = [
{ rule: 'minors', label: 'sexual content involving minors', test: (t) => CSAM.test(t) || (MINOR.test(t) && NUDE.test(t)) },
-- { rule: 'non_consensual', label: 'non-consensual sexual content of real people', test: (t) => NONCONSENSUAL.test(t) },
++ { rule: 'non_consensual', label: 'non-consensual sexual content (fiction included) or incest', test: (t) => NONCONSENSUAL.test(t) },
{ rule: 'mass_harm', label: 'weapons of mass harm or attacks on others', test: (t) => MASS_HARM.test(t) || WEAPON_INTENT.test(t) || ATTACK_INTENT.test(t) },
];
ddiff --git a/server/http/companions.js b/server/http/companions.js
iindex 76d585c..7a11da2 100644
---- a/server/http/companions.js
++++ b/server/http/companions.js
@@@ -286,7 +286,7 @@ function createCompanionRoutes(ctx) {
if (card.scenario) lines.push('Scenario: ' + card.scenario);
if (chat.persona) lines.push('The person you talk with plays: ' + chat.persona);
if (card.first_message) lines.push('Begin with: ' + card.first_message);
-- lines.push('Stay in character and answer as the character would. The hard floor holds on every model: no sexual content involving minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.');
++ lines.push('Stay in character and answer as the character would. The hard floor holds on every model: no sexual content involving minors, no non-consensual sexual content (fiction included) or incest, no help with weapons of mass harm or attacks on others.');
return lines.join(String.fromCharCode(10));
}
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 655a480..f6d85e2 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -12,7 +12,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Exchange';
--const DESCRIPTION = 'Every AI model, one account: live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.';
++const DESCRIPTION = 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join; chat with one model or three side by side; one OpenAI- and Anthropic-compatible endpoint.';
const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/chat', '/listings', '/submit', '/sandbox', '/installs', '/offers'];
const PUBLIC_PAGES = [
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a9f7649..4eb4bd3 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -74,7 +74,7 @@ function createPageRoutes(ctx) {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
-- description: 'Every AI model, one account: live prices, speed and uptime for DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds. Chat, or call one OpenAI-compatible endpoint.',
++ description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',
jsonLd: homeJsonLd(config),
body: raw(views.home({ catalog, usage, viewer: req.viewer, q, config })),
});
ddiff --git a/server/views/companions.js b/server/views/companions.js
iindex 7da530f..b854e15 100644
---- a/server/views/companions.js
++++ b/server/views/companions.js
@@@ -157,7 +157,7 @@ function cardForm({ card = null, action = '/companions/new', title = 'Make a cha
+ '<option value="everyone"' + (c.rating === 'adult' ? '' : ' selected') + '>Everyone</option>'
+ '<option value="adult"' + (c.rating === 'adult' ? ' selected' : '') + '>18+</option>'
+ '</select>'
-- + '<p class="ex-hint">A card rated 18+ whose stated age is under 18 is refused at save. The hard floor applies to every card: no sexual content involving minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.</p>'
++ + '<p class="ex-hint">A card rated 18+ whose stated age is under 18 is refused at save. The hard floor applies to every card: no sexual content involving minors, no non-consensual sexual content (fiction included) or incest, no help with weapons of mass harm or attacks on others.</p>'
+ field('avatar_url', 'Avatar image URL (optional)', c.avatar_url, 'maxlength="400"')
+ field('model', 'Model this card recommends (optional)', c.model, 'maxlength="120"')
+ '<label class="ex-label" for="ex-import">Import a card instead (JSON or PNG)</label>'
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 3b7dc38..01d9a7a 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -50,6 +50,21 @@ function familyLabel(m) {
return !name || INTERNAL.test(name) || name.length > 24 ? 'Other' : name;
}
++/** The model families on sale now, most requested first ("GPT", "DeepSeek"); internal and unnamed ones are left out. */
++function familiesOnSale(models) {
++ const out = [];
++ for (const m of [...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0))) {
++ const f = familyLabel(m);
++ if (f !== 'Other' && !out.includes(f)) out.push(f);
++ }
++ return out;
++}
++
++/** "A", "A and B", "A, B and C". */
++function listSaid(xs) {
++ return xs.length <= 1 ? (xs[0] || '') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`;
++}
++
/**
* The privacy label the design asks for, from facts only: a community machine sees the prompt; zero retention
* and no training is Private; no training alone is said as that. "Anonymized" is never claimed, because no
@@@ -94,7 +109,8 @@ function panel({ title, note = '', id, body, cls = '' }) {
function modelRow(m, { withTry = true } = {}) {
const best = cheapest(m);
const bits = [];
-- if (best) bits.push(providerLabel(best), inOut(best.price), ms(best.speed.p50_ms), pct(best.uptime));
++ // A stat not measured yet is left out, never printed as a dash ("– · –" read as noise in a row).
++ if (best) bits.push(providerLabel(best), inOut(best.price), ...(best.speed && best.speed.p50_ms != null ? [ms(best.speed.p50_ms)] : []), ...(best.uptime != null ? [`${pct(best.uptime)} up`] : []));
else if (m.offers[0]) bits.push(providerLabel(m.offers[0]), 'free capacity');
if (best && best.refusal_rate != null) bits.push(`refuses ${pct(best.refusal_rate)}`);
const pv = privacyOf(best || m.offers[0]);
@@@ -116,6 +132,8 @@ function home({ catalog, usage, viewer, q = '', config = null }) {
const paid = models.map((m) => ({ m, o: cheapest(m) })).filter((x) => x.o).sort((a, b) => blended(a.o.price) - blended(b.o.price));
const popular = [...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 8);
const bestPrice = paid[0] && paid[0].o;
++ // The hero names only the families on sale now, most requested first: never one no provider sells yet.
++ const families = familiesOnSale(models);
const preview = (paid.length >= 2 ? paid.slice(0, 2) : models.slice(0, 2).map((m) => ({ m, o: cheapest(m) })));
const stats = [];
@@@ -130,8 +148,8 @@ 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">DeepSeek, Qwen, Kimi, GLM, MiniMax, Llama and the uncensored builds: 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>
--${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model: DeepSeek, Qwen, Kimi, GLM…', value: q, icon: 'search' })}
++<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>
++${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: '/api', label: 'The API', kind: 'ghost' })}</p>
</div>
${preview.length === 2 ? previewCard(preview) : ''}
@@@ -163,9 +181,18 @@ ${panel({
/** The hero's side-by-side chat preview: real models and prices, example words, said as an example. */
function previewCard(preview) {
++ // Two models of one family would say the same example words; the second takes another wording, so the preview
++ // never shows two identical answers.
++ const said = [];
++ const answerFor = (m) => {
++ let text = EXAMPLE_ANSWER[familyLabel(m)] || EXAMPLE_ANSWER.Default;
++ if (said.includes(text)) text = Object.values(EXAMPLE_ANSWER).find((t) => !said.includes(t)) || text;
++ said.push(text);
++ return text;
++ };
const cells = preview.map(({ m, o }) => `<article class="ex-preview-cell">
<header>${mark(familyLabel(m), { size: 22, label: false })}<b>${esc(m.name)}</b></header>
--<p>${esc(EXAMPLE_ANSWER[familyLabel(m)] || EXAMPLE_ANSWER.Default)}</p>
++<p>${esc(answerFor(m))}</p>
<footer>${o ? `${money(o.price.in)} in · ${money(o.price.out)} out · ${ms(o.speed.p50_ms)}` : 'free capacity'}</footer>
</article>`).join('');
return `<aside class="ex-preview" aria-label="A preview of the side-by-side chat">
@@@ -224,7 +251,7 @@ ${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model or a provi
<div class="ex-chips" role="group" aria-label="Sort">${links}</div>
${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${familyLinks}</div>` : ''}
${unavailable(catalog)}
--${panel({ title: '', body: modelList(models, { empty: 'No model matches that search. Try a family (DeepSeek, Qwen, Kimi, GLM) or a provider.' }) })}
++${panel({ title: '', body: modelList(models, { empty: 'No model matches that search. Try a family or a provider.' }) })}
<p class="ex-more"><a href="/status">Uptime and incidents →</a></p>
</div>`;
}
@@@ -343,9 +370,9 @@ function resultCard(result) {
// ── API ────────────────────────────────────────────────────
function apiPage({ catalog, usage }) {
const models = chatModels(catalog).filter((m) => !m.uncensored).slice(0, 12);
-- const rows = models.map((m) => ({ model: `<code>${esc(m.slug)}</code>`, family: esc(familyLabel(m)), context: m.context || null }));
++ const rows = models.map((m) => ({ model: `<code>${esc(m.slug)}</code>`, context: m.context || null }));
const byDay = (usage.by_day || []).slice(-14).reverse();
-- const usageRows = byDay.map((d) => ({ day: d.day, requests: count(d.requests), tokens: count((d.tokens_in || 0) + (d.tokens_out || 0)), cost: d.cost_usd == null ? '–' : `$${Number(d.cost_usd).toFixed(4)}` }));
++ const usageRows = byDay.map((d) => ({ day: d.day, requests: count(d.requests), tokens: shortCount((d.tokens_in || 0) + (d.tokens_out || 0)), cost: d.cost_usd == null ? '–' : `$${Number(d.cost_usd) < 0.1 ? Number(d.cost_usd).toFixed(4) : Number(d.cost_usd).toFixed(2)}` }));
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: '/chat', label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${panel({ title: 'One endpoint', body: `
@@@ -356,16 +383,26 @@ ${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIB
<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })}</p>
<h3 class="ex-h3">Model ids</h3>
<p class="ex-prose"><code>openvibe/auto</code> lets the router pick by your floor, ceiling and policy, and the answer says which model ran and why. An exact id pins one artifact. Bring your own provider key and those requests pay the provider directly, at no fee from us.</p>
--${kit.table({ columns: [{ key: 'model', label: 'Model', primary: true, html: true }, { key: 'family', label: 'Family' }, { key: 'context', label: 'Context', num: true }], rows, label: 'Model ids' })}
++${kit.table({ columns: [{ key: 'model', label: 'Model id', primary: true, html: true }, { key: 'context', label: 'Context', num: true }], rows, label: 'Model ids', stack: false })}
` })}
--${panel({ title: 'Usage', note: 'Aggregate, from OpenVibe.AI: no prompt and no person.', id: 'usage', body: usage.unavailable ? kit.notice({ kind: 'info', text: 'Usage is not available right now.' }) : (usageRows.length ? kit.table({ columns: [{ key: 'day', label: 'Day', primary: true }, { key: 'requests', label: 'Requests' }, { key: 'tokens', label: 'Tokens' }, { key: 'cost', label: 'Cost' }], rows: usageRows, label: 'Usage by day', caption: 'The last 14 days, all products and people.' }) : kit.empty({ icon: 'spark', title: 'No requests yet', text: 'The first call through the endpoint will show here.' })) })}
++${panel({ title: 'Usage', note: 'Aggregate, from OpenVibe.AI: no prompt and no person.', id: 'usage', body: usage.unavailable ? kit.notice({ kind: 'info', text: 'Usage is not available right now.' }) : (usageRows.length ? kit.table({ columns: [{ key: 'day', label: 'Day', primary: true }, { key: 'requests', label: 'Requests' }, { key: 'tokens', label: 'Tokens' }, { key: 'cost', label: 'Cost' }], rows: usageRows, label: 'Usage by day', caption: 'The last 14 days, all products and people.', stack: false }) : kit.empty({ icon: 'spark', title: 'No requests yet', text: 'The first call through the endpoint will show here.' })) })}
${panel({ title: 'What it costs', body: '<p class="ex-prose">Every price is the offer\'s own, per million tokens, and the answer carries the exact cost of that run. A small free daily allowance comes with every account; it grows with account age and contribution. Paid use, when the owner turns money on, is at cost plus a margin and shown before the request.</p>' })}
</div>`;
}
++/** 2832475 → "2.83M", 48210 → "48.2k": short enough that a four-column table fits a phone. */
++function shortCount(n) {
++ const v = Number(n) || 0;
++ return v >= 1e6 ? `${(v / 1e6).toFixed(2)}M` : v >= 1e4 ? `${(v / 1e3).toFixed(1)}k` : count(v);
++}
++
// ── Sell compute ───────────────────────────────────────────
++const HOUSE_SERVER = /^local\d*$/;
++
function sellPage({ catalog, usage }) {
-- const offers = (catalog.models || []).flatMap((m) => m.offers.filter((o) => o.trust === 'community').map((o) => ({ model: m, offer: o })));
++ // OpenVibe's own model servers on its hosts (`local`, `local2`) carry community trust so the router treats their
++ // privacy conservatively, but nobody sells them: only machines people offer through Node are listed here.
++ const offers = (catalog.models || []).flatMap((m) => m.offers.filter((o) => o.trust === 'community' && !HOUSE_SERVER.test(String(o.provider || ''))).map((o) => ({ model: m, offer: o })));
const first = offers[0];
const seller = first
? sellerCard({ machine: providerLabel(first.offer), model: first.model, offer: first.offer, example: false })
@@@ -400,11 +437,12 @@ ${panel({ title: 'Machines on the network now', id: 'community', body: machineRo
function sellerCard({ machine, model, offer, example }) {
const speed = offer.speed && offer.speed.p50_ms != null ? `answers in ${ms(offer.speed.p50_ms)}` : 'speed not measured yet';
-- const label = /owner|volunteer|machine/i.test(String(machine)) ? '@name’s own machine' : `@name’s ${machine}`;
++ // No person's name: a seller card says what the machine is, never who owns it.
++ const label = example ? `A spare ${machine}` : (/owner|volunteer|machine/i.test(String(machine)) ? 'A community machine' : `A community ${machine}`);
return `<article class="ex-seller${example ? ' is-example' : ''}">
<span class="ex-mk ex-mk--machine" style="--ex-mk-size:36px" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 6h16v9H4zM8 19h8M12 15v4"/></svg></span>
<div><b>${esc(label)}</b>
--<p>${esc(model.name)} · ${speed} · ${offer.uptime == null ? 'uptime not measured yet' : `${pct(offer.uptime)} uptime`} · verified</p></div>
++<p>${esc(model.name)} · ${speed} · ${offer.uptime == null ? 'uptime not measured yet' : `${pct(offer.uptime)} uptime`}${example || offer.verified === true ? ' · verified' : ' · not verified yet'}</p></div>
${example ? '<span class="ex-badge-note">Example</span>' : ''}
</article>`;
}
@@@ -439,7 +477,7 @@ function companionsPolicy() {
+ '<li>On a chat, your own message is checked before it is sent, and the answer is checked before it is shown.</li>'
+ '<li>An answer that breaks the floor is never delivered: the chat ends and the account is reviewed.</li>'
+ '<li>An 18+ character shows only to people signed in with the 18+ confirmation, and never reaches a public list, a sitemap, llms.txt or a search.</li>'
-- + '</ul><p class="ex-prose">The same three rules as everywhere else, with the character’s own card in scope: no sexual content involving minors, no non-consensual sexual content of real people, no help with weapons of mass harm or attacks on others.</p>';
++ + '</ul><p class="ex-prose">The same three rules as everywhere else, with the character’s own card in scope: no sexual content involving minors, no non-consensual sexual content (fiction included) or incest, no help with weapons of mass harm or attacks on others.</p>';
return panel({ title: 'The floor on characters and chats', id: 'companions-floor', body });
}
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex ac4eedd..1d0f627 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -25,6 +25,15 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(!r.text.includes('requests today'), 'fewer than 1,000 requests today is not shown');
assert.ok(r.text.includes('A side-by-side answer') && r.text.includes('example words · live prices'), 'the preview is marked an example');
assert.ok(r.text.includes('api.openvibe.exchange/v1/chat/completions'), 'the endpoint');
++ const lead = (r.text.match(/<p class="ex-lead">([\s\S]*?)<\/p>/) || [])[1] || '';
++ assert.ok(/DeepSeek/.test(lead) && /Qwen/.test(lead) && /Llama/.test(lead), 'the hero names the families on sale: ' + lead);
++ assert.ok(!/Kimi|GLM|MiniMax/.test(lead), 'the hero never names a family nobody sells: ' + lead);
++ const answers = [...r.text.matchAll(/<article class="ex-preview-cell">[\s\S]*?<p>([\s\S]*?)<\/p>/g)].map((m) => m[1]);
++ assert.strictEqual(answers.length, 2, 'two preview answers');
++ assert.notStrictEqual(answers[0], answers[1], 'the two example answers differ');
++ const { displayName } = require('../server/ai.js');
++ assert.deepStrictEqual(['gpt-5-nano', 'gpt-4.1-mini', 'gpt-4o-mini', 'gpt-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'].map(displayName), ['GPT-5 nano', 'GPT-4.1 mini', 'GPT-4o mini', 'GPT-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'], 'OpenAI models are named the way OpenAI says them');
++ assert.ok(!/ · – · –|– · –/.test(r.text), 'an unmeasured stat is left out, not printed as a dash');
assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
assert.ok(r.text.includes('<main id="main"') && r.text.includes('<footer id="ov-footer"'));
});
@@@ -78,7 +87,8 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('What is sold'));
assert.ok(r.text.includes('Owner’s machine') || r.text.includes('No community capacity yet'));
assert.ok(r.text.includes('Idle-only') && r.text.includes('Kudos') && r.text.includes('sees the prompt'), 'idle-only, kudos and the privacy sentence');
-- assert.ok(r.text.includes('@name’s'), 'the seller card');
++ assert.ok(r.text.includes('A community machine') && !r.text.includes('@name'), 'the seller card names the machine, never a placeholder or a person');
++ assert.ok(r.text.includes('not verified yet'), 'an offer is said verified only when it is');
});
await check('uncensored: sign-in first, then the 18+ gate and the floor', async () => {