openvibers/openvibe.exchange Commits

Preview GPU rental quotes and expose placement strategies (#124)

OP OpenVibers committed 5 days ago · 9612b7bb72 · 6 files, +43 −6

* Preview GPU rental prices and pass placement strategy to AI

* Clarify that GPU quote previews exclude variable charges

Changes

ddiff --git a/README.md b/README.md
iindex f9851f2..59dc5c8 100644
---- a/README.md
++++ b/README.md
@@@ -85,3 +85,9 @@ AGPL-3.0-only. See [LICENSE](LICENSE).
- openvibe-shared: v4.153.0
- openvibe-publishing: v1.5.0
<!-- versions:end -->
++
++`/own` also previews GPU quotes without sign-in or a rental. Choose Cheapest, Fastest or
++Automatic for a rental; Most private and On my machine explain when a Link/OpenVibe machine
++is required. The preview uses AI's cached public GPU quote API and never takes a Balance hold.
++Provider keys, monthly caps, provisioning and the journal remain in AI; desktop/browser pools
++and their operator view remain in Run. The Exchange keeps no provider credentials.
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 42274fe..32d3797 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -649,3 +649,9 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
.ex-why-gate { margin: 8px 0; }
.ex-faq { display: grid; gap: 10px; }
.ex-faq .ovk-fold { margin: 0; }
++
++/* GPU quote and rental forms share the card's text inset, including the returned quote. */
++#quote .ovk-form, #quote [role="status"], #model .ovk-form, #weights .ovk-form {
++ margin: 14px; display: grid; gap: 10px; min-width: 0;
++}
++#quote .ovk-form select, #model .ovk-form select { width: 100%; min-width: 0; min-height: 44px; }
ddiff --git a/server/ai.js b/server/ai.js
iindex 7140f36..8b6a7ca 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -268,12 +268,13 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
}
return out || {};
}
++ async function gpuQuote(model, strategy = 'cheapest') { return ownCall('GET', `/api/v1/exchange/gpu-quotes/${encodeURIComponent(model)}?strategy=${encodeURIComponent(strategy)}`); }
async function ownStatus() { return ownCall('GET', '/api/v1/exchange/endpoints'); }
async function ownList(token) { return ownCall('GET', '/api/v1/own-endpoints', { token }); }
async function ownCreate(token, body) { return ownCall('POST', '/api/v1/own-endpoints', { token, body }); }
async function ownStop(token, id) { return ownCall('POST', `/api/v1/own-endpoints/${encodeURIComponent(id)}/stop`, { token }); }
-- return { catalog, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, ownStatus, ownList, ownCreate, ownStop, configured: Boolean(tokens) };
++ return { catalog, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, configured: Boolean(tokens) };
}
/**
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c63f010..c238dfa 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -750,7 +750,7 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
// ── Your own endpoint ────────────────────────────────────
// The private GPU page. The status is public (so the reason it is off is visible before signing in);
// a person's own endpoints are read from AI with their own Network token, never with the service token.
-- async function renderOwn(req, res, { error = null } = {}) {
++ async function renderOwn(req, res, { error = null, quote = null } = {}) {
let status = null;
let problem = error;
try { status = await ai.ownStatus(); } catch (err) {
@@@ -772,16 +772,24 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE, agentLayer: true,
title: 'Your own endpoint',
description: 'A private GPU for one model or your own weights: only you reach it, it scales to zero when idle, and your Balance pays for the seconds it runs.',
-- body: raw(views.ownPage({ status, endpoints, viewer: req.viewer, error: problem, signedIn: signedIn(req) })),
++ body: raw(views.ownPage({ status, endpoints, quote, viewer: req.viewer, error: problem, signedIn: signedIn(req) })),
});
}
r.get('/own', async (req, res) => renderOwn(req, res));
++ r.get('/own/quote', async (req, res) => {
++ const strategy = String(req.query.strategy || 'cheapest');
++ const model = String(req.query.model || '').slice(0, 80);
++ if (!['automatic','cheapest','fastest','private','own'].includes(strategy)) return renderOwn(req, res, { error: 'Choose one of the listed strategies.' });
++ try { return renderOwn(req, res, { quote: await ai.gpuQuote(model, strategy) }); }
++ catch { return renderOwn(req, res, { error: 'The live quote is unavailable. Nothing was rented.' }); }
++ });
r.post('/own', async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fown');
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
const body = {};
++ if (req.body.strategy) body.strategy = String(req.body.strategy).slice(0, 20);
if (req.body.model) body.model = String(req.body.model).slice(0, 80);
if (req.body.weights_repo || req.body.weights_file) {
body.weights = {
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex d88f690..5ed2bf2 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -910,7 +910,7 @@ ${panel({ title: 'Where those things live now', body: `<ul class="ex-list">
* then the form. The live prices and models come from AI's public status; a person's endpoints come from
* AI with their own Network token.
*/
--function ownPage({ status = null, endpoints = [], viewer = null, error = null, signedIn = false }) {
++function ownPage({ status = null, endpoints = [], quote = null, viewer = null, error = null, signedIn = false }) {
const models = (status && status.models) || [];
const off = !status || status.enabled !== true;
const why = (status && (status.reason || (status.reasons || []).join(' '))) || 'The endpoint service is not reachable right now.';
@@@ -941,12 +941,19 @@ function ownPage({ status = null, endpoints = [], viewer = null, error = null, s
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'Your own endpoint', sub: 'A private GPU for one model, or your own weights. It scales to zero when you stop using it.' })}
${error ? kit.notice({ kind: 'warn', text: esc(error) }) : ''}
++${panel({ title: 'Compare before renting', id: 'quote', body: `<p class="ex-prose">A quote does not start a GPU or charge your Balance. Prices and availability can change before launch.</p>
++<form method="get" action="/own/quote" class="ovk-form"><label for="quote-model">Model</label><select id="quote-model" name="model">${modelOptions}</select>
++<label for="quote-strategy">Placement</label><select id="quote-strategy" name="strategy"><option value="cheapest">Cheapest</option><option value="fastest">Fastest</option><option value="automatic">Automatic across marketplaces</option><option value="private">Most private</option><option value="own">On my machine</option></select>${kit.button({ label: 'Get a quote', type: 'submit' })}</form>
++${quote ? `<div role="status"><p>${quote.selected ? `${esc(quote.selected.marketplace)} · ${esc(quote.selected.gpu_name)} · ${esc(quote.selected.region)} · $${esc(quote.selected.price_per_hour)} per hour` : 'No eligible marketplace GPU.'}</p><p>${esc((quote.reasons || []).join(' · '))}</p><p>Base GPU price; storage and network charges may add to it. Quote only. Nothing was rented.</p></div>` : ''}
++<p class="ex-small">Most private and On my machine require your Link machine or OpenVibe capacity; a private endpoint on a third-party GPU does not meet those placement rules. <a href="https://openvibe.link">Connect your machine</a>.</p>` })}
++
${off ? `<div class="ex-card"><div class="ex-sec-body">${kit.notice({ kind: 'warn', text: esc(why) })}<p class="ex-prose">It turns on when the owner adds a GPU provider key and a monthly cap in the admin panel, and turns Balance billing on. Nothing is rented before then.</p></div></div>` : ''}
${!off && !signedIn ? `${kit.notice({ kind: 'info', text: 'Sign in to start a private endpoint.' })}<p>${kit.button({ href: '/auth/login?next=%2Fown', label: 'Sign in' })}</p>` : ''}
${!off ? `${panel({ title: 'Pick a model', id: 'model', body: models.length ? `<p class="ex-prose">The GPU is yours alone: only your own sign-in can call it, and it is never listed as an offer. The price is the cheapest GPU that fits, per hour; you pay your Balance for the seconds it runs.</p>
<form method="post" action="/own" class="ovk-form">
<label class="ex-label" for="own-model">Model</label>
<select id="own-model" name="model">${modelOptions}</select>
++<label for="own-strategy">Placement</label><select id="own-strategy" name="strategy"><option value="cheapest">Cheapest</option><option value="automatic">Automatic across marketplaces</option><option value="fastest">Fastest</option></select>
<label class="ex-label" for="own-idle">Stop after this many idle minutes</label>
<input id="own-idle" name="idle_minutes" type="number" min="1" max="${Number(status.max_idle_minutes || 120)}" value="${Number(status.idle_minutes || 10)}">
<p class="ex-small ex-muted">How long it stays up after your last request. Lower costs less; higher avoids a cold start.</p>
ddiff --git a/test/own.test.js b/test/own.test.js
iindex 0b37b43..1c1c648 100644
---- a/test/own.test.js
++++ b/test/own.test.js
@@@ -14,6 +14,7 @@ const { createFakeAi } = require('./helpers/ai');
status: { enabled: false, reason: 'The owner has not added a GPU provider key (Vast.ai or RunPod) yet, so no endpoint can start.', models: [], idle_minutes: 10, max_idle_minutes: 120, public_base: 'https://ai.openvibe.services/api/v1/own-endpoints' },
endpoints: [], calls: [],
};
++ ai.gpuQuote = async (model, strategy) => { state.calls.push({ kind: 'quote', model, strategy }); return { dry_run: true, selected: { marketplace: 'vast', gpu_name: 'RTX 3090', region: 'US', price_per_hour: '0.20' }, reasons: ['objective cheapest'] }; };
ai.ownStatus = async () => state.status;
ai.ownList = async (token) => { state.calls.push({ kind: 'list', token }); return { endpoints: state.endpoints, status: state.status }; };
ai.ownCreate = async (token, body) => { state.calls.push({ kind: 'create', token, body }); return { endpoint: { id: 'oep_TEST' } }; };
@@@ -38,6 +39,14 @@ const { createFakeAi } = require('./helpers/ai');
assert.ok(!r.text.includes('Start my endpoint'), 'the submit is only for a signed-in person');
});
++ await check('quote works signed out, shows live price, and creates no rental', async () => {
++ const r = await t.get('/own/quote?model=deepseek-r1-32b&strategy=cheapest');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('0.20 per hour'));
++ assert.ok(r.text.includes('Nothing was rented'));
++ assert.ok(!state.calls.some(c => c.kind === 'create'));
++ });
++
const u = t.network.addUser('gpu-owner');
await check('signed in: the model and own-weights forms, with the private promise', async () => {
const r = await t.get('/own', { as: u });
@@@ -50,13 +59,13 @@ const { createFakeAi } = require('./helpers/ai');
});
await check('create posts to AI with the person\'s own token and the chosen model', async () => {
-- const r = await t.get('/own', { as: u, form: { model: 'deepseek-r1-32b', idle_minutes: '5' }, method: 'POST' });
++ const r = await t.get('/own', { as: u, form: { model: 'deepseek-r1-32b', idle_minutes: '5', strategy: 'automatic' }, method: 'POST' });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.headers.get('location'), '/own');
const call = state.calls.find((c) => c.kind === 'create');
assert.ok(call, 'AI was asked to create it');
assert.ok(call.token, 'with the person\'s token, never the service token');
-- assert.deepStrictEqual(call.body, { model: 'deepseek-r1-32b', idle_minutes: 5 });
++ assert.deepStrictEqual(call.body, { model: 'deepseek-r1-32b', idle_minutes: 5, strategy: 'automatic' });
});
await check('own weights are passed as a weights object', async () => {