openvibers/openvibe.ai Commits

Granted monthly credits, Altare Inference with its rate limits, and Decisions (routing and checks) (#512)

OP OpenVibers committed 12 h ago · fbc55ce8a7 · 22 files, +825 −23

The owner, 11-12: "make it aware of granted API credits for both anthropic and openai", "a potentially excellent free API to test" (Altare), and "add decisions using automatic scalable cost effective modular multi provider intelligent routing".

Monthly credits from a plan (migration 0083, expand): provider_credit_plans records a plan's monthly amount, renewal day, start and optional end. ChatGPT Pro's $100/$200/$500 a month of OpenAI API credit, an Anthropic allowance, and Altare's $100 a month from December 1 all fit. Each active plan becomes one promotional grant per billing cycle (applies_to plan:<id>:<cycle start>), prorated when it starts mid-cycle and expiring when the cycle ends. A plan's grant only pays for its own cycle's spend (OpenVibe's metering since it began), so last month's use never eats this month's credit. The router's existing credit-first preference spends it first. Any provider key can carry credit now, not only anthropic and openai. Console API: POST /api/v1/admin/console/provider-credit-plans, POST …/:id { active }, with plans in the providers section's estimates.

Altare Inference: a catalogued OpenAI-compatible provider (https://inference.altare.global/v1, keys alt_inf_). It carries seven chat models at the list prices from its own /v1/models (read 2026-11-12), UNVERIFIED access and unread privacy (anonymized). Its published limits are 60 requests a minute and 30 a minute for each flagship model (glm-5.3, deepseek-v4.1-flash; docs.altare.gg/inference/limits). New: a provider's rate_limits are kept by the router (server/providers/rate-gate.js): it counts its own requests per minute and skips the provider or model whose minute is used up (skip reason rate_limited) instead of earning a 429.

Decisions: POST /api/v1/decisions (ai.run.create, or a person's own token) answers named closed-set questions about one input in one call: noul (yes/no probability), choice (one of 2-255 options) and score (a rubric level), each with a confidence. Google's Decisions API (gemini-decision-1: calibrated, $0.10 per 1M input tokens, nothing for output) answers when the gemini provider has a key. Without it, or when Google fails, the router's cheapest structured route answers text through the decide.fallback workflow (task, route and template seeded), normalized to the same shape with calibrated: false. Pictures without the Decisions API are refused honestly. Every call is metered like any request, so a credit plan pays for it.

The guided setup: a ninth step, Decisions (routing and checks). It recommends Gemini Decision 1 and gives the fallback route its own chat model (routeOffer), so "use the recommended set" never pins Google's decision model on a chat route; it is tested by one yes/no question to Google. Altare is a free choice (until December 1) for chat, code and decisions.

Tests: credit-plans (cycles, proration, own-cycle spend, expiry, off, any provider), decisions (checks, normalization, Google and the router, metering, Google down), rate-gate (Altare's catalogue entry, minute per provider and per model), and the setup, catalogue and workflow suites updated.

Changes

ddiff --git a/migrations/0083_provider_credit_plans.sql b/migrations/0083_provider_credit_plans.sql
nnew file mode 100644
iindex 0000000..0998929
---- /dev/null
++++ b/migrations/0083_provider_credit_plans.sql
@@@ -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);
ddiff --git a/server/api/console-data.js b/server/api/console-data.js
iindex c052274..09cfa73 100644
---- a/server/api/console-data.js
++++ b/server/api/console-data.js
@@@ -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) => {
ddiff --git a/server/api/decisions.js b/server/api/decisions.js
nnew file mode 100644
iindex 0000000..a075139
---- /dev/null
++++ b/server/api/decisions.js
@@@ -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 };
ddiff --git a/server/api/setup.js b/server/api/setup.js
iindex 018c263..4cf6b50 100644
---- a/server/api/setup.js
++++ b/server/api/setup.js
@@@ -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, 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`);
}
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, auth
const 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, auth
const 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);
ddiff --git a/server/app.js b/server/app.js
iindex 6ddb28b..45980f8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -268,6 +268,9 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
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.
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;
ddiff --git a/server/choices/index.js b/server/choices/index.js
iindex 695b93a..b3ce7bb 100644
---- a/server/choices/index.js
++++ b/server/choices/index.js
@@@ -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}$/;
ddiff --git a/server/openapi.js b/server/openapi.js
iindex d9bc95f..233d304 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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,
ddiff --git a/server/providers/credit-grants.js b/server/providers/credit-grants.js
iindex 08b1b58..c1ab586 100644
---- a/server/providers/credit-grants.js
++++ b/server/providers/credit-grants.js
@@@ -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.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);
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.fetc
async 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 };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 042508f..9aac6fa 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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.no
if (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++;
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex 4236f7d..e3cd1e6 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -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,
++ },
++ ],
++ },
];
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex 4c0d89c..ca25e66 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -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,
ddiff --git a/server/providers/rate-gate.js b/server/providers/rate-gate.js
nnew file mode 100644
iindex 0000000..a71f8ba
---- /dev/null
++++ b/server/providers/rate-gate.js
@@@ -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 };
ddiff --git a/server/setup/catalog.js b/server/setup/catalog.js
iindex 6a7eb8c..2a167eb 100644
---- a/server/setup/catalog.js
++++ b/server/setup/catalog.js
@@@ -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) {
ddiff --git a/server/workflows/decide.js b/server/workflows/decide.js
nnew file mode 100644
iindex 0000000..72d4fef
---- /dev/null
++++ b/server/workflows/decide.js
@@@ -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 };
ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex 200a1df..7024c0c 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -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:
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 805f05a..dc870f7 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -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 });
}
ddiff --git a/test/ai-provider-adapters.test.js b/test/ai-provider-adapters.test.js
iindex 73703f4..057af2c 100644
---- a/test/ai-provider-adapters.test.js
++++ b/test/ai-provider-adapters.test.js
@@@ -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 a
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']);
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');
ddiff --git a/test/credit-plans.test.js b/test/credit-plans.test.js
nnew file mode 100644
iindex 0000000..db85d12
---- /dev/null
++++ b/test/credit-plans.test.js
@@@ -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();
ddiff --git a/test/decisions.test.js b/test/decisions.test.js
nnew file mode 100644
iindex 0000000..116c3cf
---- /dev/null
++++ b/test/decisions.test.js
@@@ -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();
ddiff --git a/test/rate-gate.test.js b/test/rate-gate.test.js
nnew file mode 100644
iindex 0000000..341d6d4
---- /dev/null
++++ b/test/rate-gate.test.js
@@@ -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();
ddiff --git a/test/setup-api.test.js b/test/setup-api.test.js
iindex 754ca13..ec0e36d 100644
---- a/test/setup-api.test.js
++++ b/test/setup-api.test.js
@@@ -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');
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex 181e486..9265b30 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -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 } }] };
// 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'],