openvibers/openvibe.report Commits

Read the measured AI provider status from the Exchange catalog (#164)

OP OpenVibers committed 4 days ago · 88c4471379 · 3 files, +17 −2

* Show the measured AI provider status and its reason

* Merge origin/main into the provider status branch

* Merge remote-tracking branch 'origin/main' into feat/provider-status-1109

* Provider status, designed: the column shows when an offer is not up; Degraded warns, Down is red

Reviewed by Fable: a Status column of "Up" on every row says nothing (a column earns its place), so it appears only
when some routed offer is degraded or down, with its plain reason and since when. Degraded takes the warning tone.

Changes

ddiff --git a/public/css/ai-comparison.css b/public/css/ai-comparison.css
iindex 90dc81a..475279b 100644
---- a/public/css/ai-comparison.css
++++ b/public/css/ai-comparison.css
@@@ -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)}
ddiff --git a/server/views/ai-comparison.js b/server/views/ai-comparison.js
iindex 854a654..bb53a9a 100644
---- a/server/views/ai-comparison.js
++++ b/server/views/ai-comparison.js
@@@ -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');
}
ddiff --git a/test/ai-pages.test.js b/test/ai-pages.test.js
iindex 46b84a2..ec081e0 100644
---- a/test/ai-pages.test.js
++++ b/test/ai-pages.test.js
@@@ -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);