openvibers/openvibe.ai Commits

Setup: guided AI setup API, provider keys, route pins and monthly caps (#501)

OP OpenVibers committed 24 h ago · 0b1a4a8fb7 · 9 files, +882 −4

* Setup: the guided AI setup API, provider keys, route pins and monthly caps

Adds /api/v1/setup for the owner's Set up AI page: each task kind's state, the
recommended provider and price, key pages, a tiny real test through the route's
own placement, route pins, last-30-day spend and a monthly cap per kind. Provider
keys go through ai.provider_keys and are never returned; a test never falls back
to the stub. Caps are checked before a run spends. Migration 0082 stores the caps.

* Setup API is an admin surface: keep it out of the public OpenAPI document

The setup routes need a service token (ai.provider.manage), like the admin API, so they
are listed with the other internal routes in the OpenAPI walk. test/openapi.test.js passes.

* Setup: quote the route capability keys so contracts-check reads them as fields

The contracts scanner treats an unquoted capability key as an auth grant; these are route
fields (chat, image, transcribe, speak, embed), the same shape the seed uses.

Changes

ddiff --git a/migrations/0082_task_kind_caps.sql b/migrations/0082_task_kind_caps.sql
nnew file mode 100644
iindex 0000000..d691516
---- /dev/null
++++ b/migrations/0082_task_kind_caps.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- The monthly spend cap per task kind on the Setup page (OpenVibe.Network /admin/ai/setup).
++-- 0 means no cap: the kind is metered but never refused by this table. The spend itself is the
++-- runs' own cost, read for the current month; this table only holds the ceiling a person set.
++CREATE TABLE task_kind_caps (
++ task_kind text COLLATE "C" PRIMARY KEY,
++ monthly_cap_usd double precision NOT NULL DEFAULT 0 CHECK (monthly_cap_usd >= 0),
++ updated_at timestamptz NOT NULL,
++ updated_by text COLLATE "C"
++);
ddiff --git a/server/api/setup.js b/server/api/setup.js
nnew file mode 100644
iindex 0000000..018c263
---- /dev/null
++++ b/server/api/setup.js
@@@ -0,0 +1,486 @@
++'use strict';
++/**
++ * The guided AI setup API (OpenVibe.Network /admin/ai/setup).
++ *
++ * GET /api/v1/setup every step, its state, price, key page and spend
++ * PUT /api/v1/setup/providers/:provider/key write a provider key (ai.provider_keys) and turn it on
++ * POST /api/v1/setup/providers/:provider/test one tiny request with that provider
++ * POST /api/v1/setup/test/:step one tiny request through the step's own route
++ * PUT /api/v1/setup/routes/:step pin the model the route uses
++ * PUT /api/v1/setup/caps/:step the monthly spend cap for the step
++ * POST /api/v1/setup/recommended fill every route with its recommended model
++ *
++ * A provider key is written through the same sealed store the owner settings use and is never
++ * returned, logged or put in an error: the answer carries only the last four characters. A test
++ * goes through the route's placement (pool.execute) or, for web search, the engine adapter, and
++ * returns the answer and what it cost. Everything here needs ai.provider.manage; Network's service
++ * token holds it.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const childProcess = require('child_process');
++const express = require('express');
++const { CAPS } = require('../auth');
++const { AiError, sendError, providerSecret } = require('../util');
++const { STEPS, stepById, offersOf, keyPageOf } = require('../setup/catalog');
++
++const MONTH_MS = 30 * 24 * 3600 * 1000;
++const MAX_KEY = 400;
++const money = (n) => Math.round((Number(n) || 0) * 1e6) / 1e6;
++
++/** Split a route's choice into the provider and model it pins, if any. */
++function currentOf(route) {
++ if (!route) return { provider: null, model: null };
++ const constraints = route.constraints || {};
++ const authority = typeof constraints.authority === 'string' ? constraints.authority : '';
++ const [authorityProvider, ...authorityRest] = authority.split(':');
++ return {
++ provider: (authorityProvider || (route.primary && route.primary.provider) || null),
++ model: constraints.model || (authorityRest.length ? authorityRest.join(':') : null) || (route.primary && route.primary.model) || null,
++ };
++}
++
++function priceOfModel(model, step) {
++ if (!model) return null;
++ const meta = model.metadata || {};
++ if (step.modelType === 'image' && meta.price_per_image_usd != null) {
++ return { kind: 'image', per_image_usd: Number(meta.price_per_image_usd), unit: 'picture', text: `$${Number(meta.price_per_image_usd)} per picture` };
++ }
++ if (step.modelType === 'speech' && model.cost && model.cost.in_per_mtok != null) {
++ return { kind: 'characters', per_million_chars_usd: Number(model.cost.in_per_mtok), unit: '1M characters', text: `$${Number(model.cost.in_per_mtok)} per 1M characters` };
++ }
++ if (step.modelType === 'stt' && meta.price_per_minute_usd != null) {
++ return { kind: 'minute', per_minute_usd: Number(meta.price_per_minute_usd), unit: 'minute', text: `$${Number(meta.price_per_minute_usd)} per minute` };
++ }
++ if (model.cost && model.cost.in_per_mtok != null) {
++ const out = model.cost.out_per_mtok == null ? null : Number(model.cost.out_per_mtok);
++ const cached = model.cost.cached_per_mtok == null ? null : Number(model.cost.cached_per_mtok);
++ return { kind: 'tokens', in_per_mtok: Number(model.cost.in_per_mtok), out_per_mtok: out, cached_per_mtok: cached, unit: '1M tokens',
++ text: out == null ? `$${model.cost.in_per_mtok} per 1M tokens` : `$${model.cost.in_per_mtok} in · $${out} out per 1M tokens` };
++ }
++ return null;
++}
++
++/** An offer with the registry's price and display name when it has them. */
++function viewOffer(step, offer, { providers, models, configured, recommended = false }) {
++ const provider = providers.get(offer.provider) || null;
++ const model = models.get(`${offer.provider}/${offer.model}`) || null;
++ const price = priceOfModel(model, step) || offer.price;
++ return {
++ provider: offer.provider,
++ provider_name: (provider && provider.display_name) || offer.provider,
++ model: offer.model,
++ label: offer.label || (model && model.display_name) || offer.model,
++ why: offer.why || null,
++ note: offer.note || null,
++ price,
++ price_text: price ? price.text : 'price not published',
++ source: offer.source || null,
++ checked: offer.checked || null,
++ recommended,
++ keyless: Boolean(offer.keyless),
++ configured: Boolean(configured),
++ key_page: offer.key_page || keyPageOf(provider),
++ };
++}
++
++function wavClip() {
++ // A real two-second spoken clip for the speech-to-text test: ffmpeg's flite voice when the
++ // host has it (the deploy host does), else a quiet tone. Written to a temp file for the adapter.
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ai-setup-'));
++ const file = path.join(dir, 'ready.wav');
++ const args = ['-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', 'flite=text=OpenVibe is ready:voice=slt', '-t', '2', '-ar', '16000', '-ac', '1', '-y', file];
++ try {
++ childProcess.execFileSync('ffmpeg', args, { timeout: 15000, stdio: 'ignore' });
++ } catch {
++ // A tone still proves the route; only the transcript is less interesting.
++ childProcess.execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=2', '-ar', '16000', '-ac', '1', '-y', file], { timeout: 15000, stdio: 'ignore' });
++ }
++ return { file, dir };
++}
++
++function tinyPng() {
++ return { mediaType: 'image/png', base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' };
++}
++
++async function shrinkImage(b64) {
++ if (!b64) return null;
++ try {
++ const sharp = require('sharp');
++ const buf = await sharp(Buffer.from(b64, 'base64')).resize(256, 256, { fit: 'cover' }).png().toBuffer();
++ return `data:image/png;base64,${buf.toString('base64')}`;
++ } catch {
++ // sharp is optional; a provider that answered with a URL is shown as it is, and an inline
++ // picture is shown only when it is small enough to travel.
++ return b64.length <= 350000 ? `data:image/png;base64,${b64}` : null;
++ }
++}
++
++function scrub(text, secret) {
++ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
++ if (secret) s = s.split(String(secret)).join('[hidden]');
++ s = s.replace(/\b(sk|key|token)[-_][A-Za-z0-9_-]{8,}\b/gi, '[hidden]');
++ return s.length > 400 ? `${s.slice(0, 399)}…` : s;
++}
++
++function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth, settings = null, search = null, env = process.env, clock = { now: () => Date.now() }, log = console }) {
++ const r = express.Router();
++ const read = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
++ const write = auth.requireCap(CAPS.providerManage);
++ const wrap = (fn) => async (req, res) => {
++ try { const out = await fn(req); if (out !== undefined) res.json(out); }
++ catch (err) { sendError(res, err, req.ov, log); }
++ };
++ const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
++ const actorOf = (req) => ({ type: 'service', id: String((req.principal && req.principal.sub) || 'network').replace(/^svc:/, '') });
++ const actorId = (req) => `service:${(req.principal && req.principal.sub) || 'network'}`;
++
++ const providersOf = async () => new Map((await registry.listProviders()).map((p) => [p.key, p]));
++ const modelsOf = async () => new Map((await registry.listModels()).map((m) => [`${m.provider_key}/${m.model_key}`, m]));
++ const routesOf = async () => new Map((await registry.listRoutes()).map((x) => [x.key, x]));
++ const configured = (provider) => Boolean(provider && provider.key !== 'stub' && provider.status === 'active' && (provider.auth_mode === 'none' || Boolean(providerSecret(provider, env))));
++
++ async function hintOf(providerKey) {
++ if (!settings || !settings.providerKeys) return null;
++ try {
++ const marker = await settings.providerKeys.store.secretMarker(`${providerKey}.key`);
++ return marker && marker.hint ? { last4: marker.hint, set_at: marker.set_at || null, bound_to: marker.bound_to || null } : null;
++ } catch { return null; }
++ }
++
++ async function healthOf() {
++ const out = new Set();
++ try {
++ for (const row of await db.prepare("SELECT provider_key, model_key FROM provider_circuits WHERE state = 'open'").all()) out.add(`${row.provider_key}/${row.model_key || ''}`);
++ } catch { /* no circuit table yet: not failing */ }
++ try {
++ for (const row of await db.prepare("SELECT provider_key FROM provider_health WHERE state = 'open'").all()) out.add(`${row.provider_key}/`);
++ } catch { /* older database: no provider health */ }
++ return out;
++ }
++
++ async function spendOf(routeKey) {
++ const since = new Date(clock.now() - MONTH_MS).toISOString();
++ const monthStart = new Date(clock.now());
++ monthStart.setUTCDate(1); monthStart.setUTCHours(0, 0, 0, 0);
++ const aliases = (await registry.listRoutes()).filter((x) => x.alias_of === routeKey).map((x) => x.key);
++ const keys = [routeKey, ...aliases];
++ const marks = keys.map(() => '?').join(', ');
++ try {
++ const last30 = await db.prepare(`SELECT COUNT(*)::int AS calls, COALESCE(SUM(cost_usd), 0) AS cost FROM runs
++ WHERE created_at >= ? AND route_key IN (${marks}) AND status IN ('succeeded', 'cached')`).get(since, ...keys);
++ const month = await db.prepare(`SELECT COUNT(*)::int AS calls, COALESCE(SUM(cost_usd), 0) AS cost FROM runs
++ WHERE created_at >= ? AND route_key IN (${marks}) AND status IN ('succeeded', 'cached')`).get(monthStart.toISOString(), ...keys);
++ return { calls_30d: Number(last30 && last30.calls) || 0, cost_30d: money(last30 && last30.cost), calls_month: Number(month && month.calls) || 0, cost_month: money(month && month.cost) };
++ } catch { return { calls_30d: 0, cost_30d: 0, calls_month: 0, cost_month: 0 }; }
++ }
++
++ async function capOf(kind) {
++ const row = await db.prepare('SELECT monthly_cap_usd FROM task_kind_caps WHERE task_kind = ?').get(kind).catch(() => null);
++ return row ? Number(row.monthly_cap_usd) : 0;
++ }
++
++ async function routeFor(step) {
++ if (!step.route) return null;
++ let route = await registry.getRoute(step.route);
++ if (!route && step.id === 'image') {
++ // The image route exists only once a model is chosen (AI_IMAGE_MODEL seeds it in production).
++ route = await registry.createRouteVersion(step.route, {
++ 'capability': 'image', constraints: { objective: 'correctness', provider_scope: 'paid' },
++ max_output_tokens: null, response_format: 'text', timeout_ms: 120000,
++ }, { actor: 'service:network' });
++ }
++ return route;
++ }
++
++ async function ensureModel(step, providerKey, modelKey, { allowUnknown = false } = {}) {
++ if (!step.modelType) return null;
++ const existing = await registry.getModel(providerKey, modelKey);
++ if (existing) {
++ if (existing.status !== 'active') await registry.upsertModel({ provider_key: providerKey, model_key: modelKey, status: 'active' }, { actor: 'setup' });
++ return await registry.getModel(providerKey, modelKey);
++ }
++ const offer = offersOf(step).find((o) => o.provider === providerKey && o.model === modelKey);
++ if (!offer && !allowUnknown) throw new AiError(422, 'setup.model_unknown', `choose ${modelKey} from the list for ${step.name}`);
++ if (!offer && allowUnknown) return await registry.upsertModel({ provider_key: providerKey, model_key: modelKey, display_name: modelKey, type: step.modelType, status: 'active' }, { actor: 'setup' });
++ const price = offer.price || {};
++ const cost = price.kind === 'tokens'
++ ? { in_per_mtok: price.in_per_mtok, out_per_mtok: price.out_per_mtok, cached_per_mtok: price.cached_per_mtok == null ? null : price.cached_per_mtok, source: offer.source || null, verified_at: offer.checked || null, effective_from: offer.checked || null }
++ : price.kind === 'characters'
++ ? { in_per_mtok: price.per_million_chars_usd, out_per_mtok: 0, source: offer.source || null, verified_at: offer.checked || null }
++ : { in_per_mtok: price.kind === 'minute' ? 0 : null, out_per_mtok: 0, source: offer.source || null, verified_at: offer.checked || null };
++ const metadata = { setup_seeded: true, ...(price.kind === 'image' ? { price_per_image_usd: price.per_image_usd } : {}),
++ ...(price.kind === 'minute' ? { price_per_minute_usd: price.per_minute_usd } : {}),
++ ...(price.kind === 'characters' ? { unit: 'characters', price_per_million_chars_usd: price.per_million_chars_usd } : {}) };
++ return await registry.upsertModel({
++ provider_key: providerKey, model_key: modelKey, display_name: offer.label || modelKey, type: step.modelType, status: 'active',
++ cost, supports: { json: step.id === 'vision', tools: step.id === 'code', streaming: step.modelType === 'chat', vision: step.id === 'vision' },
++ metadata,
++ }, { actor: 'setup' });
++ }
++
++ async function stepView(step, { providers, models, routes, health, searchEngines }) {
++ const route = routes.get(step.route) || null;
++ const current = currentOf(route);
++ const offers = offersOf(step);
++ const recommended = viewOffer(step, step.recommended, { providers, models, configured: configured(providers.get(step.recommended.provider)), recommended: true });
++ const alternatives = step.alternatives.map((o) => viewOffer(step, o, { providers, models, configured: configured(providers.get(o.provider)) }));
++ recommended.key_hint = await hintOf(recommended.provider);
++ for (const o of alternatives) o.key_hint = await hintOf(o.provider);
++
++ let routedOffers = [];
++ if (route && pool) {
++ try { routedOffers = await pool.offers(route, step.operation, {}); } catch { routedOffers = []; }
++ }
++ const offered = new Set(routedOffers.map((o) => `${o.provider}/${o.model || ''}`));
++ const readyOffer = routedOffers.find((o) => configured(providers.get(o.provider)));
++ const failing = Boolean(route && !route.disabled && routedOffers.length && routedOffers.some((o) => health.has(`${o.provider}/${o.model || ''}`) || health.has(`${o.provider}/`)))
++ || Boolean(route && route.disabled);
++
++ let searchState = null;
++ if (step.id === 'search') {
++ const available = (searchEngines || []).filter((e) => e.available && e.healthy !== false);
++ searchState = { engines: available.map((e) => ({ id: e.id, price_per_1000: e.price_per_1000, byok: e.byok })), ready: available.length > 0 };
++ }
++
++ let state = 'not_set';
++ if (step.id === 'search') state = searchState.ready ? 'ready' : failing ? 'failing' : 'not_set';
++ else if (failing) state = 'failing';
++ else if (route && !route.disabled && readyOffer) state = 'ready';
++ else if (route && !route.disabled && routedOffers.length) state = 'not_set';
++
++ const spend = step.route ? await spendOf(step.route) : { calls_30d: 0, cost_30d: 0, calls_month: 0, cost_month: 0 };
++ const cap = await capOf(step.id);
++ const hint = await hintOf(step.recommended.provider);
++
++ // The options the defaults table offers: the curated ones first, then the registry's own
++ // active models of this kind, so a model the owner synced is never hidden.
++ const options = offers.map((o) => viewOffer(step, o, { providers, models, configured: configured(providers.get(o.provider)) }));
++ for (const model of models.values()) {
++ if (step.modelType && model.type !== step.modelType && !(step.modelType === 'chat' && model.type === 'vision')) continue;
++ if (!providers.has(model.provider_key)) continue;
++ const key = `${model.provider_key}/${model.model_key}`;
++ if (options.some((o) => `${o.provider}/${o.model}` === key)) continue;
++ options.push(viewOffer(step, { provider: model.provider_key, model: model.model_key, label: model.display_name, price: null, source: model.cost && model.cost.source }, {
++ providers, models, configured: configured(providers.get(model.provider_key)),
++ }));
++ }
++ return {
++ id: step.id, name: step.name, short: step.short, what: step.what, uses: step.uses,
++ route: step.route, state, ready: state === 'ready',
++ recommended, alternatives, options,
++ current, route_version: route ? route.version : null,
++ offers: routedOffers,
++ configured_offer: readyOffer ? { provider: readyOffer.provider, model: readyOffer.model } : null,
++ key_hint: hint,
++ key_page: recommended.key_page,
++ price: recommended.price,
++ spend, cap_usd: cap,
++ estimated_month_usd: spend.cost_30d,
++ month_usd: spend.cost_month,
++ search: searchState,
++ };
++ }
++
++ async function state() {
++ const [providers, models, routes, health, searchEngines] = await Promise.all([
++ providersOf(), modelsOf(), routesOf(), healthOf(),
++ search ? search.engines().catch(() => []) : Promise.resolve([]),
++ ]);
++ const steps = [];
++ for (const step of STEPS) steps.push(await stepView(step, { providers, models, routes, health, searchEngines }));
++ return {
++ ok: true, generated_at: new Date(clock.now()).toISOString(),
++ steps,
++ summary: { ready: steps.filter((s) => s.ready).length, total: steps.length },
++ defaults: steps.map((s) => ({
++ id: s.id, name: s.name, route: s.route, current: s.current, options: s.options,
++ estimated_month_usd: s.estimated_month_usd, month_usd: s.month_usd, calls_30d: s.spend.calls_30d,
++ cap_usd: s.cap_usd,
++ })),
++ };
++ }
++
++ r.get('/api/v1/setup', read, wrap(async () => await state()));
++
++ r.put('/api/v1/setup/providers/:provider/key', write, wrap(async (req) => {
++ const key = String((body(req).key) || '').trim();
++ if (key.length < 8 || key.length > MAX_KEY) throw new AiError(422, 'setup.key_invalid', 'a key is 8 to 400 characters');
++ if (/\s/.test(key)) throw new AiError(422, 'setup.key_invalid', 'a key has no spaces or line breaks');
++ const provider = await registry.getProvider(req.params.provider);
++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
++ if (provider.auth_mode === 'none') throw new AiError(422, 'setup.key_unneeded', `${provider.display_name || provider.key} needs no key`);
++ if (!settings || !settings.providerKeys) throw new AiError(503, 'config.unavailable', 'the provider key store is not ready');
++ try { await settings.providerKeys.setKey(provider.key, key, { actor: actorOf(req), reason: 'provider setup page' }); }
++ catch (err) { throw new AiError(err.status && Number.isInteger(err.status) ? err.status : 422, 'setup.key_rejected', scrub((err && err.message) || 'the key was not accepted', key)); }
++ if (provider.status !== 'active') await registry.upsertProvider({ key: provider.key, status: 'active' }, { actor: actorId(req), origin: 'admin' });
++ const hint = await hintOf(provider.key);
++ return { ok: true, provider: provider.key, hint, configured: true };
++ }));
++
++ /** One tiny request through the step's route, pinned to the offer being tested. */
++ async function runTest(step, { providerKey, modelKey, req }) {
++ // The key is the one thing that must never appear in an answer, a log line or an error.
++ const provider = step.id === 'search' ? null : await registry.getProvider(providerKey);
++ if (step.id !== 'search' && !provider) throw new AiError(404, 'ai.not_found', 'no such provider');
++ const secret = provider ? providerSecret(provider, env) : '';
++
++ if (step.id === 'search') {
++ if (!search) throw new AiError(503, 'search.unavailable', 'search is not configured on this service');
++ const out = await search.search('web', { q: 'OpenVibe network', limit: 3, engine: providerKey }).catch((err) => {
++ throw new AiError(err.status || 502, 'setup.test_failed', scrub(err.message, secret));
++ });
++ return {
++ ok: true, step: step.id, route: null, provider: providerKey, model: providerKey,
++ answer: { title: (out.results[0] && out.results[0].title) || 'No results', url: (out.results[0] && out.results[0].url) || null, count: out.results.length },
++ cost_usd: money(out.receipt && out.receipt.cost_usd), latency_ms: out.receipt && out.receipt.time_ms,
++ };
++ }
++
++ const route = await routeFor(step);
++ if (!route) throw new AiError(503, 'route.unavailable', `there is no route for ${step.name} yet`);
++ if (!pool) throw new AiError(503, 'route.unavailable', 'routing is not available on this service');
++ const testRoute = { ...route, constraints: { ...(route.constraints || {}), ...(modelKey ? { model: modelKey, authority: `${providerKey}:${modelKey}` } : { authority: providerKey }) } };
++ const started = clock.now();
++ let out;
++ try {
++ if (step.operation === 'chat') {
++ out = await pool.execute(testRoute, 'chat', {
++ model: modelKey, maxTokens: 32, temperature: 0,
++ system: [{ text: 'Answer with one short sentence. Never repeat the prompt.' }],
++ messages: [{ role: 'user', content: step.id === 'vision' ? 'What is in this picture? Answer in one sentence.' : 'Say that the AI setup is ready, in one short sentence.' }],
++ ...(step.id === 'vision' ? { image: tinyPng() } : {}),
++ }, { contentPolicy: 'standard' });
++ } else if (step.operation === 'image') {
++ const model = await registry.getModel(providerKey, modelKey);
++ const perImage = model && model.metadata && Number(model.metadata.price_per_image_usd);
++ out = await pool.execute(testRoute, 'image', {
++ model: modelKey, prompt: 'A small blue square on a plain white background, flat icon.', n: 1, size: '1024x1024',
++ }, { contentPolicy: 'standard', ...(Number.isFinite(perImage) && perImage >= 0 ? { imagePriceUsd: perImage } : {}) });
++ } else if (step.operation === 'embed') {
++ out = await pool.execute(testRoute, 'embed', { model: modelKey, input: 'OpenVibe answers by meaning.' }, { contentPolicy: 'standard' });
++ } else if (step.operation === 'speak') {
++ out = await pool.execute(testRoute, 'speak', { model: modelKey, voice: providerKey === 'polly' ? 'pl_joanna_neural' : 'gc_neural_chad', text: 'OpenVibe is ready.', format: 'mp3' }, { contentPolicy: 'standard' });
++ } else if (step.operation === 'transcribe') {
++ const clip = wavClip();
++ try {
++ out = await pool.execute(testRoute, 'transcribe', { model: modelKey, filePath: clip.file, language: 'en', live: false, timeoutMs: 120000 }, { contentPolicy: 'standard' });
++ } finally { try { fs.rmSync(clip.dir, { recursive: true, force: true }); } catch { /* */ } }
++ } else {
++ throw new AiError(422, 'setup.test_unavailable', `there is no test for ${step.name}`);
++ }
++ } catch (err) {
++ const status = err && err.status && Number.isInteger(err.status) ? err.status : 502;
++ throw new AiError(status >= 400 && status < 600 ? status : 502, 'setup.test_failed', scrub((err && err.message) || 'the test did not answer', secret));
++ }
++ // The stub is the network's safety net, never proof that a key works: a test that fell back
++ // to it says so instead of showing synthetic text as the provider's answer.
++ if (out.provider === 'stub' || (out.synthetic && providerKey !== 'stub')) {
++ throw new AiError(502, 'setup.test_failed', `no real ${providerKey} answer reached the route; it fell back to the stub. Check the key, the provider status and the model.`);
++ }
++ const latency = clock.now() - started;
++ const result = out.result || {};
++ let answer = null;
++ if (step.operation === 'chat') answer = { text: String(result.text || '').slice(0, 800) };
++ else if (step.operation === 'image') {
++ const first = (result.images && result.images[0]) || {};
++ answer = { image: first.url || await shrinkImage(first.b64), count: (result.images || []).length };
++ } else if (step.operation === 'embed') answer = { dimensions: (result.vectors && result.vectors[0] && result.vectors[0].length) || 0, sample: (result.vectors && result.vectors[0] || []).slice(0, 8) };
++ else if (step.operation === 'speak') answer = { characters: (out.usage && out.usage.characters) || String('OpenVibe is ready.').length, mime_type: result.mimeType || 'audio/mpeg' };
++ else if (step.operation === 'transcribe') answer = { text: String(result.text || '(no words heard)').slice(0, 800), segments: (result.segments || []).length };
++ return {
++ ok: true, step: step.id, route: step.route, provider: out.provider, model: out.model,
++ answer, cost_usd: money(out.cost), latency_ms: latency, synthetic: Boolean(out.synthetic),
++ };
++ }
++
++ r.post('/api/v1/setup/test/:step', write, wrap(async (req) => {
++ const step = stepById(req.params.step);
++ if (!step) throw new AiError(404, 'setup.step_unknown', 'no such setup step');
++ const b = body(req);
++ const providerKey = String(b.provider || step.recommended.provider);
++ if (step.id !== 'search') {
++ const provider = await registry.getProvider(providerKey);
++ if (!provider) throw new AiError(404, 'ai.not_found', `no provider ${providerKey}`);
++ if (provider.auth_mode !== 'none' && !providerSecret(provider, env)) throw new AiError(409, 'setup.key_missing', `paste the ${provider.display_name || provider.key} key first`);
++ }
++ const modelKey = step.id === 'search' ? null : String(b.model || step.recommended.model);
++ if (step.id !== 'search') await ensureModel(step, providerKey, modelKey);
++ return await runTest(step, { providerKey, modelKey, req });
++ }));
++
++ r.post('/api/v1/setup/providers/:provider/test', write, wrap(async (req) => {
++ const provider = await registry.getProvider(req.params.provider);
++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
++ const searchOnly = Array.isArray(provider.capabilities) && provider.capabilities.includes('web-search') && !provider.capabilities.some((c) => ['chat', 'speak', 'transcribe', 'embed', 'image'].includes(c));
++ const step = stepById(searchOnly ? 'search' : 'chat');
++ const modelKey = searchOnly ? null : String(body(req).model || provider.default_model || step.recommended.model);
++ if (!searchOnly) await ensureModel(step, provider.key, modelKey, { allowUnknown: true });
++ return await runTest(step, { providerKey: provider.key, modelKey });
++ }));
++
++ r.put('/api/v1/setup/routes/:step', write, wrap(async (req) => {
++ const step = stepById(req.params.step);
++ if (!step) throw new AiError(404, 'setup.step_unknown', 'no such setup step');
++ if (!step.route) throw new AiError(422, 'setup.route_absent', `${step.name} has no route to change`);
++ const b = body(req);
++ const providerKey = b.provider == null ? null : String(b.provider);
++ const modelKey = b.model == null ? null : String(b.model);
++ let route = await routeFor(step);
++ if (!route) throw new AiError(503, 'route.unavailable', `there is no route for ${step.name} yet`);
++ if (providerKey) {
++ const provider = await registry.getProvider(providerKey);
++ if (!provider) throw new AiError(404, 'ai.not_found', `no provider ${providerKey}`);
++ if (provider.status !== 'active' && !providerSecret(provider, env)) throw new AiError(409, 'setup.key_missing', `paste the ${provider.display_name || provider.key} key first`);
++ await ensureModel(step, providerKey, modelKey);
++ }
++ const constraints = { ...(route.constraints || {}) };
++ if (providerKey && modelKey) { constraints.model = modelKey; constraints.authority = `${providerKey}:${modelKey}`; }
++ else { delete constraints.model; delete constraints.authority; }
++ route = await registry.createRouteVersion(step.route, {
++ 'capability': step.capability, constraints,
++ options: route.options, max_output_tokens: route.max_output_tokens,
++ response_format: route.response_format, timeout_ms: route.timeout_ms, alias_of: route.alias_of,
++ }, { actor: actorId(req), trace: req.ov && req.ov.traceId });
++ return { ok: true, step: step.id, route: route.key, version: route.version, current: currentOf(route) };
++ }));
++
++ r.put('/api/v1/setup/caps/:step', write, wrap(async (req) => {
++ const step = stepById(req.params.step);
++ if (!step) throw new AiError(404, 'setup.step_unknown', 'no such setup step');
++ const cap = Number(body(req).cap_usd);
++ if (!Number.isFinite(cap) || cap < 0 || cap > 100000) throw new AiError(422, 'setup.cap_invalid', 'the cap is a monthly amount from 0 to 100,000');
++ const at = new Date(clock.now()).toISOString();
++ await db.prepare(`INSERT INTO task_kind_caps (task_kind, monthly_cap_usd, updated_at, updated_by) VALUES (?, ?, ?, ?)
++ ON CONFLICT (task_kind) DO UPDATE SET monthly_cap_usd = excluded.monthly_cap_usd, updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
++ .run(step.id, money(cap), at, actorOf(req).id);
++ return { ok: true, step: step.id, cap_usd: money(cap), updated_at: at };
++ }));
++
++ r.post('/api/v1/setup/recommended', write, wrap(async (req) => {
++ const applied = [];
++ for (const step of STEPS) {
++ if (!step.route) continue;
++ const offer = step.recommended;
++ try {
++ await ensureModel(step, offer.provider, offer.model);
++ const route = await routeFor(step);
++ if (!route) { applied.push({ step: step.id, ok: false, error: 'no route' }); continue; }
++ const constraints = { ...(route.constraints || {}), model: offer.model, authority: `${offer.provider}:${offer.model}` };
++ const next = await registry.createRouteVersion(step.route, {
++ 'capability': step.capability, constraints, options: route.options, max_output_tokens: route.max_output_tokens,
++ response_format: route.response_format, timeout_ms: route.timeout_ms, alias_of: route.alias_of,
++ }, { actor: actorId(req), trace: req.ov && req.ov.traceId });
++ applied.push({ step: step.id, ok: true, route: next.key, version: next.version, provider: offer.provider, model: offer.model });
++ } catch (err) {
++ applied.push({ step: step.id, ok: false, error: scrub(err.message) });
++ }
++ }
++ return { ok: true, applied };
++ }));
++
++ return r;
++}
++
++module.exports = { setupRouter, currentOf, priceOfModel, scrub };
ddiff --git a/server/app.js b/server/app.js
iindex 8b0f846..7cb5c05 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -13,6 +13,7 @@ const creations = require('./creations');
const home = require('./home');
const openapi = require('./openapi');
const { adminRouter } = require('./api/admin');
++const { setupRouter } = require('./api/setup');
const { CAPS } = require('./auth');
const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
@@@ -24,7 +25,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
--function createApp({ choices = null, searchFetch = null, 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, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null, deepDive = null, mediaStore = null }) {
++function createApp({ choices = null, searchFetch = null, 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, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null, deepDive = null, mediaStore = null, search = null }) {
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@@ -285,6 +286,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// Community capacity (design: "Offer to the network"): the public offers Exchange lists and the seller's /sell
// 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(setupRouter({ config, db, registry, pool, runs, auth, settings, search, env, clock, 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, env, gpus, gpuMarket, supply, abuse, priceBook, modelSync, creditGrants, sessionCookies, vault, settings, config, log }));
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
ddiff --git a/server/index.js b/server/index.js
iindex 7d5fcd0..81d7aaf 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -23,6 +23,7 @@ const { createRunBudgets } = require('./run-budgets');
const { createFreeAllowance } = require('./free-allowance');
const { createTiers } = require('./govern');
const { createRuns } = require('./runs');
++const taskKindCaps = require('./setup/caps');
const { seed } = require('./workflows/seed');
const { createAuth, createNetworkKeys } = require('./auth');
const { jwksClient } = require('openvibe-sdk/auth');
@@@ -184,7 +185,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// BA3: the runs and the batcher need each other (a batch-class run is created first, then queued), so
// the runs hold this facade and the batcher fills it in below, before any request can arrive.
const batchRef = {};
-- const runs = createRuns({ choices, db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef, ownerConfig,
++ const runs = createRuns({ choices, db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef, ownerConfig, caps: taskKindCaps,
// BA2: a run that waits for a provider's batch answer is not interrupted by a restart: it stays
// queued until the batcher finishes it (the provider is still working on it).
batchWaiting: async (runId) => {
@@@ -244,7 +245,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
-- const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share, deepDive, mediaStore });
++ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share, deepDive, mediaStore, search });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
ddiff --git a/server/runs.js b/server/runs.js
iindex 68887e8..e03078c 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -126,7 +126,7 @@ function routePinRequest(value, principal, wf, internal = false) {
return { provider, model };
}
--function createRuns({ choices = null, db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null, ownerConfig = null }) {
++function createRuns({ choices = null, db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null, ownerConfig = null, caps = null }) {
// The owner's economy switches (server/owner-config.js). An unconfigured kind keeps its old
// behaviour (ECONOMY_DEFAULTS), so this only ever turns a way of paying less off.
const economyConfig = ownerConfig || (() => { try { return require('./owner-config').createOwnerConfig({ db, registry, clock }); } catch { return null; } })();
@@@ -305,6 +305,9 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
throw new AiError(422, 'input.invalid', `route ${shown} is not offered for ${operation} to this caller; GET /api/v1/routes?operation=${operation} lists the offers`);
}
}
++ // The Setup page's monthly cap for this task kind: checked before any cache, quota or provider
++ // call, so a cap refuses the run before it can spend. 0 is no cap.
++ if (caps && route && route.key) await caps.check({ db, routeKey: route.key, now: clock.now() });
const options = body.options && typeof body.options === 'object' ? body.options : {};
// BA1: the latency class of this run. The workflow's own class is the route it uses
// (constraints.latency_class); a caller may ask for a SLOWER class with options.latency and never a
ddiff --git a/server/setup/caps.js b/server/setup/caps.js
nnew file mode 100644
iindex 0000000..ecf2a35
---- /dev/null
++++ b/server/setup/caps.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * The monthly cap per task kind, set on the Setup page (/admin/ai/setup → ai.task-kind-cap@1).
++ *
++ * A cap is 0 (no cap) or a USD amount. Before a run starts, what this month's completed runs on
++ * the same task kind have cost is read and compared with the cap; at or above it the run is refused
++ * with 429 cap.reached and a Retry-After to the first day of next month (UTC). The spend is the
++ * runs' own cost_usd, so a cached run (cost 0) never uses the cap and a failed run that still cost
++ * the provider is counted. Nothing here changes a provider or a price.
++ *
++ * The kind is the route's own job, one table: the same routes the Setup page walks.
++ */
++const { AiError } = require('../util');
++
++const KIND_BY_ROUTE = Object.freeze({
++ 'default.chat': 'chat', 'default.json': 'vision', 'default.image': 'image', 'live.stt': 'stt',
++ 'default.speech': 'speech', 'default.embedding': 'embedding', 'space.build': 'code',
++});
++// Routes that are aliases of a step's route (food.vision → default.json, chat.member → default.chat).
++// The direct map wins; the alias map is for a run whose recorded route is the alias itself.
++const KIND_BY_ALIAS = Object.freeze({
++ 'food.vision': 'vision', 'pics.vision': 'vision', 'space.ask': 'chat', 'chat.member': 'chat',
++ 'default.messages': 'chat', 'background.vision': 'vision',
++});
++
++function kindOfRoute(routeKey) {
++ return KIND_BY_ROUTE[routeKey] || KIND_BY_ALIAS[routeKey] || null;
++}
++
++function firstOfNextMonth(nowMs) {
++ const d = new Date(nowMs);
++ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1);
++}
++
++/**
++ * Refuse a run when its task kind has spent its monthly cap. `routeKey` is the route the run will
++ * use (after aliases), `db` the AI database, `now` the clock.
++ */
++async function check({ db, routeKey, now = Date.now() }) {
++ const kind = kindOfRoute(routeKey);
++ if (!kind) return { kind: null, capped: false };
++ const row = await db.prepare('SELECT monthly_cap_usd FROM task_kind_caps WHERE task_kind = ?').get(kind).catch(() => null);
++ const cap = row ? Number(row.monthly_cap_usd) : 0;
++ if (!(cap > 0)) return { kind, capped: false };
++ const start = new Date(now);
++ start.setUTCDate(1); start.setUTCHours(0, 0, 0, 0);
++ const spent = await db.prepare(`SELECT COALESCE(SUM(cost_usd), 0) AS cost FROM runs
++ WHERE created_at >= ? AND status IN ('succeeded', 'cached')
++ AND (route_key = ? OR route_key IN (SELECT key FROM routes WHERE alias_of = ?))`).get(start.toISOString(), routeKey, routeKey);
++ const cost = Number(spent && spent.cost) || 0;
++ if (cost >= cap) {
++ const retry = Math.max(60, Math.ceil((firstOfNextMonth(now) - now) / 1000));
++ throw new AiError(429, 'cap.reached', `the ${kind} monthly cap of $${cap} is used ($${cost.toFixed(2)} this month); it resets on the first of next month`, { retry_after_seconds: retry });
++ }
++ return { kind, capped: false, cap, cost };
++}
++
++module.exports = { check, kindOfRoute, KIND_BY_ROUTE, KIND_BY_ALIAS, firstOfNextMonth };
ddiff --git a/server/setup/catalog.js b/server/setup/catalog.js
nnew file mode 100644
iindex 0000000..6a7eb8c
---- /dev/null
++++ b/server/setup/catalog.js
@@@ -0,0 +1,172 @@
++'use strict';
++/**
++ * The Setup page's own catalogue (OpenVibe.Network /admin/ai/setup).
++ *
++ * One step per job the estate needs. Each step names the registry route products already use, the
++ * provider and model the network recommends, why (six words at most), and what the price means.
++ * These are the provider's own published list prices, read on the day named; when the registry has
++ * a model row its price wins, so the page never shows a stale number beside a live one.
++ *
++ * The page is a guided setup, not a second registry: it writes the provider key into
++ * ai.provider_keys and the model into the route, then tests through the route's own placement.
++ * server/api/setup.js is the API.
++ */
++const { keyPage } = require('../owner-inputs');
++
++const CHECKED = '2026-11-12';
++const SOURCES = Object.freeze({
++ deepseek: 'https://api-docs.deepseek.com/quick_start/pricing',
++ gemini: 'https://ai.google.dev/gemini-api/docs/pricing',
++ openaiImages: 'https://platform.openai.com/docs/pricing',
++ openaiAudio: 'https://platform.openai.com/docs/pricing',
++ googleSpeech: 'https://cloud.google.com/text-to-speech/pricing',
++ polly: 'https://aws.amazon.com/polly/pricing/',
++ openaiEmbed: 'https://platform.openai.com/docs/pricing',
++ brave: 'https://brave.com/search/api/',
++ tavily: 'https://www.tavily.com/pricing',
++ serper: 'https://serper.dev/pricing',
++});
++
++/** A price in the page's plain words; `unit` is what the number is per. */
++const tokenPrice = (inPerMtok, outPerMtok, cached = null) => ({
++ kind: 'tokens', in_per_mtok: inPerMtok, out_per_mtok: outPerMtok, cached_per_mtok: cached,
++ unit: '1M tokens', text: `$${inPerMtok} in · $${outPerMtok} out per 1M tokens`,
++});
++const picturePrice = (usd) => ({ kind: 'image', per_image_usd: usd, unit: 'picture', text: `$${usd} per picture` });
++const minutePrice = (usd) => ({ kind: 'minute', per_minute_usd: usd, unit: 'minute', text: `$${usd} per minute` });
++const charsPrice = (usd) => ({ kind: 'characters', per_million_chars_usd: usd, unit: '1M characters', text: `$${usd} per 1M characters` });
++const searchPrice = (usd) => ({ kind: 'search', per_1000_usd: usd, unit: '1,000 searches', text: `$${usd} per 1,000 searches` });
++const FREE = { kind: 'free', text: 'free', unit: null };
++
++/** One offer: where the key comes from, what it costs and whether it can be recommended. */
++function offer(provider, model, label, price, source, extra = {}) {
++ return { provider, model, label, price, source, checked: CHECKED, ...extra };
++}
++
++/**
++ * The steps, in the order the page walks them. `route` is the route products already use;
++ * `search` has no route (OpenVibe.Search picks a working engine), and `test` says what one tiny
++ * real request does so the page can show the answer and its cost.
++ */
++const STEPS = Object.freeze([
++ {
++ id: 'chat', name: 'Chat and writing', short: 'Chat', route: 'default.chat', 'capability': 'chat', operation: 'chat', modelType: 'chat',
++ what: 'Turns text into an answer, a summary or a draft.',
++ uses: 'Every AI answer on the network: chat members, the Rundown\'s writing desk, summaries and site copy.',
++ recommended: offer('deepseek', 'deepseek-flash', 'DeepSeek Flash', tokenPrice(0.15, 0.6, 0.003), SOURCES.deepseek,
++ { why: 'cheapest that looks good', note: 'The price is DeepSeek\'s off-peak list price; daytime is higher.' }),
++ alternatives: [
++ offer('deepseek', 'deepseek-chat', 'DeepSeek Chat', tokenPrice(0.26, 0.38), SOURCES.deepseek),
++ offer('openai', 'gpt-5-mini', 'GPT-5 mini', tokenPrice(0.25, 2), SOURCES.openaiImages),
++ ],
++ },
++ {
++ id: 'vision', name: 'Vision', short: 'Vision', route: 'default.json', 'capability': 'chat', operation: 'chat', modelType: 'chat',
++ what: 'Reads a picture and answers with what is in it, usually as JSON.',
++ uses: 'Food\'s pantry photos and receipts, Pics\' alt text, and Media\'s picture checks.',
++ recommended: offer('gemini', 'gemini-2.5-flash', 'Gemini 2.5 Flash', tokenPrice(0.3, 2.5), SOURCES.gemini,
++ { why: 'cheapest that looks good' }),
++ alternatives: [
++ offer('openai', 'gpt-5-mini', 'GPT-5 mini', tokenPrice(0.25, 2), SOURCES.openaiImages),
++ offer('anthropic', 'claude-opus-5-5', 'Claude Opus 5.5', tokenPrice(4, 20, 0.2), 'https://platform.claude.com/docs/en/models/opus-5-5/overview'),
++ ],
++ },
++ {
++ id: 'image', name: 'Images', short: 'Images', route: 'default.image', 'capability': 'image', operation: 'image', modelType: 'image',
++ what: 'Makes a picture from a description.',
++ uses: 'The Rundown\'s episode pictures, Vibe\'s picture button, chat personas\' faces.',
++ recommended: offer('openai', 'gpt-image-1', 'OpenAI gpt-image-1', picturePrice(0.04), SOURCES.openaiImages,
++ { why: 'cheapest that looks good', note: 'A 1024 px picture at medium quality. Small sizes may be refused; the page shows it at 256 px.' }),
++ alternatives: [
++ offer('gemini', 'gemini-2.5-flash-image', 'Gemini 2.5 Flash Image', picturePrice(0.039), SOURCES.gemini),
++ offer('xai', 'grok-2-image', 'xAI Grok Image', picturePrice(0.07), 'https://docs.x.ai/docs/models'),
++ ],
++ },
++ {
++ id: 'stt', name: 'Speech to text', short: 'Speech in', route: 'live.stt', 'capability': 'transcribe', operation: 'transcribe', modelType: 'stt',
++ what: 'Turns a recording into written words with their times.',
++ uses: 'Live\'s stream transcripts, Vibe\'s voice messages, and captions on clips.',
++ recommended: offer('whisper', 'whisper.cpp', 'whisper.cpp on this machine', FREE, 'https://github.com/ggerganov/whisper.cpp',
++ { why: 'free on our own machine', keyless: true }),
++ alternatives: [
++ offer('openai', 'whisper-1', 'OpenAI Whisper', minutePrice(0.006), SOURCES.openaiAudio),
++ offer('mistral', 'voxtral-mini-latest', 'Mistral Voxtral', minutePrice(0.003), 'https://mistral.ai/pricing'),
++ ],
++ },
++ {
++ id: 'speech', name: 'Voices', short: 'Voices', route: 'default.speech', 'capability': 'speak', operation: 'speak', modelType: 'speech',
++ what: 'Reads text aloud in a natural voice.',
++ uses: 'Vibe speaking in Chat and the Dock, and read-aloud across the network.',
++ recommended: offer('google-tts', 'gc_neural_chad', 'Google Neural2', charsPrice(16), SOURCES.googleSpeech,
++ { why: 'natural voices, fair price', key_page: { url: 'https://console.cloud.google.com/apis/credentials', how: 'Create an API key in a Google Cloud project, then enable the Text-to-Speech API in that project.' },
++ note: 'Neural2 is Google\'s mid-tier voice; Standard costs $4 per 1M characters.' }),
++ alternatives: [
++ offer('google-tts', 'gc_smooth_operator', 'Google Standard', charsPrice(4), SOURCES.googleSpeech),
++ offer('polly', 'pl_joanna_neural', 'Amazon Polly Neural', charsPrice(16), SOURCES.polly,
++ { key_page: { url: 'https://console.aws.amazon.com/iam/home#/security_credentials', how: 'Create an access key for a user that may call Polly, then paste the access key id here.' } }),
++ ],
++ },
++ {
++ id: 'embedding', name: 'Embeddings', short: 'Embeddings', route: 'default.embedding', 'capability': 'embed', operation: 'embed', modelType: 'embedding',
++ what: 'Turns text into numbers so things can be found by meaning.',
++ uses: 'Memory search, wiki and policy matching, and everything that finds an answer by meaning.',
++ recommended: offer('openai', 'text-embedding-3-small', 'OpenAI Embedding 3 small', tokenPrice(0.02, 0), SOURCES.openaiEmbed,
++ { why: 'cheapest that looks good' }),
++ alternatives: [
++ offer('local', 'nomic-embed-text', 'nomic-embed-text on this machine', FREE, 'https://ollama.com/library/nomic-embed-text', { keyless: true }),
++ offer('gemini', 'text-embedding-004', 'Gemini Embedding', tokenPrice(0.1, 0), SOURCES.gemini),
++ ],
++ },
++ {
++ id: 'search', name: 'Web search', short: 'Search', route: null, 'capability': 'web-search', operation: 'search', modelType: null,
++ what: 'Looks up the current web and brings back pages and snippets.',
++ uses: 'Deep dives, research answers, and every product that needs today\'s web.',
++ recommended: offer('openvibe', 'openvibe', 'OpenVibe Search', FREE, 'https://search.openvibe.network',
++ { why: 'included in the network', keyless: true, note: 'Our own index is free to query and needs no key; Brave and Tavily add the wider web.' }),
++ alternatives: [
++ offer('brave', 'brave', 'Brave Search', searchPrice(5), SOURCES.brave),
++ offer('tavily', 'tavily', 'Tavily', searchPrice(8), SOURCES.tavily),
++ ],
++ },
++ {
++ id: 'code', name: 'Code agents (plans)', short: 'Code', route: 'space.build', 'capability': 'chat', operation: 'chat', modelType: 'chat',
++ what: 'Plans work and writes code that follows the plan.',
++ uses: 'Codes\' plans, Space\'s app builder, and code agents working through OpenVibe.Run.',
++ recommended: offer('deepseek', 'deepseek-chat', 'DeepSeek Chat', tokenPrice(0.26, 0.38), SOURCES.deepseek,
++ { why: 'cheapest that looks good' }),
++ alternatives: [
++ offer('openai', 'gpt-5-mini', 'GPT-5 mini', tokenPrice(0.25, 2), SOURCES.openaiImages),
++ offer('anthropic', 'claude-opus-5-5', 'Claude Opus 5.5', tokenPrice(4, 20, 0.2), 'https://platform.claude.com/docs/en/models/opus-5-5/overview'),
++ ],
++ },
++]);
++
++const BY_ID = new Map(STEPS.map((s) => [s.id, s]));
++
++/** What each kind is called in the defaults table, in the page's words. */
++const KIND_LABELS = Object.freeze({
++ chat: 'Chat and writing', vision: 'Vision', image: 'Images', stt: 'Speech to text',
++ speech: 'Voices', embedding: 'Embeddings', search: 'Web search', code: 'Code agents (plans)',
++});
++
++function stepById(id) {
++ return BY_ID.get(String(id || '')) || null;
++}
++
++/** Every offer a step shows: the recommended one first, then the alternatives. */
++function offersOf(step) {
++ return [step.recommended, ...(step.alternatives || [])];
++}
++
++/** The key page for an offer's provider, with the line that says what to press there. */
++function keyPageOf(provider) {
++ if (!provider) return null;
++ try {
++ const page = keyPage(provider);
++ return page && page.get_it_at ? { url: page.get_it_at, how: page.get_it_how || 'Sign in, open the API keys page and create a key.' } : null;
++ } catch {
++ return null;
++ }
++}
++
++module.exports = { STEPS, BY_ID, KIND_LABELS, stepById, offersOf, keyPageOf, CHECKED };
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
iindex 86e275e..6a10173 100644
---- a/test/openapi.test.js
++++ b/test/openapi.test.js
@@@ -53,6 +53,8 @@ const LEFT_OUT = [
'/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',
'/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
'/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',
++ // The guided setup is an owner/service admin surface (keys, routes and caps), like the admin API.
++ '/api/v1/setup',
];
const excluded = (key) => {
const p = key.split(' ')[1];
ddiff --git a/test/setup-api.test.js b/test/setup-api.test.js
nnew file mode 100644
iindex 0000000..754ca13
---- /dev/null
++++ b/test/setup-api.test.js
@@@ -0,0 +1,144 @@
++'use strict';
++/**
++ * The guided setup API (OpenVibe.Network /admin/ai/setup): every step has a state, a price and a key
++ * page; a key is written sealed and never echoed; a test call goes through the route's own placement;
++ * route choices and monthly caps are stored; the recommended set fills the routes.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, request, token, seamServer, suite } = require('./helpers');
++
++const t = suite('setup-api');
++const owner = token('network', ['ai.provider.manage']);
++const reader = token('network', ['ai.usage.read']);
++const keys = { OV_CONFIG_KEKS: `test:${crypto.randomBytes(32).toString('base64')}`, OV_CONFIG_KEK_CURRENT: 'test', AI_USER_MODULES: 'off' };
++const KEY = 'setup-test-key-abcd1234';
++
++let h;
++let seam;
++
++t.test('the page state: eight steps, owner only, key pages and spend', async () => {
++ seam = await seamServer((body) => ({
++ choices: [{ index: 0, message: { role: 'assistant', content: `(test) ${body.messages[body.messages.length - 1].content}` }, finish_reason: 'stop' }],
++ usage: { prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 }, model: body.model,
++ }));
++ h = await boot({ env: keys });
++ let r = await request(h.base, 'GET', '/api/v1/setup', { tok: owner });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.steps.length, 8, 'one step per job');
++ assert.strictEqual(r.body.summary.total, 8);
++ assert.strictEqual(r.body.steps.find((s) => s.id === 'chat').state, 'not_set', 'no chat key yet');
++ assert.strictEqual(r.body.steps.find((s) => s.id === 'stt').state, 'ready', 'whisper.cpp on this host needs no key');
++ assert.ok(r.body.steps.every((s) => s.key_page || s.id === 'stt' || s.id === 'embedding' || s.id === 'search'), 'a key page for every paid step');
++ assert.ok(r.body.steps.find((s) => s.id === 'chat').recommended.price.text.includes('per 1M tokens'), 'price in plain numbers');
++ assert.strictEqual(r.body.defaults.length, 8, 'one defaults row per kind');
++ r = await request(h.base, 'GET', '/api/v1/setup', { tok: reader });
++ assert.strictEqual(r.status, 200, 'a usage reader may read the page');
++ const denied = await request(h.base, 'PUT', '/api/v1/setup/caps/chat', { tok: reader, body: { cap_usd: 1 } });
++ assert.strictEqual(denied.status, 403, 'a write needs provider.manage');
++});
++
++t.test('a provider key is sealed, never echoed, and the provider turns on', async () => {
++ await h.registry.upsertProvider({
++ key: 'setup-test', display_name: 'Setup Test', kind: 'openai', status: 'disabled', base_url: seam.url,
++ auth_mode: 'bearer', secret_ref: 'vault:setup-test', default_model: 'setup-model', capabilities: ['chat', 'generate'],
++ }, { actor: 'test' });
++ await h.registry.upsertModel({
++ provider_key: 'setup-test', model_key: 'setup-model', display_name: 'Setup Model', type: 'chat', status: 'active',
++ cost: { in_per_mtok: 0.2, out_per_mtok: 0.4 }, supports: { json: true, tools: true, streaming: true },
++ }, { actor: 'test' });
++ const r = await request(h.base, 'PUT', '/api/v1/setup/providers/setup-test/key', { tok: owner, body: { key: KEY } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.hint.last4, '1234', 'the answer shows the last four');
++ assert.ok(!JSON.stringify(r.body).includes(KEY), 'the key itself is never returned');
++ const provider = await h.registry.getProvider('setup-test');
++ assert.strictEqual(provider.status, 'active', 'the provider is on once its key works');
++ const state = await request(h.base, 'GET', '/api/v1/setup', { tok: owner });
++ assert.strictEqual(state.body.steps.find((s) => s.id === 'chat').state, 'ready', 'the route now has a configured offer');
++});
++
++t.test('a test call goes through the route placement and answers with its cost', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/setup/test/chat', { tok: owner, body: { provider: 'setup-test', model: 'setup-model' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.provider, 'setup-test');
++ assert.strictEqual(r.body.model, 'setup-model');
++ assert.match(r.body.answer.text, /setup is ready/i, 'the provider\'s answer is shown');
++ assert.ok(r.body.cost_usd >= 0, 'the cost is shown');
++ assert.strictEqual(seam.last.model, 'setup-model', 'the routed model reached the provider');
++ assert.ok(seam.last.messages, 'the request was a real chat call');
++});
++
++t.test('a route write pins the model the defaults table shows', async () => {
++ const r = await request(h.base, 'PUT', '/api/v1/setup/routes/chat', { tok: owner, body: { provider: 'setup-test', model: 'setup-model' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.current, { provider: 'setup-test', model: 'setup-model' });
++ const route = await h.registry.getRoute('default.chat');
++ assert.strictEqual(route.constraints.model, 'setup-model', 'the route carries the choice');
++ assert.strictEqual(route.constraints.authority, 'setup-test:setup-model');
++ const state = await request(h.base, 'GET', '/api/v1/setup', { tok: owner });
++ assert.deepStrictEqual(state.body.steps.find((s) => s.id === 'chat').current, { provider: 'setup-test', model: 'setup-model' });
++ assert.strictEqual(state.body.defaults.find((d) => d.id === 'chat').current.model, 'setup-model');
++});
++
++t.test('the monthly cap is stored per kind and follows the step', async () => {
++ const r = await request(h.base, 'PUT', '/api/v1/setup/caps/chat', { tok: owner, body: { cap_usd: 12.5 } });
++ assert.strictEqual(r.status, 200, r.text);
++ const state = await request(h.base, 'GET', '/api/v1/setup', { tok: owner });
++ assert.strictEqual(state.body.steps.find((s) => s.id === 'chat').cap_usd, 12.5);
++ assert.strictEqual(state.body.defaults.find((d) => d.id === 'chat').cap_usd, 12.5);
++ const bad = await request(h.base, 'PUT', '/api/v1/setup/caps/chat', { tok: owner, body: { cap_usd: -1 } });
++ assert.strictEqual(bad.status, 422, 'a negative cap is refused');
++});
++
++t.test('a cap refuses the next run of that kind before it spends', async () => {
++ const runner = token('network', ['ai.run.create']);
++ const first = await request(h.base, 'POST', '/api/v1/runs?wait=15000', { tok: runner, body: { workflow: 'ai.generate', input: { prompt: 'cap test one' } } });
++ assert.ok([200, 201].includes(first.status), first.text);
++ await h.db.prepare('UPDATE runs SET cost_usd = 1 WHERE id = ?').run(first.body.run.id);
++ await request(h.base, 'PUT', '/api/v1/setup/caps/chat', { tok: owner, body: { cap_usd: 0.5 } });
++ const second = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: runner, body: { workflow: 'ai.generate', input: { prompt: 'cap test two' } } });
++ assert.strictEqual(second.status, 429, second.text);
++ assert.strictEqual(second.body.code, 'cap.reached');
++ assert.ok(second.headers.get('retry-after'), 'the refusal says when the cap resets');
++ await request(h.base, 'PUT', '/api/v1/setup/caps/chat', { tok: owner, body: { cap_usd: 0 } });
++});
++
++t.test('the recommended set fills every route, including the image route it creates', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/setup/recommended', { tok: owner });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.applied.filter((x) => x.ok).length, 7, 'seven routed steps: '+JSON.stringify(r.body.applied));
++ const image = await h.registry.getRoute('default.image');
++ assert.ok(image, 'the image route exists after the set');
++ assert.strictEqual(image.constraints.model, 'gpt-image-1');
++ const chat = await h.registry.getRoute('default.chat');
++ assert.strictEqual(chat.constraints.model, 'deepseek-flash', 'the recommended chat model');
++ const model = await h.registry.getModel('deepseek', 'deepseek-flash');
++ assert.ok(model && model.status === 'active', 'the model is registered so the route can serve it');
++});
++
++t.test('a refused key is scrubbed from the test answer', async () => {
++ const badKey = 'setup-bad-key-9876';
++ await h.registry.upsertProvider({
++ key: 'setup-bad', display_name: 'Setup Bad', kind: 'openai', status: 'disabled', base_url: seam.url,
++ auth_mode: 'bearer', secret_ref: 'vault:setup-bad', default_model: 'bad-model', capabilities: ['chat'],
++ }, { actor: 'test' });
++ await h.registry.upsertModel({ provider_key: 'setup-bad', model_key: 'bad-model', type: 'chat', status: 'active' }, { actor: 'test' });
++ const put = await request(h.base, 'PUT', '/api/v1/setup/providers/setup-bad/key', { tok: owner, body: { key: badKey } });
++ assert.strictEqual(put.status, 200, put.text);
++ const original = seam.handler;
++ seam.handler = () => ({ status: 401, body: { error: { message: `invalid key ${badKey}` } } });
++ try {
++ const r = await request(h.base, 'POST', '/api/v1/setup/test/chat', { tok: owner, body: { provider: 'setup-bad', model: 'bad-model' } });
++ assert.ok(r.status >= 400, 'the refused test is an error: '+r.status+' '+r.text);
++ assert.ok(!r.text.includes(badKey), 'the provider error never carries the key back');
++ } finally { seam.handler = original; }
++});
++
++t.test('no key is required for the keyless steps', async () => {
++ const whisper = await h.registry.getProvider('whisper');
++ const r = await request(h.base, 'PUT', '/api/v1/setup/providers/whisper/key', { tok: owner, body: { key: 'anything-at-all' } });
++ assert.strictEqual(r.status, 422, 'whisper.cpp takes no key');
++ assert.ok(whisper.auth_mode === 'none');
++});
++
++t.run();