openvibers/openvibe.exchange Commits
Exchange: the compute price JSON is open to any origin and documented on /api (#230)
Changes
@@@ -164,7 +164,16 @@ async function createApp(opts = {}) {app.use(cookieParser());// ── Machine endpoints ───────────────────────────────────-- app.get('/api/compute/prices', (_req, res) => res.set('Cache-Control', 'public, max-age=60').json(require('./compute-prices')));++ app.get('/api/compute/prices', (_req, res) => res.set({++ 'Access-Control-Allow-Origin': '*',++ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',++ 'Cache-Control': 'public, max-age=60',++ }).json(require('./compute-prices')));++ app.options('/api/compute/prices', (_req, res) => res.set({++ 'Access-Control-Allow-Origin': '*',++ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',++ 'Access-Control-Max-Age': '86400',++ }).sendStatus(204));app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));// IndexNow's key file (/<key>.txt), when a key is configured.if (indexnow.enabled) app.use(indexnow.keyFile);@@@ -19,6 +19,7 @@ const PUBLIC_PAGES = [{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Every AI model, one account: live prices, speed and uptime, chat, and one endpoint.' },{ path: '/models', changefreq: 'daily', priority: 0.9, title: 'Models', text: 'Every model with every offer that serves it: price in, out and cached per million tokens, measured p50 and p95 speed, uptime and data policy.' },{ path: '/api', changefreq: 'weekly', priority: 0.7, title: 'The API', text: 'One OpenAI- and Anthropic-compatible endpoint, model ids, keys from Network and aggregate usage.' },++ { path: '/compute', changefreq: 'weekly', priority: 0.7, title: 'Compute prices', text: 'What an hour of a GPU or a computer costs at each provider: the reviewed tariff with its source, checked and review dates, as JSON at /api/compute/prices.' },{ path: '/start', changefreq: 'weekly', priority: 0.8, title: 'Start building', text: 'One key and one endpoint: the OpenAI and Anthropic SDK samples, four steps to a first answer, OpenVibe.AI’s six operations, and the sandbox and production limits.' },{ path: '/why', changefreq: 'weekly', priority: 0.7, title: 'Why this model', text: 'Every answer carries its receipt: what the router optimised, the offer that answered, every other candidate’s cost and latency, and why each was passed over.' },{ path: '/faq', changefreq: 'weekly', priority: 0.6, title: 'Questions', text: 'The endpoint, the SDKs, keys, prices, privacy, the uncensored tier, selling compute, and the move of OpenVibe.AI’s public pages here.' },@@@ -114,6 +115,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },{ title: 'The API', url: abs('/api') },++ { title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },{ title: 'Start building', url: abs('/start') },{ title: 'Why this model', url: abs('/why') },{ title: 'Questions', url: abs('/faq') },@@@ -522,6 +522,10 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.noreturn `<div class="ex-wrap ex-page">${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}++${panel({ title: 'Compute prices', id: 'compute', body: `++<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p>++<p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>++<p class="ex-prose">The same list is filtered, sorted and compared on <a href="https://openvibe.compare/compute">OpenVibe.Compare</a>, which serves the comparison at <code>https://openvibe.compare/api/v1/compute</code>.</p>` })}${panel({ title: 'One endpoint', body: `<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p><p class="ex-prose">Coming from OpenRouter or a provider's own API? <a href="/docs/migrate">Move your application to Exchange</a>: the first request, the gateway settings, routing checks and limited keys, step by step.</p>@@@ -6,11 +6,17 @@ const {createFakeAi}=require('./helpers/ai');const t=await boot({ai:createFakeAi()});try {const r=await t.get('/api/compute/prices');assert.equal(r.status,200,r.text);++ assert.equal(r.headers.get('access-control-allow-origin'),'*');assert.match(r.headers.get('cache-control'),/max-age=60/);++ const options=await t.get('/api/compute/prices',{method:'OPTIONS'});assert.equal(options.status,204);assert.equal(options.headers.get('access-control-allow-origin'),'*');const data=JSON.parse(r.text);assert.equal(data.prices.length,10);assert.equal(data.prices.find(p=>p.id==='daytona-4090-preemptible').interruptible,true);for(const p of data.prices){assert.ok(p.hourly>0);assert.ok(p.expires_at>Date.parse(p.checked_at));assert.ok(p.source.startsWith('https://'));assert.equal(p.per_second,p.hourly/3600);}const page=await t.get('/compute');assert.equal(page.status,200);assert.match(page.text,/Compute prices/);assert.match(page.text,/Model prices/);assert.match((await t.get('/')).text,/href="\/compute"/);++ assert.match(page.text,/openvibe\.compare\/compute/);++ const docs=await t.get('/api');assert.match(docs.text,/GET \/api\/compute\/prices/);assert.match(docs.text,/Access-Control-Allow-Origin: \*/);assert.match(docs.text,/max-age=60/);++ const map=await t.get('/sitemap.xml');assert.match(map.text,/https:\/\/openvibe\.exchange\/compute<\/loc>/);++ assert.match((await t.get('/llms.txt')).text,/https:\/\/openvibe\.exchange\/compute/);process.stdout.write('compute catalogue: dated source prices, spot distinction, units and model/compute links passed\n');}finally{await t.close();}})().catch(e=>{process.stderr.write(String(e.stack)+'\n');process.exitCode=1;});
@@ -164,7 +164,16 @@ async function createApp(opts = {}) {
@@ -164,7 +164,16 @@ async function createApp(opts = {}) {
app.use(cookieParser());
app.use(cookieParser());
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
- app.get('/api/compute/prices', (_req, res) => res.set('Cache-Control', 'public, max-age=60').json(require('./compute-prices')));
+ app.get('/api/compute/prices', (_req, res) => res.set({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+ 'Cache-Control': 'public, max-age=60',
+ }).json(require('./compute-prices')));
+ app.options('/api/compute/prices', (_req, res) => res.set({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
+ 'Access-Control-Max-Age': '86400',
+ }).sendStatus(204));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));
// IndexNow's key file (/<key>.txt), when a key is configured.
// IndexNow's key file (/<key>.txt), when a key is configured.
if (indexnow.enabled) app.use(indexnow.keyFile);
if (indexnow.enabled) app.use(indexnow.keyFile);
@@ -19,6 +19,7 @@ const PUBLIC_PAGES = [
@@ -19,6 +19,7 @@ const PUBLIC_PAGES = [
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Every AI model, one account: live prices, speed and uptime, chat, and one endpoint.' },
{ path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Every AI model, one account: live prices, speed and uptime, chat, and one endpoint.' },
{ path: '/models', changefreq: 'daily', priority: 0.9, title: 'Models', text: 'Every model with every offer that serves it: price in, out and cached per million tokens, measured p50 and p95 speed, uptime and data policy.' },
{ path: '/models', changefreq: 'daily', priority: 0.9, title: 'Models', text: 'Every model with every offer that serves it: price in, out and cached per million tokens, measured p50 and p95 speed, uptime and data policy.' },
{ path: '/api', changefreq: 'weekly', priority: 0.7, title: 'The API', text: 'One OpenAI- and Anthropic-compatible endpoint, model ids, keys from Network and aggregate usage.' },
{ path: '/api', changefreq: 'weekly', priority: 0.7, title: 'The API', text: 'One OpenAI- and Anthropic-compatible endpoint, model ids, keys from Network and aggregate usage.' },
+ { path: '/compute', changefreq: 'weekly', priority: 0.7, title: 'Compute prices', text: 'What an hour of a GPU or a computer costs at each provider: the reviewed tariff with its source, checked and review dates, as JSON at /api/compute/prices.' },
{ path: '/start', changefreq: 'weekly', priority: 0.8, title: 'Start building', text: 'One key and one endpoint: the OpenAI and Anthropic SDK samples, four steps to a first answer, OpenVibe.AI’s six operations, and the sandbox and production limits.' },
{ path: '/start', changefreq: 'weekly', priority: 0.8, title: 'Start building', text: 'One key and one endpoint: the OpenAI and Anthropic SDK samples, four steps to a first answer, OpenVibe.AI’s six operations, and the sandbox and production limits.' },
{ path: '/why', changefreq: 'weekly', priority: 0.7, title: 'Why this model', text: 'Every answer carries its receipt: what the router optimised, the offer that answered, every other candidate’s cost and latency, and why each was passed over.' },
{ path: '/why', changefreq: 'weekly', priority: 0.7, title: 'Why this model', text: 'Every answer carries its receipt: what the router optimised, the offer that answered, every other candidate’s cost and latency, and why each was passed over.' },
{ path: '/faq', changefreq: 'weekly', priority: 0.6, title: 'Questions', text: 'The endpoint, the SDKs, keys, prices, privacy, the uncensored tier, selling compute, and the move of OpenVibe.AI’s public pages here.' },
{ path: '/faq', changefreq: 'weekly', priority: 0.6, title: 'Questions', text: 'The endpoint, the SDKs, keys, prices, privacy, the uncensored tier, selling compute, and the move of OpenVibe.AI’s public pages here.' },
@@ -114,6 +115,7 @@ function createDiscoveryRoutes(ctx) {
@@ -114,6 +115,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
{ title: 'The API', url: abs('/api') },
{ title: 'The API', url: abs('/api') },
+ { title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },
{ title: 'Start building', url: abs('/start') },
{ title: 'Start building', url: abs('/start') },
{ title: 'Why this model', url: abs('/why') },
{ title: 'Why this model', url: abs('/why') },
{ title: 'Questions', url: abs('/faq') },
{ title: 'Questions', url: abs('/faq') },
@@ -522,6 +522,10 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no
@@ -522,6 +522,10 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no
return `<div class="ex-wrap ex-page">
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
+${panel({ title: 'Compute prices', id: 'compute', body: `
+<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p>
+<p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>
+<p class="ex-prose">The same list is filtered, sorted and compared on <a href="https://openvibe.compare/compute">OpenVibe.Compare</a>, which serves the comparison at <code>https://openvibe.compare/api/v1/compute</code>.</p>` })}
${panel({ title: 'One endpoint', body: `
${panel({ title: 'One endpoint', body: `
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
<p class="ex-prose">Coming from OpenRouter or a provider's own API? <a href="/docs/migrate">Move your application to Exchange</a>: the first request, the gateway settings, routing checks and limited keys, step by step.</p>
<p class="ex-prose">Coming from OpenRouter or a provider's own API? <a href="/docs/migrate">Move your application to Exchange</a>: the first request, the gateway settings, routing checks and limited keys, step by step.</p>
@@ -6,11 +6,17 @@ const {createFakeAi}=require('./helpers/ai');
@@ -6,11 +6,17 @@ const {createFakeAi}=require('./helpers/ai');
const t=await boot({ai:createFakeAi()});
const t=await boot({ai:createFakeAi()});
try {
try {
const r=await t.get('/api/compute/prices');assert.equal(r.status,200,r.text);
const r=await t.get('/api/compute/prices');assert.equal(r.status,200,r.text);
+ assert.equal(r.headers.get('access-control-allow-origin'),'*');assert.match(r.headers.get('cache-control'),/max-age=60/);
+ const options=await t.get('/api/compute/prices',{method:'OPTIONS'});assert.equal(options.status,204);assert.equal(options.headers.get('access-control-allow-origin'),'*');
const data=JSON.parse(r.text);assert.equal(data.prices.length,10);
const data=JSON.parse(r.text);assert.equal(data.prices.length,10);
assert.equal(data.prices.find(p=>p.id==='daytona-4090-preemptible').interruptible,true);
assert.equal(data.prices.find(p=>p.id==='daytona-4090-preemptible').interruptible,true);
for(const p of data.prices){assert.ok(p.hourly>0);assert.ok(p.expires_at>Date.parse(p.checked_at));assert.ok(p.source.startsWith('https://'));assert.equal(p.per_second,p.hourly/3600);}
for(const p of data.prices){assert.ok(p.hourly>0);assert.ok(p.expires_at>Date.parse(p.checked_at));assert.ok(p.source.startsWith('https://'));assert.equal(p.per_second,p.hourly/3600);}
const page=await t.get('/compute');assert.equal(page.status,200);assert.match(page.text,/Compute prices/);assert.match(page.text,/Model prices/);
const page=await t.get('/compute');assert.equal(page.status,200);assert.match(page.text,/Compute prices/);assert.match(page.text,/Model prices/);
assert.match((await t.get('/')).text,/href="\/compute"/);
assert.match((await t.get('/')).text,/href="\/compute"/);
+ assert.match(page.text,/openvibe\.compare\/compute/);
+ const docs=await t.get('/api');assert.match(docs.text,/GET \/api\/compute\/prices/);assert.match(docs.text,/Access-Control-Allow-Origin: \*/);assert.match(docs.text,/max-age=60/);
+ const map=await t.get('/sitemap.xml');assert.match(map.text,/https:\/\/openvibe\.exchange\/compute<\/loc>/);
+ assert.match((await t.get('/llms.txt')).text,/https:\/\/openvibe\.exchange\/compute/);
process.stdout.write('compute catalogue: dated source prices, spot distinction, units and model/compute links passed\n');
process.stdout.write('compute catalogue: dated source prices, spot distinction, units and model/compute links passed\n');
}finally{await t.close();}
}finally{await t.close();}
})().catch(e=>{process.stderr.write(String(e.stack)+'\n');process.exitCode=1;});
})().catch(e=>{process.stderr.write(String(e.stack)+'\n');process.exitCode=1;});