openvibers/openvibe.ai Commits
Owner inputs: every AI provider key and the GPU keys on Setup, and a console in plain words (#336)
Changes
@@@ -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, keysrequireFresh: (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);}@@@ -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, econst 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, er.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, eawait 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, er.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: [] }));@@@ -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, pconst 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="cardfunction 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, notconst 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 } };nnew file mode 100644@@@ -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 };@@@ -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.nofunction 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.noasync 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.noasync 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';@@@ -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 };}@@@ -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, onconst 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());@@@ -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 };@@@ -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, reassert.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 });nnew file mode 100644@@@ -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 };@@@ -103,14 +103,14 @@ async function setGpus(h, { key = 'vast-test-key', cap = '50000000000', idle = nconst 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');nnew file mode 100644@@@ -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); });
@@ -8,7 +8,7 @@ const { createRelease } = require('openvibe-shared/release');
@@ -8,7 +8,7 @@ const { createRelease } = require('openvibe-shared/release');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { createAiReadiness, registerAiGauges } = require('./observability');
const { runsRouter } = require('./api/runs');
const { runsRouter } = require('./api/runs');
const { actionsRouter } = require('./api/actions');
const { actionsRouter } = require('./api/actions');
-const { sendError } = require('./util');
+const { sendError, providerSecret } = require('./util');
const openapi = require('./openapi');
const openapi = require('./openapi');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
const { CAPS } = require('./auth');
const { CAPS } = require('./auth');
@@ -23,6 +23,7 @@ const home = require('./home');
@@ -23,6 +23,7 @@ const home = require('./home');
const pkg = require('../package.json');
const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
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 }) {
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();
const app = express();
@@ -155,6 +156,35 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -155,6 +156,35 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
log,
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);
app.use(configRoutes.handle);
}
}
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { AiError, resolveSecret, iso } = require('../util');
+const { AiError, resolveSecret, providerSecret, iso } = require('../util');
const usageSamples = require('../usage-samples');
const usageSamples = require('../usage-samples');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { staff: staffMap } = require('openvibe-contracts');
const { staff: staffMap } = require('openvibe-contracts');
@@ -53,7 +53,7 @@ const sso = require('./sso');
@@ -53,7 +53,7 @@ const sso = require('./sso');
const q = require('./queries');
const q = require('./queries');
const catalog = require('../providers/catalog');
const catalog = require('../providers/catalog');
const poolSeed = require('../providers/pool-seed');
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"). */
/** Network staff capability -> AI capability held by a console session (documented in README "Operator console"). */
const STAFF_TO_AI = Object.freeze([
const STAFF_TO_AI = Object.freeze([
@@ -147,7 +147,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -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). */
/** Every secret value this process can resolve, so that no page can ever carry one (belt and braces). */
async function secretValues() {
async function secretValues() {
const vals = new Set([clientSecret(), secret]);
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);
return [...vals].filter((v) => v && v.length >= 8);
}
}
async function scrub(text) {
async function scrub(text) {
@@ -317,8 +317,11 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -317,8 +317,11 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
const problems = [];
const problems = [];
for (const p of (await registry.listProviders()).filter((x) => x.status === 'active' && x.kind !== 'stub')) {
for (const p of (await registry.listProviders()).filter((x) => x.status === 'active' && x.kind !== 'stub')) {
const v = await ops.providerView(p);
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 stats = await cache.stats();
const frac = (c) => {
const frac = (c) => {
@@ -336,7 +339,9 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -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({
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,
...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(),
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) => {
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));
await ops.setProviderStatus(req.params.key, String(req.body.status || ''), who(req));
res.redirect(303, '/console/providers?done=provider_status');
res.redirect(303, '/console/providers?done=provider_status');
@@ -373,7 +378,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -373,7 +378,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
await vault.put(key, b.api_key, who(req));
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).
// Its model list, read right away (best effort: a provider whose list cannot be read is still added).
const added = await registry.getProvider(key);
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).
// 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);
await poolSeed.applyCatalogPrices({ registry, provider: added, actor: who(req).actor }).catch(() => null);
res.redirect(303, '/console/providers?done=provider_added');
res.redirect(303, '/console/providers?done=provider_added');
@@ -392,7 +397,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -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) => {
r.post('/console/providers/:key/sync', needs(CAPS.providerManage), wrap(async (req, res) => {
const p = await registry.getProvider(req.params.key);
const p = await registry.getProvider(req.params.key);
if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
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`);
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 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: [] }));
const priced = await poolSeed.applyCatalogPrices({ registry, provider: p, actor: who(req).actor }).catch(() => ({ priced: [], retired: [] }));
@@ -119,11 +119,58 @@ function table(caption, head, rows, empty = 'None.') {
@@ -119,11 +119,58 @@ function table(caption, head, rows, empty = 'None.') {
}
}
const NAV = [
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/gpus', 'GPUs', 'gpus'],
['/console/pricebook', 'Prices', 'pricebook'],
['/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
// 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
@@ -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);
const runs24 = Object.values(counts24).reduce((a, b) => a + b, 0);
return layout({
return layout({
title: 'Overview', section: 'overview', staff, csrf, body: html`<h1>Overview</h1>
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="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="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="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="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="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="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">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 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>
</div>
<h2>Needs attention</h2>
<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>
${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.')}
<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) {
function limits(q) {
const parts = [];
const parts = [];
if (q.max_requests != null) parts.push(`${n(q.max_requests)} requests`);
if (q.max_requests != null) parts.push(`${n(q.max_requests)} requests`);
@@ -211,34 +274,41 @@ function limitsUsed(c) {
@@ -211,34 +274,41 @@ function limitsUsed(c) {
return parts.join(' · ') || `${n(c.used.requests)} requests`;
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>
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)}/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)}/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>
<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><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>` : '');
<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({
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 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>
${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><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>${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>
<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
@@ -267,9 +337,9 @@ ${vaultReady ? '' : html`<p class="bad">Keys cannot be stored on this server: th
}
}
const KINDS = {
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>` : ''}` : '—');
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 }) {
@@ -314,6 +384,7 @@ function definitions({ staff, csrf, kind, rows, inUse }) {
}
}
return layout({
return layout({
title: k.title, section: kind, staff, csrf, body: html`<h1>${k.title}</h1>
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'
<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.'
? '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>
: '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
@@ -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 = {} }) {
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 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({
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)}
<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 }) {
@@ -613,17 +687,17 @@ function gpus({ staff, csrf, state, canManage, notice, error }) {
const mk = st.marketplaces || {};
const mk = st.marketplaces || {};
return layout({
return layout({
title: 'OpenVibe GPUs', section: 'gpus', staff, csrf, notice, error, body: html`<h1>OpenVibe GPUs</h1>
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="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">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>
</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
@@ -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
* 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.
* 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 } = {}) {
function priceChangeRow(c, { csrf, canManage, actions = false } = {}) {
const pct = c.change_pct == null ? null : Number(c.change_pct) * 100;
const pct = c.change_pct == null ? null : Number(c.change_pct) * 100;
return html`<tr>
return html`<tr>
<td><code>${c.provider_key}</code> / <code>${c.model_key}</code></td>
<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>${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>${usd(Number(c.new_price))}</td>
<td>${pct == null ? html`<span class="muted">new price</span>` : (pct >= 0 ? '+' : '') + pct.toFixed(1) + ' %'}</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
@@ -729,7 +809,7 @@ function pricebook({ staff, csrf, report, state, pending, recent, canManage, not
const row = state || {};
const row = state || {};
return layout({
return layout({
title: 'Price book', section: 'pricebook', staff, csrf, notice, error, body: html`<h1>Price book</h1>
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="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">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>
<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
@@ -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) ───────────────
// ── 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.
* 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) {
@@ -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). */
/** 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)) };
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 } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -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 };
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
*/
*/
const placement = require('openvibe-sdk/placement');
const placement = require('openvibe-sdk/placement');
const contracts = require('openvibe-contracts');
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 { retryable, sleep, ProviderError, schemaInstruction, insufficientBalance } = require('./common');
const { sourcesOf, ceilingOf, allowsProvider, allowsCommunity } = require('../free-tier');
const { sourcesOf, ceilingOf, allowsProvider, allowsCommunity } = require('../free-tier');
const { createStubProvider } = require('./stub');
const { createStubProvider } = require('./stub');
@@ -250,7 +250,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -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 statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }
function build(p) {
function build(p) {
- const apiKey = resolveSecret(p.secret_ref, env);
+ const apiKey = providerSecret(p, env);
switch (p.kind) {
switch (p.kind) {
case 'stub': return createStubProvider(p, { stats: statFor(p.key) });
case 'stub': return createStubProvider(p, { stats: statFor(p.key) });
case 'openai': return createOpenAiProvider(p, { apiKey, fetchImpl });
case 'openai': return createOpenAiProvider(p, { apiKey, fetchImpl });
@@ -266,7 +266,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -266,7 +266,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function adapter(key) {
async function adapter(key) {
const p = await registry.getProvider(key);
const p = await registry.getProvider(key);
if (!p) return null;
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);
const hit = await cache.get(key);
if (hit && hit.stamp === stamp) return { record: p, adapter: hit.adapter };
if (hit && hit.stamp === stamp) return { record: p, adapter: hit.adapter };
const a = build(p);
const a = build(p);
@@ -456,7 +456,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -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.
// request, and the adult tier has no other offer to run on.
if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
seen.add(id);
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 h = await health(p.key);
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const errorRate = st.requests ? st.errors / st.requests : 0;
const errorRate = st.requests ? st.errors / st.requests : 0;
@@ -746,7 +746,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -746,7 +746,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function skipReason(p, a, features) {
async function skipReason(p, a, features) {
if (!p || !a) return 'unknown_provider';
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
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';
if (features.some(f => !a.supports(f))) return 'unsupported';
const h = await health(p.key);
const h = await health(p.key);
if (h.state === 'open') return h.exhausted_at ? 'exhausted' : 'circuit_open';
if (h.state === 'open') return h.exhausted_at ? 'exhausted' : 'circuit_open';
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* 'active'. Lifecycle changes (deprecate, archive, disable) are status updates on one version,
* 'active'. Lifecycle changes (deprecate, archive, disable) are status updates on one version,
* audited, and never change its content.
* 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 schemas = require('./schemas');
const { provenance } = require('./providers/rate-cards');
const { provenance } = require('./providers/rate-cards');
@@ -61,7 +61,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -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. */
/** Public view: the secret reference name and whether it resolves — never the value. */
function publicProvider(p, health) {
function publicProvider(p, health) {
if (!p) return null;
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 };
return { ...p, credentials: hasSecret === null ? 'not_required' : hasSecret ? 'configured' : 'missing', health: health || null };
}
}
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
*/
*/
const { createConfigStore, ConfigError } = require('openvibe-shared/config');
const { createConfigStore, ConfigError } = require('openvibe-shared/config');
const { AiError } = require('./util');
const { AiError } = require('./util');
+const { keyPage } = require('./owner-inputs');
const EXCHANGE_NAMESPACE = 'ai.exchange';
const EXCHANGE_NAMESPACE = 'ai.exchange';
const PROVIDER_KEYS_NAMESPACE = 'ai.provider_keys';
const PROVIDER_KEYS_NAMESPACE = 'ai.provider_keys';
@@ -31,64 +32,69 @@ const GPU_GROUP = 'OpenVibe GPUs';
@@ -31,64 +32,69 @@ const GPU_GROUP = 'OpenVibe GPUs';
// namespace, so the owner's key is sealed to the marketplace it was entered for.
// 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' });
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({
const EXCHANGE_SCHEMA = Object.freeze({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$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',
type: 'object',
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
model_catalog: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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) {
@@ -169,90 +175,130 @@ function originOf(baseUrl) {
const GPU_SCHEMA = Object.freeze({
const GPU_SCHEMA = Object.freeze({
$schema: 'https://json-schema.org/draft/2020-12/schema',
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'OpenVibe GPUs',
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',
type: 'object',
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
'vast.origin': {
'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': {
'vast.key': {
type: 'string', title: 'Vast.ai API key', 'x-group': GPU_GROUP, 'x-bind': 'vast.origin',
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': {
'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': {
'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': {
'runpod.key': {
type: 'string', title: 'RunPod API key', 'x-group': GPU_GROUP, 'x-bind': 'runpod.origin',
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': {
'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_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: {
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': {
'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,
test: false,
},
},
- 'x-required-for': 'own endpoints',
+ 'x-required-for': 'Renting GPUs and own endpoints',
},
},
idle_minutes: {
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: {
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: {
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: {
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 = {};
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`] = {
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');
const classify = (key) => (String(key).endsWith('.key') ? 'secret' : 'internal');
@@ -261,7 +307,7 @@ 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
* 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.
* 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');
const vault = require('./vault');
let store = null;
let store = null;
let names = [];
let names = [];
@@ -287,8 +333,9 @@ function createProviderKeys({ db, registry, env = process.env, log = console, on
@@ -287,8 +333,9 @@ function createProviderKeys({ db, registry, env = process.env, log = console, on
const providers = await registry.listProviders();
const providers = await registry.listProviders();
const next = [...new Set(providers.map((p) => p.key))].sort();
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;
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({
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,
classify, env, log, onChange, onActivate: activate,
});
});
names = next;
names = next;
@@ -438,7 +485,7 @@ async function createSettings({ db, config, registry, clock = { now: () => Date.
@@ -438,7 +485,7 @@ async function createSettings({ db, config, registry, clock = { now: () => Date.
},
},
});
});
applyExchange(config, exchange.get());
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();
await providerKeys.refresh();
const batches = await createBatchesSettings({ db, env, log, onChange });
const batches = await createBatchesSettings({ db, env, log, onChange });
applyBatches(config, batches.values());
applyBatches(config, batches.values());
@@ -55,6 +55,16 @@ function resolveSecret(ref, env = process.env) {
@@ -55,6 +55,16 @@ function resolveSecret(ref, env = process.env) {
return env[m[1]] ? String(env[m[1]]) : '';
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) {
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));
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) {
@@ -77,4 +87,4 @@ function parseJsonLoose(text) {
} catch { return null; }
} 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 };
@@ -301,7 +301,7 @@ t.test('every page: server-rendered, no script, noindex, no-store, strict CSP, c
@@ -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'];
`/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);
for (const p of list) assertPage(await get(p, cookies.admin), p);
const prov = await get('/console/providers', cookies.admin);
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(`${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('action="/console/providers/okseam/status"'), 'an admin gets no provider buttons');
assert.ok(prov.text.includes('need <code>ai.provider.manage</code> (the owner'));
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
@@ -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));
assert.strictEqual((await h.settings.providerKeys.store.summary()).secrets['cheapapi.key'].hint, ADDED_KEY.slice(-4));
x = await get('/console/providers', cookies.owner);
x = await get('/console/providers', cookies.owner);
assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');
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 });
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');
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 });
x = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
@@ -103,14 +103,14 @@ async function setGpus(h, { key = 'vast-test-key', cap = '50000000000', idle = n
@@ -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' });
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 } });
const h = await boot({ clock: { now: () => T0 } });
try {
try {
const sum = await h.settings.gpus.store.summary();
const sum = await h.settings.gpus.store.summary();
for (const key of ['vast.key', 'runpod.key', 'monthly_cap']) {
for (const key of ['vast.key', 'runpod.key', 'monthly_cap']) {
const field = sum.schema.properties[key];
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.title || '').length > 2, key + ' has a plain title');
assert.ok(String(field.description || '').length >= 30, key + ' has a description');
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');
assert.match(String(field['x-owner-input'].get_it_at), /^https:\/\//, key + ' says where to get it');
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -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); });