openvibers/openvibe.ai Commits
Add owner provider key status, checks and safe removal (#434)
Changes
@@@ -52,3 +52,18 @@ Required-provider documentation was checked on 2026-11-10. Meta now documents MoSearch capabilities use web-search and image-search, matching the shared placement capability syntax. Brave web and images share one provider/key, as do Serper web and images. Openverse and Wikimedia Commons require no key. SearXNG has no invented public address; the owner supplies their self-hosted base.For Fable: the existing providers console now receives all seeded provider rows and protocol metadata. Its visual grouping and capability presentation remain for the main session.++++Owner key controls use the existing encrypted `ai.provider_keys` store. The console JSON exposes only presence,++the last four characters and the stored date. Saving or replacing a key makes no provider request. Deleting a key++disables the provider and clears its secret reference before removing the encrypted value; it does not fall back++to an environment key. The separate Setup panel retains its existing environment-fallback behavior.++++`POST /api/v1/admin/console/providers/:key/check` performs at most one request and never enables the provider.++It uses the model-list endpoint, a minimal search for search-only adapters, or a registered read-only account++endpoint: [OpenRouter's current key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-api-key)++and [Tavily usage](https://docs.tavily.com/documentation/api-reference/endpoint/usage). There are no retries,++redirects, generated tokens or automatic checks when a key is saved. Only HTTP status, a safe error code,++latency and check time are stored; no upstream response text is logged or returned. A model-list check confirms++endpoint access, not that a particular model can run. Missing keys result in zero requests.++++For Fable: key marker/date, explicit check button, check status/latency and remove-and-disable controls in each provider.@@@ -3,7 +3,7 @@const express = require('express');const q = require('../console/queries');const { CAPS } = require('../auth');--const { AiError } = require('../util');++const { AiError, providerSecret } = require('../util');const providerPresets = require('../console/presets');const providerCatalog = require('../providers/catalog');const poolSeed = require('../providers/pool-seed');@@@ -11,7 +11,7 @@ const gpuCatalog = require('../gpu/catalog');const { tokenCosts } = require('../gpu/token-cost');/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */--function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, gpus = null, gpuMarket = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {++function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {const r = express.Router();const supply = require('../platform/supply').createSupply({ db, registry, clock });// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:@@@ -41,8 +41,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };}if (section === 'providers') {++ const hints = vault && vault.hints ? await vault.hints() : new Map();const rows = (await Promise.all((await registry.listProviders()).map(ops.providerView))).map((p) => ({...p, key_present: p.credentials === 'configured', preference_order: p.priority,++ key_state: p.auth_mode === 'none' ? { source: 'not_required', hint: null, set_at: null }++ : { source: hints.has(p.key) ? 'stored' : p.credentials === 'configured' ? 'environment' : 'missing',++ hint: hints.get(p.key)?.hint || (providerSecret(p, env) ? `…${providerSecret(p, env).slice(-4)}` : null),++ set_at: hints.get(p.key)?.updated_at || null },++ last_check: p.metadata?.last_check || null,markup_percent: Number(p.metadata && p.metadata.markup_percent) || 0,uncensored: Boolean(p.metadata && p.metadata.uncensored),}));@@@ -106,10 +112,33 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,if (value.length < 8 || value.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');const who = { actor: actor(req), trace: req.ov && req.ov.traceId };await vault.put(provider.key, value, who);-- await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}` }, { ...who, origin: 'admin' });++ await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}`, metadata: { ...provider.metadata, last_check: null } }, { ...who, origin: 'admin' });await registry.audit(who.actor, 'provider.key_replaced', 'provider', provider.key, { trace: who.trace });return { ok: true, provider: provider.key };}));++ r.delete('/api/v1/admin/console/providers/:key/key', write, wrap(async (req) => {++ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');++ const provider = await registry.getProvider(req.params.key);++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');++ const who = { actor: actor(req), trace: req.ov && req.ov.traceId };++ // Disable first: a request racing the store deletion must not fall back to an environment key.++ await registry.upsertProvider({ key: provider.key, status: 'disabled', secret_ref: null,++ metadata: { ...provider.metadata, last_check: null } }, { ...who, origin: 'admin' });++ await vault.remove(provider.key);++ await registry.audit(who.actor, 'provider.key_removed', 'provider', provider.key, { trace: who.trace });++ return { ok: true, provider: provider.key };++ }));++ r.post('/api/v1/admin/console/providers/:key/check', write, wrap(async (req) => {++ const provider = await registry.getProvider(req.params.key);++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');++ const { checkProvider } = require('../providers/check');++ const result = await checkProvider(provider, { env, now: clock.now,++ fetchImpl: fetchImpl === globalThis.fetch ? require('../providers/guarded-fetch').guardedFetch : fetchImpl });++ // A check does not enable a provider or overwrite settings changed while its request was in flight.++ const current = await registry.getProvider(provider.key);++ if (current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });++ return { provider: provider.key, ...result };++ }));r.post('/api/v1/admin/console/providers', json, write, wrap(async (req) => {if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');const b = req.body || {};@@@ -275,7 +275,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));-- app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, gpus, gpuMarket, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));++ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).@@@ -115,9 +115,16 @@ async function testProviderKey(provider, key, { fetchImpl }) {url = `${base}/models?pageSize=1`;headers = { 'x-goog-api-key': key };}++ if (provider.metadata && provider.metadata.models_endpoint) url = provider.metadata.models_endpoint;++ if (provider.auth_mode === 'none') headers = {};++ else if (provider.kind !== 'anthropic' && provider.kind !== 'gemini' && provider.auth_mode === 'x-api-key') headers = { 'x-api-key': key };++ // A configured models endpoint receives a key only on the origin the owner entered it for.++ try { if (new URL(url).origin !== new URL(base).origin) return { ok: false, message: `${name} has a model-list address on a different origin.` }; }++ catch { return { ok: false, message: `${name} has an invalid model-list address.` }; }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);++ if (!list) return { ok: false, message: `${name} answered without a model list.` };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) };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++++const { providerSecret } = require('../util');++const { testProviderKey } = require('../owner-inputs');++const { guardedFetch } = require('./guarded-fetch');++++/** One explicit owner check, with no inference, retries, redirects or fallback. Never retain upstream text. */++async function checkProvider(provider, { env = process.env, fetchImpl = guardedFetch, now = Date.now } = {}) {++ const started = now();++ let calls = 0;++ let httpStatus = null;++ const finish = (ok, error = null, method = null) => ({++ ok, error, method, http_status: httpStatus, requests: calls,++ latency_ms: Math.max(0, now() - started), checked_at: new Date(started).toISOString(),++ });++ const key = providerSecret(provider, env);++ if (provider.auth_mode !== 'none' && !key) return finish(false, 'key_not_set');++ if (!provider.base_url) return finish(false, 'base_url_not_set');++ let origin;++ try { origin = new URL(provider.base_url).origin; } catch { return finish(false, 'invalid_base_url'); }++ let failure = 'request_failed';++ const once = async (url, init = {}) => {++ const target = new URL(String(url));++ if (target.origin !== origin || target.protocol !== 'https:' || target.username || target.password) {++ failure = 'endpoint_origin_mismatch';++ throw new Error(failure);++ }++ if (calls) { failure = 'request_limit'; throw new Error(failure); }++ calls++;++ try {++ const response = await fetchImpl(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10000) });++ httpStatus = response.status;++ if (!response.ok) failure = `http_${response.status}`;++ return response;++ } catch (err) {++ failure = ['AbortError', 'TimeoutError'].includes(err && err.name) ? 'timeout' : 'connection_failed';++ throw new Error(failure);++ }++ };++ let method = 'models';++ try {++ if (provider.metadata?.check_endpoint) {++ method = 'account';++ const header = provider.metadata.auth_header || (provider.auth_mode === 'x-api-key' ? 'x-api-key' : 'Authorization');++ const headers = provider.auth_mode === 'none' ? {} : { [header]: header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key };++ const result = await once(provider.metadata.check_endpoint, { method: 'GET', headers });++ // Account data and key labels are deliberately discarded.++ await result.body?.cancel();++ return finish(result.ok, result.ok ? null : failure, method);++ }++ if (provider.kind === 'http' && provider.metadata?.search_adapters?.length) {++ method = 'search';++ const { adapters } = require('../search');++ const adapter = provider.metadata.search_adapters.map(id => adapters.find(a => a.id === id)).find(Boolean);++ if (!adapter) return finish(false, 'check_not_supported', method);++ await adapter.search({ q: 'OpenVibe', limit: 1 }, {++ key, fetchImpl: once, now, env: { ...env, ...(adapter.setting ? { [adapter.setting]: provider.base_url } : {}) },++ });++ return finish(true, null, method);++ }++ const result = await testProviderKey(provider, key, { fetchImpl: once });++ return finish(result.ok, result.ok ? null : httpStatus >= 200 && httpStatus < 300 ? 'invalid_response' : failure, method);++ } catch {++ return finish(false, failure, method);++ }++}++++module.exports = { checkProvider };@@@ -616,6 +616,7 @@ const PROVIDERS = [},{key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',++ check_endpoint: 'https://openrouter.ai/api/v1/key',docs_url: 'https://openrouter.ai/docs/api-reference/overview',display_name: 'OpenRouter (the last fallback)',base_url: 'https://openrouter.ai/api/v1',@@@ -699,6 +700,7 @@ const PROVIDERS = [auth_header: 'x-subscription-token', models_endpoint: null, capabilities: ['web-search', 'image-search'],search_adapters: ['brave', 'brave-images'], docs_url: 'https://api-dashboard.search.brave.com/documentation/services/web-search' },{ key: 'tavily', display_name: 'Tavily', kind: 'http', base_url: 'https://api.tavily.com', models_endpoint: null,++ check_endpoint: 'https://api.tavily.com/usage',capabilities: ['web-search'], search_adapters: ['tavily'], docs_url: 'https://docs.tavily.com/documentation/api-reference/endpoint/search' },{ key: 'exa', display_name: 'Exa', kind: 'http', base_url: 'https://api.exa.ai', auth_mode: 'x-api-key', models_endpoint: null,capabilities: ['web-search'], search_adapters: ['exa'], docs_url: 'https://docs.exa.ai/reference/search' },@@@ -29,6 +29,7 @@ function recordOf(p) {metadata: {catalog: 'pool-providers',api_family: p.api_family, models_endpoint: p.models_endpoint,++ check_endpoint: p.check_endpoint || null,auth_header: p.auth_header || null, docs_url: p.docs_url || null,search_adapters: p.search_adapters || [],data_policy: p.data_policy,@@@ -97,7 +98,10 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env} else if (prev.origin === 'catalog') {const defaults = recordOf(p);const configured = prev.auth_mode === 'none' || Boolean(providerSecret(prev, env));-- if (configured && prev.base_url !== p.base_url) defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;++ if (configured && prev.base_url !== p.base_url) {++ defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;++ defaults.metadata.check_endpoint = null;++ }const metadata = { ...defaults.metadata, ...prev.metadata };if (!configured && prev.base_url !== p.base_url) {metadata.models_endpoint = defaults.metadata.models_endpoint;@@@ -116,8 +120,11 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env} else {out.skipped.push(p.key);const protocol = recordOf(p).metadata;-- if (prev.base_url !== p.base_url) protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;-- const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));++ if (prev.base_url !== p.base_url) {++ protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;++ protocol.check_endpoint = null;++ }++ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });}for (const m of p.models) {nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++++const assert = require('assert');++const crypto = require('crypto');++const { checkProvider } = require('../server/providers/check');++const { boot, request, token, suite } = require('./helpers');++const { providerSecret } = require('../server/util');++const t = suite('provider-check');++const key = 'test-owner-key-never-in-response-1234';++const replacement = 'test-replacement-key-never-in-response-5678';++const env = { CHECK_KEY: key };++const provider = { key: 'check', kind: 'openai', auth_mode: 'bearer', base_url: 'https://check.example/v1', secret_ref: 'env:CHECK_KEY' };++const response = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++const manage = token('exchange', ['ai.provider.manage', 'ai.usage.read']);++const unprivileged = token('chat', ['ai.run.read']);++++t.test('a model-list check is one free read at the registered endpoint with latency', async () => {++ const calls = [];++ let ticks = 1000;++ const result = await checkProvider({ ...provider, metadata: { models_endpoint: 'https://check.example/catalog/models' } }, {++ env, now: () => (ticks += 25), fetchImpl: async (url, init) => {++ calls.push(url);++ assert.strictEqual(init.headers.Authorization, `Bearer ${key}`);++ assert.strictEqual(init.method, 'GET');++ assert.strictEqual(init.redirect, 'error');++ return response(200, { data: [{ id: 'test' }] });++ },++ });++ assert.deepStrictEqual(calls, ['https://check.example/catalog/models']);++ assert.strictEqual(result.ok, true);++ assert.strictEqual(result.requests, 1);++ assert.strictEqual(result.latency_ms, 25);++});++++t.test('upstream refusals and thrown errors cannot echo the key; no retries', async () => {++ for (const failure of [401, 429, 500, 'throw']) {++ let calls = 0;++ const result = await checkProvider(provider, { env, fetchImpl: async () => {++ calls++;++ if (failure === 'throw') throw new Error(`bad key ${key}`);++ return response(failure, { error: { message: `bad key ${key}` } });++ } });++ assert.strictEqual(calls, 1);++ assert.strictEqual(result.ok, false);++ assert.strictEqual(result.error, failure === 'throw' ? 'connection_failed' : `http_${failure}`);++ assert.ok(!JSON.stringify(result).includes(key));++ }++});++++t.test('missing keys and cross-origin model lists never make a request', async () => {++ const fetchImpl = async () => { throw new Error('must not fetch'); };++ const missing = await checkProvider(provider, { env: {}, fetchImpl });++ assert.strictEqual(missing.error, 'key_not_set');++ const moved = await checkProvider({ ...provider, metadata: { models_endpoint: 'https://other.example/models' } }, { env, fetchImpl });++ assert.strictEqual(moved.ok, false);++ assert.strictEqual(moved.requests, 0);++});++++t.test('search checks use their adapter once, with one result and no inference fallback', async () => {++ let calls = 0;++ const result = await checkProvider({ ...provider, kind: 'http', base_url: 'https://api.search.brave.com/res/v1', metadata: { search_adapters: ['brave', 'brave-images'] } }, {++ env, fetchImpl: async (url, init) => {++ calls++;++ assert.strictEqual(new URL(url).searchParams.get('count'), '1');++ assert.strictEqual(init.headers['x-subscription-token'], key);++ return response(200, { web: { results: [] } });++ },++ });++ assert.strictEqual(result.ok, true);++ assert.strictEqual(result.method, 'search');++ assert.strictEqual(calls, 1);++});++++t.test('registered free account checks take precedence over model lists and searches', async () => {++ let calls = 0;++ const result = await checkProvider({ ...provider, kind: 'http', metadata: { check_endpoint: 'https://check.example/usage', search_adapters: ['brave'] } }, {++ env, fetchImpl: async (url) => { calls++; assert.strictEqual(url, 'https://check.example/usage'); return response(200, { label: key }); },++ });++ assert.strictEqual(result.ok, true);++ assert.strictEqual(result.method, 'account');++ assert.strictEqual(calls, 1);++ assert.ok(!JSON.stringify(result).includes(key));++});++++let h;++let requests = 0;++let authUsed;++const logs = [];++const logger = { log: (...v) => logs.push(v.join(' ')), warn: (...v) => logs.push(v.join(' ')), error: (...v) => logs.push(v.join(' ')) };++const api = (method, path, body, tok = manage) => request(h.base, method, path, { body, tok });++const path = '/api/v1/admin/console/providers/keycheck';++t.test('boot the real encrypted store and save keys without making provider calls', async () => {++ h = await boot({ env: { OV_CONFIG_KEKS: `test:${crypto.randomBytes(32).toString('base64')}`, OV_CONFIG_KEK_CURRENT: 'test', AI_USER_MODULES: 'off', CHECK_KEY: key }, log: logger,++ fetchImpl: async (url, init) => { requests++; authUsed = init.headers.Authorization; return response(200, { data: [{ id: 'test' }] }); },++ });++ await h.registry.upsertProvider({ ...provider, key: 'keycheck', status: 'active', capabilities: ['chat'] });++ requests = 0;++ for (const value of [key, replacement]) {++ const saved = await api('POST', `${path}/key`, { api_key: value });++ assert.strictEqual(saved.status, 200, saved.text);++ assert.ok(!saved.text.includes(value));++ }++ assert.strictEqual(requests, 0);++ assert.strictEqual(providerSecret(await h.registry.getProvider('keycheck'), h.env), replacement);++ const read = await api('GET', '/api/v1/admin/console/providers');++ const stored = read.body.providers.find(p => p.key === 'keycheck');++ assert.strictEqual(stored.key_state.hint, '…5678');++ assert.strictEqual(stored.key_state.source, 'stored');++ assert.ok(stored.key_state.set_at);++ assert.ok(!read.text.includes(replacement) && !read.text.includes(key));++ const history = JSON.stringify(await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys'").all());++ assert.ok(!history.includes(key) && !history.includes(replacement), 'all persisted key revisions are encrypted');++});++++t.test('the check route makes one request and records only its safe result', async () => {++ const before = requests;++ const check = await api('POST', `${path}/check`);++ assert.strictEqual(check.status, 200, check.text);++ assert.strictEqual(check.body.ok, true);++ assert.strictEqual(requests - before, 1);++ assert.strictEqual(authUsed, `Bearer ${replacement}`);++ const stored = await h.registry.getProvider('keycheck');++ assert.strictEqual(stored.metadata.last_check.ok, true);++ assert.strictEqual(stored.status, 'active');++ assert.ok(!JSON.stringify(stored.metadata).includes(replacement));++});++++t.test('a service without owner capabilities gets 403 for every key operation', async () => {++ const before = requests;++ for (const [method, suffix] of [['POST', 'key'], ['DELETE', 'key'], ['POST', 'check']]) {++ const denied = await api(method, `${path}/${suffix}`, { api_key: key }, unprivileged);++ assert.strictEqual(denied.status, 403, denied.text);++ }++ assert.strictEqual(requests, before);++});++++t.test('checking a seeded environment provider preserves its configuration ownership', async () => {++ await h.registry.upsertProvider({ ...provider, key: 'envcheck', status: 'active', capabilities: ['chat'] }, { origin: 'seed' });++ const before = requests;++ const check = await api('POST', '/api/v1/admin/console/providers/envcheck/check');++ assert.strictEqual(check.status, 200, check.text);++ assert.strictEqual(check.body.ok, true);++ assert.strictEqual(requests - before, 1);++ assert.strictEqual((await h.registry.getProvider('envcheck')).origin, 'seed');++});++++t.test('removal disables immediately, clears the stored key and disconnects environment fallback', async () => {++ const removed = await api('DELETE', `${path}/key`);++ assert.strictEqual(removed.status, 200, removed.text);++ const stored = await h.registry.getProvider('keycheck');++ assert.strictEqual(stored.status, 'disabled');++ assert.strictEqual(stored.secret_ref, null);++ assert.strictEqual(providerSecret(stored, h.env), '');++ assert.strictEqual(stored.metadata.last_check, null);++ const before = requests;++ const check = await api('POST', `${path}/check`);++ assert.strictEqual(check.body.error, 'key_not_set');++ assert.strictEqual(requests, before);++ assert.ok(!logs.join('\n').includes(key) && !logs.join('\n').includes(replacement));++});++++t.run(() => h && h.stop());
@@ -52,3 +52,18 @@ Required-provider documentation was checked on 2026-11-10. Meta now documents Mo
@@ -52,3 +52,18 @@ Required-provider documentation was checked on 2026-11-10. Meta now documents Mo
Search capabilities use web-search and image-search, matching the shared placement capability syntax. Brave web and images share one provider/key, as do Serper web and images. Openverse and Wikimedia Commons require no key. SearXNG has no invented public address; the owner supplies their self-hosted base.
Search capabilities use web-search and image-search, matching the shared placement capability syntax. Brave web and images share one provider/key, as do Serper web and images. Openverse and Wikimedia Commons require no key. SearXNG has no invented public address; the owner supplies their self-hosted base.
For Fable: the existing providers console now receives all seeded provider rows and protocol metadata. Its visual grouping and capability presentation remain for the main session.
For Fable: the existing providers console now receives all seeded provider rows and protocol metadata. Its visual grouping and capability presentation remain for the main session.
+
+Owner key controls use the existing encrypted `ai.provider_keys` store. The console JSON exposes only presence,
+the last four characters and the stored date. Saving or replacing a key makes no provider request. Deleting a key
+disables the provider and clears its secret reference before removing the encrypted value; it does not fall back
+to an environment key. The separate Setup panel retains its existing environment-fallback behavior.
+
+`POST /api/v1/admin/console/providers/:key/check` performs at most one request and never enables the provider.
+It uses the model-list endpoint, a minimal search for search-only adapters, or a registered read-only account
+endpoint: [OpenRouter's current key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-api-key)
+and [Tavily usage](https://docs.tavily.com/documentation/api-reference/endpoint/usage). There are no retries,
+redirects, generated tokens or automatic checks when a key is saved. Only HTTP status, a safe error code,
+latency and check time are stored; no upstream response text is logged or returned. A model-list check confirms
+endpoint access, not that a particular model can run. Missing keys result in zero requests.
+
+For Fable: key marker/date, explicit check button, check status/latency and remove-and-disable controls in each provider.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
const express = require('express');
const express = require('express');
const q = require('../console/queries');
const q = require('../console/queries');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
-const { AiError } = require('../util');
+const { AiError, providerSecret } = require('../util');
const providerPresets = require('../console/presets');
const providerPresets = require('../console/presets');
const providerCatalog = require('../providers/catalog');
const providerCatalog = require('../providers/catalog');
const poolSeed = require('../providers/pool-seed');
const poolSeed = require('../providers/pool-seed');
@@ -11,7 +11,7 @@ const gpuCatalog = require('../gpu/catalog');
@@ -11,7 +11,7 @@ const gpuCatalog = require('../gpu/catalog');
const { tokenCosts } = require('../gpu/token-cost');
const { tokenCosts } = require('../gpu/token-cost');
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
-function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, gpus = null, gpuMarket = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
+function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
const r = express.Router();
const r = express.Router();
const supply = require('../platform/supply').createSupply({ db, registry, clock });
const supply = require('../platform/supply').createSupply({ db, registry, clock });
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
@@ -41,8 +41,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -41,8 +41,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };
return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };
}
}
if (section === 'providers') {
if (section === 'providers') {
+ const hints = vault && vault.hints ? await vault.hints() : new Map();
const rows = (await Promise.all((await registry.listProviders()).map(ops.providerView))).map((p) => ({
const rows = (await Promise.all((await registry.listProviders()).map(ops.providerView))).map((p) => ({
...p, key_present: p.credentials === 'configured', preference_order: p.priority,
...p, key_present: p.credentials === 'configured', preference_order: p.priority,
+ key_state: p.auth_mode === 'none' ? { source: 'not_required', hint: null, set_at: null }
+ : { source: hints.has(p.key) ? 'stored' : p.credentials === 'configured' ? 'environment' : 'missing',
+ hint: hints.get(p.key)?.hint || (providerSecret(p, env) ? `…${providerSecret(p, env).slice(-4)}` : null),
+ set_at: hints.get(p.key)?.updated_at || null },
+ last_check: p.metadata?.last_check || null,
markup_percent: Number(p.metadata && p.metadata.markup_percent) || 0,
markup_percent: Number(p.metadata && p.metadata.markup_percent) || 0,
uncensored: Boolean(p.metadata && p.metadata.uncensored),
uncensored: Boolean(p.metadata && p.metadata.uncensored),
}));
}));
@@ -106,10 +112,33 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -106,10 +112,33 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
if (value.length < 8 || value.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');
if (value.length < 8 || value.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');
const who = { actor: actor(req), trace: req.ov && req.ov.traceId };
const who = { actor: actor(req), trace: req.ov && req.ov.traceId };
await vault.put(provider.key, value, who);
await vault.put(provider.key, value, who);
- await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}` }, { ...who, origin: 'admin' });
+ await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}`, metadata: { ...provider.metadata, last_check: null } }, { ...who, origin: 'admin' });
await registry.audit(who.actor, 'provider.key_replaced', 'provider', provider.key, { trace: who.trace });
await registry.audit(who.actor, 'provider.key_replaced', 'provider', provider.key, { trace: who.trace });
return { ok: true, provider: provider.key };
return { ok: true, provider: provider.key };
}));
}));
+ r.delete('/api/v1/admin/console/providers/:key/key', write, wrap(async (req) => {
+ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
+ const provider = await registry.getProvider(req.params.key);
+ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
+ const who = { actor: actor(req), trace: req.ov && req.ov.traceId };
+ // Disable first: a request racing the store deletion must not fall back to an environment key.
+ await registry.upsertProvider({ key: provider.key, status: 'disabled', secret_ref: null,
+ metadata: { ...provider.metadata, last_check: null } }, { ...who, origin: 'admin' });
+ await vault.remove(provider.key);
+ await registry.audit(who.actor, 'provider.key_removed', 'provider', provider.key, { trace: who.trace });
+ return { ok: true, provider: provider.key };
+ }));
+ r.post('/api/v1/admin/console/providers/:key/check', write, wrap(async (req) => {
+ const provider = await registry.getProvider(req.params.key);
+ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
+ const { checkProvider } = require('../providers/check');
+ const result = await checkProvider(provider, { env, now: clock.now,
+ fetchImpl: fetchImpl === globalThis.fetch ? require('../providers/guarded-fetch').guardedFetch : fetchImpl });
+ // A check does not enable a provider or overwrite settings changed while its request was in flight.
+ const current = await registry.getProvider(provider.key);
+ if (current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });
+ return { provider: provider.key, ...result };
+ }));
r.post('/api/v1/admin/console/providers', json, write, wrap(async (req) => {
r.post('/api/v1/admin/console/providers', json, write, wrap(async (req) => {
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
const b = req.body || {};
const b = req.body || {};
@@ -275,7 +275,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -275,7 +275,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
- app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, gpus, gpuMarket, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));
+ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.
// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
@@ -115,9 +115,16 @@ async function testProviderKey(provider, key, { fetchImpl }) {
@@ -115,9 +115,16 @@ async function testProviderKey(provider, key, { fetchImpl }) {
url = `${base}/models?pageSize=1`;
url = `${base}/models?pageSize=1`;
headers = { 'x-goog-api-key': key };
headers = { 'x-goog-api-key': key };
}
}
+ if (provider.metadata && provider.metadata.models_endpoint) url = provider.metadata.models_endpoint;
+ if (provider.auth_mode === 'none') headers = {};
+ else if (provider.kind !== 'anthropic' && provider.kind !== 'gemini' && provider.auth_mode === 'x-api-key') headers = { 'x-api-key': key };
+ // A configured models endpoint receives a key only on the origin the owner entered it for.
+ try { if (new URL(url).origin !== new URL(base).origin) return { ok: false, message: `${name} has a model-list address on a different origin.` }; }
+ catch { return { ok: false, message: `${name} has an invalid model-list address.` }; }
const r = await probe(fetchImpl, url, { headers });
const r = await probe(fetchImpl, url, { headers });
if (r.status >= 200 && r.status < 300) {
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);
const list = r.json && (Array.isArray(r.json.data) ? r.json.data : Array.isArray(r.json.models) ? r.json.models : null);
+ if (!list) return { ok: false, message: `${name} answered without a model list.` };
return { ok: true, message: `${name} accepted the key${list ? ` and lists ${list.length} model${list.length === 1 ? '' : 's'}` : ''}.` };
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) };
return { ok: false, message: refused(name, r) };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+
+const { providerSecret } = require('../util');
+const { testProviderKey } = require('../owner-inputs');
+const { guardedFetch } = require('./guarded-fetch');
+
+/** One explicit owner check, with no inference, retries, redirects or fallback. Never retain upstream text. */
+async function checkProvider(provider, { env = process.env, fetchImpl = guardedFetch, now = Date.now } = {}) {
+ const started = now();
+ let calls = 0;
+ let httpStatus = null;
+ const finish = (ok, error = null, method = null) => ({
+ ok, error, method, http_status: httpStatus, requests: calls,
+ latency_ms: Math.max(0, now() - started), checked_at: new Date(started).toISOString(),
+ });
+ const key = providerSecret(provider, env);
+ if (provider.auth_mode !== 'none' && !key) return finish(false, 'key_not_set');
+ if (!provider.base_url) return finish(false, 'base_url_not_set');
+ let origin;
+ try { origin = new URL(provider.base_url).origin; } catch { return finish(false, 'invalid_base_url'); }
+ let failure = 'request_failed';
+ const once = async (url, init = {}) => {
+ const target = new URL(String(url));
+ if (target.origin !== origin || target.protocol !== 'https:' || target.username || target.password) {
+ failure = 'endpoint_origin_mismatch';
+ throw new Error(failure);
+ }
+ if (calls) { failure = 'request_limit'; throw new Error(failure); }
+ calls++;
+ try {
+ const response = await fetchImpl(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10000) });
+ httpStatus = response.status;
+ if (!response.ok) failure = `http_${response.status}`;
+ return response;
+ } catch (err) {
+ failure = ['AbortError', 'TimeoutError'].includes(err && err.name) ? 'timeout' : 'connection_failed';
+ throw new Error(failure);
+ }
+ };
+ let method = 'models';
+ try {
+ if (provider.metadata?.check_endpoint) {
+ method = 'account';
+ const header = provider.metadata.auth_header || (provider.auth_mode === 'x-api-key' ? 'x-api-key' : 'Authorization');
+ const headers = provider.auth_mode === 'none' ? {} : { [header]: header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key };
+ const result = await once(provider.metadata.check_endpoint, { method: 'GET', headers });
+ // Account data and key labels are deliberately discarded.
+ await result.body?.cancel();
+ return finish(result.ok, result.ok ? null : failure, method);
+ }
+ if (provider.kind === 'http' && provider.metadata?.search_adapters?.length) {
+ method = 'search';
+ const { adapters } = require('../search');
+ const adapter = provider.metadata.search_adapters.map(id => adapters.find(a => a.id === id)).find(Boolean);
+ if (!adapter) return finish(false, 'check_not_supported', method);
+ await adapter.search({ q: 'OpenVibe', limit: 1 }, {
+ key, fetchImpl: once, now, env: { ...env, ...(adapter.setting ? { [adapter.setting]: provider.base_url } : {}) },
+ });
+ return finish(true, null, method);
+ }
+ const result = await testProviderKey(provider, key, { fetchImpl: once });
+ return finish(result.ok, result.ok ? null : httpStatus >= 200 && httpStatus < 300 ? 'invalid_response' : failure, method);
+ } catch {
+ return finish(false, failure, method);
+ }
+}
+
+module.exports = { checkProvider };
@@ -616,6 +616,7 @@ const PROVIDERS = [
@@ -616,6 +616,7 @@ const PROVIDERS = [
},
},
{
{
key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',
key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',
+ check_endpoint: 'https://openrouter.ai/api/v1/key',
docs_url: 'https://openrouter.ai/docs/api-reference/overview',
docs_url: 'https://openrouter.ai/docs/api-reference/overview',
display_name: 'OpenRouter (the last fallback)',
display_name: 'OpenRouter (the last fallback)',
base_url: 'https://openrouter.ai/api/v1',
base_url: 'https://openrouter.ai/api/v1',
@@ -699,6 +700,7 @@ const PROVIDERS = [
@@ -699,6 +700,7 @@ const PROVIDERS = [
auth_header: 'x-subscription-token', models_endpoint: null, capabilities: ['web-search', 'image-search'],
auth_header: 'x-subscription-token', models_endpoint: null, capabilities: ['web-search', 'image-search'],
search_adapters: ['brave', 'brave-images'], docs_url: 'https://api-dashboard.search.brave.com/documentation/services/web-search' },
search_adapters: ['brave', 'brave-images'], docs_url: 'https://api-dashboard.search.brave.com/documentation/services/web-search' },
{ key: 'tavily', display_name: 'Tavily', kind: 'http', base_url: 'https://api.tavily.com', models_endpoint: null,
{ key: 'tavily', display_name: 'Tavily', kind: 'http', base_url: 'https://api.tavily.com', models_endpoint: null,
+ check_endpoint: 'https://api.tavily.com/usage',
capabilities: ['web-search'], search_adapters: ['tavily'], docs_url: 'https://docs.tavily.com/documentation/api-reference/endpoint/search' },
capabilities: ['web-search'], search_adapters: ['tavily'], docs_url: 'https://docs.tavily.com/documentation/api-reference/endpoint/search' },
{ key: 'exa', display_name: 'Exa', kind: 'http', base_url: 'https://api.exa.ai', auth_mode: 'x-api-key', models_endpoint: null,
{ key: 'exa', display_name: 'Exa', kind: 'http', base_url: 'https://api.exa.ai', auth_mode: 'x-api-key', models_endpoint: null,
capabilities: ['web-search'], search_adapters: ['exa'], docs_url: 'https://docs.exa.ai/reference/search' },
capabilities: ['web-search'], search_adapters: ['exa'], docs_url: 'https://docs.exa.ai/reference/search' },
@@ -29,6 +29,7 @@ function recordOf(p) {
@@ -29,6 +29,7 @@ function recordOf(p) {
metadata: {
metadata: {
catalog: 'pool-providers',
catalog: 'pool-providers',
api_family: p.api_family, models_endpoint: p.models_endpoint,
api_family: p.api_family, models_endpoint: p.models_endpoint,
+ check_endpoint: p.check_endpoint || null,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
search_adapters: p.search_adapters || [],
search_adapters: p.search_adapters || [],
data_policy: p.data_policy,
data_policy: p.data_policy,
@@ -97,7 +98,10 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -97,7 +98,10 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
} else if (prev.origin === 'catalog') {
} else if (prev.origin === 'catalog') {
const defaults = recordOf(p);
const defaults = recordOf(p);
const configured = prev.auth_mode === 'none' || Boolean(providerSecret(prev, env));
const configured = prev.auth_mode === 'none' || Boolean(providerSecret(prev, env));
- if (configured && prev.base_url !== p.base_url) defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
+ if (configured && prev.base_url !== p.base_url) {
+ defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
+ defaults.metadata.check_endpoint = null;
+ }
const metadata = { ...defaults.metadata, ...prev.metadata };
const metadata = { ...defaults.metadata, ...prev.metadata };
if (!configured && prev.base_url !== p.base_url) {
if (!configured && prev.base_url !== p.base_url) {
metadata.models_endpoint = defaults.metadata.models_endpoint;
metadata.models_endpoint = defaults.metadata.models_endpoint;
@@ -116,8 +120,11 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -116,8 +120,11 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
} else {
} else {
out.skipped.push(p.key);
out.skipped.push(p.key);
const protocol = recordOf(p).metadata;
const protocol = recordOf(p).metadata;
- if (prev.base_url !== p.base_url) protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
- const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
+ if (prev.base_url !== p.base_url) {
+ protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
+ protocol.check_endpoint = null;
+ }
+ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
}
}
for (const m of p.models) {
for (const m of p.models) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+
+const assert = require('assert');
+const crypto = require('crypto');
+const { checkProvider } = require('../server/providers/check');
+const { boot, request, token, suite } = require('./helpers');
+const { providerSecret } = require('../server/util');
+const t = suite('provider-check');
+const key = 'test-owner-key-never-in-response-1234';
+const replacement = 'test-replacement-key-never-in-response-5678';
+const env = { CHECK_KEY: key };
+const provider = { key: 'check', kind: 'openai', auth_mode: 'bearer', base_url: 'https://check.example/v1', secret_ref: 'env:CHECK_KEY' };
+const response = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+const manage = token('exchange', ['ai.provider.manage', 'ai.usage.read']);
+const unprivileged = token('chat', ['ai.run.read']);
+
+t.test('a model-list check is one free read at the registered endpoint with latency', async () => {
+ const calls = [];
+ let ticks = 1000;
+ const result = await checkProvider({ ...provider, metadata: { models_endpoint: 'https://check.example/catalog/models' } }, {
+ env, now: () => (ticks += 25), fetchImpl: async (url, init) => {
+ calls.push(url);
+ assert.strictEqual(init.headers.Authorization, `Bearer ${key}`);
+ assert.strictEqual(init.method, 'GET');
+ assert.strictEqual(init.redirect, 'error');
+ return response(200, { data: [{ id: 'test' }] });
+ },
+ });
+ assert.deepStrictEqual(calls, ['https://check.example/catalog/models']);
+ assert.strictEqual(result.ok, true);
+ assert.strictEqual(result.requests, 1);
+ assert.strictEqual(result.latency_ms, 25);
+});
+
+t.test('upstream refusals and thrown errors cannot echo the key; no retries', async () => {
+ for (const failure of [401, 429, 500, 'throw']) {
+ let calls = 0;
+ const result = await checkProvider(provider, { env, fetchImpl: async () => {
+ calls++;
+ if (failure === 'throw') throw new Error(`bad key ${key}`);
+ return response(failure, { error: { message: `bad key ${key}` } });
+ } });
+ assert.strictEqual(calls, 1);
+ assert.strictEqual(result.ok, false);
+ assert.strictEqual(result.error, failure === 'throw' ? 'connection_failed' : `http_${failure}`);
+ assert.ok(!JSON.stringify(result).includes(key));
+ }
+});
+
+t.test('missing keys and cross-origin model lists never make a request', async () => {
+ const fetchImpl = async () => { throw new Error('must not fetch'); };
+ const missing = await checkProvider(provider, { env: {}, fetchImpl });
+ assert.strictEqual(missing.error, 'key_not_set');
+ const moved = await checkProvider({ ...provider, metadata: { models_endpoint: 'https://other.example/models' } }, { env, fetchImpl });
+ assert.strictEqual(moved.ok, false);
+ assert.strictEqual(moved.requests, 0);
+});
+
+t.test('search checks use their adapter once, with one result and no inference fallback', async () => {
+ let calls = 0;
+ const result = await checkProvider({ ...provider, kind: 'http', base_url: 'https://api.search.brave.com/res/v1', metadata: { search_adapters: ['brave', 'brave-images'] } }, {
+ env, fetchImpl: async (url, init) => {
+ calls++;
+ assert.strictEqual(new URL(url).searchParams.get('count'), '1');
+ assert.strictEqual(init.headers['x-subscription-token'], key);
+ return response(200, { web: { results: [] } });
+ },
+ });
+ assert.strictEqual(result.ok, true);
+ assert.strictEqual(result.method, 'search');
+ assert.strictEqual(calls, 1);
+});
+
+t.test('registered free account checks take precedence over model lists and searches', async () => {
+ let calls = 0;
+ const result = await checkProvider({ ...provider, kind: 'http', metadata: { check_endpoint: 'https://check.example/usage', search_adapters: ['brave'] } }, {
+ env, fetchImpl: async (url) => { calls++; assert.strictEqual(url, 'https://check.example/usage'); return response(200, { label: key }); },
+ });
+ assert.strictEqual(result.ok, true);
+ assert.strictEqual(result.method, 'account');
+ assert.strictEqual(calls, 1);
+ assert.ok(!JSON.stringify(result).includes(key));
+});
+
+let h;
+let requests = 0;
+let authUsed;
+const logs = [];
+const logger = { log: (...v) => logs.push(v.join(' ')), warn: (...v) => logs.push(v.join(' ')), error: (...v) => logs.push(v.join(' ')) };
+const api = (method, path, body, tok = manage) => request(h.base, method, path, { body, tok });
+const path = '/api/v1/admin/console/providers/keycheck';
+t.test('boot the real encrypted store and save keys without making provider calls', async () => {
+ h = await boot({ env: { OV_CONFIG_KEKS: `test:${crypto.randomBytes(32).toString('base64')}`, OV_CONFIG_KEK_CURRENT: 'test', AI_USER_MODULES: 'off', CHECK_KEY: key }, log: logger,
+ fetchImpl: async (url, init) => { requests++; authUsed = init.headers.Authorization; return response(200, { data: [{ id: 'test' }] }); },
+ });
+ await h.registry.upsertProvider({ ...provider, key: 'keycheck', status: 'active', capabilities: ['chat'] });
+ requests = 0;
+ for (const value of [key, replacement]) {
+ const saved = await api('POST', `${path}/key`, { api_key: value });
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.ok(!saved.text.includes(value));
+ }
+ assert.strictEqual(requests, 0);
+ assert.strictEqual(providerSecret(await h.registry.getProvider('keycheck'), h.env), replacement);
+ const read = await api('GET', '/api/v1/admin/console/providers');
+ const stored = read.body.providers.find(p => p.key === 'keycheck');
+ assert.strictEqual(stored.key_state.hint, '…5678');
+ assert.strictEqual(stored.key_state.source, 'stored');
+ assert.ok(stored.key_state.set_at);
+ assert.ok(!read.text.includes(replacement) && !read.text.includes(key));
+ const history = JSON.stringify(await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys'").all());
+ assert.ok(!history.includes(key) && !history.includes(replacement), 'all persisted key revisions are encrypted');
+});
+
+t.test('the check route makes one request and records only its safe result', async () => {
+ const before = requests;
+ const check = await api('POST', `${path}/check`);
+ assert.strictEqual(check.status, 200, check.text);
+ assert.strictEqual(check.body.ok, true);
+ assert.strictEqual(requests - before, 1);
+ assert.strictEqual(authUsed, `Bearer ${replacement}`);
+ const stored = await h.registry.getProvider('keycheck');
+ assert.strictEqual(stored.metadata.last_check.ok, true);
+ assert.strictEqual(stored.status, 'active');
+ assert.ok(!JSON.stringify(stored.metadata).includes(replacement));
+});
+
+t.test('a service without owner capabilities gets 403 for every key operation', async () => {
+ const before = requests;
+ for (const [method, suffix] of [['POST', 'key'], ['DELETE', 'key'], ['POST', 'check']]) {
+ const denied = await api(method, `${path}/${suffix}`, { api_key: key }, unprivileged);
+ assert.strictEqual(denied.status, 403, denied.text);
+ }
+ assert.strictEqual(requests, before);
+});
+
+t.test('checking a seeded environment provider preserves its configuration ownership', async () => {
+ await h.registry.upsertProvider({ ...provider, key: 'envcheck', status: 'active', capabilities: ['chat'] }, { origin: 'seed' });
+ const before = requests;
+ const check = await api('POST', '/api/v1/admin/console/providers/envcheck/check');
+ assert.strictEqual(check.status, 200, check.text);
+ assert.strictEqual(check.body.ok, true);
+ assert.strictEqual(requests - before, 1);
+ assert.strictEqual((await h.registry.getProvider('envcheck')).origin, 'seed');
+});
+
+t.test('removal disables immediately, clears the stored key and disconnects environment fallback', async () => {
+ const removed = await api('DELETE', `${path}/key`);
+ assert.strictEqual(removed.status, 200, removed.text);
+ const stored = await h.registry.getProvider('keycheck');
+ assert.strictEqual(stored.status, 'disabled');
+ assert.strictEqual(stored.secret_ref, null);
+ assert.strictEqual(providerSecret(stored, h.env), '');
+ assert.strictEqual(stored.metadata.last_check, null);
+ const before = requests;
+ const check = await api('POST', `${path}/check`);
+ assert.strictEqual(check.body.error, 'key_not_set');
+ assert.strictEqual(requests, before);
+ assert.ok(!logs.join('\n').includes(key) && !logs.join('\n').includes(replacement));
+});
+
+t.run(() => h && h.stop());