openvibers/openvibe.ai Commits
Add portable agent effort and receipt cost estimates (#419)
Changes
nnew file mode 100644@@@ -0,0 +1,78 @@++# Agent effort, defaults and accounting++++S1 is [Contracts PR 396](https://github.com/OpenVibers/OpenVibe.Contracts/pull/396),++version 0.381.0. This PR pins its green commit SHA because neither PR is to be merged++or tagged by this job. Reconcile the pin to the eventual release when merging.++++POST `/v1/chat/completions`, `/v1/messages`, `/api/v1/chat`, `/api/v1/generate` and++`/api/v1/runs` accept `effort: auto|low|medium|high|max`. Other direct text operations++also accept it. Omission means auto. Auto uses the selected `agent_role` default,++or the owner's task-default `effort`, then the workflow's `metadata.effort`, then medium. Receipts record the request and the++portable level actually applied; unsupported controls record null. Cache and++idempotency identities include the resolved policy, so changing effort cannot reuse++an answer computed with another policy. Ordinary provider fallback keeps that policy.++++Mappings were checked on provider documentation on 2026-11-09. Budget numbers are++OpenVibe choices rather than provider-defined effort presets:++++| Provider | Native request | Source |++| --- | --- | --- |++| OpenAI reasoning models | `reasoning_effort` (or Responses `reasoning.effort`): low/medium/high; max becomes max on GPT-6, xhigh on GPT-5.2+, high on older reasoning models | [Reasoning](https://developers.openai.com/api/docs/guides/reasoning?api-mode=chat) |++| Current Claude | `thinking: {type: adaptive}`, `output_config.effort`; Opus/Sonnet 5.5 refuse `budget_tokens` | [Thinking](https://platform.claude.com/docs/en/build-with-claude/thinking), [effort](https://platform.claude.com/docs/en/build-with-claude/effort) |++| Gemini 3/4 | `generationConfig.thinkingConfig.thinkingLevel`; max clamps to HIGH; Gemini 3 Pro medium clamps to HIGH | [Thinking](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking) |++| Gemini 2.5 | `thinkingBudget`: 1024 / 4096 / 16384 / 24576 tokens (Pro max 32768) | [Thinking](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking) |++| Qwen 3 hybrid on DashScope | low disables `enable_thinking`; others enable with `thinking_budget` 4096 / 16384 / 32768; thinking-only models omit the toggle and use 1024 for low | [Deep thinking](https://www.alibabacloud.com/help/en/model-studio/deep-thinking) |++| DeepSeek | low disables thinking; other efforts enable it (applied medium: the portable binary-on level) | [Thinking mode](https://api-docs.deepseek.com/guides/thinking_mode/) |++| Others | ignored, applied null | No native support asserted |++++Streaming uses the same request builders. Automatic OpenAI-compatible provider batches++apply the same mappings. Role-specific work stays on the ordinary background path++rather than entering a batch route that cannot preserve the role's model choice.++++## Owner defaults++++GET `/api/v1/agent-role-defaults` and PUT `/api/v1/agent-role-defaults/:role` require++`ai.config.manage`. Roles are router, gate, assessor, worker, subagent, reviewer.++PUT takes `{model: "provider/model", effort: "high"}` or++`{choice_order: ["provider/model", "provider/other"], effort: "medium"}`.++Defaults must specify a concrete effort. Choice order selects the first eligible model++from that order; existing route admission, provider access, cost caps and billing still++apply. Explicit model pins take precedence. Unavailable role models fail explicitly.++++Router/gate and subagent start low, assessor/worker medium, reviewer high. The worker++starts on the seeded DeepSeek Flash route (`deepseek/deepseek-flash`) and the reviewer on Claude Sonnet 5.5. Setting a Claude++worker without a saved reviewer switches the default reviewer to OpenAI. A saved++reviewer may not share a provider family with any worker choice. Updates and before/after++audit records share one transaction, serialized to prevent conflicting role updates.++No default provisions a key or enables a provider.++++## Estimates and ladders++++GET `/api/v1/exchange/estimate?model=&effort=&kind=&input_tokens=` is public and cached++for 30 seconds with bounded cache entries. Effort must be concrete. The result is++`{low_usd, high_usd, n, basis}`. Fewer than 20 eligible samples returns both prices null++and `basis.status: "no_data"`. Eligible samples are successful, non-synthetic run++receipts from the last 30 days with current offer prices. Kind is an exact optional++receipt label. Effort cohorts use requested effort, or applied effort for auto/legacy++receipts. No account or receipt identities are returned.++++The range is the 25th–75th percentiles of observed input/output/reasoning/cache token counts++priced at each answering route's current prices, excluding historical charges/fees.++When input_tokens is supplied, each sample scales by its output/input and cache/input++ratios. This is empirical usage, not a guaranteed quote or a price cap.++++A caller may pass `ladder_id` plus zero-based `rung`; both are required together.++Receipts retain them as ladder_id/effort_rung, with model, effort, cost and outcome.++GET `/api/v1/me/ladders/:id` returns `{ladder_id, cost_usd, rungs: [{rung,n,cost_usd}]}`++only for the caller's receipts (or its token's on-behalf-of user). Reusing another++caller's id reveals nothing. Failed attempts also get receipts; cost totals include++recorded successful provider usage before the run failed. No automatic escalation is++introduced, and a ladder never grants permission to spend.++++Catalog offers expose `cache_hit_share_7d`: cached input tokens / total input tokens++from successful, non-synthetic provider attempts in seven days, clamped to [0,1]. No++samples yields null. This is provider token-cache usage, distinct from whole-run reuse.++++Migration 0059 is expand-only, above main's 0057 and the 0058 reserved by Exchange routing PR #403.++It adds only role policy storage and a receipt time index. Tests use fixture responses++and temporary databases; no paid provider calls, production writes or deployment.nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- Optional role policy. Existing deployments and runs retain their routes.++CREATE TABLE IF NOT EXISTS agent_role_defaults (++ role TEXT PRIMARY KEY,++ body TEXT NOT NULL,++ updated_at TEXT NOT NULL++);++CREATE INDEX IF NOT EXISTS ai_receipts_effort_created ON ai_receipts (created_at);@@@ -45,6 +45,10 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, autconst view = async (p) => { const v = await ops.providerView(p); return v && { ...v, billing_profile: p.billing_profile, pool_key: p.pool_key }; };const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});++ const agentDefaults = require('../effort/defaults').createDefaults({ db, registry });++ r.get('/api/v1/agent-role-defaults', auth.requireCap(CAPS.configManage), wrap(async () => ({ roles: await agentDefaults.get() })));++ r.put('/api/v1/agent-role-defaults/:role', auth.requireCap(CAPS.configManage), wrap(async (req) => ({ role: req.params.role, default: await agentDefaults.put(req.params.role, body(req), actor(req)) })));++r.get('/api/v1/status', usageRead, wrap(async () => await ops.status()));// Providers@@@ -68,6 +68,9 @@ function exchangeRouter({ db, registry, pool, quotas = null, env = process.env,const usageRead = auth.requireCap(CAPS.usageRead);const wrap = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});++ const effortAnalytics = require('../effort/analytics').createAnalytics({ db, catalog, registry, pool, clock });++ r.get('/api/v1/exchange/estimate', publicCache(30), wrap(req => effortAnalytics.estimate(req.query)));++ r.get('/api/v1/me/ladders/:id', read, wrap(req => effortAnalytics.ladder(req.params.id, req.principal)));/** The owner a preset belongs to: the app's project, else the person the token acts for, else the service. */const ownerOf = (req) => {@@@ -28,6 +28,7 @@* with the HTTP status the run's problem had.*/const express = require('express');++const { controls } = require('../effort/defaults');const { CAPS } = require('../auth');const { appAttribution } = require('../apps');const { credentialFor } = require('./credential');@@@ -178,6 +179,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts}if (!Array.isArray(b.messages) || !b.messages.length) return anthropicError(res, 400, 'messages: must be a non-empty array');if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');++ if (/^claude-(opus|sonnet)-5-5/.test(String(b.model || '').split('/').pop()) && b.thinking && Object.hasOwn(b.thinking, 'budget_tokens')) return anthropicError(res, 400, 'Opus/Sonnet 5.5 require adaptive thinking; budget_tokens is refused');const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension).@@@ -224,7 +226,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receiptsdelete input.model;}} catch (err) { return anthropicError(res, err.status || 400, err.message, null, err.code); }-- let body = { workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };++ let body = { ...controls(b), workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };// The preset's rules ride the run into the router.if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };@@@ -42,6 +42,7 @@* shape ({ error: { message, type, code } }) with the HTTP status the run's problem had.*/const express = require('express');++const { controls } = require('../effort/defaults');const { CAPS } = require('../auth');const { appAttribution } = require('../apps');const { credentialFor } = require('./credential');@@@ -210,7 +211,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;-- let body = { workflow: 'ai.complete', input, idempotency_key: req.get('idempotency-key') || undefined };++ let body = { ...controls(b), workflow: 'ai.complete', input, idempotency_key: req.get('idempotency-key') || undefined };// The preset's rules ride the run into the router.if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };@@@ -184,9 +184,9 @@ function runsRouter({ choices = null, runs, registry, auth, config, log = consolr.post(`/api/v1/${op}`, DIRECT_OPS.includes(`ai.${op}`) ? createApp : create, async (req, res) => {try {const body = req.body || {};-- const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route, ...input } = body;++ const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route, effort, agent_role, ladder_id, rung, kind, ...input } = body;const wait = req.query.wait != null ? Math.max(0, Math.min(Number(req.query.wait) || 0, config.runs.maxWaitMs)) : config.runs.maxWaitMs;-- await createAndRespond(req, res, { workflow: `ai.${op}`, input, idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route }, wait);++ await createAndRespond(req, res, { workflow: `ai.${op}`, input, idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route, effort, agent_role, ladder_id, rung, kind }, wait);} catch (err) { sendError(res, err, req.ov, log); }});}@@@ -232,7 +232,10 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clockconst charge = capacity.chargeOf(cards.row);if (!charge || !capacity.within(cards.batch.nano_usd_per_m, charge)) continue;let body;-- try { body = prepared.bodyFor(offer.model); } catch { continue; }++ try {++ body = prepared.bodyFor(offer.model);++ if (prepared.effort) require('../effort/providers').apply(body, { model: offer.model, effort: prepared.effort }, provider);++ } catch { continue; }if (!body || !Array.isArray(body.messages) || !body.messages.length) continue;body.model = offer.model;// BA2's probe: a real hold, released at once. A payer who cannot cover the run is refused now,@@@ -64,11 +64,13 @@ function choice(value) {function defaultValue(value) {const keys = value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];if (!value || typeof value !== 'object' || Array.isArray(value)-- || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd'].includes(k))++ || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd', 'effort'].includes(k))|| (value.fallbacks != null && !Array.isArray(value.fallbacks))) {throw new AiError(422, 'defaults.invalid', 'invalid task defaults');}++ if (value.effort !== undefined && !['low', 'medium', 'high', 'max'].includes(value.effort)) throw new AiError(422, 'defaults.invalid', 'task default effort must be low|medium|high|max');const out = {++ ...(value.effort !== undefined ? { effort: value.effort } : {}),signed_out: choice(value.signed_out),signed_in: choice(value.signed_in),fallbacks: (value.fallbacks || []).map(choice),nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++const { AiError } = require('../util');++const { checkEffort } = require('./providers');++const round = v => v == null ? null : Math.round(Number(v) * 1e8) / 1e8;++function createAnalytics({ db, catalog, registry = null, pool = null, clock = { now: () => Date.now() } }) {++ const cache = new Map();++ async function estimate(query) {++ const model = query.model;++ if (typeof model !== 'string' || !model || model.length > 256) throw new AiError(422, 'input.invalid', 'model is required');++ const effort = checkEffort(query.effort || 'medium');++ if (effort === 'auto') throw new AiError(422, 'input.invalid', 'estimate requires a resolved effort');++ const kind = query.kind === undefined ? null : query.kind;++ if (kind !== null && (typeof kind !== 'string' || !/^[a-z][a-z0-9_.-]{0,99}$/.test(kind))) throw new AiError(422, 'input.invalid', 'invalid kind');++ const input = query.input_tokens === undefined ? null : Number(query.input_tokens);++ if (input !== null && (!Number.isSafeInteger(input) || input < 1 || input > 10000000)) throw new AiError(422, 'input.invalid', 'input_tokens must be an integer from 1 to 10000000');++ const key = JSON.stringify([model, effort, kind, input]);++ const hit = cache.get(key);++ if (hit && hit.until > clock.now()) return hit.value;++ const data = await catalog.models({ days: 7 });++ const prices = [];++ for (const m of data.models || []) for (const o of m.offers || []) {++ if (![m.slug, m.canonical, o.model, `${o.provider}/${o.model}`].includes(model) || !o.price) continue;++ prices.push({ provider: o.provider, model: o.model, pin: o.price.in, pout: o.price.out, pcached: o.price.cached == null ? o.price.in : o.price.cached });++ }++ // Catalog provider names are public upstream names (the internal shared route++ // may display as openai). Receipts keep route keys; price those exact routes.++ if (registry && pool && prices.length) {++ const publicPrices = prices.splice(0);++ const providers = new Map((await registry.listProviders()).map(p => [p.key, p]));++ for (const row of await registry.listModels()) {++ const provider = providers.get(row.provider_key);++ if (!provider) continue;++ const shown = require('../providers/upstream').upstreamOf(provider).key;++ if (!publicPrices.some(p => p.provider === shown && p.model === row.model_key)) continue;++ const cards = await pool.rateCardsFor(row.provider_key, row.model_key);++ if (!cards) continue;++ const p = require('../providers/rate-cards').pricesOf(cards);++ prices.push({ provider: row.provider_key, model: row.model_key, pin: p.in, pout: p.out, pcached: p.cached == null ? p.in : p.cached });++ }++ }++ const basis = { window_days: 30, percentile: [25, 75], model, effort, kind, input_tokens: input, pricing: 'current offer prices; output/input and cache/input ratios when input_tokens supplied' };++ const row = prices.length ? await db.prepare(`WITH samples AS (++ SELECT a.body::jsonb AS b FROM ai_receipts a JOIN runs r ON r.id = a.run_id++ WHERE a.created_at >= ? AND r.status = 'succeeded' AND r.synthetic = 0++ AND COALESCE(NULLIF(a.body::jsonb->>'effort_requested', 'auto'), a.body::jsonb->>'effort_applied') = ?++ AND (?::text IS NULL OR a.body::jsonb->>'kind' = ?)++ ), tokens AS (++ SELECT b, (b->'usage'->>'tokens_in')::numeric ti,++ ((b->'usage'->>'tokens_out')::numeric + COALESCE((b->'usage'->>'tokens_reasoning')::numeric, 0)) tout,++ COALESCE((b->'usage'->>'tokens_cached')::numeric, 0) tc++ FROM samples++ ), costs AS (++ SELECT ((GREATEST(ti - LEAST(tc, ti), 0) * p.pin + LEAST(tc, ti) * p.pcached + tout * p.pout)++ * CASE WHEN ?::numeric IS NULL THEN 1 ELSE ?::numeric / NULLIF(ti, 0) END) / 1000000 AS cost++ FROM tokens JOIN jsonb_to_recordset(?::jsonb) AS p(provider text, model text, pin numeric, pout numeric, pcached numeric)++ ON b->'offer'->>'provider' = p.provider AND b->'offer'->>'model' = p.model++ WHERE ti > 0 AND tout >= 0++ ) SELECT COUNT(cost)::int n, percentile_cont(0.25) WITHIN GROUP (ORDER BY cost) low,++ percentile_cont(0.75) WITHIN GROUP (ORDER BY cost) high FROM costs`).get(new Date(clock.now() - 30 * 86400000).toISOString(), effort, kind, kind, input, input, JSON.stringify(prices)) : { n: 0 };++ const n = Number(row.n);++ const value = { low_usd: n < 20 ? null : round(row.low), high_usd: n < 20 ? null : round(row.high), n, basis: { ...basis, status: n < 20 ? 'no_data' : 'receipts' } };++ if (cache.size >= 128) cache.delete(cache.keys().next().value);++ cache.set(key, { until: clock.now() + 30000, value });++ return value;++ }++ async function ladder(id, principal) {++ if (!/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new AiError(422, 'input.invalid', 'invalid ladder id');++ const subject = principal.subject || {};++ const ob = principal.onBehalfOf || principal.claims && principal.claims.on_behalf_of;++ const owner = typeof ob === 'object' ? ob && ob.id : ob;++ const rows = await db.prepare(`SELECT (body::jsonb->>'effort_rung')::int rung,++ COUNT(*)::int n, SUM(COALESCE((body::jsonb->>'cost_usd')::numeric, 0)) cost_usd++ FROM ai_receipts WHERE body::jsonb->>'ladder_id' = ?++ AND ((requester_type = ? AND requester_id = ?) OR (owner_type = 'user' AND owner_id = ?))++ GROUP BY (body::jsonb->>'effort_rung')::int ORDER BY rung`).all(id, subject.type || '', subject.id || '', owner || (subject.type === 'user' ? subject.id : ''));++ if (!rows.length) throw new AiError(404, 'ladder.not_found', 'no such ladder');++ const rungs = rows.map(r => ({ rung: Number(r.rung), n: Number(r.n), cost_usd: round(r.cost_usd) }));++ return { ladder_id: id, cost_usd: round(rungs.reduce((n, r) => n + r.cost_usd, 0)), rungs };++ }++ return { estimate, ladder };++}++async function cacheShares(db, clock) {++ const rows = await db.prepare(`SELECT q.provider_key, q.model_key, SUM(q.tokens_in)::numeric input,++ SUM(q.tokens_cached)::numeric cached FROM requests q JOIN runs r ON r.id = q.run_id++ WHERE q.created_at >= ? AND q.status = 'ok' AND r.synthetic = 0++ GROUP BY q.provider_key, q.model_key`).all(new Date(clock.now() - 7 * 86400000).toISOString());++ return new Map(rows.map(r => [`${r.provider_key}/${r.model_key}`, Number(r.input) > 0 ? Math.min(1, Math.max(0, Number(r.cached) / Number(r.input))) : null]));++}++module.exports = { createAnalytics, cacheShares };nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++const { AiError, parseJson } = require('../util');++const { checkEffort } = require('./providers');++const ROLES = ['router', 'gate', 'assessor', 'worker', 'subagent', 'reviewer'];++const BASE = {++ router: { model: 'deepseek/deepseek-flash', effort: 'low' },++ gate: { model: 'deepseek/deepseek-flash', effort: 'low' },++ assessor: { model: 'deepseek/deepseek-flash', effort: 'medium' },++ worker: { model: 'deepseek/deepseek-flash', effort: 'medium' },++ subagent: { model: 'deepseek/deepseek-flash', effort: 'low' },++ reviewer: { model: 'anthropic/claude-sonnet-5-5', effort: 'high' },++};++const modelsOf = (v) => v.model ? [v.model] : v.choice_order;++function family(model) {++ const m = model.toLowerCase();++ for (const [pattern, name] of [[/claude/, 'anthropic'], [/deepseek/, 'deepseek'], [/gpt-|\/o[134]/, 'openai'], [/gemini/, 'google'], [/qwen/, 'qwen']]) if (pattern.test(m)) return name;++ return m.split('/')[0];++}++function validate(value) {++ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !['model', 'choice_order', 'effort'].includes(k))) throw new AiError(422, 'input.invalid', 'role default needs model or choice_order, and effort');++ checkEffort(value.effort);++ if (value.effort === 'auto') throw new AiError(422, 'input.invalid', 'a role default must resolve auto to a concrete effort');++ if (Boolean(value.model) === Boolean(value.choice_order)) throw new AiError(422, 'input.invalid', 'set exactly one of model or choice_order');++ const models = modelsOf(value);++ if (!Array.isArray(models) || !models.length || models.length > 8 || new Set(models).size !== models.length || models.some(m => typeof m !== 'string' || !/^[\w.-]+\/[\w./:-]{1,200}$/.test(m))) throw new AiError(422, 'input.invalid', 'models must be distinct provider/model ids (one to eight)');++ return JSON.parse(JSON.stringify(value));++}++function createDefaults({ db, registry, clock = { now: () => Date.now() } }) {++ async function get() {++ const result = JSON.parse(JSON.stringify(BASE));++ const rows = await db.prepare('SELECT role, body FROM agent_role_defaults').all();++ for (const row of rows) if (ROLES.includes(row.role)) result[row.role] = parseJson(row.body, result[row.role]);++ // A worker changed alone still gets a different reviewer family by default.++ if (!rows.some(r => r.role === 'reviewer') && modelsOf(result.worker).some(m => family(m) === 'anthropic')) result.reviewer = { model: 'openai/gpt-5.5', effort: 'high' };++ return result;++ }++ async function put(role, value, actor) {++ if (!ROLES.includes(role)) throw new AiError(422, 'input.invalid', 'unknown agent role');++ const checked = validate(value);++ return db.tx(async () => {++ // Serialize updates to the two roles even when their rows do not exist yet.++ await db.prepare('SELECT pg_advisory_xact_lock(570157)').get();++ const before = await get();++ const after = { ...before, [role]: checked };++ if (['worker', 'reviewer'].includes(role)) {++ const reviewerSaved = await db.prepare("SELECT role FROM agent_role_defaults WHERE role = 'reviewer'").get();++ if (role === 'worker' && !reviewerSaved) after.reviewer = modelsOf(checked).some(m => family(m) === 'anthropic') ? { model: 'openai/gpt-5.5', effort: 'high' } : BASE.reviewer;++ const workers = new Set(modelsOf(after.worker).map(family));++ if (modelsOf(after.reviewer).some(m => workers.has(family(m)))) throw new AiError(422, 'input.invalid', 'reviewer must use a different provider family than worker');++ }++ await db.prepare('INSERT INTO agent_role_defaults (role, body, updated_at) VALUES (?, ?, ?) ON CONFLICT(role) DO UPDATE SET body = excluded.body, updated_at = excluded.updated_at').run(role, JSON.stringify(checked), new Date(clock.now()).toISOString());++ await registry.audit(actor.actor, 'agent.role-default.update', 'agent-role', role, { trace: actor.trace, metadata: { before: before[role], after: checked } });++ return checked;++ });++ }++ return { get, put };++}++function controls(body) {++ const out = {};++ for (const k of ['effort', 'agent_role', 'ladder_id', 'rung', 'kind']) if (body[k] !== undefined) out[k] = body[k];++ return out;++}++async function resolve(body, workflow, defaults) {++ const asked = checkEffort(body.effort === undefined ? 'auto' : body.effort);++ if (body.agent_role !== undefined && !ROLES.includes(body.agent_role)) throw new AiError(422, 'input.invalid', 'unknown agent_role');++ if ((body.ladder_id === undefined) !== (body.rung === undefined)) throw new AiError(422, 'input.invalid', 'ladder_id and rung must be supplied together');++ if (body.ladder_id !== undefined && (typeof body.ladder_id !== 'string' || !/^[A-Za-z0-9._:-]{1,128}$/.test(body.ladder_id) || !Number.isSafeInteger(body.rung) || body.rung < 0)) throw new AiError(422, 'input.invalid', 'invalid ladder_id or rung');++ if (body.kind !== undefined && (typeof body.kind !== 'string' || !/^[a-z][a-z0-9_.-]{0,99}$/.test(body.kind))) throw new AiError(422, 'input.invalid', 'invalid kind');++ const policy = body.agent_role ? (await defaults.get())[body.agent_role] : null;++ const taskEffort = policy ? policy.effort : workflow.metadata && workflow.metadata.effort || 'medium';++ const resolved = asked === 'auto' ? checkEffort(taskEffort) : asked;++ if (resolved === 'auto') throw new AiError(422, 'input.invalid', 'task default effort cannot itself be auto');++ return { effort_requested: asked, effort_resolved: resolved, ...(policy ? { agent_role: body.agent_role, role_default: policy } : {}), ...(body.ladder_id !== undefined ? { ladder_id: body.ladder_id, effort_rung: body.rung } : {}), ...(body.kind ? { kind: body.kind } : {}) };++}++module.exports = { createDefaults, controls, resolve, ROLES, family, modelsOf };nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++const { AiError } = require('../util');++const { modelsOf } = require('./defaults');++// A local wrapper leaves the shared provider router's admission, caps, billing and++// fallback rules in charge. Explicit caller model pins take precedence over defaults.++function wrapPool(pool) {++ if (!pool) return pool;++ return Object.assign(Object.create(pool), {++ async execute(route, operation, request, ctx = {}) {++ if (!['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages'].includes(operation)) return pool.execute(route, operation, request, ctx);++ const policy = ctx.effortPolicy;++ let runCtx = ctx;++ if (policy && policy.role_default && !ctx.exact && !ctx.routePin && ctx.taskChoice?.why !== 'run') {++ const offered = await pool.offers(route, operation, ctx);++ const choices = modelsOf(policy.role_default);++ const selected = choices.map(id => offered.find(o => `${o.provider}/${o.model}` === id)).filter(Boolean);++ const chosen = selected[0];++ if (!chosen) throw new AiError(503, 'role.model_unavailable', 'no model from this role default is offered by the route');++ runCtx = { ...ctx, taskChoice: null, policy: 'exact', exact: { id: `${chosen.provider}/${chosen.model}`, offers: selected.map(o => ({ provider: o.provider, model: o.model })) } };++ }++ const result = await pool.execute(route, operation, { ...request, ...(policy ? { effort: policy.effort_resolved } : {}) }, runCtx);++ return { ...result, explain: { ...(result.explain || {}), effort_applied: result.result && result.result.effortApplied || null } };++ },++ });++}++module.exports = { wrapPool };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++const { AiError } = require('../util');++const LEVELS = ['auto', 'low', 'medium', 'high', 'max'];++const TEXT = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages'];++function checkEffort(value) {++ if (!LEVELS.includes(value)) throw new AiError(422, 'input.invalid', `effort must be ${LEVELS.join('|')}`);++ return value;++}++// Verified against the providers' own documentation, 2026-11-09. Token budgets below++// are OpenVibe policy, not provider presets. Unknown models/providers receive no control.++function control({ kind, key = '', model = '', base_url = '' }, effort) {++ if (effort == null) return { applied: null, fields: {} };++ checkEffort(effort);++ if (effort === 'auto') throw new AiError(422, 'input.invalid', 'resolve auto against task defaults before calling a provider');++ const m = String(model).replace(/^models\//, '').toLowerCase();++ const source = `${key} ${base_url}`.toLowerCase();++ // https://developers.openai.com/api/docs/guides/reasoning?api-mode=chat++ if (['openai', 'responses'].includes(kind) && /openai|api\.openai\.com/.test(source) && /^(gpt-[56]|o[134])/.test(m) && !/chat|non-reasoning/.test(m)) {++ const native = effort !== 'max' ? effort : /^gpt-6/.test(m) ? 'max' : /^gpt-5\.[2-9]/.test(m) ? 'xhigh' : 'high';++ return { applied: effort === 'max' && native === 'high' ? 'high' : effort, fields: kind === 'responses' ? { reasoning: { effort: native } } : { reasoning_effort: native } };++ }++ // https://platform.claude.com/docs/en/build-with-claude/thinking++ // https://platform.claude.com/docs/en/build-with-claude/effort++ if (kind === 'anthropic' && /^claude-(opus|sonnet|haiku|fable|mythos)-(5|4-[6-9])\b/.test(m)) {++ return { applied: effort, fields: { thinking: { type: 'adaptive' }, output_config: { effort } } };++ }++ // https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking++ if (kind === 'gemini' && /^gemini-[234]/.test(m)) {++ if (/^gemini-[34]/.test(m)) {++ const level = effort === 'max' ? 'high' : effort === 'medium' && /^gemini-3-pro/.test(m) ? 'high' : effort;++ return { applied: level, fields: { generationConfig: { thinkingConfig: { thinkingLevel: level.toUpperCase() } } } };++ }++ if (/^gemini-2\.5/.test(m)) return { applied: effort, fields: { generationConfig: { thinkingConfig: { thinkingBudget: { low: 1024, medium: 4096, high: 16384, max: /pro/.test(m) ? 32768 : 24576 }[effort] } } } };++ }++ // https://www.alibabacloud.com/help/en/model-studio/deep-thinking++ if (kind === 'openai' && /qwen|dashscope/.test(source) && /^qwen3/.test(m) && !/omni|coder|instruct/.test(m)) {++ const only = /thinking|2\.4t/.test(m);++ return { applied: effort, fields: { ...(!only ? { enable_thinking: effort !== 'low' } : {}), ...(only || effort !== 'low' ? { thinking_budget: { low: 1024, medium: 4096, high: 16384, max: 32768 }[effort] } : {}) } };++ }++ // https://api-docs.deepseek.com/guides/thinking_mode/++ if (['openai', 'anthropic'].includes(kind) && /deepseek/.test(source) && /^deepseek-(v[34]|chat|reasoner|flash|pro)/.test(m)) {++ return { applied: effort === 'low' ? 'low' : 'medium', fields: { thinking: { type: effort === 'low' ? 'disabled' : 'enabled' } } };++ }++ return { applied: null, fields: {} };++}++function apply(body, req, fallback) {++ const c = req._effortControl || control({ ...fallback, model: req.model }, req.effort);++ if (/^claude-(opus|sonnet)-5-5/.test(req.model || '') && req.thinking && Object.hasOwn(req.thinking, 'budget_tokens')) {++ throw new AiError(422, 'input.invalid', 'Opus/Sonnet 5.5 require adaptive thinking; budget_tokens is refused');++ }++ for (const [key, value] of Object.entries(c.fields)) body[key] = typeof value === 'object' ? { ...(body[key] || {}), ...value } : value;++ return body;++}++function wrapAdapter(adapter, record) {++ for (const op of TEXT) if (typeof adapter[op] === 'function') {++ const call = adapter[op];++ adapter[op] = async (req) => {++ const c = control({ ...record, kind: adapter.kind, model: req.model }, req.effort);++ const result = await call({ ...req, _effortControl: c });++ return { ...result, effortApplied: c.applied };++ };++ }++ return adapter;++}++module.exports = { LEVELS, checkEffort, control, apply, wrapAdapter };nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++const { parseJson } = require('../util');++function receiptEffort(row, explain) {++ const policy = parseJson(row.options, {}).effort_policy;++ if (!policy) return {};++ const applied = explain && explain.effort_applied || null;++ return {++ effort_requested: policy.effort_requested,++ effort_applied: applied,++ ...(policy.ladder_id ? {++ ladder_id: policy.ladder_id, effort_rung: policy.effort_rung,++ ladder: row.model_key ? [{ model: row.model_key, effort: applied || policy.effort_resolved, cost_usd: Number(row.cost_usd) || 0, outcome: row.status || 'succeeded' }] : [],++ } : {}),++ };++}++module.exports = { receiptEffort };@@@ -266,6 +266,13 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (// else the answering attempt's total latency.const measured = await seen.read({ days });const measuredStats = observed.fold(measured, drafts);++ const shares = await require('../effort/analytics').cacheShares(db, clock);++ for (const d of drafts) {++ const stats = measuredStats.get(d);++ if (stats && stats.succeeded + stats.failed >= 20) {++ d.offer.cache_hit_share_7d = shares.get(`${d.providerKey}/${d.modelKey}`) ?? d.offer.cache_hit_share_7d;++ }++ }// The router's own measurements win once they have 20 samples; until then the observed runs' numbers stay.const performance = await db.prepare('SELECT * FROM provider_metrics').all();for (const d of drafts) {@@@ -79,6 +79,8 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =const paidBy = (payer && payer.paid_by) || 'openvibe_key';const byok = paidBy === 'own_key' || paidBy === 'own_machine';return {++ ...require('../effort/receipt').receiptEffort(run, explain),++ ...(parseJson(run.options, {}).effort_policy?.kind ? { kind: parseJson(run.options, {}).effort_policy.kind } : {}),id: receiptId(run.id),run_id: run.id,created_at: createdAt || new Date().toISOString(),@@@ -59,13 +59,18 @@ const SERVICE_OR_APP = [{ bearer: [] }, { app: [] }];const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });++const effortFields = {++ effort: str({ enum: ['auto', 'low', 'medium', 'high', 'max'] }),++ agent_role: str({ enum: ['router', 'gate', 'assessor', 'worker', 'subagent', 'reviewer'] }),++ ladder_id: str({ maxLength: 128 }), rung: integer({ minimum: 0 }), kind: str({ maxLength: 100 }),++};const runBody = (required = []) => ({type: 'object', ...(required.length ? { required } : {}),properties: {workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),task: str({ description: 'A registered task (GET /api/v1/tasks); its workflow runs with the task’s resolved choice. Required instead of workflow when no workflow is named.' }),choice: ref('Choice'),-- input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),++ ...effortFields, input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),options: { type: 'object', description: 'cache, debug, and the latency class: latency (interactive | background | batch); a batch-class run also takes deadline_hours (1-72, the default is 24)' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },},@@@ -73,6 +78,7 @@ const runBody = (required = []) => ({const directBody = {type: 'object',properties: {++ ...effortFields,idempotency_key: str({ minLength: 8, maxLength: 128 }), target: { type: 'object' }, attribution: { type: 'object' },on_behalf_of: ref('SubjectRef'), options: { type: 'object' },},@@@ -466,6 +472,7 @@ const exchangeModel = {capabilities: { type: 'object', properties: { tools: { type: 'boolean' }, json: { type: 'boolean' }, vision: { type: 'boolean' }, stream: { type: 'boolean' } } },modalities: arrayOf(str()), description: nullable('string'),requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),++ cache_hit_share_7d: nullable('number', { minimum: 0, maximum: 1 }),cached_7d: integer({ minimum: 0 }), cached_today: integer({ minimum: 0 }), community: { type: 'boolean' },cache_hit_share_7d: nullable('number', { minimum: 0, maximum: 1, description: 'The prompt-cache share over this model\'s offers in the window (cached prompt tokens / reported prompt tokens). Null when no offer has enough attempts to measure one.' }),offers: arrayOf(exchangeOffer),@@@ -498,6 +505,25 @@ route('get', '/exchange/market', {},}), ...errors([422]) },});++route('get', '/exchange/estimate', {++ operationId: 'exchangeEffortEstimate', summary: 'A current-price p25–p75 cost estimate from 30 days of receipts', tags: ['exchange'], security: [],++ parameters: [query('model', str()), query('effort', effortFields.effort), query('kind', str()), query('input_tokens', integer({ minimum: 1 }))],++ responses: { 200: json('Under twenty observations: null prices with n and basis.status no_data', { type: 'object', required: ['low_usd', 'high_usd', 'n', 'basis'], properties: { low_usd: nullable('number'), high_usd: nullable('number'), n: integer(), basis: { type: 'object' } } }), ...errors([422]) },++});++route('get', '/me/ladders/{id}', {++ operationId: 'myEffortLadder', summary: 'Your own ladder costs by rung', tags: ['exchange'], security: SERVICE,++ parameters: [path('id', str())], responses: { 200: json('Total cost and rung totals', { type: 'object' }), ...errors([401, 403, 404]) },++});++route('get', '/agent-role-defaults', {++ operationId: 'agentRoleDefaults', summary: 'Owner role model and effort defaults', tags: ['admin'], security: SERVICE,++ description: 'ai.config.manage. Reviewer uses a different model provider family than worker.', responses: { 200: json('Role defaults', { type: 'object' }), ...errors([401, 403]) },++});++route('put', '/agent-role-defaults/{role}', {++ operationId: 'putAgentRoleDefault', summary: 'Update and audit a role default', tags: ['admin'], security: SERVICE,++ description: 'ai.config.manage. Exactly one of model or choice_order; concrete effort.', parameters: [path('role', effortFields.agent_role)],++ requestBody: body({ type: 'object', properties: { model: str(), choice_order: arrayOf(str()), effort: effortFields.effort } }),++ responses: { 200: json('Saved default', { type: 'object' }), ...errors([401, 403, 422]) },++});route('get', '/exchange/catalog', {operationId: 'exchangeCatalog', summary: 'Every model and every offer that serves it', tags: ['exchange'], security: [],description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. Speed and uptime are read from the last seven days of real runs (time to the first token where a receipt recorded one, else the answering attempt\'s total); cached answers are counted apart and excluded from speed, uptime and requests. Uptime is succeeded / (succeeded + failed); null when no data, never 0. A prepaid offer has no price (free: true); free_tier says whether the free allowance and the anonymous Try it may use an offer at all (a named AI_FREE_PROVIDERS source under the output price ceiling). Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',@@@ -853,6 +879,7 @@ function openapi() {source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },provenance: { type: 'object' },++ effort_requested: effortFields.effort, effort_applied: nullable('string'), effort_rung: integer({ minimum: 0 }), ladder_id: str(), ladder: arrayOf({ type: 'object' }),latency_class: { type: 'string', enum: ['interactive', 'background', 'batch'] },service_tier: nullable('string', { description: 'the tier the answer really ran on: standard, flex, batch, offpeak or local' }),latency_clamped: nullable('object', { description: '{ asked, used }: the class the caller asked for and the class the run really took' }),@@@ -14,6 +14,7 @@const { postJson, ProviderError, deadline, describeAbort, schemaInstruction } = require('./common');const { sseFrames } = require('./stream');const usageParser = require('./usage');++const effortPolicy = require('../effort/providers');const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1';const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];@@@ -30,7 +31,8 @@ const isCurrentClaude = (model) => CURRENT_CLAUDE.test(String(model || ''));const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];/** jsonMode: 'format' (structured outputs), 'tool' (a forced tool, older models) or 'prose' (the schema in the system prompt). */--function buildBody({ model, system, messages, image, json, maxTokens, temperature, effort }, jsonMode) {++function buildBody(req, jsonMode) {++ const { model, system, messages, image, json, maxTokens, temperature, effort } = req;const current = isCurrentClaude(model);const mode = json ? (jsonMode || (current ? 'format' : 'tool')) : null;const body = { model, max_tokens: Math.max(1, maxTokens) };@@@ -56,7 +58,7 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperaturbody.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];body.tool_choice = { type: 'tool', name: json.name || 'result' };}-- return body;++ return effortPolicy.apply(body, req, { kind: 'anthropic', key: 'anthropic' });}/** The caller's Messages API body, with the routed model and only the fields they set. */@@@ -70,7 +72,7 @@ function messagesBody(req) {const tc = body.tool_choice;if (tc && (tc.type === 'tool' || tc.type === 'any')) body.tool_choice = { type: 'auto', ...(tc.disable_parallel_tool_use ? { disable_parallel_tool_use: true } : {}) };}-- return body;++ return effortPolicy.apply(body, req, { kind: 'anthropic', key: 'anthropic' });}const usageOf = (message) => usageParser.anthropicUsage((message && message.usage) || {});@@@ -202,7 +204,7 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.fconst adapter = { key: record.key, kind: 'anthropic', supports: (f) => caps.has(f) || (f === 'messages' && caps.has('chat')), chat, messages };for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat;-- return adapter;++ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });}module.exports = { createAnthropicProvider, buildBody, messagesBody, isCurrentClaude, DEFAULT_BASE_URL };@@@ -13,6 +13,7 @@const { ProviderError, postJson, schemaInstruction } = require('./common');const usage = require('./usage');++const effortPolicy = require('../effort/providers');const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];@@@ -48,6 +49,7 @@ function createGeminiProvider(record, { apiKey = '', fetchImpl = globalThis.fetcgenerationConfig.responseSchema = req.json.schema;}if (Object.keys(generationConfig).length) body.generationConfig = generationConfig;++ effortPolicy.apply(body, req, { kind: 'gemini' });const url = `${baseUrl}/models/${encodeURIComponent(model(req))}:generateContent`;const j = await postJson(url, apiKey ? { 'x-goog-api-key': apiKey } : {}, body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });const parts = (j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) || [];@@@ -72,7 +74,7 @@ function createGeminiProvider(record, { apiKey = '', fetchImpl = globalThis.fetcgenerate,};for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat;-- return adapter;++ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });}module.exports = { createGeminiProvider, DEFAULT_BASE_URL };@@@ -21,13 +21,15 @@ const path = require('path');const { ProviderError, postJson, deadline, describeAbort, approxTokens, schemaInstruction } = require('./common');const { sseEvents, createAccumulator } = require('./stream');const usageParser = require('./usage');++const effortPolicy = require('../effort/providers');const DEFAULT_BASE_URL = 'https://api.openai.com/v1';const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];--function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }++function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }--function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier }, jsonMode) {++function buildBody(req, jsonMode) {++ const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier } = req;const msgs = [];// Stepped down from json_schema: the schema travels in the instructions instead.const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';@@@ -54,14 +56,15 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur// The service tier (BA1): a Flex tier is slower and cheaper, and the provider may refuse it. The tier// travels in the request; the answer says which tier it really ran on (the pool prices that one).if (serviceTier) body.service_tier = String(serviceTier);-- return body;++ return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });}/*** The Chat Completions body of a complete operation (ai.complete), for a provider batch request too:* the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.*/--function buildCompleteBody({ model, openai = {}, maxTokens, temperature, serviceTier } = {}) {++function buildCompleteBody(req = {}) {++ const { model, openai = {}, maxTokens, temperature, serviceTier } = req;const o = openai || {};const body = { model, messages: o.messages };for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {@@@ -77,7 +80,7 @@ function buildCompleteBody({ model, openai = {}, maxTokens, temperature, serviceif (temperature != null) body.temperature = temperature;}if (serviceTier) body.service_tier = String(serviceTier);-- return body;++ return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });}function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {@@@ -169,7 +172,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc*/async function complete(req) {const o = req.openai || {};-- const body = buildCompleteBody({ model: req.model, openai: o, maxTokens: req.maxTokens, temperature: req.temperature, serviceTier: req.serviceTier });++ const body = buildCompleteBody({ ...req, openai: o });if (typeof req.onDelta === 'function') return await completeStreaming(body, req);const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });if (!Array.isArray(j.choices) || !j.choices.length) throw new ProviderError('completion has no choices', { code: 'provider.output' });@@@ -254,7 +257,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetcimage: images,};for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation-- return adapter;++ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });}module.exports = { createOpenAiProvider, buildBody, buildCompleteBody, DEFAULT_BASE_URL };@@@ -14,11 +14,12 @@*/const { ProviderError, postJson, schemaInstruction } = require('./common');const usageParser = require('./usage');++const effortPolicy = require('../effort/providers');const DEFAULT_BASE_URL = 'https://api.openai.com/v1';const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];--function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }++function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }/** Chat-style content (a string or [{type:'text',text}] parts) as Responses input content. */function inputContent(content) {@@@ -26,7 +27,8 @@ function inputContent(content) {return content.map(p => (p && p.type === 'text' ? { type: 'input_text', text: String(p.text || '') } : p));}--function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl }, jsonMode) {++function buildBody(req, jsonMode) {++ const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl } = req;const input = (messages || []).map((m, i) => {const last = i === messages.length - 1;if (last && image && m.role === 'user') {@@@ -50,7 +52,7 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperaturif (json && jsonMode === 'schema') body.text = { format: { type: 'json_schema', name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };else if (json && jsonMode === 'object') body.text = { format: { type: 'json_object' } };if (cacheKey && /api\.openai\.com/i.test(baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);-- return body;++ return effortPolicy.apply(body, req, { kind: 'responses', key: 'openai' });}/** The output_text parts of every output item, in order. */@@@ -105,7 +107,7 @@ function createResponsesProvider(record, { apiKey = '', fetchImpl = globalThis.fchat,};for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation-- return adapter;++ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });}module.exports = { createResponsesProvider, buildBody, outputText, DEFAULT_BASE_URL };@@@ -28,6 +28,8 @@ const schemas = require('./schemas');const events = require('./events');const usageRecord = require('./usage-record');const usageSamples = require('./usage-samples');++const effortDefaults = require('./effort/defaults');++const { receiptEffort } = require('./effort/receipt');const { buildReceipt } = require('./exchange/receipts');const privacy = require('./exchange/privacy');@@@ -127,10 +129,12 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confilet active = 0;let shuttingDown = false;++ const agentDefaults = effortDefaults.createDefaults({ db, registry, clock });function decode(r) {if (!r) return null;return {id: r.id, status: r.status,++ ...receiptEffort(r, parseJson(r.explain, null)),workflow: { key: r.workflow_key, version: r.workflow_version },template: r.template_key ? { key: r.template_key, version: r.template_version } : null,route: r.route_key ? { key: r.route_key, version: r.route_version } : null,@@@ -237,11 +241,13 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confiif (askedPolicy.error) throw new AiError(422, askedPolicy.error, askedPolicy.detail);const contentPolicy = askedPolicy.policy;const input = body.input === undefined ? {} : body.input;++ const taskDefaults = choices ? (await choices.defaults())[body.task || wf.key] : null;++ const effortPolicy = await effortDefaults.resolve(body, { ...wf, metadata: { ...wf.metadata, ...(taskDefaults?.effort ? { effort: taskDefaults.effort } : {}) } }, agentDefaults);const inputJson = stableStringify(input);if (Buffer.byteLength(inputJson) > config.runs.maxInputBytes) throw new AiError(413, 'input.too_large', `input is larger than ${config.runs.maxInputBytes} bytes`);const v = schemas.validate(wf.input_schema, input);if (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });-- const inputHash = sha256(body.choice || body.task ? { input: inputJson, choice: body.choice || null, task: body.task || null } : inputJson);++ const inputHash = sha256({ input, effortPolicy, choice: body.choice || null, task: body.task || null });const requester = principal.subject;// A developer app (ADR-014): the requester IS its project, the run is metered with the project's// id on every Billing reading, and a sandbox run may use free/local capacity only (server/providers).@@@ -440,7 +446,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confitarget: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,latency_class: latencyClass,-- options: JSON.stringify({ ...(taskChoice ? { task_choice: taskChoice } : {}), cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,++ options: JSON.stringify({ effort_policy: effortPolicy, ...(taskChoice ? { task_choice: taskChoice } : {}), cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,...(options.latency_clamped ? { latency_clamped: options.latency_clamped } : {}),...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),...((selfKeys || taskChoice && runPerson) ? { self_subject: runPerson } : {}),@@@ -461,7 +467,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confilet cacheKey = null;if (scope) {// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).-- const keyInput = CACHE_INPUT[wf.key] ? sha256(stableStringify(CACHE_INPUT[wf.key](input))) : inputHash;++ const keyInput = CACHE_INPUT[wf.key] ? sha256({ input: CACHE_INPUT[wf.key](input), effortPolicy }) : inputHash;cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash: keyInput });const hit = await cache.get(cacheKey, scope);if (hit) {@@@ -511,13 +517,14 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, conficonst appFreeOnly = Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled)));let prepared = null;if (batch && typeof batch.planRun === 'function' && engine.prepareBatch-- && credentialSubject === null && !projectProvider && !selfKeys && !freeUse && !communityOptIn && !appFreeOnly) {++ && credentialSubject === null && !projectProvider && !selfKeys && !freeUse && !communityOptIn && !appFreeOnly && !effortPolicy.role_default) {try {prepared = await engine.prepareBatch(wf, { template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null }, input, { ...ctx, runId: id });} catch (err) {log.warn(`[runs] ${id}: the batch request could not be prepared: ${(err && err.message) || err}`);}}++ if (prepared) prepared.effort = effortPolicy.effort_resolved;if (prepared && pool && typeof pool.offers === 'function') {let offers = await pool.offers(prepared.route, prepared.operation, ctx).catch(() => []);// A pinned run waits on the provider it pinned, never on another eligible one.@@@ -706,7 +713,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, conficonst money = usageRecord.moneyOf({ prices, tokens, paidBy: payer.paid_by, feeRate: (config.usage && config.usage.feeRate) || 0 });const rec = usageRecord.recordOf({system: 'ai', run_id: row.id, subject: subjectOf(row), attribution: attributionOf(row), surface: row.workflow_key || '',-- provider: payer.provider || provider || null, model: model || null, effort: opts.effort || null,++ provider: payer.provider || provider || null, model: model || null, effort: parseJson(row.explain, {})?.effort_applied || opts.effort || null,paid_by: payer.paid_by, paid_by_detail: payer.detail,input: tokens.input, output: tokens.output, reasoning: tokens.reasoning, cache_read: tokens.cache_read,cache_write: tokens.cache_write, cache_write_ttl: usage.cache_write_ttl || null,@@@ -774,10 +781,14 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, conficonst paidBy = (payer && payer.paid_by) || 'openvibe_key';const feeRate = paidBy === 'openvibe_key' ? Number((config.usage && config.usage.feeRate) || 0) : 0;const opts = parseJson(row.options, {});++ if ((row.service_tier === 'batch' || row.status === 'failed') && opts.effort_policy && answerProvider && !Object.hasOwn(explain || {}, 'effort_applied')) {++ const record = await registry.getProvider(answerProvider);++ if (record) explain = { ...(explain || {}), effort_applied: require('./effort/providers').control({ ...record, model: answerModel }, opts.effort_policy.effort_resolved).applied };++ }const receipt = buildReceipt({run: {id: row.id, workflow_key: row.workflow_key, route_key: row.route_key, route_version: row.route_version,-- provider_key: answerProvider, model_key: answerModel, cost_usd: row.cost_usd,++ provider_key: answerProvider, model_key: answerModel, cost_usd: row.cost_usd, options: row.options, status: row.status,},explain, usage, price: prices, privacy: privacy.view(label),payer: { paid_by: paidBy, detail: (payer && payer.detail) || null, fee_rate: feeRate, fee_usd: money ? money.fee_usd : 0 },@@@ -952,7 +963,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confiif (batchAnswer && batchAnswer.ok && !batchAnswer.resume) {r = { output: batchAnswer.output || { text: '' }, usage: batchAnswer.usage || { input: 0, output: 0, cached: 0, cache_write: 0 }, cost: Number(batchAnswer.cost) || 0, provider: batchAnswer.provider || null, model: batchAnswer.model || null, citations: [], explain: batchAnswer.explain || null, grounding: null, synthetic: false, fallbackUsed: false };}-- if (!r) r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,++ if (!r) r = await engine.execute(wf, row, input, { effortPolicy: opts.effort_policy, signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is// limited to free and local capacity when it is a sandbox run or when no tier budget governs its// project (AI_GOVERN_TIERS off): metered capacity is only ever spent under a project budget.@@@ -1065,6 +1076,8 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confiif (f.changes) await events.runChanged(await getRow(id));});const spent = await db.prepare("SELECT COALESCE(SUM(tokens_in),0)::bigint ti, COALESCE(SUM(tokens_out),0)::bigint tout, COALESCE(SUM(tokens_cached),0)::bigint tc, COALESCE(SUM(cost_usd),0) c FROM requests WHERE run_id = ? AND status = 'ok'").get(id);++ const lastAttempt = logged.filter(e => e.status !== 'skipped').at(-1);++ await writeReceipt({ row: { ...await getRow(id), cost_usd: Number(spent.c) || 0, provider_key: lastAttempt?.provider_key || null, model_key: lastAttempt?.model_key || null }, explain: parseJson(explain, null), usage: { input: Number(spent.ti), output: Number(spent.tout), cached: Number(spent.tc) } });// Balance: nothing reached a provider (or it failed before the first token) releases the hold; an abort// or a failure after tokens streamed settles the partial usage it did produce.if (credit && credit.holdId) {@@@ -154,6 +154,7 @@ function batchExec(b, result) {}function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {++ pool = require('../effort/pool').wrapPool(pool);// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.let proxy = null;const reader = () => proxy || (proxy = require('../media-proxy').createMediaProxy({ fetcher, log }));nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++const assert = require('assert');++const { suite, boot, request, token, ALL } = require('./helpers');++const { createOpenAiProvider } = require('../server/providers/openai');++const { createAnthropicProvider } = require('../server/providers/anthropic');++const { createGeminiProvider } = require('../server/providers/gemini');++const { createDefaults, resolve } = require('../server/effort/defaults');++const { createAnalytics } = require('../server/effort/analytics');++const { createReceipts } = require('../server/exchange/receipts');++const cases = require('./fixtures/effort/requests.json');++const t = suite('agent-effort');++const factories = { openai: createOpenAiProvider, anthropic: createAnthropicProvider, gemini: createGeminiProvider, responses: require('../server/providers/responses').createResponsesProvider };++const answer = { choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 10 }, type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], candidates: [{ content: { parts: [{ text: 'ok' }] } }] };++++t.test('native request fixtures cover chat and compatible routes without network calls', async () => {++ for (const c of cases) for (const op of c.kind === 'openai' ? ['chat', 'complete'] : c.kind === 'anthropic' ? ['chat', 'messages'] : ['chat']) {++ let sent;++ const adapter = factories[c.kind]({ key: c.provider, base_url: 'https://fixture.invalid/v1' }, { fetchImpl: async (_url, opts) => { sent = JSON.parse(opts.body); return new Response(JSON.stringify(answer), { status: 200 }); } });++ const result = await adapter[op]({ model: c.model, effort: c.effort, system: [], messages: [{ role: 'user', content: 'hello' }], openai: { messages: [{ role: 'user', content: 'hello' }] }, maxTokens: 128 });++ for (const [key, value] of Object.entries(c.fields)) {++ if (key === 'generationConfig') assert.deepStrictEqual(sent[key].thinkingConfig, value.thinkingConfig);++ else assert.deepStrictEqual(sent[key], value, `${c.provider}/${op}/${key}`);++ }++ if (c.applied === null) for (const k of ['effort', 'reasoning_effort', 'thinking', 'enable_thinking', 'thinking_budget']) assert.ok(!(k in sent), k);++ assert.strictEqual(result.effortApplied, c.applied, `${c.provider}/${op} receipt`);++ }++});++t.test('role choice order uses eligible models and preserves explicit model pins', async () => {++ let seen;++ const wrapped = require('../server/effort/pool').wrapPool({++ offers: async () => [{ provider: 'openai', model: 'gpt-5.5' }],++ execute: async (_route, _op, req, ctx) => { seen = { req, ctx }; return { result: { effortApplied: req.effort } }; },++ });++ const ctx = { effortPolicy: { effort_resolved: 'high', role_default: { choice_order: ['deepseek/unavailable', 'openai/gpt-5.5'] } } };++ await wrapped.execute({}, 'complete', {}, ctx);++ assert.strictEqual(seen.ctx.exact.id, 'openai/gpt-5.5');++ assert.strictEqual(seen.req.effort, 'high');++ await wrapped.execute({}, 'complete', {}, { ...ctx, exact: { id: 'caller/model' } });++ assert.strictEqual(seen.ctx.exact.id, 'caller/model');++ await assert.rejects(() => wrapped.execute({}, 'chat', {}, { effortPolicy: { effort_resolved: 'low', role_default: { model: 'other/no-model' } } }), /no model/);++});++t.test('streamed OpenAI and Anthropic calls carry the same effort controls', async () => {++ for (const kind of ['openai', 'anthropic']) {++ let sent;++ const text = kind === 'openai'++ ? 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1}}\n\ndata: [DONE]\n\n'++ : 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"usage":{"input_tokens":2}}}\n\nevent: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"ok"}}\n\nevent: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n';++ const a = factories[kind]({ key: kind }, { fetchImpl: async (_url, opts) => { sent = JSON.parse(opts.body); return new Response(text, { headers: { 'Content-Type': 'text/event-stream' } }); } });++ const result = await a[kind === 'openai' ? 'complete' : 'messages']({ model: kind === 'openai' ? 'gpt-5.5' : 'claude-sonnet-5-5', effort: 'high', messages: [{ role: 'user', content: 'hi' }], openai: { messages: [{ role: 'user', content: 'hi' }] }, maxTokens: 128, timeoutMs: 3000, onDelta: () => {} });++ assert.strictEqual(sent.stream, true);++ assert.strictEqual(kind === 'openai' ? sent.reasoning_effort : sent.output_config.effort, 'high');++ assert.strictEqual(result.effortApplied, 'high');++ }++});++t.test('adaptive Claude rejects legacy thinking budgets before fetch', async () => {++ const adapter = createAnthropicProvider({ key: 'anthropic' }, { fetchImpl: () => { throw new Error('must not fetch'); } });++ await assert.rejects(() => adapter.messages({ model: 'claude-opus-5-5', effort: 'high', messages: [], thinking: { type: 'enabled', budget_tokens: 1000 } }), /budget_tokens is refused/);++});++t.test('routes retain requested effort and ladder; invalid effort never creates a run', async () => {++ const h = await boot();++ const tok = token('live', ALL);++ try {++ for (const [path, input] of [++ ['/api/v1/generate', { prompt: 'hello' }],++ ['/api/v1/chat', { messages: [{ role: 'user', content: 'hello' }] }],++ ['/v1/chat/completions', { messages: [{ role: 'user', content: 'hello' }] }],++ ['/v1/messages', { messages: [{ role: 'user', content: 'hello' }], max_tokens: 64 }],++ ]) {++ const res = await request(h.base, 'POST', path, { tok, body: { ...input, effort: 'high', ladder_id: 'test-ladder', rung: 2, kind: 'codes.ticket' } });++ assert.ok([200, 201].includes(res.status), `${path}: ${res.status} ${res.text}`);++ const row = await h.db.prepare('SELECT * FROM runs ORDER BY created_at DESC LIMIT 1').get();++ const policy = JSON.parse(row.options).effort_policy;++ assert.strictEqual(policy.effort_requested, 'high');++ const receipt = await createReceipts({ db: h.db }).forRun(row.id);++ assert.strictEqual(receipt.effort_applied, null, 'stub ignores effort');++ assert.strictEqual(receipt.effort_rung, 2);++ assert.strictEqual(receipt.kind, 'codes.ticket');++ }++ const totals = await request(h.base, 'GET', '/api/v1/me/ladders/test-ladder', { tok });++ assert.strictEqual(totals.status, 200, totals.text);++ assert.strictEqual(totals.body.rungs[0].n, 4);++ assert.strictEqual((await request(h.base, 'GET', '/api/v1/me/ladders/test-ladder', { tok: token('other', ALL) })).status, 404);++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/generate', { tok, body: { prompt: 'hello', effort: 'huge' } })).status, 422);++ assert.strictEqual((await request(h.base, 'POST', '/v1/messages', { tok, body: { model: 'claude-sonnet-5-5', max_tokens: 64, messages: [{ role: 'user', content: 'hi' }], thinking: { budget_tokens: 1000 } } })).status, 400);++ } finally { await h.stop(); }++});++t.test('owner role policy is audited; reviewer stays in a different family; auto reads defaults', async () => {++ const h = await boot();++ try {++ const initial = await createDefaults({ db: h.db, registry: h.registry }).get();++ assert.strictEqual(initial.worker.model, 'deepseek/deepseek-flash');++ assert.strictEqual(initial.worker.model, `deepseek/${(await h.registry.getProvider('deepseek')).default_model}`, 'worker default matches the seeded provider even while it is disabled');++ const admin = token('network', ['ai.config.manage']);++ assert.strictEqual((await request(h.base, 'GET', '/api/v1/agent-role-defaults', { tok: token('live', ALL) })).status, 403);++ const put = await request(h.base, 'PUT', '/api/v1/agent-role-defaults/worker', { tok: admin, body: { model: 'anthropic/claude-opus-5-5', effort: 'high' } });++ assert.strictEqual(put.status, 200, put.text);++ const defs = createDefaults({ db: h.db, registry: h.registry });++ const roles = await defs.get();++ assert.strictEqual(roles.reviewer.model, 'openai/gpt-5.5');++ assert.strictEqual((await resolve({ effort: 'auto', agent_role: 'worker' }, {}, defs)).effort_resolved, 'high');++ assert.strictEqual((await resolve({ effort: 'auto' }, { metadata: { effort: 'low' } }, defs)).effort_resolved, 'low');++ const defaultsValue = require('../server/choices').defaultValue({ signed_out: { model: 'openvibe/auto', source: 'openvibe' }, signed_in: { model: 'openvibe/auto', source: 'openvibe' }, effort: 'low' });++ await h.db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(task) DO UPDATE SET value = excluded.value').run('design.icon', JSON.stringify(defaultsValue), 'test', new Date().toISOString());++ const created = await h.runs.create({ task: 'design.icon', input: { messages: [{ role: 'user', content: 'Draw an icon' }] }, effort: 'auto' }, { sub: 'svc:live', subject: { type: 'service', id: 'live' } });++ await created.promise;++ const saved = await h.db.prepare('SELECT options FROM runs WHERE id = ?').get(created.run.id);++ assert.strictEqual(JSON.parse(saved.options).effort_policy.effort_resolved, 'low');++ const audits = await h.registry.listAudit({ action: 'agent.role-default.update' });++ assert.strictEqual(audits.length, 1);++ const invalid = await request(h.base, 'PUT', '/api/v1/agent-role-defaults/reviewer', { tok: admin, body: { model: 'anthropic/claude-sonnet-5-5', effort: 'high' } });++ assert.strictEqual(invalid.status, 422, invalid.text);++ assert.strictEqual((await h.registry.listAudit({ action: 'agent.role-default.update' })).length, 1);++ } finally { await h.stop(); }++});++t.test('estimate has a 20-receipt floor, current prices, token scaling, kind and date filters', async () => {++ const h = await boot();++ try {++ const created = await h.runs.create({ workflow: 'ai.generate', input: { prompt: 'sample' }, effort: 'high' }, { sub: 'svc:live', subject: { type: 'service', id: 'live' }, caps: new Set(ALL) });++ await created.promise;++ const row = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(created.run.id);++ await h.db.prepare("UPDATE runs SET synthetic = 0, status = 'succeeded' WHERE id = ?").run(row.id);++ const receipts = createReceipts({ db: h.db });++ let now = Date.now();++ const clock = { now: () => now };++ const catalog = { models: async () => ({ models: [{ slug: 'openai/gpt-5.5', offers: [{ provider: 'openai', model: 'gpt-5.5', price: { in: 2, out: 8, cached: 1 } }] }] }) };++ const analytics = createAnalytics({ db: h.db, catalog, clock });++ const q = { model: 'gpt-5.5', effort: 'high', kind: 'codes.ticket', input_tokens: '200' };++ const existing = await receipts.forRun(row.id);++ if (existing) await h.db.prepare('DELETE FROM ai_receipts WHERE id = ?').run(existing.id);++ for (let i = 0; i < 20; i++) {++ const id = `run_estimate_${i}`;++ await h.db.prepare('INSERT INTO runs SELECT (jsonb_populate_record(NULL::runs, ?::jsonb)).*').run(JSON.stringify({ ...row, id, synthetic: 0, status: 'succeeded', idempotency_key: null, receipt_id: null }));++ await receipts.record({ id: `rcpt_sample_${i}`, run_id: id, created_at: new Date(now).toISOString(), offer: { provider: 'openai', model: 'gpt-5.5' }, effort_applied: 'high', kind: 'codes.ticket', usage: { tokens_in: 100, tokens_out: 20, tokens_reasoning: 10, tokens_cached: 50 }, cost_usd: 99 });++ if (i === 18) { const result = await analytics.estimate(q); assert.strictEqual(result.n, 19); assert.strictEqual(result.low_usd, null); now += 31000; }++ }++ const result = await analytics.estimate(q);++ assert.strictEqual(result.n, 20);++ assert.strictEqual(result.low_usd, 0.00078);++ assert.strictEqual(result.high_usd, 0.00078);++ assert.strictEqual((await analytics.estimate({ ...q, kind: 'other' })).n, 0);++ now += 31 * 86400000;++ assert.strictEqual((await analytics.estimate(q)).n, 0);++ const qrow = await h.db.prepare("SELECT id FROM requests WHERE run_id = ? AND status = 'ok' LIMIT 1").get(row.id);++ assert.ok(qrow);++ await h.db.prepare('UPDATE requests SET tokens_in = 100, tokens_cached = 25 WHERE id = ?').run(qrow.id);++ const shares = await require('../server/effort/analytics').cacheShares(h.db, { now: () => Date.now() });++ assert.strictEqual(shares.get(`${row.provider_key}/${row.model_key}`), 0.25);++ const publicResult = await request(h.base, 'GET', '/api/v1/exchange/estimate?model=gpt-5.5&effort=high');++ assert.strictEqual(publicResult.status, 200, publicResult.text);++ assert.ok(publicResult.headers.get('cache-control').includes('public'));++ } finally { await h.stop(); }++});++t.run();nnew file mode 100644@@@ -0,0 +1,157 @@++[++ {++ "provider": "openai",++ "kind": "openai",++ "model": "gpt-5.5",++ "effort": "max",++ "applied": "max",++ "fields": {++ "reasoning_effort": "xhigh"++ }++ },++ {++ "provider": "openai",++ "kind": "openai",++ "model": "o3",++ "effort": "max",++ "applied": "high",++ "fields": {++ "reasoning_effort": "high"++ }++ },++ {++ "provider": "anthropic",++ "kind": "anthropic",++ "model": "claude-opus-5-5",++ "effort": "high",++ "applied": "high",++ "fields": {++ "thinking": {++ "type": "adaptive"++ },++ "output_config": {++ "effort": "high"++ }++ }++ },++ {++ "provider": "anthropic",++ "kind": "anthropic",++ "model": "claude-sonnet-5-5",++ "effort": "low",++ "applied": "low",++ "fields": {++ "thinking": {++ "type": "adaptive"++ },++ "output_config": {++ "effort": "low"++ }++ }++ },++ {++ "provider": "gemini",++ "kind": "gemini",++ "model": "gemini-3.1-pro-preview",++ "effort": "max",++ "applied": "high",++ "fields": {++ "generationConfig": {++ "thinkingConfig": {++ "thinkingLevel": "HIGH"++ }++ }++ }++ },++ {++ "provider": "gemini",++ "kind": "gemini",++ "model": "gemini-2.5-flash",++ "effort": "medium",++ "applied": "medium",++ "fields": {++ "generationConfig": {++ "thinkingConfig": {++ "thinkingBudget": 4096++ }++ }++ }++ },++ {++ "provider": "qwen",++ "kind": "openai",++ "model": "qwen3.8-max",++ "effort": "high",++ "applied": "high",++ "fields": {++ "enable_thinking": true,++ "thinking_budget": 16384++ }++ },++ {++ "provider": "qwen",++ "kind": "openai",++ "model": "qwen3.8-max",++ "effort": "low",++ "applied": "low",++ "fields": {++ "enable_thinking": false++ }++ },++ {++ "provider": "deepseek",++ "kind": "openai",++ "model": "deepseek-v4-flash",++ "effort": "high",++ "applied": "medium",++ "fields": {++ "thinking": {++ "type": "enabled"++ }++ }++ },++ {++ "provider": "deepseek",++ "kind": "anthropic",++ "model": "deepseek-v4-flash",++ "effort": "low",++ "applied": "low",++ "fields": {++ "thinking": {++ "type": "disabled"++ }++ }++ },++ {++ "provider": "unknown",++ "kind": "openai",++ "model": "llama-3",++ "effort": "high",++ "applied": null,++ "fields": {}++ },++ {++ "provider": "openai",++ "kind": "responses",++ "model": "gpt-6-astra",++ "effort": "max",++ "applied": "max",++ "fields": {++ "reasoning": {++ "effort": "max"++ }++ }++ },++ {++ "provider": "deepseek",++ "kind": "openai",++ "model": "deepseek-flash",++ "effort": "low",++ "applied": "low",++ "fields": {++ "thinking": {++ "type": "disabled"++ }++ }++ }++]@@@ -56,6 +56,7 @@ const LEFT_OUT = [];const excluded = (key) => {const p = key.split(' ')[1];++ if (key === 'GET /api/v1/me/ladders/{id}') return false; // read-only accounting, no credential fieldsreturn LEFT_OUT.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));};@@@ -28,7 +28,7 @@ assert.strictEqual(base.low_power, false, 'the run\'s own input is not changed')// The run cache keys on it; a recording's words are kept two weeks.const runs = fs.readFileSync(path.join(__dirname, '..', 'server', 'runs.js'), 'utf8');--assert.match(runs, /const keyInput = CACHE_INPUT\[wf\.key\] \? sha256\(stableStringify\(CACHE_INPUT\[wf\.key\]\(input\)\)\) : inputHash;/);++assert.match(runs, /const keyInput = CACHE_INPUT\[wf\.key\] \? sha256\(\{ input: CACHE_INPUT\[wf\.key\]\(input\), effortPolicy \}\) : inputHash;/);assert.match(runs, /cache\.keyFor\(\{[^}]*inputHash: keyInput \}\)/);const wf = live.workflows.find((w) => w.key === 'live.media.transcribe');assert.strictEqual(wf.cache_mode, 'private', 'still scoped to the requester');
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+# Agent effort, defaults and accounting
+
+S1 is [Contracts PR 396](https://github.com/OpenVibers/OpenVibe.Contracts/pull/396),
+version 0.381.0. This PR pins its green commit SHA because neither PR is to be merged
+or tagged by this job. Reconcile the pin to the eventual release when merging.
+
+POST `/v1/chat/completions`, `/v1/messages`, `/api/v1/chat`, `/api/v1/generate` and
+`/api/v1/runs` accept `effort: auto|low|medium|high|max`. Other direct text operations
+also accept it. Omission means auto. Auto uses the selected `agent_role` default,
+or the owner's task-default `effort`, then the workflow's `metadata.effort`, then medium. Receipts record the request and the
+portable level actually applied; unsupported controls record null. Cache and
+idempotency identities include the resolved policy, so changing effort cannot reuse
+an answer computed with another policy. Ordinary provider fallback keeps that policy.
+
+Mappings were checked on provider documentation on 2026-11-09. Budget numbers are
+OpenVibe choices rather than provider-defined effort presets:
+
+| Provider | Native request | Source |
+| --- | --- | --- |
+| OpenAI reasoning models | `reasoning_effort` (or Responses `reasoning.effort`): low/medium/high; max becomes max on GPT-6, xhigh on GPT-5.2+, high on older reasoning models | [Reasoning](https://developers.openai.com/api/docs/guides/reasoning?api-mode=chat) |
+| Current Claude | `thinking: {type: adaptive}`, `output_config.effort`; Opus/Sonnet 5.5 refuse `budget_tokens` | [Thinking](https://platform.claude.com/docs/en/build-with-claude/thinking), [effort](https://platform.claude.com/docs/en/build-with-claude/effort) |
+| Gemini 3/4 | `generationConfig.thinkingConfig.thinkingLevel`; max clamps to HIGH; Gemini 3 Pro medium clamps to HIGH | [Thinking](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking) |
+| Gemini 2.5 | `thinkingBudget`: 1024 / 4096 / 16384 / 24576 tokens (Pro max 32768) | [Thinking](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking) |
+| Qwen 3 hybrid on DashScope | low disables `enable_thinking`; others enable with `thinking_budget` 4096 / 16384 / 32768; thinking-only models omit the toggle and use 1024 for low | [Deep thinking](https://www.alibabacloud.com/help/en/model-studio/deep-thinking) |
+| DeepSeek | low disables thinking; other efforts enable it (applied medium: the portable binary-on level) | [Thinking mode](https://api-docs.deepseek.com/guides/thinking_mode/) |
+| Others | ignored, applied null | No native support asserted |
+
+Streaming uses the same request builders. Automatic OpenAI-compatible provider batches
+apply the same mappings. Role-specific work stays on the ordinary background path
+rather than entering a batch route that cannot preserve the role's model choice.
+
+## Owner defaults
+
+GET `/api/v1/agent-role-defaults` and PUT `/api/v1/agent-role-defaults/:role` require
+`ai.config.manage`. Roles are router, gate, assessor, worker, subagent, reviewer.
+PUT takes `{model: "provider/model", effort: "high"}` or
+`{choice_order: ["provider/model", "provider/other"], effort: "medium"}`.
+Defaults must specify a concrete effort. Choice order selects the first eligible model
+from that order; existing route admission, provider access, cost caps and billing still
+apply. Explicit model pins take precedence. Unavailable role models fail explicitly.
+
+Router/gate and subagent start low, assessor/worker medium, reviewer high. The worker
+starts on the seeded DeepSeek Flash route (`deepseek/deepseek-flash`) and the reviewer on Claude Sonnet 5.5. Setting a Claude
+worker without a saved reviewer switches the default reviewer to OpenAI. A saved
+reviewer may not share a provider family with any worker choice. Updates and before/after
+audit records share one transaction, serialized to prevent conflicting role updates.
+No default provisions a key or enables a provider.
+
+## Estimates and ladders
+
+GET `/api/v1/exchange/estimate?model=&effort=&kind=&input_tokens=` is public and cached
+for 30 seconds with bounded cache entries. Effort must be concrete. The result is
+`{low_usd, high_usd, n, basis}`. Fewer than 20 eligible samples returns both prices null
+and `basis.status: "no_data"`. Eligible samples are successful, non-synthetic run
+receipts from the last 30 days with current offer prices. Kind is an exact optional
+receipt label. Effort cohorts use requested effort, or applied effort for auto/legacy
+receipts. No account or receipt identities are returned.
+
+The range is the 25th–75th percentiles of observed input/output/reasoning/cache token counts
+priced at each answering route's current prices, excluding historical charges/fees.
+When input_tokens is supplied, each sample scales by its output/input and cache/input
+ratios. This is empirical usage, not a guaranteed quote or a price cap.
+
+A caller may pass `ladder_id` plus zero-based `rung`; both are required together.
+Receipts retain them as ladder_id/effort_rung, with model, effort, cost and outcome.
+GET `/api/v1/me/ladders/:id` returns `{ladder_id, cost_usd, rungs: [{rung,n,cost_usd}]}`
+only for the caller's receipts (or its token's on-behalf-of user). Reusing another
+caller's id reveals nothing. Failed attempts also get receipts; cost totals include
+recorded successful provider usage before the run failed. No automatic escalation is
+introduced, and a ladder never grants permission to spend.
+
+Catalog offers expose `cache_hit_share_7d`: cached input tokens / total input tokens
+from successful, non-synthetic provider attempts in seven days, clamped to [0,1]. No
+samples yields null. This is provider token-cache usage, distinct from whole-run reuse.
+
+Migration 0059 is expand-only, above main's 0057 and the 0058 reserved by Exchange routing PR #403.
+It adds only role policy storage and a receipt time index. Tests use fixture responses
+and temporary databases; no paid provider calls, production writes or deployment.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- Optional role policy. Existing deployments and runs retain their routes.
+CREATE TABLE IF NOT EXISTS agent_role_defaults (
+ role TEXT PRIMARY KEY,
+ body TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS ai_receipts_effort_created ON ai_receipts (created_at);
@@ -45,6 +45,10 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, aut
@@ -45,6 +45,10 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, aut
const view = async (p) => { const v = await ops.providerView(p); return v && { ...v, billing_profile: p.billing_profile, pool_key: p.pool_key }; };
const view = async (p) => { const v = await ops.providerView(p); return v && { ...v, billing_profile: p.billing_profile, pool_key: p.pool_key }; };
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
+ const agentDefaults = require('../effort/defaults').createDefaults({ db, registry });
+ r.get('/api/v1/agent-role-defaults', auth.requireCap(CAPS.configManage), wrap(async () => ({ roles: await agentDefaults.get() })));
+ r.put('/api/v1/agent-role-defaults/:role', auth.requireCap(CAPS.configManage), wrap(async (req) => ({ role: req.params.role, default: await agentDefaults.put(req.params.role, body(req), actor(req)) })));
+
r.get('/api/v1/status', usageRead, wrap(async () => await ops.status()));
r.get('/api/v1/status', usageRead, wrap(async () => await ops.status()));
// Providers
// Providers
@@ -68,6 +68,9 @@ function exchangeRouter({ db, registry, pool, quotas = null, env = process.env,
@@ -68,6 +68,9 @@ function exchangeRouter({ db, registry, pool, quotas = null, env = process.env,
const usageRead = auth.requireCap(CAPS.usageRead);
const usageRead = auth.requireCap(CAPS.usageRead);
const wrap = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };
const wrap = (fn) => async (req, res) => { try { const out = await fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
+ const effortAnalytics = require('../effort/analytics').createAnalytics({ db, catalog, registry, pool, clock });
+ r.get('/api/v1/exchange/estimate', publicCache(30), wrap(req => effortAnalytics.estimate(req.query)));
+ r.get('/api/v1/me/ladders/:id', read, wrap(req => effortAnalytics.ladder(req.params.id, req.principal)));
/** The owner a preset belongs to: the app's project, else the person the token acts for, else the service. */
/** The owner a preset belongs to: the app's project, else the person the token acts for, else the service. */
const ownerOf = (req) => {
const ownerOf = (req) => {
@@ -28,6 +28,7 @@
@@ -28,6 +28,7 @@
* with the HTTP status the run's problem had.
* with the HTTP status the run's problem had.
*/
*/
const express = require('express');
const express = require('express');
+const { controls } = require('../effort/defaults');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { appAttribution } = require('../apps');
const { appAttribution } = require('../apps');
const { credentialFor } = require('./credential');
const { credentialFor } = require('./credential');
@@ -178,6 +179,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -178,6 +179,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
}
}
if (!Array.isArray(b.messages) || !b.messages.length) return anthropicError(res, 400, 'messages: must be a non-empty array');
if (!Array.isArray(b.messages) || !b.messages.length) return anthropicError(res, 400, 'messages: must be a non-empty array');
if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');
if (!Number.isInteger(b.max_tokens) || b.max_tokens < 1) return anthropicError(res, 400, 'max_tokens: must be a positive integer');
+ if (/^claude-(opus|sonnet)-5-5/.test(String(b.model || '').split('/').pop()) && b.thinking && Object.hasOwn(b.thinking, 'budget_tokens')) return anthropicError(res, 400, 'Opus/Sonnet 5.5 require adaptive thinking; budget_tokens is refused');
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension).
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension).
@@ -224,7 +226,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -224,7 +226,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
delete input.model;
delete input.model;
}
}
} catch (err) { return anthropicError(res, err.status || 400, err.message, null, err.code); }
} catch (err) { return anthropicError(res, err.status || 400, err.message, null, err.code); }
- let body = { workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };
+ let body = { ...controls(b), workflow: 'ai.messages', input, idempotency_key: req.get('idempotency-key') || undefined };
// The preset's rules ride the run into the router.
// The preset's rules ride the run into the router.
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
@@ -42,6 +42,7 @@
@@ -42,6 +42,7 @@
* shape ({ error: { message, type, code } }) with the HTTP status the run's problem had.
* shape ({ error: { message, type, code } }) with the HTTP status the run's problem had.
*/
*/
const express = require('express');
const express = require('express');
+const { controls } = require('../effort/defaults');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { appAttribution } = require('../apps');
const { appAttribution } = require('../apps');
const { credentialFor } = require('./credential');
const { credentialFor } = require('./credential');
@@ -210,7 +211,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -210,7 +211,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
// AI Exchange F: the caller's liability cap for the Balance hold (an OpenVibe extension, like x_openvibe).
if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
if (b.max_cost_usd != null) input.max_cost_usd = b.max_cost_usd;
- let body = { workflow: 'ai.complete', input, idempotency_key: req.get('idempotency-key') || undefined };
+ let body = { ...controls(b), workflow: 'ai.complete', input, idempotency_key: req.get('idempotency-key') || undefined };
// The preset's rules ride the run into the router.
// The preset's rules ride the run into the router.
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
if (preset) body = { ...body, preset: { name: preset.name, scope: preset.scope, policy: preset.policy } };
if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
if (preset && preset.policy.adult && b.policy == null) body = { ...body, policy: 'uncensored' };
@@ -184,9 +184,9 @@ function runsRouter({ choices = null, runs, registry, auth, config, log = consol
@@ -184,9 +184,9 @@ function runsRouter({ choices = null, runs, registry, auth, config, log = consol
r.post(`/api/v1/${op}`, DIRECT_OPS.includes(`ai.${op}`) ? createApp : create, async (req, res) => {
r.post(`/api/v1/${op}`, DIRECT_OPS.includes(`ai.${op}`) ? createApp : create, async (req, res) => {
try {
try {
const body = req.body || {};
const body = req.body || {};
- const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route, ...input } = body;
+ const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route, effort, agent_role, ladder_id, rung, kind, ...input } = body;
const wait = req.query.wait != null ? Math.max(0, Math.min(Number(req.query.wait) || 0, config.runs.maxWaitMs)) : config.runs.maxWaitMs;
const wait = req.query.wait != null ? Math.max(0, Math.min(Number(req.query.wait) || 0, config.runs.maxWaitMs)) : config.runs.maxWaitMs;
- await createAndRespond(req, res, { workflow: `ai.${op}`, input, idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route }, wait);
+ await createAndRespond(req, res, { workflow: `ai.${op}`, input, idempotency_key: idem, target, attribution, on_behalf_of: obo, options, route, effort, agent_role, ladder_id, rung, kind }, wait);
} catch (err) { sendError(res, err, req.ov, log); }
} catch (err) { sendError(res, err, req.ov, log); }
});
});
}
}
@@ -232,7 +232,10 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
@@ -232,7 +232,10 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
const charge = capacity.chargeOf(cards.row);
const charge = capacity.chargeOf(cards.row);
if (!charge || !capacity.within(cards.batch.nano_usd_per_m, charge)) continue;
if (!charge || !capacity.within(cards.batch.nano_usd_per_m, charge)) continue;
let body;
let body;
- try { body = prepared.bodyFor(offer.model); } catch { continue; }
+ try {
+ body = prepared.bodyFor(offer.model);
+ if (prepared.effort) require('../effort/providers').apply(body, { model: offer.model, effort: prepared.effort }, provider);
+ } catch { continue; }
if (!body || !Array.isArray(body.messages) || !body.messages.length) continue;
if (!body || !Array.isArray(body.messages) || !body.messages.length) continue;
body.model = offer.model;
body.model = offer.model;
// BA2's probe: a real hold, released at once. A payer who cannot cover the run is refused now,
// BA2's probe: a real hold, released at once. A payer who cannot cover the run is refused now,
@@ -64,11 +64,13 @@ function choice(value) {
@@ -64,11 +64,13 @@ function choice(value) {
function defaultValue(value) {
function defaultValue(value) {
const keys = value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
const keys = value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
if (!value || typeof value !== 'object' || Array.isArray(value)
if (!value || typeof value !== 'object' || Array.isArray(value)
- || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd'].includes(k))
+ || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd', 'effort'].includes(k))
|| (value.fallbacks != null && !Array.isArray(value.fallbacks))) {
|| (value.fallbacks != null && !Array.isArray(value.fallbacks))) {
throw new AiError(422, 'defaults.invalid', 'invalid task defaults');
throw new AiError(422, 'defaults.invalid', 'invalid task defaults');
}
}
+ if (value.effort !== undefined && !['low', 'medium', 'high', 'max'].includes(value.effort)) throw new AiError(422, 'defaults.invalid', 'task default effort must be low|medium|high|max');
const out = {
const out = {
+ ...(value.effort !== undefined ? { effort: value.effort } : {}),
signed_out: choice(value.signed_out),
signed_out: choice(value.signed_out),
signed_in: choice(value.signed_in),
signed_in: choice(value.signed_in),
fallbacks: (value.fallbacks || []).map(choice),
fallbacks: (value.fallbacks || []).map(choice),
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+const { AiError } = require('../util');
+const { checkEffort } = require('./providers');
+const round = v => v == null ? null : Math.round(Number(v) * 1e8) / 1e8;
+function createAnalytics({ db, catalog, registry = null, pool = null, clock = { now: () => Date.now() } }) {
+ const cache = new Map();
+ async function estimate(query) {
+ const model = query.model;
+ if (typeof model !== 'string' || !model || model.length > 256) throw new AiError(422, 'input.invalid', 'model is required');
+ const effort = checkEffort(query.effort || 'medium');
+ if (effort === 'auto') throw new AiError(422, 'input.invalid', 'estimate requires a resolved effort');
+ const kind = query.kind === undefined ? null : query.kind;
+ if (kind !== null && (typeof kind !== 'string' || !/^[a-z][a-z0-9_.-]{0,99}$/.test(kind))) throw new AiError(422, 'input.invalid', 'invalid kind');
+ const input = query.input_tokens === undefined ? null : Number(query.input_tokens);
+ if (input !== null && (!Number.isSafeInteger(input) || input < 1 || input > 10000000)) throw new AiError(422, 'input.invalid', 'input_tokens must be an integer from 1 to 10000000');
+ const key = JSON.stringify([model, effort, kind, input]);
+ const hit = cache.get(key);
+ if (hit && hit.until > clock.now()) return hit.value;
+ const data = await catalog.models({ days: 7 });
+ const prices = [];
+ for (const m of data.models || []) for (const o of m.offers || []) {
+ if (![m.slug, m.canonical, o.model, `${o.provider}/${o.model}`].includes(model) || !o.price) continue;
+ prices.push({ provider: o.provider, model: o.model, pin: o.price.in, pout: o.price.out, pcached: o.price.cached == null ? o.price.in : o.price.cached });
+ }
+ // Catalog provider names are public upstream names (the internal shared route
+ // may display as openai). Receipts keep route keys; price those exact routes.
+ if (registry && pool && prices.length) {
+ const publicPrices = prices.splice(0);
+ const providers = new Map((await registry.listProviders()).map(p => [p.key, p]));
+ for (const row of await registry.listModels()) {
+ const provider = providers.get(row.provider_key);
+ if (!provider) continue;
+ const shown = require('../providers/upstream').upstreamOf(provider).key;
+ if (!publicPrices.some(p => p.provider === shown && p.model === row.model_key)) continue;
+ const cards = await pool.rateCardsFor(row.provider_key, row.model_key);
+ if (!cards) continue;
+ const p = require('../providers/rate-cards').pricesOf(cards);
+ prices.push({ provider: row.provider_key, model: row.model_key, pin: p.in, pout: p.out, pcached: p.cached == null ? p.in : p.cached });
+ }
+ }
+ const basis = { window_days: 30, percentile: [25, 75], model, effort, kind, input_tokens: input, pricing: 'current offer prices; output/input and cache/input ratios when input_tokens supplied' };
+ const row = prices.length ? await db.prepare(`WITH samples AS (
+ SELECT a.body::jsonb AS b FROM ai_receipts a JOIN runs r ON r.id = a.run_id
+ WHERE a.created_at >= ? AND r.status = 'succeeded' AND r.synthetic = 0
+ AND COALESCE(NULLIF(a.body::jsonb->>'effort_requested', 'auto'), a.body::jsonb->>'effort_applied') = ?
+ AND (?::text IS NULL OR a.body::jsonb->>'kind' = ?)
+ ), tokens AS (
+ SELECT b, (b->'usage'->>'tokens_in')::numeric ti,
+ ((b->'usage'->>'tokens_out')::numeric + COALESCE((b->'usage'->>'tokens_reasoning')::numeric, 0)) tout,
+ COALESCE((b->'usage'->>'tokens_cached')::numeric, 0) tc
+ FROM samples
+ ), costs AS (
+ SELECT ((GREATEST(ti - LEAST(tc, ti), 0) * p.pin + LEAST(tc, ti) * p.pcached + tout * p.pout)
+ * CASE WHEN ?::numeric IS NULL THEN 1 ELSE ?::numeric / NULLIF(ti, 0) END) / 1000000 AS cost
+ FROM tokens JOIN jsonb_to_recordset(?::jsonb) AS p(provider text, model text, pin numeric, pout numeric, pcached numeric)
+ ON b->'offer'->>'provider' = p.provider AND b->'offer'->>'model' = p.model
+ WHERE ti > 0 AND tout >= 0
+ ) SELECT COUNT(cost)::int n, percentile_cont(0.25) WITHIN GROUP (ORDER BY cost) low,
+ percentile_cont(0.75) WITHIN GROUP (ORDER BY cost) high FROM costs`).get(new Date(clock.now() - 30 * 86400000).toISOString(), effort, kind, kind, input, input, JSON.stringify(prices)) : { n: 0 };
+ const n = Number(row.n);
+ const value = { low_usd: n < 20 ? null : round(row.low), high_usd: n < 20 ? null : round(row.high), n, basis: { ...basis, status: n < 20 ? 'no_data' : 'receipts' } };
+ if (cache.size >= 128) cache.delete(cache.keys().next().value);
+ cache.set(key, { until: clock.now() + 30000, value });
+ return value;
+ }
+ async function ladder(id, principal) {
+ if (!/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new AiError(422, 'input.invalid', 'invalid ladder id');
+ const subject = principal.subject || {};
+ const ob = principal.onBehalfOf || principal.claims && principal.claims.on_behalf_of;
+ const owner = typeof ob === 'object' ? ob && ob.id : ob;
+ const rows = await db.prepare(`SELECT (body::jsonb->>'effort_rung')::int rung,
+ COUNT(*)::int n, SUM(COALESCE((body::jsonb->>'cost_usd')::numeric, 0)) cost_usd
+ FROM ai_receipts WHERE body::jsonb->>'ladder_id' = ?
+ AND ((requester_type = ? AND requester_id = ?) OR (owner_type = 'user' AND owner_id = ?))
+ GROUP BY (body::jsonb->>'effort_rung')::int ORDER BY rung`).all(id, subject.type || '', subject.id || '', owner || (subject.type === 'user' ? subject.id : ''));
+ if (!rows.length) throw new AiError(404, 'ladder.not_found', 'no such ladder');
+ const rungs = rows.map(r => ({ rung: Number(r.rung), n: Number(r.n), cost_usd: round(r.cost_usd) }));
+ return { ladder_id: id, cost_usd: round(rungs.reduce((n, r) => n + r.cost_usd, 0)), rungs };
+ }
+ return { estimate, ladder };
+}
+async function cacheShares(db, clock) {
+ const rows = await db.prepare(`SELECT q.provider_key, q.model_key, SUM(q.tokens_in)::numeric input,
+ SUM(q.tokens_cached)::numeric cached FROM requests q JOIN runs r ON r.id = q.run_id
+ WHERE q.created_at >= ? AND q.status = 'ok' AND r.synthetic = 0
+ GROUP BY q.provider_key, q.model_key`).all(new Date(clock.now() - 7 * 86400000).toISOString());
+ return new Map(rows.map(r => [`${r.provider_key}/${r.model_key}`, Number(r.input) > 0 ? Math.min(1, Math.max(0, Number(r.cached) / Number(r.input))) : null]));
+}
+module.exports = { createAnalytics, cacheShares };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+const { AiError, parseJson } = require('../util');
+const { checkEffort } = require('./providers');
+const ROLES = ['router', 'gate', 'assessor', 'worker', 'subagent', 'reviewer'];
+const BASE = {
+ router: { model: 'deepseek/deepseek-flash', effort: 'low' },
+ gate: { model: 'deepseek/deepseek-flash', effort: 'low' },
+ assessor: { model: 'deepseek/deepseek-flash', effort: 'medium' },
+ worker: { model: 'deepseek/deepseek-flash', effort: 'medium' },
+ subagent: { model: 'deepseek/deepseek-flash', effort: 'low' },
+ reviewer: { model: 'anthropic/claude-sonnet-5-5', effort: 'high' },
+};
+const modelsOf = (v) => v.model ? [v.model] : v.choice_order;
+function family(model) {
+ const m = model.toLowerCase();
+ for (const [pattern, name] of [[/claude/, 'anthropic'], [/deepseek/, 'deepseek'], [/gpt-|\/o[134]/, 'openai'], [/gemini/, 'google'], [/qwen/, 'qwen']]) if (pattern.test(m)) return name;
+ return m.split('/')[0];
+}
+function validate(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !['model', 'choice_order', 'effort'].includes(k))) throw new AiError(422, 'input.invalid', 'role default needs model or choice_order, and effort');
+ checkEffort(value.effort);
+ if (value.effort === 'auto') throw new AiError(422, 'input.invalid', 'a role default must resolve auto to a concrete effort');
+ if (Boolean(value.model) === Boolean(value.choice_order)) throw new AiError(422, 'input.invalid', 'set exactly one of model or choice_order');
+ const models = modelsOf(value);
+ if (!Array.isArray(models) || !models.length || models.length > 8 || new Set(models).size !== models.length || models.some(m => typeof m !== 'string' || !/^[\w.-]+\/[\w./:-]{1,200}$/.test(m))) throw new AiError(422, 'input.invalid', 'models must be distinct provider/model ids (one to eight)');
+ return JSON.parse(JSON.stringify(value));
+}
+function createDefaults({ db, registry, clock = { now: () => Date.now() } }) {
+ async function get() {
+ const result = JSON.parse(JSON.stringify(BASE));
+ const rows = await db.prepare('SELECT role, body FROM agent_role_defaults').all();
+ for (const row of rows) if (ROLES.includes(row.role)) result[row.role] = parseJson(row.body, result[row.role]);
+ // A worker changed alone still gets a different reviewer family by default.
+ if (!rows.some(r => r.role === 'reviewer') && modelsOf(result.worker).some(m => family(m) === 'anthropic')) result.reviewer = { model: 'openai/gpt-5.5', effort: 'high' };
+ return result;
+ }
+ async function put(role, value, actor) {
+ if (!ROLES.includes(role)) throw new AiError(422, 'input.invalid', 'unknown agent role');
+ const checked = validate(value);
+ return db.tx(async () => {
+ // Serialize updates to the two roles even when their rows do not exist yet.
+ await db.prepare('SELECT pg_advisory_xact_lock(570157)').get();
+ const before = await get();
+ const after = { ...before, [role]: checked };
+ if (['worker', 'reviewer'].includes(role)) {
+ const reviewerSaved = await db.prepare("SELECT role FROM agent_role_defaults WHERE role = 'reviewer'").get();
+ if (role === 'worker' && !reviewerSaved) after.reviewer = modelsOf(checked).some(m => family(m) === 'anthropic') ? { model: 'openai/gpt-5.5', effort: 'high' } : BASE.reviewer;
+ const workers = new Set(modelsOf(after.worker).map(family));
+ if (modelsOf(after.reviewer).some(m => workers.has(family(m)))) throw new AiError(422, 'input.invalid', 'reviewer must use a different provider family than worker');
+ }
+ await db.prepare('INSERT INTO agent_role_defaults (role, body, updated_at) VALUES (?, ?, ?) ON CONFLICT(role) DO UPDATE SET body = excluded.body, updated_at = excluded.updated_at').run(role, JSON.stringify(checked), new Date(clock.now()).toISOString());
+ await registry.audit(actor.actor, 'agent.role-default.update', 'agent-role', role, { trace: actor.trace, metadata: { before: before[role], after: checked } });
+ return checked;
+ });
+ }
+ return { get, put };
+}
+function controls(body) {
+ const out = {};
+ for (const k of ['effort', 'agent_role', 'ladder_id', 'rung', 'kind']) if (body[k] !== undefined) out[k] = body[k];
+ return out;
+}
+async function resolve(body, workflow, defaults) {
+ const asked = checkEffort(body.effort === undefined ? 'auto' : body.effort);
+ if (body.agent_role !== undefined && !ROLES.includes(body.agent_role)) throw new AiError(422, 'input.invalid', 'unknown agent_role');
+ if ((body.ladder_id === undefined) !== (body.rung === undefined)) throw new AiError(422, 'input.invalid', 'ladder_id and rung must be supplied together');
+ if (body.ladder_id !== undefined && (typeof body.ladder_id !== 'string' || !/^[A-Za-z0-9._:-]{1,128}$/.test(body.ladder_id) || !Number.isSafeInteger(body.rung) || body.rung < 0)) throw new AiError(422, 'input.invalid', 'invalid ladder_id or rung');
+ if (body.kind !== undefined && (typeof body.kind !== 'string' || !/^[a-z][a-z0-9_.-]{0,99}$/.test(body.kind))) throw new AiError(422, 'input.invalid', 'invalid kind');
+ const policy = body.agent_role ? (await defaults.get())[body.agent_role] : null;
+ const taskEffort = policy ? policy.effort : workflow.metadata && workflow.metadata.effort || 'medium';
+ const resolved = asked === 'auto' ? checkEffort(taskEffort) : asked;
+ if (resolved === 'auto') throw new AiError(422, 'input.invalid', 'task default effort cannot itself be auto');
+ return { effort_requested: asked, effort_resolved: resolved, ...(policy ? { agent_role: body.agent_role, role_default: policy } : {}), ...(body.ladder_id !== undefined ? { ladder_id: body.ladder_id, effort_rung: body.rung } : {}), ...(body.kind ? { kind: body.kind } : {}) };
+}
+module.exports = { createDefaults, controls, resolve, ROLES, family, modelsOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+const { AiError } = require('../util');
+const { modelsOf } = require('./defaults');
+// A local wrapper leaves the shared provider router's admission, caps, billing and
+// fallback rules in charge. Explicit caller model pins take precedence over defaults.
+function wrapPool(pool) {
+ if (!pool) return pool;
+ return Object.assign(Object.create(pool), {
+ async execute(route, operation, request, ctx = {}) {
+ if (!['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages'].includes(operation)) return pool.execute(route, operation, request, ctx);
+ const policy = ctx.effortPolicy;
+ let runCtx = ctx;
+ if (policy && policy.role_default && !ctx.exact && !ctx.routePin && ctx.taskChoice?.why !== 'run') {
+ const offered = await pool.offers(route, operation, ctx);
+ const choices = modelsOf(policy.role_default);
+ const selected = choices.map(id => offered.find(o => `${o.provider}/${o.model}` === id)).filter(Boolean);
+ const chosen = selected[0];
+ if (!chosen) throw new AiError(503, 'role.model_unavailable', 'no model from this role default is offered by the route');
+ runCtx = { ...ctx, taskChoice: null, policy: 'exact', exact: { id: `${chosen.provider}/${chosen.model}`, offers: selected.map(o => ({ provider: o.provider, model: o.model })) } };
+ }
+ const result = await pool.execute(route, operation, { ...request, ...(policy ? { effort: policy.effort_resolved } : {}) }, runCtx);
+ return { ...result, explain: { ...(result.explain || {}), effort_applied: result.result && result.result.effortApplied || null } };
+ },
+ });
+}
+module.exports = { wrapPool };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+const { AiError } = require('../util');
+const LEVELS = ['auto', 'low', 'medium', 'high', 'max'];
+const TEXT = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'complete', 'messages'];
+function checkEffort(value) {
+ if (!LEVELS.includes(value)) throw new AiError(422, 'input.invalid', `effort must be ${LEVELS.join('|')}`);
+ return value;
+}
+// Verified against the providers' own documentation, 2026-11-09. Token budgets below
+// are OpenVibe policy, not provider presets. Unknown models/providers receive no control.
+function control({ kind, key = '', model = '', base_url = '' }, effort) {
+ if (effort == null) return { applied: null, fields: {} };
+ checkEffort(effort);
+ if (effort === 'auto') throw new AiError(422, 'input.invalid', 'resolve auto against task defaults before calling a provider');
+ const m = String(model).replace(/^models\//, '').toLowerCase();
+ const source = `${key} ${base_url}`.toLowerCase();
+ // https://developers.openai.com/api/docs/guides/reasoning?api-mode=chat
+ if (['openai', 'responses'].includes(kind) && /openai|api\.openai\.com/.test(source) && /^(gpt-[56]|o[134])/.test(m) && !/chat|non-reasoning/.test(m)) {
+ const native = effort !== 'max' ? effort : /^gpt-6/.test(m) ? 'max' : /^gpt-5\.[2-9]/.test(m) ? 'xhigh' : 'high';
+ return { applied: effort === 'max' && native === 'high' ? 'high' : effort, fields: kind === 'responses' ? { reasoning: { effort: native } } : { reasoning_effort: native } };
+ }
+ // https://platform.claude.com/docs/en/build-with-claude/thinking
+ // https://platform.claude.com/docs/en/build-with-claude/effort
+ if (kind === 'anthropic' && /^claude-(opus|sonnet|haiku|fable|mythos)-(5|4-[6-9])\b/.test(m)) {
+ return { applied: effort, fields: { thinking: { type: 'adaptive' }, output_config: { effort } } };
+ }
+ // https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking
+ if (kind === 'gemini' && /^gemini-[234]/.test(m)) {
+ if (/^gemini-[34]/.test(m)) {
+ const level = effort === 'max' ? 'high' : effort === 'medium' && /^gemini-3-pro/.test(m) ? 'high' : effort;
+ return { applied: level, fields: { generationConfig: { thinkingConfig: { thinkingLevel: level.toUpperCase() } } } };
+ }
+ if (/^gemini-2\.5/.test(m)) return { applied: effort, fields: { generationConfig: { thinkingConfig: { thinkingBudget: { low: 1024, medium: 4096, high: 16384, max: /pro/.test(m) ? 32768 : 24576 }[effort] } } } };
+ }
+ // https://www.alibabacloud.com/help/en/model-studio/deep-thinking
+ if (kind === 'openai' && /qwen|dashscope/.test(source) && /^qwen3/.test(m) && !/omni|coder|instruct/.test(m)) {
+ const only = /thinking|2\.4t/.test(m);
+ return { applied: effort, fields: { ...(!only ? { enable_thinking: effort !== 'low' } : {}), ...(only || effort !== 'low' ? { thinking_budget: { low: 1024, medium: 4096, high: 16384, max: 32768 }[effort] } : {}) } };
+ }
+ // https://api-docs.deepseek.com/guides/thinking_mode/
+ if (['openai', 'anthropic'].includes(kind) && /deepseek/.test(source) && /^deepseek-(v[34]|chat|reasoner|flash|pro)/.test(m)) {
+ return { applied: effort === 'low' ? 'low' : 'medium', fields: { thinking: { type: effort === 'low' ? 'disabled' : 'enabled' } } };
+ }
+ return { applied: null, fields: {} };
+}
+function apply(body, req, fallback) {
+ const c = req._effortControl || control({ ...fallback, model: req.model }, req.effort);
+ if (/^claude-(opus|sonnet)-5-5/.test(req.model || '') && req.thinking && Object.hasOwn(req.thinking, 'budget_tokens')) {
+ throw new AiError(422, 'input.invalid', 'Opus/Sonnet 5.5 require adaptive thinking; budget_tokens is refused');
+ }
+ for (const [key, value] of Object.entries(c.fields)) body[key] = typeof value === 'object' ? { ...(body[key] || {}), ...value } : value;
+ return body;
+}
+function wrapAdapter(adapter, record) {
+ for (const op of TEXT) if (typeof adapter[op] === 'function') {
+ const call = adapter[op];
+ adapter[op] = async (req) => {
+ const c = control({ ...record, kind: adapter.kind, model: req.model }, req.effort);
+ const result = await call({ ...req, _effortControl: c });
+ return { ...result, effortApplied: c.applied };
+ };
+ }
+ return adapter;
+}
+module.exports = { LEVELS, checkEffort, control, apply, wrapAdapter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+const { parseJson } = require('../util');
+function receiptEffort(row, explain) {
+ const policy = parseJson(row.options, {}).effort_policy;
+ if (!policy) return {};
+ const applied = explain && explain.effort_applied || null;
+ return {
+ effort_requested: policy.effort_requested,
+ effort_applied: applied,
+ ...(policy.ladder_id ? {
+ ladder_id: policy.ladder_id, effort_rung: policy.effort_rung,
+ ladder: row.model_key ? [{ model: row.model_key, effort: applied || policy.effort_resolved, cost_usd: Number(row.cost_usd) || 0, outcome: row.status || 'succeeded' }] : [],
+ } : {}),
+ };
+}
+module.exports = { receiptEffort };
@@ -266,6 +266,13 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -266,6 +266,13 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// else the answering attempt's total latency.
// else the answering attempt's total latency.
const measured = await seen.read({ days });
const measured = await seen.read({ days });
const measuredStats = observed.fold(measured, drafts);
const measuredStats = observed.fold(measured, drafts);
+ const shares = await require('../effort/analytics').cacheShares(db, clock);
+ for (const d of drafts) {
+ const stats = measuredStats.get(d);
+ if (stats && stats.succeeded + stats.failed >= 20) {
+ d.offer.cache_hit_share_7d = shares.get(`${d.providerKey}/${d.modelKey}`) ?? d.offer.cache_hit_share_7d;
+ }
+ }
// The router's own measurements win once they have 20 samples; until then the observed runs' numbers stay.
// The router's own measurements win once they have 20 samples; until then the observed runs' numbers stay.
const performance = await db.prepare('SELECT * FROM provider_metrics').all();
const performance = await db.prepare('SELECT * FROM provider_metrics').all();
for (const d of drafts) {
for (const d of drafts) {
@@ -79,6 +79,8 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =
@@ -79,6 +79,8 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =
const paidBy = (payer && payer.paid_by) || 'openvibe_key';
const paidBy = (payer && payer.paid_by) || 'openvibe_key';
const byok = paidBy === 'own_key' || paidBy === 'own_machine';
const byok = paidBy === 'own_key' || paidBy === 'own_machine';
return {
return {
+ ...require('../effort/receipt').receiptEffort(run, explain),
+ ...(parseJson(run.options, {}).effort_policy?.kind ? { kind: parseJson(run.options, {}).effort_policy.kind } : {}),
id: receiptId(run.id),
id: receiptId(run.id),
run_id: run.id,
run_id: run.id,
created_at: createdAt || new Date().toISOString(),
created_at: createdAt || new Date().toISOString(),
@@ -59,13 +59,18 @@ const SERVICE_OR_APP = [{ bearer: [] }, { app: [] }];
@@ -59,13 +59,18 @@ const SERVICE_OR_APP = [{ bearer: [] }, { app: [] }];
const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });
const json = (description, schema) => ({ description, ...(schema ? { content: { 'application/json': { schema } } } : {}) });
const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
+const effortFields = {
+ effort: str({ enum: ['auto', 'low', 'medium', 'high', 'max'] }),
+ agent_role: str({ enum: ['router', 'gate', 'assessor', 'worker', 'subagent', 'reviewer'] }),
+ ladder_id: str({ maxLength: 128 }), rung: integer({ minimum: 0 }), kind: str({ maxLength: 100 }),
+};
const runBody = (required = []) => ({
const runBody = (required = []) => ({
type: 'object', ...(required.length ? { required } : {}),
type: 'object', ...(required.length ? { required } : {}),
properties: {
properties: {
workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),
workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),
task: str({ description: 'A registered task (GET /api/v1/tasks); its workflow runs with the task’s resolved choice. Required instead of workflow when no workflow is named.' }),
task: str({ description: 'A registered task (GET /api/v1/tasks); its workflow runs with the task’s resolved choice. Required instead of workflow when no workflow is named.' }),
choice: ref('Choice'),
choice: ref('Choice'),
- input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
+ ...effortFields, input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),
target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),
options: { type: 'object', description: 'cache, debug, and the latency class: latency (interactive | background | batch); a batch-class run also takes deadline_hours (1-72, the default is 24)' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },
options: { type: 'object', description: 'cache, debug, and the latency class: latency (interactive | background | batch); a batch-class run also takes deadline_hours (1-72, the default is 24)' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },
},
},
@@ -73,6 +78,7 @@ const runBody = (required = []) => ({
@@ -73,6 +78,7 @@ const runBody = (required = []) => ({
const directBody = {
const directBody = {
type: 'object',
type: 'object',
properties: {
properties: {
+ ...effortFields,
idempotency_key: str({ minLength: 8, maxLength: 128 }), target: { type: 'object' }, attribution: { type: 'object' },
idempotency_key: str({ minLength: 8, maxLength: 128 }), target: { type: 'object' }, attribution: { type: 'object' },
on_behalf_of: ref('SubjectRef'), options: { type: 'object' },
on_behalf_of: ref('SubjectRef'), options: { type: 'object' },
},
},
@@ -466,6 +472,7 @@ const exchangeModel = {
@@ -466,6 +472,7 @@ const exchangeModel = {
capabilities: { type: 'object', properties: { tools: { type: 'boolean' }, json: { type: 'boolean' }, vision: { type: 'boolean' }, stream: { type: 'boolean' } } },
capabilities: { type: 'object', properties: { tools: { type: 'boolean' }, json: { type: 'boolean' }, vision: { type: 'boolean' }, stream: { type: 'boolean' } } },
modalities: arrayOf(str()), description: nullable('string'),
modalities: arrayOf(str()), description: nullable('string'),
requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),
requests_7d: integer({ minimum: 0 }), requests_today: integer({ minimum: 0 }),
+ cache_hit_share_7d: nullable('number', { minimum: 0, maximum: 1 }),
cached_7d: integer({ minimum: 0 }), cached_today: integer({ minimum: 0 }), community: { type: 'boolean' },
cached_7d: integer({ minimum: 0 }), cached_today: integer({ minimum: 0 }), community: { type: 'boolean' },
cache_hit_share_7d: nullable('number', { minimum: 0, maximum: 1, description: 'The prompt-cache share over this model\'s offers in the window (cached prompt tokens / reported prompt tokens). Null when no offer has enough attempts to measure one.' }),
cache_hit_share_7d: nullable('number', { minimum: 0, maximum: 1, description: 'The prompt-cache share over this model\'s offers in the window (cached prompt tokens / reported prompt tokens). Null when no offer has enough attempts to measure one.' }),
offers: arrayOf(exchangeOffer),
offers: arrayOf(exchangeOffer),
@@ -498,6 +505,25 @@ route('get', '/exchange/market', {
@@ -498,6 +505,25 @@ route('get', '/exchange/market', {
},
},
}), ...errors([422]) },
}), ...errors([422]) },
});
});
+route('get', '/exchange/estimate', {
+ operationId: 'exchangeEffortEstimate', summary: 'A current-price p25–p75 cost estimate from 30 days of receipts', tags: ['exchange'], security: [],
+ parameters: [query('model', str()), query('effort', effortFields.effort), query('kind', str()), query('input_tokens', integer({ minimum: 1 }))],
+ responses: { 200: json('Under twenty observations: null prices with n and basis.status no_data', { type: 'object', required: ['low_usd', 'high_usd', 'n', 'basis'], properties: { low_usd: nullable('number'), high_usd: nullable('number'), n: integer(), basis: { type: 'object' } } }), ...errors([422]) },
+});
+route('get', '/me/ladders/{id}', {
+ operationId: 'myEffortLadder', summary: 'Your own ladder costs by rung', tags: ['exchange'], security: SERVICE,
+ parameters: [path('id', str())], responses: { 200: json('Total cost and rung totals', { type: 'object' }), ...errors([401, 403, 404]) },
+});
+route('get', '/agent-role-defaults', {
+ operationId: 'agentRoleDefaults', summary: 'Owner role model and effort defaults', tags: ['admin'], security: SERVICE,
+ description: 'ai.config.manage. Reviewer uses a different model provider family than worker.', responses: { 200: json('Role defaults', { type: 'object' }), ...errors([401, 403]) },
+});
+route('put', '/agent-role-defaults/{role}', {
+ operationId: 'putAgentRoleDefault', summary: 'Update and audit a role default', tags: ['admin'], security: SERVICE,
+ description: 'ai.config.manage. Exactly one of model or choice_order; concrete effort.', parameters: [path('role', effortFields.agent_role)],
+ requestBody: body({ type: 'object', properties: { model: str(), choice_order: arrayOf(str()), effort: effortFields.effort } }),
+ responses: { 200: json('Saved default', { type: 'object' }), ...errors([401, 403, 422]) },
+});
route('get', '/exchange/catalog', {
route('get', '/exchange/catalog', {
operationId: 'exchangeCatalog', summary: 'Every model and every offer that serves it', tags: ['exchange'], security: [],
operationId: 'exchangeCatalog', summary: 'Every model and every offer that serves it', tags: ['exchange'], security: [],
description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. Speed and uptime are read from the last seven days of real runs (time to the first token where a receipt recorded one, else the answering attempt\'s total); cached answers are counted apart and excluded from speed, uptime and requests. Uptime is succeeded / (succeeded + failed); null when no data, never 0. A prepaid offer has no price (free: true); free_tier says whether the free allowance and the anonymous Try it may use an offer at all (a named AI_FREE_PROVIDERS source under the output price ceiling). Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',
description: 'Public. The live offers: price in, out and cached per million tokens, measured p50/p95, uptime, requests, and the provider data policy. Speed and uptime are read from the last seven days of real runs (time to the first token where a receipt recorded one, else the answering attempt\'s total); cached answers are counted apart and excluded from speed, uptime and requests. Uptime is succeeded / (succeeded + failed); null when no data, never 0. A prepaid offer has no price (free: true); free_tier says whether the free allowance and the anonymous Try it may use an offer at all (a named AI_FREE_PROVIDERS source under the output price ceiling). Models carry a kind (chat, embedding, stt, image); chat lists must use kind "chat" and never mix embeddings or speech-to-text.',
@@ -853,6 +879,7 @@ function openapi() {
@@ -853,6 +879,7 @@ function openapi() {
source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),
source_service: nullable('string'), target: nullable('object'), input: nullable('object'), output: nullable('object'),
error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },
error: nullable('object', { properties: { code: str(), detail: str() } }), synthetic: { type: 'boolean' },
provenance: { type: 'object' },
provenance: { type: 'object' },
+ effort_requested: effortFields.effort, effort_applied: nullable('string'), effort_rung: integer({ minimum: 0 }), ladder_id: str(), ladder: arrayOf({ type: 'object' }),
latency_class: { type: 'string', enum: ['interactive', 'background', 'batch'] },
latency_class: { type: 'string', enum: ['interactive', 'background', 'batch'] },
service_tier: nullable('string', { description: 'the tier the answer really ran on: standard, flex, batch, offpeak or local' }),
service_tier: nullable('string', { description: 'the tier the answer really ran on: standard, flex, batch, offpeak or local' }),
latency_clamped: nullable('object', { description: '{ asked, used }: the class the caller asked for and the class the run really took' }),
latency_clamped: nullable('object', { description: '{ asked, used }: the class the caller asked for and the class the run really took' }),
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
const { postJson, ProviderError, deadline, describeAbort, schemaInstruction } = require('./common');
const { postJson, ProviderError, deadline, describeAbort, schemaInstruction } = require('./common');
const { sseFrames } = require('./stream');
const { sseFrames } = require('./stream');
const usageParser = require('./usage');
const usageParser = require('./usage');
+const effortPolicy = require('../effort/providers');
const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1';
const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1';
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
@@ -30,7 +31,8 @@ const isCurrentClaude = (model) => CURRENT_CLAUDE.test(String(model || ''));
@@ -30,7 +31,8 @@ const isCurrentClaude = (model) => CURRENT_CLAUDE.test(String(model || ''));
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
/** jsonMode: 'format' (structured outputs), 'tool' (a forced tool, older models) or 'prose' (the schema in the system prompt). */
/** jsonMode: 'format' (structured outputs), 'tool' (a forced tool, older models) or 'prose' (the schema in the system prompt). */
-function buildBody({ model, system, messages, image, json, maxTokens, temperature, effort }, jsonMode) {
+function buildBody(req, jsonMode) {
+ const { model, system, messages, image, json, maxTokens, temperature, effort } = req;
const current = isCurrentClaude(model);
const current = isCurrentClaude(model);
const mode = json ? (jsonMode || (current ? 'format' : 'tool')) : null;
const mode = json ? (jsonMode || (current ? 'format' : 'tool')) : null;
const body = { model, max_tokens: Math.max(1, maxTokens) };
const body = { model, max_tokens: Math.max(1, maxTokens) };
@@ -56,7 +58,7 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
@@ -56,7 +58,7 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];
body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];
body.tool_choice = { type: 'tool', name: json.name || 'result' };
body.tool_choice = { type: 'tool', name: json.name || 'result' };
}
}
- return body;
+ return effortPolicy.apply(body, req, { kind: 'anthropic', key: 'anthropic' });
}
}
/** The caller's Messages API body, with the routed model and only the fields they set. */
/** The caller's Messages API body, with the routed model and only the fields they set. */
@@ -70,7 +72,7 @@ function messagesBody(req) {
@@ -70,7 +72,7 @@ function messagesBody(req) {
const tc = body.tool_choice;
const tc = body.tool_choice;
if (tc && (tc.type === 'tool' || tc.type === 'any')) body.tool_choice = { type: 'auto', ...(tc.disable_parallel_tool_use ? { disable_parallel_tool_use: true } : {}) };
if (tc && (tc.type === 'tool' || tc.type === 'any')) body.tool_choice = { type: 'auto', ...(tc.disable_parallel_tool_use ? { disable_parallel_tool_use: true } : {}) };
}
}
- return body;
+ return effortPolicy.apply(body, req, { kind: 'anthropic', key: 'anthropic' });
}
}
const usageOf = (message) => usageParser.anthropicUsage((message && message.usage) || {});
const usageOf = (message) => usageParser.anthropicUsage((message && message.usage) || {});
@@ -202,7 +204,7 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.f
@@ -202,7 +204,7 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.f
const adapter = { key: record.key, kind: 'anthropic', supports: (f) => caps.has(f) || (f === 'messages' && caps.has('chat')), chat, messages };
const adapter = { key: record.key, kind: 'anthropic', supports: (f) => caps.has(f) || (f === 'messages' && caps.has('chat')), chat, messages };
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat;
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat;
- return adapter;
+ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });
}
}
module.exports = { createAnthropicProvider, buildBody, messagesBody, isCurrentClaude, DEFAULT_BASE_URL };
module.exports = { createAnthropicProvider, buildBody, messagesBody, isCurrentClaude, DEFAULT_BASE_URL };
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
const { ProviderError, postJson, schemaInstruction } = require('./common');
const { ProviderError, postJson, schemaInstruction } = require('./common');
const usage = require('./usage');
const usage = require('./usage');
+const effortPolicy = require('../effort/providers');
const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
@@ -48,6 +49,7 @@ function createGeminiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -48,6 +49,7 @@ function createGeminiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
generationConfig.responseSchema = req.json.schema;
generationConfig.responseSchema = req.json.schema;
}
}
if (Object.keys(generationConfig).length) body.generationConfig = generationConfig;
if (Object.keys(generationConfig).length) body.generationConfig = generationConfig;
+ effortPolicy.apply(body, req, { kind: 'gemini' });
const url = `${baseUrl}/models/${encodeURIComponent(model(req))}:generateContent`;
const url = `${baseUrl}/models/${encodeURIComponent(model(req))}:generateContent`;
const j = await postJson(url, apiKey ? { 'x-goog-api-key': apiKey } : {}, body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
const j = await postJson(url, apiKey ? { 'x-goog-api-key': apiKey } : {}, body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
const parts = (j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) || [];
const parts = (j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) || [];
@@ -72,7 +74,7 @@ function createGeminiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -72,7 +74,7 @@ function createGeminiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
generate,
generate,
};
};
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat;
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat;
- return adapter;
+ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });
}
}
module.exports = { createGeminiProvider, DEFAULT_BASE_URL };
module.exports = { createGeminiProvider, DEFAULT_BASE_URL };
@@ -21,13 +21,15 @@ const path = require('path');
@@ -21,13 +21,15 @@ const path = require('path');
const { ProviderError, postJson, deadline, describeAbort, approxTokens, schemaInstruction } = require('./common');
const { ProviderError, postJson, deadline, describeAbort, approxTokens, schemaInstruction } = require('./common');
const { sseEvents, createAccumulator } = require('./stream');
const { sseEvents, createAccumulator } = require('./stream');
const usageParser = require('./usage');
const usageParser = require('./usage');
+const effortPolicy = require('../effort/providers');
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
-function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }
+function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }
-function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier }, jsonMode) {
+function buildBody(req, jsonMode) {
+ const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier } = req;
const msgs = [];
const msgs = [];
// Stepped down from json_schema: the schema travels in the instructions instead.
// Stepped down from json_schema: the schema travels in the instructions instead.
const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';
const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';
@@ -54,14 +56,15 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
@@ -54,14 +56,15 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
// The service tier (BA1): a Flex tier is slower and cheaper, and the provider may refuse it. The tier
// The service tier (BA1): a Flex tier is slower and cheaper, and the provider may refuse it. The tier
// travels in the request; the answer says which tier it really ran on (the pool prices that one).
// travels in the request; the answer says which tier it really ran on (the pool prices that one).
if (serviceTier) body.service_tier = String(serviceTier);
if (serviceTier) body.service_tier = String(serviceTier);
- return body;
+ return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });
}
}
/**
/**
* The Chat Completions body of a complete operation (ai.complete), for a provider batch request too:
* The Chat Completions body of a complete operation (ai.complete), for a provider batch request too:
* the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.
* the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.
*/
*/
-function buildCompleteBody({ model, openai = {}, maxTokens, temperature, serviceTier } = {}) {
+function buildCompleteBody(req = {}) {
+ const { model, openai = {}, maxTokens, temperature, serviceTier } = req;
const o = openai || {};
const o = openai || {};
const body = { model, messages: o.messages };
const body = { model, messages: o.messages };
for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
@@ -77,7 +80,7 @@ function buildCompleteBody({ model, openai = {}, maxTokens, temperature, service
@@ -77,7 +80,7 @@ function buildCompleteBody({ model, openai = {}, maxTokens, temperature, service
if (temperature != null) body.temperature = temperature;
if (temperature != null) body.temperature = temperature;
}
}
if (serviceTier) body.service_tier = String(serviceTier);
if (serviceTier) body.service_tier = String(serviceTier);
- return body;
+ return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });
}
}
function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
@@ -169,7 +172,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -169,7 +172,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
*/
*/
async function complete(req) {
async function complete(req) {
const o = req.openai || {};
const o = req.openai || {};
- const body = buildCompleteBody({ model: req.model, openai: o, maxTokens: req.maxTokens, temperature: req.temperature, serviceTier: req.serviceTier });
+ const body = buildCompleteBody({ ...req, openai: o });
if (typeof req.onDelta === 'function') return await completeStreaming(body, req);
if (typeof req.onDelta === 'function') return await completeStreaming(body, req);
const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
if (!Array.isArray(j.choices) || !j.choices.length) throw new ProviderError('completion has no choices', { code: 'provider.output' });
if (!Array.isArray(j.choices) || !j.choices.length) throw new ProviderError('completion has no choices', { code: 'provider.output' });
@@ -254,7 +257,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
@@ -254,7 +257,7 @@ function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetc
image: images,
image: images,
};
};
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation
- return adapter;
+ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });
}
}
module.exports = { createOpenAiProvider, buildBody, buildCompleteBody, DEFAULT_BASE_URL };
module.exports = { createOpenAiProvider, buildBody, buildCompleteBody, DEFAULT_BASE_URL };
@@ -14,11 +14,12 @@
@@ -14,11 +14,12 @@
*/
*/
const { ProviderError, postJson, schemaInstruction } = require('./common');
const { ProviderError, postJson, schemaInstruction } = require('./common');
const usageParser = require('./usage');
const usageParser = require('./usage');
+const effortPolicy = require('../effort/providers');
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
-function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }
+function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }
/** Chat-style content (a string or [{type:'text',text}] parts) as Responses input content. */
/** Chat-style content (a string or [{type:'text',text}] parts) as Responses input content. */
function inputContent(content) {
function inputContent(content) {
@@ -26,7 +27,8 @@ function inputContent(content) {
@@ -26,7 +27,8 @@ function inputContent(content) {
return content.map(p => (p && p.type === 'text' ? { type: 'input_text', text: String(p.text || '') } : p));
return content.map(p => (p && p.type === 'text' ? { type: 'input_text', text: String(p.text || '') } : p));
}
}
-function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl }, jsonMode) {
+function buildBody(req, jsonMode) {
+ const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl } = req;
const input = (messages || []).map((m, i) => {
const input = (messages || []).map((m, i) => {
const last = i === messages.length - 1;
const last = i === messages.length - 1;
if (last && image && m.role === 'user') {
if (last && image && m.role === 'user') {
@@ -50,7 +52,7 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
@@ -50,7 +52,7 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
if (json && jsonMode === 'schema') body.text = { format: { type: 'json_schema', name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };
if (json && jsonMode === 'schema') body.text = { format: { type: 'json_schema', name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };
else if (json && jsonMode === 'object') body.text = { format: { type: 'json_object' } };
else if (json && jsonMode === 'object') body.text = { format: { type: 'json_object' } };
if (cacheKey && /api\.openai\.com/i.test(baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);
if (cacheKey && /api\.openai\.com/i.test(baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);
- return body;
+ return effortPolicy.apply(body, req, { kind: 'responses', key: 'openai' });
}
}
/** The output_text parts of every output item, in order. */
/** The output_text parts of every output item, in order. */
@@ -105,7 +107,7 @@ function createResponsesProvider(record, { apiKey = '', fetchImpl = globalThis.f
@@ -105,7 +107,7 @@ function createResponsesProvider(record, { apiKey = '', fetchImpl = globalThis.f
chat,
chat,
};
};
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation
for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation
- return adapter;
+ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });
}
}
module.exports = { createResponsesProvider, buildBody, outputText, DEFAULT_BASE_URL };
module.exports = { createResponsesProvider, buildBody, outputText, DEFAULT_BASE_URL };
@@ -28,6 +28,8 @@ const schemas = require('./schemas');
@@ -28,6 +28,8 @@ const schemas = require('./schemas');
const events = require('./events');
const events = require('./events');
const usageRecord = require('./usage-record');
const usageRecord = require('./usage-record');
const usageSamples = require('./usage-samples');
const usageSamples = require('./usage-samples');
+const effortDefaults = require('./effort/defaults');
+const { receiptEffort } = require('./effort/receipt');
const { buildReceipt } = require('./exchange/receipts');
const { buildReceipt } = require('./exchange/receipts');
const privacy = require('./exchange/privacy');
const privacy = require('./exchange/privacy');
@@ -127,10 +129,12 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -127,10 +129,12 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
let active = 0;
let active = 0;
let shuttingDown = false;
let shuttingDown = false;
+ const agentDefaults = effortDefaults.createDefaults({ db, registry, clock });
function decode(r) {
function decode(r) {
if (!r) return null;
if (!r) return null;
return {
return {
id: r.id, status: r.status,
id: r.id, status: r.status,
+ ...receiptEffort(r, parseJson(r.explain, null)),
workflow: { key: r.workflow_key, version: r.workflow_version },
workflow: { key: r.workflow_key, version: r.workflow_version },
template: r.template_key ? { key: r.template_key, version: r.template_version } : null,
template: r.template_key ? { key: r.template_key, version: r.template_version } : null,
route: r.route_key ? { key: r.route_key, version: r.route_version } : null,
route: r.route_key ? { key: r.route_key, version: r.route_version } : null,
@@ -237,11 +241,13 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -237,11 +241,13 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
if (askedPolicy.error) throw new AiError(422, askedPolicy.error, askedPolicy.detail);
if (askedPolicy.error) throw new AiError(422, askedPolicy.error, askedPolicy.detail);
const contentPolicy = askedPolicy.policy;
const contentPolicy = askedPolicy.policy;
const input = body.input === undefined ? {} : body.input;
const input = body.input === undefined ? {} : body.input;
+ const taskDefaults = choices ? (await choices.defaults())[body.task || wf.key] : null;
+ const effortPolicy = await effortDefaults.resolve(body, { ...wf, metadata: { ...wf.metadata, ...(taskDefaults?.effort ? { effort: taskDefaults.effort } : {}) } }, agentDefaults);
const inputJson = stableStringify(input);
const inputJson = stableStringify(input);
if (Buffer.byteLength(inputJson) > config.runs.maxInputBytes) throw new AiError(413, 'input.too_large', `input is larger than ${config.runs.maxInputBytes} bytes`);
if (Buffer.byteLength(inputJson) > config.runs.maxInputBytes) throw new AiError(413, 'input.too_large', `input is larger than ${config.runs.maxInputBytes} bytes`);
const v = schemas.validate(wf.input_schema, input);
const v = schemas.validate(wf.input_schema, input);
if (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });
if (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });
- const inputHash = sha256(body.choice || body.task ? { input: inputJson, choice: body.choice || null, task: body.task || null } : inputJson);
+ const inputHash = sha256({ input, effortPolicy, choice: body.choice || null, task: body.task || null });
const requester = principal.subject;
const requester = principal.subject;
// A developer app (ADR-014): the requester IS its project, the run is metered with the project's
// A developer app (ADR-014): the requester IS its project, the run is metered with the project's
// id on every Billing reading, and a sandbox run may use free/local capacity only (server/providers).
// id on every Billing reading, and a sandbox run may use free/local capacity only (server/providers).
@@ -440,7 +446,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -440,7 +446,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
latency_class: latencyClass,
latency_class: latencyClass,
- options: JSON.stringify({ ...(taskChoice ? { task_choice: taskChoice } : {}), cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
+ options: JSON.stringify({ effort_policy: effortPolicy, ...(taskChoice ? { task_choice: taskChoice } : {}), cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
...(options.latency_clamped ? { latency_clamped: options.latency_clamped } : {}),
...(options.latency_clamped ? { latency_clamped: options.latency_clamped } : {}),
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...((selfKeys || taskChoice && runPerson) ? { self_subject: runPerson } : {}),
...((selfKeys || taskChoice && runPerson) ? { self_subject: runPerson } : {}),
@@ -461,7 +467,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -461,7 +467,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
let cacheKey = null;
let cacheKey = null;
if (scope) {
if (scope) {
// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).
// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).
- const keyInput = CACHE_INPUT[wf.key] ? sha256(stableStringify(CACHE_INPUT[wf.key](input))) : inputHash;
+ const keyInput = CACHE_INPUT[wf.key] ? sha256({ input: CACHE_INPUT[wf.key](input), effortPolicy }) : inputHash;
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash: keyInput });
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash: keyInput });
const hit = await cache.get(cacheKey, scope);
const hit = await cache.get(cacheKey, scope);
if (hit) {
if (hit) {
@@ -511,13 +517,14 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -511,13 +517,14 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
const appFreeOnly = Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled)));
const appFreeOnly = Boolean(ctx.appRun && (ctx.sandbox || !(tiers && tiers.enabled)));
let prepared = null;
let prepared = null;
if (batch && typeof batch.planRun === 'function' && engine.prepareBatch
if (batch && typeof batch.planRun === 'function' && engine.prepareBatch
- && credentialSubject === null && !projectProvider && !selfKeys && !freeUse && !communityOptIn && !appFreeOnly) {
+ && credentialSubject === null && !projectProvider && !selfKeys && !freeUse && !communityOptIn && !appFreeOnly && !effortPolicy.role_default) {
try {
try {
prepared = await engine.prepareBatch(wf, { template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null }, input, { ...ctx, runId: id });
prepared = await engine.prepareBatch(wf, { template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null }, input, { ...ctx, runId: id });
} catch (err) {
} catch (err) {
log.warn(`[runs] ${id}: the batch request could not be prepared: ${(err && err.message) || err}`);
log.warn(`[runs] ${id}: the batch request could not be prepared: ${(err && err.message) || err}`);
}
}
}
}
+ if (prepared) prepared.effort = effortPolicy.effort_resolved;
if (prepared && pool && typeof pool.offers === 'function') {
if (prepared && pool && typeof pool.offers === 'function') {
let offers = await pool.offers(prepared.route, prepared.operation, ctx).catch(() => []);
let offers = await pool.offers(prepared.route, prepared.operation, ctx).catch(() => []);
// A pinned run waits on the provider it pinned, never on another eligible one.
// A pinned run waits on the provider it pinned, never on another eligible one.
@@ -706,7 +713,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -706,7 +713,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
const money = usageRecord.moneyOf({ prices, tokens, paidBy: payer.paid_by, feeRate: (config.usage && config.usage.feeRate) || 0 });
const money = usageRecord.moneyOf({ prices, tokens, paidBy: payer.paid_by, feeRate: (config.usage && config.usage.feeRate) || 0 });
const rec = usageRecord.recordOf({
const rec = usageRecord.recordOf({
system: 'ai', run_id: row.id, subject: subjectOf(row), attribution: attributionOf(row), surface: row.workflow_key || '',
system: 'ai', run_id: row.id, subject: subjectOf(row), attribution: attributionOf(row), surface: row.workflow_key || '',
- provider: payer.provider || provider || null, model: model || null, effort: opts.effort || null,
+ provider: payer.provider || provider || null, model: model || null, effort: parseJson(row.explain, {})?.effort_applied || opts.effort || null,
paid_by: payer.paid_by, paid_by_detail: payer.detail,
paid_by: payer.paid_by, paid_by_detail: payer.detail,
input: tokens.input, output: tokens.output, reasoning: tokens.reasoning, cache_read: tokens.cache_read,
input: tokens.input, output: tokens.output, reasoning: tokens.reasoning, cache_read: tokens.cache_read,
cache_write: tokens.cache_write, cache_write_ttl: usage.cache_write_ttl || null,
cache_write: tokens.cache_write, cache_write_ttl: usage.cache_write_ttl || null,
@@ -774,10 +781,14 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -774,10 +781,14 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
const paidBy = (payer && payer.paid_by) || 'openvibe_key';
const paidBy = (payer && payer.paid_by) || 'openvibe_key';
const feeRate = paidBy === 'openvibe_key' ? Number((config.usage && config.usage.feeRate) || 0) : 0;
const feeRate = paidBy === 'openvibe_key' ? Number((config.usage && config.usage.feeRate) || 0) : 0;
const opts = parseJson(row.options, {});
const opts = parseJson(row.options, {});
+ if ((row.service_tier === 'batch' || row.status === 'failed') && opts.effort_policy && answerProvider && !Object.hasOwn(explain || {}, 'effort_applied')) {
+ const record = await registry.getProvider(answerProvider);
+ if (record) explain = { ...(explain || {}), effort_applied: require('./effort/providers').control({ ...record, model: answerModel }, opts.effort_policy.effort_resolved).applied };
+ }
const receipt = buildReceipt({
const receipt = buildReceipt({
run: {
run: {
id: row.id, workflow_key: row.workflow_key, route_key: row.route_key, route_version: row.route_version,
id: row.id, workflow_key: row.workflow_key, route_key: row.route_key, route_version: row.route_version,
- provider_key: answerProvider, model_key: answerModel, cost_usd: row.cost_usd,
+ provider_key: answerProvider, model_key: answerModel, cost_usd: row.cost_usd, options: row.options, status: row.status,
},
},
explain, usage, price: prices, privacy: privacy.view(label),
explain, usage, price: prices, privacy: privacy.view(label),
payer: { paid_by: paidBy, detail: (payer && payer.detail) || null, fee_rate: feeRate, fee_usd: money ? money.fee_usd : 0 },
payer: { paid_by: paidBy, detail: (payer && payer.detail) || null, fee_rate: feeRate, fee_usd: money ? money.fee_usd : 0 },
@@ -952,7 +963,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -952,7 +963,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
if (batchAnswer && batchAnswer.ok && !batchAnswer.resume) {
if (batchAnswer && batchAnswer.ok && !batchAnswer.resume) {
r = { output: batchAnswer.output || { text: '' }, usage: batchAnswer.usage || { input: 0, output: 0, cached: 0, cache_write: 0 }, cost: Number(batchAnswer.cost) || 0, provider: batchAnswer.provider || null, model: batchAnswer.model || null, citations: [], explain: batchAnswer.explain || null, grounding: null, synthetic: false, fallbackUsed: false };
r = { output: batchAnswer.output || { text: '' }, usage: batchAnswer.usage || { input: 0, output: 0, cached: 0, cache_write: 0 }, cost: Number(batchAnswer.cost) || 0, provider: batchAnswer.provider || null, model: batchAnswer.model || null, citations: [], explain: batchAnswer.explain || null, grounding: null, synthetic: false, fallbackUsed: false };
}
}
- if (!r) r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
+ if (!r) r = await engine.execute(wf, row, input, { effortPolicy: opts.effort_policy, signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
// project (AI_GOVERN_TIERS off): metered capacity is only ever spent under a project budget.
// project (AI_GOVERN_TIERS off): metered capacity is only ever spent under a project budget.
@@ -1065,6 +1076,8 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -1065,6 +1076,8 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
if (f.changes) await events.runChanged(await getRow(id));
if (f.changes) await events.runChanged(await getRow(id));
});
});
const spent = await db.prepare("SELECT COALESCE(SUM(tokens_in),0)::bigint ti, COALESCE(SUM(tokens_out),0)::bigint tout, COALESCE(SUM(tokens_cached),0)::bigint tc, COALESCE(SUM(cost_usd),0) c FROM requests WHERE run_id = ? AND status = 'ok'").get(id);
const spent = await db.prepare("SELECT COALESCE(SUM(tokens_in),0)::bigint ti, COALESCE(SUM(tokens_out),0)::bigint tout, COALESCE(SUM(tokens_cached),0)::bigint tc, COALESCE(SUM(cost_usd),0) c FROM requests WHERE run_id = ? AND status = 'ok'").get(id);
+ const lastAttempt = logged.filter(e => e.status !== 'skipped').at(-1);
+ await writeReceipt({ row: { ...await getRow(id), cost_usd: Number(spent.c) || 0, provider_key: lastAttempt?.provider_key || null, model_key: lastAttempt?.model_key || null }, explain: parseJson(explain, null), usage: { input: Number(spent.ti), output: Number(spent.tout), cached: Number(spent.tc) } });
// Balance: nothing reached a provider (or it failed before the first token) releases the hold; an abort
// Balance: nothing reached a provider (or it failed before the first token) releases the hold; an abort
// or a failure after tokens streamed settles the partial usage it did produce.
// or a failure after tokens streamed settles the partial usage it did produce.
if (credit && credit.holdId) {
if (credit && credit.holdId) {
@@ -154,6 +154,7 @@ function batchExec(b, result) {
@@ -154,6 +154,7 @@ function batchExec(b, result) {
}
}
function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {
function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {
+ pool = require('../effort/pool').wrapPool(pool);
// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.
// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.
let proxy = null;
let proxy = null;
const reader = () => proxy || (proxy = require('../media-proxy').createMediaProxy({ fetcher, log }));
const reader = () => proxy || (proxy = require('../media-proxy').createMediaProxy({ fetcher, log }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+const assert = require('assert');
+const { suite, boot, request, token, ALL } = require('./helpers');
+const { createOpenAiProvider } = require('../server/providers/openai');
+const { createAnthropicProvider } = require('../server/providers/anthropic');
+const { createGeminiProvider } = require('../server/providers/gemini');
+const { createDefaults, resolve } = require('../server/effort/defaults');
+const { createAnalytics } = require('../server/effort/analytics');
+const { createReceipts } = require('../server/exchange/receipts');
+const cases = require('./fixtures/effort/requests.json');
+const t = suite('agent-effort');
+const factories = { openai: createOpenAiProvider, anthropic: createAnthropicProvider, gemini: createGeminiProvider, responses: require('../server/providers/responses').createResponsesProvider };
+const answer = { choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 10 }, type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], candidates: [{ content: { parts: [{ text: 'ok' }] } }] };
+
+t.test('native request fixtures cover chat and compatible routes without network calls', async () => {
+ for (const c of cases) for (const op of c.kind === 'openai' ? ['chat', 'complete'] : c.kind === 'anthropic' ? ['chat', 'messages'] : ['chat']) {
+ let sent;
+ const adapter = factories[c.kind]({ key: c.provider, base_url: 'https://fixture.invalid/v1' }, { fetchImpl: async (_url, opts) => { sent = JSON.parse(opts.body); return new Response(JSON.stringify(answer), { status: 200 }); } });
+ const result = await adapter[op]({ model: c.model, effort: c.effort, system: [], messages: [{ role: 'user', content: 'hello' }], openai: { messages: [{ role: 'user', content: 'hello' }] }, maxTokens: 128 });
+ for (const [key, value] of Object.entries(c.fields)) {
+ if (key === 'generationConfig') assert.deepStrictEqual(sent[key].thinkingConfig, value.thinkingConfig);
+ else assert.deepStrictEqual(sent[key], value, `${c.provider}/${op}/${key}`);
+ }
+ if (c.applied === null) for (const k of ['effort', 'reasoning_effort', 'thinking', 'enable_thinking', 'thinking_budget']) assert.ok(!(k in sent), k);
+ assert.strictEqual(result.effortApplied, c.applied, `${c.provider}/${op} receipt`);
+ }
+});
+t.test('role choice order uses eligible models and preserves explicit model pins', async () => {
+ let seen;
+ const wrapped = require('../server/effort/pool').wrapPool({
+ offers: async () => [{ provider: 'openai', model: 'gpt-5.5' }],
+ execute: async (_route, _op, req, ctx) => { seen = { req, ctx }; return { result: { effortApplied: req.effort } }; },
+ });
+ const ctx = { effortPolicy: { effort_resolved: 'high', role_default: { choice_order: ['deepseek/unavailable', 'openai/gpt-5.5'] } } };
+ await wrapped.execute({}, 'complete', {}, ctx);
+ assert.strictEqual(seen.ctx.exact.id, 'openai/gpt-5.5');
+ assert.strictEqual(seen.req.effort, 'high');
+ await wrapped.execute({}, 'complete', {}, { ...ctx, exact: { id: 'caller/model' } });
+ assert.strictEqual(seen.ctx.exact.id, 'caller/model');
+ await assert.rejects(() => wrapped.execute({}, 'chat', {}, { effortPolicy: { effort_resolved: 'low', role_default: { model: 'other/no-model' } } }), /no model/);
+});
+t.test('streamed OpenAI and Anthropic calls carry the same effort controls', async () => {
+ for (const kind of ['openai', 'anthropic']) {
+ let sent;
+ const text = kind === 'openai'
+ ? 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1}}\n\ndata: [DONE]\n\n'
+ : 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"usage":{"input_tokens":2}}}\n\nevent: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"ok"}}\n\nevent: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n';
+ const a = factories[kind]({ key: kind }, { fetchImpl: async (_url, opts) => { sent = JSON.parse(opts.body); return new Response(text, { headers: { 'Content-Type': 'text/event-stream' } }); } });
+ const result = await a[kind === 'openai' ? 'complete' : 'messages']({ model: kind === 'openai' ? 'gpt-5.5' : 'claude-sonnet-5-5', effort: 'high', messages: [{ role: 'user', content: 'hi' }], openai: { messages: [{ role: 'user', content: 'hi' }] }, maxTokens: 128, timeoutMs: 3000, onDelta: () => {} });
+ assert.strictEqual(sent.stream, true);
+ assert.strictEqual(kind === 'openai' ? sent.reasoning_effort : sent.output_config.effort, 'high');
+ assert.strictEqual(result.effortApplied, 'high');
+ }
+});
+t.test('adaptive Claude rejects legacy thinking budgets before fetch', async () => {
+ const adapter = createAnthropicProvider({ key: 'anthropic' }, { fetchImpl: () => { throw new Error('must not fetch'); } });
+ await assert.rejects(() => adapter.messages({ model: 'claude-opus-5-5', effort: 'high', messages: [], thinking: { type: 'enabled', budget_tokens: 1000 } }), /budget_tokens is refused/);
+});
+t.test('routes retain requested effort and ladder; invalid effort never creates a run', async () => {
+ const h = await boot();
+ const tok = token('live', ALL);
+ try {
+ for (const [path, input] of [
+ ['/api/v1/generate', { prompt: 'hello' }],
+ ['/api/v1/chat', { messages: [{ role: 'user', content: 'hello' }] }],
+ ['/v1/chat/completions', { messages: [{ role: 'user', content: 'hello' }] }],
+ ['/v1/messages', { messages: [{ role: 'user', content: 'hello' }], max_tokens: 64 }],
+ ]) {
+ const res = await request(h.base, 'POST', path, { tok, body: { ...input, effort: 'high', ladder_id: 'test-ladder', rung: 2, kind: 'codes.ticket' } });
+ assert.ok([200, 201].includes(res.status), `${path}: ${res.status} ${res.text}`);
+ const row = await h.db.prepare('SELECT * FROM runs ORDER BY created_at DESC LIMIT 1').get();
+ const policy = JSON.parse(row.options).effort_policy;
+ assert.strictEqual(policy.effort_requested, 'high');
+ const receipt = await createReceipts({ db: h.db }).forRun(row.id);
+ assert.strictEqual(receipt.effort_applied, null, 'stub ignores effort');
+ assert.strictEqual(receipt.effort_rung, 2);
+ assert.strictEqual(receipt.kind, 'codes.ticket');
+ }
+ const totals = await request(h.base, 'GET', '/api/v1/me/ladders/test-ladder', { tok });
+ assert.strictEqual(totals.status, 200, totals.text);
+ assert.strictEqual(totals.body.rungs[0].n, 4);
+ assert.strictEqual((await request(h.base, 'GET', '/api/v1/me/ladders/test-ladder', { tok: token('other', ALL) })).status, 404);
+ assert.strictEqual((await request(h.base, 'POST', '/api/v1/generate', { tok, body: { prompt: 'hello', effort: 'huge' } })).status, 422);
+ assert.strictEqual((await request(h.base, 'POST', '/v1/messages', { tok, body: { model: 'claude-sonnet-5-5', max_tokens: 64, messages: [{ role: 'user', content: 'hi' }], thinking: { budget_tokens: 1000 } } })).status, 400);
+ } finally { await h.stop(); }
+});
+t.test('owner role policy is audited; reviewer stays in a different family; auto reads defaults', async () => {
+ const h = await boot();
+ try {
+ const initial = await createDefaults({ db: h.db, registry: h.registry }).get();
+ assert.strictEqual(initial.worker.model, 'deepseek/deepseek-flash');
+ assert.strictEqual(initial.worker.model, `deepseek/${(await h.registry.getProvider('deepseek')).default_model}`, 'worker default matches the seeded provider even while it is disabled');
+ const admin = token('network', ['ai.config.manage']);
+ assert.strictEqual((await request(h.base, 'GET', '/api/v1/agent-role-defaults', { tok: token('live', ALL) })).status, 403);
+ const put = await request(h.base, 'PUT', '/api/v1/agent-role-defaults/worker', { tok: admin, body: { model: 'anthropic/claude-opus-5-5', effort: 'high' } });
+ assert.strictEqual(put.status, 200, put.text);
+ const defs = createDefaults({ db: h.db, registry: h.registry });
+ const roles = await defs.get();
+ assert.strictEqual(roles.reviewer.model, 'openai/gpt-5.5');
+ assert.strictEqual((await resolve({ effort: 'auto', agent_role: 'worker' }, {}, defs)).effort_resolved, 'high');
+ assert.strictEqual((await resolve({ effort: 'auto' }, { metadata: { effort: 'low' } }, defs)).effort_resolved, 'low');
+ const defaultsValue = require('../server/choices').defaultValue({ signed_out: { model: 'openvibe/auto', source: 'openvibe' }, signed_in: { model: 'openvibe/auto', source: 'openvibe' }, effort: 'low' });
+ await h.db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(task) DO UPDATE SET value = excluded.value').run('design.icon', JSON.stringify(defaultsValue), 'test', new Date().toISOString());
+ const created = await h.runs.create({ task: 'design.icon', input: { messages: [{ role: 'user', content: 'Draw an icon' }] }, effort: 'auto' }, { sub: 'svc:live', subject: { type: 'service', id: 'live' } });
+ await created.promise;
+ const saved = await h.db.prepare('SELECT options FROM runs WHERE id = ?').get(created.run.id);
+ assert.strictEqual(JSON.parse(saved.options).effort_policy.effort_resolved, 'low');
+ const audits = await h.registry.listAudit({ action: 'agent.role-default.update' });
+ assert.strictEqual(audits.length, 1);
+ const invalid = await request(h.base, 'PUT', '/api/v1/agent-role-defaults/reviewer', { tok: admin, body: { model: 'anthropic/claude-sonnet-5-5', effort: 'high' } });
+ assert.strictEqual(invalid.status, 422, invalid.text);
+ assert.strictEqual((await h.registry.listAudit({ action: 'agent.role-default.update' })).length, 1);
+ } finally { await h.stop(); }
+});
+t.test('estimate has a 20-receipt floor, current prices, token scaling, kind and date filters', async () => {
+ const h = await boot();
+ try {
+ const created = await h.runs.create({ workflow: 'ai.generate', input: { prompt: 'sample' }, effort: 'high' }, { sub: 'svc:live', subject: { type: 'service', id: 'live' }, caps: new Set(ALL) });
+ await created.promise;
+ const row = await h.db.prepare('SELECT * FROM runs WHERE id = ?').get(created.run.id);
+ await h.db.prepare("UPDATE runs SET synthetic = 0, status = 'succeeded' WHERE id = ?").run(row.id);
+ const receipts = createReceipts({ db: h.db });
+ let now = Date.now();
+ const clock = { now: () => now };
+ const catalog = { models: async () => ({ models: [{ slug: 'openai/gpt-5.5', offers: [{ provider: 'openai', model: 'gpt-5.5', price: { in: 2, out: 8, cached: 1 } }] }] }) };
+ const analytics = createAnalytics({ db: h.db, catalog, clock });
+ const q = { model: 'gpt-5.5', effort: 'high', kind: 'codes.ticket', input_tokens: '200' };
+ const existing = await receipts.forRun(row.id);
+ if (existing) await h.db.prepare('DELETE FROM ai_receipts WHERE id = ?').run(existing.id);
+ for (let i = 0; i < 20; i++) {
+ const id = `run_estimate_${i}`;
+ await h.db.prepare('INSERT INTO runs SELECT (jsonb_populate_record(NULL::runs, ?::jsonb)).*').run(JSON.stringify({ ...row, id, synthetic: 0, status: 'succeeded', idempotency_key: null, receipt_id: null }));
+ await receipts.record({ id: `rcpt_sample_${i}`, run_id: id, created_at: new Date(now).toISOString(), offer: { provider: 'openai', model: 'gpt-5.5' }, effort_applied: 'high', kind: 'codes.ticket', usage: { tokens_in: 100, tokens_out: 20, tokens_reasoning: 10, tokens_cached: 50 }, cost_usd: 99 });
+ if (i === 18) { const result = await analytics.estimate(q); assert.strictEqual(result.n, 19); assert.strictEqual(result.low_usd, null); now += 31000; }
+ }
+ const result = await analytics.estimate(q);
+ assert.strictEqual(result.n, 20);
+ assert.strictEqual(result.low_usd, 0.00078);
+ assert.strictEqual(result.high_usd, 0.00078);
+ assert.strictEqual((await analytics.estimate({ ...q, kind: 'other' })).n, 0);
+ now += 31 * 86400000;
+ assert.strictEqual((await analytics.estimate(q)).n, 0);
+ const qrow = await h.db.prepare("SELECT id FROM requests WHERE run_id = ? AND status = 'ok' LIMIT 1").get(row.id);
+ assert.ok(qrow);
+ await h.db.prepare('UPDATE requests SET tokens_in = 100, tokens_cached = 25 WHERE id = ?').run(qrow.id);
+ const shares = await require('../server/effort/analytics').cacheShares(h.db, { now: () => Date.now() });
+ assert.strictEqual(shares.get(`${row.provider_key}/${row.model_key}`), 0.25);
+ const publicResult = await request(h.base, 'GET', '/api/v1/exchange/estimate?model=gpt-5.5&effort=high');
+ assert.strictEqual(publicResult.status, 200, publicResult.text);
+ assert.ok(publicResult.headers.get('cache-control').includes('public'));
+ } finally { await h.stop(); }
+});
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -0,0 +1,157 @@
+[
+ {
+ "provider": "openai",
+ "kind": "openai",
+ "model": "gpt-5.5",
+ "effort": "max",
+ "applied": "max",
+ "fields": {
+ "reasoning_effort": "xhigh"
+ }
+ },
+ {
+ "provider": "openai",
+ "kind": "openai",
+ "model": "o3",
+ "effort": "max",
+ "applied": "high",
+ "fields": {
+ "reasoning_effort": "high"
+ }
+ },
+ {
+ "provider": "anthropic",
+ "kind": "anthropic",
+ "model": "claude-opus-5-5",
+ "effort": "high",
+ "applied": "high",
+ "fields": {
+ "thinking": {
+ "type": "adaptive"
+ },
+ "output_config": {
+ "effort": "high"
+ }
+ }
+ },
+ {
+ "provider": "anthropic",
+ "kind": "anthropic",
+ "model": "claude-sonnet-5-5",
+ "effort": "low",
+ "applied": "low",
+ "fields": {
+ "thinking": {
+ "type": "adaptive"
+ },
+ "output_config": {
+ "effort": "low"
+ }
+ }
+ },
+ {
+ "provider": "gemini",
+ "kind": "gemini",
+ "model": "gemini-3.1-pro-preview",
+ "effort": "max",
+ "applied": "high",
+ "fields": {
+ "generationConfig": {
+ "thinkingConfig": {
+ "thinkingLevel": "HIGH"
+ }
+ }
+ }
+ },
+ {
+ "provider": "gemini",
+ "kind": "gemini",
+ "model": "gemini-2.5-flash",
+ "effort": "medium",
+ "applied": "medium",
+ "fields": {
+ "generationConfig": {
+ "thinkingConfig": {
+ "thinkingBudget": 4096
+ }
+ }
+ }
+ },
+ {
+ "provider": "qwen",
+ "kind": "openai",
+ "model": "qwen3.8-max",
+ "effort": "high",
+ "applied": "high",
+ "fields": {
+ "enable_thinking": true,
+ "thinking_budget": 16384
+ }
+ },
+ {
+ "provider": "qwen",
+ "kind": "openai",
+ "model": "qwen3.8-max",
+ "effort": "low",
+ "applied": "low",
+ "fields": {
+ "enable_thinking": false
+ }
+ },
+ {
+ "provider": "deepseek",
+ "kind": "openai",
+ "model": "deepseek-v4-flash",
+ "effort": "high",
+ "applied": "medium",
+ "fields": {
+ "thinking": {
+ "type": "enabled"
+ }
+ }
+ },
+ {
+ "provider": "deepseek",
+ "kind": "anthropic",
+ "model": "deepseek-v4-flash",
+ "effort": "low",
+ "applied": "low",
+ "fields": {
+ "thinking": {
+ "type": "disabled"
+ }
+ }
+ },
+ {
+ "provider": "unknown",
+ "kind": "openai",
+ "model": "llama-3",
+ "effort": "high",
+ "applied": null,
+ "fields": {}
+ },
+ {
+ "provider": "openai",
+ "kind": "responses",
+ "model": "gpt-6-astra",
+ "effort": "max",
+ "applied": "max",
+ "fields": {
+ "reasoning": {
+ "effort": "max"
+ }
+ }
+ },
+ {
+ "provider": "deepseek",
+ "kind": "openai",
+ "model": "deepseek-flash",
+ "effort": "low",
+ "applied": "low",
+ "fields": {
+ "thinking": {
+ "type": "disabled"
+ }
+ }
+ }
+]
@@ -56,6 +56,7 @@ const LEFT_OUT = [
@@ -56,6 +56,7 @@ const LEFT_OUT = [
];
];
const excluded = (key) => {
const excluded = (key) => {
const p = key.split(' ')[1];
const p = key.split(' ')[1];
+ if (key === 'GET /api/v1/me/ladders/{id}') return false; // read-only accounting, no credential fields
return LEFT_OUT.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
return LEFT_OUT.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
};
};
@@ -28,7 +28,7 @@ assert.strictEqual(base.low_power, false, 'the run\'s own input is not changed')
@@ -28,7 +28,7 @@ assert.strictEqual(base.low_power, false, 'the run\'s own input is not changed')
// The run cache keys on it; a recording's words are kept two weeks.
// The run cache keys on it; a recording's words are kept two weeks.
const runs = fs.readFileSync(path.join(__dirname, '..', 'server', 'runs.js'), 'utf8');
const runs = fs.readFileSync(path.join(__dirname, '..', 'server', 'runs.js'), 'utf8');
-assert.match(runs, /const keyInput = CACHE_INPUT\[wf\.key\] \? sha256\(stableStringify\(CACHE_INPUT\[wf\.key\]\(input\)\)\) : inputHash;/);
+assert.match(runs, /const keyInput = CACHE_INPUT\[wf\.key\] \? sha256\(\{ input: CACHE_INPUT\[wf\.key\]\(input\), effortPolicy \}\) : inputHash;/);
assert.match(runs, /cache\.keyFor\(\{[^}]*inputHash: keyInput \}\)/);
assert.match(runs, /cache\.keyFor\(\{[^}]*inputHash: keyInput \}\)/);
const wf = live.workflows.find((w) => w.key === 'live.media.transcribe');
const wf = live.workflows.find((w) => w.key === 'live.media.transcribe');
assert.strictEqual(wf.cache_mode, 'private', 'still scoped to the requester');
assert.strictEqual(wf.cache_mode, 'private', 'still scoped to the requester');