openvibers/openvibe.ai Commits
Add the second provider wave and route by its traits (#452)
Changes
@@@ -43,6 +43,7 @@ const { createWhisperProvider } = require('./whisper');const { createSpeechProvider } = require('./speech');const rates = require('./rate-cards');const tiers = require('./tiers');++const traits = require('./traits');const latency = require('../latency');const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');const modelCatalog = require('./model-catalog');@@@ -238,6 +239,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// One local generation at a time (AI_LOCAL_LLM_CONCURRENCY, default 1): the host's model shares its CPU// with live streams, so a paid outage must not turn into a local-model stampede.const localLimit = Math.max(1, (config.localLlm && config.localLlm.concurrency) || 1);++ // Per-provider in-flight calls, for the concurrency cap a provider states (IteraCompute: 1 per account).++ const activeCalls = new Map();let localActive = 0;const exhaustedCooldownMs = () => Math.max(60000, Number(config.breaker.exhaustedCooldownMs) || 10 * 60000);// L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).@@@ -701,6 +704,28 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noreturn local.length ? [...local, ...rest] : list;}++ /**++ * Spend a free allowance first (2026-11-10): while a provider's stated monthly/one-time tokens are not++ * used up and the promotion has not ended, its offers lead the order for eligible work. It is never++ * moved ahead for a person's private data when the provider trains on or retains a prompt.++ */++ async function orderFreeAllowance(list, ctx = {}) {++ const privateRequest = traits.isPrivateRequest(ctx);++ const first = [];++ const rest = [];++ for (const c of list) {++ const p = await registry.getProvider(c.provider);++ if (!p) { rest.push(c); continue; }++ if (privateRequest && traits.trainsOrRetains(p)) { rest.push(c); continue; }++ const row = c.model ? await registry.getModel(c.provider, c.model) : null;++ const allowed = traits.latencyAllows(p, row, ctx.latencyClass || 'interactive');++ const a = allowed ? traits.allowanceLeft(p, { used: await allowanceUsed(p.key, p), now: clock.now() }) : null;++ if (a && !a.ended && a.left > 0 && traits.traitsOf(p) && traits.traitsOf(p).free_only === true) first.push(c);++ else rest.push(c);++ }++ return first.length ? [...first, ...rest] : list;++ }++/*** The tier one candidate may run on, for a run that may wait:*@@@ -865,6 +890,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no* health (placement_state error rate + the breaker); hysteresis through the route's current placement.* The stub is never a placement candidate (synthetic output must not win on price) — it is appended last.*/++ const getAllowanceUsage = db.prepare(`SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ? AND day >= ?`);++ /** Tokens this provider's free allowance has used this period (0 for a provider with no allowance). */++ async function allowanceUsed(providerKey, record) {++ try {++ const since = traits.allowancePeriodStart(record, clock.now());++ const row = since ? await getAllowanceUsage.get(providerKey, since)++ : await db.prepare('SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ?').get(providerKey);++ return Number((row && row.t) || 0);++ } catch { return 0; }++ }++ /** The request's latency class, as the router will use it. */++ const latencyClassOf = (route, ctx) => (ctx.latencyClass && ['interactive', 'background', 'batch'].includes(ctx.latencyClass))++ ? ctx.latencyClass : ((route && route.constraints && route.constraints.latency_class) || 'interactive');++async function poolCandidates(route, features, operation, ctx = {}) {const stats = await statsByProvider();const offers = [];@@@ -872,6 +911,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst cardsByProvider = {};const seen = new Set();const accessExcluded = [];++ const traitExcluded = [];++ const latencyClass = latencyClassOf(route, ctx);// A dynamic policy alias (D3) looks at every active chat/vision model the provider registered, not only its// default; the placement and the task router then choose among them. Auto keeps today's one-offer-per-provider.const expandModels = Boolean(ctx.aliasPolicy && ctx.aliasPolicy !== 'auto') && !(route.constraints && route.constraints.model);@@@ -887,12 +928,29 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// The content policy narrows the pool before placement: an uncensored offer never answers a standard// request, and the adult tier has no other offer to run on.if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;++ // The offer's own traits (2026-11-10): a background/batch-only provider never answers an++ // interactive request; a provider that trains on or retains a prompt never receives a person's++ // private data; a free-only allowance that ended or is spent is not called again.++ const row = model ? await registry.getModel(p.key, model) : null;++ // A trait keeps the offer out of the call, not out of placement: it stays in the pool with its++ // reason (status down, like a credential-less offer), so explain has one entry per placement offer.++ let traitReason = null;++ if (!traits.latencyAllows(p, row, latencyClass)) {++ traitReason = `latency_class ${latencyClass} not offered (${(traits.latencyClassesOf(p, row) || []).join('/') || 'interactive only'})`;++ } else if (traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {++ traitReason = 'private data: the provider trains on or retains prompts';++ } else {++ const allowance = traits.freeAllowanceOf(p) ? traits.allowanceLeft(p, { used: await allowanceUsed(p.key, p), now: clock.now() }) : null;++ if (allowance && allowance.ended) traitReason = `free allowance ended ${allowance.ends_at}`;++ else if (allowance && traits.traitsOf(p) && traits.traitsOf(p).free_only === true && allowance.left <= 0) traitReason = 'free allowance spent';++ }++ if (traitReason) traitExcluded.push({ provider: p.key, model, eligible: false, excluded_reason: traitReason, cost: null, latency: null });seen.add(id);const creds = p.auth_mode === 'none' || credentialNotRequired(p) || Boolean(providerSecret(p, env));const h = await health(p.key);const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };const errorRate = st.requests ? st.errors / st.requests : 0;-- const status = p.status !== 'active' || !creds ? 'down' : h.state === 'open' ? 'down' : (h.state === 'half_open' || errorRate > 0.5) ? 'degraded' : 'up';++ const status = traitReason || p.status !== 'active' || !creds ? 'down' : h.state === 'open' ? 'down' : (h.state === 'half_open' || errorRate > 0.5) ? 'degraded' : 'up';const free = isFreeProvider(p);// Placement prices an offer on one card: input tokens, the part of a call known before it runs.// The cached-input and output cards ride along (costOf bills all three; the state counts each metric).@@@ -918,7 +976,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;if (!accessOffered(p)) {-- accessExcluded.push({ provider: p.key, model: (route.constraints && route.constraints.model) || p.default_model || null, eligible: false,++ // A commercial-gate exclusion is provider-wide: it names no model, so explain's entry has the++ // same id shape as the placement candidate it stands for.++ accessExcluded.push({ provider: p.key, model: null, eligible: false,excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });continue;}@@@ -946,7 +1006,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noif (scope === 'local' && !isLocalProvider(p)) continue;if (scope === 'paid' && isFreeProvider(p)) continue;if (!accessOffered(p)) {-- accessExcluded.push({ provider: p.key, model: pin.model || p.default_model || null, eligible: false,++ accessExcluded.push({ provider: p.key, model: null, eligible: false,excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });continue;}@@@ -1000,6 +1060,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no};// An offer the commercial gate kept out is named with its reason, never silently missing from the explain.for (const x of accessExcluded) if (!explain.candidates.some((c) => c.provider === x.provider && c.model === x.model)) explain.candidates.push(x);++ // An offer the provider's own traits kept out is named with the trait's reason, on the placement++ // entry it already has; an offer placement never saw is added.++ for (const x of traitExcluded) {++ const hit = explain.candidates.find((c) => c.provider === x.provider && (c.model || null) === (x.model || null));++ if (hit) hit.excluded_reason = x.excluded_reason; else explain.candidates.push(x);++ }const order = [];if (result.selected) order.push(result.selected);const rest = result.candidates.filter(x => x.id !== result.selected).sort((a, b) => {@@@ -1028,14 +1094,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst offered = appOffered(ctx, p);const access = accessOffered(p);const policyOk = p ? await policyAllows(registry, p, modelId, ctx.contentPolicy || 'standard') : false;++ const row = p && modelId ? await registry.getModel(p.key, modelId) : null;++ const latencyOk = p ? traits.latencyAllows(p, row, (ctx.latencyClass && ['interactive', 'background', 'batch'].includes(ctx.latencyClass)) ? ctx.latencyClass : ((route.constraints && route.constraints.latency_class) || 'interactive')) : false;++ const privateOk = p ? !(traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) : false;const excluded = !policyOk ? 'content_policy': !offered ? (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only')-- : !access ? `provider_access ${modelCatalog.accessOf(p)}` : why;++ : !access ? `provider_access ${modelCatalog.accessOf(p)}` : !latencyOk ? 'latency_class'++ : !privateOk ? 'private_data_training' : why;explain.candidates.push({-- provider: c.provider, model: modelId, eligible: offered && access && policyOk && !why,++ provider: c.provider, model: modelId, eligible: offered && access && policyOk && latencyOk && privateOk && !why,excluded_reason: excluded, cost: null, latency: null,});-- if (offered && access && policyOk) order.push({ provider: c.provider, model: c.model || null });++ if (offered && access && policyOk && latencyOk && privateOk) order.push({ provider: c.provider, model: c.model || null });}return { order, explain };}@@@ -1707,6 +1777,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}explain.preset = { name: ctx.presetName || null, policy: ctx.presetPolicy };}++ // A free allowance is spent first for the work it can answer (2026-11-10).++ if (!exact && !ordered) list = await orderFreeAllowance(list, { ...ctx, latencyClass: ctx.latencyClass || (route.constraints && route.constraints.latency_class) || 'interactive' });// The owner's fixed/cheapest/fastest order survives the router's re-ranking: re-apply// here, on the authoritative list, so an owner's uncensored first/second always holds.// The floor (server/policy/floor.js) and the 18+ gate run before/after every attempt@@@ -1810,6 +1882,32 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst 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 };++ // The provider's own traits, checked before the call: a class it does not offer, a person's++ // private data it would train on or retain, or a concurrency cap already reached. Each is a++ // skip with its reason, never an error: the next offer answers.++ const traitRow = p && model ? await registry.getModel(p.key, model) : null;++ if (p && !traits.latencyAllows(p, traitRow, latencyClass)) {++ statFor(c.provider).skipped++;++ tried.push({ provider: c.provider, skipped: 'latency_class' });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the provider does not serve this latency class', status: 'skipped', error: 'latency_class' });++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'latency_class' });++ continue;++ }++ if (p && traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {++ statFor(c.provider).skipped++;++ tried.push({ provider: c.provider, skipped: 'private_data_training' });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the provider trains on or retains prompts', status: 'skipped', error: 'private_data_training' });++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'private_data_training' });++ continue;++ }++ const concurrencyCap = p ? traits.concurrencyOf(p) : null;++ if (concurrencyCap != null && (activeCalls.get(c.provider) || 0) >= concurrencyCap) {++ statFor(c.provider).skipped++;++ tried.push({ provider: c.provider, skipped: 'concurrency_cap' });++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the provider is at its concurrency cap', status: 'skipped', error: `concurrency_cap (${concurrencyCap})` });++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'concurrency_cap' });++ continue;++ }// Spend safety: the free allowance / anonymous Try it runs only from its named sources// (server/free-tier.js). A provider outside them is skipped even when it is cheaper: who pays// matters, not only the price. The person's own keys (above) are always tried first and are never@@@ -1884,8 +1982,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// L36 Y7: the fallback chain stops after three providers.if (attemptsMade >= maxAttempts) break;attemptsMade += 1;-- // Claim the local slot synchronously, before any await, so two requests cannot both pass the check.++ // Claim the local slot and the provider's concurrency slot synchronously, before any await, so two++ // requests cannot both pass the check.if (local) localActive++;++ if (concurrencyCap != null) activeCalls.set(c.provider, (activeCalls.get(c.provider) || 0) + 1);let lastErr = null;try {// An image generation outlasts the shared provider's chat timeout (AI_PROVIDER_TIMEOUT_MS): the route's wins. So does a@@@ -1905,7 +2005,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// not serve it) sends the same run once more on the standard tier: the refusal is counted and// logged, and it produced no tokens, so nothing is billed for it. The batch class has no tier// yet: its own API is BA2, so a batch run is priced at the standard rate until then.-- const wantedTier = latencyClass === 'background' ? await tierFor({ p, model, operation, at: clock.now() }) : 'standard';++ // An offer that names its own service tier (the OpenRouter Particle Flex offer) runs on that tier;++ // every other offer asks the provider's own tier rule.++ const tierRow = p && model ? await registry.getModel(p.key, model) : null;++ const offeredTier = tierRow && tierRow.metadata && tierRow.metadata.service_tier;++ const wantedTier = latencyClass === 'background'++ ? (offeredTier === 'flex' ? 'flex' : await tierFor({ p, model, operation, at: clock.now() }))++ : 'standard';let askedTier = wantedTier;let attempt = 0;while (attempt <= retries) {@@@ -1960,6 +2066,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}// The service tier travels with the request (BA1): a Flex tier is slower and cheaper.if (askedTier === 'flex') callReq = { ...callReq, serviceTier: 'flex' };++ // OpenRouter's provider routing, from the offer's own settings (only/order/sort, max_price,++ // data_collection, quantizations): the offer can pin one endpoint exactly.++ if (attemptRow && attemptRow.metadata && attemptRow.metadata.routing) callReq = { ...callReq, routing: attemptRow.metadata.routing };try {const fn = a[operation];if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });@@@ -2065,6 +2174,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no}} finally {if (local) localActive--;++ if (concurrencyCap != null) activeCalls.set(c.provider, Math.max(0, (activeCalls.get(c.provider) || 1) - 1));}if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;// classifyFailure decides what a failure means: balance/auth open at once, a run of transient@@@ -28,8 +28,21 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enricfunction isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }++/**++ * The provider routing object of an OpenRouter offer (2026-11-10): only the fields OpenRouter documents,++ * copied from the model offer's own settings. It lets an offer pin one endpoint exactly (only/order, no++ * fallback) or rank by price under a cap, with data collection denied and a quantization filter.++ */++const ROUTING_KEYS = ['order', 'only', 'ignore', 'allow_fallbacks', 'sort', 'max_price', 'data_collection', 'quantizations', 'zdr', 'require_parameters', 'preferred_min_throughput', 'preferred_max_latency', 'enforce_distillable_text'];++function providerRouting(routing) {++ if (!routing || typeof routing !== 'object' || Array.isArray(routing)) return null;++ const out = {};++ for (const key of ROUTING_KEYS) if (routing[key] !== undefined && routing[key] !== null) out[key] = routing[key];++ return Object.keys(out).length ? out : null;++}++function buildBody(req, jsonMode) {-- const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier } = req;++ const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier, routing } = req;const msgs = [];// Stepped down from json_schema: the schema travels in the instructions instead.const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';@@@ -56,6 +69,9 @@ function buildBody(req, jsonMode) {// The service tier (BA1): a Flex tier is slower and cheaper, and the provider may refuse it. The tier// travels in the request; the answer says which tier it really ran on (the pool prices that one).if (serviceTier) body.service_tier = String(serviceTier);++ const provider = providerRouting(routing);++ if (provider) body.provider = provider;++ if (routing && routing.service_tier && !body.service_tier) body.service_tier = String(routing.service_tier);return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });}@@@ -64,7 +80,7 @@ function buildBody(req, jsonMode) {* the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.*/function buildCompleteBody(req = {}) {-- const { model, openai = {}, maxTokens, temperature, serviceTier } = req;++ const { model, openai = {}, maxTokens, temperature, serviceTier, routing } = req;const o = openai || {};const body = { model, messages: o.messages };for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {@@@ -80,6 +96,9 @@ function buildCompleteBody(req = {}) {if (temperature != null) body.temperature = temperature;}if (serviceTier) body.service_tier = String(serviceTier);++ const provider = providerRouting(routing);++ if (provider) body.provider = provider;++ if (routing && routing.service_tier && !body.service_tier) body.service_tier = String(routing.service_tier);return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });}@@@ -91,6 +91,24 @@ const privacy = (o) => ({checked: o.checked || CHECKED,});++/**++ * The traits beyond price (2026-11-10 provider wave): the classes of work, the concurrency cap, a free++ * allowance with its end date, how it is paid, and the OpenRouter routing an offer pins. An absent key++ * means the provider states nothing, never a guessed value. Every record names where and when it was read.++ */++const traits = (o) => ({++ ...(Array.isArray(o.latency_classes) ? { latency_classes: o.latency_classes.filter((c) => ['interactive', 'background', 'batch'].includes(c)) } : {}),++ ...(o.tier_latency && typeof o.tier_latency === 'object' ? { tier_latency: o.tier_latency } : {}),++ ...(Number.isInteger(o.concurrency) && o.concurrency > 0 ? { concurrency: o.concurrency } : {}),++ ...(o.free_allowance ? { free_allowance: o.free_allowance } : {}),++ ...(o.payment ? { payment: o.payment } : {}),++ ...(o.free_only === true ? { free_only: true } : {}),++ ...(o.batch_window_hours ? { batch_window_hours: o.batch_window_hours } : {}),++ ...(o.trains_note ? { trains_note: o.trains_note } : {}),++ source: o.source || null,++ checked: o.checked || NEW_CHECKED,++});++const PROVIDERS = [{key: 'qwen',@@@ -639,8 +657,21 @@ const PROVIDERS = [resale: 'UNVERIFIED (2026-11-03 matrix does not list OpenRouter). The price book (2026-10-30) records that its terms contemplate selling capacity inside your own product; that is not the same as an executed agreement.',}),// OpenRouter lists hundreds of models and prices each one itself; the catalogue sync reads its own list with-- // the key. No model is seeded here: a price we did not read from OpenRouter is not one of ours to publish.-- models: [],++ // the key. One offer is pinned here because the owner asked for it exactly: DeepSeek V4.1 Flash 1017 on++ // Particle AI's Flex endpoint, at the price OpenRouter's own endpoint list gives it, with the routing++ // object that keeps the call on Particle (no fallback) and the data policy at deny.++ models: [{++ model_key: 'deepseek/deepseek-v4.1-flash-1017',++ display_name: 'DeepSeek V4.1 Flash 1017 (Particle Flex)', family: 'deepseek',++ context_window: 1048576, max_output: 131072,++ cost: { in_per_mtok: 0.025, cached_per_mtok: 0.003, out_per_mtok: 0.25 },++ source: 'https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints', checked: '2026-11-10',++ supports: { json: true, tools: true, streaming: true, vision: true },++ quantization: null, refusal_rate: null,++ service_tier: 'flex',++ routing: { only: ['particle-ai'], allow_fallbacks: false, service_tier: 'flex', data_collection: 'deny' },++ note: 'the Particle AI endpoint at service tier flex ($0.025 in / $0.25 out / $0.003 cached, read from OpenRouter on 2026-11-10); pinning it with no fallback keeps the price honest',++ }],},...[['venice', 'Venice AI', 'https://api.venice.ai/api/v1', 'venice-uncensored-1-2', 'https://docs.venice.ai/overview/pricing', 'PRIVATE', 'UNVERIFIED', { in: 0.20, cached: null, out: 0.90 }, 131072, true],@@@ -721,8 +752,32 @@ const PROVIDERS = [// Each starts disabled with no key; the owner puts its key in the owner settings and syncs its model list.{ key: 'parasail', display_name: 'Parasail', base_url: 'https://api.parasail.io/v1',docs_url: 'https://docs.parasail.io/parasail-docs/products/overview/models', capabilities: ['chat', 'embed'] },-- { key: 'particle-ai', display_name: 'Particle AI', base_url: 'https://api.particle.ai/v1',-- docs_url: 'https://docs.particle.ai/', capabilities: ['chat'] },++ {++ key: 'particle-ai', keys_url: 'https://platform.particle.ai', display_name: 'Particle AI',++ base_url: 'https://api.particle.ai/v1', default_model: 'particle-1', timeout_ms: 900000,++ docs_url: 'https://docs.particle.ai/', capabilities: ['chat', 'tools', 'vision'],++ provider_access: 'UNVERIFIED', priority: 35,++ // Particle's own Flex tier is 50% off every token price and may wait up to 15 minutes for capacity++ // (https://docs.particle.ai/service-tiers, read 2026-11-10). It is a background/batch tier: never an++ // answer in front of a person. Direct prices differ from OpenRouter's listing, so no model price is++ // seeded here: the owner syncs the direct catalogue and its own reading fills the price.++ tier_rates: { flex: 0.5 },++ traits: traits({++ latency_classes: ['interactive', 'background', 'batch'], tier_latency: { flex: ['background', 'batch'] },++ payment: 'prepaid', source: 'https://docs.particle.ai/service-tiers', checked: '2026-11-10',++ }),++ privacy: privacy({++ label: 'anonymized', terms_url: 'https://docs.particle.ai/data-retention',++ wording: '"Particle operates zero-data-retention for inference traffic: prompts and completions are processed in memory and never persisted." The page says nothing about training the prompt (data retention, 2026-11-10), so the offer is not trusted with private data.',++ checked: '2026-11-10',++ }),++ data_policy: policy({++ trains: null, retention: 'none', trust: 'first-party', region: null,++ retention_note: 'prompts and completions are processed in memory and never persisted; prefix caches are transient',++ terms: 'https://docs.particle.ai/data-retention', checked: '2026-11-10',++ }),++ models: [],++ },{ key: 'inference-net', display_name: 'Inference.net', base_url: 'https://api.inference.net/v1', models_public: true,docs_url: 'https://docs.inference.net/', capabilities: ['chat'] },{ key: 'atlas-cloud', display_name: 'AtlasCloud', base_url: 'https://api.atlascloud.ai/v1', models_public: true,@@@ -741,6 +796,123 @@ const PROVIDERS = [{ key: 'scaleway', display_name: 'Scaleway', base_url: 'https://api.scaleway.ai/v1',docs_url: 'https://www.scaleway.com/en/developers/api/generative-apis', capabilities: ['chat', 'embed'] },++ // The 2026-11-10 provider wave. Each was read on its own page or keyless list that day; each starts++ // disabled with no key. The owner adds the key and the money; nothing is created or spent here.++ {++ key: 'iteracompute', keys_url: 'https://iteracompute.com/console/api-keys', display_name: 'IteraCompute',++ base_url: 'https://api.iteracompute.com/v1', default_model: 'Auto', timeout_ms: 180000,++ docs_url: 'https://iteracompute.com/docs.html', capabilities: ['chat', 'tools'],++ provider_access: 'UNVERIFIED', priority: 20,++ traits: traits({++ latency_classes: ['background', 'batch'], concurrency: 1,++ free_allowance: {++ tokens: 30000000, period: 'month', shared: true, ends_at: '2026-11-24T08:00:00Z',++ context_window: 131072, max_output: 8192,++ note: '30M tokens a month across every key and free route, input and output including reasoning, resets at 00:00 UTC on the 1st, no rollover; the promotion ends at 00:00 Pacific on 2026-11-24',++ },++ payment: 'none', free_only: true,++ source: 'https://iteracompute.com/free/', checked: '2026-11-10',++ }),++ privacy: privacy({++ label: 'anonymized', terms_url: 'https://iteracompute.com/privacy.html',++ wording: 'the free tier page says "Auto does not provide a zero-data-retention guarantee; review the data policy before sending sensitive information" (read 2026-11-10)',++ checked: '2026-11-10',++ }),++ data_policy: policy({++ trains: null, retention: null, trust: 'first-party', region: null,++ retention_note: 'no zero-retention guarantee is stated; the model and precision may change between requests',++ terms: 'https://iteracompute.com/privacy.html', checked: '2026-11-10',++ }),++ models: [{++ model_key: 'Auto', display_name: 'Auto (free shared pool)', family: 'auto', context_window: 131072, max_output: 8192,++ cost: { in_per_mtok: 0, cached_per_mtok: null, out_per_mtok: 0 },++ source: 'https://iteracompute.com/free/', checked: '2026-11-10',++ supports: { json: true, tools: false, streaming: true, vision: false },++ quantization: null, refusal_rate: null,++ note: 'one queued or running request per account; the model, version and precision behind Auto may change',++ }],++ },++ {++ key: 'sable', keys_url: 'https://sableapi.com/auth/sign-in', display_name: 'Sable',++ base_url: 'https://sableapi.com/v1', default_model: 'deepseek-v4.1-flash-1017-uncensored', timeout_ms: 120000,++ docs_url: 'https://sableapi.com/docs', capabilities: ['chat', 'tools', 'vision'],++ provider_access: 'UNVERIFIED', priority: 20, uncensored: true,++ traits: traits({++ latency_classes: ['interactive', 'background'],++ payment: 'crypto-prepaid', source: 'https://sableapi.com/models', checked: '2026-11-10',++ }),++ privacy: privacy({++ label: 'anonymized', terms_url: 'https://sableapi.com/privacy',++ wording: '"Prompts and outputs: Never logged, stored or used for training." but "prompt cache ... is discarded when newer entries need the space, usually within an hour to a few days" (privacy, last updated 2026-11-09)',++ checked: '2026-11-10',++ }),++ data_policy: policy({++ trains: false, retention: 'limited', trust: 'first-party', region: null,++ retention_note: 'prompts and outputs are not logged or stored; prompt-cache state is kept for an hour to a few days',++ terms: 'https://sableapi.com/privacy', checked: '2026-11-10',++ }),++ models: [{++ model_key: 'deepseek-v4.1-flash-1017-uncensored', display_name: 'DeepSeek V4.1 Flash 1017 (uncensored)',++ family: 'deepseek', context_window: 512000,++ cost: { in_per_mtok: 0.15, cached_per_mtok: 0.01, out_per_mtok: 0.60 },++ source: 'https://sableapi.com/models', checked: '2026-11-10',++ supports: { json: true, tools: true, streaming: true, vision: true },++ quantization: null, refusal_rate: null, uncensored: true,++ note: 'refusals removed; 512K context; prepaid balance (crypto accepted); the 18+ floor still runs before it',++ }],++ },++ {++ key: 'inception', keys_url: 'https://platform.inceptionlabs.ai', display_name: 'Inception Labs',++ base_url: 'https://api.inceptionlabs.ai/v1', default_model: 'mercury-3', timeout_ms: 90000,++ docs_url: 'https://docs.inceptionlabs.ai/get-started/models', capabilities: ['chat', 'tools', 'json'],++ provider_access: 'UNVERIFIED', priority: 25,++ traits: traits({++ latency_classes: ['interactive', 'background', 'batch'], payment: 'prepaid',++ free_allowance: { tokens: 100000000, period: 'once', shared: true, ends_at: null, note: '100M tokens once per new account, shared across Inception models; no end date published' },++ trains_note: 'trains on submissions unless the "Improve the model for everyone" setting is off',++ source: 'https://docs.inceptionlabs.ai/resources/faq', checked: '2026-11-10',++ }),++ privacy: privacy({++ label: 'anonymized', terms_url: 'https://www.inceptionlabs.ai/docs/privacy-policy',++ wording: 'the FAQ states submissions are used to improve the model unless "Improve the model for everyone" is switched off (read 2026-11-10); the setting is off only when the owner records that it is',++ checked: '2026-11-10',++ }),++ data_policy: policy({++ trains: true, retention: 'standard', trust: 'first-party', region: null,++ retention_note: 'trains on submissions unless the owner records that "Improve the model for everyone" is off',++ terms: 'https://www.inceptionlabs.ai/docs/privacy-policy', checked: '2026-11-10',++ }),++ models: [{++ model_key: 'mercury-3', display_name: 'Mercury 3', family: 'mercury', context_window: 1000000, max_output: 131072,++ cost: { in_per_mtok: 0.20, cached_per_mtok: 0.02, out_per_mtok: 0.70 },++ source: 'https://docs.inceptionlabs.ai/get-started/models', checked: '2026-11-10',++ supports: { json: true, tools: true, streaming: true, vision: false },++ quantization: null, refusal_rate: null,++ note: 'launch price is $0.10 in / $0.35 out with $0.01 cached and no published end date; the list price is recorded here so a budget does not assume the discount lasts',++ }],++ },++ {++ key: 'dosai', keys_url: 'https://platform.dos.ai', display_name: 'DOS.AI',++ base_url: 'https://api.dos.ai/v1', default_model: 'deepseek-v4.1-flash', timeout_ms: 900000,++ docs_url: 'https://docs.dos.ai/api-reference/batches', capabilities: ['chat', 'tools'],++ provider_access: 'UNVERIFIED', priority: 20,++ // A batch-only offer: 50% off retail with a 24-hour completion window, and batch volume does not++ // consume interactive rate limits (https://docs.dos.ai/api-reference/batches, read 2026-11-10).++ // No absolute price is published for the six models, so the book records the multiplier, not a guess.++ tier_rates: { batch: 0.5 },++ traits: traits({++ latency_classes: ['batch'], batch_window_hours: 24, payment: 'prepaid',++ source: 'https://docs.dos.ai/api-reference/batches', checked: '2026-11-10',++ }),++ privacy: privacy({++ label: 'anonymized', terms_url: 'https://docs.dos.ai',++ wording: 'retention and training terms were not confirmed on the batch page read on 2026-11-10',++ checked: '2026-11-10',++ }),++ data_policy: policy({ trains: null, retention: null, terms: 'https://docs.dos.ai', checked: '2026-11-10' }),++ models: [],++ },++];// The seed, console and personal-key forms consume these same records.@@@ -39,6 +39,8 @@ function recordOf(p) {privacy: p.privacy,// The tiers the provider itself sells and at what price (BA1): a Flex tier, a batch API....(p.tier_rates ? { tier_rates: p.tier_rates } : {}),++ // The traits beyond price (latency class, concurrency, free allowance, payment, sources).++ ...(p.traits ? { traits: p.traits } : {}),...(p.uncensored ? { uncensored: true } : {}),...(p.role ? { role: p.role } : {}),},@@@ -59,6 +61,11 @@ function modelOf(p, m) {refusal_rate: m.refusal_rate == null ? null : m.refusal_rate,...(m.note ? { note: m.note } : {}),...(m.uncensored ? { uncensored: true } : {}),++ // A per-offer fact: the OpenRouter provider routing it pins, the service tier it runs on, and any++ // latency classes narrower than the provider's.++ ...(m.routing ? { routing: m.routing } : {}),++ ...(m.service_tier ? { service_tier: m.service_tier } : {}),++ ...(Array.isArray(m.latency_classes) ? { latency_classes: m.latency_classes } : {}),data_policy: p.data_policy,privacy: p.privacy,};nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++/**++ * The traits a provider record carries beyond its price (part 1 of the 2026-11-10 provider wave): the++ * classes of work it may answer, how many requests it takes at once, a free allowance and when it ends,++ * how it is paid, and the OpenRouter provider routing an offer pins.++ *++ * They live in metadata.traits on the provider row (server/providers/pool-seed.js writes them from++ * server/providers/pool-providers.js) and in metadata on a model row for the per-offer facts. The++ * router reads them; nothing here calls a provider or invents a fact.++ *++ * latency_classes interactive | background | batch absent: every class++ * tier_latency { flex: ['background', 'batch'] } a tier's own classes (Flex never interactive)++ * concurrency number at most that many calls at once++ * free_allowance { tokens, period, ends_at, ... } spent first, then stops at its date++ * payment 'crypto-prepaid' | null++ * trains_note the provider's own wording about training, when it is not in data_policy++ */++++const CLASSES = ['interactive', 'background', 'batch'];++const RETENTION_KEEPS = new Set(['limited', 'standard']);++++function traitsOf(record) {++ return (record && record.metadata && record.metadata.traits) || null;++}++++/** The provider record's data policy (the facts the Exchange privacy label reads). */++function policyOf(record) {++ return (record && record.metadata && record.metadata.data_policy) || null;++}++++/** The classes one offer may answer, or null when it may answer every class. */++function latencyClassesOf(record, model, tier = 'standard') {++ const t = traitsOf(record);++ if (t && t.tier_latency && tier && Array.isArray(t.tier_latency[tier])) return t.tier_latency[tier];++ if (t && Array.isArray(t.latency_classes) && t.latency_classes.length) return t.latency_classes;++ const m = (model && model.metadata) || null;++ if (m && Array.isArray(m.latency_classes) && m.latency_classes.length) return m.latency_classes;++ // A model offer that names a Flex service tier is a Flex offer: it may wait, never answer in front of a person.++ if (m && String(m.service_tier || '').toLowerCase() === 'flex') return ['background', 'batch'];++ return null;++}++++/** May this offer answer a request of that class? An unknown class is interactive (the safe default). */++function latencyAllows(record, model, latencyClass = 'interactive', tier = 'standard') {++ const classes = latencyClassesOf(record, model, tier);++ const asked = CLASSES.includes(latencyClass) ? latencyClass : 'interactive';++ return !classes || classes.includes(asked);++}++++/** The free allowance the provider states, or null. */++function freeAllowanceOf(record) {++ const t = traitsOf(record);++ return t && t.free_allowance ? t.free_allowance : null;++}++++/** The provider's concurrency cap, or null when it states none. */++function concurrencyOf(record) {++ const t = traitsOf(record);++ const n = t && Number(t.concurrency);++ return Number.isInteger(n) && n > 0 ? n : null;++}++++/**++ * What is left of a free allowance. `used` is the provider's token count for the current period from++ * usage_daily (the caller reads it; this function does no I/O).++ *++ * → { tokens, used, left, ends_at, ended, period, shared } or null when the provider has none.++ */++function allowanceLeft(record, { used = 0, now = Date.now() } = {}) {++ const a = freeAllowanceOf(record);++ if (!a) return null;++ const tokens = Number(a.tokens);++ if (!Number.isFinite(tokens) || tokens <= 0) return null;++ const endsAt = a.ends_at ? Date.parse(a.ends_at) : null;++ const ended = Number.isFinite(endsAt) && now >= endsAt;++ const spent = Math.max(0, Number(used) || 0);++ return {++ tokens,++ used: spent,++ left: ended ? 0 : Math.max(0, tokens - spent),++ ends_at: a.ends_at || null,++ ended,++ period: a.period || 'once',++ shared: a.shared !== false,++ };++}++++/** The start of the allowance's current period, as an ISO day, or null for a one-time allowance. */++function allowancePeriodStart(record, now = Date.now()) {++ const a = freeAllowanceOf(record);++ if (!a) return null;++ if (a.starts_at) return String(a.starts_at).slice(0, 10);++ if (a.period === 'month') {++ const d = new Date(now);++ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).toISOString().slice(0, 10);++ }++ return null;++}++++/**++ * Does this provider train on or retain a prompt? The two facts the private rule reads. A provider that++ * says nothing is not trusted with a person's private data: unknown counts as retaining.++ */++function trainsOrRetains(record) {++ const p = policyOf(record);++ if (!p) return true;++ // Training must be an explicit "no": a provider that says nothing is not trusted with private data.++ if (p.trains !== false && p.training !== false) return true;++ if (p.retention == null) return true;++ return RETENTION_KEEPS.has(String(p.retention));++}++++/** Is this request a person's private data? A preset that asks for strict privacy is; so is `options.private`. */++function isPrivateRequest(ctx = {}) {++ if (ctx.private === true) return true;++ const policy = ctx.presetPolicy || null;++ return Boolean(policy && String(policy.data_policy || '').toLowerCase() === 'private');++}++++module.exports = {++ CLASSES, traitsOf, policyOf, latencyClassesOf, latencyAllows, freeAllowanceOf, concurrencyOf,++ allowanceLeft, allowancePeriodStart, trainsOrRetains, isPrivateRequest,++};@@@ -366,6 +366,17 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confipresetPolicy = {};}}++ // The caller's own data policy (2026-11-10): private means a person's private data, so no offer that++ // trains on or retains a prompt may answer it; anonymized (or community) marks work that is not++ // sensitive. The same rule a preset carries, so a caller can be strict without a saved preset.++ const askedDataPolicy = body.data_policy != null ? body.data_policy++ : (options && options.data_policy != null ? options.data_policy : null);++ if (askedDataPolicy != null) {++ const wanted = String(askedDataPolicy).trim().toLowerCase();++ if (!['private', 'anonymized', 'community'].includes(wanted)) throw new AiError(422, 'input.invalid', 'data_policy must be private, anonymized or community');++ presetPolicy = { ...(presetPolicy || {}), data_policy: wanted };++ presetName = presetName || `caller data policy: ${wanted}`;++ }if (presetPolicy && typeof presetPolicy.community === 'boolean') communityOptIn = presetPolicy.community;if (projectProvider) {if (!app) throw new AiError(403, 'capability.denied', 'only a developer app names its project\'s own provider');@@@ -445,7 +456,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi...(communityOptIn ? { communityOptIn: true } : {}),...(pinned ? { routePin: pinned } : {}),// The preset's policy, and the name it was named by: the router filters the offers with the policy.-- ...(preset ? { presetPolicy, presetName } : {}),++ ...(preset || presetPolicy ? { presetPolicy, presetName } : {}),// BA1: the class this run may wait in. The router reads it to pick the tier.latencyClass,// BA1: a caller that waits (?wait=) gets its answer inside the wait: a Flex attempt may use the wait@@@ -36,6 +36,7 @@ const DOMAIN = {baseten: 'baseten.co', hyperbolic: 'hyperbolic.xyz', parasail: 'parasail.io', 'particle-ai': 'particle.ai','inference-net': 'inference.net', 'atlas-cloud': 'atlascloud.ai', avian: 'avian.io',nanogpt: 'nano-gpt.com', infermatic: 'totalgpt.ai', arliai: 'arliai.com', friendli: 'friendli.ai', scaleway: 'scaleway.com',++ iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai',};const onItsOwnSite = (url, key) => {const host = new URL(url).hostname;@@@ -49,7 +50,7 @@ t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an aassert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));-- assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 50);++ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 54);assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');nnew file mode 100644@@@ -0,0 +1,190 @@++'use strict';++/**++ * The 2026-11-10 provider wave, parts 1 and 2.++ *++ * Part 1: the new provider records (Particle AI and its Flex tier, the OpenRouter Particle pin,++ * IteraCompute, Sable, Inception, DOS.AI) are seeded off and keyless, with their traits — classes of++ * work, concurrency, free allowance and its end, payment, training and retention — each with the page++ * and the day it was read.++ *++ * Part 2: routing honours them. A background/batch-only provider never answers an interactive request;++ * a provider that trains on or retains a prompt never receives a person's private data; a concurrency++ * cap skips to the next offer (never errors); a free allowance leads while it lasts and stops at its++ * date; an uncensored offer only ever answers behind the 18+ policy.++ */++const assert = require('assert');++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');++const { PROVIDERS } = require('../server/providers/pool-providers');++const seed = require('../server/providers/pool-seed');++const traits = require('../server/providers/traits');++const { buildBody, buildCompleteBody } = require('../server/providers/openai');++++const t = suite('provider-wave2');++++// ── Part 1: the records ────────────────────────────────────────────────────────++++t.test('each new provider is a record, off, keyless, with its traits, price and sources', () => {++ const by = (key) => PROVIDERS.find((p) => p.key === key);++ const itera = by('iteracompute');++ assert.ok(itera, 'IteraCompute is catalogued');++ assert.strictEqual(new URL(itera.base_url).hostname, 'api.iteracompute.com');++ const irow = seed.recordOf(itera);++ assert.strictEqual(irow.status, 'disabled', 'starts off');++ assert.strictEqual(irow.secret_ref, 'vault:iteracompute', 'a key slot, no key');++ assert.deepStrictEqual(irow.metadata.traits.latency_classes, ['background', 'batch'], 'never interactive');++ assert.strictEqual(irow.metadata.traits.concurrency, 1, 'one request per account');++ assert.strictEqual(irow.metadata.traits.free_allowance.tokens, 30000000);++ assert.strictEqual(irow.metadata.traits.free_allowance.ends_at, '2026-11-24T08:00:00Z', 'promotion end');++ assert.strictEqual(irow.metadata.traits.free_only, true);++ assert.match(itera.privacy.wording, /zero-data-retention guarantee/i, 'the retention statement is quoted');++++ const sable = by('sable');++ assert.strictEqual(sable.base_url, 'https://sableapi.com/v1');++ assert.strictEqual(seed.recordOf(sable).metadata.uncensored, true);++ assert.strictEqual(seed.recordOf(sable).metadata.traits.payment, 'crypto-prepaid');++ assert.strictEqual(seed.recordOf(sable).metadata.data_policy.trains, false);++ assert.strictEqual(seed.recordOf(sable).metadata.data_policy.retention, 'limited', 'prompt cache hours to days');++ const sableModel = seed.modelOf(sable, sable.models[0]);++ assert.deepStrictEqual([sableModel.cost.in_per_mtok, sableModel.cost.out_per_mtok, sableModel.cost.cached_per_mtok], [0.15, 0.6, 0.01]);++ assert.strictEqual(sableModel.metadata.uncensored, true);++ assert.strictEqual(sableModel.supports.tools && sableModel.supports.vision, true, 'tools and vision');++++ const inception = by('inception');++ assert.strictEqual(seed.recordOf(inception).metadata.data_policy.trains, true, 'trains until the owner records the setting off');++ assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.tokens, 100000000);++ assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.period, 'once');++ const mercury = seed.modelOf(inception, inception.models[0]);++ assert.deepStrictEqual([mercury.cost.in_per_mtok, mercury.cost.out_per_mtok, mercury.cost.cached_per_mtok], [0.2, 0.7, 0.02], 'the list price is recorded');++ assert.match(mercury.metadata.note, /\$0\.10|\$0\.35/, 'the launch price is noted, not assumed');++++ assert.strictEqual(seed.recordOf(by('particle-ai')).metadata.tier_rates.flex, 0.5, 'Particle Flex is half price');++ assert.deepStrictEqual(seed.recordOf(by('particle-ai')).metadata.traits.tier_latency.flex, ['background', 'batch']);++ assert.strictEqual(seed.recordOf(by('dosai')).metadata.traits.latency_classes[0], 'batch', 'DOS.AI is batch only');++++ const openrouter = by('openrouter');++ const pinned = seed.modelOf(openrouter, openrouter.models[0]);++ assert.deepStrictEqual([pinned.cost.in_per_mtok, pinned.cost.out_per_mtok, pinned.cost.cached_per_mtok], [0.025, 0.25, 0.003], 'the Particle Flex price read from OpenRouter');++ assert.deepStrictEqual(pinned.metadata.routing, { only: ['particle-ai'], allow_fallbacks: false, service_tier: 'flex', data_collection: 'deny' });++ assert.strictEqual(pinned.metadata.service_tier, 'flex');++ assert.strictEqual(traits.latencyAllows({ metadata: {} }, pinned, 'interactive'), false, 'a Flex offer never answers in front of a person');++ assert.strictEqual(traits.latencyAllows({ metadata: {} }, pinned, 'background'), true);++});++++t.test('the traits read an allowance, a period and training honestly', () => {++ const record = { metadata: { traits: { latency_classes: ['background', 'batch'], concurrency: 1, free_allowance: { tokens: 100, period: 'month', ends_at: '2026-11-24T08:00:00Z' } } } };++ assert.strictEqual(traits.latencyAllows(record, null, 'interactive'), false);++ assert.strictEqual(traits.latencyAllows(record, null, 'background'), true);++ assert.strictEqual(traits.concurrencyOf(record), 1);++ const before = traits.allowanceLeft(record, { used: 40, now: Date.parse('2026-11-20T00:00:00Z') });++ assert.deepStrictEqual([before.tokens, before.used, before.left, before.ended], [100, 40, 60, false]);++ const after = traits.allowanceLeft(record, { used: 40, now: Date.parse('2026-11-25T00:00:00Z') });++ assert.deepStrictEqual([after.left, after.ended], [0, true], 'zero after the end date');++ assert.strictEqual(traits.allowancePeriodStart(record, Date.parse('2026-11-20T10:00:00Z')), '2026-11-01', 'the month it counts');++ assert.strictEqual(traits.trainsOrRetains({ metadata: { data_policy: { trains: true, retention: 'none' } } }), true);++ assert.strictEqual(traits.trainsOrRetains({ metadata: { data_policy: { trains: false, retention: 'limited' } } }), true, 'retaining counts');++ assert.strictEqual(traits.trainsOrRetains({ metadata: { data_policy: { trains: false, retention: 'none' } } }), false);++ assert.strictEqual(traits.trainsOrRetains({ metadata: {} }), true, 'saying nothing is not a promise');++ assert.strictEqual(traits.isPrivateRequest({ presetPolicy: { data_policy: 'private' } }), true);++ assert.strictEqual(traits.isPrivateRequest({}), false);++});++++t.test('an OpenRouter offer can pin a provider and carry the tier, and drops fields it does not document', () => {++ const body = buildBody({ model: 'deepseek/deepseek-v4.1-flash-1017', system: [], messages: [{ role: 'user', content: 'hi' }], maxTokens: 5,++ routing: { only: ['particle-ai'], allow_fallbacks: false, data_collection: 'deny', quantizations: ['fp8'], service_tier: 'flex', bogus: true } });++ assert.deepStrictEqual(body.provider, { only: ['particle-ai'], allow_fallbacks: false, data_collection: 'deny', quantizations: ['fp8'] });++ assert.strictEqual(body.service_tier, 'flex');++ assert.strictEqual(body.provider.bogus, undefined, 'nothing undocumented is forwarded');++ const complete = buildCompleteBody({ model: 'm', openai: { messages: [{ role: 'user', content: 'hi' }] }, maxTokens: 5, routing: { sort: 'price', max_price: { completion: 1 }, data_collection: 'deny' } });++ assert.deepStrictEqual(complete.provider, { sort: 'price', max_price: { completion: 1 }, data_collection: 'deny' });++});++++// ── Part 2: routing honours them ──────────────────────────────────────────────++++let h;++let seam;++let slowSeam;++let release;++const provider = (body) => h.registry.upsertProvider({ kind: 'http', auth_mode: 'none', status: 'active', capabilities: ['chat', 'generate'], priority: 50, ...body });++const model = (provider_key, model_key, cost, metadata = {}) => h.registry.upsertModel({ provider_key, model_key, status: 'active', cost, metadata, supports: { streaming: true } });++++t.test('boot with a seam provider and a fallback', async () => {++ seam = await seamServer(() => ({ text: 'answered', model: 'seam-model', usage: { input: 4, output: 2 } }));++ slowSeam = await seamServer(() => new Promise((resolve) => { release = () => resolve({ text: 'slow answer', model: 'seam-model', usage: { input: 4, output: 2 } }); }));++ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });++ await provider({ key: 'fallback-open', base_url: seam.url, timeout_ms: 5000, metadata: {++ data_policy: { trains: false, training: false, retention: 'none' }, privacy: { label: 'private', wording: 'keeps nothing, trains on nothing', checked: '2026-11-10' },++ } });++ await model('fallback-open', 'm', { in_per_mtok: 1, out_per_mtok: 1 });++});++++t.test('a background-only provider never answers an interactive request, and answers a background one', async () => {++ await provider({ key: 'slowpool', base_url: seam.url, timeout_ms: 5000, metadata: { traits: { latency_classes: ['background', 'batch'], source: 'https://example.test/terms', checked: '2026-11-10' } } });++ await model('slowpool', 'm', { in_per_mtok: 0, out_per_mtok: 0 });++ const route = { key: 'wave-latency', primary: { provider: 'slowpool', model: 'm' }, fallbacks: [{ provider: 'fallback-open', model: 'm' }] };++ const interactive = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hi' }], maxTokens: 5 });++ assert.strictEqual(interactive.provider, 'fallback-open', 'the interactive request skips the background-only offer');++ assert.ok(interactive.explain.candidates.some((c) => c.provider === 'slowpool' && /latency_class/.test(c.excluded_reason || '')), 'the reason is named');++ const background = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hi' }], maxTokens: 5 }, { latencyClass: 'background' });++ assert.strictEqual(background.provider, 'slowpool', 'a run that may wait uses it');++});++++t.test('a provider that trains on prompts never receives a person\'s private data', async () => {++ await provider({ key: 'trainer', base_url: seam.url, timeout_ms: 5000, metadata: { data_policy: { trains: true, retention: 'standard' }, traits: { source: 'https://example.test/privacy', checked: '2026-11-10' } } });++ await model('trainer', 'm', { in_per_mtok: 0, out_per_mtok: 0 });++ const route = { key: 'wave-private', primary: { provider: 'trainer', model: 'm' }, fallbacks: [{ provider: 'fallback-open', model: 'm' }] };++ const privateRun = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'my private note' }], maxTokens: 5 },++ { presetPolicy: { data_policy: 'private' }, presetName: 'private' });++ assert.strictEqual(privateRun.provider, 'fallback-open', 'the private request never reaches the trainer');++ assert.ok(privateRun.explain.candidates.some((c) => c.provider === 'trainer' && /private|train/i.test(c.excluded_reason || '')), 'the reason is named');++ const ordinary = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'a public question' }], maxTokens: 5 });++ assert.strictEqual(ordinary.provider, 'trainer', 'work not marked private may use it');++});++++t.test('a concurrency cap skips to the next offer, never errors', async () => {++ await provider({ key: 'one-at-a-time', base_url: slowSeam.url, timeout_ms: 10000, metadata: { traits: { concurrency: 1, source: 'https://example.test/limits', checked: '2026-11-10' } } });++ await model('one-at-a-time', 'm', { in_per_mtok: 0, out_per_mtok: 0 });++ const route = { key: 'wave-cap', primary: { provider: 'one-at-a-time', model: 'm' }, fallbacks: [{ provider: 'fallback-open', model: 'm' }] };++ const first = h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'first' }], maxTokens: 5 });++ await new Promise((r) => setTimeout(r, 120));++ const second = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'second' }], maxTokens: 5 });++ assert.strictEqual(second.provider, 'fallback-open', 'the second request skips the busy provider');++ assert.ok(second.explain.attempts.some((a) => a.provider === 'one-at-a-time' && /concurrency_cap/.test(a.error || '')), 'the cap is named');++ release();++ const done = await first;++ assert.strictEqual(done.provider, 'one-at-a-time', 'the first call still finishes on the provider');++});++++t.test('a free allowance leads while it lasts, and stops at its end date', async () => {++ await provider({ key: 'free-pool', base_url: seam.url, timeout_ms: 5000, metadata: { traits: { free_only: true, free_allowance: { tokens: 1000, period: 'month', ends_at: '2026-11-24T08:00:00Z' }, source: 'https://example.test/free', checked: '2026-11-10' } } });++ await model('free-pool', 'm', { in_per_mtok: 9, out_per_mtok: 9 });++ const route = { key: 'wave-free', capability: 'chat', primary: { provider: 'fallback-open', model: 'm' }, fallbacks: [] };++ const run = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'use the free tokens first' }], maxTokens: 5 });++ assert.strictEqual(run.provider, 'free-pool', 'the free allowance is spent before the cheaper-looking paid price');++ // Spend the allowance: the same offer is skipped.++ const day = new Date().toISOString().slice(0, 10);++ await h.db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, cost_usd) VALUES (?, 'service:test', '', 'test', 'free-pool', 'm', 1, 900, 200, 0)`).run(day);++ const spent = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'again' }], maxTokens: 5 });++ assert.strictEqual(spent.provider, 'fallback-open', 'a spent free-only allowance is not called');++});++++t.test('an uncensored offer (Sable) answers only the 18+ policy, never a standard request', async () => {++ const sable = PROVIDERS.find((p) => p.key === 'sable');++ await provider({ key: 'sable', base_url: seam.url, timeout_ms: 5000, metadata: seed.recordOf(sable).metadata });++ await model('sable', sable.models[0].model_key, { in_per_mtok: 0.15, out_per_mtok: 0.6, cached_per_mtok: 0.01 }, { uncensored: true, data_policy: sable.data_policy, privacy: sable.privacy });++ const route = { key: 'wave-uncensored', capability: 'chat', primary: { provider: 'fallback-open', model: 'm' }, fallbacks: [] };++ const standard = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], maxTokens: 5 });++ assert.strictEqual(standard.provider, 'fallback-open', 'a standard request never runs the uncensored offer');++ // The 18+ gate has no adult status here: an uncensored request is refused before any call.++ await assert.rejects(++ () => h.pool.execute({ ...route, key: 'wave-adult' }, 'chat', { messages: [{ role: 'user', content: 'hello' }], maxTokens: 5 }, { contentPolicy: 'uncensored' }),++ (err) => [400, 403, 503].includes(err.status),++ 'an uncensored request without the 18+ floor is refused',++ );++});++++t.test('a caller may name its data policy, and a bad one is refused', async () => {++ const bad = await request(h.base, 'POST', '/api/v1/generate', { tok: token('svc', ALL), body: { prompt: 'hi', options: { data_policy: 'nonsense' } } });++ assert.strictEqual(bad.status, 422, bad.text);++});++++t.run(() => h && h.stop());
@@ -43,6 +43,7 @@ const { createWhisperProvider } = require('./whisper');
@@ -43,6 +43,7 @@ const { createWhisperProvider } = require('./whisper');
const { createSpeechProvider } = require('./speech');
const { createSpeechProvider } = require('./speech');
const rates = require('./rate-cards');
const rates = require('./rate-cards');
const tiers = require('./tiers');
const tiers = require('./tiers');
+const traits = require('./traits');
const latency = require('../latency');
const latency = require('../latency');
const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');
const { detectTask, capabilitiesOf, hardNeeds, rank, reasonFor, workKind, leadingText, preferredTarget, preferMatches } = require('./task');
const modelCatalog = require('./model-catalog');
const modelCatalog = require('./model-catalog');
@@ -238,6 +239,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -238,6 +239,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// One local generation at a time (AI_LOCAL_LLM_CONCURRENCY, default 1): the host's model shares its CPU
// One local generation at a time (AI_LOCAL_LLM_CONCURRENCY, default 1): the host's model shares its CPU
// with live streams, so a paid outage must not turn into a local-model stampede.
// with live streams, so a paid outage must not turn into a local-model stampede.
const localLimit = Math.max(1, (config.localLlm && config.localLlm.concurrency) || 1);
const localLimit = Math.max(1, (config.localLlm && config.localLlm.concurrency) || 1);
+ // Per-provider in-flight calls, for the concurrency cap a provider states (IteraCompute: 1 per account).
+ const activeCalls = new Map();
let localActive = 0;
let localActive = 0;
const exhaustedCooldownMs = () => Math.max(60000, Number(config.breaker.exhaustedCooldownMs) || 10 * 60000);
const exhaustedCooldownMs = () => Math.max(60000, Number(config.breaker.exhaustedCooldownMs) || 10 * 60000);
// L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).
// L36 Y7: the provider that last answered this prompt, for five minutes (server/providers/warm-cache.js).
@@ -701,6 +704,28 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -701,6 +704,28 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return local.length ? [...local, ...rest] : list;
return local.length ? [...local, ...rest] : list;
}
}
+ /**
+ * Spend a free allowance first (2026-11-10): while a provider's stated monthly/one-time tokens are not
+ * used up and the promotion has not ended, its offers lead the order for eligible work. It is never
+ * moved ahead for a person's private data when the provider trains on or retains a prompt.
+ */
+ async function orderFreeAllowance(list, ctx = {}) {
+ const privateRequest = traits.isPrivateRequest(ctx);
+ const first = [];
+ const rest = [];
+ for (const c of list) {
+ const p = await registry.getProvider(c.provider);
+ if (!p) { rest.push(c); continue; }
+ if (privateRequest && traits.trainsOrRetains(p)) { rest.push(c); continue; }
+ const row = c.model ? await registry.getModel(c.provider, c.model) : null;
+ const allowed = traits.latencyAllows(p, row, ctx.latencyClass || 'interactive');
+ const a = allowed ? traits.allowanceLeft(p, { used: await allowanceUsed(p.key, p), now: clock.now() }) : null;
+ if (a && !a.ended && a.left > 0 && traits.traitsOf(p) && traits.traitsOf(p).free_only === true) first.push(c);
+ else rest.push(c);
+ }
+ return first.length ? [...first, ...rest] : list;
+ }
+
/**
/**
* The tier one candidate may run on, for a run that may wait:
* The tier one candidate may run on, for a run that may wait:
*
*
@@ -865,6 +890,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -865,6 +890,20 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
* health (placement_state error rate + the breaker); hysteresis through the route's current placement.
* health (placement_state error rate + the breaker); hysteresis through the route's current placement.
* The stub is never a placement candidate (synthetic output must not win on price) — it is appended last.
* The stub is never a placement candidate (synthetic output must not win on price) — it is appended last.
*/
*/
+ const getAllowanceUsage = db.prepare(`SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ? AND day >= ?`);
+ /** Tokens this provider's free allowance has used this period (0 for a provider with no allowance). */
+ async function allowanceUsed(providerKey, record) {
+ try {
+ const since = traits.allowancePeriodStart(record, clock.now());
+ const row = since ? await getAllowanceUsage.get(providerKey, since)
+ : await db.prepare('SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ?').get(providerKey);
+ return Number((row && row.t) || 0);
+ } catch { return 0; }
+ }
+ /** The request's latency class, as the router will use it. */
+ const latencyClassOf = (route, ctx) => (ctx.latencyClass && ['interactive', 'background', 'batch'].includes(ctx.latencyClass))
+ ? ctx.latencyClass : ((route && route.constraints && route.constraints.latency_class) || 'interactive');
+
async function poolCandidates(route, features, operation, ctx = {}) {
async function poolCandidates(route, features, operation, ctx = {}) {
const stats = await statsByProvider();
const stats = await statsByProvider();
const offers = [];
const offers = [];
@@ -872,6 +911,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -872,6 +911,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const cardsByProvider = {};
const cardsByProvider = {};
const seen = new Set();
const seen = new Set();
const accessExcluded = [];
const accessExcluded = [];
+ const traitExcluded = [];
+ const latencyClass = latencyClassOf(route, ctx);
// A dynamic policy alias (D3) looks at every active chat/vision model the provider registered, not only its
// A dynamic policy alias (D3) looks at every active chat/vision model the provider registered, not only its
// default; the placement and the task router then choose among them. Auto keeps today's one-offer-per-provider.
// default; the placement and the task router then choose among them. Auto keeps today's one-offer-per-provider.
const expandModels = Boolean(ctx.aliasPolicy && ctx.aliasPolicy !== 'auto') && !(route.constraints && route.constraints.model);
const expandModels = Boolean(ctx.aliasPolicy && ctx.aliasPolicy !== 'auto') && !(route.constraints && route.constraints.model);
@@ -887,12 +928,29 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -887,12 +928,29 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// The content policy narrows the pool before placement: an uncensored offer never answers a standard
// The content policy narrows the pool before placement: an uncensored offer never answers a standard
// request, and the adult tier has no other offer to run on.
// request, and the adult tier has no other offer to run on.
if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
if (!(await policyAllows(registry, p, model, ctx.contentPolicy || 'standard'))) return;
+ // The offer's own traits (2026-11-10): a background/batch-only provider never answers an
+ // interactive request; a provider that trains on or retains a prompt never receives a person's
+ // private data; a free-only allowance that ended or is spent is not called again.
+ const row = model ? await registry.getModel(p.key, model) : null;
+ // A trait keeps the offer out of the call, not out of placement: it stays in the pool with its
+ // reason (status down, like a credential-less offer), so explain has one entry per placement offer.
+ let traitReason = null;
+ if (!traits.latencyAllows(p, row, latencyClass)) {
+ traitReason = `latency_class ${latencyClass} not offered (${(traits.latencyClassesOf(p, row) || []).join('/') || 'interactive only'})`;
+ } else if (traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {
+ traitReason = 'private data: the provider trains on or retains prompts';
+ } else {
+ const allowance = traits.freeAllowanceOf(p) ? traits.allowanceLeft(p, { used: await allowanceUsed(p.key, p), now: clock.now() }) : null;
+ if (allowance && allowance.ended) traitReason = `free allowance ended ${allowance.ends_at}`;
+ else if (allowance && traits.traitsOf(p) && traits.traitsOf(p).free_only === true && allowance.left <= 0) traitReason = 'free allowance spent';
+ }
+ if (traitReason) traitExcluded.push({ provider: p.key, model, eligible: false, excluded_reason: traitReason, cost: null, latency: null });
seen.add(id);
seen.add(id);
const creds = p.auth_mode === 'none' || credentialNotRequired(p) || Boolean(providerSecret(p, env));
const creds = p.auth_mode === 'none' || credentialNotRequired(p) || Boolean(providerSecret(p, env));
const h = await health(p.key);
const h = await health(p.key);
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const st = stats.get(p.key) || { p95: null, requests: 0, errors: 0 };
const errorRate = st.requests ? st.errors / st.requests : 0;
const errorRate = st.requests ? st.errors / st.requests : 0;
- const status = p.status !== 'active' || !creds ? 'down' : h.state === 'open' ? 'down' : (h.state === 'half_open' || errorRate > 0.5) ? 'degraded' : 'up';
+ const status = traitReason || p.status !== 'active' || !creds ? 'down' : h.state === 'open' ? 'down' : (h.state === 'half_open' || errorRate > 0.5) ? 'degraded' : 'up';
const free = isFreeProvider(p);
const free = isFreeProvider(p);
// Placement prices an offer on one card: input tokens, the part of a call known before it runs.
// Placement prices an offer on one card: input tokens, the part of a call known before it runs.
// The cached-input and output cards ride along (costOf bills all three; the state counts each metric).
// The cached-input and output cards ride along (costOf bills all three; the state counts each metric).
@@ -918,7 +976,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -918,7 +976,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (!accessOffered(p)) {
if (!accessOffered(p)) {
- accessExcluded.push({ provider: p.key, model: (route.constraints && route.constraints.model) || p.default_model || null, eligible: false,
+ // A commercial-gate exclusion is provider-wide: it names no model, so explain's entry has the
+ // same id shape as the placement candidate it stands for.
+ accessExcluded.push({ provider: p.key, model: null, eligible: false,
excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });
excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });
continue;
continue;
}
}
@@ -946,7 +1006,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -946,7 +1006,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'local' && !isLocalProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (scope === 'paid' && isFreeProvider(p)) continue;
if (!accessOffered(p)) {
if (!accessOffered(p)) {
- accessExcluded.push({ provider: p.key, model: pin.model || p.default_model || null, eligible: false,
+ accessExcluded.push({ provider: p.key, model: null, eligible: false,
excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });
excluded_reason: `provider_access ${modelCatalog.accessOf(p)}`, cost: null, latency: null });
continue;
continue;
}
}
@@ -1000,6 +1060,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1000,6 +1060,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
};
};
// An offer the commercial gate kept out is named with its reason, never silently missing from the explain.
// An offer the commercial gate kept out is named with its reason, never silently missing from the explain.
for (const x of accessExcluded) if (!explain.candidates.some((c) => c.provider === x.provider && c.model === x.model)) explain.candidates.push(x);
for (const x of accessExcluded) if (!explain.candidates.some((c) => c.provider === x.provider && c.model === x.model)) explain.candidates.push(x);
+ // An offer the provider's own traits kept out is named with the trait's reason, on the placement
+ // entry it already has; an offer placement never saw is added.
+ for (const x of traitExcluded) {
+ const hit = explain.candidates.find((c) => c.provider === x.provider && (c.model || null) === (x.model || null));
+ if (hit) hit.excluded_reason = x.excluded_reason; else explain.candidates.push(x);
+ }
const order = [];
const order = [];
if (result.selected) order.push(result.selected);
if (result.selected) order.push(result.selected);
const rest = result.candidates.filter(x => x.id !== result.selected).sort((a, b) => {
const rest = result.candidates.filter(x => x.id !== result.selected).sort((a, b) => {
@@ -1028,14 +1094,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1028,14 +1094,18 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const offered = appOffered(ctx, p);
const offered = appOffered(ctx, p);
const access = accessOffered(p);
const access = accessOffered(p);
const policyOk = p ? await policyAllows(registry, p, modelId, ctx.contentPolicy || 'standard') : false;
const policyOk = p ? await policyAllows(registry, p, modelId, ctx.contentPolicy || 'standard') : false;
+ const row = p && modelId ? await registry.getModel(p.key, modelId) : null;
+ const latencyOk = p ? traits.latencyAllows(p, row, (ctx.latencyClass && ['interactive', 'background', 'batch'].includes(ctx.latencyClass)) ? ctx.latencyClass : ((route.constraints && route.constraints.latency_class) || 'interactive')) : false;
+ const privateOk = p ? !(traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) : false;
const excluded = !policyOk ? 'content_policy'
const excluded = !policyOk ? 'content_policy'
: !offered ? (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only')
: !offered ? (p && billingProfile(p) === 'byok' ? 'byok' : 'free_only')
- : !access ? `provider_access ${modelCatalog.accessOf(p)}` : why;
+ : !access ? `provider_access ${modelCatalog.accessOf(p)}` : !latencyOk ? 'latency_class'
+ : !privateOk ? 'private_data_training' : why;
explain.candidates.push({
explain.candidates.push({
- provider: c.provider, model: modelId, eligible: offered && access && policyOk && !why,
+ provider: c.provider, model: modelId, eligible: offered && access && policyOk && latencyOk && privateOk && !why,
excluded_reason: excluded, cost: null, latency: null,
excluded_reason: excluded, cost: null, latency: null,
});
});
- if (offered && access && policyOk) order.push({ provider: c.provider, model: c.model || null });
+ if (offered && access && policyOk && latencyOk && privateOk) order.push({ provider: c.provider, model: c.model || null });
}
}
return { order, explain };
return { order, explain };
}
}
@@ -1707,6 +1777,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1707,6 +1777,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
explain.preset = { name: ctx.presetName || null, policy: ctx.presetPolicy };
explain.preset = { name: ctx.presetName || null, policy: ctx.presetPolicy };
}
}
+ // A free allowance is spent first for the work it can answer (2026-11-10).
+ if (!exact && !ordered) list = await orderFreeAllowance(list, { ...ctx, latencyClass: ctx.latencyClass || (route.constraints && route.constraints.latency_class) || 'interactive' });
// The owner's fixed/cheapest/fastest order survives the router's re-ranking: re-apply
// The owner's fixed/cheapest/fastest order survives the router's re-ranking: re-apply
// here, on the authoritative list, so an owner's uncensored first/second always holds.
// here, on the authoritative list, so an owner's uncensored first/second always holds.
// The floor (server/policy/floor.js) and the 18+ gate run before/after every attempt
// The floor (server/policy/floor.js) and the 18+ gate run before/after every attempt
@@ -1810,6 +1882,32 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1810,6 +1882,32 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const chosen = ranked.reasons.get(c.provider + ':' + (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 };
+ // The provider's own traits, checked before the call: a class it does not offer, a person's
+ // private data it would train on or retain, or a concurrency cap already reached. Each is a
+ // skip with its reason, never an error: the next offer answers.
+ const traitRow = p && model ? await registry.getModel(p.key, model) : null;
+ if (p && !traits.latencyAllows(p, traitRow, latencyClass)) {
+ statFor(c.provider).skipped++;
+ tried.push({ provider: c.provider, skipped: 'latency_class' });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the provider does not serve this latency class', status: 'skipped', error: 'latency_class' });
+ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'latency_class' });
+ continue;
+ }
+ if (p && traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {
+ statFor(c.provider).skipped++;
+ tried.push({ provider: c.provider, skipped: 'private_data_training' });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the provider trains on or retains prompts', status: 'skipped', error: 'private_data_training' });
+ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'private_data_training' });
+ continue;
+ }
+ const concurrencyCap = p ? traits.concurrencyOf(p) : null;
+ if (concurrencyCap != null && (activeCalls.get(c.provider) || 0) >= concurrencyCap) {
+ statFor(c.provider).skipped++;
+ tried.push({ provider: c.provider, skipped: 'concurrency_cap' });
+ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the provider is at its concurrency cap', status: 'skipped', error: `concurrency_cap (${concurrencyCap})` });
+ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'concurrency_cap' });
+ continue;
+ }
// Spend safety: the free allowance / anonymous Try it runs only from its named sources
// Spend safety: the free allowance / anonymous Try it runs only from its named sources
// (server/free-tier.js). A provider outside them is skipped even when it is cheaper: who pays
// (server/free-tier.js). A provider outside them is skipped even when it is cheaper: who pays
// matters, not only the price. The person's own keys (above) are always tried first and are never
// matters, not only the price. The person's own keys (above) are always tried first and are never
@@ -1884,8 +1982,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1884,8 +1982,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// L36 Y7: the fallback chain stops after three providers.
// L36 Y7: the fallback chain stops after three providers.
if (attemptsMade >= maxAttempts) break;
if (attemptsMade >= maxAttempts) break;
attemptsMade += 1;
attemptsMade += 1;
- // Claim the local slot synchronously, before any await, so two requests cannot both pass the check.
+ // Claim the local slot and the provider's concurrency slot synchronously, before any await, so two
+ // requests cannot both pass the check.
if (local) localActive++;
if (local) localActive++;
+ if (concurrencyCap != null) activeCalls.set(c.provider, (activeCalls.get(c.provider) || 0) + 1);
let lastErr = null;
let lastErr = null;
try {
try {
// 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
@@ -1905,7 +2005,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1905,7 +2005,13 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// not serve it) sends the same run once more on the standard tier: the refusal is counted and
// not serve it) sends the same run once more on the standard tier: the refusal is counted and
// logged, and it produced no tokens, so nothing is billed for it. The batch class has no tier
// logged, and it produced no tokens, so nothing is billed for it. The batch class has no tier
// yet: its own API is BA2, so a batch run is priced at the standard rate until then.
// yet: its own API is BA2, so a batch run is priced at the standard rate until then.
- const wantedTier = latencyClass === 'background' ? await tierFor({ p, model, operation, at: clock.now() }) : 'standard';
+ // An offer that names its own service tier (the OpenRouter Particle Flex offer) runs on that tier;
+ // every other offer asks the provider's own tier rule.
+ const tierRow = p && model ? await registry.getModel(p.key, model) : null;
+ const offeredTier = tierRow && tierRow.metadata && tierRow.metadata.service_tier;
+ const wantedTier = latencyClass === 'background'
+ ? (offeredTier === 'flex' ? 'flex' : await tierFor({ p, model, operation, at: clock.now() }))
+ : 'standard';
let askedTier = wantedTier;
let askedTier = wantedTier;
let attempt = 0;
let attempt = 0;
while (attempt <= retries) {
while (attempt <= retries) {
@@ -1960,6 +2066,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1960,6 +2066,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
// The service tier travels with the request (BA1): a Flex tier is slower and cheaper.
// The service tier travels with the request (BA1): a Flex tier is slower and cheaper.
if (askedTier === 'flex') callReq = { ...callReq, serviceTier: 'flex' };
if (askedTier === 'flex') callReq = { ...callReq, serviceTier: 'flex' };
+ // OpenRouter's provider routing, from the offer's own settings (only/order/sort, max_price,
+ // data_collection, quantizations): the offer can pin one endpoint exactly.
+ if (attemptRow && attemptRow.metadata && attemptRow.metadata.routing) callReq = { ...callReq, routing: attemptRow.metadata.routing };
try {
try {
const fn = a[operation];
const fn = a[operation];
if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });
if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });
@@ -2065,6 +2174,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -2065,6 +2174,7 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
}
}
} finally {
} finally {
if (local) localActive--;
if (local) localActive--;
+ if (concurrencyCap != null) activeCalls.set(c.provider, Math.max(0, (activeCalls.get(c.provider) || 1) - 1));
}
}
if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;
if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;
// classifyFailure decides what a failure means: balance/auth open at once, a run of transient
// classifyFailure decides what a failure means: balance/auth open at once, a run of transient
@@ -28,8 +28,21 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enric
@@ -28,8 +28,21 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enric
function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }
function isReasoningModel(m) { return /^(gpt-[56]|o\d)/i.test(m || ''); }
+/**
+ * The provider routing object of an OpenRouter offer (2026-11-10): only the fields OpenRouter documents,
+ * copied from the model offer's own settings. It lets an offer pin one endpoint exactly (only/order, no
+ * fallback) or rank by price under a cap, with data collection denied and a quantization filter.
+ */
+const ROUTING_KEYS = ['order', 'only', 'ignore', 'allow_fallbacks', 'sort', 'max_price', 'data_collection', 'quantizations', 'zdr', 'require_parameters', 'preferred_min_throughput', 'preferred_max_latency', 'enforce_distillable_text'];
+function providerRouting(routing) {
+ if (!routing || typeof routing !== 'object' || Array.isArray(routing)) return null;
+ const out = {};
+ for (const key of ROUTING_KEYS) if (routing[key] !== undefined && routing[key] !== null) out[key] = routing[key];
+ return Object.keys(out).length ? out : null;
+}
+
function buildBody(req, jsonMode) {
function buildBody(req, jsonMode) {
- const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier } = req;
+ const { model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl, serviceTier, routing } = req;
const msgs = [];
const msgs = [];
// Stepped down from json_schema: the schema travels in the instructions instead.
// Stepped down from json_schema: the schema travels in the instructions instead.
const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';
const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';
@@ -56,6 +69,9 @@ function buildBody(req, jsonMode) {
@@ -56,6 +69,9 @@ function buildBody(req, jsonMode) {
// The service tier (BA1): a Flex tier is slower and cheaper, and the provider may refuse it. The tier
// The service tier (BA1): a Flex tier is slower and cheaper, and the provider may refuse it. The tier
// travels in the request; the answer says which tier it really ran on (the pool prices that one).
// travels in the request; the answer says which tier it really ran on (the pool prices that one).
if (serviceTier) body.service_tier = String(serviceTier);
if (serviceTier) body.service_tier = String(serviceTier);
+ const provider = providerRouting(routing);
+ if (provider) body.provider = provider;
+ if (routing && routing.service_tier && !body.service_tier) body.service_tier = String(routing.service_tier);
return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });
return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });
}
}
@@ -64,7 +80,7 @@ function buildBody(req, jsonMode) {
@@ -64,7 +80,7 @@ function buildBody(req, jsonMode) {
* the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.
* the caller's own request with the routed model, a reasoning model's allowance, and the Flex tier.
*/
*/
function buildCompleteBody(req = {}) {
function buildCompleteBody(req = {}) {
- const { model, openai = {}, maxTokens, temperature, serviceTier } = req;
+ const { model, openai = {}, maxTokens, temperature, serviceTier, routing } = req;
const o = openai || {};
const o = openai || {};
const body = { model, messages: o.messages };
const body = { model, messages: o.messages };
for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
for (const k of ['tools', 'tool_choice', 'parallel_tool_calls', 'response_format', 'stop', 'seed', 'top_p', 'presence_penalty', 'frequency_penalty']) {
@@ -80,6 +96,9 @@ function buildCompleteBody(req = {}) {
@@ -80,6 +96,9 @@ function buildCompleteBody(req = {}) {
if (temperature != null) body.temperature = temperature;
if (temperature != null) body.temperature = temperature;
}
}
if (serviceTier) body.service_tier = String(serviceTier);
if (serviceTier) body.service_tier = String(serviceTier);
+ const provider = providerRouting(routing);
+ if (provider) body.provider = provider;
+ if (routing && routing.service_tier && !body.service_tier) body.service_tier = String(routing.service_tier);
return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });
return effortPolicy.apply(body, req, { kind: 'openai', key: 'openai' });
}
}
@@ -91,6 +91,24 @@ const privacy = (o) => ({
@@ -91,6 +91,24 @@ const privacy = (o) => ({
checked: o.checked || CHECKED,
checked: o.checked || CHECKED,
});
});
+/**
+ * The traits beyond price (2026-11-10 provider wave): the classes of work, the concurrency cap, a free
+ * allowance with its end date, how it is paid, and the OpenRouter routing an offer pins. An absent key
+ * means the provider states nothing, never a guessed value. Every record names where and when it was read.
+ */
+const traits = (o) => ({
+ ...(Array.isArray(o.latency_classes) ? { latency_classes: o.latency_classes.filter((c) => ['interactive', 'background', 'batch'].includes(c)) } : {}),
+ ...(o.tier_latency && typeof o.tier_latency === 'object' ? { tier_latency: o.tier_latency } : {}),
+ ...(Number.isInteger(o.concurrency) && o.concurrency > 0 ? { concurrency: o.concurrency } : {}),
+ ...(o.free_allowance ? { free_allowance: o.free_allowance } : {}),
+ ...(o.payment ? { payment: o.payment } : {}),
+ ...(o.free_only === true ? { free_only: true } : {}),
+ ...(o.batch_window_hours ? { batch_window_hours: o.batch_window_hours } : {}),
+ ...(o.trains_note ? { trains_note: o.trains_note } : {}),
+ source: o.source || null,
+ checked: o.checked || NEW_CHECKED,
+});
+
const PROVIDERS = [
const PROVIDERS = [
{
{
key: 'qwen',
key: 'qwen',
@@ -639,8 +657,21 @@ const PROVIDERS = [
@@ -639,8 +657,21 @@ const PROVIDERS = [
resale: 'UNVERIFIED (2026-11-03 matrix does not list OpenRouter). The price book (2026-10-30) records that its terms contemplate selling capacity inside your own product; that is not the same as an executed agreement.',
resale: 'UNVERIFIED (2026-11-03 matrix does not list OpenRouter). The price book (2026-10-30) records that its terms contemplate selling capacity inside your own product; that is not the same as an executed agreement.',
}),
}),
// OpenRouter lists hundreds of models and prices each one itself; the catalogue sync reads its own list with
// OpenRouter lists hundreds of models and prices each one itself; the catalogue sync reads its own list with
- // the key. No model is seeded here: a price we did not read from OpenRouter is not one of ours to publish.
- models: [],
+ // the key. One offer is pinned here because the owner asked for it exactly: DeepSeek V4.1 Flash 1017 on
+ // Particle AI's Flex endpoint, at the price OpenRouter's own endpoint list gives it, with the routing
+ // object that keeps the call on Particle (no fallback) and the data policy at deny.
+ models: [{
+ model_key: 'deepseek/deepseek-v4.1-flash-1017',
+ display_name: 'DeepSeek V4.1 Flash 1017 (Particle Flex)', family: 'deepseek',
+ context_window: 1048576, max_output: 131072,
+ cost: { in_per_mtok: 0.025, cached_per_mtok: 0.003, out_per_mtok: 0.25 },
+ source: 'https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints', checked: '2026-11-10',
+ supports: { json: true, tools: true, streaming: true, vision: true },
+ quantization: null, refusal_rate: null,
+ service_tier: 'flex',
+ routing: { only: ['particle-ai'], allow_fallbacks: false, service_tier: 'flex', data_collection: 'deny' },
+ note: 'the Particle AI endpoint at service tier flex ($0.025 in / $0.25 out / $0.003 cached, read from OpenRouter on 2026-11-10); pinning it with no fallback keeps the price honest',
+ }],
},
},
...[
...[
['venice', 'Venice AI', 'https://api.venice.ai/api/v1', 'venice-uncensored-1-2', 'https://docs.venice.ai/overview/pricing', 'PRIVATE', 'UNVERIFIED', { in: 0.20, cached: null, out: 0.90 }, 131072, true],
['venice', 'Venice AI', 'https://api.venice.ai/api/v1', 'venice-uncensored-1-2', 'https://docs.venice.ai/overview/pricing', 'PRIVATE', 'UNVERIFIED', { in: 0.20, cached: null, out: 0.90 }, 131072, true],
@@ -721,8 +752,32 @@ const PROVIDERS = [
@@ -721,8 +752,32 @@ const PROVIDERS = [
// Each starts disabled with no key; the owner puts its key in the owner settings and syncs its model list.
// Each starts disabled with no key; the owner puts its key in the owner settings and syncs its model list.
{ key: 'parasail', display_name: 'Parasail', base_url: 'https://api.parasail.io/v1',
{ key: 'parasail', display_name: 'Parasail', base_url: 'https://api.parasail.io/v1',
docs_url: 'https://docs.parasail.io/parasail-docs/products/overview/models', capabilities: ['chat', 'embed'] },
docs_url: 'https://docs.parasail.io/parasail-docs/products/overview/models', capabilities: ['chat', 'embed'] },
- { key: 'particle-ai', display_name: 'Particle AI', base_url: 'https://api.particle.ai/v1',
- docs_url: 'https://docs.particle.ai/', capabilities: ['chat'] },
+ {
+ key: 'particle-ai', keys_url: 'https://platform.particle.ai', display_name: 'Particle AI',
+ base_url: 'https://api.particle.ai/v1', default_model: 'particle-1', timeout_ms: 900000,
+ docs_url: 'https://docs.particle.ai/', capabilities: ['chat', 'tools', 'vision'],
+ provider_access: 'UNVERIFIED', priority: 35,
+ // Particle's own Flex tier is 50% off every token price and may wait up to 15 minutes for capacity
+ // (https://docs.particle.ai/service-tiers, read 2026-11-10). It is a background/batch tier: never an
+ // answer in front of a person. Direct prices differ from OpenRouter's listing, so no model price is
+ // seeded here: the owner syncs the direct catalogue and its own reading fills the price.
+ tier_rates: { flex: 0.5 },
+ traits: traits({
+ latency_classes: ['interactive', 'background', 'batch'], tier_latency: { flex: ['background', 'batch'] },
+ payment: 'prepaid', source: 'https://docs.particle.ai/service-tiers', checked: '2026-11-10',
+ }),
+ privacy: privacy({
+ label: 'anonymized', terms_url: 'https://docs.particle.ai/data-retention',
+ wording: '"Particle operates zero-data-retention for inference traffic: prompts and completions are processed in memory and never persisted." The page says nothing about training the prompt (data retention, 2026-11-10), so the offer is not trusted with private data.',
+ checked: '2026-11-10',
+ }),
+ data_policy: policy({
+ trains: null, retention: 'none', trust: 'first-party', region: null,
+ retention_note: 'prompts and completions are processed in memory and never persisted; prefix caches are transient',
+ terms: 'https://docs.particle.ai/data-retention', checked: '2026-11-10',
+ }),
+ models: [],
+ },
{ key: 'inference-net', display_name: 'Inference.net', base_url: 'https://api.inference.net/v1', models_public: true,
{ key: 'inference-net', display_name: 'Inference.net', base_url: 'https://api.inference.net/v1', models_public: true,
docs_url: 'https://docs.inference.net/', capabilities: ['chat'] },
docs_url: 'https://docs.inference.net/', capabilities: ['chat'] },
{ key: 'atlas-cloud', display_name: 'AtlasCloud', base_url: 'https://api.atlascloud.ai/v1', models_public: true,
{ key: 'atlas-cloud', display_name: 'AtlasCloud', base_url: 'https://api.atlascloud.ai/v1', models_public: true,
@@ -741,6 +796,123 @@ const PROVIDERS = [
@@ -741,6 +796,123 @@ const PROVIDERS = [
{ key: 'scaleway', display_name: 'Scaleway', base_url: 'https://api.scaleway.ai/v1',
{ key: 'scaleway', display_name: 'Scaleway', base_url: 'https://api.scaleway.ai/v1',
docs_url: 'https://www.scaleway.com/en/developers/api/generative-apis', capabilities: ['chat', 'embed'] },
docs_url: 'https://www.scaleway.com/en/developers/api/generative-apis', capabilities: ['chat', 'embed'] },
+ // The 2026-11-10 provider wave. Each was read on its own page or keyless list that day; each starts
+ // disabled with no key. The owner adds the key and the money; nothing is created or spent here.
+ {
+ key: 'iteracompute', keys_url: 'https://iteracompute.com/console/api-keys', display_name: 'IteraCompute',
+ base_url: 'https://api.iteracompute.com/v1', default_model: 'Auto', timeout_ms: 180000,
+ docs_url: 'https://iteracompute.com/docs.html', capabilities: ['chat', 'tools'],
+ provider_access: 'UNVERIFIED', priority: 20,
+ traits: traits({
+ latency_classes: ['background', 'batch'], concurrency: 1,
+ free_allowance: {
+ tokens: 30000000, period: 'month', shared: true, ends_at: '2026-11-24T08:00:00Z',
+ context_window: 131072, max_output: 8192,
+ note: '30M tokens a month across every key and free route, input and output including reasoning, resets at 00:00 UTC on the 1st, no rollover; the promotion ends at 00:00 Pacific on 2026-11-24',
+ },
+ payment: 'none', free_only: true,
+ source: 'https://iteracompute.com/free/', checked: '2026-11-10',
+ }),
+ privacy: privacy({
+ label: 'anonymized', terms_url: 'https://iteracompute.com/privacy.html',
+ wording: 'the free tier page says "Auto does not provide a zero-data-retention guarantee; review the data policy before sending sensitive information" (read 2026-11-10)',
+ checked: '2026-11-10',
+ }),
+ data_policy: policy({
+ trains: null, retention: null, trust: 'first-party', region: null,
+ retention_note: 'no zero-retention guarantee is stated; the model and precision may change between requests',
+ terms: 'https://iteracompute.com/privacy.html', checked: '2026-11-10',
+ }),
+ models: [{
+ model_key: 'Auto', display_name: 'Auto (free shared pool)', family: 'auto', context_window: 131072, max_output: 8192,
+ cost: { in_per_mtok: 0, cached_per_mtok: null, out_per_mtok: 0 },
+ source: 'https://iteracompute.com/free/', checked: '2026-11-10',
+ supports: { json: true, tools: false, streaming: true, vision: false },
+ quantization: null, refusal_rate: null,
+ note: 'one queued or running request per account; the model, version and precision behind Auto may change',
+ }],
+ },
+ {
+ key: 'sable', keys_url: 'https://sableapi.com/auth/sign-in', display_name: 'Sable',
+ base_url: 'https://sableapi.com/v1', default_model: 'deepseek-v4.1-flash-1017-uncensored', timeout_ms: 120000,
+ docs_url: 'https://sableapi.com/docs', capabilities: ['chat', 'tools', 'vision'],
+ provider_access: 'UNVERIFIED', priority: 20, uncensored: true,
+ traits: traits({
+ latency_classes: ['interactive', 'background'],
+ payment: 'crypto-prepaid', source: 'https://sableapi.com/models', checked: '2026-11-10',
+ }),
+ privacy: privacy({
+ label: 'anonymized', terms_url: 'https://sableapi.com/privacy',
+ wording: '"Prompts and outputs: Never logged, stored or used for training." but "prompt cache ... is discarded when newer entries need the space, usually within an hour to a few days" (privacy, last updated 2026-11-09)',
+ checked: '2026-11-10',
+ }),
+ data_policy: policy({
+ trains: false, retention: 'limited', trust: 'first-party', region: null,
+ retention_note: 'prompts and outputs are not logged or stored; prompt-cache state is kept for an hour to a few days',
+ terms: 'https://sableapi.com/privacy', checked: '2026-11-10',
+ }),
+ models: [{
+ model_key: 'deepseek-v4.1-flash-1017-uncensored', display_name: 'DeepSeek V4.1 Flash 1017 (uncensored)',
+ family: 'deepseek', context_window: 512000,
+ cost: { in_per_mtok: 0.15, cached_per_mtok: 0.01, out_per_mtok: 0.60 },
+ source: 'https://sableapi.com/models', checked: '2026-11-10',
+ supports: { json: true, tools: true, streaming: true, vision: true },
+ quantization: null, refusal_rate: null, uncensored: true,
+ note: 'refusals removed; 512K context; prepaid balance (crypto accepted); the 18+ floor still runs before it',
+ }],
+ },
+ {
+ key: 'inception', keys_url: 'https://platform.inceptionlabs.ai', display_name: 'Inception Labs',
+ base_url: 'https://api.inceptionlabs.ai/v1', default_model: 'mercury-3', timeout_ms: 90000,
+ docs_url: 'https://docs.inceptionlabs.ai/get-started/models', capabilities: ['chat', 'tools', 'json'],
+ provider_access: 'UNVERIFIED', priority: 25,
+ traits: traits({
+ latency_classes: ['interactive', 'background', 'batch'], payment: 'prepaid',
+ free_allowance: { tokens: 100000000, period: 'once', shared: true, ends_at: null, note: '100M tokens once per new account, shared across Inception models; no end date published' },
+ trains_note: 'trains on submissions unless the "Improve the model for everyone" setting is off',
+ source: 'https://docs.inceptionlabs.ai/resources/faq', checked: '2026-11-10',
+ }),
+ privacy: privacy({
+ label: 'anonymized', terms_url: 'https://www.inceptionlabs.ai/docs/privacy-policy',
+ wording: 'the FAQ states submissions are used to improve the model unless "Improve the model for everyone" is switched off (read 2026-11-10); the setting is off only when the owner records that it is',
+ checked: '2026-11-10',
+ }),
+ data_policy: policy({
+ trains: true, retention: 'standard', trust: 'first-party', region: null,
+ retention_note: 'trains on submissions unless the owner records that "Improve the model for everyone" is off',
+ terms: 'https://www.inceptionlabs.ai/docs/privacy-policy', checked: '2026-11-10',
+ }),
+ models: [{
+ model_key: 'mercury-3', display_name: 'Mercury 3', family: 'mercury', context_window: 1000000, max_output: 131072,
+ cost: { in_per_mtok: 0.20, cached_per_mtok: 0.02, out_per_mtok: 0.70 },
+ source: 'https://docs.inceptionlabs.ai/get-started/models', checked: '2026-11-10',
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ quantization: null, refusal_rate: null,
+ note: 'launch price is $0.10 in / $0.35 out with $0.01 cached and no published end date; the list price is recorded here so a budget does not assume the discount lasts',
+ }],
+ },
+ {
+ key: 'dosai', keys_url: 'https://platform.dos.ai', display_name: 'DOS.AI',
+ base_url: 'https://api.dos.ai/v1', default_model: 'deepseek-v4.1-flash', timeout_ms: 900000,
+ docs_url: 'https://docs.dos.ai/api-reference/batches', capabilities: ['chat', 'tools'],
+ provider_access: 'UNVERIFIED', priority: 20,
+ // A batch-only offer: 50% off retail with a 24-hour completion window, and batch volume does not
+ // consume interactive rate limits (https://docs.dos.ai/api-reference/batches, read 2026-11-10).
+ // No absolute price is published for the six models, so the book records the multiplier, not a guess.
+ tier_rates: { batch: 0.5 },
+ traits: traits({
+ latency_classes: ['batch'], batch_window_hours: 24, payment: 'prepaid',
+ source: 'https://docs.dos.ai/api-reference/batches', checked: '2026-11-10',
+ }),
+ privacy: privacy({
+ label: 'anonymized', terms_url: 'https://docs.dos.ai',
+ wording: 'retention and training terms were not confirmed on the batch page read on 2026-11-10',
+ checked: '2026-11-10',
+ }),
+ data_policy: policy({ trains: null, retention: null, terms: 'https://docs.dos.ai', checked: '2026-11-10' }),
+ models: [],
+ },
+
];
];
// The seed, console and personal-key forms consume these same records.
// The seed, console and personal-key forms consume these same records.
@@ -39,6 +39,8 @@ function recordOf(p) {
@@ -39,6 +39,8 @@ function recordOf(p) {
privacy: p.privacy,
privacy: p.privacy,
// The tiers the provider itself sells and at what price (BA1): a Flex tier, a batch API.
// The tiers the provider itself sells and at what price (BA1): a Flex tier, a batch API.
...(p.tier_rates ? { tier_rates: p.tier_rates } : {}),
...(p.tier_rates ? { tier_rates: p.tier_rates } : {}),
+ // The traits beyond price (latency class, concurrency, free allowance, payment, sources).
+ ...(p.traits ? { traits: p.traits } : {}),
...(p.uncensored ? { uncensored: true } : {}),
...(p.uncensored ? { uncensored: true } : {}),
...(p.role ? { role: p.role } : {}),
...(p.role ? { role: p.role } : {}),
},
},
@@ -59,6 +61,11 @@ function modelOf(p, m) {
@@ -59,6 +61,11 @@ function modelOf(p, m) {
refusal_rate: m.refusal_rate == null ? null : m.refusal_rate,
refusal_rate: m.refusal_rate == null ? null : m.refusal_rate,
...(m.note ? { note: m.note } : {}),
...(m.note ? { note: m.note } : {}),
...(m.uncensored ? { uncensored: true } : {}),
...(m.uncensored ? { uncensored: true } : {}),
+ // A per-offer fact: the OpenRouter provider routing it pins, the service tier it runs on, and any
+ // latency classes narrower than the provider's.
+ ...(m.routing ? { routing: m.routing } : {}),
+ ...(m.service_tier ? { service_tier: m.service_tier } : {}),
+ ...(Array.isArray(m.latency_classes) ? { latency_classes: m.latency_classes } : {}),
data_policy: p.data_policy,
data_policy: p.data_policy,
privacy: p.privacy,
privacy: p.privacy,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+/**
+ * The traits a provider record carries beyond its price (part 1 of the 2026-11-10 provider wave): the
+ * classes of work it may answer, how many requests it takes at once, a free allowance and when it ends,
+ * how it is paid, and the OpenRouter provider routing an offer pins.
+ *
+ * They live in metadata.traits on the provider row (server/providers/pool-seed.js writes them from
+ * server/providers/pool-providers.js) and in metadata on a model row for the per-offer facts. The
+ * router reads them; nothing here calls a provider or invents a fact.
+ *
+ * latency_classes interactive | background | batch absent: every class
+ * tier_latency { flex: ['background', 'batch'] } a tier's own classes (Flex never interactive)
+ * concurrency number at most that many calls at once
+ * free_allowance { tokens, period, ends_at, ... } spent first, then stops at its date
+ * payment 'crypto-prepaid' | null
+ * trains_note the provider's own wording about training, when it is not in data_policy
+ */
+
+const CLASSES = ['interactive', 'background', 'batch'];
+const RETENTION_KEEPS = new Set(['limited', 'standard']);
+
+function traitsOf(record) {
+ return (record && record.metadata && record.metadata.traits) || null;
+}
+
+/** The provider record's data policy (the facts the Exchange privacy label reads). */
+function policyOf(record) {
+ return (record && record.metadata && record.metadata.data_policy) || null;
+}
+
+/** The classes one offer may answer, or null when it may answer every class. */
+function latencyClassesOf(record, model, tier = 'standard') {
+ const t = traitsOf(record);
+ if (t && t.tier_latency && tier && Array.isArray(t.tier_latency[tier])) return t.tier_latency[tier];
+ if (t && Array.isArray(t.latency_classes) && t.latency_classes.length) return t.latency_classes;
+ const m = (model && model.metadata) || null;
+ if (m && Array.isArray(m.latency_classes) && m.latency_classes.length) return m.latency_classes;
+ // A model offer that names a Flex service tier is a Flex offer: it may wait, never answer in front of a person.
+ if (m && String(m.service_tier || '').toLowerCase() === 'flex') return ['background', 'batch'];
+ return null;
+}
+
+/** May this offer answer a request of that class? An unknown class is interactive (the safe default). */
+function latencyAllows(record, model, latencyClass = 'interactive', tier = 'standard') {
+ const classes = latencyClassesOf(record, model, tier);
+ const asked = CLASSES.includes(latencyClass) ? latencyClass : 'interactive';
+ return !classes || classes.includes(asked);
+}
+
+/** The free allowance the provider states, or null. */
+function freeAllowanceOf(record) {
+ const t = traitsOf(record);
+ return t && t.free_allowance ? t.free_allowance : null;
+}
+
+/** The provider's concurrency cap, or null when it states none. */
+function concurrencyOf(record) {
+ const t = traitsOf(record);
+ const n = t && Number(t.concurrency);
+ return Number.isInteger(n) && n > 0 ? n : null;
+}
+
+/**
+ * What is left of a free allowance. `used` is the provider's token count for the current period from
+ * usage_daily (the caller reads it; this function does no I/O).
+ *
+ * → { tokens, used, left, ends_at, ended, period, shared } or null when the provider has none.
+ */
+function allowanceLeft(record, { used = 0, now = Date.now() } = {}) {
+ const a = freeAllowanceOf(record);
+ if (!a) return null;
+ const tokens = Number(a.tokens);
+ if (!Number.isFinite(tokens) || tokens <= 0) return null;
+ const endsAt = a.ends_at ? Date.parse(a.ends_at) : null;
+ const ended = Number.isFinite(endsAt) && now >= endsAt;
+ const spent = Math.max(0, Number(used) || 0);
+ return {
+ tokens,
+ used: spent,
+ left: ended ? 0 : Math.max(0, tokens - spent),
+ ends_at: a.ends_at || null,
+ ended,
+ period: a.period || 'once',
+ shared: a.shared !== false,
+ };
+}
+
+/** The start of the allowance's current period, as an ISO day, or null for a one-time allowance. */
+function allowancePeriodStart(record, now = Date.now()) {
+ const a = freeAllowanceOf(record);
+ if (!a) return null;
+ if (a.starts_at) return String(a.starts_at).slice(0, 10);
+ if (a.period === 'month') {
+ const d = new Date(now);
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).toISOString().slice(0, 10);
+ }
+ return null;
+}
+
+/**
+ * Does this provider train on or retain a prompt? The two facts the private rule reads. A provider that
+ * says nothing is not trusted with a person's private data: unknown counts as retaining.
+ */
+function trainsOrRetains(record) {
+ const p = policyOf(record);
+ if (!p) return true;
+ // Training must be an explicit "no": a provider that says nothing is not trusted with private data.
+ if (p.trains !== false && p.training !== false) return true;
+ if (p.retention == null) return true;
+ return RETENTION_KEEPS.has(String(p.retention));
+}
+
+/** Is this request a person's private data? A preset that asks for strict privacy is; so is `options.private`. */
+function isPrivateRequest(ctx = {}) {
+ if (ctx.private === true) return true;
+ const policy = ctx.presetPolicy || null;
+ return Boolean(policy && String(policy.data_policy || '').toLowerCase() === 'private');
+}
+
+module.exports = {
+ CLASSES, traitsOf, policyOf, latencyClassesOf, latencyAllows, freeAllowanceOf, concurrencyOf,
+ allowanceLeft, allowancePeriodStart, trainsOrRetains, isPrivateRequest,
+};
@@ -366,6 +366,17 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -366,6 +366,17 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
presetPolicy = {};
presetPolicy = {};
}
}
}
}
+ // The caller's own data policy (2026-11-10): private means a person's private data, so no offer that
+ // trains on or retains a prompt may answer it; anonymized (or community) marks work that is not
+ // sensitive. The same rule a preset carries, so a caller can be strict without a saved preset.
+ const askedDataPolicy = body.data_policy != null ? body.data_policy
+ : (options && options.data_policy != null ? options.data_policy : null);
+ if (askedDataPolicy != null) {
+ const wanted = String(askedDataPolicy).trim().toLowerCase();
+ if (!['private', 'anonymized', 'community'].includes(wanted)) throw new AiError(422, 'input.invalid', 'data_policy must be private, anonymized or community');
+ presetPolicy = { ...(presetPolicy || {}), data_policy: wanted };
+ presetName = presetName || `caller data policy: ${wanted}`;
+ }
if (presetPolicy && typeof presetPolicy.community === 'boolean') communityOptIn = presetPolicy.community;
if (presetPolicy && typeof presetPolicy.community === 'boolean') communityOptIn = presetPolicy.community;
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');
@@ -445,7 +456,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -445,7 +456,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
...(communityOptIn ? { communityOptIn: true } : {}),
...(communityOptIn ? { communityOptIn: true } : {}),
...(pinned ? { routePin: pinned } : {}),
...(pinned ? { routePin: pinned } : {}),
// The preset's policy, and the name it was named by: the router filters the offers with the policy.
// The preset's policy, and the name it was named by: the router filters the offers with the policy.
- ...(preset ? { presetPolicy, presetName } : {}),
+ ...(preset || presetPolicy ? { presetPolicy, presetName } : {}),
// BA1: the class this run may wait in. The router reads it to pick the tier.
// BA1: the class this run may wait in. The router reads it to pick the tier.
latencyClass,
latencyClass,
// BA1: a caller that waits (?wait=) gets its answer inside the wait: a Flex attempt may use the wait
// BA1: a caller that waits (?wait=) gets its answer inside the wait: a Flex attempt may use the wait
@@ -36,6 +36,7 @@ const DOMAIN = {
@@ -36,6 +36,7 @@ const DOMAIN = {
baseten: 'baseten.co', hyperbolic: 'hyperbolic.xyz', parasail: 'parasail.io', 'particle-ai': 'particle.ai',
baseten: 'baseten.co', hyperbolic: 'hyperbolic.xyz', parasail: 'parasail.io', 'particle-ai': 'particle.ai',
'inference-net': 'inference.net', 'atlas-cloud': 'atlascloud.ai', avian: 'avian.io',
'inference-net': 'inference.net', 'atlas-cloud': 'atlascloud.ai', avian: 'avian.io',
nanogpt: 'nano-gpt.com', infermatic: 'totalgpt.ai', arliai: 'arliai.com', friendli: 'friendli.ai', scaleway: 'scaleway.com',
nanogpt: 'nano-gpt.com', infermatic: 'totalgpt.ai', arliai: 'arliai.com', friendli: 'friendli.ai', scaleway: 'scaleway.com',
+ iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai',
};
};
const onItsOwnSite = (url, key) => {
const onItsOwnSite = (url, key) => {
const host = new URL(url).hostname;
const host = new URL(url).hostname;
@@ -49,7 +50,7 @@ t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an a
@@ -49,7 +50,7 @@ t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an a
assert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),
assert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),
['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);
['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);
assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
- assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 50);
+ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 54);
assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');
assert.strictEqual(pool.byKey.get('meta').base_url, 'https://api.meta.ai/v1');
assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');
assert.strictEqual(pool.byKey.get('perplexity').kind, 'responses');
assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');
assert.strictEqual(require('../server/console/presets').credentialProvider('groq').model, 'openai/gpt-oss-20b', 'existing personal-key default is preserved');
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * The 2026-11-10 provider wave, parts 1 and 2.
+ *
+ * Part 1: the new provider records (Particle AI and its Flex tier, the OpenRouter Particle pin,
+ * IteraCompute, Sable, Inception, DOS.AI) are seeded off and keyless, with their traits — classes of
+ * work, concurrency, free allowance and its end, payment, training and retention — each with the page
+ * and the day it was read.
+ *
+ * Part 2: routing honours them. A background/batch-only provider never answers an interactive request;
+ * a provider that trains on or retains a prompt never receives a person's private data; a concurrency
+ * cap skips to the next offer (never errors); a free allowance leads while it lasts and stops at its
+ * date; an uncensored offer only ever answers behind the 18+ policy.
+ */
+const assert = require('assert');
+const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
+const { PROVIDERS } = require('../server/providers/pool-providers');
+const seed = require('../server/providers/pool-seed');
+const traits = require('../server/providers/traits');
+const { buildBody, buildCompleteBody } = require('../server/providers/openai');
+
+const t = suite('provider-wave2');
+
+// ── Part 1: the records ────────────────────────────────────────────────────────
+
+t.test('each new provider is a record, off, keyless, with its traits, price and sources', () => {
+ const by = (key) => PROVIDERS.find((p) => p.key === key);
+ const itera = by('iteracompute');
+ assert.ok(itera, 'IteraCompute is catalogued');
+ assert.strictEqual(new URL(itera.base_url).hostname, 'api.iteracompute.com');
+ const irow = seed.recordOf(itera);
+ assert.strictEqual(irow.status, 'disabled', 'starts off');
+ assert.strictEqual(irow.secret_ref, 'vault:iteracompute', 'a key slot, no key');
+ assert.deepStrictEqual(irow.metadata.traits.latency_classes, ['background', 'batch'], 'never interactive');
+ assert.strictEqual(irow.metadata.traits.concurrency, 1, 'one request per account');
+ assert.strictEqual(irow.metadata.traits.free_allowance.tokens, 30000000);
+ assert.strictEqual(irow.metadata.traits.free_allowance.ends_at, '2026-11-24T08:00:00Z', 'promotion end');
+ assert.strictEqual(irow.metadata.traits.free_only, true);
+ assert.match(itera.privacy.wording, /zero-data-retention guarantee/i, 'the retention statement is quoted');
+
+ const sable = by('sable');
+ assert.strictEqual(sable.base_url, 'https://sableapi.com/v1');
+ assert.strictEqual(seed.recordOf(sable).metadata.uncensored, true);
+ assert.strictEqual(seed.recordOf(sable).metadata.traits.payment, 'crypto-prepaid');
+ assert.strictEqual(seed.recordOf(sable).metadata.data_policy.trains, false);
+ assert.strictEqual(seed.recordOf(sable).metadata.data_policy.retention, 'limited', 'prompt cache hours to days');
+ const sableModel = seed.modelOf(sable, sable.models[0]);
+ assert.deepStrictEqual([sableModel.cost.in_per_mtok, sableModel.cost.out_per_mtok, sableModel.cost.cached_per_mtok], [0.15, 0.6, 0.01]);
+ assert.strictEqual(sableModel.metadata.uncensored, true);
+ assert.strictEqual(sableModel.supports.tools && sableModel.supports.vision, true, 'tools and vision');
+
+ const inception = by('inception');
+ assert.strictEqual(seed.recordOf(inception).metadata.data_policy.trains, true, 'trains until the owner records the setting off');
+ assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.tokens, 100000000);
+ assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.period, 'once');
+ const mercury = seed.modelOf(inception, inception.models[0]);
+ assert.deepStrictEqual([mercury.cost.in_per_mtok, mercury.cost.out_per_mtok, mercury.cost.cached_per_mtok], [0.2, 0.7, 0.02], 'the list price is recorded');
+ assert.match(mercury.metadata.note, /\$0\.10|\$0\.35/, 'the launch price is noted, not assumed');
+
+ assert.strictEqual(seed.recordOf(by('particle-ai')).metadata.tier_rates.flex, 0.5, 'Particle Flex is half price');
+ assert.deepStrictEqual(seed.recordOf(by('particle-ai')).metadata.traits.tier_latency.flex, ['background', 'batch']);
+ assert.strictEqual(seed.recordOf(by('dosai')).metadata.traits.latency_classes[0], 'batch', 'DOS.AI is batch only');
+
+ const openrouter = by('openrouter');
+ const pinned = seed.modelOf(openrouter, openrouter.models[0]);
+ assert.deepStrictEqual([pinned.cost.in_per_mtok, pinned.cost.out_per_mtok, pinned.cost.cached_per_mtok], [0.025, 0.25, 0.003], 'the Particle Flex price read from OpenRouter');
+ assert.deepStrictEqual(pinned.metadata.routing, { only: ['particle-ai'], allow_fallbacks: false, service_tier: 'flex', data_collection: 'deny' });
+ assert.strictEqual(pinned.metadata.service_tier, 'flex');
+ assert.strictEqual(traits.latencyAllows({ metadata: {} }, pinned, 'interactive'), false, 'a Flex offer never answers in front of a person');
+ assert.strictEqual(traits.latencyAllows({ metadata: {} }, pinned, 'background'), true);
+});
+
+t.test('the traits read an allowance, a period and training honestly', () => {
+ const record = { metadata: { traits: { latency_classes: ['background', 'batch'], concurrency: 1, free_allowance: { tokens: 100, period: 'month', ends_at: '2026-11-24T08:00:00Z' } } } };
+ assert.strictEqual(traits.latencyAllows(record, null, 'interactive'), false);
+ assert.strictEqual(traits.latencyAllows(record, null, 'background'), true);
+ assert.strictEqual(traits.concurrencyOf(record), 1);
+ const before = traits.allowanceLeft(record, { used: 40, now: Date.parse('2026-11-20T00:00:00Z') });
+ assert.deepStrictEqual([before.tokens, before.used, before.left, before.ended], [100, 40, 60, false]);
+ const after = traits.allowanceLeft(record, { used: 40, now: Date.parse('2026-11-25T00:00:00Z') });
+ assert.deepStrictEqual([after.left, after.ended], [0, true], 'zero after the end date');
+ assert.strictEqual(traits.allowancePeriodStart(record, Date.parse('2026-11-20T10:00:00Z')), '2026-11-01', 'the month it counts');
+ assert.strictEqual(traits.trainsOrRetains({ metadata: { data_policy: { trains: true, retention: 'none' } } }), true);
+ assert.strictEqual(traits.trainsOrRetains({ metadata: { data_policy: { trains: false, retention: 'limited' } } }), true, 'retaining counts');
+ assert.strictEqual(traits.trainsOrRetains({ metadata: { data_policy: { trains: false, retention: 'none' } } }), false);
+ assert.strictEqual(traits.trainsOrRetains({ metadata: {} }), true, 'saying nothing is not a promise');
+ assert.strictEqual(traits.isPrivateRequest({ presetPolicy: { data_policy: 'private' } }), true);
+ assert.strictEqual(traits.isPrivateRequest({}), false);
+});
+
+t.test('an OpenRouter offer can pin a provider and carry the tier, and drops fields it does not document', () => {
+ const body = buildBody({ model: 'deepseek/deepseek-v4.1-flash-1017', system: [], messages: [{ role: 'user', content: 'hi' }], maxTokens: 5,
+ routing: { only: ['particle-ai'], allow_fallbacks: false, data_collection: 'deny', quantizations: ['fp8'], service_tier: 'flex', bogus: true } });
+ assert.deepStrictEqual(body.provider, { only: ['particle-ai'], allow_fallbacks: false, data_collection: 'deny', quantizations: ['fp8'] });
+ assert.strictEqual(body.service_tier, 'flex');
+ assert.strictEqual(body.provider.bogus, undefined, 'nothing undocumented is forwarded');
+ const complete = buildCompleteBody({ model: 'm', openai: { messages: [{ role: 'user', content: 'hi' }] }, maxTokens: 5, routing: { sort: 'price', max_price: { completion: 1 }, data_collection: 'deny' } });
+ assert.deepStrictEqual(complete.provider, { sort: 'price', max_price: { completion: 1 }, data_collection: 'deny' });
+});
+
+// ── Part 2: routing honours them ──────────────────────────────────────────────
+
+let h;
+let seam;
+let slowSeam;
+let release;
+const provider = (body) => h.registry.upsertProvider({ kind: 'http', auth_mode: 'none', status: 'active', capabilities: ['chat', 'generate'], priority: 50, ...body });
+const model = (provider_key, model_key, cost, metadata = {}) => h.registry.upsertModel({ provider_key, model_key, status: 'active', cost, metadata, supports: { streaming: true } });
+
+t.test('boot with a seam provider and a fallback', async () => {
+ seam = await seamServer(() => ({ text: 'answered', model: 'seam-model', usage: { input: 4, output: 2 } }));
+ slowSeam = await seamServer(() => new Promise((resolve) => { release = () => resolve({ text: 'slow answer', model: 'seam-model', usage: { input: 4, output: 2 } }); }));
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
+ await provider({ key: 'fallback-open', base_url: seam.url, timeout_ms: 5000, metadata: {
+ data_policy: { trains: false, training: false, retention: 'none' }, privacy: { label: 'private', wording: 'keeps nothing, trains on nothing', checked: '2026-11-10' },
+ } });
+ await model('fallback-open', 'm', { in_per_mtok: 1, out_per_mtok: 1 });
+});
+
+t.test('a background-only provider never answers an interactive request, and answers a background one', async () => {
+ await provider({ key: 'slowpool', base_url: seam.url, timeout_ms: 5000, metadata: { traits: { latency_classes: ['background', 'batch'], source: 'https://example.test/terms', checked: '2026-11-10' } } });
+ await model('slowpool', 'm', { in_per_mtok: 0, out_per_mtok: 0 });
+ const route = { key: 'wave-latency', primary: { provider: 'slowpool', model: 'm' }, fallbacks: [{ provider: 'fallback-open', model: 'm' }] };
+ const interactive = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hi' }], maxTokens: 5 });
+ assert.strictEqual(interactive.provider, 'fallback-open', 'the interactive request skips the background-only offer');
+ assert.ok(interactive.explain.candidates.some((c) => c.provider === 'slowpool' && /latency_class/.test(c.excluded_reason || '')), 'the reason is named');
+ const background = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hi' }], maxTokens: 5 }, { latencyClass: 'background' });
+ assert.strictEqual(background.provider, 'slowpool', 'a run that may wait uses it');
+});
+
+t.test('a provider that trains on prompts never receives a person\'s private data', async () => {
+ await provider({ key: 'trainer', base_url: seam.url, timeout_ms: 5000, metadata: { data_policy: { trains: true, retention: 'standard' }, traits: { source: 'https://example.test/privacy', checked: '2026-11-10' } } });
+ await model('trainer', 'm', { in_per_mtok: 0, out_per_mtok: 0 });
+ const route = { key: 'wave-private', primary: { provider: 'trainer', model: 'm' }, fallbacks: [{ provider: 'fallback-open', model: 'm' }] };
+ const privateRun = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'my private note' }], maxTokens: 5 },
+ { presetPolicy: { data_policy: 'private' }, presetName: 'private' });
+ assert.strictEqual(privateRun.provider, 'fallback-open', 'the private request never reaches the trainer');
+ assert.ok(privateRun.explain.candidates.some((c) => c.provider === 'trainer' && /private|train/i.test(c.excluded_reason || '')), 'the reason is named');
+ const ordinary = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'a public question' }], maxTokens: 5 });
+ assert.strictEqual(ordinary.provider, 'trainer', 'work not marked private may use it');
+});
+
+t.test('a concurrency cap skips to the next offer, never errors', async () => {
+ await provider({ key: 'one-at-a-time', base_url: slowSeam.url, timeout_ms: 10000, metadata: { traits: { concurrency: 1, source: 'https://example.test/limits', checked: '2026-11-10' } } });
+ await model('one-at-a-time', 'm', { in_per_mtok: 0, out_per_mtok: 0 });
+ const route = { key: 'wave-cap', primary: { provider: 'one-at-a-time', model: 'm' }, fallbacks: [{ provider: 'fallback-open', model: 'm' }] };
+ const first = h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'first' }], maxTokens: 5 });
+ await new Promise((r) => setTimeout(r, 120));
+ const second = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'second' }], maxTokens: 5 });
+ assert.strictEqual(second.provider, 'fallback-open', 'the second request skips the busy provider');
+ assert.ok(second.explain.attempts.some((a) => a.provider === 'one-at-a-time' && /concurrency_cap/.test(a.error || '')), 'the cap is named');
+ release();
+ const done = await first;
+ assert.strictEqual(done.provider, 'one-at-a-time', 'the first call still finishes on the provider');
+});
+
+t.test('a free allowance leads while it lasts, and stops at its end date', async () => {
+ await provider({ key: 'free-pool', base_url: seam.url, timeout_ms: 5000, metadata: { traits: { free_only: true, free_allowance: { tokens: 1000, period: 'month', ends_at: '2026-11-24T08:00:00Z' }, source: 'https://example.test/free', checked: '2026-11-10' } } });
+ await model('free-pool', 'm', { in_per_mtok: 9, out_per_mtok: 9 });
+ const route = { key: 'wave-free', capability: 'chat', primary: { provider: 'fallback-open', model: 'm' }, fallbacks: [] };
+ const run = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'use the free tokens first' }], maxTokens: 5 });
+ assert.strictEqual(run.provider, 'free-pool', 'the free allowance is spent before the cheaper-looking paid price');
+ // Spend the allowance: the same offer is skipped.
+ const day = new Date().toISOString().slice(0, 10);
+ await h.db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, cost_usd) VALUES (?, 'service:test', '', 'test', 'free-pool', 'm', 1, 900, 200, 0)`).run(day);
+ const spent = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'again' }], maxTokens: 5 });
+ assert.strictEqual(spent.provider, 'fallback-open', 'a spent free-only allowance is not called');
+});
+
+t.test('an uncensored offer (Sable) answers only the 18+ policy, never a standard request', async () => {
+ const sable = PROVIDERS.find((p) => p.key === 'sable');
+ await provider({ key: 'sable', base_url: seam.url, timeout_ms: 5000, metadata: seed.recordOf(sable).metadata });
+ await model('sable', sable.models[0].model_key, { in_per_mtok: 0.15, out_per_mtok: 0.6, cached_per_mtok: 0.01 }, { uncensored: true, data_policy: sable.data_policy, privacy: sable.privacy });
+ const route = { key: 'wave-uncensored', capability: 'chat', primary: { provider: 'fallback-open', model: 'm' }, fallbacks: [] };
+ const standard = await h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], maxTokens: 5 });
+ assert.strictEqual(standard.provider, 'fallback-open', 'a standard request never runs the uncensored offer');
+ // The 18+ gate has no adult status here: an uncensored request is refused before any call.
+ await assert.rejects(
+ () => h.pool.execute({ ...route, key: 'wave-adult' }, 'chat', { messages: [{ role: 'user', content: 'hello' }], maxTokens: 5 }, { contentPolicy: 'uncensored' }),
+ (err) => [400, 403, 503].includes(err.status),
+ 'an uncensored request without the 18+ floor is refused',
+ );
+});
+
+t.test('a caller may name its data policy, and a bad one is refused', async () => {
+ const bad = await request(h.base, 'POST', '/api/v1/generate', { tok: token('svc', ALL), body: { prompt: 'hi', options: { data_policy: 'nonsense' } } });
+ assert.strictEqual(bad.status, 422, bad.text);
+});
+
+t.run(() => h && h.stop());