openvibers/openvibe.exchange Commits
Add owner console endpoints for provider pricing controls (#160)
Changes
@@@ -8,7 +8,7 @@ const { defaultsPage } = require('../views/task-defaults');const READS = Object.freeze({supply: '/api/v1/admin/console/supply',-- overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/models', routes: '/api/v1/routes',++ overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/admin/console/models', routes: '/api/v1/routes',templates: '/api/v1/templates', workflows: '/api/v1/workflows', runs: '/api/v1/admin/console/runs', quotas: '/api/v1/admin/console/quotas',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',@@@ -79,6 +79,18 @@ function createConsoleRoutes({ config, ai }) {return page(req, res, 'gpus', { quote: data });} catch (err) { return page(req, res, 'gpus', {}, err.message || 'The GPU quote is unavailable.', 502); }});++ // Small JSON read models for the owner panels and clients. Keep the page renderer out of this path so the++ // provider, model and pricebook sections can be arranged by the console owner without changing the API shape.++ r.get('/console/data/:section', async (req, res) => {++ const section = req.params.section;++ if (!['providers', 'models', 'pricebook'].includes(section)) return res.sendStatus(404);++ try {++ const data = await ai.adminRequest(READS[section]);++ const scrub = (value) => Array.isArray(value) ? value.map(scrub) : value && typeof value === 'object'++ ? Object.fromEntries(Object.entries(value).filter(([key]) => !['api_key', 'secret', 'key_value'].includes(key.toLowerCase())).map(([key, child]) => [key, scrub(child)])) : value;++ return res.set('Cache-Control', 'no-store').json(scrub(data));++ } catch (err) { return res.status(err.status === 404 ? 404 : 502).json({ error: err.message || 'AI admin API did not answer.' }); }++ });r.get('/console/:section/:key', async (req, res) => {const { section, key } = req.params;const path = section === 'providers' && key === 'new' ? '/api/v1/admin/console/providers'@@@ -129,6 +141,10 @@ function createConsoleRoutes({ config, ai }) {await ai.adminRequest(`/api/v1/providers/${key}/${endpoint}`, { method: 'POST', subject: req.viewer.subject });} else if (action === 'reset') await ai.adminRequest(`/api/v1/providers/${key}/reset`, { method: 'POST', subject: req.viewer.subject });else if (action === 'key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'POST', subject: req.viewer.subject, body: { api_key: String(req.body.api_key || '') } });++ else if (action === 'settings') await ai.adminRequest(`/api/v1/providers/${key}`, { method: 'PATCH', subject: req.viewer.subject, body: {++ status: req.body.status, provider_access: req.body.provider_access, priority: Number(req.body.preference_order),++ markup_percent: Number(req.body.markup_percent), uncensored: req.body.uncensored === '1',++ } });else return res.sendStatus(404);return res.redirect(303, '/console/providers');} catch (err) { return page(req, res, 'providers', {}, err.message || 'The change could not be saved.', 502); }@@@ -176,6 +192,19 @@ function createConsoleRoutes({ config, ai }) {return res.redirect(303, '/console/models');} catch (err) { return page(req, res, 'models', {}, err.message || 'The change could not be saved.', 502); }});++ r.post('/console/models/:provider/:model/override', async (req, res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ const number = (value) => value === '' || value == null ? null : Number(value);++ try {++ const cost = {};++ for (const [form, field] of [['input_per_million', 'in_per_mtok'], ['cached_per_million', 'cached_per_mtok'], ['output_per_million', 'out_per_mtok']])++ if (Object.prototype.hasOwnProperty.call(req.body, form)) cost[field] = number(req.body[form]);++ await ai.adminRequest(`/api/v1/models/${encodeURIComponent(req.params.provider)}/${encodeURIComponent(req.params.model)}`, {++ method: 'PATCH', subject: req.viewer.subject, body: { owner_override: { reason: String(req.body.reason || ''), cost } },++ });++ return res.redirect(303, '/console/models');++ } catch (err) { return page(req, res, 'models', {}, err.message || 'The model price override could not be saved.', 502); }++ });r.post('/console/quotas', async (req, res) => {if (!sameOrigin(req)) return res.sendStatus(403);try {@@@ -24,7 +24,7 @@ app.use(createConsoleRoutes({await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));const base = `http://127.0.0.1:${server.address().port}`;try {-- const consolePaths = ['/console', ...Object.keys(READS).map((section) => `/console/${section}`), '/console/providers/new', '/console/providers/openai', '/console/gpus/model-a/quote'];++ const consolePaths = ['/console', ...Object.keys(READS).map((section) => `/console/${section}`), '/console/providers/new', '/console/providers/openai', '/console/gpus/model-a/quote', '/console/data/providers', '/console/data/models', '/console/data/pricebook'];for (const path of consolePaths) for (const headers of [{}, { 'x-test-role': 'user' }, { 'x-test-role': 'admin' }]) {const response = await fetch(`${base}${path}`, { headers });assert.strictEqual(response.status, 404, `${path}: signed-out people and non-owners see no console route`);@@@ -39,6 +39,12 @@ app.use(createConsoleRoutes({const page = await fetch(`${base}${path}`, { headers: { 'x-test-role': 'owner' } });assert.strictEqual(page.status, 200, `${path}: ${await page.text()}`);}++ for (const section of ['providers', 'models', 'pricebook']) {++ const data = await fetch(`${base}/console/data/${section}`, { headers: { 'x-test-role': 'owner' } });++ assert.strictEqual(data.status, 200);++ assert.ok((data.headers.get('content-type') || '').includes('application/json'));++ assert.ok(!JSON.stringify(await data.json()).includes(fakeSecret), `${section} data omits key values`);++ }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',@@@ -55,6 +61,19 @@ app.use(createConsoleRoutes({assert.strictEqual(createProvider.status, 303);assert.ok(writes.some((write) => write.path === '/api/v1/admin/console/providers' && write.body.api_key === fakeSecret), 'provider creation uses AI JSON admin API');++ const providerSettings = await fetch(`${base}/console/providers/groq/settings`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ status: 'active', provider_access: 'AUTHORIZED_WHOLESALE', preference_order: '7', markup_percent: '12.5', uncensored: '1' }), redirect: 'manual',++ });++ assert.strictEqual(providerSettings.status, 303);++ assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));++ const modelOverride = await fetch(`${base}/console/models/groq/openai%2Fgpt-oss-120b/override`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ input_per_million: '0.2', cached_per_million: '', output_per_million: '0.7', reason: 'Owner approved adjustment' }), redirect: 'manual',++ });++ assert.strictEqual(modelOverride.status, 303);++ assert.ok(writes.some((write) => write.path === '/api/v1/models/groq/openai%2Fgpt-oss-120b' && write.body.owner_override.reason === 'Owner approved adjustment'));++} finally { await new Promise((resolve) => server.close(resolve)); }process.stdout.write('console-merge: owner gate and secret redaction passed\n');})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 1; });
@@ -8,7 +8,7 @@ const { defaultsPage } = require('../views/task-defaults');
@@ -8,7 +8,7 @@ const { defaultsPage } = require('../views/task-defaults');
const READS = Object.freeze({
const READS = Object.freeze({
supply: '/api/v1/admin/console/supply',
supply: '/api/v1/admin/console/supply',
- overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/models', routes: '/api/v1/routes',
+ overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/admin/console/models', routes: '/api/v1/routes',
templates: '/api/v1/templates', workflows: '/api/v1/workflows', runs: '/api/v1/admin/console/runs', quotas: '/api/v1/admin/console/quotas',
templates: '/api/v1/templates', workflows: '/api/v1/workflows', runs: '/api/v1/admin/console/runs', quotas: '/api/v1/admin/console/quotas',
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',
@@ -79,6 +79,18 @@ function createConsoleRoutes({ config, ai }) {
@@ -79,6 +79,18 @@ function createConsoleRoutes({ config, ai }) {
return page(req, res, 'gpus', { quote: data });
return page(req, res, 'gpus', { quote: data });
} catch (err) { return page(req, res, 'gpus', {}, err.message || 'The GPU quote is unavailable.', 502); }
} catch (err) { return page(req, res, 'gpus', {}, err.message || 'The GPU quote is unavailable.', 502); }
});
});
+ // Small JSON read models for the owner panels and clients. Keep the page renderer out of this path so the
+ // provider, model and pricebook sections can be arranged by the console owner without changing the API shape.
+ r.get('/console/data/:section', async (req, res) => {
+ const section = req.params.section;
+ if (!['providers', 'models', 'pricebook'].includes(section)) return res.sendStatus(404);
+ try {
+ const data = await ai.adminRequest(READS[section]);
+ const scrub = (value) => Array.isArray(value) ? value.map(scrub) : value && typeof value === 'object'
+ ? Object.fromEntries(Object.entries(value).filter(([key]) => !['api_key', 'secret', 'key_value'].includes(key.toLowerCase())).map(([key, child]) => [key, scrub(child)])) : value;
+ return res.set('Cache-Control', 'no-store').json(scrub(data));
+ } catch (err) { return res.status(err.status === 404 ? 404 : 502).json({ error: err.message || 'AI admin API did not answer.' }); }
+ });
r.get('/console/:section/:key', async (req, res) => {
r.get('/console/:section/:key', async (req, res) => {
const { section, key } = req.params;
const { section, key } = req.params;
const path = section === 'providers' && key === 'new' ? '/api/v1/admin/console/providers'
const path = section === 'providers' && key === 'new' ? '/api/v1/admin/console/providers'
@@ -129,6 +141,10 @@ function createConsoleRoutes({ config, ai }) {
@@ -129,6 +141,10 @@ function createConsoleRoutes({ config, ai }) {
await ai.adminRequest(`/api/v1/providers/${key}/${endpoint}`, { method: 'POST', subject: req.viewer.subject });
await ai.adminRequest(`/api/v1/providers/${key}/${endpoint}`, { method: 'POST', subject: req.viewer.subject });
} else if (action === 'reset') await ai.adminRequest(`/api/v1/providers/${key}/reset`, { method: 'POST', subject: req.viewer.subject });
} else if (action === 'reset') await ai.adminRequest(`/api/v1/providers/${key}/reset`, { method: 'POST', subject: req.viewer.subject });
else if (action === 'key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'POST', subject: req.viewer.subject, body: { api_key: String(req.body.api_key || '') } });
else if (action === 'key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'POST', subject: req.viewer.subject, body: { api_key: String(req.body.api_key || '') } });
+ else if (action === 'settings') await ai.adminRequest(`/api/v1/providers/${key}`, { method: 'PATCH', subject: req.viewer.subject, body: {
+ status: req.body.status, provider_access: req.body.provider_access, priority: Number(req.body.preference_order),
+ markup_percent: Number(req.body.markup_percent), uncensored: req.body.uncensored === '1',
+ } });
else return res.sendStatus(404);
else return res.sendStatus(404);
return res.redirect(303, '/console/providers');
return res.redirect(303, '/console/providers');
} catch (err) { return page(req, res, 'providers', {}, err.message || 'The change could not be saved.', 502); }
} catch (err) { return page(req, res, 'providers', {}, err.message || 'The change could not be saved.', 502); }
@@ -176,6 +192,19 @@ function createConsoleRoutes({ config, ai }) {
@@ -176,6 +192,19 @@ function createConsoleRoutes({ config, ai }) {
return res.redirect(303, '/console/models');
return res.redirect(303, '/console/models');
} catch (err) { return page(req, res, 'models', {}, err.message || 'The change could not be saved.', 502); }
} catch (err) { return page(req, res, 'models', {}, err.message || 'The change could not be saved.', 502); }
});
});
+ r.post('/console/models/:provider/:model/override', async (req, res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ const number = (value) => value === '' || value == null ? null : Number(value);
+ try {
+ const cost = {};
+ for (const [form, field] of [['input_per_million', 'in_per_mtok'], ['cached_per_million', 'cached_per_mtok'], ['output_per_million', 'out_per_mtok']])
+ if (Object.prototype.hasOwnProperty.call(req.body, form)) cost[field] = number(req.body[form]);
+ await ai.adminRequest(`/api/v1/models/${encodeURIComponent(req.params.provider)}/${encodeURIComponent(req.params.model)}`, {
+ method: 'PATCH', subject: req.viewer.subject, body: { owner_override: { reason: String(req.body.reason || ''), cost } },
+ });
+ return res.redirect(303, '/console/models');
+ } catch (err) { return page(req, res, 'models', {}, err.message || 'The model price override could not be saved.', 502); }
+ });
r.post('/console/quotas', async (req, res) => {
r.post('/console/quotas', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
try {
try {
@@ -24,7 +24,7 @@ app.use(createConsoleRoutes({
@@ -24,7 +24,7 @@ app.use(createConsoleRoutes({
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
try {
try {
- const consolePaths = ['/console', ...Object.keys(READS).map((section) => `/console/${section}`), '/console/providers/new', '/console/providers/openai', '/console/gpus/model-a/quote'];
+ const consolePaths = ['/console', ...Object.keys(READS).map((section) => `/console/${section}`), '/console/providers/new', '/console/providers/openai', '/console/gpus/model-a/quote', '/console/data/providers', '/console/data/models', '/console/data/pricebook'];
for (const path of consolePaths) for (const headers of [{}, { 'x-test-role': 'user' }, { 'x-test-role': 'admin' }]) {
for (const path of consolePaths) for (const headers of [{}, { 'x-test-role': 'user' }, { 'x-test-role': 'admin' }]) {
const response = await fetch(`${base}${path}`, { headers });
const response = await fetch(`${base}${path}`, { headers });
assert.strictEqual(response.status, 404, `${path}: signed-out people and non-owners see no console route`);
assert.strictEqual(response.status, 404, `${path}: signed-out people and non-owners see no console route`);
@@ -39,6 +39,12 @@ app.use(createConsoleRoutes({
@@ -39,6 +39,12 @@ app.use(createConsoleRoutes({
const page = await fetch(`${base}${path}`, { headers: { 'x-test-role': 'owner' } });
const page = await fetch(`${base}${path}`, { headers: { 'x-test-role': 'owner' } });
assert.strictEqual(page.status, 200, `${path}: ${await page.text()}`);
assert.strictEqual(page.status, 200, `${path}: ${await page.text()}`);
}
}
+ for (const section of ['providers', 'models', 'pricebook']) {
+ const data = await fetch(`${base}/console/data/${section}`, { headers: { 'x-test-role': 'owner' } });
+ assert.strictEqual(data.status, 200);
+ assert.ok((data.headers.get('content-type') || '').includes('application/json'));
+ assert.ok(!JSON.stringify(await data.json()).includes(fakeSecret), `${section} data omits key values`);
+ }
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',
@@ -55,6 +61,19 @@ app.use(createConsoleRoutes({
@@ -55,6 +61,19 @@ app.use(createConsoleRoutes({
assert.strictEqual(createProvider.status, 303);
assert.strictEqual(createProvider.status, 303);
assert.ok(writes.some((write) => write.path === '/api/v1/admin/console/providers' && write.body.api_key === fakeSecret), 'provider creation uses AI JSON admin API');
assert.ok(writes.some((write) => write.path === '/api/v1/admin/console/providers' && write.body.api_key === fakeSecret), 'provider creation uses AI JSON admin API');
+ const providerSettings = await fetch(`${base}/console/providers/groq/settings`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ status: 'active', provider_access: 'AUTHORIZED_WHOLESALE', preference_order: '7', markup_percent: '12.5', uncensored: '1' }), redirect: 'manual',
+ });
+ assert.strictEqual(providerSettings.status, 303);
+ assert.ok(writes.some((write) => write.path === '/api/v1/providers/groq' && write.body.markup_percent === 12.5 && write.body.uncensored === true));
+ const modelOverride = await fetch(`${base}/console/models/groq/openai%2Fgpt-oss-120b/override`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ input_per_million: '0.2', cached_per_million: '', output_per_million: '0.7', reason: 'Owner approved adjustment' }), redirect: 'manual',
+ });
+ assert.strictEqual(modelOverride.status, 303);
+ assert.ok(writes.some((write) => write.path === '/api/v1/models/groq/openai%2Fgpt-oss-120b' && write.body.owner_override.reason === 'Owner approved adjustment'));
+
} finally { await new Promise((resolve) => server.close(resolve)); }
} finally { await new Promise((resolve) => server.close(resolve)); }
process.stdout.write('console-merge: owner gate and secret redaction passed\n');
process.stdout.write('console-merge: owner gate and secret redaction passed\n');
})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 1; });
})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 1; });