openvibers/openvibe.ai Commits

Add portable agent effort and receipt cost estimates (#419)

OP OpenVibers committed 3 days ago · c41ec791c8 · 27 files, +773 −31

* Add portable effort, role defaults and receipt cost analytics

* Merge current task choices and resolve effort from task defaults

* Cover streaming and Responses effort and price reasoning tokens

* Reserve migration 0058 above the open market metadata migration

* Pin the green agent Contracts 0.381.0 commit

* Match role defaults to the seeded DeepSeek Flash route

* Update effort fixtures for task schemas and cache policy

* Merge main and reserve migration 0059 for agent effort

* Merge main market identity and current workflow fixes

* Merge main and update agent effort defaults

* Sequence effort defaults migration after Exchange

* Merge Exchange main and enforce cache sample floor

* Keep catalog sample floor while exposing cache analytics

* Merge remote-tracking branch 'origin/main' into job/ship2-ai410

* Merge traffic share main and sequence effort migration

Changes

ddiff --git a/docs/agent-effort.md b/docs/agent-effort.md
nnew file mode 100644
iindex 0000000..1ca0178
---- /dev/null
++++ b/docs/agent-effort.md
@@@ -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.
ddiff --git a/migrations/0064_agent_effort_defaults.sql b/migrations/0064_agent_effort_defaults.sql
nnew file mode 100644
iindex 0000000..0b4089e
---- /dev/null
++++ b/migrations/0064_agent_effort_defaults.sql
@@@ -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);
ddiff --git a/server/api/admin.js b/server/api/admin.js
iindex 63beb5a..4ed785a 100644
---- a/server/api/admin.js
++++ b/server/api/admin.js
@@@ -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 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
ddiff --git a/server/api/exchange.js b/server/api/exchange.js
iindex 65f32bb..d78dcd2 100644
---- a/server/api/exchange.js
++++ b/server/api/exchange.js
@@@ -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) => {
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex aa6a90c..87f3b27 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -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, receipts
delete 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' };
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex 91dc3c3..fa08fda 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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' };
ddiff --git a/server/api/runs.js b/server/api/runs.js
iindex 34f6991..c4143f1 100644
---- a/server/api/runs.js
++++ b/server/api/runs.js
@@@ -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) => {
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); }
});
}
ddiff --git a/server/batch/batcher.js b/server/batch/batcher.js
iindex 3f13dd9..8074cca 100644
---- a/server/batch/batcher.js
++++ b/server/batch/batcher.js
@@@ -232,7 +232,10 @@ function createBatcher({ db, runs, registry, credits = null, config = {}, clock
const 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,
ddiff --git a/server/choices/index.js b/server/choices/index.js
iindex 1fbb4d6..cbbd5c2 100644
---- a/server/choices/index.js
++++ b/server/choices/index.js
@@@ -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),
ddiff --git a/server/effort/analytics.js b/server/effort/analytics.js
nnew file mode 100644
iindex 0000000..becf53b
---- /dev/null
++++ b/server/effort/analytics.js
@@@ -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 };
ddiff --git a/server/effort/defaults.js b/server/effort/defaults.js
nnew file mode 100644
iindex 0000000..d19661b
---- /dev/null
++++ b/server/effort/defaults.js
@@@ -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 };
ddiff --git a/server/effort/pool.js b/server/effort/pool.js
nnew file mode 100644
iindex 0000000..cd351f3
---- /dev/null
++++ b/server/effort/pool.js
@@@ -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 };
ddiff --git a/server/effort/providers.js b/server/effort/providers.js
nnew file mode 100644
iindex 0000000..1533a1c
---- /dev/null
++++ b/server/effort/providers.js
@@@ -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 };
ddiff --git a/server/effort/receipt.js b/server/effort/receipt.js
nnew file mode 100644
iindex 0000000..c4d54ac
---- /dev/null
++++ b/server/effort/receipt.js
@@@ -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 };
ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex b2c42a9..5794f99 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -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) {
ddiff --git a/server/exchange/receipts.js b/server/exchange/receipts.js
iindex 220384c..aa2999b 100644
---- a/server/exchange/receipts.js
++++ b/server/exchange/receipts.js
@@@ -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(),
ddiff --git a/server/openapi.js b/server/openapi.js
iindex dd780aa..8c19667 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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' }),
ddiff --git a/server/providers/anthropic.js b/server/providers/anthropic.js
iindex 498d8eb..152f5c6 100644
---- a/server/providers/anthropic.js
++++ b/server/providers/anthropic.js
@@@ -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, temperatur
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' };
}
-- 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.f
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;
-- return adapter;
++ return effortPolicy.wrapAdapter(adapter, { ...record, base_url: baseUrl });
}
module.exports = { createAnthropicProvider, buildBody, messagesBody, isCurrentClaude, DEFAULT_BASE_URL };
ddiff --git a/server/providers/gemini.js b/server/providers/gemini.js
iindex a0af3d4..9887103 100644
---- a/server/providers/gemini.js
++++ b/server/providers/gemini.js
@@@ -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.fetc
generationConfig.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.fetc
generate,
};
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 };
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex 321a35d..4ccd21b 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -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, service
if (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.fetc
image: 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 };
ddiff --git a/server/providers/responses.js b/server/providers/responses.js
iindex 056a76e..84e74f8 100644
---- a/server/providers/responses.js
++++ b/server/providers/responses.js
@@@ -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, temperatur
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' } };
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.f
chat,
};
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 };
ddiff --git a/server/runs.js b/server/runs.js
iindex 4c3b21a..174b421 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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, confi
let 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, confi
if (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, confi
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,
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, confi
let 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, confi
const 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, confi
const 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, confi
const 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, confi
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 };
}
-- 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, confi
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 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) {
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
iindex 312f65e..9a0dd34 100644
---- a/server/workflows/engine.js
++++ b/server/workflows/engine.js
@@@ -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 }));
ddiff --git a/test/agent-effort.test.js b/test/agent-effort.test.js
nnew file mode 100644
iindex 0000000..2db93b9
---- /dev/null
++++ b/test/agent-effort.test.js
@@@ -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();
ddiff --git a/test/fixtures/effort/requests.json b/test/fixtures/effort/requests.json
nnew file mode 100644
iindex 0000000..effacbe
---- /dev/null
++++ b/test/fixtures/effort/requests.json
@@@ -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"
++ }
++ }
++ }
++]
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
iindex 84fa644..86e275e 100644
---- a/test/openapi.test.js
++++ b/test/openapi.test.js
@@@ -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 fields
return LEFT_OUT.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
};
ddiff --git a/test/transcribe-range-cache.test.js b/test/transcribe-range-cache.test.js
iindex dfb5225..c20bfb3 100644
---- a/test/transcribe-range-cache.test.js
++++ b/test/transcribe-range-cache.test.js
@@@ -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');