openvibers/openvibe.ai Commits
Owner controls, rented GPU capacity and pending confirmed prices (#454)
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++-- phase: expand++-- The owner's provider controls and the routing rules of the 2026-11-10 provider wave.++--++-- provider_controls: one row per provider the owner changed. `enabled` null follows the provider's own++-- status; `tier` is the service tier the owner wants (standard or flex); `base_url`, `model_key`,++-- `hourly_usd` and `throughput_tokens_per_hour` are the owner's own OpenAI-compatible endpoint (the rented++-- Runpod/Vast vLLM), whose key lives in the provider's sealed key slot, never here.++--++-- owner_inference_rules: one row, 'default'. Every switch starts at the safe value:++-- private_training false a provider that trains on or retains prompts never gets private data++-- flex_interactive false a Flex offer never answers an interactive request++-- latency_limited_interactive false a background/batch-only provider never answers interactively++-- free_allowance_first true spend a free allowance before paid capacity++-- The hard floor and the 18+ gate are not here: they are not switches (server/policy/).++++CREATE TABLE IF NOT EXISTS provider_controls (++ provider_key text COLLATE "C" PRIMARY KEY,++ enabled boolean,++ tier text COLLATE "C",++ base_url text COLLATE "C",++ model_key text COLLATE "C",++ hourly_usd double precision,++ throughput_tokens_per_hour bigint,++ updated_by text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++++CREATE TABLE IF NOT EXISTS owner_inference_rules (++ id text COLLATE "C" PRIMARY KEY,++ private_training boolean NOT NULL DEFAULT false,++ flex_interactive boolean NOT NULL DEFAULT false,++ latency_limited_interactive boolean NOT NULL DEFAULT false,++ free_allowance_first boolean NOT NULL DEFAULT true,++ updated_by text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);nnew file mode 100644@@@ -0,0 +1,49 @@++[++ {++ "provider_key": "sable",++ "model_key": "deepseek-v4.1-flash-1017-uncensored",++ "display_name": "DeepSeek V4.1 Flash 1017 (uncensored)",++ "type": "chat",++ "in_per_mtok": 0.15,++ "cached_per_mtok": 0.01,++ "out_per_mtok": 0.6,++ "context_window": 512000,++ "source": "https://sableapi.com/models",++ "checked_at": "2026-11-10"++ },++ {++ "provider_key": "inception",++ "model_key": "mercury-3",++ "display_name": "Mercury 3",++ "type": "chat",++ "in_per_mtok": 0.2,++ "cached_per_mtok": 0.02,++ "out_per_mtok": 0.7,++ "context_window": 1000000,++ "source": "https://docs.inceptionlabs.ai/get-started/models",++ "checked_at": "2026-11-10"++ },++ {++ "provider_key": "openrouter",++ "model_key": "deepseek/deepseek-v4.1-flash-1017",++ "display_name": "DeepSeek V4.1 Flash 1017 (Particle Flex)",++ "type": "chat",++ "in_per_mtok": 0.025,++ "cached_per_mtok": 0.003,++ "out_per_mtok": 0.25,++ "context_window": 1048576,++ "source": "https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints",++ "checked_at": "2026-11-10"++ },++ {++ "provider_key": "iteracompute",++ "model_key": "Auto",++ "display_name": "Auto (free shared pool)",++ "type": "chat",++ "in_per_mtok": 0,++ "out_per_mtok": 0,++ "context_window": 131072,++ "source": "https://iteracompute.com/free/",++ "checked_at": "2026-11-10"++ }++]@@@ -117,6 +117,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,spend_cap: caps.find((c) => c.provider_key === p.key) || null,spend: spend.find((s) => s.provider_key === p.key) || null })),models: (await registry.listModels()).map((m) => { const p = providers.find((x) => x.key === m.provider_key); return modelView(m, p); }),++ controls: await ownerConfig.providerState(), rules: await ownerConfig.getRules(),++ compute: await ownerConfig.compute(),++ pricebook: { confirmed: await ownerConfig.confirmedPrices() },prices, caps, routing: await ownerConfig.getRouting(), economy: await ownerConfig.getEconomy(),costs_24h: await ownerConfig.costs('24h'), costs_7d: await ownerConfig.costs('7d'), kinds,};@@@ -138,6 +141,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,r.post('/api/v1/admin/console/owner-inference/spend-caps', json, write, wrap(async (req) => ({ cap: await ownerConfig.setCap(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));r.post('/api/v1/admin/console/owner-inference/routing', json, write, wrap(async (req) => ({ routing: await ownerConfig.setRouting(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));r.post('/api/v1/admin/console/owner-inference/economy', json, write, wrap(async (req) => ({ economy: await ownerConfig.setEconomy(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));++ r.post('/api/v1/admin/console/owner-inference/providers', json, write, wrap(async (req) => ({ provider: await ownerConfig.setProvider(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));++ r.post('/api/v1/admin/console/owner-inference/rules', json, write, wrap(async (req) => ({ rules: await ownerConfig.setRules(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));++ r.post('/api/v1/admin/console/owner-inference/endpoint', json, write, wrap(async (req) => ({ provider: await ownerConfig.setEndpoint(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));r.post('/api/v1/admin/console/providers/:key/key', json, write, wrap(async (req) => {if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');const provider = await registry.getProvider(req.params.key);@@@ -236,6 +236,9 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receiptsif (b.prefer) body = { ...body, prefer: b.prefer };// The Exchange remake's content policy (`policy: "uncensored"`), enforced by the router.if (b.policy != null) body = { ...body, policy: b.policy };++ // The Exchange API's service tier and the caller's data policy (2026-11-10).++ if (b.service_tier != null) body = { ...body, service_tier: b.service_tier };++ if (b.data_policy != null) body = { ...body, data_policy: b.data_policy };if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };const own = credentialFor(req);@@@ -222,6 +222,10 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =// The Exchange remake's content policy (`policy: "uncensored"`): an OpenVibe extension, enforced by the router// (18+ account, uncensored offers only, the floor, the label). on_behalf_of names the person it is for.if (b.policy != null) body = { ...body, policy: b.policy };++ // The Exchange API's service tier and the caller's data policy (2026-11-10): flex asks for the slower,++ // cheaper tier; private keeps the prompt off every provider that trains on or retains it.++ if (b.service_tier != null) body = { ...body, service_tier: b.service_tier };++ if (b.data_policy != null) body = { ...body, data_policy: b.data_policy };if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };// A first-party service naming the person it acts for (the runs API's on_behalf_of). Their free allowance@@@ -16,6 +16,7 @@ const rates = require('../providers/rate-cards');const upstream = require('../providers/upstream');const observed = require('./observed');const privacy = require('./privacy');++const traits = require('../providers/traits');const { allowsOffer } = require('../free-tier');const { offerStatus } = require('../providers/failure');@@@ -187,6 +188,23 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (const refusalRows = await db.prepare('SELECT provider_key, model_key, refusal_rate, prompt_set, measured_at, measured_on FROM refusal_rates').all().catch(() => []);const refusalBy = new Map((refusalRows || []).map((r) => [`${r.provider_key}/${r.model_key}`, r]));++ // The free allowance a provider states, and what it has used this period (usage_daily): the Exchange++ // shows the tokens that are left and their end date, beside the offer.++ const allowanceUsedCache = new Map();++ const allowanceUsedFor = async (providerKey, since) => {++ const cacheKey = `${providerKey}|${since || ''}`;++ if (allowanceUsedCache.has(cacheKey)) return allowanceUsedCache.get(cacheKey);++ let used = 0;++ try {++ const row = since++ ? await db.prepare('SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ? AND day >= ?').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);++ used = Number((row && row.t) || 0);++ } catch { used = 0; }++ allowanceUsedCache.set(cacheKey, used);++ return used;++ };++const groups = new Map();const drafts = [];for (const row of await registry.listModels()) {@@@ -215,6 +233,11 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (// The refusal rate of this offer, as the refusal job measured it.const refusal = refusalBy.get(`${row.provider_key}/${row.model_key}`) || null;const health = statusFor(provider.key, row.model_key);++ const providerTraits = (provider.metadata && provider.metadata.traits) || null;++ const allowanceRecord = traits.freeAllowanceOf(provider);++ const allowance = allowanceRecord++ ? traits.allowanceLeft(provider, { used: await allowanceUsedFor(provider.key, traits.allowancePeriodStart(provider, now)), now })++ : null;const offer = {provider: up.key,provider_name: up.name,@@@ -244,6 +267,16 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (price_checked_at: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? checkedAt : null,// The same day in words: what the catalog shows under the price is when somebody read it.price_checked: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? `price checked ${checkedAt}` : null,++ // The tier the offer runs on: an offer that names Flex (the OpenRouter Particle pin) says so;++ // a provider that sells a Flex tier is marked with the tier, never assumed to be serving it.++ service_tier: (row.metadata && row.metadata.service_tier) || null,++ // The classes of work the offer may answer. null means every class (the usual case).++ latency_class: traits.latencyClassesOf(provider, row),++ // The free allowance the provider states: tokens left, the period and its end date.++ free_allowance: allowance ? {++ tokens: allowance.tokens, used: allowance.used, left: allowance.left, period: allowance.period,++ shared: allowance.shared, ends_at: allowance.ends_at, ended: allowance.ended,++ } : null,data_policy: policyOf(provider),// The privacy label of this offer, from the same facts the router uses (server/exchange/privacy.js):// Private (zero retention, no training, or the host's own deployment), Anonymized, Community.nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * The capacity we rent ourselves (2026-11-10, part 3 of the provider wave): Runpod and Vast.ai GPU hours,++ * read from the providers' own public pages on the day named, and the owner's choice to run an open model++ * on one of them through an OpenAI-compatible vLLM endpoint.++ *++ * Nothing here rents, starts or stops anything, and no account is created: the provisioner++ * (server/gpu/provisioner.js) does that only when the owner asks, with the owner's key. The per-million++ * token price of a rented card is hourly USD × 1,000,000 / the model's measured tokens an hour++ * (server/gpu/token-cost.js); a number nobody measured is not invented here.++ */++const CHECKED = '2026-11-10';++++const COMPUTE_PROVIDERS = Object.freeze([++ {++ key: 'runpod',++ display_name: 'Runpod',++ kind: 'gpu',++ billing: 'Pods per second; serverless per second rounded up from worker start to full stop',++ docs_url: 'https://docs.runpod.io/',++ endpoint: {++ kind: 'openai-compatible vLLM',++ base_url_pattern: 'https://{pod_id}-{port}.proxy.runpod.io/v1 or https://api.runpod.ai/v2/{endpoint_id}/openai/v1',++ note: 'the owner adds the endpoint URL and its key; the price is the GPU hour, shown per million tokens from the measured throughput',++ },++ pods: [++ { gpu: 'RTX A5000 24GB', hourly_usd: 0.27, vram_mb: 24576 },++ { gpu: 'RTX 3090 24GB', hourly_usd: 0.50, vram_mb: 24576 },++ { gpu: 'A40 48GB', hourly_usd: 0.59, vram_mb: 49152 },++ { gpu: 'RTX 4090 24GB', hourly_usd: 0.89, vram_mb: 24576 },++ { gpu: 'L40S 48GB', hourly_usd: 1.09, vram_mb: 49152 },++ { gpu: 'A100 80GB', hourly_usd: 1.79, vram_mb: 81920 },++ { gpu: 'H100 80GB PCIe', hourly_usd: 2.89, vram_mb: 81920 },++ ],++ serverless: [++ { gpu: 'RTX 5090 32GB', hourly_usd: 1.58 },++ { gpu: 'H200 141GB', hourly_usd: 5.93 },++ { gpu: 'B200 192GB', hourly_usd: 8.64 },++ ],++ storage: { container_usd_per_gb_month: 0.10, network_volume_usd_per_gb_month: 0.07, egress: 'no ingress/egress fees reported by the providers we read' },++ credits: {++ starter_usd: 1000, starter_eligibility: 'raised under $1M', venture_usd: 5000, venture_eligibility: 'backed by a verified VC (a16z, Neo, South Park Commons), raised under $5M',++ growth: [{ commit_usd: 25000, bonus_usd: 10000 }, { commit_usd: 50000, bonus_usd: 25000 }],++ expires: 'one year after issue', note: 'the owner applies; nothing was applied for here',++ },++ source: 'https://www.runpod.io/pricing',++ credits_source: 'https://www.runpod.io/startup-program',++ checked: CHECKED,++ },++ {++ key: 'vast',++ display_name: 'Vast.ai',++ kind: 'gpu',++ billing: 'per second on demand; interruptible offers are preemptible',++ docs_url: 'https://docs.vast.ai/',++ endpoint: {++ kind: 'openai-compatible vLLM or llama.cpp',++ base_url_pattern: 'http://{host}:{port}/v1',++ note: 'the owner adds the instance URL and its key; spot capacity is interruptible, so it is background only',++ },++ // On-demand median and the spot floor from the public GPU index; both are marketplace readings, not a++ // list price. The price book's live reader (server/gpu/market.js) replaces them when it next runs.++ pods: [++ { gpu: 'RTX 3090 24GB', hourly_usd: 0.177, spot_usd: 0.12, vram_mb: 24576 },++ { gpu: 'RTX 4090 24GB', hourly_usd: 0.470, spot_usd: 0.14, vram_mb: 24576 },++ { gpu: 'L40S 48GB', hourly_usd: 0.734, vram_mb: 49152 },++ { gpu: 'A100 80GB', hourly_usd: 0.843, spot_usd: null, vram_mb: 81920 },++ { gpu: 'H100 80GB', hourly_usd: 2.401, spot_usd: 1.73, vram_mb: 81920 },++ { gpu: 'H200 141GB', hourly_usd: 5.054, vram_mb: 141312 },++ { gpu: 'B200 192GB', hourly_usd: 8.751, vram_mb: 196608 },++ ],++ storage: null,++ credits: { match: 'dollar-for-dollar credit match on spend', note: 'the owner applies; nothing was applied for here' },++ source: 'https://www.darkcompute.cfd/gpu-index',++ credits_source: 'https://www.vast.ai/startup',++ checked: CHECKED,++ traits: { latency_classes: ['background', 'batch'], interruptible: true },++ },++]);++++const byKey = new Map(COMPUTE_PROVIDERS.map((p) => [p.key, p]));++++/** One record, or null. */++function get(key) {++ return byKey.get(String(key || '')) || null;++}++++/** The cheapest GPU hour each provider lists, for a one-line summary. */++function cheapest(provider) {++ const prices = (provider.pods || []).map((p) => Number(p.hourly_usd)).filter((n) => Number.isFinite(n) && n > 0);++ return prices.length ? Math.min(...prices) : null;++}++++module.exports = { COMPUTE_PROVIDERS, CHECKED, get, cheapest };@@@ -17,8 +17,12 @@* (server/policy/tier.js) are not configurable here and have no switch: they run in the pool* before and after every attempt, including an owner's fixed order for uncensored.*/++const fs = require('fs');++const path = require('path');const { AiError, parseJson } = require('./util');const { isUncensored } = require('./providers/pool-providers');++const traits = require('./providers/traits');++const computeProviders = require('./gpu/compute-providers');const KINDS = ['chat', 'uncensored', 'embed', 'image', 'transcribe', 'batch'];const STRATEGIES = ['auto', 'cheapest', 'fastest', 'fixed'];@@@ -29,6 +33,12 @@ const STRATEGIES = ['auto', 'cheapest', 'fastest', 'fixed'];* less OFF. The uncensored kind never serves a stored answer, so its answer cache reads off.*/const ECONOMY_DEFAULTS = Object.freeze({ batches: true, prompt_cache: true, small_first: false, output_limit: null, answer_cache: true });++/**++ * The routing rules of the provider wave (migration 0075). Every one starts at its safe value; the owner may++ * change it on the inference page, and the pool reads the row before each placement.++ */++const RULE_DEFAULTS = Object.freeze({ private_training: false, flex_interactive: false, latency_limited_interactive: false, free_allowance_first: true });++const CONFIRMED_PRICES_FILE = path.join(__dirname, '..', 'scripts', 'pricebook-proposals-2026-11-10b.json');const economyDefaultsFor = (capability) => ({ ...ECONOMY_DEFAULTS, ...(capability === 'uncensored' ? { answer_cache: false } : {}) });/** The kind of work a workflow is, from its own steps (server/workflows). */@@@ -193,6 +203,168 @@ function createOwnerConfig({ db, registry, clock = { now: () => Date.now() } } =return (await getEconomy()).find((r) => r.capability === capability);}++ // ── The provider wave's controls (migration 0075) ──────────++ /** The safe routing rules, merged with the owner's row when there is one. */++ async function getRules() {++ const row = await db.prepare("SELECT * FROM owner_inference_rules WHERE id = 'default'").get();++ return {++ ...RULE_DEFAULTS,++ ...(row ? {++ private_training: !!row.private_training, flex_interactive: !!row.flex_interactive,++ latency_limited_interactive: !!row.latency_limited_interactive, free_allowance_first: !!row.free_allowance_first,++ } : {}),++ updated_by: row ? row.updated_by : null,++ updated_at: row ? row.updated_at : null,++ };++ }++ async function setRules(input, { actor = 'system', trace = null } = {}) {++ const b = input || {};++ const val = (v, fallback) => (typeof v === 'boolean' ? v : fallback);++ const prev = await getRules();++ const next = {++ private_training: val(b.private_training, prev.private_training),++ flex_interactive: val(b.flex_interactive, prev.flex_interactive),++ latency_limited_interactive: val(b.latency_limited_interactive, prev.latency_limited_interactive),++ free_allowance_first: val(b.free_allowance_first, prev.free_allowance_first),++ };++ const t = now();++ await db.prepare(`INSERT INTO owner_inference_rules (id, private_training, flex_interactive, latency_limited_interactive, free_allowance_first, updated_by, updated_at)++ VALUES ('default', ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET private_training = excluded.private_training, flex_interactive = excluded.flex_interactive,++ latency_limited_interactive = excluded.latency_limited_interactive, free_allowance_first = excluded.free_allowance_first, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)++ .run(next.private_training ? true : false, next.flex_interactive ? true : false, next.latency_limited_interactive ? true : false, next.free_allowance_first ? true : false, actor, t);++ await registry.audit(actor, 'owner_inference.rules', 'owner_inference', 'default', { trace, metadata: next });++ return await getRules();++ }++ async function controlFor(providerKey) {++ return await db.prepare('SELECT * FROM provider_controls WHERE provider_key = ?').get(providerKey);++ }++ async function controls() {++ return await db.prepare('SELECT * FROM provider_controls ORDER BY provider_key').all();++ }++ /** What a free allowance has used this period, from usage_daily. */++ async function allowanceUsedFor(providerKey, since = null) {++ const row = since++ ? await db.prepare('SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ? AND day >= ?').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);++ }++ /**++ * Every provider with a trait the wave added, the owner's control of it, and the allowance that is left.++ * A provider with no traits row is not repeated here: nothing about it changed.++ */++ async function providerState() {++ const seen = new Map((await controls()).map((r2) => [r2.provider_key, r2]));++ const out = [];++ for (const p of await registry.listProviders()) {++ const t = (p.metadata && p.metadata.traits) || null;++ if (!t && !seen.has(p.key)) continue;++ const c = seen.get(p.key) || null;++ let allowance = null;++ if (t && t.free_allowance) {++ const used = await allowanceUsedFor(p.key, traits.allowancePeriodStart(p, clock.now()));++ allowance = traits.allowanceLeft(p, { used, now: clock.now() });++ }++ let endpoint = null;++ if (c && c.base_url) {++ const hourly = Number(c.hourly_usd);++ const throughput = Number(c.throughput_tokens_per_hour);++ endpoint = {++ base_url: c.base_url, model_key: c.model_key || null, hourly_usd: Number.isFinite(hourly) ? hourly : null,++ throughput_tokens_per_hour: Number.isFinite(throughput) ? throughput : null,++ usd_per_mtok: Number.isFinite(hourly) && hourly > 0 && Number.isFinite(throughput) && throughput > 0 ? (hourly * 1e6) / throughput : null,++ };++ }++ out.push({++ key: p.key, display_name: p.display_name, status: p.status, base_url: p.base_url,++ enabled: c && c.enabled != null ? !!c.enabled : p.status === 'active',++ tier: c && c.tier ? c.tier : null,++ traits: t, data_policy: (p.metadata && p.metadata.data_policy) || null, privacy: (p.metadata && p.metadata.privacy) || null,++ allowance, endpoint, updated_at: c ? c.updated_at : null,++ });++ }++ return out;++ }++ /** Turn one provider on or off, and choose the tier the owner wants it asked for. */++ async function setProvider(input, { actor = 'system', trace = null } = {}) {++ const key = String((input || {}).provider_key || '').trim();++ const p = key ? await registry.getProvider(key) : null;++ if (!p) throw new AiError(404, 'ai.not_found', `no provider ${key || '(empty)'}`);++ const tier = (input || {}).tier == null || (input || {}).tier === '' ? null : String((input || {}).tier);++ if (tier != null && !['standard', 'flex'].includes(tier)) throw new AiError(422, 'ai.invalid', 'tier must be standard or flex');++ const hasEnabled = typeof (input || {}).enabled === 'boolean';++ const prev = await controlFor(key);++ const t = now();++ await db.prepare(`INSERT INTO provider_controls (provider_key, enabled, tier, base_url, model_key, hourly_usd, throughput_tokens_per_hour, updated_by, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(provider_key) DO UPDATE SET enabled = excluded.enabled, tier = excluded.tier, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)++ .run(key, hasEnabled ? (input.enabled ? true : false) : (prev ? prev.enabled : null), tier,++ prev ? prev.base_url : null, prev ? prev.model_key : null, prev ? prev.hourly_usd : null, prev ? prev.throughput_tokens_per_hour : null, actor, t);++ if (hasEnabled) await registry.upsertProvider({ key, status: input.enabled ? 'active' : 'disabled' }, { actor, trace });++ await registry.audit(actor, 'provider.control', 'provider', key, { trace, metadata: { enabled: hasEnabled ? input.enabled === true : (prev ? prev.enabled : null), tier } });++ return (await providerState()).find((r2) => r2.key === key) || null;++ }++ /**++ * The owner's own OpenAI-compatible endpoint on a rented GPU: its URL, model, hourly price and measured++ * throughput. The key is saved in the provider's sealed key slot, never here. The model is priced per++ * million tokens from the hour and the throughput (server/gpu/token-cost.js does the same arithmetic).++ */++ async function setEndpoint(input, { actor = 'system', trace = null } = {}) {++ const b = input || {};++ let url = null;++ try { url = new URL(String(b.base_url || '')); } catch { /* refused below */ }++ if (!url || url.protocol !== 'https:' || url.username || url.password) throw new AiError(422, 'ai.invalid', 'base_url must be an https URL');++ const model = String(b.model_key || '').trim();++ if (!model || model.length > 200) throw new AiError(422, 'ai.invalid', 'model_key is required');++ const hourly = Number(b.hourly_usd);++ const throughput = Number(b.throughput_tokens_per_hour);++ if (!Number.isFinite(hourly) || hourly <= 0 || hourly > 1000) throw new AiError(422, 'ai.invalid', 'hourly_usd must be a positive dollar amount');++ if (!Number.isInteger(throughput) || throughput <= 0) throw new AiError(422, 'ai.invalid', 'throughput_tokens_per_hour must be a positive whole number of tokens an hour');++ const key = 'runpod-vllm';++ const provider = await registry.getProvider(key);++ if (!provider) throw new AiError(404, 'ai.not_found', `no provider ${key}`);++ await registry.upsertProvider({++ key, base_url: url.href.replace(/\/+$/, ''), default_model: model, status: 'disabled', auth_mode: 'bearer', secret_ref: `vault:${key}`,++ capabilities: ['chat', 'tools'],++ }, { actor, trace });++ const usdPerMtok = (hourly * 1e6) / throughput;++ await registry.upsertModel({++ provider_key: key, model_key: model, display_name: `${model} (my endpoint)`, type: 'chat', status: 'active',++ context_window: b.context_window ? Number(b.context_window) : null, max_output: b.max_output ? Number(b.max_output) : null,++ cost: { in_per_mtok: usdPerMtok, out_per_mtok: usdPerMtok, cached_per_mtok: null, source: url.href, verified_at: new Date(clock.now()).toISOString().slice(0, 10) },++ supports: { json: true, tools: true, streaming: true, vision: false },++ metadata: { catalog: 'owner-endpoint', hourly_usd: hourly, throughput_tokens_per_hour: throughput, compute_provider: 'runpod', throughput_source: 'owner-measured' },++ }, { actor, trace });++ const prev = await controlFor(key);++ const t = now();++ await db.prepare(`INSERT INTO provider_controls (provider_key, enabled, tier, base_url, model_key, hourly_usd, throughput_tokens_per_hour, updated_by, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(provider_key) DO UPDATE SET base_url = excluded.base_url, model_key = excluded.model_key,++ hourly_usd = excluded.hourly_usd, throughput_tokens_per_hour = excluded.throughput_tokens_per_hour, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)++ .run(key, prev ? prev.enabled : false, prev ? prev.tier : null, url.href, model, hourly, throughput, actor, t);++ await registry.audit(actor, 'provider.endpoint', 'provider', key, { trace, metadata: { base_url: url.href, model_key: model, hourly_usd: hourly, throughput_tokens_per_hour: throughput, usd_per_mtok: Number(usdPerMtok.toFixed(6)) } });++ return (await providerState()).find((r2) => r2.key === key) || null;++ }++ /** The capacity we rent ourselves and the owner's endpoint choice. */++ async function compute() {++ const c = await controlFor('runpod-vllm');++ const hourly = c ? Number(c.hourly_usd) : null;++ const throughput = c ? Number(c.throughput_tokens_per_hour) : null;++ return {++ providers: computeProviders.COMPUTE_PROVIDERS,++ endpoint: c && c.base_url ? {++ base_url: c.base_url, model_key: c.model_key || null,++ hourly_usd: Number.isFinite(hourly) ? hourly : null,++ throughput_tokens_per_hour: Number.isFinite(throughput) ? throughput : null,++ usd_per_mtok: Number.isFinite(hourly) && hourly > 0 && Number.isFinite(throughput) && throughput > 0 ? (hourly * 1e6) / throughput : null,++ key_state: 'see the provider key slot',++ } : null,++ checked: computeProviders.CHECKED,++ };++ }++ /** The confirmed readings bundled with this release, as pending price-book changes for the owner to accept. */++ async function confirmedPrices() {++ try { return JSON.parse(fs.readFileSync(CONFIRMED_PRICES_FILE, 'utf8')); }++ catch { return []; }++ }++// ── Costs (24 h / 7 d) and savings (7 d) ───────────────────async function costs(window = '7d') {const days = window === '24h' ? 1 : 7;@@@ -262,7 +434,8 @@ function createOwnerConfig({ db, registry, clock = { now: () => Date.now() } } =};}-- return { KINDS, STRATEGIES, getCaps, setCap, capHit, spendFor, getRouting, setRouting, routingFor, getEconomy, economyFor, setEconomy, costs, savings };++ return { KINDS, STRATEGIES, RULE_DEFAULTS, getCaps, setCap, capHit, spendFor, getRouting, setRouting, routingFor, getEconomy, economyFor, setEconomy,++ getRules, setRules, controlFor, controls, providerState, setProvider, setEndpoint, compute, confirmedPrices, costs, savings };}module.exports = { createOwnerConfig, KINDS, STRATEGIES, capabilityOfRun, workflowKind };@@@ -378,12 +378,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noasync function adapter(key) {const p = await registry.getProvider(key);if (!p) return null;-- const stamp = `${p.updated_at}|${p.secret_ref}|${providerSecret(p, env) ? 1 : 0}`;++ // The owner's own endpoint URL (Runpod vLLM) is stored with the controls, never in the catalogue:++ // the provider record keeps its own origin, and the owner's URL is what the adapter calls.++ const control = ownerSettings && ownerSettings.controlFor ? await ownerSettings.controlFor(key).catch(() => null) : null;++ const record = control && control.base_url ? { ...p, base_url: control.base_url } : p;++ const stamp = `${p.updated_at}|${p.secret_ref}|${providerSecret(p, env) ? 1 : 0}|${control ? control.updated_at : ''}`;const hit = await cache.get(key);-- if (hit && hit.stamp === stamp) return { record: p, adapter: hit.adapter };-- const a = build(p);++ if (hit && hit.stamp === stamp) return { record, adapter: hit.adapter };++ const a = build(record);cache.set(key, { stamp, adapter: a });-- return { record: p, adapter: a };++ return { record, adapter: a };}// ── Circuit breaker (per provider + model) ─────────────@@@ -710,7 +714,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no* 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 rules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;++ if (rules && rules.free_allowance_first === false) return list;++ const privateRequest = !(rules && rules.private_training === true) && traits.isPrivateRequest(ctx);const first = [];const rest = [];for (const c of list) {@@@ -740,6 +746,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noasync function tierFor({ p, model, operation, at }) {if (!p) return 'standard';if (isLocalModel(p)) return 'local';++ // The owner's tier choice for this provider (migration 0075) is the first word; a provider that does++ // not sell Flex keeps the standard tier rather than being asked for a tier it refused.++ const control = ownerSettings && ownerSettings.controlFor ? await ownerSettings.controlFor(p.key).catch(() => null) : null;++ if (control && control.tier === 'standard') return 'standard';++ if (control && control.tier === 'flex' && tiers.TIERED_OPS.has(operation)) {++ const row = model ? await registry.getModel(p.key, model) : null;++ if (tiers.multiplierOf(p, 'flex') != null || (row && row.metadata && row.metadata.service_tier === 'flex')) return 'flex';++ return 'standard';++ }if (tiers.TIERED_OPS.has(operation) && tiers.multiplierOf(p, 'flex') != null) return 'flex';if (offPeak && model) {const off = await offPeak.priceAt(p.key, model, at);@@@ -913,6 +928,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst accessExcluded = [];const traitExcluded = [];const latencyClass = latencyClassOf(route, ctx);++ const rules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;++ const allowLimitedInteractive = Boolean(rules && rules.latency_limited_interactive === true);++ const allowFlexInteractive = Boolean(rules && rules.flex_interactive === true);++ const allowPrivateTraining = Boolean(rules && rules.private_training === true);// 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);@@@ -935,9 +954,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// 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)) {++ if (!traits.latencyAllows(p, row, latencyClass, 'standard', { allowLimitedInteractive, allowFlexInteractive })) {traitReason = `latency_class ${latencyClass} not offered (${(traits.latencyClassesOf(p, row) || []).join('/') || 'interactive only'})`;-- } else if (traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {++ } else if (!allowPrivateTraining && 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;@@@ -1085,6 +1104,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noasync function pinnedCandidates(route, features, ctx = {}) {const list = [route.primary, ...(route.fallbacks || [])].filter(c => c && c.provider);const explain = { objective: null, reasons: ['pinned route: the primary, then its fallbacks'], selected: null, candidates: [] };++ const rules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;++ const allowLimitedInteractive = Boolean(rules && rules.latency_limited_interactive === true);++ const allowFlexInteractive = Boolean(rules && rules.flex_interactive === true);++ const allowPrivateTraining = Boolean(rules && rules.private_training === true);const order = [];for (const c of list) {const entry = await adapter(c.provider);@@@ -1095,8 +1118,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst 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 latencyOk = p ? traits.latencyAllows(p, row, (ctx.latencyClass && ['interactive', 'background', 'batch'].includes(ctx.latencyClass)) ? ctx.latencyClass : ((route.constraints && route.constraints.latency_class) || 'interactive'), 'standard', { allowLimitedInteractive, allowFlexInteractive }) : false;++ const privateOk = p ? !(!allowPrivateTraining && 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)}` : !latencyOk ? 'latency_class'@@@ -1868,6 +1891,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// order stays the router's own (the authority keeps critical work); what changes is the tier each// candidate runs on, and that is decided per candidate below.const latencyClass = latency.isClass(ctx.latencyClass) ? ctx.latencyClass : latency.DEFAULT;++ // The owner's routing rules (migration 0075): the safe value is the default when no row exists.++ const inferenceRules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;++ const allowLimitedInteractive = Boolean(inferenceRules && inferenceRules.latency_limited_interactive === true);++ const allowFlexInteractive = Boolean(inferenceRules && inferenceRules.flex_interactive === true);++ const allowPrivateTraining = Boolean(inferenceRules && inferenceRules.private_training === true);if (latencyClass !== latency.DEFAULT && !exact && !ordered) {list = await orderForWaiting(list, { route, operation, req });explain.latency_class = latencyClass;@@@ -1886,14 +1914,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no// 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)) {++ if (p && !traits.latencyAllows(p, traitRow, latencyClass, 'standard', { allowLimitedInteractive, allowFlexInteractive })) {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)) {++ if (p && !allowPrivateTraining && 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' });@@@ -664,13 +664,14 @@ const PROVIDERS = [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 },++ // The confirmed price is a pending price-book change, not a live price: the owner accepts it.++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },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',++ 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; pending price-book change); pinning it with no fallback keeps the price honest',}],},...[@@@ -854,11 +855,12 @@ const PROVIDERS = [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 },++ // The confirmed price is a pending price-book change, not a live price: the owner accepts it.++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },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',++ note: 'refusals removed; 512K context; prepaid balance (crypto accepted); the 18+ floor still runs before it; $0.15 in / $0.60 out / $0.01 cached is a pending price-book change',}],},{@@@ -884,13 +886,35 @@ const PROVIDERS = [}),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 },++ // The confirmed list price is a pending price-book change, not a live price: the owner accepts it.++ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },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',++ note: 'launch price is $0.10 in / $0.35 out with $0.01 cached and no published end date; the $0.20/$0.70 list price is a pending price-book change so a budget does not assume the discount lasts',}],},++ {++ // The owner's own rented GPU (part 3): an OpenAI-compatible vLLM endpoint on Runpod. The base URL and++ // the model are the owner's, set on the owner inference page; the key goes in the provider's key slot.++ // Nothing is rented here, and no price is seeded: the price is the GPU hour, converted to tokens at++ // the measured throughput when the owner saves the endpoint.++ key: 'runpod-vllm', keys_url: 'https://console.runpod.io/', display_name: 'Runpod vLLM (my endpoint)',++ base_url: 'https://api.runpod.ai/v2', default_model: null, timeout_ms: 180000,++ docs_url: 'https://docs.runpod.io/serverless/vllm/overview', capabilities: ['chat', 'tools'],++ provider_access: 'OWN_DEPLOYMENT', priority: 30,++ traits: traits({++ latency_classes: ['background', 'batch'], payment: 'hourly', compute_provider: 'runpod',++ source: 'https://www.runpod.io/pricing', checked: '2026-11-10',++ }),++ privacy: privacy({++ label: 'anonymized', terms_url: 'https://docs.runpod.io/',++ wording: 'the endpoint runs on a rented GPU the owner controls; the host may see memory, so no zero-retention promise is made',++ checked: '2026-11-10',++ }),++ data_policy: policy({ trains: false, retention: null, trust: 'first-party', region: null, retention_note: 'vLLM keeps no prompt log by default; the rented host is the open risk', terms: 'https://docs.runpod.io/', checked: '2026-11-10' }),++ models: [],++ },{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,@@@ -40,10 +40,25 @@ function latencyClassesOf(record, model, tier = 'standard') {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);++/**++ * May this offer answer a request of that class? An unknown class is interactive (the safe default).++ *++ * The two owner switches of part 5 are the only way past a provider's own class list:++ * allowLimitedInteractive a background/batch-only provider may answer interactively++ * allowFlexInteractive a Flex offer may answer interactively++ * Both default false and are read from owner_inference_rules by the pool.++ */++function latencyAllows(record, model, latencyClass = 'interactive', tier = 'standard', { allowLimitedInteractive = false, allowFlexInteractive = false } = {}) {const asked = CLASSES.includes(latencyClass) ? latencyClass : 'interactive';++ const m = (model && model.metadata) || null;++ const flexModel = Boolean(m && String(m.service_tier || '').toLowerCase() === 'flex');++ if (asked === 'interactive' && flexModel && !allowFlexInteractive) return false;++ if (allowLimitedInteractive) return true;++ // The owner allowed the Flex offer in front of a person: only the provider's own classes still apply.++ const t = traitsOf(record);++ const classes = flexModel && allowFlexInteractive++ ? ((t && t.latency_classes) || null)++ : latencyClassesOf(record, model, tier);return !classes || classes.includes(asked);}@@@ -310,6 +310,13 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi// (constraints.latency_class); a caller may ask for a SLOWER class with options.latency and never a// faster one: a background workflow stays in the background whatever the caller asks for.const workflowClass = latency.ofRoute(route);++ // The Exchange API's service tier (2026-11-10): flex asks for the slower, cheaper tier the router already++ // knows as the background class. Any other value is refused rather than ignored.++ const askedTier = body.service_tier == null ? null : String(body.service_tier).trim().toLowerCase();++ if (askedTier != null && !['auto', 'default', 'standard', 'flex', 'priority'].includes(askedTier)) {++ throw new AiError(422, 'input.invalid', 'service_tier must be auto, standard or flex');++ }++ if (askedTier === 'flex' && options.latency == null) options.latency = 'background';const askedLatency = options.latency != null ? options.latency : null;const resolvedLatency = latency.resolve(workflowClass, askedLatency);if (resolvedLatency.error) throw new AiError(422, 'input.invalid', resolvedLatency.error, { latency_class: workflowClass });@@@ -36,7 +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',++ iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai', 'runpod-vllm': 'runpod.io',};const onItsOwnSite = (url, key) => {const host = new URL(url).hostname;@@@ -50,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, 54);++ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 55);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,132 @@++'use strict';++/**++ * The owner's controls over the provider wave (migration 0075), the rented capacity, and the confirmed++ * prices as pending price-book changes.++ *++ * - every new provider is listed with its traits, its allowance left and its end date, off by default;++ * - the owner can turn one on/off and choose standard/flex, audited, and a staff member without++ * ai.provider.manage is refused;++ * - the routing rules start safe and the owner's row is what the pool reads;++ * - the owner's own Runpod vLLM endpoint is priced per million tokens from the hour and the measured++ * throughput, with its key left in the sealed key slot;++ * - the confirmed readings enter the price book as pending changes; none is applied without the owner.++ */++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++const { createPriceStore } = require('../server/pricebook/store');++const { proposePriceChanges } = require('../server/pricebook/propose');++const compute = require('../server/gpu/compute-providers');++++const t = suite('provider-controls');++const OWNER = token('owner', ['ai.provider.manage', 'ai.usage.read']);++const STAFF = token('staff', ['ai.run.read']);++++let h;++t.test('boot and read the new provider controls', async () => {++ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });++ const view = await request(h.base, 'GET', '/api/v1/admin/console/owner-inference', { tok: token('svc', ALL) });++ assert.strictEqual(view.status, 200, view.text);++ const sable = view.body.controls.find((p) => p.key === 'sable');++ assert.ok(sable, 'Sable is listed');++ assert.strictEqual(sable.enabled, false, 'off');++ assert.strictEqual(sable.traits.payment, 'crypto-prepaid');++ assert.strictEqual(sable.data_policy.retention, 'limited');++ assert.ok(sable.traits.source.includes('sableapi.com'), 'the source travels with the trait');++ assert.strictEqual(sable.traits.checked, '2026-11-10');++ const itera = view.body.controls.find((p) => p.key === 'iteracompute');++ assert.deepStrictEqual(itera.traits.latency_classes, ['background', 'batch']);++ assert.strictEqual(itera.traits.concurrency, 1);++ assert.strictEqual(itera.allowance.left, 30000000, 'the allowance is unspent');++ assert.strictEqual(itera.allowance.ends_at, '2026-11-24T08:00:00Z');++ assert.deepStrictEqual(view.body.rules, { private_training: false, flex_interactive: false, latency_limited_interactive: false, free_allowance_first: true, updated_by: null, updated_at: null });++ assert.strictEqual(view.body.compute.providers.length, 2, 'Runpod and Vast');++ assert.strictEqual(view.body.compute.providers.find((p) => p.key === 'runpod').pods.find((g) => g.gpu === 'RTX 3090 24GB').hourly_usd, 0.5);++ assert.strictEqual(view.body.pricebook.confirmed.length, 4, 'four confirmed readings are bundled');++});++++t.test('only the owner turns a provider on or chooses its tier; the write is audited', async () => {++ const refused = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: STAFF, body: { provider_key: 'sable', enabled: true } });++ assert.strictEqual(refused.status, 403, 'staff refused');++ const on = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'sable', enabled: true, tier: 'standard' } });++ assert.strictEqual(on.status, 200, on.text);++ assert.strictEqual(on.body.provider.enabled, true);++ assert.strictEqual(on.body.provider.tier, 'standard');++ const row = await h.registry.getProvider('sable');++ assert.strictEqual(row.status, 'active');++ const audit = await h.registry.listAudit({ action: 'provider.control', limit: 5 });++ assert.ok(audit.some((a) => a.target_id === 'sable'), 'the change is audited');++ const off = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'sable', enabled: false } });++ assert.strictEqual(off.body.provider.enabled, false);++ assert.strictEqual((await h.registry.getProvider('sable')).status, 'disabled');++ const bad = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'sable', tier: 'priority' } });++ assert.strictEqual(bad.status, 422, 'an unknown tier is refused');++ const missing = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'nope', enabled: true } });++ assert.strictEqual(missing.status, 404);++});++++t.test('the rules are saved, audited, and the pool reads them', async () => {++ const saved = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/rules', { tok: OWNER, body: { latency_limited_interactive: true } });++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.body.rules.latency_limited_interactive, true);++ assert.strictEqual(saved.body.rules.private_training, false, 'the other safe defaults hold');++ // The pool now lets a background-only provider answer interactively.++ const limited = await h.registry.upsertProvider({ key: 'limited-ok', kind: 'stub', status: 'active', auth_mode: 'none', capabilities: ['chat'], default_model: 'm',++ metadata: { traits: { latency_classes: ['background'], source: 'https://example.test', checked: '2026-11-10' } } });++ await h.registry.upsertModel({ provider_key: limited.key, model_key: 'm', status: 'active', cost: { in_per_mtok: 0, out_per_mtok: 0 }, metadata: {} });++ const run = await h.pool.execute({ key: 'rule-route', primary: { provider: 'limited-ok', model: 'm' }, fallbacks: [] }, 'chat', { messages: [{ role: 'user', content: 'hi' }], maxTokens: 5 });++ assert.strictEqual(run.provider, 'limited-ok', 'the owner switch let it answer');++ const back = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/rules', { tok: OWNER, body: { latency_limited_interactive: false } });++ assert.strictEqual(back.body.rules.latency_limited_interactive, false, 'back to the safe value');++});++++t.test('the owner endpoint is priced per million tokens from the hour and the throughput, keyless', async () => {++ const bad = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/endpoint', { tok: OWNER, body: { base_url: 'http://insecure.test/v1', model_key: 'm', hourly_usd: 1, throughput_tokens_per_hour: 1000 } });++ assert.strictEqual(bad.status, 422, 'plain http refused');++ const out = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/endpoint', { tok: OWNER, body: { base_url: 'https://pod-abc-8000.proxy.runpod.io/v1', model_key: 'deepseek-v4.1-flash', hourly_usd: 0.5, throughput_tokens_per_hour: 1000000 } });++ assert.strictEqual(out.status, 200, out.text);++ assert.strictEqual(out.body.provider.endpoint.hourly_usd, 0.5);++ assert.strictEqual(out.body.provider.endpoint.usd_per_mtok, 0.5, '$0.50 an hour at 1M tokens an hour is $0.50 a million');++ const provider = await h.registry.getProvider('runpod-vllm');++ assert.strictEqual(provider.base_url, 'https://pod-abc-8000.proxy.runpod.io/v1');++ assert.strictEqual(provider.secret_ref, 'vault:runpod-vllm', 'the key slot, never a value');++ const model = await h.registry.getModel('runpod-vllm', 'deepseek-v4.1-flash');++ assert.strictEqual(model.cost.in_per_mtok, 0.5);++ assert.strictEqual(model.metadata.hourly_usd, 0.5);++ const view = await request(h.base, 'GET', '/api/v1/admin/console/owner-inference', { tok: token('svc', ALL) });++ assert.strictEqual(view.body.compute.endpoint.usd_per_mtok, 0.5, 'the compute block shows the same arithmetic');++ assert.ok(!JSON.stringify(view.body).includes('vault:'), 'no secret reference is echoed as a value');++});++++t.test('the confirmed readings become pending changes; nothing is applied automatically', async () => {++ const view = await request(h.base, 'GET', '/api/v1/admin/console/owner-inference', { tok: token('svc', ALL) });++ const proposals = view.body.pricebook.confirmed;++ const store = createPriceStore({ db: h.db });++ const dry = await proposePriceChanges({ registry: h.registry, store, proposals, dryRun: true });++ assert.ok(dry.proposed.some((p) => p.provider_key === 'sable'), 'the Sable reading is proposed');++ assert.ok(dry.proposed.some((p) => p.provider_key === 'inception'), 'the Inception reading is proposed');++ assert.ok(dry.proposed.some((p) => p.provider_key === 'openrouter'), 'the OpenRouter Particle Flex reading is proposed');++ const sable = dry.proposed.find((p) => p.provider_key === 'sable');++ assert.strictEqual(sable.fields.find((f) => f.field === 'in_per_mtok').next, 0.15);++ assert.match(sable.source, /sableapi\.com/, 'the provider page is the source');++ assert.strictEqual(sable.checked_at, '2026-11-10');++ const stillNull = await h.registry.getModel('sable', 'deepseek-v4.1-flash-1017-uncensored');++ assert.strictEqual(stillNull.cost.in_per_mtok, null, 'a dry run writes no price');++ const out = await proposePriceChanges({ registry: h.registry, store, proposals, actor: 'test' });++ assert.ok(out.proposed.length >= 3, 'the readings are written as pending');++ const pending = await store.pending(20);++ assert.ok(pending.some((p) => p.provider_key === 'sable' && p.status === 'pending'), 'pending until the owner decides');++ const live = await h.registry.getModel('sable', 'deepseek-v4.1-flash-1017-uncensored');++ assert.strictEqual(live.cost.in_per_mtok, null, 'the live price is still unchanged');++});++++t.test('the rented capacity records name their prices and the startup credit note', () => {++ const runpod = compute.get('runpod');++ assert.strictEqual(compute.cheapest(runpod), 0.27);++ assert.strictEqual(runpod.credits.starter_usd, 1000);++ assert.match(runpod.credits.note, /nothing was applied/i);++ const vast = compute.get('vast');++ assert.strictEqual(vast.traits.latency_classes[0], 'background', 'spot capacity is background only');++ assert.strictEqual(vast.pods.find((g) => g.gpu.includes('3090')).spot_usd, 0.12);++});++++t.run(() => h && h.stop());@@@ -45,7 +45,7 @@ t.test('each new provider is a record, off, keyless, with its traits, price andassert.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.deepStrictEqual([sableModel.cost.in_per_mtok, sableModel.cost.out_per_mtok], [null, null], 'the confirmed price waits in the price book');assert.strictEqual(sableModel.metadata.uncensored, true);assert.strictEqual(sableModel.supports.tools && sableModel.supports.vision, true, 'tools and vision');@@@ -54,7 +54,7 @@ t.test('each new provider is a record, off, keyless, with its traits, price andassert.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.strictEqual(mercury.cost.in_per_mtok, null, 'the confirmed price waits in the price book');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');@@@ -63,7 +63,8 @@ t.test('each new provider is a record, off, keyless, with its traits, price andconst 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.strictEqual(pinned.cost.in_per_mtok, null, 'the Particle Flex price waits in the price book');++ assert.match(pinned.metadata.note, /pending price-book change/);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');@@@ -187,4 +188,18 @@ t.test('a caller may name its data policy, and a bad one is refused', async () =assert.strictEqual(bad.status, 422, bad.text);});++t.test('the Exchange API accepts service_tier flex and a data policy', async () => {++ const h2 = await boot({ env: { AI_STUB_FALLBACK: 'true', AI_FREE_PROVIDERS: 'stub' } });++ const tok = token('exchange', ALL);++ try {++ const badTier = await request(h2.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hi' }], service_tier: 'turbo' } });++ assert.strictEqual(badTier.status, 400, badTier.text);++ const badPolicy = await request(h2.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hi' }], data_policy: 'secret' } });++ assert.strictEqual(badPolicy.status, 400, badPolicy.text);++ const run = await request(h2.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hi' }], service_tier: 'flex', data_policy: 'anonymized' } });++ assert.strictEqual(run.status, 200, run.text);++ assert.ok(run.body && run.body.choices && run.body.choices.length, 'the flex request answered');++ } finally { await h2.stop(); }++});++t.run(() => h && h.stop());
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+-- phase: expand
+-- The owner's provider controls and the routing rules of the 2026-11-10 provider wave.
+--
+-- provider_controls: one row per provider the owner changed. `enabled` null follows the provider's own
+-- status; `tier` is the service tier the owner wants (standard or flex); `base_url`, `model_key`,
+-- `hourly_usd` and `throughput_tokens_per_hour` are the owner's own OpenAI-compatible endpoint (the rented
+-- Runpod/Vast vLLM), whose key lives in the provider's sealed key slot, never here.
+--
+-- owner_inference_rules: one row, 'default'. Every switch starts at the safe value:
+-- private_training false a provider that trains on or retains prompts never gets private data
+-- flex_interactive false a Flex offer never answers an interactive request
+-- latency_limited_interactive false a background/batch-only provider never answers interactively
+-- free_allowance_first true spend a free allowance before paid capacity
+-- The hard floor and the 18+ gate are not here: they are not switches (server/policy/).
+
+CREATE TABLE IF NOT EXISTS provider_controls (
+ provider_key text COLLATE "C" PRIMARY KEY,
+ enabled boolean,
+ tier text COLLATE "C",
+ base_url text COLLATE "C",
+ model_key text COLLATE "C",
+ hourly_usd double precision,
+ throughput_tokens_per_hour bigint,
+ updated_by text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS owner_inference_rules (
+ id text COLLATE "C" PRIMARY KEY,
+ private_training boolean NOT NULL DEFAULT false,
+ flex_interactive boolean NOT NULL DEFAULT false,
+ latency_limited_interactive boolean NOT NULL DEFAULT false,
+ free_allowance_first boolean NOT NULL DEFAULT true,
+ updated_by text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+[
+ {
+ "provider_key": "sable",
+ "model_key": "deepseek-v4.1-flash-1017-uncensored",
+ "display_name": "DeepSeek V4.1 Flash 1017 (uncensored)",
+ "type": "chat",
+ "in_per_mtok": 0.15,
+ "cached_per_mtok": 0.01,
+ "out_per_mtok": 0.6,
+ "context_window": 512000,
+ "source": "https://sableapi.com/models",
+ "checked_at": "2026-11-10"
+ },
+ {
+ "provider_key": "inception",
+ "model_key": "mercury-3",
+ "display_name": "Mercury 3",
+ "type": "chat",
+ "in_per_mtok": 0.2,
+ "cached_per_mtok": 0.02,
+ "out_per_mtok": 0.7,
+ "context_window": 1000000,
+ "source": "https://docs.inceptionlabs.ai/get-started/models",
+ "checked_at": "2026-11-10"
+ },
+ {
+ "provider_key": "openrouter",
+ "model_key": "deepseek/deepseek-v4.1-flash-1017",
+ "display_name": "DeepSeek V4.1 Flash 1017 (Particle Flex)",
+ "type": "chat",
+ "in_per_mtok": 0.025,
+ "cached_per_mtok": 0.003,
+ "out_per_mtok": 0.25,
+ "context_window": 1048576,
+ "source": "https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints",
+ "checked_at": "2026-11-10"
+ },
+ {
+ "provider_key": "iteracompute",
+ "model_key": "Auto",
+ "display_name": "Auto (free shared pool)",
+ "type": "chat",
+ "in_per_mtok": 0,
+ "out_per_mtok": 0,
+ "context_window": 131072,
+ "source": "https://iteracompute.com/free/",
+ "checked_at": "2026-11-10"
+ }
+]
@@ -117,6 +117,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -117,6 +117,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
spend_cap: caps.find((c) => c.provider_key === p.key) || null,
spend_cap: caps.find((c) => c.provider_key === p.key) || null,
spend: spend.find((s) => s.provider_key === p.key) || null })),
spend: spend.find((s) => s.provider_key === p.key) || null })),
models: (await registry.listModels()).map((m) => { const p = providers.find((x) => x.key === m.provider_key); return modelView(m, p); }),
models: (await registry.listModels()).map((m) => { const p = providers.find((x) => x.key === m.provider_key); return modelView(m, p); }),
+ controls: await ownerConfig.providerState(), rules: await ownerConfig.getRules(),
+ compute: await ownerConfig.compute(),
+ pricebook: { confirmed: await ownerConfig.confirmedPrices() },
prices, caps, routing: await ownerConfig.getRouting(), economy: await ownerConfig.getEconomy(),
prices, caps, routing: await ownerConfig.getRouting(), economy: await ownerConfig.getEconomy(),
costs_24h: await ownerConfig.costs('24h'), costs_7d: await ownerConfig.costs('7d'), kinds,
costs_24h: await ownerConfig.costs('24h'), costs_7d: await ownerConfig.costs('7d'), kinds,
};
};
@@ -138,6 +141,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -138,6 +141,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
r.post('/api/v1/admin/console/owner-inference/spend-caps', json, write, wrap(async (req) => ({ cap: await ownerConfig.setCap(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/spend-caps', json, write, wrap(async (req) => ({ cap: await ownerConfig.setCap(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/routing', json, write, wrap(async (req) => ({ routing: await ownerConfig.setRouting(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/routing', json, write, wrap(async (req) => ({ routing: await ownerConfig.setRouting(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/economy', json, write, wrap(async (req) => ({ economy: await ownerConfig.setEconomy(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/owner-inference/economy', json, write, wrap(async (req) => ({ economy: await ownerConfig.setEconomy(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
+ r.post('/api/v1/admin/console/owner-inference/providers', json, write, wrap(async (req) => ({ provider: await ownerConfig.setProvider(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
+ r.post('/api/v1/admin/console/owner-inference/rules', json, write, wrap(async (req) => ({ rules: await ownerConfig.setRules(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
+ r.post('/api/v1/admin/console/owner-inference/endpoint', json, write, wrap(async (req) => ({ provider: await ownerConfig.setEndpoint(req.body || {}, { actor: actor(req), trace: req.ov && req.ov.traceId }) })));
r.post('/api/v1/admin/console/providers/:key/key', json, write, wrap(async (req) => {
r.post('/api/v1/admin/console/providers/:key/key', json, write, wrap(async (req) => {
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
const provider = await registry.getProvider(req.params.key);
const provider = await registry.getProvider(req.params.key);
@@ -236,6 +236,9 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -236,6 +236,9 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
if (b.prefer) body = { ...body, prefer: b.prefer };
if (b.prefer) body = { ...body, prefer: b.prefer };
// The Exchange remake's content policy (`policy: "uncensored"`), enforced by the router.
// The Exchange remake's content policy (`policy: "uncensored"`), enforced by the router.
if (b.policy != null) body = { ...body, policy: b.policy };
if (b.policy != null) body = { ...body, policy: b.policy };
+ // The Exchange API's service tier and the caller's data policy (2026-11-10).
+ if (b.service_tier != null) body = { ...body, service_tier: b.service_tier };
+ if (b.data_policy != null) body = { ...body, data_policy: b.data_policy };
if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };
if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
const own = credentialFor(req);
const own = credentialFor(req);
@@ -222,6 +222,10 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -222,6 +222,10 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
// The Exchange remake's content policy (`policy: "uncensored"`): an OpenVibe extension, enforced by the router
// The Exchange remake's content policy (`policy: "uncensored"`): an OpenVibe extension, enforced by the router
// (18+ account, uncensored offers only, the floor, the label). on_behalf_of names the person it is for.
// (18+ account, uncensored offers only, the floor, the label). on_behalf_of names the person it is for.
if (b.policy != null) body = { ...body, policy: b.policy };
if (b.policy != null) body = { ...body, policy: b.policy };
+ // The Exchange API's service tier and the caller's data policy (2026-11-10): flex asks for the slower,
+ // cheaper tier; private keeps the prompt off every provider that trains on or retains it.
+ if (b.service_tier != null) body = { ...body, service_tier: b.service_tier };
+ if (b.data_policy != null) body = { ...body, data_policy: b.data_policy };
if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };
if (b.on_behalf_of != null) body = { ...body, on_behalf_of: b.on_behalf_of };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
if (req.principal.kind === 'app') body = { ...body, attribution: appAttribution(req.principal) };
// A first-party service naming the person it acts for (the runs API's on_behalf_of). Their free allowance
// A first-party service naming the person it acts for (the runs API's on_behalf_of). Their free allowance
@@ -16,6 +16,7 @@ const rates = require('../providers/rate-cards');
@@ -16,6 +16,7 @@ const rates = require('../providers/rate-cards');
const upstream = require('../providers/upstream');
const upstream = require('../providers/upstream');
const observed = require('./observed');
const observed = require('./observed');
const privacy = require('./privacy');
const privacy = require('./privacy');
+const traits = require('../providers/traits');
const { allowsOffer } = require('../free-tier');
const { allowsOffer } = require('../free-tier');
const { offerStatus } = require('../providers/failure');
const { offerStatus } = require('../providers/failure');
@@ -187,6 +188,23 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -187,6 +188,23 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
const refusalRows = await db.prepare('SELECT provider_key, model_key, refusal_rate, prompt_set, measured_at, measured_on FROM refusal_rates').all().catch(() => []);
const refusalRows = await db.prepare('SELECT provider_key, model_key, refusal_rate, prompt_set, measured_at, measured_on FROM refusal_rates').all().catch(() => []);
const refusalBy = new Map((refusalRows || []).map((r) => [`${r.provider_key}/${r.model_key}`, r]));
const refusalBy = new Map((refusalRows || []).map((r) => [`${r.provider_key}/${r.model_key}`, r]));
+ // The free allowance a provider states, and what it has used this period (usage_daily): the Exchange
+ // shows the tokens that are left and their end date, beside the offer.
+ const allowanceUsedCache = new Map();
+ const allowanceUsedFor = async (providerKey, since) => {
+ const cacheKey = `${providerKey}|${since || ''}`;
+ if (allowanceUsedCache.has(cacheKey)) return allowanceUsedCache.get(cacheKey);
+ let used = 0;
+ try {
+ const row = since
+ ? await db.prepare('SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ? AND day >= ?').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);
+ used = Number((row && row.t) || 0);
+ } catch { used = 0; }
+ allowanceUsedCache.set(cacheKey, used);
+ return used;
+ };
+
const groups = new Map();
const groups = new Map();
const drafts = [];
const drafts = [];
for (const row of await registry.listModels()) {
for (const row of await registry.listModels()) {
@@ -215,6 +233,11 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -215,6 +233,11 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// The refusal rate of this offer, as the refusal job measured it.
// The refusal rate of this offer, as the refusal job measured it.
const refusal = refusalBy.get(`${row.provider_key}/${row.model_key}`) || null;
const refusal = refusalBy.get(`${row.provider_key}/${row.model_key}`) || null;
const health = statusFor(provider.key, row.model_key);
const health = statusFor(provider.key, row.model_key);
+ const providerTraits = (provider.metadata && provider.metadata.traits) || null;
+ const allowanceRecord = traits.freeAllowanceOf(provider);
+ const allowance = allowanceRecord
+ ? traits.allowanceLeft(provider, { used: await allowanceUsedFor(provider.key, traits.allowancePeriodStart(provider, now)), now })
+ : null;
const offer = {
const offer = {
provider: up.key,
provider: up.key,
provider_name: up.name,
provider_name: up.name,
@@ -244,6 +267,16 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -244,6 +267,16 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
price_checked_at: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? checkedAt : null,
price_checked_at: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? checkedAt : null,
// The same day in words: what the catalog shows under the price is when somebody read it.
// The same day in words: what the catalog shows under the price is when somebody read it.
price_checked: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? `price checked ${checkedAt}` : null,
price_checked: checkedAt && checkedAt !== rates.UNKNOWN_DATE ? `price checked ${checkedAt}` : null,
+ // The tier the offer runs on: an offer that names Flex (the OpenRouter Particle pin) says so;
+ // a provider that sells a Flex tier is marked with the tier, never assumed to be serving it.
+ service_tier: (row.metadata && row.metadata.service_tier) || null,
+ // The classes of work the offer may answer. null means every class (the usual case).
+ latency_class: traits.latencyClassesOf(provider, row),
+ // The free allowance the provider states: tokens left, the period and its end date.
+ free_allowance: allowance ? {
+ tokens: allowance.tokens, used: allowance.used, left: allowance.left, period: allowance.period,
+ shared: allowance.shared, ends_at: allowance.ends_at, ended: allowance.ended,
+ } : null,
data_policy: policyOf(provider),
data_policy: policyOf(provider),
// The privacy label of this offer, from the same facts the router uses (server/exchange/privacy.js):
// The privacy label of this offer, from the same facts the router uses (server/exchange/privacy.js):
// Private (zero retention, no training, or the host's own deployment), Anonymized, Community.
// Private (zero retention, no training, or the host's own deployment), Anonymized, Community.
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * The capacity we rent ourselves (2026-11-10, part 3 of the provider wave): Runpod and Vast.ai GPU hours,
+ * read from the providers' own public pages on the day named, and the owner's choice to run an open model
+ * on one of them through an OpenAI-compatible vLLM endpoint.
+ *
+ * Nothing here rents, starts or stops anything, and no account is created: the provisioner
+ * (server/gpu/provisioner.js) does that only when the owner asks, with the owner's key. The per-million
+ * token price of a rented card is hourly USD × 1,000,000 / the model's measured tokens an hour
+ * (server/gpu/token-cost.js); a number nobody measured is not invented here.
+ */
+const CHECKED = '2026-11-10';
+
+const COMPUTE_PROVIDERS = Object.freeze([
+ {
+ key: 'runpod',
+ display_name: 'Runpod',
+ kind: 'gpu',
+ billing: 'Pods per second; serverless per second rounded up from worker start to full stop',
+ docs_url: 'https://docs.runpod.io/',
+ endpoint: {
+ kind: 'openai-compatible vLLM',
+ base_url_pattern: 'https://{pod_id}-{port}.proxy.runpod.io/v1 or https://api.runpod.ai/v2/{endpoint_id}/openai/v1',
+ note: 'the owner adds the endpoint URL and its key; the price is the GPU hour, shown per million tokens from the measured throughput',
+ },
+ pods: [
+ { gpu: 'RTX A5000 24GB', hourly_usd: 0.27, vram_mb: 24576 },
+ { gpu: 'RTX 3090 24GB', hourly_usd: 0.50, vram_mb: 24576 },
+ { gpu: 'A40 48GB', hourly_usd: 0.59, vram_mb: 49152 },
+ { gpu: 'RTX 4090 24GB', hourly_usd: 0.89, vram_mb: 24576 },
+ { gpu: 'L40S 48GB', hourly_usd: 1.09, vram_mb: 49152 },
+ { gpu: 'A100 80GB', hourly_usd: 1.79, vram_mb: 81920 },
+ { gpu: 'H100 80GB PCIe', hourly_usd: 2.89, vram_mb: 81920 },
+ ],
+ serverless: [
+ { gpu: 'RTX 5090 32GB', hourly_usd: 1.58 },
+ { gpu: 'H200 141GB', hourly_usd: 5.93 },
+ { gpu: 'B200 192GB', hourly_usd: 8.64 },
+ ],
+ storage: { container_usd_per_gb_month: 0.10, network_volume_usd_per_gb_month: 0.07, egress: 'no ingress/egress fees reported by the providers we read' },
+ credits: {
+ starter_usd: 1000, starter_eligibility: 'raised under $1M', venture_usd: 5000, venture_eligibility: 'backed by a verified VC (a16z, Neo, South Park Commons), raised under $5M',
+ growth: [{ commit_usd: 25000, bonus_usd: 10000 }, { commit_usd: 50000, bonus_usd: 25000 }],
+ expires: 'one year after issue', note: 'the owner applies; nothing was applied for here',
+ },
+ source: 'https://www.runpod.io/pricing',
+ credits_source: 'https://www.runpod.io/startup-program',
+ checked: CHECKED,
+ },
+ {
+ key: 'vast',
+ display_name: 'Vast.ai',
+ kind: 'gpu',
+ billing: 'per second on demand; interruptible offers are preemptible',
+ docs_url: 'https://docs.vast.ai/',
+ endpoint: {
+ kind: 'openai-compatible vLLM or llama.cpp',
+ base_url_pattern: 'http://{host}:{port}/v1',
+ note: 'the owner adds the instance URL and its key; spot capacity is interruptible, so it is background only',
+ },
+ // On-demand median and the spot floor from the public GPU index; both are marketplace readings, not a
+ // list price. The price book's live reader (server/gpu/market.js) replaces them when it next runs.
+ pods: [
+ { gpu: 'RTX 3090 24GB', hourly_usd: 0.177, spot_usd: 0.12, vram_mb: 24576 },
+ { gpu: 'RTX 4090 24GB', hourly_usd: 0.470, spot_usd: 0.14, vram_mb: 24576 },
+ { gpu: 'L40S 48GB', hourly_usd: 0.734, vram_mb: 49152 },
+ { gpu: 'A100 80GB', hourly_usd: 0.843, spot_usd: null, vram_mb: 81920 },
+ { gpu: 'H100 80GB', hourly_usd: 2.401, spot_usd: 1.73, vram_mb: 81920 },
+ { gpu: 'H200 141GB', hourly_usd: 5.054, vram_mb: 141312 },
+ { gpu: 'B200 192GB', hourly_usd: 8.751, vram_mb: 196608 },
+ ],
+ storage: null,
+ credits: { match: 'dollar-for-dollar credit match on spend', note: 'the owner applies; nothing was applied for here' },
+ source: 'https://www.darkcompute.cfd/gpu-index',
+ credits_source: 'https://www.vast.ai/startup',
+ checked: CHECKED,
+ traits: { latency_classes: ['background', 'batch'], interruptible: true },
+ },
+]);
+
+const byKey = new Map(COMPUTE_PROVIDERS.map((p) => [p.key, p]));
+
+/** One record, or null. */
+function get(key) {
+ return byKey.get(String(key || '')) || null;
+}
+
+/** The cheapest GPU hour each provider lists, for a one-line summary. */
+function cheapest(provider) {
+ const prices = (provider.pods || []).map((p) => Number(p.hourly_usd)).filter((n) => Number.isFinite(n) && n > 0);
+ return prices.length ? Math.min(...prices) : null;
+}
+
+module.exports = { COMPUTE_PROVIDERS, CHECKED, get, cheapest };
@@ -17,8 +17,12 @@
@@ -17,8 +17,12 @@
* (server/policy/tier.js) are not configurable here and have no switch: they run in the pool
* (server/policy/tier.js) are not configurable here and have no switch: they run in the pool
* before and after every attempt, including an owner's fixed order for uncensored.
* before and after every attempt, including an owner's fixed order for uncensored.
*/
*/
+const fs = require('fs');
+const path = require('path');
const { AiError, parseJson } = require('./util');
const { AiError, parseJson } = require('./util');
const { isUncensored } = require('./providers/pool-providers');
const { isUncensored } = require('./providers/pool-providers');
+const traits = require('./providers/traits');
+const computeProviders = require('./gpu/compute-providers');
const KINDS = ['chat', 'uncensored', 'embed', 'image', 'transcribe', 'batch'];
const KINDS = ['chat', 'uncensored', 'embed', 'image', 'transcribe', 'batch'];
const STRATEGIES = ['auto', 'cheapest', 'fastest', 'fixed'];
const STRATEGIES = ['auto', 'cheapest', 'fastest', 'fixed'];
@@ -29,6 +33,12 @@ const STRATEGIES = ['auto', 'cheapest', 'fastest', 'fixed'];
@@ -29,6 +33,12 @@ const STRATEGIES = ['auto', 'cheapest', 'fastest', 'fixed'];
* less OFF. The uncensored kind never serves a stored answer, so its answer cache reads off.
* less OFF. The uncensored kind never serves a stored answer, so its answer cache reads off.
*/
*/
const ECONOMY_DEFAULTS = Object.freeze({ batches: true, prompt_cache: true, small_first: false, output_limit: null, answer_cache: true });
const ECONOMY_DEFAULTS = Object.freeze({ batches: true, prompt_cache: true, small_first: false, output_limit: null, answer_cache: true });
+/**
+ * The routing rules of the provider wave (migration 0075). Every one starts at its safe value; the owner may
+ * change it on the inference page, and the pool reads the row before each placement.
+ */
+const RULE_DEFAULTS = Object.freeze({ private_training: false, flex_interactive: false, latency_limited_interactive: false, free_allowance_first: true });
+const CONFIRMED_PRICES_FILE = path.join(__dirname, '..', 'scripts', 'pricebook-proposals-2026-11-10b.json');
const economyDefaultsFor = (capability) => ({ ...ECONOMY_DEFAULTS, ...(capability === 'uncensored' ? { answer_cache: false } : {}) });
const economyDefaultsFor = (capability) => ({ ...ECONOMY_DEFAULTS, ...(capability === 'uncensored' ? { answer_cache: false } : {}) });
/** The kind of work a workflow is, from its own steps (server/workflows). */
/** The kind of work a workflow is, from its own steps (server/workflows). */
@@ -193,6 +203,168 @@ function createOwnerConfig({ db, registry, clock = { now: () => Date.now() } } =
@@ -193,6 +203,168 @@ function createOwnerConfig({ db, registry, clock = { now: () => Date.now() } } =
return (await getEconomy()).find((r) => r.capability === capability);
return (await getEconomy()).find((r) => r.capability === capability);
}
}
+ // ── The provider wave's controls (migration 0075) ──────────
+ /** The safe routing rules, merged with the owner's row when there is one. */
+ async function getRules() {
+ const row = await db.prepare("SELECT * FROM owner_inference_rules WHERE id = 'default'").get();
+ return {
+ ...RULE_DEFAULTS,
+ ...(row ? {
+ private_training: !!row.private_training, flex_interactive: !!row.flex_interactive,
+ latency_limited_interactive: !!row.latency_limited_interactive, free_allowance_first: !!row.free_allowance_first,
+ } : {}),
+ updated_by: row ? row.updated_by : null,
+ updated_at: row ? row.updated_at : null,
+ };
+ }
+ async function setRules(input, { actor = 'system', trace = null } = {}) {
+ const b = input || {};
+ const val = (v, fallback) => (typeof v === 'boolean' ? v : fallback);
+ const prev = await getRules();
+ const next = {
+ private_training: val(b.private_training, prev.private_training),
+ flex_interactive: val(b.flex_interactive, prev.flex_interactive),
+ latency_limited_interactive: val(b.latency_limited_interactive, prev.latency_limited_interactive),
+ free_allowance_first: val(b.free_allowance_first, prev.free_allowance_first),
+ };
+ const t = now();
+ await db.prepare(`INSERT INTO owner_inference_rules (id, private_training, flex_interactive, latency_limited_interactive, free_allowance_first, updated_by, updated_at)
+ VALUES ('default', ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET private_training = excluded.private_training, flex_interactive = excluded.flex_interactive,
+ latency_limited_interactive = excluded.latency_limited_interactive, free_allowance_first = excluded.free_allowance_first, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
+ .run(next.private_training ? true : false, next.flex_interactive ? true : false, next.latency_limited_interactive ? true : false, next.free_allowance_first ? true : false, actor, t);
+ await registry.audit(actor, 'owner_inference.rules', 'owner_inference', 'default', { trace, metadata: next });
+ return await getRules();
+ }
+ async function controlFor(providerKey) {
+ return await db.prepare('SELECT * FROM provider_controls WHERE provider_key = ?').get(providerKey);
+ }
+ async function controls() {
+ return await db.prepare('SELECT * FROM provider_controls ORDER BY provider_key').all();
+ }
+ /** What a free allowance has used this period, from usage_daily. */
+ async function allowanceUsedFor(providerKey, since = null) {
+ const row = since
+ ? await db.prepare('SELECT COALESCE(SUM(tokens_in + tokens_out), 0)::bigint AS t FROM usage_daily WHERE provider_key = ? AND day >= ?').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);
+ }
+ /**
+ * Every provider with a trait the wave added, the owner's control of it, and the allowance that is left.
+ * A provider with no traits row is not repeated here: nothing about it changed.
+ */
+ async function providerState() {
+ const seen = new Map((await controls()).map((r2) => [r2.provider_key, r2]));
+ const out = [];
+ for (const p of await registry.listProviders()) {
+ const t = (p.metadata && p.metadata.traits) || null;
+ if (!t && !seen.has(p.key)) continue;
+ const c = seen.get(p.key) || null;
+ let allowance = null;
+ if (t && t.free_allowance) {
+ const used = await allowanceUsedFor(p.key, traits.allowancePeriodStart(p, clock.now()));
+ allowance = traits.allowanceLeft(p, { used, now: clock.now() });
+ }
+ let endpoint = null;
+ if (c && c.base_url) {
+ const hourly = Number(c.hourly_usd);
+ const throughput = Number(c.throughput_tokens_per_hour);
+ endpoint = {
+ base_url: c.base_url, model_key: c.model_key || null, hourly_usd: Number.isFinite(hourly) ? hourly : null,
+ throughput_tokens_per_hour: Number.isFinite(throughput) ? throughput : null,
+ usd_per_mtok: Number.isFinite(hourly) && hourly > 0 && Number.isFinite(throughput) && throughput > 0 ? (hourly * 1e6) / throughput : null,
+ };
+ }
+ out.push({
+ key: p.key, display_name: p.display_name, status: p.status, base_url: p.base_url,
+ enabled: c && c.enabled != null ? !!c.enabled : p.status === 'active',
+ tier: c && c.tier ? c.tier : null,
+ traits: t, data_policy: (p.metadata && p.metadata.data_policy) || null, privacy: (p.metadata && p.metadata.privacy) || null,
+ allowance, endpoint, updated_at: c ? c.updated_at : null,
+ });
+ }
+ return out;
+ }
+ /** Turn one provider on or off, and choose the tier the owner wants it asked for. */
+ async function setProvider(input, { actor = 'system', trace = null } = {}) {
+ const key = String((input || {}).provider_key || '').trim();
+ const p = key ? await registry.getProvider(key) : null;
+ if (!p) throw new AiError(404, 'ai.not_found', `no provider ${key || '(empty)'}`);
+ const tier = (input || {}).tier == null || (input || {}).tier === '' ? null : String((input || {}).tier);
+ if (tier != null && !['standard', 'flex'].includes(tier)) throw new AiError(422, 'ai.invalid', 'tier must be standard or flex');
+ const hasEnabled = typeof (input || {}).enabled === 'boolean';
+ const prev = await controlFor(key);
+ const t = now();
+ await db.prepare(`INSERT INTO provider_controls (provider_key, enabled, tier, base_url, model_key, hourly_usd, throughput_tokens_per_hour, updated_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(provider_key) DO UPDATE SET enabled = excluded.enabled, tier = excluded.tier, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
+ .run(key, hasEnabled ? (input.enabled ? true : false) : (prev ? prev.enabled : null), tier,
+ prev ? prev.base_url : null, prev ? prev.model_key : null, prev ? prev.hourly_usd : null, prev ? prev.throughput_tokens_per_hour : null, actor, t);
+ if (hasEnabled) await registry.upsertProvider({ key, status: input.enabled ? 'active' : 'disabled' }, { actor, trace });
+ await registry.audit(actor, 'provider.control', 'provider', key, { trace, metadata: { enabled: hasEnabled ? input.enabled === true : (prev ? prev.enabled : null), tier } });
+ return (await providerState()).find((r2) => r2.key === key) || null;
+ }
+ /**
+ * The owner's own OpenAI-compatible endpoint on a rented GPU: its URL, model, hourly price and measured
+ * throughput. The key is saved in the provider's sealed key slot, never here. The model is priced per
+ * million tokens from the hour and the throughput (server/gpu/token-cost.js does the same arithmetic).
+ */
+ async function setEndpoint(input, { actor = 'system', trace = null } = {}) {
+ const b = input || {};
+ let url = null;
+ try { url = new URL(String(b.base_url || '')); } catch { /* refused below */ }
+ if (!url || url.protocol !== 'https:' || url.username || url.password) throw new AiError(422, 'ai.invalid', 'base_url must be an https URL');
+ const model = String(b.model_key || '').trim();
+ if (!model || model.length > 200) throw new AiError(422, 'ai.invalid', 'model_key is required');
+ const hourly = Number(b.hourly_usd);
+ const throughput = Number(b.throughput_tokens_per_hour);
+ if (!Number.isFinite(hourly) || hourly <= 0 || hourly > 1000) throw new AiError(422, 'ai.invalid', 'hourly_usd must be a positive dollar amount');
+ if (!Number.isInteger(throughput) || throughput <= 0) throw new AiError(422, 'ai.invalid', 'throughput_tokens_per_hour must be a positive whole number of tokens an hour');
+ const key = 'runpod-vllm';
+ const provider = await registry.getProvider(key);
+ if (!provider) throw new AiError(404, 'ai.not_found', `no provider ${key}`);
+ await registry.upsertProvider({
+ key, base_url: url.href.replace(/\/+$/, ''), default_model: model, status: 'disabled', auth_mode: 'bearer', secret_ref: `vault:${key}`,
+ capabilities: ['chat', 'tools'],
+ }, { actor, trace });
+ const usdPerMtok = (hourly * 1e6) / throughput;
+ await registry.upsertModel({
+ provider_key: key, model_key: model, display_name: `${model} (my endpoint)`, type: 'chat', status: 'active',
+ context_window: b.context_window ? Number(b.context_window) : null, max_output: b.max_output ? Number(b.max_output) : null,
+ cost: { in_per_mtok: usdPerMtok, out_per_mtok: usdPerMtok, cached_per_mtok: null, source: url.href, verified_at: new Date(clock.now()).toISOString().slice(0, 10) },
+ supports: { json: true, tools: true, streaming: true, vision: false },
+ metadata: { catalog: 'owner-endpoint', hourly_usd: hourly, throughput_tokens_per_hour: throughput, compute_provider: 'runpod', throughput_source: 'owner-measured' },
+ }, { actor, trace });
+ const prev = await controlFor(key);
+ const t = now();
+ await db.prepare(`INSERT INTO provider_controls (provider_key, enabled, tier, base_url, model_key, hourly_usd, throughput_tokens_per_hour, updated_by, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(provider_key) DO UPDATE SET base_url = excluded.base_url, model_key = excluded.model_key,
+ hourly_usd = excluded.hourly_usd, throughput_tokens_per_hour = excluded.throughput_tokens_per_hour, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
+ .run(key, prev ? prev.enabled : false, prev ? prev.tier : null, url.href, model, hourly, throughput, actor, t);
+ await registry.audit(actor, 'provider.endpoint', 'provider', key, { trace, metadata: { base_url: url.href, model_key: model, hourly_usd: hourly, throughput_tokens_per_hour: throughput, usd_per_mtok: Number(usdPerMtok.toFixed(6)) } });
+ return (await providerState()).find((r2) => r2.key === key) || null;
+ }
+ /** The capacity we rent ourselves and the owner's endpoint choice. */
+ async function compute() {
+ const c = await controlFor('runpod-vllm');
+ const hourly = c ? Number(c.hourly_usd) : null;
+ const throughput = c ? Number(c.throughput_tokens_per_hour) : null;
+ return {
+ providers: computeProviders.COMPUTE_PROVIDERS,
+ endpoint: c && c.base_url ? {
+ base_url: c.base_url, model_key: c.model_key || null,
+ hourly_usd: Number.isFinite(hourly) ? hourly : null,
+ throughput_tokens_per_hour: Number.isFinite(throughput) ? throughput : null,
+ usd_per_mtok: Number.isFinite(hourly) && hourly > 0 && Number.isFinite(throughput) && throughput > 0 ? (hourly * 1e6) / throughput : null,
+ key_state: 'see the provider key slot',
+ } : null,
+ checked: computeProviders.CHECKED,
+ };
+ }
+ /** The confirmed readings bundled with this release, as pending price-book changes for the owner to accept. */
+ async function confirmedPrices() {
+ try { return JSON.parse(fs.readFileSync(CONFIRMED_PRICES_FILE, 'utf8')); }
+ catch { return []; }
+ }
+
// ── Costs (24 h / 7 d) and savings (7 d) ───────────────────
// ── Costs (24 h / 7 d) and savings (7 d) ───────────────────
async function costs(window = '7d') {
async function costs(window = '7d') {
const days = window === '24h' ? 1 : 7;
const days = window === '24h' ? 1 : 7;
@@ -262,7 +434,8 @@ function createOwnerConfig({ db, registry, clock = { now: () => Date.now() } } =
@@ -262,7 +434,8 @@ function createOwnerConfig({ db, registry, clock = { now: () => Date.now() } } =
};
};
}
}
- return { KINDS, STRATEGIES, getCaps, setCap, capHit, spendFor, getRouting, setRouting, routingFor, getEconomy, economyFor, setEconomy, costs, savings };
+ return { KINDS, STRATEGIES, RULE_DEFAULTS, getCaps, setCap, capHit, spendFor, getRouting, setRouting, routingFor, getEconomy, economyFor, setEconomy,
+ getRules, setRules, controlFor, controls, providerState, setProvider, setEndpoint, compute, confirmedPrices, costs, savings };
}
}
module.exports = { createOwnerConfig, KINDS, STRATEGIES, capabilityOfRun, workflowKind };
module.exports = { createOwnerConfig, KINDS, STRATEGIES, capabilityOfRun, workflowKind };
@@ -378,12 +378,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -378,12 +378,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function adapter(key) {
async function adapter(key) {
const p = await registry.getProvider(key);
const p = await registry.getProvider(key);
if (!p) return null;
if (!p) return null;
- const stamp = `${p.updated_at}|${p.secret_ref}|${providerSecret(p, env) ? 1 : 0}`;
+ // The owner's own endpoint URL (Runpod vLLM) is stored with the controls, never in the catalogue:
+ // the provider record keeps its own origin, and the owner's URL is what the adapter calls.
+ const control = ownerSettings && ownerSettings.controlFor ? await ownerSettings.controlFor(key).catch(() => null) : null;
+ const record = control && control.base_url ? { ...p, base_url: control.base_url } : p;
+ const stamp = `${p.updated_at}|${p.secret_ref}|${providerSecret(p, env) ? 1 : 0}|${control ? control.updated_at : ''}`;
const hit = await cache.get(key);
const hit = await cache.get(key);
- if (hit && hit.stamp === stamp) return { record: p, adapter: hit.adapter };
- const a = build(p);
+ if (hit && hit.stamp === stamp) return { record, adapter: hit.adapter };
+ const a = build(record);
cache.set(key, { stamp, adapter: a });
cache.set(key, { stamp, adapter: a });
- return { record: p, adapter: a };
+ return { record, adapter: a };
}
}
// ── Circuit breaker (per provider + model) ─────────────
// ── Circuit breaker (per provider + model) ─────────────
@@ -710,7 +714,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -710,7 +714,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
* moved ahead for a person's private data when the provider trains on or retains a prompt.
* moved ahead for a person's private data when the provider trains on or retains a prompt.
*/
*/
async function orderFreeAllowance(list, ctx = {}) {
async function orderFreeAllowance(list, ctx = {}) {
- const privateRequest = traits.isPrivateRequest(ctx);
+ const rules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;
+ if (rules && rules.free_allowance_first === false) return list;
+ const privateRequest = !(rules && rules.private_training === true) && traits.isPrivateRequest(ctx);
const first = [];
const first = [];
const rest = [];
const rest = [];
for (const c of list) {
for (const c of list) {
@@ -740,6 +746,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -740,6 +746,15 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function tierFor({ p, model, operation, at }) {
async function tierFor({ p, model, operation, at }) {
if (!p) return 'standard';
if (!p) return 'standard';
if (isLocalModel(p)) return 'local';
if (isLocalModel(p)) return 'local';
+ // The owner's tier choice for this provider (migration 0075) is the first word; a provider that does
+ // not sell Flex keeps the standard tier rather than being asked for a tier it refused.
+ const control = ownerSettings && ownerSettings.controlFor ? await ownerSettings.controlFor(p.key).catch(() => null) : null;
+ if (control && control.tier === 'standard') return 'standard';
+ if (control && control.tier === 'flex' && tiers.TIERED_OPS.has(operation)) {
+ const row = model ? await registry.getModel(p.key, model) : null;
+ if (tiers.multiplierOf(p, 'flex') != null || (row && row.metadata && row.metadata.service_tier === 'flex')) return 'flex';
+ return 'standard';
+ }
if (tiers.TIERED_OPS.has(operation) && tiers.multiplierOf(p, 'flex') != null) return 'flex';
if (tiers.TIERED_OPS.has(operation) && tiers.multiplierOf(p, 'flex') != null) return 'flex';
if (offPeak && model) {
if (offPeak && model) {
const off = await offPeak.priceAt(p.key, model, at);
const off = await offPeak.priceAt(p.key, model, at);
@@ -913,6 +928,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -913,6 +928,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const accessExcluded = [];
const accessExcluded = [];
const traitExcluded = [];
const traitExcluded = [];
const latencyClass = latencyClassOf(route, ctx);
const latencyClass = latencyClassOf(route, ctx);
+ const rules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;
+ const allowLimitedInteractive = Boolean(rules && rules.latency_limited_interactive === true);
+ const allowFlexInteractive = Boolean(rules && rules.flex_interactive === true);
+ const allowPrivateTraining = Boolean(rules && rules.private_training === true);
// 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);
@@ -935,9 +954,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -935,9 +954,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// A trait keeps the offer out of the call, not out of placement: it stays in the pool with its
// 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.
// reason (status down, like a credential-less offer), so explain has one entry per placement offer.
let traitReason = null;
let traitReason = null;
- if (!traits.latencyAllows(p, row, latencyClass)) {
+ if (!traits.latencyAllows(p, row, latencyClass, 'standard', { allowLimitedInteractive, allowFlexInteractive })) {
traitReason = `latency_class ${latencyClass} not offered (${(traits.latencyClassesOf(p, row) || []).join('/') || 'interactive only'})`;
traitReason = `latency_class ${latencyClass} not offered (${(traits.latencyClassesOf(p, row) || []).join('/') || 'interactive only'})`;
- } else if (traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {
+ } else if (!allowPrivateTraining && traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {
traitReason = 'private data: the provider trains on or retains prompts';
traitReason = 'private data: the provider trains on or retains prompts';
} else {
} else {
const allowance = traits.freeAllowanceOf(p) ? traits.allowanceLeft(p, { used: await allowanceUsed(p.key, p), now: clock.now() }) : null;
const allowance = traits.freeAllowanceOf(p) ? traits.allowanceLeft(p, { used: await allowanceUsed(p.key, p), now: clock.now() }) : null;
@@ -1085,6 +1104,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1085,6 +1104,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function pinnedCandidates(route, features, ctx = {}) {
async function pinnedCandidates(route, features, ctx = {}) {
const list = [route.primary, ...(route.fallbacks || [])].filter(c => c && c.provider);
const list = [route.primary, ...(route.fallbacks || [])].filter(c => c && c.provider);
const explain = { objective: null, reasons: ['pinned route: the primary, then its fallbacks'], selected: null, candidates: [] };
const explain = { objective: null, reasons: ['pinned route: the primary, then its fallbacks'], selected: null, candidates: [] };
+ const rules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;
+ const allowLimitedInteractive = Boolean(rules && rules.latency_limited_interactive === true);
+ const allowFlexInteractive = Boolean(rules && rules.flex_interactive === true);
+ const allowPrivateTraining = Boolean(rules && rules.private_training === true);
const order = [];
const order = [];
for (const c of list) {
for (const c of list) {
const entry = await adapter(c.provider);
const entry = await adapter(c.provider);
@@ -1095,8 +1118,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1095,8 +1118,8 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
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 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 latencyOk = p ? traits.latencyAllows(p, row, (ctx.latencyClass && ['interactive', 'background', 'batch'].includes(ctx.latencyClass)) ? ctx.latencyClass : ((route.constraints && route.constraints.latency_class) || 'interactive'), 'standard', { allowLimitedInteractive, allowFlexInteractive }) : false;
+ const privateOk = p ? !(!allowPrivateTraining && 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)}` : !latencyOk ? 'latency_class'
: !access ? `provider_access ${modelCatalog.accessOf(p)}` : !latencyOk ? 'latency_class'
@@ -1868,6 +1891,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1868,6 +1891,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// order stays the router's own (the authority keeps critical work); what changes is the tier each
// order stays the router's own (the authority keeps critical work); what changes is the tier each
// candidate runs on, and that is decided per candidate below.
// candidate runs on, and that is decided per candidate below.
const latencyClass = latency.isClass(ctx.latencyClass) ? ctx.latencyClass : latency.DEFAULT;
const latencyClass = latency.isClass(ctx.latencyClass) ? ctx.latencyClass : latency.DEFAULT;
+ // The owner's routing rules (migration 0075): the safe value is the default when no row exists.
+ const inferenceRules = ownerSettings ? await ownerSettings.getRules().catch(() => null) : null;
+ const allowLimitedInteractive = Boolean(inferenceRules && inferenceRules.latency_limited_interactive === true);
+ const allowFlexInteractive = Boolean(inferenceRules && inferenceRules.flex_interactive === true);
+ const allowPrivateTraining = Boolean(inferenceRules && inferenceRules.private_training === true);
if (latencyClass !== latency.DEFAULT && !exact && !ordered) {
if (latencyClass !== latency.DEFAULT && !exact && !ordered) {
list = await orderForWaiting(list, { route, operation, req });
list = await orderForWaiting(list, { route, operation, req });
explain.latency_class = latencyClass;
explain.latency_class = latencyClass;
@@ -1886,14 +1914,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1886,14 +1914,14 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// private data it would train on or retain, or a concurrency cap already reached. Each is a
// 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.
// skip with its reason, never an error: the next offer answers.
const traitRow = p && model ? await registry.getModel(p.key, model) : null;
const traitRow = p && model ? await registry.getModel(p.key, model) : null;
- if (p && !traits.latencyAllows(p, traitRow, latencyClass)) {
+ if (p && !traits.latencyAllows(p, traitRow, latencyClass, 'standard', { allowLimitedInteractive, allowFlexInteractive })) {
statFor(c.provider).skipped++;
statFor(c.provider).skipped++;
tried.push({ provider: c.provider, skipped: 'latency_class' });
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' });
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' });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'latency_class' });
continue;
continue;
}
}
- if (p && traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {
+ if (p && !allowPrivateTraining && traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) {
statFor(c.provider).skipped++;
statFor(c.provider).skipped++;
tried.push({ provider: c.provider, skipped: 'private_data_training' });
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' });
attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the provider trains on or retains prompts', status: 'skipped', error: 'private_data_training' });
@@ -664,13 +664,14 @@ const PROVIDERS = [
@@ -664,13 +664,14 @@ const PROVIDERS = [
model_key: 'deepseek/deepseek-v4.1-flash-1017',
model_key: 'deepseek/deepseek-v4.1-flash-1017',
display_name: 'DeepSeek V4.1 Flash 1017 (Particle Flex)', family: 'deepseek',
display_name: 'DeepSeek V4.1 Flash 1017 (Particle Flex)', family: 'deepseek',
context_window: 1048576, max_output: 131072,
context_window: 1048576, max_output: 131072,
- cost: { in_per_mtok: 0.025, cached_per_mtok: 0.003, out_per_mtok: 0.25 },
+ // The confirmed price is a pending price-book change, not a live price: the owner accepts it.
+ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
source: 'https://openrouter.ai/api/v1/models/deepseek/deepseek-v4.1-flash-1017/endpoints', checked: '2026-11-10',
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 },
supports: { json: true, tools: true, streaming: true, vision: true },
quantization: null, refusal_rate: null,
quantization: null, refusal_rate: null,
service_tier: 'flex',
service_tier: 'flex',
routing: { only: ['particle-ai'], allow_fallbacks: false, service_tier: 'flex', data_collection: 'deny' },
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',
+ 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; pending price-book change); pinning it with no fallback keeps the price honest',
}],
}],
},
},
...[
...[
@@ -854,11 +855,12 @@ const PROVIDERS = [
@@ -854,11 +855,12 @@ const PROVIDERS = [
models: [{
models: [{
model_key: 'deepseek-v4.1-flash-1017-uncensored', display_name: 'DeepSeek V4.1 Flash 1017 (uncensored)',
model_key: 'deepseek-v4.1-flash-1017-uncensored', display_name: 'DeepSeek V4.1 Flash 1017 (uncensored)',
family: 'deepseek', context_window: 512000,
family: 'deepseek', context_window: 512000,
- cost: { in_per_mtok: 0.15, cached_per_mtok: 0.01, out_per_mtok: 0.60 },
+ // The confirmed price is a pending price-book change, not a live price: the owner accepts it.
+ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
source: 'https://sableapi.com/models', checked: '2026-11-10',
source: 'https://sableapi.com/models', checked: '2026-11-10',
supports: { json: true, tools: true, streaming: true, vision: true },
supports: { json: true, tools: true, streaming: true, vision: true },
quantization: null, refusal_rate: null, uncensored: true,
quantization: null, refusal_rate: null, uncensored: true,
- note: 'refusals removed; 512K context; prepaid balance (crypto accepted); the 18+ floor still runs before it',
+ note: 'refusals removed; 512K context; prepaid balance (crypto accepted); the 18+ floor still runs before it; $0.15 in / $0.60 out / $0.01 cached is a pending price-book change',
}],
}],
},
},
{
{
@@ -884,13 +886,35 @@ const PROVIDERS = [
@@ -884,13 +886,35 @@ const PROVIDERS = [
}),
}),
models: [{
models: [{
model_key: 'mercury-3', display_name: 'Mercury 3', family: 'mercury', context_window: 1000000, max_output: 131072,
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 },
+ // The confirmed list price is a pending price-book change, not a live price: the owner accepts it.
+ cost: { in_per_mtok: null, cached_per_mtok: null, out_per_mtok: null },
source: 'https://docs.inceptionlabs.ai/get-started/models', checked: '2026-11-10',
source: 'https://docs.inceptionlabs.ai/get-started/models', checked: '2026-11-10',
supports: { json: true, tools: true, streaming: true, vision: false },
supports: { json: true, tools: true, streaming: true, vision: false },
quantization: null, refusal_rate: null,
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',
+ note: 'launch price is $0.10 in / $0.35 out with $0.01 cached and no published end date; the $0.20/$0.70 list price is a pending price-book change so a budget does not assume the discount lasts',
}],
}],
},
},
+ {
+ // The owner's own rented GPU (part 3): an OpenAI-compatible vLLM endpoint on Runpod. The base URL and
+ // the model are the owner's, set on the owner inference page; the key goes in the provider's key slot.
+ // Nothing is rented here, and no price is seeded: the price is the GPU hour, converted to tokens at
+ // the measured throughput when the owner saves the endpoint.
+ key: 'runpod-vllm', keys_url: 'https://console.runpod.io/', display_name: 'Runpod vLLM (my endpoint)',
+ base_url: 'https://api.runpod.ai/v2', default_model: null, timeout_ms: 180000,
+ docs_url: 'https://docs.runpod.io/serverless/vllm/overview', capabilities: ['chat', 'tools'],
+ provider_access: 'OWN_DEPLOYMENT', priority: 30,
+ traits: traits({
+ latency_classes: ['background', 'batch'], payment: 'hourly', compute_provider: 'runpod',
+ source: 'https://www.runpod.io/pricing', checked: '2026-11-10',
+ }),
+ privacy: privacy({
+ label: 'anonymized', terms_url: 'https://docs.runpod.io/',
+ wording: 'the endpoint runs on a rented GPU the owner controls; the host may see memory, so no zero-retention promise is made',
+ checked: '2026-11-10',
+ }),
+ data_policy: policy({ trains: false, retention: null, trust: 'first-party', region: null, retention_note: 'vLLM keeps no prompt log by default; the rented host is the open risk', terms: 'https://docs.runpod.io/', checked: '2026-11-10' }),
+ models: [],
+ },
{
{
key: 'dosai', keys_url: 'https://platform.dos.ai', display_name: 'DOS.AI',
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,
base_url: 'https://api.dos.ai/v1', default_model: 'deepseek-v4.1-flash', timeout_ms: 900000,
@@ -40,10 +40,25 @@ function latencyClassesOf(record, model, tier = 'standard') {
@@ -40,10 +40,25 @@ function latencyClassesOf(record, model, tier = 'standard') {
return null;
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);
+/**
+ * May this offer answer a request of that class? An unknown class is interactive (the safe default).
+ *
+ * The two owner switches of part 5 are the only way past a provider's own class list:
+ * allowLimitedInteractive a background/batch-only provider may answer interactively
+ * allowFlexInteractive a Flex offer may answer interactively
+ * Both default false and are read from owner_inference_rules by the pool.
+ */
+function latencyAllows(record, model, latencyClass = 'interactive', tier = 'standard', { allowLimitedInteractive = false, allowFlexInteractive = false } = {}) {
const asked = CLASSES.includes(latencyClass) ? latencyClass : 'interactive';
const asked = CLASSES.includes(latencyClass) ? latencyClass : 'interactive';
+ const m = (model && model.metadata) || null;
+ const flexModel = Boolean(m && String(m.service_tier || '').toLowerCase() === 'flex');
+ if (asked === 'interactive' && flexModel && !allowFlexInteractive) return false;
+ if (allowLimitedInteractive) return true;
+ // The owner allowed the Flex offer in front of a person: only the provider's own classes still apply.
+ const t = traitsOf(record);
+ const classes = flexModel && allowFlexInteractive
+ ? ((t && t.latency_classes) || null)
+ : latencyClassesOf(record, model, tier);
return !classes || classes.includes(asked);
return !classes || classes.includes(asked);
}
}
@@ -310,6 +310,13 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
@@ -310,6 +310,13 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
// (constraints.latency_class); a caller may ask for a SLOWER class with options.latency and never a
// (constraints.latency_class); a caller may ask for a SLOWER class with options.latency and never a
// faster one: a background workflow stays in the background whatever the caller asks for.
// faster one: a background workflow stays in the background whatever the caller asks for.
const workflowClass = latency.ofRoute(route);
const workflowClass = latency.ofRoute(route);
+ // The Exchange API's service tier (2026-11-10): flex asks for the slower, cheaper tier the router already
+ // knows as the background class. Any other value is refused rather than ignored.
+ const askedTier = body.service_tier == null ? null : String(body.service_tier).trim().toLowerCase();
+ if (askedTier != null && !['auto', 'default', 'standard', 'flex', 'priority'].includes(askedTier)) {
+ throw new AiError(422, 'input.invalid', 'service_tier must be auto, standard or flex');
+ }
+ if (askedTier === 'flex' && options.latency == null) options.latency = 'background';
const askedLatency = options.latency != null ? options.latency : null;
const askedLatency = options.latency != null ? options.latency : null;
const resolvedLatency = latency.resolve(workflowClass, askedLatency);
const resolvedLatency = latency.resolve(workflowClass, askedLatency);
if (resolvedLatency.error) throw new AiError(422, 'input.invalid', resolvedLatency.error, { latency_class: workflowClass });
if (resolvedLatency.error) throw new AiError(422, 'input.invalid', resolvedLatency.error, { latency_class: workflowClass });
@@ -36,7 +36,7 @@ const DOMAIN = {
@@ -36,7 +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',
+ iteracompute: 'iteracompute.com', sable: 'sableapi.com', inception: 'inceptionlabs.ai', dosai: 'dos.ai', 'runpod-vllm': 'runpod.io',
};
};
const onItsOwnSite = (url, key) => {
const onItsOwnSite = (url, key) => {
const host = new URL(url).hostname;
const host = new URL(url).hostname;
@@ -50,7 +50,7 @@ t.test('provider presets are OpenAI-compatible, with a base URL, a timeout, an a
@@ -50,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, 54);
+ assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 55);
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,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * The owner's controls over the provider wave (migration 0075), the rented capacity, and the confirmed
+ * prices as pending price-book changes.
+ *
+ * - every new provider is listed with its traits, its allowance left and its end date, off by default;
+ * - the owner can turn one on/off and choose standard/flex, audited, and a staff member without
+ * ai.provider.manage is refused;
+ * - the routing rules start safe and the owner's row is what the pool reads;
+ * - the owner's own Runpod vLLM endpoint is priced per million tokens from the hour and the measured
+ * throughput, with its key left in the sealed key slot;
+ * - the confirmed readings enter the price book as pending changes; none is applied without the owner.
+ */
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+const { createPriceStore } = require('../server/pricebook/store');
+const { proposePriceChanges } = require('../server/pricebook/propose');
+const compute = require('../server/gpu/compute-providers');
+
+const t = suite('provider-controls');
+const OWNER = token('owner', ['ai.provider.manage', 'ai.usage.read']);
+const STAFF = token('staff', ['ai.run.read']);
+
+let h;
+t.test('boot and read the new provider controls', async () => {
+ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
+ const view = await request(h.base, 'GET', '/api/v1/admin/console/owner-inference', { tok: token('svc', ALL) });
+ assert.strictEqual(view.status, 200, view.text);
+ const sable = view.body.controls.find((p) => p.key === 'sable');
+ assert.ok(sable, 'Sable is listed');
+ assert.strictEqual(sable.enabled, false, 'off');
+ assert.strictEqual(sable.traits.payment, 'crypto-prepaid');
+ assert.strictEqual(sable.data_policy.retention, 'limited');
+ assert.ok(sable.traits.source.includes('sableapi.com'), 'the source travels with the trait');
+ assert.strictEqual(sable.traits.checked, '2026-11-10');
+ const itera = view.body.controls.find((p) => p.key === 'iteracompute');
+ assert.deepStrictEqual(itera.traits.latency_classes, ['background', 'batch']);
+ assert.strictEqual(itera.traits.concurrency, 1);
+ assert.strictEqual(itera.allowance.left, 30000000, 'the allowance is unspent');
+ assert.strictEqual(itera.allowance.ends_at, '2026-11-24T08:00:00Z');
+ assert.deepStrictEqual(view.body.rules, { private_training: false, flex_interactive: false, latency_limited_interactive: false, free_allowance_first: true, updated_by: null, updated_at: null });
+ assert.strictEqual(view.body.compute.providers.length, 2, 'Runpod and Vast');
+ assert.strictEqual(view.body.compute.providers.find((p) => p.key === 'runpod').pods.find((g) => g.gpu === 'RTX 3090 24GB').hourly_usd, 0.5);
+ assert.strictEqual(view.body.pricebook.confirmed.length, 4, 'four confirmed readings are bundled');
+});
+
+t.test('only the owner turns a provider on or chooses its tier; the write is audited', async () => {
+ const refused = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: STAFF, body: { provider_key: 'sable', enabled: true } });
+ assert.strictEqual(refused.status, 403, 'staff refused');
+ const on = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'sable', enabled: true, tier: 'standard' } });
+ assert.strictEqual(on.status, 200, on.text);
+ assert.strictEqual(on.body.provider.enabled, true);
+ assert.strictEqual(on.body.provider.tier, 'standard');
+ const row = await h.registry.getProvider('sable');
+ assert.strictEqual(row.status, 'active');
+ const audit = await h.registry.listAudit({ action: 'provider.control', limit: 5 });
+ assert.ok(audit.some((a) => a.target_id === 'sable'), 'the change is audited');
+ const off = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'sable', enabled: false } });
+ assert.strictEqual(off.body.provider.enabled, false);
+ assert.strictEqual((await h.registry.getProvider('sable')).status, 'disabled');
+ const bad = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'sable', tier: 'priority' } });
+ assert.strictEqual(bad.status, 422, 'an unknown tier is refused');
+ const missing = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/providers', { tok: OWNER, body: { provider_key: 'nope', enabled: true } });
+ assert.strictEqual(missing.status, 404);
+});
+
+t.test('the rules are saved, audited, and the pool reads them', async () => {
+ const saved = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/rules', { tok: OWNER, body: { latency_limited_interactive: true } });
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.body.rules.latency_limited_interactive, true);
+ assert.strictEqual(saved.body.rules.private_training, false, 'the other safe defaults hold');
+ // The pool now lets a background-only provider answer interactively.
+ const limited = await h.registry.upsertProvider({ key: 'limited-ok', kind: 'stub', status: 'active', auth_mode: 'none', capabilities: ['chat'], default_model: 'm',
+ metadata: { traits: { latency_classes: ['background'], source: 'https://example.test', checked: '2026-11-10' } } });
+ await h.registry.upsertModel({ provider_key: limited.key, model_key: 'm', status: 'active', cost: { in_per_mtok: 0, out_per_mtok: 0 }, metadata: {} });
+ const run = await h.pool.execute({ key: 'rule-route', primary: { provider: 'limited-ok', model: 'm' }, fallbacks: [] }, 'chat', { messages: [{ role: 'user', content: 'hi' }], maxTokens: 5 });
+ assert.strictEqual(run.provider, 'limited-ok', 'the owner switch let it answer');
+ const back = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/rules', { tok: OWNER, body: { latency_limited_interactive: false } });
+ assert.strictEqual(back.body.rules.latency_limited_interactive, false, 'back to the safe value');
+});
+
+t.test('the owner endpoint is priced per million tokens from the hour and the throughput, keyless', async () => {
+ const bad = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/endpoint', { tok: OWNER, body: { base_url: 'http://insecure.test/v1', model_key: 'm', hourly_usd: 1, throughput_tokens_per_hour: 1000 } });
+ assert.strictEqual(bad.status, 422, 'plain http refused');
+ const out = await request(h.base, 'POST', '/api/v1/admin/console/owner-inference/endpoint', { tok: OWNER, body: { base_url: 'https://pod-abc-8000.proxy.runpod.io/v1', model_key: 'deepseek-v4.1-flash', hourly_usd: 0.5, throughput_tokens_per_hour: 1000000 } });
+ assert.strictEqual(out.status, 200, out.text);
+ assert.strictEqual(out.body.provider.endpoint.hourly_usd, 0.5);
+ assert.strictEqual(out.body.provider.endpoint.usd_per_mtok, 0.5, '$0.50 an hour at 1M tokens an hour is $0.50 a million');
+ const provider = await h.registry.getProvider('runpod-vllm');
+ assert.strictEqual(provider.base_url, 'https://pod-abc-8000.proxy.runpod.io/v1');
+ assert.strictEqual(provider.secret_ref, 'vault:runpod-vllm', 'the key slot, never a value');
+ const model = await h.registry.getModel('runpod-vllm', 'deepseek-v4.1-flash');
+ assert.strictEqual(model.cost.in_per_mtok, 0.5);
+ assert.strictEqual(model.metadata.hourly_usd, 0.5);
+ const view = await request(h.base, 'GET', '/api/v1/admin/console/owner-inference', { tok: token('svc', ALL) });
+ assert.strictEqual(view.body.compute.endpoint.usd_per_mtok, 0.5, 'the compute block shows the same arithmetic');
+ assert.ok(!JSON.stringify(view.body).includes('vault:'), 'no secret reference is echoed as a value');
+});
+
+t.test('the confirmed readings become pending changes; nothing is applied automatically', async () => {
+ const view = await request(h.base, 'GET', '/api/v1/admin/console/owner-inference', { tok: token('svc', ALL) });
+ const proposals = view.body.pricebook.confirmed;
+ const store = createPriceStore({ db: h.db });
+ const dry = await proposePriceChanges({ registry: h.registry, store, proposals, dryRun: true });
+ assert.ok(dry.proposed.some((p) => p.provider_key === 'sable'), 'the Sable reading is proposed');
+ assert.ok(dry.proposed.some((p) => p.provider_key === 'inception'), 'the Inception reading is proposed');
+ assert.ok(dry.proposed.some((p) => p.provider_key === 'openrouter'), 'the OpenRouter Particle Flex reading is proposed');
+ const sable = dry.proposed.find((p) => p.provider_key === 'sable');
+ assert.strictEqual(sable.fields.find((f) => f.field === 'in_per_mtok').next, 0.15);
+ assert.match(sable.source, /sableapi\.com/, 'the provider page is the source');
+ assert.strictEqual(sable.checked_at, '2026-11-10');
+ const stillNull = await h.registry.getModel('sable', 'deepseek-v4.1-flash-1017-uncensored');
+ assert.strictEqual(stillNull.cost.in_per_mtok, null, 'a dry run writes no price');
+ const out = await proposePriceChanges({ registry: h.registry, store, proposals, actor: 'test' });
+ assert.ok(out.proposed.length >= 3, 'the readings are written as pending');
+ const pending = await store.pending(20);
+ assert.ok(pending.some((p) => p.provider_key === 'sable' && p.status === 'pending'), 'pending until the owner decides');
+ const live = await h.registry.getModel('sable', 'deepseek-v4.1-flash-1017-uncensored');
+ assert.strictEqual(live.cost.in_per_mtok, null, 'the live price is still unchanged');
+});
+
+t.test('the rented capacity records name their prices and the startup credit note', () => {
+ const runpod = compute.get('runpod');
+ assert.strictEqual(compute.cheapest(runpod), 0.27);
+ assert.strictEqual(runpod.credits.starter_usd, 1000);
+ assert.match(runpod.credits.note, /nothing was applied/i);
+ const vast = compute.get('vast');
+ assert.strictEqual(vast.traits.latency_classes[0], 'background', 'spot capacity is background only');
+ assert.strictEqual(vast.pods.find((g) => g.gpu.includes('3090')).spot_usd, 0.12);
+});
+
+t.run(() => h && h.stop());
@@ -45,7 +45,7 @@ t.test('each new provider is a record, off, keyless, with its traits, price and
@@ -45,7 +45,7 @@ t.test('each new provider is a record, off, keyless, with its traits, price and
assert.strictEqual(seed.recordOf(sable).metadata.data_policy.trains, false);
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');
assert.strictEqual(seed.recordOf(sable).metadata.data_policy.retention, 'limited', 'prompt cache hours to days');
const sableModel = seed.modelOf(sable, sable.models[0]);
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.deepStrictEqual([sableModel.cost.in_per_mtok, sableModel.cost.out_per_mtok], [null, null], 'the confirmed price waits in the price book');
assert.strictEqual(sableModel.metadata.uncensored, true);
assert.strictEqual(sableModel.metadata.uncensored, true);
assert.strictEqual(sableModel.supports.tools && sableModel.supports.vision, true, 'tools and vision');
assert.strictEqual(sableModel.supports.tools && sableModel.supports.vision, true, 'tools and vision');
@@ -54,7 +54,7 @@ t.test('each new provider is a record, off, keyless, with its traits, price and
@@ -54,7 +54,7 @@ t.test('each new provider is a record, off, keyless, with its traits, price and
assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.tokens, 100000000);
assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.tokens, 100000000);
assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.period, 'once');
assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.period, 'once');
const mercury = seed.modelOf(inception, inception.models[0]);
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.strictEqual(mercury.cost.in_per_mtok, null, 'the confirmed price waits in the price book');
assert.match(mercury.metadata.note, /\$0\.10|\$0\.35/, 'the launch price is noted, not assumed');
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.strictEqual(seed.recordOf(by('particle-ai')).metadata.tier_rates.flex, 0.5, 'Particle Flex is half price');
@@ -63,7 +63,8 @@ t.test('each new provider is a record, off, keyless, with its traits, price and
@@ -63,7 +63,8 @@ t.test('each new provider is a record, off, keyless, with its traits, price and
const openrouter = by('openrouter');
const openrouter = by('openrouter');
const pinned = seed.modelOf(openrouter, openrouter.models[0]);
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.strictEqual(pinned.cost.in_per_mtok, null, 'the Particle Flex price waits in the price book');
+ assert.match(pinned.metadata.note, /pending price-book change/);
assert.deepStrictEqual(pinned.metadata.routing, { only: ['particle-ai'], allow_fallbacks: false, service_tier: 'flex', data_collection: 'deny' });
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(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, 'interactive'), false, 'a Flex offer never answers in front of a person');
@@ -187,4 +188,18 @@ t.test('a caller may name its data policy, and a bad one is refused', async () =
@@ -187,4 +188,18 @@ t.test('a caller may name its data policy, and a bad one is refused', async () =
assert.strictEqual(bad.status, 422, bad.text);
assert.strictEqual(bad.status, 422, bad.text);
});
});
+t.test('the Exchange API accepts service_tier flex and a data policy', async () => {
+ const h2 = await boot({ env: { AI_STUB_FALLBACK: 'true', AI_FREE_PROVIDERS: 'stub' } });
+ const tok = token('exchange', ALL);
+ try {
+ const badTier = await request(h2.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hi' }], service_tier: 'turbo' } });
+ assert.strictEqual(badTier.status, 400, badTier.text);
+ const badPolicy = await request(h2.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hi' }], data_policy: 'secret' } });
+ assert.strictEqual(badPolicy.status, 400, badPolicy.text);
+ const run = await request(h2.base, 'POST', '/v1/chat/completions', { tok, body: { messages: [{ role: 'user', content: 'hi' }], service_tier: 'flex', data_policy: 'anonymized' } });
+ assert.strictEqual(run.status, 200, run.text);
+ assert.ok(run.body && run.body.choices && run.body.choices.length, 'the flex request answered');
+ } finally { await h2.stop(); }
+});
+
t.run(() => h && h.stop());
t.run(() => h && h.stop());