openvibers/openvibe.report Commits
Read the measured AI provider status from the Exchange catalog (#164)
Changes
@@@ -108,3 +108,4 @@/* Numbers in a header wrap into a grid instead of scrolling off a phone. */.ai-head .ovk-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,140px),1fr));overflow:visible}@media (max-width:700px){.ai-try{position:static;width:auto;border-radius:16px;box-shadow:none}}++.ai-warn{color:var(--warning,#f59e0b)}@@@ -185,9 +185,21 @@ const ownServer = (p) => /^(local|whisper)$/i.test(String(p.provider || '')) ||const providerName = (p) => ownServer(p) ? 'OpenVibe server' : String(p.provider_name || p.provider || 'Provider').replace(/\s*\((?:the |an? )[^)]*\)\s*$/i, '');const providerMark = (p) => ownServer(p) ? marks.mark('openvibe', { size: 24, label: false }) : marks.mark(p.provider || p.provider_name, { size: 24, label: false });function providerTable(list) {++ // Status is a word (the state-in-words rule): Up, Degraded with the warning tone, Down with the bad one, each with++ // its plain reason and since when. The column shows only when some offer is not up (a column earns its place).++ const notUp = (p) => p.routed !== false && ['down', 'degraded'].includes(String(p.status || 'up'));++ const statusCell = (p) => {++ if (p.routed === false) return quiet('Not routed here');++ if (!notUp(p)) return 'Up';++ const down = String(p.status) === 'down';++ const why = p.status_reason ? `<small>${esc(p.status_reason)}</small>` : '';++ const since = p.status_since ? `<small>since ${esc(String(p.status_since).slice(0, 16).replace('T', ' '))} UTC</small>` : '';++ return `<b class="${down ? 'ai-bad' : 'ai-warn'}">${down ? 'Down' : 'Degraded'}</b>${why}${since}`;++ };const rows = list.map((p) => ({provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,++ status: statusCell(p),price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),reply: p.routed === false ? quiet('Not routed here') : measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('Not measured yet'),errors: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('Not measured yet'),@@@ -198,6 +210,7 @@ function providerTable(list) {columns: [{ key: 'provider', label: 'Provider', primary: true, html: true },{ key: 'model', label: 'Model', html: true },++ list.some(notUp) && { key: 'status', label: 'Status', html: true },{ key: 'price', label: 'Price per 1M', html: true },{ key: 'reply', label: 'Reply time', html: true },{ key: 'errors', label: 'Errors', html: true },@@@ -210,7 +223,7 @@ function providerTable(list) {function providers({ providers: list = [] } = {}) {const sorted = [...list].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`-- + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”.</p></header>`++ + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”. Status is measured from the Exchange catalogue: down while an offer\'s circuit is open, degraded when more than 5% of at least 10 requests in the last hour failed, with the plain reason.</p></header>`+ providerTable(sorted);return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');}@@@ -145,7 +145,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')await check('comparison pages show fixture measurements, filter parameters and canonical model pairs', async () => {const fixture = await boot({-- aiCatalogue: { catalog: async () => ({ generated_at: '2026-11-09T00:00:00Z', models: [{ slug: 'maker/model-a', maker: 'Maker A', weights: 'open', status: 'current', offers: [{ provider: 'Provider One', price: { in: 1, out: 0.5 }, speed: { p50_ms: 120 }, uptime: 0.99 }] }, { slug: 'maker/model-b', maker: 'Maker B', weights: 'closed', status: 'current', offers: [] }] }) },++ aiCatalogue: { catalog: async () => ({ generated_at: '2026-11-09T00:00:00Z', models: [{ slug: 'maker/model-a', maker: 'Maker A', weights: 'open', status: 'current', offers: [{ provider: 'Provider One', price: { in: 1, out: 0.5 }, speed: { p50_ms: 120 }, uptime: 0.99, status: 'down', status_reason: 'the provider says the account is out of credit', status_since: '2026-11-09T09:00:00Z' }] }, { slug: 'maker/model-b', maker: 'Maker B', weights: 'closed', status: 'current', offers: [] }] }) },fetchImpl: async (url, opts) => {const u = new URL(String(url));if (u.port === '5230' && u.pathname === '/api/v1/kinds') return jsonResponse({ kinds: [{ id: 'code', name: 'Code' }] });@@@ -163,6 +163,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));const provider = await fixture.get('/ai/providers');assert.ok(provider.text.includes('Provider One') && provider.text.includes('Not measured yet'), 'an offer without requests has no measurement');++ assert.ok(provider.text.includes('Down') && provider.text.includes('the provider says the account is out of credit') && provider.text.includes('since 2026-11-09 09:00 UTC'), 'the measured status and its reason are shown');assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
@@ -108,3 +108,4 @@
@@ -108,3 +108,4 @@
/* Numbers in a header wrap into a grid instead of scrolling off a phone. */
/* Numbers in a header wrap into a grid instead of scrolling off a phone. */
.ai-head .ovk-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,140px),1fr));overflow:visible}
.ai-head .ovk-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,140px),1fr));overflow:visible}
@media (max-width:700px){.ai-try{position:static;width:auto;border-radius:16px;box-shadow:none}}
@media (max-width:700px){.ai-try{position:static;width:auto;border-radius:16px;box-shadow:none}}
+.ai-warn{color:var(--warning,#f59e0b)}
@@ -185,9 +185,21 @@ const ownServer = (p) => /^(local|whisper)$/i.test(String(p.provider || '')) ||
@@ -185,9 +185,21 @@ const ownServer = (p) => /^(local|whisper)$/i.test(String(p.provider || '')) ||
const providerName = (p) => ownServer(p) ? 'OpenVibe server' : String(p.provider_name || p.provider || 'Provider').replace(/\s*\((?:the |an? )[^)]*\)\s*$/i, '');
const providerName = (p) => ownServer(p) ? 'OpenVibe server' : String(p.provider_name || p.provider || 'Provider').replace(/\s*\((?:the |an? )[^)]*\)\s*$/i, '');
const providerMark = (p) => ownServer(p) ? marks.mark('openvibe', { size: 24, label: false }) : marks.mark(p.provider || p.provider_name, { size: 24, label: false });
const providerMark = (p) => ownServer(p) ? marks.mark('openvibe', { size: 24, label: false }) : marks.mark(p.provider || p.provider_name, { size: 24, label: false });
function providerTable(list) {
function providerTable(list) {
+ // Status is a word (the state-in-words rule): Up, Degraded with the warning tone, Down with the bad one, each with
+ // its plain reason and since when. The column shows only when some offer is not up (a column earns its place).
+ const notUp = (p) => p.routed !== false && ['down', 'degraded'].includes(String(p.status || 'up'));
+ const statusCell = (p) => {
+ if (p.routed === false) return quiet('Not routed here');
+ if (!notUp(p)) return 'Up';
+ const down = String(p.status) === 'down';
+ const why = p.status_reason ? `<small>${esc(p.status_reason)}</small>` : '';
+ const since = p.status_since ? `<small>since ${esc(String(p.status_since).slice(0, 16).replace('T', ' '))} UTC</small>` : '';
+ return `<b class="${down ? 'ai-bad' : 'ai-warn'}">${down ? 'Down' : 'Degraded'}</b>${why}${since}`;
+ };
const rows = list.map((p) => ({
const rows = list.map((p) => ({
provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,
provider: `<span class="ai-prov">${providerMark(p)}<b>${esc(providerName(p))}</b></span>`,
model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,
model: `<a href="${idPath(p.model)}">${esc(shortName(p.model))}</a>`,
+ status: statusCell(p),
price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),
price: p.price && value(p.price.out) != null ? `<b>${esc(money(p.price.out))}</b> out · ${esc(money(p.price.in) || '–')} in` : quiet('No price listed'),
reply: p.routed === false ? quiet('Not routed here') : measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('Not measured yet'),
reply: p.routed === false ? quiet('Not routed here') : measured(p) && p.speed && value(p.speed.p50_ms) != null ? `<b>${esc(ms(p.speed.p50_ms))}</b><small>p95 ${esc(ms(p.speed.p95_ms) || '–')}</small>` : quiet('Not measured yet'),
errors: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('Not measured yet'),
errors: p.routed === false ? quiet('Not routed here') : measured(p) && share(p.error_rate) != null ? `<b class="${Number(typeof p.error_rate === 'object' ? p.error_rate.value : p.error_rate) > 0.05 ? 'ai-bad' : ''}">${esc(share(p.error_rate))}</b>` : quiet('Not measured yet'),
@@ -198,6 +210,7 @@ function providerTable(list) {
@@ -198,6 +210,7 @@ function providerTable(list) {
columns: [
columns: [
{ key: 'provider', label: 'Provider', primary: true, html: true },
{ key: 'provider', label: 'Provider', primary: true, html: true },
{ key: 'model', label: 'Model', html: true },
{ key: 'model', label: 'Model', html: true },
+ list.some(notUp) && { key: 'status', label: 'Status', html: true },
{ key: 'price', label: 'Price per 1M', html: true },
{ key: 'price', label: 'Price per 1M', html: true },
{ key: 'reply', label: 'Reply time', html: true },
{ key: 'reply', label: 'Reply time', html: true },
{ key: 'errors', label: 'Errors', html: true },
{ key: 'errors', label: 'Errors', html: true },
@@ -210,7 +223,7 @@ function providerTable(list) {
@@ -210,7 +223,7 @@ function providerTable(list) {
function providers({ providers: list = [] } = {}) {
function providers({ providers: list = [] } = {}) {
const sorted = [...list].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));
const sorted = [...list].sort((a, b) => (Number(b.requests_7d) || 0) - (Number(a.requests_7d) || 0));
const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`
const body = `<header class="ai-head ai-head--page"><p class="ai-kicker"><a href="/ai">AI comparison</a></p><h1>AI providers</h1>`
- + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”.</p></header>`
+ + `<p class="ai-lede">Every model and provider offer in the market. Prices come from each provider's own page. Reply time, errors and uptime are measured on real requests over the last seven days; offers outside the router say “Not routed here”, while routed offers without measurements say “Not measured yet”. Status is measured from the Exchange catalogue: down while an offer\'s circuit is open, degraded when more than 5% of at least 10 requests in the last hour failed, with the plain reason.</p></header>`
+ providerTable(sorted);
+ providerTable(sorted);
return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');
return page('AI providers', 'AI providers compared: price per million tokens, reply time, error rate and uptime measured on real traffic.', body, '/ai/providers');
}
}
@@ -145,7 +145,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
@@ -145,7 +145,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
await check('comparison pages show fixture measurements, filter parameters and canonical model pairs', async () => {
await check('comparison pages show fixture measurements, filter parameters and canonical model pairs', async () => {
const fixture = await boot({
const fixture = await boot({
- aiCatalogue: { catalog: async () => ({ generated_at: '2026-11-09T00:00:00Z', models: [{ slug: 'maker/model-a', maker: 'Maker A', weights: 'open', status: 'current', offers: [{ provider: 'Provider One', price: { in: 1, out: 0.5 }, speed: { p50_ms: 120 }, uptime: 0.99 }] }, { slug: 'maker/model-b', maker: 'Maker B', weights: 'closed', status: 'current', offers: [] }] }) },
+ aiCatalogue: { catalog: async () => ({ generated_at: '2026-11-09T00:00:00Z', models: [{ slug: 'maker/model-a', maker: 'Maker A', weights: 'open', status: 'current', offers: [{ provider: 'Provider One', price: { in: 1, out: 0.5 }, speed: { p50_ms: 120 }, uptime: 0.99, status: 'down', status_reason: 'the provider says the account is out of credit', status_since: '2026-11-09T09:00:00Z' }] }, { slug: 'maker/model-b', maker: 'Maker B', weights: 'closed', status: 'current', offers: [] }] }) },
fetchImpl: async (url, opts) => {
fetchImpl: async (url, opts) => {
const u = new URL(String(url));
const u = new URL(String(url));
if (u.port === '5230' && u.pathname === '/api/v1/kinds') return jsonResponse({ kinds: [{ id: 'code', name: 'Code' }] });
if (u.port === '5230' && u.pathname === '/api/v1/kinds') return jsonResponse({ kinds: [{ id: 'code', name: 'Code' }] });
@@ -163,6 +163,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
@@ -163,6 +163,7 @@ const { boot, check, done, stubFetch, jsonResponse } = require('./helpers/boot')
assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));
assert.ok(filtered.text.includes('maker/model-a') && !filtered.text.includes('maker/model-b'));
const provider = await fixture.get('/ai/providers');
const provider = await fixture.get('/ai/providers');
assert.ok(provider.text.includes('Provider One') && provider.text.includes('Not measured yet'), 'an offer without requests has no measurement');
assert.ok(provider.text.includes('Provider One') && provider.text.includes('Not measured yet'), 'an offer without requests has no measurement');
+ assert.ok(provider.text.includes('Down') && provider.text.includes('the provider says the account is out of credit') && provider.text.includes('since 2026-11-09 09:00 UTC'), 'the measured status and its reason are shown');
assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare?models=maker%2Fmodel-a,maker%2Fmodel-b')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-a-vs-maker%2Fmodel-b')).status, 200);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);
assert.strictEqual((await fixture.get('/ai/compare/maker%2Fmodel-b-vs-maker%2Fmodel-a')).status, 301);