openvibers/openvibe.ai Commits

Owner inputs: every AI provider key and the GPU keys on Setup, and a console in plain words (#336)

OP OpenVibers committed 6 days ago · abaa044934 · 12 files, +753 −163

* Owner inputs: every AI provider key and the GPU keys on Setup, and a console in plain words

Every provider key in ai.provider_keys that a provider needs is an x-owner-input
(group ai-providers) with the provider's key page, a line on what to pick there and a
test; the shared provider's key is also the image model's key (x-required-for Image
generation when AI_IMAGE_MODEL is set). The Vast.ai and RunPod keys and the monthly
cap are owner inputs in the gpus group. Every field of ai.exchange, ai.gpus and
ai.provider_keys has a plain title and a 30+ character description; the routing
tuning and the bound addresses are x-advanced.

A provider's key is now the panel's when one is set and what its secret_ref names
otherwise (util.providerSecret), so the server's settings file stays the fallback.

POST /internal/config/:namespace/test/:key (ai.config.manage) makes one cheap
authenticated read (the provider's model list, Vast.ai's account, RunPod's pods) and
answers in one line with the key scrubbed; 409 config.not_set with nothing to test.

The operator console uses the design's words: AI providers with Key and Health in
sentences, Practice answers, who pays and the terms, prices per million words, Limits
for quotas, Saved AI tasks, prompt templates and routing rules, the queue as a
sentence, the GPU page and the price book rewritten, and a glossary page.

* Merge remote-tracking branch 'origin/main' into feat/owner-inputs

* Pin openvibe-contracts v0.311.0

* Merge main: the GPU keys keep the Setup copy and group id, and say own endpoints need them too

Changes

ddiff --git a/server/app.js b/server/app.js
iindex df9c85a..6b4597f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -8,7 +8,7 @@ const { createRelease } = require('openvibe-shared/release');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { runsRouter } = require('./api/runs');
const { actionsRouter } = require('./api/actions');
--const { sendError } = require('./util');
++const { sendError, providerSecret } = require('./util');
const openapi = require('./openapi');
const { adminRouter } = require('./api/admin');
const { CAPS } = require('./auth');
@@@ -23,6 +23,7 @@ const home = require('./home');
const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
++const ownerInputs = require('./owner-inputs');
function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {
const app = express();
@@@ -155,6 +156,35 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
log,
});
++ // The Setup tab's Test button (owner-setup design): one cheap authenticated read with the stored key, an answer
++ // in one line, never the key (server/owner-inputs.js). Before the config routes, so a Shared
++ // release with its own test hook never shadows this one.
++ app.use(ownerInputs.testRoute({
++ guard: auth.requireCap(CAPS.configManage),
++ log,
++ now: clock.now,
++ resolve: async (namespace, key) => {
++ if (namespace === settings.providerKeys.namespace && key.endsWith('.key')) {
++ const provider = await registry.getProvider(key.slice(0, -4));
++ if (!provider || provider.kind === 'stub' || provider.kind === 'whisper' || provider.auth_mode === 'none') return null;
++ const own = require('./vault').get(provider.key);
++ if (own) return { value: own, source: 'vault', provider };
++ const ref = /^env:([A-Z][A-Z0-9_]{0,63})$/.exec(String(provider.secret_ref || ''));
++ const value = providerSecret(provider, env);
++ return { value, source: value ? (ref ? 'env' : 'vault') : null, env: ref ? ref[1] : null, provider };
++ }
++ if (namespace === settings.gpus.namespace && (key === 'vast.key' || key === 'runpod.key')) {
++ const v = settings.gpus.values();
++ const marketplace = key.slice(0, -4);
++ const value = marketplace === 'vast' ? v.vast_key : v.runpod_key;
++ return { value, source: value ? 'vault' : null, marketplace, origin: marketplace === 'vast' ? v.vast_origin : v.runpod_origin };
++ }
++ return null;
++ },
++ run: (namespace, key, value, found) => (found.provider
++ ? ownerInputs.testProviderKey(found.provider, value, { fetchImpl })
++ : ownerInputs.testGpuKey(found.marketplace, value, { fetchImpl, origin: found.origin })),
++ }));
app.use(configRoutes.handle);
}
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 6133739..7daf9aa 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -44,7 +44,7 @@
*/
const crypto = require('crypto');
const express = require('express');
--const { AiError, resolveSecret, iso } = require('../util');
++const { AiError, resolveSecret, providerSecret, iso } = require('../util');
const usageSamples = require('../usage-samples');
const { CAPS } = require('../auth');
const { staff: staffMap } = require('openvibe-contracts');
@@@ -53,7 +53,7 @@ const sso = require('./sso');
const q = require('./queries');
const catalog = require('../providers/catalog');
const poolSeed = require('../providers/pool-seed');
--const { CSP, esc, pages } = require('./views');
++const { CSP, esc, pages, healthText } = require('./views');
/** Network staff capability -> AI capability held by a console session (documented in README "Operator console"). */
const STAFF_TO_AI = Object.freeze([
@@@ -147,7 +147,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
/** Every secret value this process can resolve, so that no page can ever carry one (belt and braces). */
async function secretValues() {
const vals = new Set([clientSecret(), secret]);
-- for (const p of await registry.listProviders()) vals.add(resolveSecret(p.secret_ref, env));
++ for (const p of await registry.listProviders()) vals.add(providerSecret(p, env));
return [...vals].filter((v) => v && v.length >= 8);
}
async function scrub(text) {
@@@ -317,8 +317,11 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
const problems = [];
for (const p of (await registry.listProviders()).filter((x) => x.status === 'active' && x.kind !== 'stub')) {
const v = await ops.providerView(p);
-- if (v.credentials === 'missing') problems.push({ key: p.key, kind: p.kind, problem: `credentials missing (${p.secret_ref || 'no reference'})` });
-- if (v.health && v.health.state !== 'closed') problems.push({ key: p.key, kind: p.kind, problem: `circuit ${v.health.state}` });
++ if (v.credentials === 'missing') {
++ const ref = /^env:([A-Z][A-Z0-9_]*)$/.exec(String(p.secret_ref || ''));
++ problems.push({ key: p.key, kind: p.kind, problem: ref ? `No key: the server's settings file has no ${ref[1]}, and none was entered here. Enter one in the admin panel's Setup tab.` : 'No key: enter one in the admin panel\'s Setup tab or on the AI providers page.' });
++ }
++ if (v.health && v.health.state !== 'closed') problems.push({ key: p.key, kind: p.kind, problem: healthText(v.health, { breaker: config.breaker, now: clock.now() }).text });
}
const stats = await cache.stats();
const frac = (c) => {
@@@ -336,7 +339,9 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
r.get('/console/providers', read, wrap(async (req, res) => await send(res, 200, pages.providers({
...common(req), rows: (await Promise.all((await registry.listProviders()).map(ops.providerView))), models: await registry.listModels(), stubFallback: config.stubFallback,
canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), keyHints: vault ? await vault.hints() : new Map(),
++ breaker: config.breaker, now: clock.now(),
}))));
++ r.get('/console/glossary', read, wrap(async (req, res) => await send(res, 200, pages.glossary({ ...common(req) }))));
r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap(async (req, res) => {
await ops.setProviderStatus(req.params.key, String(req.body.status || ''), who(req));
res.redirect(303, '/console/providers?done=provider_status');
@@@ -373,7 +378,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
await vault.put(key, b.api_key, who(req));
// Its model list, read right away (best effort: a provider whose list cannot be read is still added).
const added = await registry.getProvider(key);
-- await catalog.sync({ registry, provider: added, apiKey: resolveSecret(added.secret_ref, env), fetchImpl, actor: who(req).actor }).catch(() => null);
++ await catalog.sync({ registry, provider: added, apiKey: providerSecret(added, env), fetchImpl, actor: who(req).actor }).catch(() => null);
// The provider's own model ids get the price the catalogue recorded for them, matched by name (pool-seed).
await poolSeed.applyCatalogPrices({ registry, provider: added, actor: who(req).actor }).catch(() => null);
res.redirect(303, '/console/providers?done=provider_added');
@@@ -392,7 +397,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
r.post('/console/providers/:key/sync', needs(CAPS.providerManage), wrap(async (req, res) => {
const p = await registry.getProvider(req.params.key);
if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
-- const apiKey = resolveSecret(p.secret_ref, env);
++ const apiKey = providerSecret(p, env);
if (!apiKey && p.auth_mode !== 'none') throw new AiError(422, 'ai.invalid', `${p.key} has no key to read its model list with`);
const out = await catalog.sync({ registry, provider: p, apiKey, fetchImpl, actor: who(req).actor });
const priced = await poolSeed.applyCatalogPrices({ registry, provider: p, actor: who(req).actor }).catch(() => ({ priced: [], retired: [] }));
ddiff --git a/server/console/views.js b/server/console/views.js
iindex 338d791..5569d38 100644
---- a/server/console/views.js
++++ b/server/console/views.js
@@@ -119,11 +119,58 @@ function table(caption, head, rows, empty = 'None.') {
}
const NAV = [
-- ['/console', 'Overview', 'overview'], ['/console/providers', 'Providers', 'providers'], ['/console/routes', 'Routes', 'routes'],
-- ['/console/templates', 'Templates', 'templates'], ['/console/workflows', 'Workflows', 'workflows'], ['/console/runs', 'Runs', 'runs'],
-- ['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/abuse', 'Abuse', 'abuse'], ['/console/audit', 'Audit', 'audit'],
++ ['/console', 'Overview', 'overview'], ['/console/providers', 'AI providers', 'providers'], ['/console/routes', 'Routing rules', 'routes'],
++ ['/console/templates', 'Prompt templates', 'templates'], ['/console/workflows', 'Saved AI tasks', 'workflows'], ['/console/runs', 'Runs', 'runs'],
++ ['/console/quotas', 'Limits', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/abuse', 'Abuse', 'abuse'], ['/console/audit', 'Audit', 'audit'],
['/console/gpus', 'GPUs', 'gpus'],
['/console/pricebook', 'Prices', 'pricebook'],
++ ['/console/glossary', 'What words mean', 'glossary'],
++];
++
++// ── Plain words (the owner-setup design, section 3) ──────────
++/** A provider's health as a sentence, never a circuit code (closed / open / half_open stay in grey after it). */
++function healthText(h, { breaker = {}, now = Date.now() } = {}) {
++ const st = (h && h.state) || 'closed';
++ if (st === 'closed') return { text: h && h.consecutive_failures ? `Working (${h.consecutive_failures} error${h.consecutive_failures === 1 ? '' : 's'} in a row so far)` : 'Working', cls: 'ok' };
++ if (st === 'half_open') return { text: 'Testing again: the next request tries it once', cls: 'warn' };
++ const cooldown = h.exhausted_at ? Number(breaker.exhaustedCooldownMs || 600000) : Number(breaker.cooldownMs || 60000);
++ const at = h.opened_at ? new Date(Number(h.opened_at) + cooldown) : null;
++ const hhmm = at && Number.isFinite(at.getTime()) ? `${at.toISOString().slice(11, 16)} UTC` : 'soon';
++ const why = h.exhausted_at ? 'the provider says the account is out of balance' : `${h.consecutive_failures || 1} error${(h.consecutive_failures || 1) === 1 ? '' : 's'} in a row`;
++ void now;
++ return { text: `Paused after ${why}; tries again at ${hhmm}`, cls: 'bad' };
++}
++/** Where a provider's key comes from, in words: entered here, the server's settings file, not set or not needed. */
++function keyText(p, hint) {
++ if (p.credentials === 'not_required' || p.auth_mode === 'none') return html`<span class="muted">Not needed</span>`;
++ const ref = /^env:([A-Z][A-Z0-9_]*)$/.exec(String(p.secret_ref || ''));
++ if (hint) return html`<span class="ok">Entered here, locked</span><br><span class="muted">ends ${hint.hint}, set ${when(hint.updated_at)}</span>`;
++ if (p.credentials === 'configured' && ref) return html`<span class="ok">From the server's settings file</span> (<code>${ref[1]}</code>)`;
++ if (p.credentials === 'configured') return html`<span class="ok">Entered here, locked</span>`;
++ return html`<span class="bad">Not set</span>${ref ? html`<br><span class="muted">the settings file has no <code>${ref[1]}</code></span>` : ''}`;
++}
++/** A billing profile in words. */
++const BILLING_WORDS = { metered: 'Spends Credits per request', subscription: 'A plan with a monthly fee shared by everyone', prepaid: 'A prepaid balance at the provider', payg: 'A prepaid balance at the provider', free: 'Free', customer_byok: "The person's own key pays", byok: "The person's own key pays" };
++/** What a provider's terms allow, in words. */
++const ACCESS_WORDS = { OWN_DEPLOYMENT: 'Runs on our own machine: no provider terms', AUTHORIZED_WHOLESALE: 'Our agreement allows reselling it', CUSTOMER_BYOK: "Only with the person's own key", RESTRICTED: 'Do not sell: their terms forbid it', UNVERIFIED: 'Not checked yet: treated as Do not sell' };
++const billingText = (p) => BILLING_WORDS[String((p.billing_profile || (p.metadata && p.metadata.billing_profile) || '')).toLowerCase()] || '';
++const accessText = (p) => ACCESS_WORDS[String(p.provider_access || 'UNVERIFIED').toUpperCase()] || '';
++/** The queue as a sentence. */
++const queueText = (q) => `Doing ${n(q.running)} now, ${n(q.queued)} waiting. At most ${n(q.max_concurrent)} at once; more wait in line (up to ${n(q.max_queued)}).`;
++const STUB_WORDS = 'For testing only. When on, people get a canned answer instead of an error.';
++/** The words that must stay, each with one line (the console's glossary page, and the admin panel's). */
++const GLOSSARY = [
++ ['Credits', 'What people spend on AI. A paid request holds Credits before it runs and spends what it used after.'],
++ ['BYOK (bring your own key)', "A person's own provider key: their requests run on it and their provider bills them, not OpenVibe."],
++ ['Resale', "Selling a provider's models to other people. Only providers whose terms allow it (\"Our agreement allows reselling it\") are offered to others."],
++ ['Cache', 'A stored answer. The same request, from the same person, for the same task and versions, gets it back without paying a provider again.'],
++ ['Limit (quota)', 'The most requests, tokens or dollars one service, person or app may use in a minute, hour or day. Past it, requests are refused until the window resets.'],
++ ['Saved AI task (workflow)', 'A named job such as "summarize a stream", made of steps, each using a prompt template and a routing rule.'],
++ ['Prompt template', 'The words sent to the model for a task, with blanks the request fills in.'],
++ ['Routing rule (route)', 'Which providers and models may answer a task, and in what order they are tried when one fails.'],
++ ['Token', 'The unit models read and write, about three quarters of a word. Prices are per million tokens.'],
++ ['Health', 'Whether a provider is answering. After several errors in a row it is paused for a while so requests go elsewhere, then tried once again.'],
++ ['Practice answers', 'Canned answers from a built-in test provider, used only when every real provider fails and the switch is on. For testing, never for people.'],
];
// The console's pages are noindex; the one public page drawn with this layout (/stats) passes robots, a description and
@@@ -171,29 +218,45 @@ function overview({ staff, csrf, st, counts24, failures24, queue, cacheTotals, p
const runs24 = Object.values(counts24).reduce((a, b) => a + b, 0);
return layout({
title: 'Overview', section: 'overview', staff, csrf, body: html`<h1>Overview</h1>
++<p class="muted">How OpenVibe's AI is doing right now: which providers answer, what ran and failed today, and what it cost. Come here first when something seems slow or wrong; each card links to the page that explains it.</p>
<div class="card"><p class="m0">This session may: ${staff.caps.map((c, i) => html`${i ? '; ' : ''}${CAP_LABELS[c] || c}`)}.</p></div>
<div class="grid">
--<div class="card"><div class="k">Providers</div><div class="v">${n(st.providers.filter((p) => p.status === 'active').length)} active</div>
--<div class="muted">${problems.length ? html`<span class="bad">${problems.length} need attention</span>` : 'no credential or circuit problem'} · stub fallback ${stubFallback ? 'on' : 'off'}</div><a href="/console/providers">Providers</a></div>
++<div class="card"><div class="k">AI providers</div><div class="v">${n(st.providers.filter((p) => p.status === 'active').length)} switched on</div>
++<div class="muted">${problems.length ? html`<span class="bad">${problems.length} need${problems.length === 1 ? 's' : ''} attention</span>` : 'every key works and none is paused'}</div>
++<div class="muted">Practice answers when every provider fails: <strong>${stubFallback ? 'on' : 'off'}</strong>. ${STUB_WORDS}</div><a href="/console/providers">AI providers</a></div>
<div class="card"><div class="k">Runs, last 24 h</div><div class="v">${n(runs24)}</div>
<div class="muted">${Object.entries(counts24).map(([s, c], i) => html`${i ? ' · ' : ''}${s} ${n(c)}`)}</div><a href="/console/runs">Runs</a></div>
<div class="card"><div class="k">Failed, last 24 h</div><div class="v">${counts24.failed ? html`<span class="bad">${n(counts24.failed)}</span>` : '0'}</div>
<div class="muted">${failures24.length ? failures24.slice(0, 3).map((f, i) => html`${i ? ' · ' : ''}${f.error_code || '(none)'} ${n(f.n)}`) : 'none'}</div><a href="/console/runs?status=failed">Failed runs</a></div>
<div class="card"><div class="k">Spend today (${st.today.day})</div><div class="v">${usd(st.today.cost_usd)}</div><div class="muted">${n(st.today.requests)} provider requests</div><a href="/console/usage">Usage</a></div>
--<div class="card"><div class="k">Queue</div><div class="v">${n(queue.running)} running</div><div class="muted">${n(queue.queued)} queued · up to ${n(queue.max_concurrent)} at once, ${n(queue.max_queued)} waiting</div></div>
--<div class="card"><div class="k">Definitions</div><div class="v">${n(st.workflows)} workflows</div><div class="muted">${n(st.templates)} templates · ${n(st.routes)} routes</div><a href="/console/workflows">Workflows</a></div>
++<div class="card"><div class="k">Queue</div><div class="v">${n(queue.running)} running</div><div class="muted">${queueText(queue)}</div></div>
++<div class="card"><div class="k">Saved AI tasks</div><div class="v">${n(st.workflows)}</div><div class="muted">${n(st.templates)} prompt templates · ${n(st.routes)} routing rules. A saved task runs prompt templates on the providers its routing rule allows.</div><a href="/console/workflows">Saved AI tasks</a></div>
<div class="card"><div class="k">Cache</div><div class="v">${n(cacheTotals.entries)} entries</div><div class="muted">${n(cacheTotals.hits)} hits served</div><a href="/console/cache">Cache</a></div>
</div>
<h2>Needs attention</h2>
--${table('Provider problems', ['Provider', 'Kind', 'Problem'], problems.map((p) => html`<tr><td><code>${p.key}</code></td><td>${p.kind}</td><td class="bad">${p.problem}</td></tr>`), 'none.')}
++${table('Provider problems', ['Provider', 'Kind', 'What is wrong'], problems.map((p) => html`<tr><td><code>${p.key}</code></td><td>${p.kind}</td><td class="bad">${p.problem}</td></tr>`), 'none: every switched-on provider has a key and is answering.')}
${table('Failed runs in the last 24 hours, by error code', ['Error code', 'Runs', 'Workflows', 'Last'], failures24.map((f) => html`<tr>
<td><a href="/console/runs${qs({ status: 'failed', code: f.error_code })}"><code>${f.error_code || '(none)'}</code></a></td><td>${n(f.n)}</td><td>${n(f.workflows)}</td><td>${when(f.last_at)}</td></tr>`), 'no failed runs.')}
--${table('Busiest quota windows', ['Scope', 'Window', 'Used', 'Limit', 'Resets in'], topQuotas.map((c) => html`<tr>
--<td>${c.quota.scope_type} <code>${c.scope_id}</code>${c.quota.workflow_prefix ? html` · <code>${c.quota.workflow_prefix}*</code>` : ''}</td><td>${c.quota.window}</td>
--<td>${limitsUsed(c)}</td><td>${limits(c.quota)}</td><td>${secs(c.resets_in_seconds)}</td></tr>`), 'no active quota.')}`,
++${table('Limits closest to running out', ['Who it limits', 'Window', 'Used', 'Limit', 'Resets in'], topQuotas.map((c) => html`<tr>
++<td>${scopeText(c.quota.scope_type, c.scope_id)}${c.quota.workflow_prefix ? html` · tasks starting <code>${c.quota.workflow_prefix}</code>` : ''}</td><td>${WINDOW_WORDS[c.quota.window] || c.quota.window}</td>
++<td>${limitsUsed(c)}</td><td>${limits(c.quota)}</td><td>${secs(c.resets_in_seconds)}</td></tr>`), 'no limit is set.')}`,
});
}
++/** Who a limit applies to, in words; the scope type and id stay in code after it. */
++const SCOPE_WORDS = {
++ global: { label: 'Everyone together', line: 'One shared limit for every request to AI.' },
++ service: { label: 'One OpenVibe service', line: 'A service such as Live or Codes; * gives every service its own limit.' },
++ actor: { label: 'One person or agent', line: 'The person or agent a request is made for; * gives each one their own limit.' },
++ attribution: { label: "One app's user", line: 'A user of a service as that service names them (live:user:42); * gives each their own limit.' },
++};
++const WINDOW_WORDS = { minute: 'per minute', hour: 'per hour', day: 'per day (UTC)' };
++function scopeText(type, id) {
++ const w = SCOPE_WORDS[type];
++ const who = id === '*' ? (type === 'global' ? '' : ', each on its own') : '';
++ return html`${w ? w.label : type}${who} <code>${id}</code>`;
++}
++
function limits(q) {
const parts = [];
if (q.max_requests != null) parts.push(`${n(q.max_requests)} requests`);
@@@ -211,34 +274,41 @@ function limitsUsed(c) {
return parts.join(' · ') || `${n(c.used.requests)} requests`;
}
--function providers({ staff, csrf, rows, models, canManage, stubFallback, notice, error, keyHints = new Map() }) {
-- const keyCell = (p) => {
-- const h = keyHints.get(p.key);
-- return h && String(p.secret_ref || '') === `vault:${p.key}` ? html`<br><span class="muted">key ${h.hint}, set ${when(h.updated_at)}</span>` : '';
++function providers({ staff, csrf, rows, models, canManage, stubFallback, notice, error, keyHints = new Map(), breaker = {}, now = Date.now() }) {
++ const healthCell = (p) => {
++ const h = healthText(p.health, { breaker, now });
++ return html`<span class="${h.cls}">${h.text}</span> <span class="muted">${(p.health && p.health.state) || 'closed'}</span>${p.health && p.health.last_error ? html`<br><span class="muted">last error: ${clip(p.health.last_error, 160)}</span>` : ''}`;
++ };
++ const termsCell = (p) => {
++ const b = billingText(p);
++ const a = accessText(p);
++ return html`${b || '—'}${a ? html`<br><span class="muted">${a}</span>` : ''}`;
};
const manageRow = (p) => (canManage && p.kind !== 'stub' && p.kind !== 'whisper' ? html`<tr class="sub"><td colspan="10"><details><summary>Key and models for <code>${p.key}</code></summary>
<form class="inline" method="post" action="/console/providers/${enc(p.key)}/sync">${csrfField(csrf)}<button type="submit">Read its model list</button> <span class="muted">every model it offers, described (what it does, how strong); new ones arrive switched off</span></form>
<form class="inline" method="post" action="/console/providers/${enc(p.key)}/key">${csrfField(csrf)}<label>New API key <input type="password" name="api_key" required minlength="8" autocomplete="off" spellcheck="false"></label><button type="submit">Replace key</button></form>
<form class="inline" method="post" action="/console/providers/${enc(p.key)}/models">${csrfField(csrf)}<label>Model <input type="text" name="model_key" required maxlength="200" placeholder="deepseek-flash" spellcheck="false"></label>
<label><input type="checkbox" name="vision" value="1"> reads pictures</label> <label><input type="checkbox" name="tools" value="1"> tools</label>
--<label>$ in / Mtok <input type="number" name="cost_in" step="0.0001" min="0" class="num"></label> <label>$ out / Mtok <input type="number" name="cost_out" step="0.0001" min="0" class="num"></label>
--<label>$ cache read / Mtok <input type="number" name="cost_cache_read" step="0.0001" min="0" class="num"></label> <label>$ cache write / Mtok <input type="number" name="cost_cache_write" step="0.0001" min="0" class="num"></label>
++<label>Price per million words read (input), in dollars <input type="number" name="cost_in" step="0.0001" min="0" class="num"></label> <label>Price per million words written (output), in dollars <input type="number" name="cost_out" step="0.0001" min="0" class="num"></label>
++<label>Price per million words read from cache, in dollars <input type="number" name="cost_cache_read" step="0.0001" min="0" class="num"></label> <label>Price per million words written to cache, in dollars <input type="number" name="cost_cache_write" step="0.0001" min="0" class="num"></label>
<label>from the price page <input type="url" name="price_source" placeholder="https://…/pricing"></label><button type="submit">Register model</button></form></details></td></tr>` : '');
return layout({
-- title: 'Providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>Providers and models</h1>
--${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a route's page.</span></p>` : ''}
--<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>, or <code>vault:NAME</code> for a key added here, sealed in the AI configuration store and bound to the provider's origin) resolved inside the service; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>
--${table('Providers', ['Provider', 'Kind', 'Status', 'Credentials', 'Circuit', 'Base URL', 'Default model', 'Can do', 'Priority / timeout', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
--<td><code>${p.key}</code><br><span class="muted">${p.display_name}</span></td><td>${p.kind}</td><td>${badge(p.status)}</td>
--<td>${badge(p.credentials)}${p.secret_ref ? html`<br><code>${p.secret_ref}</code>` : ''}${keyCell(p)}<br><span class="muted">${p.auth_mode}</span></td>
--<td>${badge(p.health.state)}${p.health.consecutive_failures ? html`<br><span class="muted">${n(p.health.consecutive_failures)} failure(s) in a row</span>` : ''}${p.health.last_error ? html`<br><span class="muted">${clip(p.health.last_error, 160)}</span>` : ''}</td>
++ title: 'AI providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>AI providers</h1>
++<p class="muted">The companies whose models OpenVibe can use, and the key for each. Add one to make its models available; a provider with no working key is skipped.</p>
++${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a routing rule's page.</span></p>` : ''}
++<p class="muted">A key entered here is locked with AI's own lock key, bound to the provider's address and never shown again (only its last four characters). A key from the server's settings file is named, never shown. When a provider fails, the request goes to the next one in this order.</p>
++<div class="card"><p class="m0">Practice answers when every provider fails: <strong>${stubFallback ? 'on' : 'off'}</strong> <span class="muted">(<code>AI_STUB_FALLBACK</code>)</span>. ${STUB_WORDS}</p></div>
++${table('AI providers, in the order they are tried', ['Provider', 'Kind', 'Status', 'Key', 'Health', 'Who pays · terms', 'Base URL', 'Default model', 'Can do', 'Order · time limit', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
++<td><code>${p.key}</code><br><span class="muted">${p.display_name}</span></td><td>${p.kind}</td><td>${p.status === 'active' ? html`<span class="ok">On</span>` : html`<span class="bad">Off</span>`}</td>
++<td>${keyText(p, keyHints.get(p.key))}</td>
++<td>${healthCell(p)}</td><td>${termsCell(p)}</td>
<td class="mono">${safeUrl(p.base_url)}</td><td>${p.default_model ? html`<code>${p.default_model}</code>` : '—'}</td><td>${(p.capabilities || []).join(', ') || '—'}</td>
--<td>${n(p.priority)} · ${n(p.timeout_ms)} ms<br><span class="muted">${p.origin}, updated ${when(p.updated_at)}</span></td>
++<td>${n(p.priority)} · ${n(Math.round((p.timeout_ms || 0) / 1000))} s<br><span class="muted">${p.origin}, updated ${when(p.updated_at)}</span></td>
${canManage ? html`<td><form class="inline" method="post" action="/console/providers/${enc(p.key)}/status">${csrfField(csrf)}<input type="hidden" name="status" value="${p.status === 'active' ? 'disabled' : 'active'}"><button type="submit" class="${p.status === 'active' ? 'danger' : ''}">${p.status === 'active' ? 'Disable' : 'Enable'}<span class="sr-only"> ${p.key}</span></button></form>
--${p.health.state !== 'closed' || p.health.consecutive_failures ? html` <form class="inline" method="post" action="/console/providers/${enc(p.key)}/reset">${csrfField(csrf)}<button type="submit">Reset circuit<span class="sr-only"> of ${p.key}</span></button></form>` : ''}</td>` : ''}
--</tr>${manageRow(p)}`), 'no providers.')}
--${canManage ? '' : html`<p class="muted">Enabling, disabling and circuit resets need <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}
--${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Strength', 'Context / max output', 'Cost per Mtok (in / out / cached / cache write)', 'Supports'], models.map((m) => html`<tr>
++${p.health.state !== 'closed' || p.health.consecutive_failures ? html` <form class="inline" method="post" action="/console/providers/${enc(p.key)}/reset">${csrfField(csrf)}<button type="submit">Try it again now<span class="sr-only"> (${p.key})</span></button></form>` : ''}</td>` : ''}
++</tr>${manageRow(p)}`), 'no providers yet. Add DeepSeek to start: Add a provider.')}
++${canManage ? '' : html`<p class="muted">Switching providers on and off and trying a paused one again need <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}
++${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Strength', 'Context / longest answer (tokens)', 'Price per million words, in dollars (read / written / read from cache / written to cache)', 'Supports'], models.map((m) => html`<tr>
<td><code>${m.provider_key}</code></td><td><code>${m.model_key}</code>${m.display_name && m.display_name !== m.model_key ? html`<br><span class="muted">${m.display_name}</span>` : ''}</td><td>${m.type}</td>
<td>${badge(m.status)}${m.metadata && m.metadata.missing_since ? html`<br><span class="muted">no longer listed since ${when(m.metadata.missing_since)}</span>` : ''}${canManage ? html`<form class="inline" method="post" action="/console/models/${enc(m.provider_key)}/${enc(m.model_key)}/status">${csrfField(csrf)}<input type="hidden" name="status" value="${m.status === 'active' ? 'disabled' : 'active'}"><button type="submit">${m.status === 'active' ? 'Turn off' : 'Turn on'}<span class="sr-only"> ${m.model_key}</span></button></form>` : ''}</td>
<td>${m.metadata && m.metadata.tier ? html`${m.metadata.tier}${m.metadata.reasoning ? ', thinks first' : ''}${m.metadata.note ? html`<br><span class="muted">${m.metadata.note}</span>` : ''}` : '—'}</td>
@@@ -267,9 +337,9 @@ ${vaultReady ? '' : html`<p class="bad">Keys cannot be stored on this server: th
}
const KINDS = {
-- routes: { one: 'route', title: 'Routes', statuses: ['active', 'disabled'] },
-- templates: { one: 'template', title: 'Templates', statuses: ['draft', 'active', 'deprecated', 'archived'] },
-- workflows: { one: 'workflow', title: 'Workflows', statuses: ['draft', 'active', 'deprecated', 'archived'] },
++ routes: { one: 'routing rule', title: 'Routing rules', statuses: ['active', 'disabled'], line: 'A routing rule says which providers and models may answer a task, and in what order they are tried when one fails. Change one to move a task to a cheaper or stronger model.' },
++ templates: { one: 'prompt template', title: 'Prompt templates', statuses: ['draft', 'active', 'deprecated', 'archived'], line: 'A prompt template is the words sent to the model for a task, with blanks the request fills in. Change one when answers need different instructions.' },
++ workflows: { one: 'saved AI task', title: 'Saved AI tasks', statuses: ['draft', 'active', 'deprecated', 'archived'], line: 'A saved AI task is a named job (summarize a stream, describe a picture) made of steps, each using a prompt template and a routing rule. Services ask for a task by its name.' },
};
const target = (c) => (c ? html`<code>${c.provider}</code>${c.model ? html` / <code>${c.model}</code>` : ''}` : '—');
@@@ -314,6 +384,7 @@ function definitions({ staff, csrf, kind, rows, inUse }) {
}
return layout({
title: k.title, section: kind, staff, csrf, body: html`<h1>${k.title}</h1>
++<p class="muted">${k.line}</p>
<p class="muted">Every edit is a new version and old versions stay readable; runs record the versions they used. ${kind === 'routes'
? 'A route runs its newest version, which must be active.'
: 'Runs use the newest active version ("in use"); a newer draft is not used until it is made active.'} Versions are created through the admin API (<code>POST /api/v1/${kind}/:key/versions</code>).</p>
@@@ -457,27 +528,30 @@ ${output.text != null ? html`<pre>${output.text}</pre>` : html`<div class="card
function quotas({ staff, csrf, counters, all, canManage, notice, error, form = {} }) {
const sel = (name, opts, cur) => html`<select name="${name}" id="q-${name}">${opts.map((o) => html`<option value="${o}"${o === cur ? html` selected` : ''}>${o}</option>`)}</select>`;
++ const labelled = { scope_type: Object.fromEntries(Object.entries(SCOPE_WORDS).map(([k, v]) => [k, `${v.label}: ${v.line}`])), window: { minute: 'Per minute', hour: 'Per hour', day: 'Per day (UTC)' }, status: { active: 'On: requests past it are refused', disabled: 'Off: kept, but nothing is refused' } };
++ const choose = (name, opts, cur) => html`<select name="${name}" id="q-${name}">${opts.map((o) => html`<option value="${o}"${o === cur ? html` selected` : ''}>${(labelled[name] && labelled[name][o]) || o}</option>`)}</select>`;
++ void sel;
return layout({
-- title: 'Quotas', section: 'quotas', staff, csrf, notice, error, body: html`<h1>Quotas</h1>
--<p class="muted">A quota limits requests, tokens and/or cost for one scope in one UTC window, and is checked before any provider call (a refusal is 429 <code>quota.exceeded</code>). Scope <code>*</code> on a service, actor or attribution quota gives each principal its own window.</p>
--${table('Active quotas and their current windows', ['Scope', 'Window', 'Workflow prefix', 'Limit', 'Used this window', 'Window started', 'Resets in'], counters.map((c) => html`<tr>
--<td>${c.quota.scope_type} <code>${c.scope_id}</code></td><td>${c.quota.window}</td><td>${c.quota.workflow_prefix ? html`<code>${c.quota.workflow_prefix}</code>` : '—'}</td>
--<td>${limits(c.quota)}</td><td>${limitsUsed(c)}</td><td>${when(c.window_start)}</td><td>${secs(c.resets_in_seconds)}</td></tr>`), 'no active quota with a single scope.')}
--${table('Every quota', ['#', 'Scope', 'Window', 'Workflow prefix', 'Limit', 'Status', 'Origin', 'Updated'], all.map((q) => html`<tr>
--<td>${q.id}</td><td>${q.scope_type} <code>${q.scope_id}</code></td><td>${q.window}</td><td>${q.workflow_prefix ? html`<code>${q.workflow_prefix}</code>` : '—'}</td>
--<td>${limits(q)}</td><td>${badge(q.status)}</td><td>${q.origin}</td><td>${when(q.updated_at)}</td></tr>`), 'no quotas.')}
--${canManage ? html`<h2>Add or change a quota</h2>
++ title: 'Limits', section: 'quotas', staff, csrf, notice, error, body: html`<h1>Limits</h1>
++<p class="muted">A limit caps how many requests, tokens or dollars one service, person or app may use in a minute, hour or day; it is checked before any provider is paid, and a request past it is refused until the window resets (the API answers 429 <code>quota.exceeded</code>). Change one when a service needs more room or to stop one person or app from spending too much.</p>
++${table('Limits in use and how much of each is used', ['Who it limits', 'Window', 'Only tasks starting with', 'Limit', 'Used this window', 'Window started', 'Resets in'], counters.map((c) => html`<tr>
++<td>${scopeText(c.quota.scope_type, c.scope_id)}</td><td>${WINDOW_WORDS[c.quota.window] || c.quota.window}</td><td>${c.quota.workflow_prefix ? html`<code>${c.quota.workflow_prefix}</code>` : 'every task'}</td>
++<td>${limits(c.quota)}</td><td>${limitsUsed(c)}</td><td>${when(c.window_start)}</td><td>${secs(c.resets_in_seconds)}</td></tr>`), 'no limit is in use for a single service, person or app.')}
++${table('Every limit', ['#', 'Who it limits', 'Window', 'Only tasks starting with', 'Limit', 'Status', 'Set by', 'Updated'], all.map((q) => html`<tr>
++<td>${q.id}</td><td>${scopeText(q.scope_type, q.scope_id)}</td><td>${WINDOW_WORDS[q.window] || q.window}</td><td>${q.workflow_prefix ? html`<code>${q.workflow_prefix}</code>` : 'every task'}</td>
++<td>${limits(q)}</td><td>${q.status === 'active' ? html`<span class="ok">On</span>` : html`<span class="muted">Off</span>`} <span class="muted">${q.status}</span></td><td>${q.origin}</td><td>${when(q.updated_at)}</td></tr>`), 'no limits yet. Add one below to cap a service, a person or an app.')}
++${canManage ? html`<h2>Add or change a limit</h2>
<form class="stack" method="post" action="/console/quotas">${csrfField(csrf)}
--<p class="muted m0">A quota with the same scope, window and workflow prefix is updated in place. Leave a limit empty for none; disable a quota with its status.</p>
--<label for="q-scope_type">Scope type${sel('scope_type', ['global', 'service', 'actor', 'attribution'], form.scope_type || 'service')}</label>
--<label>Scope id (<code>*</code> for global, or each principal)<input type="text" name="scope_id" maxlength="200" value="${form.scope_id || ''}" autocomplete="off" spellcheck="false" placeholder="live"></label>
--<label for="q-window">Window${sel('window', ['minute', 'hour', 'day'], form.window || 'day')}</label>
--<label>Max requests<input type="number" name="max_requests" min="0" step="1" value="${form.max_requests || ''}"></label>
--<label>Max tokens<input type="number" name="max_tokens" min="0" step="1" value="${form.max_tokens || ''}"></label>
--<label>Max cost (USD)<input type="number" name="max_cost_usd" min="0" step="any" value="${form.max_cost_usd || ''}"></label>
--<label>Workflow prefix (optional)<input type="text" name="workflow_prefix" maxlength="120" value="${form.workflow_prefix || ''}" autocomplete="off" spellcheck="false" placeholder="live."></label>
--<label for="q-status">Status${sel('status', ['active', 'disabled'], form.status || 'active')}</label>
--<button class="primary" type="submit">Save quota</button></form>` : html`<p class="muted">Changing quotas needs <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}`,
++<p class="muted m0">A limit for the same who, window and task prefix is updated in place. Leave a number empty for no limit on it; switch a limit off with its status instead of deleting it.</p>
++<label for="q-scope_type">Who it limits${choose('scope_type', ['global', 'service', 'actor', 'attribution'], form.scope_type || 'service')}</label>
++<label>Which one (<code>*</code> for everyone, or each one on its own; a service name such as <code>live</code>, a person such as <code>user:usr_…</code>)<input type="text" name="scope_id" maxlength="200" value="${form.scope_id || ''}" autocomplete="off" spellcheck="false" placeholder="live"></label>
++<label for="q-window">Window${choose('window', ['minute', 'hour', 'day'], form.window || 'day')}</label>
++<label>Most requests in the window<input type="number" name="max_requests" min="0" step="1" value="${form.max_requests || ''}"></label>
++<label>Most tokens in the window (a token is about three quarters of a word)<input type="number" name="max_tokens" min="0" step="1" value="${form.max_tokens || ''}"></label>
++<label>Most dollars in the window<input type="number" name="max_cost_usd" min="0" step="any" value="${form.max_cost_usd || ''}"></label>
++<label>Only saved AI tasks whose name starts with (optional)<input type="text" name="workflow_prefix" maxlength="120" value="${form.workflow_prefix || ''}" autocomplete="off" spellcheck="false" placeholder="live."></label>
++<label for="q-status">Status${choose('status', ['active', 'disabled'], form.status || 'active')}</label>
++<button class="primary" type="submit">Save limit</button></form>` : html`<p class="muted">Changing limits needs <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}`,
});
}
@@@ -613,17 +687,17 @@ function gpus({ staff, csrf, state, canManage, notice, error }) {
const mk = st.marketplaces || {};
return layout({
title: 'OpenVibe GPUs', section: 'gpus', staff, csrf, notice, error, body: html`<h1>OpenVibe GPUs</h1>
--<p class="muted">The rented GPUs: what is running, what it cost this month, and the cap it may never pass. The provisioner is ${st.enabled ? 'on' : 'off'}${st.enabled ? '' : ' (' + st.reason + ')'}: it starts nothing until a marketplace API key and a monthly cap are set in the owner settings.</p>
++<p class="muted">The GPUs AI rents by the second to run open models: what is running, what it cost this month, and the monthly cap it may never pass. Come here to start or stop a model's GPU, or to see why none is running; the keys and the cap are set in the admin panel's Setup tab.</p>
<div class="grid">
-- <div class="card"><div class="k">Status</div><div class="v">${st.enabled ? 'on' : 'off'}</div><div class="muted">${st.reason || 'a key and a cap are set'}</div></div>
++ <div class="card"><div class="k">Renting GPUs</div><div class="v">${st.enabled ? 'On' : 'Off'}</div><div class="muted">${st.enabled ? 'a marketplace key and a monthly cap are set' : html`Off because ${st.reason || 'no marketplace key or monthly cap is set'}. Nothing is rented and nothing is spent until both are set.`}</div></div>
<div class="card"><div class="k">Spent this month</div><div class="v">${usdNano(st.spent_nano)}</div><div class="muted">${st.month}</div></div>
-- <div class="card"><div class="k">Committed</div><div class="v">${usdNano(st.committed_nano)}</div><div class="muted">the worst case: what the running instances may still cost</div></div>
-- <div class="card"><div class="k">Monthly cap</div><div class="v">${st.cap_nano ? usdNano(st.cap_nano) : '—'}</div><div class="muted">never passed</div></div>
++ <div class="card"><div class="k">Could still cost</div><div class="v">${usdNano(st.committed_nano)}</div><div class="muted">the most the GPUs running now may cost before they are given back</div></div>
++ <div class="card"><div class="k">Monthly cap</div><div class="v">${st.cap_nano ? usdNano(st.cap_nano) : 'Not set'}</div><div class="muted">${st.cap_nano ? 'AI never starts a GPU that could pass it' : 'without a cap nothing is rented'}</div></div>
</div>
--<p class="muted">Idle ${n(st.idle_minutes)} minute(s) before an instance scales to zero · max runtime ${n(st.max_runtime_minutes)} minute(s) · warm window ${st.warm_window || 'none'} · region ${st.region} · Vast.ai key ${mk.vast ? 'set' : 'not set'} · RunPod key ${mk.runpod ? 'set' : 'not set'}</p>
--<p class="muted">Vast and RunPod bill per second, so the ledger charges the seconds an instance really ran: the price per second is next to the hourly price. The cold start is measured from the launch to the first answered request.</p>
--${table('Models', ['Model', 'Marketplace', 'Runtime', 'GPU', 'Status', 'Cold start', 'Actions'], models.map((m) => gpuModelRow(m, canManage, csrf)), 'no model is known.')}
--${table('Instances', ['Model', 'Marketplace', 'GPU', 'Price / hour', 'Status', 'Endpoint', 'Started', 'Cold start', 'Reserved', 'Spent'], instances.map((i) => gpuInstanceRow(i)), 'no instance has been rented.')}
++<p class="muted">An unused GPU is given back after ${n(st.idle_minutes)} minute${Number(st.idle_minutes) === 1 ? '' : 's'}; one GPU runs at most ${n(st.max_runtime_minutes)} minutes; ${st.warm_window ? `GPUs stay ready ${st.warm_window} UTC` : 'no hours keep GPUs ready'}; GPUs are rented in ${st.region}. Vast.ai key: ${mk.vast ? 'set' : 'not set'} · RunPod key: ${mk.runpod ? 'set' : 'not set'}.</p>
++<p class="muted">Vast.ai and RunPod bill by the second, so only the seconds a GPU really ran are charged; the price per second is next to the price per hour. "First answer after" is the time from renting a GPU to its first answer.</p>
++${table('Models that can run on a rented GPU', ['Model', 'Marketplace', 'Runs with', 'GPU', 'Status', 'First answer after', 'Actions'], models.map((m) => gpuModelRow(m, canManage, csrf)), 'no model is set up to run on a rented GPU yet.')}
++${table('GPUs rented', ['Model', 'Marketplace', 'GPU', 'Price per hour', 'Status', 'Address', 'Started', 'First answer after', 'Most it may cost', 'Spent'], instances.map((i) => gpuInstanceRow(i)), 'no GPU has been rented.')}
`,
});
}
@@@ -705,11 +779,17 @@ ${table('Audit rows, newest first', ['#', 'At', 'Actor', 'Action', 'Target', 'De
* one was applied by the job and is in the log. The listing the price was read from is on every row, so the
* owner can go and read it before saying yes.
*/
++const PRICE_FIELD_WORDS = {
++ in_per_mtok: 'Price per million words read (input), in dollars',
++ out_per_mtok: 'Price per million words written (output), in dollars',
++ cached_per_mtok: 'Price per million words read from cache, in dollars',
++ cache_write_per_mtok: 'Price per million words written to cache, in dollars',
++};
function priceChangeRow(c, { csrf, canManage, actions = false } = {}) {
const pct = c.change_pct == null ? null : Number(c.change_pct) * 100;
return html`<tr>
<td><code>${c.provider_key}</code> / <code>${c.model_key}</code></td>
-- <td>${c.field.replace(/_per_mtok$/, '')}</td>
++ <td>${PRICE_FIELD_WORDS[c.field] || c.field.replace(/_per_mtok$/, '')}</td>
<td>${c.old_price == null ? html`<span class="muted">no price</span>` : usd(Number(c.old_price))}</td>
<td>${usd(Number(c.new_price))}</td>
<td>${pct == null ? html`<span class="muted">new price</span>` : (pct >= 0 ? '+' : '') + pct.toFixed(1) + ' %'}</td>
@@@ -729,7 +809,7 @@ function pricebook({ staff, csrf, report, state, pending, recent, canManage, not
const row = state || {};
return layout({
title: 'Price book', section: 'pricebook', staff, csrf, notice, error, body: html`<h1>Price book</h1>
--<p class="muted">Every catalogued provider's own price listing, read once a week. A price comes from the provider that sells it and never from a guess; a listing that cannot be read leaves every price alone. A change of more than 20% waits here for the owner; a smaller one the job applies at once.</p>
++<p class="muted">What each model costs, read once a week from the provider's own price page. Come here to approve or reject a big price change: a change of more than 20% waits for you, a smaller one is applied at once, and a page that cannot be read leaves every price as it was.</p>
<div class="grid">
<div class="card"><div class="k">Last run</div><div class="v">${row.last_run_at ? String(row.last_run_at).slice(0, 10) : (st.checked_at || 'never')}</div><div class="muted">${row.last_run_at ? 'finished ' + when(row.last_run_at) : 'the job has not run yet'}</div></div>
<div class="card"><div class="k">Waiting for the owner</div><div class="v">${n((pending || []).length)}</div><div class="muted">a change of more than 20%</div></div>
@@@ -748,6 +828,15 @@ ${table('Applied price changes', ['Model', 'Price', 'Was', 'Now', 'Change', 'Che
});
}
++/** What the words on these pages mean: the same glossary as the admin panel's. */
++function glossary({ staff, csrf }) {
++ return layout({
++ title: 'What words mean', section: 'glossary', staff, csrf, body: html`<h1>What words mean</h1>
++<p class="muted">The few words these pages cannot avoid, each in one line. Everything else on the console is meant to read without them; if something still does not, it is a mistake to fix.</p>
++<div class="card"><dl>${GLOSSARY.map(([word, line]) => html`<dt>${word}</dt><dd>${line}</dd>`)}</dl></div>`,
++ });
++}
++
// ── Person: one run (GET /runs/:id, signed in) ───────────────
/**
* A person's receipt for one run: status, workflow, model, cost, times, and the output or the error.
@@@ -806,4 +895,4 @@ function allowance(cards) {
/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */
const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };
--module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, abuse: abusePage, audit, exchange, gpus, pricebook } };
++module.exports = { CSP, html, esc, safeUrl, statsFormat, healthText, queueText, GLOSSARY, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, abuse: abusePage, audit, exchange, gpus, pricebook, glossary } };
ddiff --git a/server/owner-inputs.js b/server/owner-inputs.js
nnew file mode 100644
iindex 0000000..ecc9d9c
---- /dev/null
++++ b/server/owner-inputs.js
@@@ -0,0 +1,173 @@
++'use strict';
++/**
++ * Owner inputs (the owner-setup design of 2026-11-07): what the admin panel's Setup tab shows for AI's keys, and the
++ * test hook behind its Test button.
++ *
++ * POST /internal/config/:namespace/test/:key (ai.config.manage, the same guard as the config routes)
++ * → 200 { ok, message, state, source, namespace, key, tested_at } (409 config.not_set when nothing is set)
++ * The shape of openvibe-shared 4.125.0's test hook ({ ok, message }, 409 config.not_set) with more beside it:
++ * state is connected (the provider accepted it) or not_working (refused, or no answer); source is where the
++ * value came from: vault (entered in the admin panel) or env (the server's settings file, which the code falls
++ * back to and so is tested too). The value is read on the server, sent only to the provider it belongs to, and
++ * never appears in the answer, a log line or an error: a provider's own message is scrubbed of it and cut to one
++ * short line. It is mounted before the config routes, so a Shared upgrade's own hook never shadows it.
++ *
++ * Every test makes one cheap authenticated read: the provider's model list, the GPU marketplace's account or
++ * instance list. Nothing is created, rented or spent.
++ */
++const { PRESETS } = require('./console/presets');
++
++// The Setup groups by id (openvibe-shared 4.125.0's OWNER_INPUT_GROUPS): x-owner-input.group is one of these ids.
++const SETUP_GROUPS = Object.freeze(['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources',
++ 'design-publishing', 'domains', 'legal']);
++const TEST_TIMEOUT_MS = 10_000;
++
++/** Where to get a provider's key, by the origin of its base URL: the presets' key pages, and a line on what to pick. */
++const KEY_HOW = Object.freeze({
++ deepseek: 'Sign in, open API keys and create one. DeepSeek only answers while the account has a topped-up balance.',
++ openrouter: 'Create a key; give it a credit limit to cap what OpenVibe can spend through it.',
++ groq: 'Create an API key. The free tier is enough to test it; paid limits need billing on the account.',
++ gemini: 'Create an API key in a Google Cloud project; turn billing on in that project for paid limits.',
++ mistral: 'Create an API key in a workspace on a paid plan (the free Experiment plan works for testing).',
++ together: 'Open Settings, API keys, and create a key for the account that pays for it.',
++ fireworks: 'Open Account, API keys, and create a key.',
++ cerebras: 'Sign in, open API Keys and create one.',
++ xai: 'Open API Keys in the xAI console and create a key that may use chat models.',
++ openai: 'Create a secret key for the project that pays for it. A restricted key needs Models read and Model capabilities write.',
++ anthropic: 'Create a key in the workspace that pays for it (Settings, API keys).',
++ 'deepseek-anthropic': 'Sign in, open API keys and create one. DeepSeek only answers while the account has a topped-up balance.',
++ 'minimax-anthropic': 'Open Account, Interface key, and create an API key for the pay-as-you-go plan.',
++});
++
++function originOf(url) {
++ try { const u = new URL(String(url || '')); return u.protocol === 'https:' || u.protocol === 'http:' ? u.origin : ''; } catch { return ''; }
++}
++
++/** { get_it_at, get_it_how } for a provider record ({ key, base_url }): its preset's key page, else its own site. */
++function keyPage(provider) {
++ const origin = originOf(provider && provider.base_url);
++ const preset = PRESETS.find((p) => p.keys && p.base_url && (p.id === (provider && provider.key) || (origin && originOf(p.base_url) === origin && p.base_url.replace(/\/+$/, '') === String(provider.base_url || '').replace(/\/+$/, ''))))
++ || PRESETS.find((p) => p.keys && p.base_url && origin && originOf(p.base_url) === origin);
++ if (preset) return { get_it_at: preset.keys, get_it_how: KEY_HOW[preset.id] || 'Sign in at the provider, open its API keys page and create a key.' };
++ const site = origin && origin.startsWith('https:') ? origin.replace(/\/\/api\./, '//') : null;
++ return { get_it_at: site, get_it_how: 'Sign in at the provider, open its API keys page and create a key for the account that pays.' };
++}
++
++/** One line, at most 200 characters, with every given secret replaced. */
++function oneLine(text, secrets = []) {
++ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
++ for (const v of secrets) {
++ if (typeof v !== 'string' || v.length < 4) continue;
++ s = s.split(v).join('[hidden]');
++ if (v.length >= 12) s = s.split(v.slice(0, 12)).join('[hidden]');
++ }
++ return s.length > 200 ? `${s.slice(0, 199)}…` : s;
++}
++
++/** A fetch with a time limit; never throws, answers { status, json, error }. */
++async function probe(fetchImpl, url, { headers = {}, method = 'GET', timeoutMs = TEST_TIMEOUT_MS } = {}) {
++ let res;
++ try {
++ res = await fetchImpl(url, { method, headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(timeoutMs) });
++ } catch (err) {
++ return { status: 0, json: null, error: err && err.name === 'TimeoutError' ? 'it did not answer within 10 seconds' : `it could not be reached (${(err && (err.code || err.message)) || 'network error'})` };
++ }
++ const text = await res.text().catch(() => '');
++ let json = null;
++ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
++ return { status: res.status, json, error: null };
++}
++
++/** The provider's own refusal text, if it sent one. */
++function providerSays(json) {
++ if (!json || typeof json !== 'object') return '';
++ const e = json.error;
++ if (typeof e === 'string') return e;
++ if (e && typeof e.message === 'string') return e.message;
++ if (typeof json.message === 'string') return json.message;
++ if (typeof json.detail === 'string') return json.detail;
++ return '';
++}
++
++function refused(name, r) {
++ if (r.error) return `${name} ${r.error}.`;
++ const says = providerSays(r.json);
++ if (r.status === 401 || r.status === 403) return `${name} refused the key (${r.status})${says ? `: ${says}` : '.'}`;
++ if (r.status === 402) return `${name} accepted the key but says the account has no balance (402)${says ? `: ${says}` : '.'}`;
++ if (r.status === 429) return `${name} is limiting requests right now (429); try again in a minute.`;
++ return `${name} answered ${r.status}${says ? `: ${says}` : '.'}`;
++}
++
++/**
++ * Test one provider key: the provider's model list, which every OpenAI-compatible API, Anthropic and Gemini serve
++ * for free. provider is the registry record; key the value to test.
++ */
++async function testProviderKey(provider, key, { fetchImpl }) {
++ const name = provider.display_name || provider.key;
++ const base = String(provider.base_url || '').replace(/\/+$/, '');
++ if (!base) return { ok: false, message: `${name} has no base URL, so there is nothing to send the key to.` };
++ let url = `${base}/models`;
++ let headers = { Authorization: `Bearer ${key}` };
++ if (provider.kind === 'anthropic') {
++ headers = /anthropic\.com/i.test(base) ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : { 'x-api-key': key, Authorization: `Bearer ${key}`, 'anthropic-version': '2023-06-01' };
++ if (!/\/v1$/.test(base) && /anthropic\.com/i.test(base)) url = `${base}/v1/models`;
++ } else if (provider.kind === 'gemini') {
++ url = `${base}/models?pageSize=1`;
++ headers = { 'x-goog-api-key': key };
++ }
++ const r = await probe(fetchImpl, url, { headers });
++ if (r.status >= 200 && r.status < 300) {
++ const list = r.json && (Array.isArray(r.json.data) ? r.json.data : Array.isArray(r.json.models) ? r.json.models : null);
++ return { ok: true, message: `${name} accepted the key${list ? ` and lists ${list.length} model${list.length === 1 ? '' : 's'}` : ''}.` };
++ }
++ return { ok: false, message: refused(name, r) };
++}
++
++/** Test a GPU marketplace key: Vast.ai's current user, RunPod's pod list (both read-only, nothing is rented). */
++async function testGpuKey(marketplace, key, { fetchImpl, origin }) {
++ if (marketplace === 'vast') {
++ const r = await probe(fetchImpl, `${origin || 'https://console.vast.ai'}/api/v0/users/current/`, { headers: { Authorization: `Bearer ${key}` } });
++ if (r.status >= 200 && r.status < 300) {
++ const credit = r.json && Number.isFinite(Number(r.json.credit)) ? ` The account has $${Number(r.json.credit).toFixed(2)} of credit.` : '';
++ return { ok: true, message: `Vast.ai accepted the key.${credit}` };
++ }
++ return { ok: false, message: refused('Vast.ai', r) };
++ }
++ const r = await probe(fetchImpl, `${origin || 'https://api.runpod.io'}/v2/pods`, { headers: { Authorization: `Bearer ${key}` } });
++ if (r.status >= 200 && r.status < 300) return { ok: true, message: 'RunPod accepted the key.' };
++ return { ok: false, message: refused('RunPod', r) };
++}
++
++/**
++ * The test route. `resolve(namespace, key)` answers { value, source } (or null when the namespace/key has no test),
++ * `tests[namespace](key, value, ctx)` runs the check. Mounted next to adminRoutes, behind the same guard.
++ */
++function testRoute({ guard, basePath = '/internal/config', resolve, run, log = console, now = () => Date.now() }) {
++ const re = new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}/([^/]+)/test/([^/]+)$`);
++ return function handle(req, res, next) {
++ if (String(req.method || '').toUpperCase() !== 'POST') return next();
++ const m = re.exec(String(req.path || req.url || '').split('?')[0]);
++ if (!m) return next();
++ guard(req, res, async (err) => {
++ if (err) return next(err);
++ let namespace = m[1];
++ let key = m[2];
++ try { namespace = decodeURIComponent(namespace); key = decodeURIComponent(key); } catch { /* keep them as they are */ }
++ const found = await Promise.resolve(resolve(namespace, key)).catch(() => null);
++ if (!found) return res.status(404).json({ type: 'about:blank', status: 404, code: 'config.test_not_found', detail: `${namespace} has no test for ${key}` });
++ const tested_at = new Date(now()).toISOString();
++ const base = { namespace, key, source: found.source || null, tested_at };
++ // As openvibe-shared 4.125.0's hook: nothing to test is 409 config.not_set (neither entered nor in the settings file).
++ if (!found.value) return res.status(409).type('application/problem+json').json({ type: 'about:blank', status: 409, code: 'config.not_set', detail: `${key} is not set, so there is nothing to test` });
++ let out;
++ try { out = await run(namespace, key, found.value, found); } catch (e) { out = { ok: false, message: `The test could not run: ${(e && e.message) || 'unknown error'}` }; }
++ const ok = Boolean(out && out.ok);
++ const from = found.source === 'env' && found.env ? ` (the value from the server's settings file, ${found.env})` : '';
++ const message = oneLine(`${(out && out.message) || (ok ? 'It works.' : 'It did not work.')}${from}`, [found.value]);
++ (log.info || log.log).call(log, `[config] test ${namespace} ${key}: ${ok ? 'connected' : 'not working'}`);
++ return res.json({ ...base, ok, state: ok ? 'connected' : 'not_working', message });
++ });
++ };
++}
++
++module.exports = { SETUP_GROUPS, KEY_HOW, keyPage, oneLine, probe, testProviderKey, testGpuKey, testRoute };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 0174fac..83ce0a8 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -26,7 +26,7 @@
*/
const placement = require('openvibe-sdk/placement');
const contracts = require('openvibe-contracts');
--const { AiError, resolveSecret, sha256 } = require('../util');
++const { AiError, providerSecret, sha256 } = require('../util');
const { retryable, sleep, ProviderError, schemaInstruction, insufficientBalance } = require('./common');
const { sourcesOf, ceilingOf, allowsProvider, allowsCommunity } = require('../free-tier');
const { createStubProvider } = require('./stub');
@@@ -250,7 +250,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }
function build(p) {
-- const apiKey = resolveSecret(p.secret_ref, env);
++ const apiKey = providerSecret(p, env);
switch (p.kind) {
case 'stub': return createStubProvider(p, { stats: statFor(p.key) });
case 'openai': return createOpenAiProvider(p, { apiKey, fetchImpl });
@@@ -266,7 +266,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function adapter(key) {
const p = await registry.getProvider(key);
if (!p) return null;
-- const stamp = `${p.updated_at}|${p.secret_ref}|${resolveSecret(p.secret_ref, env) ? 1 : 0}`;
++ const stamp = `${p.updated_at}|${p.secret_ref}|${providerSecret(p, env) ? 1 : 0}`;
const hit = await cache.get(key);
if (hit && hit.stamp === stamp) return { record: p, adapter: hit.adapter };
const a = build(p);
@@@ -456,7 +456,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// request, and the adult tier has no other offer to run on.
if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
seen.add(id);
-- const creds = p.auth_mode === 'none' || credentialNotRequired(p) || Boolean(resolveSecret(p.secret_ref, env));
++ const creds = p.auth_mode === 'none' || credentialNotRequired(p) || Boolean(providerSecret(p, env));
const h = await health(p.key);
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const errorRate = st.requests ? st.errors / st.requests : 0;
@@@ -746,7 +746,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function skipReason(p, a, features) {
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
-- if (p.auth_mode !== 'none' && !credentialNotRequired(p) && !resolveSecret(p.secret_ref, env)) return 'no_credentials';
++ if (p.auth_mode !== 'none' && !credentialNotRequired(p) && !providerSecret(p, env)) return 'no_credentials';
if (features.some(f => !a.supports(f))) return 'unsupported';
const h = await health(p.key);
if (h.state === 'open') return h.exhausted_at ? 'exhausted' : 'circuit_open';
ddiff --git a/server/registry.js b/server/registry.js
iindex cadc80b..edb3c43 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -9,7 +9,7 @@
* 'active'. Lifecycle changes (deprecate, archive, disable) are status updates on one version,
* audited, and never change its content.
*/
--const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = require('./util');
++const { sha256, parseJson, iso, AiError, secretRefValid, providerSecret } = require('./util');
const schemas = require('./schemas');
const { provenance } = require('./providers/rate-cards');
@@@ -61,7 +61,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
/** Public view: the secret reference name and whether it resolves — never the value. */
function publicProvider(p, health) {
if (!p) return null;
-- const hasSecret = p.auth_mode === 'none' ? null : Boolean(resolveSecret(p.secret_ref, env));
++ const hasSecret = p.auth_mode === 'none' ? null : Boolean(providerSecret(p, env));
return { ...p, credentials: hasSecret === null ? 'not_required' : hasSecret ? 'configured' : 'missing', health: health || null };
}
ddiff --git a/server/settings.js b/server/settings.js
iindex 2735f08..36503db 100644
---- a/server/settings.js
++++ b/server/settings.js
@@@ -17,6 +17,7 @@
*/
const { createConfigStore, ConfigError } = require('openvibe-shared/config');
const { AiError } = require('./util');
++const { keyPage } = require('./owner-inputs');
const EXCHANGE_NAMESPACE = 'ai.exchange';
const PROVIDER_KEYS_NAMESPACE = 'ai.provider_keys';
@@@ -31,64 +32,69 @@ const GPU_GROUP = 'OpenVibe GPUs';
// namespace, so the owner's key is sealed to the marketplace it was entered for.
const GPU_ORIGINS = Object.freeze({ vast: 'https://console.vast.ai', runpod: 'https://api.runpod.io' });
--/** Bounds from server/config.js (load()), so the schema and the code agree. */
++/**
++ * Bounds from server/config.js (load()), so the schema and the code agree. Every field has a title in plain words and
++ * a description that says what it does, what happens at the default and what more or less means (the owner-setup
++ * design's clarity rules); the routing-evidence tuning is x-advanced, folded under "Advanced settings".
++ */
const EXCHANGE_SCHEMA = Object.freeze({
$schema: 'https://json-schema.org/draft/2020-12/schema',
-- title: 'AI Exchange',
++ title: 'What the Exchange offers',
++ description: 'Which models people can pick on openvibe.exchange, whether paid AI spends Credits, and how AI chooses between the models of a route. Change these when you open the Exchange, start charging, or want AI to learn which model works best.',
type: 'object',
additionalProperties: false,
properties: {
model_catalog: {
-- type: 'boolean', title: 'Model catalogue', 'x-group': GROUP, 'x-env': 'AI_MODEL_CATALOG',
-- description: 'Serve the truthful model catalogue: exact ids, dynamic aliases and the offers each route can use. Off means today\'s single `openvibe` model.',
++ type: 'boolean', title: 'List every model by name', 'x-group': GROUP, 'x-env': 'AI_MODEL_CATALOG',
++ description: 'When on, the Exchange and the API list every model by its exact name, with the providers that can serve it. Off (the default) shows a single model called "openvibe" that picks one for the person.',
},
credits: {
-- type: 'boolean', title: 'Credits', 'x-group': GROUP, 'x-env': 'AI_CREDITS', 'x-danger': true,
-- description: 'Paid AI requests hold and spend Credits',
++ type: 'boolean', title: 'Charge Credits for paid AI', 'x-group': GROUP, 'x-env': 'AI_CREDITS', 'x-danger': true,
++ description: 'When on, a paid AI request first holds Credits from the person\'s balance and then spends what it used. Off (the default) charges nothing. Turn it on only after Billing\'s Credits are open; turning it off again stops charging at once.',
},
routing_evidence: {
-- type: 'boolean', title: 'Routing evidence', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE',
-- description: 'Rank alias routes by measured cost per success instead of their recorded order.',
++ type: 'boolean', title: 'Pick models by measured results', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE',
++ description: 'When on, AI orders the models of a route by what each one really cost per good answer, measured from recent requests. Off (the default) keeps the order the route was written in.',
},
alpha: {
-- type: 'number', title: 'Evidence EWMA alpha', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_ALPHA',
-- minimum: 0.01, maximum: 1,
-- description: 'How fast a new measurement moves the per-deployment EWMA (0.01 keeps the most history, 1 trusts the last call).',
++ type: 'number', title: 'How fast results change the order', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_ALPHA', 'x-advanced': true,
++ minimum: 0.01, maximum: 1, 'x-example': 0.2,
++ description: 'How much one new request moves a model\'s measured score, from 0.01 to 1 (default 0.2). Higher follows the last few requests; lower remembers a longer history.',
},
min_samples: {
-- type: 'integer', title: 'Minimum samples', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_SAMPLES',
-- minimum: 1,
-- description: 'Below this many attempts evidence is sparse: it is reported, never trusted to reorder a route.',
++ type: 'integer', title: 'Requests before results count', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_SAMPLES', 'x-advanced': true,
++ minimum: 1, 'x-example': 20,
++ description: 'A model needs at least this many measured requests before its results may change the order (default 20). Higher waits for more proof; lower trusts results sooner.',
},
success_floor: {
-- type: 'number', title: 'Success floor', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_SUCCESS_FLOOR',
-- minimum: 0.01, maximum: 1,
-- description: 'A candidate below this success probability is never promoted by evidence.',
++ type: 'number', title: 'Lowest success rate to move up', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_SUCCESS_FLOOR', 'x-advanced': true,
++ minimum: 0.01, maximum: 1, 'x-example': 0.1,
++ description: 'A model that answers well less often than this share of the time (0.1 is one in ten, the default) is never moved up, however cheap it is.',
},
hysteresis_decisions: {
-- type: 'integer', title: 'Hysteresis decisions', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_DECISIONS',
-- minimum: 1,
-- description: 'Consecutive decisions a challenger must win before it replaces the incumbent.',
++ type: 'integer', title: 'Wins needed to take first place', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_DECISIONS', 'x-advanced': true,
++ minimum: 1, 'x-example': 3,
++ description: 'How many choices in a row a challenger must win before it replaces the model in first place (default 3). Higher keeps the order steady; lower switches sooner.',
},
exploration_share: {
-- type: 'number', title: 'Exploration share', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_EXPLORE',
-- minimum: 0, maximum: 1,
-- description: 'The share of decisions that explore a challenger; 0 never leaves the incumbent.',
++ type: 'number', title: 'Share of requests that try another model', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_EXPLORE', 'x-advanced': true,
++ minimum: 0, maximum: 1, 'x-example': 0.05,
++ description: 'The share of requests sent to a challenger to measure it, from 0 to 1 (default 0: never). 0.05 tries another model on one request in twenty.',
},
affinity_gain: {
-- type: 'number', title: 'Affinity gain', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_AFFINITY',
-- minimum: 0, maximum: 1,
-- description: 'How much a deployment\'s familiarity with a prompt family adds to its measured score.',
++ type: 'number', title: 'Bonus for a model that knows the task', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_AFFINITY', 'x-advanced': true,
++ minimum: 0, maximum: 1, 'x-example': 0.05,
++ description: 'How much a model\'s past success on the same kind of prompt adds to its score, from 0 to 1 (default 0.05). Higher keeps a task on the model that did it well.',
},
min_gain: {
-- type: 'number', title: 'Minimum gain', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_GAIN',
-- minimum: 0, maximum: 1,
-- description: 'A challenger replaces the incumbent only when it scores at least this much better.',
++ type: 'number', title: 'How much better a challenger must be', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_MIN_GAIN', 'x-advanced': true,
++ minimum: 0, maximum: 1, 'x-example': 0.15,
++ description: 'A challenger replaces the first model only when it scores at least this much better, from 0 to 1 (default 0.15, fifteen percent). Higher switches less often.',
},
expected_output_tokens: {
-- type: 'integer', title: 'Expected output tokens', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_OUTPUT_TOKENS',
-- minimum: 1,
-- description: 'The output-token estimate used to price a candidate before it answers.',
++ type: 'integer', title: 'Expected answer length (tokens)', 'x-group': GROUP, 'x-env': 'AI_ROUTING_EVIDENCE_OUTPUT_TOKENS', 'x-advanced': true,
++ minimum: 1, 'x-example': 512,
++ description: 'The answer length, in tokens (about three quarters of a word each), used to price a model before it answers (default 512). Raise it when most answers are long.',
},
},
});
@@@ -169,90 +175,130 @@ function originOf(baseUrl) {
const GPU_SCHEMA = Object.freeze({
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'OpenVibe GPUs',
++ description: 'The GPUs AI rents by the second to run open models, the key of the marketplace it rents from and the most it may spend in a month. Change these to start renting, to move to another marketplace or to raise or lower the cap.',
type: 'object',
additionalProperties: false,
properties: {
'vast.origin': {
-- type: 'string', title: 'Vast.ai origin', 'x-group': GPU_GROUP,
-- description: 'The origin of the Vast.ai API. AI writes it; the key below is sealed to it.',
++ type: 'string', title: 'Address the Vast.ai key is locked to', 'x-group': GPU_GROUP, 'x-advanced': true,
++ description: 'The Vast.ai address AI sends the key to. AI writes it itself; the key below only opens for this address, so it can never be sent anywhere else.',
},
'vast.key': {
type: 'string', title: 'Vast.ai API key', 'x-group': GPU_GROUP, 'x-bind': 'vast.origin',
-- description: "The owner's write-only Vast.ai API key, sealed to the origin above.",
++ description: 'The key AI rents Vast.ai GPUs with. Without it (and without a RunPod key) no GPU is rented. It is locked after saving and never shown again, only its last four characters.',
++ 'x-example': 'a1b2c3…',
'x-owner-input': {
-- group: 'GPUs',
-- unlocks: 'Lets OpenVibe rent a GPU on Vast.ai for OpenVibe GPUs and for a person’s own endpoint. Without it Vast.ai is never searched and nothing is rented there.',
-- get_it_at: 'https://console.vast.ai/account/',
-- get_it_how: 'Open Account → API Keys, create a key, and paste it here. It is stored locked and never shown again.',
-- test: false,
++ group: 'gpus',
++ unlocks: 'Lets AI rent Vast.ai GPUs to run open models when they are needed, and for a person’s own endpoint. Without a GPU key and a monthly cap nothing is rented.',
++ get_it_at: 'https://cloud.vast.ai/manage-keys/',
++ get_it_how: 'Create an API key with the Instance read and write permissions, for the account whose credit pays for the GPUs.',
++ test: true,
},
-- 'x-required-for': 'own endpoints',
++ 'x-required-for': 'Renting GPUs and own endpoints',
},
'runpod.origin': {
-- type: 'string', title: 'RunPod origin', 'x-group': GPU_GROUP,
-- description: 'The origin of the RunPod API. AI writes it; the key below is sealed to it.',
++ type: 'string', title: 'Address the RunPod key is locked to', 'x-group': GPU_GROUP, 'x-advanced': true,
++ description: 'The RunPod address AI sends the key to. AI writes it itself; the key below only opens for this address, so it can never be sent anywhere else.',
},
'runpod.key': {
type: 'string', title: 'RunPod API key', 'x-group': GPU_GROUP, 'x-bind': 'runpod.origin',
-- description: "The owner's write-only RunPod API key, sealed to the origin above.",
++ description: 'The key AI rents RunPod GPUs with. Without it (and without a Vast.ai key) no GPU is rented. It is locked after saving and never shown again, only its last four characters.',
++ 'x-example': 'rpa_…',
'x-owner-input': {
-- group: 'GPUs',
-- unlocks: 'Lets OpenVibe rent a GPU on RunPod when RunPod is cheaper or Vast.ai has no fit. Without it RunPod is never searched and nothing is rented there.',
++ group: 'gpus',
++ unlocks: 'Lets AI rent RunPod GPUs to run open models when they are needed, and for a person’s own endpoint. Without a GPU key and a monthly cap nothing is rented.',
get_it_at: 'https://www.runpod.io/console/user/settings',
-- get_it_how: 'Open Settings → API Keys, create a key, and paste it here. It is stored locked and never shown again.',
-- test: false,
++ get_it_how: 'Under API Keys create a key with Read/Write access, for the account whose balance pays for the GPUs.',
++ test: true,
},
-- 'x-required-for': 'own endpoints',
++ 'x-required-for': 'Renting GPUs and own endpoints',
},
monthly_cap: {
-- type: 'integer', title: 'Monthly GPU cap', 'x-group': GPU_GROUP, 'x-unit': 'nano_usd', 'x-danger': true,
-- minimum: 0,
-- description: 'The hard monthly cap for the rented GPUs, in nano-USD. The provisioner never spends past it: it refuses to start an instance and says why. Without a cap the provisioner stays off.',
++ type: 'integer', title: 'Most the GPUs may cost in a month', 'x-group': GPU_GROUP, 'x-unit': 'nano_usd', 'x-danger': true,
++ minimum: 0, 'x-example': 50000000000,
++ description: 'The most the rented GPUs may cost in one calendar month (in billionths of a dollar: 50000000000 is $50). AI never passes it: it refuses to start a GPU and says why. Unset, nothing is rented.',
'x-owner-input': {
-- group: 'GPUs',
-- unlocks: 'Sets the most the rented GPUs may cost you in a month. A new instance (including a person’s own endpoint) is refused before it starts when its time would pass this cap. Without it nothing is rented.',
-- get_it_at: 'https://openvibe.network/admin#ai',
-- get_it_how: 'Decide the most you are willing to spend on rented GPUs in a month, and enter it here. Lower is safer; higher lets more models and endpoints run.',
++ group: 'gpus',
++ unlocks: 'Lets AI rent GPUs up to this amount each month. Without a cap nothing is rented, even with a key.',
++ get_it_at: 'https://ai.openvibe.services/console/gpus',
++ get_it_how: 'Pick the most you are happy to spend on GPUs in a month; the GPU page shows what this month has cost so far.',
test: false,
},
-- 'x-required-for': 'own endpoints',
++ 'x-required-for': 'Renting GPUs and own endpoints',
},
idle_minutes: {
-- type: 'integer', title: 'Idle minutes', 'x-group': GPU_GROUP,
-- minimum: 1, maximum: 1440,
-- description: 'How long an instance serves nobody before it scales to zero (the default is 10).',
++ type: 'integer', title: 'Minutes a GPU may sit unused (minutes)', 'x-group': GPU_GROUP,
++ minimum: 1, maximum: 1440, 'x-example': 10,
++ description: 'How many minutes a rented GPU may serve nobody before it is given back (default 10). Higher answers the next request faster; lower costs less.',
},
max_runtime_minutes: {
-- type: 'integer', title: 'Max runtime (minutes)', 'x-group': GPU_GROUP,
-- minimum: 1, maximum: 10080,
-- description: 'How long one instance may run at most. When the time is up the instance is given back, so the money never runs past the cap (the default is 720).',
++ type: 'integer', title: 'Longest one GPU may run (minutes)', 'x-group': GPU_GROUP,
++ minimum: 1, maximum: 10080, 'x-example': 720,
++ description: 'How many minutes one rented GPU may run at most before it is given back, busy or not (default 720, twelve hours). Lower limits what one GPU can cost.',
},
warm_window: {
-- type: 'string', title: 'Warm window (UTC)', 'x-group': GPU_GROUP,
-- pattern: '^([0-2][0-9]):[0-5][0-9]-([0-2][0-9]):[0-5][0-9]$',
-- description: 'An optional window in which idle instances stay up, as "19:00-23:00" (UTC). Empty means no warm window.',
++ type: 'string', title: 'Hours to keep GPUs ready (UTC)', 'x-group': GPU_GROUP,
++ pattern: '^([0-2][0-9]):[0-5][0-9]-([0-2][0-9]):[0-5][0-9]$', 'x-example': '19:00-23:00',
++ description: 'A daily window, in UTC, when an unused GPU stays rented so the first answer is fast, written like 19:00-23:00. Empty (the default) gives every unused GPU back after the idle minutes.',
},
region: {
-- type: 'string', title: 'Region', 'x-group': GPU_GROUP,
-- description: 'The region the marketplace offers must be in (the default is US).',
++ type: 'string', title: 'Region the GPUs must be in', 'x-group': GPU_GROUP, 'x-example': 'US',
++ description: 'The region a rented GPU must be in, as the marketplace names it (default US). Another region may be cheaper but answers people here more slowly.',
},
},
});
--/** The namespace for the providers there are today: <p>.origin (internal) and <p>.key (secret, bound to it). */
--function providerKeysSchema(providers) {
++/** Providers that never take a key: the stub, whisper.cpp and the local model servers (auth_mode none). */
++const keyless = (p) => p.kind === 'stub' || p.kind === 'whisper' || p.auth_mode === 'none';
++
++/**
++ * The namespace for the providers there are today: <p>.origin (internal, advanced) and <p>.key (secret, bound to it).
++ * `providers` are registry records (a bare key is accepted too). A provider that takes a key is an owner input in the
++ * Setup tab's "AI providers" group, with its key page and a test (its model list); the shared provider's key is also
++ * the image model's key, because images run on the shared provider's Images API (AI_IMAGE_MODEL).
++ */
++function providerKeysSchema(providers, { imageModel = '' } = {}) {
const properties = {};
-- for (const key of providers) {
++ for (const raw of providers) {
++ const p = typeof raw === 'string' ? { key: raw } : raw;
++ const key = p.key;
++ const name = p.display_name || key;
properties[`${key}.origin`] = {
-- type: 'string', title: `${key} origin`, 'x-group': PROVIDER_GROUP,
-- description: `The origin of ${key}'s base URL. AI writes it; the key below is sealed to it.`,
++ type: 'string', title: `Address the ${name} key is locked to`, 'x-group': PROVIDER_GROUP, 'x-advanced': true,
++ description: `The address of ${name}'s API that its key is sent to. AI writes it from the provider's base URL; the key only opens for this address, so it can never be sent anywhere else.`,
};
-- properties[`${key}.key`] = {
-- type: 'string', title: `${key} key`, 'x-group': PROVIDER_GROUP, 'x-bind': `${key}.origin`,
-- description: `The owner's write-only API key for ${key}, sealed to its origin; changing the base URL locks it until it is entered again.`,
++ const field = {
++ type: 'string', title: `${name} API key`, 'x-group': PROVIDER_GROUP, 'x-bind': `${key}.origin`,
++ description: `The key AI uses to call ${name}. It is locked after saving and never shown again, only its last four characters; changing the provider's base URL locks it until it is entered again.`,
};
++ if (keyless(p)) {
++ field.title = `${name} key (not used)`;
++ field.description = `${name} runs without a key, so nothing needs to be entered here. A key saved here would be sent only to the address above.`;
++ field['x-advanced'] = true;
++ } else {
++ const page = keyPage(p);
++ const images = key === 'shared' && imageModel;
++ field['x-owner-input'] = {
++ group: 'ai-providers',
++ unlocks: images
++ ? `Lets AI answer with ${name}'s models and make pictures with ${imageModel}. Without a working key ${name} is skipped and image generation stays off.`
++ : key === 'shared' && p.kind !== 'anthropic'
++ ? `Lets AI answer with ${name}'s models; it is also the image model's key once an image model is chosen (AI_IMAGE_MODEL). Without a working key ${name} is skipped.`
++ : `Lets AI answer with ${name}'s models. Without a working key ${name} is skipped and its requests go to the next provider.`,
++ ...(page.get_it_at ? { get_it_at: page.get_it_at } : {}),
++ get_it_how: page.get_it_how,
++ test: true,
++ };
++ if (images) field['x-required-for'] = 'Image generation';
++ }
++ properties[`${key}.key`] = field;
}
-- return { $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'AI provider keys', type: 'object', additionalProperties: false, properties };
++ return {
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'AI providers',
++ description: 'The companies whose models OpenVibe can use, and the key for each. Add or replace a key when you sign up with a provider or rotate a key; a provider with no working key is skipped.',
++ type: 'object', additionalProperties: false, properties,
++ };
}
const classify = (key) => (String(key).endsWith('.key') ? 'secret' : 'internal');
@@@ -261,7 +307,7 @@ const classify = (key) => (String(key).endsWith('.key') ? 'secret' : 'internal')
* The ai.provider_keys store, with the schema rebuilt when a provider is added. `store` is a Proxy so the admin
* routes, mounted once, keep using the current store; the vault cache is refreshed in onActivate.
*/
--function createProviderKeys({ db, registry, env = process.env, log = console, onChange }) {
++function createProviderKeys({ db, registry, env = process.env, log = console, onChange, imageModel = '' }) {
const vault = require('./vault');
let store = null;
let names = [];
@@@ -287,8 +333,9 @@ function createProviderKeys({ db, registry, env = process.env, log = console, on
const providers = await registry.listProviders();
const next = [...new Set(providers.map((p) => p.key))].sort();
if (store && next.length === names.length && next.every((k, i) => k === names[i])) return store;
++ const records = next.map((k) => providers.find((p) => p.key === k));
store = await createConfigStore({
-- db, service: SERVICE, namespace: PROVIDER_KEYS_NAMESPACE, schema: providerKeysSchema(next),
++ db, service: SERVICE, namespace: PROVIDER_KEYS_NAMESPACE, schema: providerKeysSchema(records, { imageModel }),
classify, env, log, onChange, onActivate: activate,
});
names = next;
@@@ -438,7 +485,7 @@ async function createSettings({ db, config, registry, clock = { now: () => Date.
},
});
applyExchange(config, exchange.get());
-- const providerKeys = createProviderKeys({ db, registry, env, log, onChange });
++ const providerKeys = createProviderKeys({ db, registry, env, log, onChange, imageModel: (config.image && config.image.model) || '' });
await providerKeys.refresh();
const batches = await createBatchesSettings({ db, env, log, onChange });
applyBatches(config, batches.values());
ddiff --git a/server/util.js b/server/util.js
iindex 868dc3b..3585350 100644
---- a/server/util.js
++++ b/server/util.js
@@@ -55,6 +55,16 @@ function resolveSecret(ref, env = process.env) {
return env[m[1]] ? String(env[m[1]]) : '';
}
++/**
++ * A provider's key: the owner's key in the provider-key store when one is set (the admin panel's Setup tab or the
++ * console), else what its secret_ref names (env:NAME, the server's settings file, stays the fallback).
++ */
++function providerSecret(p, env = process.env) {
++ if (!p) return '';
++ const own = p.key ? require('./vault').get(p.key) : '';
++ return own || resolveSecret(p.secret_ref, env);
++}
++
function secretRefValid(ref) {
return ref == null || ref === '' || /^env:[A-Z][A-Z0-9_]{0,63}$/.test(String(ref)) || /^vault:[a-z0-9][a-z0-9._-]{0,63}$/.test(String(ref));
}
@@@ -77,4 +87,4 @@ function parseJsonLoose(text) {
} catch { return null; }
}
--module.exports = { stableStringify, sha256, parseJson, iso, AiError, sendError, resolveSecret, secretRefValid, parseJsonLoose };
++module.exports = { stableStringify, sha256, parseJson, iso, AiError, sendError, resolveSecret, providerSecret, secretRefValid, parseJsonLoose };
ddiff --git a/test/console.test.js b/test/console.test.js
iindex d6b6899..608b7a6 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -301,7 +301,7 @@ t.test('every page: server-rendered, no script, noindex, no-store, strict CSP, c
`/console/runs/${runIds.failLive}`, `/console/runs/${runIds.ok}`, '/console/quotas', '/console/usage', '/console/cache', '/console/audit', '/console/audit?kind=all'];
for (const p of list) assertPage(await get(p, cookies.admin), p);
const prov = await get('/console/providers', cookies.admin);
-- assert.ok(prov.text.includes('<code>env:AI_API_KEY</code>'), 'the secret reference name is shown');
++ assert.ok(prov.text.includes('<code>AI_API_KEY</code>') && /From the server's settings file|the settings file has no/.test(prov.text), 'the settings-file name is shown, in words');
assert.ok(prov.text.includes(`${seam.url}/complete`) && !prov.text.includes('token='), 'base URLs lose their query string');
assert.ok(!prov.text.includes('action="/console/providers/okseam/status"'), 'an admin gets no provider buttons');
assert.ok(prov.text.includes('need <code>ai.provider.manage</code> (the owner'));
@@@ -414,7 +414,7 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, re
assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].hint, ADDED_KEY.slice(-4));
x = await get('/console/providers', cookies.owner);
assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');
-- assert.match(x.text, new RegExp(`key …${ADDED_KEY.slice(-4)}`));
++ assert.match(x.text, new RegExp(`Entered here, locked</span><br><span class="muted">ends …${ADDED_KEY.slice(-4)}`));
x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', base_url: 'https://api.cheap.test/v1', api_key: ADDED_KEY, _csrf: csrf.owner });
assert.ok(x.status >= 400, 'no second provider of the same name');
x = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });
ddiff --git a/test/lint-schema.js b/test/lint-schema.js
nnew file mode 100644
iindex 0000000..d5cf829
---- /dev/null
++++ b/test/lint-schema.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * A local stand-in for openvibe-shared/config's lintSchema and its annotation checks (Shared 4.125.0, Shared PR #310,
++ * not tagged when this was written): the owner-setup design's clarity rules, checked on a namespace's schema, so a
++ * schema that passes here also passes when the pin moves to 4.125.0. Returns a list of problems ([] is clean).
++ *
++ * - every field has a title and a description of at least 30 characters;
++ * - every enum option has words (x-enum-labels: { value: { label, description } });
++ * - an x-owner-input names a Setup group, says what it unlocks, links an https page and says what to pick there;
++ * - x-required-for, when present, names a feature.
++ */
++// openvibe-shared 4.125.0's OWNER_INPUT_GROUPS ids, and the members an x-owner-input may have.
++const GROUPS = new Set(['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources',
++ 'design-publishing', 'domains', 'legal']);
++const MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);
++
++function lintField(name, f, out) {
++ if (!f || typeof f !== 'object') return;
++ if (typeof f.title !== 'string' || !f.title.trim()) out.push(`${name}: no title`);
++ if (typeof f.description !== 'string' || f.description.trim().length < 30) out.push(`${name}: the description is missing or under 30 characters`);
++ const options = Array.isArray(f.enum) ? f.enum : (f.items && Array.isArray(f.items.enum) ? f.items.enum : null);
++ if (options) {
++ const labels = f['x-enum-labels'] || (f.items && f.items['x-enum-labels']);
++ for (const v of options) {
++ const l = labels && labels[String(v)];
++ if (!l || typeof l.label !== 'string' || !l.label.trim() || typeof l.description !== 'string' || !l.description.trim()) out.push(`${name}: the option ${JSON.stringify(v)} has no label and line`);
++ }
++ }
++ const oi = f['x-owner-input'];
++ if (oi !== undefined) {
++ if (!oi || typeof oi !== 'object') out.push(`${name}: x-owner-input must be an object`);
++ else {
++ for (const m of Object.keys(oi)) if (!MEMBERS.has(m)) out.push(`${name}: x-owner-input has an unknown member ${m}`);
++ if (!GROUPS.has(oi.group)) out.push(`${name}: x-owner-input.group ${JSON.stringify(oi.group)} is not a Setup group`);
++ if (typeof oi.unlocks !== 'string' || oi.unlocks.trim().length < 20) out.push(`${name}: x-owner-input.unlocks must say what it turns on`);
++ if (oi.get_it_at !== undefined && !/^https:\/\/[^\s]+$/.test(String(oi.get_it_at))) out.push(`${name}: x-owner-input.get_it_at must be an https URL`);
++ if (typeof oi.get_it_how !== 'string' || oi.get_it_how.trim().length < 10) out.push(`${name}: x-owner-input.get_it_how must say what to pick there`);
++ if (typeof oi.test !== 'boolean') out.push(`${name}: x-owner-input.test must be true or false`);
++ }
++ }
++ if (f['x-example'] !== undefined && !['string', 'number', 'boolean'].includes(typeof f['x-example'])) out.push(`${name}: x-example must be a string, a number or true/false`);
++ if (f['x-advanced'] !== undefined && typeof f['x-advanced'] !== 'boolean') out.push(`${name}: x-advanced must be true or false`);
++ if (f['x-enum-labels'] && options) for (const v of Object.keys(f['x-enum-labels'])) if (!options.map((o) => (typeof o === 'string' ? o : JSON.stringify(o))).includes(v)) out.push(`${name}: x-enum-labels names ${v}, which is not an option`);
++ if (f['x-required-for'] !== undefined && (typeof f['x-required-for'] !== 'string' || !f['x-required-for'].trim())) out.push(`${name}: x-required-for must name a feature`);
++ if (f.properties && typeof f.properties === 'object') for (const [k, sub] of Object.entries(f.properties)) lintField(`${name}.${k}`, sub, out);
++}
++
++function lintSchema(schema) {
++ const out = [];
++ if (!schema || typeof schema !== 'object') return ['no schema'];
++ if (typeof schema.title !== 'string' || !schema.title.trim()) out.push('(namespace): no title');
++ if (typeof schema.description !== 'string' || schema.description.trim().length < 30) out.push('(namespace): the description is missing or under 30 characters');
++ for (const [k, f] of Object.entries(schema.properties || {})) lintField(k, f, out);
++ return out;
++}
++
++module.exports = { lintSchema, GROUPS };
ddiff --git a/test/own-endpoints.test.js b/test/own-endpoints.test.js
iindex aca3e1b..4229bec 100644
---- a/test/own-endpoints.test.js
++++ b/test/own-endpoints.test.js
@@@ -103,14 +103,14 @@ async function setGpus(h, { key = 'vast-test-key', cap = '50000000000', idle = n
const billingEnv = (billing) => ({ AI_CREDITS: '1', OV_BILLING_INTERNAL_URL: billing.url, OV_NETWORK_INTERNAL_URL: billing.url, OV_OAUTH_CLIENT_SECRET: 'test-secret' });
--t.test('the two owner inputs are marked for Setup: key and cap, group GPUs, needed for own endpoints', async () => {
++t.test('the two owner inputs are marked for Setup: key and cap, group gpus, needed for own endpoints', async () => {
const h = await boot({ clock: { now: () => T0 } });
try {
const sum = await h.settings.gpus.store.summary();
for (const key of ['vast.key', 'runpod.key', 'monthly_cap']) {
const field = sum.schema.properties[key];
-- assert.strictEqual(field['x-owner-input'].group, 'GPUs', key + ' is grouped under GPUs');
-- assert.strictEqual(field['x-required-for'], 'own endpoints', key + ' is needed for own endpoints');
++ assert.strictEqual(field['x-owner-input'].group, 'gpus', key + ' is grouped under GPUs (the group id)');
++ assert.match(String(field['x-required-for']), /own endpoints/, key + ' is needed for own endpoints');
assert.ok(String(field.title || '').length > 2, key + ' has a plain title');
assert.ok(String(field.description || '').length >= 30, key + ' has a description');
assert.match(String(field['x-owner-input'].get_it_at), /^https:\/\//, key + ' says where to get it');
ddiff --git a/test/owner-inputs.test.js b/test/owner-inputs.test.js
nnew file mode 100644
iindex 0000000..61b0ea4
---- /dev/null
++++ b/test/owner-inputs.test.js
@@@ -0,0 +1,179 @@
++'use strict';
++/**
++ * Owner inputs (the owner-setup design of 2026-11-07): every AI provider key and the GPU keys are x-owner-input fields
++ * the admin panel's Setup tab lists, with a key page, a line on what to pick there and a test; every field of every AI
++ * namespace has a title and a 30+ character description; the code uses the key entered in the panel when there is
++ * one and the server's settings file otherwise; the test hook answers in one line and never carries the key, not even
++ * when the provider echoes it back; no log line carries a key.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, request, token, suite } = require('./helpers');
++const { lintSchema } = require('./lint-schema');
++const { providerSecret } = require('../server/util');
++
++const t = suite('owner-inputs');
++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const KEK = crypto.randomBytes(32).toString('base64');
++const ENV_KEY = 'made-up-env-key-FROM-THE-SETTINGS-FILE-4c1d9e0a7b';
++const PANEL_KEY = 'made-up-panel-key-ENTERED-IN-SETUP-NEVER-SHOWN-93ab';
++const BAD_KEY = 'made-up-bad-key-THE-PROVIDER-ECHOES-THIS-BACK-5e2f';
++const VAST_KEY = 'made-up-vast-key-NEVER-IN-A-LOG-OR-ANSWER-11d0ffee';
++const SECRETS = [ENV_KEY, PANEL_KEY, BAD_KEY, VAST_KEY];
++const cfgTok = token('network', ['ai.config.manage', 'ai.provider.manage', 'ai.usage.read']);
++const opsTok = token('network', ['ai.provider.manage']);
++const noCap = token('live', ['ai.run.read']);
++const owner = { 'X-OV-Subject': OWNER, 'X-OV-Fresh': '1' };
++const cfg = (h, method, path, body) => request(h.base, method, path, { tok: cfgTok, body, headers: owner });
++
++const calls = [];
++/** The providers and marketplaces, faked: a good key is listed, a bad one is refused with the key echoed back. */
++async function fakeFetch(url, init = {}) {
++ const u = String(url);
++ const headers = init.headers || {};
++ const auth = String(headers.Authorization || headers.authorization || headers['x-api-key'] || '');
++ calls.push({ url: u, auth });
++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++ if (u.startsWith('https://keytest.example/v1/models')) {
++ if (auth.endsWith(ENV_KEY) || auth.endsWith(PANEL_KEY)) return json(200, { object: 'list', data: [{ id: 'm-1' }, { id: 'm-2' }] });
++ return json(401, { error: { message: `Incorrect API key provided: ${auth.replace(/^Bearer /, '')}. You can find your key at the dashboard.` } });
++ }
++ if (u === 'https://console.vast.ai/api/v0/users/current/') {
++ return auth === `Bearer ${VAST_KEY}` ? json(200, { id: 7, credit: 12.5 }) : json(401, { detail: `bad key ${auth}` });
++ }
++ return json(404, { error: 'not here' });
++}
++
++let h;
++const logs = [];
++const capture = { log: (...a) => logs.push(a.join(' ')), info: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
++const consoleLines = [];
++const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };
++
++t.test('boot with a KEK, an env key for one provider and an image model', async () => {
++ for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };
++ h = await boot({
++ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', AI_USER_MODULES: 'off', KEYTEST_API_KEY: ENV_KEY, AI_IMAGE_MODEL: 'gpt-image-1' },
++ log: capture, fetchImpl: fakeFetch,
++ });
++ const created = await request(h.base, 'POST', '/api/v1/providers', {
++ tok: opsTok,
++ body: { key: 'keytest', display_name: 'Key Test', kind: 'openai', status: 'active', base_url: 'https://keytest.example/v1', auth_mode: 'bearer', secret_ref: 'env:KEYTEST_API_KEY', capabilities: ['chat'], timeout_ms: 5000 },
++ });
++ assert.strictEqual(created.status, 201, created.text);
++ await h.settings.providerKeys.refresh();
++});
++
++t.test('every field of every AI namespace has a title, a 30+ character description and labelled options', async () => {
++ const r = await cfg(h, 'GET', '/internal/config');
++ assert.strictEqual(r.status, 200, r.text);
++ const names = r.body.namespaces.map((n) => n.namespace).sort();
++ assert.deepStrictEqual(names, ['ai.exchange', 'ai.gpus', 'ai.provider_keys']);
++ for (const ns of r.body.namespaces) assert.deepStrictEqual(lintSchema(ns.schema), [], `${ns.namespace}: ${lintSchema(ns.schema).join('; ')}`);
++});
++
++t.test('a provider that takes a key is an owner input in the AI providers group, with its key page and a test', async () => {
++ const r = await cfg(h, 'GET', '/internal/config/ai.provider_keys');
++ const p = r.body.schema.properties;
++ const oi = p['keytest.key']['x-owner-input'];
++ assert.strictEqual(oi.group, 'ai-providers');
++ assert.strictEqual(oi.test, true);
++ assert.match(oi.unlocks, /Key Test/);
++ assert.match(oi.get_it_how, /key/i);
++ assert.strictEqual(p['keytest.key'].title, 'Key Test API key');
++ assert.strictEqual(p['keytest.origin']['x-advanced'], true, 'the bound address is an advanced field');
++ const ds = p['deepseek.key']['x-owner-input'];
++ assert.deepStrictEqual([ds.group, ds.get_it_at], ['ai-providers', 'https://platform.deepseek.com/api_keys']);
++ assert.strictEqual(p['stub.key']['x-owner-input'], undefined, 'the stub takes no key');
++ assert.strictEqual(p['whisper.key']['x-owner-input'], undefined, 'whisper.cpp takes no key');
++ assert.strictEqual(p['shared.key']['x-required-for'], 'Image generation', 'the image model runs on the shared provider: its key is the image key');
++ assert.match(p['shared.key']['x-owner-input'].unlocks, /gpt-image-1/);
++});
++
++t.test('the GPU keys and the monthly cap are owner inputs in the GPUs group', async () => {
++ const r = await cfg(h, 'GET', '/internal/config/ai.gpus');
++ const p = r.body.schema.properties;
++ assert.deepStrictEqual([p['vast.key']['x-owner-input'].group, p['vast.key']['x-owner-input'].test], ['gpus', true]);
++ assert.strictEqual(p['vast.key']['x-owner-input'].get_it_at, 'https://cloud.vast.ai/manage-keys/');
++ assert.deepStrictEqual([p['runpod.key']['x-owner-input'].group, p['runpod.key']['x-owner-input'].test], ['gpus', true]);
++ assert.strictEqual(p.monthly_cap['x-owner-input'].group, 'gpus');
++ assert.strictEqual(p.monthly_cap['x-required-for'], 'Renting GPUs and own endpoints');
++ assert.strictEqual(p.monthly_cap['x-unit'], 'nano_usd');
++});
++
++t.test('without a key in the panel the provider uses the server settings file, and the test says so', async () => {
++ const provider = await h.registry.getProvider('keytest');
++ assert.strictEqual(providerSecret(provider, h.env), ENV_KEY);
++ const r = await cfg(h, 'POST', '/internal/config/ai.provider_keys/test/keytest.key');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.ok, r.body.state, r.body.source], [true, 'connected', 'env']);
++ assert.match(r.body.message, /^Key Test accepted the key and lists 2 models\. \(the value from the server's settings file, KEYTEST_API_KEY\)$/);
++ assert.ok(!r.body.message.includes('\n'));
++ assert.ok(calls.some((c) => c.url === 'https://keytest.example/v1/models' && c.auth === `Bearer ${ENV_KEY}`));
++});
++
++t.test('a key entered in the panel wins over the settings file at use time', async () => {
++ const put = await cfg(h, 'PUT', '/internal/config/ai.provider_keys/secrets/keytest.key', { value: PANEL_KEY, reason: 'setup' });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.ok(!put.text.includes(PANEL_KEY));
++ const provider = await h.registry.getProvider('keytest');
++ assert.strictEqual(providerSecret(provider, h.env), PANEL_KEY, 'the pool now sends the panel key');
++ const view = await request(h.base, 'GET', '/api/v1/providers/keytest', { tok: opsTok });
++ assert.strictEqual(view.body.credentials || (view.body.provider && view.body.provider.credentials), 'configured');
++ const r = await cfg(h, 'POST', '/internal/config/ai.provider_keys/test/keytest.key');
++ assert.deepStrictEqual([r.body.ok, r.body.state, r.body.source], [true, 'connected', 'vault']);
++ assert.ok(calls.some((c) => c.auth === `Bearer ${PANEL_KEY}`), 'the test used the panel key');
++});
++
++t.test('a refused key is Not working in one line, and the echoed key never comes back', async () => {
++ const put = await cfg(h, 'PUT', '/internal/config/ai.provider_keys/secrets/keytest.key', { value: BAD_KEY, reason: 'a wrong key' });
++ assert.strictEqual(put.status, 200, put.text);
++ const r = await cfg(h, 'POST', '/internal/config/ai.provider_keys/test/keytest.key');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.ok, r.body.state], [false, 'not_working']);
++ assert.match(r.body.message, /^Key Test refused the key \(401\): Incorrect API key provided: \[hidden\]/);
++ assert.ok(r.body.message.length <= 200 && !r.body.message.includes('\n'));
++ assert.ok(!r.text.includes(BAD_KEY) && !r.text.includes(BAD_KEY.slice(0, 12)), 'the answer never carries the key');
++});
++
++t.test('removing the panel key falls back to the settings file', async () => {
++ const del = await cfg(h, 'DELETE', '/internal/config/ai.provider_keys/secrets/keytest.key', { reason: 'back to env' });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.strictEqual(providerSecret(await h.registry.getProvider('keytest'), h.env), ENV_KEY);
++});
++
++t.test('a GPU key is tested with a read-only account call; an unset one is 409 config.not_set', async () => {
++ const none = await cfg(h, 'POST', '/internal/config/ai.gpus/test/runpod.key');
++ assert.deepStrictEqual([none.status, none.body.code], [409, 'config.not_set'], 'as Shared 4.125.0\'s hook: nothing to test');
++ const put = await cfg(h, 'PUT', '/internal/config/ai.gpus/secrets/vast.key', { value: VAST_KEY, reason: 'setup' });
++ assert.strictEqual(put.status, 200, put.text);
++ const r = await cfg(h, 'POST', '/internal/config/ai.gpus/test/vast.key');
++ assert.deepStrictEqual([r.body.ok, r.body.state], [true, 'connected']);
++ assert.strictEqual(r.body.message, 'Vast.ai accepted the key. The account has $12.50 of credit.');
++ assert.ok(!calls.some((c) => /\/asks\/|\/bundles|DELETE/.test(c.url)), 'nothing was searched, rented or given back');
++});
++
++t.test('the test hook is guarded and only answers for keys that have a test', async () => {
++ const r = await request(h.base, 'POST', '/internal/config/ai.provider_keys/test/keytest.key', { tok: noCap, headers: owner });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied']);
++ const none = await cfg(h, 'POST', '/internal/config/ai.provider_keys/test/stub.key');
++ assert.deepStrictEqual([none.status, none.body.code], [404, 'config.test_not_found']);
++ const cap = await cfg(h, 'POST', '/internal/config/ai.gpus/test/monthly_cap');
++ assert.strictEqual(cap.status, 404);
++});
++
++t.test('no log line, console line or audit row carries a key', async () => {
++ for (const k of Object.keys(original)) console[k] = original[k];
++ const audit = JSON.stringify(await h.db.prepare('SELECT * FROM audit_log').all());
++ for (const s of SECRETS) {
++ assert.ok(logs.every((line) => !line.includes(s)), `a log line carries ${s.slice(0, 16)}…`);
++ assert.ok(consoleLines.every((line) => !line.includes(s)), `a console line carries ${s.slice(0, 16)}…`);
++ assert.ok(!audit.includes(s), 'the audit carries a key');
++ }
++ assert.ok(logs.some((line) => /\[config\] test ai\.provider_keys keytest\.key: not working/.test(line)), 'the test outcome is logged, without the key');
++ await h.stop();
++});
++
++(async () => {
++ await t.run();
++})().catch((err) => { console.error(err); process.exit(1); });