openvibers/openvibe.ai Commits
AI tasks and choices: one system for which model does what (#397)
Changes
nnew file mode 100644@@@ -0,0 +1,15 @@++# Tasks and model choices++++`GET /api/v1/tasks` lists registered tasks. A service with `ai.workflow.manage` registers its own namespace with `POST /api/v1/tasks`: `{name, kind, sentence, default: {model, source}, workflow?}`. The task may use its own workflow or a general AI workflow.++++`GET /api/v1/choices?task=design.icon` returns the resolved choice, its reason, the owner's recommendation, and options grouped under `openvibe`, `keys`, and `machines`. Personal options require the person's token. Measurements come from Exchange's catalogue and Report; missing prices, speed or quality remain null. Price estimates for text assume 1,000 input and 1,000 output tokens. Adult models require Network's existing adult confirmation and the run still enforces the content floor.++++`PUT /api/v1/me/choices/:task` accepts `{model, source, machine?}`. Source is `openvibe`, `keys`, or `machines`; only machines name a `dev_…` machine or `any`. A service token cannot save choices for a person. A saved choice is the person's across every first-party site: a site session token may write it from that site's own origin (the origins come from the service manifests in Contracts, never a hard-coded list), and Exchange proxies the person's session for the rest. No provider secrets leave AI.++++`POST /api/v1/runs` accepts either `{task, input, choice?}` or a workflow with `choice`. Resolution is run choice, saved task choice, kind preference, owner defaults, then the built-in. The response records `choice` and `answered`; explain includes the task, resolution reason and fallback index. Chosen runs bypass the answer cache. Retry retains their task and choice. Personal-machine calls use the existing machine transport and ownership checks in `server/machines.js`.++++`GET|PUT /api/v1/admin/task-defaults` requires a verified person with admin or owner role, never a service's manage capability. PUT accepts `{defaults: [{task, value}]}`. Each value has `signed_out`, `signed_in`, `fallbacks`, `image_model`, `search_engines: [{engine, enabled}]`, and `max_cost_usd`. Changes and their before/after values commit with the audit entry. Reads cache for 60 seconds; writes invalidate this process immediately.++++Owner fallback order is preserved. No fallback occurs after a stream emits text or a content-policy refusal. A finite ceiling reserves conservative token upper bounds across model steps and retries before dispatch; unknown prices are refused. Managed images have no per-image rate card in the current router, so a finite image ceiling fails closed until such a card exists. Own keys and machines are not charged to OpenVibe.++++The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++CREATE TABLE ai_tasks (++ name text PRIMARY KEY, service text NOT NULL, kind text NOT NULL,++ sentence text NOT NULL, builtin text NOT NULL, workflow text NOT NULL,++ updated_at text NOT NULL++);++CREATE TABLE ai_task_choices (++ subject text NOT NULL, task text NOT NULL REFERENCES ai_tasks(name),++ choice text NOT NULL, updated_at text NOT NULL, PRIMARY KEY(subject, task)++);++CREATE TABLE ai_task_defaults (++ task text PRIMARY KEY REFERENCES ai_tasks(name), value text NOT NULL,++ updated_by text NOT NULL, updated_at text NOT NULL++);@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.175.0","pg": "^8.23.0"@@@ -1543,9 +1543,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.373.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",-- "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",++ "version": "0.375.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",++ "integrity": "sha512-+HI3XAkqkDW1ANR0SyGSXAYTko/z0SsDB9uIdswQCiYixoS/9M3RYGqPOFr+VevvZH9CG9hXvW0Tp3ecc0qaXw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.175.0","pg": "^8.23.0"@@@ -39,6 +39,9 @@ const TABLES = [// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own// settings, so they go with the account; the row holds no key.{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },++ // The person's saved model for each AI task (rich-content plan §1): their own setting, and the row holds a model++ // name and a source, never a key.++ { table: 'ai_task_choices', subject: 'subject', file: 'ai-task-choices.json', columns: ['task', 'choice', 'updated_at'], order: 'updated_at' },// L55 C3: the apps the person allowed to use their own key. Names only: no key, no hint, no material of any kind.{ table: 'ai_key_grants', subject: 'subject', file: 'key-grants.json', columns: ['service', 'provider', 'created_at'], order: 'created_at' },// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is@@@ -32,7 +32,7 @@ const { AiError, sendError } = require('../util');const { CAPS, namespaceAllowed, appOrCap } = require('../auth');const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');--function runsRouter({ runs, registry, auth, config, log = console }) {++function runsRouter({ choices = null, runs, registry, auth, config, log = console }) {const r = express.Router();const create = auth.personOrCap(CAPS.runCreate);const read = auth.personOrCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);@@@ -62,6 +62,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {async function createAndRespond(req, res, body, waitMs) {if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);++ if (body.task && !body.workflow) { const task = await choices?.task(String(body.task)); if (!task) throw new AiError(404, 'task.not_found', 'unknown task'); body = { ...body, workflow: task.workflow }; }checkNamespace(req, String(body.workflow || ''));const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;// BA1: the caller's wait goes with the run, so a background run answers inside it (a Flex tier that is@@@ -79,6 +80,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {*/async function createAndStream(req, res, body, waitMs) {if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);++ if (body.task && !body.workflow) { const task = await choices?.task(String(body.task)); if (!task) throw new AiError(404, 'task.not_found', 'unknown task'); body = { ...body, workflow: task.workflow }; }checkNamespace(req, String(body.workflow || ''));const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;let opened = false;@@@ -22,7 +22,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');const ownerInputs = require('./owner-inputs');--function createApp({ searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {++function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -214,7 +214,7 @@ function createApp({ searchFetch = null, config, db, registry, pool, quotas, cac// Operator actions shared by the admin API and the operator console (one implementation, one audit row).const ops = createOps({ db, registry, pool, quotas, cache, runs });-- app.use(runsRouter({ runs, registry, auth, config, log }));++ app.use(runsRouter({ choices, runs, registry, auth, config, log }));app.use(actionsRouter({ runs, registry, auth, log }));// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));@@@ -240,7 +240,7 @@ function createApp({ searchFetch = null, config, db, registry, pool, quotas, cac// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.if (credentials && preferences && grants) {-- app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ app.use(require('./me').meRouter({ choices, credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));}// The person's human settings moved to Exchange; keep the JSON API here for first-party clients.app.use('/you', (req, res) => res.redirect(301, `${config.exchangeUrl}/privacy/keys${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * A run started from an AI task: one source answers every model step, in the order the resolved choice names.++ * A fallback is explicit and tried only when the one before it failed before it produced any text; a streamed delta++ * or a content-floor refusal ends the run, because a second voice after the first one's words would be a lie.++ */++const { AiError } = require('../util');++const { canonicalOf } = require('../exchange/catalog');++const { pricesOf } = require('../providers/rate-cards');++++async function executeChoice({ execute, registry, poolRates, maxAttempts = 1, route, operation, req, ctx }) {++ const plan = ctx.taskChoice;++ const candidates = [plan.choice, ...plan.fallbacks];++ let streamed = false;++ let last = null;++ const onDelta = ctx.onDelta ? (...args) => { streamed = true; return ctx.onDelta(...args); } : undefined;++++ for (let i = 0; i < candidates.length; i += 1) {++ const picked = candidates[i];++ try {++ // One attempt: the choice owns the source, so the person's other keys, their kind default, a community++ // offer and a soft preference are all set aside for this step.++ const next = { ...ctx, taskChoice: null, selfCredentials: [], selfPreferences: null, communityOptIn: false, prefer: null, ...(onDelta ? { onDelta } : {}) };++ let offers = [];++++ if (picked.source === 'machines') {++ if (!ctx.onBehalfOf) throw new AiError(401, 'choice.sign_in', 'a machine choice needs a person');++ next.credential = { provider: 'node', providerKey: `byo:self:${ctx.onBehalfOf}:node`, owner: 'self', subject: ctx.onBehalfOf, machine: picked.machine, models: { chat: picked.model } };++ } else {++ const isImage = operation === 'image';++ const [rows, providers] = await Promise.all([registry.listModels(), registry.listProviders()]);++ const byKey = new Map(providers.map((p) => [p.key, p]));++ const dynamic = picked.model.startsWith('openvibe/');++ for (const row of rows) {++ const provider = byKey.get(row.provider_key);++ if (!provider || provider.status !== 'active' || row.status !== 'active') continue;++ if (isImage ? row.type !== 'image' : row.type === 'image') continue;++ const id = canonicalOf(row, provider);++ if (!dynamic && id !== picked.model && row.model_key !== picked.model) continue;++ const adult = Boolean(row.metadata?.uncensored) || /uncensor|abliterat/i.test(`${id} ${row.metadata?.variant || ''}`);++ // A dynamic choice picks among the standard offers; an uncensored model is never chosen in silence.++ if (dynamic && adult) continue;++ if (adult) next.contentPolicy = 'uncensored';++ offers.push({ provider: provider.key, model: row.model_key });++ }++ if (picked.source === 'keys') {++ // The person's own key: only a provider they have a key for may answer, and their key pays.++ const provider = picked.model.split('/')[0];++ const cred = (ctx.selfCredentials || []).find((k) => k.provider === provider || offers.some((o) => o.provider === k.provider));++ if (!cred) throw new AiError(422, 'ai.needs_key', 'add your key for this model on Exchange');++ const model = (offers.find((o) => o.provider === cred.provider) || {}).model || picked.model.slice(provider.length + 1);++ next.credential = { ...cred, models: { chat: model } };++ } else {++ if (!offers.length) throw new AiError(422, 'choice.unavailable', `no deployment of ${picked.model} is available`);++ if (picked.model === 'openvibe/cheapest') {++ const priced = await Promise.all(offers.map(async (o) => {++ const card = await poolRates(o.provider, o.model);++ if (!card) return { o, price: Infinity };++ const prices = pricesOf(card);++ return { o, price: prices.in + prices.out };++ }));++ offers = priced.sort((a, b) => a.price - b.price).map((x) => x.o);++ }++ // openvibe/auto means the network's own router decides; every other choice pins exactly its model.++ if (picked.model !== 'openvibe/auto') next.exact = { id: picked.model, offers };++ }++ }++++ if (plan.max_cost_usd != null && picked.source === 'openvibe') {++ if (operation === 'image') throw new AiError(402, 'choice.cost_ceiling', 'a priced image offer needs a per-image upper bound before it can use this ceiling');++ const remaining = plan.max_cost_usd - (ctx.choiceBudget.used || 0);++ const inputTokens = Buffer.byteLength(JSON.stringify(req));++ const outputTokens = Number(req.maxTokens || (req.openai && req.openai.max_tokens) || 4096);++ // Reserve a conservative upper bound for this step, attempts included. An unrated paid model is refused++ // rather than guessed at, and a local or stub provider costs nothing.++ const rated = await Promise.all(offers.map(async (o) => {++ const card = await poolRates(o.provider, o.model);++ const prices = card ? pricesOf(card) : null;++ const provider = prices ? null : await registry.getProvider(o.provider);++ const cost = prices ? maxAttempts * (inputTokens * prices.in + outputTokens * prices.out) / 1e6++ : provider && ['local', 'stub'].includes(provider.kind) ? 0 : Infinity;++ return { ...o, cost };++ }));++ const admitted = rated.filter((o) => Number.isFinite(o.cost) && o.cost <= remaining);++ if (!admitted.length) throw new AiError(402, 'choice.cost_ceiling', 'no selected model fits the remaining task cost ceiling');++ // Every deployment of a pinned model can be attempted, so all of their bounds are reserved together.++ const limited = [];++ let reserved = 0;++ for (const o of admitted) {++ if (reserved + o.cost > remaining) continue;++ limited.push(o);++ reserved += o.cost;++ }++ ctx.choiceBudget.used = (ctx.choiceBudget.used || 0) + reserved;++ next.exact = { id: picked.model, offers: limited.sort((a, b) => a.cost - b.cost).map(({ provider, model }) => ({ provider, model })) };++ }++++ const forwarded = onDelta && req.onDelta ? { ...req, onDelta: (...args) => { streamed = true; return req.onDelta(...args); } } : req;++ const answer = await execute(route, operation, forwarded, next);++ answer.explain = {++ ...(answer.explain || {}),++ task_choice: {++ task: plan.task.name,++ requested: plan.choice,++ answered: { model: answer.model || picked.model, source: picked.source, ...(picked.machine ? { machine: picked.machine } : {}) },++ why: plan.why,++ fallback_index: i,++ },++ };++ answer.fallbackUsed = answer.fallbackUsed || i > 0;++ return answer;++ } catch (err) {++ last = err;++ if (streamed || (ctx.signal && ctx.signal.aborted) || /^policy\./.test(err.code || '') || err.status === 403) throw err;++ }++ }++ throw last || new AiError(503, 'choice.unavailable', 'no choice could answer');++}++++module.exports = { executeChoice };nnew file mode 100644@@@ -0,0 +1,261 @@++'use strict';++/**++ * AI tasks and choices (rich-content plan §1): one system for which model does what, owned by OpenVibe.AI and managed++ * on Exchange. A task is a named piece of AI work with a kind; a choice names a model and the source it comes from++ * (OpenVibe's supply, the person's own key, or one of their machines through Link).++ *++ * Resolution, first match wins: the run's own choice, the person's saved choice for the task, their default for the++ * kind (ai.preferences.defaults), the owner's default for the task, and the network's built-in. The owner's defaults++ * are read through a 60-second cache and every change is audited with its before and after.++ */++const { AiError, iso } = require('../util');++const { PROVIDERS: ENGINES } = require('../research');++++const KINDS = ['text', 'code', 'vision', 'image', 'search'];++const SOURCES = ['openvibe', 'keys', 'machines'];++const GENERAL = ['ai.generate', 'ai.complete', 'ai.image', 'research.gather'];++const MAX_COST_USD = 1000;++const CACHE_MS = 60000;++++const built = (model = 'openvibe/auto') => ({ model, source: 'openvibe' });++++// The tasks the plan names, seeded once. A service registers its own with POST /api/v1/tasks.++const SEEDS = [++ ['blog.devblog.write', 'text', 'Write a named devblog from the team’s changes.', 'ai.generate'],++ ['blog.devblog.art', 'image', 'Draw title art for a devblog.', 'ai.image'],++ ['wiki.article.write', 'text', 'Write a sourced wiki article.', 'ai.generate'],++ ['wiki.article.images', 'search', 'Find licensed pictures for a wiki article.', 'research.gather'],++ ['design.icon', 'code', 'Draw an original SVG icon.', 'ai.chat'],++ ['chat.reply', 'text', 'Answer a chat message.', 'ai.complete'],++ ['image.generate', 'image', 'Make an image from a description.', 'ai.image'],++ ['search.web', 'search', 'Find sources on the web.', 'research.gather'],++ ['search.images', 'search', 'Find licensed images.', 'research.gather'],++ ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'ai.generate'],++];++++const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;++const MACHINE_RE = /^(any|dev_[0-9A-HJKMNP-TV-Z]{26})$/;++const TASK_RE = /^[a-z][a-z0-9_-]*\.[a-z0-9_.-]{1,100}$/;++const ENGINE_RE = /^[a-z][a-z0-9_-]{0,39}$/;++++/** A search task answers from a search engine: its choice is `search/<engine>` from the research providers. */++function checkSearchChoice(kind, picked) {++ if (kind !== 'search') return;++ const engine = String(picked.model).replace(/^search\//, '');++ if (!picked.model.startsWith('search/') || !ENGINES.includes(engine)) {++ throw new AiError(422, 'choice.invalid', `a search task names an engine: ${ENGINES.map((e) => `search/${e}`).join(', ')}`);++ }++}++++/** One model and its source, or 422. A machine choice names the machine; no other source does. */++function choice(value) {++ const usable = value && typeof value === 'object' && !Array.isArray(value);++ const extra = usable ? Object.keys(value).filter((k) => !['model', 'source', 'machine'].includes(k)) : null;++ if (!usable || extra.length || !SOURCES.includes(value.source) || typeof value.model !== 'string' || !MODEL_RE.test(value.model)) {++ throw new AiError(422, 'choice.invalid', 'a choice needs a model and a source (openvibe, keys or machines)');++ }++ if (value.source === 'machines' ? !MACHINE_RE.test(value.machine || '') : value.machine != null) {++ throw new AiError(422, 'choice.invalid', 'only a machine choice names a machine');++ }++ return { model: value.model, source: value.source, ...(value.machine ? { machine: value.machine } : {}) };++}++++/** The owner's default for one task (ai.task-defaults@1), checked field by field, or 422. */++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))++ || (value.fallbacks != null && !Array.isArray(value.fallbacks))) {++ throw new AiError(422, 'defaults.invalid', 'invalid task defaults');++ }++ const out = {++ signed_out: choice(value.signed_out),++ signed_in: choice(value.signed_in),++ fallbacks: (value.fallbacks || []).map(choice),++ image_model: value.image_model == null ? null : choice(value.image_model),++ search_engines: value.search_engines == null ? [] : value.search_engines,++ max_cost_usd: value.max_cost_usd == null ? null : value.max_cost_usd,++ };++ const all = [out.signed_out, out.signed_in, ...out.fallbacks, ...(out.image_model ? [out.image_model] : [])];++ if (all.some((c) => c.source !== 'openvibe')) throw new AiError(422, 'defaults.invalid', 'the owner’s defaults use OpenVibe supply');++ if (out.fallbacks.length > 10) throw new AiError(422, 'defaults.invalid', 'at most ten fallbacks');++ if (!Array.isArray(out.search_engines) || out.search_engines.length > 10++ || out.search_engines.some((e) => !e || !ENGINE_RE.test(e.engine) || typeof e.enabled !== 'boolean')++ || new Set(out.search_engines.map((e) => e.engine)).size !== out.search_engines.length) {++ throw new AiError(422, 'defaults.invalid', 'invalid search engine order');++ }++ if (out.max_cost_usd !== null && (typeof out.max_cost_usd !== 'number' || !Number.isFinite(out.max_cost_usd)++ || out.max_cost_usd < 0 || out.max_cost_usd > MAX_COST_USD)) {++ throw new AiError(422, 'defaults.invalid', `max_cost_usd must be 0–${MAX_COST_USD} or null`);++ }++ return out;++}++++function createChoices({ db, registry, preferences, credentials, machines, catalog, adultStatus, ratings = async () => [], clock = { now: () => Date.now() } }) {++ let cached = null;++ let cachedAt = 0;++++ const decode = (r) => r && ({ name: r.name, service: r.service, kind: r.kind, sentence: r.sentence, default: JSON.parse(r.builtin), workflow: r.workflow });++ const task = async (name) => decode(await db.prepare('SELECT * FROM ai_tasks WHERE name = ?').get(name));++ const tasks = async () => (await db.prepare('SELECT * FROM ai_tasks ORDER BY name').all()).map(decode);++++ async function seed() {++ for (const [name, kind, sentence, workflow] of SEEDS) {++ const model = kind === 'search' ? (name === 'search.web' ? 'search/wikipedia' : 'search/commons') : 'openvibe/auto';++ await db.prepare('INSERT INTO ai_tasks (name, service, kind, sentence, builtin, workflow, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO NOTHING')++ .run(name, name.split('.')[0], kind, sentence, JSON.stringify(built(model)), workflow, iso(clock.now()));++ }++ // Design's icon generator is the first consumer and what it already does becomes the owner's default:++ // signed out the cheapest good model, signed in Claude.++ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')++ .run('design.icon', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('anthropic/claude-opus-5-5'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.5 }), 'seed', iso(clock.now()));++ }++++ async function register(body, service) {++ if (!body || !TASK_RE.test(body.name || '') || body.name.split('.')[0] !== service++ || !KINDS.includes(body.kind) || typeof body.sentence !== 'string' || !body.sentence.trim() || body.sentence.length > 300) {++ throw new AiError(422, 'task.invalid', 'register a named task in your service namespace with a kind and a sentence');++ }++ const c = choice(body.default);++ const workflow = body.workflow || body.name;++ if (!workflow.startsWith(`${service}.`) && !GENERAL.includes(workflow)) {++ throw new AiError(422, 'task.invalid', 'the workflow must belong to the service or be a general AI workflow');++ }++ await db.tx(async () => {++ await db.prepare(`INSERT INTO ai_tasks (name, service, kind, sentence, builtin, workflow, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)++ ON CONFLICT (name) DO UPDATE SET kind = excluded.kind, sentence = excluded.sentence, builtin = excluded.builtin, workflow = excluded.workflow, updated_at = excluded.updated_at`)++ .run(body.name, service, body.kind, body.sentence, JSON.stringify(c), workflow, iso(clock.now()));++ await registry.audit(service, 'task.register', 'task', body.name, { metadata: { kind: body.kind } });++ });++ return task(body.name);++ }++++ async function defaults() {++ if (!cached || clock.now() - cachedAt >= CACHE_MS) {++ cached = Object.fromEntries((await db.prepare('SELECT * FROM ai_task_defaults ORDER BY task').all()).map((r) => [r.task, JSON.parse(r.value)]));++ cachedAt = clock.now();++ }++ return structuredClone(cached);++ }++++ async function putDefaults(body, actor) {++ if (!body || !Array.isArray(body.defaults) || body.defaults.length > 200) throw new AiError(422, 'defaults.invalid', 'defaults must be a list of task settings');++ const list = [];++ for (const row of body.defaults) {++ const t = await task(row.task);++ if (!t) throw new AiError(404, 'task.not_found', 'unknown task');++ const value = defaultValue(row.value);++ for (const c of [value.signed_out, value.signed_in, ...value.fallbacks]) checkSearchChoice(t.kind, c);++ list.push([row.task, value]);++ }++ await db.tx(async () => {++ for (const [name, value] of list) {++ const before = await db.prepare('SELECT value FROM ai_task_defaults WHERE task = ?').get(name);++ await db.prepare(`INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?)++ ON CONFLICT (task) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)++ .run(name, JSON.stringify(value), actor, iso(clock.now()));++ await registry.audit(actor, 'task.defaults', 'task', name, { metadata: { before: before ? JSON.parse(before.value) : null, after: value } });++ }++ });++ cached = null;++ return defaults();++ }++++ async function save(subject, name, value) {++ const t = await task(name);++ if (!t) throw new AiError(404, 'task.not_found', 'unknown task');++ const c = choice(value);++ checkSearchChoice(t.kind, c);++ await db.prepare(`INSERT INTO ai_task_choices (subject, task, choice, updated_at) VALUES (?, ?, ?, ?)++ ON CONFLICT (subject, task) DO UPDATE SET choice = excluded.choice, updated_at = excluded.updated_at`)++ .run(subject, name, JSON.stringify(c), iso(clock.now()));++ return c;++ }++++ /** The resolved choice for a task: first match wins, and `why` says which step picked it. */++ async function resolve(name, { subject = null, choice: explicit = null } = {}) {++ let t = await task(name);++ if (!t) {++ // A workflow without a registered task resolves as one: the caller may use any workflow.++ const wf = await registry.activeWorkflow(name);++ if (!wf) throw new AiError(404, 'task.not_found', `unknown task ${name}`);++ t = { name, kind: wf.steps.some((s) => s.kind === 'image') ? 'image' : 'text', sentence: wf.name, default: built(), workflow: name };++ }++ const owner = (await defaults())[name] || null;++ const saved = subject ? await db.prepare('SELECT choice FROM ai_task_choices WHERE subject = ? AND task = ?').get(subject, name) : null;++ const prefs = subject && preferences ? await preferences.get(subject) : null;++ const perKind = prefs && prefs.defaults ? prefs.defaults[t.kind] : null;++ const fromOwner = (owner && (t.kind === 'image' && owner.image_model ? owner.image_model : subject ? owner.signed_in : owner.signed_out)) || null;++ const picked = explicit ? { value: choice(explicit), why: 'run' }++ : saved ? { value: JSON.parse(saved.choice), why: 'saved' }++ : perKind && perKind !== 'auto' ? { value: built(perKind), why: 'kind' }++ : fromOwner ? { value: fromOwner, why: 'owner' }++ : { value: t.default, why: 'builtin' };++ checkSearchChoice(t.kind, picked.value);++ if (picked.value.source !== 'openvibe' && !subject) throw new AiError(401, 'choice.sign_in', 'sign in to use your keys or machines');++ if (prefs && prefs.only_my_keys && picked.value.source === 'openvibe') throw new AiError(422, 'ai.needs_key', 'only_my_keys is on; choose one of your keys or machines');++ return {++ task: t, choice: picked.value, why: picked.why, only_my_keys: Boolean(prefs && prefs.only_my_keys),++ // A fallback is the owner's list, so it applies only when the owner's default supplied the choice.++ fallbacks: picked.why === 'owner' ? owner.fallbacks : [],++ max_cost_usd: owner ? owner.max_cost_usd : null,++ search_engines: owner ? owner.search_engines : [],++ };++ }++++ /** What the chip's sheet shows: the resolved choice, why, and the options grouped by source. */++ async function get(name, { subject = null } = {}) {++ const resolved = await resolve(name, { subject });++ if (resolved.task.kind === 'search') {++ return {++ ...resolved, recommended: resolved.choice, adult: false,++ options: {++ openvibe: ENGINES.map((engine) => ({ model: `search/${engine}`, source: 'openvibe', name: engine, maker: null, price_estimate_usd: engine === 'deepseek' ? null : 0, speed_ms: null, quality: null, adult: false })),++ keys: [], machines: [],++ },++ };++ }++ const [qualityRows, adult, raw, keys, own] = await Promise.all([++ ratings(),++ subject && adultStatus ? adultStatus.adultFor(subject).catch(() => false) : false,++ catalog.models({ kind: resolved.task.kind === 'image' ? 'image' : 'chat' }),++ subject && credentials ? credentials.listSelf(subject) : [],++ subject && machines ? machines.mine(subject).catch(() => []) : [],++ ]);++ const options = { openvibe: [], keys: [], machines: [] };++ for (const m of raw.models) {++ if (m.uncensored && !adult) continue;++ if (resolved.task.kind === 'vision' && !(m.capabilities && m.capabilities.vision)) continue;++ // An estimate for one run: 1,000 input and 1,000 output tokens, the plan's own measure.++ const prices = m.offers.filter((o) => o.price).map((o) => (o.price.in + o.price.out) / 1000);++ const speeds = m.offers.map((o) => o.speed && o.speed.p50_ms).filter((n) => n != null).sort((a, b) => a - b);++ const rated = qualityRows.find((r) => r.id === m.canonical);++ const row = {++ model: m.canonical, source: 'openvibe', name: m.name, maker: m.canonical.split('/')[0],++ price_estimate_usd: prices.length ? Math.min(...prices) : null,++ speed_ms: speeds.length ? speeds[0] : null,++ quality: rated && rated.rating ? (rated.rating.value ?? rated.rating.rating ?? null) : null,++ adult: Boolean(m.uncensored),++ };++ options.openvibe.push(row);++ if (keys.some((key) => m.offers.some((o) => o.provider === key.provider))) options.keys.push({ ...row, source: 'keys', price_estimate_usd: 0 });++ }++ for (const machine of own) {++ if (!machine.online) continue;++ for (const model of machine.models) {++ options.machines.push({ model, source: 'machines', machine: machine.id, name: model, maker: null, machine_name: machine.name, price_estimate_usd: 0, speed_ms: null, quality: null, adult: false });++ }++ }++ const owner = (await defaults())[name];++ const recommended = owner ? (subject ? owner.signed_in : owner.signed_out) : resolved.task.default;++ // A dynamic choice (openvibe/auto, openvibe/cheapest) shows the cheapest priced model it may use, so the chip++ // names a real model before it runs; the run itself still goes through the router.++ const priced = options.openvibe.filter((o) => o.price_estimate_usd != null && !o.adult).sort((a, b) => a.price_estimate_usd - b.price_estimate_usd);++ const concrete = (c) => !c.model.startsWith('openvibe/') || !priced.length ? c : choice({ model: priced[0].model, source: 'openvibe' });++ return { ...resolved, choice: concrete(resolved.choice), recommended: concrete(recommended), options, adult };++ }++++ return { seed, tasks, task, register, defaults, putDefaults, save, resolve, get };++}++++module.exports = { createChoices, choice, defaultValue, SEEDS, KINDS, SOURCES };nnew file mode 100644@@@ -0,0 +1,12 @@++'use strict';++// Public Report evidence. Unknown stays unknown; a failed read never invents a ranking.++function createRatings({fetchImpl=globalThis.fetch,clock={now:()=>Date.now()}}={}) {++ let rows=[],at=0;++ return async()=>{++ if(clock.now()-at<120000)return rows;++ try {const res=await fetchImpl('https://openvibe.report/api/v1/ai/leaderboard?limit=1000',{headers:{Accept:'application/json'},signal:AbortSignal.timeout(3000)});if(res.ok){const body=await res.json();rows=Array.isArray(body.rows)?body.rows:[];at=clock.now();}}++ catch { /* measurements are optional */ }++ return rows;++ };++}++module.exports={createRatings};@@@ -119,6 +119,8 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {// The Exchange's routing presets and the receipts of the answered requests (server/exchange/).const presets = require('./exchange/presets').createPresets({ db, clock, log });const receipts = require('./exchange/receipts').createReceipts({ db, clock, log });++ const choices = require('./choices').createChoices({ db, registry, preferences, credentials, machines, adultStatus, ratings: require('./choices/ratings').createRatings({ fetchImpl, clock }), clock, catalog: require('./exchange/catalog').createCatalog({ db, registry, pool, config, env, clock }) });++ await choices.seed();const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });ensureCredits();// A stable facade: the store may create the Credits manager after boot (AI_CREDITS turned on live), and the runs@@@ -149,7 +151,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {// BA3: the runs and the batcher need each other (a batch-class run is created first, then queued), so// the runs hold this facade and the batcher fills it in below, before any request can arrive.const batchRef = {};-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef,++ const runs = createRuns({ choices, db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef,// BA2: a run that waits for a provider's batch answer is not interrupted by a restart: it stays// queued until the batcher finishes it (the provider is still working on it).batchWaiting: async (runId) => {@@@ -203,7 +205,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });++ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -24,7 +24,7 @@* The key is never returned (a four-character hint only), never logged and never put in an error.*/const express = require('express');--const { http } = require('openvibe-contracts');++const { http, services } = require('openvibe-contracts');const { verifyUserToken } = require('openvibe-sdk/auth');const { AiError } = require('./util');const { bearer } = require('./auth');@@@ -40,6 +40,13 @@ function audiencesOf(claims) {return (Array.isArray(aud) ? aud : aud == null ? [] : [aud]).map(String);}++/**++ * Every first-party site's origin, from Contracts' service manifests. A person's saved choice is theirs on any of++ * our sites (Design's Draw it, Blog's editor, Chat), so a site's own session token may write it from its own origin.++ * A service outside the manifests is never trusted.++ */++const SITE_ORIGINS = new Set((services.manifests || []).map((m) => m.publicOrigin).filter(Boolean).map((u) => { try { return new URL(u).origin; } catch { return null; } }).filter(Boolean));++/*** The same-origin check for the person-session cookie (the console's csrfOk rule, without a form token).* A write with neither Origin nor Sec-Fetch-Site is refused: a site can make a bare request with the cookie, so the@@@ -53,6 +60,7 @@ function sameOrigin(req, config, { write = false } = {}) {if (origin) {try {const actual = new URL(origin).origin;++ if (req.path.startsWith('/api/v1/me/choices/') && SITE_ORIGINS.has(actual)) return true;return [config.baseUrl, config.exchangeUrl].some((base) => {try { return actual === new URL(base).origin; } catch { return false; }});@@@ -61,7 +69,7 @@ function sameOrigin(req, config, { write = false } = {}) {return true;}--function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sessionCookies = null, sendError }) {++function meRouter({ choices = null, credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sessionCookies = null, sendError }) {const r = express.Router();/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */@@@ -93,8 +101,11 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl// request came from Exchange (which holds the user's token in its httpOnly session),// or when the person minted an AI-audience token directly. No service token can enter this route.const audiences = audiencesOf(claims);++ // A person's saved choice is theirs across our sites, so a site session may write it from that site's++ // own origin; every other setting is Exchange's.++ const siteChoice = req.path.startsWith('/api/v1/me/choices/') && req.get('origin') && SITE_ORIGINS.has(new URL(req.get('origin')).origin);const exchangeWrite = audiences.includes('openvibe.network') && sameOrigin(req, config, { write: true })-- && req.get('origin') && new URL(req.get('origin')).origin === new URL(config.exchangeUrl).origin;++ && req.get('origin') && (new URL(req.get('origin')).origin === new URL(config.exchangeUrl).origin || siteChoice);if (write && !audiences.includes(WRITE_AUDIENCE) && !exchangeWrite) {return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });}@@@ -127,6 +138,20 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cltry { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }};++ if (choices) {++ const reply = fn => async (req, res) => { try { res.set('Cache-Control', 'private, no-store').json(await fn(req)); } catch (err) { sendError(res, err, req.ov); } };++ const optional = async (req, res, next) => { const token = bearer(req); if (!token) return next(); try { const service = await auth.verify(token); if (service?.ok) return next(); } catch {} return guard(req, res, next); };++ const admin = (req, res, next) => ['admin', 'owner'].includes(req.principal?.claims?.role) ? next() : res.status(403).json({ code: 'role.admin_required', detail: 'An admin role is required.' });++ r.get('/api/v1/tasks', reply(async () => ({ tasks: await choices.tasks() })));++ r.post('/api/v1/tasks', auth.requireCap('ai.workflow.manage'), reply(async req => {++ if (req.principal.subject.type !== 'service') throw new AiError(403, 'capability.denied', 'only services register tasks');++ return { task: await choices.register(req.body, req.principal.subject.id) };++ }));++ r.get('/api/v1/choices', optional, reply(req => choices.get(String(req.query.task || ''), { subject: req.principal?.subject?.id || null })));++ r.put('/api/v1/me/choices/:task', guard, reply(async req => ({ choice: await choices.save(personOf(req), req.params.task, req.body) })));++ r.get('/api/v1/admin/task-defaults', guard, admin, reply(async () => ({ tasks: await choices.tasks(), defaults: await choices.defaults() })));++ r.put('/api/v1/admin/task-defaults', guard, admin, reply(async req => ({ defaults: await choices.putDefaults(req.body, personOf(req)) })));++ }r.get('/api/v1/me/search-credentials', guard, run(async req => ({ engines: await credentials.listSearch(personOf(req)) })));r.put('/api/v1/me/search-credentials/:engine', guard, run(async req => {const out = await credentials.putSearch(personOf(req), req.params.engine, req.body);@@@ -63,6 +63,8 @@ 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 }),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=…' },@@@ -81,6 +83,28 @@ function route(method, p, operation) {(PATHS[p] = PATHS[p] || {})[method] = operation;}++// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────++route('get', '/tasks', {++ operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],++ description: 'Public. A task names its service, its kind (text, code, vision, image, search), one sentence and its built-in default choice. A service registers its own with POST /api/v1/tasks.',++ responses: { 200: json('The catalogue', { type: 'object', required: ['tasks'], properties: { tasks: arrayOf(ref('Task')) } }), ...errors([429]) },++});++route('post', '/tasks', {++ operationId: 'registerTask', summary: 'Register a task in the caller’s own namespace', tags: ['choices'], security: SERVICE,++ description: 'ai.workflow.manage. Only a service token registers tasks, and only under its own namespace: the workflow is the service’s own or a general AI workflow (ai.generate, ai.complete, ai.image, research.gather). A registration is audited.',++ requestBody: body({ type: 'object', required: ['name', 'kind', 'sentence', 'default'], properties: {++ name: str({ pattern: '^[a-z][a-z0-9_-]*\\.[a-z0-9_.-]{1,100}$' }), kind: str({ enum: ['text', 'code', 'vision', 'image', 'search'] }),++ sentence: str({ maxLength: 300 }), default: ref('Choice'), workflow: str(),++ } }),++ responses: { 200: json('The registered task', { type: 'object', required: ['task'], properties: { task: ref('Task') } }), 422: problem, ...errors([400, 401, 403]) },++});++route('get', '/choices', {++ operationId: 'getChoice', summary: 'The resolved model for a task, and the options to choose from', tags: ['choices'], security: [],++ description: 'The person\'s own token is optional: with none, the signed-out default resolves. Returns the choice, why that step picked it (run, saved, kind, owner, builtin), the owner\'s recommendation and the options grouped by source (openvibe, keys, machines). Price is an estimate for one run; a missing measurement stays null, never zero.',++ parameters: [query('task', str({ pattern: '^[a-z][a-z0-9_-]*\\.[a-z0-9_.-]{1,100}$' }), 'The task (design.icon). Omitted, the route answers 404.')],++ responses: { 200: json('The resolved choice and the options', ref('ChoiceView')), 401: problem, 404: problem, 422: problem, ...errors([429]) },++});++// ── The apps a person allows to use their own key (L55 C3) ───────────────────────────────────route('get', '/allowed-keys/{subject}', {operationId: 'allowedKeys', summary: 'Which providers this person allowed this service to use their own key for', tags: ['keys'], security: SERVICE,@@@ -745,7 +769,7 @@ function openapi() {contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },},servers: [{ url: SITE }],-- tags: ['runs', 'operations', 'actions', 'exchange', 'presets', 'receipts'].map((name) => ({ name })),++ tags: ['runs', 'operations', 'actions', 'choices', 'exchange', 'presets', 'receipts'].map((name) => ({ name })),components: {securitySchemes: {bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or user token for audience openvibe.ai carrying the route\'s capability (ai.run.create, ai.run.read, ai.actions.read)' },@@@ -753,6 +777,26 @@ function openapi() {},schemas: {SubjectRef: { type: 'object', required: ['type', 'id'], properties: { type: str(), id: str() } },++ Choice: { type: 'object', required: ['model', 'source'], additionalProperties: false, properties: {++ model: str({ pattern: '^[a-zA-Z0-9._:/@+\\-]{1,200}$' }),++ source: str({ enum: ['openvibe', 'keys', 'machines'], description: 'OpenVibe’s supply, the person’s own key, or one of their machines through OpenVibe Link' }),++ machine: str({ pattern: '^(any|dev_[0-9A-HJKMNP-TV-Z]{26})$', description: 'only a machine choice names one' }),++ } },++ Task: { type: 'object', required: ['name', 'kind', 'sentence', 'default'], properties: {++ name: str(), service: str(), kind: str({ enum: ['text', 'code', 'vision', 'image', 'search'] }),++ sentence: str(), default: ref('Choice'), workflow: str(),++ } },++ ChoiceView: { type: 'object', required: ['task', 'choice', 'why', 'options'], properties: {++ task: ref('Task'), choice: ref('Choice'), recommended: ref('Choice'),++ why: str({ enum: ['run', 'saved', 'kind', 'owner', 'builtin'], description: 'which resolution step picked the choice' }),++ options: { type: 'object', properties: { openvibe: arrayOf(ref('ChoiceOption')), keys: arrayOf(ref('ChoiceOption')), machines: arrayOf(ref('ChoiceOption')) } },++ fallbacks: arrayOf(ref('Choice')), max_cost_usd: nullable('number'), adult: { type: 'boolean' },++ } },++ ChoiceOption: { type: 'object', required: ['model', 'source', 'name'], properties: {++ model: str(), source: str({ enum: ['openvibe', 'keys', 'machines'] }), name: str(), maker: nullable('string'),++ price_estimate_usd: nullable('number', { description: 'an estimate for one run (1,000 input and 1,000 output tokens); null when no price was measured' }),++ speed_ms: nullable('number'), quality: nullable('number'), adult: { type: 'boolean' }, machine: str(), machine_name: str(),++ } },WorkflowRef: { type: 'object', required: ['key', 'version'], properties: { key: str(), version: integer() } },Run: { type: 'object', required: ['id', 'status', 'workflow'], properties: {id: str(), status: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] },@@@ -766,6 +810,8 @@ function openapi() {latency_clamped: nullable('object', { description: '{ asked, used }: the class the caller asked for and the class the run really took' }),usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),++ choice: nullable('object', { description: 'the choice the run was started with (a task run or an explicit choice)' }),++ answered: nullable('object', { description: '{ model, source, machine? }: what actually answered' }),retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),created_at: str({ format: 'date-time' }), started_at: nullable('string', { format: 'date-time' }), finished_at: nullable('string', { format: 'date-time' }),} },@@@ -14,9 +14,23 @@ const DEFAULTS = Object.freeze({only_my_keys: false,free_first: true,price_ceiling_usd_per_mtok: null,-- defaults: Object.freeze({ text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }),++ defaults: Object.freeze({ text: 'auto', code: 'auto', vision: 'auto', image: 'auto', search: 'auto' }),});++/** ai.me-preferences@1, with `defaults.search` checked here while the pinned schema predates it. */++function validatePreferences(body) {++ const schema = contracts.schema('ai.me-preferences@1');++ if (!schema) return null;++ const defaults = body && body.defaults;++ const knowsSearch = Boolean(schema.properties && schema.properties.defaults && schema.properties.defaults.properties && schema.properties.defaults.properties.search);++ if (knowsSearch || !defaults || typeof defaults !== 'object') return contracts.validate('ai.me-preferences@1', body);++ const { search, ...rest } = defaults;++ const out = contracts.validate('ai.me-preferences@1', { ...body, defaults: rest });++ if (search == null) return out;++ if (typeof search === 'string' && search.length && search.length <= 120) return out;++ return { valid: false, errors: [{ path: '/defaults/search', message: 'must be a model id or auto (1-120 characters)' }] };++}++/** The same rules ai.me-preferences@1 states, for a service whose contracts pin predates 0.257.0. */function manualPreferences(body) {if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';@@@ -27,6 +41,7 @@ function manualPreferences(body) {for (const k of ['text', 'code', 'vision', 'image']) {if (typeof d[k] !== 'string' || !d[k].length || d[k].length > 120) return `defaults.${k} must be a model id or auto (1-120 characters)`;}++ if (d.search != null && (typeof d.search !== 'string' || !d.search.length || d.search.length > 120)) return 'defaults.search must be a model id or auto (1-120 characters)';return null;}@@@ -48,8 +63,10 @@ function createPreferences({ db, clock = { now: () => Date.now() } }) {async function put(subject, body) {// ai.me-preferences@1 arrives with openvibe-contracts 0.257.0; before that pin the shape is checked here.++ // defaults.search arrives with the AI-choices release: a pin that predates it has no `search` property, so++ // that one field is checked here and the rest against the schema it does carry.let v = null;-- try { v = contracts.schema('ai.me-preferences@1') ? contracts.validate('ai.me-preferences@1', body) : null; } catch { v = null; }++ try { v = validatePreferences(body); } catch { v = null; }const manual = v ? null : manualPreferences(body);if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-preferences@1', { errors: v ? v.errors : [{ path: '', message: manual }] });const value = {@@@ -58,7 +75,7 @@ function createPreferences({ db, clock = { now: () => Date.now() } }) {price_ceiling_usd_per_mtok: body.price_ceiling_usd_per_mtok == null ? null : Number(body.price_ceiling_usd_per_mtok),defaults: {text: String(body.defaults.text), code: String(body.defaults.code),-- vision: String(body.defaults.vision), image: String(body.defaults.image),++ vision: String(body.defaults.vision), image: String(body.defaults.image), search: String(body.defaults.search || 'auto'),},};const at = iso(clock.now());@@@ -877,7 +877,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no*/async function executeOnMachine(cred, operation, req, ctx = {}) {const completing = operation === 'complete';-- if (!completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own machine answers text only, not ${operation}`);++ if (!completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'image'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own machine answers text only, not ${operation}`);if (req.image) throw new AiError(422, 'credential.unsupported', 'a person\'s own machine reads no pictures yet');const wanted = ctx.role || roleOfRoute(ctx.routeKey);const model = (wanted && cred.models[wanted]) || cred.models.chat;@@@ -930,7 +930,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst messaging = operation === 'messages';const anthropicFormat = PROVIDER_KIND[cred.provider] === 'anthropic' || cred.provider === 'anthropic' || cred.provider === 'custom';if (messaging && !anthropicFormat) throw new AiError(422, 'credential.unsupported', `the ${cred.provider} credential does not speak the Messages API`);-- if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);++ if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'image'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;// The person's model for this role: the run's input.role, else the Live role its route names (live.director).const wanted = ctx.role || roleOfRoute(ctx.routeKey);@@@ -958,7 +958,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));// The person's own key (BYOK) is never a managed resale offer: the disclosure says CUSTOMER_BYOK.const ownFacts = { resolved_model: `${cred.provider}/${model}`, revision: null, deployment: cred.providerKey, provider_access: 'CUSTOMER_BYOK', rate_version: null, policy: 'byok', fallback_reason: null };-- const call = messaging ? a.messages : completing ? a.complete : a.chat;++ const call = operation === 'image' ? a.image : messaging ? a.messages : completing ? a.complete : a.chat;++ if (typeof call !== 'function') throw new AiError(422, 'credential.unsupported', 'this provider cannot generate images');let streamed = false;const onDelta = (completing || messaging) && typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;let lastErr = null;@@@ -1047,6 +1048,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}async function execute(route, operation, req, ctx = {}) {++ if (ctx.taskChoice) return require('../choices/execute').executeChoice({ execute, registry, poolRates: rateCardsFor, maxAttempts: config.providerRetries + 1, route, operation, req, ctx });const contentPolicy = ctx.contentPolicy || 'standard';const refuseFloor = (r) => new AiError(403, 'policy.floor_refused', r.detail, { rule: r.rule, policy: contentPolicy });if (ctx.credential) {@@@ -119,7 +119,7 @@ function routePinRequest(value, principal, wf) {return { provider, model };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null }) {++function createRuns({ choices = null, db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }// BA2: the runs that wait for a provider's own batch API: they are created at once and finish later.const deferred = new Map(); // run id -> { controller, scope, ctx, input, hold, resolveDone }@@@ -154,6 +154,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// for and the class the run really ran in); nothing else about the run changes.latency_clamped: (parseJson(r.options, {}) || {}).latency_clamped || null,usage: { tokens_in: r.tokens_in, tokens_out: r.tokens_out, cost_usd: r.cost_usd, attempts: r.attempts, usage_sample_ids: r.usage_sample_ids || (r.usage_sample_id ? [r.usage_sample_id] : null) },++ choice: (parseJson(r.options, {}) || {}).task_choice?.choice || null,++ answered: parseJson(r.explain, null)?.task_choice?.answered || null,explain: parseJson(r.explain, null),citations_count: r.citations_count,grounding: parseJson(r.grounding, null),@@@ -210,7 +212,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now*/async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null, discount = null, exact = null, costCheck = null, communityOfferId = null, callerWaitMs = 0 } = {}) {if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');-- const wfKey = String(body.workflow || '');++ const namedTask = choices && body.task ? await choices.task(String(body.task)) : null;++ if (body.task && !namedTask) throw new AiError(404, 'task.not_found', 'unknown task');++ const wfKey = String(body.workflow || namedTask?.workflow || '');const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);if (!wf) throw new AiError(404, 'workflow.not_found', `no active workflow ${wfKey}${body.version ? ` v${body.version}` : ''}`);if (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);@@@ -237,7 +241,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowif (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(inputJson);++ const inputHash = sha256(body.choice || body.task ? { input: inputJson, choice: body.choice || null, task: body.task || null } : inputJson);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).@@@ -345,6 +349,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now}const runPerson = personOwnToken ? requester.id : personSubject;const personPays = Boolean(runPerson) && personOwnToken;++ if (body.choice && !choices) throw new AiError(503, 'choice.unavailable', 'task choices are unavailable');++ if (body.choice && (pinned || body.credential || projectProvider)) throw new AiError(422, 'choice.invalid', 'choice cannot be combined with another routing control');++ const taskChoice = choices && (namedTask || body.choice || await choices.task(wf.key))++ ? await choices.resolve(namedTask ? namedTask.name : wf.key, { subject: runPerson, choice: body.choice }) : null;++ if (taskChoice && app && taskChoice.choice.source !== 'openvibe') throw new AiError(403, 'capability.denied', 'app runs use OpenVibe supply');++// The calling service's own budget (a run token's registered budget): with it, a run for a named person may// use paid capacity, counted against that budget; without it, only free capacity serves the run.const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;@@@ -361,7 +371,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now&& (Boolean(runPerson) || (anonymousTryIt && wf.key === 'ai.complete'));// The injected run record names the person a user runs for, so preferences and cache scope follow them.if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };-- const selfKeys = Boolean(credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));++ const selfKeys = taskChoice?.choice.source === 'machines' || Boolean((!taskChoice || taskChoice.choice.source !== 'openvibe') && credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.if (runBudgets && principal.jti) {@@@ -372,7 +382,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// field on the OpenAI and Messages routes and an input/option field on the direct ones; parsed as an exact// decimal into integer nano-USD, never through a float (0.000000001 is one nano, 0.0000000011 rounds up).const rawMaxCost = body.max_cost_usd != null ? body.max_cost_usd : options.max_cost_usd != null ? options.max_cost_usd : input.max_cost_usd;-- const maxCostNano = rawMaxCost == null ? null : parseCostUsd(rawMaxCost);++ let maxCostNano = rawMaxCost == null ? null : parseCostUsd(rawMaxCost);++ if (taskChoice?.max_cost_usd != null) { const cap = parseCostUsd(taskChoice.max_cost_usd); maxCostNano = maxCostNano == null || cap < maxCostNano ? cap : maxCostNano; }const ctx = {requesterType: requester.type, requesterId: requester.id,...(principal.jti ? { runJti: principal.jti } : {}),@@@ -429,10 +440,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowtarget: 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({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,++ 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.latency_clamped ? { latency_clamped: options.latency_clamped } : {}),...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),-- ...(selfKeys ? { self_subject: runPerson } : {}),++ ...((selfKeys || taskChoice && runPerson) ? { self_subject: runPerson } : {}),...(runPerson ? { person_pays: personPays } : {}),...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}),@@@ -446,7 +457,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// and another model's answer under the same prompt would silently break the pin. A run that may use the// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.// A community or adult-tier answer cannot be reused by a request without the same policy.-- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn || contentPolicy !== 'standard' ? null : cache.scopeFor(wf.cache_mode, ctx);++ const scope = taskChoice || options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn || contentPolicy !== 'standard' ? null : cache.scopeFor(wf.cache_mode, ctx);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).@@@ -957,6 +968,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowcommunityFree,...(ctx.routePin ? { routePin: ctx.routePin } : {}),// L37: the soft preference the caller made (provider, and a model if it named one).++ taskChoice: opts.task_choice || null, choiceBudget: { used: 0 },prefer: opts.prefer || null,// The routing preset's rules (server/exchange/constraints.js): the router filters the offers with// them, and a hard max_price no offer fits refuses the run before any provider is called.@@@ -1135,6 +1147,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowworkflow: row.workflow_key, input: parseJson(row.input, {}), target: parseJson(row.target, null) || undefined,attribution: parseJson(row.attribution, null) || undefined, on_behalf_of: parseJson(row.on_behalf_of, null) || undefined,route: opts.pinned_offer || undefined,++ ...(opts.task_choice ? { task: opts.task_choice.task.name, choice: opts.task_choice.choice } : {}),options: { ...opts, cache: false },}, principal, { trace, requestId, retryOf: id });}@@@ -35,6 +35,7 @@ const schemas = require('../schemas');const { PREPARE, POSTPROCESS } = require('./hooks');const modelCatalog = require('../providers/model-catalog');const { buildBody, buildCompleteBody } = require('../providers/openai');++const { PROVIDERS: ENGINES } = require('../research');/** The last user message's text, for the queue's own record of what one item asks (never sent as-is). */function lastUserText(messages) {@@@ -429,19 +430,52 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa/** research: no model call. A developer app's run leaves the paid web search out (its DeepSeek calls are ours). */async function researchStep(step, input, wf, run, ctx) {-- let output;++ let output, answered = null, engineIndex = 0, candidates = [];try {// Search's own web ladder calls this endpoint for DeepSeek and Wikipedia. It must not be asked back// (Search -> AI research -> Search): every other caller gets the fabric first, Search keeps DeepSeek.const fromSearch = ctx.requesterType === 'service' && ctx.requesterId === 'search';-- output = await megasource().gather(input, { signal: ctx.signal, allowPaid: !ctx.appRun, allowFabric: !fromSearch });++ if (ctx.taskChoice?.task.kind === 'search') {++ // A search task runs the engines its choice names, in order: the owner's saved order when the owner's++ // default won, else the chosen engine and the task's fallbacks. A disabled engine is never called, and++ // DeepSeek (the one paid engine) is skipped under a cost ceiling, which has no per-run bound for it.++ const plan = ctx.taskChoice;++ if (plan.choice.source !== 'openvibe') throw new AiError(422, 'choice.unsupported', 'search uses OpenVibe search engines only');++ const engineOf = (model) => String(model).replace(/^search\//, '');++ const asked = engineOf(plan.choice.model);++ const ordered = plan.why === 'owner' && plan.search_engines.length++ ? plan.search_engines.filter((e) => e.enabled).map((e) => e.engine)++ : [...new Set([asked, ...plan.fallbacks.map((c) => engineOf(c.model))])];++ if (!ordered.length) throw new AiError(422, 'choice.unavailable', 'no search engine is enabled for this task');++ candidates = ordered.map((engine) => ({++ provider: engine, model: `search/${engine}`,++ eligible: engine !== 'deepseek' || plan.max_cost_usd == null,++ excluded_reason: engine === 'deepseek' && plan.max_cost_usd != null ? 'cost ceiling has no per-run bound' : null,++ }));++ const images = plan.task.kind === 'search' && plan.task.name !== 'search.web';++ for (const engine of ordered) {++ if (ENGINES.includes(engine) === false) throw new AiError(422, 'choice.unavailable', `unknown search engine ${engine}`);++ if (engine === 'deepseek' && plan.max_cost_usd != null) continue;++ const got = await megasource().gather({ ...input, providers: [engine], images }, { signal: ctx.signal, allowPaid: !ctx.appRun && plan.max_cost_usd == null, allowFabric: false });++ if (images ? got.images.length : got.sources.length) { output = got; answered = engine; engineIndex = ordered.indexOf(engine); break; }++ output = got;++ }++ if (answered == null) throw new AiError(402, 'choice.cost_ceiling', 'no enabled search engine answered within this task’s ceiling');++ } else output = await megasource().gather(input, { signal: ctx.signal, allowPaid: !ctx.appRun, allowFabric: !fromSearch });} catch (err) {if (err && err.status === 422) throw new AiError(422, 'input.invalid', err.message);throw err;}if (!output.sources.length && !output.images.length) throw new AiError(502, 'source.unavailable', `no source answered for "${output.query}"`, { providers: output.providers });// Recorded like a provider call: DeepSeek's per-call price counts against the cost quotas and the daily usage.-- const exec = { provider: 'research', model: 'megasource', usage: { input: 0, output: 0, cached: 0 }, cost: Number(output.usage && output.usage.cost_usd) || 0, synthetic: false, fallbackUsed: false, explain: null };++ const exec = { provider: 'research', model: 'megasource', usage: { input: 0, output: 0, cached: 0 }, cost: Number(output.usage && output.usage.cost_usd) || 0, synthetic: false, fallbackUsed: false, explain: ctx.taskChoice ? {++ // The contract's explain needs an objective, a reason, what was selected and the candidates; a search++ // task's candidates are its engines, and the one that answered is the selection.++ objective: 'exact',++ reasons: [`search task ${ctx.taskChoice.task.name}: ${answered} answered`],++ selected: `search/${answered}`, candidates,++ task_choice: { task: ctx.taskChoice.task.name, requested: ctx.taskChoice.choice, answered: { model: `search/${answered}`, source: 'openvibe' }, why: ctx.taskChoice.why, fallback_index: engineIndex },++ } : null };return { output, exec };}nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++const assert=require('assert');++const {boot,request,token,suite,tmpDir,ALL}=require('./helpers');++const {createChoices,choice}=require('../server/choices');++const {executeChoice}=require('../server/choices/execute');++const t=suite('task choices');let h;++const svc=token('design',ALL);++const subject='usr_01ARZ3NDEKTSV4RRFFQ69G5FAV';++const user=role=>token('person',[],{actorType:'user',sub:subject,extra:{subject_id:subject,role}});++const c=model=>({model,source:'openvibe'});++t.test('catalogue, personal isolation and admin role gate',async()=>{++ h=await boot({dir:tmpDir()});++ let r=await request(h.base,'GET','/api/v1/tasks');assert.equal(r.status,200,r.text);assert(r.body.tasks.some(t=>t.name==='design.icon'));++ r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:svc,body:c('a/b')});assert.equal(r.status,403);++ r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:user('user'),body:c('a/b')});assert.equal(r.status,200,r.text);++ r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('user')});assert.equal(r.status,403);++ r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:svc});assert.equal(r.status,403);++ r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('admin')});assert.equal(r.status,200,r.text);assert.equal(r.body.defaults['design.icon'].signed_in.model,'anthropic/claude-opus-5-5');++ r=await request(h.base,'POST','/api/v1/tasks',{tok:svc,body:{name:'wiki.foreign',kind:'text',sentence:'No.',default:c('a/b')}});assert.equal(r.status,422);++});++t.test('a site session saves a choice only from a first-party origin, and search preferences are kept',async()=>{++ const site=token('person',[],{aud:'openvibe.network',actorType:'user',sub:subject,extra:{subject_id:subject,role:'user'}});++ let r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:site,headers:{Origin:'https://openvibe.blog','Sec-Fetch-Site':'same-origin'},body:c('a/blog')});++ assert.equal(r.status,200,r.text);++ r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:site,headers:{Origin:'https://evil.example','Sec-Fetch-Site':'same-origin'},body:c('a/bad')});++ assert.deepEqual([r.status,r.body.code],[403,'capability.denied']);++ const prefs={only_my_keys:false,free_first:true,price_ceiling_usd_per_mtok:null,defaults:{text:'auto',code:'auto',vision:'auto',image:'auto',search:'search/commons'}};++ r=await request(h.base,'PUT','/api/v1/me/ai/preferences',{tok:user('user'),body:prefs});assert.equal(r.status,200,r.text);++ assert.equal(r.body.defaults.search,'search/commons');++ r=await request(h.base,'PUT','/api/v1/me/ai/preferences',{tok:user('user'),body:{...prefs,defaults:{...prefs.defaults,search:7}}});assert.equal(r.status,422,r.text);++});++t.test('the seeded search tasks resolve to an engine and refuse a model',async()=>{++ const store=createChoices({db:h.db,registry:h.registry,clock:{now:()=>Date.now()},preferences:{get:async()=>null}});++ let r=await store.resolve('search.web');assert.equal(r.task.kind,'search');assert.equal(r.why,'builtin');assert.equal(r.choice.model,'search/wikipedia');++ r=await store.resolve('wiki.article.images');assert.equal(r.choice.model,'search/commons');++ await assert.rejects(()=>store.save(subject,'search.web',c('anthropic/claude-opus-5-5')),e=>e.code==='choice.invalid');++ const value={signed_out:c('search/openverse'),signed_in:c('openverse/model'),fallbacks:[],image_model:null,search_engines:[],max_cost_usd:null};++ await assert.rejects(()=>store.putDefaults({defaults:[{task:'search.web',value}]},subject),e=>e.code==='choice.invalid');++});++t.test('a search task records a contract-valid run and the engines it considered',async()=>{++ const r=await request(h.base,'POST','/api/v1/runs?wait=10000',{tok:svc,body:{task:'search.web',input:{query:'SQLite'}}});++ assert.equal(r.status,201,r.text);++ assert.equal(r.body.run.status,'succeeded',r.text);++ for(const field of ['objective','reasons','selected','candidates']) assert(Object.hasOwn(r.body.run.explain,field),field);++ assert.equal(r.body.run.explain.selected,'search/wikipedia');++ assert.deepEqual(r.body.run.explain.candidates.map(c=>c.provider),['wikipedia']);++});++t.test('model choices reject extra fields and require machine identity',()=>{assert.throws(()=>choice({model:'x',source:'machines'}));assert.throws(()=>choice({...c('a/b'),api_key:'no'}));});++t.test('precedence, cache expires in 60s, audit and only-my-keys',async()=>{++ let now=100000,prefs={defaults:{code:'kind/model'}};++ const store=createChoices({db:h.db,registry:h.registry,clock:{now:()=>now},preferences:{get:async()=>prefs}});++ let r=await store.resolve('design.icon',{subject,choice:c('run/model')});assert.equal(r.why,'run');++ r=await store.resolve('design.icon',{subject});assert.equal(r.why,'saved');++ const second='usr_01ARZ3NDEKTSV4RRFFQ69G5FAW';r=await store.resolve('design.icon',{subject:second});assert.equal(r.why,'kind');++ prefs={defaults:{}};r=await store.resolve('design.icon',{subject:second});assert.equal(r.why,'owner');assert.equal(r.choice.model,'anthropic/claude-opus-5-5');++ r=await store.resolve('design.icon');assert.equal(r.choice.model,'openvibe/cheapest');++ r=await store.resolve('chat.reply');assert.equal(r.why,'builtin');++ await store.defaults();await h.db.prepare('UPDATE ai_task_defaults SET value = ? WHERE task = ?').run(JSON.stringify({signed_out:c('new/model'),signed_in:c('new/model'),fallbacks:[],max_cost_usd:0.1,search_engines:[]}), 'design.icon');++ assert.equal((await store.resolve('design.icon')).choice.model,'openvibe/cheapest');now+=60000;assert.equal((await store.resolve('design.icon')).choice.model,'new/model');++ prefs={only_my_keys:true,defaults:{}};await assert.rejects(()=>store.resolve('design.icon',{subject:second}),e=>e.code==='ai.needs_key');++ const value={signed_out:c('a/b'),signed_in:c('a/c'),fallbacks:[c('a/d')],image_model:null,search_engines:[],max_cost_usd:0.2};++ await store.putDefaults({defaults:[{task:'design.icon',value}]},subject);++ assert(await h.db.prepare("SELECT * FROM audit_log WHERE action = 'task.defaults'").get());++});++t.test('exact source routing, ordered fallback and no fallback after a delta',async()=>{++ const calls=[];const registry={listModels:async()=>[{provider_key:'a',model_key:'one',status:'active',type:'chat'},{provider_key:'a',model_key:'two',status:'active',type:'chat'}],listProviders:async()=>[{key:'a',status:'active'}]};++ const plan={task:{name:'test.run'},choice:c('a/one'),fallbacks:[c('a/two')],why:'owner',max_cost_usd:null};++ const args={registry,route:{},operation:'chat',req:{},ctx:{taskChoice:plan,choiceBudget:{used:0}},execute:async(route,op,req,ctx)=>{calls.push(ctx.exact.offers[0].model);if(calls.length===1)throw new Error('unavailable');return {model:'two'};}};++ const result=await executeChoice(args);assert.deepEqual(calls,['one','two']);assert.equal(result.explain.task_choice.fallback_index,1);++ calls.length=0;await assert.rejects(()=>executeChoice({...args,ctx:{...args.ctx,onDelta:()=>{}},execute:async(r,o,q,ctx)=>{calls.push('called');ctx.onDelta('hello');throw new Error('broken');}}));assert.equal(calls.length,1);++});++t.test('task run enforces an explicit model and records its source without caching',async()=>{++ await h.registry.upsertProvider({key:'choice-stub',kind:'stub',auth_mode:'none',capabilities:['chat','generate','complete'],default_model:'choice-one'});++ await h.registry.upsertModel({provider_key:'choice-stub',model_key:'choice-one',type:'chat',supports:{streaming:true,vision:true,json:true,tools:true}});++ let r=await request(h.base,'POST','/api/v1/runs?wait=5000',{tok:svc,body:{task:'design.icon',choice:c('choice-stub/choice-one'),input:{messages:[{role:'user',content:'Draw an icon'}]}}});++ assert.equal(r.status,201,r.text);assert.equal(r.body.run.status,'succeeded',r.text);assert.equal(r.body.run.provenance.model,'choice-one');assert.equal(r.body.run.answered.source,'openvibe');assert.equal(r.body.run.answered.model,'choice-one');assert.equal(r.body.run.choice.model,'choice-stub/choice-one');++ r=await request(h.base,'POST','/api/v1/runs?wait=5000',{tok:svc,body:{workflow:'ai.generate',choice:c('choice-stub/choice-one'),input:{prompt:'Every workflow accepts choice'}}});assert.equal(r.body.run.status,'succeeded',r.text);++});++t.test('missing price under a cost ceiling never dispatches',async()=>{++ let dispatched=false;++ await assert.rejects(()=>executeChoice({execute:async()=>{dispatched=true;},registry:{listModels:async()=>[{provider_key:'a',model_key:'one',type:'chat',status:'active'}],listProviders:async()=>[{key:'a',status:'active'}],getProvider:async()=>({kind:'openai'})},poolRates:async()=>null,route:{},operation:'chat',req:{maxTokens:100},ctx:{taskChoice:{task:{name:'test'},choice:c('a/one'),fallbacks:[],max_cost_usd:0.01},choiceBudget:{used:0}}}),e=>e.code==='choice.cost_ceiling');++ assert.equal(dispatched,false);++});++t.test('finish',async()=>h.stop());++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+# Tasks and model choices
+
+`GET /api/v1/tasks` lists registered tasks. A service with `ai.workflow.manage` registers its own namespace with `POST /api/v1/tasks`: `{name, kind, sentence, default: {model, source}, workflow?}`. The task may use its own workflow or a general AI workflow.
+
+`GET /api/v1/choices?task=design.icon` returns the resolved choice, its reason, the owner's recommendation, and options grouped under `openvibe`, `keys`, and `machines`. Personal options require the person's token. Measurements come from Exchange's catalogue and Report; missing prices, speed or quality remain null. Price estimates for text assume 1,000 input and 1,000 output tokens. Adult models require Network's existing adult confirmation and the run still enforces the content floor.
+
+`PUT /api/v1/me/choices/:task` accepts `{model, source, machine?}`. Source is `openvibe`, `keys`, or `machines`; only machines name a `dev_…` machine or `any`. A service token cannot save choices for a person. A saved choice is the person's across every first-party site: a site session token may write it from that site's own origin (the origins come from the service manifests in Contracts, never a hard-coded list), and Exchange proxies the person's session for the rest. No provider secrets leave AI.
+
+`POST /api/v1/runs` accepts either `{task, input, choice?}` or a workflow with `choice`. Resolution is run choice, saved task choice, kind preference, owner defaults, then the built-in. The response records `choice` and `answered`; explain includes the task, resolution reason and fallback index. Chosen runs bypass the answer cache. Retry retains their task and choice. Personal-machine calls use the existing machine transport and ownership checks in `server/machines.js`.
+
+`GET|PUT /api/v1/admin/task-defaults` requires a verified person with admin or owner role, never a service's manage capability. PUT accepts `{defaults: [{task, value}]}`. Each value has `signed_out`, `signed_in`, `fallbacks`, `image_model`, `search_engines: [{engine, enabled}]`, and `max_cost_usd`. Changes and their before/after values commit with the audit entry. Reads cache for 60 seconds; writes invalidate this process immediately.
+
+Owner fallback order is preserved. No fallback occurs after a stream emits text or a content-policy refusal. A finite ceiling reserves conservative token upper bounds across model steps and retries before dispatch; unknown prices are refused. Managed images have no per-image rate card in the current router, so a finite image ceiling fails closed until such a card exists. Own keys and machines are not charged to OpenVibe.
+
+The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+CREATE TABLE ai_tasks (
+ name text PRIMARY KEY, service text NOT NULL, kind text NOT NULL,
+ sentence text NOT NULL, builtin text NOT NULL, workflow text NOT NULL,
+ updated_at text NOT NULL
+);
+CREATE TABLE ai_task_choices (
+ subject text NOT NULL, task text NOT NULL REFERENCES ai_tasks(name),
+ choice text NOT NULL, updated_at text NOT NULL, PRIMARY KEY(subject, task)
+);
+CREATE TABLE ai_task_defaults (
+ task text PRIMARY KEY REFERENCES ai_tasks(name), value text NOT NULL,
+ updated_by text NOT NULL, updated_at text NOT NULL
+);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.175.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.175.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1543,9 +1543,9 @@
@@ -1543,9 +1543,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.373.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
- "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",
+ "version": "0.375.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",
+ "integrity": "sha512-+HI3XAkqkDW1ANR0SyGSXAYTko/z0SsDB9uIdswQCiYixoS/9M3RYGqPOFr+VevvZH9CG9hXvW0Tp3ecc0qaXw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.375.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.175.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.175.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -39,6 +39,9 @@ const TABLES = [
@@ -39,6 +39,9 @@ const TABLES = [
// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
// settings, so they go with the account; the row holds no key.
// settings, so they go with the account; the row holds no key.
{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
+ // The person's saved model for each AI task (rich-content plan §1): their own setting, and the row holds a model
+ // name and a source, never a key.
+ { table: 'ai_task_choices', subject: 'subject', file: 'ai-task-choices.json', columns: ['task', 'choice', 'updated_at'], order: 'updated_at' },
// L55 C3: the apps the person allowed to use their own key. Names only: no key, no hint, no material of any kind.
// L55 C3: the apps the person allowed to use their own key. Names only: no key, no hint, no material of any kind.
{ table: 'ai_key_grants', subject: 'subject', file: 'key-grants.json', columns: ['service', 'provider', 'created_at'], order: 'created_at' },
{ table: 'ai_key_grants', subject: 'subject', file: 'key-grants.json', columns: ['service', 'provider', 'created_at'], order: 'created_at' },
// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
@@ -32,7 +32,7 @@ const { AiError, sendError } = require('../util');
@@ -32,7 +32,7 @@ const { AiError, sendError } = require('../util');
const { CAPS, namespaceAllowed, appOrCap } = require('../auth');
const { CAPS, namespaceAllowed, appOrCap } = require('../auth');
const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');
const { appMayRun, appAttribution, DIRECT_OPS } = require('../apps');
-function runsRouter({ runs, registry, auth, config, log = console }) {
+function runsRouter({ choices = null, runs, registry, auth, config, log = console }) {
const r = express.Router();
const r = express.Router();
const create = auth.personOrCap(CAPS.runCreate);
const create = auth.personOrCap(CAPS.runCreate);
const read = auth.personOrCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
const read = auth.personOrCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
@@ -62,6 +62,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -62,6 +62,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
async function createAndRespond(req, res, body, waitMs) {
async function createAndRespond(req, res, body, waitMs) {
if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
+ if (body.task && !body.workflow) { const task = await choices?.task(String(body.task)); if (!task) throw new AiError(404, 'task.not_found', 'unknown task'); body = { ...body, workflow: task.workflow }; }
checkNamespace(req, String(body.workflow || ''));
checkNamespace(req, String(body.workflow || ''));
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
// BA1: the caller's wait goes with the run, so a background run answers inside it (a Flex tier that is
// BA1: the caller's wait goes with the run, so a background run answers inside it (a Flex tier that is
@@ -79,6 +80,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -79,6 +80,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
*/
*/
async function createAndStream(req, res, body, waitMs) {
async function createAndStream(req, res, body, waitMs) {
if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
+ if (body.task && !body.workflow) { const task = await choices?.task(String(body.task)); if (!task) throw new AiError(404, 'task.not_found', 'unknown task'); body = { ...body, workflow: task.workflow }; }
checkNamespace(req, String(body.workflow || ''));
checkNamespace(req, String(body.workflow || ''));
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
let opened = false;
let opened = false;
@@ -22,7 +22,7 @@ const pkg = require('../package.json');
@@ -22,7 +22,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
const ownerInputs = require('./owner-inputs');
-function createApp({ searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {
+function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -214,7 +214,7 @@ function createApp({ searchFetch = null, config, db, registry, pool, quotas, cac
@@ -214,7 +214,7 @@ function createApp({ searchFetch = null, config, db, registry, pool, quotas, cac
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
const ops = createOps({ db, registry, pool, quotas, cache, runs });
const ops = createOps({ db, registry, pool, quotas, cache, runs });
- app.use(runsRouter({ runs, registry, auth, config, log }));
+ app.use(runsRouter({ choices, runs, registry, auth, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
@@ -240,7 +240,7 @@ function createApp({ searchFetch = null, config, db, registry, pool, quotas, cac
@@ -240,7 +240,7 @@ function createApp({ searchFetch = null, config, db, registry, pool, quotas, cac
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
if (credentials && preferences && grants) {
if (credentials && preferences && grants) {
- app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ app.use(require('./me').meRouter({ choices, credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sessionCookies, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
}
}
// The person's human settings moved to Exchange; keep the JSON API here for first-party clients.
// The person's human settings moved to Exchange; keep the JSON API here for first-party clients.
app.use('/you', (req, res) => res.redirect(301, `${config.exchangeUrl}/privacy/keys${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));
app.use('/you', (req, res) => res.redirect(301, `${config.exchangeUrl}/privacy/keys${req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''}`));
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * A run started from an AI task: one source answers every model step, in the order the resolved choice names.
+ * A fallback is explicit and tried only when the one before it failed before it produced any text; a streamed delta
+ * or a content-floor refusal ends the run, because a second voice after the first one's words would be a lie.
+ */
+const { AiError } = require('../util');
+const { canonicalOf } = require('../exchange/catalog');
+const { pricesOf } = require('../providers/rate-cards');
+
+async function executeChoice({ execute, registry, poolRates, maxAttempts = 1, route, operation, req, ctx }) {
+ const plan = ctx.taskChoice;
+ const candidates = [plan.choice, ...plan.fallbacks];
+ let streamed = false;
+ let last = null;
+ const onDelta = ctx.onDelta ? (...args) => { streamed = true; return ctx.onDelta(...args); } : undefined;
+
+ for (let i = 0; i < candidates.length; i += 1) {
+ const picked = candidates[i];
+ try {
+ // One attempt: the choice owns the source, so the person's other keys, their kind default, a community
+ // offer and a soft preference are all set aside for this step.
+ const next = { ...ctx, taskChoice: null, selfCredentials: [], selfPreferences: null, communityOptIn: false, prefer: null, ...(onDelta ? { onDelta } : {}) };
+ let offers = [];
+
+ if (picked.source === 'machines') {
+ if (!ctx.onBehalfOf) throw new AiError(401, 'choice.sign_in', 'a machine choice needs a person');
+ next.credential = { provider: 'node', providerKey: `byo:self:${ctx.onBehalfOf}:node`, owner: 'self', subject: ctx.onBehalfOf, machine: picked.machine, models: { chat: picked.model } };
+ } else {
+ const isImage = operation === 'image';
+ const [rows, providers] = await Promise.all([registry.listModels(), registry.listProviders()]);
+ const byKey = new Map(providers.map((p) => [p.key, p]));
+ const dynamic = picked.model.startsWith('openvibe/');
+ for (const row of rows) {
+ const provider = byKey.get(row.provider_key);
+ if (!provider || provider.status !== 'active' || row.status !== 'active') continue;
+ if (isImage ? row.type !== 'image' : row.type === 'image') continue;
+ const id = canonicalOf(row, provider);
+ if (!dynamic && id !== picked.model && row.model_key !== picked.model) continue;
+ const adult = Boolean(row.metadata?.uncensored) || /uncensor|abliterat/i.test(`${id} ${row.metadata?.variant || ''}`);
+ // A dynamic choice picks among the standard offers; an uncensored model is never chosen in silence.
+ if (dynamic && adult) continue;
+ if (adult) next.contentPolicy = 'uncensored';
+ offers.push({ provider: provider.key, model: row.model_key });
+ }
+ if (picked.source === 'keys') {
+ // The person's own key: only a provider they have a key for may answer, and their key pays.
+ const provider = picked.model.split('/')[0];
+ const cred = (ctx.selfCredentials || []).find((k) => k.provider === provider || offers.some((o) => o.provider === k.provider));
+ if (!cred) throw new AiError(422, 'ai.needs_key', 'add your key for this model on Exchange');
+ const model = (offers.find((o) => o.provider === cred.provider) || {}).model || picked.model.slice(provider.length + 1);
+ next.credential = { ...cred, models: { chat: model } };
+ } else {
+ if (!offers.length) throw new AiError(422, 'choice.unavailable', `no deployment of ${picked.model} is available`);
+ if (picked.model === 'openvibe/cheapest') {
+ const priced = await Promise.all(offers.map(async (o) => {
+ const card = await poolRates(o.provider, o.model);
+ if (!card) return { o, price: Infinity };
+ const prices = pricesOf(card);
+ return { o, price: prices.in + prices.out };
+ }));
+ offers = priced.sort((a, b) => a.price - b.price).map((x) => x.o);
+ }
+ // openvibe/auto means the network's own router decides; every other choice pins exactly its model.
+ if (picked.model !== 'openvibe/auto') next.exact = { id: picked.model, offers };
+ }
+ }
+
+ if (plan.max_cost_usd != null && picked.source === 'openvibe') {
+ if (operation === 'image') throw new AiError(402, 'choice.cost_ceiling', 'a priced image offer needs a per-image upper bound before it can use this ceiling');
+ const remaining = plan.max_cost_usd - (ctx.choiceBudget.used || 0);
+ const inputTokens = Buffer.byteLength(JSON.stringify(req));
+ const outputTokens = Number(req.maxTokens || (req.openai && req.openai.max_tokens) || 4096);
+ // Reserve a conservative upper bound for this step, attempts included. An unrated paid model is refused
+ // rather than guessed at, and a local or stub provider costs nothing.
+ const rated = await Promise.all(offers.map(async (o) => {
+ const card = await poolRates(o.provider, o.model);
+ const prices = card ? pricesOf(card) : null;
+ const provider = prices ? null : await registry.getProvider(o.provider);
+ const cost = prices ? maxAttempts * (inputTokens * prices.in + outputTokens * prices.out) / 1e6
+ : provider && ['local', 'stub'].includes(provider.kind) ? 0 : Infinity;
+ return { ...o, cost };
+ }));
+ const admitted = rated.filter((o) => Number.isFinite(o.cost) && o.cost <= remaining);
+ if (!admitted.length) throw new AiError(402, 'choice.cost_ceiling', 'no selected model fits the remaining task cost ceiling');
+ // Every deployment of a pinned model can be attempted, so all of their bounds are reserved together.
+ const limited = [];
+ let reserved = 0;
+ for (const o of admitted) {
+ if (reserved + o.cost > remaining) continue;
+ limited.push(o);
+ reserved += o.cost;
+ }
+ ctx.choiceBudget.used = (ctx.choiceBudget.used || 0) + reserved;
+ next.exact = { id: picked.model, offers: limited.sort((a, b) => a.cost - b.cost).map(({ provider, model }) => ({ provider, model })) };
+ }
+
+ const forwarded = onDelta && req.onDelta ? { ...req, onDelta: (...args) => { streamed = true; return req.onDelta(...args); } } : req;
+ const answer = await execute(route, operation, forwarded, next);
+ answer.explain = {
+ ...(answer.explain || {}),
+ task_choice: {
+ task: plan.task.name,
+ requested: plan.choice,
+ answered: { model: answer.model || picked.model, source: picked.source, ...(picked.machine ? { machine: picked.machine } : {}) },
+ why: plan.why,
+ fallback_index: i,
+ },
+ };
+ answer.fallbackUsed = answer.fallbackUsed || i > 0;
+ return answer;
+ } catch (err) {
+ last = err;
+ if (streamed || (ctx.signal && ctx.signal.aborted) || /^policy\./.test(err.code || '') || err.status === 403) throw err;
+ }
+ }
+ throw last || new AiError(503, 'choice.unavailable', 'no choice could answer');
+}
+
+module.exports = { executeChoice };
new file mode 100644
new file mode 100644
@@ -0,0 +1,261 @@
@@ -0,0 +1,261 @@
+'use strict';
+/**
+ * AI tasks and choices (rich-content plan §1): one system for which model does what, owned by OpenVibe.AI and managed
+ * on Exchange. A task is a named piece of AI work with a kind; a choice names a model and the source it comes from
+ * (OpenVibe's supply, the person's own key, or one of their machines through Link).
+ *
+ * Resolution, first match wins: the run's own choice, the person's saved choice for the task, their default for the
+ * kind (ai.preferences.defaults), the owner's default for the task, and the network's built-in. The owner's defaults
+ * are read through a 60-second cache and every change is audited with its before and after.
+ */
+const { AiError, iso } = require('../util');
+const { PROVIDERS: ENGINES } = require('../research');
+
+const KINDS = ['text', 'code', 'vision', 'image', 'search'];
+const SOURCES = ['openvibe', 'keys', 'machines'];
+const GENERAL = ['ai.generate', 'ai.complete', 'ai.image', 'research.gather'];
+const MAX_COST_USD = 1000;
+const CACHE_MS = 60000;
+
+const built = (model = 'openvibe/auto') => ({ model, source: 'openvibe' });
+
+// The tasks the plan names, seeded once. A service registers its own with POST /api/v1/tasks.
+const SEEDS = [
+ ['blog.devblog.write', 'text', 'Write a named devblog from the team’s changes.', 'ai.generate'],
+ ['blog.devblog.art', 'image', 'Draw title art for a devblog.', 'ai.image'],
+ ['wiki.article.write', 'text', 'Write a sourced wiki article.', 'ai.generate'],
+ ['wiki.article.images', 'search', 'Find licensed pictures for a wiki article.', 'research.gather'],
+ ['design.icon', 'code', 'Draw an original SVG icon.', 'ai.chat'],
+ ['chat.reply', 'text', 'Answer a chat message.', 'ai.complete'],
+ ['image.generate', 'image', 'Make an image from a description.', 'ai.image'],
+ ['search.web', 'search', 'Find sources on the web.', 'research.gather'],
+ ['search.images', 'search', 'Find licensed images.', 'research.gather'],
+ ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'ai.generate'],
+];
+
+const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
+const MACHINE_RE = /^(any|dev_[0-9A-HJKMNP-TV-Z]{26})$/;
+const TASK_RE = /^[a-z][a-z0-9_-]*\.[a-z0-9_.-]{1,100}$/;
+const ENGINE_RE = /^[a-z][a-z0-9_-]{0,39}$/;
+
+/** A search task answers from a search engine: its choice is `search/<engine>` from the research providers. */
+function checkSearchChoice(kind, picked) {
+ if (kind !== 'search') return;
+ const engine = String(picked.model).replace(/^search\//, '');
+ if (!picked.model.startsWith('search/') || !ENGINES.includes(engine)) {
+ throw new AiError(422, 'choice.invalid', `a search task names an engine: ${ENGINES.map((e) => `search/${e}`).join(', ')}`);
+ }
+}
+
+/** One model and its source, or 422. A machine choice names the machine; no other source does. */
+function choice(value) {
+ const usable = value && typeof value === 'object' && !Array.isArray(value);
+ const extra = usable ? Object.keys(value).filter((k) => !['model', 'source', 'machine'].includes(k)) : null;
+ if (!usable || extra.length || !SOURCES.includes(value.source) || typeof value.model !== 'string' || !MODEL_RE.test(value.model)) {
+ throw new AiError(422, 'choice.invalid', 'a choice needs a model and a source (openvibe, keys or machines)');
+ }
+ if (value.source === 'machines' ? !MACHINE_RE.test(value.machine || '') : value.machine != null) {
+ throw new AiError(422, 'choice.invalid', 'only a machine choice names a machine');
+ }
+ return { model: value.model, source: value.source, ...(value.machine ? { machine: value.machine } : {}) };
+}
+
+/** The owner's default for one task (ai.task-defaults@1), checked field by field, or 422. */
+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))
+ || (value.fallbacks != null && !Array.isArray(value.fallbacks))) {
+ throw new AiError(422, 'defaults.invalid', 'invalid task defaults');
+ }
+ const out = {
+ signed_out: choice(value.signed_out),
+ signed_in: choice(value.signed_in),
+ fallbacks: (value.fallbacks || []).map(choice),
+ image_model: value.image_model == null ? null : choice(value.image_model),
+ search_engines: value.search_engines == null ? [] : value.search_engines,
+ max_cost_usd: value.max_cost_usd == null ? null : value.max_cost_usd,
+ };
+ const all = [out.signed_out, out.signed_in, ...out.fallbacks, ...(out.image_model ? [out.image_model] : [])];
+ if (all.some((c) => c.source !== 'openvibe')) throw new AiError(422, 'defaults.invalid', 'the owner’s defaults use OpenVibe supply');
+ if (out.fallbacks.length > 10) throw new AiError(422, 'defaults.invalid', 'at most ten fallbacks');
+ if (!Array.isArray(out.search_engines) || out.search_engines.length > 10
+ || out.search_engines.some((e) => !e || !ENGINE_RE.test(e.engine) || typeof e.enabled !== 'boolean')
+ || new Set(out.search_engines.map((e) => e.engine)).size !== out.search_engines.length) {
+ throw new AiError(422, 'defaults.invalid', 'invalid search engine order');
+ }
+ if (out.max_cost_usd !== null && (typeof out.max_cost_usd !== 'number' || !Number.isFinite(out.max_cost_usd)
+ || out.max_cost_usd < 0 || out.max_cost_usd > MAX_COST_USD)) {
+ throw new AiError(422, 'defaults.invalid', `max_cost_usd must be 0–${MAX_COST_USD} or null`);
+ }
+ return out;
+}
+
+function createChoices({ db, registry, preferences, credentials, machines, catalog, adultStatus, ratings = async () => [], clock = { now: () => Date.now() } }) {
+ let cached = null;
+ let cachedAt = 0;
+
+ const decode = (r) => r && ({ name: r.name, service: r.service, kind: r.kind, sentence: r.sentence, default: JSON.parse(r.builtin), workflow: r.workflow });
+ const task = async (name) => decode(await db.prepare('SELECT * FROM ai_tasks WHERE name = ?').get(name));
+ const tasks = async () => (await db.prepare('SELECT * FROM ai_tasks ORDER BY name').all()).map(decode);
+
+ async function seed() {
+ for (const [name, kind, sentence, workflow] of SEEDS) {
+ const model = kind === 'search' ? (name === 'search.web' ? 'search/wikipedia' : 'search/commons') : 'openvibe/auto';
+ await db.prepare('INSERT INTO ai_tasks (name, service, kind, sentence, builtin, workflow, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (name) DO NOTHING')
+ .run(name, name.split('.')[0], kind, sentence, JSON.stringify(built(model)), workflow, iso(clock.now()));
+ }
+ // Design's icon generator is the first consumer and what it already does becomes the owner's default:
+ // signed out the cheapest good model, signed in Claude.
+ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
+ .run('design.icon', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('anthropic/claude-opus-5-5'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.5 }), 'seed', iso(clock.now()));
+ }
+
+ async function register(body, service) {
+ if (!body || !TASK_RE.test(body.name || '') || body.name.split('.')[0] !== service
+ || !KINDS.includes(body.kind) || typeof body.sentence !== 'string' || !body.sentence.trim() || body.sentence.length > 300) {
+ throw new AiError(422, 'task.invalid', 'register a named task in your service namespace with a kind and a sentence');
+ }
+ const c = choice(body.default);
+ const workflow = body.workflow || body.name;
+ if (!workflow.startsWith(`${service}.`) && !GENERAL.includes(workflow)) {
+ throw new AiError(422, 'task.invalid', 'the workflow must belong to the service or be a general AI workflow');
+ }
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO ai_tasks (name, service, kind, sentence, builtin, workflow, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (name) DO UPDATE SET kind = excluded.kind, sentence = excluded.sentence, builtin = excluded.builtin, workflow = excluded.workflow, updated_at = excluded.updated_at`)
+ .run(body.name, service, body.kind, body.sentence, JSON.stringify(c), workflow, iso(clock.now()));
+ await registry.audit(service, 'task.register', 'task', body.name, { metadata: { kind: body.kind } });
+ });
+ return task(body.name);
+ }
+
+ async function defaults() {
+ if (!cached || clock.now() - cachedAt >= CACHE_MS) {
+ cached = Object.fromEntries((await db.prepare('SELECT * FROM ai_task_defaults ORDER BY task').all()).map((r) => [r.task, JSON.parse(r.value)]));
+ cachedAt = clock.now();
+ }
+ return structuredClone(cached);
+ }
+
+ async function putDefaults(body, actor) {
+ if (!body || !Array.isArray(body.defaults) || body.defaults.length > 200) throw new AiError(422, 'defaults.invalid', 'defaults must be a list of task settings');
+ const list = [];
+ for (const row of body.defaults) {
+ const t = await task(row.task);
+ if (!t) throw new AiError(404, 'task.not_found', 'unknown task');
+ const value = defaultValue(row.value);
+ for (const c of [value.signed_out, value.signed_in, ...value.fallbacks]) checkSearchChoice(t.kind, c);
+ list.push([row.task, value]);
+ }
+ await db.tx(async () => {
+ for (const [name, value] of list) {
+ const before = await db.prepare('SELECT value FROM ai_task_defaults WHERE task = ?').get(name);
+ await db.prepare(`INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT (task) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
+ .run(name, JSON.stringify(value), actor, iso(clock.now()));
+ await registry.audit(actor, 'task.defaults', 'task', name, { metadata: { before: before ? JSON.parse(before.value) : null, after: value } });
+ }
+ });
+ cached = null;
+ return defaults();
+ }
+
+ async function save(subject, name, value) {
+ const t = await task(name);
+ if (!t) throw new AiError(404, 'task.not_found', 'unknown task');
+ const c = choice(value);
+ checkSearchChoice(t.kind, c);
+ await db.prepare(`INSERT INTO ai_task_choices (subject, task, choice, updated_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT (subject, task) DO UPDATE SET choice = excluded.choice, updated_at = excluded.updated_at`)
+ .run(subject, name, JSON.stringify(c), iso(clock.now()));
+ return c;
+ }
+
+ /** The resolved choice for a task: first match wins, and `why` says which step picked it. */
+ async function resolve(name, { subject = null, choice: explicit = null } = {}) {
+ let t = await task(name);
+ if (!t) {
+ // A workflow without a registered task resolves as one: the caller may use any workflow.
+ const wf = await registry.activeWorkflow(name);
+ if (!wf) throw new AiError(404, 'task.not_found', `unknown task ${name}`);
+ t = { name, kind: wf.steps.some((s) => s.kind === 'image') ? 'image' : 'text', sentence: wf.name, default: built(), workflow: name };
+ }
+ const owner = (await defaults())[name] || null;
+ const saved = subject ? await db.prepare('SELECT choice FROM ai_task_choices WHERE subject = ? AND task = ?').get(subject, name) : null;
+ const prefs = subject && preferences ? await preferences.get(subject) : null;
+ const perKind = prefs && prefs.defaults ? prefs.defaults[t.kind] : null;
+ const fromOwner = (owner && (t.kind === 'image' && owner.image_model ? owner.image_model : subject ? owner.signed_in : owner.signed_out)) || null;
+ const picked = explicit ? { value: choice(explicit), why: 'run' }
+ : saved ? { value: JSON.parse(saved.choice), why: 'saved' }
+ : perKind && perKind !== 'auto' ? { value: built(perKind), why: 'kind' }
+ : fromOwner ? { value: fromOwner, why: 'owner' }
+ : { value: t.default, why: 'builtin' };
+ checkSearchChoice(t.kind, picked.value);
+ if (picked.value.source !== 'openvibe' && !subject) throw new AiError(401, 'choice.sign_in', 'sign in to use your keys or machines');
+ if (prefs && prefs.only_my_keys && picked.value.source === 'openvibe') throw new AiError(422, 'ai.needs_key', 'only_my_keys is on; choose one of your keys or machines');
+ return {
+ task: t, choice: picked.value, why: picked.why, only_my_keys: Boolean(prefs && prefs.only_my_keys),
+ // A fallback is the owner's list, so it applies only when the owner's default supplied the choice.
+ fallbacks: picked.why === 'owner' ? owner.fallbacks : [],
+ max_cost_usd: owner ? owner.max_cost_usd : null,
+ search_engines: owner ? owner.search_engines : [],
+ };
+ }
+
+ /** What the chip's sheet shows: the resolved choice, why, and the options grouped by source. */
+ async function get(name, { subject = null } = {}) {
+ const resolved = await resolve(name, { subject });
+ if (resolved.task.kind === 'search') {
+ return {
+ ...resolved, recommended: resolved.choice, adult: false,
+ options: {
+ openvibe: ENGINES.map((engine) => ({ model: `search/${engine}`, source: 'openvibe', name: engine, maker: null, price_estimate_usd: engine === 'deepseek' ? null : 0, speed_ms: null, quality: null, adult: false })),
+ keys: [], machines: [],
+ },
+ };
+ }
+ const [qualityRows, adult, raw, keys, own] = await Promise.all([
+ ratings(),
+ subject && adultStatus ? adultStatus.adultFor(subject).catch(() => false) : false,
+ catalog.models({ kind: resolved.task.kind === 'image' ? 'image' : 'chat' }),
+ subject && credentials ? credentials.listSelf(subject) : [],
+ subject && machines ? machines.mine(subject).catch(() => []) : [],
+ ]);
+ const options = { openvibe: [], keys: [], machines: [] };
+ for (const m of raw.models) {
+ if (m.uncensored && !adult) continue;
+ if (resolved.task.kind === 'vision' && !(m.capabilities && m.capabilities.vision)) continue;
+ // An estimate for one run: 1,000 input and 1,000 output tokens, the plan's own measure.
+ const prices = m.offers.filter((o) => o.price).map((o) => (o.price.in + o.price.out) / 1000);
+ const speeds = m.offers.map((o) => o.speed && o.speed.p50_ms).filter((n) => n != null).sort((a, b) => a - b);
+ const rated = qualityRows.find((r) => r.id === m.canonical);
+ const row = {
+ model: m.canonical, source: 'openvibe', name: m.name, maker: m.canonical.split('/')[0],
+ price_estimate_usd: prices.length ? Math.min(...prices) : null,
+ speed_ms: speeds.length ? speeds[0] : null,
+ quality: rated && rated.rating ? (rated.rating.value ?? rated.rating.rating ?? null) : null,
+ adult: Boolean(m.uncensored),
+ };
+ options.openvibe.push(row);
+ if (keys.some((key) => m.offers.some((o) => o.provider === key.provider))) options.keys.push({ ...row, source: 'keys', price_estimate_usd: 0 });
+ }
+ for (const machine of own) {
+ if (!machine.online) continue;
+ for (const model of machine.models) {
+ options.machines.push({ model, source: 'machines', machine: machine.id, name: model, maker: null, machine_name: machine.name, price_estimate_usd: 0, speed_ms: null, quality: null, adult: false });
+ }
+ }
+ const owner = (await defaults())[name];
+ const recommended = owner ? (subject ? owner.signed_in : owner.signed_out) : resolved.task.default;
+ // A dynamic choice (openvibe/auto, openvibe/cheapest) shows the cheapest priced model it may use, so the chip
+ // names a real model before it runs; the run itself still goes through the router.
+ const priced = options.openvibe.filter((o) => o.price_estimate_usd != null && !o.adult).sort((a, b) => a.price_estimate_usd - b.price_estimate_usd);
+ const concrete = (c) => !c.model.startsWith('openvibe/') || !priced.length ? c : choice({ model: priced[0].model, source: 'openvibe' });
+ return { ...resolved, choice: concrete(resolved.choice), recommended: concrete(recommended), options, adult };
+ }
+
+ return { seed, tasks, task, register, defaults, putDefaults, save, resolve, get };
+}
+
+module.exports = { createChoices, choice, defaultValue, SEEDS, KINDS, SOURCES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+'use strict';
+// Public Report evidence. Unknown stays unknown; a failed read never invents a ranking.
+function createRatings({fetchImpl=globalThis.fetch,clock={now:()=>Date.now()}}={}) {
+ let rows=[],at=0;
+ return async()=>{
+ if(clock.now()-at<120000)return rows;
+ try {const res=await fetchImpl('https://openvibe.report/api/v1/ai/leaderboard?limit=1000',{headers:{Accept:'application/json'},signal:AbortSignal.timeout(3000)});if(res.ok){const body=await res.json();rows=Array.isArray(body.rows)?body.rows:[];at=clock.now();}}
+ catch { /* measurements are optional */ }
+ return rows;
+ };
+}
+module.exports={createRatings};
@@ -119,6 +119,8 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -119,6 +119,8 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// The Exchange's routing presets and the receipts of the answered requests (server/exchange/).
// The Exchange's routing presets and the receipts of the answered requests (server/exchange/).
const presets = require('./exchange/presets').createPresets({ db, clock, log });
const presets = require('./exchange/presets').createPresets({ db, clock, log });
const receipts = require('./exchange/receipts').createReceipts({ db, clock, log });
const receipts = require('./exchange/receipts').createReceipts({ db, clock, log });
+ const choices = require('./choices').createChoices({ db, registry, preferences, credentials, machines, adultStatus, ratings: require('./choices/ratings').createRatings({ fetchImpl, clock }), clock, catalog: require('./exchange/catalog').createCatalog({ db, registry, pool, config, env, clock }) });
+ await choices.seed();
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
ensureCredits();
ensureCredits();
// A stable facade: the store may create the Credits manager after boot (AI_CREDITS turned on live), and the runs
// A stable facade: the store may create the Credits manager after boot (AI_CREDITS turned on live), and the runs
@@ -149,7 +151,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -149,7 +151,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// BA3: the runs and the batcher need each other (a batch-class run is created first, then queued), so
// BA3: the runs and the batcher need each other (a batch-class run is created first, then queued), so
// the runs hold this facade and the batcher fills it in below, before any request can arrive.
// the runs hold this facade and the batcher fills it in below, before any request can arrive.
const batchRef = {};
const batchRef = {};
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef,
+ const runs = createRuns({ choices, db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse, webhooks, batch: batchRef,
// BA2: a run that waits for a provider's batch answer is not interrupted by a restart: it stays
// BA2: a run that waits for a provider's batch answer is not interrupted by a restart: it stays
// queued until the batcher finishes it (the provider is still working on it).
// queued until the batcher finishes it (the provider is still working on it).
batchWaiting: async (runId) => {
batchWaiting: async (runId) => {
@@ -203,7 +205,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -203,7 +205,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });
+ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* The key is never returned (a four-character hint only), never logged and never put in an error.
* The key is never returned (a four-character hint only), never logged and never put in an error.
*/
*/
const express = require('express');
const express = require('express');
-const { http } = require('openvibe-contracts');
+const { http, services } = require('openvibe-contracts');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { AiError } = require('./util');
const { AiError } = require('./util');
const { bearer } = require('./auth');
const { bearer } = require('./auth');
@@ -40,6 +40,13 @@ function audiencesOf(claims) {
@@ -40,6 +40,13 @@ function audiencesOf(claims) {
return (Array.isArray(aud) ? aud : aud == null ? [] : [aud]).map(String);
return (Array.isArray(aud) ? aud : aud == null ? [] : [aud]).map(String);
}
}
+/**
+ * Every first-party site's origin, from Contracts' service manifests. A person's saved choice is theirs on any of
+ * our sites (Design's Draw it, Blog's editor, Chat), so a site's own session token may write it from its own origin.
+ * A service outside the manifests is never trusted.
+ */
+const SITE_ORIGINS = new Set((services.manifests || []).map((m) => m.publicOrigin).filter(Boolean).map((u) => { try { return new URL(u).origin; } catch { return null; } }).filter(Boolean));
+
/**
/**
* The same-origin check for the person-session cookie (the console's csrfOk rule, without a form token).
* The same-origin check for the person-session cookie (the console's csrfOk rule, without a form token).
* A write with neither Origin nor Sec-Fetch-Site is refused: a site can make a bare request with the cookie, so the
* A write with neither Origin nor Sec-Fetch-Site is refused: a site can make a bare request with the cookie, so the
@@ -53,6 +60,7 @@ function sameOrigin(req, config, { write = false } = {}) {
@@ -53,6 +60,7 @@ function sameOrigin(req, config, { write = false } = {}) {
if (origin) {
if (origin) {
try {
try {
const actual = new URL(origin).origin;
const actual = new URL(origin).origin;
+ if (req.path.startsWith('/api/v1/me/choices/') && SITE_ORIGINS.has(actual)) return true;
return [config.baseUrl, config.exchangeUrl].some((base) => {
return [config.baseUrl, config.exchangeUrl].some((base) => {
try { return actual === new URL(base).origin; } catch { return false; }
try { return actual === new URL(base).origin; } catch { return false; }
});
});
@@ -61,7 +69,7 @@ function sameOrigin(req, config, { write = false } = {}) {
@@ -61,7 +69,7 @@ function sameOrigin(req, config, { write = false } = {}) {
return true;
return true;
}
}
-function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sessionCookies = null, sendError }) {
+function meRouter({ choices = null, credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sessionCookies = null, sendError }) {
const r = express.Router();
const r = express.Router();
/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
@@ -93,8 +101,11 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
@@ -93,8 +101,11 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
// request came from Exchange (which holds the user's token in its httpOnly session),
// request came from Exchange (which holds the user's token in its httpOnly session),
// or when the person minted an AI-audience token directly. No service token can enter this route.
// or when the person minted an AI-audience token directly. No service token can enter this route.
const audiences = audiencesOf(claims);
const audiences = audiencesOf(claims);
+ // A person's saved choice is theirs across our sites, so a site session may write it from that site's
+ // own origin; every other setting is Exchange's.
+ const siteChoice = req.path.startsWith('/api/v1/me/choices/') && req.get('origin') && SITE_ORIGINS.has(new URL(req.get('origin')).origin);
const exchangeWrite = audiences.includes('openvibe.network') && sameOrigin(req, config, { write: true })
const exchangeWrite = audiences.includes('openvibe.network') && sameOrigin(req, config, { write: true })
- && req.get('origin') && new URL(req.get('origin')).origin === new URL(config.exchangeUrl).origin;
+ && req.get('origin') && (new URL(req.get('origin')).origin === new URL(config.exchangeUrl).origin || siteChoice);
if (write && !audiences.includes(WRITE_AUDIENCE) && !exchangeWrite) {
if (write && !audiences.includes(WRITE_AUDIENCE) && !exchangeWrite) {
return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });
return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });
}
}
@@ -127,6 +138,20 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
@@ -127,6 +138,20 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }
try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }
};
};
+ if (choices) {
+ const reply = fn => async (req, res) => { try { res.set('Cache-Control', 'private, no-store').json(await fn(req)); } catch (err) { sendError(res, err, req.ov); } };
+ const optional = async (req, res, next) => { const token = bearer(req); if (!token) return next(); try { const service = await auth.verify(token); if (service?.ok) return next(); } catch {} return guard(req, res, next); };
+ const admin = (req, res, next) => ['admin', 'owner'].includes(req.principal?.claims?.role) ? next() : res.status(403).json({ code: 'role.admin_required', detail: 'An admin role is required.' });
+ r.get('/api/v1/tasks', reply(async () => ({ tasks: await choices.tasks() })));
+ r.post('/api/v1/tasks', auth.requireCap('ai.workflow.manage'), reply(async req => {
+ if (req.principal.subject.type !== 'service') throw new AiError(403, 'capability.denied', 'only services register tasks');
+ return { task: await choices.register(req.body, req.principal.subject.id) };
+ }));
+ r.get('/api/v1/choices', optional, reply(req => choices.get(String(req.query.task || ''), { subject: req.principal?.subject?.id || null })));
+ r.put('/api/v1/me/choices/:task', guard, reply(async req => ({ choice: await choices.save(personOf(req), req.params.task, req.body) })));
+ r.get('/api/v1/admin/task-defaults', guard, admin, reply(async () => ({ tasks: await choices.tasks(), defaults: await choices.defaults() })));
+ r.put('/api/v1/admin/task-defaults', guard, admin, reply(async req => ({ defaults: await choices.putDefaults(req.body, personOf(req)) })));
+ }
r.get('/api/v1/me/search-credentials', guard, run(async req => ({ engines: await credentials.listSearch(personOf(req)) })));
r.get('/api/v1/me/search-credentials', guard, run(async req => ({ engines: await credentials.listSearch(personOf(req)) })));
r.put('/api/v1/me/search-credentials/:engine', guard, run(async req => {
r.put('/api/v1/me/search-credentials/:engine', guard, run(async req => {
const out = await credentials.putSearch(personOf(req), req.params.engine, req.body);
const out = await credentials.putSearch(personOf(req), req.params.engine, req.body);
@@ -63,6 +63,8 @@ const runBody = (required = []) => ({
@@ -63,6 +63,8 @@ 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.' }),
+ choice: ref('Choice'),
input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
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=…' },
@@ -81,6 +83,28 @@ function route(method, p, operation) {
@@ -81,6 +83,28 @@ function route(method, p, operation) {
(PATHS[p] = PATHS[p] || {})[method] = operation;
(PATHS[p] = PATHS[p] || {})[method] = operation;
}
}
+// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────
+route('get', '/tasks', {
+ operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],
+ description: 'Public. A task names its service, its kind (text, code, vision, image, search), one sentence and its built-in default choice. A service registers its own with POST /api/v1/tasks.',
+ responses: { 200: json('The catalogue', { type: 'object', required: ['tasks'], properties: { tasks: arrayOf(ref('Task')) } }), ...errors([429]) },
+});
+route('post', '/tasks', {
+ operationId: 'registerTask', summary: 'Register a task in the caller’s own namespace', tags: ['choices'], security: SERVICE,
+ description: 'ai.workflow.manage. Only a service token registers tasks, and only under its own namespace: the workflow is the service’s own or a general AI workflow (ai.generate, ai.complete, ai.image, research.gather). A registration is audited.',
+ requestBody: body({ type: 'object', required: ['name', 'kind', 'sentence', 'default'], properties: {
+ name: str({ pattern: '^[a-z][a-z0-9_-]*\\.[a-z0-9_.-]{1,100}$' }), kind: str({ enum: ['text', 'code', 'vision', 'image', 'search'] }),
+ sentence: str({ maxLength: 300 }), default: ref('Choice'), workflow: str(),
+ } }),
+ responses: { 200: json('The registered task', { type: 'object', required: ['task'], properties: { task: ref('Task') } }), 422: problem, ...errors([400, 401, 403]) },
+});
+route('get', '/choices', {
+ operationId: 'getChoice', summary: 'The resolved model for a task, and the options to choose from', tags: ['choices'], security: [],
+ description: 'The person\'s own token is optional: with none, the signed-out default resolves. Returns the choice, why that step picked it (run, saved, kind, owner, builtin), the owner\'s recommendation and the options grouped by source (openvibe, keys, machines). Price is an estimate for one run; a missing measurement stays null, never zero.',
+ parameters: [query('task', str({ pattern: '^[a-z][a-z0-9_-]*\\.[a-z0-9_.-]{1,100}$' }), 'The task (design.icon). Omitted, the route answers 404.')],
+ responses: { 200: json('The resolved choice and the options', ref('ChoiceView')), 401: problem, 404: problem, 422: problem, ...errors([429]) },
+});
+
// ── The apps a person allows to use their own key (L55 C3) ───────────────────────────────────
// ── The apps a person allows to use their own key (L55 C3) ───────────────────────────────────
route('get', '/allowed-keys/{subject}', {
route('get', '/allowed-keys/{subject}', {
operationId: 'allowedKeys', summary: 'Which providers this person allowed this service to use their own key for', tags: ['keys'], security: SERVICE,
operationId: 'allowedKeys', summary: 'Which providers this person allowed this service to use their own key for', tags: ['keys'], security: SERVICE,
@@ -745,7 +769,7 @@ function openapi() {
@@ -745,7 +769,7 @@ function openapi() {
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.AI', email: '[email protected]' },
},
},
servers: [{ url: SITE }],
servers: [{ url: SITE }],
- tags: ['runs', 'operations', 'actions', 'exchange', 'presets', 'receipts'].map((name) => ({ name })),
+ tags: ['runs', 'operations', 'actions', 'choices', 'exchange', 'presets', 'receipts'].map((name) => ({ name })),
components: {
components: {
securitySchemes: {
securitySchemes: {
bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or user token for audience openvibe.ai carrying the route\'s capability (ai.run.create, ai.run.read, ai.actions.read)' },
bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'A Network service or user token for audience openvibe.ai carrying the route\'s capability (ai.run.create, ai.run.read, ai.actions.read)' },
@@ -753,6 +777,26 @@ function openapi() {
@@ -753,6 +777,26 @@ function openapi() {
},
},
schemas: {
schemas: {
SubjectRef: { type: 'object', required: ['type', 'id'], properties: { type: str(), id: str() } },
SubjectRef: { type: 'object', required: ['type', 'id'], properties: { type: str(), id: str() } },
+ Choice: { type: 'object', required: ['model', 'source'], additionalProperties: false, properties: {
+ model: str({ pattern: '^[a-zA-Z0-9._:/@+\\-]{1,200}$' }),
+ source: str({ enum: ['openvibe', 'keys', 'machines'], description: 'OpenVibe’s supply, the person’s own key, or one of their machines through OpenVibe Link' }),
+ machine: str({ pattern: '^(any|dev_[0-9A-HJKMNP-TV-Z]{26})$', description: 'only a machine choice names one' }),
+ } },
+ Task: { type: 'object', required: ['name', 'kind', 'sentence', 'default'], properties: {
+ name: str(), service: str(), kind: str({ enum: ['text', 'code', 'vision', 'image', 'search'] }),
+ sentence: str(), default: ref('Choice'), workflow: str(),
+ } },
+ ChoiceView: { type: 'object', required: ['task', 'choice', 'why', 'options'], properties: {
+ task: ref('Task'), choice: ref('Choice'), recommended: ref('Choice'),
+ why: str({ enum: ['run', 'saved', 'kind', 'owner', 'builtin'], description: 'which resolution step picked the choice' }),
+ options: { type: 'object', properties: { openvibe: arrayOf(ref('ChoiceOption')), keys: arrayOf(ref('ChoiceOption')), machines: arrayOf(ref('ChoiceOption')) } },
+ fallbacks: arrayOf(ref('Choice')), max_cost_usd: nullable('number'), adult: { type: 'boolean' },
+ } },
+ ChoiceOption: { type: 'object', required: ['model', 'source', 'name'], properties: {
+ model: str(), source: str({ enum: ['openvibe', 'keys', 'machines'] }), name: str(), maker: nullable('string'),
+ price_estimate_usd: nullable('number', { description: 'an estimate for one run (1,000 input and 1,000 output tokens); null when no price was measured' }),
+ speed_ms: nullable('number'), quality: nullable('number'), adult: { type: 'boolean' }, machine: str(), machine_name: str(),
+ } },
WorkflowRef: { type: 'object', required: ['key', 'version'], properties: { key: str(), version: integer() } },
WorkflowRef: { type: 'object', required: ['key', 'version'], properties: { key: str(), version: integer() } },
Run: { type: 'object', required: ['id', 'status', 'workflow'], properties: {
Run: { type: 'object', required: ['id', 'status', 'workflow'], properties: {
id: str(), status: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] },
id: str(), status: { type: 'string', enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] },
@@ -766,6 +810,8 @@ function openapi() {
@@ -766,6 +810,8 @@ function openapi() {
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' }),
usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },
usage: { type: 'object', properties: { tokens_in: nullable('integer'), tokens_out: nullable('integer'), cost_usd: nullable('number'), attempts: nullable('integer'), usage_sample_ids: nullable(arrayOf(str())) } },
explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),
explain: nullable('object'), citations_count: nullable('integer'), grounding: nullable('object'),
+ choice: nullable('object', { description: 'the choice the run was started with (a task run or an explicit choice)' }),
+ answered: nullable('object', { description: '{ model, source, machine? }: what actually answered' }),
retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),
retry_of: nullable('string'), idempotency_key: nullable('string'), trace_id: nullable('string'),
created_at: str({ format: 'date-time' }), started_at: nullable('string', { format: 'date-time' }), finished_at: nullable('string', { format: 'date-time' }),
created_at: str({ format: 'date-time' }), started_at: nullable('string', { format: 'date-time' }), finished_at: nullable('string', { format: 'date-time' }),
} },
} },
@@ -14,9 +14,23 @@ const DEFAULTS = Object.freeze({
@@ -14,9 +14,23 @@ const DEFAULTS = Object.freeze({
only_my_keys: false,
only_my_keys: false,
free_first: true,
free_first: true,
price_ceiling_usd_per_mtok: null,
price_ceiling_usd_per_mtok: null,
- defaults: Object.freeze({ text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }),
+ defaults: Object.freeze({ text: 'auto', code: 'auto', vision: 'auto', image: 'auto', search: 'auto' }),
});
});
+/** ai.me-preferences@1, with `defaults.search` checked here while the pinned schema predates it. */
+function validatePreferences(body) {
+ const schema = contracts.schema('ai.me-preferences@1');
+ if (!schema) return null;
+ const defaults = body && body.defaults;
+ const knowsSearch = Boolean(schema.properties && schema.properties.defaults && schema.properties.defaults.properties && schema.properties.defaults.properties.search);
+ if (knowsSearch || !defaults || typeof defaults !== 'object') return contracts.validate('ai.me-preferences@1', body);
+ const { search, ...rest } = defaults;
+ const out = contracts.validate('ai.me-preferences@1', { ...body, defaults: rest });
+ if (search == null) return out;
+ if (typeof search === 'string' && search.length && search.length <= 120) return out;
+ return { valid: false, errors: [{ path: '/defaults/search', message: 'must be a model id or auto (1-120 characters)' }] };
+}
+
/** The same rules ai.me-preferences@1 states, for a service whose contracts pin predates 0.257.0. */
/** The same rules ai.me-preferences@1 states, for a service whose contracts pin predates 0.257.0. */
function manualPreferences(body) {
function manualPreferences(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
@@ -27,6 +41,7 @@ function manualPreferences(body) {
@@ -27,6 +41,7 @@ function manualPreferences(body) {
for (const k of ['text', 'code', 'vision', 'image']) {
for (const k of ['text', 'code', 'vision', 'image']) {
if (typeof d[k] !== 'string' || !d[k].length || d[k].length > 120) return `defaults.${k} must be a model id or auto (1-120 characters)`;
if (typeof d[k] !== 'string' || !d[k].length || d[k].length > 120) return `defaults.${k} must be a model id or auto (1-120 characters)`;
}
}
+ if (d.search != null && (typeof d.search !== 'string' || !d.search.length || d.search.length > 120)) return 'defaults.search must be a model id or auto (1-120 characters)';
return null;
return null;
}
}
@@ -48,8 +63,10 @@ function createPreferences({ db, clock = { now: () => Date.now() } }) {
@@ -48,8 +63,10 @@ function createPreferences({ db, clock = { now: () => Date.now() } }) {
async function put(subject, body) {
async function put(subject, body) {
// ai.me-preferences@1 arrives with openvibe-contracts 0.257.0; before that pin the shape is checked here.
// ai.me-preferences@1 arrives with openvibe-contracts 0.257.0; before that pin the shape is checked here.
+ // defaults.search arrives with the AI-choices release: a pin that predates it has no `search` property, so
+ // that one field is checked here and the rest against the schema it does carry.
let v = null;
let v = null;
- try { v = contracts.schema('ai.me-preferences@1') ? contracts.validate('ai.me-preferences@1', body) : null; } catch { v = null; }
+ try { v = validatePreferences(body); } catch { v = null; }
const manual = v ? null : manualPreferences(body);
const manual = v ? null : manualPreferences(body);
if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-preferences@1', { errors: v ? v.errors : [{ path: '', message: manual }] });
if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-preferences@1', { errors: v ? v.errors : [{ path: '', message: manual }] });
const value = {
const value = {
@@ -58,7 +75,7 @@ function createPreferences({ db, clock = { now: () => Date.now() } }) {
@@ -58,7 +75,7 @@ function createPreferences({ db, clock = { now: () => Date.now() } }) {
price_ceiling_usd_per_mtok: body.price_ceiling_usd_per_mtok == null ? null : Number(body.price_ceiling_usd_per_mtok),
price_ceiling_usd_per_mtok: body.price_ceiling_usd_per_mtok == null ? null : Number(body.price_ceiling_usd_per_mtok),
defaults: {
defaults: {
text: String(body.defaults.text), code: String(body.defaults.code),
text: String(body.defaults.text), code: String(body.defaults.code),
- vision: String(body.defaults.vision), image: String(body.defaults.image),
+ vision: String(body.defaults.vision), image: String(body.defaults.image), search: String(body.defaults.search || 'auto'),
},
},
};
};
const at = iso(clock.now());
const at = iso(clock.now());
@@ -877,7 +877,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -877,7 +877,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
*/
*/
async function executeOnMachine(cred, operation, req, ctx = {}) {
async function executeOnMachine(cred, operation, req, ctx = {}) {
const completing = operation === 'complete';
const completing = operation === 'complete';
- if (!completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own machine answers text only, not ${operation}`);
+ if (!completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'image'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own machine answers text only, not ${operation}`);
if (req.image) throw new AiError(422, 'credential.unsupported', 'a person\'s own machine reads no pictures yet');
if (req.image) throw new AiError(422, 'credential.unsupported', 'a person\'s own machine reads no pictures yet');
const wanted = ctx.role || roleOfRoute(ctx.routeKey);
const wanted = ctx.role || roleOfRoute(ctx.routeKey);
const model = (wanted && cred.models[wanted]) || cred.models.chat;
const model = (wanted && cred.models[wanted]) || cred.models.chat;
@@ -930,7 +930,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -930,7 +930,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const messaging = operation === 'messages';
const messaging = operation === 'messages';
const anthropicFormat = PROVIDER_KIND[cred.provider] === 'anthropic' || cred.provider === 'anthropic' || cred.provider === 'custom';
const anthropicFormat = PROVIDER_KIND[cred.provider] === 'anthropic' || cred.provider === 'anthropic' || cred.provider === 'custom';
if (messaging && !anthropicFormat) throw new AiError(422, 'credential.unsupported', `the ${cred.provider} credential does not speak the Messages API`);
if (messaging && !anthropicFormat) throw new AiError(422, 'credential.unsupported', `the ${cred.provider} credential does not speak the Messages API`);
- if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
+ if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'image'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
// The person's model for this role: the run's input.role, else the Live role its route names (live.director).
// The person's model for this role: the run's input.role, else the Live role its route names (live.director).
const wanted = ctx.role || roleOfRoute(ctx.routeKey);
const wanted = ctx.role || roleOfRoute(ctx.routeKey);
@@ -958,7 +958,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -958,7 +958,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
// The person's own key (BYOK) is never a managed resale offer: the disclosure says CUSTOMER_BYOK.
// The person's own key (BYOK) is never a managed resale offer: the disclosure says CUSTOMER_BYOK.
const ownFacts = { resolved_model: `${cred.provider}/${model}`, revision: null, deployment: cred.providerKey, provider_access: 'CUSTOMER_BYOK', rate_version: null, policy: 'byok', fallback_reason: null };
const ownFacts = { resolved_model: `${cred.provider}/${model}`, revision: null, deployment: cred.providerKey, provider_access: 'CUSTOMER_BYOK', rate_version: null, policy: 'byok', fallback_reason: null };
- const call = messaging ? a.messages : completing ? a.complete : a.chat;
+ const call = operation === 'image' ? a.image : messaging ? a.messages : completing ? a.complete : a.chat;
+ if (typeof call !== 'function') throw new AiError(422, 'credential.unsupported', 'this provider cannot generate images');
let streamed = false;
let streamed = false;
const onDelta = (completing || messaging) && typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;
const onDelta = (completing || messaging) && typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;
let lastErr = null;
let lastErr = null;
@@ -1047,6 +1048,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1047,6 +1048,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
async function execute(route, operation, req, ctx = {}) {
async function execute(route, operation, req, ctx = {}) {
+ if (ctx.taskChoice) return require('../choices/execute').executeChoice({ execute, registry, poolRates: rateCardsFor, maxAttempts: config.providerRetries + 1, route, operation, req, ctx });
const contentPolicy = ctx.contentPolicy || 'standard';
const contentPolicy = ctx.contentPolicy || 'standard';
const refuseFloor = (r) => new AiError(403, 'policy.floor_refused', r.detail, { rule: r.rule, policy: contentPolicy });
const refuseFloor = (r) => new AiError(403, 'policy.floor_refused', r.detail, { rule: r.rule, policy: contentPolicy });
if (ctx.credential) {
if (ctx.credential) {
@@ -119,7 +119,7 @@ function routePinRequest(value, principal, wf) {
@@ -119,7 +119,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
return { provider, model };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null }) {
+function createRuns({ choices = null, db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null, webhooks = null, batchWaiting = null, batch = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
// BA2: the runs that wait for a provider's own batch API: they are created at once and finish later.
// BA2: the runs that wait for a provider's own batch API: they are created at once and finish later.
const deferred = new Map(); // run id -> { controller, scope, ctx, input, hold, resolveDone }
const deferred = new Map(); // run id -> { controller, scope, ctx, input, hold, resolveDone }
@@ -154,6 +154,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -154,6 +154,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// for and the class the run really ran in); nothing else about the run changes.
// for and the class the run really ran in); nothing else about the run changes.
latency_clamped: (parseJson(r.options, {}) || {}).latency_clamped || null,
latency_clamped: (parseJson(r.options, {}) || {}).latency_clamped || null,
usage: { tokens_in: r.tokens_in, tokens_out: r.tokens_out, cost_usd: r.cost_usd, attempts: r.attempts, usage_sample_ids: r.usage_sample_ids || (r.usage_sample_id ? [r.usage_sample_id] : null) },
usage: { tokens_in: r.tokens_in, tokens_out: r.tokens_out, cost_usd: r.cost_usd, attempts: r.attempts, usage_sample_ids: r.usage_sample_ids || (r.usage_sample_id ? [r.usage_sample_id] : null) },
+ choice: (parseJson(r.options, {}) || {}).task_choice?.choice || null,
+ answered: parseJson(r.explain, null)?.task_choice?.answered || null,
explain: parseJson(r.explain, null),
explain: parseJson(r.explain, null),
citations_count: r.citations_count,
citations_count: r.citations_count,
grounding: parseJson(r.grounding, null),
grounding: parseJson(r.grounding, null),
@@ -210,7 +212,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -210,7 +212,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
*/
*/
async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null, discount = null, exact = null, costCheck = null, communityOfferId = null, callerWaitMs = 0 } = {}) {
async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null, discount = null, exact = null, costCheck = null, communityOfferId = null, callerWaitMs = 0 } = {}) {
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
- const wfKey = String(body.workflow || '');
+ const namedTask = choices && body.task ? await choices.task(String(body.task)) : null;
+ if (body.task && !namedTask) throw new AiError(404, 'task.not_found', 'unknown task');
+ const wfKey = String(body.workflow || namedTask?.workflow || '');
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
if (!wf) throw new AiError(404, 'workflow.not_found', `no active workflow ${wfKey}${body.version ? ` v${body.version}` : ''}`);
if (!wf) throw new AiError(404, 'workflow.not_found', `no active workflow ${wfKey}${body.version ? ` v${body.version}` : ''}`);
if (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);
if (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);
@@ -237,7 +241,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -237,7 +241,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
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(inputJson);
+ const inputHash = sha256(body.choice || body.task ? { input: inputJson, choice: body.choice || null, task: body.task || null } : inputJson);
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).
@@ -345,6 +349,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -345,6 +349,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
const runPerson = personOwnToken ? requester.id : personSubject;
const runPerson = personOwnToken ? requester.id : personSubject;
const personPays = Boolean(runPerson) && personOwnToken;
const personPays = Boolean(runPerson) && personOwnToken;
+ if (body.choice && !choices) throw new AiError(503, 'choice.unavailable', 'task choices are unavailable');
+ if (body.choice && (pinned || body.credential || projectProvider)) throw new AiError(422, 'choice.invalid', 'choice cannot be combined with another routing control');
+ const taskChoice = choices && (namedTask || body.choice || await choices.task(wf.key))
+ ? await choices.resolve(namedTask ? namedTask.name : wf.key, { subject: runPerson, choice: body.choice }) : null;
+ if (taskChoice && app && taskChoice.choice.source !== 'openvibe') throw new AiError(403, 'capability.denied', 'app runs use OpenVibe supply');
+
// The calling service's own budget (a run token's registered budget): with it, a run for a named person may
// The calling service's own budget (a run token's registered budget): with it, a run for a named person may
// use paid capacity, counted against that budget; without it, only free capacity serves the run.
// use paid capacity, counted against that budget; without it, only free capacity serves the run.
const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;
const tokenBudget = runBudgets && principal.jti ? await runBudgets.spent(principal.jti) : null;
@@ -361,7 +371,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -361,7 +371,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
&& (Boolean(runPerson) || (anonymousTryIt && wf.key === 'ai.complete'));
&& (Boolean(runPerson) || (anonymousTryIt && wf.key === 'ai.complete'));
// The injected run record names the person a user runs for, so preferences and cache scope follow them.
// The injected run record names the person a user runs for, so preferences and cache scope follow them.
if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };
if (runPerson && !personSubject) body = { ...body, on_behalf_of: { type: 'user', id: runPerson } };
- const selfKeys = Boolean(credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));
+ const selfKeys = taskChoice?.choice.source === 'machines' || Boolean((!taskChoice || taskChoice.choice.source !== 'openvibe') && credentials && runPerson && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(runPerson));
// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before
// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before
// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.
// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.
if (runBudgets && principal.jti) {
if (runBudgets && principal.jti) {
@@ -372,7 +382,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -372,7 +382,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// field on the OpenAI and Messages routes and an input/option field on the direct ones; parsed as an exact
// field on the OpenAI and Messages routes and an input/option field on the direct ones; parsed as an exact
// decimal into integer nano-USD, never through a float (0.000000001 is one nano, 0.0000000011 rounds up).
// decimal into integer nano-USD, never through a float (0.000000001 is one nano, 0.0000000011 rounds up).
const rawMaxCost = body.max_cost_usd != null ? body.max_cost_usd : options.max_cost_usd != null ? options.max_cost_usd : input.max_cost_usd;
const rawMaxCost = body.max_cost_usd != null ? body.max_cost_usd : options.max_cost_usd != null ? options.max_cost_usd : input.max_cost_usd;
- const maxCostNano = rawMaxCost == null ? null : parseCostUsd(rawMaxCost);
+ let maxCostNano = rawMaxCost == null ? null : parseCostUsd(rawMaxCost);
+ if (taskChoice?.max_cost_usd != null) { const cap = parseCostUsd(taskChoice.max_cost_usd); maxCostNano = maxCostNano == null || cap < maxCostNano ? cap : maxCostNano; }
const ctx = {
const ctx = {
requesterType: requester.type, requesterId: requester.id,
requesterType: requester.type, requesterId: requester.id,
...(principal.jti ? { runJti: principal.jti } : {}),
...(principal.jti ? { runJti: principal.jti } : {}),
@@ -429,10 +440,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -429,10 +440,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
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({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
+ 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.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 ? { self_subject: runPerson } : {}),
+ ...((selfKeys || taskChoice && runPerson) ? { self_subject: runPerson } : {}),
...(runPerson ? { person_pays: personPays } : {}),
...(runPerson ? { person_pays: personPays } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}),
...(communityOptIn ? { community: true } : {}), ...(prefer ? { prefer } : {}),
@@ -446,7 +457,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -446,7 +457,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// and another model's answer under the same prompt would silently break the pin. A run that may use the
// and another model's answer under the same prompt would silently break the pin. A run that may use the
// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
// A community or adult-tier answer cannot be reused by a request without the same policy.
// A community or adult-tier answer cannot be reused by a request without the same policy.
- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn || contentPolicy !== 'standard' ? null : cache.scopeFor(wf.cache_mode, ctx);
+ const scope = taskChoice || options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn || contentPolicy !== 'standard' ? null : cache.scopeFor(wf.cache_mode, ctx);
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).
@@ -957,6 +968,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -957,6 +968,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
communityFree,
communityFree,
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
// L37: the soft preference the caller made (provider, and a model if it named one).
// L37: the soft preference the caller made (provider, and a model if it named one).
+ taskChoice: opts.task_choice || null, choiceBudget: { used: 0 },
prefer: opts.prefer || null,
prefer: opts.prefer || null,
// The routing preset's rules (server/exchange/constraints.js): the router filters the offers with
// The routing preset's rules (server/exchange/constraints.js): the router filters the offers with
// them, and a hard max_price no offer fits refuses the run before any provider is called.
// them, and a hard max_price no offer fits refuses the run before any provider is called.
@@ -1135,6 +1147,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -1135,6 +1147,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
workflow: row.workflow_key, input: parseJson(row.input, {}), target: parseJson(row.target, null) || undefined,
workflow: row.workflow_key, input: parseJson(row.input, {}), target: parseJson(row.target, null) || undefined,
attribution: parseJson(row.attribution, null) || undefined, on_behalf_of: parseJson(row.on_behalf_of, null) || undefined,
attribution: parseJson(row.attribution, null) || undefined, on_behalf_of: parseJson(row.on_behalf_of, null) || undefined,
route: opts.pinned_offer || undefined,
route: opts.pinned_offer || undefined,
+ ...(opts.task_choice ? { task: opts.task_choice.task.name, choice: opts.task_choice.choice } : {}),
options: { ...opts, cache: false },
options: { ...opts, cache: false },
}, principal, { trace, requestId, retryOf: id });
}, principal, { trace, requestId, retryOf: id });
}
}
@@ -35,6 +35,7 @@ const schemas = require('../schemas');
@@ -35,6 +35,7 @@ const schemas = require('../schemas');
const { PREPARE, POSTPROCESS } = require('./hooks');
const { PREPARE, POSTPROCESS } = require('./hooks');
const modelCatalog = require('../providers/model-catalog');
const modelCatalog = require('../providers/model-catalog');
const { buildBody, buildCompleteBody } = require('../providers/openai');
const { buildBody, buildCompleteBody } = require('../providers/openai');
+const { PROVIDERS: ENGINES } = require('../research');
/** The last user message's text, for the queue's own record of what one item asks (never sent as-is). */
/** The last user message's text, for the queue's own record of what one item asks (never sent as-is). */
function lastUserText(messages) {
function lastUserText(messages) {
@@ -429,19 +430,52 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -429,19 +430,52 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
/** research: no model call. A developer app's run leaves the paid web search out (its DeepSeek calls are ours). */
/** research: no model call. A developer app's run leaves the paid web search out (its DeepSeek calls are ours). */
async function researchStep(step, input, wf, run, ctx) {
async function researchStep(step, input, wf, run, ctx) {
- let output;
+ let output, answered = null, engineIndex = 0, candidates = [];
try {
try {
// Search's own web ladder calls this endpoint for DeepSeek and Wikipedia. It must not be asked back
// Search's own web ladder calls this endpoint for DeepSeek and Wikipedia. It must not be asked back
// (Search -> AI research -> Search): every other caller gets the fabric first, Search keeps DeepSeek.
// (Search -> AI research -> Search): every other caller gets the fabric first, Search keeps DeepSeek.
const fromSearch = ctx.requesterType === 'service' && ctx.requesterId === 'search';
const fromSearch = ctx.requesterType === 'service' && ctx.requesterId === 'search';
- output = await megasource().gather(input, { signal: ctx.signal, allowPaid: !ctx.appRun, allowFabric: !fromSearch });
+ if (ctx.taskChoice?.task.kind === 'search') {
+ // A search task runs the engines its choice names, in order: the owner's saved order when the owner's
+ // default won, else the chosen engine and the task's fallbacks. A disabled engine is never called, and
+ // DeepSeek (the one paid engine) is skipped under a cost ceiling, which has no per-run bound for it.
+ const plan = ctx.taskChoice;
+ if (plan.choice.source !== 'openvibe') throw new AiError(422, 'choice.unsupported', 'search uses OpenVibe search engines only');
+ const engineOf = (model) => String(model).replace(/^search\//, '');
+ const asked = engineOf(plan.choice.model);
+ const ordered = plan.why === 'owner' && plan.search_engines.length
+ ? plan.search_engines.filter((e) => e.enabled).map((e) => e.engine)
+ : [...new Set([asked, ...plan.fallbacks.map((c) => engineOf(c.model))])];
+ if (!ordered.length) throw new AiError(422, 'choice.unavailable', 'no search engine is enabled for this task');
+ candidates = ordered.map((engine) => ({
+ provider: engine, model: `search/${engine}`,
+ eligible: engine !== 'deepseek' || plan.max_cost_usd == null,
+ excluded_reason: engine === 'deepseek' && plan.max_cost_usd != null ? 'cost ceiling has no per-run bound' : null,
+ }));
+ const images = plan.task.kind === 'search' && plan.task.name !== 'search.web';
+ for (const engine of ordered) {
+ if (ENGINES.includes(engine) === false) throw new AiError(422, 'choice.unavailable', `unknown search engine ${engine}`);
+ if (engine === 'deepseek' && plan.max_cost_usd != null) continue;
+ const got = await megasource().gather({ ...input, providers: [engine], images }, { signal: ctx.signal, allowPaid: !ctx.appRun && plan.max_cost_usd == null, allowFabric: false });
+ if (images ? got.images.length : got.sources.length) { output = got; answered = engine; engineIndex = ordered.indexOf(engine); break; }
+ output = got;
+ }
+ if (answered == null) throw new AiError(402, 'choice.cost_ceiling', 'no enabled search engine answered within this task’s ceiling');
+ } else output = await megasource().gather(input, { signal: ctx.signal, allowPaid: !ctx.appRun, allowFabric: !fromSearch });
} catch (err) {
} catch (err) {
if (err && err.status === 422) throw new AiError(422, 'input.invalid', err.message);
if (err && err.status === 422) throw new AiError(422, 'input.invalid', err.message);
throw err;
throw err;
}
}
if (!output.sources.length && !output.images.length) throw new AiError(502, 'source.unavailable', `no source answered for "${output.query}"`, { providers: output.providers });
if (!output.sources.length && !output.images.length) throw new AiError(502, 'source.unavailable', `no source answered for "${output.query}"`, { providers: output.providers });
// Recorded like a provider call: DeepSeek's per-call price counts against the cost quotas and the daily usage.
// Recorded like a provider call: DeepSeek's per-call price counts against the cost quotas and the daily usage.
- const exec = { provider: 'research', model: 'megasource', usage: { input: 0, output: 0, cached: 0 }, cost: Number(output.usage && output.usage.cost_usd) || 0, synthetic: false, fallbackUsed: false, explain: null };
+ const exec = { provider: 'research', model: 'megasource', usage: { input: 0, output: 0, cached: 0 }, cost: Number(output.usage && output.usage.cost_usd) || 0, synthetic: false, fallbackUsed: false, explain: ctx.taskChoice ? {
+ // The contract's explain needs an objective, a reason, what was selected and the candidates; a search
+ // task's candidates are its engines, and the one that answered is the selection.
+ objective: 'exact',
+ reasons: [`search task ${ctx.taskChoice.task.name}: ${answered} answered`],
+ selected: `search/${answered}`, candidates,
+ task_choice: { task: ctx.taskChoice.task.name, requested: ctx.taskChoice.choice, answered: { model: `search/${answered}`, source: 'openvibe' }, why: ctx.taskChoice.why, fallback_index: engineIndex },
+ } : null };
return { output, exec };
return { output, exec };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+const assert=require('assert');
+const {boot,request,token,suite,tmpDir,ALL}=require('./helpers');
+const {createChoices,choice}=require('../server/choices');
+const {executeChoice}=require('../server/choices/execute');
+const t=suite('task choices');let h;
+const svc=token('design',ALL);
+const subject='usr_01ARZ3NDEKTSV4RRFFQ69G5FAV';
+const user=role=>token('person',[],{actorType:'user',sub:subject,extra:{subject_id:subject,role}});
+const c=model=>({model,source:'openvibe'});
+t.test('catalogue, personal isolation and admin role gate',async()=>{
+ h=await boot({dir:tmpDir()});
+ let r=await request(h.base,'GET','/api/v1/tasks');assert.equal(r.status,200,r.text);assert(r.body.tasks.some(t=>t.name==='design.icon'));
+ r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:svc,body:c('a/b')});assert.equal(r.status,403);
+ r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:user('user'),body:c('a/b')});assert.equal(r.status,200,r.text);
+ r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('user')});assert.equal(r.status,403);
+ r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:svc});assert.equal(r.status,403);
+ r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('admin')});assert.equal(r.status,200,r.text);assert.equal(r.body.defaults['design.icon'].signed_in.model,'anthropic/claude-opus-5-5');
+ r=await request(h.base,'POST','/api/v1/tasks',{tok:svc,body:{name:'wiki.foreign',kind:'text',sentence:'No.',default:c('a/b')}});assert.equal(r.status,422);
+});
+t.test('a site session saves a choice only from a first-party origin, and search preferences are kept',async()=>{
+ const site=token('person',[],{aud:'openvibe.network',actorType:'user',sub:subject,extra:{subject_id:subject,role:'user'}});
+ let r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:site,headers:{Origin:'https://openvibe.blog','Sec-Fetch-Site':'same-origin'},body:c('a/blog')});
+ assert.equal(r.status,200,r.text);
+ r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:site,headers:{Origin:'https://evil.example','Sec-Fetch-Site':'same-origin'},body:c('a/bad')});
+ assert.deepEqual([r.status,r.body.code],[403,'capability.denied']);
+ const prefs={only_my_keys:false,free_first:true,price_ceiling_usd_per_mtok:null,defaults:{text:'auto',code:'auto',vision:'auto',image:'auto',search:'search/commons'}};
+ r=await request(h.base,'PUT','/api/v1/me/ai/preferences',{tok:user('user'),body:prefs});assert.equal(r.status,200,r.text);
+ assert.equal(r.body.defaults.search,'search/commons');
+ r=await request(h.base,'PUT','/api/v1/me/ai/preferences',{tok:user('user'),body:{...prefs,defaults:{...prefs.defaults,search:7}}});assert.equal(r.status,422,r.text);
+});
+t.test('the seeded search tasks resolve to an engine and refuse a model',async()=>{
+ const store=createChoices({db:h.db,registry:h.registry,clock:{now:()=>Date.now()},preferences:{get:async()=>null}});
+ let r=await store.resolve('search.web');assert.equal(r.task.kind,'search');assert.equal(r.why,'builtin');assert.equal(r.choice.model,'search/wikipedia');
+ r=await store.resolve('wiki.article.images');assert.equal(r.choice.model,'search/commons');
+ await assert.rejects(()=>store.save(subject,'search.web',c('anthropic/claude-opus-5-5')),e=>e.code==='choice.invalid');
+ const value={signed_out:c('search/openverse'),signed_in:c('openverse/model'),fallbacks:[],image_model:null,search_engines:[],max_cost_usd:null};
+ await assert.rejects(()=>store.putDefaults({defaults:[{task:'search.web',value}]},subject),e=>e.code==='choice.invalid');
+});
+t.test('a search task records a contract-valid run and the engines it considered',async()=>{
+ const r=await request(h.base,'POST','/api/v1/runs?wait=10000',{tok:svc,body:{task:'search.web',input:{query:'SQLite'}}});
+ assert.equal(r.status,201,r.text);
+ assert.equal(r.body.run.status,'succeeded',r.text);
+ for(const field of ['objective','reasons','selected','candidates']) assert(Object.hasOwn(r.body.run.explain,field),field);
+ assert.equal(r.body.run.explain.selected,'search/wikipedia');
+ assert.deepEqual(r.body.run.explain.candidates.map(c=>c.provider),['wikipedia']);
+});
+t.test('model choices reject extra fields and require machine identity',()=>{assert.throws(()=>choice({model:'x',source:'machines'}));assert.throws(()=>choice({...c('a/b'),api_key:'no'}));});
+t.test('precedence, cache expires in 60s, audit and only-my-keys',async()=>{
+ let now=100000,prefs={defaults:{code:'kind/model'}};
+ const store=createChoices({db:h.db,registry:h.registry,clock:{now:()=>now},preferences:{get:async()=>prefs}});
+ let r=await store.resolve('design.icon',{subject,choice:c('run/model')});assert.equal(r.why,'run');
+ r=await store.resolve('design.icon',{subject});assert.equal(r.why,'saved');
+ const second='usr_01ARZ3NDEKTSV4RRFFQ69G5FAW';r=await store.resolve('design.icon',{subject:second});assert.equal(r.why,'kind');
+ prefs={defaults:{}};r=await store.resolve('design.icon',{subject:second});assert.equal(r.why,'owner');assert.equal(r.choice.model,'anthropic/claude-opus-5-5');
+ r=await store.resolve('design.icon');assert.equal(r.choice.model,'openvibe/cheapest');
+ r=await store.resolve('chat.reply');assert.equal(r.why,'builtin');
+ await store.defaults();await h.db.prepare('UPDATE ai_task_defaults SET value = ? WHERE task = ?').run(JSON.stringify({signed_out:c('new/model'),signed_in:c('new/model'),fallbacks:[],max_cost_usd:0.1,search_engines:[]}), 'design.icon');
+ assert.equal((await store.resolve('design.icon')).choice.model,'openvibe/cheapest');now+=60000;assert.equal((await store.resolve('design.icon')).choice.model,'new/model');
+ prefs={only_my_keys:true,defaults:{}};await assert.rejects(()=>store.resolve('design.icon',{subject:second}),e=>e.code==='ai.needs_key');
+ const value={signed_out:c('a/b'),signed_in:c('a/c'),fallbacks:[c('a/d')],image_model:null,search_engines:[],max_cost_usd:0.2};
+ await store.putDefaults({defaults:[{task:'design.icon',value}]},subject);
+ assert(await h.db.prepare("SELECT * FROM audit_log WHERE action = 'task.defaults'").get());
+});
+t.test('exact source routing, ordered fallback and no fallback after a delta',async()=>{
+ const calls=[];const registry={listModels:async()=>[{provider_key:'a',model_key:'one',status:'active',type:'chat'},{provider_key:'a',model_key:'two',status:'active',type:'chat'}],listProviders:async()=>[{key:'a',status:'active'}]};
+ const plan={task:{name:'test.run'},choice:c('a/one'),fallbacks:[c('a/two')],why:'owner',max_cost_usd:null};
+ const args={registry,route:{},operation:'chat',req:{},ctx:{taskChoice:plan,choiceBudget:{used:0}},execute:async(route,op,req,ctx)=>{calls.push(ctx.exact.offers[0].model);if(calls.length===1)throw new Error('unavailable');return {model:'two'};}};
+ const result=await executeChoice(args);assert.deepEqual(calls,['one','two']);assert.equal(result.explain.task_choice.fallback_index,1);
+ calls.length=0;await assert.rejects(()=>executeChoice({...args,ctx:{...args.ctx,onDelta:()=>{}},execute:async(r,o,q,ctx)=>{calls.push('called');ctx.onDelta('hello');throw new Error('broken');}}));assert.equal(calls.length,1);
+});
+t.test('task run enforces an explicit model and records its source without caching',async()=>{
+ await h.registry.upsertProvider({key:'choice-stub',kind:'stub',auth_mode:'none',capabilities:['chat','generate','complete'],default_model:'choice-one'});
+ await h.registry.upsertModel({provider_key:'choice-stub',model_key:'choice-one',type:'chat',supports:{streaming:true,vision:true,json:true,tools:true}});
+ let r=await request(h.base,'POST','/api/v1/runs?wait=5000',{tok:svc,body:{task:'design.icon',choice:c('choice-stub/choice-one'),input:{messages:[{role:'user',content:'Draw an icon'}]}}});
+ assert.equal(r.status,201,r.text);assert.equal(r.body.run.status,'succeeded',r.text);assert.equal(r.body.run.provenance.model,'choice-one');assert.equal(r.body.run.answered.source,'openvibe');assert.equal(r.body.run.answered.model,'choice-one');assert.equal(r.body.run.choice.model,'choice-stub/choice-one');
+ r=await request(h.base,'POST','/api/v1/runs?wait=5000',{tok:svc,body:{workflow:'ai.generate',choice:c('choice-stub/choice-one'),input:{prompt:'Every workflow accepts choice'}}});assert.equal(r.body.run.status,'succeeded',r.text);
+});
+t.test('missing price under a cost ceiling never dispatches',async()=>{
+ let dispatched=false;
+ await assert.rejects(()=>executeChoice({execute:async()=>{dispatched=true;},registry:{listModels:async()=>[{provider_key:'a',model_key:'one',type:'chat',status:'active'}],listProviders:async()=>[{key:'a',status:'active'}],getProvider:async()=>({kind:'openai'})},poolRates:async()=>null,route:{},operation:'chat',req:{maxTokens:100},ctx:{taskChoice:{task:{name:'test'},choice:c('a/one'),fallbacks:[],max_cost_usd:0.01},choiceBudget:{used:0}}}),e=>e.code==='choice.cost_ceiling');
+ assert.equal(dispatched,false);
+});
+t.test('finish',async()=>h.stop());
+t.run();