openvibers/openvibe.exchange Commits

Move the AI operator console to Exchange (#130)

OP OpenVibers committed 5 days ago · a5dc05840e · 11 files, +345 −4

Changes

ddiff --git a/.env.example b/.env.example
iindex ef301d6..b7d36cd 100644
---- a/.env.example
++++ b/.env.example
@@@ -35,6 +35,9 @@ OV_OAUTH_CLIENT_SECRET=
#OV_AI_URL=https://ai.openvibe.services
#OV_AI_AUDIENCE=openvibe.ai
#OV_AI_SCOPE=ai.run.create
++# The owner console uses this Exchange service grant to read/write AI's admin JSON API.
++# Network grants these capabilities to the existing `exchange` client; keep this scope to the needed AI capabilities.
++#OV_AI_CONSOLE_SCOPE=ai.usage.read ai.workflow.manage ai.provider.manage
#EXCHANGE_AI_TIMEOUT_MS=30000
#EXCHANGE_AI_WAIT_MS=25000
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 32d3797..c16f00c 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -655,3 +655,20 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
margin: 14px; display: grid; gap: 10px; min-width: 0;
}
#quote .ovk-form select, #model .ovk-form select { width: 100%; min-width: 0; min-height: 44px; }
++
++/* Owner-only AI operator console, rendered in Exchange's page shell and tokens. */
++.ex-console { min-width: 0; }
++.ex-console-head { max-width: 900px; margin: 0 auto 18px; }
++.ex-console-head h1 { margin: 4px 0 8px; }
++.ex-eyebrow { color: var(--text-muted, #7386a3); font-size: .78rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
++.ex-console-nav { display: flex; flex-wrap: wrap; gap: 8px; max-width: 1100px; margin: 0 auto 18px; }
++.ex-console-tab { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; }
++.ex-console-tab:hover, .ex-console-tab.is-active { color: var(--text, #f4f7fb); border-color: var(--accent, #758cff); }
++.ex-console-panel { max-width: 1100px; margin: 0 auto; min-width: 0; padding: 16px; border: 1px solid var(--border, #1f2d47); border-radius: 18px; background: var(--bg-secondary, #101828); }
++.ex-console-panel h2 { margin: 0 0 12px; }
++.ex-console-panel pre { max-width: 100%; max-height: 68vh; overflow: auto; white-space: pre; overflow-wrap: normal; padding: 14px; border-radius: 12px; background: var(--bg, #0b1220); color: var(--text-secondary, #c0cce0); font-size: .82rem; }
++.ex-console-error { max-width: 1100px; margin: 12px auto; padding: 12px; border: 1px solid var(--danger, #b8324a); border-radius: 12px; overflow-wrap: anywhere; }
++.ex-console-action { display: inline-flex; align-items: end; flex-wrap: wrap; gap: 8px; margin: 4px 8px 8px 0; }
++.ex-console-action label, .ex-console-quota label { display: grid; gap: 5px; min-width: 0; }
++.ex-console-action input, .ex-console-action select, .ex-console-quota input, .ex-console-quota select { min-width: 0; min-height: 44px; }
++.ex-console-quota { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr)); gap: 10px; margin: 8px 0 14px; }
ddiff --git a/server/ai.js b/server/ai.js
iindex 8b6a7ca..0581ce3 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -30,6 +30,11 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
audience: config.ai.audience, scope: config.ai.scope, fetch: fetchImpl,
}) : null;
++ const adminTokens = config.oauth.clientSecret ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: config.ai.audience, scope: config.ai.consoleScope, fetch: fetchImpl,
++ }) : null;
const empty = (what) => ({ generated_at: new Date(now()).toISOString(), unavailable: true, count: 0, models: [], families: [], by_day: [], by_model: [], by_product: [], today: null, totals: null, what });
let catalogHit = { at: 0, value: null };
@@@ -52,6 +57,21 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
return body;
}
++ /** Owner console calls to AI's JSON admin API. No HTML is fetched or relayed. */
++ async function adminRequest(path, { method = 'GET', body = null, subject = null } = {}) {
++ if (!adminTokens) throw Object.assign(new Error('AI console service credentials are not configured'), { status: 503 });
++ const token = await adminTokens.getToken();
++ const res = await fetchImpl(`${config.ai.url}${path}`, {
++ method,
++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(subject ? { 'X-OV-Subject': String(subject) } : {}), ...(body == null ? {} : { 'Content-Type': 'application/json' }) },
++ ...(body == null ? {} : { body: JSON.stringify(body) }),
++ signal: AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ const out = await jsonOf(res);
++ if (!res.ok) throw Object.assign(new Error((out && (out.detail || out.title || out.error)) || `AI answered ${res.status}`), { status: res.status, code: out && out.code });
++ return out || {};
++ }
++
async function catalog() {
if (catalogHit.value && now() - catalogHit.at < CATALOG_MS) return catalogHit.value;
try {
@@@ -274,7 +294,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
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, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, configured: Boolean(tokens) };
++
++ return { catalog, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, configured: Boolean(tokens) };
}
/**
ddiff --git a/server/app.js b/server/app.js
iindex 3db6c84..66a905c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -44,6 +44,7 @@ const { createLorebooks } = require('./companions/lorebook');
const { createLocker } = require('./companions/locker');
const { createNoteTaker } = require('./companions/taking-notes');
const { createPageRoutes } = require('./http/pages');
++const { createConsoleRoutes } = require('./http/console');
const { createArenaRoutes } = require('./http/arena');
const { createCompanionRoutes } = require('./http/companions');
const { createServiceReadiness } = require('./observability');
@@@ -250,6 +251,7 @@ async function createApp(opts = {}) {
// The Arena and the rankings: /arena, /arena/:id and /rankings.
app.use(createArenaRoutes(ctx));
app.use(createPageRoutes(ctx));
++ app.use(createConsoleRoutes(ctx));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
ddiff --git a/server/config.js b/server/config.js
iindex d470de2..41ccbe0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -68,6 +68,7 @@ function load(env = process.env) {
url: trim(env.OV_AI_URL || 'https://ai.openvibe.services'),
audience: env.OV_AI_AUDIENCE || 'openvibe.ai',
scope: env.OV_AI_SCOPE || 'ai.run.create',
++ consoleScope: env.OV_AI_CONSOLE_SCOPE || 'ai.usage.read ai.workflow.manage ai.provider.manage',
timeoutMs: Math.max(2000, int(env.EXCHANGE_AI_TIMEOUT_MS, 30_000)),
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
},
ddiff --git a/server/http/console.js b/server/http/console.js
nnew file mode 100644
iindex 0000000..2e10fc2
---- /dev/null
++++ b/server/http/console.js
@@@ -0,0 +1,181 @@
++'use strict';
++
++const express = require('express');
++const { raw } = require('../render/html');
++const { send } = require('../render/layout');
++const { consolePage } = require('../views/console');
++
++const READS = Object.freeze({
++ overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/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',
++ abuse: '/api/v1/admin/console/abuse', glossary: '/api/v1/admin/console/glossary',
++});
++
++function createConsoleRoutes({ config, ai }) {
++ const r = express.Router();
++ r.use(express.urlencoded({ extended: false, limit: '32kb' }));
++ const isOwner = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject && req.viewer.role === 'owner');
++ const page = (req, res, section, data, error = null, status = 200) => send(res, status, {
++ viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'AI operator console',
++ body: raw(consolePage({ section, data, error })),
++ });
++ r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
++ r.get('/console/runs/:id', async (req, res) => {
++ try { return page(req, res, 'runs', await ai.adminRequest(`/api/v1/admin/console/run?id=${encodeURIComponent(req.params.id)}`)); }
++ catch (err) { return page(req, res, 'runs', {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502); }
++ });
++ r.get('/console/gpus/:model/quote', async (req, res) => {
++ try {
++ const data = await ai.adminRequest(`/api/v1/admin/console/gpus/${encodeURIComponent(req.params.model)}/quote${req.query.strategy ? `?strategy=${encodeURIComponent(req.query.strategy)}` : ''}`);
++ return page(req, res, 'gpus', { quote: data });
++ } catch (err) { return page(req, res, 'gpus', {}, err.message || 'The GPU quote is unavailable.', 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'
++ : ['providers', 'routes', 'templates', 'workflows'].includes(section) ? `/api/v1/${section}/${encodeURIComponent(key)}` : null;
++ if (!path) return res.sendStatus(404);
++ try { return page(req, res, section, await ai.adminRequest(path)); }
++ catch (err) { return page(req, res, section, {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502); }
++ });
++ r.get(['/console', '/console/:section'], async (req, res) => {
++ const section = req.params.section || 'overview';
++ const endpoint = READS[section];
++ if (!endpoint) return res.sendStatus(404);
++ try {
++ const data = await ai.adminRequest(endpoint + (req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''));
++ return page(req, res, section, data);
++ } catch (err) {
++ return page(req, res, section, {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502);
++ }
++ });
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return false;
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++ };
++ r.post('/console/providers', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest('/api/v1/admin/console/providers', { method: 'POST', subject: req.viewer.subject, body: {
++ ...req.body, vision: req.body.vision === '1',
++ } });
++ return res.redirect(303, '/console/providers');
++ } catch (err) { return page(req, res, 'providers', {}, err.message || 'The provider could not be added.', 502); }
++ });
++ r.post('/console/providers/:key/:action', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ const key = encodeURIComponent(req.params.key);
++ const action = req.params.action;
++ try {
++ if (action === 'status') {
++ const endpoint = req.body.status === 'disabled' ? 'disable' : 'enable';
++ 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 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); }
++ });
++ r.post('/console/provider-credits', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest('/api/v1/admin/console/provider-credits', { method: 'POST', subject: req.viewer.subject, body: { ...req.body, amount_usd: Number(req.body.amount_usd), remaining_as_of_usd: req.body.remaining_as_of_usd === '' ? Number(req.body.amount_usd) : Number(req.body.remaining_as_of_usd) } });
++ return res.redirect(303, '/console/providers');
++ } catch (err) { return page(req, res, 'providers', {}, err.message || 'The credit record could not be saved.', 502); }
++ });
++ r.post('/console/provider-credit-preference', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest('/api/v1/admin/console/provider-credit-preference', { method: 'POST', subject: req.viewer.subject, body: { provider: req.body.provider, enabled: req.body.enabled === '1', floor_usd: Number(req.body.floor_usd) } });
++ return res.redirect(303, '/console/providers');
++ } catch (err) { return page(req, res, 'providers', {}, err.message || 'The credit preference could not be saved.', 502); }
++ });
++ r.post('/console/provider-session-credentials', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest('/api/v1/admin/console/provider-session-credentials', { method: 'POST', subject: req.viewer.subject, body: req.body });
++ return res.redirect(303, '/console/providers');
++ } catch { return page(req, res, 'providers', {}, 'The provider session could not be saved.', 502); }
++ });
++ r.post('/console/provider-session-credentials/:id/remove', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest(`/api/v1/admin/console/provider-session-credentials/${encodeURIComponent(req.params.id)}`, { method: 'DELETE', subject: req.viewer.subject });
++ return res.redirect(303, '/console/providers');
++ } catch { return page(req, res, 'providers', {}, 'The provider session could not be removed.', 502); }
++ });
++ r.post('/console/workflows/:key/versions/:version/status', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest(`/api/v1/workflows/${encodeURIComponent(req.params.key)}/versions/${encodeURIComponent(req.params.version)}/status`, { method: 'POST', subject: req.viewer.subject, body: { status: req.body.status } });
++ return res.redirect(303, '/console/workflows');
++ } catch (err) { return page(req, res, 'workflows', {}, err.message || 'The change could not be saved.', 502); }
++ });
++ r.post('/console/models/:provider/:model/status', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ const status = req.body.status === 'disabled' ? 'disabled' : 'active';
++ await ai.adminRequest(`/api/v1/models/${encodeURIComponent(req.params.provider)}/${encodeURIComponent(req.params.model)}`, { method: 'PATCH', subject: req.viewer.subject, body: { status } });
++ 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/quotas', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ const b = req.body || {};
++ const body = { scope_type: String(b.scope_type || ''), scope_id: String(b.scope_id || ''), window: String(b.window || 'day') };
++ for (const k of ['max_requests', 'max_tokens', 'max_cost_usd']) if (b[k] !== '' && b[k] != null) body[k] = Number(b[k]);
++ await ai.adminRequest('/api/v1/quotas', { method: 'POST', subject: req.viewer.subject, body });
++ return res.redirect(303, '/console/quotas');
++ } catch (err) { return page(req, res, 'quotas', {}, err.message || 'The quota could not be saved.', 502); }
++ });
++ r.post('/console/cache/purge', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ const q = req.body.workflow ? `?workflow=${encodeURIComponent(req.body.workflow)}` : '';
++ await ai.adminRequest(`/api/v1/cache${q}`, { method: 'DELETE', subject: req.viewer.subject });
++ return res.redirect(303, '/console/cache');
++ } catch (err) { return page(req, res, 'cache', {}, err.message || 'The cache could not be purged.', 502); }
++ });
++ r.post('/console/gpus/:model/:action', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest(`/api/v1/admin/console/gpus/${encodeURIComponent(req.params.model)}/${encodeURIComponent(req.params.action)}`, { method: 'POST', subject: req.viewer.subject });
++ return res.redirect(303, '/console/gpus');
++ } catch (err) { return page(req, res, 'gpus', {}, err.message || 'The GPU request could not be saved.', 502); }
++ });
++ r.post('/console/pricebook/changes/:id', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest(`/api/v1/admin/console/pricebook/changes/${encodeURIComponent(req.params.id)}`, { method: 'POST', subject: req.viewer.subject, body: { decision: req.body.decision } });
++ return res.redirect(303, '/console/pricebook');
++ } catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The price decision could not be saved.', 502); }
++ });
++ r.post('/console/pricebook/run', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest('/api/v1/admin/console/pricebook/run', { method: 'POST', subject: req.viewer.subject });
++ return res.redirect(303, '/console/pricebook');
++ } catch (err) { return page(req, res, 'pricebook', {}, err.message || 'The price book could not run.', 502); }
++ });
++ r.post('/console/exchange/credits/:id/resolve', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest(`/api/v1/admin/console/exchange/credits/${encodeURIComponent(req.params.id)}/resolve`, { method: 'POST', subject: req.viewer.subject });
++ return res.redirect(303, '/console/exchange');
++ } catch (err) { return page(req, res, 'exchange', {}, err.message || 'The settle row could not be resolved.', 502); }
++ });
++ r.post('/console/runs/:id/cancel', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ await ai.adminRequest(`/api/v1/admin/console/runs/${encodeURIComponent(req.params.id)}/cancel`, { method: 'POST', subject: req.viewer.subject });
++ return res.redirect(303, '/console/runs');
++ } catch (err) { return page(req, res, 'runs', {}, err.message || 'The run could not be cancelled.', 502); }
++ });
++ return r;
++}
++
++module.exports = { createConsoleRoutes, READS };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 8e9efc4..91cd015 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -126,7 +126,8 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
{ title: 'Release metadata (JSON)', url: abs('/release.json') },
-- { title: 'OpenVibe.AI', url: 'https://ai.openvibe.services', note: 'the router, the six operations, a run’s receipt and the operator console; its public pages are here now' },
++ { title: 'AI router API', url: 'https://ai.openvibe.services', note: 'the API and run receipts' },
++ { title: 'AI operator console', url: abs('/console'), note: 'owner-only controls for providers, models, routes, quotas, usage and runs' },
{ title: 'OpenVibe Link', url: 'https://openvibe.link', note: 'offer a machine to the network' },
],
}));
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex b2d74b8..4c395da 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -63,7 +63,10 @@ function renderPage(o) {
const nav = {
service: 'exchange',
apiBase: NETWORK_URL,
-- links: NAV.map((l) => ({ label: l.label, href: l.href, icon: l.icon, ...(l.more ? { more: true } : {}), active: active(pathNow, l.href) })),
++ links: [
++ ...NAV.map((l) => ({ label: l.label, href: l.href, icon: l.icon, ...(l.more ? { more: true } : {}), active: active(pathNow, l.href) })),
++ ...(viewer.role === 'owner' ? [{ label: 'AI console', href: '/console', icon: 'fa-sliders', active: active(pathNow, '/console') }] : []),
++ ],
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
ddiff --git a/server/views/console.js b/server/views/console.js
nnew file mode 100644
iindex 0000000..e2f5597
---- /dev/null
++++ b/server/views/console.js
@@@ -0,0 +1,52 @@
++'use strict';
++
++const { esc } = require('../render/html');
++
++const SECTIONS = [
++ ['Overview', '/console'], ['Providers', '/console/providers'], ['Models', '/console/models'],
++ ['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'],
++];
++
++function safe(value) {
++ if (Array.isArray(value)) return value.map(safe);
++ if (!value || typeof value !== 'object') return value;
++ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, /secret|token|cookie|api.?key|credential_value/i.test(key) ? '[hidden]' : safe(item)]));
++}
++
++function consolePage({ section = 'overview', data = {}, error = null }) {
++ const active = `/console${section === 'overview' ? '' : `/${section}`}`;
++ 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 === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
++ const key = encodeURIComponent(p.key || '');
++ const next = p.status === 'disabled' ? 'active' : 'disabled';
++ return `<form class="ex-console-action" method="post" action="/console/providers/${key}/status"><input type="hidden" name="status" value="${next}"><button class="ovk-button ovk-button-quiet" type="submit">${next === 'active' ? 'Enable' : 'Disable'} ${esc(p.key || 'provider')}</button></form><form class="ex-console-action" method="post" action="/console/providers/${key}/reset"><button class="ovk-button ovk-button-quiet" type="submit">Reset circuit</button></form><form class="ex-console-action" method="post" action="/console/providers/${key}/key"><label>Replace ${esc(p.key || 'provider')} key<input type="password" name="api_key" autocomplete="new-password" minlength="8" maxlength="400" required></label><button class="ovk-button ovk-button-quiet" type="submit">Save key</button></form>`;
++ }).join('');
++ if (section === 'providers') actions += `<form class="ovk-form ex-console-action" method="post" action="/console/providers"><h3>Add provider</h3><label>Preset<select name="preset"><option value="custom">OpenAI-compatible</option><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option></select></label><label>Provider ID<input name="key" required minlength="2" maxlength="41" pattern="[a-z0-9][a-z0-9._-]+"></label><label>Display name<input name="display_name" maxlength="80"></label><label>Base URL<input name="base_url" type="url" required placeholder="https://api.example.com/v1"></label><label>Default model<input name="default_model" maxlength="256"></label><label>Provider API key<input name="api_key" type="password" autocomplete="new-password" required minlength="8" maxlength="400"></label><label><input name="vision" type="checkbox" value="1"> Supports vision</label><button class="ovk-button" type="submit">Add provider</button></form><form class="ovk-form ex-console-quota" method="post" action="/console/provider-credits"><h3>Record prepaid credit</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Kind<select name="kind"><option>promotional</option><option>purchased</option></select></label><label>Amount USD<input name="amount_usd" type="number" min="0" step="0.01" required></label><label>As of<input name="as_of" type="date" required></label><label>Remaining USD<input name="remaining_as_of_usd" type="number" min="0" step="0.01"></label><label>Applies to<input name="applies_to" maxlength="200"></label><button class="ovk-button" type="submit">Save credit</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-credit-preference"><h3>Promotional credit preference</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Use promotional credit first<select name="enabled"><option value="1">Yes</option><option value="0">No</option></select></label><label>Minimum remaining USD<input name="floor_usd" type="number" min="0" step="0.01" value="5"></label><button class="ovk-button ovk-button-quiet" type="submit">Save preference</button></form><form class="ovk-form ex-console-action" method="post" action="/console/provider-session-credentials"><h3>Provider account session</h3><label>Provider<select name="provider"><option>anthropic</option><option>openai</option></select></label><label>Session cookie<input name="cookie" type="password" autocomplete="new-password" required minlength="16" maxlength="4096"></label><label>Label<input name="label" maxlength="80"></label><label>Anthropic organization ID<input name="org_id" placeholder="UUID"></label><button class="ovk-button ovk-button-quiet" type="submit">Save session</button></form>`;
++ if (section === 'providers' && Array.isArray(data.session_credentials)) actions += data.session_credentials.map((c) => `<form class="ex-console-action" method="post" action="/console/provider-session-credentials/${encodeURIComponent(c.id)}/remove"><span>${esc(c.provider)} · ${esc(c.label || c.id)} · ${c.expired ? 'expired' : 'saved'}</span><button class="ovk-button ovk-button-quiet" type="submit">Remove session</button></form>`).join('');
++ if (section === 'models' && Array.isArray(data.models)) actions = data.models.map((m) => `<form class="ex-console-action" method="post" action="/console/models/${encodeURIComponent(m.provider)}/${encodeURIComponent(m.model || m.id)}/status"><label>Status for ${esc(m.provider)}/${esc(m.model || m.id)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-button ovk-button-quiet" type="submit">Save status</button></form>`).join('');
++ if (section === 'workflows' && Array.isArray(data.workflows)) actions = data.workflows.flatMap((w) => (w.versions || []).map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(w.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(w.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-button ovk-button-quiet" type="submit">Save status</button></form>`)).join('');
++ if (section === 'workflows' && data.workflow && Array.isArray(data.versions)) actions = data.versions.map((v) => `<form class="ex-console-action" method="post" action="/console/workflows/${encodeURIComponent(data.workflow.key)}/versions/${encodeURIComponent(v.version)}/status"><label>Status for ${esc(data.workflow.key)} v${esc(v.version)} <select name="status"><option value="active">Active</option><option value="disabled">Disabled</option></select></label><button class="ovk-button ovk-button-quiet" type="submit">Save status</button></form>`).join('');
++ if (section === 'quotas') actions = `<form class="ovk-form ex-console-quota" method="post" action="/console/quotas"><label>Scope type<input name="scope_type" required value="service"></label><label>Scope ID<input name="scope_id" required></label><label>Window<select name="window"><option>day</option><option>month</option></select></label><label>Max requests<input name="max_requests" type="number" min="0"></label><label>Max tokens<input name="max_tokens" type="number" min="0"></label><label>Max cost USD<input name="max_cost_usd" type="number" min="0" step="0.01"></label><button class="ovk-button" type="submit">Save quota</button></form>`;
++ if (section === 'cache') actions = `<form class="ex-console-action" method="post" action="/console/cache/purge"><label>Workflow (optional)<input name="workflow"></label><button class="ovk-button ovk-button-quiet" type="submit">Purge cache</button></form>`;
++ if (section === 'gpus' && data.state && Array.isArray(data.state.models)) actions = data.state.models.map((m) => `<form class="ex-console-action" method="post" action="/console/gpus/${encodeURIComponent(m.key)}/launch"><button class="ovk-button ovk-button-quiet" type="submit">Request ${esc(m.display_name || m.key)}</button></form><form class="ex-console-action" method="post" action="/console/gpus/${encodeURIComponent(m.key)}/stop"><button class="ovk-button ovk-button-quiet" type="submit">Stop ${esc(m.display_name || m.key)}</button></form>`).join('');
++ if (section === 'pricebook' && Array.isArray(data.pending)) actions = `<form class="ex-console-action" method="post" action="/console/pricebook/run"><button class="ovk-button ovk-button-quiet" type="submit">Read provider listings now</button></form>` + data.pending.map((c) => `<form class="ex-console-action" method="post" action="/console/pricebook/changes/${encodeURIComponent(c.id)}"><button class="ovk-button ovk-button-quiet" name="decision" value="approve">Approve ${esc(c.provider_key || c.model_key)}</button><button class="ovk-button ovk-button-quiet" name="decision" value="reject">Reject</button></form>`).join('');
++ if (section === 'exchange' && data.credits && Array.isArray(data.credits.review)) actions = data.credits.review.map((c) => `<form class="ex-console-action" method="post" action="/console/exchange/credits/${encodeURIComponent(c.id)}/resolve"><button class="ovk-button ovk-button-quiet" type="submit">Mark ${esc(c.id)} resolved</button></form>`).join('');
++ if (section === 'runs') {
++ const rows = Array.isArray(data.rows) ? data.rows : [];
++ actions = rows.filter((run) => ['queued', 'running'].includes(run.status)).map((run) => `<form class="ex-console-action" method="post" action="/console/runs/${encodeURIComponent(run.id)}/cancel"><button class="ovk-button ovk-button-quiet" type="submit">Cancel ${esc(run.id)}</button></form>`).join('');
++ if (data.run && ['queued', 'running'].includes(data.run.status)) actions += `<form class="ex-console-action" method="post" action="/console/runs/${encodeURIComponent(data.run.id)}/cancel"><button class="ovk-button ovk-button-quiet" type="submit">Cancel run</button></form>`;
++ }
++ return `<div class="ex-wrap ex-page ex-console">
++<header class="ex-console-head"><p class="ex-eyebrow">OpenVibe.Exchange · owner</p><h1>AI operator console</h1><p class="ex-prose">Manage the AI router through its JSON admin API. Provider secrets are never shown here.</p></header>
++<nav class="ex-console-nav" aria-label="AI console">${nav}</nav>
++${error ? `<p class="ex-console-error" role="alert">${esc(error)}</p>` : ''}
++<section class="ex-console-panel"><h2>${esc(section[0].toUpperCase() + section.slice(1))}</h2>${actions}<pre><code>${json}</code></pre></section>
++</div>`;
++}
++
++module.exports = { consolePage, SECTIONS };
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 5ed2bf2..ac44058 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -1150,7 +1150,7 @@ const FAQ = [
{ q: 'Where are my keys?', a: 'Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time at openvibe.network/developers. They are shown once and never stored here.' },
{ q: 'Can I sell compute?', a: 'Yes, through OpenVibe Link: offer a GPU or a spare machine to the network for credits. Machines and pools are what is sold; personal subscription plans and personal API keys are not. Every offer is labelled on the answer.' },
{ q: 'What is the uncensored tier?', a: 'Abliterated models for adults. The 18+ confirmation sits on your account, and the same floor applies to every request on the tier: no minors, no non-consensual content, no help with mass harm. A breach ends the account.' },
-- { q: 'Is ai.openvibe.services still a site of its own?', a: 'No. Its public pages redirect here, and its page list, price page and update log are gone from that address. The API stays there unchanged for OpenVibe’s services and developer apps, and AI’s own run pages and operator console stay for the people who need their session.' },
++ { q: 'Is ai.openvibe.services still a site of its own?', a: 'No. Its public pages redirect here, and its page list, price page, update log and operator console are on OpenVibe.Exchange. The API stays there for OpenVibe’s services and developer apps.' },
{ q: 'Where did AI’s prices and latency page go?', a: 'The live numbers are on Models and each model’s page: price in, out and cached per million tokens, measured p50 and p95 speed, uptime and the free allowance. The status page keeps the per-offer uptime and the model changelog.' },
];
ddiff --git a/test/console-merge.test.js b/test/console-merge.test.js
nnew file mode 100644
iindex 0000000..fd3a083
---- /dev/null
++++ b/test/console-merge.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++
++const assert = require('assert');
++const express = require('express');
++const { createServer } = require('http');
++const { createConsoleRoutes, READS } = require('../server/http/console');
++
++const calls = [];
++const writes = [];
++const fakeSecret = 'sk-provider-secret-must-never-render';
++const app = express();
++app.use((req, _res, next) => {
++ const role = req.get('x-test-role');
++ if (role) req.viewer = { kind: 'user', subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX', role, displayName: 'Owner' };
++ next();
++});
++app.use(createConsoleRoutes({
++ config: { baseUrl: 'https://openvibe.exchange' },
++ ai: { async adminRequest(path, options = {}) { calls.push(path); if (options.method) writes.push({ path, ...options }); return { providers: [{ key: 'openai', status: 'active', api_key: fakeSecret }] }; } },
++}));
++
++(async () => {
++ const server = createServer(app);
++ 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'];
++ 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`);
++ }
++ const response = await fetch(`${base}/console/providers`, { headers: { 'x-test-role': 'owner' } });
++ const html = await response.text();
++ assert.strictEqual(response.status, 200, html);
++ assert.ok(html.includes('AI operator console'));
++ assert.ok(!html.includes(fakeSecret), 'admin JSON values that resemble secrets never reach page HTML');
++ assert.deepStrictEqual(calls, [READS.providers]);
++ for (const path of consolePaths) {
++ const page = await fetch(`${base}${path}`, { headers: { 'x-test-role': 'owner' } });
++ assert.strictEqual(page.status, 200, `${path}: ${await page.text()}`);
++ }
++ 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',
++ });
++ const keyAnswer = await keyWrite.text();
++ 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 createProvider = await fetch(`${base}/console/providers`, {
++ method: 'POST', headers: { 'x-test-role': 'owner', Origin: 'https://openvibe.exchange', 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ preset: 'custom', key: 'custom-one', base_url: 'https://api.example.test/v1', api_key: fakeSecret }), redirect: 'manual',
++ });
++ 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');
++
++ } 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; });