openvibers/openvibe.ai Commits
Setup: guided AI setup API, provider keys, route pins and monthly caps (#501)
Changes
nnew file mode 100644@@@ -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"++);nnew file mode 100644@@@ -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 };@@@ -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;@@@ -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') {@@@ -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, confithrow 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 annew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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];nnew file mode 100644@@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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"
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,486 @@
@@ -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 };
@@ -13,6 +13,7 @@ const creations = require('./creations');
@@ -13,6 +13,7 @@ const creations = require('./creations');
const home = require('./home');
const home = require('./home');
const openapi = require('./openapi');
const openapi = require('./openapi');
const { adminRouter } = require('./api/admin');
const { adminRouter } = require('./api/admin');
+const { setupRouter } = require('./api/setup');
const { CAPS } = require('./auth');
const { CAPS } = require('./auth');
const { createOps } = require('./ops');
const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
@@ -24,7 +25,7 @@ const pkg = require('../package.json');
@@ -24,7 +25,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
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();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.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') });
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
@@ -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
// 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.
// 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(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(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 }));
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;
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
@@ -23,6 +23,7 @@ const { createRunBudgets } = require('./run-budgets');
@@ -23,6 +23,7 @@ const { createRunBudgets } = require('./run-budgets');
const { createFreeAllowance } = require('./free-allowance');
const { createFreeAllowance } = require('./free-allowance');
const { createTiers } = require('./govern');
const { createTiers } = require('./govern');
const { createRuns } = require('./runs');
const { createRuns } = require('./runs');
+const taskKindCaps = require('./setup/caps');
const { seed } = require('./workflows/seed');
const { seed } = require('./workflows/seed');
const { createAuth, createNetworkKeys } = require('./auth');
const { createAuth, createNetworkKeys } = require('./auth');
const { jwksClient } = require('openvibe-sdk/auth');
const { jwksClient } = require('openvibe-sdk/auth');
@@ -184,7 +185,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -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
// 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.
// the runs hold this facade and the batcher fills it in below, before any request can arrive.
const batchRef = {};
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
// 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).
// queued until the batcher finishes it (the provider is still working on it).
batchWaiting: async (runId) => {
batchWaiting: async (runId) => {
@@ -244,7 +245,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -244,7 +245,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
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 jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -126,7 +126,7 @@ function routePinRequest(value, principal, wf, internal = false) {
@@ -126,7 +126,7 @@ function routePinRequest(value, principal, wf, internal = false) {
return { provider, model };
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
// 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.
// 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; } })();
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
@@ -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`);
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 : {};
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
// 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
// (constraints.latency_class); a caller may ask for a SLOWER class with options.latency and never a
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -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 };
@@ -53,6 +53,8 @@ const LEFT_OUT = [
@@ -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/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/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
'/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',
'/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 excluded = (key) => {
const p = key.split(' ')[1];
const p = key.split(' ')[1];
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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();