openvibers/openvibe.network Commits

Manage worker providers and budgets from the owner panel (#814)

OP OpenVibers committed 2 days ago · 3c9e44f1ed · 7 files, +48 −13

* Expose owner-only worker provider settings and budget alerts

* Merge main and retain released compute configuration contract

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex e813d5bf..c6f9abf2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"otpauth": "9.6.0",
@@@ -2679,9 +2679,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.401.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0",
-- "integrity": "sha512-NKuWV2l97rd2f7SZ2PkdDRvw3SeY8WrmYByorDSLvadJFFEcdSwXAilsSIa1QA8YqRkzcXi2eHdCGRmb6WZR3w==",
++ "version": "0.404.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0",
++ "integrity": "sha512-yJsuZIqKEsjlx92Y5c0C/8wJBdA713NFNu797KVbvZHSvgPJJS/75pdE3t9pFiuo/Bb6XgfwHIaHGqHPM/bRAw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex b4c94238..f95f7f74 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.401.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.404.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"otpauth": "9.6.0",
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex eb35ee64..510ee162 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -587,11 +587,11 @@ const PAI_NAMES = {
network: 'OpenVibe.Network', ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals', work: 'OpenVibe.Work',
-- sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains', media: 'OpenVibe.Media',
++ sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains', media: 'OpenVibe.Media', run: 'OpenVibe.Run',
};
// The services whose settings are AI settings: their cards are on this tab. Every other service's owner inputs are
// on the Setup tab; the "Where keys are kept" list covers them all.
--const PAI_AI_SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor'];
++const PAI_AI_SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor', 'run'];
const PAI_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
const PAI_BILLING_LABEL = {
metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone',
@@@ -780,6 +780,7 @@ function paiRender(root) {
+ '<section id="pai-inference" class="pai-section"><div class="pai-section-head"><h3>Who answers, and what it costs</h3><p class="muted">For each kind of work: which provider answers first, the most it may cost, the providers never to use and the ways of paying less; then a spending limit for each provider and what it all cost. Changes apply to the next call.</p></div><div id="oin-body"><p class="muted">Loading…</p></div></section>'
+ '<section id="pai-exchange" class="pai-section"><div class="pai-section-head"><h3>What the Exchange offers</h3><p class="muted">Which models people can pick on openvibe.exchange, the free tier, and the spending limits that protect your balance.</p></div><div id="pai-cards-exchange" data-pai-cards></div></section>'
+ '<section id="pai-money" class="pai-section"><div class="pai-section-head"><h3>Money</h3><p class="muted">Credits are what people spend on AI; these switches decide whether people can buy them, top up and pay sellers. Each switch says what it turns on before you change it. ' + paiWhat('credits') + '</p></div><div id="pai-cards-money" data-pai-cards></div></section>'
++ + '<section><h3>Worker compute budgets</h3><p><a href="https://openvibe.exchange/compute">Compute prices on Exchange</a></p><div id="pai-compute-status">Loading…</div></section>'
+ '<section id="pai-services" class="pai-section"><div class="pai-section-head"><h3>Other services\' AI settings</h3><p class="muted">AI settings that live in other parts of OpenVibe (Codes, Actor, Bench…), one card per service. Their other keys (publishing, sign-in apps, data sources) are on the <a href="/admin/setup">Setup</a> tab.</p></div><div id="pai-cards-services" data-pai-cards></div></section>'
+ '<section id="pai-vault" class="pai-section"><div class="pai-section-head"><h3>Where keys are kept</h3><p class="muted">Every key you enter is locked with that service\'s own lock key and can never be read back, not even by you. A service without its lock key stores keys unlocked and says so here: fix that first. ' + paiWhat('lock-key') + '</p></div><div id="pai-vault-list"></div></section>'
);
@@@ -787,6 +788,7 @@ function paiRender(root) {
paiRenderProviders();
paiRenderCards();
paiRenderVault();
++ paiLoadCompute();
if (typeof oinLoad === 'function') oinLoad();
const add = document.getElementById('pai-add-provider');
if (add) add.addEventListener('click', () => paiProviderDialog(null));
@@@ -807,6 +809,19 @@ function paiRender(root) {
if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(root);
}
++async function paiLoadCompute() {
++ const el = document.getElementById('pai-compute-status');
++ if (!el) return;
++ try {
++ const { compute } = await paiApi('/compute/status');
++ el.innerHTML = '<p>' + (compute.enabled ? 'Reconciler enabled' : 'Reconciler disabled') +
++ (compute.dry_run ? ' · Dry run' : ' · Live') + '</p><ul>' + (compute.budgets || []).map(b =>
++ '<li' + (b.near_cap ? ' role="alert"' : '') + '>' + esc(b.provider) + ': ' +
++ esc((Number(b.committed_micros) / 1e6).toFixed(4)) + ' of ' + esc(b.cap) + ' ' + esc(b.currency) +
++ ' committed' + (b.near_cap ? ' — at least 80% of the monthly cap' : '') + '</li>').join('') + '</ul>';
++ } catch { el.textContent = 'Compute budgets are unavailable.'; }
++}
++
function paiRenderWarnings() {
const box = document.getElementById('pai-warnings');
if (!box) return;
ddiff --git a/server/admin/ai-config-proxy.js b/server/admin/ai-config-proxy.js
iindex a5058d11..8f6fe8be 100644
---- a/server/admin/ai-config-proxy.js
++++ b/server/admin/ai-config-proxy.js
@@@ -46,13 +46,13 @@ const { isFreshSignIn } = require('../identity/reauth');
// 2026-11-07). A service that has not mounted openvibe-shared/config's routes yet answers 404 or does not answer;
// the panel says it "has not published settings yet".
const SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor',
-- 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media'];
++ 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media', 'run'];
const LABELS = {
ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals',
work: 'OpenVibe.Work', sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains',
-- media: 'OpenVibe.Media',
++ media: 'OpenVibe.Media', run: 'OpenVibe.Run',
};
const TEST_TIMEOUT_MS = 20000; // the service gives its tester 15 s; this leaves it time to answer
const TEST_MESSAGE_MAX = 300;
@@@ -380,6 +380,14 @@ function createAiConfigProxy(opts = {}) {
send(res, 200, { ok: true, services: out });
});
++ r.get('/compute/status', async (req, res) => {
++ try {
++ const out = await callService(req, 'run', '/internal/config/compute-status');
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'run', out);
++ send(res, 200, { ok: true, compute: out.answer });
++ } catch (err) { fail(res, err); }
++ });
++
// ── AI's provider registry (its own admin API, ai.provider.manage) ──
const providerPath = (suffix = '') => `/api/v1/providers${suffix}`;
async function providerCall(req, method, pathName, body = null, { fresh = false, secret = null } = {}) {
ddiff --git a/server/config.js b/server/config.js
iindex 1523faaa..b6de5d64 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -206,6 +206,9 @@ module.exports = {
codes: {
internalUrl: process.env.OV_CODES_INTERNAL_URL || 'http://127.0.0.1:4900',
},
++ run: {
++ internalUrl: process.env.OV_RUN_INTERNAL_URL || 'http://127.0.0.1:4920',
++ },
actor: {
internalUrl: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
},
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 9fc8a534..5ef81399 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -372,7 +372,7 @@ const DEFAULT_GRANTS = [
// each service mounts /internal/config). A grant is seeded only once the pinned Contracts knows its capability, so
// this works on the pin before 0.308.0 and seeds all eight at the first boot after the pin moves. New rows, as for
// the nine: no RESTORED_GRANTS or CHANGED_DEFAULT_NAMESPACES entry.
-- ...['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']
++ ...['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media', 'run']
.filter((svc) => capabilities.get(`${svc}.config.manage`))
.map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
// The editorial queue's AI editor (Contracts 0.315.0, 2026-11-07): the orchestrator's service principal records
ddiff --git a/test/ai-config-proxy.test.js b/test/ai-config-proxy.test.js
iindex 05c82cde..4f092e4c 100644
---- a/test/ai-config-proxy.test.js
++++ b/test/ai-config-proxy.test.js
@@@ -66,11 +66,12 @@ function summary(service, namespace, extra = {}) {
const OWNER = 'rootowner';
const ai = await fake('ai');
const billing = await fake('billing');
++ const run = await fake('run');
let w = null;
try {
w = await buildWorld({
label: 'ai-config',
-- env: { OV_AI_INTERNAL_URL: ai.url, OV_BILLING_INTERNAL_URL: billing.url, AI_CONFIG_WRITES_PER_HOUR: '60' },
++ env: { OV_AI_INTERNAL_URL: ai.url, OV_BILLING_INTERNAL_URL: billing.url, OV_RUN_INTERNAL_URL: run.url, AI_CONFIG_WRITES_PER_HOUR: '60' },
});
const owner = w.users.owner;
ai.seen.length = 0; billing.seen.length = 0;
@@@ -107,6 +108,13 @@ function summary(service, namespace, extra = {}) {
'GET /internal/config/billing.credits': credits,
});
++ run.routes['GET /internal/config/compute-status'] = {enabled:false,dry_run:true,budgets:[{provider:'daytona',near_cap:true,cap:'10',committed_micros:'8000000',currency:'USD'}]};
++ assert.strictEqual((await call('staff','GET','/api/admin/ai/config/compute/status')).status,403);
++ const compute = await ownerCall('GET','/api/admin/ai/config/compute/status');
++ assert.strictEqual(compute.status,200);
++ assert.strictEqual(compute.body.compute.budgets[0].near_cap,true);
++ assert.ok(run.seen[0].headers['x-ov-subject']);
++
// ── Only the owner: a user and a plain admin are refused, and nothing reaches a service ──
let r = await call('alice', 'GET', '/api/admin/ai/config/services');
assert.strictEqual(r.status, 403, 'a user never gets the owner settings');
@@@ -117,7 +125,7 @@ function summary(service, namespace, extra = {}) {
// ── GET /services: the seventeen services and their operator-set internal URLs ──
r = await ownerCall('GET', '/api/admin/ai/config/services');
assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(r.body.services.map((s) => s.service), ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor', 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media']);
++ assert.deepStrictEqual(r.body.services.map((s) => s.service), ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor', 'chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media', 'run']);
assert.strictEqual(r.body.services.find((s) => s.service === 'deals').name, 'OpenVibe.Deals');
assert.strictEqual(r.body.services.find((s) => s.service === 'deals').capability, 'deals.config.manage');
assert.strictEqual(r.body.services.find((s) => s.service === 'ai').internal_url, ai.url);
@@@ -246,7 +254,7 @@ function summary(service, namespace, extra = {}) {
}
// The eight Setup services: Network holds each grant as soon as the pinned Contracts (0.308.0) knows it.
const { capabilities, version: contractsVersion } = require('openvibe-contracts');
-- const setupServices = ['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media'];
++ const setupServices = ['chat', 'connect', 'deals', 'work', 'sources', 'design', 'domains', 'media', 'run'];
for (const svc of setupServices) {
const known = !!capabilities.get(`${svc}.config.manage`);
assert.strictEqual(has('network', `${svc}.config.manage`, `openvibe.${svc}`), known, `Network holds ${svc}.config.manage exactly when Contracts knows it`);
@@@ -285,5 +293,6 @@ function summary(service, namespace, extra = {}) {
if (w) await w.stop();
await new Promise((r) => ai.srv.close(r));
await new Promise((r) => billing.srv.close(r));
++ await new Promise((r) => run.srv.close(r));
}
})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });