openvibers/openvibe.exchange Commits
Your own endpoint: the page to create a private GPU (#99)
Changes
@@@ -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) };}/**@@@ -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.' },@@@ -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();@@@ -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' },@@@ -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')}/<id>/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 };@@@ -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);nnew file mode 100644@@@ -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); });
@@ -218,7 +218,37 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -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 { 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) };
}
}
/**
/**
@@ -20,6 +20,7 @@ const PUBLIC_PAGES = [
@@ -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: '/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: '/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: '/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: '/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: '/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.' },
{ 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.' },
@@ -639,6 +639,73 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
@@ -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 ───────────────────────────────────────────
// ── Uncensored ───────────────────────────────────────────
r.get('/uncensored', async (req, res) => {
r.get('/uncensored', async (req, res) => {
const catalog = await catalogOf();
const catalog = await catalogOf();
@@ -26,6 +26,7 @@ const NAV = [
@@ -26,6 +26,7 @@ const NAV = [
{ label: 'Rankings', href: '/rankings' },
{ label: 'Rankings', href: '/rankings' },
{ label: 'API', href: '/api' },
{ label: 'API', href: '/api' },
{ label: 'Sell compute', href: '/sell' },
{ label: 'Sell compute', href: '/sell' },
+ { label: 'Your endpoint', href: '/own' },
{ label: 'Uncensored', href: '/uncensored' },
{ label: 'Uncensored', href: '/uncensored' },
{ label: 'AI in OpenVibe', href: '/ai' },
{ label: 'AI in OpenVibe', href: '/ai' },
{ label: 'Status', href: '/status' },
{ label: 'Status', href: '/status' },
@@ -718,4 +718,75 @@ ${panel({ title: 'Where those things live now', body: `<ul class="ex-list">
@@ -718,4 +718,75 @@ ${panel({ title: 'Where those things live now', body: `<ul class="ex-list">
</div>`;
</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')}/<id>/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 };
@@ -71,6 +71,8 @@ function createFakeAi({ chat = null } = {}) {
@@ -71,6 +71,8 @@ function createFakeAi({ chat = null } = {}) {
configured: true,
configured: true,
async catalog() { return catalog; },
async catalog() { return catalog; },
async usage() { return usage; },
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) {
async chat(args) {
calls.push(args);
calls.push(args);
if (chat) return chat(args);
if (chat) return chat(args);
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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); });