openvibers/openvibe.ai Commits
The router picks for the task: a task-aware router with an explain (L33 P3, L36 Y7), and a soft preference (L37) (#227)
Changes
@@@ -748,7 +748,7 @@ ai.openvibe.network notice from OpenVibe.Sites.Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.257.0++- openvibe-contracts: v0.262.0- openvibe-sdk: v0.75.0- openvibe-shared: v4.66.0<!-- versions:end -->@@@ -7,7 +7,7 @@"runtime": "node 22, express, postgresql","port": 4700,"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",-- "contracts": "openvibe-contracts v0.257.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)",++ "contracts": "openvibe-contracts v0.262.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)","sdk": "openvibe-sdk v0.75.0","shared": "openvibe-shared v4.66.0","capabilities": [@@@ -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.257.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0","pg": "^8.23.0"@@@ -1540,9 +1540,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.257.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",-- "integrity": "sha512-fzyXLBKCqrOjUvlUB6UKxZQgrwOMhXaQK8lyxmmpkIujcd8AaIRW0DVTvYGIRJGCf/1Z3RiYS5gK1XLG8rkXFA==",++ "version": "0.262.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",++ "integrity": "sha512-E5zcp/ucdLH5q22jWxzdw1tgDRt9Y4y4TEVZvp3C6xwe2DOlDVDYgASHv8lF7QSHHmkmm9narAeQKmLrMBBhLA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,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.257.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0","pg": "^8.23.0"@@@ -140,6 +140,8 @@ function messagesRouter({ runs, auth, config, log = console }) {const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];let body = { workflow: 'ai.messages', input };++ // L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.++ if (b.prefer) body = { ...body, prefer: b.prefer };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };const own = credentialFor(req);if (own.error) return anthropicError(res, own.status, own.error);@@@ -127,6 +127,8 @@ function openaiRouter({ runs, auth, config, log = console }) {const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));let body = { workflow: 'ai.complete', input };++ // L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.++ if (b.prefer) body = { ...body, prefer: b.prefer };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };// The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.const personKey = credentialFor(req);@@@ -133,6 +133,10 @@ function load(env = process.env) {costWeight: float(env.AI_PLACEMENT_COST_WEIGHT, 0.5),latencyWeight: float(env.AI_PLACEMENT_LATENCY_WEIGHT, 0.5),},++ // L36 Y7: the task-aware router's fallback chain tries at most this many providers.++ routing: {++ maxAttempts: Math.max(1, int(env.AI_ROUTING_MAX_ATTEMPTS, 3)),++ },// Configurable local HTTP seam: POST {operation, request} JSON to this URL.httpSeamUrl: trimUrl(env.AI_HTTP_SEAM_URL),// A local model server speaking the OpenAI API (llama.cpp's llama-server, Ollama's /v1): the provider `local`,@@@ -31,6 +31,8 @@ const { createAnthropicProvider } = require('./anthropic');const { createHttpSeamProvider } = require('./http');const { createWhisperProvider } = require('./whisper');const rates = require('./rate-cards');++const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');++const { createWarmCache } = require('./warm-cache');/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */function callerFault(err) {@@@ -49,12 +51,23 @@ const CREDENTIAL_DEFAULT_MODEL = { ...DEFAULT_MODEL, anthropic: 'claude-haiku-5-/** The key as explain names it, never the key itself (L36 Y1/Y7). */const paidByOf = (cred) => `your ${cred.provider} key …${String(cred.hint || '').slice(-4) || '????'}`;--/** The kind of work a run does, for the person's ai.me-preferences.defaults. */--function workKind(operation, req, ctx = {}) {-- if (operation === 'image') return 'image';-- if (req && req.image) return 'vision';-- const hint = `${ctx.workflowKey || ''} ${ctx.routeKey || ''}`;-- return /\b(code|coding|codes|build|design|program|developer)\b/i.test(hint) ? 'code' : 'text';++// The kind of work a run does (the person's ai.me-preferences.defaults has one default per kind)++// comes from the task detector: server/providers/task.js.++/** The default per-provider timeout when neither the catalogue nor the provider names one. */++const DEFAULT_PROVIDER_TIMEOUT_MS = 30000;++/** The fallback chain's budget when the config does not name one. */++const DEFAULT_MAX_ATTEMPTS = 3;++/**++ * Who the warm-cache memory belongs to: the person, else the app or service that asked, and the route it++ * asked on (the same prompt on another route is another job, and its cache is a different one).++ */++const warmSubject = (ctx = {}, route = null) =>++ (ctx.selfSubject || (ctx.requesterType ? ctx.requesterType + ':' + ctx.requesterId : null) || 'anonymous') + '|' + String((route && route.key) || '');++/** The person's own default model for this kind of work, when they named one (L36 Y3). */++function preferredModelFor(prefs, operation, req, ctx) {++ const kind = workKind(operation, req, ctx);++ const choice = prefs && prefs.defaults ? prefs.defaults[kind] : null;++ return choice && choice !== 'auto' ? choice : null;}/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */function credentialCanServe(provider, operation, req) {@@@ -83,6 +96,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });const cache = new Map(); // key -> { stamp, adapter }const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)++ // L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).++ const warm = createWarmCache({ now: () => clock.now() });function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }@@@ -401,6 +416,91 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noreturn { order, explain, placement: result, rateCards: rest.rateCards || [], states: rest.states || [] }; // placement: the platform.placement-result@1 explain is built from (pool routes)}++ /**++ * What the catalogue knows about one candidate (L36 Y7): its capabilities, its prices, its strength tier.++ */++ async function describeCandidate(c) {++ const entry = await adapter(c.provider);++ const p = entry && entry.record;++ const model = c.model || (p && p.default_model) || null;++ const row = model ? await registry.getModel(c.provider, model) : null;++ const caps = capabilitiesOf(row, p);++ const prices = await priceFor(c.provider, model);++ return { provider: c.provider, model, caps, row, record: p, priceIn: Number(prices.in) || 0, priceOut: Number(prices.out) || 0 };++ }++++ /**++ * The provider's own timeout for one model (L36 Y7): the catalogue's model row first, then the++ * provider's preset. It is the ceiling: a person can never set a longer one.++ */++ async function providerTimeout(p, model) {++ const row = model ? await registry.getModel(p.key, model) : null;++ const meta = row && row.metadata ? row.metadata : {};++ return Number(meta.timeout_ms) || Number(p.timeout_ms) || 0;++ }++++ /**++ * What the catalogue knows about the model a caller asked for (L37): the provider, and what the model can do.++ * A model the catalogue never described is not refused: the provider decides.++ */++ async function preferredModel(provider, model, task) {++ const entry = provider ? await adapter(provider) : null;++ const p = entry && entry.record;++ const row = model ? await registry.getModel(provider, model) : null;++ const caps = capabilitiesOf(row, p);++ return { known: Boolean(entry), provider, model, caps, missing: hardNeeds(caps, task) };++ }++++ /** The health the pool left a candidate with: 0 up, 1 degraded, 2 down. */++ function healthRank(excluded) {++ const why = String(excluded || '');++ if (/health down/.test(why)) return 2;++ if (/health degraded/.test(why)) return 1;++ return 0;++ }++++ /**++ * L36 Y7: pick for the task. The hard needs come first (a model that cannot do the job is never tried),++ * then fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength++ * tier first. The explain says what the task was and why every candidate is there.++ */++ async function rankCandidates({ list, explain, task, route = null, warmProvider = null, preferred = null, ceiling = null, tierFirst = null, keepOrder = false }) {++ const byKey = new Map((explain.candidates || []).map((c) => [c.provider + ':' + (c.model || ''), c]));++ const authority = route && route.constraints ? (route.constraints.authority || null) : null;++ const entries = [];++ const rest = [];++ let stub = null;++ for (const c of list) {++ if (c.provider === 'stub') { stub = c; continue; } // synthetic output is never a candidate++ const seen = byKey.get(c.provider + ':' + (c.model || ''));++ const d = await describeCandidate(c);++ const missing = hardNeeds(d.caps, task);++ if (missing.length) {++ // A model that cannot do this job is never tried: it leaves the order with its reason.++ if (seen) { seen.eligible = false; seen.excluded_reason = missing.join(','); }++ continue;++ }++ const over = ceiling != null && d.priceOut > Number(ceiling);++ const entry = {++ provider: c.provider, model: d.model, price: over ? Infinity : d.priceIn,++ tier: d.caps.tier || 'balanced', health: healthRank(seen && seen.excluded_reason),++ latency: seen ? seen.latency : null, candidate: c, eligible: !(seen && seen.eligible === false),++ };++ // A pinned route keeps its own order (and its skipped candidates where the pin put them).++ if (keepOrder) { entries.push(entry); continue; }++ if (!entry.eligible) { rest.push(c); continue; } // the pool excluded it (health, cost)++ entries.push(entry);++ }++ const ranked = keepOrder++ ? { order: entries.map((e) => e.candidate), ranked: entries.slice(0, 5) }++ : rank(entries, { task, warm: warmProvider, preferred, authority, tierFirst });++ const order = keepOrder ? ranked.order : [...ranked.order.map((e) => ({ provider: e.provider, model: e.model })), ...rest];++ if (stub) order.push(stub);++ const view = ranked.ranked.map((e) => ({ provider: e.provider, model: e.model, why: reasonFor(e, task), cost: Number.isFinite(e.price) ? e.price : null, tier: e.tier }));++ const reasons = new Map(ranked.order.map((e) => [e.provider + ':' + (e.model || ''), reasonFor(e, task)]));++ return { order, ranked: view, reasons, keepOrder };++ }++async function skipReason(p, a, features) {if (!p || !a) return 'unknown_provider';if (p.status !== 'active') return 'disabled';@@@ -585,6 +685,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (req.image) features.push('vision');if (req.json) features.push('json');if (req.openai && Array.isArray(req.openai.tools) && req.openai.tools.length) features.push('tools');++ // L36 Y7: the task, read off the request itself: what it needs, what it wants out, how heavy it is.++ const task = detectTask({ operation, req, ctx, route });++ // L37: a soft preference. The caller names a provider (and a model if it wants one). It never disables the++ // person's own keys, never overrides only_my_keys, never beats the price ceiling, and never forces a model++ // that fails a hard need. An unknown id is ignored, never an error.++ const prefer = preferredTarget(req.prefer);++ const preferState = prefer ? { provider: prefer.provider, model: prefer.model, honoured: false, why: '' } : null;const tried = [];let unreadable = null; // a candidate could not read the caller's media (whisper's ffmpeg): no other will either// A streamed answer (/v1/chat/completions, stream: true) is the caller's once a delta has gone out: a failure@@@ -599,23 +706,42 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {const order = [...selfList].sort((a, b) => {const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);-- return rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider));++ // L37: the person's own key for the provider they asked for answers first, at no fee.++ const want = (x) => (prefer && prefer.provider && String(x.provider) === prefer.provider) ? 0 : 1;++ return want(a) - want(b) || rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider));});for (const cred of order) {if (!credentialCanServe(cred.provider, operation, req)) {selfAttempts.push({ key: paidByOf(cred), provider: cred.provider, model: null, why: 'your own key', status: 'skipped', error: `this key cannot serve ${operation}` });continue;}-- const pick = selfModelFor(cred, operation, req, ctx, prefs);++ let pick = selfModelFor(cred, operation, req, ctx, prefs);++ // L37: the model they named, when it can do this job. A model that cannot is never forced.++ if (prefer && prefer.model && (!prefer.provider || String(cred.provider) === prefer.provider)) {++ const check = await preferredModel(prefer.provider || cred.provider, prefer.model, task);++ if (check.missing.length) {++ if (preferState && !preferState.why) preferState.why = check.missing.includes('no_vision') ? 'the model you named cannot see' : 'the model you named cannot do this job';++ } else {++ pick = { model: prefer.model, why: 'your own ' + cred.provider + ' key, with the model you asked for' };++ }++ }const key = paidByOf(cred);try {const out = await executeWithCredential(cred, operation, req, { ...ctx, defaultModel: pick.model });const attempts = [...selfAttempts, { key, provider: cred.provider, model: pick.model, why: pick.why, status: 'ok', error: null }];++ // L37: the preference was honoured when their own key for that provider answered.++ if (preferState && prefer.provider && String(cred.provider) === prefer.provider) {++ preferState.honoured = true;++ preferState.why = 'your own ' + cred.provider + ' key answered, as you asked';++ } else if (preferState && !preferState.honoured && !preferState.why) {++ preferState.why = 'your ' + cred.provider + ' key answered instead: you have no key for the one you asked for';++ }return {...out, fallbackUsed: false,explain: {objective: 'own-keys', reasons: ['your own keys are tried before the network\'s providers'],paid_by: key, selected: `${cred.provider}/${pick.model}`, candidates: [], attempts,++ prefer: preferState ? { ...preferState } : null,},};} catch (err) {@@@ -628,7 +754,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst names = providers.length ? providers.join(', ') : 'any provider that can serve this request';throw new AiError(422, 'ai.needs_key',`only_my_keys is on and none of your keys could serve this request; add a key for ${names} under Your AI`,-- { providers, paid_by: null, attempts: selfAttempts, explain: { objective: 'own-keys', reasons: ['only_my_keys: no network fallback'], paid_by: null, selected: null, candidates: [], attempts: selfAttempts } });++ { providers, paid_by: null, attempts: selfAttempts, explain: { objective: 'own-keys', reasons: ['only_my_keys: no network fallback'], paid_by: null, selected: null, candidates: [], attempts: selfAttempts, prefer: preferState ? { ...preferState, why: preferState.why || 'you have no key for it, and only_my_keys never falls back to the network' } : null } });}}let { order: list, explain } = await candidates(route, features, operation, ctx);@@@ -643,6 +769,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}list = [...list].sort((a, b) => Number(free.get(b.provider)) - Number(free.get(a.provider)));}++ // L36 Y7: pick for the task. The hard needs first (a model that cannot do the job is never tried), then++ // fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength tier.++ const warmProvider = warm.get(warmSubject(ctx, route), leadingText(req));++ // L36 Y3: with free_first on, the free tiers lead (the price decides); with it off, the strength tier++ // leads and the free tiers are the failover, not the first choice.++ const freeFirst = ctx.selfSubject ? prefs.free_first !== false : true;++ const ranked = await rankCandidates({++ list, explain, task, route, warmProvider,++ preferred: preferredModelFor(prefs, operation, req, ctx),++ ceiling: prefs.price_ceiling_usd_per_mtok,++ tierFirst: task.weight === 'heavy' || !freeFirst,++ // A pinned route keeps its own order: the pin is the admin's decision, not the router's.++ keepOrder: !(route.capability && operation),++ });++ list = ranked.order;++ explain.task = task;++ explain.ranked = ranked.ranked;++ // L37: the soft preference. The provider (and model) the caller asked for goes first, when it can do the++ // job; a pinned route keeps its own order (the pin is the admin's decision).++ if (preferState) {++ if (prefer.provider && !(await registry.getProvider(prefer.provider))) preferState.why = 'no provider by that name';++ else if (prefer.model) {++ // L37: the model they named cannot do this job: it is never forced.++ const check = await preferredModel(prefer.provider, prefer.model, task);++ if (check.missing.length) preferState.why = check.missing.includes('no_vision') ? 'the model you named cannot see' : 'the model you named cannot do this job';++ }++ if (ranked.keepOrder) {++ preferState.why = 'this route is pinned; the pin decides';++ } else {++ const at = list.findIndex((c) => preferMatches(c.provider, c.model, prefer));++ if (at > 0) {++ const [chosen] = list.splice(at, 1);++ list.unshift(chosen);++ } else if (at < 0 && !preferState.why) {++ preferState.why = 'no ' + (prefer.provider || 'provider') + (prefer.model ? '/' + prefer.model : '') + ' offer could serve this request';++ }++ }++ explain.prefer = preferState;++ }const attempts = [...selfAttempts];explain.paid_by = 'openvibe';explain.attempts = attempts;@@@ -659,12 +824,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.nolist = [chosen];explain.reasons = [`pinned to ${chosen.model ? `${chosen.provider}/${chosen.model}` : chosen.provider} for this run`, ...(explain.reasons || [])];}++ // L36 Y7: the fallback chain stops after three providers.++ const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;++ let attemptsMade = 0;for (let i = 0; i < list.length; i++) {const c = list[i];const entry = await adapter(c.provider);const p = entry && entry.record;const a = entry && entry.adapter;const model = c.model || (p && p.default_model) || null;++ const chosen = ranked.reasons.get(c.provider + ':' + (model || '')) || null;const fallback = i > 0;const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };// L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).@@@ -673,9 +842,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;++ if (preferState && preferMatches(c.provider, model, prefer)) { preferState.honoured = false; preferState.why = 'it is over your price ceiling'; }statFor(c.provider).skipped++;tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });-- attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: whyPrice });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: whyPrice });if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'over_price_ceiling' });continue;}@@@ -684,16 +854,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (why) {statFor(c.provider).skipped++;tried.push({ provider: c.provider, skipped: why });-- attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: why });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: why });if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });continue;}++ // L36 Y7: the fallback chain stops after three providers.++ if (attemptsMade >= maxAttempts) break;++ attemptsMade += 1;// An image generation outlasts the shared provider's chat timeout (AI_PROVIDER_TIMEOUT_MS): the route's wins. So does a// route that says so (options.outlast_provider_timeout: space.build writes a whole app, a minute or two). Otherwise// the provider's timeout stays strict, so a slow provider fails over quickly.const outlast = operation === 'image' || Boolean(route.options && route.options.outlast_provider_timeout);-- const cap = outlast ? Math.max(p.timeout_ms || 0, Number(route.timeout_ms) || 0, operation === 'image' ? 600000 : 0) : (p.timeout_ms || 600000);-- const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || p.timeout_ms || 30000, cap));++ const own = await providerTimeout(p, model);++ const cap = outlast ? Math.max(own, Number(route.timeout_ms) || 0, operation === 'image' ? 600000 : 0) : (own || 600000);++ const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || own || DEFAULT_PROVIDER_TIMEOUT_MS, cap));const retries = config.providerRetries;let lastErr = null;for (let attempt = 0; attempt <= retries; attempt++) {@@@ -726,7 +900,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}explain.selected = c.provider;explain.paid_by = 'openvibe';-- attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: 'ok', error: null });++ // L37: the preference was honoured when the offer it named answered.++ if (preferState && preferMatches(c.provider, model, prefer)) {++ preferState.honoured = true;++ preferState.why = 'the ' + c.provider + ' offer answered, as you asked';++ }++ attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: chosen || (fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route'), status: 'ok', error: null });++ warm.remember(warmSubject(ctx, route), leadingText(req), c.provider);explain.attempts = attempts;return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };} catch (err) {@@@ -749,7 +929,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;if (!callerFault(lastErr) && !rateLimited(lastErr)) await recordFailure(c.provider, lastErr);tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });-- attempts.push({ key: 'openvibe', provider: c.provider, model: lastErr && lastErr.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: lastErr && lastErr.code === 'provider.timeout' ? 'timeout' : 'failed', error: String(lastErr && lastErr.message || lastErr).slice(0, 300) });++ attempts.push({ key: 'openvibe', provider: c.provider, model: lastErr && lastErr.model || model, why: chosen || (fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route'), status: lastErr && lastErr.code === 'provider.timeout' ? 'timeout' : 'failed', error: String(lastErr && lastErr.message || lastErr).slice(0, 300) });log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);}explain.selected = null;nnew file mode 100644@@@ -0,0 +1,244 @@++'use strict';++/**++ * The task-aware router (plan L33 P3 "the router picks for the task", L36 Y7 "a smarter router").++ *++ * detectTask({ operation, req, ctx, route }) → { vision, tools, json, min_context, reasoning, output, weight }++ * capabilitiesOf(model, provider) → what the catalogue knows about one model++ * hardNeeds(capabilities, task) → why a model cannot do this job ([] when it can)++ * rank(candidates, { task, warm, preferred }) → the order the router tries, and why each one is there++ *++ * The requirement set is read off the request itself — a picture in, tools, a JSON schema, how much text,++ * what it wants out — and off the kind of work (the workflow and the route name it). No model is called++ * and nothing is guessed: what a model can do comes from the catalogue (server/providers/catalog.js) and++ * from the provider record, never from a hunch.++ *++ * The rules, in one place:++ *++ * vision an image in the request (or a vision operation)++ * tools the request carries tools (the OpenAI tools array, or the Messages API's own)++ * json the request asks for JSON out (a schema, or response_format)++ * min_context the prompt's own size (four characters a token) plus room for the answer++ * reasoning deep when the request asks to think (effort high/max, reasoning true), some for code++ * output text, image, audio or video++ * weight light (classify, short extraction), standard, heavy (code, deep reasoning, long text)++ */++const TIER_RANK = { fast: 0, balanced: 1, strong: 2, frontier: 3 };++const DEFAULT_TIER = 'balanced';++++/** The kind of work a run does: the person's ai.me-preferences.defaults has one default per kind. */++function workKind(operation, req = {}, ctx = {}) {++ if (operation === 'image') return 'image';++ if (req && req.image) return 'vision';++ const hint = String(ctx.workflowKey || '') + ' ' + String(ctx.routeKey || '');++ return /\b(code|coding|codes|build|design|program|developer)\b/i.test(hint) ? 'code' : 'text';++}++++/** Rough token count of a text: four characters a token. */++const tokensOf = (text) => Math.ceil(String(text || '').length / 4);++++/** The prompt that leads a request: its system text, then its messages. */++function leadingText(req = {}) {++ const sys = Array.isArray(req.system) ? req.system.map((x) => (typeof x === 'string' ? x : (x && x.text) || '')).join('\n')++ : (typeof req.system === 'string' ? req.system : '');++ const msgs = Array.isArray(req.messages) ? req.messages.map((m) => {++ if (!m) return '';++ const c = m.content;++ if (typeof c === 'string') return c;++ if (Array.isArray(c)) return c.map((p) => (p && p.text) || '').join(' ');++ return '';++ }).join('\n') : '';++ return sys + '\n' + msgs;++}++++/** The tools a request carries (the OpenAI tools array, or the Messages API's own). */++function toolCount(req = {}) {++ const openai = req.openai && Array.isArray(req.openai.tools) ? req.openai.tools.length : 0;++ const direct = Array.isArray(req.tools) ? req.tools.length : 0;++ return Math.max(openai, direct);++}++++/** Does the request ask for JSON out? (a schema, or the OpenAI response_format) */++function wantsJson(req = {}, route = null) {++ if (req.json) return true;++ const rf = req.openai && req.openai.response_format;++ if (rf && (rf.type === 'json_object' || rf.type === 'json_schema')) return true;++ if (route && route.response_format === 'json') return true;++ return false;++}++++/** The reasoning effort the request asks for: 'none' | 'some' | 'deep' ('' when it says nothing). */++function effortOf(req = {}) {++ const raw = String(req.effort || (req.reasoning && typeof req.reasoning === 'object' ? req.reasoning.effort : '') || '').toLowerCase();++ if (['high', 'max', 'xhigh', 'deep', 'maximum'].includes(raw)) return 'deep';++ if (['medium', 'some', 'standard'].includes(raw)) return 'some';++ if (['low', 'minimal', 'none', 'off'].includes(raw)) return 'none';++ if (req.reasoning === true || req.thinking === true) return 'deep';++ return '';++}++++/** What the request wants out. */++function outputOf(operation, req = {}) {++ if (operation === 'image' || req.image_out) return 'image';++ if (operation === 'video' || req.video) return 'video';++ if (operation === 'speak' || operation === 'tts' || req.audio) return 'audio';++ return 'text';++}++++/** How heavy the work is: light, standard or heavy. */++function weightOf({ operation, kind, reasoning, tools, min_context, maxTokens }) {++ // Code, deep reasoning, a long context or a long answer: the strong models.++ if (reasoning === 'deep' || kind === 'code' || min_context > 32000 || (Number(maxTokens) || 0) > 4000) return 'heavy';++ // A short classification or extraction: the cheapest model that can do it.++ if (!tools && kind !== 'code' && min_context <= 4000 && ['classify', 'extract', 'summarize', 'enrich'].includes(operation)) return 'light';++ return 'standard';++}++++/**++ * The requirement set of one request, read without a model call.++ * { vision, tools, json, min_context, reasoning: none|some|deep, output: text|image|audio|video, weight: light|standard|heavy }++ */++function detectTask({ operation = 'chat', req = {}, ctx = {}, route = null } = {}) {++ const kind = workKind(operation, req, ctx);++ const vision = Boolean(req.image) || operation === 'vision';++ const tools = toolCount(req) > 0;++ const json = wantsJson(req, route);++ const min_context = Math.max(1024, tokensOf(leadingText(req)) + (vision ? 1024 : 0) + 512);++ const effort = effortOf(req);++ const reasoning = effort === 'deep' ? 'deep' : (effort === 'some' || kind === 'code' ? 'some' : 'none');++ const output = outputOf(operation, req);++ const weight = weightOf({ operation, kind, reasoning, tools, min_context, maxTokens: req.maxTokens });++ return { vision, tools, json, min_context, reasoning, output, weight };++}++++/**++ * What the catalogue knows about one model. The model row is the authority; the provider record's declared++ * capabilities are the fallback for a model the catalogue has not described yet (a model typed in by a++ * person, or a provider whose row carries no capabilities). What the catalogue does not say stays unknown:++ * an unknown context window is not a reason to refuse the work.++ */++function capabilitiesOf(model, provider) {++ const provCaps = new Set((provider && provider.capabilities) || []);++ const meta = (model && model.metadata) || {};++ const supports = (model && model.supports) || null;++ // A model the catalogue described (it synced it) says what it can do: its word beats the provider's++ // general claim. A model a person typed in keeps the provider's claim, so a provider that serves images++ // is not silenced by a row that never mentioned them.++ const described = Boolean(meta.synced_at || meta.listed);++ // true, false or null (nobody said). The catalogue's word on a model it described is the authority; a model++ // nobody described keeps the provider's word; what nobody said stays unknown, and unknown is not a refusal.++ const yes = (flag) => {++ const row = supports ? supports[flag] : null;++ if (row === true) return true;++ if (row === false) return described ? false : (provCaps.has(flag) ? true : null);++ return provCaps.has(flag) ? true : null;++ };++ const type = (model && model.type) || null;++ return {++ vision: yes('vision'),++ tools: yes('tools'),++ json: yes('json'),++ context_window: model && Number(model.context_window) > 0 ? Number(model.context_window) : null,++ reasoning: meta.reasoning === true ? true : (meta.reasoning === false ? false : null),++ tier: TIER_RANK[meta.tier] === undefined ? null : meta.tier,++ type,++ image: type === 'image' || provCaps.has('image'),++ video: type === 'video' || provCaps.has('video'),++ audio: type === 'audio' || type === 'speech' || provCaps.has('audio') || provCaps.has('speech'),++ timeout_ms: Number(meta.timeout_ms) > 0 ? Number(meta.timeout_ms) : null,++ };++}++++/**++ * The hard needs: why a model cannot do this job ([] when it can). A model that misses one is never tried.++ */++function hardNeeds(c, task) {++ const missing = [];++ if (task.vision && c.vision === false) missing.push('no_vision');++ if (task.tools && c.tools === false) missing.push('no_tools');++ if (task.json && c.json === false) missing.push('no_json');++ if (task.reasoning === 'deep' && c.reasoning === false) missing.push('no_reasoning');++ if (task.min_context && c.context_window && c.context_window < task.min_context) missing.push('context_too_small');++ if (task.output === 'image' && !c.image) missing.push('no_image_out');++ if (task.output === 'video' && !c.video) missing.push('no_video_out');++ if (task.output === 'audio' && !c.audio) missing.push('no_audio_out');++ return missing;++}++++/** The strength tier's rank; a model the catalogue has not tiered counts as balanced. */++const tierRank = (tier) => (TIER_RANK[tier] === undefined ? TIER_RANK[DEFAULT_TIER] : TIER_RANK[tier]);++++/**++ * The order the router tries, and why each candidate is there.++ *++ * light / standard the cheapest model that can do the job (free capacity first: it costs nothing)++ * heavy the catalogue's strength tier first, then the price++ * within a tier the person's own default, then health, then latency, then price++ *++ * A warm prompt cache is worth up to 10 % of the price, so a near tie goes to the provider that already++ * has this prompt cached (L36 Y7). The order is stable: two candidates that tie keep the pool's order.++ */++function rank(candidates, { task, warm = null, preferred = null, authority = null, tierFirst = null, keepOrder = false } = {}) {++ const isAuthority = (c) => Boolean(authority && (authority === c.provider || authority === c.provider + ':' + (c.model || '')));++ const lead = tierFirst == null ? task.weight === 'heavy' : Boolean(tierFirst);++ const prepared = candidates.map((c, index) => {++ const price = Number(c.price) || 0;++ const isWarm = Boolean(warm && c.provider === warm);++ return { ...c, index, price, warm: isWarm, effective: isWarm ? price * 0.9 : price, tierRank: tierRank(c.tier), authority: isAuthority(c) };++ });++ const cmp = (a, b) => {++ if (keepOrder) return a.index - b.index; // a pinned route keeps its own order++ if (a.authority !== b.authority) return a.authority ? -1 : 1;++ if (preferred && (a.model === preferred) !== (b.model === preferred)) return a.model === preferred ? -1 : 1;++ if (lead && a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;++ if (a.effective !== b.effective) return a.effective - b.effective;++ if (a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;++ if (a.health !== b.health) return (a.health || 0) - (b.health || 0);++ if (a.latency !== b.latency) return (a.latency == null ? Infinity : a.latency) - (b.latency == null ? Infinity : b.latency);++ return a.index - b.index;++ };++ const sorted = [...prepared].sort(cmp);++ return { order: sorted, ranked: sorted.slice(0, 5) };++}++++/** Why a candidate is there, in a few words (the explain's 'ranked'). */++function reasonFor(entry, task) {++ if (entry.warm) return 'warm cache';++ const caps = [];++ if (task.vision) caps.push('vision');++ if (task.tools) caps.push('tools');++ if (task.json) caps.push('JSON');++ const withCaps = caps.length ? ' with ' + caps.join(' and ') : '';++ if (task.weight === 'heavy') return 'the ' + (entry.tier || 'strong') + ' tier' + withCaps;++ return 'the cheapest' + withCaps;++}++++++/**++ * The soft preference a request may carry (L37): { provider, model }. The model may be left out, and a string++ * "provider/model" is read as the same thing. Nothing here fails: an unknown id is simply never matched.++ */++function preferredTarget(raw) {++ if (!raw) return null;++ let provider = null;++ let model = null;++ if (typeof raw === 'string') {++ const at = raw.indexOf('/');++ provider = (at === -1 ? raw : raw.slice(0, at)).trim();++ model = at === -1 ? null : raw.slice(at + 1).trim();++ } else if (typeof raw === 'object') {++ provider = raw.provider != null ? String(raw.provider).trim() : null;++ model = raw.model != null ? String(raw.model).trim() : null;++ }++ if (!provider && !model) return null;++ return { provider: provider || null, model: model || null };++}++++/** Does this candidate answer to the preference? The provider always, and the model when one was named. */++function preferMatches(provider, model, prefer) {++ if (!prefer) return false;++ if (prefer.provider && String(provider || '') !== prefer.provider) return false;++ if (prefer.model && String(model || '') !== prefer.model) return false;++ return Boolean(prefer.provider || prefer.model);++}++++module.exports = { preferredTarget, preferMatches, detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, tokensOf, tierRank, TIER_RANK, DEFAULT_TIER };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * A tiny, in-process memory of the provider that last answered a prompt (L36 Y7: prefer the provider whose++ * prompt cache is already warm). The key is the person or app plus a hash of the first 2 KB of the leading++ * prompt; the value is the provider key; it expires after five minutes. Nothing is persisted, nothing leaves++ * the process, and the map is capped so it can never grow into a cache.++ */++const crypto = require('crypto');++++const DEFAULT_TTL_MS = 5 * 60 * 1000;++const DEFAULT_MAX = 256;++const PROMPT_BYTES = 2048;++++/** A hash of the first 2 KB of a prompt. */++const hashOf = (text) => crypto.createHash('sha256').update(String(text || '').slice(0, PROMPT_BYTES)).digest('hex');++++function createWarmCache({ ttlMs = DEFAULT_TTL_MS, max = DEFAULT_MAX, now = () => Date.now() } = {}) {++ const entries = new Map(); // key → { provider, at }++++ function remember(subject, prompt, provider) {++ if (!provider) return;++ const key = String(subject || 'anonymous') + '|' + hashOf(prompt);++ entries.set(key, { provider, at: now() });++ while (entries.size > max) entries.delete(entries.keys().next().value); // the oldest goes first++ }++++ function get(subject, prompt) {++ const key = String(subject || 'anonymous') + '|' + hashOf(prompt);++ const hit = entries.get(key);++ if (!hit) return null;++ if (now() - hit.at > ttlMs) { entries.delete(key); return null; }++ return hit.provider;++ }++++ return { remember, get, hashOf, size: () => entries.size, clear: () => entries.clear() };++}++++module.exports = { createWarmCache, hashOf, DEFAULT_TTL_MS, DEFAULT_MAX, PROMPT_BYTES };@@@ -19,6 +19,7 @@const { ids } = require('openvibe-contracts');const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');const { preferenceLines } = require('./user-modules');++const { preferredTarget } = require('./providers/task');const schemas = require('./schemas');const events = require('./events');const usageSamples = require('./usage-samples');@@@ -242,6 +243,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// An app's own provider (ADR-056 project secret): only on ai.complete, only for an app; its key spends the// project's money at its provider, so no tier budget holds it (the app's quotas still count it).const projectProvider = body.project_provider == null ? null : projectProviderOf(body.project_provider);++ // L37: the soft preference (a provider, and a model if the caller wants one). It is a wish, not a pin.++ const prefer = preferredTarget(body.prefer);if (projectProvider) {if (!app) throw new AiError(403, 'capability.denied', 'only a developer app names its project\'s own provider');if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');@@@ -282,7 +285,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowoptions: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),...(selfKeys ? { self_subject: personSubject } : {}),-- ...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}) }), created_at: now,++ ...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),++ ...(prefer ? { prefer } : {}) }), created_at: now,};base.input = stableStringify(kept.value);@@@ -436,6 +440,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// Who asked, for a step that must not call the caller back (research: Search's own web ladder).requesterType: ctx.requesterType, requesterId: ctx.requesterId,...(ctx.routePin ? { routePin: ctx.routePin } : {}),++ // L37: the soft preference the caller made (provider, and a model if it named one).++ prefer: opts.prefer || null,...(onCredentialFailed ? { onCredentialFailed } : {}),...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),@@@ -186,6 +186,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spatemperature: input.temperature == null ? null : input.temperature,timeoutMs: input.timeout_ms || route.timeout_ms || ROLE_TIMEOUT[input.role] || 25000,cacheKey: input.cache_key || null,++ prefer: ctx.prefer || null,};const exec = await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });let json = exec.result.json || null;@@@ -205,6 +206,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spamaxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),temperature: input.temperature == null ? null : input.temperature,timeoutMs: Math.max(route.timeout_ms || 0, 120000),++ prefer: ctx.prefer || null,...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),};const exec = await pool.execute(route, 'complete', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });@@@ -223,6 +225,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spamessages: input.messages,maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),timeoutMs: Math.max(route.timeout_ms || 0, 120000),++ prefer: ctx.prefer || null,...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),};for (const k of ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata']) {nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++/**++ * L37: a soft preference. The caller may name a provider (and a model) they would rather have. It is a wish, not a++ * pin: the person's own key for that provider answers first (at no fee), else the network offer for it, and the++ * normal routing is the fallback. It never disables own keys, never overrides only_my_keys, never beats the price++ * ceiling, and never forces a model that fails a hard need. An unknown id is ignored, never an error.++ */++const assert = require('assert');++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');++++const t = suite('prefer-route');++const KEY = 'cd'.repeat(32);++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';++const ANTHROPIC_KEY = 'test-anthropic-key-not-a-secret';++const OPENAI_KEY = 'test-openai-key-not-a-secret';++const person = token('dana', ALL, { sub: DANA, actorType: 'user' });++const service = token('live', ALL);++const calls = [];++let h;++++const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++const credentialFetch = async (url, init = {}) => {++ calls.push({ url: String(url), auth: (init.headers && (init.headers.Authorization || init.headers['x-api-key'])) || null });++ if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });++ if (String(url).endsWith('/messages')) return json({ model: 'm-one', content: [{ type: 'text', text: 'hello from dana key' }], usage: { input_tokens: 4, output_tokens: 2 } });++ if (String(url).endsWith('/chat/completions')) return json({ model: 'm-one', choices: [{ message: { role: 'assistant', content: 'hello from dana key' } }], usage: { prompt_tokens: 4, completion_tokens: 2 } });++ return json({});++};++++async function provider(body) {++ const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });++ assert.strictEqual(r.status, 201, r.text);++}++async function model(providerKey, modelKey, body) {++ const r = await request(h.base, 'POST', '/api/v1/models', { tok: service, body: { provider_key: providerKey, model_key: modelKey, type: 'chat', status: 'active', ...body } });++ assert.strictEqual(r.status, 201, r.text);++}++const prefs = (over) => ({ only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }, ...(over || {}) });++const DESCRIBED = { listed: true, synced_at: '2026-10-31T00:00:00.000Z' };++++t.test('boot', async () => {++ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' }, credentialFetch });++});++++t.test("a person with their own anthropic key, asking for anthropic/claude-opus-5-5, runs on their key at no fee", async () => {++ const net = await seamServer(() => ({ text: 'the network answer', model: 'claude-opus-5-5', usage: { input: 3, output: 1 } }));++ await provider({ key: 'anthropic', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'claude-opus-5-5' });++ await model('anthropic', 'claude-opus-5-5', { cost: { in_per_mtok: 5, out_per_mtok: 25 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });++ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/anthropic', { tok: person, body: { provider: 'anthropic', key: ANTHROPIC_KEY, models: { chat: 'm-one' } } });++ assert.strictEqual(r.status, 200, r.text);++ const before = net.calls;++ r = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: {++ workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' }, on_behalf_of: { type: 'user', id: DANA },++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },++ } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));++ assert.ok(/your anthropic key/.test(r.body.run.explain.paid_by), 'their own key paid: ' + r.body.run.explain.paid_by);++ assert.strictEqual(Number(r.body.run.cost_usd || 0), 0, 'their own key costs them nothing');++ assert.strictEqual(r.body.run.explain.prefer.honoured, true, JSON.stringify(r.body.run.explain.prefer));++ assert.strictEqual(r.body.run.explain.prefer.provider, 'anthropic');++ assert.strictEqual(r.body.run.explain.prefer.model, 'claude-opus-5-5');++ assert.strictEqual(net.calls, before, 'the network offer was never called');++ await net.close();++});++++t.test('without a key of their own, it runs on the network offer for that provider', async () => {++ const net = await seamServer(() => ({ text: 'the network answer', model: 'claude-opus-5-5', usage: { input: 3, output: 1 } }));++ await provider({ key: 'anthropic', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'claude-opus-5-5' });++ await model('anthropic', 'claude-opus-5-5', { cost: { in_per_mtok: 5, out_per_mtok: 25 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: {++ workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' },++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },++ } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));++ assert.strictEqual(r.body.run.provenance.provider, 'anthropic', 'the offer they named answered');++ assert.strictEqual(r.body.run.explain.prefer.honoured, true, JSON.stringify(r.body.run.explain.prefer));++ assert.ok(net.calls > 0, 'the network offer was called');++ await net.close();++});++++t.test('only_my_keys with no matching key does not fall to the network', async () => {++ const net = await seamServer(() => ({ text: 'the network answer', model: 'claude-opus-5-5', usage: { input: 3, output: 1 } }));++ await provider({ key: 'anthropic', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'claude-opus-5-5' });++ await model('anthropic', 'claude-opus-5-5', { cost: { in_per_mtok: 5, out_per_mtok: 25 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });++ // They keep a key, but not for the provider they ask for.++ let r = await request(h.base, 'DELETE', '/api/v1/me/credentials/anthropic', { tok: person });++ assert.ok([200, 204].includes(r.status), r.text);++ r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: OPENAI_KEY, models: { chat: 'm-chat' } } });++ assert.strictEqual(r.status, 200, r.text);++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefs({ only_my_keys: true }) });++ assert.strictEqual(r.status, 200, r.text);++ const before = net.calls;++ r = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: {++ workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' }, on_behalf_of: { type: 'user', id: DANA },++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },++ } });++ const run = r.body.run;++ assert.ok(run.status === 'succeeded' || (run.error && run.error.code === 'ai.needs_key'), JSON.stringify(run).slice(0, 400));++ assert.strictEqual(run.explain.prefer.honoured, false, JSON.stringify(run.explain.prefer));++ assert.ok(run.explain.prefer.why, 'the explain says why the preference was not honoured');++ assert.strictEqual(net.calls, before, 'the network was never called');++ if (run.status === 'succeeded') assert.ok(/your openai key/.test(run.explain.paid_by), 'their own key paid: ' + run.explain.paid_by);++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefs() });++ assert.strictEqual(r.status, 200, r.text);++ await request(h.base, 'DELETE', '/api/v1/me/credentials/openai', { tok: person });++ await net.close();++});++++t.test('a prefer that needs vision on a text-only model is not honoured', async () => {++ const see = await seamServer(() => ({ text: 'I can see', model: 'seer-m', usage: { input: 3, output: 1 } }));++ const blind = await seamServer(() => ({ text: 'I cannot', model: 'blind-m', usage: { input: 3, output: 1 } }));++ await provider({ key: 'seer', base_url: see.url, capabilities: ['chat', 'generate', 'vision', 'json'], default_model: 'seer-m' });++ await model('seer', 'seer-m', { supports: { json: true, vision: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ await provider({ key: 'blind', base_url: blind.url, capabilities: ['chat', 'generate', 'json'], default_model: 'blind-m' });++ await model('blind', 'blind-m', { supports: { json: true, vision: false }, metadata: { tier: 'balanced', ...DESCRIBED } });++ const r = await request(h.base, 'POST', '/api/v1/routes/test.prefer/versions', { tok: service, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });++ assert.strictEqual(r.status, 201, r.text);++ const route = r.body.route;++ const exec = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'what is in this picture?' }], image: { base64: 'aGk=' }, timeoutMs: 3000, prefer: { provider: 'blind', model: 'blind-m' } }, { routeKey: route.key, routeVersion: route.version });++ assert.strictEqual(exec.provider, 'seer', 'the text-only model never answers a picture');++ assert.strictEqual(exec.explain.prefer.honoured, false, JSON.stringify(exec.explain.prefer));++ assert.ok(/cannot see/.test(exec.explain.prefer.why), exec.explain.prefer.why);++ await see.close(); await blind.close();++});++++t.test('an unknown provider or model is ignored, never an error', async () => {++ const net = await seamServer(() => ({ text: 'the network answer', model: 'm', usage: { input: 3, output: 1 } }));++ await provider({ key: 'plain', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'plain-m' });++ await model('plain', 'plain-m', { supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ const r = await request(h.base, 'POST', '/api/v1/routes/test.unknown/versions', { tok: service, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });++ const route = r.body.route;++ const exec = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000, prefer: { provider: 'nobody', model: 'nothing' } }, { routeKey: route.key, routeVersion: route.version });++ assert.ok(exec.provider, 'the run still answers');++ assert.strictEqual(exec.explain.prefer.honoured, false);++ assert.ok(/no provider by that name/.test(exec.explain.prefer.why), exec.explain.prefer.why);++ await net.close();++});++++t.test('a pinned route behaves as before', async () => {++ const net = await seamServer(() => ({ text: 'the network answer', model: 'm', usage: { input: 3, output: 1 } }));++ const pinned = await seamServer(() => ({ text: 'the pinned answer', model: 'm', usage: { input: 3, output: 1 } }));++ await provider({ key: 'pinned-one', base_url: pinned.url, capabilities: ['chat', 'generate', 'json'], default_model: 'p-m' });++ await model('pinned-one', 'p-m', { supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ await provider({ key: 'other', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'o-m' });++ await model('other', 'o-m', { supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ const r = await request(h.base, 'POST', '/api/v1/routes/test.pinned/versions', { tok: service, body: { primary: { provider: 'pinned-one' }, fallbacks: [{ provider: 'other' }], timeout_ms: 3000 } });++ assert.strictEqual(r.status, 201, r.text);++ const route = r.body.route;++ const exec = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000, prefer: { provider: 'other', model: 'o-m' } }, { routeKey: route.key, routeVersion: route.version });++ assert.strictEqual(exec.provider, 'pinned-one', 'the pin decides, as before');++ assert.strictEqual(exec.explain.prefer.honoured, false);++ assert.ok(/pinned/.test(exec.explain.prefer.why), exec.explain.prefer.why);++ await net.close(); await pinned.close();++});++++t.test('shutdown', async () => { await h.stop(); });++t.run();nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++/**++ * The task-aware router (plan L33 P3 "the router picks for the task", L36 Y7 "a smarter router").++ * - a vision request never goes to a model that cannot see: the hard needs come first;++ * - a light job takes the cheapest capable model, a heavy one the catalogue's strength tier;++ * - the person's free-first, price ceiling and per-kind default are respected;++ * - a warm prompt cache wins a near tie;++ * - the fallback chain stops after three attempts, and only_my_keys never leaves the person's keys;++ * - the explain carries the requirement set (task), the ranked candidates and every attempt.++ */++const assert = require('assert');++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');++++const t = suite('task-router');++const tok = token('live', ALL);++let h;++++async function provider(body) {++ const r = await request(h.base, 'POST', '/api/v1/providers', { tok, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });++ assert.strictEqual(r.status, 201, r.text);++}++async function model(providerKey, modelKey, body) {++ const r = await request(h.base, 'POST', '/api/v1/models', { tok, body: { provider_key: providerKey, model_key: modelKey, type: 'chat', status: 'active', ...body } });++ assert.strictEqual(r.status, 201, r.text);++}++async function route(key, body) {++ const r = await request(h.base, 'POST', '/api/v1/routes/' + key + '/versions', { tok, body });++ assert.strictEqual(r.status, 201, r.text);++ return r.body.route;++}++const prefs = (over) => ({ only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }, ...(over || {}) });++const DESCRIBED = { listed: true, synced_at: '2026-10-31T00:00:00.000Z' };++++t.test('a vision request never goes to a model that cannot see', async () => {++ const seer = await seamServer(() => ({ text: 'a picture of a cat', model: 'seer-m', usage: { input: 5, output: 2 } }));++ const blind = await seamServer(() => ({ text: 'I never saw it', model: 'blind-m', usage: { input: 5, output: 2 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ // Both providers serve images; the catalogue says one of the two models does not.++ await provider({ key: 'seer', base_url: seer.url, capabilities: ['chat', 'generate', 'vision', 'json'], default_model: 'seer-m' });++ await model('seer', 'seer-m', { supports: { json: true, vision: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ await provider({ key: 'blind', base_url: blind.url, capabilities: ['chat', 'generate', 'vision', 'json'], default_model: 'blind-m' });++ await model('blind', 'blind-m', { supports: { json: true, vision: false }, metadata: { tier: 'balanced', ...DESCRIBED } });++ const r = await route('test.vision', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });++++ const exec = await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'what is in this picture?' }], image: { base64: 'aGk=' }, timeoutMs: 3000 }, { routeKey: r.key, routeVersion: r.version });++ assert.strictEqual(exec.provider, 'seer', 'the model that cannot see is never tried');++ assert.strictEqual(blind.calls, 0, 'the text-only model was never called');++ assert.strictEqual(exec.explain.task.vision, true, 'the explain says the task needed vision');++ assert.deepStrictEqual(Object.keys(exec.explain.task).sort(), ['json', 'min_context', 'output', 'reasoning', 'tools', 'vision', 'weight']);++ const c = exec.explain.candidates.find((x) => x.provider === 'blind');++ assert.strictEqual(c.eligible, false, 'the text-only model is excluded');++ assert.match(c.excluded_reason, /no_vision/);++ await h.stop(); await seer.close(); await blind.close();++});++++t.test('a light job takes the cheapest capable model; a heavy one the strong tier', async () => {++ const srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 3, output: 1 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ await provider({ key: 'weak', base_url: srv.url, capabilities: ['chat', 'classify', 'generate', 'json'], default_model: 'weak-m' });++ await model('weak', 'weak-m', { cost: { in_per_mtok: 1, out_per_mtok: 1 }, supports: { json: true }, metadata: { tier: 'fast', ...DESCRIBED } });++ await provider({ key: 'strong', base_url: srv.url, capabilities: ['chat', 'classify', 'generate', 'json'], default_model: 'strong-m' });++ await model('strong', 'strong-m', { cost: { in_per_mtok: 50, out_per_mtok: 50 }, supports: { json: true }, metadata: { tier: 'strong', reasoning: true, ...DESCRIBED } });++ const r = await route('test.task-fit', { capability: 'classify', constraints: { objective: 'balanced' }, timeout_ms: 3000 });++++ const light = await h.pool.execute(r, 'classify', { messages: [{ role: 'user', content: 'is this positive?' }], timeoutMs: 3000 }, { routeKey: r.key, routeVersion: r.version });++ assert.strictEqual(light.explain.task.weight, 'light', 'a short classification is light work');++ assert.strictEqual(light.provider, 'weak', 'a light job takes the cheapest capable model');++ assert.strictEqual(light.explain.ranked[0].provider, 'weak');++ assert.match(light.explain.ranked[0].why, /cheapest/);++++ const heavy = await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'write the function' }], reasoning: true, maxTokens: 8000, timeoutMs: 3000 }, { routeKey: 'code.demo', routeVersion: r.version, workflowKey: 'code.demo' });++ assert.strictEqual(heavy.explain.task.weight, 'heavy', 'code with deep reasoning is heavy work');++ assert.strictEqual(heavy.explain.task.reasoning, 'deep');++ assert.strictEqual(heavy.provider, 'strong', 'a heavy job takes the strong tier, whatever it costs');++ assert.match(heavy.explain.ranked[0].why, /strong tier/);++ await h.stop(); await srv.close();++});++++t.test("the person's free-first and price ceiling are respected", async () => {++ const srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 3, output: 1 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ await provider({ key: 'freebie', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'free-m', metadata: { billing_profile: 'free', paid: false } });++ await model('freebie', 'free-m', { supports: { json: true }, metadata: { tier: 'fast', ...DESCRIBED } });++ await provider({ key: 'paywall', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'pay-m' });++ await model('paywall', 'pay-m', { cost: { in_per_mtok: 2, out_per_mtok: 20 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });++ const r = await route('test.prefs', { capability: 'chat', constraints: { objective: 'balanced' }, timeout_ms: 3000 });++ const ctx = { routeKey: r.key, routeVersion: r.version, selfSubject: 'user:1' };++ const req = { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 };++++ const free = await h.pool.execute(r, 'chat', req, { ...ctx, selfPreferences: prefs() });++ assert.strictEqual(free.provider, 'freebie', 'free_first puts the free tier first');++++ const noFree = await h.pool.execute(r, 'chat', { ...req, messages: [{ role: 'user', content: 'third' }] }, { ...ctx, selfPreferences: prefs({ free_first: false }) });++ assert.strictEqual(noFree.provider, 'paywall', 'without free_first the strength tier leads');++++ // With the free tier away, the paid model is over the person's ceiling and is never called.++ const off = await request(h.base, 'POST', '/api/v1/providers/freebie/disable', { tok });++ assert.strictEqual(off.status, 200, off.text);++ let err = null;++ try {++ await h.pool.execute(r, 'chat', { ...req, messages: [{ role: 'user', content: 'fourth' }] }, { ...ctx, selfPreferences: prefs({ price_ceiling_usd_per_mtok: 5 }) });++ } catch (e) { err = e; }++ assert.ok(err, 'nothing under the ceiling could answer');++ assert.strictEqual(err.code, 'provider.unavailable');++ const over = err.extra.explain.attempts.find((a) => a.provider === 'paywall');++ assert.ok(over && /over_price_ceiling/.test(over.error), 'the model over the ceiling is skipped with its reason');++ await h.stop(); await srv.close();++});++++t.test('a warm prompt cache wins a near tie', async () => {++ const one = await seamServer(() => ({ text: 'from one', model: 'm', usage: { input: 3, output: 1 } }));++ const two = await seamServer(() => ({ text: 'from two', model: 'm', usage: { input: 3, output: 1 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ await provider({ key: 'warm-one', base_url: one.url, capabilities: ['chat', 'generate'], default_model: 'w1' });++ await model('warm-one', 'w1', { cost: { in_per_mtok: 10, out_per_mtok: 10 }, supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ await provider({ key: 'warm-two', base_url: two.url, capabilities: ['chat', 'generate'], default_model: 'w2' });++ await model('warm-two', 'w2', { cost: { in_per_mtok: 9.5, out_per_mtok: 9.5 }, supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ const r = await route('test.warm', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });++ const ctx = { routeKey: r.key, routeVersion: r.version, selfSubject: 'user:1' };++ const req = { messages: [{ role: 'user', content: 'the same prompt, again and again' }], timeoutMs: 3000 };++++ // The cheaper provider is away, so the other one answers and its prompt cache is warm.++ const off = await request(h.base, 'POST', '/api/v1/providers/warm-two/disable', { tok });++ assert.strictEqual(off.status, 200, off.text);++ const first = await h.pool.execute(r, 'chat', req, ctx);++ assert.strictEqual(first.provider, 'warm-one', 'the one that could answer did');++ const on = await request(h.base, 'POST', '/api/v1/providers/warm-two/enable', { tok });++ assert.strictEqual(on.status, 200, on.text);++++ const second = await h.pool.execute(r, 'chat', req, ctx);++ assert.strictEqual(second.provider, 'warm-one', 'the warm cache wins the near tie');++ assert.strictEqual(second.explain.ranked[0].why, 'warm cache');++ assert.ok(second.explain.ranked.length <= 5, 'the ranked list stays short');++ await h.stop(); await one.close(); await two.close();++});++++t.test('the fallback chain stops after three attempts', async () => {++ const dead = await seamServer(() => ({ status: 500, body: { error: { message: 'down' } } }));++ const good = await seamServer(() => ({ text: 'never reached', model: 'm', usage: { input: 1, output: 1 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_BREAKER_FAILURES: '50' } });++ for (const k of ['d1', 'd2', 'd3', 'd4']) {++ await provider({ key: k, base_url: dead.url, capabilities: ['chat', 'generate'], default_model: k + '-m' });++ await model(k, k + '-m', { cost: { in_per_mtok: 1, out_per_mtok: 1 }, supports: { json: true } });++ }++ await provider({ key: 'last', base_url: good.url, capabilities: ['chat', 'generate'], default_model: 'last-m' });++ await model('last', 'last-m', { cost: { in_per_mtok: 99, out_per_mtok: 99 }, supports: { json: true } });++ const r = await route('test.chain', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });++++ let err = null;++ try { await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'hi' }], timeoutMs: 3000 }, { routeKey: r.key, routeVersion: r.version }); } catch (e) { err = e; }++ assert.ok(err, 'the chain gave up');++ assert.strictEqual(err.code, 'provider.unavailable');++ const attempts = err.extra.explain.attempts;++ assert.strictEqual(attempts.filter((a) => a.status === 'failed').length, 3, 'three attempts, no more');++ assert.ok(attempts.every((a) => a.why), 'every attempt says why it was chosen');++ assert.ok(attempts[0].error, 'a failed attempt says what went wrong');++ assert.strictEqual(good.calls, 0, 'the fourth candidate was never tried');++ await h.stop(); await dead.close(); await good.close();++});++++t.test("only_my_keys never leaves the person's keys", async () => {++ const srv = await seamServer(() => ({ text: 'a network answer', model: 'm', usage: { input: 1, output: 1 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ await provider({ key: 'network', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'n-m' });++ await model('network', 'n-m', { supports: { json: true } });++ const r = await route('test.onlymine', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });++++ let err = null;++ try {++ await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'hi' }], timeoutMs: 3000 },++ { routeKey: r.key, routeVersion: r.version, selfSubject: 'user:1', selfPreferences: prefs({ only_my_keys: true }) });++ } catch (e) { err = e; }++ assert.ok(err, 'the run failed');++ assert.strictEqual(err.status, 422);++ assert.strictEqual(err.code, 'ai.needs_key');++ assert.strictEqual(srv.calls, 0, 'no network provider was called');++ await h.stop(); await srv.close();++});++++t.test('the run carries the task and the ranked candidates', async () => {++ const srv = await seamServer(() => ({ text: 'the answer', model: 'm', usage: { input: 4, output: 2 } }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ await provider({ key: 'runner', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'run-m' });++ await model('runner', 'run-m', { cost: { in_per_mtok: 1, out_per_mtok: 2 }, supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });++ const res = await request(h.base, 'POST', '/api/v1/generate?wait=8000', { tok, body: { prompt: 'say something', options: { cache: false } } });++ assert.strictEqual(res.status, 201, res.text);++ const run = res.body.run;++ assert.strictEqual(run.status, 'succeeded', JSON.stringify(run).slice(0, 400));++ assert.ok(run.explain.task, 'the run explains the task it read');++ assert.strictEqual(run.explain.task.output, 'text');++ assert.ok(Array.isArray(run.explain.ranked) && run.explain.ranked.length, 'the run explains the ranked candidates');++ assert.ok(run.explain.ranked.every((x) => x.provider && x.why), 'every ranked candidate has a reason');++ assert.ok(run.explain.attempts.length && run.explain.attempts.every((a) => a.why), 'every attempt says why it was chosen');++ await h.stop(); await srv.close();++});++++t.run();
@@ -748,7 +748,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
@@ -748,7 +748,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.257.0
+- openvibe-contracts: v0.262.0
- openvibe-sdk: v0.75.0
- openvibe-sdk: v0.75.0
- openvibe-shared: v4.66.0
- openvibe-shared: v4.66.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"runtime": "node 22, express, postgresql",
"port": 4700,
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
- "contracts": "openvibe-contracts v0.257.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)",
+ "contracts": "openvibe-contracts v0.262.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)",
"sdk": "openvibe-sdk v0.75.0",
"sdk": "openvibe-sdk v0.75.0",
"shared": "openvibe-shared v4.66.0",
"shared": "openvibe-shared v4.66.0",
"capabilities": [
"capabilities": [
@@ -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.257.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1540,9 +1540,9 @@
@@ -1540,9 +1540,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.257.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",
- "integrity": "sha512-fzyXLBKCqrOjUvlUB6UKxZQgrwOMhXaQK8lyxmmpkIujcd8AaIRW0DVTvYGIRJGCf/1Z3RiYS5gK1XLG8rkXFA==",
+ "version": "0.262.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",
+ "integrity": "sha512-E5zcp/ucdLH5q22jWxzdw1tgDRt9Y4y4TEVZvp3C6xwe2DOlDVDYgASHv8lF7QSHHmkmm9narAeQKmLrMBBhLA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,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.257.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.262.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.66.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -140,6 +140,8 @@ function messagesRouter({ runs, auth, config, log = console }) {
@@ -140,6 +140,8 @@ function messagesRouter({ runs, auth, config, log = console }) {
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
const input = { messages: b.messages, max_tokens: Math.min(b.max_tokens, MAX_TOKENS) };
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
for (const k of FIELDS) if (b[k] !== undefined && b[k] !== null) input[k] = b[k];
let body = { workflow: 'ai.messages', input };
let body = { workflow: 'ai.messages', input };
+ // L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
+ if (b.prefer) body = { ...body, prefer: b.prefer };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
const own = credentialFor(req);
const own = credentialFor(req);
if (own.error) return anthropicError(res, own.status, own.error);
if (own.error) return anthropicError(res, own.status, own.error);
@@ -127,6 +127,8 @@ function openaiRouter({ runs, auth, config, log = console }) {
@@ -127,6 +127,8 @@ function openaiRouter({ runs, auth, config, log = console }) {
const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;
const maxTokens = b.max_completion_tokens != null ? b.max_completion_tokens : b.max_tokens;
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
if (maxTokens != null) input.max_tokens = Math.max(1, Math.min(Number(maxTokens) || 1, 16000));
let body = { workflow: 'ai.complete', input };
let body = { workflow: 'ai.complete', input };
+ // L37: the soft preference (provider, and a model if the caller wants one). A wish, not a pin.
+ if (b.prefer) body = { ...body, prefer: b.prefer };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
// The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.
// The person's own key (Codes C3): x-openvibe-credential names the provider whose key this request uses.
const personKey = credentialFor(req);
const personKey = credentialFor(req);
@@ -133,6 +133,10 @@ function load(env = process.env) {
@@ -133,6 +133,10 @@ function load(env = process.env) {
costWeight: float(env.AI_PLACEMENT_COST_WEIGHT, 0.5),
costWeight: float(env.AI_PLACEMENT_COST_WEIGHT, 0.5),
latencyWeight: float(env.AI_PLACEMENT_LATENCY_WEIGHT, 0.5),
latencyWeight: float(env.AI_PLACEMENT_LATENCY_WEIGHT, 0.5),
},
},
+ // L36 Y7: the task-aware router's fallback chain tries at most this many providers.
+ routing: {
+ maxAttempts: Math.max(1, int(env.AI_ROUTING_MAX_ATTEMPTS, 3)),
+ },
// Configurable local HTTP seam: POST {operation, request} JSON to this URL.
// Configurable local HTTP seam: POST {operation, request} JSON to this URL.
httpSeamUrl: trimUrl(env.AI_HTTP_SEAM_URL),
httpSeamUrl: trimUrl(env.AI_HTTP_SEAM_URL),
// A local model server speaking the OpenAI API (llama.cpp's llama-server, Ollama's /v1): the provider `local`,
// A local model server speaking the OpenAI API (llama.cpp's llama-server, Ollama's /v1): the provider `local`,
@@ -31,6 +31,8 @@ const { createAnthropicProvider } = require('./anthropic');
@@ -31,6 +31,8 @@ const { createAnthropicProvider } = require('./anthropic');
const { createHttpSeamProvider } = require('./http');
const { createHttpSeamProvider } = require('./http');
const { createWhisperProvider } = require('./whisper');
const { createWhisperProvider } = require('./whisper');
const rates = require('./rate-cards');
const rates = require('./rate-cards');
+const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');
+const { createWarmCache } = require('./warm-cache');
/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */
/** 4xx that the request itself caused (bad image, schema, max_tokens...), as opposed to auth, rate or availability. */
function callerFault(err) {
function callerFault(err) {
@@ -49,12 +51,23 @@ const CREDENTIAL_DEFAULT_MODEL = { ...DEFAULT_MODEL, anthropic: 'claude-haiku-5-
@@ -49,12 +51,23 @@ const CREDENTIAL_DEFAULT_MODEL = { ...DEFAULT_MODEL, anthropic: 'claude-haiku-5-
/** The key as explain names it, never the key itself (L36 Y1/Y7). */
/** The key as explain names it, never the key itself (L36 Y1/Y7). */
const paidByOf = (cred) => `your ${cred.provider} key …${String(cred.hint || '').slice(-4) || '????'}`;
const paidByOf = (cred) => `your ${cred.provider} key …${String(cred.hint || '').slice(-4) || '????'}`;
-/** The kind of work a run does, for the person's ai.me-preferences.defaults. */
-function workKind(operation, req, ctx = {}) {
- if (operation === 'image') return 'image';
- if (req && req.image) return 'vision';
- const hint = `${ctx.workflowKey || ''} ${ctx.routeKey || ''}`;
- return /\b(code|coding|codes|build|design|program|developer)\b/i.test(hint) ? 'code' : 'text';
+// The kind of work a run does (the person's ai.me-preferences.defaults has one default per kind)
+// comes from the task detector: server/providers/task.js.
+/** The default per-provider timeout when neither the catalogue nor the provider names one. */
+const DEFAULT_PROVIDER_TIMEOUT_MS = 30000;
+/** The fallback chain's budget when the config does not name one. */
+const DEFAULT_MAX_ATTEMPTS = 3;
+/**
+ * Who the warm-cache memory belongs to: the person, else the app or service that asked, and the route it
+ * asked on (the same prompt on another route is another job, and its cache is a different one).
+ */
+const warmSubject = (ctx = {}, route = null) =>
+ (ctx.selfSubject || (ctx.requesterType ? ctx.requesterType + ':' + ctx.requesterId : null) || 'anonymous') + '|' + String((route && route.key) || '');
+/** The person's own default model for this kind of work, when they named one (L36 Y3). */
+function preferredModelFor(prefs, operation, req, ctx) {
+ const kind = workKind(operation, req, ctx);
+ const choice = prefs && prefs.defaults ? prefs.defaults[kind] : null;
+ return choice && choice !== 'auto' ? choice : null;
}
}
/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */
/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */
function credentialCanServe(provider, operation, req) {
function credentialCanServe(provider, operation, req) {
@@ -83,6 +96,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -83,6 +96,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const machines = machineModels || require('../machines').createMachineModels({ config, env, fetchImpl });
const cache = new Map(); // key -> { stamp, adapter }
const cache = new Map(); // key -> { stamp, adapter }
const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)
const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)
+ // L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).
+ const warm = createWarmCache({ now: () => clock.now() });
function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }
function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }
@@ -401,6 +416,91 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -401,6 +416,91 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return { order, explain, placement: result, rateCards: rest.rateCards || [], states: rest.states || [] }; // placement: the platform.placement-result@1 explain is built from (pool routes)
return { order, explain, placement: result, rateCards: rest.rateCards || [], states: rest.states || [] }; // placement: the platform.placement-result@1 explain is built from (pool routes)
}
}
+ /**
+ * What the catalogue knows about one candidate (L36 Y7): its capabilities, its prices, its strength tier.
+ */
+ async function describeCandidate(c) {
+ const entry = await adapter(c.provider);
+ const p = entry && entry.record;
+ const model = c.model || (p && p.default_model) || null;
+ const row = model ? await registry.getModel(c.provider, model) : null;
+ const caps = capabilitiesOf(row, p);
+ const prices = await priceFor(c.provider, model);
+ return { provider: c.provider, model, caps, row, record: p, priceIn: Number(prices.in) || 0, priceOut: Number(prices.out) || 0 };
+ }
+
+ /**
+ * The provider's own timeout for one model (L36 Y7): the catalogue's model row first, then the
+ * provider's preset. It is the ceiling: a person can never set a longer one.
+ */
+ async function providerTimeout(p, model) {
+ const row = model ? await registry.getModel(p.key, model) : null;
+ const meta = row && row.metadata ? row.metadata : {};
+ return Number(meta.timeout_ms) || Number(p.timeout_ms) || 0;
+ }
+
+ /**
+ * What the catalogue knows about the model a caller asked for (L37): the provider, and what the model can do.
+ * A model the catalogue never described is not refused: the provider decides.
+ */
+ async function preferredModel(provider, model, task) {
+ const entry = provider ? await adapter(provider) : null;
+ const p = entry && entry.record;
+ const row = model ? await registry.getModel(provider, model) : null;
+ const caps = capabilitiesOf(row, p);
+ return { known: Boolean(entry), provider, model, caps, missing: hardNeeds(caps, task) };
+ }
+
+ /** The health the pool left a candidate with: 0 up, 1 degraded, 2 down. */
+ function healthRank(excluded) {
+ const why = String(excluded || '');
+ if (/health down/.test(why)) return 2;
+ if (/health degraded/.test(why)) return 1;
+ return 0;
+ }
+
+ /**
+ * L36 Y7: pick for the task. The hard needs come first (a model that cannot do the job is never tried),
+ * then fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength
+ * tier first. The explain says what the task was and why every candidate is there.
+ */
+ async function rankCandidates({ list, explain, task, route = null, warmProvider = null, preferred = null, ceiling = null, tierFirst = null, keepOrder = false }) {
+ const byKey = new Map((explain.candidates || []).map((c) => [c.provider + ':' + (c.model || ''), c]));
+ const authority = route && route.constraints ? (route.constraints.authority || null) : null;
+ const entries = [];
+ const rest = [];
+ let stub = null;
+ for (const c of list) {
+ if (c.provider === 'stub') { stub = c; continue; } // synthetic output is never a candidate
+ const seen = byKey.get(c.provider + ':' + (c.model || ''));
+ const d = await describeCandidate(c);
+ const missing = hardNeeds(d.caps, task);
+ if (missing.length) {
+ // A model that cannot do this job is never tried: it leaves the order with its reason.
+ if (seen) { seen.eligible = false; seen.excluded_reason = missing.join(','); }
+ continue;
+ }
+ const over = ceiling != null && d.priceOut > Number(ceiling);
+ const entry = {
+ provider: c.provider, model: d.model, price: over ? Infinity : d.priceIn,
+ tier: d.caps.tier || 'balanced', health: healthRank(seen && seen.excluded_reason),
+ latency: seen ? seen.latency : null, candidate: c, eligible: !(seen && seen.eligible === false),
+ };
+ // A pinned route keeps its own order (and its skipped candidates where the pin put them).
+ if (keepOrder) { entries.push(entry); continue; }
+ if (!entry.eligible) { rest.push(c); continue; } // the pool excluded it (health, cost)
+ entries.push(entry);
+ }
+ const ranked = keepOrder
+ ? { order: entries.map((e) => e.candidate), ranked: entries.slice(0, 5) }
+ : rank(entries, { task, warm: warmProvider, preferred, authority, tierFirst });
+ const order = keepOrder ? ranked.order : [...ranked.order.map((e) => ({ provider: e.provider, model: e.model })), ...rest];
+ if (stub) order.push(stub);
+ const view = ranked.ranked.map((e) => ({ provider: e.provider, model: e.model, why: reasonFor(e, task), cost: Number.isFinite(e.price) ? e.price : null, tier: e.tier }));
+ const reasons = new Map(ranked.order.map((e) => [e.provider + ':' + (e.model || ''), reasonFor(e, task)]));
+ return { order, ranked: view, reasons, keepOrder };
+ }
+
async function skipReason(p, a, features) {
async function skipReason(p, a, features) {
if (!p || !a) return 'unknown_provider';
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
if (p.status !== 'active') return 'disabled';
@@ -585,6 +685,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -585,6 +685,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (req.image) features.push('vision');
if (req.image) features.push('vision');
if (req.json) features.push('json');
if (req.json) features.push('json');
if (req.openai && Array.isArray(req.openai.tools) && req.openai.tools.length) features.push('tools');
if (req.openai && Array.isArray(req.openai.tools) && req.openai.tools.length) features.push('tools');
+ // L36 Y7: the task, read off the request itself: what it needs, what it wants out, how heavy it is.
+ const task = detectTask({ operation, req, ctx, route });
+ // L37: a soft preference. The caller names a provider (and a model if it wants one). It never disables the
+ // person's own keys, never overrides only_my_keys, never beats the price ceiling, and never forces a model
+ // that fails a hard need. An unknown id is ignored, never an error.
+ const prefer = preferredTarget(req.prefer);
+ const preferState = prefer ? { provider: prefer.provider, model: prefer.model, honoured: false, why: '' } : null;
const tried = [];
const tried = [];
let unreadable = null; // a candidate could not read the caller's media (whisper's ffmpeg): no other will either
let unreadable = null; // a candidate could not read the caller's media (whisper's ffmpeg): no other will either
// A streamed answer (/v1/chat/completions, stream: true) is the caller's once a delta has gone out: a failure
// A streamed answer (/v1/chat/completions, stream: true) is the caller's once a delta has gone out: a failure
@@ -599,23 +706,42 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -599,23 +706,42 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {
if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {
const order = [...selfList].sort((a, b) => {
const order = [...selfList].sort((a, b) => {
const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);
const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);
- return rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider));
+ // L37: the person's own key for the provider they asked for answers first, at no fee.
+ const want = (x) => (prefer && prefer.provider && String(x.provider) === prefer.provider) ? 0 : 1;
+ return want(a) - want(b) || rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider));
});
});
for (const cred of order) {
for (const cred of order) {
if (!credentialCanServe(cred.provider, operation, req)) {
if (!credentialCanServe(cred.provider, operation, req)) {
selfAttempts.push({ key: paidByOf(cred), provider: cred.provider, model: null, why: 'your own key', status: 'skipped', error: `this key cannot serve ${operation}` });
selfAttempts.push({ key: paidByOf(cred), provider: cred.provider, model: null, why: 'your own key', status: 'skipped', error: `this key cannot serve ${operation}` });
continue;
continue;
}
}
- const pick = selfModelFor(cred, operation, req, ctx, prefs);
+ let pick = selfModelFor(cred, operation, req, ctx, prefs);
+ // L37: the model they named, when it can do this job. A model that cannot is never forced.
+ if (prefer && prefer.model && (!prefer.provider || String(cred.provider) === prefer.provider)) {
+ const check = await preferredModel(prefer.provider || cred.provider, prefer.model, task);
+ if (check.missing.length) {
+ if (preferState && !preferState.why) preferState.why = check.missing.includes('no_vision') ? 'the model you named cannot see' : 'the model you named cannot do this job';
+ } else {
+ pick = { model: prefer.model, why: 'your own ' + cred.provider + ' key, with the model you asked for' };
+ }
+ }
const key = paidByOf(cred);
const key = paidByOf(cred);
try {
try {
const out = await executeWithCredential(cred, operation, req, { ...ctx, defaultModel: pick.model });
const out = await executeWithCredential(cred, operation, req, { ...ctx, defaultModel: pick.model });
const attempts = [...selfAttempts, { key, provider: cred.provider, model: pick.model, why: pick.why, status: 'ok', error: null }];
const attempts = [...selfAttempts, { key, provider: cred.provider, model: pick.model, why: pick.why, status: 'ok', error: null }];
+ // L37: the preference was honoured when their own key for that provider answered.
+ if (preferState && prefer.provider && String(cred.provider) === prefer.provider) {
+ preferState.honoured = true;
+ preferState.why = 'your own ' + cred.provider + ' key answered, as you asked';
+ } else if (preferState && !preferState.honoured && !preferState.why) {
+ preferState.why = 'your ' + cred.provider + ' key answered instead: you have no key for the one you asked for';
+ }
return {
return {
...out, fallbackUsed: false,
...out, fallbackUsed: false,
explain: {
explain: {
objective: 'own-keys', reasons: ['your own keys are tried before the network\'s providers'],
objective: 'own-keys', reasons: ['your own keys are tried before the network\'s providers'],
paid_by: key, selected: `${cred.provider}/${pick.model}`, candidates: [], attempts,
paid_by: key, selected: `${cred.provider}/${pick.model}`, candidates: [], attempts,
+ prefer: preferState ? { ...preferState } : null,
},
},
};
};
} catch (err) {
} catch (err) {
@@ -628,7 +754,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -628,7 +754,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const names = providers.length ? providers.join(', ') : 'any provider that can serve this request';
const names = providers.length ? providers.join(', ') : 'any provider that can serve this request';
throw new AiError(422, 'ai.needs_key',
throw new AiError(422, 'ai.needs_key',
`only_my_keys is on and none of your keys could serve this request; add a key for ${names} under Your AI`,
`only_my_keys is on and none of your keys could serve this request; add a key for ${names} under Your AI`,
- { providers, paid_by: null, attempts: selfAttempts, explain: { objective: 'own-keys', reasons: ['only_my_keys: no network fallback'], paid_by: null, selected: null, candidates: [], attempts: selfAttempts } });
+ { providers, paid_by: null, attempts: selfAttempts, explain: { objective: 'own-keys', reasons: ['only_my_keys: no network fallback'], paid_by: null, selected: null, candidates: [], attempts: selfAttempts, prefer: preferState ? { ...preferState, why: preferState.why || 'you have no key for it, and only_my_keys never falls back to the network' } : null } });
}
}
}
}
let { order: list, explain } = await candidates(route, features, operation, ctx);
let { order: list, explain } = await candidates(route, features, operation, ctx);
@@ -643,6 +769,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -643,6 +769,45 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
list = [...list].sort((a, b) => Number(free.get(b.provider)) - Number(free.get(a.provider)));
list = [...list].sort((a, b) => Number(free.get(b.provider)) - Number(free.get(a.provider)));
}
}
+ // L36 Y7: pick for the task. The hard needs first (a model that cannot do the job is never tried), then
+ // fit and cost: a light job takes the cheapest capable model, a heavy one the catalogue's strength tier.
+ const warmProvider = warm.get(warmSubject(ctx, route), leadingText(req));
+ // L36 Y3: with free_first on, the free tiers lead (the price decides); with it off, the strength tier
+ // leads and the free tiers are the failover, not the first choice.
+ const freeFirst = ctx.selfSubject ? prefs.free_first !== false : true;
+ const ranked = await rankCandidates({
+ list, explain, task, route, warmProvider,
+ preferred: preferredModelFor(prefs, operation, req, ctx),
+ ceiling: prefs.price_ceiling_usd_per_mtok,
+ tierFirst: task.weight === 'heavy' || !freeFirst,
+ // A pinned route keeps its own order: the pin is the admin's decision, not the router's.
+ keepOrder: !(route.capability && operation),
+ });
+ list = ranked.order;
+ explain.task = task;
+ explain.ranked = ranked.ranked;
+ // L37: the soft preference. The provider (and model) the caller asked for goes first, when it can do the
+ // job; a pinned route keeps its own order (the pin is the admin's decision).
+ if (preferState) {
+ if (prefer.provider && !(await registry.getProvider(prefer.provider))) preferState.why = 'no provider by that name';
+ else if (prefer.model) {
+ // L37: the model they named cannot do this job: it is never forced.
+ const check = await preferredModel(prefer.provider, prefer.model, task);
+ if (check.missing.length) preferState.why = check.missing.includes('no_vision') ? 'the model you named cannot see' : 'the model you named cannot do this job';
+ }
+ if (ranked.keepOrder) {
+ preferState.why = 'this route is pinned; the pin decides';
+ } else {
+ const at = list.findIndex((c) => preferMatches(c.provider, c.model, prefer));
+ if (at > 0) {
+ const [chosen] = list.splice(at, 1);
+ list.unshift(chosen);
+ } else if (at < 0 && !preferState.why) {
+ preferState.why = 'no ' + (prefer.provider || 'provider') + (prefer.model ? '/' + prefer.model : '') + ' offer could serve this request';
+ }
+ }
+ explain.prefer = preferState;
+ }
const attempts = [...selfAttempts];
const attempts = [...selfAttempts];
explain.paid_by = 'openvibe';
explain.paid_by = 'openvibe';
explain.attempts = attempts;
explain.attempts = attempts;
@@ -659,12 +824,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -659,12 +824,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
list = [chosen];
list = [chosen];
explain.reasons = [`pinned to ${chosen.model ? `${chosen.provider}/${chosen.model}` : chosen.provider} for this run`, ...(explain.reasons || [])];
explain.reasons = [`pinned to ${chosen.model ? `${chosen.provider}/${chosen.model}` : chosen.provider} for this run`, ...(explain.reasons || [])];
}
}
+ // L36 Y7: the fallback chain stops after three providers.
+ const maxAttempts = (config.routing && config.routing.maxAttempts) || DEFAULT_MAX_ATTEMPTS;
+ let attemptsMade = 0;
for (let i = 0; i < list.length; i++) {
for (let i = 0; i < list.length; i++) {
const c = list[i];
const c = list[i];
const entry = await adapter(c.provider);
const entry = await adapter(c.provider);
const p = entry && entry.record;
const p = entry && entry.record;
const a = entry && entry.adapter;
const a = entry && entry.adapter;
const model = c.model || (p && p.default_model) || null;
const model = c.model || (p && p.default_model) || null;
+ const chosen = ranked.reasons.get(c.provider + ':' + (model || '')) || null;
const fallback = i > 0;
const fallback = i > 0;
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
// L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).
// L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).
@@ -673,9 +842,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -673,9 +842,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;
const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;
if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {
if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {
const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;
const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;
+ if (preferState && preferMatches(c.provider, model, prefer)) { preferState.honoured = false; preferState.why = 'it is over your price ceiling'; }
statFor(c.provider).skipped++;
statFor(c.provider).skipped++;
tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });
tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });
- attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: whyPrice });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: whyPrice });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'over_price_ceiling' });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'over_price_ceiling' });
continue;
continue;
}
}
@@ -684,16 +854,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -684,16 +854,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (why) {
if (why) {
statFor(c.provider).skipped++;
statFor(c.provider).skipped++;
tried.push({ provider: c.provider, skipped: why });
tried.push({ provider: c.provider, skipped: why });
- attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: why });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: chosen || 'the router chose it', status: 'skipped', error: why });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
continue;
continue;
}
}
+ // L36 Y7: the fallback chain stops after three providers.
+ if (attemptsMade >= maxAttempts) break;
+ attemptsMade += 1;
// An image generation outlasts the shared provider's chat timeout (AI_PROVIDER_TIMEOUT_MS): the route's wins. So does a
// An image generation outlasts the shared provider's chat timeout (AI_PROVIDER_TIMEOUT_MS): the route's wins. So does a
// route that says so (options.outlast_provider_timeout: space.build writes a whole app, a minute or two). Otherwise
// route that says so (options.outlast_provider_timeout: space.build writes a whole app, a minute or two). Otherwise
// the provider's timeout stays strict, so a slow provider fails over quickly.
// the provider's timeout stays strict, so a slow provider fails over quickly.
const outlast = operation === 'image' || Boolean(route.options && route.options.outlast_provider_timeout);
const outlast = operation === 'image' || Boolean(route.options && route.options.outlast_provider_timeout);
- const cap = outlast ? Math.max(p.timeout_ms || 0, Number(route.timeout_ms) || 0, operation === 'image' ? 600000 : 0) : (p.timeout_ms || 600000);
- const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || p.timeout_ms || 30000, cap));
+ const own = await providerTimeout(p, model);
+ const cap = outlast ? Math.max(own, Number(route.timeout_ms) || 0, operation === 'image' ? 600000 : 0) : (own || 600000);
+ const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || own || DEFAULT_PROVIDER_TIMEOUT_MS, cap));
const retries = config.providerRetries;
const retries = config.providerRetries;
let lastErr = null;
let lastErr = null;
for (let attempt = 0; attempt <= retries; attempt++) {
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -726,7 +900,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -726,7 +900,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
explain.selected = c.provider;
explain.selected = c.provider;
explain.paid_by = 'openvibe';
explain.paid_by = 'openvibe';
- attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: 'ok', error: null });
+ // L37: the preference was honoured when the offer it named answered.
+ if (preferState && preferMatches(c.provider, model, prefer)) {
+ preferState.honoured = true;
+ preferState.why = 'the ' + c.provider + ' offer answered, as you asked';
+ }
+ attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: chosen || (fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route'), status: 'ok', error: null });
+ warm.remember(warmSubject(ctx, route), leadingText(req), c.provider);
explain.attempts = attempts;
explain.attempts = attempts;
return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
} catch (err) {
} catch (err) {
@@ -749,7 +929,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -749,7 +929,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;
if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;
if (!callerFault(lastErr) && !rateLimited(lastErr)) await recordFailure(c.provider, lastErr);
if (!callerFault(lastErr) && !rateLimited(lastErr)) await recordFailure(c.provider, lastErr);
tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });
tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });
- attempts.push({ key: 'openvibe', provider: c.provider, model: lastErr && lastErr.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: lastErr && lastErr.code === 'provider.timeout' ? 'timeout' : 'failed', error: String(lastErr && lastErr.message || lastErr).slice(0, 300) });
+ attempts.push({ key: 'openvibe', provider: c.provider, model: lastErr && lastErr.model || model, why: chosen || (fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route'), status: lastErr && lastErr.code === 'provider.timeout' ? 'timeout' : 'failed', error: String(lastErr && lastErr.message || lastErr).slice(0, 300) });
log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);
log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);
}
}
explain.selected = null;
explain.selected = null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,244 @@
@@ -0,0 +1,244 @@
+'use strict';
+/**
+ * The task-aware router (plan L33 P3 "the router picks for the task", L36 Y7 "a smarter router").
+ *
+ * detectTask({ operation, req, ctx, route }) → { vision, tools, json, min_context, reasoning, output, weight }
+ * capabilitiesOf(model, provider) → what the catalogue knows about one model
+ * hardNeeds(capabilities, task) → why a model cannot do this job ([] when it can)
+ * rank(candidates, { task, warm, preferred }) → the order the router tries, and why each one is there
+ *
+ * The requirement set is read off the request itself — a picture in, tools, a JSON schema, how much text,
+ * what it wants out — and off the kind of work (the workflow and the route name it). No model is called
+ * and nothing is guessed: what a model can do comes from the catalogue (server/providers/catalog.js) and
+ * from the provider record, never from a hunch.
+ *
+ * The rules, in one place:
+ *
+ * vision an image in the request (or a vision operation)
+ * tools the request carries tools (the OpenAI tools array, or the Messages API's own)
+ * json the request asks for JSON out (a schema, or response_format)
+ * min_context the prompt's own size (four characters a token) plus room for the answer
+ * reasoning deep when the request asks to think (effort high/max, reasoning true), some for code
+ * output text, image, audio or video
+ * weight light (classify, short extraction), standard, heavy (code, deep reasoning, long text)
+ */
+const TIER_RANK = { fast: 0, balanced: 1, strong: 2, frontier: 3 };
+const DEFAULT_TIER = 'balanced';
+
+/** The kind of work a run does: the person's ai.me-preferences.defaults has one default per kind. */
+function workKind(operation, req = {}, ctx = {}) {
+ if (operation === 'image') return 'image';
+ if (req && req.image) return 'vision';
+ const hint = String(ctx.workflowKey || '') + ' ' + String(ctx.routeKey || '');
+ return /\b(code|coding|codes|build|design|program|developer)\b/i.test(hint) ? 'code' : 'text';
+}
+
+/** Rough token count of a text: four characters a token. */
+const tokensOf = (text) => Math.ceil(String(text || '').length / 4);
+
+/** The prompt that leads a request: its system text, then its messages. */
+function leadingText(req = {}) {
+ const sys = Array.isArray(req.system) ? req.system.map((x) => (typeof x === 'string' ? x : (x && x.text) || '')).join('\n')
+ : (typeof req.system === 'string' ? req.system : '');
+ const msgs = Array.isArray(req.messages) ? req.messages.map((m) => {
+ if (!m) return '';
+ const c = m.content;
+ if (typeof c === 'string') return c;
+ if (Array.isArray(c)) return c.map((p) => (p && p.text) || '').join(' ');
+ return '';
+ }).join('\n') : '';
+ return sys + '\n' + msgs;
+}
+
+/** The tools a request carries (the OpenAI tools array, or the Messages API's own). */
+function toolCount(req = {}) {
+ const openai = req.openai && Array.isArray(req.openai.tools) ? req.openai.tools.length : 0;
+ const direct = Array.isArray(req.tools) ? req.tools.length : 0;
+ return Math.max(openai, direct);
+}
+
+/** Does the request ask for JSON out? (a schema, or the OpenAI response_format) */
+function wantsJson(req = {}, route = null) {
+ if (req.json) return true;
+ const rf = req.openai && req.openai.response_format;
+ if (rf && (rf.type === 'json_object' || rf.type === 'json_schema')) return true;
+ if (route && route.response_format === 'json') return true;
+ return false;
+}
+
+/** The reasoning effort the request asks for: 'none' | 'some' | 'deep' ('' when it says nothing). */
+function effortOf(req = {}) {
+ const raw = String(req.effort || (req.reasoning && typeof req.reasoning === 'object' ? req.reasoning.effort : '') || '').toLowerCase();
+ if (['high', 'max', 'xhigh', 'deep', 'maximum'].includes(raw)) return 'deep';
+ if (['medium', 'some', 'standard'].includes(raw)) return 'some';
+ if (['low', 'minimal', 'none', 'off'].includes(raw)) return 'none';
+ if (req.reasoning === true || req.thinking === true) return 'deep';
+ return '';
+}
+
+/** What the request wants out. */
+function outputOf(operation, req = {}) {
+ if (operation === 'image' || req.image_out) return 'image';
+ if (operation === 'video' || req.video) return 'video';
+ if (operation === 'speak' || operation === 'tts' || req.audio) return 'audio';
+ return 'text';
+}
+
+/** How heavy the work is: light, standard or heavy. */
+function weightOf({ operation, kind, reasoning, tools, min_context, maxTokens }) {
+ // Code, deep reasoning, a long context or a long answer: the strong models.
+ if (reasoning === 'deep' || kind === 'code' || min_context > 32000 || (Number(maxTokens) || 0) > 4000) return 'heavy';
+ // A short classification or extraction: the cheapest model that can do it.
+ if (!tools && kind !== 'code' && min_context <= 4000 && ['classify', 'extract', 'summarize', 'enrich'].includes(operation)) return 'light';
+ return 'standard';
+}
+
+/**
+ * The requirement set of one request, read without a model call.
+ * { vision, tools, json, min_context, reasoning: none|some|deep, output: text|image|audio|video, weight: light|standard|heavy }
+ */
+function detectTask({ operation = 'chat', req = {}, ctx = {}, route = null } = {}) {
+ const kind = workKind(operation, req, ctx);
+ const vision = Boolean(req.image) || operation === 'vision';
+ const tools = toolCount(req) > 0;
+ const json = wantsJson(req, route);
+ const min_context = Math.max(1024, tokensOf(leadingText(req)) + (vision ? 1024 : 0) + 512);
+ const effort = effortOf(req);
+ const reasoning = effort === 'deep' ? 'deep' : (effort === 'some' || kind === 'code' ? 'some' : 'none');
+ const output = outputOf(operation, req);
+ const weight = weightOf({ operation, kind, reasoning, tools, min_context, maxTokens: req.maxTokens });
+ return { vision, tools, json, min_context, reasoning, output, weight };
+}
+
+/**
+ * What the catalogue knows about one model. The model row is the authority; the provider record's declared
+ * capabilities are the fallback for a model the catalogue has not described yet (a model typed in by a
+ * person, or a provider whose row carries no capabilities). What the catalogue does not say stays unknown:
+ * an unknown context window is not a reason to refuse the work.
+ */
+function capabilitiesOf(model, provider) {
+ const provCaps = new Set((provider && provider.capabilities) || []);
+ const meta = (model && model.metadata) || {};
+ const supports = (model && model.supports) || null;
+ // A model the catalogue described (it synced it) says what it can do: its word beats the provider's
+ // general claim. A model a person typed in keeps the provider's claim, so a provider that serves images
+ // is not silenced by a row that never mentioned them.
+ const described = Boolean(meta.synced_at || meta.listed);
+ // true, false or null (nobody said). The catalogue's word on a model it described is the authority; a model
+ // nobody described keeps the provider's word; what nobody said stays unknown, and unknown is not a refusal.
+ const yes = (flag) => {
+ const row = supports ? supports[flag] : null;
+ if (row === true) return true;
+ if (row === false) return described ? false : (provCaps.has(flag) ? true : null);
+ return provCaps.has(flag) ? true : null;
+ };
+ const type = (model && model.type) || null;
+ return {
+ vision: yes('vision'),
+ tools: yes('tools'),
+ json: yes('json'),
+ context_window: model && Number(model.context_window) > 0 ? Number(model.context_window) : null,
+ reasoning: meta.reasoning === true ? true : (meta.reasoning === false ? false : null),
+ tier: TIER_RANK[meta.tier] === undefined ? null : meta.tier,
+ type,
+ image: type === 'image' || provCaps.has('image'),
+ video: type === 'video' || provCaps.has('video'),
+ audio: type === 'audio' || type === 'speech' || provCaps.has('audio') || provCaps.has('speech'),
+ timeout_ms: Number(meta.timeout_ms) > 0 ? Number(meta.timeout_ms) : null,
+ };
+}
+
+/**
+ * The hard needs: why a model cannot do this job ([] when it can). A model that misses one is never tried.
+ */
+function hardNeeds(c, task) {
+ const missing = [];
+ if (task.vision && c.vision === false) missing.push('no_vision');
+ if (task.tools && c.tools === false) missing.push('no_tools');
+ if (task.json && c.json === false) missing.push('no_json');
+ if (task.reasoning === 'deep' && c.reasoning === false) missing.push('no_reasoning');
+ if (task.min_context && c.context_window && c.context_window < task.min_context) missing.push('context_too_small');
+ if (task.output === 'image' && !c.image) missing.push('no_image_out');
+ if (task.output === 'video' && !c.video) missing.push('no_video_out');
+ if (task.output === 'audio' && !c.audio) missing.push('no_audio_out');
+ return missing;
+}
+
+/** The strength tier's rank; a model the catalogue has not tiered counts as balanced. */
+const tierRank = (tier) => (TIER_RANK[tier] === undefined ? TIER_RANK[DEFAULT_TIER] : TIER_RANK[tier]);
+
+/**
+ * The order the router tries, and why each candidate is there.
+ *
+ * light / standard the cheapest model that can do the job (free capacity first: it costs nothing)
+ * heavy the catalogue's strength tier first, then the price
+ * within a tier the person's own default, then health, then latency, then price
+ *
+ * A warm prompt cache is worth up to 10 % of the price, so a near tie goes to the provider that already
+ * has this prompt cached (L36 Y7). The order is stable: two candidates that tie keep the pool's order.
+ */
+function rank(candidates, { task, warm = null, preferred = null, authority = null, tierFirst = null, keepOrder = false } = {}) {
+ const isAuthority = (c) => Boolean(authority && (authority === c.provider || authority === c.provider + ':' + (c.model || '')));
+ const lead = tierFirst == null ? task.weight === 'heavy' : Boolean(tierFirst);
+ const prepared = candidates.map((c, index) => {
+ const price = Number(c.price) || 0;
+ const isWarm = Boolean(warm && c.provider === warm);
+ return { ...c, index, price, warm: isWarm, effective: isWarm ? price * 0.9 : price, tierRank: tierRank(c.tier), authority: isAuthority(c) };
+ });
+ const cmp = (a, b) => {
+ if (keepOrder) return a.index - b.index; // a pinned route keeps its own order
+ if (a.authority !== b.authority) return a.authority ? -1 : 1;
+ if (preferred && (a.model === preferred) !== (b.model === preferred)) return a.model === preferred ? -1 : 1;
+ if (lead && a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;
+ if (a.effective !== b.effective) return a.effective - b.effective;
+ if (a.tierRank !== b.tierRank) return b.tierRank - a.tierRank;
+ if (a.health !== b.health) return (a.health || 0) - (b.health || 0);
+ if (a.latency !== b.latency) return (a.latency == null ? Infinity : a.latency) - (b.latency == null ? Infinity : b.latency);
+ return a.index - b.index;
+ };
+ const sorted = [...prepared].sort(cmp);
+ return { order: sorted, ranked: sorted.slice(0, 5) };
+}
+
+/** Why a candidate is there, in a few words (the explain's 'ranked'). */
+function reasonFor(entry, task) {
+ if (entry.warm) return 'warm cache';
+ const caps = [];
+ if (task.vision) caps.push('vision');
+ if (task.tools) caps.push('tools');
+ if (task.json) caps.push('JSON');
+ const withCaps = caps.length ? ' with ' + caps.join(' and ') : '';
+ if (task.weight === 'heavy') return 'the ' + (entry.tier || 'strong') + ' tier' + withCaps;
+ return 'the cheapest' + withCaps;
+}
+
+
+/**
+ * The soft preference a request may carry (L37): { provider, model }. The model may be left out, and a string
+ * "provider/model" is read as the same thing. Nothing here fails: an unknown id is simply never matched.
+ */
+function preferredTarget(raw) {
+ if (!raw) return null;
+ let provider = null;
+ let model = null;
+ if (typeof raw === 'string') {
+ const at = raw.indexOf('/');
+ provider = (at === -1 ? raw : raw.slice(0, at)).trim();
+ model = at === -1 ? null : raw.slice(at + 1).trim();
+ } else if (typeof raw === 'object') {
+ provider = raw.provider != null ? String(raw.provider).trim() : null;
+ model = raw.model != null ? String(raw.model).trim() : null;
+ }
+ if (!provider && !model) return null;
+ return { provider: provider || null, model: model || null };
+}
+
+/** Does this candidate answer to the preference? The provider always, and the model when one was named. */
+function preferMatches(provider, model, prefer) {
+ if (!prefer) return false;
+ if (prefer.provider && String(provider || '') !== prefer.provider) return false;
+ if (prefer.model && String(model || '') !== prefer.model) return false;
+ return Boolean(prefer.provider || prefer.model);
+}
+
+module.exports = { preferredTarget, preferMatches, detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, tokensOf, tierRank, TIER_RANK, DEFAULT_TIER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * A tiny, in-process memory of the provider that last answered a prompt (L36 Y7: prefer the provider whose
+ * prompt cache is already warm). The key is the person or app plus a hash of the first 2 KB of the leading
+ * prompt; the value is the provider key; it expires after five minutes. Nothing is persisted, nothing leaves
+ * the process, and the map is capped so it can never grow into a cache.
+ */
+const crypto = require('crypto');
+
+const DEFAULT_TTL_MS = 5 * 60 * 1000;
+const DEFAULT_MAX = 256;
+const PROMPT_BYTES = 2048;
+
+/** A hash of the first 2 KB of a prompt. */
+const hashOf = (text) => crypto.createHash('sha256').update(String(text || '').slice(0, PROMPT_BYTES)).digest('hex');
+
+function createWarmCache({ ttlMs = DEFAULT_TTL_MS, max = DEFAULT_MAX, now = () => Date.now() } = {}) {
+ const entries = new Map(); // key → { provider, at }
+
+ function remember(subject, prompt, provider) {
+ if (!provider) return;
+ const key = String(subject || 'anonymous') + '|' + hashOf(prompt);
+ entries.set(key, { provider, at: now() });
+ while (entries.size > max) entries.delete(entries.keys().next().value); // the oldest goes first
+ }
+
+ function get(subject, prompt) {
+ const key = String(subject || 'anonymous') + '|' + hashOf(prompt);
+ const hit = entries.get(key);
+ if (!hit) return null;
+ if (now() - hit.at > ttlMs) { entries.delete(key); return null; }
+ return hit.provider;
+ }
+
+ return { remember, get, hashOf, size: () => entries.size, clear: () => entries.clear() };
+}
+
+module.exports = { createWarmCache, hashOf, DEFAULT_TTL_MS, DEFAULT_MAX, PROMPT_BYTES };
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
const { preferenceLines } = require('./user-modules');
const { preferenceLines } = require('./user-modules');
+const { preferredTarget } = require('./providers/task');
const schemas = require('./schemas');
const schemas = require('./schemas');
const events = require('./events');
const events = require('./events');
const usageSamples = require('./usage-samples');
const usageSamples = require('./usage-samples');
@@ -242,6 +243,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -242,6 +243,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// An app's own provider (ADR-056 project secret): only on ai.complete, only for an app; its key spends the
// An app's own provider (ADR-056 project secret): only on ai.complete, only for an app; its key spends the
// project's money at its provider, so no tier budget holds it (the app's quotas still count it).
// project's money at its provider, so no tier budget holds it (the app's quotas still count it).
const projectProvider = body.project_provider == null ? null : projectProviderOf(body.project_provider);
const projectProvider = body.project_provider == null ? null : projectProviderOf(body.project_provider);
+ // L37: the soft preference (a provider, and a model if the caller wants one). It is a wish, not a pin.
+ const prefer = preferredTarget(body.prefer);
if (projectProvider) {
if (projectProvider) {
if (!app) throw new AiError(403, 'capability.denied', 'only a developer app names its project\'s own provider');
if (!app) throw new AiError(403, 'capability.denied', 'only a developer app names its project\'s own provider');
if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');
if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');
@@ -282,7 +285,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -282,7 +285,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,
options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...(selfKeys ? { self_subject: personSubject } : {}),
...(selfKeys ? { self_subject: personSubject } : {}),
- ...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}) }), created_at: now,
+ ...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}),
+ ...(prefer ? { prefer } : {}) }), created_at: now,
};
};
base.input = stableStringify(kept.value);
base.input = stableStringify(kept.value);
@@ -436,6 +440,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -436,6 +440,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// Who asked, for a step that must not call the caller back (research: Search's own web ladder).
// Who asked, for a step that must not call the caller back (research: Search's own web ladder).
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
...(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).
+ prefer: opts.prefer || null,
...(onCredentialFailed ? { onCredentialFailed } : {}),
...(onCredentialFailed ? { onCredentialFailed } : {}),
...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
@@ -186,6 +186,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -186,6 +186,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
temperature: input.temperature == null ? null : input.temperature,
temperature: input.temperature == null ? null : input.temperature,
timeoutMs: input.timeout_ms || route.timeout_ms || ROLE_TIMEOUT[input.role] || 25000,
timeoutMs: input.timeout_ms || route.timeout_ms || ROLE_TIMEOUT[input.role] || 25000,
cacheKey: input.cache_key || null,
cacheKey: input.cache_key || null,
+ prefer: ctx.prefer || null,
};
};
const exec = await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });
const exec = await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });
let json = exec.result.json || null;
let json = exec.result.json || null;
@@ -205,6 +206,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -205,6 +206,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),
maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),
temperature: input.temperature == null ? null : input.temperature,
temperature: input.temperature == null ? null : input.temperature,
timeoutMs: Math.max(route.timeout_ms || 0, 120000),
timeoutMs: Math.max(route.timeout_ms || 0, 120000),
+ prefer: ctx.prefer || null,
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
};
};
const exec = await pool.execute(route, 'complete', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });
const exec = await pool.execute(route, 'complete', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ m: openai.messages, t: openai.tools || null }) });
@@ -223,6 +225,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -223,6 +225,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
messages: input.messages,
messages: input.messages,
maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),
maxTokens: Math.max(1, Math.round(input.max_tokens || route.max_output_tokens || 1024)),
timeoutMs: Math.max(route.timeout_ms || 0, 120000),
timeoutMs: Math.max(route.timeout_ms || 0, 120000),
+ prefer: ctx.prefer || null,
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}),
};
};
for (const k of ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata']) {
for (const k of ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata']) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+/**
+ * L37: a soft preference. The caller may name a provider (and a model) they would rather have. It is a wish, not a
+ * pin: the person's own key for that provider answers first (at no fee), else the network offer for it, and the
+ * normal routing is the fallback. It never disables own keys, never overrides only_my_keys, never beats the price
+ * ceiling, and never forces a model that fails a hard need. An unknown id is ignored, never an error.
+ */
+const assert = require('assert');
+const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
+
+const t = suite('prefer-route');
+const KEY = 'cd'.repeat(32);
+const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';
+const ANTHROPIC_KEY = 'test-anthropic-key-not-a-secret';
+const OPENAI_KEY = 'test-openai-key-not-a-secret';
+const person = token('dana', ALL, { sub: DANA, actorType: 'user' });
+const service = token('live', ALL);
+const calls = [];
+let h;
+
+const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+const credentialFetch = async (url, init = {}) => {
+ calls.push({ url: String(url), auth: (init.headers && (init.headers.Authorization || init.headers['x-api-key'])) || null });
+ if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });
+ if (String(url).endsWith('/messages')) return json({ model: 'm-one', content: [{ type: 'text', text: 'hello from dana key' }], usage: { input_tokens: 4, output_tokens: 2 } });
+ if (String(url).endsWith('/chat/completions')) return json({ model: 'm-one', choices: [{ message: { role: 'assistant', content: 'hello from dana key' } }], usage: { prompt_tokens: 4, completion_tokens: 2 } });
+ return json({});
+};
+
+async function provider(body) {
+ const r = await request(h.base, 'POST', '/api/v1/providers', { tok: service, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });
+ assert.strictEqual(r.status, 201, r.text);
+}
+async function model(providerKey, modelKey, body) {
+ const r = await request(h.base, 'POST', '/api/v1/models', { tok: service, body: { provider_key: providerKey, model_key: modelKey, type: 'chat', status: 'active', ...body } });
+ assert.strictEqual(r.status, 201, r.text);
+}
+const prefs = (over) => ({ only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }, ...(over || {}) });
+const DESCRIBED = { listed: true, synced_at: '2026-10-31T00:00:00.000Z' };
+
+t.test('boot', async () => {
+ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' }, credentialFetch });
+});
+
+t.test("a person with their own anthropic key, asking for anthropic/claude-opus-5-5, runs on their key at no fee", async () => {
+ const net = await seamServer(() => ({ text: 'the network answer', model: 'claude-opus-5-5', usage: { input: 3, output: 1 } }));
+ await provider({ key: 'anthropic', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'claude-opus-5-5' });
+ await model('anthropic', 'claude-opus-5-5', { cost: { in_per_mtok: 5, out_per_mtok: 25 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });
+ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/anthropic', { tok: person, body: { provider: 'anthropic', key: ANTHROPIC_KEY, models: { chat: 'm-one' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ const before = net.calls;
+ r = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: {
+ workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' }, on_behalf_of: { type: 'user', id: DANA },
+ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
+ } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));
+ assert.ok(/your anthropic key/.test(r.body.run.explain.paid_by), 'their own key paid: ' + r.body.run.explain.paid_by);
+ assert.strictEqual(Number(r.body.run.cost_usd || 0), 0, 'their own key costs them nothing');
+ assert.strictEqual(r.body.run.explain.prefer.honoured, true, JSON.stringify(r.body.run.explain.prefer));
+ assert.strictEqual(r.body.run.explain.prefer.provider, 'anthropic');
+ assert.strictEqual(r.body.run.explain.prefer.model, 'claude-opus-5-5');
+ assert.strictEqual(net.calls, before, 'the network offer was never called');
+ await net.close();
+});
+
+t.test('without a key of their own, it runs on the network offer for that provider', async () => {
+ const net = await seamServer(() => ({ text: 'the network answer', model: 'claude-opus-5-5', usage: { input: 3, output: 1 } }));
+ await provider({ key: 'anthropic', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'claude-opus-5-5' });
+ await model('anthropic', 'claude-opus-5-5', { cost: { in_per_mtok: 5, out_per_mtok: 25 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: {
+ workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' },
+ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
+ } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));
+ assert.strictEqual(r.body.run.provenance.provider, 'anthropic', 'the offer they named answered');
+ assert.strictEqual(r.body.run.explain.prefer.honoured, true, JSON.stringify(r.body.run.explain.prefer));
+ assert.ok(net.calls > 0, 'the network offer was called');
+ await net.close();
+});
+
+t.test('only_my_keys with no matching key does not fall to the network', async () => {
+ const net = await seamServer(() => ({ text: 'the network answer', model: 'claude-opus-5-5', usage: { input: 3, output: 1 } }));
+ await provider({ key: 'anthropic', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'claude-opus-5-5' });
+ await model('anthropic', 'claude-opus-5-5', { cost: { in_per_mtok: 5, out_per_mtok: 25 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });
+ // They keep a key, but not for the provider they ask for.
+ let r = await request(h.base, 'DELETE', '/api/v1/me/credentials/anthropic', { tok: person });
+ assert.ok([200, 204].includes(r.status), r.text);
+ r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: OPENAI_KEY, models: { chat: 'm-chat' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefs({ only_my_keys: true }) });
+ assert.strictEqual(r.status, 200, r.text);
+ const before = net.calls;
+ r = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: {
+ workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' }, on_behalf_of: { type: 'user', id: DANA },
+ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' },
+ } });
+ const run = r.body.run;
+ assert.ok(run.status === 'succeeded' || (run.error && run.error.code === 'ai.needs_key'), JSON.stringify(run).slice(0, 400));
+ assert.strictEqual(run.explain.prefer.honoured, false, JSON.stringify(run.explain.prefer));
+ assert.ok(run.explain.prefer.why, 'the explain says why the preference was not honoured');
+ assert.strictEqual(net.calls, before, 'the network was never called');
+ if (run.status === 'succeeded') assert.ok(/your openai key/.test(run.explain.paid_by), 'their own key paid: ' + run.explain.paid_by);
+ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefs() });
+ assert.strictEqual(r.status, 200, r.text);
+ await request(h.base, 'DELETE', '/api/v1/me/credentials/openai', { tok: person });
+ await net.close();
+});
+
+t.test('a prefer that needs vision on a text-only model is not honoured', async () => {
+ const see = await seamServer(() => ({ text: 'I can see', model: 'seer-m', usage: { input: 3, output: 1 } }));
+ const blind = await seamServer(() => ({ text: 'I cannot', model: 'blind-m', usage: { input: 3, output: 1 } }));
+ await provider({ key: 'seer', base_url: see.url, capabilities: ['chat', 'generate', 'vision', 'json'], default_model: 'seer-m' });
+ await model('seer', 'seer-m', { supports: { json: true, vision: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ await provider({ key: 'blind', base_url: blind.url, capabilities: ['chat', 'generate', 'json'], default_model: 'blind-m' });
+ await model('blind', 'blind-m', { supports: { json: true, vision: false }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ const r = await request(h.base, 'POST', '/api/v1/routes/test.prefer/versions', { tok: service, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });
+ assert.strictEqual(r.status, 201, r.text);
+ const route = r.body.route;
+ const exec = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'what is in this picture?' }], image: { base64: 'aGk=' }, timeoutMs: 3000, prefer: { provider: 'blind', model: 'blind-m' } }, { routeKey: route.key, routeVersion: route.version });
+ assert.strictEqual(exec.provider, 'seer', 'the text-only model never answers a picture');
+ assert.strictEqual(exec.explain.prefer.honoured, false, JSON.stringify(exec.explain.prefer));
+ assert.ok(/cannot see/.test(exec.explain.prefer.why), exec.explain.prefer.why);
+ await see.close(); await blind.close();
+});
+
+t.test('an unknown provider or model is ignored, never an error', async () => {
+ const net = await seamServer(() => ({ text: 'the network answer', model: 'm', usage: { input: 3, output: 1 } }));
+ await provider({ key: 'plain', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'plain-m' });
+ await model('plain', 'plain-m', { supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ const r = await request(h.base, 'POST', '/api/v1/routes/test.unknown/versions', { tok: service, body: { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 } });
+ const route = r.body.route;
+ const exec = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000, prefer: { provider: 'nobody', model: 'nothing' } }, { routeKey: route.key, routeVersion: route.version });
+ assert.ok(exec.provider, 'the run still answers');
+ assert.strictEqual(exec.explain.prefer.honoured, false);
+ assert.ok(/no provider by that name/.test(exec.explain.prefer.why), exec.explain.prefer.why);
+ await net.close();
+});
+
+t.test('a pinned route behaves as before', async () => {
+ const net = await seamServer(() => ({ text: 'the network answer', model: 'm', usage: { input: 3, output: 1 } }));
+ const pinned = await seamServer(() => ({ text: 'the pinned answer', model: 'm', usage: { input: 3, output: 1 } }));
+ await provider({ key: 'pinned-one', base_url: pinned.url, capabilities: ['chat', 'generate', 'json'], default_model: 'p-m' });
+ await model('pinned-one', 'p-m', { supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ await provider({ key: 'other', base_url: net.url, capabilities: ['chat', 'generate', 'json'], default_model: 'o-m' });
+ await model('other', 'o-m', { supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ const r = await request(h.base, 'POST', '/api/v1/routes/test.pinned/versions', { tok: service, body: { primary: { provider: 'pinned-one' }, fallbacks: [{ provider: 'other' }], timeout_ms: 3000 } });
+ assert.strictEqual(r.status, 201, r.text);
+ const route = r.body.route;
+ const exec = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000, prefer: { provider: 'other', model: 'o-m' } }, { routeKey: route.key, routeVersion: route.version });
+ assert.strictEqual(exec.provider, 'pinned-one', 'the pin decides, as before');
+ assert.strictEqual(exec.explain.prefer.honoured, false);
+ assert.ok(/pinned/.test(exec.explain.prefer.why), exec.explain.prefer.why);
+ await net.close(); await pinned.close();
+});
+
+t.test('shutdown', async () => { await h.stop(); });
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+/**
+ * The task-aware router (plan L33 P3 "the router picks for the task", L36 Y7 "a smarter router").
+ * - a vision request never goes to a model that cannot see: the hard needs come first;
+ * - a light job takes the cheapest capable model, a heavy one the catalogue's strength tier;
+ * - the person's free-first, price ceiling and per-kind default are respected;
+ * - a warm prompt cache wins a near tie;
+ * - the fallback chain stops after three attempts, and only_my_keys never leaves the person's keys;
+ * - the explain carries the requirement set (task), the ranked candidates and every attempt.
+ */
+const assert = require('assert');
+const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
+
+const t = suite('task-router');
+const tok = token('live', ALL);
+let h;
+
+async function provider(body) {
+ const r = await request(h.base, 'POST', '/api/v1/providers', { tok, body: { kind: 'http', auth_mode: 'none', timeout_ms: 3000, ...body } });
+ assert.strictEqual(r.status, 201, r.text);
+}
+async function model(providerKey, modelKey, body) {
+ const r = await request(h.base, 'POST', '/api/v1/models', { tok, body: { provider_key: providerKey, model_key: modelKey, type: 'chat', status: 'active', ...body } });
+ assert.strictEqual(r.status, 201, r.text);
+}
+async function route(key, body) {
+ const r = await request(h.base, 'POST', '/api/v1/routes/' + key + '/versions', { tok, body });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.body.route;
+}
+const prefs = (over) => ({ only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }, ...(over || {}) });
+const DESCRIBED = { listed: true, synced_at: '2026-10-31T00:00:00.000Z' };
+
+t.test('a vision request never goes to a model that cannot see', async () => {
+ const seer = await seamServer(() => ({ text: 'a picture of a cat', model: 'seer-m', usage: { input: 5, output: 2 } }));
+ const blind = await seamServer(() => ({ text: 'I never saw it', model: 'blind-m', usage: { input: 5, output: 2 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ // Both providers serve images; the catalogue says one of the two models does not.
+ await provider({ key: 'seer', base_url: seer.url, capabilities: ['chat', 'generate', 'vision', 'json'], default_model: 'seer-m' });
+ await model('seer', 'seer-m', { supports: { json: true, vision: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ await provider({ key: 'blind', base_url: blind.url, capabilities: ['chat', 'generate', 'vision', 'json'], default_model: 'blind-m' });
+ await model('blind', 'blind-m', { supports: { json: true, vision: false }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ const r = await route('test.vision', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });
+
+ const exec = await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'what is in this picture?' }], image: { base64: 'aGk=' }, timeoutMs: 3000 }, { routeKey: r.key, routeVersion: r.version });
+ assert.strictEqual(exec.provider, 'seer', 'the model that cannot see is never tried');
+ assert.strictEqual(blind.calls, 0, 'the text-only model was never called');
+ assert.strictEqual(exec.explain.task.vision, true, 'the explain says the task needed vision');
+ assert.deepStrictEqual(Object.keys(exec.explain.task).sort(), ['json', 'min_context', 'output', 'reasoning', 'tools', 'vision', 'weight']);
+ const c = exec.explain.candidates.find((x) => x.provider === 'blind');
+ assert.strictEqual(c.eligible, false, 'the text-only model is excluded');
+ assert.match(c.excluded_reason, /no_vision/);
+ await h.stop(); await seer.close(); await blind.close();
+});
+
+t.test('a light job takes the cheapest capable model; a heavy one the strong tier', async () => {
+ const srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 3, output: 1 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ await provider({ key: 'weak', base_url: srv.url, capabilities: ['chat', 'classify', 'generate', 'json'], default_model: 'weak-m' });
+ await model('weak', 'weak-m', { cost: { in_per_mtok: 1, out_per_mtok: 1 }, supports: { json: true }, metadata: { tier: 'fast', ...DESCRIBED } });
+ await provider({ key: 'strong', base_url: srv.url, capabilities: ['chat', 'classify', 'generate', 'json'], default_model: 'strong-m' });
+ await model('strong', 'strong-m', { cost: { in_per_mtok: 50, out_per_mtok: 50 }, supports: { json: true }, metadata: { tier: 'strong', reasoning: true, ...DESCRIBED } });
+ const r = await route('test.task-fit', { capability: 'classify', constraints: { objective: 'balanced' }, timeout_ms: 3000 });
+
+ const light = await h.pool.execute(r, 'classify', { messages: [{ role: 'user', content: 'is this positive?' }], timeoutMs: 3000 }, { routeKey: r.key, routeVersion: r.version });
+ assert.strictEqual(light.explain.task.weight, 'light', 'a short classification is light work');
+ assert.strictEqual(light.provider, 'weak', 'a light job takes the cheapest capable model');
+ assert.strictEqual(light.explain.ranked[0].provider, 'weak');
+ assert.match(light.explain.ranked[0].why, /cheapest/);
+
+ const heavy = await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'write the function' }], reasoning: true, maxTokens: 8000, timeoutMs: 3000 }, { routeKey: 'code.demo', routeVersion: r.version, workflowKey: 'code.demo' });
+ assert.strictEqual(heavy.explain.task.weight, 'heavy', 'code with deep reasoning is heavy work');
+ assert.strictEqual(heavy.explain.task.reasoning, 'deep');
+ assert.strictEqual(heavy.provider, 'strong', 'a heavy job takes the strong tier, whatever it costs');
+ assert.match(heavy.explain.ranked[0].why, /strong tier/);
+ await h.stop(); await srv.close();
+});
+
+t.test("the person's free-first and price ceiling are respected", async () => {
+ const srv = await seamServer(() => ({ text: 'an answer', model: 'm', usage: { input: 3, output: 1 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ await provider({ key: 'freebie', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'free-m', metadata: { billing_profile: 'free', paid: false } });
+ await model('freebie', 'free-m', { supports: { json: true }, metadata: { tier: 'fast', ...DESCRIBED } });
+ await provider({ key: 'paywall', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'pay-m' });
+ await model('paywall', 'pay-m', { cost: { in_per_mtok: 2, out_per_mtok: 20 }, supports: { json: true }, metadata: { tier: 'strong', ...DESCRIBED } });
+ const r = await route('test.prefs', { capability: 'chat', constraints: { objective: 'balanced' }, timeout_ms: 3000 });
+ const ctx = { routeKey: r.key, routeVersion: r.version, selfSubject: 'user:1' };
+ const req = { messages: [{ role: 'user', content: 'hello' }], timeoutMs: 3000 };
+
+ const free = await h.pool.execute(r, 'chat', req, { ...ctx, selfPreferences: prefs() });
+ assert.strictEqual(free.provider, 'freebie', 'free_first puts the free tier first');
+
+ const noFree = await h.pool.execute(r, 'chat', { ...req, messages: [{ role: 'user', content: 'third' }] }, { ...ctx, selfPreferences: prefs({ free_first: false }) });
+ assert.strictEqual(noFree.provider, 'paywall', 'without free_first the strength tier leads');
+
+ // With the free tier away, the paid model is over the person's ceiling and is never called.
+ const off = await request(h.base, 'POST', '/api/v1/providers/freebie/disable', { tok });
+ assert.strictEqual(off.status, 200, off.text);
+ let err = null;
+ try {
+ await h.pool.execute(r, 'chat', { ...req, messages: [{ role: 'user', content: 'fourth' }] }, { ...ctx, selfPreferences: prefs({ price_ceiling_usd_per_mtok: 5 }) });
+ } catch (e) { err = e; }
+ assert.ok(err, 'nothing under the ceiling could answer');
+ assert.strictEqual(err.code, 'provider.unavailable');
+ const over = err.extra.explain.attempts.find((a) => a.provider === 'paywall');
+ assert.ok(over && /over_price_ceiling/.test(over.error), 'the model over the ceiling is skipped with its reason');
+ await h.stop(); await srv.close();
+});
+
+t.test('a warm prompt cache wins a near tie', async () => {
+ const one = await seamServer(() => ({ text: 'from one', model: 'm', usage: { input: 3, output: 1 } }));
+ const two = await seamServer(() => ({ text: 'from two', model: 'm', usage: { input: 3, output: 1 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ await provider({ key: 'warm-one', base_url: one.url, capabilities: ['chat', 'generate'], default_model: 'w1' });
+ await model('warm-one', 'w1', { cost: { in_per_mtok: 10, out_per_mtok: 10 }, supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ await provider({ key: 'warm-two', base_url: two.url, capabilities: ['chat', 'generate'], default_model: 'w2' });
+ await model('warm-two', 'w2', { cost: { in_per_mtok: 9.5, out_per_mtok: 9.5 }, supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ const r = await route('test.warm', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });
+ const ctx = { routeKey: r.key, routeVersion: r.version, selfSubject: 'user:1' };
+ const req = { messages: [{ role: 'user', content: 'the same prompt, again and again' }], timeoutMs: 3000 };
+
+ // The cheaper provider is away, so the other one answers and its prompt cache is warm.
+ const off = await request(h.base, 'POST', '/api/v1/providers/warm-two/disable', { tok });
+ assert.strictEqual(off.status, 200, off.text);
+ const first = await h.pool.execute(r, 'chat', req, ctx);
+ assert.strictEqual(first.provider, 'warm-one', 'the one that could answer did');
+ const on = await request(h.base, 'POST', '/api/v1/providers/warm-two/enable', { tok });
+ assert.strictEqual(on.status, 200, on.text);
+
+ const second = await h.pool.execute(r, 'chat', req, ctx);
+ assert.strictEqual(second.provider, 'warm-one', 'the warm cache wins the near tie');
+ assert.strictEqual(second.explain.ranked[0].why, 'warm cache');
+ assert.ok(second.explain.ranked.length <= 5, 'the ranked list stays short');
+ await h.stop(); await one.close(); await two.close();
+});
+
+t.test('the fallback chain stops after three attempts', async () => {
+ const dead = await seamServer(() => ({ status: 500, body: { error: { message: 'down' } } }));
+ const good = await seamServer(() => ({ text: 'never reached', model: 'm', usage: { input: 1, output: 1 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_BREAKER_FAILURES: '50' } });
+ for (const k of ['d1', 'd2', 'd3', 'd4']) {
+ await provider({ key: k, base_url: dead.url, capabilities: ['chat', 'generate'], default_model: k + '-m' });
+ await model(k, k + '-m', { cost: { in_per_mtok: 1, out_per_mtok: 1 }, supports: { json: true } });
+ }
+ await provider({ key: 'last', base_url: good.url, capabilities: ['chat', 'generate'], default_model: 'last-m' });
+ await model('last', 'last-m', { cost: { in_per_mtok: 99, out_per_mtok: 99 }, supports: { json: true } });
+ const r = await route('test.chain', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });
+
+ let err = null;
+ try { await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'hi' }], timeoutMs: 3000 }, { routeKey: r.key, routeVersion: r.version }); } catch (e) { err = e; }
+ assert.ok(err, 'the chain gave up');
+ assert.strictEqual(err.code, 'provider.unavailable');
+ const attempts = err.extra.explain.attempts;
+ assert.strictEqual(attempts.filter((a) => a.status === 'failed').length, 3, 'three attempts, no more');
+ assert.ok(attempts.every((a) => a.why), 'every attempt says why it was chosen');
+ assert.ok(attempts[0].error, 'a failed attempt says what went wrong');
+ assert.strictEqual(good.calls, 0, 'the fourth candidate was never tried');
+ await h.stop(); await dead.close(); await good.close();
+});
+
+t.test("only_my_keys never leaves the person's keys", async () => {
+ const srv = await seamServer(() => ({ text: 'a network answer', model: 'm', usage: { input: 1, output: 1 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ await provider({ key: 'network', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'n-m' });
+ await model('network', 'n-m', { supports: { json: true } });
+ const r = await route('test.onlymine', { capability: 'chat', constraints: { objective: 'cheapest' }, timeout_ms: 3000 });
+
+ let err = null;
+ try {
+ await h.pool.execute(r, 'chat', { messages: [{ role: 'user', content: 'hi' }], timeoutMs: 3000 },
+ { routeKey: r.key, routeVersion: r.version, selfSubject: 'user:1', selfPreferences: prefs({ only_my_keys: true }) });
+ } catch (e) { err = e; }
+ assert.ok(err, 'the run failed');
+ assert.strictEqual(err.status, 422);
+ assert.strictEqual(err.code, 'ai.needs_key');
+ assert.strictEqual(srv.calls, 0, 'no network provider was called');
+ await h.stop(); await srv.close();
+});
+
+t.test('the run carries the task and the ranked candidates', async () => {
+ const srv = await seamServer(() => ({ text: 'the answer', model: 'm', usage: { input: 4, output: 2 } }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ await provider({ key: 'runner', base_url: srv.url, capabilities: ['chat', 'generate'], default_model: 'run-m' });
+ await model('runner', 'run-m', { cost: { in_per_mtok: 1, out_per_mtok: 2 }, supports: { json: true }, metadata: { tier: 'balanced', ...DESCRIBED } });
+ const res = await request(h.base, 'POST', '/api/v1/generate?wait=8000', { tok, body: { prompt: 'say something', options: { cache: false } } });
+ assert.strictEqual(res.status, 201, res.text);
+ const run = res.body.run;
+ assert.strictEqual(run.status, 'succeeded', JSON.stringify(run).slice(0, 400));
+ assert.ok(run.explain.task, 'the run explains the task it read');
+ assert.strictEqual(run.explain.task.output, 'text');
+ assert.ok(Array.isArray(run.explain.ranked) && run.explain.ranked.length, 'the run explains the ranked candidates');
+ assert.ok(run.explain.ranked.every((x) => x.provider && x.why), 'every ranked candidate has a reason');
+ assert.ok(run.explain.attempts.length && run.explain.attempts.every((a) => a.why), 'every attempt says why it was chosen');
+ await h.stop(); await srv.close();
+});
+
+t.run();