openvibers/openvibe.exchange Commits
Expose owner provider order controls in the console (#175)
Changes
@@@ -13,7 +13,7 @@ const READS = Object.freeze({usage: '/api/v1/admin/console/usage', cache: '/api/v1/admin/console/cache', audit: '/api/v1/admin/console/audit',gpus: '/api/v1/admin/console/gpus', pricebook: '/api/v1/admin/console/pricebook', exchange: '/api/v1/admin/console/exchange',abuse: '/api/v1/admin/console/abuse', glossary: '/api/v1/admin/console/glossary',-- sync: '/api/v1/admin/console/sync',++ sync: '/api/v1/admin/console/sync', orders: '/api/v1/admin/console/orders',});function createConsoleRoutes({ config, ai }) {@@@ -124,6 +124,19 @@ function createConsoleRoutes({ config, ai }) {if (!origin) return false;try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }};++ r.post('/console/orders/:capability', async (req, res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ let choices;++ try { choices = JSON.parse(String(req.body.choices || '[]')); }++ catch { return page(req, res, 'orders', {}, 'Choices must be a JSON array.', 422); }++ if (!Array.isArray(choices) || !['0', '1'].includes(req.body.enabled)) return res.sendStatus(422);++ try {++ await ai.adminRequest(`/api/v1/admin/console/orders/${encodeURIComponent(req.params.capability)}`, {++ method: 'POST', subject: req.viewer.subject, body: { enabled: req.body.enabled === '1', choices },++ });++ return res.redirect(303, '/console/orders');++ } catch (err) { return page(req, res, 'orders', {}, 'Provider order could not be saved.', err.status === 422 ? 422 : 502); }++ });r.post('/console/supply', async (req,res) => {if (!sameOrigin(req)) return res.sendStatus(403);const body = { model: req.body.model, runtime: req.body.runtime };@@@ -8,7 +8,7 @@ const SECTIONS = [['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],-- ['Exchange', '/console/exchange'], ['Abuse', '/console/abuse'], ['Audit', '/console/audit'], ['Glossary', '/console/glossary'], ['Model syncs', '/console/sync'],++ ['Exchange', '/console/exchange'], ['Abuse', '/console/abuse'], ['Audit', '/console/audit'], ['Glossary', '/console/glossary'], ['Model syncs', '/console/sync'], ['Provider order', '/console/orders'],];function safe(value) {@@@ -22,6 +22,7 @@ function consolePage({ section = 'overview', data = {}, error = null }) {const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');const json = esc(JSON.stringify(safe(data), null, 2));let actions = '';++ if (section === 'orders' && Array.isArray(data.orders)) actions = data.orders.map(order => `<form class="ovk-form" method="post" action="/console/orders/${encodeURIComponent(order.capability)}"><h2>${esc(order.capability)}</h2><label>Use this order<select name="enabled"><option value="0"${order.enabled ? '' : ' selected'}>Off</option><option value="1"${order.enabled ? ' selected' : ''}>On</option></select></label><label>Provider and model choices<textarea name="choices" rows="6" required>${esc(JSON.stringify(order.choices || [], null, 2))}</textarea></label><p>First healthy choice answers. An empty enabled array allows no provider. Version ${esc(order.version || 'none')}.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save order</button></form>`).join('');if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-btn ovk-btn--primary" type="submit">Save prices and ceilings</button></form>`;if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {@@@ -45,6 +45,14 @@ app.use(createConsoleRoutes({assert.ok((data.headers.get('content-type') || '').includes('application/json'));assert.ok(!JSON.stringify(await data.json()).includes(fakeSecret), `${section} data omits key values`);}++ for (const role of ['user', 'admin']) {++ const count = writes.length;++ const deniedOrder = await fetch(`${base}/console/orders/chat`, { method: 'POST', headers: { 'x-test-role': role, Origin: 'https://openvibe.exchange' } });++ assert.strictEqual(deniedOrder.status, 403);++ assert.strictEqual(writes.length, count);++ }++ const orderCsrf = await fetch(`${base}/console/orders/chat`, { method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://evil.example' } });++ assert.strictEqual(orderCsrf.status, 403);const keyWrite = await fetch(`${base}/console/providers/openai/key`, {method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },body: new URLSearchParams({ api_key: fakeSecret }), redirect: 'manual',@@@ -53,6 +61,15 @@ app.use(createConsoleRoutes({assert.strictEqual(keyWrite.status, 303);assert.ok(!keyAnswer.includes(fakeSecret), 'a provider key POST never echoes its value');assert.deepStrictEqual(writes[0], { path: '/api/v1/admin/console/providers/openai/key', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: { api_key: fakeSecret } });++ const orderWrite = await fetch(`${base}/console/orders/chat`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ enabled: '1', choices: '[{"provider":"openai","model":"m"}]' }), redirect: 'manual',++ });++ assert.strictEqual(orderWrite.status, 303);++ assert.deepStrictEqual(writes.at(-1).body, { enabled: true, choices: [{ provider: 'openai', model: 'm' }] });++ const orderHtml = require('../server/views/console').consolePage({ section: 'orders', data: { orders: [{ capability: 'chat', enabled: true, choices: [{ provider: '<x>', model: 'm' }] }] } });++ assert.ok(orderHtml.includes('/console/orders/chat'));++ assert.ok(orderHtml.includes('<x>'));for (const action of ['key', 'remove-key', 'check', 'sync', 'model-settings']) {for (const role of ['user', 'admin']) {const count = writes.length;
@@ -13,7 +13,7 @@ const READS = Object.freeze({
@@ -13,7 +13,7 @@ const READS = Object.freeze({
usage: '/api/v1/admin/console/usage', cache: '/api/v1/admin/console/cache', audit: '/api/v1/admin/console/audit',
usage: '/api/v1/admin/console/usage', cache: '/api/v1/admin/console/cache', audit: '/api/v1/admin/console/audit',
gpus: '/api/v1/admin/console/gpus', pricebook: '/api/v1/admin/console/pricebook', exchange: '/api/v1/admin/console/exchange',
gpus: '/api/v1/admin/console/gpus', pricebook: '/api/v1/admin/console/pricebook', exchange: '/api/v1/admin/console/exchange',
abuse: '/api/v1/admin/console/abuse', glossary: '/api/v1/admin/console/glossary',
abuse: '/api/v1/admin/console/abuse', glossary: '/api/v1/admin/console/glossary',
- sync: '/api/v1/admin/console/sync',
+ sync: '/api/v1/admin/console/sync', orders: '/api/v1/admin/console/orders',
});
});
function createConsoleRoutes({ config, ai }) {
function createConsoleRoutes({ config, ai }) {
@@ -124,6 +124,19 @@ function createConsoleRoutes({ config, ai }) {
@@ -124,6 +124,19 @@ function createConsoleRoutes({ config, ai }) {
if (!origin) return false;
if (!origin) return false;
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
};
};
+ r.post('/console/orders/:capability', async (req, res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ let choices;
+ try { choices = JSON.parse(String(req.body.choices || '[]')); }
+ catch { return page(req, res, 'orders', {}, 'Choices must be a JSON array.', 422); }
+ if (!Array.isArray(choices) || !['0', '1'].includes(req.body.enabled)) return res.sendStatus(422);
+ try {
+ await ai.adminRequest(`/api/v1/admin/console/orders/${encodeURIComponent(req.params.capability)}`, {
+ method: 'POST', subject: req.viewer.subject, body: { enabled: req.body.enabled === '1', choices },
+ });
+ return res.redirect(303, '/console/orders');
+ } catch (err) { return page(req, res, 'orders', {}, 'Provider order could not be saved.', err.status === 422 ? 422 : 502); }
+ });
r.post('/console/supply', async (req,res) => {
r.post('/console/supply', async (req,res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
const body = { model: req.body.model, runtime: req.body.runtime };
const body = { model: req.body.model, runtime: req.body.runtime };
@@ -8,7 +8,7 @@ const SECTIONS = [
@@ -8,7 +8,7 @@ const SECTIONS = [
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
- ['Exchange', '/console/exchange'], ['Abuse', '/console/abuse'], ['Audit', '/console/audit'], ['Glossary', '/console/glossary'], ['Model syncs', '/console/sync'],
+ ['Exchange', '/console/exchange'], ['Abuse', '/console/abuse'], ['Audit', '/console/audit'], ['Glossary', '/console/glossary'], ['Model syncs', '/console/sync'], ['Provider order', '/console/orders'],
];
];
function safe(value) {
function safe(value) {
@@ -22,6 +22,7 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
@@ -22,6 +22,7 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');
const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');
const json = esc(JSON.stringify(safe(data), null, 2));
const json = esc(JSON.stringify(safe(data), null, 2));
let actions = '';
let actions = '';
+ if (section === 'orders' && Array.isArray(data.orders)) actions = data.orders.map(order => `<form class="ovk-form" method="post" action="/console/orders/${encodeURIComponent(order.capability)}"><h2>${esc(order.capability)}</h2><label>Use this order<select name="enabled"><option value="0"${order.enabled ? '' : ' selected'}>Off</option><option value="1"${order.enabled ? ' selected' : ''}>On</option></select></label><label>Provider and model choices<textarea name="choices" rows="6" required>${esc(JSON.stringify(order.choices || [], null, 2))}</textarea></label><p>First healthy choice answers. An empty enabled array allows no provider. Version ${esc(order.version || 'none')}.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save order</button></form>`).join('');
if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-btn ovk-btn--primary" type="submit">Save prices and ceilings</button></form>`;
if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-btn ovk-btn--primary" type="submit">Save prices and ceilings</button></form>`;
if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
@@ -45,6 +45,14 @@ app.use(createConsoleRoutes({
@@ -45,6 +45,14 @@ app.use(createConsoleRoutes({
assert.ok((data.headers.get('content-type') || '').includes('application/json'));
assert.ok((data.headers.get('content-type') || '').includes('application/json'));
assert.ok(!JSON.stringify(await data.json()).includes(fakeSecret), `${section} data omits key values`);
assert.ok(!JSON.stringify(await data.json()).includes(fakeSecret), `${section} data omits key values`);
}
}
+ for (const role of ['user', 'admin']) {
+ const count = writes.length;
+ const deniedOrder = await fetch(`${base}/console/orders/chat`, { method: 'POST', headers: { 'x-test-role': role, Origin: 'https://openvibe.exchange' } });
+ assert.strictEqual(deniedOrder.status, 403);
+ assert.strictEqual(writes.length, count);
+ }
+ const orderCsrf = await fetch(`${base}/console/orders/chat`, { method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://evil.example' } });
+ assert.strictEqual(orderCsrf.status, 403);
const keyWrite = await fetch(`${base}/console/providers/openai/key`, {
const keyWrite = await fetch(`${base}/console/providers/openai/key`, {
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ api_key: fakeSecret }), redirect: 'manual',
body: new URLSearchParams({ api_key: fakeSecret }), redirect: 'manual',
@@ -53,6 +61,15 @@ app.use(createConsoleRoutes({
@@ -53,6 +61,15 @@ app.use(createConsoleRoutes({
assert.strictEqual(keyWrite.status, 303);
assert.strictEqual(keyWrite.status, 303);
assert.ok(!keyAnswer.includes(fakeSecret), 'a provider key POST never echoes its value');
assert.ok(!keyAnswer.includes(fakeSecret), 'a provider key POST never echoes its value');
assert.deepStrictEqual(writes[0], { path: '/api/v1/admin/console/providers/openai/key', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: { api_key: fakeSecret } });
assert.deepStrictEqual(writes[0], { path: '/api/v1/admin/console/providers/openai/key', method: 'POST', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', body: { api_key: fakeSecret } });
+ const orderWrite = await fetch(`${base}/console/orders/chat`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ enabled: '1', choices: '[{"provider":"openai","model":"m"}]' }), redirect: 'manual',
+ });
+ assert.strictEqual(orderWrite.status, 303);
+ assert.deepStrictEqual(writes.at(-1).body, { enabled: true, choices: [{ provider: 'openai', model: 'm' }] });
+ const orderHtml = require('../server/views/console').consolePage({ section: 'orders', data: { orders: [{ capability: 'chat', enabled: true, choices: [{ provider: '<x>', model: 'm' }] }] } });
+ assert.ok(orderHtml.includes('/console/orders/chat'));
+ assert.ok(orderHtml.includes('<x>'));
for (const action of ['key', 'remove-key', 'check', 'sync', 'model-settings']) {
for (const action of ['key', 'remove-key', 'check', 'sync', 'model-settings']) {
for (const role of ['user', 'admin']) {
for (const role of ['user', 'admin']) {
const count = writes.length;
const count = writes.length;