openvibers/openvibe.network Commits
Manage worker providers and budgets from the owner panel (#814)
Changes
@@@ -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",@@@ -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",@@@ -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;@@@ -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 answerconst 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 } = {}) {@@@ -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',},@@@ -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@@@ -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); });
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -2679,9 +2679,9 @@
@@ -2679,9 +2679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -587,11 +587,11 @@ const PAI_NAMES = {
@@ -587,11 +587,11 @@ const PAI_NAMES = {
network: 'OpenVibe.Network', ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
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',
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',
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
// 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.
// 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_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
const PAI_BILLING_LABEL = {
const PAI_BILLING_LABEL = {
metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone',
metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone',
@@ -780,6 +780,7 @@ function paiRender(root) {
@@ -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-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-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 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-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>'
+ '<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) {
@@ -787,6 +788,7 @@ function paiRender(root) {
paiRenderProviders();
paiRenderProviders();
paiRenderCards();
paiRenderCards();
paiRenderVault();
paiRenderVault();
+ paiLoadCompute();
if (typeof oinLoad === 'function') oinLoad();
if (typeof oinLoad === 'function') oinLoad();
const add = document.getElementById('pai-add-provider');
const add = document.getElementById('pai-add-provider');
if (add) add.addEventListener('click', () => paiProviderDialog(null));
if (add) add.addEventListener('click', () => paiProviderDialog(null));
@@ -807,6 +809,19 @@ function paiRender(root) {
@@ -807,6 +809,19 @@ function paiRender(root) {
if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(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() {
function paiRenderWarnings() {
const box = document.getElementById('pai-warnings');
const box = document.getElementById('pai-warnings');
if (!box) return;
if (!box) return;
@@ -46,13 +46,13 @@ const { isFreshSignIn } = require('../identity/reauth');
@@ -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;
// 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".
// the panel says it "has not published settings yet".
const SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor',
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 = {
const LABELS = {
ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals',
actor: 'OpenVibe.Actor', chat: 'OpenVibe.Chat', connect: 'OpenVibe Connect', deals: 'OpenVibe.Deals',
work: 'OpenVibe.Work', sources: 'OpenVibe.Sources', design: 'OpenVibe.Design', domains: 'OpenVibe.Domains',
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_TIMEOUT_MS = 20000; // the service gives its tester 15 s; this leaves it time to answer
const TEST_MESSAGE_MAX = 300;
const TEST_MESSAGE_MAX = 300;
@@ -380,6 +380,14 @@ function createAiConfigProxy(opts = {}) {
@@ -380,6 +380,14 @@ function createAiConfigProxy(opts = {}) {
send(res, 200, { ok: true, services: out });
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) ──
// ── AI's provider registry (its own admin API, ai.provider.manage) ──
const providerPath = (suffix = '') => `/api/v1/providers${suffix}`;
const providerPath = (suffix = '') => `/api/v1/providers${suffix}`;
async function providerCall(req, method, pathName, body = null, { fresh = false, secret = null } = {}) {
async function providerCall(req, method, pathName, body = null, { fresh = false, secret = null } = {}) {
@@ -206,6 +206,9 @@ module.exports = {
@@ -206,6 +206,9 @@ module.exports = {
codes: {
codes: {
internalUrl: process.env.OV_CODES_INTERNAL_URL || 'http://127.0.0.1:4900',
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: {
actor: {
internalUrl: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
internalUrl: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
},
},
@@ -372,7 +372,7 @@ const DEFAULT_GRANTS = [
@@ -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
// 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
// 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.
// 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`))
.filter((svc) => capabilities.get(`${svc}.config.manage`))
.map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
.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
// The editorial queue's AI editor (Contracts 0.315.0, 2026-11-07): the orchestrator's service principal records
@@ -66,11 +66,12 @@ function summary(service, namespace, extra = {}) {
@@ -66,11 +66,12 @@ function summary(service, namespace, extra = {}) {
const OWNER = 'rootowner';
const OWNER = 'rootowner';
const ai = await fake('ai');
const ai = await fake('ai');
const billing = await fake('billing');
const billing = await fake('billing');
+ const run = await fake('run');
let w = null;
let w = null;
try {
try {
w = await buildWorld({
w = await buildWorld({
label: 'ai-config',
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;
const owner = w.users.owner;
ai.seen.length = 0; billing.seen.length = 0;
ai.seen.length = 0; billing.seen.length = 0;
@@ -107,6 +108,13 @@ function summary(service, namespace, extra = {}) {
@@ -107,6 +108,13 @@ function summary(service, namespace, extra = {}) {
'GET /internal/config/billing.credits': credits,
'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 ──
// ── 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');
let r = await call('alice', 'GET', '/api/admin/ai/config/services');
assert.strictEqual(r.status, 403, 'a user never gets the owner settings');
assert.strictEqual(r.status, 403, 'a user never gets the owner settings');
@@ -117,7 +125,7 @@ function summary(service, namespace, extra = {}) {
@@ -117,7 +125,7 @@ function summary(service, namespace, extra = {}) {
// ── GET /services: the seventeen services and their operator-set internal URLs ──
// ── GET /services: the seventeen services and their operator-set internal URLs ──
r = await ownerCall('GET', '/api/admin/ai/config/services');
r = await ownerCall('GET', '/api/admin/ai/config/services');
assert.strictEqual(r.status, 200, r.text);
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').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 === 'deals').capability, 'deals.config.manage');
assert.strictEqual(r.body.services.find((s) => s.service === 'ai').internal_url, ai.url);
assert.strictEqual(r.body.services.find((s) => s.service === 'ai').internal_url, ai.url);
@@ -246,7 +254,7 @@ function summary(service, namespace, extra = {}) {
@@ -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.
// 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 { 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) {
for (const svc of setupServices) {
const known = !!capabilities.get(`${svc}.config.manage`);
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`);
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 = {}) {
@@ -285,5 +293,6 @@ function summary(service, namespace, extra = {}) {
if (w) await w.stop();
if (w) await w.stop();
await new Promise((r) => ai.srv.close(r));
await new Promise((r) => ai.srv.close(r));
await new Promise((r) => billing.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); });
})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });