openvibers/openvibe.ai Commits

Owner controls, rented GPU capacity and pending confirmed prices (#454)

OP OpenVibers committed 2 days ago · 1b1d59264b · 15 files, +645 −26

* Add the second provider wave and route by its traits

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

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

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

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

* Owner controls, rented GPU capacity and pending confirmed prices

Migration 0075 adds owner_inference_rules (four switches at their safe values)
and provider_controls (on/off, tier, and the owner's own endpoint URL, model,
GPU hour and throughput). The console API reads and writes both; the router
reads the rules so the owner can change a safe default, and the adapter uses
the owner endpoint URL.

Runpod and Vast.ai are compute records with their GPU hours, storage, startup
credit note and sources. Saving a Runpod vLLM endpoint prices its model per
million tokens from the hour and the measured throughput; the key stays in the
provider's sealed slot. The four confirmed model prices are bundled and enter
the price book only as pending changes the owner accepts.

The exchange catalog carries each offer's service tier, latency class and free
allowance left; the OpenAI and Messages APIs accept service_tier: flex and the
caller's data_policy.

* Merge the provider wave branch

* Honour the owner's chosen tier for a provider

* Merge main after the provider wave

Changes

ddiff --git a/migrations/0075_provider_controls.sql b/migrations/0075_provider_controls.sql
nnew file mode 100644
iindex 0000000..4509662
---- /dev/null
++++ b/migrations/0075_provider_controls.sql
@@@ -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
++);
ddiff --git a/scripts/pricebook-proposals-2026-11-10b.json b/scripts/pricebook-proposals-2026-11-10b.json
nnew file mode 100644
iindex 0000000..c6df921
---- /dev/null
++++ b/scripts/pricebook-proposals-2026-11-10b.json
@@@ -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"
++ }
++]
ddiff --git a/server/api/console-data.js b/server/api/console-data.js
iindex def8345..22118f7 100644
---- a/server/api/console-data.js
++++ b/server/api/console-data.js
@@@ -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);
ddiff --git a/server/api/messages.js b/server/api/messages.js
iindex aa17de1..20116b7 100644
---- a/server/api/messages.js
++++ b/server/api/messages.js
@@@ -236,6 +236,9 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
if (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);
ddiff --git a/server/api/openai.js b/server/api/openai.js
iindex 2374443..0045a83 100644
---- a/server/api/openai.js
++++ b/server/api/openai.js
@@@ -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
ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex 1f6769f..a9f4703 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -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.
ddiff --git a/server/gpu/compute-providers.js b/server/gpu/compute-providers.js
nnew file mode 100644
iindex 0000000..e3fd16d
---- /dev/null
++++ b/server/gpu/compute-providers.js
@@@ -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 };
ddiff --git a/server/owner-config.js b/server/owner-config.js
iindex 48ac485..a216ef9 100644
---- a/server/owner-config.js
++++ b/server/owner-config.js
@@@ -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 };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 4d9c309..0794d6b 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -378,12 +378,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async 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.no
async 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.no
const 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.no
async 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.no
const access = accessOffered(p);
const policyOk = p ? await policyAllows(registry, p, modelId, ctx.contentPolicy || 'standard') : false;
const row = p && modelId ? await registry.getModel(p.key, modelId) : null;
-- const latencyOk = p ? traits.latencyAllows(p, row, (ctx.latencyClass && ['interactive', 'background', 'batch'].includes(ctx.latencyClass)) ? ctx.latencyClass : ((route.constraints && route.constraints.latency_class) || 'interactive')) : false;
-- const privateOk = p ? !(traits.isPrivateRequest(ctx) && traits.trainsOrRetains(p)) : false;
++ const 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' });
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex eb59826..858988b 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -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,
ddiff --git a/server/providers/traits.js b/server/providers/traits.js
iindex 2030822..9b7105b 100644
---- a/server/providers/traits.js
++++ b/server/providers/traits.js
@@@ -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);
}
ddiff --git a/server/runs.js b/server/runs.js
iindex 7b9c0fe..03d9b56 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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 });
ddiff --git a/test/ai-provider-adapters.test.js b/test/ai-provider-adapters.test.js
iindex eeaceec..121a2ef 100644
---- a/test/ai-provider-adapters.test.js
++++ b/test/ai-provider-adapters.test.js
@@@ -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 a
assert.deepStrictEqual(pool.PROVIDERS.slice(0, 23).map((p) => p.key),
['qwen', 'kimi', 'glm', 'minimax', 'deepinfra', 'novita', 'featherless', 'refuseless', 'abliteration', 'openrouter', 'venice', 'abliteratedmodels', 'uplinkswitch', 'chutes', 'siliconflow', 'nebius', 'together', 'fireworks', 'groq', 'cerebras', 'sambanova', 'baseten', 'hyperbolic']);
assert.deepStrictEqual(pool.PROVIDER_KEYS, pool.PROVIDERS.map((p) => p.key));
-- assert.strictEqual(new Set(pool.PROVIDER_KEYS).size, 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');
ddiff --git a/test/provider-controls.test.js b/test/provider-controls.test.js
nnew file mode 100644
iindex 0000000..f10ca80
---- /dev/null
++++ b/test/provider-controls.test.js
@@@ -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());
ddiff --git a/test/provider-wave2.test.js b/test/provider-wave2.test.js
iindex c1dc691..2ded885 100644
---- a/test/provider-wave2.test.js
++++ b/test/provider-wave2.test.js
@@@ -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.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 and
assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.tokens, 100000000);
assert.strictEqual(seed.recordOf(inception).metadata.traits.free_allowance.period, 'once');
const mercury = seed.modelOf(inception, inception.models[0]);
-- assert.deepStrictEqual([mercury.cost.in_per_mtok, mercury.cost.out_per_mtok, mercury.cost.cached_per_mtok], [0.2, 0.7, 0.02], 'the list price is recorded');
++ assert.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 and
const openrouter = by('openrouter');
const pinned = seed.modelOf(openrouter, openrouter.models[0]);
-- assert.deepStrictEqual([pinned.cost.in_per_mtok, pinned.cost.out_per_mtok, pinned.cost.cached_per_mtok], [0.025, 0.25, 0.003], 'the Particle Flex price read from OpenRouter');
++ assert.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());