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)

OP OpenVibers committed 13 days ago · b93f9bfff4 · 14 files, +858 −23

* The router picks for the task: a task-aware router with an explain (L33 P3, L36 Y7)

The router reads what a request needs (a picture, tools, a JSON schema, how much text, what it wants out) with no model call, and picks for it. The hard needs come first, so 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. The fallback chain stops after three providers, a warm prompt cache wins a near tie, the per-provider timeout comes from the catalogue's model row before the provider's preset, and the explain gains the requirement set (task), the ranked candidates with their reasons, and the reason for every attempt.

* openvibe-contracts v0.262.0 (was v0.257.0)

The router's explain is ai.run@1 1.1.0, released in v0.262.0. Bumps the pin, the lockfile and the version lines.

* A soft preference for the provider a caller would rather have (L37)

A request may carry prefer: { provider, model }; the model may be left out. The router puts the preferred provider and model first when they are eligible: the person's own key for that provider answers first, at no fee, else the network offer for it. After that comes the normal task ranking. A preference 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 provider or model is ignored with honoured: false, never an error, and the explain says what happened.

* Merge branch 'main' of github.com:OpenVibers/OpenVibe.AI into pin/ai227

# Conflicts:
#	README.md
#	STATUS.json

* Merge branch 'feature/ai-task-router' of github.com:OpenVibers/OpenVibe.AI into pin/ai227

Changes

ddiff --git a/README.md b/README.md
iindex aeac3aa..5305894 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex b0268df..a19650b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 5bf3260..3fe89cc 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex eb8d91e..4a9a8df 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex 6398577..a6eb586 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -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);
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex a5a156a..db9fc5e 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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);
ddiff --git a/server/config.js b/server/config.js
iindex 4810377..560db9c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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`,
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 1376516..3406fbc 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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.no
const 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.no
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) {
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
@@@ -585,6 +685,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (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.no
if (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.no
const 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.no
list = [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.no
const 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.no
if (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.no
if (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;
ddiff --git a/server/providers/task.js b/server/providers/task.js
nnew file mode 100644
iindex 0000000..087bf4a
---- /dev/null
++++ b/server/providers/task.js
@@@ -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 };
ddiff --git a/server/providers/warm-cache.js b/server/providers/warm-cache.js
nnew file mode 100644
iindex 0000000..c02c3d3
---- /dev/null
++++ b/server/providers/warm-cache.js
@@@ -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 };
ddiff --git a/server/runs.js b/server/runs.js
iindex 50a0bcd..887ed4d 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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 = { now
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 } : {}),
...(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 } : {}),
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
iindex 36acd21..7db50eb 100644
---- a/server/workflows/engine.js
++++ b/server/workflows/engine.js
@@@ -186,6 +186,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
temperature: 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 = {}, spa
maxTokens: 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 = {}, spa
messages: 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']) {
ddiff --git a/test/prefer-route.test.js b/test/prefer-route.test.js
nnew file mode 100644
iindex 0000000..90ef867
---- /dev/null
++++ b/test/prefer-route.test.js
@@@ -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();
ddiff --git a/test/task-router.test.js b/test/task-router.test.js
nnew file mode 100644
iindex 0000000..b9a450c
---- /dev/null
++++ b/test/task-router.test.js
@@@ -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();