openvibers/openvibe.compare Commits
Compare: compute prices from Exchange, with filters, sorts and a side-by-side (#11)
Changes
@@@ -3,9 +3,10 @@## Project overviewPublic, source-linked comparisons for the OpenVibe network: AI models (price and measured Bench--quality/speed), AI coding agents (Codes harnesses plus verified Bench runs) and OpenVibe plans--(Billing's public plan list). Node.js/Express, fed entirely by other services' public APIs, **no--database, no accounts, no write API**. Server-rendered pages; `openvibe-shared` for the shell.++quality/speed), AI coding agents (Codes harnesses plus verified Bench runs), OpenVibe plans++(Billing's public plan list) and compute prices (Exchange's reviewed GPU/computer tariffs).++Node.js/Express, fed entirely by other services' public APIs, **no database, no accounts, no write++API**. Server-rendered pages; `openvibe-shared` for the shell.- Origin `https://openvibe.compare`, port `5280`, unit `openvibe-compare.service`.- One rule above all: **missing evidence is not zero**. A thing without a measurement is unranked,@@@ -17,8 +18,9 @@ database, no accounts, no write API**. Server-rendered pages; `openvibe-shared`- `GET /comparisons/:slug` — one comparison; weight overrides via `?w.<criterion>=`.- `GET /things/:id` — one thing across every comparison that contains it; `/models/:org/:model`301s here.--- `GET /api/v1/comparisons[/:slug]` — the same data as JSON, CORS-allowed for the origins in-- [server/origins.json](server/origins.json).++- `GET /compute` — compute prices from Exchange: filters, sorts and a two/three-offer side-by-side.++- `GET /api/v1/comparisons[/:slug]` and `GET /api/v1/compute` — the same data as JSON, CORS-allowed++ for the origins in [server/origins.json](server/origins.json).- `robots.txt`, `sitemap.xml`, `llms.txt`, Dataset JSON-LD, and `Accept: text/markdown` negotiation —all generated from the same data, never hand-written separately.@@@ -27,7 +29,8 @@ schema with no API in v1).## Who it reads--Five public endpoints, all in [server/data.js](server/data.js) `ENDPOINTS`:++Six public endpoints: five in [server/data.js](server/data.js) `ENDPOINTS`, and compute in++[server/compute.js](server/compute.js) `SOURCE`:| Feed | What ||---|---|@@@ -36,6 +39,7 @@ Five public endpoints, all in [server/data.js](server/data.js) `ENDPOINTS`:| `bench.openvibe.services/api/v1/runs?kind=codes.patch` / `codes.ticket` | verified Bench runs (OpenVibe.Bench) || `openvibe.codes/api/v1/harnesses` | coding harness catalog (OpenVibe.Codes) || `billing.openvibe.network/api/v1/plans` | published plans (OpenVibe.Billing) |++| `openvibe.exchange/api/compute/prices` | reviewed compute tariffs per provider and region (OpenVibe.Exchange) |It does not call AI, Live, Network or any authenticated API. Nothing here writes anywhere.@@@ -46,6 +50,9 @@ It does not call AI, Live, Network or any authenticated API. Nothing here writeshealth/ready, `/release.json`, `/api/v1`, pages, 404 and 503.- **Data:** [server/data.js](server/data.js) — fetches the five feeds (8 s timeout), builds the threecomparisons, coalesces concurrent reads and caches for ten minutes; a failed feed becomes `null`.++- **Compute:** [server/compute.js](server/compute.js) normalizes Exchange's tariffs and derives the++ month (730 h) and GPU GB-hour prices; [server/compute-views.js](server/compute-views.js) renders the++ page, Markdown and Dataset JSON-LD. A failed feed is not cached.- **Views:** [server/views.js](server/views.js) — HTML, markdown, escaping, JSON-LD, the method text.- **Ranking:** [public/rank.js](public/rank.js) — the one ranking implementation, required by both theserver views and the browser.@@@ -68,11 +75,11 @@ There is no `dev` script and no build step: edit `server/` or `public/` and rest## Testing--`test/compare.test.js` uses Node's built-in test runner with fixture feeds. It pins the rules that++`test/compare.test.js` and `test/compute.test.js` use Node's built-in test runner with fixture feeds. It pins the rules thatmatter: every measurement is finite, dated, sourced, and references a known thing and criterion;missing evidence stays missing; exact model identity only; ranking handles direction, equal values,zero/invalid weights and all-zero weights (unranked); pages and JSON agree. `ov test` runs the same--file plus the repository checks the harness adds.++file and `test/compute.test.js` plus the repository checks the harness adds.## Conventions@@@ -110,14 +117,16 @@ vhost and unit in sync with [deploy/](deploy/); a docs-only change needs no depl- **Do not invent a write path.** Builder and version history are deliberately absent from v1.- **Do not cache a failed feed into an old value.** It is `null` for that read; the next requestretries, and the comparison lists it as unavailable.--- **Do not hand-write `sitemap.xml`, `llms.txt` or JSON-LD.** They are generated from `getData()` so-- they cannot drift from the pages.++- **Do not hand-write `sitemap.xml`, `llms.txt` or JSON-LD.** They are generated from `getData()` and++ `getCompute()` so they cannot drift from the pages.++- **Compute is read from Exchange, never copied.** A tariff's checked date and review date are shown++ as published; an offer past `expires_at` is marked, never presented as current.- **Keep `public/rank.js` framework-free and dual-use** (server `require` and browser global); thetest loads it both ways.- **CORS is an exact allow-list** ([server/origins.json](server/origins.json)); no `*` and nocredentials.--- **`Accept: text/markdown`** on `/`, `/comparisons/:slug` and `/things/:id` is part of the product;-- keep it when touching the views.++- **`Accept: text/markdown`** on `/`, `/comparisons/:slug`, `/things/:id` and `/compute` is part of++ the product; keep it when touching the views.- **Do not remove the 503 handler**: an unexpected feed failure must answer explicitly, not 500.## Documentation@@@ -3,9 +3,9 @@ Source-linked comparisons for anythingCompare v1 serves public comparisons at https://openvibe.compare. Run `npm ci`, then `npm start`; check with `ov test`.--Data: Exchange price-book market, Bench public arena/verified runs, Codes harness catalog, Billing public plans. Reads are coalesced and cached for ten minutes. Price provenance retains the provider's checked date; a board without an update timestamp labels its date as the observation date. Failed feeds produce explicit unavailable states. There is no private data or paid execution.++Data: Exchange price-book market and its reviewed compute tariffs, Bench public arena/verified runs, Codes harness catalog, Billing public plans. Reads are coalesced and cached for ten minutes. Price provenance retains the provider's checked date; a board without an update timestamp labels its date as the observation date. Failed feeds produce explicit unavailable states. There is no private data or paid execution. Compute prices are read from OpenVibe.Exchange's `GET /api/compute/prices`; Compare adds only derived arithmetic (per month at 24/7, per GPU GB-hour), never a copied tariff.--Pages: `/`, `/comparisons/:slug`, `/things/:id` (URI-encoded identity); JSON at `/api/v1/comparisons[/slug]`. Pages negotiate `Accept: text/markdown`. Dataset JSON-LD, sitemap and llms.txt are generated from the same data. Desktop tables and phone cards share ranking logic. Weight URL parameters are `w.<criterion>`; all-zero weights leave things unranked. Builder and version-history fields are reserved, with no write API in v1.++Pages: `/`, `/comparisons/:slug`, `/things/:id` (URI-encoded identity), and `/compute` (filters by kind, GPU model, region and interruptible; sorts by price per hour, per GPU GB-hour and per month at 24/7; a two- or three-offer side-by-side). JSON at `/api/v1/comparisons[/slug]` and `/api/v1/compute`. Pages negotiate `Accept: text/markdown`. Dataset JSON-LD, sitemap and llms.txt are generated from the same data. Desktop tables and phone cards share ranking logic; compute cards run to 899 px and its table starts at 900 px. Weight URL parameters are `w.<criterion>`; all-zero weights leave things unranked. Builder and version-history fields are reserved, with no write API in v1.Launch: Host register-compare.py registers port 5280 with a lifecycle override until the separate Contracts manifest is released. Use merge-deploy.sh, then ov-vhost-install for deploy/nginx/openvibe.compare.conf. No database or service credentials are required.@@@ -17,3 +17,43 @@ main a,main summary{overflow-wrap:anywhere}/* Shared's 34px sign-in link has an 8px expanded hit area. At the phone's6px outer gutter it overruns the viewport by 2px; keep a 48px tap area. */@media(max-width:799px){#navbar-mount .openvibe-navbar .openvibe-navbar-login::after{right:-6px}}++/* Compute prices: cards on a phone, a table from 900 px, and a side-by-side that scrolls inside its box. */++.compute-source{margin:1rem 0;overflow-wrap:anywhere}++.compute-filters fieldset{border:1px solid currentColor;border-radius:.5rem;padding:0 1rem 1rem}++.compute-filters .ovk-set-row{border-top:0;padding:.35rem 0;flex-wrap:wrap;gap:.5rem}++.compute-filters select{max-width:100%}++.compute-filters .compute-submit{margin:.75rem 0 0}++.compute-sort{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem;margin:1rem 0}++.compute-sort a[aria-current]{font-weight:700;text-decoration:none}++.compute-count{overflow-wrap:anywhere}++.compute-picked{overflow-wrap:anywhere}++.compute-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,18rem),1fr));gap:1rem}++.compute-card{display:flex;flex-direction:column}++.compute-card h2{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;margin:0 0 .5rem;overflow-wrap:anywhere}++.compute-kind{font-weight:400;opacity:.8}++.compute-price{margin:.25rem 0 .75rem}++.compute-price b{font-size:1.6rem}++.compute-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.5rem 1rem;margin:.5rem 0}++.compute-facts>div{min-width:0}++.compute-facts dt{font-size:.8rem;opacity:.8}++.compute-facts dd{margin:.1rem 0 0;overflow-wrap:anywhere}++.compute-promo,.compute-overdue{margin:.5rem 0;overflow-wrap:anywhere}++.compute-promo{font-size:.9rem}++.compute-links{display:flex;flex-wrap:wrap;gap:.35rem 1rem;margin:.75rem 0 0}++.compute-use{margin-top:auto;padding-top:.5rem}++.compute-use-links{display:inline;overflow-wrap:anywhere}++.compute-table{display:none;max-width:100%;overflow:auto}++.compute-table table,.compute-side table{width:100%;border-collapse:collapse}++.compute-table th,.compute-table td,.compute-side th,.compute-side td{padding:.5rem;border-bottom:1px solid currentColor;text-align:left;vertical-align:top;overflow-wrap:anywhere}++.compute-table caption,.compute-side caption{text-align:left;padding:.5rem 0}++.compute-side{margin-top:2rem;max-width:100%}++.compute-side-scroll{max-width:100%;overflow-x:auto}++.compute-side table{min-width:0;table-layout:fixed}++.compute-side thead th:first-child{width:28%}++.compute-side th,.compute-side td{font-size:.9rem}++.compute-num{white-space:nowrap;font-variant-numeric:tabular-nums}++.compute-side thead a{font-size:.85rem}++.compute-empty{padding:1rem 0}++.compute-filters .ovk-set-text{min-width:0}++@media(min-width:900px){.compute-cards{display:none}.compute-table{display:block}}++@media(max-width:899px){.compute-table{display:none}}@@@ -2,9 +2,11 @@const express=require('express');const path=require('node:path');const {createData}=require('./data');++const {createCompute,api:computeApi}=require('./compute');++const computeViews=require('./compute-views');const v=require('./views');const origins=new Set(require('./origins.json'));--function createApp({getData=createData()}={}) {++function createApp({getData=createData(),getCompute=createCompute()}={}) {const app=express();app.disable('x-powered-by');app.use((req,res,next)=>{res.set('X-Content-Type-Options','nosniff');res.set('Referrer-Policy','strict-origin-when-cross-origin');next();});app.use(express.static(path.join(__dirname,'../public')));@@@ -19,20 +21,22 @@ function createApp({getData=createData()}={}) {res.vary('Origin');res.set('Access-Control-Allow-Methods','GET, HEAD, OPTIONS');if(req.method==='OPTIONS')return res.sendStatus(204);next();});++ app.get('/api/v1/compute',wrap(async(req,res)=>res.json(computeApi(await getCompute()))));app.get('/api/v1/comparisons',wrap(async(req,res)=>res.json({comparisons:await getData()})));app.get('/api/v1/comparisons/:slug',wrap(async(req,res)=>{const c=(await getData()).find(c=>c.slug===req.params.slug);return c?res.json(c):res.status(404).json({error:'comparison.not_found'});}));app.get('/robots.txt',(_q,r)=>r.type('text').send('User-agent: *\nAllow: /\nSitemap: https://openvibe.compare/sitemap.xml\n'));-- app.get('/llms.txt',wrap(async(_q,r)=>r.type('text').send('# OpenVibe.Compare\n\nDated comparisons. Missing evidence is not zero.\n\n'+(await getData()).map(c=>`- [${c.title}](https://openvibe.compare/comparisons/${c.slug}): ${c.summary}`).join('\n')+'\n\nJSON: https://openvibe.compare/api/v1/comparisons\nEach page also accepts Accept: text/markdown.\n')));++ app.get('/llms.txt',wrap(async(_q,r)=>{const [cs,compute]=await Promise.all([getData(),getCompute()]);return r.type('text').send('# OpenVibe.Compare\n\nDated comparisons. Missing evidence is not zero.\n\n'+`- [Compute prices](https://openvibe.compare/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month at 24/7${compute.checked_at?`; checked ${compute.checked_at.slice(0,10)}`:''}.\n`+cs.map(c=>`- [${c.title}](https://openvibe.compare/comparisons/${c.slug}): ${c.summary}`).join('\n')+'\n\nJSON: https://openvibe.compare/api/v1/comparisons\nCompute JSON: https://openvibe.compare/api/v1/compute\nEach page also accepts Accept: text/markdown.\n')}));app.get('/sitemap.xml',wrap(async(_q,r)=>{-- const cs=await getData();const paths=new Map([['/',cs.map(c=>c.updated_at).sort().at(-1)]]);++ const [cs,compute]=await Promise.all([getData(),getCompute()]);const newest=cs.map(c=>c.updated_at).sort().at(-1);const paths=new Map([['/',newest],['/compute',compute.checked_at||newest]]);for(const c of cs){paths.set(`/comparisons/${c.slug}`,c.updated_at);for(const t of c.things)paths.set(v.thingPath(t.id),c.updated_at);}r.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+[...paths].map(([p,d])=>`<url><loc>https://openvibe.compare${v.esc(p)}</loc><lastmod>${v.esc(d)}</lastmod></url>`).join('')+'</urlset>');}));app.get('/',wrap(async(req,res)=>{-- const all=await getData();res.vary('Accept');-- if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send('# Compare anything\n\n'+all.map(c=>`- [${c.title}](/comparisons/${c.slug}): ${c.summary}`).join('\n'));-- res.send(v.page('Compare anything',v.home(all,String(req.query.q||'').slice(0,120),String(req.query.task||'balanced'),String(req.query.budget||'').slice(0,20))));++ const [all,compute]=await Promise.all([getData(),getCompute()]);res.vary('Accept');++ if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send('# Compare anything\n\n- [Compute prices](/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month.\n'+all.map(c=>`- [${c.title}](/comparisons/${c.slug}): ${c.summary}`).join('\n'));++ res.send(v.page('Compare anything',v.home(all,compute,String(req.query.q||'').slice(0,120),String(req.query.task||'balanced'),String(req.query.budget||'').slice(0,20))));}));++ app.get('/compute',wrap(async(req,res)=>{const compute=await getCompute();res.vary('Accept');if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(computeViews.markdown(compute,req.query));res.send(v.page('Compute prices',computeViews.body(compute,req.query),'/compute',[computeViews.jsonLd(compute)]));}));app.get('/comparisons/:slug',wrap(async(req,res)=>{const c=(await getData()).find(c=>c.slug===req.params.slug);if(!c)return res.sendStatus(404);res.vary('Accept');if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(v.markdown(c));nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++/** The /compute page: filters, sorts and a side-by-side over Exchange's reviewed compute prices. */++const kit = require('openvibe-shared/kit');++const { PAGE, MONTH_HOURS, select, href, label } = require('./compute');++++const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c');++const day = (iso) => (iso ? iso.slice(0, 10) : '—');++const month = (iso) => (iso ? new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : '—');++const money = (v) => {++ const n = Number(v);++ if (!Number.isFinite(n)) return '—';++ const digits = Math.abs(n) >= 1 ? 2 : 4;++ return `$${n.toFixed(digits).replace(/\.?0+$/, '')}`;++};++const precise = (v) => { const n = Number(v); return Number.isFinite(n) ? `$${n.toFixed(8).replace(/\.?0+$/, '')}` : '—'; };++const gb = (mb) => (Number.isFinite(Number(mb)) && Number(mb) > 0 ? `${Math.round(Number(mb) / 1024 * 10) / 10} GB` : null);++const memory = (o) => (o.kind === 'gpu' ? [o.gpu, gb(o.gpu_mb)].filter(Boolean).join(' · ') : [o.cpu ? `${o.cpu} vCPU` : null, gb(o.ram_mb)].filter(Boolean).join(' · '));++const checkedLabel = (c) => (c.checked_days_ago == null ? 'at an unknown date' : c.checked_days_ago === 0 ? 'today' : c.checked_days_ago === 1 ? '1 day ago' : `${c.checked_days_ago} days ago`);++const offerName = (o) => `${o.provider} ${memory(o) || label(o.kind)}${o.kind === 'gpu' ? ` (${o.interruptible ? 'interruptible' : 'on demand'})` : ''}`;++const badge = (text, tone = '') => kit.badge(text, tone);++++function cheapestLine(compute) {++ const pick = (kind) => compute.offers.find((o) => o.id === compute.cheapest[kind]);++ const parts = [];++ for (const kind of ['gpu', 'cpu']) {++ const o = pick(kind);++ if (o) parts.push(`cheapest ${kind === 'gpu' ? 'GPU' : 'computer'}: <strong>${esc(o.provider)} ${esc(memory(o) || '')}</strong> at ${money(o.hourly)}/hour`);++ }++ return parts.length ? ` · ${parts.join(' · ')}` : '';++}++++function sortNav(q) {++ const sorts = [['hourly', 'price per hour'], ['gpu-hour', 'price per GPU GB-hour'], ['month', 'price per month (24/7)']];++ return `<nav class="compute-sort" aria-label="Sort prices"><span>Sort by</span>${sorts.map(([v, l]) => `<a href="${esc(href(q, { sort: v }))}"${q.sort === v ? ' aria-current="true"' : ''}>${esc(l)}</a>`).join('')}</nav>`;++}++++function pickLink(o, q) {++ const picked = q.pick.includes(o.id);++ if (picked) return { href: href(q, { pick: q.pick.filter((id) => id !== o.id) }), label: 'Remove from side-by-side' };++ if (q.pick.length >= 3) return { href: null, label: 'Side-by-side is full (3)' };++ return { href: href(q, { pick: [...q.pick, o.id] }), label: 'Add to side-by-side' };++}++++function flags(o) {++ return [++ o.cheapest ? badge(`Cheapest ${o.kind === 'gpu' ? 'GPU' : 'computer'}`, 'ok') : '',++ o.interruptible ? badge('Interruptible') : '',++ o.expired ? badge('Review overdue', 'warn') : '',++ ].filter(Boolean).join('');++}++++function useLinks() {++ return `<span class="compute-use-links">Use it on OpenVibe: <a href="https://openvibe.exchange/models">models on Exchange</a> · <a href="https://openvibe.run/">computers on Run</a></span>`;++}++++function card(o, q) {++ const p = pickLink(o, q);++ return `<article class="ovk-card compute-card" data-offer="${esc(o.id)}">++<h2><span>${esc(o.provider)}</span> <small class="compute-kind">${label(o.kind)}</small>${flags(o)}</h2>++<p class="compute-price"><b>${money(o.hourly)}</b> per hour</p>++<dl class="compute-facts">++<div><dt>${o.kind === 'gpu' ? 'GPU' : 'Computer'}</dt><dd>${esc(memory(o) || 'Not stated')}</dd></div>++<div><dt>Region</dt><dd>${esc(o.region || 'Not stated')}</dd></div>++<div><dt>Per month (${MONTH_HOURS} h, 24/7)</dt><dd>${money(o.monthly_24_7)}</dd></div>++<div><dt>${o.kind === 'gpu' ? 'Per GPU GB-hour' : 'Per second'}</dt><dd>${o.kind === 'gpu' && o.hourly_per_gpu_gb != null ? money(o.hourly_per_gpu_gb) : precise(o.per_second)}</dd></div>++<div><dt>Checked</dt><dd><time datetime="${esc(o.checked_at)}">${day(o.checked_at)}</time></dd></div>++<div><dt>Billing</dt><dd>${o.billing_seconds ? `per second (${o.billing_seconds} s) ` : 'per second'}</dd></div>++</dl>++${o.promotion ? `<p class="compute-promo">${esc(o.promotion)}${o.promotion_expires ? ` (ends ${esc(day(o.promotion_expires))})` : ''}</p>` : ''}++${o.expired ? `<p class="compute-overdue">Review date passed <time datetime="${esc(o.expires_at)}">${esc(month(o.expires_at))}</time>: this is the last reviewed price, not a re-checked one.</p>` : ''}++<p class="compute-links"><a href="${esc(o.source)}">Provider price</a>${p.href ? ` · <a href="${esc(p.href)}" rel="nofollow">${esc(p.label)}</a>` : ` · <span>${esc(p.label)}</span>`}</p>++<p class="compute-use">${useLinks()}</p>++</article>`;++}++++function table(offers, q) {++ return `<div class="compute-table"><table><caption>Compute prices, cheapest first</caption><thead><tr><th scope="col">Provider</th><th scope="col">Kind and resources</th><th scope="col">Region</th><th scope="col">Per hour</th><th scope="col">Per GPU GB-hour</th><th scope="col">Per month (${MONTH_HOURS} h)</th><th scope="col">Notes</th><th scope="col">Side-by-side</th></tr></thead><tbody>${offers.map((o) => {++ const p = pickLink(o, q);++ return `<tr data-offer="${esc(o.id)}"><th scope="row"><a href="${esc(o.source)}">${esc(o.provider)}</a><br><small>${esc(o.id)}</small></th><td>${esc(memory(o) || 'Not stated')}</td><td>${esc(o.region || '—')}</td><td class="compute-num">${money(o.hourly)}</td><td class="compute-num">${o.hourly_per_gpu_gb != null ? money(o.hourly_per_gpu_gb) : '—'}</td><td class="compute-num">${money(o.monthly_24_7)}</td><td>${flags(o) || '—'}${o.promotion ? `<br><small>${esc(o.promotion)}</small>` : ''}${o.expired ? `<br><small>Review date passed ${esc(day(o.expires_at))}</small>` : ''}</td><td>${p.href ? `<a href="${esc(p.href)}" rel="nofollow">${esc(q.pick.includes(o.id) ? 'Remove' : 'Add')}</a>` : '—'}</td></tr>`;++ }).join('')}</tbody></table></div>`;++}++++function sideBySide(picks, q) {++ const rows = [++ ['Kind', (o) => label(o.kind)],++ ['Resources', (o) => esc(memory(o) || '—')],++ ['Region', (o) => esc(o.region || '—')],++ ['Per hour', (o) => money(o.hourly)],++ ['Per second', (o) => precise(o.per_second)],++ ['Per month (24/7)', (o) => money(o.monthly_24_7)],++ ['Per GPU GB-hour', (o) => (o.hourly_per_gpu_gb != null ? money(o.hourly_per_gpu_gb) : '—')],++ ['Interruptible', (o) => (o.interruptible ? 'Yes' : 'No')],++ ['Billing', (o) => esc(o.billing_seconds ? `per second (${o.billing_seconds} s)` : 'per second')],++ ['Promotion', (o) => (o.promotion ? esc(o.promotion) : '—')],++ ['Checked', (o) => `<time datetime="${esc(o.checked_at)}">${day(o.checked_at)}</time>`],++ ['Review due', (o) => `<time datetime="${esc(o.expires_at)}">${esc(day(o.expires_at))}</time>${o.expired ? ` ${badge('Overdue', 'warn')}` : ''}`],++ ['Provider price', (o) => `<a href="${esc(o.source)}">${esc(o.provider)}</a>`],++ ['Use it on OpenVibe', () => `<a href="https://openvibe.exchange/models">models on Exchange</a> · <a href="https://openvibe.run/">computers on Run</a>`],++ ];++ return `<section class="compute-side" id="side-by-side" aria-labelledby="side-title"><h2 id="side-title">Side-by-side</h2><p>Two or three picks, the facts that differ.</p><div class="compute-side-scroll"><table><caption>${picks.length} offers side by side</caption><thead><tr><th scope="col">Field</th>${picks.map((o) => `<th scope="col">${esc(o.provider)}<br><small>${esc(memory(o) || label(o.kind))}${o.kind === 'gpu' ? ` · ${o.interruptible ? 'interruptible' : 'on demand'}` : ''}</small><br><a href="${esc(href(q, { pick: q.pick.filter((id) => id !== o.id) }))}">Remove</a></th>`).join('')}</tr></thead><tbody>${rows.map(([name, value]) => `<tr><th scope="row">${name}</th>${picks.map((o) => `<td>${value(o)}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;++}++++function filters(compute, q) {++ const gpus = [...new Set(compute.offers.map((o) => o.gpu).filter(Boolean))].sort();++ const regions = [...new Set(compute.offers.map((o) => o.region).filter(Boolean))].sort();++ if (q.gpu && !gpus.includes(q.gpu)) gpus.push(q.gpu);++ if (q.region && !regions.includes(q.region)) regions.push(q.region);++ return `<form class="compute-filters" method="get" action="/compute"><fieldset><legend>Filter prices</legend>++${kit.selectRow({ name: 'kind', label: 'Kind', options: [['', 'GPUs and computers'], ['gpu', 'GPUs'], ['cpu', 'Computers']], value: q.kind })}++${kit.selectRow({ name: 'gpu', label: 'GPU model', options: [['', 'Any GPU model'], ...gpus.map((g) => [g, g])], value: q.gpu })}++${kit.selectRow({ name: 'region', label: 'Region', options: [['', 'Any region'], ...regions.map((r) => [r, r])], value: q.region })}++${kit.selectRow({ name: 'interruptible', label: 'Pricing', options: [['', 'Any pricing'], ['no', 'On demand'], ['yes', 'Interruptible']], value: q.interruptible })}++${q.sort !== 'hourly' ? `<input type="hidden" name="sort" value="${esc(q.sort)}">` : ''}${q.pick.length ? `<input type="hidden" name="pick" value="${esc(q.pick.join(','))}">` : ''}++<p class="compute-submit"><button class="ovk-btn ovk-btn--primary" type="submit">Show prices</button> <a href="/compute">Reset</a></p>++</fieldset></form>`;++}++++function body(compute, query) {++ const { q, offers, picks } = select(compute, query);++ const when = checkedLabel(compute);++ return `${kit.head({ title: 'Compute prices', icon: 'server', sub: `What an hour of a GPU or a computer costs at each provider, checked ${when}.`, sticky: false })}++<p class="compute-source">Data from <a href="${esc(PAGE)}">OpenVibe.Exchange</a> · <a href="/api/v1/compute">JSON</a> · USD${compute.checked_at ? ` · checked <time datetime="${esc(compute.checked_at)}">${esc(day(compute.checked_at))}</time>` : ''}</p>++${compute.unavailable ? kit.notice({ kind: 'warn', text: compute.unavailable }) : ''}++${compute.expired_count ? kit.notice({ kind: 'warn', text: `${compute.expired_count} offer${compute.expired_count === 1 ? '' : 's'} passed the review date${compute.expired_at ? ` on ${month(compute.expired_at)}` : ''}; shown as the last reviewed price, not as current.` }) : ''}++${filters(compute, q)}++${sortNav(q)}++<p role="status" class="compute-count">${offers.length === compute.offers.length ? `${offers.length} offer${offers.length === 1 ? '' : 's'}` : `${offers.length} of ${compute.offers.length} offers`}${cheapestLine(compute)}</p>++${picks.length ? `<p class="compute-picked">Side-by-side: ${picks.map((o) => esc(offerName(o))).join(' vs ')}${picks.length < 2 ? ' (pick one more)' : ` · <a href="${esc(href(q, { pick: [] }))}">Clear</a>`}</p>` : ''}++${offers.length ? `<div class="compute-cards">${offers.map((o) => card(o, q)).join('')}</div>${table(offers, q)}` : `<p class="compute-empty">No offers match these filters. <a href="/compute">Clear them</a>.</p>`}++${picks.length >= 2 ? sideBySide(picks, q) : ''}++<section class="compute-use"><h2>Use it on OpenVibe</h2><p>${useLinks()}. The provider sets the price above; OpenVibe's own rate for a model run is on <a href="https://openvibe.exchange/models">Exchange</a>.</p></section>++`;++}++++function markdown(compute, query) {++ const { q, offers, picks } = select(compute, query);++ const lines = [`# Compute prices`, '', `What an hour of a GPU or a computer costs at each provider, checked ${checkedLabel(compute)}.`, '', `Data from [OpenVibe.Exchange](${PAGE}); JSON at https://openvibe.compare/api/v1/compute. Prices in USD, reviewed on provider price pages.`, ''];++ if (compute.unavailable) lines.push(`> ${compute.unavailable}`, '');++ if (compute.expired_count) lines.push(`> ${compute.expired_count} offers passed their review date; shown as the last reviewed price, not as current.`, '');++ if (q.kind || q.gpu || q.region || q.interruptible) lines.push(`Filtered: ${[q.kind && label(q.kind), q.gpu, q.region, q.interruptible && (q.interruptible === 'yes' ? 'interruptible' : 'on demand')].filter(Boolean).join(', ')}.`, '');++ lines.push(`Sorted by ${q.sort === 'gpu-hour' ? 'price per GPU GB-hour' : q.sort === 'month' ? 'price per month' : 'price per hour'}.`, '');++ for (const o of offers) {++ lines.push(`## ${offerName(o)}`, '');++ lines.push(`- Kind: ${label(o.kind)}`);++ lines.push(`- Region: ${o.region || 'not stated'}`);++ lines.push(`- Per hour: ${money(o.hourly)}${o.cheapest ? ' (cheapest of its kind)' : ''}`);++ if (o.hourly_per_gpu_gb != null) lines.push(`- Per GPU GB-hour: ${money(o.hourly_per_gpu_gb)}`);++ lines.push(`- Per month (${MONTH_HOURS} h, 24/7): ${money(o.monthly_24_7)}`);++ lines.push(`- Interruptible: ${o.interruptible ? 'yes' : 'no'}`);++ lines.push(`- Checked: ${day(o.checked_at)}; review due: ${o.expires_at ? day(o.expires_at) : 'not stated'}${o.expired ? ' (passed)' : ''}`);++ if (o.promotion) lines.push(`- Promotion: ${o.promotion}`);++ lines.push(`- Source: [${o.provider} price page](${o.source})`, '');++ }++ if (picks.length) {++ lines.push('## Side-by-side', '');++ for (const o of picks) lines.push(`### ${offerName(o)}`, `- ${money(o.hourly)} per hour; ${o.hourly_per_gpu_gb != null ? `${money(o.hourly_per_gpu_gb)} per GPU GB-hour; ` : ''}${money(o.monthly_24_7)} per month (24/7); ${o.interruptible ? 'interruptible' : 'on demand'}`, '');++ }++ lines.push('## Use it on OpenVibe', '', 'Run models on [OpenVibe.Exchange](https://openvibe.exchange/models) or computers and jobs on [OpenVibe.Run](https://openvibe.run/).', '');++ return lines.join('\n');++}++++function jsonLd(compute) {++ return {++ '@context': 'https://schema.org', '@type': 'Dataset', name: 'Compute prices',++ description: 'What an hour of a GPU or a computer costs at each reviewed provider, from OpenVibe.Exchange.',++ url: 'https://openvibe.compare/compute', dateModified: compute.checked_at || compute.retrieved_at,++ creator: { '@type': 'Organization', name: 'OpenVibe' },++ distribution: { '@type': 'DataDownload', encodingFormat: 'application/json', contentUrl: 'https://openvibe.compare/api/v1/compute' },++ };++}++++module.exports = { body, markdown, jsonLd, checkedLabel };nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/** Compute prices on OpenVibe.Compare: read once from OpenVibe.Exchange, normalized, never copied.++ * Exchange publishes reviewed provider tariffs at GET /api/compute/prices; Compare adds only derived++ * arithmetic (per month at 24/7 and per GPU GB-hour) and the page's filters, sorts and picks. */++const SOURCE = 'https://openvibe.exchange/api/compute/prices';++const PAGE = 'https://openvibe.exchange/compute';++const MONTH_HOURS = 730;++const DAY = 86400000;++const KINDS = ['gpu', 'cpu'];++const SORTS = ['hourly', 'gpu-hour', 'month'];++++const text = (v) => (typeof v === 'string' ? v : '');++const positive = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };++const nonNegative = (v) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : null; };++const iso = (v) => {++ if (v == null || v === '') return null;++ const t = typeof v === 'number' ? v : Date.parse(String(v));++ return Number.isFinite(t) ? new Date(t).toISOString() : null;++};++const label = (kind) => (kind === 'gpu' ? 'GPU' : 'computer');++++function offer(p, now) {++ if (!p || typeof p.id !== 'string' || !p.id) return null;++ const hourly = positive(p.hourly);++ const kind = KINDS.includes(p.kind) ? p.kind : null;++ const checked_at = iso(p.checked_at);++ const source = text(p.source);++ if (!hourly || !kind || !checked_at || !/^https:\/\//.test(source)) return null;++ const gpu_mb = kind === 'gpu' ? positive(p.gpu_mb) : null;++ const expires_at = iso(p.expires_at);++ const per_second = positive(p.per_second) || hourly / 3600;++ return {++ id: p.id,++ provider: text(p.provider) || 'unknown',++ region: text(p.region) || null,++ kind,++ cpu: nonNegative(p.cpu),++ ram_mb: positive(p.ram_mb),++ gpu: kind === 'gpu' ? text(p.gpu) || 'GPU' : null,++ gpu_mb,++ hourly,++ per_second,++ monthly_24_7: Math.round(hourly * MONTH_HOURS * 10000) / 10000,++ hourly_per_gpu_gb: gpu_mb ? Math.round(hourly / (gpu_mb / 1024) * 10000) / 10000 : null,++ billing_seconds: positive(p.billing_seconds),++ interruptible: Boolean(p.interruptible),++ promotion: text(p.promotion) || null,++ promotion_expires: iso(p.promotion_expires),++ checked_at,++ expires_at,++ expired: Boolean(expires_at && Date.parse(expires_at) < now),++ source,++ };++}++++function buildCompute(feed, now = Date.now()) {++ if (!feed || !Array.isArray(feed.prices)) {++ return {++ source: SOURCE, page: PAGE, checked_at: null, checked_days_ago: null, retrieved_at: new Date(now).toISOString(),++ offers: [], cheapest: { gpu: null, cpu: null }, expired_count: 0,++ unavailable: 'OpenVibe.Exchange did not answer, so no compute price is shown as current.',++ };++ }++ const offers = feed.prices.map((p) => offer(p, now)).filter(Boolean)++ .sort((a, b) => a.hourly - b.hourly || a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));++ const checked_at = iso(feed.checked_at) || [...offers].map((o) => o.checked_at).sort().at(-1) || null;++ const cheapest = { gpu: null, cpu: null };++ // An offer past its review date is not marked cheapest: the badge means a current price.++ for (const pool of [offers.filter((o) => !o.expired), offers]) for (const o of pool) if (!cheapest[o.kind]) cheapest[o.kind] = o.id;++ for (const o of offers) o.cheapest = o.id === cheapest[o.kind];++ const expired = offers.filter((o) => o.expired);++ return {++ source: SOURCE, page: PAGE, checked_at,++ checked_days_ago: checked_at ? Math.max(0, Math.floor((now - Date.parse(checked_at)) / DAY)) : null,++ retrieved_at: new Date(now).toISOString(),++ offers, cheapest,++ expired_count: expired.length,++ expired_at: expired.length ? expired.map((o) => o.expires_at).sort()[0] : null,++ };++}++++function parseQuery(query = {}) {++ const pick = [...new Set(text(query.pick).split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 3);++ return {++ kind: KINDS.includes(text(query.kind)) ? text(query.kind) : '',++ gpu: text(query.gpu).slice(0, 80),++ region: text(query.region).slice(0, 80),++ interruptible: ['yes', 'no'].includes(text(query.interruptible)) ? text(query.interruptible) : '',++ sort: SORTS.includes(text(query.sort)) ? text(query.sort) : 'hourly',++ pick,++ };++}++++function sortOffers(offers, sort) {++ const value = (o) => (sort === 'gpu-hour' ? o.hourly_per_gpu_gb : sort === 'month' ? o.monthly_24_7 : o.hourly);++ return [...offers].sort((a, b) => {++ const x = value(a), y = value(b);++ if (x == null && y == null) return a.hourly - b.hourly;++ if (x == null) return 1;++ if (y == null) return -1;++ return x - y || a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);++ });++}++++/** What the page shows for one query: the filters, the sorted offers, and the picks that still exist. */++function select(compute, query = {}) {++ const q = parseQuery(query);++ const offers = (compute.offers || []).filter((o) =>++ (!q.kind || o.kind === q.kind) &&++ (!q.gpu || o.gpu === q.gpu) &&++ (!q.region || o.region === q.region) &&++ (!q.interruptible || o.interruptible === (q.interruptible === 'yes')));++ const picks = q.pick.map((id) => (compute.offers || []).find((o) => o.id === id)).filter(Boolean);++ return { q, offers: sortOffers(offers, q.sort), picks };++}++++/** The query string without the defaults, so every link stays short and shareable. */++function queryString(q = {}) {++ const p = new URLSearchParams();++ if (q.kind) p.set('kind', q.kind);++ if (q.gpu) p.set('gpu', q.gpu);++ if (q.region) p.set('region', q.region);++ if (q.interruptible) p.set('interruptible', q.interruptible);++ if (q.sort && q.sort !== 'hourly') p.set('sort', q.sort);++ if (q.pick && q.pick.length) p.set('pick', q.pick.join(','));++ return p;++}++const href = (q, changes = {}) => { const s = queryString({ ...q, ...changes }).toString(); return `/compute${s ? `?${s}` : ''}`; };++++function api(compute) {++ return {++ source: 'OpenVibe.Exchange',++ source_url: compute.source,++ page: compute.page,++ retrieved_at: compute.retrieved_at,++ checked_at: compute.checked_at,++ currency: 'USD',++ month_hours: MONTH_HOURS,++ offers: compute.offers,++ cheapest: compute.cheapest,++ expired_count: compute.expired_count,++ ...(compute.unavailable ? { unavailable: compute.unavailable } : {}),++ };++}++++function createCompute({ fetchImpl = fetch, now = Date.now, ttl = 600000 } = {}) {++ let cached, expires = 0, pending;++ return async function get() {++ if (cached && now() < expires) return cached;++ if (pending) return pending;++ pending = (async () => {++ let feed = null;++ try {++ const r = await fetchImpl(SOURCE, { signal: AbortSignal.timeout(8000), headers: { Accept: 'application/json' } });++ if (r.ok) feed = await r.json();++ } catch { feed = null; }++ // A failed feed is not cached into a value: the next request retries it.++ if (!feed) return buildCompute(null, now());++ cached = buildCompute(feed, now());++ expires = now() + ttl;++ return cached;++ })().finally(() => { pending = null; });++ return pending;++ };++}++++module.exports = { SOURCE, PAGE, MONTH_HOURS, buildCompute, createCompute, parseQuery, select, sortOffers, queryString, href, api, label };@@@ -21,7 +21,14 @@ function comparison(c,input={}) {<section id="method"><h2>Method</h2><p>${esc(c.method)}</p><h3>Sources</h3><ul>${c.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.label)}</a></li>`).join('')}</ul></section><script type="application/json" id="comparison-data">${json(c)}</script><script src="/rank.js" defer></script><script src="/comparison.js" defer></script>`;}--function home(all,q='',task='balanced',budget='') {++function computeCard(compute,q='') {++ const when=compute.checked_days_ago==null?'at an unknown date':compute.checked_days_ago===0?'today':compute.checked_days_ago===1?'1 day ago':`${compute.checked_days_ago} days ago`;++ const cheapest=compute.offers.find(o=>o.id===compute.cheapest.gpu);++ const text=`GPUs and computers from OpenVibe.Exchange: per hour, per GPU GB-hour and per month at 24/7, checked ${when}.`;++ if(q && !('compute prices '+text).toLowerCase().includes(q.toLowerCase())) return '';++ return `<article class="ovk-card"><h2><a href="/compute">Compute prices</a></h2><p>${esc(text)}</p>${compute.unavailable?`<p role="status">${esc(compute.unavailable)}</p>`:`<p>${compute.offers.length} offers${cheapest?` · cheapest GPU: ${esc(cheapest.provider)} ${esc(cheapest.gpu||'')} at $${cheapest.hourly.toFixed(2)}/hour`:''}</p>`}<p><a href="/api/v1/compute">JSON</a></p></article>`;++}++function home(all,compute,q='',task='balanced',budget='') {const c=all.find(c=>c.slug==='ai-models');const taskWeights={balanced:{input:40,output:40,quality:20},writing:{input:10,output:30,quality:60},coding:{input:20,output:20,quality:60},speed:{input:10,output:10,quality:0,speed:80}};const limit=budget===''?Infinity:Number(budget);@@@ -30,8 +37,8 @@ function home(all,q='',task='balanced',budget='') {<p>Task presets adjust preferences. The available arena data does not establish task-specific coding or writing quality. Budget applies to output tokens, not monthly spend.</p><div class="picks">${picks.map(({thing:t})=>`<article class="ovk-card"><h2><a href="${thingPath(t.id)}">${esc(t.name)}</a></h2><p>Fits your output-price budget and ranks under these weights${c.results.some(r=>r.criterion==='quality')?'':'; quality has not been measured'}.</p><p>Output price (${esc(c.criteria.find(k=>k.id==='output').unit)}): ${number(c.results.find(r=>r.thing===t.id&&r.criterion==='output'))}</p></article>`).join('')||'<p>No measured picks fit this budget.</p>'}</div><form><label>Search comparisons <input name="q" value="${esc(q)}" type="search"></label><button class="ovk-btn ovk-btn--quiet">Search</button></form>-- <div class="comparison-list">${all.filter(c=>(c.title+' '+c.summary).toLowerCase().includes(q.toLowerCase())).map(c=>`<article class="ovk-card"><h2><a href="/comparisons/${c.slug}">${esc(c.title)}</a></h2><p>${esc(c.summary)}</p>${rank(c).filter(r=>r.score!==null).slice(0,3).map(r=>`<div>${esc(r.thing.name)} <meter min="0" max="100" value="${r.score}">${r.score.toFixed(1)}</meter> ${score(r.score,c)}</div>`).join('') || '<p>No comparable measurements yet.</p>'}<p>${c.things.length} things · ${c.results.length} dated results</p></article>`).join('')}</div><section id="method"><h2>How picks are ranked</h2><p>${esc(c?.method || '')}</p></section>`;++ <div class="comparison-list">${computeCard(compute,q)}${all.filter(c=>(c.title+' '+c.summary).toLowerCase().includes(q.toLowerCase())).map(c=>`<article class="ovk-card"><h2><a href="/comparisons/${c.slug}">${esc(c.title)}</a></h2><p>${esc(c.summary)}</p>${rank(c).filter(r=>r.score!==null).slice(0,3).map(r=>`<div>${esc(r.thing.name)} <meter min="0" max="100" value="${r.score}">${r.score.toFixed(1)}</meter> ${score(r.score,c)}</div>`).join('') || '<p>No comparable measurements yet.</p>'}<p>${c.things.length} things · ${c.results.length} dated results</p></article>`).join('')}</div><section id="method"><h2>How picks are ranked</h2><p>${esc(c?.method || '')}</p></section>`;}function markdown(c){return `# ${c.title}\n\n${c.summary}\n\nUpdated: ${c.updated_at}\n\n`+c.things.map(t=>`## ${t.name}\n\n`+c.criteria.map(k=>{const r=c.results.find(r=>r.thing===t.id&&r.criterion===k.id);return `- ${k.label}: ${r?`${r.value} ${k.unit} — ${r.as_of} — [${r.source.label}](${r.source.url})`:'Not measured'}`;}).join('\n')).join('\n\n')+`\n\n## Method\n\n${c.method}\n`;}--function page(title,body,path='/',ld=[]) {return shell.page({name:'OpenVibe.Compare',siteName:'OpenVibe.Compare',title,description:'Compare things with dated sources, transparent methods and your own priorities.',canonical:`https://openvibe.compare${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'compare',apiBase:'https://openvibe.network',links:[{label:'Comparisons',href:'/'}]},footer:{service:'compare',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/compare.css"><script src="/compare-account.js" defer></script>`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}--module.exports={comparison,home,page,markdown,esc,thingPath,number};++function page(title,body,path='/',ld=[]) {return shell.page({name:'OpenVibe.Compare',siteName:'OpenVibe.Compare',title,description:'Compare things with dated sources, transparent methods and your own priorities.',canonical:`https://openvibe.compare${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'compare',apiBase:'https://openvibe.network',links:[{label:'Comparisons',href:'/'},{label:'Compute',href:'/compute'}]},footer:{service:'compare',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/compare.css"><script src="/compare-account.js" defer></script>`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}++module.exports={comparison,home,page,markdown,esc,thingPath,number,computeCard};nnew file mode 100644@@@ -0,0 +1,199 @@++'use strict';++const test = require('node:test'), assert = require('node:assert/strict');++const { buildCompute, createCompute, select, parseQuery, SOURCE, MONTH_HOURS } = require('../server/compute');++const computeViews = require('../server/compute-views');++const { build } = require('../server/data');++++const at = '2026-11-11T00:00:00.000Z';++const now = Date.parse('2026-11-12T12:00:00.000Z');++const until = Date.parse('2026-11-18T00:00:00Z');++const gpu = (id, provider, gpu, gpu_mb, hourly, extra = {}) => ({ id, provider, kind: 'gpu', gpu, gpu_mb, hourly, per_second: hourly / 3600, billing_seconds: 1, checked_at: at, expires_at: until, source: `https://example.com/${provider}`, ...extra });++const cpu = (id, provider, region, cpu, ram_mb, hourly) => ({ id, provider, region, kind: 'cpu', cpu, ram_mb, gpu_mb: 0, hourly, per_second: hourly / 3600, billing_seconds: 1, checked_at: at, expires_at: until, source: `https://example.com/${provider}` });++const feed = {++ checked_at: at,++ prices: [++ gpu('daytona-4090-preemptible', 'daytona', 'RTX 4090', 24576, .57, { interruptible: true }),++ gpu('runpod-4090-pod', 'runpod', 'RTX 4090', 24576, .89, { promotion: '$10 credit' }),++ gpu('daytona-4090-ondemand', 'daytona', 'RTX 4090', 24576, .99),++ cpu('fly-iad-2x2', 'fly', 'iad', 2, 2048, 13.39 / 720),++ cpu('baseten-us-pdx-1-2x4', 'baseten', 'us-pdx-1', 2, 4096, .2154),++ { ...gpu('old-4090', 'old', 'RTX 4090', 24576, .5), checked_at: '2026-10-01T00:00:00.000Z', expires_at: '2026-11-01T00:00:00Z' },++ { id: 'bad-source', provider: 'x', kind: 'gpu', gpu: 'RTX 4090', gpu_mb: 1024, hourly: 1, checked_at: at, source: 'http://insecure.example.com' },++ { id: 'bad-price', provider: 'x', kind: 'gpu', hourly: 0, checked_at: at, source: 'https://example.com/x' },++ ],++};++const compute = buildCompute(feed, now);++++test('compute prices are normalized, derived from Exchange fields, and invalid rows are left out', () => {++ assert.equal(compute.offers.length, 6);++ assert.equal(compute.unavailable, undefined);++ assert.equal(compute.checked_at, at);++ assert.equal(compute.checked_days_ago, 1);++ assert.equal(compute.source, SOURCE);++ assert.equal(compute.offers[0].id, 'fly-iad-2x2');++ const pre = compute.offers.find((o) => o.id === 'daytona-4090-preemptible');++ assert.equal(pre.cheapest, true);++ assert.equal(pre.hourly, .57);++ assert.equal(pre.monthly_24_7, Math.round(.57 * MONTH_HOURS * 10000) / 10000);++ assert.equal(pre.hourly_per_gpu_gb, Math.round(.57 / 24 * 10000) / 10000);++ assert.equal(pre.interruptible, true);++ assert.equal(compute.cheapest.gpu, 'daytona-4090-preemptible');++ assert.equal(compute.cheapest.cpu, 'fly-iad-2x2');++ assert.equal(compute.expired_count, 1);++ assert.equal(compute.expired_at, '2026-11-01T00:00:00.000Z');++ assert.equal(compute.offers.filter((o) => o.cheapest).length, 2);++ assert.equal(computeViews.checkedLabel(compute), '1 day ago');++});++++test('filters, places for the missing GPU GB-hour, and picks respect the query and the 3-pick limit', () => {++ assert.deepEqual(select(compute, { kind: 'gpu' }).offers.map((o) => o.id), ['old-4090', 'daytona-4090-preemptible', 'runpod-4090-pod', 'daytona-4090-ondemand']);++ assert.deepEqual(select(compute, { kind: 'gpu', interruptible: 'yes' }).offers.map((o) => o.id), ['daytona-4090-preemptible']);++ assert.deepEqual(select(compute, { interruptible: 'no', kind: 'cpu' }).offers.map((o) => o.id), ['fly-iad-2x2', 'baseten-us-pdx-1-2x4']);++ assert.deepEqual(select(compute, { region: 'us-pdx-1' }).offers.map((o) => o.id), ['baseten-us-pdx-1-2x4']);++ assert.deepEqual(select(compute, { gpu: 'RTX 4090' }).offers.length, 4);++ const byGpuHour = select(compute, { sort: 'gpu-hour' }).offers;++ assert.equal(byGpuHour[0].id, 'old-4090');++ assert.deepEqual(byGpuHour.slice(-2).map((o) => o.kind), ['cpu', 'cpu']);++ const q = parseQuery({ pick: 'runpod-4090-pod,daytona-4090-preemptible,fly-iad-2x2,baseten-us-pdx-1-2x4,nope', sort: 'nope' });++ assert.deepEqual(q.pick, ['runpod-4090-pod', 'daytona-4090-preemptible', 'fly-iad-2x2']);++ assert.equal(q.sort, 'hourly');++ assert.deepEqual(select(compute, { pick: 'runpod-4090-pod,nope' }).picks.map((o) => o.id), ['runpod-4090-pod']);++});++++test('the page shows both layouts, the source line, the cheapest of each kind, expiry and a 2-3 pick side-by-side', () => {++ const html = computeViews.body(compute, {});++ assert.match(html, /compute-cards/);++ assert.match(html, /compute-table/);++ assert.match(html, /Data from <a href="https:\/\/openvibe\.exchange\/compute">OpenVibe\.Exchange<\/a>/);++ assert.match(html, /checked 1 day ago/);++ assert.match(html, /Cheapest GPU/);++ assert.match(html, /Cheapest computer/);++ assert.match(html, /per GPU GB-hour/);++ assert.match(html, /Per month \(730 h, 24\/7\)/);++ assert.match(html, /Provider price/);++ assert.match(html, /https:\/\/example\.com\/daytona/);++ assert.match(html, /models on Exchange/);++ assert.match(html, /computers on Run/);++ assert.match(html, /Review overdue/);++ assert.match(html, /passed the review date/);++ assert.match(html, /name="kind"/);++ assert.match(html, /name="gpu"/);++ assert.match(html, /name="region"/);++ assert.match(html, /name="interruptible"/);++ assert.doesNotMatch(html, /id="side-by-side"/);++ const picked = computeViews.body(compute, { pick: 'runpod-4090-pod,daytona-4090-preemptible,fly-iad-2x2' });++ assert.match(picked, /id="side-by-side"/);++ assert.match(picked, /3 offers side by side/);++ assert.match(picked, /Remove/);++ assert.match(computeViews.body(compute, { kind: 'cpu' }), /2 of 6 offers/);++});++++test('markdown and Dataset JSON-LD are generated from the same selection', () => {++ const md = computeViews.markdown(compute, { kind: 'gpu', pick: 'runpod-4090-pod,daytona-4090-preemptible' });++ assert.match(md, /# Compute prices/);++ assert.match(md, /checked 1 day ago/);++ assert.match(md, /Data from \[OpenVibe\.Exchange\]/);++ assert.match(md, /daytona/);++ assert.match(md, /cheapest of its kind/);++ assert.match(md, /## Side-by-side/);++ assert.doesNotMatch(md, /fly-iad/);++ const ld = computeViews.jsonLd(compute);++ assert.equal(ld['@type'], 'Dataset');++ assert.equal(ld.contentUrl, undefined);++ assert.equal(ld.distribution.contentUrl, 'https://openvibe.compare/api/v1/compute');++ assert.equal(ld.dateModified, at);++});++++test('the cache coalesces, lasts ten minutes, and a failed feed is not cached into a value', async () => {++ let calls = 0, t = now;++ const get = createCompute({ now: () => t, fetchImpl: async (url) => {++ calls++;++ assert.equal(url, SOURCE);++ return { ok: true, json: async () => feed };++ } });++ await Promise.all([get(), get()]);++ assert.equal(calls, 1);++ t += 599999;++ await get();++ assert.equal(calls, 1);++ t += 2;++ await get();++ assert.equal(calls, 2);++ let failures = 0;++ const down = createCompute({ now: () => t, fetchImpl: async () => { failures++; throw Error('offline'); } });++ assert.equal((await down()).unavailable, 'OpenVibe.Exchange did not answer, so no compute price is shown as current.');++ await down();++ assert.equal(failures, 2);++});++++test('API, page, phone/table markup, filters, Markdown, discovery and CORS', async () => {++ const { createApp } = require('../server/app');++ const fixture = { market: { offers: [] }, harnesses: { harnesses: [] }, runs: { runs: [] }, plans: { plans: [] }, quality: { board: { rows: [] } } };++ const data = build(fixture, at);++ const server = createApp({ getData: async () => data, getCompute: async () => compute }).listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ let r = await fetch(base + '/api/v1/compute', { headers: { Origin: 'https://openvibe.network' } });++ assert.equal(r.status, 200);++ assert.equal(r.headers.get('access-control-allow-origin'), 'https://openvibe.network');++ const body = await r.json();++ assert.equal(body.source, 'OpenVibe.Exchange');++ assert.equal(body.source_url, SOURCE);++ assert.equal(body.offers.length, 6);++ assert.equal(body.cheapest.gpu, 'daytona-4090-preemptible');++ assert.equal(body.month_hours, MONTH_HOURS);++ r = await fetch(base + '/api/v1/compute', { headers: { Origin: 'https://evil.example' } });++ assert.equal(r.headers.get('access-control-allow-origin'), null);++++ const html = await (await fetch(base + '/compute?kind=gpu&interruptible=yes&sort=gpu-hour')).text();++ assert.match(html, /compute-cards/);++ assert.match(html, /compute-table/);++ assert.match(html, /Dataset/);++ assert.match(html, /contentUrl":"https:\/\/openvibe\.compare\/api\/v1\/compute/);++ assert.match(html, /name="interruptible"/);++ assert.match(html, /href="\/compute\?kind=gpu&interruptible=yes&sort=month"/);++ assert.equal((html.match(/data-offer="runpod-4090-pod"/g) || []).length, 0);++ const picked = await (await fetch(base + '/compute?pick=runpod-4090-pod,daytona-4090-preemptible')).text();++ assert.match(picked, /id="side-by-side"/);++ assert.match(picked, /2 offers side by side/);++ const md = await (await fetch(base + '/compute', { headers: { Accept: 'text/markdown' } })).text();++ assert.match(md, /# Compute prices/);++ assert.match(md, /OpenVibe\.Exchange/);++ const llms = await (await fetch(base + '/llms.txt')).text();++ assert.match(llms, /\[Compute prices\]\(https:\/\/openvibe\.compare\/compute\)/);++ assert.match(llms, /Compute JSON: https:\/\/openvibe\.compare\/api\/v1\/compute/);++ const sitemap = await (await fetch(base + '/sitemap.xml')).text();++ assert.match(sitemap, /<loc>https:\/\/openvibe\.compare\/compute<\/loc><lastmod>2026-11-11T00:00:00.000Z<\/lastmod>/);++ const home = await (await fetch(base + '/')).text();++ assert.match(home, /href="\/compute"/);++ assert.match(home, /Compute prices/);++ assert.match(home, /cheapest GPU/);++ const homeMd = await (await fetch(base + '/', { headers: { Accept: 'text/markdown' } })).text();++ assert.match(homeMd, /\[Compute prices\]\(\/compute\)/);++ for (const url of ['/compute', '/api/v1/compute']) assert.equal((await fetch(base + url)).status, 200, url);++ } finally {++ await new Promise((r) => server.close(r));++ }++});++++test('an unavailable Exchange is an explicit unavailable state on the page and the API', async () => {++ const down = buildCompute(null, now);++ assert.equal(down.offers.length, 0);++ assert.match(computeViews.body(down, {}), /did not answer/);++ assert.match(computeViews.body(down, {}), /0 offers/);++ assert.match(computeViews.markdown(down, {}), /did not answer/);++ const { createApp } = require('../server/app');++ const fixture = { market: { offers: [] }, harnesses: { harnesses: [] }, runs: { runs: [] }, plans: { plans: [] }, quality: { board: { rows: [] } } };++ const server = createApp({ getData: async () => build(fixture, at), getCompute: async () => down }).listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ const body = await (await fetch(base + '/api/v1/compute')).json();++ assert.deepEqual(body.offers, []);++ assert.match(body.unavailable, /did not answer/);++ assert.equal((await fetch(base + '/compute')).status, 200);++ } finally {++ await new Promise((r) => server.close(r));++ }++});
@@ -3,9 +3,10 @@
@@ -3,9 +3,10 @@
## Project overview
## Project overview
Public, source-linked comparisons for the OpenVibe network: AI models (price and measured Bench
Public, source-linked comparisons for the OpenVibe network: AI models (price and measured Bench
-quality/speed), AI coding agents (Codes harnesses plus verified Bench runs) and OpenVibe plans
-(Billing's public plan list). Node.js/Express, fed entirely by other services' public APIs, **no
-database, no accounts, no write API**. Server-rendered pages; `openvibe-shared` for the shell.
+quality/speed), AI coding agents (Codes harnesses plus verified Bench runs), OpenVibe plans
+(Billing's public plan list) and compute prices (Exchange's reviewed GPU/computer tariffs).
+Node.js/Express, fed entirely by other services' public APIs, **no database, no accounts, no write
+API**. Server-rendered pages; `openvibe-shared` for the shell.
- Origin `https://openvibe.compare`, port `5280`, unit `openvibe-compare.service`.
- Origin `https://openvibe.compare`, port `5280`, unit `openvibe-compare.service`.
- One rule above all: **missing evidence is not zero**. A thing without a measurement is unranked,
- One rule above all: **missing evidence is not zero**. A thing without a measurement is unranked,
@@ -17,8 +18,9 @@ database, no accounts, no write API**. Server-rendered pages; `openvibe-shared`
@@ -17,8 +18,9 @@ database, no accounts, no write API**. Server-rendered pages; `openvibe-shared`
- `GET /comparisons/:slug` — one comparison; weight overrides via `?w.<criterion>=`.
- `GET /comparisons/:slug` — one comparison; weight overrides via `?w.<criterion>=`.
- `GET /things/:id` — one thing across every comparison that contains it; `/models/:org/:model`
- `GET /things/:id` — one thing across every comparison that contains it; `/models/:org/:model`
301s here.
301s here.
-- `GET /api/v1/comparisons[/:slug]` — the same data as JSON, CORS-allowed for the origins in
- [server/origins.json](server/origins.json).
+- `GET /compute` — compute prices from Exchange: filters, sorts and a two/three-offer side-by-side.
+- `GET /api/v1/comparisons[/:slug]` and `GET /api/v1/compute` — the same data as JSON, CORS-allowed
+ for the origins in [server/origins.json](server/origins.json).
- `robots.txt`, `sitemap.xml`, `llms.txt`, Dataset JSON-LD, and `Accept: text/markdown` negotiation —
- `robots.txt`, `sitemap.xml`, `llms.txt`, Dataset JSON-LD, and `Accept: text/markdown` negotiation —
all generated from the same data, never hand-written separately.
all generated from the same data, never hand-written separately.
@@ -27,7 +29,8 @@ schema with no API in v1).
@@ -27,7 +29,8 @@ schema with no API in v1).
## Who it reads
## Who it reads
-Five public endpoints, all in [server/data.js](server/data.js) `ENDPOINTS`:
+Six public endpoints: five in [server/data.js](server/data.js) `ENDPOINTS`, and compute in
+[server/compute.js](server/compute.js) `SOURCE`:
| Feed | What |
| Feed | What |
|---|---|
|---|---|
@@ -36,6 +39,7 @@ Five public endpoints, all in [server/data.js](server/data.js) `ENDPOINTS`:
@@ -36,6 +39,7 @@ Five public endpoints, all in [server/data.js](server/data.js) `ENDPOINTS`:
| `bench.openvibe.services/api/v1/runs?kind=codes.patch` / `codes.ticket` | verified Bench runs (OpenVibe.Bench) |
| `bench.openvibe.services/api/v1/runs?kind=codes.patch` / `codes.ticket` | verified Bench runs (OpenVibe.Bench) |
| `openvibe.codes/api/v1/harnesses` | coding harness catalog (OpenVibe.Codes) |
| `openvibe.codes/api/v1/harnesses` | coding harness catalog (OpenVibe.Codes) |
| `billing.openvibe.network/api/v1/plans` | published plans (OpenVibe.Billing) |
| `billing.openvibe.network/api/v1/plans` | published plans (OpenVibe.Billing) |
+| `openvibe.exchange/api/compute/prices` | reviewed compute tariffs per provider and region (OpenVibe.Exchange) |
It does not call AI, Live, Network or any authenticated API. Nothing here writes anywhere.
It does not call AI, Live, Network or any authenticated API. Nothing here writes anywhere.
@@ -46,6 +50,9 @@ It does not call AI, Live, Network or any authenticated API. Nothing here writes
@@ -46,6 +50,9 @@ It does not call AI, Live, Network or any authenticated API. Nothing here writes
health/ready, `/release.json`, `/api/v1`, pages, 404 and 503.
health/ready, `/release.json`, `/api/v1`, pages, 404 and 503.
- **Data:** [server/data.js](server/data.js) — fetches the five feeds (8 s timeout), builds the three
- **Data:** [server/data.js](server/data.js) — fetches the five feeds (8 s timeout), builds the three
comparisons, coalesces concurrent reads and caches for ten minutes; a failed feed becomes `null`.
comparisons, coalesces concurrent reads and caches for ten minutes; a failed feed becomes `null`.
+- **Compute:** [server/compute.js](server/compute.js) normalizes Exchange's tariffs and derives the
+ month (730 h) and GPU GB-hour prices; [server/compute-views.js](server/compute-views.js) renders the
+ page, Markdown and Dataset JSON-LD. A failed feed is not cached.
- **Views:** [server/views.js](server/views.js) — HTML, markdown, escaping, JSON-LD, the method text.
- **Views:** [server/views.js](server/views.js) — HTML, markdown, escaping, JSON-LD, the method text.
- **Ranking:** [public/rank.js](public/rank.js) — the one ranking implementation, required by both the
- **Ranking:** [public/rank.js](public/rank.js) — the one ranking implementation, required by both the
server views and the browser.
server views and the browser.
@@ -68,11 +75,11 @@ There is no `dev` script and no build step: edit `server/` or `public/` and rest
@@ -68,11 +75,11 @@ There is no `dev` script and no build step: edit `server/` or `public/` and rest
## Testing
## Testing
-`test/compare.test.js` uses Node's built-in test runner with fixture feeds. It pins the rules that
+`test/compare.test.js` and `test/compute.test.js` use Node's built-in test runner with fixture feeds. It pins the rules that
matter: every measurement is finite, dated, sourced, and references a known thing and criterion;
matter: every measurement is finite, dated, sourced, and references a known thing and criterion;
missing evidence stays missing; exact model identity only; ranking handles direction, equal values,
missing evidence stays missing; exact model identity only; ranking handles direction, equal values,
zero/invalid weights and all-zero weights (unranked); pages and JSON agree. `ov test` runs the same
zero/invalid weights and all-zero weights (unranked); pages and JSON agree. `ov test` runs the same
-file plus the repository checks the harness adds.
+file and `test/compute.test.js` plus the repository checks the harness adds.
## Conventions
## Conventions
@@ -110,14 +117,16 @@ vhost and unit in sync with [deploy/](deploy/); a docs-only change needs no depl
@@ -110,14 +117,16 @@ vhost and unit in sync with [deploy/](deploy/); a docs-only change needs no depl
- **Do not invent a write path.** Builder and version history are deliberately absent from v1.
- **Do not invent a write path.** Builder and version history are deliberately absent from v1.
- **Do not cache a failed feed into an old value.** It is `null` for that read; the next request
- **Do not cache a failed feed into an old value.** It is `null` for that read; the next request
retries, and the comparison lists it as unavailable.
retries, and the comparison lists it as unavailable.
-- **Do not hand-write `sitemap.xml`, `llms.txt` or JSON-LD.** They are generated from `getData()` so
- they cannot drift from the pages.
+- **Do not hand-write `sitemap.xml`, `llms.txt` or JSON-LD.** They are generated from `getData()` and
+ `getCompute()` so they cannot drift from the pages.
+- **Compute is read from Exchange, never copied.** A tariff's checked date and review date are shown
+ as published; an offer past `expires_at` is marked, never presented as current.
- **Keep `public/rank.js` framework-free and dual-use** (server `require` and browser global); the
- **Keep `public/rank.js` framework-free and dual-use** (server `require` and browser global); the
test loads it both ways.
test loads it both ways.
- **CORS is an exact allow-list** ([server/origins.json](server/origins.json)); no `*` and no
- **CORS is an exact allow-list** ([server/origins.json](server/origins.json)); no `*` and no
credentials.
credentials.
-- **`Accept: text/markdown`** on `/`, `/comparisons/:slug` and `/things/:id` is part of the product;
- keep it when touching the views.
+- **`Accept: text/markdown`** on `/`, `/comparisons/:slug`, `/things/:id` and `/compute` is part of
+ the product; keep it when touching the views.
- **Do not remove the 503 handler**: an unexpected feed failure must answer explicitly, not 500.
- **Do not remove the 503 handler**: an unexpected feed failure must answer explicitly, not 500.
## Documentation
## Documentation
@@ -3,9 +3,9 @@ Source-linked comparisons for anything
@@ -3,9 +3,9 @@ Source-linked comparisons for anything
Compare v1 serves public comparisons at https://openvibe.compare. Run `npm ci`, then `npm start`; check with `ov test`.
Compare v1 serves public comparisons at https://openvibe.compare. Run `npm ci`, then `npm start`; check with `ov test`.
-Data: Exchange price-book market, Bench public arena/verified runs, Codes harness catalog, Billing public plans. Reads are coalesced and cached for ten minutes. Price provenance retains the provider's checked date; a board without an update timestamp labels its date as the observation date. Failed feeds produce explicit unavailable states. There is no private data or paid execution.
+Data: Exchange price-book market and its reviewed compute tariffs, Bench public arena/verified runs, Codes harness catalog, Billing public plans. Reads are coalesced and cached for ten minutes. Price provenance retains the provider's checked date; a board without an update timestamp labels its date as the observation date. Failed feeds produce explicit unavailable states. There is no private data or paid execution. Compute prices are read from OpenVibe.Exchange's `GET /api/compute/prices`; Compare adds only derived arithmetic (per month at 24/7, per GPU GB-hour), never a copied tariff.
-Pages: `/`, `/comparisons/:slug`, `/things/:id` (URI-encoded identity); JSON at `/api/v1/comparisons[/slug]`. Pages negotiate `Accept: text/markdown`. Dataset JSON-LD, sitemap and llms.txt are generated from the same data. Desktop tables and phone cards share ranking logic. Weight URL parameters are `w.<criterion>`; all-zero weights leave things unranked. Builder and version-history fields are reserved, with no write API in v1.
+Pages: `/`, `/comparisons/:slug`, `/things/:id` (URI-encoded identity), and `/compute` (filters by kind, GPU model, region and interruptible; sorts by price per hour, per GPU GB-hour and per month at 24/7; a two- or three-offer side-by-side). JSON at `/api/v1/comparisons[/slug]` and `/api/v1/compute`. Pages negotiate `Accept: text/markdown`. Dataset JSON-LD, sitemap and llms.txt are generated from the same data. Desktop tables and phone cards share ranking logic; compute cards run to 899 px and its table starts at 900 px. Weight URL parameters are `w.<criterion>`; all-zero weights leave things unranked. Builder and version-history fields are reserved, with no write API in v1.
Launch: Host register-compare.py registers port 5280 with a lifecycle override until the separate Contracts manifest is released. Use merge-deploy.sh, then ov-vhost-install for deploy/nginx/openvibe.compare.conf. No database or service credentials are required.
Launch: Host register-compare.py registers port 5280 with a lifecycle override until the separate Contracts manifest is released. Use merge-deploy.sh, then ov-vhost-install for deploy/nginx/openvibe.compare.conf. No database or service credentials are required.
@@ -17,3 +17,43 @@ main a,main summary{overflow-wrap:anywhere}
@@ -17,3 +17,43 @@ main a,main summary{overflow-wrap:anywhere}
/* Shared's 34px sign-in link has an 8px expanded hit area. At the phone's
/* Shared's 34px sign-in link has an 8px expanded hit area. At the phone's
6px outer gutter it overruns the viewport by 2px; keep a 48px tap area. */
6px outer gutter it overruns the viewport by 2px; keep a 48px tap area. */
@media(max-width:799px){#navbar-mount .openvibe-navbar .openvibe-navbar-login::after{right:-6px}}
@media(max-width:799px){#navbar-mount .openvibe-navbar .openvibe-navbar-login::after{right:-6px}}
+/* Compute prices: cards on a phone, a table from 900 px, and a side-by-side that scrolls inside its box. */
+.compute-source{margin:1rem 0;overflow-wrap:anywhere}
+.compute-filters fieldset{border:1px solid currentColor;border-radius:.5rem;padding:0 1rem 1rem}
+.compute-filters .ovk-set-row{border-top:0;padding:.35rem 0;flex-wrap:wrap;gap:.5rem}
+.compute-filters select{max-width:100%}
+.compute-filters .compute-submit{margin:.75rem 0 0}
+.compute-sort{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem;margin:1rem 0}
+.compute-sort a[aria-current]{font-weight:700;text-decoration:none}
+.compute-count{overflow-wrap:anywhere}
+.compute-picked{overflow-wrap:anywhere}
+.compute-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,18rem),1fr));gap:1rem}
+.compute-card{display:flex;flex-direction:column}
+.compute-card h2{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;margin:0 0 .5rem;overflow-wrap:anywhere}
+.compute-kind{font-weight:400;opacity:.8}
+.compute-price{margin:.25rem 0 .75rem}
+.compute-price b{font-size:1.6rem}
+.compute-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.5rem 1rem;margin:.5rem 0}
+.compute-facts>div{min-width:0}
+.compute-facts dt{font-size:.8rem;opacity:.8}
+.compute-facts dd{margin:.1rem 0 0;overflow-wrap:anywhere}
+.compute-promo,.compute-overdue{margin:.5rem 0;overflow-wrap:anywhere}
+.compute-promo{font-size:.9rem}
+.compute-links{display:flex;flex-wrap:wrap;gap:.35rem 1rem;margin:.75rem 0 0}
+.compute-use{margin-top:auto;padding-top:.5rem}
+.compute-use-links{display:inline;overflow-wrap:anywhere}
+.compute-table{display:none;max-width:100%;overflow:auto}
+.compute-table table,.compute-side table{width:100%;border-collapse:collapse}
+.compute-table th,.compute-table td,.compute-side th,.compute-side td{padding:.5rem;border-bottom:1px solid currentColor;text-align:left;vertical-align:top;overflow-wrap:anywhere}
+.compute-table caption,.compute-side caption{text-align:left;padding:.5rem 0}
+.compute-side{margin-top:2rem;max-width:100%}
+.compute-side-scroll{max-width:100%;overflow-x:auto}
+.compute-side table{min-width:0;table-layout:fixed}
+.compute-side thead th:first-child{width:28%}
+.compute-side th,.compute-side td{font-size:.9rem}
+.compute-num{white-space:nowrap;font-variant-numeric:tabular-nums}
+.compute-side thead a{font-size:.85rem}
+.compute-empty{padding:1rem 0}
+.compute-filters .ovk-set-text{min-width:0}
+@media(min-width:900px){.compute-cards{display:none}.compute-table{display:block}}
+@media(max-width:899px){.compute-table{display:none}}
@@ -2,9 +2,11 @@
@@ -2,9 +2,11 @@
const express=require('express');
const express=require('express');
const path=require('node:path');
const path=require('node:path');
const {createData}=require('./data');
const {createData}=require('./data');
+const {createCompute,api:computeApi}=require('./compute');
+const computeViews=require('./compute-views');
const v=require('./views');
const v=require('./views');
const origins=new Set(require('./origins.json'));
const origins=new Set(require('./origins.json'));
-function createApp({getData=createData()}={}) {
+function createApp({getData=createData(),getCompute=createCompute()}={}) {
const app=express();app.disable('x-powered-by');
const app=express();app.disable('x-powered-by');
app.use((req,res,next)=>{res.set('X-Content-Type-Options','nosniff');res.set('Referrer-Policy','strict-origin-when-cross-origin');next();});
app.use((req,res,next)=>{res.set('X-Content-Type-Options','nosniff');res.set('Referrer-Policy','strict-origin-when-cross-origin');next();});
app.use(express.static(path.join(__dirname,'../public')));
app.use(express.static(path.join(__dirname,'../public')));
@@ -19,20 +21,22 @@ function createApp({getData=createData()}={}) {
@@ -19,20 +21,22 @@ function createApp({getData=createData()}={}) {
res.vary('Origin');res.set('Access-Control-Allow-Methods','GET, HEAD, OPTIONS');
res.vary('Origin');res.set('Access-Control-Allow-Methods','GET, HEAD, OPTIONS');
if(req.method==='OPTIONS')return res.sendStatus(204);next();
if(req.method==='OPTIONS')return res.sendStatus(204);next();
});
});
+ app.get('/api/v1/compute',wrap(async(req,res)=>res.json(computeApi(await getCompute()))));
app.get('/api/v1/comparisons',wrap(async(req,res)=>res.json({comparisons:await getData()})));
app.get('/api/v1/comparisons',wrap(async(req,res)=>res.json({comparisons:await getData()})));
app.get('/api/v1/comparisons/:slug',wrap(async(req,res)=>{const c=(await getData()).find(c=>c.slug===req.params.slug);return c?res.json(c):res.status(404).json({error:'comparison.not_found'});}));
app.get('/api/v1/comparisons/:slug',wrap(async(req,res)=>{const c=(await getData()).find(c=>c.slug===req.params.slug);return c?res.json(c):res.status(404).json({error:'comparison.not_found'});}));
app.get('/robots.txt',(_q,r)=>r.type('text').send('User-agent: *\nAllow: /\nSitemap: https://openvibe.compare/sitemap.xml\n'));
app.get('/robots.txt',(_q,r)=>r.type('text').send('User-agent: *\nAllow: /\nSitemap: https://openvibe.compare/sitemap.xml\n'));
- app.get('/llms.txt',wrap(async(_q,r)=>r.type('text').send('# OpenVibe.Compare\n\nDated comparisons. Missing evidence is not zero.\n\n'+(await getData()).map(c=>`- [${c.title}](https://openvibe.compare/comparisons/${c.slug}): ${c.summary}`).join('\n')+'\n\nJSON: https://openvibe.compare/api/v1/comparisons\nEach page also accepts Accept: text/markdown.\n')));
+ app.get('/llms.txt',wrap(async(_q,r)=>{const [cs,compute]=await Promise.all([getData(),getCompute()]);return r.type('text').send('# OpenVibe.Compare\n\nDated comparisons. Missing evidence is not zero.\n\n'+`- [Compute prices](https://openvibe.compare/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month at 24/7${compute.checked_at?`; checked ${compute.checked_at.slice(0,10)}`:''}.\n`+cs.map(c=>`- [${c.title}](https://openvibe.compare/comparisons/${c.slug}): ${c.summary}`).join('\n')+'\n\nJSON: https://openvibe.compare/api/v1/comparisons\nCompute JSON: https://openvibe.compare/api/v1/compute\nEach page also accepts Accept: text/markdown.\n')}));
app.get('/sitemap.xml',wrap(async(_q,r)=>{
app.get('/sitemap.xml',wrap(async(_q,r)=>{
- const cs=await getData();const paths=new Map([['/',cs.map(c=>c.updated_at).sort().at(-1)]]);
+ const [cs,compute]=await Promise.all([getData(),getCompute()]);const newest=cs.map(c=>c.updated_at).sort().at(-1);const paths=new Map([['/',newest],['/compute',compute.checked_at||newest]]);
for(const c of cs){paths.set(`/comparisons/${c.slug}`,c.updated_at);for(const t of c.things)paths.set(v.thingPath(t.id),c.updated_at);}
for(const c of cs){paths.set(`/comparisons/${c.slug}`,c.updated_at);for(const t of c.things)paths.set(v.thingPath(t.id),c.updated_at);}
r.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+[...paths].map(([p,d])=>`<url><loc>https://openvibe.compare${v.esc(p)}</loc><lastmod>${v.esc(d)}</lastmod></url>`).join('')+'</urlset>');
r.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+[...paths].map(([p,d])=>`<url><loc>https://openvibe.compare${v.esc(p)}</loc><lastmod>${v.esc(d)}</lastmod></url>`).join('')+'</urlset>');
}));
}));
app.get('/',wrap(async(req,res)=>{
app.get('/',wrap(async(req,res)=>{
- const all=await getData();res.vary('Accept');
- if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send('# Compare anything\n\n'+all.map(c=>`- [${c.title}](/comparisons/${c.slug}): ${c.summary}`).join('\n'));
- res.send(v.page('Compare anything',v.home(all,String(req.query.q||'').slice(0,120),String(req.query.task||'balanced'),String(req.query.budget||'').slice(0,20))));
+ const [all,compute]=await Promise.all([getData(),getCompute()]);res.vary('Accept');
+ if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send('# Compare anything\n\n- [Compute prices](/compute): GPUs and computers from OpenVibe.Exchange, per hour, per GPU GB-hour and per month.\n'+all.map(c=>`- [${c.title}](/comparisons/${c.slug}): ${c.summary}`).join('\n'));
+ res.send(v.page('Compare anything',v.home(all,compute,String(req.query.q||'').slice(0,120),String(req.query.task||'balanced'),String(req.query.budget||'').slice(0,20))));
}));
}));
+ app.get('/compute',wrap(async(req,res)=>{const compute=await getCompute();res.vary('Accept');if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(computeViews.markdown(compute,req.query));res.send(v.page('Compute prices',computeViews.body(compute,req.query),'/compute',[computeViews.jsonLd(compute)]));}));
app.get('/comparisons/:slug',wrap(async(req,res)=>{
app.get('/comparisons/:slug',wrap(async(req,res)=>{
const c=(await getData()).find(c=>c.slug===req.params.slug);if(!c)return res.sendStatus(404);res.vary('Accept');
const c=(await getData()).find(c=>c.slug===req.params.slug);if(!c)return res.sendStatus(404);res.vary('Accept');
if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(v.markdown(c));
if(req.accepts(['html','text/markdown'])==='text/markdown')return res.type('text/markdown').send(v.markdown(c));
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+/** The /compute page: filters, sorts and a side-by-side over Exchange's reviewed compute prices. */
+const kit = require('openvibe-shared/kit');
+const { PAGE, MONTH_HOURS, select, href, label } = require('./compute');
+
+const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const json = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
+const day = (iso) => (iso ? iso.slice(0, 10) : '—');
+const month = (iso) => (iso ? new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : '—');
+const money = (v) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return '—';
+ const digits = Math.abs(n) >= 1 ? 2 : 4;
+ return `$${n.toFixed(digits).replace(/\.?0+$/, '')}`;
+};
+const precise = (v) => { const n = Number(v); return Number.isFinite(n) ? `$${n.toFixed(8).replace(/\.?0+$/, '')}` : '—'; };
+const gb = (mb) => (Number.isFinite(Number(mb)) && Number(mb) > 0 ? `${Math.round(Number(mb) / 1024 * 10) / 10} GB` : null);
+const memory = (o) => (o.kind === 'gpu' ? [o.gpu, gb(o.gpu_mb)].filter(Boolean).join(' · ') : [o.cpu ? `${o.cpu} vCPU` : null, gb(o.ram_mb)].filter(Boolean).join(' · '));
+const checkedLabel = (c) => (c.checked_days_ago == null ? 'at an unknown date' : c.checked_days_ago === 0 ? 'today' : c.checked_days_ago === 1 ? '1 day ago' : `${c.checked_days_ago} days ago`);
+const offerName = (o) => `${o.provider} ${memory(o) || label(o.kind)}${o.kind === 'gpu' ? ` (${o.interruptible ? 'interruptible' : 'on demand'})` : ''}`;
+const badge = (text, tone = '') => kit.badge(text, tone);
+
+function cheapestLine(compute) {
+ const pick = (kind) => compute.offers.find((o) => o.id === compute.cheapest[kind]);
+ const parts = [];
+ for (const kind of ['gpu', 'cpu']) {
+ const o = pick(kind);
+ if (o) parts.push(`cheapest ${kind === 'gpu' ? 'GPU' : 'computer'}: <strong>${esc(o.provider)} ${esc(memory(o) || '')}</strong> at ${money(o.hourly)}/hour`);
+ }
+ return parts.length ? ` · ${parts.join(' · ')}` : '';
+}
+
+function sortNav(q) {
+ const sorts = [['hourly', 'price per hour'], ['gpu-hour', 'price per GPU GB-hour'], ['month', 'price per month (24/7)']];
+ return `<nav class="compute-sort" aria-label="Sort prices"><span>Sort by</span>${sorts.map(([v, l]) => `<a href="${esc(href(q, { sort: v }))}"${q.sort === v ? ' aria-current="true"' : ''}>${esc(l)}</a>`).join('')}</nav>`;
+}
+
+function pickLink(o, q) {
+ const picked = q.pick.includes(o.id);
+ if (picked) return { href: href(q, { pick: q.pick.filter((id) => id !== o.id) }), label: 'Remove from side-by-side' };
+ if (q.pick.length >= 3) return { href: null, label: 'Side-by-side is full (3)' };
+ return { href: href(q, { pick: [...q.pick, o.id] }), label: 'Add to side-by-side' };
+}
+
+function flags(o) {
+ return [
+ o.cheapest ? badge(`Cheapest ${o.kind === 'gpu' ? 'GPU' : 'computer'}`, 'ok') : '',
+ o.interruptible ? badge('Interruptible') : '',
+ o.expired ? badge('Review overdue', 'warn') : '',
+ ].filter(Boolean).join('');
+}
+
+function useLinks() {
+ return `<span class="compute-use-links">Use it on OpenVibe: <a href="https://openvibe.exchange/models">models on Exchange</a> · <a href="https://openvibe.run/">computers on Run</a></span>`;
+}
+
+function card(o, q) {
+ const p = pickLink(o, q);
+ return `<article class="ovk-card compute-card" data-offer="${esc(o.id)}">
+<h2><span>${esc(o.provider)}</span> <small class="compute-kind">${label(o.kind)}</small>${flags(o)}</h2>
+<p class="compute-price"><b>${money(o.hourly)}</b> per hour</p>
+<dl class="compute-facts">
+<div><dt>${o.kind === 'gpu' ? 'GPU' : 'Computer'}</dt><dd>${esc(memory(o) || 'Not stated')}</dd></div>
+<div><dt>Region</dt><dd>${esc(o.region || 'Not stated')}</dd></div>
+<div><dt>Per month (${MONTH_HOURS} h, 24/7)</dt><dd>${money(o.monthly_24_7)}</dd></div>
+<div><dt>${o.kind === 'gpu' ? 'Per GPU GB-hour' : 'Per second'}</dt><dd>${o.kind === 'gpu' && o.hourly_per_gpu_gb != null ? money(o.hourly_per_gpu_gb) : precise(o.per_second)}</dd></div>
+<div><dt>Checked</dt><dd><time datetime="${esc(o.checked_at)}">${day(o.checked_at)}</time></dd></div>
+<div><dt>Billing</dt><dd>${o.billing_seconds ? `per second (${o.billing_seconds} s) ` : 'per second'}</dd></div>
+</dl>
+${o.promotion ? `<p class="compute-promo">${esc(o.promotion)}${o.promotion_expires ? ` (ends ${esc(day(o.promotion_expires))})` : ''}</p>` : ''}
+${o.expired ? `<p class="compute-overdue">Review date passed <time datetime="${esc(o.expires_at)}">${esc(month(o.expires_at))}</time>: this is the last reviewed price, not a re-checked one.</p>` : ''}
+<p class="compute-links"><a href="${esc(o.source)}">Provider price</a>${p.href ? ` · <a href="${esc(p.href)}" rel="nofollow">${esc(p.label)}</a>` : ` · <span>${esc(p.label)}</span>`}</p>
+<p class="compute-use">${useLinks()}</p>
+</article>`;
+}
+
+function table(offers, q) {
+ return `<div class="compute-table"><table><caption>Compute prices, cheapest first</caption><thead><tr><th scope="col">Provider</th><th scope="col">Kind and resources</th><th scope="col">Region</th><th scope="col">Per hour</th><th scope="col">Per GPU GB-hour</th><th scope="col">Per month (${MONTH_HOURS} h)</th><th scope="col">Notes</th><th scope="col">Side-by-side</th></tr></thead><tbody>${offers.map((o) => {
+ const p = pickLink(o, q);
+ return `<tr data-offer="${esc(o.id)}"><th scope="row"><a href="${esc(o.source)}">${esc(o.provider)}</a><br><small>${esc(o.id)}</small></th><td>${esc(memory(o) || 'Not stated')}</td><td>${esc(o.region || '—')}</td><td class="compute-num">${money(o.hourly)}</td><td class="compute-num">${o.hourly_per_gpu_gb != null ? money(o.hourly_per_gpu_gb) : '—'}</td><td class="compute-num">${money(o.monthly_24_7)}</td><td>${flags(o) || '—'}${o.promotion ? `<br><small>${esc(o.promotion)}</small>` : ''}${o.expired ? `<br><small>Review date passed ${esc(day(o.expires_at))}</small>` : ''}</td><td>${p.href ? `<a href="${esc(p.href)}" rel="nofollow">${esc(q.pick.includes(o.id) ? 'Remove' : 'Add')}</a>` : '—'}</td></tr>`;
+ }).join('')}</tbody></table></div>`;
+}
+
+function sideBySide(picks, q) {
+ const rows = [
+ ['Kind', (o) => label(o.kind)],
+ ['Resources', (o) => esc(memory(o) || '—')],
+ ['Region', (o) => esc(o.region || '—')],
+ ['Per hour', (o) => money(o.hourly)],
+ ['Per second', (o) => precise(o.per_second)],
+ ['Per month (24/7)', (o) => money(o.monthly_24_7)],
+ ['Per GPU GB-hour', (o) => (o.hourly_per_gpu_gb != null ? money(o.hourly_per_gpu_gb) : '—')],
+ ['Interruptible', (o) => (o.interruptible ? 'Yes' : 'No')],
+ ['Billing', (o) => esc(o.billing_seconds ? `per second (${o.billing_seconds} s)` : 'per second')],
+ ['Promotion', (o) => (o.promotion ? esc(o.promotion) : '—')],
+ ['Checked', (o) => `<time datetime="${esc(o.checked_at)}">${day(o.checked_at)}</time>`],
+ ['Review due', (o) => `<time datetime="${esc(o.expires_at)}">${esc(day(o.expires_at))}</time>${o.expired ? ` ${badge('Overdue', 'warn')}` : ''}`],
+ ['Provider price', (o) => `<a href="${esc(o.source)}">${esc(o.provider)}</a>`],
+ ['Use it on OpenVibe', () => `<a href="https://openvibe.exchange/models">models on Exchange</a> · <a href="https://openvibe.run/">computers on Run</a>`],
+ ];
+ return `<section class="compute-side" id="side-by-side" aria-labelledby="side-title"><h2 id="side-title">Side-by-side</h2><p>Two or three picks, the facts that differ.</p><div class="compute-side-scroll"><table><caption>${picks.length} offers side by side</caption><thead><tr><th scope="col">Field</th>${picks.map((o) => `<th scope="col">${esc(o.provider)}<br><small>${esc(memory(o) || label(o.kind))}${o.kind === 'gpu' ? ` · ${o.interruptible ? 'interruptible' : 'on demand'}` : ''}</small><br><a href="${esc(href(q, { pick: q.pick.filter((id) => id !== o.id) }))}">Remove</a></th>`).join('')}</tr></thead><tbody>${rows.map(([name, value]) => `<tr><th scope="row">${name}</th>${picks.map((o) => `<td>${value(o)}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
+}
+
+function filters(compute, q) {
+ const gpus = [...new Set(compute.offers.map((o) => o.gpu).filter(Boolean))].sort();
+ const regions = [...new Set(compute.offers.map((o) => o.region).filter(Boolean))].sort();
+ if (q.gpu && !gpus.includes(q.gpu)) gpus.push(q.gpu);
+ if (q.region && !regions.includes(q.region)) regions.push(q.region);
+ return `<form class="compute-filters" method="get" action="/compute"><fieldset><legend>Filter prices</legend>
+${kit.selectRow({ name: 'kind', label: 'Kind', options: [['', 'GPUs and computers'], ['gpu', 'GPUs'], ['cpu', 'Computers']], value: q.kind })}
+${kit.selectRow({ name: 'gpu', label: 'GPU model', options: [['', 'Any GPU model'], ...gpus.map((g) => [g, g])], value: q.gpu })}
+${kit.selectRow({ name: 'region', label: 'Region', options: [['', 'Any region'], ...regions.map((r) => [r, r])], value: q.region })}
+${kit.selectRow({ name: 'interruptible', label: 'Pricing', options: [['', 'Any pricing'], ['no', 'On demand'], ['yes', 'Interruptible']], value: q.interruptible })}
+${q.sort !== 'hourly' ? `<input type="hidden" name="sort" value="${esc(q.sort)}">` : ''}${q.pick.length ? `<input type="hidden" name="pick" value="${esc(q.pick.join(','))}">` : ''}
+<p class="compute-submit"><button class="ovk-btn ovk-btn--primary" type="submit">Show prices</button> <a href="/compute">Reset</a></p>
+</fieldset></form>`;
+}
+
+function body(compute, query) {
+ const { q, offers, picks } = select(compute, query);
+ const when = checkedLabel(compute);
+ return `${kit.head({ title: 'Compute prices', icon: 'server', sub: `What an hour of a GPU or a computer costs at each provider, checked ${when}.`, sticky: false })}
+<p class="compute-source">Data from <a href="${esc(PAGE)}">OpenVibe.Exchange</a> · <a href="/api/v1/compute">JSON</a> · USD${compute.checked_at ? ` · checked <time datetime="${esc(compute.checked_at)}">${esc(day(compute.checked_at))}</time>` : ''}</p>
+${compute.unavailable ? kit.notice({ kind: 'warn', text: compute.unavailable }) : ''}
+${compute.expired_count ? kit.notice({ kind: 'warn', text: `${compute.expired_count} offer${compute.expired_count === 1 ? '' : 's'} passed the review date${compute.expired_at ? ` on ${month(compute.expired_at)}` : ''}; shown as the last reviewed price, not as current.` }) : ''}
+${filters(compute, q)}
+${sortNav(q)}
+<p role="status" class="compute-count">${offers.length === compute.offers.length ? `${offers.length} offer${offers.length === 1 ? '' : 's'}` : `${offers.length} of ${compute.offers.length} offers`}${cheapestLine(compute)}</p>
+${picks.length ? `<p class="compute-picked">Side-by-side: ${picks.map((o) => esc(offerName(o))).join(' vs ')}${picks.length < 2 ? ' (pick one more)' : ` · <a href="${esc(href(q, { pick: [] }))}">Clear</a>`}</p>` : ''}
+${offers.length ? `<div class="compute-cards">${offers.map((o) => card(o, q)).join('')}</div>${table(offers, q)}` : `<p class="compute-empty">No offers match these filters. <a href="/compute">Clear them</a>.</p>`}
+${picks.length >= 2 ? sideBySide(picks, q) : ''}
+<section class="compute-use"><h2>Use it on OpenVibe</h2><p>${useLinks()}. The provider sets the price above; OpenVibe's own rate for a model run is on <a href="https://openvibe.exchange/models">Exchange</a>.</p></section>
+`;
+}
+
+function markdown(compute, query) {
+ const { q, offers, picks } = select(compute, query);
+ const lines = [`# Compute prices`, '', `What an hour of a GPU or a computer costs at each provider, checked ${checkedLabel(compute)}.`, '', `Data from [OpenVibe.Exchange](${PAGE}); JSON at https://openvibe.compare/api/v1/compute. Prices in USD, reviewed on provider price pages.`, ''];
+ if (compute.unavailable) lines.push(`> ${compute.unavailable}`, '');
+ if (compute.expired_count) lines.push(`> ${compute.expired_count} offers passed their review date; shown as the last reviewed price, not as current.`, '');
+ if (q.kind || q.gpu || q.region || q.interruptible) lines.push(`Filtered: ${[q.kind && label(q.kind), q.gpu, q.region, q.interruptible && (q.interruptible === 'yes' ? 'interruptible' : 'on demand')].filter(Boolean).join(', ')}.`, '');
+ lines.push(`Sorted by ${q.sort === 'gpu-hour' ? 'price per GPU GB-hour' : q.sort === 'month' ? 'price per month' : 'price per hour'}.`, '');
+ for (const o of offers) {
+ lines.push(`## ${offerName(o)}`, '');
+ lines.push(`- Kind: ${label(o.kind)}`);
+ lines.push(`- Region: ${o.region || 'not stated'}`);
+ lines.push(`- Per hour: ${money(o.hourly)}${o.cheapest ? ' (cheapest of its kind)' : ''}`);
+ if (o.hourly_per_gpu_gb != null) lines.push(`- Per GPU GB-hour: ${money(o.hourly_per_gpu_gb)}`);
+ lines.push(`- Per month (${MONTH_HOURS} h, 24/7): ${money(o.monthly_24_7)}`);
+ lines.push(`- Interruptible: ${o.interruptible ? 'yes' : 'no'}`);
+ lines.push(`- Checked: ${day(o.checked_at)}; review due: ${o.expires_at ? day(o.expires_at) : 'not stated'}${o.expired ? ' (passed)' : ''}`);
+ if (o.promotion) lines.push(`- Promotion: ${o.promotion}`);
+ lines.push(`- Source: [${o.provider} price page](${o.source})`, '');
+ }
+ if (picks.length) {
+ lines.push('## Side-by-side', '');
+ for (const o of picks) lines.push(`### ${offerName(o)}`, `- ${money(o.hourly)} per hour; ${o.hourly_per_gpu_gb != null ? `${money(o.hourly_per_gpu_gb)} per GPU GB-hour; ` : ''}${money(o.monthly_24_7)} per month (24/7); ${o.interruptible ? 'interruptible' : 'on demand'}`, '');
+ }
+ lines.push('## Use it on OpenVibe', '', 'Run models on [OpenVibe.Exchange](https://openvibe.exchange/models) or computers and jobs on [OpenVibe.Run](https://openvibe.run/).', '');
+ return lines.join('\n');
+}
+
+function jsonLd(compute) {
+ return {
+ '@context': 'https://schema.org', '@type': 'Dataset', name: 'Compute prices',
+ description: 'What an hour of a GPU or a computer costs at each reviewed provider, from OpenVibe.Exchange.',
+ url: 'https://openvibe.compare/compute', dateModified: compute.checked_at || compute.retrieved_at,
+ creator: { '@type': 'Organization', name: 'OpenVibe' },
+ distribution: { '@type': 'DataDownload', encodingFormat: 'application/json', contentUrl: 'https://openvibe.compare/api/v1/compute' },
+ };
+}
+
+module.exports = { body, markdown, jsonLd, checkedLabel };
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/** Compute prices on OpenVibe.Compare: read once from OpenVibe.Exchange, normalized, never copied.
+ * Exchange publishes reviewed provider tariffs at GET /api/compute/prices; Compare adds only derived
+ * arithmetic (per month at 24/7 and per GPU GB-hour) and the page's filters, sorts and picks. */
+const SOURCE = 'https://openvibe.exchange/api/compute/prices';
+const PAGE = 'https://openvibe.exchange/compute';
+const MONTH_HOURS = 730;
+const DAY = 86400000;
+const KINDS = ['gpu', 'cpu'];
+const SORTS = ['hourly', 'gpu-hour', 'month'];
+
+const text = (v) => (typeof v === 'string' ? v : '');
+const positive = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
+const nonNegative = (v) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : null; };
+const iso = (v) => {
+ if (v == null || v === '') return null;
+ const t = typeof v === 'number' ? v : Date.parse(String(v));
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
+};
+const label = (kind) => (kind === 'gpu' ? 'GPU' : 'computer');
+
+function offer(p, now) {
+ if (!p || typeof p.id !== 'string' || !p.id) return null;
+ const hourly = positive(p.hourly);
+ const kind = KINDS.includes(p.kind) ? p.kind : null;
+ const checked_at = iso(p.checked_at);
+ const source = text(p.source);
+ if (!hourly || !kind || !checked_at || !/^https:\/\//.test(source)) return null;
+ const gpu_mb = kind === 'gpu' ? positive(p.gpu_mb) : null;
+ const expires_at = iso(p.expires_at);
+ const per_second = positive(p.per_second) || hourly / 3600;
+ return {
+ id: p.id,
+ provider: text(p.provider) || 'unknown',
+ region: text(p.region) || null,
+ kind,
+ cpu: nonNegative(p.cpu),
+ ram_mb: positive(p.ram_mb),
+ gpu: kind === 'gpu' ? text(p.gpu) || 'GPU' : null,
+ gpu_mb,
+ hourly,
+ per_second,
+ monthly_24_7: Math.round(hourly * MONTH_HOURS * 10000) / 10000,
+ hourly_per_gpu_gb: gpu_mb ? Math.round(hourly / (gpu_mb / 1024) * 10000) / 10000 : null,
+ billing_seconds: positive(p.billing_seconds),
+ interruptible: Boolean(p.interruptible),
+ promotion: text(p.promotion) || null,
+ promotion_expires: iso(p.promotion_expires),
+ checked_at,
+ expires_at,
+ expired: Boolean(expires_at && Date.parse(expires_at) < now),
+ source,
+ };
+}
+
+function buildCompute(feed, now = Date.now()) {
+ if (!feed || !Array.isArray(feed.prices)) {
+ return {
+ source: SOURCE, page: PAGE, checked_at: null, checked_days_ago: null, retrieved_at: new Date(now).toISOString(),
+ offers: [], cheapest: { gpu: null, cpu: null }, expired_count: 0,
+ unavailable: 'OpenVibe.Exchange did not answer, so no compute price is shown as current.',
+ };
+ }
+ const offers = feed.prices.map((p) => offer(p, now)).filter(Boolean)
+ .sort((a, b) => a.hourly - b.hourly || a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
+ const checked_at = iso(feed.checked_at) || [...offers].map((o) => o.checked_at).sort().at(-1) || null;
+ const cheapest = { gpu: null, cpu: null };
+ // An offer past its review date is not marked cheapest: the badge means a current price.
+ for (const pool of [offers.filter((o) => !o.expired), offers]) for (const o of pool) if (!cheapest[o.kind]) cheapest[o.kind] = o.id;
+ for (const o of offers) o.cheapest = o.id === cheapest[o.kind];
+ const expired = offers.filter((o) => o.expired);
+ return {
+ source: SOURCE, page: PAGE, checked_at,
+ checked_days_ago: checked_at ? Math.max(0, Math.floor((now - Date.parse(checked_at)) / DAY)) : null,
+ retrieved_at: new Date(now).toISOString(),
+ offers, cheapest,
+ expired_count: expired.length,
+ expired_at: expired.length ? expired.map((o) => o.expires_at).sort()[0] : null,
+ };
+}
+
+function parseQuery(query = {}) {
+ const pick = [...new Set(text(query.pick).split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 3);
+ return {
+ kind: KINDS.includes(text(query.kind)) ? text(query.kind) : '',
+ gpu: text(query.gpu).slice(0, 80),
+ region: text(query.region).slice(0, 80),
+ interruptible: ['yes', 'no'].includes(text(query.interruptible)) ? text(query.interruptible) : '',
+ sort: SORTS.includes(text(query.sort)) ? text(query.sort) : 'hourly',
+ pick,
+ };
+}
+
+function sortOffers(offers, sort) {
+ const value = (o) => (sort === 'gpu-hour' ? o.hourly_per_gpu_gb : sort === 'month' ? o.monthly_24_7 : o.hourly);
+ return [...offers].sort((a, b) => {
+ const x = value(a), y = value(b);
+ if (x == null && y == null) return a.hourly - b.hourly;
+ if (x == null) return 1;
+ if (y == null) return -1;
+ return x - y || a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);
+ });
+}
+
+/** What the page shows for one query: the filters, the sorted offers, and the picks that still exist. */
+function select(compute, query = {}) {
+ const q = parseQuery(query);
+ const offers = (compute.offers || []).filter((o) =>
+ (!q.kind || o.kind === q.kind) &&
+ (!q.gpu || o.gpu === q.gpu) &&
+ (!q.region || o.region === q.region) &&
+ (!q.interruptible || o.interruptible === (q.interruptible === 'yes')));
+ const picks = q.pick.map((id) => (compute.offers || []).find((o) => o.id === id)).filter(Boolean);
+ return { q, offers: sortOffers(offers, q.sort), picks };
+}
+
+/** The query string without the defaults, so every link stays short and shareable. */
+function queryString(q = {}) {
+ const p = new URLSearchParams();
+ if (q.kind) p.set('kind', q.kind);
+ if (q.gpu) p.set('gpu', q.gpu);
+ if (q.region) p.set('region', q.region);
+ if (q.interruptible) p.set('interruptible', q.interruptible);
+ if (q.sort && q.sort !== 'hourly') p.set('sort', q.sort);
+ if (q.pick && q.pick.length) p.set('pick', q.pick.join(','));
+ return p;
+}
+const href = (q, changes = {}) => { const s = queryString({ ...q, ...changes }).toString(); return `/compute${s ? `?${s}` : ''}`; };
+
+function api(compute) {
+ return {
+ source: 'OpenVibe.Exchange',
+ source_url: compute.source,
+ page: compute.page,
+ retrieved_at: compute.retrieved_at,
+ checked_at: compute.checked_at,
+ currency: 'USD',
+ month_hours: MONTH_HOURS,
+ offers: compute.offers,
+ cheapest: compute.cheapest,
+ expired_count: compute.expired_count,
+ ...(compute.unavailable ? { unavailable: compute.unavailable } : {}),
+ };
+}
+
+function createCompute({ fetchImpl = fetch, now = Date.now, ttl = 600000 } = {}) {
+ let cached, expires = 0, pending;
+ return async function get() {
+ if (cached && now() < expires) return cached;
+ if (pending) return pending;
+ pending = (async () => {
+ let feed = null;
+ try {
+ const r = await fetchImpl(SOURCE, { signal: AbortSignal.timeout(8000), headers: { Accept: 'application/json' } });
+ if (r.ok) feed = await r.json();
+ } catch { feed = null; }
+ // A failed feed is not cached into a value: the next request retries it.
+ if (!feed) return buildCompute(null, now());
+ cached = buildCompute(feed, now());
+ expires = now() + ttl;
+ return cached;
+ })().finally(() => { pending = null; });
+ return pending;
+ };
+}
+
+module.exports = { SOURCE, PAGE, MONTH_HOURS, buildCompute, createCompute, parseQuery, select, sortOffers, queryString, href, api, label };
@@ -21,7 +21,14 @@ function comparison(c,input={}) {
@@ -21,7 +21,14 @@ function comparison(c,input={}) {
<section id="method"><h2>Method</h2><p>${esc(c.method)}</p><h3>Sources</h3><ul>${c.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.label)}</a></li>`).join('')}</ul></section>
<section id="method"><h2>Method</h2><p>${esc(c.method)}</p><h3>Sources</h3><ul>${c.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.label)}</a></li>`).join('')}</ul></section>
<script type="application/json" id="comparison-data">${json(c)}</script><script src="/rank.js" defer></script><script src="/comparison.js" defer></script>`;
<script type="application/json" id="comparison-data">${json(c)}</script><script src="/rank.js" defer></script><script src="/comparison.js" defer></script>`;
}
}
-function home(all,q='',task='balanced',budget='') {
+function computeCard(compute,q='') {
+ const when=compute.checked_days_ago==null?'at an unknown date':compute.checked_days_ago===0?'today':compute.checked_days_ago===1?'1 day ago':`${compute.checked_days_ago} days ago`;
+ const cheapest=compute.offers.find(o=>o.id===compute.cheapest.gpu);
+ const text=`GPUs and computers from OpenVibe.Exchange: per hour, per GPU GB-hour and per month at 24/7, checked ${when}.`;
+ if(q && !('compute prices '+text).toLowerCase().includes(q.toLowerCase())) return '';
+ return `<article class="ovk-card"><h2><a href="/compute">Compute prices</a></h2><p>${esc(text)}</p>${compute.unavailable?`<p role="status">${esc(compute.unavailable)}</p>`:`<p>${compute.offers.length} offers${cheapest?` · cheapest GPU: ${esc(cheapest.provider)} ${esc(cheapest.gpu||'')} at $${cheapest.hourly.toFixed(2)}/hour`:''}</p>`}<p><a href="/api/v1/compute">JSON</a></p></article>`;
+}
+function home(all,compute,q='',task='balanced',budget='') {
const c=all.find(c=>c.slug==='ai-models');
const c=all.find(c=>c.slug==='ai-models');
const taskWeights={balanced:{input:40,output:40,quality:20},writing:{input:10,output:30,quality:60},coding:{input:20,output:20,quality:60},speed:{input:10,output:10,quality:0,speed:80}};
const taskWeights={balanced:{input:40,output:40,quality:20},writing:{input:10,output:30,quality:60},coding:{input:20,output:20,quality:60},speed:{input:10,output:10,quality:0,speed:80}};
const limit=budget===''?Infinity:Number(budget);
const limit=budget===''?Infinity:Number(budget);
@@ -30,8 +37,8 @@ function home(all,q='',task='balanced',budget='') {
@@ -30,8 +37,8 @@ function home(all,q='',task='balanced',budget='') {
<p>Task presets adjust preferences. The available arena data does not establish task-specific coding or writing quality. Budget applies to output tokens, not monthly spend.</p>
<p>Task presets adjust preferences. The available arena data does not establish task-specific coding or writing quality. Budget applies to output tokens, not monthly spend.</p>
<div class="picks">${picks.map(({thing:t})=>`<article class="ovk-card"><h2><a href="${thingPath(t.id)}">${esc(t.name)}</a></h2><p>Fits your output-price budget and ranks under these weights${c.results.some(r=>r.criterion==='quality')?'':'; quality has not been measured'}.</p><p>Output price (${esc(c.criteria.find(k=>k.id==='output').unit)}): ${number(c.results.find(r=>r.thing===t.id&&r.criterion==='output'))}</p></article>`).join('')||'<p>No measured picks fit this budget.</p>'}</div>
<div class="picks">${picks.map(({thing:t})=>`<article class="ovk-card"><h2><a href="${thingPath(t.id)}">${esc(t.name)}</a></h2><p>Fits your output-price budget and ranks under these weights${c.results.some(r=>r.criterion==='quality')?'':'; quality has not been measured'}.</p><p>Output price (${esc(c.criteria.find(k=>k.id==='output').unit)}): ${number(c.results.find(r=>r.thing===t.id&&r.criterion==='output'))}</p></article>`).join('')||'<p>No measured picks fit this budget.</p>'}</div>
<form><label>Search comparisons <input name="q" value="${esc(q)}" type="search"></label><button class="ovk-btn ovk-btn--quiet">Search</button></form>
<form><label>Search comparisons <input name="q" value="${esc(q)}" type="search"></label><button class="ovk-btn ovk-btn--quiet">Search</button></form>
- <div class="comparison-list">${all.filter(c=>(c.title+' '+c.summary).toLowerCase().includes(q.toLowerCase())).map(c=>`<article class="ovk-card"><h2><a href="/comparisons/${c.slug}">${esc(c.title)}</a></h2><p>${esc(c.summary)}</p>${rank(c).filter(r=>r.score!==null).slice(0,3).map(r=>`<div>${esc(r.thing.name)} <meter min="0" max="100" value="${r.score}">${r.score.toFixed(1)}</meter> ${score(r.score,c)}</div>`).join('') || '<p>No comparable measurements yet.</p>'}<p>${c.things.length} things · ${c.results.length} dated results</p></article>`).join('')}</div><section id="method"><h2>How picks are ranked</h2><p>${esc(c?.method || '')}</p></section>`;
+ <div class="comparison-list">${computeCard(compute,q)}${all.filter(c=>(c.title+' '+c.summary).toLowerCase().includes(q.toLowerCase())).map(c=>`<article class="ovk-card"><h2><a href="/comparisons/${c.slug}">${esc(c.title)}</a></h2><p>${esc(c.summary)}</p>${rank(c).filter(r=>r.score!==null).slice(0,3).map(r=>`<div>${esc(r.thing.name)} <meter min="0" max="100" value="${r.score}">${r.score.toFixed(1)}</meter> ${score(r.score,c)}</div>`).join('') || '<p>No comparable measurements yet.</p>'}<p>${c.things.length} things · ${c.results.length} dated results</p></article>`).join('')}</div><section id="method"><h2>How picks are ranked</h2><p>${esc(c?.method || '')}</p></section>`;
}
}
function markdown(c){return `# ${c.title}\n\n${c.summary}\n\nUpdated: ${c.updated_at}\n\n`+c.things.map(t=>`## ${t.name}\n\n`+c.criteria.map(k=>{const r=c.results.find(r=>r.thing===t.id&&r.criterion===k.id);return `- ${k.label}: ${r?`${r.value} ${k.unit} — ${r.as_of} — [${r.source.label}](${r.source.url})`:'Not measured'}`;}).join('\n')).join('\n\n')+`\n\n## Method\n\n${c.method}\n`;}
function markdown(c){return `# ${c.title}\n\n${c.summary}\n\nUpdated: ${c.updated_at}\n\n`+c.things.map(t=>`## ${t.name}\n\n`+c.criteria.map(k=>{const r=c.results.find(r=>r.thing===t.id&&r.criterion===k.id);return `- ${k.label}: ${r?`${r.value} ${k.unit} — ${r.as_of} — [${r.source.label}](${r.source.url})`:'Not measured'}`;}).join('\n')).join('\n\n')+`\n\n## Method\n\n${c.method}\n`;}
-function page(title,body,path='/',ld=[]) {return shell.page({name:'OpenVibe.Compare',siteName:'OpenVibe.Compare',title,description:'Compare things with dated sources, transparent methods and your own priorities.',canonical:`https://openvibe.compare${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'compare',apiBase:'https://openvibe.network',links:[{label:'Comparisons',href:'/'}]},footer:{service:'compare',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/compare.css"><script src="/compare-account.js" defer></script>`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
-module.exports={comparison,home,page,markdown,esc,thingPath,number};
+function page(title,body,path='/',ld=[]) {return shell.page({name:'OpenVibe.Compare',siteName:'OpenVibe.Compare',title,description:'Compare things with dated sources, transparent methods and your own priorities.',canonical:`https://openvibe.compare${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'compare',apiBase:'https://openvibe.network',links:[{label:'Comparisons',href:'/'},{label:'Compute',href:'/compute'}]},footer:{service:'compare',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/compare.css"><script src="/compare-account.js" defer></script>`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
+module.exports={comparison,home,page,markdown,esc,thingPath,number,computeCard};
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+'use strict';
+const test = require('node:test'), assert = require('node:assert/strict');
+const { buildCompute, createCompute, select, parseQuery, SOURCE, MONTH_HOURS } = require('../server/compute');
+const computeViews = require('../server/compute-views');
+const { build } = require('../server/data');
+
+const at = '2026-11-11T00:00:00.000Z';
+const now = Date.parse('2026-11-12T12:00:00.000Z');
+const until = Date.parse('2026-11-18T00:00:00Z');
+const gpu = (id, provider, gpu, gpu_mb, hourly, extra = {}) => ({ id, provider, kind: 'gpu', gpu, gpu_mb, hourly, per_second: hourly / 3600, billing_seconds: 1, checked_at: at, expires_at: until, source: `https://example.com/${provider}`, ...extra });
+const cpu = (id, provider, region, cpu, ram_mb, hourly) => ({ id, provider, region, kind: 'cpu', cpu, ram_mb, gpu_mb: 0, hourly, per_second: hourly / 3600, billing_seconds: 1, checked_at: at, expires_at: until, source: `https://example.com/${provider}` });
+const feed = {
+ checked_at: at,
+ prices: [
+ gpu('daytona-4090-preemptible', 'daytona', 'RTX 4090', 24576, .57, { interruptible: true }),
+ gpu('runpod-4090-pod', 'runpod', 'RTX 4090', 24576, .89, { promotion: '$10 credit' }),
+ gpu('daytona-4090-ondemand', 'daytona', 'RTX 4090', 24576, .99),
+ cpu('fly-iad-2x2', 'fly', 'iad', 2, 2048, 13.39 / 720),
+ cpu('baseten-us-pdx-1-2x4', 'baseten', 'us-pdx-1', 2, 4096, .2154),
+ { ...gpu('old-4090', 'old', 'RTX 4090', 24576, .5), checked_at: '2026-10-01T00:00:00.000Z', expires_at: '2026-11-01T00:00:00Z' },
+ { id: 'bad-source', provider: 'x', kind: 'gpu', gpu: 'RTX 4090', gpu_mb: 1024, hourly: 1, checked_at: at, source: 'http://insecure.example.com' },
+ { id: 'bad-price', provider: 'x', kind: 'gpu', hourly: 0, checked_at: at, source: 'https://example.com/x' },
+ ],
+};
+const compute = buildCompute(feed, now);
+
+test('compute prices are normalized, derived from Exchange fields, and invalid rows are left out', () => {
+ assert.equal(compute.offers.length, 6);
+ assert.equal(compute.unavailable, undefined);
+ assert.equal(compute.checked_at, at);
+ assert.equal(compute.checked_days_ago, 1);
+ assert.equal(compute.source, SOURCE);
+ assert.equal(compute.offers[0].id, 'fly-iad-2x2');
+ const pre = compute.offers.find((o) => o.id === 'daytona-4090-preemptible');
+ assert.equal(pre.cheapest, true);
+ assert.equal(pre.hourly, .57);
+ assert.equal(pre.monthly_24_7, Math.round(.57 * MONTH_HOURS * 10000) / 10000);
+ assert.equal(pre.hourly_per_gpu_gb, Math.round(.57 / 24 * 10000) / 10000);
+ assert.equal(pre.interruptible, true);
+ assert.equal(compute.cheapest.gpu, 'daytona-4090-preemptible');
+ assert.equal(compute.cheapest.cpu, 'fly-iad-2x2');
+ assert.equal(compute.expired_count, 1);
+ assert.equal(compute.expired_at, '2026-11-01T00:00:00.000Z');
+ assert.equal(compute.offers.filter((o) => o.cheapest).length, 2);
+ assert.equal(computeViews.checkedLabel(compute), '1 day ago');
+});
+
+test('filters, places for the missing GPU GB-hour, and picks respect the query and the 3-pick limit', () => {
+ assert.deepEqual(select(compute, { kind: 'gpu' }).offers.map((o) => o.id), ['old-4090', 'daytona-4090-preemptible', 'runpod-4090-pod', 'daytona-4090-ondemand']);
+ assert.deepEqual(select(compute, { kind: 'gpu', interruptible: 'yes' }).offers.map((o) => o.id), ['daytona-4090-preemptible']);
+ assert.deepEqual(select(compute, { interruptible: 'no', kind: 'cpu' }).offers.map((o) => o.id), ['fly-iad-2x2', 'baseten-us-pdx-1-2x4']);
+ assert.deepEqual(select(compute, { region: 'us-pdx-1' }).offers.map((o) => o.id), ['baseten-us-pdx-1-2x4']);
+ assert.deepEqual(select(compute, { gpu: 'RTX 4090' }).offers.length, 4);
+ const byGpuHour = select(compute, { sort: 'gpu-hour' }).offers;
+ assert.equal(byGpuHour[0].id, 'old-4090');
+ assert.deepEqual(byGpuHour.slice(-2).map((o) => o.kind), ['cpu', 'cpu']);
+ const q = parseQuery({ pick: 'runpod-4090-pod,daytona-4090-preemptible,fly-iad-2x2,baseten-us-pdx-1-2x4,nope', sort: 'nope' });
+ assert.deepEqual(q.pick, ['runpod-4090-pod', 'daytona-4090-preemptible', 'fly-iad-2x2']);
+ assert.equal(q.sort, 'hourly');
+ assert.deepEqual(select(compute, { pick: 'runpod-4090-pod,nope' }).picks.map((o) => o.id), ['runpod-4090-pod']);
+});
+
+test('the page shows both layouts, the source line, the cheapest of each kind, expiry and a 2-3 pick side-by-side', () => {
+ const html = computeViews.body(compute, {});
+ assert.match(html, /compute-cards/);
+ assert.match(html, /compute-table/);
+ assert.match(html, /Data from <a href="https:\/\/openvibe\.exchange\/compute">OpenVibe\.Exchange<\/a>/);
+ assert.match(html, /checked 1 day ago/);
+ assert.match(html, /Cheapest GPU/);
+ assert.match(html, /Cheapest computer/);
+ assert.match(html, /per GPU GB-hour/);
+ assert.match(html, /Per month \(730 h, 24\/7\)/);
+ assert.match(html, /Provider price/);
+ assert.match(html, /https:\/\/example\.com\/daytona/);
+ assert.match(html, /models on Exchange/);
+ assert.match(html, /computers on Run/);
+ assert.match(html, /Review overdue/);
+ assert.match(html, /passed the review date/);
+ assert.match(html, /name="kind"/);
+ assert.match(html, /name="gpu"/);
+ assert.match(html, /name="region"/);
+ assert.match(html, /name="interruptible"/);
+ assert.doesNotMatch(html, /id="side-by-side"/);
+ const picked = computeViews.body(compute, { pick: 'runpod-4090-pod,daytona-4090-preemptible,fly-iad-2x2' });
+ assert.match(picked, /id="side-by-side"/);
+ assert.match(picked, /3 offers side by side/);
+ assert.match(picked, /Remove/);
+ assert.match(computeViews.body(compute, { kind: 'cpu' }), /2 of 6 offers/);
+});
+
+test('markdown and Dataset JSON-LD are generated from the same selection', () => {
+ const md = computeViews.markdown(compute, { kind: 'gpu', pick: 'runpod-4090-pod,daytona-4090-preemptible' });
+ assert.match(md, /# Compute prices/);
+ assert.match(md, /checked 1 day ago/);
+ assert.match(md, /Data from \[OpenVibe\.Exchange\]/);
+ assert.match(md, /daytona/);
+ assert.match(md, /cheapest of its kind/);
+ assert.match(md, /## Side-by-side/);
+ assert.doesNotMatch(md, /fly-iad/);
+ const ld = computeViews.jsonLd(compute);
+ assert.equal(ld['@type'], 'Dataset');
+ assert.equal(ld.contentUrl, undefined);
+ assert.equal(ld.distribution.contentUrl, 'https://openvibe.compare/api/v1/compute');
+ assert.equal(ld.dateModified, at);
+});
+
+test('the cache coalesces, lasts ten minutes, and a failed feed is not cached into a value', async () => {
+ let calls = 0, t = now;
+ const get = createCompute({ now: () => t, fetchImpl: async (url) => {
+ calls++;
+ assert.equal(url, SOURCE);
+ return { ok: true, json: async () => feed };
+ } });
+ await Promise.all([get(), get()]);
+ assert.equal(calls, 1);
+ t += 599999;
+ await get();
+ assert.equal(calls, 1);
+ t += 2;
+ await get();
+ assert.equal(calls, 2);
+ let failures = 0;
+ const down = createCompute({ now: () => t, fetchImpl: async () => { failures++; throw Error('offline'); } });
+ assert.equal((await down()).unavailable, 'OpenVibe.Exchange did not answer, so no compute price is shown as current.');
+ await down();
+ assert.equal(failures, 2);
+});
+
+test('API, page, phone/table markup, filters, Markdown, discovery and CORS', async () => {
+ const { createApp } = require('../server/app');
+ const fixture = { market: { offers: [] }, harnesses: { harnesses: [] }, runs: { runs: [] }, plans: { plans: [] }, quality: { board: { rows: [] } } };
+ const data = build(fixture, at);
+ const server = createApp({ getData: async () => data, getCompute: async () => compute }).listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ let r = await fetch(base + '/api/v1/compute', { headers: { Origin: 'https://openvibe.network' } });
+ assert.equal(r.status, 200);
+ assert.equal(r.headers.get('access-control-allow-origin'), 'https://openvibe.network');
+ const body = await r.json();
+ assert.equal(body.source, 'OpenVibe.Exchange');
+ assert.equal(body.source_url, SOURCE);
+ assert.equal(body.offers.length, 6);
+ assert.equal(body.cheapest.gpu, 'daytona-4090-preemptible');
+ assert.equal(body.month_hours, MONTH_HOURS);
+ r = await fetch(base + '/api/v1/compute', { headers: { Origin: 'https://evil.example' } });
+ assert.equal(r.headers.get('access-control-allow-origin'), null);
+
+ const html = await (await fetch(base + '/compute?kind=gpu&interruptible=yes&sort=gpu-hour')).text();
+ assert.match(html, /compute-cards/);
+ assert.match(html, /compute-table/);
+ assert.match(html, /Dataset/);
+ assert.match(html, /contentUrl":"https:\/\/openvibe\.compare\/api\/v1\/compute/);
+ assert.match(html, /name="interruptible"/);
+ assert.match(html, /href="\/compute\?kind=gpu&interruptible=yes&sort=month"/);
+ assert.equal((html.match(/data-offer="runpod-4090-pod"/g) || []).length, 0);
+ const picked = await (await fetch(base + '/compute?pick=runpod-4090-pod,daytona-4090-preemptible')).text();
+ assert.match(picked, /id="side-by-side"/);
+ assert.match(picked, /2 offers side by side/);
+ const md = await (await fetch(base + '/compute', { headers: { Accept: 'text/markdown' } })).text();
+ assert.match(md, /# Compute prices/);
+ assert.match(md, /OpenVibe\.Exchange/);
+ const llms = await (await fetch(base + '/llms.txt')).text();
+ assert.match(llms, /\[Compute prices\]\(https:\/\/openvibe\.compare\/compute\)/);
+ assert.match(llms, /Compute JSON: https:\/\/openvibe\.compare\/api\/v1\/compute/);
+ const sitemap = await (await fetch(base + '/sitemap.xml')).text();
+ assert.match(sitemap, /<loc>https:\/\/openvibe\.compare\/compute<\/loc><lastmod>2026-11-11T00:00:00.000Z<\/lastmod>/);
+ const home = await (await fetch(base + '/')).text();
+ assert.match(home, /href="\/compute"/);
+ assert.match(home, /Compute prices/);
+ assert.match(home, /cheapest GPU/);
+ const homeMd = await (await fetch(base + '/', { headers: { Accept: 'text/markdown' } })).text();
+ assert.match(homeMd, /\[Compute prices\]\(\/compute\)/);
+ for (const url of ['/compute', '/api/v1/compute']) assert.equal((await fetch(base + url)).status, 200, url);
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+});
+
+test('an unavailable Exchange is an explicit unavailable state on the page and the API', async () => {
+ const down = buildCompute(null, now);
+ assert.equal(down.offers.length, 0);
+ assert.match(computeViews.body(down, {}), /did not answer/);
+ assert.match(computeViews.body(down, {}), /0 offers/);
+ assert.match(computeViews.markdown(down, {}), /did not answer/);
+ const { createApp } = require('../server/app');
+ const fixture = { market: { offers: [] }, harnesses: { harnesses: [] }, runs: { runs: [] }, plans: { plans: [] }, quality: { board: { rows: [] } } };
+ const server = createApp({ getData: async () => build(fixture, at), getCompute: async () => down }).listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ const body = await (await fetch(base + '/api/v1/compute')).json();
+ assert.deepEqual(body.offers, []);
+ assert.match(body.unavailable, /did not answer/);
+ assert.equal((await fetch(base + '/compute')).status, 200);
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+});