openvibers/openvibe.exchange Commits

Add owner console endpoints for provider pricing controls (#160)

OP OpenVibers committed 4 days ago · 418c289149 · 2 files, +50 −2

Changes

ddiff --git a/server/http/console.js b/server/http/console.js
iindex aafdb6d..547284e 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -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 {
ddiff --git a/test/console-merge.test.js b/test/console-merge.test.js
iindex fd3a083..575860f 100644
---- a/test/console-merge.test.js
++++ b/test/console-merge.test.js
@@@ -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; });