openvibers/openvibe.exchange Commits

Your own endpoint: the page to create a private GPU (#99)

OP OpenVibers committed 6 days ago · eae1babac8 · 7 files, +277 −2

/own is the Exchange page for the private GPU rent: pick a provisioner
model or bring a Hugging Face .gguf and an optional LoRA, choose the idle
minutes, and see the person's endpoints with their price, URL and stop
button. When the feature is off (no GPU provider key, no monthly cap, or
Balance billing off) the page says the plain reason AI publishes and
shows no form.

Every call goes to OpenVibe.AI with the person's own Network token, never
the service token: an endpoint spends that person's Balance. The page is
in the nav and the discovery index, and the agent-layer twins serve it.

Changes

ddiff --git a/server/ai.js b/server/ai.js
iindex 64d7275..9623b4b 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -218,7 +218,37 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
return { on: Boolean(on), projects: targets.length - 1, projects_set: Math.max(0, written - 1), projects_failed: failed };
}
-- return { catalog, usage, chat, presetsFor, setKeyPrivacy, ownedProjects, configured: Boolean(tokens) };
++ // ── your own endpoint (the Exchange remake, build order 7) ────────────────
++ // The public status is on AI's Exchange read API; everything per person is on AI's own endpoint API,
++ // called with the person's Network token (never the service token: an endpoint spends their Balance).
++ async function ownCall(method, p, { token = null, body = undefined } = {}) {
++ const headers = { Accept: 'application/json' };
++ if (token) headers.Authorization = `Bearer ${token}`;
++ if (body !== undefined) headers['Content-Type'] = 'application/json';
++ let res;
++ try {
++ res = await fetchImpl(`${config.ai.url}${p}`, {
++ method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ } catch (err) {
++ throw Object.assign(new Error('OpenVibe.AI is unreachable right now'), { code: 'ai.unavailable', status: 503 });
++ }
++ const out = await jsonOf(res);
++ if (!res.ok) {
++ const err = new Error((out && (out.detail || out.title)) || `AI answered ${res.status}`);
++ err.code = (out && out.code) || 'ai.failed';
++ err.status = res.status;
++ throw err;
++ }
++ return out || {};
++ }
++ 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, chat, presetsFor, setKeyPrivacy, ownedProjects, ownStatus, ownList, ownCreate, ownStop, configured: Boolean(tokens) };
}
/**
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex c3c9e08..c29ed1d 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -20,6 +20,7 @@ const PUBLIC_PAGES = [
{ path: '/models', changefreq: 'daily', priority: 0.9, title: 'Models', text: 'Every model with every offer that serves it: price in and out per million tokens, 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: '/sell', changefreq: 'weekly', priority: 0.6, title: 'Sell your compute', text: 'OpenVibe Node offers a GPU or a spare machine to the network for credits; what is not sold here, said plainly.' },
++ { path: '/own', changefreq: 'weekly', priority: 0.6, title: 'Your own endpoint', text: 'A private GPU for one model or your own Hugging Face weights; only you reach it, it scales to zero when idle, and your Balance pays for the seconds it runs.' },
{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
{ path: '/rankings', changefreq: 'daily', priority: 0.8, title: 'Rankings', text: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.' },
{ path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5bb3f40..481f91a 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -639,6 +639,73 @@ ${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 } = {}) {
++ let status = null;
++ let problem = error;
++ try { status = await ai.ownStatus(); } catch (err) {
++ log.warn('[OpenVibe.Exchange] the endpoint status read failed:', (err && err.message) || err);
++ problem = problem || 'OpenVibe.AI is not reachable right now. Reload in a moment.';
++ }
++ let endpoints = [];
++ if (signedIn(req) && status && status.enabled) {
++ const token = req.token || (req.viewer && req.viewer.token);
++ try {
++ const out = await ai.ownList(token);
++ endpoints = (out && out.endpoints) || [];
++ } catch (err) {
++ log.warn('[OpenVibe.Exchange] the endpoints read failed:', (err && err.message) || err);
++ problem = problem || 'Your endpoints could not be read right now. Reload in a moment.';
++ }
++ }
++ return page(req, res, {
++ 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) })),
++ });
++ }
++
++ r.get('/own', async (req, res) => renderOwn(req, res));
++
++ 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.model) body.model = String(req.body.model).slice(0, 80);
++ if (req.body.weights_repo || req.body.weights_file) {
++ body.weights = {
++ repo: String(req.body.weights_repo || '').trim(),
++ file: String(req.body.weights_file || '').trim(),
++ ...(req.body.lora_repo ? { lora_repo: String(req.body.lora_repo).trim() } : {}),
++ ...(req.body.lora_file ? { lora_file: String(req.body.lora_file).trim() } : {}),
++ };
++ }
++ if (req.body.idle_minutes) body.idle_minutes = Number(req.body.idle_minutes);
++ if (req.body.display_name) body.display_name = String(req.body.display_name).slice(0, 120);
++ const token = req.token || (req.viewer && req.viewer.token);
++ try {
++ await ai.ownCreate(token, body);
++ return res.redirect(303, '/own');
++ } catch (err) {
++ return renderOwn(req, res, { error: (err && err.message) || 'Your endpoint could not be started.' });
++ }
++ });
++
++ r.post('/own/:id/stop', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fown');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const token = req.token || (req.viewer && req.viewer.token);
++ try {
++ await ai.ownStop(token, String(req.params.id));
++ return res.redirect(303, '/own');
++ } catch (err) {
++ return renderOwn(req, res, { error: (err && err.message) || 'Your endpoint could not be stopped.' });
++ }
++ });
++
// ── Uncensored ───────────────────────────────────────────
r.get('/uncensored', async (req, res) => {
const catalog = await catalogOf();
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex fb9e200..1e36b6a 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -26,6 +26,7 @@ const NAV = [
{ label: 'Rankings', href: '/rankings' },
{ label: 'API', href: '/api' },
{ label: 'Sell compute', href: '/sell' },
++ { label: 'Your endpoint', href: '/own' },
{ label: 'Uncensored', href: '/uncensored' },
{ label: 'AI in OpenVibe', href: '/ai' },
{ label: 'Status', href: '/status' },
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex d8af76c..ea22aee 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -718,4 +718,75 @@ ${panel({ title: 'Where those things live now', body: `<ul class="ex-list">
</div>`;
}
--module.exports = { audienceDefaults, home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, statusPage, retired, sealedChat, privacyOf, answerPrivacy, providerLabel, familyLabel, chatModels, ENDPOINT };
++// ── Your own endpoint ──────────────────────────────────────
++/**
++ * The private GPU page. It shows what the endpoint is, the plain reason it is off when it is, and only
++ * 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 }) {
++ 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.';
++ const modelOptions = models.map((m) => `<option value="${esc(m.key)}">${esc(m.display_name)}${m.vram_mb ? ` · ${Math.round(m.vram_mb / 1024)} GB` : ''}</option>`).join('');
++ const statusWords = (e) => {
++ const s = String(e.status || '');
++ if (s === 'requested') return 'Starting: finding a GPU and taking the price for it.';
++ if (s === 'starting') return 'Starting: the GPU is being set up.';
++ if (s === 'running') return 'Running.';
++ if (s === 'stopping') return 'Stopping.';
++ if (s === 'stopped') return e.reason || 'Stopped.';
++ if (s === 'refused') return e.reason || 'It could not start.';
++ if (s === 'error') return e.reason || 'It stopped because the GPU did.';
++ return s;
++ };
++ const row = (e) => {
++ const perHour = e.price_per_hour == null ? 'the price is shown once a GPU is picked' : `$${Number(e.price_per_hour).toFixed(2)} an hour`;
++ const charged = e.charged_nano && e.charged_nano !== '0' ? `$${(Number(e.charged_nano) / 1e9).toFixed(4)}` : null;
++ const url = e.status === 'running' && e.url ? `<p class="ex-small ex-muted">Your endpoint: <code>${esc(e.url)}</code></p><p class="ex-small ex-muted">Only your own OpenVibe sign-in reaches it. The model id is <code>${esc(e.id)}</code>.</p>` : '';
++ const stop = ['requested', 'starting', 'running', 'stopping'].includes(e.status)
++ ? `<form method="post" action="/own/${encodeURIComponent(e.id)}/stop" class="ovk-form">${kit.button({ label: 'Stop', type: 'submit', kind: 'quiet' })}</form>` : '';
++ return `<article class="ex-seller"><div><b>${esc(e.display_name)}</b>
++<p>${esc(statusWords(e))} · ${esc(perHour)} · ${Number(e.idle_minutes)} idle minutes</p>${url}${charged ? `<p class="ex-small ex-muted">Paid from your Balance: ${esc(charged)}</p>` : ''}</div>${stop}</article>`;
++ };
++ const list = endpoints.length
++ ? endpoints.map(row).join('')
++ : kit.empty({ icon: 'spark', title: 'No endpoint yet', text: 'Pick a model below, or bring your own weights, and your private endpoint starts when a GPU is free.' });
++ 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) }) : ''}
++${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 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>
++${signedIn ? kit.button({ label: 'Start my endpoint', type: 'submit' }) : ''}
++</form>` : kit.empty({ icon: 'robot', title: 'No GPU model is listed', text: 'The owner has not made a model available for a private endpoint yet.' }) })}
++${panel({ title: 'Or bring your own weights', id: 'weights', body: `<p class="ex-prose">A Hugging Face <code>.gguf</code> file and, if you use one, a LoRA. Only files from huggingface.co: the command is built for you and shown to the owner before the first launch.</p>
++<form method="post" action="/own" class="ovk-form">
++<label class="ex-label" for="own-repo">Weights repository</label>
++<input id="own-repo" name="weights_repo" placeholder="user/model-GGUF" autocapitalize="off" spellcheck="false">
++<label class="ex-label" for="own-file">Weights file</label>
++<input id="own-file" name="weights_file" placeholder="model-Q4_K_M.gguf" autocapitalize="off" spellcheck="false">
++<label class="ex-label" for="own-lora-repo">LoRA repository (optional)</label>
++<input id="own-lora-repo" name="lora_repo" placeholder="user/my-lora-GGUF" autocapitalize="off" spellcheck="false">
++<label class="ex-label" for="own-lora-file">LoRA file (optional)</label>
++<input id="own-lora-file" name="lora_file" placeholder="adapter.gguf" autocapitalize="off" spellcheck="false">
++<label class="ex-label" for="own-name">Name (optional)</label>
++<input id="own-name" name="display_name" placeholder="My fine-tune">
++${signedIn ? kit.button({ label: 'Start with my weights', type: 'submit' }) : ''}
++</form>` })}
++${panel({ title: 'How it works', id: 'how', body: `<ul class="ex-list">
++<li><strong>Private.</strong> The GPU is never registered as an offer; only your own OpenVibe sign-in reaches it, through <code>${esc((status && status.public_base) || 'https://ai.openvibe.services/api/v1/own-endpoints')}/&lt;id&gt;/v1</code>.</li>
++<li><strong>Scale to zero.</strong> After your idle minutes it is given back, and the price stops with it.</li>
++<li><strong>Your Balance pays.</strong> Billing holds the worst case before the GPU is rented and charges only the seconds it ran. The owner's monthly GPU cap is checked first: at the cap, no new endpoint starts.</li>
++</ul>` })}
++${panel({ title: 'Your endpoints', id: 'mine', body: signedIn ? list : kit.empty({ icon: 'lock', title: 'Sign in to see your endpoints', text: 'They belong to your account.' }) })}` : ''}
++</div>`;
++}
++
++module.exports = { audienceDefaults, home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, ownPage, statusPage, retired, sealedChat, privacyOf, answerPrivacy, providerLabel, familyLabel, chatModels, ENDPOINT };
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
iindex 7fa81d4..a28b991 100644
---- a/test/helpers/ai.js
++++ b/test/helpers/ai.js
@@@ -71,6 +71,8 @@ function createFakeAi({ chat = null } = {}) {
configured: true,
async catalog() { return catalog; },
async usage() { return usage; },
++ async ownStatus() { return { enabled: false, reason: 'The owner has not added a GPU provider key yet, so no endpoint can start.', models: [], idle_minutes: 10, public_base: 'https://ai.openvibe.services/api/v1/own-endpoints' }; },
++ async ownList() { return { endpoints: [] }; },
async chat(args) {
calls.push(args);
if (chat) return chat(args);
ddiff --git a/test/own.test.js b/test/own.test.js
nnew file mode 100644
iindex 0000000..0b37b43
---- /dev/null
++++ b/test/own.test.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * Your own endpoint on OpenVibe.Exchange (the Exchange remake, build order 7): the page shows the plain
++ * reason the feature is off, then (once it is on) the model / own-weights forms and the person's
++ * endpoints. It never talks to a GPU itself: every call goes to AI with the person's own Network token.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++
++(async () => {
++ const ai = createFakeAi();
++ const state = {
++ 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.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' } }; };
++ ai.ownStop = async (token, id) => { state.calls.push({ kind: 'stop', token, id }); return { endpoint: { id, status: 'stopping' } }; };
++ const t = await boot({ ai });
++ try {
++ await check('off: the reason is on the page, with no form', async () => {
++ const r = await t.get('/own');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Your own endpoint'), 'the page title');
++ assert.ok(r.text.includes('has not added a GPU provider key'), 'the plain reason');
++ assert.ok(!r.text.includes('Start my endpoint'), 'no form while it is off');
++ assert.ok(!r.text.includes('bring your own weights'), 'no weights form while it is off');
++ });
++
++ await check('on but signed out: it asks to sign in and names no private data', async () => {
++ state.status = { enabled: true, models: [{ key: 'deepseek-r1-32b', display_name: 'DeepSeek R1 32B distill, Q4', vram_mb: 22000 }], idle_minutes: 10, max_idle_minutes: 120, public_base: 'https://ai.openvibe.services/api/v1/own-endpoints' };
++ const r = await t.get('/own');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Sign in to start a private endpoint'));
++ assert.ok(r.text.includes('DeepSeek R1 32B distill'), 'the model is named');
++ assert.ok(!r.text.includes('Start my endpoint'), 'the submit is only for a signed-in person');
++ });
++
++ 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 });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Start my endpoint'));
++ assert.ok(r.text.includes('bring your own weights'));
++ assert.ok(r.text.includes('never listed as an offer'), 'says the endpoint is private');
++ assert.ok(r.text.includes('scales to zero') || r.text.includes('idle'), 'says it scales to zero');
++ assert.ok(r.text.includes('Your Balance pays'));
++ });
++
++ 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' });
++ 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 });
++ });
++
++ await check('own weights are passed as a weights object', async () => {
++ state.calls.length = 0;
++ const r = await t.get('/own', { as: u, form: { weights_repo: 'someone/My-GGUF', weights_file: 'm.gguf', lora_repo: 'someone/lora', lora_file: 'adapter.gguf', display_name: 'Mine' }, method: 'POST' });
++ assert.strictEqual(r.status, 303, r.text);
++ const call = state.calls.find((c) => c.kind === 'create');
++ assert.deepStrictEqual(call.body.weights, { repo: 'someone/My-GGUF', file: 'm.gguf', lora_repo: 'someone/lora', lora_file: 'adapter.gguf' });
++ assert.strictEqual(call.body.display_name, 'Mine');
++ });
++
++ await check('the list shows the URL and a stop button; stop posts to AI', async () => {
++ state.endpoints = [{
++ id: 'oep_01JAB2C3D4E5F6G7H8J9K0MNPQ', status: 'running', display_name: 'DeepSeek R1 32B distill, Q4',
++ idle_minutes: 5, price_per_hour: '0.1348', charged_nano: '2246667', url: 'https://ai.openvibe.services/api/v1/own-endpoints/oep_01JAB2C3D4E5F6G7H8J9K0MNPQ/v1',
++ }];
++ state.calls.length = 0;
++ const r = await t.get('/own', { as: u });
++ assert.ok(r.text.includes('https://ai.openvibe.services/api/v1/own-endpoints/oep_01JAB2C3D4E5F6G7H8J9K0MNPQ/v1'), 'the endpoint URL');
++ assert.ok(r.text.includes('$0.13 an hour'), 'the price');
++ assert.ok(r.text.includes('/own/oep_01JAB2C3D4E5F6G7H8J9K0MNPQ/stop'), 'the stop form');
++ const stopped = await t.get('/own/oep_01JAB2C3D4E5F6G7H8J9K0MNPQ/stop', { as: u, method: 'POST' });
++ assert.strictEqual(stopped.status, 303);
++ assert.ok(state.calls.some((c) => c.kind === 'stop' && c.id === 'oep_01JAB2C3D4E5F6G7H8J9K0MNPQ'));
++ });
++
++ await check('a cross-site post is refused before AI is called', async () => {
++ state.calls.length = 0;
++ const r = await t.get('/own', { as: u, form: { model: 'deepseek-r1-32b' }, method: 'POST', origin: 'https://evil.example' });
++ assert.strictEqual(r.status, 403);
++ assert.ok(!state.calls.some((c) => c.kind === 'create'));
++ });
++
++ await check('AI unreachable: the page says so and does not invent a reason', async () => {
++ ai.ownStatus = async () => { throw Object.assign(new Error('down'), { code: 'ai.unavailable' }); };
++ const r = await t.get('/own');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('not reachable right now'));
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });