openvibers/openvibe.ai Commits

Add the second provider wave and route by its traits (#452)

OP OpenVibers committed 2 days ago · 5b72ad01de · 8 files, +649 −16

* Add the second provider wave and route by its traits

Particle AI (standard and Flex), IteraCompute, Sable, Inception and DOS.AI are
catalogue records, off and keyless, with their training, retention, latency,
concurrency, free allowance, payment and sources. OpenRouter gains the pinned
Particle Flex offer and forwards provider routing (only/order, fallbacks,
sort, max_price, data_collection, quantizations) from the offer settings.

The router now honours those traits: a background/batch-only offer never
answers an interactive request, a provider that trains on or retains prompts
never receives a person's private data, a concurrency cap skips to the next
offer, a free allowance leads while it lasts and stops at its end date, and a
caller may name its own data policy.

* Keep trait-excluded offers in placement and guard the allowance read

A provider kept out by its latency class, privacy facts or a spent allowance
still enters placement with status down, so explain keeps one entry per
placement offer and names the trait's reason. The allowance query only runs
for a provider that states one.

Changes

ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 8066dcf..4d9c309 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -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.no
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:
*
@@@ -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.no
const 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.no
if (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.no
if (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.no
const 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.no
const chosen = ranked.reasons.get(c.provider + ':' + (model || '')) || null;
const fallback = i > 0;
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
++ // 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
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex 4ccd21b..55a6a0d 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -28,8 +28,21 @@ const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enric
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) {
-- 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' });
}
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex 2b160a7..eb59826 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -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.
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex e29d0d5..8f29850 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -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,
};
ddiff --git a/server/providers/traits.js b/server/providers/traits.js
nnew file mode 100644
iindex 0000000..2030822
---- /dev/null
++++ b/server/providers/traits.js
@@@ -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,
++};
ddiff --git a/server/runs.js b/server/runs.js
iindex ef6bfd0..7b9c0fe 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -366,6 +366,17 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
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 (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
ddiff --git a/test/ai-provider-adapters.test.js b/test/ai-provider-adapters.test.js
iindex d723a4e..eeaceec 100644
---- a/test/ai-provider-adapters.test.js
++++ b/test/ai-provider-adapters.test.js
@@@ -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 a
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']);
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');
ddiff --git a/test/provider-wave2.test.js b/test/provider-wave2.test.js
nnew file mode 100644
iindex 0000000..c1dc691
---- /dev/null
++++ b/test/provider-wave2.test.js
@@@ -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());