openvibers/openvibe.exchange Commits
Add provider model sync controls to the console (#173)
Changes
@@@ -13,6 +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',});function createConsoleRoutes({ config, ai }) {@@@ -95,6 +96,10 @@ function createConsoleRoutes({ config, ai }) {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/providers/:key/models', async (req, res) => {++ try { return page(req, res, 'models', await ai.adminRequest(`/api/v1/admin/console/models?provider=${encodeURIComponent(req.params.key)}`)); }++ catch { return page(req, res, 'models', {}, 'The models could not be read.', 502); }++ });r.get('/console/:section/:key', async (req, res) => {const { section, key } = req.params;const path = section === 'providers' && key === 'new' ? '/api/v1/admin/console/providers'@@@ -147,6 +152,12 @@ function createConsoleRoutes({ config, ai }) {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 === 'remove-key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'DELETE', subject: req.viewer.subject });else if (action === 'check') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject });++ else if (action === 'sync') {++ await ai.adminRequest(`/api/v1/admin/console/providers/${key}/sync`, { method: 'POST', subject: req.viewer.subject });++ return res.redirect(303, `/console/providers/${key}/models`);++ } else if (action === 'model-settings') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/model-settings`, { method: 'POST', subject: req.viewer.subject, body: {++ auto_list_new_models: req.body.auto_list_new_models === '1', model_sync_interval_minutes: Number(req.body.model_sync_interval_minutes || 0),++ } });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',@@@ -198,6 +209,15 @@ 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/settings', async (req, res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ try {++ await ai.adminRequest(`/api/v1/models/${encodeURIComponent(req.params.provider)}/${encodeURIComponent(req.params.model)}`, {++ method: 'PATCH', subject: req.viewer.subject, body: { display_name: String(req.body.display_name || ''), content_tier: req.body.content_tier },++ });++ return res.redirect(303, `/console/providers/${encodeURIComponent(req.params.provider)}/models`);++ } catch { return page(req, res, 'models', {}, 'The model settings 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);@@@ -74,12 +74,20 @@ function providerCard(p, models) {<label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label><p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p></form>`;++ const modelActions = p.metadata?.models_endpoint || ['openai', 'anthropic', 'responses', 'gemini'].includes(p.kind) ? `++ <p><a href="/console/providers/${key}/models">Models</a> · <a href="/console/sync">Sync log</a></p>++ <form method="post" action="/console/providers/${key}/sync"><button class="ovk-btn ovk-btn--quiet" type="submit">Sync models</button></form>++ <form method="post" action="/console/providers/${key}/model-settings">++ <label><input type="checkbox" name="auto_list_new_models" value="1"${p.metadata?.auto_list_new_models ? ' checked' : ''}>List new models automatically</label>++ <label>Sync schedule<select name="model_sync_interval_minutes">${[[0, 'Off'], [60, 'Hourly'], [360, 'Every six hours'], [1440, 'Daily']].map(([minutes, name]) => `<option value="${minutes}"${Number(p.metadata?.model_sync_interval_minutes || 0) === minutes ? ' selected' : ''}>${name}</option>`).join('')}</select></label>++ <button class="ovk-btn ovk-btn--quiet" type="submit">Save model settings</button>++ </form>` : '';return `<li class="exc-prov${on ? '' : ' is-off'}" id="p-${esc(p.key)}"><div class="exc-prov-head">${mark(p.key || p.display_name)}<div class="exc-prov-name"><b>${esc(p.display_name || p.key)}</b><small>${esc(host(p.base_url))}${mine.length ? ` · ${mine.length} ${mine.length === 1 ? 'model' : 'models'}` : ''} · order ${esc(p.preference_order ?? p.priority ?? '–')}${Number(p.markup_percent) ? ` · +${esc(p.markup_percent)}%` : ''}</small></div><form method="post" action="/console/providers/${key}/status" class="exc-quick"><input type="hidden" name="status" value="${on ? 'disabled' : 'active'}">${btn(on ? 'Turn off' : 'Turn on')}</form></div><p class="exc-tags">${tags}</p>${accessNote ? `<p class="exc-note">${esc(accessNote)}</p>` : ''}-- <details class="exc-fold"><summary>Settings and key</summary><div class="exc-fold-body">${settings}${keyState.source === 'not_required' ? '' : keyForm}${keyActions}++ <details class="exc-fold"><summary>Settings and key</summary><div class="exc-fold-body">${settings}${keyState.source === 'not_required' ? '' : keyForm}${keyActions}${modelActions}<form method="post" action="/console/providers/${key}/reset" class="exc-inline"><p class="exc-note">Reset clears its failure count and opens it to traffic again.</p>${btn('Reset health')}</form></div></details></li>`;}@@@ -146,6 +154,13 @@ function modelsSection(data = {}) {<div class="exc-model-head"><b>${esc(m.display_name || id)}</b><small>${esc(id)}</small><form method="post" action="${path}/status" class="exc-quick"><select name="status" aria-label="Status of ${esc(id)}">${['active', 'disabled'].map((s) => `<option value="${s}"${(m.status || 'active') === s ? ' selected' : ''}>${s === 'active' ? 'On' : 'Off'}</option>`).join('')}</select>${btn('Set')}</form></div><div class="exc-model-prices"><span><em>Accepted</em>${priceTriple(accepted)}</span>${o ? `<span class="is-override"><em>Your price</em>${priceTriple(o.cost)}<small>${esc(o.reason || '')}</small></span>` : ''}</div>++ ${m.metadata?.missing_since ? `<p>Gone from the provider list since ${esc(m.metadata.missing_since)}.</p>` : ''}++ ${accepted?.source ? `<p>Price source: <a href="${esc(accepted.source)}" rel="noopener noreferrer">${esc(accepted.source)}</a></p>` : ''}++ <form method="post" action="${path}/settings">++ <label>Display name<input name="display_name" required maxlength="200" value="${esc(m.display_name || id)}"></label>++ <label>Content tier<select name="content_tier"><option value="standard"${m.content_tier !== 'uncensored' ? ' selected' : ''}>Standard</option><option value="uncensored"${m.content_tier === 'uncensored' ? ' selected' : ''}>Uncensored (18+)</option></select></label>++ <button class="ovk-btn ovk-btn--quiet" type="submit">Save model settings</button>++ </form><details class="exc-fold"><summary>${o ? 'Change your price' : 'Set your own price'}</summary><div class="exc-fold-body"><form class="exc-form exc-form--price" method="post" action="${path}/override"><label><span>In / 1M</span><input name="input_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.in_per_mtok != null ? o.cost.in_per_mtok : '')}"></label><label><span>Cached / 1M</span><input name="cached_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.cached_per_mtok != null ? o.cost.cached_per_mtok : '')}"></label>@@@ -157,9 +172,15 @@ function modelsSection(data = {}) {return `<div class="exc"><header class="exc-head"><h2>Models</h2><p>Every model by provider. The accepted price is what the price book confirmed on the provider's own page; your price, with its reason, replaces it until you clear it. The provider's markup applies on top.</p></header>${models.length ? [...byProvider.entries()].map(([prov, list]) => `<section class="exc-sec"><h3>${mark(prov, 24)} ${esc(prov)} <span class="exc-n">${list.length}</span></h3><ul class="exc-list">${list.map(row).join('')}</ul></section>`).join('') : '<p class="exc-note">No models yet: add a provider and its models arrive.</p>'}++${Array.isArray(data.sync_log) ? syncSection(data) : ''}</div>`;}++function syncSection(data = {}) {++ const rows = Array.isArray(data.sync_log) ? data.sync_log : [];++ return `<table><caption>Model sync log</caption><thead><tr><th>Provider</th><th>Started</th><th>Status</th><th>Added</th><th>Updated</th><th>Gone</th><th>Error</th></tr></thead><tbody>${rows.map(r => `<tr><td>${esc(r.provider_key)}</td><td>${esc(r.started_at)}</td><td>${esc(r.status)}</td><td>${esc(r.added)}</td><td>${esc(r.updated)}</td><td>${esc(r.missing)}</td><td>${esc(r.error_code || '')}</td></tr>`).join('')}</tbody></table>`;++}++// ── Price book ──function pricebookSection(data = {}) {const pending = Array.isArray(data.pending) ? data.pending : [];@@@ -185,5 +206,5 @@ ${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-</div>`;}--const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection };--module.exports = { providersSection, modelsSection, pricebookSection, SECTIONS, CSS: marks.CSS };++const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, sync: syncSection };++module.exports = { providersSection, modelsSection, pricebookSection, syncSection, SECTIONS, CSS: marks.CSS };@@@ -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'],++ ['Exchange', '/console/exchange'], ['Abuse', '/console/abuse'], ['Audit', '/console/audit'], ['Glossary', '/console/glossary'], ['Model syncs', '/console/sync'],];function safe(value) {@@@ -5,7 +5,8 @@ const { providersSection, modelsSection, pricebookSection } = require('../serverconst secret = 'sk-live-never-on-a-page';const providers = [-- { key: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', status: 'active', key_present: true, preference_order: 10, markup_percent: 0, provider_access: 'AUTHORIZED_WHOLESALE', health: { state: 'up' }, api_key: secret,++ { key: 'openai', kind: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', status: 'active', key_present: true, preference_order: 10, markup_percent: 0, provider_access: 'AUTHORIZED_WHOLESALE', health: { state: 'up' }, api_key: secret,++ metadata: { auto_list_new_models: true, model_sync_interval_minutes: 60 },key_state: { source: 'stored', hint: '…abcd', set_at: '2026-11-10T00:00:00.000Z' },last_check: { ok: false, error: 'http_401', latency_ms: 42, checked_at: '2026-11-10T00:01:00.000Z' } },{ key: 'venice', display_name: 'Venice AI', base_url: 'https://api.venice.ai/api/v1', status: 'disabled', key_present: false, preference_order: 60, uncensored: true, provider_access: 'UNVERIFIED' },@@@ -25,6 +26,8 @@ assert.ok(html.includes('type="password"'), 'keys are typed into password fieldsassert.ok(html.includes('Key ends in …abcd') && html.includes('set on 2026-11-10'), 'only the key marker and date are rendered');assert.ok(html.includes('http_401') && html.includes('42 ms'), 'the last check and latency are visible');assert.ok(html.includes('action="/console/providers/openai/check"') && html.includes('action="/console/providers/openai/remove-key"'), 'explicit check and removal controls');++assert.ok(html.includes('action="/console/providers/openai/sync"') && html.includes('href="/console/providers/openai/models"'), 'models can be read and opened per provider');++assert.ok(html.includes('name="auto_list_new_models" value="1" checked') && html.includes('value="60" selected'), 'stored model sync preferences render');const locked = providersSection({ providers: [{ ...providers[0], key_present: false }], models: [], presets: [] });assert.ok(locked.includes('action="/console/providers/openai/remove-key"'), 'a stored key remains removable when it cannot be decrypted');const order = ['Cheap', 'Pricey', 'Unpriced'].map((n) => html.indexOf(`<b>${n}</b>`));@@@ -41,6 +44,7 @@ const models = modelsSection({ models: [assert.ok(models.includes('Your price') && models.includes('promo'), 'the owner price shows with its reason');assert.ok(models.includes('name="clear" value="1"'), 'the owner price can be cleared');assert.ok(models.includes('name="reason" required'), 'a price needs a reason');++assert.ok(models.includes('name="display_name"') && models.includes('name="content_tier"'), 'model names and content tiers are editable');const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek', model_key: 'v4-flash', field: 'out_per_mtok', old_price: 1.2, new_price: 0.9, change_pct: -0.25, source: 'https://example/pricing', checked_at: '2026-11-09' }], recent: [] });assert.ok(book.includes('value="approve"'), 'Accept sends approve, the word AI takes');@@@ -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', '/console/data/providers', '/console/data/models', '/console/data/pricebook'];++ const consolePaths = ['/console', ...Object.keys(READS).map((section) => `/console/${section}`), '/console/providers/new', '/console/providers/openai', '/console/providers/openai/models', '/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, headers['x-test-role'] ? 403 : 404, `${path}: signed-in non-owners are forbidden; signed-out people see no console route`);@@@ -53,7 +53,7 @@ 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 } });-- for (const action of ['key', 'remove-key', 'check']) {++ for (const action of ['key', 'remove-key', 'check', 'sync', 'model-settings']) {for (const role of ['user', 'admin']) {const count = writes.length;const denied = await fetch(`${base}/console/providers/openai/${action}`, {@@@ -76,6 +76,24 @@ app.use(createConsoleRoutes({assert.strictEqual(writes.length, count + 1);assert.deepStrictEqual(writes.at(-1), { path: `/api/v1/admin/console/providers/openai/${endpoint}`, method, subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX' });}++ const sync = await fetch(`${base}/console/providers/openai/sync`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',++ });++ assert.strictEqual(sync.status, 303);++ assert.strictEqual(sync.headers.get('location'), '/console/providers/openai/models');++ assert.strictEqual(writes.at(-1).path, '/api/v1/admin/console/providers/openai/sync');++ const schedule = await fetch(`${base}/console/providers/openai/model-settings`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ auto_list_new_models: '1', model_sync_interval_minutes: '360' }), redirect: 'manual',++ });++ assert.strictEqual(schedule.status, 303);++ assert.deepStrictEqual(writes.at(-1).body, { auto_list_new_models: true, model_sync_interval_minutes: 360 });++ const modelSettings = await fetch(`${base}/console/models/openai/model-a/settings`, {++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ display_name: 'Owner name', content_tier: 'uncensored' }), redirect: 'manual',++ });++ assert.strictEqual(modelSettings.status, 303);++ assert.deepStrictEqual(writes.at(-1).body, { display_name: 'Owner name', content_tier: 'uncensored' });const createProvider = await fetch(`${base}/console/providers`, {method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
@@ -13,6 +13,7 @@ const READS = Object.freeze({
@@ -13,6 +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',
});
});
function createConsoleRoutes({ config, ai }) {
function createConsoleRoutes({ config, ai }) {
@@ -95,6 +96,10 @@ function createConsoleRoutes({ config, ai }) {
@@ -95,6 +96,10 @@ function createConsoleRoutes({ config, ai }) {
return res.set('Cache-Control', 'no-store').json(scrub(data));
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.' }); }
} catch (err) { return res.status(err.status === 404 ? 404 : 502).json({ error: err.message || 'AI admin API did not answer.' }); }
});
});
+ r.get('/console/providers/:key/models', async (req, res) => {
+ try { return page(req, res, 'models', await ai.adminRequest(`/api/v1/admin/console/models?provider=${encodeURIComponent(req.params.key)}`)); }
+ catch { return page(req, res, 'models', {}, 'The models could not be read.', 502); }
+ });
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'
@@ -147,6 +152,12 @@ function createConsoleRoutes({ config, ai }) {
@@ -147,6 +152,12 @@ function createConsoleRoutes({ config, ai }) {
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 === 'remove-key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'DELETE', subject: req.viewer.subject });
else if (action === 'remove-key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'DELETE', subject: req.viewer.subject });
else if (action === 'check') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject });
else if (action === 'check') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject });
+ else if (action === 'sync') {
+ await ai.adminRequest(`/api/v1/admin/console/providers/${key}/sync`, { method: 'POST', subject: req.viewer.subject });
+ return res.redirect(303, `/console/providers/${key}/models`);
+ } else if (action === 'model-settings') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/model-settings`, { method: 'POST', subject: req.viewer.subject, body: {
+ auto_list_new_models: req.body.auto_list_new_models === '1', model_sync_interval_minutes: Number(req.body.model_sync_interval_minutes || 0),
+ } });
else if (action === 'settings') await ai.adminRequest(`/api/v1/providers/${key}`, { method: 'PATCH', subject: req.viewer.subject, body: {
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),
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',
markup_percent: Number(req.body.markup_percent), uncensored: req.body.uncensored === '1',
@@ -198,6 +209,15 @@ function createConsoleRoutes({ config, ai }) {
@@ -198,6 +209,15 @@ 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/settings', async (req, res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ try {
+ await ai.adminRequest(`/api/v1/models/${encodeURIComponent(req.params.provider)}/${encodeURIComponent(req.params.model)}`, {
+ method: 'PATCH', subject: req.viewer.subject, body: { display_name: String(req.body.display_name || ''), content_tier: req.body.content_tier },
+ });
+ return res.redirect(303, `/console/providers/${encodeURIComponent(req.params.provider)}/models`);
+ } catch { return page(req, res, 'models', {}, 'The model settings could not be saved.', 502); }
+ });
r.post('/console/models/:provider/:model/override', async (req, res) => {
r.post('/console/models/:provider/:model/override', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
if (!sameOrigin(req)) return res.sendStatus(403);
const number = (value) => value === '' || value == null ? null : Number(value);
const number = (value) => value === '' || value == null ? null : Number(value);
@@ -74,12 +74,20 @@ function providerCard(p, models) {
@@ -74,12 +74,20 @@ function providerCard(p, models) {
<label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label>
<label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label>
<p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p>
<p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p>
</form>`;
</form>`;
+ const modelActions = p.metadata?.models_endpoint || ['openai', 'anthropic', 'responses', 'gemini'].includes(p.kind) ? `
+ <p><a href="/console/providers/${key}/models">Models</a> · <a href="/console/sync">Sync log</a></p>
+ <form method="post" action="/console/providers/${key}/sync"><button class="ovk-btn ovk-btn--quiet" type="submit">Sync models</button></form>
+ <form method="post" action="/console/providers/${key}/model-settings">
+ <label><input type="checkbox" name="auto_list_new_models" value="1"${p.metadata?.auto_list_new_models ? ' checked' : ''}>List new models automatically</label>
+ <label>Sync schedule<select name="model_sync_interval_minutes">${[[0, 'Off'], [60, 'Hourly'], [360, 'Every six hours'], [1440, 'Daily']].map(([minutes, name]) => `<option value="${minutes}"${Number(p.metadata?.model_sync_interval_minutes || 0) === minutes ? ' selected' : ''}>${name}</option>`).join('')}</select></label>
+ <button class="ovk-btn ovk-btn--quiet" type="submit">Save model settings</button>
+ </form>` : '';
return `<li class="exc-prov${on ? '' : ' is-off'}" id="p-${esc(p.key)}">
return `<li class="exc-prov${on ? '' : ' is-off'}" id="p-${esc(p.key)}">
<div class="exc-prov-head">${mark(p.key || p.display_name)}<div class="exc-prov-name"><b>${esc(p.display_name || p.key)}</b><small>${esc(host(p.base_url))}${mine.length ? ` · ${mine.length} ${mine.length === 1 ? 'model' : 'models'}` : ''} · order ${esc(p.preference_order ?? p.priority ?? '–')}${Number(p.markup_percent) ? ` · +${esc(p.markup_percent)}%` : ''}</small></div>
<div class="exc-prov-head">${mark(p.key || p.display_name)}<div class="exc-prov-name"><b>${esc(p.display_name || p.key)}</b><small>${esc(host(p.base_url))}${mine.length ? ` · ${mine.length} ${mine.length === 1 ? 'model' : 'models'}` : ''} · order ${esc(p.preference_order ?? p.priority ?? '–')}${Number(p.markup_percent) ? ` · +${esc(p.markup_percent)}%` : ''}</small></div>
<form method="post" action="/console/providers/${key}/status" class="exc-quick"><input type="hidden" name="status" value="${on ? 'disabled' : 'active'}">${btn(on ? 'Turn off' : 'Turn on')}</form></div>
<form method="post" action="/console/providers/${key}/status" class="exc-quick"><input type="hidden" name="status" value="${on ? 'disabled' : 'active'}">${btn(on ? 'Turn off' : 'Turn on')}</form></div>
<p class="exc-tags">${tags}</p>
<p class="exc-tags">${tags}</p>
${accessNote ? `<p class="exc-note">${esc(accessNote)}</p>` : ''}
${accessNote ? `<p class="exc-note">${esc(accessNote)}</p>` : ''}
- <details class="exc-fold"><summary>Settings and key</summary><div class="exc-fold-body">${settings}${keyState.source === 'not_required' ? '' : keyForm}${keyActions}
+ <details class="exc-fold"><summary>Settings and key</summary><div class="exc-fold-body">${settings}${keyState.source === 'not_required' ? '' : keyForm}${keyActions}${modelActions}
<form method="post" action="/console/providers/${key}/reset" class="exc-inline"><p class="exc-note">Reset clears its failure count and opens it to traffic again.</p>${btn('Reset health')}</form></div></details>
<form method="post" action="/console/providers/${key}/reset" class="exc-inline"><p class="exc-note">Reset clears its failure count and opens it to traffic again.</p>${btn('Reset health')}</form></div></details>
</li>`;
</li>`;
}
}
@@ -146,6 +154,13 @@ function modelsSection(data = {}) {
@@ -146,6 +154,13 @@ function modelsSection(data = {}) {
<div class="exc-model-head"><b>${esc(m.display_name || id)}</b><small>${esc(id)}</small>
<div class="exc-model-head"><b>${esc(m.display_name || id)}</b><small>${esc(id)}</small>
<form method="post" action="${path}/status" class="exc-quick"><select name="status" aria-label="Status of ${esc(id)}">${['active', 'disabled'].map((s) => `<option value="${s}"${(m.status || 'active') === s ? ' selected' : ''}>${s === 'active' ? 'On' : 'Off'}</option>`).join('')}</select>${btn('Set')}</form></div>
<form method="post" action="${path}/status" class="exc-quick"><select name="status" aria-label="Status of ${esc(id)}">${['active', 'disabled'].map((s) => `<option value="${s}"${(m.status || 'active') === s ? ' selected' : ''}>${s === 'active' ? 'On' : 'Off'}</option>`).join('')}</select>${btn('Set')}</form></div>
<div class="exc-model-prices"><span><em>Accepted</em>${priceTriple(accepted)}</span>${o ? `<span class="is-override"><em>Your price</em>${priceTriple(o.cost)}<small>${esc(o.reason || '')}</small></span>` : ''}</div>
<div class="exc-model-prices"><span><em>Accepted</em>${priceTriple(accepted)}</span>${o ? `<span class="is-override"><em>Your price</em>${priceTriple(o.cost)}<small>${esc(o.reason || '')}</small></span>` : ''}</div>
+ ${m.metadata?.missing_since ? `<p>Gone from the provider list since ${esc(m.metadata.missing_since)}.</p>` : ''}
+ ${accepted?.source ? `<p>Price source: <a href="${esc(accepted.source)}" rel="noopener noreferrer">${esc(accepted.source)}</a></p>` : ''}
+ <form method="post" action="${path}/settings">
+ <label>Display name<input name="display_name" required maxlength="200" value="${esc(m.display_name || id)}"></label>
+ <label>Content tier<select name="content_tier"><option value="standard"${m.content_tier !== 'uncensored' ? ' selected' : ''}>Standard</option><option value="uncensored"${m.content_tier === 'uncensored' ? ' selected' : ''}>Uncensored (18+)</option></select></label>
+ <button class="ovk-btn ovk-btn--quiet" type="submit">Save model settings</button>
+ </form>
<details class="exc-fold"><summary>${o ? 'Change your price' : 'Set your own price'}</summary><div class="exc-fold-body"><form class="exc-form exc-form--price" method="post" action="${path}/override">
<details class="exc-fold"><summary>${o ? 'Change your price' : 'Set your own price'}</summary><div class="exc-fold-body"><form class="exc-form exc-form--price" method="post" action="${path}/override">
<label><span>In / 1M</span><input name="input_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.in_per_mtok != null ? o.cost.in_per_mtok : '')}"></label>
<label><span>In / 1M</span><input name="input_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.in_per_mtok != null ? o.cost.in_per_mtok : '')}"></label>
<label><span>Cached / 1M</span><input name="cached_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.cached_per_mtok != null ? o.cost.cached_per_mtok : '')}"></label>
<label><span>Cached / 1M</span><input name="cached_per_million" type="number" min="0" step="0.0001" inputmode="decimal" value="${esc(o && o.cost && o.cost.cached_per_mtok != null ? o.cost.cached_per_mtok : '')}"></label>
@@ -157,9 +172,15 @@ function modelsSection(data = {}) {
@@ -157,9 +172,15 @@ function modelsSection(data = {}) {
return `<div class="exc">
return `<div class="exc">
<header class="exc-head"><h2>Models</h2><p>Every model by provider. The accepted price is what the price book confirmed on the provider's own page; your price, with its reason, replaces it until you clear it. The provider's markup applies on top.</p></header>
<header class="exc-head"><h2>Models</h2><p>Every model by provider. The accepted price is what the price book confirmed on the provider's own page; your price, with its reason, replaces it until you clear it. The provider's markup applies on top.</p></header>
${models.length ? [...byProvider.entries()].map(([prov, list]) => `<section class="exc-sec"><h3>${mark(prov, 24)} ${esc(prov)} <span class="exc-n">${list.length}</span></h3><ul class="exc-list">${list.map(row).join('')}</ul></section>`).join('') : '<p class="exc-note">No models yet: add a provider and its models arrive.</p>'}
${models.length ? [...byProvider.entries()].map(([prov, list]) => `<section class="exc-sec"><h3>${mark(prov, 24)} ${esc(prov)} <span class="exc-n">${list.length}</span></h3><ul class="exc-list">${list.map(row).join('')}</ul></section>`).join('') : '<p class="exc-note">No models yet: add a provider and its models arrive.</p>'}
+${Array.isArray(data.sync_log) ? syncSection(data) : ''}
</div>`;
</div>`;
}
}
+function syncSection(data = {}) {
+ const rows = Array.isArray(data.sync_log) ? data.sync_log : [];
+ return `<table><caption>Model sync log</caption><thead><tr><th>Provider</th><th>Started</th><th>Status</th><th>Added</th><th>Updated</th><th>Gone</th><th>Error</th></tr></thead><tbody>${rows.map(r => `<tr><td>${esc(r.provider_key)}</td><td>${esc(r.started_at)}</td><td>${esc(r.status)}</td><td>${esc(r.added)}</td><td>${esc(r.updated)}</td><td>${esc(r.missing)}</td><td>${esc(r.error_code || '')}</td></tr>`).join('')}</tbody></table>`;
+}
+
// ── Price book ──
// ── Price book ──
function pricebookSection(data = {}) {
function pricebookSection(data = {}) {
const pending = Array.isArray(data.pending) ? data.pending : [];
const pending = Array.isArray(data.pending) ? data.pending : [];
@@ -185,5 +206,5 @@ ${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-
@@ -185,5 +206,5 @@ ${recent.length ? `<section class="exc-sec"><h3>Applied lately <span class="exc-
</div>`;
</div>`;
}
}
-const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection };
-module.exports = { providersSection, modelsSection, pricebookSection, SECTIONS, CSS: marks.CSS };
+const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, sync: syncSection };
+module.exports = { providersSection, modelsSection, pricebookSection, syncSection, SECTIONS, CSS: marks.CSS };
@@ -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'],
+ ['Exchange', '/console/exchange'], ['Abuse', '/console/abuse'], ['Audit', '/console/audit'], ['Glossary', '/console/glossary'], ['Model syncs', '/console/sync'],
];
];
function safe(value) {
function safe(value) {
@@ -5,7 +5,8 @@ const { providersSection, modelsSection, pricebookSection } = require('../server
@@ -5,7 +5,8 @@ const { providersSection, modelsSection, pricebookSection } = require('../server
const secret = 'sk-live-never-on-a-page';
const secret = 'sk-live-never-on-a-page';
const providers = [
const providers = [
- { key: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', status: 'active', key_present: true, preference_order: 10, markup_percent: 0, provider_access: 'AUTHORIZED_WHOLESALE', health: { state: 'up' }, api_key: secret,
+ { key: 'openai', kind: 'openai', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', status: 'active', key_present: true, preference_order: 10, markup_percent: 0, provider_access: 'AUTHORIZED_WHOLESALE', health: { state: 'up' }, api_key: secret,
+ metadata: { auto_list_new_models: true, model_sync_interval_minutes: 60 },
key_state: { source: 'stored', hint: '…abcd', set_at: '2026-11-10T00:00:00.000Z' },
key_state: { source: 'stored', hint: '…abcd', set_at: '2026-11-10T00:00:00.000Z' },
last_check: { ok: false, error: 'http_401', latency_ms: 42, checked_at: '2026-11-10T00:01:00.000Z' } },
last_check: { ok: false, error: 'http_401', latency_ms: 42, checked_at: '2026-11-10T00:01:00.000Z' } },
{ key: 'venice', display_name: 'Venice AI', base_url: 'https://api.venice.ai/api/v1', status: 'disabled', key_present: false, preference_order: 60, uncensored: true, provider_access: 'UNVERIFIED' },
{ key: 'venice', display_name: 'Venice AI', base_url: 'https://api.venice.ai/api/v1', status: 'disabled', key_present: false, preference_order: 60, uncensored: true, provider_access: 'UNVERIFIED' },
@@ -25,6 +26,8 @@ assert.ok(html.includes('type="password"'), 'keys are typed into password fields
@@ -25,6 +26,8 @@ assert.ok(html.includes('type="password"'), 'keys are typed into password fields
assert.ok(html.includes('Key ends in …abcd') && html.includes('set on 2026-11-10'), 'only the key marker and date are rendered');
assert.ok(html.includes('Key ends in …abcd') && html.includes('set on 2026-11-10'), 'only the key marker and date are rendered');
assert.ok(html.includes('http_401') && html.includes('42 ms'), 'the last check and latency are visible');
assert.ok(html.includes('http_401') && html.includes('42 ms'), 'the last check and latency are visible');
assert.ok(html.includes('action="/console/providers/openai/check"') && html.includes('action="/console/providers/openai/remove-key"'), 'explicit check and removal controls');
assert.ok(html.includes('action="/console/providers/openai/check"') && html.includes('action="/console/providers/openai/remove-key"'), 'explicit check and removal controls');
+assert.ok(html.includes('action="/console/providers/openai/sync"') && html.includes('href="/console/providers/openai/models"'), 'models can be read and opened per provider');
+assert.ok(html.includes('name="auto_list_new_models" value="1" checked') && html.includes('value="60" selected'), 'stored model sync preferences render');
const locked = providersSection({ providers: [{ ...providers[0], key_present: false }], models: [], presets: [] });
const locked = providersSection({ providers: [{ ...providers[0], key_present: false }], models: [], presets: [] });
assert.ok(locked.includes('action="/console/providers/openai/remove-key"'), 'a stored key remains removable when it cannot be decrypted');
assert.ok(locked.includes('action="/console/providers/openai/remove-key"'), 'a stored key remains removable when it cannot be decrypted');
const order = ['Cheap', 'Pricey', 'Unpriced'].map((n) => html.indexOf(`<b>${n}</b>`));
const order = ['Cheap', 'Pricey', 'Unpriced'].map((n) => html.indexOf(`<b>${n}</b>`));
@@ -41,6 +44,7 @@ const models = modelsSection({ models: [
@@ -41,6 +44,7 @@ const models = modelsSection({ models: [
assert.ok(models.includes('Your price') && models.includes('promo'), 'the owner price shows with its reason');
assert.ok(models.includes('Your price') && models.includes('promo'), 'the owner price shows with its reason');
assert.ok(models.includes('name="clear" value="1"'), 'the owner price can be cleared');
assert.ok(models.includes('name="clear" value="1"'), 'the owner price can be cleared');
assert.ok(models.includes('name="reason" required'), 'a price needs a reason');
assert.ok(models.includes('name="reason" required'), 'a price needs a reason');
+assert.ok(models.includes('name="display_name"') && models.includes('name="content_tier"'), 'model names and content tiers are editable');
const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek', model_key: 'v4-flash', field: 'out_per_mtok', old_price: 1.2, new_price: 0.9, change_pct: -0.25, source: 'https://example/pricing', checked_at: '2026-11-09' }], recent: [] });
const book = pricebookSection({ pending: [{ id: 'pc_1', provider_key: 'deepseek', model_key: 'v4-flash', field: 'out_per_mtok', old_price: 1.2, new_price: 0.9, change_pct: -0.25, source: 'https://example/pricing', checked_at: '2026-11-09' }], recent: [] });
assert.ok(book.includes('value="approve"'), 'Accept sends approve, the word AI takes');
assert.ok(book.includes('value="approve"'), 'Accept sends approve, the word AI takes');
@@ -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', '/console/data/providers', '/console/data/models', '/console/data/pricebook'];
+ const consolePaths = ['/console', ...Object.keys(READS).map((section) => `/console/${section}`), '/console/providers/new', '/console/providers/openai', '/console/providers/openai/models', '/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, headers['x-test-role'] ? 403 : 404, `${path}: signed-in non-owners are forbidden; signed-out people see no console route`);
assert.strictEqual(response.status, headers['x-test-role'] ? 403 : 404, `${path}: signed-in non-owners are forbidden; signed-out people see no console route`);
@@ -53,7 +53,7 @@ app.use(createConsoleRoutes({
@@ -53,7 +53,7 @@ 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 } });
- for (const action of ['key', 'remove-key', 'check']) {
+ 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;
const denied = await fetch(`${base}/console/providers/openai/${action}`, {
const denied = await fetch(`${base}/console/providers/openai/${action}`, {
@@ -76,6 +76,24 @@ app.use(createConsoleRoutes({
@@ -76,6 +76,24 @@ app.use(createConsoleRoutes({
assert.strictEqual(writes.length, count + 1);
assert.strictEqual(writes.length, count + 1);
assert.deepStrictEqual(writes.at(-1), { path: `/api/v1/admin/console/providers/openai/${endpoint}`, method, subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX' });
assert.deepStrictEqual(writes.at(-1), { path: `/api/v1/admin/console/providers/openai/${endpoint}`, method, subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX' });
}
}
+ const sync = await fetch(`${base}/console/providers/openai/sync`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange' }, redirect: 'manual',
+ });
+ assert.strictEqual(sync.status, 303);
+ assert.strictEqual(sync.headers.get('location'), '/console/providers/openai/models');
+ assert.strictEqual(writes.at(-1).path, '/api/v1/admin/console/providers/openai/sync');
+ const schedule = await fetch(`${base}/console/providers/openai/model-settings`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ auto_list_new_models: '1', model_sync_interval_minutes: '360' }), redirect: 'manual',
+ });
+ assert.strictEqual(schedule.status, 303);
+ assert.deepStrictEqual(writes.at(-1).body, { auto_list_new_models: true, model_sync_interval_minutes: 360 });
+ const modelSettings = await fetch(`${base}/console/models/openai/model-a/settings`, {
+ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ display_name: 'Owner name', content_tier: 'uncensored' }), redirect: 'manual',
+ });
+ assert.strictEqual(modelSettings.status, 303);
+ assert.deepStrictEqual(writes.at(-1).body, { display_name: 'Owner name', content_tier: 'uncensored' });
const createProvider = await fetch(`${base}/console/providers`, {
const createProvider = await fetch(`${base}/console/providers`, {
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' },