openvibers/openvibe.ai Commits
Granted monthly credits, Altare Inference with its rate limits, and Decisions (routing and checks) (#512)
Changes
nnew file mode 100644@@@ -0,0 +1,21 @@++-- phase: expand++-- Monthly API credits that come with a subscription or a plan (2026-11-12, the owner: "make it aware of granted API++-- credits for both anthropic and openai"): ChatGPT Pro's $100, $200 or $500 a month of OpenAI API credit, an++-- Anthropic allowance, Altare Inference's $100 a month from 2026-12-01. Each plan becomes one promotional grant per++-- billing cycle in provider_credit_grants (applies_to 'plan:<id>:<cycle start>', expiring when the cycle ends), so++-- the router spends it first and nothing carries over; the first cycle is prorated when the plan starts mid-cycle.++CREATE TABLE IF NOT EXISTS provider_credit_plans (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ provider_key text COLLATE "C" NOT NULL,++ label text COLLATE "C" NOT NULL,++ monthly_usd numeric(14, 6) NOT NULL CHECK (monthly_usd >= 0),++ renews_day integer NOT NULL CHECK (renews_day BETWEEN 1 AND 31),++ starts_on text COLLATE "C" NOT NULL,++ ends_on text COLLATE "C",++ prorate_first bigint NOT NULL DEFAULT 1,++ active bigint NOT NULL DEFAULT 1,++ notes text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++CREATE INDEX IF NOT EXISTS provider_credit_plans_provider ON provider_credit_plans(provider_key, active);@@@ -11,6 +11,13 @@ const gpuCatalog = require('../gpu/catalog');const { tokenCosts } = require('../gpu/token-cost');/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */++/** A provider key a credit record may name: Anthropic, OpenAI or any catalogued or registered provider (a lowercase key). */++function creditProviderOf(value) {++ const key = String(value || '').trim().toLowerCase();++ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(key)) throw new AiError(422, 'ai.invalid', 'name a provider key, such as openai, anthropic or altare');++ return key;++}++function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {const r = express.Router();const providerOrder = require('../providers/order').createProviderOrder({ registry });@@@ -80,9 +87,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,uncensored: Boolean(p.metadata && p.metadata.uncensored),}));const poolProviders = require('../providers/pool-providers').PROVIDERS;-- const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => ({++ // Every provider with credit on record (prepaid, promotional, or a plan's monthly grant), Anthropic and OpenAI always.++ const creditPlans = creditGrants && creditGrants.listPlans ? await creditGrants.listPlans() : [];++ const creditProviders = [...new Set(['anthropic', 'openai', ...creditPlans.map((p) => p.provider_key), ...(creditGrants ? creditGrants.values().map((v) => v.provider) : [])])];++ const estimates = creditGrants ? await Promise.all(creditProviders.map(async (provider) => ({provider, grants: await creditGrants.list(provider), estimate: creditGrants.values().find((x) => x.provider === provider) || null,preference: await creditGrants.preference(provider),++ plans: creditPlans.filter((p) => p.provider_key === provider).map((p) => ({ id: Number(p.id), label: p.label, monthly_usd: Number(p.monthly_usd), renews_day: Number(p.renews_day),++ starts_on: p.starts_on, ends_on: p.ends_on || null, active: Boolean(Number(p.active)), notes: p.notes || null })),}))) : [];const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];@@@ -258,7 +270,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,r.post('/api/v1/admin/console/provider-credits', json, write, wrap(async (req) => {if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit records are not configured');const b = req.body || {};-- const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';++ const provider = creditProviderOf(b.provider);const grant = await creditGrants.add({ provider_key: provider, kind: String(b.kind || 'promotional'), amount_usd: Number(b.amount_usd),as_of: String(b.as_of || ''), received_at: String(b.received_at || b.as_of || ''),remaining_as_of_usd: b.remaining_as_of_usd == null ? Number(b.amount_usd) : Number(b.remaining_as_of_usd),@@@ -267,10 +279,33 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,applies_to: String(b.applies_to || '').slice(0, 200) || null });return { grant };}));++ // Monthly credits that come with a subscription or a plan (ChatGPT Pro's API credits, an Anthropic allowance, Altare's++ // free $100 a month): one promotional grant per billing cycle, spent first, gone when the cycle ends.++ r.post('/api/v1/admin/console/provider-credit-plans', json, write, wrap(async (req) => {++ if (!creditGrants || !creditGrants.addPlan) throw new AiError(503, 'ai.unavailable', 'provider credit plans are not configured');++ const b = req.body || {};++ try {++ const plan = await creditGrants.addPlan({ provider_key: creditProviderOf(b.provider), label: b.label, monthly_usd: Number(b.monthly_usd), renews_day: Number(b.renews_day),++ starts_on: String(b.starts_on || ''), ends_on: b.ends_on ? String(b.ends_on) : null, prorate_first: b.prorate_first !== false && b.prorate_first !== 'off', notes: b.notes || null });++ await creditGrants.poll().catch(() => null);++ return { plan };++ } catch (err) {++ if (err instanceof TypeError) throw new AiError(422, 'ai.invalid', err.message);++ throw err;++ }++ }));++ r.post('/api/v1/admin/console/provider-credit-plans/:id', json, write, wrap(async (req) => {++ if (!creditGrants || !creditGrants.setPlanActive) throw new AiError(503, 'ai.unavailable', 'provider credit plans are not configured');++ if (!/^\d{1,12}$/.test(String(req.params.id))) throw new AiError(422, 'ai.invalid', 'a plan id is a number');++ const changed = await creditGrants.setPlanActive(Number(req.params.id), (req.body || {}).active === true || (req.body || {}).active === 'on');++ if (!changed) throw new AiError(404, 'ai.not_found', 'no such plan');++ await creditGrants.poll().catch(() => null);++ return { ok: true };++ }));r.post('/api/v1/admin/console/provider-credit-preference', json, write, wrap(async (req) => {if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit preferences are not configured');const b = req.body || {};-- const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';++ const provider = creditProviderOf(b.provider);return { preference: await creditGrants.setPreference({ provider_key: provider, enabled: b.enabled === true, floor_usd: Number(b.floor_usd) }) };}));r.post('/api/v1/admin/console/provider-session-credentials', json, write, wrap(async (req) => {nnew file mode 100644@@@ -0,0 +1,138 @@++'use strict';++/**++ * POST /api/v1/decisions (2026-11-12, the owner: "Add decisions using automatic scalable cost effective modular multi++ * provider intelligent routing solution on OpenVibe Exchange"). Closed-set questions about one input in one call: yes++ * or no (`noul`), one of a list (`choice`), a rubric level (`score`), each with probabilities and a confidence, for++ * routing, moderation checks, grading and tool choice without a full model writing an answer.++ *++ * { "input": "…" | [{ "type": "text", "text": "…" }, { "type": "image", "uri": "https://…", "mime_type": "image/png" }],++ * "questions": { "is_spam": { "type": "noul", "instructions": "Is this spam?" },++ * "kind": { "type": "choice", "instructions": "What is it?", "criteria": { "bug": "…", "idea": "…" } },++ * "severity": { "type": "score", "instructions": "How bad?", "criteria": ["fine", "minor", "major"] } },++ * "prefer": "calibrated" | "cheap" }++ * → { "answers": { "is_spam": { "type": "noul", "noul": 0.03, "confidence": 0.97 }, … }, "calibrated": true,++ * "receipt": { "provider": "gemini", "model": "gemini-decision-1", "input_tokens": 120, "cost_usd": 0.000012 } }++ *++ * The providers, in order (modular: each is one function below; a new one is one more entry):++ * gemini Google's Decisions API, gemini-decision-1 (https://aistudio.google.com/docs/decisions-api): one forward pass,++ * calibrated probabilities, up to 1,000 questions, pictures, audio, video and PDFs; $0.10 per 1M input++ * tokens, nothing for output (Google's price, 2026-11-11). Used when the gemini provider's key resolves.++ * Its free tier may train on what it reads: public or synthetic data only (the owner's note, 11-12).++ * router decide.fallback on the router's cheap structured-output route (default.json): text only, up to 100++ * questions, estimated and not calibrated (calibrated: false). Used without a Gemini key, when Google is++ * away, or with prefer: "cheap" for text when the router's offer is free.++ * Every call is metered like any request (provider_key, cost), so a credit plan pays for it and the owner sees it.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { AiError, sendError, providerSecret } = require('../util');++const decide = require('../workflows/decide');++++const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/decisions';++const GEMINI_MODEL = 'gemini-decision-1';++const GEMINI_PER_MTOK = 0.10;++const NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;++const MAX_GEMINI_QUESTIONS = 1000;++const WAIT_MS = 30000;++++/** The request, checked: { parts, text, questions } or an AiError. */++function checked(body) {++ const b = body && typeof body === 'object' ? body : {};++ let parts;++ if (typeof b.input === 'string') parts = [{ type: 'text', text: b.input }];++ else if (Array.isArray(b.input)) parts = b.input;++ else throw new AiError(422, 'input.invalid', 'input is text, or a list of content parts');++ if (!parts.length || parts.length > 50) throw new AiError(422, 'input.invalid', 'input has 1 to 50 parts');++ for (const p of parts) {++ if (!p || typeof p !== 'object') throw new AiError(422, 'input.invalid', 'every input part is an object');++ if (p.type === 'text') { if (typeof p.text !== 'string' || !p.text.length || p.text.length > 200000) throw new AiError(422, 'input.invalid', 'a text part has 1 to 200,000 characters'); }++ else if (['image', 'audio', 'video', 'document'].includes(p.type)) { if (!/^https:\/\/\S{1,2000}$/.test(String(p.uri || ''))) throw new AiError(422, 'input.invalid', `a ${p.type} part names an https uri`); }++ else throw new AiError(422, 'input.invalid', 'a part is text, image, audio, video or document');++ }++ const qs = b.questions && typeof b.questions === 'object' && !Array.isArray(b.questions) ? b.questions : null;++ const names = qs ? Object.keys(qs) : [];++ if (!names.length || names.length > MAX_GEMINI_QUESTIONS) throw new AiError(422, 'input.invalid', `1 to ${MAX_GEMINI_QUESTIONS} questions, by name`);++ const questions = {};++ for (const name of names) {++ const q = qs[name] || {};++ if (!NAME_RE.test(name)) throw new AiError(422, 'input.invalid', `a question name is lowercase letters, digits and _ (${name.slice(0, 40)})`);++ if (!['noul', 'choice', 'score'].includes(q.type)) throw new AiError(422, 'input.invalid', `${name}: type is noul, choice or score`);++ if (typeof q.instructions !== 'string' || !q.instructions.trim() || q.instructions.length > 4000) throw new AiError(422, 'input.invalid', `${name}: instructions are 1 to 4,000 characters`);++ if (q.type === 'choice') {++ const keys = q.criteria && typeof q.criteria === 'object' && !Array.isArray(q.criteria) ? Object.keys(q.criteria) : [];++ if (keys.length < 2 || keys.length > 255) throw new AiError(422, 'input.invalid', `${name}: a choice names 2 to 255 options in criteria`);++ }++ if (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 20)) throw new AiError(422, 'input.invalid', `${name}: a score lists 2 to 20 rubric levels in criteria`);++ if (q.type === 'noul' && q.criteria != null && (typeof q.criteria !== 'object' || Array.isArray(q.criteria))) throw new AiError(422, 'input.invalid', `${name}: a yes/no criteria is { true_description, false_description }`);++ questions[name] = { type: q.type, instructions: q.instructions.trim(), ...(q.criteria != null ? { criteria: q.criteria } : {}) };++ }++ const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n\n');++ const prefer = b.prefer === 'cheap' ? 'cheap' : 'calibrated';++ return { parts, text, questions, prefer, onlyText: parts.every((p) => p.type === 'text') };++}++++function decisionsRouter({ runs, auth, db, registry, env = process.env, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {++ const r = express.Router();++ r.use(http.middleware());++ const guard = auth.personOrCap('ai.run.create');++++ const meter = async (row) => {++ try {++ await db.prepare("INSERT INTO requests (operation, provider_key, model_key, status, cost_usd, created_at) VALUES ('classify', ?, ?, ?, ?, ?)")++ .run(row.provider, row.model, row.status, row.cost_usd || 0, new Date(now()).toISOString());++ } catch (err) { log.warn(`[ai] decisions meter: ${String(err && err.message || err).slice(0, 160)}`); }++ };++++ /** Google's Decisions API with the gemini provider's own key, or null when there is no key. */++ async function viaGemini(req) {++ const row = registry && registry.getProvider ? await registry.getProvider('gemini').catch(() => null) : null;++ const key = row ? providerSecret(row, env) : (env.GEMINI_API_KEY || null);++ if (!key) return null;++ const input = req.onlyText ? req.text : req.parts.map((p) => (p.type === 'text' ? { type: 'text', text: p.text } : { type: p.type, uri: p.uri, ...(p.mime_type ? { mime_type: p.mime_type } : {}) }));++ const res = await fetchImpl(GEMINI_URL, {++ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(20000),++ headers: { 'x-goog-api-key': key, 'content-type': 'application/json', accept: 'application/json' },++ body: JSON.stringify({ model: GEMINI_MODEL, input, questions: req.questions }),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok || !data || typeof data.answers !== 'object') {++ await meter({ provider: 'gemini', model: GEMINI_MODEL, status: 'error' });++ const err = new Error(`gemini decisions ${res.status}`); err.status = res.status; throw err;++ }++ const tokens = Number(data.usage && data.usage.total_input_tokens) || 0;++ const cost = Math.round((tokens / 1e6) * GEMINI_PER_MTOK * 1e8) / 1e8;++ await meter({ provider: 'gemini', model: GEMINI_MODEL, status: 'ok', cost_usd: cost });++ return { answers: data.answers, calibrated: true, receipt: { provider: 'gemini', model: GEMINI_MODEL, input_tokens: tokens, cost_usd: cost } };++ }++++ /** The router's cheap structured-output route (decide.fallback): text only, estimated probabilities. */++ async function viaRouter(req, http) {++ if (Object.keys(req.questions).length > decide.MAX_QUESTIONS) throw new AiError(422, 'input.invalid', `without the Decisions API, at most ${decide.MAX_QUESTIONS} questions`);++ const created = await runs.create({ workflow: 'decide.fallback', task: 'decide.fallback', input: { input: req.text, questions: req.questions } }, http.principal, {++ trace: http.ov && http.ov.traceId, requestId: http.ov && http.ov.requestId, callerWaitMs: WAIT_MS,++ });++ const run = await runs.explained(await runs.wait(created, WAIT_MS));++ if (run.status !== 'succeeded' || !run.output) throw new AiError(503, 'decisions.unavailable', (run.error && run.error.detail) || 'no route answered the questions');++ return { answers: run.output.answers, calibrated: false,++ receipt: { provider: String((run.provenance && run.provenance.provider) || 'router').slice(0, 100), model: String((run.provenance && run.provenance.model) || 'unknown').slice(0, 200), run_id: String(run.id), cost_usd: Number(run.usage && run.usage.cost_usd) || 0 } };++ }++++ r.post('/api/v1/decisions', guard, async (req, res) => {++ try {++ const q = checked(req.body);++ let out = null;++ // Calibrated first (Google's Decisions API) unless the caller asked for the cheapest text answer.++ if (q.prefer === 'calibrated' || !q.onlyText) {++ try { out = await viaGemini(q); } catch (err) { log.warn(`[ai] decisions via gemini failed: ${String(err && err.message || err).slice(0, 160)}`); out = null; }++ }++ if (!out) {++ if (!q.onlyText) throw new AiError(503, 'decisions.unavailable', 'pictures, audio, video and documents need the Decisions API (a Gemini key on OpenVibe.AI)');++ out = await viaRouter(q, req);++ }++ res.json(out);++ } catch (err) { sendError(res, err, req); }++ });++ return r;++}++++module.exports = { decisionsRouter, checked };@@@ -124,7 +124,10 @@ function scrub(text, secret) {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 }) {++/** Google's Decisions API model: answered directly by POST /api/v1/decisions, never a route's chat model. */++const isDecisionModel = (m) => String(m || '') === 'gemini-decision-1';++++function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth, settings = null, search = null, env = process.env, clock = { now: () => Date.now() }, log = console, fetchImpl = globalThis.fetch }) {const r = express.Router();const read = auth.requireCap(CAPS.providerManage, CAPS.usageRead);const write = auth.requireCap(CAPS.providerManage);@@@ -245,7 +248,9 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth}let state = 'not_set';-- if (step.id === 'search') state = searchState.ready ? 'ready' : failing ? 'failing' : 'not_set';++ // Decisions are ready with a Gemini key (the Decisions API) or with a working fallback offer on decide.fallback.++ if (step.id === 'decide' && configured(providers.get('gemini'))) state = 'ready';++ else 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';@@@ -338,6 +343,21 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth};}++ // Gemini Decision 1 is Google's Decisions API, not a chat model: one tiny yes/no question to it directly.++ if (step.id === 'decide' && providerKey === 'gemini') {++ if (!secret) throw new AiError(409, 'setup.no_key', 'save the Gemini key first');++ const started = clock.now();++ const res = await fetchImpl('https://generativelanguage.googleapis.com/v1beta/decisions', {++ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(20000),++ headers: { 'x-goog-api-key': secret, 'content-type': 'application/json', accept: 'application/json' },++ body: JSON.stringify({ model: 'gemini-decision-1', input: 'The OpenVibe AI setup sends this sentence to check that decisions work.', questions: { is_check: { type: 'noul', instructions: 'Is this a setup check?' } } }),++ }).catch((err) => { throw new AiError(502, 'setup.test_failed', scrub(String(err && err.message || err), secret)); });++ const data = await res.json().catch(() => ({}));++ if (!res.ok || !data.answers) throw new AiError(502, 'setup.test_failed', scrub(`Google answered ${res.status}: ${(data.error && data.error.message) || 'no answers'}`, secret));++ const tokens = Number(data.usage && data.usage.total_input_tokens) || 0;++ return { ok: true, step: step.id, route: null, provider: 'gemini', model: 'gemini-decision-1',++ answer: { text: `P(yes) = ${data.answers.is_check && data.answers.is_check.noul}` }, cost_usd: money(tokens / 1e6 * 0.1), latency_ms: clock.now() - started };++ }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');@@@ -406,7 +426,8 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, authif (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);++ // The Decisions API is tested directly; it never becomes a chat model row the router could pick.++ if (step.id !== 'search' && !isDecisionModel(modelKey)) await ensureModel(step, providerKey, modelKey);return await runTest(step, { providerKey, modelKey, req });}));@@@ -427,6 +448,7 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, authconst b = body(req);const providerKey = b.provider == null ? null : String(b.provider);const modelKey = b.model == null ? null : String(b.model);++ if (isDecisionModel(modelKey)) throw new AiError(422, 'setup.not_a_route_model', 'Gemini Decision 1 is the Decisions API: it needs only the Gemini key, and the route keeps a chat model for text without it');let route = await routeFor(step);if (!route) throw new AiError(503, 'route.unavailable', `there is no route for ${step.name} yet`);if (providerKey) {@@@ -462,7 +484,7 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, authconst applied = [];for (const step of STEPS) {if (!step.route) continue;-- const offer = step.recommended;++ const offer = step.routeOffer || step.recommended;try {await ensureModel(step, offer.provider, offer.model);const route = await routeFor(step);@@@ -268,6 +268,9 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, papp.use(actionsRouter({ runs, registry, auth, log }));// People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.app.use(require('./api/ui-layout').uiLayoutRouter({ runs, auth, log }));++ // Decisions (2026-11-12): closed-set questions about one input, Google's Decisions API first, the router's cheap++ // structured route without it (server/api/decisions.js).++ app.use(require('./api/decisions').decisionsRouter({ runs, auth, db, registry, env, fetchImpl, log }));app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));@@@ -286,7 +289,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(setupRouter({ config, db, registry, pool, runs, auth, settings, search, env, clock, log, fetchImpl }));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;@@@ -34,6 +34,7 @@ const SEEDS = [['search.images', 'search', 'Find licensed images.', 'research.gather'],['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],['ui.layout.suggest', 'text', 'Turn a person’s words into a layout for one surface.', 'ui.layout.suggest'],++ ['decide.fallback', 'text', 'Answer closed-set questions about one input when the Decisions API cannot.', 'decide.fallback'],];const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;@@@ -1076,6 +1076,21 @@ route('post', '/ui/layout/suggest', {responses: { 200: json('ui.layout-suggestion@1', uiLayoutSuggestion), ...errors([400, 401, 403, 422, 429, 503]) },});++// ── Decisions (2026-11-12): closed-set questions about one input, Google's Decisions API first ─────────────────++route('post', '/decisions', {++ operationId: 'decide', summary: 'Answer yes/no, choice and rubric questions about one input in one call', tags: ['decisions'], security: SERVICE,++ description: 'ai.run.create (or a person\'s own token). Up to 1,000 named questions (noul: the probability of yes; choice: one of 2-255 options; score: a rubric level from 0) about one input (text, or content parts: text, image, audio, video, document by https uri). Google\'s Decisions API (gemini-decision-1) answers when the gemini provider has a key: calibrated, $0.10 per 1M input tokens. Without it the router\'s cheap structured route answers text (up to 100 questions) with estimated probabilities (calibrated: false). prefer: "cheap" asks for the router first.',++ requestBody: body({++ type: 'object', required: ['input', 'questions'],++ properties: {++ input: { oneOf: [str({ minLength: 1, maxLength: 200000 }), { type: 'array', minItems: 1, maxItems: 50, items: { type: 'object' } }] },++ questions: { type: 'object', minProperties: 1, maxProperties: 1000, additionalProperties: { type: 'object', required: ['type', 'instructions'], properties: { type: str({ enum: ['noul', 'choice', 'score'] }), instructions: str({ minLength: 1, maxLength: 4000 }), criteria: {} } } },++ prefer: str({ enum: ['calibrated', 'cheap'] }),++ },++ }),++ responses: { 200: json('The answers', { type: 'object', required: ['answers', 'calibrated', 'receipt'], properties: { answers: { type: 'object' }, calibrated: { type: 'boolean' }, receipt: { type: 'object' } } }), ...errors([400, 401, 403, 422, 503]) },++});++// ── Routing presets and receipts (the Exchange, 2026-11-06) ───────────────────────────────────route('get', '/presets', {operationId: 'listPresets', summary: 'The routing presets this caller may name', tags: ['presets'], security: SERVICE_OR_APP,@@@ -26,6 +26,24 @@ function allocateDetailed(grants, spentUsd, now = Date.now()) {}return { ...out, grantBalances };}++/**++ * The billing cycle that holds `now` for a plan that renews on `day` of the month (UTC). A plan that renews on the 31st++ * renews on the last day of a shorter month. → { start, end } in milliseconds.++ */++function cycleOf(day, now) {++ const at = (y, m) => { const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate(); return Date.UTC(y, m, Math.min(day, last)); };++ const d = new Date(now);++ let y = d.getUTCFullYear(), m = d.getUTCMonth();++ let start = at(y, m);++ if (start > now) { m -= 1; if (m < 0) { m = 11; y -= 1; } start = at(y, m); }++ let ny = y, nm = m + 1;++ if (nm > 11) { nm = 0; ny += 1; }++ return { start, end: at(ny, nm) };++}++const planGrantKey = (planId, cycleStart) => `plan:${planId}:${new Date(cycleStart).toISOString().slice(0, 10)}`;++const isPlanGrant = (g) => /^plan:\d+:/.test(String(g && g.applies_to || ''));++const round2 = (n) => Math.round(n * 100) / 100;++function allocate(grants, spentUsd, now = Date.now()) { const { promotional, purchased } = allocateDetailed(grants, spentUsd, now); return { promotional, purchased }; }function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() } }) {@@@ -38,6 +56,69 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetcconst result = await db.prepare('INSERT INTO provider_credit_grants (provider_key, kind, amount_usd, as_of, expires_at, applies_to, created_at, received_at, remaining_as_of_usd, remaining_as_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id').get(provider_key, kind, Number(amount_usd), as_of, expires_at, applies_to, new Date(clock.now()).toISOString(), received_at, Number(remaining_as_of_usd), remaining_as_of);return result;}++ // ── Monthly credits from a plan (2026-11-12): one promotional grant per billing cycle, expiring when it ends ──++ const listPlans = async (provider = null) => (provider++ ? db.prepare('SELECT * FROM provider_credit_plans WHERE provider_key = ? ORDER BY id').all(provider)++ : db.prepare('SELECT * FROM provider_credit_plans ORDER BY provider_key, id').all());++ async function addPlan(plan) {++ const p = plan || {};++ const provider = String(p.provider_key || '').trim();++ const monthly = Number(p.monthly_usd);++ const day = Number(p.renews_day);++ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(provider)) throw new TypeError('a plan names a provider key');++ if (!String(p.label || '').trim()) throw new TypeError('a plan has a label (what grants it, e.g. "ChatGPT Pro API credits")');++ if (!Number.isFinite(monthly) || monthly < 0 || monthly > 1000000) throw new TypeError('monthly_usd is a nonnegative amount');++ if (!Number.isInteger(day) || day < 1 || day > 31) throw new TypeError('renews_day is a day of the month, 1-31');++ if (!p.starts_on || Number.isNaN(Date.parse(p.starts_on))) throw new TypeError('starts_on is a date');++ if (p.ends_on && Number.isNaN(Date.parse(p.ends_on))) throw new TypeError('ends_on is a date');++ const at = new Date(clock.now()).toISOString();++ const row = await db.prepare(`INSERT INTO provider_credit_plans (provider_key, label, monthly_usd, renews_day, starts_on, ends_on, prorate_first, active, notes, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) RETURNING id`).get(provider, String(p.label).trim().slice(0, 120), monthly, day,++ new Date(p.starts_on).toISOString(), p.ends_on ? new Date(p.ends_on).toISOString() : null, p.prorate_first === false ? 0 : 1,++ p.notes ? String(p.notes).slice(0, 500) : null, at, at);++ await materialize();++ return row;++ }++ async function setPlanActive(id, active) {++ const n = (await db.prepare('UPDATE provider_credit_plans SET active = ?, updated_at = ? WHERE id = ?').run(active ? 1 : 0, new Date(clock.now()).toISOString(), Number(id))).changes;++ if (!active) {++ // A plan turned off stops granting from now: this cycle's unexpired grant ends today.++ await db.prepare(`UPDATE provider_credit_grants SET expires_at = ? WHERE applies_to LIKE ? AND (expires_at IS NULL OR expires_at > ?)`)++ .run(new Date(clock.now()).toISOString(), `plan:${Number(id)}:%`, new Date(clock.now()).toISOString());++ } else await materialize();++ return n;++ }++ /** Each active plan's grant for the cycle that holds now, once (the first cycle prorated when it starts mid-cycle). */++ async function materialize(now = clock.now()) {++ const made = [];++ for (const p of await listPlans()) {++ if (!Number(p.active)) continue;++ const startsAt = Date.parse(p.starts_on);++ if (!Number.isFinite(startsAt) || startsAt > now) continue;++ if (p.ends_on && Date.parse(p.ends_on) <= now) continue;++ const cycle = cycleOf(Number(p.renews_day), now);++ const applies = planGrantKey(p.id, cycle.start);++ const have = await db.prepare('SELECT id FROM provider_credit_grants WHERE provider_key = ? AND applies_to = ?').get(p.provider_key, applies);++ if (have) continue;++ const begin = Math.max(cycle.start, startsAt);++ const end = p.ends_on ? Math.min(cycle.end, Date.parse(p.ends_on)) : cycle.end;++ const full = Number(p.monthly_usd);++ const amount = Number(p.prorate_first) && begin > cycle.start ? round2(full * (end - begin) / (cycle.end - cycle.start)) : full;++ made.push(await add({ provider_key: p.provider_key, kind: 'promotional', amount_usd: amount, as_of: new Date(begin).toISOString(),++ expires_at: new Date(end).toISOString(), applies_to: applies }));++ }++ return made;++ }++ /** The spend OpenVibe metered for one provider from `sinceMs` on (a plan grant's own cycle). */++ async function meteredSince(provider, sinceMs) {++ const r = await db.prepare("SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM requests WHERE provider_key = ? AND status = 'ok' AND created_at >= ?").get(provider, new Date(sinceMs).toISOString());++ return Number(r && r.spent) || 0;++ }++ async function providersWithCredit() {++ const rows = await db.prepare('SELECT DISTINCT provider_key FROM provider_credit_grants UNION SELECT DISTINCT provider_key FROM provider_credit_plans').all();++ return [...new Set(['anthropic', 'openai', ...rows.map((r) => r && r.provider_key).filter((k) => typeof k === 'string' && k)])];++ }++async function preference(provider = 'anthropic') {const row = await db.prepare('SELECT use_promotional_first, promotional_floor_usd FROM provider_credit_preferences WHERE provider_key = ?').get(provider);return row ? { enabled: Boolean(row.use_promotional_first), floor_usd: Number(row.promotional_floor_usd) } : { enabled: true, floor_usd: 5 };@@@ -103,12 +184,27 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetcasync function poll() {if (running) return running;running = (async () => {-- for (const provider of ['anthropic', 'openai']) {++ await materialize().catch(() => null);++ const now = clock.now();++ for (const provider of await providersWithCredit()) {const grants = await list(provider);if (!grants.length) continue;-- const spentResult = await spend(provider, grants);-- const balances = allocateDetailed(grants, spentResult.usd, clock.now());-- latest.set(provider, { ...balances, grants, preference: await preference(provider), spent_usd: spentResult.usd, source: spentResult.source, at: clock.now() });++ // A plan's grant only ever pays for its own cycle: its spend is what was metered since it began, so++ // last month's use never eats this month's credit (and an expired one is simply gone).++ const planGrants = grants.filter((g) => isPlanGrant(g) && (!g.expires_at || Date.parse(g.expires_at) > now));++ const plans = [];++ let absorbed = 0;++ for (const g of planGrants) {++ const used = await meteredSince(provider, Date.parse(g.as_of));++ const amount = Math.max(0, Number(g.amount_usd) || 0);++ absorbed += Math.min(amount, used);++ plans.push({ id: g.id, applies_to: g.applies_to, amount_usd: amount, used_usd: Math.min(amount, used), remaining_usd: Math.max(0, amount - used), expires_at: g.expires_at });++ }++ const others = grants.filter((g) => !isPlanGrant(g));++ const spentResult = others.length ? await spend(provider, others) : { usd: 0, source: "OpenVibe's own metering" };++ const balances = allocateDetailed(others, Math.max(0, spentResult.usd - absorbed), now);++ const planLeft = plans.reduce((a, p) => a + p.remaining_usd, 0);++ latest.set(provider, { ...balances, promotional: balances.promotional + planLeft, plans, grants, preference: await preference(provider), spent_usd: spentResult.usd + absorbed, source: spentResult.source, at: now });}return values();})().finally(() => { running = null; });@@@ -116,7 +212,7 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetc}function values() { return [...latest.entries()].map(([provider, value]) => ({ provider, ...value })); }const lastAdminAtByProvider = new Map();-- return { add, list, poll, values, preference, setPreference, shouldPrefer };++ return { add, list, poll, values, preference, setPreference, shouldPrefer, listPlans, addPlan, setPlanActive, materialize };}--module.exports = { createCreditGrants, centsToUsd, allocate, allocateDetailed };++module.exports = { createCreditGrants, centsToUsd, allocate, allocateDetailed, cycleOf, planGrantKey };@@@ -233,6 +233,7 @@ function withoutPromptCache(req) {}function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null, creditGrants = null, ownerNotify = null, ownerConfig = null }) {++ const rateGate = require('./rate-gate').createRateGate({ now: () => clock.now() });// A person's own machine as their model (credential provider node, server/machines.js).const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });const cache = new Map(); // key -> { stamp, adapter }@@@ -1304,6 +1305,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (row && row.status !== 'active') return 'model_disabled';if (p.auth_mode !== 'none' && !credentialNotRequired(p) && !providerSecret(p, env)) return 'no_credentials';if (features.some(f => !a.supports(f))) return 'unsupported';++ // Its published limit for this minute is used up (Altare's 60 a minute, 30 for each flagship): the next offer.++ if (rateGate.full(p, model)) return 'rate_limited';const h = await health(p.key, model, { stamp: stampFor(p) });if (h.state === 'open') {if (h.failure_class === 'balance' || h.exhausted_at) return 'exhausted';@@@ -2022,6 +2025,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// L36 Y7: the fallback chain stops after three providers.if (attemptsMade >= maxAttempts) break;attemptsMade += 1;++ rateGate.take(p, model);// Claim the local slot and the provider's concurrency slot synchronously, before any await, so two// requests cannot both pass the check.if (local) localActive++;@@@ -971,6 +971,83 @@ const PROVIDERS = [data_policy: policy({ trains: null, retention: null, terms: 'https://docs.dos.ai', checked: '2026-11-10' }),models: [],},++ // Altare Inference (2026-11-12, the owner: "a potentially excellent free API to test"): an OpenAI-compatible API for++ // open models. Free until 2026-12-01 (usage does not count toward the included amount; rate limits still apply), then++ // Always Free includes $100 of usage a month at list price, recorded in the console as a monthly credit plan++ // (https://docs.altare.gg/inference/pricing). Keys start alt_inf_ (Team → Inference → API keys). Its published limits++ // are kept by the router (rate_limits, server/providers/rate-gate.js). UNVERIFIED until the owner reads its terms,++ // so it serves OpenVibe's own work and stays out of paid managed routes; privacy is unread, so it is anonymized.++ {++ key: 'altare',++ docs_url: 'https://docs.altare.gg/inference',++ keys_url: 'https://altare.gg/console',++ display_name: 'Altare Inference',++ base_url: 'https://inference.altare.global/v1',++ default_model: 'deepseek-v4.1-flash',++ timeout_ms: 90000,++ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],++ models_public: true,++ provider_access: 'UNVERIFIED',++ priority: 25,++ rate_limits: {++ rpm: 60, model_rpm: { 'glm-5.3': 30, 'deepseek-v4.1-flash': 30 }, plan: 'Always Free',++ source: 'https://docs.altare.gg/inference/limits', checked: '2026-11-12',++ note: 'per team, per minute; Plus 240 and 90 a model, Business 480 and 90',++ },++ free_until: '2026-12-01',++ monthly_included_usd: { amount: 100, from: '2026-12-01', plan: 'Always Free', source: 'https://docs.altare.gg/inference/pricing' },++ models: [++ {++ model_key: 'glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,++ cost: { in_per_mtok: 1.4, cached_per_mtok: 0.26, out_per_mtok: 4.4 },++ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null, note: 'a flagship model: 30 requests a minute of its own on the free plan (gated: true in /v1/models)',++ },++ {++ model_key: 'deepseek-v4.1-flash', display_name: 'DeepSeek V4.1 Flash', family: 'deepseek', context_window: 1048576,++ cost: { in_per_mtok: 0.3, cached_per_mtok: 0.006, out_per_mtok: 1.2 },++ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null, note: 'a flagship model: 30 requests a minute of its own on the free plan (gated: true in /v1/models)',++ },++ {++ model_key: 'qwen3.8-27b', display_name: 'Qwen 3.8 27B', family: 'qwen', context_window: 262144,++ cost: { in_per_mtok: 0.45, cached_per_mtok: 0.05, out_per_mtok: 3.2 },++ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null,++ },++ {++ model_key: 'gemma-4-26b', display_name: 'Gemma 4 26B', family: 'gemma', context_window: 256000,++ cost: { in_per_mtok: 0.1, cached_per_mtok: 0.05, out_per_mtok: 0.3 },++ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null,++ },++ {++ model_key: 'glm-4.7-flash', display_name: 'GLM-4.7 Flash', family: 'glm', context_window: 131072,++ cost: { in_per_mtok: 0.0605, cached_per_mtok: null, out_per_mtok: 0.4 },++ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null,++ },++ {++ model_key: 'gpt-oss-120b', display_name: 'gpt-oss 120B', family: 'gpt-oss', context_window: 128000,++ cost: { in_per_mtok: 0.35, cached_per_mtok: null, out_per_mtok: 0.75 },++ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null,++ },++ {++ model_key: 'gpt-oss-20b', display_name: 'gpt-oss 20B', family: 'gpt-oss', context_window: 128000,++ cost: { in_per_mtok: 0.2, cached_per_mtok: null, out_per_mtok: 0.3 },++ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null,++ },++ ],++ },];@@@ -35,6 +35,8 @@ function recordOf(p) {auth_header: p.auth_header || null, docs_url: p.docs_url || null,search_adapters: p.search_adapters || [],data_policy: p.data_policy,++ // A provider's published request limits, kept by the router (server/providers/rate-gate.js).++ rate_limits: p.rate_limits || null,// The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on// nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.privacy: p.privacy,nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++/**++ * A provider's published request limits, kept by the router itself (2026-11-12: Altare Inference's free plan, 60++ * requests a minute and 30 a minute for each flagship model, https://docs.altare.gg/inference/limits). A provider row++ * carries them in metadata.rate_limits (the catalogue's `rate_limits`, seeded by pool-seed): { rpm, model_rpm: { model:++ * n } }. Each request the router sends is counted in its UTC minute; a provider (or one of its models) whose minute is++ * used up is skipped with `rate_limited`, so the router steps to the next offer instead of earning a 429. The provider's++ * own 429 still opens nothing on its own (failure.js classes it rate_limit). The counts live in this process only: one++ * AI process serves the router, and a restart starts the minute again, which can only under-count by one minute.++ */++const { byKey } = require('./pool-providers');++++function createRateGate({ now = () => Date.now(), margin = 1 } = {}) {++ const counts = new Map(); // `${provider}` or `${provider}\u0000${model}` -> { minute, n }++ const minuteOf = (t) => Math.floor(t / 60000);++ const limitsOf = (p) => {++ if (!p) return null;++ const own = p.metadata && p.metadata.rate_limits;++ if (own && typeof own === 'object') return own;++ const cat = byKey && typeof byKey.get === 'function' ? byKey.get(p.key) : null;++ return cat && cat.rate_limits ? cat.rate_limits : null;++ };++ const used = (key, m) => { const c = counts.get(key); return c && c.minute === m ? c.n : 0; };++ const bump = (key, m) => {++ const c = counts.get(key);++ if (c && c.minute === m) c.n += 1; else counts.set(key, { minute: m, n: 1 });++ if (counts.size > 5000) counts.delete(counts.keys().next().value);++ };++ const modelLimit = (l, model) => {++ if (!model || !l.model_rpm) return null;++ const leaf = String(model).split('/').pop().toLowerCase();++ for (const [k, v] of Object.entries(l.model_rpm)) if (String(k).toLowerCase() === leaf && Number(v) > 0) return Number(v);++ return null;++ };++ /** True when this provider (or this model of it) has used its minute. */++ function full(p, model) {++ const l = limitsOf(p);++ if (!l) return false;++ const m = minuteOf(now());++ if (Number(l.rpm) > 0 && used(p.key, m) >= Math.floor(Number(l.rpm) * margin)) return true;++ const ml = modelLimit(l, model);++ return ml != null && used(`${p.key}\u0000${String(model).toLowerCase()}`, m) >= Math.floor(ml * margin);++ }++ /** One request sent to this provider and model. */++ function take(p, model) {++ const l = limitsOf(p);++ if (!l) return;++ const m = minuteOf(now());++ bump(p.key, m);++ if (modelLimit(l, model) != null) bump(`${p.key}\u0000${String(model).toLowerCase()}`, m);++ }++ /** What a provider has left this minute, for the console: { rpm, used, models: { model: { limit, used } } } or null. */++ function view(p) {++ const l = limitsOf(p);++ if (!l) return null;++ const m = minuteOf(now());++ const models = {};++ for (const [k, v] of Object.entries(l.model_rpm || {})) models[k] = { limit: Number(v), used: used(`${p.key}\u0000${String(k).toLowerCase()}`, m) };++ return { rpm: Number(l.rpm) || null, used: used(p.key, m), models };++ }++ return { full, take, view };++}++++module.exports = { createRateGate };@@@ -14,6 +14,9 @@const { keyPage } = require('../owner-inputs');const CHECKED = '2026-11-12';++// Altare Inference is free until 2026-12-01 (usage does not count toward the included amount; rate limits apply), then++// Always Free includes $100 of usage a month (https://docs.altare.gg/inference/pricing): an offer worth trying first.++const ALTARE_NOTE = 'Free until December 1, then $100 of usage a month on Altare\'s free plan (60 requests a minute; 30 for its flagship models). Record the $100 as a monthly credit on Exchange → Console → Providers.';const SOURCES = Object.freeze({deepseek: 'https://api-docs.deepseek.com/quick_start/pricing',gemini: 'https://ai.google.dev/gemini-api/docs/pricing',@@@ -25,6 +28,8 @@ const SOURCES = Object.freeze({brave: 'https://brave.com/search/api/',tavily: 'https://www.tavily.com/pricing',serper: 'https://serper.dev/pricing',++ altare: 'https://inference.altare.global/v1/models',++ geminiDecision: 'https://blog.google/innovation-and-ai/technology/developers-tools/gemini-decisions-api/',});/** A price in the page's plain words; `unit` is what the number is per. */@@@ -56,6 +61,7 @@ const STEPS = Object.freeze([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('altare', 'deepseek-v4.1-flash', 'DeepSeek V4.1 Flash on Altare', tokenPrice(0.3, 1.2, 0.006), SOURCES.altare, { why: 'free until December 1', note: ALTARE_NOTE }),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),],@@@ -135,10 +141,25 @@ const STEPS = Object.freeze([recommended: offer('deepseek', 'deepseek-chat', 'DeepSeek Chat', tokenPrice(0.26, 0.38), SOURCES.deepseek,{ why: 'cheapest that looks good' }),alternatives: [++ offer('altare', 'glm-5.3', 'GLM-5.3 on Altare', tokenPrice(1.4, 4.4, 0.26), SOURCES.altare, { why: 'free until December 1', note: ALTARE_NOTE }),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: 'decide', name: 'Decisions (routing and checks)', short: 'Decisions', route: 'decide.fallback', 'capability': 'chat', operation: 'chat', modelType: 'chat',++ what: 'Answers yes/no, one-of-a-list and rubric questions about a text or a picture, with how sure it is.',++ uses: 'POST /api/v1/decisions: routing a request to the right model, moderation checks, grading and tool choice, without a full model writing an answer.',++ recommended: offer('gemini', 'gemini-decision-1', 'Gemini Decision 1', { kind: 'tokens', in_per_mtok: 0.1, out_per_mtok: 0, cached_per_mtok: null, unit: '1M tokens', text: '$0.10 per 1M input tokens, nothing for output' }, SOURCES.geminiDecision,++ { why: 'calibrated and fast', note: 'Google\'s Decisions API answers in one pass, with calibrated probabilities. It has a free tier, which may use what it reads to improve Google\'s products: test with public or synthetic data. Without a Gemini key, the cheap structured route below answers text instead, uncalibrated.' }),++ // The fallback route's own model: Gemini Decision 1 is Google's Decisions API, not a chat model, so it never sits++ // on decide.fallback; the route gets the cheap structured model and the Decisions API needs only the Gemini key.++ routeOffer: offer('deepseek', 'deepseek-flash', 'DeepSeek Flash', tokenPrice(0.15, 0.6, 0.003), SOURCES.deepseek),++ alternatives: [++ offer('altare', 'gemma-4-26b', 'Gemma 4 26B on Altare', tokenPrice(0.1, 0.3, 0.05), SOURCES.altare, { why: 'free until December 1', note: ALTARE_NOTE }),++ offer('deepseek', 'deepseek-flash', 'DeepSeek Flash', tokenPrice(0.15, 0.6, 0.003), SOURCES.deepseek),++ ],++ },]);const BY_ID = new Map(STEPS.map((s) => [s.id, s]));@@@ -146,7 +167,7 @@ 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)',++ speech: 'Voices', embedding: 'Embeddings', search: 'Web search', code: 'Code agents (plans)', decide: 'Decisions',});function stepById(id) {nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++/**++ * decide.fallback (2026-11-12): the Decisions API's answer when Google's Decisions API (gemini-decision-1) cannot take a++ * request (no Gemini key, or Google is away). The same closed-set questions about one text input, answered by the++ * router's cheap structured-output route (default.json) as estimated probabilities. They are not calibrated, and the++ * answer says so (calibrated: false); server/api/decisions.js chooses between the two.++ *++ * Question types are Google's: `noul` (yes or no: the probability of yes), `choice` (one of 2-255 named options: the++ * winner and a probability for each) and `score` (an ordered rubric from level 0: the weighted level and a probability++ * for each level). Every answer carries a confidence from 0 to 1.++ */++const MAX_QUESTIONS = 100;++++const clamp = (n, lo = 0.01, hi = 0.99) => Math.min(hi, Math.max(lo, Number.isFinite(Number(n)) ? Number(n) : 0.5));++++/** The model's probabilities -> a normalized distribution over the expected keys (missing ones share what is left). */++function normalized(raw, keys) {++ const vals = keys.map((k) => Math.max(0, Number(raw && raw[k]) || 0));++ const sum = vals.reduce((a, b) => a + b, 0);++ if (!(sum > 0)) return Object.fromEntries(keys.map((k) => [k, 1 / keys.length]));++ return Object.fromEntries(keys.map((k, i) => [k, Math.round((vals[i] / sum) * 1e4) / 1e4]));++}++++/** One question's answer from the model's JSON, in the Decisions API's shape. */++function answerOf(q, raw) {++ const a = raw && typeof raw === 'object' ? raw : {};++ if (q.type === 'noul') {++ const p = clamp(a.noul != null ? a.noul : a.probability);++ return { type: 'noul', noul: p, confidence: Math.round(Math.max(p, 1 - p) * 100) / 100 };++ }++ if (q.type === 'choice') {++ const keys = Object.keys(q.criteria || {});++ const probabilities = normalized(a.probabilities, keys);++ const choice = keys.includes(a.choice) ? a.choice : keys.reduce((best, k) => (probabilities[k] > probabilities[best] ? k : best), keys[0]);++ return { type: 'choice', choice, probabilities, confidence: Math.round(Math.max(...Object.values(probabilities)) * 100) / 100 };++ }++ const levels = Array.isArray(q.criteria) ? q.criteria.map((_, i) => String(i)) : [];++ const probabilities = normalized(a.probabilities, levels);++ const score = levels.reduce((s, k) => s + Number(k) * probabilities[k], 0);++ return { type: 'score', score: Math.round(score * 1000) / 1000, probabilities, legend: Object.fromEntries((q.criteria || []).map((c, i) => [String(i), String(c)])),++ confidence: Math.round(Math.max(...Object.values(probabilities), 0) * 100) / 100 };++}++++const templates = [{++ key: 'decide.fallback', name: 'Decisions without the Decisions API',++ description: 'Closed-set questions about one input (yes/no, one of a list, a rubric level), each answered with probabilities.',++ input_schema: { type: 'object' },++ output_schema: { type: 'object' },++ system_prompt: 'You judge one input against independent closed-set questions. Answer every question by its name, and only from the input. '++ + 'For "noul" give {"noul": P} where P is the probability (0.01 to 0.99) that the answer is yes. '++ + 'For "choice" give {"choice": "<one option name>", "probabilities": {"<option>": p, …}} over exactly the listed options, summing to 1. '++ + 'For "score" give {"probabilities": {"0": p, "1": p, …}} over the rubric levels, summing to 1. '++ + 'Probabilities say how sure you are: use the middle when the input does not say. Questions cannot see each other. '++ + 'Output only JSON: {"answers": {"<question name>": {…}, …}}.',++ user_prompt: '{{json payload}}',++ default_route: 'decide.fallback', owner: 'ai', metadata: { json_name: 'decisions' },++}];++++const workflows = [{++ key: 'decide.fallback', name: 'Decisions (fallback)', namespace: 'ai',++ description: 'The Decisions API\'s fallback: the cheap structured-output route answers closed-set questions about a text input with estimated, uncalibrated probabilities.',++ input_schema: {++ type: 'object', additionalProperties: false, required: ['input', 'questions'],++ properties: {++ input: { type: 'string', minLength: 1, maxLength: 200000 },++ questions: { type: 'object', minProperties: 1, maxProperties: MAX_QUESTIONS },++ },++ },++ output_schema: { type: 'object' },++ steps: [{ kind: 'llm', template: 'decide.fallback', operation: 'classify', output: 'json', prepare: 'decide.fallback', postprocess: 'decide.fallback', params: { max_tokens: 4000, temperature: 0, timeout_ms: 60000 } }],++ cache_mode: 'none',++}];++++function prepare(input) {++ const questions = Object.fromEntries(Object.entries(input.questions || {}).map(([name, q]) => [name, {++ type: q.type, instructions: q.instructions, ...(q.criteria ? { criteria: q.criteria } : {}),++ }]));++ return { vars: { payload: { input: input.input, questions } } };++}++++function postprocess(r, input) {++ const raw = r && r.json && typeof r.json === 'object' ? (r.json.answers || r.json) : {};++ const answers = {};++ for (const [name, q] of Object.entries(input.questions || {})) answers[name] = answerOf(q, raw[name]);++ return { answers, calibrated: false };++}++++module.exports = { templates, workflows, prepare, postprocess, answerOf, MAX_QUESTIONS };@@@ -646,6 +646,7 @@ const POSTPROCESS = {{ const space = require('./space'); Object.assign(PREPARE, space.PREPARE); Object.assign(POSTPROCESS, space.POSTPROCESS); }// People's own controls (2026-11-11): the layout suggestion's prepare and postprocess live with its template.{ const ui = require('./ui-layout'); PREPARE['ui.layout.suggest'] = ui.prepare; POSTPROCESS['ui.layout.suggest'] = ui.postprocess; }++{ const d = require('./decide'); PREPARE['decide.fallback'] = d.prepare; POSTPROCESS['decide.fallback'] = d.postprocess; }/*** What the run cache keys on, for a workflow whose input carries parts that do not change the answer (item 151, C12:@@@ -27,6 +27,7 @@ const meme = require('./meme');const changelog = require('./changelog');const pictures = require('./pictures');const uiLayout = require('./ui-layout');++const decide = require('./decide');const poolSeed = require('../providers/pool-seed');const { resolveSecret } = require('../util');@@@ -260,6 +261,8 @@ async function seed({ registry, quotas, config, env = process.env, db }) {// A layout suggestion is a small JSON arrangement, not a conversation: the cheapest structured-output// offer that is healthy, with room for every part of a surface (make your own controls, 2026-11-11).await registry.seedVersioned('route', 'ui.layout.suggest', pool(60000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.2 }, max_output_tokens: 4000, response_format: 'json' }));++ // Decisions without the Decisions API (2026-11-12): small JSON probabilities, the cheapest healthy structured offer.++ await registry.seedVersioned('route', 'decide.fallback', pool(60000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0 }, max_output_tokens: 4000, response_format: 'json' }));await registry.seedVersioned('route', 'default.embedding', pool(30000, { 'capability': 'embed', constraints: { objective: 'balanced' } }));// Speech (the Exchange speech route): the voice pins the exact offer, so the pool is only the fallback// order when no voice is pinned; every provider that declares `speak` may answer it.@@@ -318,11 +321,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });// Templates, then workflows (workflows reference templates)-- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates, ...uiLayout.templates]) {++ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates, ...uiLayout.templates, ...decide.templates]) {const { key, ...def } = t;await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });}-- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...deepDive.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...deepDive.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows, ...decide.workflows]) {const { key, ...def } = w;await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });}@@@ -36,7 +36,7 @@ const DOMAIN = {baseten: 'baseten.co', hyperbolic: 'hyperbolic.xyz', parasail: 'parasail.io', 'particle-ai': 'particle.ai','inference-net': 'inference.net', 'atlas-cloud': 'atlascloud.ai', avian: 'avian.io',nanogpt: 'nano-gpt.com', infermatic: 'totalgpt.ai', arliai: 'arliai.com', friendli: 'friendli.ai', scaleway: 'scaleway.com', anthropic: 'claude.com',-- iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai', 'runpod-vllm': 'runpod.io', meta: 'meta.ai',++ iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai', 'runpod-vllm': 'runpod.io', meta: 'meta.ai', altare: 'altare.global',};const onItsOwnSite = (url, key) => {const host = new URL(url).hostname;@@@ -50,7 +50,7 @@ t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an aassert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));-- assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 55);++ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 56);assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++/**++ * Monthly credits from a plan (2026-11-12, the owner: "make it aware of granted API credits for both anthropic and++ * openai"): a plan is one promotional grant per billing cycle that expires when the cycle ends, prorated when it starts++ * mid-cycle; it only pays for its own cycle's spend; the router prefers its provider while it lasts; a plan turned off++ * ends this cycle's grant today. Any provider key works (Altare's $100 a month from December 1).++ */++const assert = require('assert');++const { createCreditGrants, cycleOf } = require('../server/providers/credit-grants');++const { testDb } = require('./db');++const { suite } = require('./helpers');++++const t = suite('credit-plans');++const DAY = 86400000;++++t.test('a cycle renews on its day, and the 31st on a short month\'s last day', async () => {++ const c = cycleOf(5, Date.parse('2026-11-12T10:00:00Z'));++ assert.deepStrictEqual([new Date(c.start).toISOString(), new Date(c.end).toISOString()], ['2026-11-05T00:00:00.000Z', '2026-12-05T00:00:00.000Z']);++ const early = cycleOf(20, Date.parse('2026-11-12T10:00:00Z'));++ assert.deepStrictEqual([new Date(early.start).toISOString(), new Date(early.end).toISOString()], ['2026-10-20T00:00:00.000Z', '2026-11-20T00:00:00.000Z']);++ const short = cycleOf(31, Date.parse('2026-02-15T00:00:00Z'));++ assert.deepStrictEqual([new Date(short.start).toISOString(), new Date(short.end).toISOString()], ['2026-01-31T00:00:00.000Z', '2026-02-28T00:00:00.000Z']);++});++++t.test('a plan grants once per cycle, prorated at first, spends only its own cycle, and is preferred while it lasts', async () => {++ const tdb = await testDb();++ try {++ const db = tdb.db;++ let now = Date.parse('2026-11-20T00:00:00Z');++ const credits = createCreditGrants({ db, env: {}, clock: { now: () => now } });++ // ChatGPT Pro's $200 a month, renewing on the 5th, started on the 20th: the first grant is the 15 days left of 30.++ await credits.addPlan({ provider_key: 'openai', label: 'ChatGPT Pro API credits', monthly_usd: 200, renews_day: 5, starts_on: '2026-11-20' });++ let grants = await credits.list('openai');++ assert.strictEqual(grants.length, 1);++ assert.strictEqual(Number(grants[0].amount_usd), 100, 'prorated: 15 of 30 days');++ assert.strictEqual(grants[0].expires_at, '2026-12-05T00:00:00.000Z', 'unused credit expires when the cycle ends');++ await credits.materialize();++ assert.strictEqual((await credits.list('openai')).length, 1, 'once per cycle');++++ // Spend before the plan began is not its business; spend inside its cycle is.++ const spend = (at, usd) => db.prepare("INSERT INTO requests (operation, provider_key, status, cost_usd, created_at) VALUES ('chat', 'openai', 'ok', ?, ?)").run(usd, new Date(at).toISOString());++ await spend(Date.parse('2026-11-10T00:00:00Z'), 40);++ await spend(Date.parse('2026-11-21T00:00:00Z'), 30);++ now = Date.parse('2026-11-22T00:00:00Z');++ let out = (await credits.poll()).find((x) => x.provider === 'openai');++ assert.strictEqual(out.plans.length, 1);++ assert.strictEqual(out.plans[0].remaining_usd, 70, 'its own cycle\'s 30 only, not the 40 from before');++ assert.strictEqual(out.promotional, 70);++ assert.strictEqual(credits.shouldPrefer('openai'), true, 'the router spends granted credit first');++++ // The next cycle: a full $200, the old grant expired and gone from the balance.++ now = Date.parse('2026-12-06T00:00:00Z');++ out = (await credits.poll()).find((x) => x.provider === 'openai');++ grants = await credits.list('openai');++ assert.strictEqual(grants.length, 2);++ assert.strictEqual(out.plans.length, 1);++ assert.strictEqual(out.plans[0].amount_usd, 200);++ assert.strictEqual(out.promotional, 200, 'last cycle\'s leftover does not carry over');++++ // Off: this cycle's grant ends today and no new one comes.++ const plan = (await credits.listPlans('openai'))[0];++ await credits.setPlanActive(plan.id, false);++ now += DAY;++ out = (await credits.poll()).find((x) => x.provider === 'openai');++ assert.strictEqual(out.promotional, 0);++ assert.strictEqual(credits.shouldPrefer('openai'), false);++++ // Any provider: Altare's $100 a month from December 1, on the 1st.++ await credits.addPlan({ provider_key: 'altare', label: 'Altare Always Free', monthly_usd: 100, renews_day: 1, starts_on: '2026-12-01' });++ out = (await credits.poll()).find((x) => x.provider === 'altare');++ assert.ok(out && out.plans[0].amount_usd === 100, JSON.stringify(out && out.plans));++ await assert.rejects(credits.addPlan({ provider_key: 'openai', label: '', monthly_usd: 10, renews_day: 1, starts_on: '2026-12-01' }), /label/);++ await assert.rejects(credits.addPlan({ provider_key: 'openai', label: 'x', monthly_usd: 10, renews_day: 40, starts_on: '2026-12-01' }), /renews_day/);++ } finally { await tdb.close(); }++});++++t.run();nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++// Decisions (2026-11-12): POST /api/v1/decisions answers closed-set questions about one input. With a Gemini key the++// call goes to Google's Decisions API (gemini-decision-1) and comes back calibrated, metered at $0.10 per 1M input++// tokens; without one (or when Google fails) the router's cheap structured route answers text through decide.fallback,++// normalized to the same shape and marked calibrated: false; pictures without the Decisions API are refused honestly.++const assert = require('assert');++const { boot, request, seamServer, suite, token } = require('./helpers');++const { checked } = require('../server/api/decisions');++const { answerOf } = require('../server/workflows/decide');++++const t = suite('decisions');++const CAP = token('network', ['ai.run.create'], { sub: 'svc:network' });++const NOCAP = token('network', ['ai.layout.suggest'], { sub: 'svc:network' });++const QUESTIONS = {++ is_spam: { type: 'noul', instructions: 'Is this message spam?' },++ kind: { type: 'choice', instructions: 'What is it?', criteria: { bug: 'Something is broken.', idea: 'A wish for something new.' } },++ severity: { type: 'score', instructions: 'How bad is it?', criteria: ['fine', 'minor', 'major'] },++};++++t.test('the request is checked: names, types, choices, rubric levels', async () => {++ assert.throws(() => checked({ input: 'x', questions: {} }), /1 to 1000 questions/);++ assert.throws(() => checked({ input: 'x', questions: { 'Bad Name': { type: 'noul', instructions: 'x' } } }), /question name/);++ assert.throws(() => checked({ input: 'x', questions: { a: { type: 'maybe', instructions: 'x' } } }), /noul, choice or score/);++ assert.throws(() => checked({ input: 'x', questions: { a: { type: 'choice', instructions: 'x', criteria: { only: 'one' } } } }), /2 to 255 options/);++ assert.throws(() => checked({ input: 'x', questions: { a: { type: 'score', instructions: 'x', criteria: ['one'] } } }), /2 to 20 rubric levels/);++ assert.throws(() => checked({ input: [{ type: 'image', uri: 'http://insecure' }], questions: { a: { type: 'noul', instructions: 'x' } } }), /https uri/);++ const ok = checked({ input: 'hello', questions: QUESTIONS });++ assert.strictEqual(ok.onlyText, true);++ assert.strictEqual(ok.prefer, 'calibrated');++});++++t.test('the fallback\'s answers are normalized to the Decisions API\'s shape', async () => {++ assert.deepStrictEqual(answerOf(QUESTIONS.is_spam, { noul: 1.4 }), { type: 'noul', noul: 0.99, confidence: 0.99 });++ const c = answerOf(QUESTIONS.kind, { choice: 'nonsense', probabilities: { bug: 3, idea: 1 } });++ assert.deepStrictEqual([c.choice, c.probabilities], ['bug', { bug: 0.75, idea: 0.25 }], 'probabilities sum to 1 and the winner is a listed option');++ const s = answerOf(QUESTIONS.severity, { probabilities: { 0: 0, 1: 1, 2: 1 } });++ assert.strictEqual(s.score, 1.5);++ assert.deepStrictEqual(s.legend, { 0: 'fine', 1: 'minor', 2: 'major' });++});++++t.test('with a Gemini key: Google\'s Decisions API, calibrated and metered; without one: the router, not calibrated', async () => {++ const google = [];++ let googleDown = false;++ const fetchImpl = async (url, opts) => {++ if (String(url) === 'https://generativelanguage.googleapis.com/v1beta/decisions') {++ google.push({ key: opts.headers['x-goog-api-key'], body: JSON.parse(opts.body) });++ if (googleDown) return { ok: false, status: 503, json: async () => ({ error: { message: 'down' } }) };++ return { ok: true, status: 200, json: async () => ({ answers: { is_spam: { type: 'noul', noul: 0.04, confidence: 0.96 } }, usage: { total_input_tokens: 2000000, total_output_tokens: 0 } }) };++ }++ return globalThis.fetch(url, opts);++ };++ const seam = await seamServer(async () => ({ json: { answers: { is_spam: { noul: 0.2 }, kind: { choice: 'idea', probabilities: { bug: 0.1, idea: 0.9 } }, severity: { probabilities: { 0: 1 } } } } }));++ const h = await boot({ fetchImpl, env: { AI_HTTP_SEAM_URL: seam.url, AI_USER_MODULES: 'off', AI_FREE_PROVIDERS: 'http-seam', AI_FREE_DAILY_TOKENS: '100000', GEMINI_DECIDE_TEST_KEY: 'g-key' } });++ try {++ await h.registry.upsertProvider({ key: 'http-seam', capabilities: ['chat', 'generate', 'json', 'classify'], default_model: 'decide-fake', billing_profile: 'free', provider_access: 'OWN_DEPLOYMENT' }, { actor: 'test' });++ await h.registry.upsertModel({ provider_key: 'http-seam', model_key: 'decide-fake', type: 'chat', status: 'active', supports: { json: true }, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 } }, { actor: 'test' });++ await h.registry.upsertProvider({ key: 'stub', status: 'disabled' }, { actor: 'test' });++++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/decisions', { tok: NOCAP, body: { input: 'buy cheap pills', questions: QUESTIONS } })).status, 403, 'needs ai.run.create');++++ // No Gemini key yet: the router answers, estimated.++ let r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: 'Could the menu remember my last tab?', questions: QUESTIONS } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.calibrated, false);++ assert.strictEqual(r.body.answers.kind.choice, 'idea');++ assert.strictEqual(r.body.answers.is_spam.noul, 0.2);++ assert.strictEqual(r.body.receipt.model, 'decide-fake');++ assert.strictEqual(google.length, 0);++++ // Pictures need the Decisions API.++ r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: [{ type: 'image', uri: 'https://example.com/a.png' }], questions: { is_cat: { type: 'noul', instructions: 'Is it a cat?' } } } });++ assert.strictEqual(r.status, 503, r.text);++++ // With the gemini provider's key: Google answers, calibrated, and the call is metered.++ await h.registry.upsertProvider({ key: 'gemini', secret_ref: 'env:GEMINI_DECIDE_TEST_KEY' }, { actor: 'test' });++ r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: 'buy cheap pills', questions: { is_spam: QUESTIONS.is_spam } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.calibrated, true);++ assert.deepStrictEqual(r.body.answers.is_spam, { type: 'noul', noul: 0.04, confidence: 0.96 });++ assert.deepStrictEqual([google[0].key, google[0].body.model, google[0].body.input], ['g-key', 'gemini-decision-1', 'buy cheap pills']);++ assert.strictEqual(r.body.receipt.cost_usd, 0.2, '2M input tokens at $0.10 per 1M, nothing for output');++ const row = await h.db.prepare("SELECT provider_key, model_key, cost_usd FROM requests WHERE provider_key = 'gemini' ORDER BY id DESC LIMIT 1").get();++ assert.deepStrictEqual([row.model_key, Number(row.cost_usd)], ['gemini-decision-1', 0.2], 'metered like any request');++++ // Google away: the router answers the text instead.++ googleDown = true;++ r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: 'buy cheap pills', questions: QUESTIONS } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.calibrated, false);++ } finally { await h.stop(); await seam.close(); }++});++++t.run();nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++// A provider's published request limits (2026-11-12, Altare Inference's free plan: 60 a minute, 30 a minute for each++// flagship model): the router counts its own requests per minute and skips the provider, or the one model, whose++// minute is used up; the next minute opens again; a provider without limits is never held.++const assert = require('assert');++const { createRateGate } = require('../server/providers/rate-gate');++const { PROVIDERS } = require('../server/providers/pool-providers');++const { suite } = require('./helpers');++++const t = suite('rate-gate');++++t.test('Altare is catalogued with its published limits and prices', async () => {++ const a = PROVIDERS.find((p) => p.key === 'altare');++ assert.ok(a, 'Altare Inference is a catalogued provider');++ assert.strictEqual(a.base_url, 'https://inference.altare.global/v1');++ assert.deepStrictEqual([a.rate_limits.rpm, a.rate_limits.model_rpm['glm-5.3'], a.rate_limits.model_rpm['deepseek-v4.1-flash']], [60, 30, 30]);++ assert.strictEqual(a.provider_access, 'UNVERIFIED', 'out of paid managed routes until the owner reads its terms');++ const flash = a.models.find((m) => m.model_key === 'deepseek-v4.1-flash');++ assert.deepStrictEqual(flash.cost, { in_per_mtok: 0.3, cached_per_mtok: 0.006, out_per_mtok: 1.2 });++});++++t.test('a minute fills, per provider and per flagship model, and the next minute opens', async () => {++ let now = Date.parse('2026-11-12T20:00:05Z');++ const gate = createRateGate({ now: () => now });++ const p = { key: 'altare', metadata: { rate_limits: { rpm: 60, model_rpm: { 'deepseek-v4.1-flash': 30 } } } };++ for (let i = 0; i < 30; i++) { assert.strictEqual(gate.full(p, 'deepseek-v4.1-flash'), false); gate.take(p, 'deepseek-v4.1-flash'); }++ assert.strictEqual(gate.full(p, 'deepseek-v4.1-flash'), true, 'the flagship model\'s 30 are used');++ assert.strictEqual(gate.full(p, 'gemma-4-26b'), false, 'another model still has the provider\'s minute');++ for (let i = 0; i < 30; i++) gate.take(p, 'gemma-4-26b');++ assert.strictEqual(gate.full(p, 'gemma-4-26b'), true, 'the provider\'s 60 are used');++ assert.deepStrictEqual(gate.view(p), { rpm: 60, used: 60, models: { 'deepseek-v4.1-flash': { limit: 30, used: 30 } } });++ now += 60000;++ assert.strictEqual(gate.full(p, 'deepseek-v4.1-flash'), false, 'a new minute');++ assert.strictEqual(gate.full({ key: 'deepseek', metadata: {} }, 'deepseek-chat'), false, 'no limits, never held');++ assert.strictEqual(gate.full({ key: 'altare', metadata: {} }, 'glm-5.3'), false, 'the catalogue\'s limits apply when the row has none');++});++++t.run();@@@ -25,13 +25,13 @@ t.test('the page state: eight steps, owner only, key pages and spend', async ()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.length, 9, 'one step per job (Decisions since 11-12)');++ assert.strictEqual(r.body.summary.total, 9);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');++ assert.strictEqual(r.body.defaults.length, 9, '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 } });@@@ -106,7 +106,9 @@ t.test('a cap refuses the next run of that kind before it spends', async () => {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));++ assert.strictEqual(r.body.applied.filter((x) => x.ok).length, 8, 'eight routed steps: '+JSON.stringify(r.body.applied));++ const decideRoute = await h.registry.getRoute('decide.fallback');++ assert.strictEqual(decideRoute.constraints.model, 'deepseek-flash', 'the Decisions fallback route gets a chat model, never gemini-decision-1');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');@@@ -103,6 +103,7 @@ EXAMPLES['space.app.ask'] = { space: 'Pirate helper', instructions: 'Talk like aEXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };// People's own controls (2026-11-11): the fake surface is deliberately small; the stub's answer is// refused by the layout validator, which is still a schema-valid suggestion and exercises that path.++EXAMPLES['decide.fallback'] = { input: 'Could the menu remember my last tab?', questions: { is_question: { type: 'noul', instructions: 'Is it a question?' }, kind: { type: 'choice', instructions: 'What is it?', criteria: { bug: 'Something is broken.', idea: 'A wish.' } } } };EXAMPLES['ui.layout.suggest'] = {catalog: {id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+-- phase: expand
+-- Monthly API credits that come with a subscription or a plan (2026-11-12, the owner: "make it aware of granted API
+-- credits for both anthropic and openai"): ChatGPT Pro's $100, $200 or $500 a month of OpenAI API credit, an
+-- Anthropic allowance, Altare Inference's $100 a month from 2026-12-01. Each plan becomes one promotional grant per
+-- billing cycle in provider_credit_grants (applies_to 'plan:<id>:<cycle start>', expiring when the cycle ends), so
+-- the router spends it first and nothing carries over; the first cycle is prorated when the plan starts mid-cycle.
+CREATE TABLE IF NOT EXISTS provider_credit_plans (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ provider_key text COLLATE "C" NOT NULL,
+ label text COLLATE "C" NOT NULL,
+ monthly_usd numeric(14, 6) NOT NULL CHECK (monthly_usd >= 0),
+ renews_day integer NOT NULL CHECK (renews_day BETWEEN 1 AND 31),
+ starts_on text COLLATE "C" NOT NULL,
+ ends_on text COLLATE "C",
+ prorate_first bigint NOT NULL DEFAULT 1,
+ active bigint NOT NULL DEFAULT 1,
+ notes text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX IF NOT EXISTS provider_credit_plans_provider ON provider_credit_plans(provider_key, active);
@@ -11,6 +11,13 @@ const gpuCatalog = require('../gpu/catalog');
@@ -11,6 +11,13 @@ const gpuCatalog = require('../gpu/catalog');
const { tokenCosts } = require('../gpu/token-cost');
const { tokenCosts } = require('../gpu/token-cost');
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
+/** A provider key a credit record may name: Anthropic, OpenAI or any catalogued or registered provider (a lowercase key). */
+function creditProviderOf(value) {
+ const key = String(value || '').trim().toLowerCase();
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(key)) throw new AiError(422, 'ai.invalid', 'name a provider key, such as openai, anthropic or altare');
+ return key;
+}
+
function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
const r = express.Router();
const r = express.Router();
const providerOrder = require('../providers/order').createProviderOrder({ registry });
const providerOrder = require('../providers/order').createProviderOrder({ registry });
@@ -80,9 +87,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -80,9 +87,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
uncensored: Boolean(p.metadata && p.metadata.uncensored),
uncensored: Boolean(p.metadata && p.metadata.uncensored),
}));
}));
const poolProviders = require('../providers/pool-providers').PROVIDERS;
const poolProviders = require('../providers/pool-providers').PROVIDERS;
- const estimates = creditGrants ? await Promise.all(['anthropic', 'openai'].map(async (provider) => ({
+ // Every provider with credit on record (prepaid, promotional, or a plan's monthly grant), Anthropic and OpenAI always.
+ const creditPlans = creditGrants && creditGrants.listPlans ? await creditGrants.listPlans() : [];
+ const creditProviders = [...new Set(['anthropic', 'openai', ...creditPlans.map((p) => p.provider_key), ...(creditGrants ? creditGrants.values().map((v) => v.provider) : [])])];
+ const estimates = creditGrants ? await Promise.all(creditProviders.map(async (provider) => ({
provider, grants: await creditGrants.list(provider), estimate: creditGrants.values().find((x) => x.provider === provider) || null,
provider, grants: await creditGrants.list(provider), estimate: creditGrants.values().find((x) => x.provider === provider) || null,
preference: await creditGrants.preference(provider),
preference: await creditGrants.preference(provider),
+ plans: creditPlans.filter((p) => p.provider_key === provider).map((p) => ({ id: Number(p.id), label: p.label, monthly_usd: Number(p.monthly_usd), renews_day: Number(p.renews_day),
+ starts_on: p.starts_on, ends_on: p.ends_on || null, active: Boolean(Number(p.active)), notes: p.notes || null })),
}))) : [];
}))) : [];
const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
@@ -258,7 +270,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -258,7 +270,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
r.post('/api/v1/admin/console/provider-credits', json, write, wrap(async (req) => {
r.post('/api/v1/admin/console/provider-credits', json, write, wrap(async (req) => {
if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit records are not configured');
if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit records are not configured');
const b = req.body || {};
const b = req.body || {};
- const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';
+ const provider = creditProviderOf(b.provider);
const grant = await creditGrants.add({ provider_key: provider, kind: String(b.kind || 'promotional'), amount_usd: Number(b.amount_usd),
const grant = await creditGrants.add({ provider_key: provider, kind: String(b.kind || 'promotional'), amount_usd: Number(b.amount_usd),
as_of: String(b.as_of || ''), received_at: String(b.received_at || b.as_of || ''),
as_of: String(b.as_of || ''), received_at: String(b.received_at || b.as_of || ''),
remaining_as_of_usd: b.remaining_as_of_usd == null ? Number(b.amount_usd) : Number(b.remaining_as_of_usd),
remaining_as_of_usd: b.remaining_as_of_usd == null ? Number(b.amount_usd) : Number(b.remaining_as_of_usd),
@@ -267,10 +279,33 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -267,10 +279,33 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
applies_to: String(b.applies_to || '').slice(0, 200) || null });
applies_to: String(b.applies_to || '').slice(0, 200) || null });
return { grant };
return { grant };
}));
}));
+ // Monthly credits that come with a subscription or a plan (ChatGPT Pro's API credits, an Anthropic allowance, Altare's
+ // free $100 a month): one promotional grant per billing cycle, spent first, gone when the cycle ends.
+ r.post('/api/v1/admin/console/provider-credit-plans', json, write, wrap(async (req) => {
+ if (!creditGrants || !creditGrants.addPlan) throw new AiError(503, 'ai.unavailable', 'provider credit plans are not configured');
+ const b = req.body || {};
+ try {
+ const plan = await creditGrants.addPlan({ provider_key: creditProviderOf(b.provider), label: b.label, monthly_usd: Number(b.monthly_usd), renews_day: Number(b.renews_day),
+ starts_on: String(b.starts_on || ''), ends_on: b.ends_on ? String(b.ends_on) : null, prorate_first: b.prorate_first !== false && b.prorate_first !== 'off', notes: b.notes || null });
+ await creditGrants.poll().catch(() => null);
+ return { plan };
+ } catch (err) {
+ if (err instanceof TypeError) throw new AiError(422, 'ai.invalid', err.message);
+ throw err;
+ }
+ }));
+ r.post('/api/v1/admin/console/provider-credit-plans/:id', json, write, wrap(async (req) => {
+ if (!creditGrants || !creditGrants.setPlanActive) throw new AiError(503, 'ai.unavailable', 'provider credit plans are not configured');
+ if (!/^\d{1,12}$/.test(String(req.params.id))) throw new AiError(422, 'ai.invalid', 'a plan id is a number');
+ const changed = await creditGrants.setPlanActive(Number(req.params.id), (req.body || {}).active === true || (req.body || {}).active === 'on');
+ if (!changed) throw new AiError(404, 'ai.not_found', 'no such plan');
+ await creditGrants.poll().catch(() => null);
+ return { ok: true };
+ }));
r.post('/api/v1/admin/console/provider-credit-preference', json, write, wrap(async (req) => {
r.post('/api/v1/admin/console/provider-credit-preference', json, write, wrap(async (req) => {
if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit preferences are not configured');
if (!creditGrants) throw new AiError(503, 'ai.unavailable', 'provider credit preferences are not configured');
const b = req.body || {};
const b = req.body || {};
- const provider = ['anthropic', 'openai'].includes(String(b.provider)) ? String(b.provider) : 'anthropic';
+ const provider = creditProviderOf(b.provider);
return { preference: await creditGrants.setPreference({ provider_key: provider, enabled: b.enabled === true, floor_usd: Number(b.floor_usd) }) };
return { preference: await creditGrants.setPreference({ provider_key: provider, enabled: b.enabled === true, floor_usd: Number(b.floor_usd) }) };
}));
}));
r.post('/api/v1/admin/console/provider-session-credentials', json, write, wrap(async (req) => {
r.post('/api/v1/admin/console/provider-session-credentials', json, write, wrap(async (req) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+'use strict';
+/**
+ * POST /api/v1/decisions (2026-11-12, the owner: "Add decisions using automatic scalable cost effective modular multi
+ * provider intelligent routing solution on OpenVibe Exchange"). Closed-set questions about one input in one call: yes
+ * or no (`noul`), one of a list (`choice`), a rubric level (`score`), each with probabilities and a confidence, for
+ * routing, moderation checks, grading and tool choice without a full model writing an answer.
+ *
+ * { "input": "…" | [{ "type": "text", "text": "…" }, { "type": "image", "uri": "https://…", "mime_type": "image/png" }],
+ * "questions": { "is_spam": { "type": "noul", "instructions": "Is this spam?" },
+ * "kind": { "type": "choice", "instructions": "What is it?", "criteria": { "bug": "…", "idea": "…" } },
+ * "severity": { "type": "score", "instructions": "How bad?", "criteria": ["fine", "minor", "major"] } },
+ * "prefer": "calibrated" | "cheap" }
+ * → { "answers": { "is_spam": { "type": "noul", "noul": 0.03, "confidence": 0.97 }, … }, "calibrated": true,
+ * "receipt": { "provider": "gemini", "model": "gemini-decision-1", "input_tokens": 120, "cost_usd": 0.000012 } }
+ *
+ * The providers, in order (modular: each is one function below; a new one is one more entry):
+ * gemini Google's Decisions API, gemini-decision-1 (https://aistudio.google.com/docs/decisions-api): one forward pass,
+ * calibrated probabilities, up to 1,000 questions, pictures, audio, video and PDFs; $0.10 per 1M input
+ * tokens, nothing for output (Google's price, 2026-11-11). Used when the gemini provider's key resolves.
+ * Its free tier may train on what it reads: public or synthetic data only (the owner's note, 11-12).
+ * router decide.fallback on the router's cheap structured-output route (default.json): text only, up to 100
+ * questions, estimated and not calibrated (calibrated: false). Used without a Gemini key, when Google is
+ * away, or with prefer: "cheap" for text when the router's offer is free.
+ * Every call is metered like any request (provider_key, cost), so a credit plan pays for it and the owner sees it.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { AiError, sendError, providerSecret } = require('../util');
+const decide = require('../workflows/decide');
+
+const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/decisions';
+const GEMINI_MODEL = 'gemini-decision-1';
+const GEMINI_PER_MTOK = 0.10;
+const NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
+const MAX_GEMINI_QUESTIONS = 1000;
+const WAIT_MS = 30000;
+
+/** The request, checked: { parts, text, questions } or an AiError. */
+function checked(body) {
+ const b = body && typeof body === 'object' ? body : {};
+ let parts;
+ if (typeof b.input === 'string') parts = [{ type: 'text', text: b.input }];
+ else if (Array.isArray(b.input)) parts = b.input;
+ else throw new AiError(422, 'input.invalid', 'input is text, or a list of content parts');
+ if (!parts.length || parts.length > 50) throw new AiError(422, 'input.invalid', 'input has 1 to 50 parts');
+ for (const p of parts) {
+ if (!p || typeof p !== 'object') throw new AiError(422, 'input.invalid', 'every input part is an object');
+ if (p.type === 'text') { if (typeof p.text !== 'string' || !p.text.length || p.text.length > 200000) throw new AiError(422, 'input.invalid', 'a text part has 1 to 200,000 characters'); }
+ else if (['image', 'audio', 'video', 'document'].includes(p.type)) { if (!/^https:\/\/\S{1,2000}$/.test(String(p.uri || ''))) throw new AiError(422, 'input.invalid', `a ${p.type} part names an https uri`); }
+ else throw new AiError(422, 'input.invalid', 'a part is text, image, audio, video or document');
+ }
+ const qs = b.questions && typeof b.questions === 'object' && !Array.isArray(b.questions) ? b.questions : null;
+ const names = qs ? Object.keys(qs) : [];
+ if (!names.length || names.length > MAX_GEMINI_QUESTIONS) throw new AiError(422, 'input.invalid', `1 to ${MAX_GEMINI_QUESTIONS} questions, by name`);
+ const questions = {};
+ for (const name of names) {
+ const q = qs[name] || {};
+ if (!NAME_RE.test(name)) throw new AiError(422, 'input.invalid', `a question name is lowercase letters, digits and _ (${name.slice(0, 40)})`);
+ if (!['noul', 'choice', 'score'].includes(q.type)) throw new AiError(422, 'input.invalid', `${name}: type is noul, choice or score`);
+ if (typeof q.instructions !== 'string' || !q.instructions.trim() || q.instructions.length > 4000) throw new AiError(422, 'input.invalid', `${name}: instructions are 1 to 4,000 characters`);
+ if (q.type === 'choice') {
+ const keys = q.criteria && typeof q.criteria === 'object' && !Array.isArray(q.criteria) ? Object.keys(q.criteria) : [];
+ if (keys.length < 2 || keys.length > 255) throw new AiError(422, 'input.invalid', `${name}: a choice names 2 to 255 options in criteria`);
+ }
+ if (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2 || q.criteria.length > 20)) throw new AiError(422, 'input.invalid', `${name}: a score lists 2 to 20 rubric levels in criteria`);
+ if (q.type === 'noul' && q.criteria != null && (typeof q.criteria !== 'object' || Array.isArray(q.criteria))) throw new AiError(422, 'input.invalid', `${name}: a yes/no criteria is { true_description, false_description }`);
+ questions[name] = { type: q.type, instructions: q.instructions.trim(), ...(q.criteria != null ? { criteria: q.criteria } : {}) };
+ }
+ const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n\n');
+ const prefer = b.prefer === 'cheap' ? 'cheap' : 'calibrated';
+ return { parts, text, questions, prefer, onlyText: parts.every((p) => p.type === 'text') };
+}
+
+function decisionsRouter({ runs, auth, db, registry, env = process.env, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
+ const r = express.Router();
+ r.use(http.middleware());
+ const guard = auth.personOrCap('ai.run.create');
+
+ const meter = async (row) => {
+ try {
+ await db.prepare("INSERT INTO requests (operation, provider_key, model_key, status, cost_usd, created_at) VALUES ('classify', ?, ?, ?, ?, ?)")
+ .run(row.provider, row.model, row.status, row.cost_usd || 0, new Date(now()).toISOString());
+ } catch (err) { log.warn(`[ai] decisions meter: ${String(err && err.message || err).slice(0, 160)}`); }
+ };
+
+ /** Google's Decisions API with the gemini provider's own key, or null when there is no key. */
+ async function viaGemini(req) {
+ const row = registry && registry.getProvider ? await registry.getProvider('gemini').catch(() => null) : null;
+ const key = row ? providerSecret(row, env) : (env.GEMINI_API_KEY || null);
+ if (!key) return null;
+ const input = req.onlyText ? req.text : req.parts.map((p) => (p.type === 'text' ? { type: 'text', text: p.text } : { type: p.type, uri: p.uri, ...(p.mime_type ? { mime_type: p.mime_type } : {}) }));
+ const res = await fetchImpl(GEMINI_URL, {
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(20000),
+ headers: { 'x-goog-api-key': key, 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({ model: GEMINI_MODEL, input, questions: req.questions }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || !data || typeof data.answers !== 'object') {
+ await meter({ provider: 'gemini', model: GEMINI_MODEL, status: 'error' });
+ const err = new Error(`gemini decisions ${res.status}`); err.status = res.status; throw err;
+ }
+ const tokens = Number(data.usage && data.usage.total_input_tokens) || 0;
+ const cost = Math.round((tokens / 1e6) * GEMINI_PER_MTOK * 1e8) / 1e8;
+ await meter({ provider: 'gemini', model: GEMINI_MODEL, status: 'ok', cost_usd: cost });
+ return { answers: data.answers, calibrated: true, receipt: { provider: 'gemini', model: GEMINI_MODEL, input_tokens: tokens, cost_usd: cost } };
+ }
+
+ /** The router's cheap structured-output route (decide.fallback): text only, estimated probabilities. */
+ async function viaRouter(req, http) {
+ if (Object.keys(req.questions).length > decide.MAX_QUESTIONS) throw new AiError(422, 'input.invalid', `without the Decisions API, at most ${decide.MAX_QUESTIONS} questions`);
+ const created = await runs.create({ workflow: 'decide.fallback', task: 'decide.fallback', input: { input: req.text, questions: req.questions } }, http.principal, {
+ trace: http.ov && http.ov.traceId, requestId: http.ov && http.ov.requestId, callerWaitMs: WAIT_MS,
+ });
+ const run = await runs.explained(await runs.wait(created, WAIT_MS));
+ if (run.status !== 'succeeded' || !run.output) throw new AiError(503, 'decisions.unavailable', (run.error && run.error.detail) || 'no route answered the questions');
+ return { answers: run.output.answers, calibrated: false,
+ receipt: { provider: String((run.provenance && run.provenance.provider) || 'router').slice(0, 100), model: String((run.provenance && run.provenance.model) || 'unknown').slice(0, 200), run_id: String(run.id), cost_usd: Number(run.usage && run.usage.cost_usd) || 0 } };
+ }
+
+ r.post('/api/v1/decisions', guard, async (req, res) => {
+ try {
+ const q = checked(req.body);
+ let out = null;
+ // Calibrated first (Google's Decisions API) unless the caller asked for the cheapest text answer.
+ if (q.prefer === 'calibrated' || !q.onlyText) {
+ try { out = await viaGemini(q); } catch (err) { log.warn(`[ai] decisions via gemini failed: ${String(err && err.message || err).slice(0, 160)}`); out = null; }
+ }
+ if (!out) {
+ if (!q.onlyText) throw new AiError(503, 'decisions.unavailable', 'pictures, audio, video and documents need the Decisions API (a Gemini key on OpenVibe.AI)');
+ out = await viaRouter(q, req);
+ }
+ res.json(out);
+ } catch (err) { sendError(res, err, req); }
+ });
+ return r;
+}
+
+module.exports = { decisionsRouter, checked };
@@ -124,7 +124,10 @@ function scrub(text, secret) {
@@ -124,7 +124,10 @@ function scrub(text, secret) {
return s.length > 400 ? `${s.slice(0, 399)}…` : s;
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 }) {
+/** Google's Decisions API model: answered directly by POST /api/v1/decisions, never a route's chat model. */
+const isDecisionModel = (m) => String(m || '') === 'gemini-decision-1';
+
+function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth, settings = null, search = null, env = process.env, clock = { now: () => Date.now() }, log = console, fetchImpl = globalThis.fetch }) {
const r = express.Router();
const r = express.Router();
const read = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
const read = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
const write = auth.requireCap(CAPS.providerManage);
const write = auth.requireCap(CAPS.providerManage);
@@ -245,7 +248,9 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
@@ -245,7 +248,9 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
}
}
let state = 'not_set';
let state = 'not_set';
- if (step.id === 'search') state = searchState.ready ? 'ready' : failing ? 'failing' : 'not_set';
+ // Decisions are ready with a Gemini key (the Decisions API) or with a working fallback offer on decide.fallback.
+ if (step.id === 'decide' && configured(providers.get('gemini'))) state = 'ready';
+ else if (step.id === 'search') state = searchState.ready ? 'ready' : failing ? 'failing' : 'not_set';
else if (failing) state = 'failing';
else if (failing) state = 'failing';
else if (route && !route.disabled && readyOffer) state = 'ready';
else if (route && !route.disabled && readyOffer) state = 'ready';
else if (route && !route.disabled && routedOffers.length) state = 'not_set';
else if (route && !route.disabled && routedOffers.length) state = 'not_set';
@@ -338,6 +343,21 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
@@ -338,6 +343,21 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
};
};
}
}
+ // Gemini Decision 1 is Google's Decisions API, not a chat model: one tiny yes/no question to it directly.
+ if (step.id === 'decide' && providerKey === 'gemini') {
+ if (!secret) throw new AiError(409, 'setup.no_key', 'save the Gemini key first');
+ const started = clock.now();
+ const res = await fetchImpl('https://generativelanguage.googleapis.com/v1beta/decisions', {
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(20000),
+ headers: { 'x-goog-api-key': secret, 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({ model: 'gemini-decision-1', input: 'The OpenVibe AI setup sends this sentence to check that decisions work.', questions: { is_check: { type: 'noul', instructions: 'Is this a setup check?' } } }),
+ }).catch((err) => { throw new AiError(502, 'setup.test_failed', scrub(String(err && err.message || err), secret)); });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || !data.answers) throw new AiError(502, 'setup.test_failed', scrub(`Google answered ${res.status}: ${(data.error && data.error.message) || 'no answers'}`, secret));
+ const tokens = Number(data.usage && data.usage.total_input_tokens) || 0;
+ return { ok: true, step: step.id, route: null, provider: 'gemini', model: 'gemini-decision-1',
+ answer: { text: `P(yes) = ${data.answers.is_check && data.answers.is_check.noul}` }, cost_usd: money(tokens / 1e6 * 0.1), latency_ms: clock.now() - started };
+ }
const route = await routeFor(step);
const route = await routeFor(step);
if (!route) throw new AiError(503, 'route.unavailable', `there is no route for ${step.name} yet`);
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');
if (!pool) throw new AiError(503, 'route.unavailable', 'routing is not available on this service');
@@ -406,7 +426,8 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
@@ -406,7 +426,8 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
if (provider.auth_mode !== 'none' && !providerSecret(provider, env)) throw new AiError(409, 'setup.key_missing', `paste the ${provider.display_name || provider.key} key first`);
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);
const modelKey = step.id === 'search' ? null : String(b.model || step.recommended.model);
- if (step.id !== 'search') await ensureModel(step, providerKey, modelKey);
+ // The Decisions API is tested directly; it never becomes a chat model row the router could pick.
+ if (step.id !== 'search' && !isDecisionModel(modelKey)) await ensureModel(step, providerKey, modelKey);
return await runTest(step, { providerKey, modelKey, req });
return await runTest(step, { providerKey, modelKey, req });
}));
}));
@@ -427,6 +448,7 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
@@ -427,6 +448,7 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
const b = body(req);
const b = body(req);
const providerKey = b.provider == null ? null : String(b.provider);
const providerKey = b.provider == null ? null : String(b.provider);
const modelKey = b.model == null ? null : String(b.model);
const modelKey = b.model == null ? null : String(b.model);
+ if (isDecisionModel(modelKey)) throw new AiError(422, 'setup.not_a_route_model', 'Gemini Decision 1 is the Decisions API: it needs only the Gemini key, and the route keeps a chat model for text without it');
let route = await routeFor(step);
let route = await routeFor(step);
if (!route) throw new AiError(503, 'route.unavailable', `there is no route for ${step.name} yet`);
if (!route) throw new AiError(503, 'route.unavailable', `there is no route for ${step.name} yet`);
if (providerKey) {
if (providerKey) {
@@ -462,7 +484,7 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
@@ -462,7 +484,7 @@ function setupRouter({ config = {}, db, registry, pool = null, runs = null, auth
const applied = [];
const applied = [];
for (const step of STEPS) {
for (const step of STEPS) {
if (!step.route) continue;
if (!step.route) continue;
- const offer = step.recommended;
+ const offer = step.routeOffer || step.recommended;
try {
try {
await ensureModel(step, offer.provider, offer.model);
await ensureModel(step, offer.provider, offer.model);
const route = await routeFor(step);
const route = await routeFor(step);
@@ -268,6 +268,9 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -268,6 +268,9 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
app.use(actionsRouter({ runs, registry, auth, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
// People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.
// People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.
app.use(require('./api/ui-layout').uiLayoutRouter({ runs, auth, log }));
app.use(require('./api/ui-layout').uiLayoutRouter({ runs, auth, log }));
+ // Decisions (2026-11-12): closed-set questions about one input, Google's Decisions API first, the router's cheap
+ // structured route without it (server/api/decisions.js).
+ app.use(require('./api/decisions').decisionsRouter({ runs, auth, db, registry, env, fetchImpl, log }));
app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));
app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
@@ -286,7 +289,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -286,7 +289,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(setupRouter({ config, db, registry, pool, runs, auth, settings, search, env, clock, log, fetchImpl }));
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;
@@ -34,6 +34,7 @@ const SEEDS = [
@@ -34,6 +34,7 @@ const SEEDS = [
['search.images', 'search', 'Find licensed images.', 'research.gather'],
['search.images', 'search', 'Find licensed images.', 'research.gather'],
['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],
['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],
['ui.layout.suggest', 'text', 'Turn a person’s words into a layout for one surface.', 'ui.layout.suggest'],
['ui.layout.suggest', 'text', 'Turn a person’s words into a layout for one surface.', 'ui.layout.suggest'],
+ ['decide.fallback', 'text', 'Answer closed-set questions about one input when the Decisions API cannot.', 'decide.fallback'],
];
];
const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
@@ -1076,6 +1076,21 @@ route('post', '/ui/layout/suggest', {
@@ -1076,6 +1076,21 @@ route('post', '/ui/layout/suggest', {
responses: { 200: json('ui.layout-suggestion@1', uiLayoutSuggestion), ...errors([400, 401, 403, 422, 429, 503]) },
responses: { 200: json('ui.layout-suggestion@1', uiLayoutSuggestion), ...errors([400, 401, 403, 422, 429, 503]) },
});
});
+// ── Decisions (2026-11-12): closed-set questions about one input, Google's Decisions API first ─────────────────
+route('post', '/decisions', {
+ operationId: 'decide', summary: 'Answer yes/no, choice and rubric questions about one input in one call', tags: ['decisions'], security: SERVICE,
+ description: 'ai.run.create (or a person\'s own token). Up to 1,000 named questions (noul: the probability of yes; choice: one of 2-255 options; score: a rubric level from 0) about one input (text, or content parts: text, image, audio, video, document by https uri). Google\'s Decisions API (gemini-decision-1) answers when the gemini provider has a key: calibrated, $0.10 per 1M input tokens. Without it the router\'s cheap structured route answers text (up to 100 questions) with estimated probabilities (calibrated: false). prefer: "cheap" asks for the router first.',
+ requestBody: body({
+ type: 'object', required: ['input', 'questions'],
+ properties: {
+ input: { oneOf: [str({ minLength: 1, maxLength: 200000 }), { type: 'array', minItems: 1, maxItems: 50, items: { type: 'object' } }] },
+ questions: { type: 'object', minProperties: 1, maxProperties: 1000, additionalProperties: { type: 'object', required: ['type', 'instructions'], properties: { type: str({ enum: ['noul', 'choice', 'score'] }), instructions: str({ minLength: 1, maxLength: 4000 }), criteria: {} } } },
+ prefer: str({ enum: ['calibrated', 'cheap'] }),
+ },
+ }),
+ responses: { 200: json('The answers', { type: 'object', required: ['answers', 'calibrated', 'receipt'], properties: { answers: { type: 'object' }, calibrated: { type: 'boolean' }, receipt: { type: 'object' } } }), ...errors([400, 401, 403, 422, 503]) },
+});
+
// ── Routing presets and receipts (the Exchange, 2026-11-06) ───────────────────────────────────
// ── Routing presets and receipts (the Exchange, 2026-11-06) ───────────────────────────────────
route('get', '/presets', {
route('get', '/presets', {
operationId: 'listPresets', summary: 'The routing presets this caller may name', tags: ['presets'], security: SERVICE_OR_APP,
operationId: 'listPresets', summary: 'The routing presets this caller may name', tags: ['presets'], security: SERVICE_OR_APP,
@@ -26,6 +26,24 @@ function allocateDetailed(grants, spentUsd, now = Date.now()) {
@@ -26,6 +26,24 @@ function allocateDetailed(grants, spentUsd, now = Date.now()) {
}
}
return { ...out, grantBalances };
return { ...out, grantBalances };
}
}
+/**
+ * The billing cycle that holds `now` for a plan that renews on `day` of the month (UTC). A plan that renews on the 31st
+ * renews on the last day of a shorter month. → { start, end } in milliseconds.
+ */
+function cycleOf(day, now) {
+ const at = (y, m) => { const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate(); return Date.UTC(y, m, Math.min(day, last)); };
+ const d = new Date(now);
+ let y = d.getUTCFullYear(), m = d.getUTCMonth();
+ let start = at(y, m);
+ if (start > now) { m -= 1; if (m < 0) { m = 11; y -= 1; } start = at(y, m); }
+ let ny = y, nm = m + 1;
+ if (nm > 11) { nm = 0; ny += 1; }
+ return { start, end: at(ny, nm) };
+}
+const planGrantKey = (planId, cycleStart) => `plan:${planId}:${new Date(cycleStart).toISOString().slice(0, 10)}`;
+const isPlanGrant = (g) => /^plan:\d+:/.test(String(g && g.applies_to || ''));
+const round2 = (n) => Math.round(n * 100) / 100;
+
function allocate(grants, spentUsd, now = Date.now()) { const { promotional, purchased } = allocateDetailed(grants, spentUsd, now); return { promotional, purchased }; }
function allocate(grants, spentUsd, now = Date.now()) { const { promotional, purchased } = allocateDetailed(grants, spentUsd, now); return { promotional, purchased }; }
function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() } }) {
function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() } }) {
@@ -38,6 +56,69 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetc
@@ -38,6 +56,69 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetc
const result = await db.prepare('INSERT INTO provider_credit_grants (provider_key, kind, amount_usd, as_of, expires_at, applies_to, created_at, received_at, remaining_as_of_usd, remaining_as_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id').get(provider_key, kind, Number(amount_usd), as_of, expires_at, applies_to, new Date(clock.now()).toISOString(), received_at, Number(remaining_as_of_usd), remaining_as_of);
const result = await db.prepare('INSERT INTO provider_credit_grants (provider_key, kind, amount_usd, as_of, expires_at, applies_to, created_at, received_at, remaining_as_of_usd, remaining_as_of) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id').get(provider_key, kind, Number(amount_usd), as_of, expires_at, applies_to, new Date(clock.now()).toISOString(), received_at, Number(remaining_as_of_usd), remaining_as_of);
return result;
return result;
}
}
+ // ── Monthly credits from a plan (2026-11-12): one promotional grant per billing cycle, expiring when it ends ──
+ const listPlans = async (provider = null) => (provider
+ ? db.prepare('SELECT * FROM provider_credit_plans WHERE provider_key = ? ORDER BY id').all(provider)
+ : db.prepare('SELECT * FROM provider_credit_plans ORDER BY provider_key, id').all());
+ async function addPlan(plan) {
+ const p = plan || {};
+ const provider = String(p.provider_key || '').trim();
+ const monthly = Number(p.monthly_usd);
+ const day = Number(p.renews_day);
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(provider)) throw new TypeError('a plan names a provider key');
+ if (!String(p.label || '').trim()) throw new TypeError('a plan has a label (what grants it, e.g. "ChatGPT Pro API credits")');
+ if (!Number.isFinite(monthly) || monthly < 0 || monthly > 1000000) throw new TypeError('monthly_usd is a nonnegative amount');
+ if (!Number.isInteger(day) || day < 1 || day > 31) throw new TypeError('renews_day is a day of the month, 1-31');
+ if (!p.starts_on || Number.isNaN(Date.parse(p.starts_on))) throw new TypeError('starts_on is a date');
+ if (p.ends_on && Number.isNaN(Date.parse(p.ends_on))) throw new TypeError('ends_on is a date');
+ const at = new Date(clock.now()).toISOString();
+ const row = await db.prepare(`INSERT INTO provider_credit_plans (provider_key, label, monthly_usd, renews_day, starts_on, ends_on, prorate_first, active, notes, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) RETURNING id`).get(provider, String(p.label).trim().slice(0, 120), monthly, day,
+ new Date(p.starts_on).toISOString(), p.ends_on ? new Date(p.ends_on).toISOString() : null, p.prorate_first === false ? 0 : 1,
+ p.notes ? String(p.notes).slice(0, 500) : null, at, at);
+ await materialize();
+ return row;
+ }
+ async function setPlanActive(id, active) {
+ const n = (await db.prepare('UPDATE provider_credit_plans SET active = ?, updated_at = ? WHERE id = ?').run(active ? 1 : 0, new Date(clock.now()).toISOString(), Number(id))).changes;
+ if (!active) {
+ // A plan turned off stops granting from now: this cycle's unexpired grant ends today.
+ await db.prepare(`UPDATE provider_credit_grants SET expires_at = ? WHERE applies_to LIKE ? AND (expires_at IS NULL OR expires_at > ?)`)
+ .run(new Date(clock.now()).toISOString(), `plan:${Number(id)}:%`, new Date(clock.now()).toISOString());
+ } else await materialize();
+ return n;
+ }
+ /** Each active plan's grant for the cycle that holds now, once (the first cycle prorated when it starts mid-cycle). */
+ async function materialize(now = clock.now()) {
+ const made = [];
+ for (const p of await listPlans()) {
+ if (!Number(p.active)) continue;
+ const startsAt = Date.parse(p.starts_on);
+ if (!Number.isFinite(startsAt) || startsAt > now) continue;
+ if (p.ends_on && Date.parse(p.ends_on) <= now) continue;
+ const cycle = cycleOf(Number(p.renews_day), now);
+ const applies = planGrantKey(p.id, cycle.start);
+ const have = await db.prepare('SELECT id FROM provider_credit_grants WHERE provider_key = ? AND applies_to = ?').get(p.provider_key, applies);
+ if (have) continue;
+ const begin = Math.max(cycle.start, startsAt);
+ const end = p.ends_on ? Math.min(cycle.end, Date.parse(p.ends_on)) : cycle.end;
+ const full = Number(p.monthly_usd);
+ const amount = Number(p.prorate_first) && begin > cycle.start ? round2(full * (end - begin) / (cycle.end - cycle.start)) : full;
+ made.push(await add({ provider_key: p.provider_key, kind: 'promotional', amount_usd: amount, as_of: new Date(begin).toISOString(),
+ expires_at: new Date(end).toISOString(), applies_to: applies }));
+ }
+ return made;
+ }
+ /** The spend OpenVibe metered for one provider from `sinceMs` on (a plan grant's own cycle). */
+ async function meteredSince(provider, sinceMs) {
+ const r = await db.prepare("SELECT COALESCE(SUM(cost_usd), 0) AS spent FROM requests WHERE provider_key = ? AND status = 'ok' AND created_at >= ?").get(provider, new Date(sinceMs).toISOString());
+ return Number(r && r.spent) || 0;
+ }
+ async function providersWithCredit() {
+ const rows = await db.prepare('SELECT DISTINCT provider_key FROM provider_credit_grants UNION SELECT DISTINCT provider_key FROM provider_credit_plans').all();
+ return [...new Set(['anthropic', 'openai', ...rows.map((r) => r && r.provider_key).filter((k) => typeof k === 'string' && k)])];
+ }
+
async function preference(provider = 'anthropic') {
async function preference(provider = 'anthropic') {
const row = await db.prepare('SELECT use_promotional_first, promotional_floor_usd FROM provider_credit_preferences WHERE provider_key = ?').get(provider);
const row = await db.prepare('SELECT use_promotional_first, promotional_floor_usd FROM provider_credit_preferences WHERE provider_key = ?').get(provider);
return row ? { enabled: Boolean(row.use_promotional_first), floor_usd: Number(row.promotional_floor_usd) } : { enabled: true, floor_usd: 5 };
return row ? { enabled: Boolean(row.use_promotional_first), floor_usd: Number(row.promotional_floor_usd) } : { enabled: true, floor_usd: 5 };
@@ -103,12 +184,27 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetc
@@ -103,12 +184,27 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetc
async function poll() {
async function poll() {
if (running) return running;
if (running) return running;
running = (async () => {
running = (async () => {
- for (const provider of ['anthropic', 'openai']) {
+ await materialize().catch(() => null);
+ const now = clock.now();
+ for (const provider of await providersWithCredit()) {
const grants = await list(provider);
const grants = await list(provider);
if (!grants.length) continue;
if (!grants.length) continue;
- const spentResult = await spend(provider, grants);
- const balances = allocateDetailed(grants, spentResult.usd, clock.now());
- latest.set(provider, { ...balances, grants, preference: await preference(provider), spent_usd: spentResult.usd, source: spentResult.source, at: clock.now() });
+ // A plan's grant only ever pays for its own cycle: its spend is what was metered since it began, so
+ // last month's use never eats this month's credit (and an expired one is simply gone).
+ const planGrants = grants.filter((g) => isPlanGrant(g) && (!g.expires_at || Date.parse(g.expires_at) > now));
+ const plans = [];
+ let absorbed = 0;
+ for (const g of planGrants) {
+ const used = await meteredSince(provider, Date.parse(g.as_of));
+ const amount = Math.max(0, Number(g.amount_usd) || 0);
+ absorbed += Math.min(amount, used);
+ plans.push({ id: g.id, applies_to: g.applies_to, amount_usd: amount, used_usd: Math.min(amount, used), remaining_usd: Math.max(0, amount - used), expires_at: g.expires_at });
+ }
+ const others = grants.filter((g) => !isPlanGrant(g));
+ const spentResult = others.length ? await spend(provider, others) : { usd: 0, source: "OpenVibe's own metering" };
+ const balances = allocateDetailed(others, Math.max(0, spentResult.usd - absorbed), now);
+ const planLeft = plans.reduce((a, p) => a + p.remaining_usd, 0);
+ latest.set(provider, { ...balances, promotional: balances.promotional + planLeft, plans, grants, preference: await preference(provider), spent_usd: spentResult.usd + absorbed, source: spentResult.source, at: now });
}
}
return values();
return values();
})().finally(() => { running = null; });
})().finally(() => { running = null; });
@@ -116,7 +212,7 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetc
@@ -116,7 +212,7 @@ function createCreditGrants({ db, env = process.env, fetchImpl = globalThis.fetc
}
}
function values() { return [...latest.entries()].map(([provider, value]) => ({ provider, ...value })); }
function values() { return [...latest.entries()].map(([provider, value]) => ({ provider, ...value })); }
const lastAdminAtByProvider = new Map();
const lastAdminAtByProvider = new Map();
- return { add, list, poll, values, preference, setPreference, shouldPrefer };
+ return { add, list, poll, values, preference, setPreference, shouldPrefer, listPlans, addPlan, setPlanActive, materialize };
}
}
-module.exports = { createCreditGrants, centsToUsd, allocate, allocateDetailed };
+module.exports = { createCreditGrants, centsToUsd, allocate, allocateDetailed, cycleOf, planGrantKey };
@@ -233,6 +233,7 @@ function withoutPromptCache(req) {
@@ -233,6 +233,7 @@ function withoutPromptCache(req) {
}
}
function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null, creditGrants = null, ownerNotify = null, ownerConfig = null }) {
function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, community = null, bench = null, variantEvidence = null, adultStatus = null, offPeak = null, creditGrants = null, ownerNotify = null, ownerConfig = null }) {
+ const rateGate = require('./rate-gate').createRateGate({ now: () => clock.now() });
// A person's own machine as their model (credential provider node, server/machines.js).
// A person's own machine as their model (credential provider node, server/machines.js).
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
const cache = new Map(); // key -> { stamp, adapter }
@@ -1304,6 +1305,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1304,6 +1305,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (row && row.status !== 'active') return 'model_disabled';
if (row && row.status !== 'active') return 'model_disabled';
if (p.auth_mode !== 'none' && !credentialNotRequired(p) && !providerSecret(p, env)) return 'no_credentials';
if (p.auth_mode !== 'none' && !credentialNotRequired(p) && !providerSecret(p, env)) return 'no_credentials';
if (features.some(f => !a.supports(f))) return 'unsupported';
if (features.some(f => !a.supports(f))) return 'unsupported';
+ // Its published limit for this minute is used up (Altare's 60 a minute, 30 for each flagship): the next offer.
+ if (rateGate.full(p, model)) return 'rate_limited';
const h = await health(p.key, model, { stamp: stampFor(p) });
const h = await health(p.key, model, { stamp: stampFor(p) });
if (h.state === 'open') {
if (h.state === 'open') {
if (h.failure_class === 'balance' || h.exhausted_at) return 'exhausted';
if (h.failure_class === 'balance' || h.exhausted_at) return 'exhausted';
@@ -2022,6 +2025,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -2022,6 +2025,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// L36 Y7: the fallback chain stops after three providers.
// L36 Y7: the fallback chain stops after three providers.
if (attemptsMade >= maxAttempts) break;
if (attemptsMade >= maxAttempts) break;
attemptsMade += 1;
attemptsMade += 1;
+ rateGate.take(p, model);
// Claim the local slot and the provider's concurrency slot synchronously, before any await, so two
// Claim the local slot and the provider's concurrency slot synchronously, before any await, so two
// requests cannot both pass the check.
// requests cannot both pass the check.
if (local) localActive++;
if (local) localActive++;
@@ -971,6 +971,83 @@ const PROVIDERS = [
@@ -971,6 +971,83 @@ const PROVIDERS = [
data_policy: policy({ trains: null, retention: null, terms: 'https://docs.dos.ai', checked: '2026-11-10' }),
data_policy: policy({ trains: null, retention: null, terms: 'https://docs.dos.ai', checked: '2026-11-10' }),
models: [],
models: [],
},
},
+ // Altare Inference (2026-11-12, the owner: "a potentially excellent free API to test"): an OpenAI-compatible API for
+ // open models. Free until 2026-12-01 (usage does not count toward the included amount; rate limits still apply), then
+ // Always Free includes $100 of usage a month at list price, recorded in the console as a monthly credit plan
+ // (https://docs.altare.gg/inference/pricing). Keys start alt_inf_ (Team → Inference → API keys). Its published limits
+ // are kept by the router (rate_limits, server/providers/rate-gate.js). UNVERIFIED until the owner reads its terms,
+ // so it serves OpenVibe's own work and stays out of paid managed routes; privacy is unread, so it is anonymized.
+ {
+ key: 'altare',
+ docs_url: 'https://docs.altare.gg/inference',
+ keys_url: 'https://altare.gg/console',
+ display_name: 'Altare Inference',
+ base_url: 'https://inference.altare.global/v1',
+ default_model: 'deepseek-v4.1-flash',
+ timeout_ms: 90000,
+ capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
+ models_public: true,
+ provider_access: 'UNVERIFIED',
+ priority: 25,
+ rate_limits: {
+ rpm: 60, model_rpm: { 'glm-5.3': 30, 'deepseek-v4.1-flash': 30 }, plan: 'Always Free',
+ source: 'https://docs.altare.gg/inference/limits', checked: '2026-11-12',
+ note: 'per team, per minute; Plus 240 and 90 a model, Business 480 and 90',
+ },
+ free_until: '2026-12-01',
+ monthly_included_usd: { amount: 100, from: '2026-12-01', plan: 'Always Free', source: 'https://docs.altare.gg/inference/pricing' },
+ models: [
+ {
+ model_key: 'glm-5.3', display_name: 'GLM-5.3', family: 'glm', context_window: 1048576,
+ cost: { in_per_mtok: 1.4, cached_per_mtok: 0.26, out_per_mtok: 4.4 },
+ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null, note: 'a flagship model: 30 requests a minute of its own on the free plan (gated: true in /v1/models)',
+ },
+ {
+ model_key: 'deepseek-v4.1-flash', display_name: 'DeepSeek V4.1 Flash', family: 'deepseek', context_window: 1048576,
+ cost: { in_per_mtok: 0.3, cached_per_mtok: 0.006, out_per_mtok: 1.2 },
+ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null, note: 'a flagship model: 30 requests a minute of its own on the free plan (gated: true in /v1/models)',
+ },
+ {
+ model_key: 'qwen3.8-27b', display_name: 'Qwen 3.8 27B', family: 'qwen', context_window: 262144,
+ cost: { in_per_mtok: 0.45, cached_per_mtok: 0.05, out_per_mtok: 3.2 },
+ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null,
+ },
+ {
+ model_key: 'gemma-4-26b', display_name: 'Gemma 4 26B', family: 'gemma', context_window: 256000,
+ cost: { in_per_mtok: 0.1, cached_per_mtok: 0.05, out_per_mtok: 0.3 },
+ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null,
+ },
+ {
+ model_key: 'glm-4.7-flash', display_name: 'GLM-4.7 Flash', family: 'glm', context_window: 131072,
+ cost: { in_per_mtok: 0.0605, cached_per_mtok: null, out_per_mtok: 0.4 },
+ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null,
+ },
+ {
+ model_key: 'gpt-oss-120b', display_name: 'gpt-oss 120B', family: 'gpt-oss', context_window: 128000,
+ cost: { in_per_mtok: 0.35, cached_per_mtok: null, out_per_mtok: 0.75 },
+ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null,
+ },
+ {
+ model_key: 'gpt-oss-20b', display_name: 'gpt-oss 20B', family: 'gpt-oss', context_window: 128000,
+ cost: { in_per_mtok: 0.2, cached_per_mtok: null, out_per_mtok: 0.3 },
+ source: 'https://inference.altare.global/v1/models', checked: '2026-11-12',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null,
+ },
+ ],
+ },
];
];
@@ -35,6 +35,8 @@ function recordOf(p) {
@@ -35,6 +35,8 @@ function recordOf(p) {
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
search_adapters: p.search_adapters || [],
search_adapters: p.search_adapters || [],
data_policy: p.data_policy,
data_policy: p.data_policy,
+ // A provider's published request limits, kept by the router (server/providers/rate-gate.js).
+ rate_limits: p.rate_limits || null,
// The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on
// The privacy label of the provider: `private` only where its own terms say it keeps nothing and trains on
// nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.
// nothing, `anonymized` for everyone else. The wording and the page it was read from travel with it.
privacy: p.privacy,
privacy: p.privacy,
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+/**
+ * A provider's published request limits, kept by the router itself (2026-11-12: Altare Inference's free plan, 60
+ * requests a minute and 30 a minute for each flagship model, https://docs.altare.gg/inference/limits). A provider row
+ * carries them in metadata.rate_limits (the catalogue's `rate_limits`, seeded by pool-seed): { rpm, model_rpm: { model:
+ * n } }. Each request the router sends is counted in its UTC minute; a provider (or one of its models) whose minute is
+ * used up is skipped with `rate_limited`, so the router steps to the next offer instead of earning a 429. The provider's
+ * own 429 still opens nothing on its own (failure.js classes it rate_limit). The counts live in this process only: one
+ * AI process serves the router, and a restart starts the minute again, which can only under-count by one minute.
+ */
+const { byKey } = require('./pool-providers');
+
+function createRateGate({ now = () => Date.now(), margin = 1 } = {}) {
+ const counts = new Map(); // `${provider}` or `${provider}\u0000${model}` -> { minute, n }
+ const minuteOf = (t) => Math.floor(t / 60000);
+ const limitsOf = (p) => {
+ if (!p) return null;
+ const own = p.metadata && p.metadata.rate_limits;
+ if (own && typeof own === 'object') return own;
+ const cat = byKey && typeof byKey.get === 'function' ? byKey.get(p.key) : null;
+ return cat && cat.rate_limits ? cat.rate_limits : null;
+ };
+ const used = (key, m) => { const c = counts.get(key); return c && c.minute === m ? c.n : 0; };
+ const bump = (key, m) => {
+ const c = counts.get(key);
+ if (c && c.minute === m) c.n += 1; else counts.set(key, { minute: m, n: 1 });
+ if (counts.size > 5000) counts.delete(counts.keys().next().value);
+ };
+ const modelLimit = (l, model) => {
+ if (!model || !l.model_rpm) return null;
+ const leaf = String(model).split('/').pop().toLowerCase();
+ for (const [k, v] of Object.entries(l.model_rpm)) if (String(k).toLowerCase() === leaf && Number(v) > 0) return Number(v);
+ return null;
+ };
+ /** True when this provider (or this model of it) has used its minute. */
+ function full(p, model) {
+ const l = limitsOf(p);
+ if (!l) return false;
+ const m = minuteOf(now());
+ if (Number(l.rpm) > 0 && used(p.key, m) >= Math.floor(Number(l.rpm) * margin)) return true;
+ const ml = modelLimit(l, model);
+ return ml != null && used(`${p.key}\u0000${String(model).toLowerCase()}`, m) >= Math.floor(ml * margin);
+ }
+ /** One request sent to this provider and model. */
+ function take(p, model) {
+ const l = limitsOf(p);
+ if (!l) return;
+ const m = minuteOf(now());
+ bump(p.key, m);
+ if (modelLimit(l, model) != null) bump(`${p.key}\u0000${String(model).toLowerCase()}`, m);
+ }
+ /** What a provider has left this minute, for the console: { rpm, used, models: { model: { limit, used } } } or null. */
+ function view(p) {
+ const l = limitsOf(p);
+ if (!l) return null;
+ const m = minuteOf(now());
+ const models = {};
+ for (const [k, v] of Object.entries(l.model_rpm || {})) models[k] = { limit: Number(v), used: used(`${p.key}\u0000${String(k).toLowerCase()}`, m) };
+ return { rpm: Number(l.rpm) || null, used: used(p.key, m), models };
+ }
+ return { full, take, view };
+}
+
+module.exports = { createRateGate };
@@ -14,6 +14,9 @@
@@ -14,6 +14,9 @@
const { keyPage } = require('../owner-inputs');
const { keyPage } = require('../owner-inputs');
const CHECKED = '2026-11-12';
const CHECKED = '2026-11-12';
+// Altare Inference is free until 2026-12-01 (usage does not count toward the included amount; rate limits apply), then
+// Always Free includes $100 of usage a month (https://docs.altare.gg/inference/pricing): an offer worth trying first.
+const ALTARE_NOTE = 'Free until December 1, then $100 of usage a month on Altare\'s free plan (60 requests a minute; 30 for its flagship models). Record the $100 as a monthly credit on Exchange → Console → Providers.';
const SOURCES = Object.freeze({
const SOURCES = Object.freeze({
deepseek: 'https://api-docs.deepseek.com/quick_start/pricing',
deepseek: 'https://api-docs.deepseek.com/quick_start/pricing',
gemini: 'https://ai.google.dev/gemini-api/docs/pricing',
gemini: 'https://ai.google.dev/gemini-api/docs/pricing',
@@ -25,6 +28,8 @@ const SOURCES = Object.freeze({
@@ -25,6 +28,8 @@ const SOURCES = Object.freeze({
brave: 'https://brave.com/search/api/',
brave: 'https://brave.com/search/api/',
tavily: 'https://www.tavily.com/pricing',
tavily: 'https://www.tavily.com/pricing',
serper: 'https://serper.dev/pricing',
serper: 'https://serper.dev/pricing',
+ altare: 'https://inference.altare.global/v1/models',
+ geminiDecision: 'https://blog.google/innovation-and-ai/technology/developers-tools/gemini-decisions-api/',
});
});
/** A price in the page's plain words; `unit` is what the number is per. */
/** A price in the page's plain words; `unit` is what the number is per. */
@@ -56,6 +61,7 @@ const STEPS = Object.freeze([
@@ -56,6 +61,7 @@ const STEPS = Object.freeze([
recommended: offer('deepseek', 'deepseek-flash', 'DeepSeek Flash', tokenPrice(0.15, 0.6, 0.003), SOURCES.deepseek,
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.' }),
{ why: 'cheapest that looks good', note: 'The price is DeepSeek\'s off-peak list price; daytime is higher.' }),
alternatives: [
alternatives: [
+ offer('altare', 'deepseek-v4.1-flash', 'DeepSeek V4.1 Flash on Altare', tokenPrice(0.3, 1.2, 0.006), SOURCES.altare, { why: 'free until December 1', note: ALTARE_NOTE }),
offer('deepseek', 'deepseek-chat', 'DeepSeek Chat', tokenPrice(0.26, 0.38), SOURCES.deepseek),
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),
offer('openai', 'gpt-5-mini', 'GPT-5 mini', tokenPrice(0.25, 2), SOURCES.openaiImages),
],
],
@@ -135,10 +141,25 @@ const STEPS = Object.freeze([
@@ -135,10 +141,25 @@ const STEPS = Object.freeze([
recommended: offer('deepseek', 'deepseek-chat', 'DeepSeek Chat', tokenPrice(0.26, 0.38), SOURCES.deepseek,
recommended: offer('deepseek', 'deepseek-chat', 'DeepSeek Chat', tokenPrice(0.26, 0.38), SOURCES.deepseek,
{ why: 'cheapest that looks good' }),
{ why: 'cheapest that looks good' }),
alternatives: [
alternatives: [
+ offer('altare', 'glm-5.3', 'GLM-5.3 on Altare', tokenPrice(1.4, 4.4, 0.26), SOURCES.altare, { why: 'free until December 1', note: ALTARE_NOTE }),
offer('openai', 'gpt-5-mini', 'GPT-5 mini', tokenPrice(0.25, 2), SOURCES.openaiImages),
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'),
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: 'decide', name: 'Decisions (routing and checks)', short: 'Decisions', route: 'decide.fallback', 'capability': 'chat', operation: 'chat', modelType: 'chat',
+ what: 'Answers yes/no, one-of-a-list and rubric questions about a text or a picture, with how sure it is.',
+ uses: 'POST /api/v1/decisions: routing a request to the right model, moderation checks, grading and tool choice, without a full model writing an answer.',
+ recommended: offer('gemini', 'gemini-decision-1', 'Gemini Decision 1', { kind: 'tokens', in_per_mtok: 0.1, out_per_mtok: 0, cached_per_mtok: null, unit: '1M tokens', text: '$0.10 per 1M input tokens, nothing for output' }, SOURCES.geminiDecision,
+ { why: 'calibrated and fast', note: 'Google\'s Decisions API answers in one pass, with calibrated probabilities. It has a free tier, which may use what it reads to improve Google\'s products: test with public or synthetic data. Without a Gemini key, the cheap structured route below answers text instead, uncalibrated.' }),
+ // The fallback route's own model: Gemini Decision 1 is Google's Decisions API, not a chat model, so it never sits
+ // on decide.fallback; the route gets the cheap structured model and the Decisions API needs only the Gemini key.
+ routeOffer: offer('deepseek', 'deepseek-flash', 'DeepSeek Flash', tokenPrice(0.15, 0.6, 0.003), SOURCES.deepseek),
+ alternatives: [
+ offer('altare', 'gemma-4-26b', 'Gemma 4 26B on Altare', tokenPrice(0.1, 0.3, 0.05), SOURCES.altare, { why: 'free until December 1', note: ALTARE_NOTE }),
+ offer('deepseek', 'deepseek-flash', 'DeepSeek Flash', tokenPrice(0.15, 0.6, 0.003), SOURCES.deepseek),
+ ],
+ },
]);
]);
const BY_ID = new Map(STEPS.map((s) => [s.id, s]));
const BY_ID = new Map(STEPS.map((s) => [s.id, s]));
@@ -146,7 +167,7 @@ const BY_ID = new Map(STEPS.map((s) => [s.id, s]));
@@ -146,7 +167,7 @@ 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. */
/** What each kind is called in the defaults table, in the page's words. */
const KIND_LABELS = Object.freeze({
const KIND_LABELS = Object.freeze({
chat: 'Chat and writing', vision: 'Vision', image: 'Images', stt: 'Speech to text',
chat: 'Chat and writing', vision: 'Vision', image: 'Images', stt: 'Speech to text',
- speech: 'Voices', embedding: 'Embeddings', search: 'Web search', code: 'Code agents (plans)',
+ speech: 'Voices', embedding: 'Embeddings', search: 'Web search', code: 'Code agents (plans)', decide: 'Decisions',
});
});
function stepById(id) {
function stepById(id) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * decide.fallback (2026-11-12): the Decisions API's answer when Google's Decisions API (gemini-decision-1) cannot take a
+ * request (no Gemini key, or Google is away). The same closed-set questions about one text input, answered by the
+ * router's cheap structured-output route (default.json) as estimated probabilities. They are not calibrated, and the
+ * answer says so (calibrated: false); server/api/decisions.js chooses between the two.
+ *
+ * Question types are Google's: `noul` (yes or no: the probability of yes), `choice` (one of 2-255 named options: the
+ * winner and a probability for each) and `score` (an ordered rubric from level 0: the weighted level and a probability
+ * for each level). Every answer carries a confidence from 0 to 1.
+ */
+const MAX_QUESTIONS = 100;
+
+const clamp = (n, lo = 0.01, hi = 0.99) => Math.min(hi, Math.max(lo, Number.isFinite(Number(n)) ? Number(n) : 0.5));
+
+/** The model's probabilities -> a normalized distribution over the expected keys (missing ones share what is left). */
+function normalized(raw, keys) {
+ const vals = keys.map((k) => Math.max(0, Number(raw && raw[k]) || 0));
+ const sum = vals.reduce((a, b) => a + b, 0);
+ if (!(sum > 0)) return Object.fromEntries(keys.map((k) => [k, 1 / keys.length]));
+ return Object.fromEntries(keys.map((k, i) => [k, Math.round((vals[i] / sum) * 1e4) / 1e4]));
+}
+
+/** One question's answer from the model's JSON, in the Decisions API's shape. */
+function answerOf(q, raw) {
+ const a = raw && typeof raw === 'object' ? raw : {};
+ if (q.type === 'noul') {
+ const p = clamp(a.noul != null ? a.noul : a.probability);
+ return { type: 'noul', noul: p, confidence: Math.round(Math.max(p, 1 - p) * 100) / 100 };
+ }
+ if (q.type === 'choice') {
+ const keys = Object.keys(q.criteria || {});
+ const probabilities = normalized(a.probabilities, keys);
+ const choice = keys.includes(a.choice) ? a.choice : keys.reduce((best, k) => (probabilities[k] > probabilities[best] ? k : best), keys[0]);
+ return { type: 'choice', choice, probabilities, confidence: Math.round(Math.max(...Object.values(probabilities)) * 100) / 100 };
+ }
+ const levels = Array.isArray(q.criteria) ? q.criteria.map((_, i) => String(i)) : [];
+ const probabilities = normalized(a.probabilities, levels);
+ const score = levels.reduce((s, k) => s + Number(k) * probabilities[k], 0);
+ return { type: 'score', score: Math.round(score * 1000) / 1000, probabilities, legend: Object.fromEntries((q.criteria || []).map((c, i) => [String(i), String(c)])),
+ confidence: Math.round(Math.max(...Object.values(probabilities), 0) * 100) / 100 };
+}
+
+const templates = [{
+ key: 'decide.fallback', name: 'Decisions without the Decisions API',
+ description: 'Closed-set questions about one input (yes/no, one of a list, a rubric level), each answered with probabilities.',
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ system_prompt: 'You judge one input against independent closed-set questions. Answer every question by its name, and only from the input. '
+ + 'For "noul" give {"noul": P} where P is the probability (0.01 to 0.99) that the answer is yes. '
+ + 'For "choice" give {"choice": "<one option name>", "probabilities": {"<option>": p, …}} over exactly the listed options, summing to 1. '
+ + 'For "score" give {"probabilities": {"0": p, "1": p, …}} over the rubric levels, summing to 1. '
+ + 'Probabilities say how sure you are: use the middle when the input does not say. Questions cannot see each other. '
+ + 'Output only JSON: {"answers": {"<question name>": {…}, …}}.',
+ user_prompt: '{{json payload}}',
+ default_route: 'decide.fallback', owner: 'ai', metadata: { json_name: 'decisions' },
+}];
+
+const workflows = [{
+ key: 'decide.fallback', name: 'Decisions (fallback)', namespace: 'ai',
+ description: 'The Decisions API\'s fallback: the cheap structured-output route answers closed-set questions about a text input with estimated, uncalibrated probabilities.',
+ input_schema: {
+ type: 'object', additionalProperties: false, required: ['input', 'questions'],
+ properties: {
+ input: { type: 'string', minLength: 1, maxLength: 200000 },
+ questions: { type: 'object', minProperties: 1, maxProperties: MAX_QUESTIONS },
+ },
+ },
+ output_schema: { type: 'object' },
+ steps: [{ kind: 'llm', template: 'decide.fallback', operation: 'classify', output: 'json', prepare: 'decide.fallback', postprocess: 'decide.fallback', params: { max_tokens: 4000, temperature: 0, timeout_ms: 60000 } }],
+ cache_mode: 'none',
+}];
+
+function prepare(input) {
+ const questions = Object.fromEntries(Object.entries(input.questions || {}).map(([name, q]) => [name, {
+ type: q.type, instructions: q.instructions, ...(q.criteria ? { criteria: q.criteria } : {}),
+ }]));
+ return { vars: { payload: { input: input.input, questions } } };
+}
+
+function postprocess(r, input) {
+ const raw = r && r.json && typeof r.json === 'object' ? (r.json.answers || r.json) : {};
+ const answers = {};
+ for (const [name, q] of Object.entries(input.questions || {})) answers[name] = answerOf(q, raw[name]);
+ return { answers, calibrated: false };
+}
+
+module.exports = { templates, workflows, prepare, postprocess, answerOf, MAX_QUESTIONS };
@@ -646,6 +646,7 @@ const POSTPROCESS = {
@@ -646,6 +646,7 @@ const POSTPROCESS = {
{ const space = require('./space'); Object.assign(PREPARE, space.PREPARE); Object.assign(POSTPROCESS, space.POSTPROCESS); }
{ const space = require('./space'); Object.assign(PREPARE, space.PREPARE); Object.assign(POSTPROCESS, space.POSTPROCESS); }
// People's own controls (2026-11-11): the layout suggestion's prepare and postprocess live with its template.
// People's own controls (2026-11-11): the layout suggestion's prepare and postprocess live with its template.
{ const ui = require('./ui-layout'); PREPARE['ui.layout.suggest'] = ui.prepare; POSTPROCESS['ui.layout.suggest'] = ui.postprocess; }
{ const ui = require('./ui-layout'); PREPARE['ui.layout.suggest'] = ui.prepare; POSTPROCESS['ui.layout.suggest'] = ui.postprocess; }
+{ const d = require('./decide'); PREPARE['decide.fallback'] = d.prepare; POSTPROCESS['decide.fallback'] = d.postprocess; }
/**
/**
* What the run cache keys on, for a workflow whose input carries parts that do not change the answer (item 151, C12:
* What the run cache keys on, for a workflow whose input carries parts that do not change the answer (item 151, C12:
@@ -27,6 +27,7 @@ const meme = require('./meme');
@@ -27,6 +27,7 @@ const meme = require('./meme');
const changelog = require('./changelog');
const changelog = require('./changelog');
const pictures = require('./pictures');
const pictures = require('./pictures');
const uiLayout = require('./ui-layout');
const uiLayout = require('./ui-layout');
+const decide = require('./decide');
const poolSeed = require('../providers/pool-seed');
const poolSeed = require('../providers/pool-seed');
const { resolveSecret } = require('../util');
const { resolveSecret } = require('../util');
@@ -260,6 +261,8 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -260,6 +261,8 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
// A layout suggestion is a small JSON arrangement, not a conversation: the cheapest structured-output
// A layout suggestion is a small JSON arrangement, not a conversation: the cheapest structured-output
// offer that is healthy, with room for every part of a surface (make your own controls, 2026-11-11).
// offer that is healthy, with room for every part of a surface (make your own controls, 2026-11-11).
await registry.seedVersioned('route', 'ui.layout.suggest', pool(60000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.2 }, max_output_tokens: 4000, response_format: 'json' }));
await registry.seedVersioned('route', 'ui.layout.suggest', pool(60000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.2 }, max_output_tokens: 4000, response_format: 'json' }));
+ // Decisions without the Decisions API (2026-11-12): small JSON probabilities, the cheapest healthy structured offer.
+ await registry.seedVersioned('route', 'decide.fallback', pool(60000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0 }, max_output_tokens: 4000, response_format: 'json' }));
await registry.seedVersioned('route', 'default.embedding', pool(30000, { 'capability': 'embed', constraints: { objective: 'balanced' } }));
await registry.seedVersioned('route', 'default.embedding', pool(30000, { 'capability': 'embed', constraints: { objective: 'balanced' } }));
// Speech (the Exchange speech route): the voice pins the exact offer, so the pool is only the fallback
// Speech (the Exchange speech route): the voice pins the exact offer, so the pool is only the fallback
// order when no voice is pinned; every provider that declares `speak` may answer it.
// order when no voice is pinned; every provider that declares `speak` may answer it.
@@ -318,11 +321,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -318,11 +321,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
// Templates, then workflows (workflows reference templates)
// Templates, then workflows (workflows reference templates)
- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates, ...uiLayout.templates]) {
+ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates, ...uiLayout.templates, ...decide.templates]) {
const { key, ...def } = t;
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
}
}
- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...deepDive.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {
+ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...deepDive.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows, ...decide.workflows]) {
const { key, ...def } = w;
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
}
@@ -36,7 +36,7 @@ const DOMAIN = {
@@ -36,7 +36,7 @@ const DOMAIN = {
baseten: 'baseten.co', hyperbolic: 'hyperbolic.xyz', parasail: 'parasail.io', 'particle-ai': 'particle.ai',
baseten: 'baseten.co', hyperbolic: 'hyperbolic.xyz', parasail: 'parasail.io', 'particle-ai': 'particle.ai',
'inference-net': 'inference.net', 'atlas-cloud': 'atlascloud.ai', avian: 'avian.io',
'inference-net': 'inference.net', 'atlas-cloud': 'atlascloud.ai', avian: 'avian.io',
nanogpt: 'nano-gpt.com', infermatic: 'totalgpt.ai', arliai: 'arliai.com', friendli: 'friendli.ai', scaleway: 'scaleway.com', anthropic: 'claude.com',
nanogpt: 'nano-gpt.com', infermatic: 'totalgpt.ai', arliai: 'arliai.com', friendli: 'friendli.ai', scaleway: 'scaleway.com', anthropic: 'claude.com',
- iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai', 'runpod-vllm': 'runpod.io', meta: 'meta.ai',
+ iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai', 'runpod-vllm': 'runpod.io', meta: 'meta.ai', altare: 'altare.global',
};
};
const onItsOwnSite = (url, key) => {
const onItsOwnSite = (url, key) => {
const host = new URL(url).hostname;
const host = new URL(url).hostname;
@@ -50,7 +50,7 @@ t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an a
@@ -50,7 +50,7 @@ t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an a
assert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),
assert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),
['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);
['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);
assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
- assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 55);
+ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 56);
assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');
assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');
assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');
assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');
assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');
assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * Monthly credits from a plan (2026-11-12, the owner: "make it aware of granted API credits for both anthropic and
+ * openai"): a plan is one promotional grant per billing cycle that expires when the cycle ends, prorated when it starts
+ * mid-cycle; it only pays for its own cycle's spend; the router prefers its provider while it lasts; a plan turned off
+ * ends this cycle's grant today. Any provider key works (Altare's $100 a month from December 1).
+ */
+const assert = require('assert');
+const { createCreditGrants, cycleOf } = require('../server/providers/credit-grants');
+const { testDb } = require('./db');
+const { suite } = require('./helpers');
+
+const t = suite('credit-plans');
+const DAY = 86400000;
+
+t.test('a cycle renews on its day, and the 31st on a short month\'s last day', async () => {
+ const c = cycleOf(5, Date.parse('2026-11-12T10:00:00Z'));
+ assert.deepStrictEqual([new Date(c.start).toISOString(), new Date(c.end).toISOString()], ['2026-11-05T00:00:00.000Z', '2026-12-05T00:00:00.000Z']);
+ const early = cycleOf(20, Date.parse('2026-11-12T10:00:00Z'));
+ assert.deepStrictEqual([new Date(early.start).toISOString(), new Date(early.end).toISOString()], ['2026-10-20T00:00:00.000Z', '2026-11-20T00:00:00.000Z']);
+ const short = cycleOf(31, Date.parse('2026-02-15T00:00:00Z'));
+ assert.deepStrictEqual([new Date(short.start).toISOString(), new Date(short.end).toISOString()], ['2026-01-31T00:00:00.000Z', '2026-02-28T00:00:00.000Z']);
+});
+
+t.test('a plan grants once per cycle, prorated at first, spends only its own cycle, and is preferred while it lasts', async () => {
+ const tdb = await testDb();
+ try {
+ const db = tdb.db;
+ let now = Date.parse('2026-11-20T00:00:00Z');
+ const credits = createCreditGrants({ db, env: {}, clock: { now: () => now } });
+ // ChatGPT Pro's $200 a month, renewing on the 5th, started on the 20th: the first grant is the 15 days left of 30.
+ await credits.addPlan({ provider_key: 'openai', label: 'ChatGPT Pro API credits', monthly_usd: 200, renews_day: 5, starts_on: '2026-11-20' });
+ let grants = await credits.list('openai');
+ assert.strictEqual(grants.length, 1);
+ assert.strictEqual(Number(grants[0].amount_usd), 100, 'prorated: 15 of 30 days');
+ assert.strictEqual(grants[0].expires_at, '2026-12-05T00:00:00.000Z', 'unused credit expires when the cycle ends');
+ await credits.materialize();
+ assert.strictEqual((await credits.list('openai')).length, 1, 'once per cycle');
+
+ // Spend before the plan began is not its business; spend inside its cycle is.
+ const spend = (at, usd) => db.prepare("INSERT INTO requests (operation, provider_key, status, cost_usd, created_at) VALUES ('chat', 'openai', 'ok', ?, ?)").run(usd, new Date(at).toISOString());
+ await spend(Date.parse('2026-11-10T00:00:00Z'), 40);
+ await spend(Date.parse('2026-11-21T00:00:00Z'), 30);
+ now = Date.parse('2026-11-22T00:00:00Z');
+ let out = (await credits.poll()).find((x) => x.provider === 'openai');
+ assert.strictEqual(out.plans.length, 1);
+ assert.strictEqual(out.plans[0].remaining_usd, 70, 'its own cycle\'s 30 only, not the 40 from before');
+ assert.strictEqual(out.promotional, 70);
+ assert.strictEqual(credits.shouldPrefer('openai'), true, 'the router spends granted credit first');
+
+ // The next cycle: a full $200, the old grant expired and gone from the balance.
+ now = Date.parse('2026-12-06T00:00:00Z');
+ out = (await credits.poll()).find((x) => x.provider === 'openai');
+ grants = await credits.list('openai');
+ assert.strictEqual(grants.length, 2);
+ assert.strictEqual(out.plans.length, 1);
+ assert.strictEqual(out.plans[0].amount_usd, 200);
+ assert.strictEqual(out.promotional, 200, 'last cycle\'s leftover does not carry over');
+
+ // Off: this cycle's grant ends today and no new one comes.
+ const plan = (await credits.listPlans('openai'))[0];
+ await credits.setPlanActive(plan.id, false);
+ now += DAY;
+ out = (await credits.poll()).find((x) => x.provider === 'openai');
+ assert.strictEqual(out.promotional, 0);
+ assert.strictEqual(credits.shouldPrefer('openai'), false);
+
+ // Any provider: Altare's $100 a month from December 1, on the 1st.
+ await credits.addPlan({ provider_key: 'altare', label: 'Altare Always Free', monthly_usd: 100, renews_day: 1, starts_on: '2026-12-01' });
+ out = (await credits.poll()).find((x) => x.provider === 'altare');
+ assert.ok(out && out.plans[0].amount_usd === 100, JSON.stringify(out && out.plans));
+ await assert.rejects(credits.addPlan({ provider_key: 'openai', label: '', monthly_usd: 10, renews_day: 1, starts_on: '2026-12-01' }), /label/);
+ await assert.rejects(credits.addPlan({ provider_key: 'openai', label: 'x', monthly_usd: 10, renews_day: 40, starts_on: '2026-12-01' }), /renews_day/);
+ } finally { await tdb.close(); }
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+// Decisions (2026-11-12): POST /api/v1/decisions answers closed-set questions about one input. With a Gemini key the
+// call goes to Google's Decisions API (gemini-decision-1) and comes back calibrated, metered at $0.10 per 1M input
+// tokens; without one (or when Google fails) the router's cheap structured route answers text through decide.fallback,
+// normalized to the same shape and marked calibrated: false; pictures without the Decisions API are refused honestly.
+const assert = require('assert');
+const { boot, request, seamServer, suite, token } = require('./helpers');
+const { checked } = require('../server/api/decisions');
+const { answerOf } = require('../server/workflows/decide');
+
+const t = suite('decisions');
+const CAP = token('network', ['ai.run.create'], { sub: 'svc:network' });
+const NOCAP = token('network', ['ai.layout.suggest'], { sub: 'svc:network' });
+const QUESTIONS = {
+ is_spam: { type: 'noul', instructions: 'Is this message spam?' },
+ kind: { type: 'choice', instructions: 'What is it?', criteria: { bug: 'Something is broken.', idea: 'A wish for something new.' } },
+ severity: { type: 'score', instructions: 'How bad is it?', criteria: ['fine', 'minor', 'major'] },
+};
+
+t.test('the request is checked: names, types, choices, rubric levels', async () => {
+ assert.throws(() => checked({ input: 'x', questions: {} }), /1 to 1000 questions/);
+ assert.throws(() => checked({ input: 'x', questions: { 'Bad Name': { type: 'noul', instructions: 'x' } } }), /question name/);
+ assert.throws(() => checked({ input: 'x', questions: { a: { type: 'maybe', instructions: 'x' } } }), /noul, choice or score/);
+ assert.throws(() => checked({ input: 'x', questions: { a: { type: 'choice', instructions: 'x', criteria: { only: 'one' } } } }), /2 to 255 options/);
+ assert.throws(() => checked({ input: 'x', questions: { a: { type: 'score', instructions: 'x', criteria: ['one'] } } }), /2 to 20 rubric levels/);
+ assert.throws(() => checked({ input: [{ type: 'image', uri: 'http://insecure' }], questions: { a: { type: 'noul', instructions: 'x' } } }), /https uri/);
+ const ok = checked({ input: 'hello', questions: QUESTIONS });
+ assert.strictEqual(ok.onlyText, true);
+ assert.strictEqual(ok.prefer, 'calibrated');
+});
+
+t.test('the fallback\'s answers are normalized to the Decisions API\'s shape', async () => {
+ assert.deepStrictEqual(answerOf(QUESTIONS.is_spam, { noul: 1.4 }), { type: 'noul', noul: 0.99, confidence: 0.99 });
+ const c = answerOf(QUESTIONS.kind, { choice: 'nonsense', probabilities: { bug: 3, idea: 1 } });
+ assert.deepStrictEqual([c.choice, c.probabilities], ['bug', { bug: 0.75, idea: 0.25 }], 'probabilities sum to 1 and the winner is a listed option');
+ const s = answerOf(QUESTIONS.severity, { probabilities: { 0: 0, 1: 1, 2: 1 } });
+ assert.strictEqual(s.score, 1.5);
+ assert.deepStrictEqual(s.legend, { 0: 'fine', 1: 'minor', 2: 'major' });
+});
+
+t.test('with a Gemini key: Google\'s Decisions API, calibrated and metered; without one: the router, not calibrated', async () => {
+ const google = [];
+ let googleDown = false;
+ const fetchImpl = async (url, opts) => {
+ if (String(url) === 'https://generativelanguage.googleapis.com/v1beta/decisions') {
+ google.push({ key: opts.headers['x-goog-api-key'], body: JSON.parse(opts.body) });
+ if (googleDown) return { ok: false, status: 503, json: async () => ({ error: { message: 'down' } }) };
+ return { ok: true, status: 200, json: async () => ({ answers: { is_spam: { type: 'noul', noul: 0.04, confidence: 0.96 } }, usage: { total_input_tokens: 2000000, total_output_tokens: 0 } }) };
+ }
+ return globalThis.fetch(url, opts);
+ };
+ const seam = await seamServer(async () => ({ json: { answers: { is_spam: { noul: 0.2 }, kind: { choice: 'idea', probabilities: { bug: 0.1, idea: 0.9 } }, severity: { probabilities: { 0: 1 } } } } }));
+ const h = await boot({ fetchImpl, env: { AI_HTTP_SEAM_URL: seam.url, AI_USER_MODULES: 'off', AI_FREE_PROVIDERS: 'http-seam', AI_FREE_DAILY_TOKENS: '100000', GEMINI_DECIDE_TEST_KEY: 'g-key' } });
+ try {
+ await h.registry.upsertProvider({ key: 'http-seam', capabilities: ['chat', 'generate', 'json', 'classify'], default_model: 'decide-fake', billing_profile: 'free', provider_access: 'OWN_DEPLOYMENT' }, { actor: 'test' });
+ await h.registry.upsertModel({ provider_key: 'http-seam', model_key: 'decide-fake', type: 'chat', status: 'active', supports: { json: true }, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 } }, { actor: 'test' });
+ await h.registry.upsertProvider({ key: 'stub', status: 'disabled' }, { actor: 'test' });
+
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/decisions', { tok: NOCAP, body: { input: 'buy cheap pills', questions: QUESTIONS } })).status, 403, 'needs ai.run.create');
+
+ // No Gemini key yet: the router answers, estimated.
+ let r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: 'Could the menu remember my last tab?', questions: QUESTIONS } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.calibrated, false);
+ assert.strictEqual(r.body.answers.kind.choice, 'idea');
+ assert.strictEqual(r.body.answers.is_spam.noul, 0.2);
+ assert.strictEqual(r.body.receipt.model, 'decide-fake');
+ assert.strictEqual(google.length, 0);
+
+ // Pictures need the Decisions API.
+ r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: [{ type: 'image', uri: 'https://example.com/a.png' }], questions: { is_cat: { type: 'noul', instructions: 'Is it a cat?' } } } });
+ assert.strictEqual(r.status, 503, r.text);
+
+ // With the gemini provider's key: Google answers, calibrated, and the call is metered.
+ await h.registry.upsertProvider({ key: 'gemini', secret_ref: 'env:GEMINI_DECIDE_TEST_KEY' }, { actor: 'test' });
+ r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: 'buy cheap pills', questions: { is_spam: QUESTIONS.is_spam } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.calibrated, true);
+ assert.deepStrictEqual(r.body.answers.is_spam, { type: 'noul', noul: 0.04, confidence: 0.96 });
+ assert.deepStrictEqual([google[0].key, google[0].body.model, google[0].body.input], ['g-key', 'gemini-decision-1', 'buy cheap pills']);
+ assert.strictEqual(r.body.receipt.cost_usd, 0.2, '2M input tokens at $0.10 per 1M, nothing for output');
+ const row = await h.db.prepare("SELECT provider_key, model_key, cost_usd FROM requests WHERE provider_key = 'gemini' ORDER BY id DESC LIMIT 1").get();
+ assert.deepStrictEqual([row.model_key, Number(row.cost_usd)], ['gemini-decision-1', 0.2], 'metered like any request');
+
+ // Google away: the router answers the text instead.
+ googleDown = true;
+ r = await request(h.base, 'POST', '/api/v1/decisions', { tok: CAP, body: { input: 'buy cheap pills', questions: QUESTIONS } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.calibrated, false);
+ } finally { await h.stop(); await seam.close(); }
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+// A provider's published request limits (2026-11-12, Altare Inference's free plan: 60 a minute, 30 a minute for each
+// flagship model): the router counts its own requests per minute and skips the provider, or the one model, whose
+// minute is used up; the next minute opens again; a provider without limits is never held.
+const assert = require('assert');
+const { createRateGate } = require('../server/providers/rate-gate');
+const { PROVIDERS } = require('../server/providers/pool-providers');
+const { suite } = require('./helpers');
+
+const t = suite('rate-gate');
+
+t.test('Altare is catalogued with its published limits and prices', async () => {
+ const a = PROVIDERS.find((p) => p.key === 'altare');
+ assert.ok(a, 'Altare Inference is a catalogued provider');
+ assert.strictEqual(a.base_url, 'https://inference.altare.global/v1');
+ assert.deepStrictEqual([a.rate_limits.rpm, a.rate_limits.model_rpm['glm-5.3'], a.rate_limits.model_rpm['deepseek-v4.1-flash']], [60, 30, 30]);
+ assert.strictEqual(a.provider_access, 'UNVERIFIED', 'out of paid managed routes until the owner reads its terms');
+ const flash = a.models.find((m) => m.model_key === 'deepseek-v4.1-flash');
+ assert.deepStrictEqual(flash.cost, { in_per_mtok: 0.3, cached_per_mtok: 0.006, out_per_mtok: 1.2 });
+});
+
+t.test('a minute fills, per provider and per flagship model, and the next minute opens', async () => {
+ let now = Date.parse('2026-11-12T20:00:05Z');
+ const gate = createRateGate({ now: () => now });
+ const p = { key: 'altare', metadata: { rate_limits: { rpm: 60, model_rpm: { 'deepseek-v4.1-flash': 30 } } } };
+ for (let i = 0; i < 30; i++) { assert.strictEqual(gate.full(p, 'deepseek-v4.1-flash'), false); gate.take(p, 'deepseek-v4.1-flash'); }
+ assert.strictEqual(gate.full(p, 'deepseek-v4.1-flash'), true, 'the flagship model\'s 30 are used');
+ assert.strictEqual(gate.full(p, 'gemma-4-26b'), false, 'another model still has the provider\'s minute');
+ for (let i = 0; i < 30; i++) gate.take(p, 'gemma-4-26b');
+ assert.strictEqual(gate.full(p, 'gemma-4-26b'), true, 'the provider\'s 60 are used');
+ assert.deepStrictEqual(gate.view(p), { rpm: 60, used: 60, models: { 'deepseek-v4.1-flash': { limit: 30, used: 30 } } });
+ now += 60000;
+ assert.strictEqual(gate.full(p, 'deepseek-v4.1-flash'), false, 'a new minute');
+ assert.strictEqual(gate.full({ key: 'deepseek', metadata: {} }, 'deepseek-chat'), false, 'no limits, never held');
+ assert.strictEqual(gate.full({ key: 'altare', metadata: {} }, 'glm-5.3'), false, 'the catalogue\'s limits apply when the row has none');
+});
+
+t.run();
@@ -25,13 +25,13 @@ t.test('the page state: eight steps, owner only, key pages and spend', async ()
@@ -25,13 +25,13 @@ t.test('the page state: eight steps, owner only, key pages and spend', async ()
h = await boot({ env: keys });
h = await boot({ env: keys });
let r = await request(h.base, 'GET', '/api/v1/setup', { tok: owner });
let r = await request(h.base, 'GET', '/api/v1/setup', { tok: owner });
assert.strictEqual(r.status, 200, r.text);
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.length, 9, 'one step per job (Decisions since 11-12)');
+ assert.strictEqual(r.body.summary.total, 9);
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 === '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.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.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.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');
+ assert.strictEqual(r.body.defaults.length, 9, 'one defaults row per kind');
r = await request(h.base, 'GET', '/api/v1/setup', { tok: reader });
r = await request(h.base, 'GET', '/api/v1/setup', { tok: reader });
assert.strictEqual(r.status, 200, 'a usage reader may read the page');
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 } });
const denied = await request(h.base, 'PUT', '/api/v1/setup/caps/chat', { tok: reader, body: { cap_usd: 1 } });
@@ -106,7 +106,9 @@ t.test('a cap refuses the next run of that kind before it spends', async () => {
@@ -106,7 +106,9 @@ t.test('a cap refuses the next run of that kind before it spends', async () => {
t.test('the recommended set fills every route, including the image route it creates', async () => {
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 });
const r = await request(h.base, 'POST', '/api/v1/setup/recommended', { tok: owner });
assert.strictEqual(r.status, 200, r.text);
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));
+ assert.strictEqual(r.body.applied.filter((x) => x.ok).length, 8, 'eight routed steps: '+JSON.stringify(r.body.applied));
+ const decideRoute = await h.registry.getRoute('decide.fallback');
+ assert.strictEqual(decideRoute.constraints.model, 'deepseek-flash', 'the Decisions fallback route gets a chat model, never gemini-decision-1');
const image = await h.registry.getRoute('default.image');
const image = await h.registry.getRoute('default.image');
assert.ok(image, 'the image route exists after the set');
assert.ok(image, 'the image route exists after the set');
assert.strictEqual(image.constraints.model, 'gpt-image-1');
assert.strictEqual(image.constraints.model, 'gpt-image-1');
@@ -103,6 +103,7 @@ EXAMPLES['space.app.ask'] = { space: 'Pirate helper', instructions: 'Talk like a
@@ -103,6 +103,7 @@ EXAMPLES['space.app.ask'] = { space: 'Pirate helper', instructions: 'Talk like a
EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };
EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };
// People's own controls (2026-11-11): the fake surface is deliberately small; the stub's answer is
// People's own controls (2026-11-11): the fake surface is deliberately small; the stub's answer is
// refused by the layout validator, which is still a schema-valid suggestion and exercises that path.
// refused by the layout validator, which is still a schema-valid suggestion and exercises that path.
+EXAMPLES['decide.fallback'] = { input: 'Could the menu remember my last tab?', questions: { is_question: { type: 'noul', instructions: 'Is it a question?' }, kind: { type: 'choice', instructions: 'What is it?', criteria: { bug: 'Something is broken.', idea: 'A wish.' } } } };
EXAMPLES['ui.layout.suggest'] = {
EXAMPLES['ui.layout.suggest'] = {
catalog: {
catalog: {
id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone'],
id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone'],