openvibers/openvibe.ai Commits
Open an approved offer and bound live GPU supply (#476)
Changes
@@@ -46,6 +46,26 @@ pages only, never signs in, never rents, and a failed source keeps its last rowsMigration 0052 is expand-only. These changes need money/security review before deployment; this PR does not deploy.++## Opening an offer++++The owner console's Models section reads one route per model (`GET /api/v1/admin/console/models/:provider/:model/route`)++with the gates the catalogue and router apply: provider on, key present, model on and still listed, commercial access++resellable, and an input and output price approved. `POST /api/v1/admin/console/models/:provider/:model/open` is the one++owner action: it approves that model's waiting price-book changes (or writes the owner's fixed per-token `price`), switches++the provider and model on, and records `offer.opened` with the prices and gates. A provider with no key is refused with++`offer.no_key` before anything is written. Nothing in this action rents, spends or calls a provider.++++## Live GPU supply++++`exchange_supply_settings.settings.enabled` is a per-model owner switch (default off). A plan can always be saved and++priced; turning it on also needs the provider key, the monthly cap, `ai.gpus.costs_bounded`, `ai.gpus.extra_hourly_nano`++and `ai.gpus.dry_run=false`, or `save` answers `gpu.owner_gate` and changes nothing. The provisioner's minute tick asks the++supply planner first: when an enabled plan's measured demand makes renting cheaper than buying the same tokens, it queues++one `gpu_requests` launch (never more while a request or instance is open). The provisioner still checks the key, the++monthly reservation and the plan's approved hourly/monthly ceilings before it rents; the selected marketplace offer above++either ceiling is not selected. The registered GPU offer keeps the plan's fixed input/output prices++(`urn:openvibe:owner-fixed-price`), so the catalogue and router serve the fixed route.++## Public market observations`GET https://api.openvibe.exchange/api/v1/exchange/market` returns@@@ -11,10 +11,10 @@ const gpuCatalog = require('../gpu/catalog');const { tokenCosts } = require('../gpu/token-cost');/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */--function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {++function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {const r = express.Router();const providerOrder = require('../providers/order').createProviderOrder({ registry });-- const supply = require('../platform/supply').createSupply({ db, registry, clock });++ const supplyManager = supply || require('../platform/supply').createSupply({ db, registry, clock });const ownerConfig = require('../owner-config').createOwnerConfig({ db, registry, clock });// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:// Exchange renders it with its existing console classes.@@@ -27,9 +27,13 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,const read = auth.requireCap(CAPS.usageRead);const write = auth.requireCap(CAPS.providerManage);const workflowWrite = auth.requireCap(CAPS.workflowManage);-- const modelView = (m, provider) => ({ ...m, provider: m.provider_key, model: m.model_key, accepted_price: m.cost,++ // The offer's route and the owner's one action that opens it (server/exchange/offers.js): the same registry++ // facts the catalog and router read, never a second list of models or prices.++ const offerRoutes = require('../exchange/offers').createOfferRoutes({ registry, priceBook, env, clock, log });++ const modelView = (m, provider, pending = 0) => ({ ...m, provider: m.provider_key, model: m.model_key, accepted_price: m.cost,content_tier: require('../providers/pool-providers').isUncensored(provider, m) ? 'uncensored' : 'standard',-- owner_override: m.metadata?.owner_override || null });++ owner_override: m.metadata?.owner_override || null,++ route: offerRoutes.routeOf({ provider, model: m, pending, env }) });const actor = (req) => {const subject = String(req.get('x-ov-subject') || '');return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) ? subject : req.principal.sub;@@@ -38,9 +42,17 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,try { res.set('Cache-Control', 'no-store').json(await fn(req)); }catch (err) { log.error(`[console-json] ${req.method} ${req.path} failed (${err && err.code || 'ai.internal'})`); res.status(err.status || 500).json({ error: 'AI admin data is unavailable.', code: err.code || 'ai.internal' }); }};++ // One offer's route, and the owner's one action that opens it: approve its waiting prices (or write the++ // owner's fixed per-token price), switch the provider and the model on, then answer the route as it stands.++ r.get('/api/v1/admin/console/models/:provider/:model/route', read, wrap(async (req) => ({ route: await offerRoutes.read(req.params.provider, req.params.model) })));++ r.post('/api/v1/admin/console/models/:provider/:model/open', write, json, wrap(async (req) => {++ const body = req.body || {};++ const price = body.price && typeof body.price === 'object' ? body.price : null;++ return await offerRoutes.open(req.params.provider, req.params.model, { price, actor: actor(req), trace: req.ov && req.ov.traceId });++ }));r.get('/api/v1/admin/console/:section', read, wrap(async (req) => {const section = req.params.section;-- if (section === 'supply') return { ...await supply.report(), ...await marketPayload() };++ if (section === 'supply') return { ...await supplyManager.report(), ...await marketPayload() };if (section === 'overview') {const since = new Date(clock.now() - 86400000).toISOString();return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };@@@ -65,12 +77,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,}))) : [];const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];-- return { providers: rows, models: (await registry.listModels()).map(m => modelView(m, rows.find(p => p.key === m.provider_key))), presets: poolProviders.map((p) => ({ key: p.key, display_name: p.display_name, base_url: p.base_url, default_model: p.default_model, provider_access: p.provider_access, uncensored: Boolean(p.uncensored), models: p.models })), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };++ const pendingBy = await offerRoutes.pendingByModel();++ return { providers: rows, models: (await registry.listModels()).map(m => modelView(m, rows.find(p => p.key === m.provider_key), offerRoutes.pendingOf(pendingBy, m.provider_key, m.model_key))), presets: poolProviders.map((p) => ({ key: p.key, display_name: p.display_name, base_url: p.base_url, default_model: p.default_model, provider_access: p.provider_access, uncensored: Boolean(p.uncensored), models: p.models })), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };}if (section === 'models') {const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;const providers = await registry.listProviders();-- return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key))),++ const pendingBy = await offerRoutes.pendingByModel();++ return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key), offerRoutes.pendingOf(pendingBy, m.provider_key, m.model_key))),provider: provider || null, sync_log: modelSync ? await modelSync.read(provider) : [] };}if (section === 'orders') return { orders: await providerOrder.list() };@@@ -137,7 +151,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,return { ...await gpuMarket.refresh(), ...await marketPayload() };}));r.post('/api/v1/admin/console/supply/quotes', json, write, wrap(async req => ({ recorded: await require('../pricebook/gpus').recordGpuQuotes(db, JSON.stringify(req.body.quotes)), dry_run: true })));-- r.post('/api/v1/admin/console/supply', json, write, wrap(req => supply.save(req.body || {}, actor(req))));++ r.post('/api/v1/admin/console/supply', json, write, wrap(req => supplyManager.save(req.body || {}, actor(req))));r.post('/api/v1/admin/console/orders/:capability', json, write, wrap(req => providerOrder.save(req.params.capability, req.body, { actor: actor(req), trace: req.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 }) })));@@@ -24,7 +24,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');const ownerInputs = require('./owner-inputs');--function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {++function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -281,7 +281,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));-- app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, abuse, priceBook, modelSync, creditGrants, sessionCookies, vault, settings, config, log }));++ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, supply, abuse, priceBook, modelSync, creditGrants, sessionCookies, vault, settings, config, log }));// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).nnew file mode 100644@@@ -0,0 +1,207 @@++'use strict';++/**++ * One offer's route, and the owner's one action that opens it.++ *++ * The Exchange catalogue lists a model only when every gate passes (server/exchange/catalog.js):++ * the provider is on, its key is present, the model is on and still listed, its commercial access may++ * be resold, and a price is known. The router additionally skips a model that is missing from the++ * provider's own list and an offer whose circuit is open. This module reads those registry facts++ * (never a second list) and answers, per provider/model: is this offer on the route now, and if not++ * which gate stops it.++ *++ * `open` is the owner's decision, made once and written once:++ * - the pending price-book changes of that model are approved (or the owner's fixed per-token price++ * is written), because a route is never served at a price nobody agreed to;++ * - the provider is switched on and the model is switched on;++ * - the same audit log records who opened it, with the price and the gates that were closed.++ * A provider with no key is refused before anything is written: the key is the owner's to add, and++ * this module never touches a secret. Nothing here rents, spends or calls a provider.++ */++const { AiError } = require('../util');++const upstream = require('../providers/upstream');++++const ACCESS_UNSELLABLE = new Set(['CUSTOMER_BYOK', 'RESTRICTED']);++const KINDS = ['chat', 'vision', 'embedding', 'stt', 'image'];++++const number = (value) => {++ if (value === null || value === undefined || value === '') return null;++ const n = Number(value);++ return Number.isFinite(n) && n >= 0 ? n : null;++};++const day = (iso) => String(iso || '').slice(0, 10);++++/**++ * The price the router would charge, after the owner's override. `source` says where it came from:++ * the accepted provider price, the owner's fixed price, or null when nobody priced it.++ */++function priceOf(model) {++ const override = model && model.metadata && model.metadata.owner_override && model.metadata.owner_override.cost;++ const base = (model && model.cost) || {};++ const cost = { ...base, ...(override || {}) };++ const fixed = Boolean(model && model.metadata && (model.metadata.owner_fixed_price === true || override));++ return {++ in: number(cost.in_per_mtok),++ out: number(cost.out_per_mtok),++ cached: number(cost.cached_per_mtok),++ source: fixed ? 'owner' : (cost.source ? (String(cost.source).startsWith('urn:openvibe:ai:unverified:') ? null : cost.source) : null),++ verified_at: cost.verified_at || null,++ owner: Boolean(override) ? 'override' : fixed ? 'fixed' : null,++ };++}++++/**++ * The route of one provider/model. `pending` is how many of its price changes wait for a decision.++ * The reasons are ordered the way the catalog tests them, so the first one is the first thing to fix.++ */++function routeOf({ provider = null, model = null, pending = 0, env = process.env } = {}) {++ const reasons = [];++ if (!provider) reasons.push('no_provider');++ if (!model) reasons.push('no_model');++ if (provider && provider.status !== 'active') reasons.push('provider_off');++ if (provider && !upstream.credentialPresent(provider, env)) reasons.push('no_key');++ if (provider && ACCESS_UNSELLABLE.has(provider.provider_access || 'UNVERIFIED')) reasons.push('access_restricted');++ if (model && model.status !== 'active') reasons.push('model_off');++ if (model && model.metadata && model.metadata.missing_since) reasons.push('model_gone');++ if (model && !KINDS.includes(model.type || 'chat')) reasons.push('kind_not_offered');++ const price = model ? priceOf(model) : null;++ if (model && (price.in === null || price.out === null)) reasons.push('price_unknown');++ const live = Boolean(provider && model) && reasons.length === 0;++ return {++ provider: provider ? provider.key : null,++ model: model ? model.model_key : null,++ live,++ reasons,++ provider_status: provider ? provider.status : null,++ key_present: Boolean(provider && upstream.credentialPresent(provider, env)),++ provider_access: provider ? provider.provider_access || 'UNVERIFIED' : null,++ model_status: model ? model.status : null,++ missing_since: model && model.metadata ? model.metadata.missing_since || null : null,++ price,++ pending_prices: Number(pending) || 0,++ };++}++++function createOfferRoutes({ registry, priceBook = null, env = process.env, clock = { now: () => Date.now() }, log = console } = {}) {++ const now = () => new Date(clock.now()).toISOString();++++ /** The pending change counts of a set of models, read once. */++ async function pendingByModel() {++ const counts = new Map();++ if (!priceBook) return counts;++ const rows = await priceBook.pending(500).catch(() => []);++ for (const row of rows || []) {++ const key = `${row.provider_key}\u0000${row.model_key}`;++ counts.set(key, (counts.get(key) || 0) + 1);++ }++ return counts;++ }++++ function pendingOf(counts, providerKey, modelKey) {++ return counts.get(`${providerKey}\u0000${modelKey}`) || 0;++ }++++ /** One model's route, with its waiting price changes. */++ async function read(providerKey, modelKey) {++ const [provider, model] = await Promise.all([registry.getProvider(providerKey), registry.getModel(providerKey, modelKey)]);++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');++ if (!model) throw new AiError(404, 'ai.not_found', 'no such model');++ const pending = priceBook++ ? (await priceBook.pending(500).catch(() => [])).filter((c) => c.provider_key === providerKey && c.model_key === modelKey).length++ : 0;++ return routeOf({ provider, model, pending, env });++ }++++ /** A fixed price the owner typed for this offer, in USD per million tokens. */++ function fixedCost(input) {++ if (!input || typeof input !== 'object') throw new AiError(422, 'ai.invalid', 'price must be an object');++ const reason = String(input.reason || '').trim();++ if (!reason) throw new AiError(422, 'ai.invalid', 'a fixed price needs a reason');++ if (reason.length > 500) throw new AiError(422, 'ai.invalid', 'the reason is at most 500 characters');++ const cost = {};++ for (const field of ['in_per_mtok', 'out_per_mtok', 'cached_per_mtok']) {++ if (input[field] === undefined || input[field] === null || input[field] === '') continue;++ const n = number(input[field]);++ if (n === null) throw new AiError(422, 'ai.invalid', `price.${field} must be a non-negative number`);++ cost[field] = n;++ }++ if (cost.in_per_mtok === undefined || cost.out_per_mtok === undefined) {++ throw new AiError(422, 'ai.invalid', 'a fixed price needs both input and output per million tokens');++ }++ return { reason, cost };++ }++++ /**++ * Open the offer: approve its waiting prices (or write the owner's fixed price), switch the provider++ * and the model on, and answer the route as it now stands.++ */++ async function open(providerKey, modelKey, { price = null, actor = 'owner', trace = null } = {}) {++ const [provider, model] = await Promise.all([registry.getProvider(providerKey), registry.getModel(providerKey, modelKey)]);++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');++ if (!model) throw new AiError(404, 'ai.not_found', 'no such model');++ // Every refusal comes before any write: a half-opened offer is not a state the owner asked for.++ if (!upstream.credentialPresent(provider, env)) {++ throw new AiError(409, 'offer.no_key', `Add the ${provider.display_name || provider.key} key first; nothing was changed.`);++ }++ if (ACCESS_UNSELLABLE.has(provider.provider_access || 'UNVERIFIED')) {++ throw new AiError(409, 'offer.access_restricted', `${provider.display_name || provider.key} may not be resold on the Exchange; its commercial access is ${provider.provider_access}.`);++ }++ if (model.metadata && model.metadata.missing_since) {++ throw new AiError(409, 'offer.model_gone', 'The provider no longer lists this model; sync its models before opening the route.');++ }++ const fixed = price ? fixedCost(price) : null;++ let approved = 0;++ let fixedWritten = false;++ if (fixed) {++ const metadata = { ...(model.metadata || {}), owner_fixed_price: true, owner_fixed_price_reason: fixed.reason, owner_fixed_price_at: now() };++ delete metadata.owner_override;++ await registry.upsertModel({++ ...model,++ status: model.status,++ cost: { ...fixed.cost, source: 'urn:openvibe:owner-fixed-price', verified_at: day(now()) },++ metadata,++ }, { actor, trace });++ fixedWritten = true;++ } else if (priceBook) {++ const waiting = (await priceBook.pending(500)).filter((c) => c.provider_key === providerKey && c.model_key === modelKey);++ const failed = [];++ // One by one, never in parallel: each approval rewrites the model's cost from its own field.++ for (const change of waiting) {++ const out = await priceBook.decide(change.id, { decision: 'approve', actor });++ if (!out.ok) failed.push({ id: change.id, error: out.error });++ else approved += 1;++ }++ if (failed.length) {++ throw new AiError(409, 'offer.price_approval_failed', `${failed.length} waiting price(s) could not be approved; the rest were approved and can be seen in the price book.`);++ }++ }++ const priced = priceOf(await registry.getModel(providerKey, modelKey));++ if (priced.in === null || priced.out === null) {++ throw new AiError(409, 'offer.no_price', 'Approve a price in the price book (or save a fixed price) before opening the route.');++ }++ const enabled = { provider: false, model: false };++ if (provider.status !== 'active') {++ await registry.upsertProvider({ key: provider.key, status: 'active' }, { actor, trace, origin: 'console' });++ enabled.provider = true;++ }++ const fresh = await registry.getModel(providerKey, modelKey);++ if (fresh.status !== 'active') {++ await registry.upsertModel({ ...fresh, status: 'active' }, { actor, trace });++ enabled.model = true;++ }++ const route = await read(providerKey, modelKey);++ await registry.audit(actor, 'offer.opened', 'model', `${providerKey}/${modelKey}`, {++ trace,++ metadata: {++ approved_prices: approved, fixed_price: fixedWritten,++ fixed_in_per_mtok: fixedWritten ? priced.in : null, fixed_out_per_mtok: fixedWritten ? priced.out : null,++ provider_enabled: enabled.provider, model_enabled: enabled.model, live: route.live,++ },++ });++ log.log(`[exchange] offer ${providerKey}/${modelKey} opened${fixedWritten ? ' at a fixed price' : ''}: ${approved} price(s) approved, provider ${enabled.provider ? 'on' : 'already on'}, model ${enabled.model ? 'on' : 'already on'}`);++ return { ok: true, opened: true, already_live: route.live && !enabled.provider && !enabled.model && !fixedWritten && approved === 0, route };++ }++++ return { read, open, routeOf, priceOf, pendingByModel, pendingOf };++}++++module.exports = { createOfferRoutes, routeOf, priceOf };@@@ -224,6 +224,22 @@ function createGpuProvisioner({const names = ['vast', 'runpod'].filter(name => !rentalOnly || cfg[name + '_key']);const offers = [];const failures = [];++ // The owner's approved supply plan pins the hourly and monthly ceilings per model (server/platform/supply.js).++ // Enforcing them here, at rent time, is what makes an approved host a bound and not only a comparison: a++ // live marketplace offer above the ceiling is not selected, however good it looks.++ const supplyRow = await db.prepare('SELECT settings FROM exchange_supply_settings WHERE model = ?').get(String(modelKey));++ const approved = supplyRow ? (() => { try { return JSON.parse(supplyRow.settings); } catch { return null; } })() : null;++ const withinApproved = (o) => {++ if (!approved) return true;++ const hourly = Number(o.price_per_hour);++ const hourlyCeiling = Number(approved.hourly_ceiling_usd);++ const monthlyCeiling = Number(approved.monthly_ceiling_usd);++ const vram = Number(approved.required_vram_mb);++ if (Number.isFinite(hourlyCeiling) && hourly > hourlyCeiling) return false;++ if (Number.isFinite(monthlyCeiling) && hourly * 24 * 31 > monthlyCeiling) return false;++ if (Number.isFinite(vram) && vram > 0 && o.gpu_ram_mb < vram) return false;++ return true;++ };for (const name of names) {const adapter = name === 'vast' ? vastAdapter({ fetchImpl, key: cfg.vast_key || null }) : runpodAdapter({ fetchImpl, key: cfg.runpod_key || null });try {@@@ -231,10 +247,13 @@ function createGpuProvisioner({diskGb: spec.requirements.disk_gb, numGpus: spec.requirements.num_gpus,minReliability: spec.requirements.min_reliability, region, maxPricePerHour: 10, limit: 50 });// Never trust marketplace-side filters alone.-- offers.push(...found.filter(o => o.gpu_ram_mb >= spec.requirements.vram_mb && o.num_gpus >= spec.requirements.num_gpus++ const fitting = found.filter(o => o.gpu_ram_mb >= spec.requirements.vram_mb && o.num_gpus >= spec.requirements.num_gpus&& o.disk_gb >= spec.requirements.disk_gb && o.reliability >= spec.requirements.min_reliability&& Number(o.price_per_hour) > 0 && Number.isFinite(Number(o.price_per_hour))-- && (!region || String(o.region).toUpperCase() === String(region).toUpperCase())));++ && (!region || String(o.region).toUpperCase() === String(region).toUpperCase()));++ const inside = fitting.filter(withinApproved);++ if (approved && inside.length < fitting.length) failures.push('offers above the approved supply ceiling were left out');++ offers.push(...inside);} catch { failures.push(name + ': quote API unavailable'); }}const candidates = offers.map(o => ({ id: o.marketplace + ':' + o.id, tier: 'partner', health: 'up', region: o.region,@@@ -245,9 +264,14 @@ function createGpuProvisioner({return { dry_run: true, strategy, selected, offers, reasons: [...decision.reasons, ...(strategy === 'fastest' && !offers.some(o => o.latency_ms != null) ? ['latency unmeasured; choosing by price within the required region'] : []), ...failures], expires_at: iso(now() + 60000) };}++ /** May this provisioner serve the model? The supply tick asks before it queues a launch. */++ function supports(modelKey) {++ return Boolean(catalog.find(modelKey) || models.some((m) => m.key === modelKey));++ }++/** Ask for a model to start or stop. The reconciler does it; nothing here acts directly. */async function request(modelKey, action, actor = 'service:ai') {-- if (!catalog.find(modelKey) && !models.some((m) => m.key === modelKey)) throw new AiError(404, 'ai.not_found', 'no such GPU model');++ if (!supports(modelKey)) throw new AiError(404, 'ai.not_found', 'no such GPU model');if (action !== 'launch' && action !== 'stop') throw new AiError(422, 'ai.invalid', 'the action must be launch or stop');await db.prepare('INSERT INTO gpu_requests (model_key, action, at, actor, created_at) VALUES (?, ?, ?, ?, ?)').run(String(modelKey), action, iso(now()), String(actor || '').slice(0, 200), iso(now()));@@@ -914,6 +938,7 @@ function createGpuProvisioner({attachEndpoints,launchSpec,request,++ supports,config,moneySummary,sweepOrphans,@@@ -57,6 +57,10 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {// API key and a monthly cap in the owner settings (ai.gpus). The reconciler rents, health-checks and// scales the instances to zero; it never spends past the cap.const gpus = require('./gpu/provisioner').createGpuProvisioner({ db, registry, settings, clock, fetchImpl, env, log });++ // The owner's GPU supply plans (server/platform/supply.js): fixed per-token prices and the ceilings of the host++ // they are approved for. Live supply is an explicit per-model switch; its tick places launch requests only,++ // and the provisioner below still checks the key, the monthly cap and the reservation before it rents.++ const supply = require('./platform/supply').createSupply({ db, registry, clock, gpus, log });// The public GPU marketplace prices (server/gpu/market.js): a due-based public-page reader for the console's// supply view. It never signs in or rents; a failed source keeps its last reading, marked stale.const gpuMarket = require('./gpu/market').createGpuMarket({ db, config, clock, fetchImpl, log });@@@ -229,7 +233,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share });++ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -258,7 +262,9 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {let gpuReconciler = null;if (listen && config.nodeEnv !== 'test') {gpuReconciler = setInterval(() => {-- ownEndpoints.tick()++ // Supply queues a launch request first; the provisioner then fulfils it under the cap in the same minute.++ supply.tick()++ .then(() => ownEndpoints.tick()).then(() => gpus.tick()).catch((err) => log.warn('[gpu] the reconciler failed: ' + err.message));}, 60 * 1000);@@@ -364,7 +370,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {if (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, modelSync, modelDiscovery, refusal, willingness, abliterationTax, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, modelSync, modelDiscovery, refusal, willingness, abliterationTax, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, supply, abuse, ownEndpoints, close };}if (require.main === module) {@@@ -72,9 +72,9 @@ function decide({ demand, quotes, settings, now = Date.now() }) {? 'needs_measurement': !capped? 'ceiling_required'-- : reseller !== null && rental < reseller-- ? 'would_rent'-- : 'use_reseller',++ : reseller != null && rental >= reseller++ ? 'use_reseller'++ : 'would_rent',selected: chosen,reseller_cost_usd: reseller,rental_cost_usd: rental,@@@ -88,7 +88,23 @@ function decide({ demand, quotes, settings, now = Date.now() }) {idle_minutes: settings.idle_minutes,};}--function createSupply({ db, registry, clock = { now: () => Date.now() } }) {++function createSupply({ db, registry, clock = { now: () => Date.now() }, gpus = null, log = console }) {++ /**++ * The owner gate of live supply: the provisioner must have the marketplace key, the monthly cap and the++ * explicit cost bound, and quote-only mode must be off. Without all of them a supply plan can be saved and++ * priced, but it never asks for a rental.++ */++ function gate() {++ if (!gpus || typeof gpus.config !== 'function') return { ok: false, reason: 'The GPU provisioner is off on this host.' };++ const cfg = gpus.config();++ if (!cfg.enabled) return { ok: false, reason: cfg.reason === 'no Vast.ai or RunPod API key'++ ? 'Add a Vast.ai or RunPod API key first; nothing was changed.'++ : cfg.reason === 'no monthly cap'++ ? 'Set a positive monthly GPU cap first; nothing was changed.'++ : 'Set ai.gpus.extra_hourly_nano and ai.gpus.costs_bounded=true first; nothing was changed.' };++ if (cfg.dry_run) return { ok: false, reason: 'GPU rentals are in quote-only mode (ai.gpus.dry_run); turn that off first; nothing was changed.' };++ return { ok: true, cfg };++ }async function save(b, actor) {if (typeof b.model !== 'string' ||@@@ -110,18 +126,26 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {'ai.invalid','Model, runtime, prices, ceilings, VRAM and idle timeout are required',);-- const value = Object.fromEntries(-- [-- 'model',-- 'runtime',-- 'input_per_million',-- 'output_per_million',-- 'hourly_ceiling_usd',-- 'monthly_ceiling_usd',-- 'required_vram_mb',-- 'idle_minutes',-- ].map((k) => [k, b[k]]),-- );++ const enabled = b.enabled === true;++ if (enabled) {++ const g = gate();++ if (!g.ok) throw new AiError(409, 'gpu.owner_gate', g.reason);++ }++ const value = {++ ...Object.fromEntries(++ [++ 'model',++ 'runtime',++ 'input_per_million',++ 'output_per_million',++ 'hourly_ceiling_usd',++ 'monthly_ceiling_usd',++ 'required_vram_mb',++ 'idle_minutes',++ ].map((k) => [k, b[k]]),++ ),++ enabled,++ };await db.prepare('INSERT INTO exchange_supply_settings(model,settings,updated_at,actor) VALUES(?,?,?,?) ON CONFLICT(model) DO UPDATE SET settings=excluded.settings,updated_at=excluded.updated_at,actor=excluded.actor',@@@ -154,7 +178,18 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {},{ actor },);-- return { settings: value, dry_run: true };++ await registry.audit(actor, enabled ? 'supply.open' : 'supply.save', 'gpu_supply', value.model, {++ metadata: {++ enabled,++ input_per_million: value.input_per_million,++ output_per_million: value.output_per_million,++ hourly_ceiling_usd: value.hourly_ceiling_usd,++ monthly_ceiling_usd: value.monthly_ceiling_usd,++ required_vram_mb: value.required_vram_mb,++ idle_minutes: value.idle_minutes,++ },++ });++ return { settings: value, enabled, dry_run: !enabled };}async function report() {const rows = await db@@@ -177,8 +212,14 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {).all(since);const modelRows = await registry.listModels();++ const gpuProviders = new Map(providers.filter((p) => String(p.key).startsWith('gpu.')).map((p) => [p.key, p]));++ const supplyGate = gate();++ const liveSettings = rows.map((r) => JSON.parse(r.settings)).filter((s) => s.enabled === true);return {-- dry_run: true,++ // Live supply is the owner's explicit switch plus every owner gate; a plan stays quote-only otherwise.++ dry_run: !(liveSettings.length && supplyGate.ok),++ live: Boolean(liveSettings.length && supplyGate.ok),++ gate: supplyGate,price_book: quotes,reports: rows.map((r) => {const settings = JSON.parse(r.settings);@@@ -221,7 +262,7 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {1e6,).filter(Number.isFinite);-- return decide({++ const plan = decide({settings,demand: {...usage,@@@ -232,10 +273,68 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {quotes: observations,now: clock.now(),});++ // The route the fixed prices would serve: a GPU host registers this model as a normal provider++ // (server/gpu/provisioner.js), so the registry answer is the truth, not a second list.++ const registered = modelRows.filter((m) => m.model_key === settings.model && gpuProviders.has(m.provider_key));++ const row = registered.find((m) => gpuProviders.get(m.provider_key).status === 'active') || registered[0] || null;++ const owner = row ? gpuProviders.get(row.provider_key) : null;++ const route = row ? {++ registered: true,++ provider_key: row.provider_key,++ provider_status: owner.status,++ model_status: row.status,++ fixed: row.metadata?.owner_fixed_price === true,++ price: { in: row.cost?.in_per_mtok ?? null, out: row.cost?.out_per_mtok ?? null },++ live: owner.status === 'active' && row.status === 'active' && row.cost?.in_per_mtok != null && row.cost?.out_per_mtok != null,++ } : { registered: false, live: false, reason: 'No GPU host has registered this model yet.' };++ return {++ ...plan,++ enabled: settings.enabled === true,++ active: settings.enabled === true && supplyGate.ok,++ route,++ };}),settings: rows.map((r) => JSON.parse(r.settings)),};}-- return { save, report };++++ /**++ * The reconciler half of live supply: when the owner enabled a plan and the plan says renting is cheaper++ * than buying the same tokens, ask the existing GPU reconciler to launch it. It places a request only:++ * the provisioner checks the key, the monthly cap, the reservation and the owner's approved hourly ceiling++ * before it rents anything. One pending request and one live instance per model, however often this ticks.++ */++ async function tick() {++ const g = gate();++ if (!g.ok) return { queued: [], reason: g.reason };++ const plan = await report();++ const queued = [];++ for (const row of plan.reports) {++ if (!row.enabled || row.action !== 'would_rent' || !row.model) continue;++ if (typeof gpus.supports === 'function' && !gpus.supports(row.model)) continue;++ const running = await db.prepare("SELECT 1 FROM gpu_instances WHERE model_key = ? AND status IN ('starting','running','stopping') LIMIT 1").get(row.model);++ if (running) continue;++ const waiting = await db.prepare("SELECT 1 FROM gpu_requests WHERE model_key = ? AND action = 'launch' AND done_at IS NULL LIMIT 1").get(row.model);++ if (waiting) continue;++ try {++ await gpus.request(row.model, 'launch', 'service:supply');++ await registry.audit('service:supply', 'supply.launch_requested', 'gpu_supply', row.model, {++ metadata: {++ hourly_usd: row.selected?.hourly_usd ?? null,++ input_per_million: row.price?.in ?? null,++ output_per_million: row.price?.out ?? null,++ rental_cost_usd: row.rental_cost_usd ?? null,++ reseller_cost_usd: row.reseller_cost_usd ?? null,++ },++ });++ queued.push(row.model);++ } catch (err) {++ log.warn(`[supply] could not request ${row.model}: ${String((err && err.message) || err).slice(0, 200)}`);++ }++ }++ return { queued, checked: plan.reports.length };++ }++++ return { save, report, tick, gate };}module.exports = { adapter, decide, createSupply };@@@ -116,16 +116,24 @@ function serves(upstream, provider, row) {}/**-- * Is the credential this provider needs present right now? No credential for a keyless record (local,-- * whisper), and none needed for a free or pooled-subscription provider. A missing env/vault reference is-- * not configured: its models must not appear as offers.++ * Is the credential this provider needs present right now, whether or not the provider is switched on? No++ * credential for a keyless record (local, whisper), and none needed for a free or pooled-subscription provider.++ * A missing env/vault reference is not present.*/--function configured(provider, env = process.env) {-- if (!provider || provider.status !== 'active') return false;++function credentialPresent(provider, env = process.env) {++ if (!provider) return false;if (provider.auth_mode === 'none') return true;if (provider.billing_profile === 'free') return true;if (provider.billing_profile === 'subscription' && !provider.secret_ref) return true;try { return Boolean(resolveSecret(provider.secret_ref, env)); } catch (e) { return false; }}--module.exports = { upstreamOf, serves, configured, familyOfModel, NAMES };++/**++ * Is the credential this provider needs present right now and is the provider on? A missing env/vault++ * reference is not configured: its models must not appear as offers.++ */++function configured(provider, env = process.env) {++ return Boolean(provider && provider.status === 'active' && credentialPresent(provider, env));++}++++module.exports = { upstreamOf, serves, configured, credentialPresent, familyOfModel, NAMES };@@@ -136,6 +136,55 @@ const { decide, adapter } = require('../server/platform/supply');assert.equal(report.dry_run,true);assert.equal(report.price_book.length,1);assert.equal(report.reports[0].action,'would_stop');++++ // A model with no published resale price still rents when the GPU hour is cheaper than the tokens++ // would be: before 2026-11-11 decide() answered "use_reseller" even when there was no reseller.++ const noReseller = decide({ settings, demand: { tokens_in: 1e6, tokens_out: 1e6, reseller_cost_usd: null },++ quotes: [{ hourly_usd: 0.5, measured_tokens_per_second: 50, vram_mb: 24000, observed_at: new Date().toISOString() }] });++ assert.equal(noReseller.action, 'would_rent');++++ // Live supply is an owner switch behind every owner gate: without the provisioner it cannot be turned on.++ await assert.rejects(supply.save({ ...settings, enabled: true }, 'owner'), (e) => e.code === 'gpu.owner_gate');++++ // Demand for the model, twenty measured requests and a registered GPU provider are what the plan reads.++ const at = new Date().toISOString();++ await h.registry.upsertProvider({ key: 'gpu.m', display_name: 'GPU m', kind: 'openai', status: 'active',++ base_url: 'https://gpu.example/v1', auth_mode: 'none', capabilities: ['chat'], provider_access: 'OWN_DEPLOYMENT',++ metadata: { gpu: { gpu_name: 'RTX 4090' } } }, { actor: 'test' });++ await h.registry.upsertModel({ provider_key: 'gpu.m', model_key: 'm', type: 'chat', status: 'active',++ cost: { in_per_mtok: 1, out_per_mtok: 2, source: 'urn:openvibe:owner-fixed-price', verified_at: at.slice(0, 10) },++ metadata: { owner_fixed_price: true } }, { actor: 'test' });++ await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, input, input_hash,++ synthetic, provider_key, model_key, tokens_in, tokens_out, cost_usd, created_at)++ VALUES ('run_supply_demand', 'ai.complete', 1, 'succeeded', 'service', 'svc:test', '{}', 'h', 0, 'gpu.m', 'm', 1000000, 1000000, 3, ?)`)++ .run(at);++ for (let i = 0; i < 20; i++) await h.db.prepare(`INSERT INTO requests (operation, provider_key, model_key, status, tokens_out, latency_ms, created_at)++ VALUES ('chat', 'gpu.m', 'm', 'ok', 100, 1000, ?)`).run(at);++ const queued = [];++ const fakeGpus = {++ config: () => ({ enabled: true, dry_run: false, reason: '', cap_nano: 1000n, monthly_cap: 1 }),++ request: async (model, action, actor) => {++ queued.push([model, action, actor]);++ await h.db.prepare('INSERT INTO gpu_requests (model_key, action, at, actor, created_at) VALUES (?, ?, ?, ?, ?)')++ .run(model, action, at, actor, at);++ return { ok: true, model_key: model, action };++ },++ };++ const live = createSupply({ db: h.db, registry: h.registry, gpus: fakeGpus });++ const saved = await live.save({ ...settings, enabled: true }, 'owner');++ assert.equal(saved.enabled, true);++ assert.equal(saved.dry_run, false);++ const planned = await live.report();++ assert.equal(planned.live, true);++ assert.equal(planned.reports[0].enabled, true);++ assert.equal(planned.reports[0].action, 'would_rent');++ assert.equal(planned.reports[0].route.registered, true);++ assert.equal(planned.reports[0].route.fixed, true);++ const first = await live.tick();++ assert.deepEqual(first.queued, ['m']);++ const second = await live.tick();++ assert.deepEqual(second.queued, [], 'one pending launch is enough: no request storm');++ assert.deepEqual(queued, [['m', 'launch', 'service:supply']]);await assert.rejects(createSearch().read('https://127.0.0.1'),/IP-literal/);console.log('Exchange key isolation, concurrent caps, revocation, search normalization and dry-run gates passed',@@@ -93,6 +93,30 @@ t.test('the cheapest offer that fits is rented, registered as an offer and healt} finally { await h.stop(); }});++t.test('the approved supply ceiling bounds what the provisioner may rent', async () => {++ const api = createGpuFetch();++ let now = T0;++ const h = await boot({ clock: { now: () => now }, fetchImpl: api.fetchImpl });++ const savePlan = (hourlyCeiling) => h.db.prepare(`INSERT INTO exchange_supply_settings (model, settings, updated_at, actor)++ VALUES ('deepseek-r1-32b', ?, ?, 'test') ON CONFLICT(model) DO UPDATE SET settings = excluded.settings, updated_at = excluded.updated_at`)++ .run(JSON.stringify({ model: 'deepseek-r1-32b', runtime: 'vllm', input_per_million: 1, output_per_million: 2,++ hourly_ceiling_usd: hourlyCeiling, monthly_ceiling_usd: 1000, required_vram_mb: 24000, idle_minutes: 10 }), new Date(now).toISOString());++ try {++ await settings(h, { cap: '50000000000' });++ await savePlan(0.1); // cheaper than any offer in the recorded marketplace answer++ const quote = await h.gpus.preview('deepseek-r1-32b');++ assert.strictEqual(quote.selected, null, 'no offer above the approved hour is selected');++ assert.ok(quote.reasons.some((r) => /approved supply ceiling/.test(r)), quote.reasons.join('; '));++ const refused = await h.gpus.launch('deepseek-r1-32b');++ assert.strictEqual(refused.ok, false);++ assert.strictEqual(refused.code, 'gpu.no_offer');++ assert.strictEqual(api.calls.create.length, 0, 'nothing over the approved ceiling was rented');++ await savePlan(0.2);++ const allowed = await h.gpus.preview('deepseek-r1-32b');++ assert.strictEqual(String(allowed.selected && allowed.selected.id), '111', 'the cheapest offer under the ceiling is selected');++ } finally { await h.stop(); }++});++t.test('the monthly cap is never passed: the launch is refused and says why', async () => {const api = createGpuFetch();let now = T0;nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * The owner's one action that opens an offer (server/exchange/offers.js and the console JSON API):++ * an offer is live only when its provider is on, its key is present, its model is on and its price is++ * approved; opening it approves the waiting prices (or writes the owner's fixed per-token price) and++ * switches both on, with one audit row. A missing key is refused before anything is written.++ *++ * No provider is called, no key is printed, and every model/provider here is a local record.++ */++const assert = require('assert');++const { boot, suite, token, request } = require('./helpers');++++const t = suite('offer-routes');++const READ = token('console', ['ai.usage.read']);++const WRITE = token('console', ['ai.provider.manage']);++++async function seed(h, key, { env = 'TEST_REFUSELESS_KEY', status = 'disabled', price = null } = {}) {++ await h.registry.upsertProvider({++ key, display_name: `${key} test`, kind: 'openai', status,++ base_url: 'https://api.example.test/v1', auth_mode: 'bearer', secret_ref: `env:${env}`,++ capabilities: ['chat'], provider_access: 'UNVERIFIED',++ }, { actor: 'test' });++ const model = await h.registry.upsertModel({++ provider_key: key, model_key: 'abliterated-model', display_name: 'Abliterated test model',++ type: 'chat', status: 'disabled', context_window: 32768,++ cost: { in_per_mtok: 1, out_per_mtok: 3, source: 'https://api.example.test/pricing', verified_at: '2026-11-10' },++ supports: { json: true, tools: false, streaming: true, vision: false },++ metadata: { uncensored: true },++ }, { actor: 'test' });++ return model;++}++++t.test('an unkeyed provider is not live, and opening it writes nothing', async () => {++ const h = await boot({ env: { TEST_REFUSELESS_KEY: '' } });++ try {++ await seed(h, 'keyless');++ const before = await h.registry.getModel('keyless', 'abliterated-model');++ const route = await request(h.base, 'GET', '/api/v1/admin/console/models/keyless/abliterated-model/route', { tok: READ });++ assert.strictEqual(route.status, 200, route.text);++ assert.strictEqual(route.body.route.live, false);++ assert.strictEqual(route.body.route.key_present, false);++ assert.ok(route.body.route.reasons.includes('no_key'));++ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/keyless/abliterated-model/open', { tok: WRITE, body: {} });++ assert.strictEqual(opened.status, 409, opened.text);++ assert.strictEqual(opened.body.code, 'offer.no_key');++ const after = await h.registry.getModel('keyless', 'abliterated-model');++ assert.strictEqual(after.status, 'disabled', 'the model was not switched on');++ const provider = await h.registry.getProvider('keyless');++ assert.strictEqual(provider.status, 'disabled', 'the provider was not switched on');++ assert.strictEqual(before.cost.in_per_mtok, after.cost.in_per_mtok, 'the price was not touched');++ } finally { await h.stop(); }++});++++t.test('opening approves the waiting prices, switches the offer on, and the route is live', async () => {++ const h = await boot({ env: { TEST_REFUSELESS_KEY: 'test-only-key' } });++ try {++ await seed(h, 'refuseless');++ const store = h.priceBook.store;++ await store.record({ provider_key: 'refuseless', model_key: 'abliterated-model', field: 'in_per_mtok',++ old_price: 1, new_price: 2, change_pct: 1, source: 'https://api.example.test/pricing', checked_at: '2026-11-10', status: 'pending' });++ await store.record({ provider_key: 'refuseless', model_key: 'abliterated-model', field: 'out_per_mtok',++ old_price: 3, new_price: 5, change_pct: 0.66, source: 'https://api.example.test/pricing', checked_at: '2026-11-10', status: 'pending' });++ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/refuseless/abliterated-model/open', { tok: WRITE, body: {} });++ assert.strictEqual(opened.status, 200, opened.text);++ assert.strictEqual(opened.body.route.live, true);++ assert.strictEqual(opened.body.route.price.in, 2);++ assert.strictEqual(opened.body.route.price.out, 5);++ const model = await h.registry.getModel('refuseless', 'abliterated-model');++ assert.strictEqual(model.status, 'active');++ assert.strictEqual(model.cost.in_per_mtok, 2);++ assert.strictEqual(model.cost.out_per_mtok, 5);++ assert.strictEqual((await h.registry.getProvider('refuseless')).status, 'active');++ const waiting = await h.priceBook.pending(50);++ assert.strictEqual(waiting.length, 0, 'both waiting prices were decided');++ const audit = await h.db.prepare("SELECT action, metadata FROM audit_log WHERE action = 'offer.opened' ORDER BY id DESC LIMIT 1").get();++ assert.ok(audit, 'the open is audited');++ assert.ok(JSON.parse(audit.metadata).live, 'the audit says the route is live');++ } finally { await h.stop(); }++});++++t.test('a fixed per-token price is the owner’s approval, without a waiting change', async () => {++ const h = await boot({ env: { TEST_REFUSELESS_KEY: 'test-only-key' } });++ try {++ await seed(h, 'hosted');++ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/hosted/abliterated-model/open', { tok: WRITE,++ body: { price: { in_per_mtok: 0.2, out_per_mtok: 0.7, reason: 'owner approved a fixed host price' } } });++ assert.strictEqual(opened.status, 200, opened.text);++ assert.strictEqual(opened.body.route.live, true);++ assert.strictEqual(opened.body.route.price.source, 'owner');++ const model = await h.registry.getModel('hosted', 'abliterated-model');++ assert.strictEqual(model.cost.in_per_mtok, 0.2);++ assert.strictEqual(model.cost.out_per_mtok, 0.7);++ assert.strictEqual(model.metadata.owner_fixed_price, true);++ assert.strictEqual(model.metadata.owner_fixed_price_reason, 'owner approved a fixed host price');++ // The price shown in the models console is the same live route.++ const models = await request(h.base, 'GET', '/api/v1/admin/console/models', { tok: READ });++ assert.strictEqual(models.status, 200, models.text);++ const row = models.body.models.find((m) => m.provider === 'hosted');++ assert.strictEqual(row.route.live, true);++ assert.strictEqual(row.route.price.source, 'owner');++ } finally { await h.stop(); }++});++++t.test('an offer with no price and no waiting change stays closed', async () => {++ const h = await boot({ env: { TEST_REFUSELESS_KEY: 'test-only-key' } });++ try {++ await seed(h, 'unpriced');++ await h.registry.upsertModel({++ provider_key: 'unpriced', model_key: 'abliterated-model', type: 'chat', status: 'disabled',++ cost: { in_per_mtok: null, out_per_mtok: null }, metadata: {},++ }, { actor: 'test' });++ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/unpriced/abliterated-model/open', { tok: WRITE, body: {} });++ assert.strictEqual(opened.status, 409, opened.text);++ assert.strictEqual(opened.body.code, 'offer.no_price');++ assert.strictEqual((await h.registry.getModel('unpriced', 'abliterated-model')).status, 'disabled');++ assert.strictEqual((await h.registry.getProvider('unpriced')).status, 'disabled');++ } finally { await h.stop(); }++});++++t.run();
@@ -46,6 +46,26 @@ pages only, never signs in, never rents, and a failed source keeps its last rows
@@ -46,6 +46,26 @@ pages only, never signs in, never rents, and a failed source keeps its last rows
Migration 0052 is expand-only. These changes need money/security review before deployment; this PR does not deploy.
Migration 0052 is expand-only. These changes need money/security review before deployment; this PR does not deploy.
+## Opening an offer
+
+The owner console's Models section reads one route per model (`GET /api/v1/admin/console/models/:provider/:model/route`)
+with the gates the catalogue and router apply: provider on, key present, model on and still listed, commercial access
+resellable, and an input and output price approved. `POST /api/v1/admin/console/models/:provider/:model/open` is the one
+owner action: it approves that model's waiting price-book changes (or writes the owner's fixed per-token `price`), switches
+the provider and model on, and records `offer.opened` with the prices and gates. A provider with no key is refused with
+`offer.no_key` before anything is written. Nothing in this action rents, spends or calls a provider.
+
+## Live GPU supply
+
+`exchange_supply_settings.settings.enabled` is a per-model owner switch (default off). A plan can always be saved and
+priced; turning it on also needs the provider key, the monthly cap, `ai.gpus.costs_bounded`, `ai.gpus.extra_hourly_nano`
+and `ai.gpus.dry_run=false`, or `save` answers `gpu.owner_gate` and changes nothing. The provisioner's minute tick asks the
+supply planner first: when an enabled plan's measured demand makes renting cheaper than buying the same tokens, it queues
+one `gpu_requests` launch (never more while a request or instance is open). The provisioner still checks the key, the
+monthly reservation and the plan's approved hourly/monthly ceilings before it rents; the selected marketplace offer above
+either ceiling is not selected. The registered GPU offer keeps the plan's fixed input/output prices
+(`urn:openvibe:owner-fixed-price`), so the catalogue and router serve the fixed route.
+
## Public market observations
## Public market observations
`GET https://api.openvibe.exchange/api/v1/exchange/market` returns
`GET https://api.openvibe.exchange/api/v1/exchange/market` returns
@@ -11,10 +11,10 @@ const gpuCatalog = require('../gpu/catalog');
@@ -11,10 +11,10 @@ const gpuCatalog = require('../gpu/catalog');
const { tokenCosts } = require('../gpu/token-cost');
const { tokenCosts } = require('../gpu/token-cost');
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
-function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
+function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
const r = express.Router();
const r = express.Router();
const providerOrder = require('../providers/order').createProviderOrder({ registry });
const providerOrder = require('../providers/order').createProviderOrder({ registry });
- const supply = require('../platform/supply').createSupply({ db, registry, clock });
+ const supplyManager = supply || require('../platform/supply').createSupply({ db, registry, clock });
const ownerConfig = require('../owner-config').createOwnerConfig({ db, registry, clock });
const ownerConfig = require('../owner-config').createOwnerConfig({ db, registry, clock });
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
// Exchange renders it with its existing console classes.
// Exchange renders it with its existing console classes.
@@ -27,9 +27,13 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -27,9 +27,13 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
const read = auth.requireCap(CAPS.usageRead);
const read = auth.requireCap(CAPS.usageRead);
const write = auth.requireCap(CAPS.providerManage);
const write = auth.requireCap(CAPS.providerManage);
const workflowWrite = auth.requireCap(CAPS.workflowManage);
const workflowWrite = auth.requireCap(CAPS.workflowManage);
- const modelView = (m, provider) => ({ ...m, provider: m.provider_key, model: m.model_key, accepted_price: m.cost,
+ // The offer's route and the owner's one action that opens it (server/exchange/offers.js): the same registry
+ // facts the catalog and router read, never a second list of models or prices.
+ const offerRoutes = require('../exchange/offers').createOfferRoutes({ registry, priceBook, env, clock, log });
+ const modelView = (m, provider, pending = 0) => ({ ...m, provider: m.provider_key, model: m.model_key, accepted_price: m.cost,
content_tier: require('../providers/pool-providers').isUncensored(provider, m) ? 'uncensored' : 'standard',
content_tier: require('../providers/pool-providers').isUncensored(provider, m) ? 'uncensored' : 'standard',
- owner_override: m.metadata?.owner_override || null });
+ owner_override: m.metadata?.owner_override || null,
+ route: offerRoutes.routeOf({ provider, model: m, pending, env }) });
const actor = (req) => {
const actor = (req) => {
const subject = String(req.get('x-ov-subject') || '');
const subject = String(req.get('x-ov-subject') || '');
return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) ? subject : req.principal.sub;
return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) ? subject : req.principal.sub;
@@ -38,9 +42,17 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -38,9 +42,17 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
try { res.set('Cache-Control', 'no-store').json(await fn(req)); }
try { res.set('Cache-Control', 'no-store').json(await fn(req)); }
catch (err) { log.error(`[console-json] ${req.method} ${req.path} failed (${err && err.code || 'ai.internal'})`); res.status(err.status || 500).json({ error: 'AI admin data is unavailable.', code: err.code || 'ai.internal' }); }
catch (err) { log.error(`[console-json] ${req.method} ${req.path} failed (${err && err.code || 'ai.internal'})`); res.status(err.status || 500).json({ error: 'AI admin data is unavailable.', code: err.code || 'ai.internal' }); }
};
};
+ // One offer's route, and the owner's one action that opens it: approve its waiting prices (or write the
+ // owner's fixed per-token price), switch the provider and the model on, then answer the route as it stands.
+ r.get('/api/v1/admin/console/models/:provider/:model/route', read, wrap(async (req) => ({ route: await offerRoutes.read(req.params.provider, req.params.model) })));
+ r.post('/api/v1/admin/console/models/:provider/:model/open', write, json, wrap(async (req) => {
+ const body = req.body || {};
+ const price = body.price && typeof body.price === 'object' ? body.price : null;
+ return await offerRoutes.open(req.params.provider, req.params.model, { price, actor: actor(req), trace: req.ov && req.ov.traceId });
+ }));
r.get('/api/v1/admin/console/:section', read, wrap(async (req) => {
r.get('/api/v1/admin/console/:section', read, wrap(async (req) => {
const section = req.params.section;
const section = req.params.section;
- if (section === 'supply') return { ...await supply.report(), ...await marketPayload() };
+ if (section === 'supply') return { ...await supplyManager.report(), ...await marketPayload() };
if (section === 'overview') {
if (section === 'overview') {
const since = new Date(clock.now() - 86400000).toISOString();
const since = new Date(clock.now() - 86400000).toISOString();
return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };
return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };
@@ -65,12 +77,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -65,12 +77,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
}))) : [];
}))) : [];
const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
- return { providers: rows, models: (await registry.listModels()).map(m => modelView(m, rows.find(p => p.key === m.provider_key))), presets: poolProviders.map((p) => ({ key: p.key, display_name: p.display_name, base_url: p.base_url, default_model: p.default_model, provider_access: p.provider_access, uncensored: Boolean(p.uncensored), models: p.models })), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };
+ const pendingBy = await offerRoutes.pendingByModel();
+ return { providers: rows, models: (await registry.listModels()).map(m => modelView(m, rows.find(p => p.key === m.provider_key), offerRoutes.pendingOf(pendingBy, m.provider_key, m.model_key))), presets: poolProviders.map((p) => ({ key: p.key, display_name: p.display_name, base_url: p.base_url, default_model: p.default_model, provider_access: p.provider_access, uncensored: Boolean(p.uncensored), models: p.models })), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };
}
}
if (section === 'models') {
if (section === 'models') {
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
const providers = await registry.listProviders();
const providers = await registry.listProviders();
- return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key))),
+ const pendingBy = await offerRoutes.pendingByModel();
+ return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key), offerRoutes.pendingOf(pendingBy, m.provider_key, m.model_key))),
provider: provider || null, sync_log: modelSync ? await modelSync.read(provider) : [] };
provider: provider || null, sync_log: modelSync ? await modelSync.read(provider) : [] };
}
}
if (section === 'orders') return { orders: await providerOrder.list() };
if (section === 'orders') return { orders: await providerOrder.list() };
@@ -137,7 +151,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -137,7 +151,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
return { ...await gpuMarket.refresh(), ...await marketPayload() };
return { ...await gpuMarket.refresh(), ...await marketPayload() };
}));
}));
r.post('/api/v1/admin/console/supply/quotes', json, write, wrap(async req => ({ recorded: await require('../pricebook/gpus').recordGpuQuotes(db, JSON.stringify(req.body.quotes)), dry_run: true })));
r.post('/api/v1/admin/console/supply/quotes', json, write, wrap(async req => ({ recorded: await require('../pricebook/gpus').recordGpuQuotes(db, JSON.stringify(req.body.quotes)), dry_run: true })));
- r.post('/api/v1/admin/console/supply', json, write, wrap(req => supply.save(req.body || {}, actor(req))));
+ r.post('/api/v1/admin/console/supply', json, write, wrap(req => supplyManager.save(req.body || {}, actor(req))));
r.post('/api/v1/admin/console/orders/:capability', json, write, wrap(req => providerOrder.save(req.params.capability, req.body, { actor: actor(req), trace: req.traceId })));
r.post('/api/v1/admin/console/orders/:capability', json, write, wrap(req => providerOrder.save(req.params.capability, req.body, { actor: actor(req), trace: req.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/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 }) })));
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
const ownerInputs = require('./owner-inputs');
-function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {
+function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, supply = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -281,7 +281,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -281,7 +281,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
- app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, abuse, priceBook, modelSync, creditGrants, sessionCookies, vault, settings, config, log }));
+ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, supply, abuse, priceBook, modelSync, creditGrants, sessionCookies, vault, settings, config, log }));
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.
// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+'use strict';
+/**
+ * One offer's route, and the owner's one action that opens it.
+ *
+ * The Exchange catalogue lists a model only when every gate passes (server/exchange/catalog.js):
+ * the provider is on, its key is present, the model is on and still listed, its commercial access may
+ * be resold, and a price is known. The router additionally skips a model that is missing from the
+ * provider's own list and an offer whose circuit is open. This module reads those registry facts
+ * (never a second list) and answers, per provider/model: is this offer on the route now, and if not
+ * which gate stops it.
+ *
+ * `open` is the owner's decision, made once and written once:
+ * - the pending price-book changes of that model are approved (or the owner's fixed per-token price
+ * is written), because a route is never served at a price nobody agreed to;
+ * - the provider is switched on and the model is switched on;
+ * - the same audit log records who opened it, with the price and the gates that were closed.
+ * A provider with no key is refused before anything is written: the key is the owner's to add, and
+ * this module never touches a secret. Nothing here rents, spends or calls a provider.
+ */
+const { AiError } = require('../util');
+const upstream = require('../providers/upstream');
+
+const ACCESS_UNSELLABLE = new Set(['CUSTOMER_BYOK', 'RESTRICTED']);
+const KINDS = ['chat', 'vision', 'embedding', 'stt', 'image'];
+
+const number = (value) => {
+ if (value === null || value === undefined || value === '') return null;
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0 ? n : null;
+};
+const day = (iso) => String(iso || '').slice(0, 10);
+
+/**
+ * The price the router would charge, after the owner's override. `source` says where it came from:
+ * the accepted provider price, the owner's fixed price, or null when nobody priced it.
+ */
+function priceOf(model) {
+ const override = model && model.metadata && model.metadata.owner_override && model.metadata.owner_override.cost;
+ const base = (model && model.cost) || {};
+ const cost = { ...base, ...(override || {}) };
+ const fixed = Boolean(model && model.metadata && (model.metadata.owner_fixed_price === true || override));
+ return {
+ in: number(cost.in_per_mtok),
+ out: number(cost.out_per_mtok),
+ cached: number(cost.cached_per_mtok),
+ source: fixed ? 'owner' : (cost.source ? (String(cost.source).startsWith('urn:openvibe:ai:unverified:') ? null : cost.source) : null),
+ verified_at: cost.verified_at || null,
+ owner: Boolean(override) ? 'override' : fixed ? 'fixed' : null,
+ };
+}
+
+/**
+ * The route of one provider/model. `pending` is how many of its price changes wait for a decision.
+ * The reasons are ordered the way the catalog tests them, so the first one is the first thing to fix.
+ */
+function routeOf({ provider = null, model = null, pending = 0, env = process.env } = {}) {
+ const reasons = [];
+ if (!provider) reasons.push('no_provider');
+ if (!model) reasons.push('no_model');
+ if (provider && provider.status !== 'active') reasons.push('provider_off');
+ if (provider && !upstream.credentialPresent(provider, env)) reasons.push('no_key');
+ if (provider && ACCESS_UNSELLABLE.has(provider.provider_access || 'UNVERIFIED')) reasons.push('access_restricted');
+ if (model && model.status !== 'active') reasons.push('model_off');
+ if (model && model.metadata && model.metadata.missing_since) reasons.push('model_gone');
+ if (model && !KINDS.includes(model.type || 'chat')) reasons.push('kind_not_offered');
+ const price = model ? priceOf(model) : null;
+ if (model && (price.in === null || price.out === null)) reasons.push('price_unknown');
+ const live = Boolean(provider && model) && reasons.length === 0;
+ return {
+ provider: provider ? provider.key : null,
+ model: model ? model.model_key : null,
+ live,
+ reasons,
+ provider_status: provider ? provider.status : null,
+ key_present: Boolean(provider && upstream.credentialPresent(provider, env)),
+ provider_access: provider ? provider.provider_access || 'UNVERIFIED' : null,
+ model_status: model ? model.status : null,
+ missing_since: model && model.metadata ? model.metadata.missing_since || null : null,
+ price,
+ pending_prices: Number(pending) || 0,
+ };
+}
+
+function createOfferRoutes({ registry, priceBook = null, env = process.env, clock = { now: () => Date.now() }, log = console } = {}) {
+ const now = () => new Date(clock.now()).toISOString();
+
+ /** The pending change counts of a set of models, read once. */
+ async function pendingByModel() {
+ const counts = new Map();
+ if (!priceBook) return counts;
+ const rows = await priceBook.pending(500).catch(() => []);
+ for (const row of rows || []) {
+ const key = `${row.provider_key}\u0000${row.model_key}`;
+ counts.set(key, (counts.get(key) || 0) + 1);
+ }
+ return counts;
+ }
+
+ function pendingOf(counts, providerKey, modelKey) {
+ return counts.get(`${providerKey}\u0000${modelKey}`) || 0;
+ }
+
+ /** One model's route, with its waiting price changes. */
+ async function read(providerKey, modelKey) {
+ const [provider, model] = await Promise.all([registry.getProvider(providerKey), registry.getModel(providerKey, modelKey)]);
+ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
+ if (!model) throw new AiError(404, 'ai.not_found', 'no such model');
+ const pending = priceBook
+ ? (await priceBook.pending(500).catch(() => [])).filter((c) => c.provider_key === providerKey && c.model_key === modelKey).length
+ : 0;
+ return routeOf({ provider, model, pending, env });
+ }
+
+ /** A fixed price the owner typed for this offer, in USD per million tokens. */
+ function fixedCost(input) {
+ if (!input || typeof input !== 'object') throw new AiError(422, 'ai.invalid', 'price must be an object');
+ const reason = String(input.reason || '').trim();
+ if (!reason) throw new AiError(422, 'ai.invalid', 'a fixed price needs a reason');
+ if (reason.length > 500) throw new AiError(422, 'ai.invalid', 'the reason is at most 500 characters');
+ const cost = {};
+ for (const field of ['in_per_mtok', 'out_per_mtok', 'cached_per_mtok']) {
+ if (input[field] === undefined || input[field] === null || input[field] === '') continue;
+ const n = number(input[field]);
+ if (n === null) throw new AiError(422, 'ai.invalid', `price.${field} must be a non-negative number`);
+ cost[field] = n;
+ }
+ if (cost.in_per_mtok === undefined || cost.out_per_mtok === undefined) {
+ throw new AiError(422, 'ai.invalid', 'a fixed price needs both input and output per million tokens');
+ }
+ return { reason, cost };
+ }
+
+ /**
+ * Open the offer: approve its waiting prices (or write the owner's fixed price), switch the provider
+ * and the model on, and answer the route as it now stands.
+ */
+ async function open(providerKey, modelKey, { price = null, actor = 'owner', trace = null } = {}) {
+ const [provider, model] = await Promise.all([registry.getProvider(providerKey), registry.getModel(providerKey, modelKey)]);
+ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
+ if (!model) throw new AiError(404, 'ai.not_found', 'no such model');
+ // Every refusal comes before any write: a half-opened offer is not a state the owner asked for.
+ if (!upstream.credentialPresent(provider, env)) {
+ throw new AiError(409, 'offer.no_key', `Add the ${provider.display_name || provider.key} key first; nothing was changed.`);
+ }
+ if (ACCESS_UNSELLABLE.has(provider.provider_access || 'UNVERIFIED')) {
+ throw new AiError(409, 'offer.access_restricted', `${provider.display_name || provider.key} may not be resold on the Exchange; its commercial access is ${provider.provider_access}.`);
+ }
+ if (model.metadata && model.metadata.missing_since) {
+ throw new AiError(409, 'offer.model_gone', 'The provider no longer lists this model; sync its models before opening the route.');
+ }
+ const fixed = price ? fixedCost(price) : null;
+ let approved = 0;
+ let fixedWritten = false;
+ if (fixed) {
+ const metadata = { ...(model.metadata || {}), owner_fixed_price: true, owner_fixed_price_reason: fixed.reason, owner_fixed_price_at: now() };
+ delete metadata.owner_override;
+ await registry.upsertModel({
+ ...model,
+ status: model.status,
+ cost: { ...fixed.cost, source: 'urn:openvibe:owner-fixed-price', verified_at: day(now()) },
+ metadata,
+ }, { actor, trace });
+ fixedWritten = true;
+ } else if (priceBook) {
+ const waiting = (await priceBook.pending(500)).filter((c) => c.provider_key === providerKey && c.model_key === modelKey);
+ const failed = [];
+ // One by one, never in parallel: each approval rewrites the model's cost from its own field.
+ for (const change of waiting) {
+ const out = await priceBook.decide(change.id, { decision: 'approve', actor });
+ if (!out.ok) failed.push({ id: change.id, error: out.error });
+ else approved += 1;
+ }
+ if (failed.length) {
+ throw new AiError(409, 'offer.price_approval_failed', `${failed.length} waiting price(s) could not be approved; the rest were approved and can be seen in the price book.`);
+ }
+ }
+ const priced = priceOf(await registry.getModel(providerKey, modelKey));
+ if (priced.in === null || priced.out === null) {
+ throw new AiError(409, 'offer.no_price', 'Approve a price in the price book (or save a fixed price) before opening the route.');
+ }
+ const enabled = { provider: false, model: false };
+ if (provider.status !== 'active') {
+ await registry.upsertProvider({ key: provider.key, status: 'active' }, { actor, trace, origin: 'console' });
+ enabled.provider = true;
+ }
+ const fresh = await registry.getModel(providerKey, modelKey);
+ if (fresh.status !== 'active') {
+ await registry.upsertModel({ ...fresh, status: 'active' }, { actor, trace });
+ enabled.model = true;
+ }
+ const route = await read(providerKey, modelKey);
+ await registry.audit(actor, 'offer.opened', 'model', `${providerKey}/${modelKey}`, {
+ trace,
+ metadata: {
+ approved_prices: approved, fixed_price: fixedWritten,
+ fixed_in_per_mtok: fixedWritten ? priced.in : null, fixed_out_per_mtok: fixedWritten ? priced.out : null,
+ provider_enabled: enabled.provider, model_enabled: enabled.model, live: route.live,
+ },
+ });
+ log.log(`[exchange] offer ${providerKey}/${modelKey} opened${fixedWritten ? ' at a fixed price' : ''}: ${approved} price(s) approved, provider ${enabled.provider ? 'on' : 'already on'}, model ${enabled.model ? 'on' : 'already on'}`);
+ return { ok: true, opened: true, already_live: route.live && !enabled.provider && !enabled.model && !fixedWritten && approved === 0, route };
+ }
+
+ return { read, open, routeOf, priceOf, pendingByModel, pendingOf };
+}
+
+module.exports = { createOfferRoutes, routeOf, priceOf };
@@ -224,6 +224,22 @@ function createGpuProvisioner({
@@ -224,6 +224,22 @@ function createGpuProvisioner({
const names = ['vast', 'runpod'].filter(name => !rentalOnly || cfg[name + '_key']);
const names = ['vast', 'runpod'].filter(name => !rentalOnly || cfg[name + '_key']);
const offers = [];
const offers = [];
const failures = [];
const failures = [];
+ // The owner's approved supply plan pins the hourly and monthly ceilings per model (server/platform/supply.js).
+ // Enforcing them here, at rent time, is what makes an approved host a bound and not only a comparison: a
+ // live marketplace offer above the ceiling is not selected, however good it looks.
+ const supplyRow = await db.prepare('SELECT settings FROM exchange_supply_settings WHERE model = ?').get(String(modelKey));
+ const approved = supplyRow ? (() => { try { return JSON.parse(supplyRow.settings); } catch { return null; } })() : null;
+ const withinApproved = (o) => {
+ if (!approved) return true;
+ const hourly = Number(o.price_per_hour);
+ const hourlyCeiling = Number(approved.hourly_ceiling_usd);
+ const monthlyCeiling = Number(approved.monthly_ceiling_usd);
+ const vram = Number(approved.required_vram_mb);
+ if (Number.isFinite(hourlyCeiling) && hourly > hourlyCeiling) return false;
+ if (Number.isFinite(monthlyCeiling) && hourly * 24 * 31 > monthlyCeiling) return false;
+ if (Number.isFinite(vram) && vram > 0 && o.gpu_ram_mb < vram) return false;
+ return true;
+ };
for (const name of names) {
for (const name of names) {
const adapter = name === 'vast' ? vastAdapter({ fetchImpl, key: cfg.vast_key || null }) : runpodAdapter({ fetchImpl, key: cfg.runpod_key || null });
const adapter = name === 'vast' ? vastAdapter({ fetchImpl, key: cfg.vast_key || null }) : runpodAdapter({ fetchImpl, key: cfg.runpod_key || null });
try {
try {
@@ -231,10 +247,13 @@ function createGpuProvisioner({
@@ -231,10 +247,13 @@ function createGpuProvisioner({
diskGb: spec.requirements.disk_gb, numGpus: spec.requirements.num_gpus,
diskGb: spec.requirements.disk_gb, numGpus: spec.requirements.num_gpus,
minReliability: spec.requirements.min_reliability, region, maxPricePerHour: 10, limit: 50 });
minReliability: spec.requirements.min_reliability, region, maxPricePerHour: 10, limit: 50 });
// Never trust marketplace-side filters alone.
// Never trust marketplace-side filters alone.
- offers.push(...found.filter(o => o.gpu_ram_mb >= spec.requirements.vram_mb && o.num_gpus >= spec.requirements.num_gpus
+ const fitting = found.filter(o => o.gpu_ram_mb >= spec.requirements.vram_mb && o.num_gpus >= spec.requirements.num_gpus
&& o.disk_gb >= spec.requirements.disk_gb && o.reliability >= spec.requirements.min_reliability
&& o.disk_gb >= spec.requirements.disk_gb && o.reliability >= spec.requirements.min_reliability
&& Number(o.price_per_hour) > 0 && Number.isFinite(Number(o.price_per_hour))
&& Number(o.price_per_hour) > 0 && Number.isFinite(Number(o.price_per_hour))
- && (!region || String(o.region).toUpperCase() === String(region).toUpperCase())));
+ && (!region || String(o.region).toUpperCase() === String(region).toUpperCase()));
+ const inside = fitting.filter(withinApproved);
+ if (approved && inside.length < fitting.length) failures.push('offers above the approved supply ceiling were left out');
+ offers.push(...inside);
} catch { failures.push(name + ': quote API unavailable'); }
} catch { failures.push(name + ': quote API unavailable'); }
}
}
const candidates = offers.map(o => ({ id: o.marketplace + ':' + o.id, tier: 'partner', health: 'up', region: o.region,
const candidates = offers.map(o => ({ id: o.marketplace + ':' + o.id, tier: 'partner', health: 'up', region: o.region,
@@ -245,9 +264,14 @@ function createGpuProvisioner({
@@ -245,9 +264,14 @@ function createGpuProvisioner({
return { dry_run: true, strategy, selected, offers, reasons: [...decision.reasons, ...(strategy === 'fastest' && !offers.some(o => o.latency_ms != null) ? ['latency unmeasured; choosing by price within the required region'] : []), ...failures], expires_at: iso(now() + 60000) };
return { dry_run: true, strategy, selected, offers, reasons: [...decision.reasons, ...(strategy === 'fastest' && !offers.some(o => o.latency_ms != null) ? ['latency unmeasured; choosing by price within the required region'] : []), ...failures], expires_at: iso(now() + 60000) };
}
}
+ /** May this provisioner serve the model? The supply tick asks before it queues a launch. */
+ function supports(modelKey) {
+ return Boolean(catalog.find(modelKey) || models.some((m) => m.key === modelKey));
+ }
+
/** Ask for a model to start or stop. The reconciler does it; nothing here acts directly. */
/** Ask for a model to start or stop. The reconciler does it; nothing here acts directly. */
async function request(modelKey, action, actor = 'service:ai') {
async function request(modelKey, action, actor = 'service:ai') {
- if (!catalog.find(modelKey) && !models.some((m) => m.key === modelKey)) throw new AiError(404, 'ai.not_found', 'no such GPU model');
+ if (!supports(modelKey)) throw new AiError(404, 'ai.not_found', 'no such GPU model');
if (action !== 'launch' && action !== 'stop') throw new AiError(422, 'ai.invalid', 'the action must be launch or stop');
if (action !== 'launch' && action !== 'stop') throw new AiError(422, 'ai.invalid', 'the action must be launch or stop');
await db.prepare('INSERT INTO gpu_requests (model_key, action, at, actor, created_at) VALUES (?, ?, ?, ?, ?)')
await db.prepare('INSERT INTO gpu_requests (model_key, action, at, actor, created_at) VALUES (?, ?, ?, ?, ?)')
.run(String(modelKey), action, iso(now()), String(actor || '').slice(0, 200), iso(now()));
.run(String(modelKey), action, iso(now()), String(actor || '').slice(0, 200), iso(now()));
@@ -914,6 +938,7 @@ function createGpuProvisioner({
@@ -914,6 +938,7 @@ function createGpuProvisioner({
attachEndpoints,
attachEndpoints,
launchSpec,
launchSpec,
request,
request,
+ supports,
config,
config,
moneySummary,
moneySummary,
sweepOrphans,
sweepOrphans,
@@ -57,6 +57,10 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -57,6 +57,10 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// API key and a monthly cap in the owner settings (ai.gpus). The reconciler rents, health-checks and
// API key and a monthly cap in the owner settings (ai.gpus). The reconciler rents, health-checks and
// scales the instances to zero; it never spends past the cap.
// scales the instances to zero; it never spends past the cap.
const gpus = require('./gpu/provisioner').createGpuProvisioner({ db, registry, settings, clock, fetchImpl, env, log });
const gpus = require('./gpu/provisioner').createGpuProvisioner({ db, registry, settings, clock, fetchImpl, env, log });
+ // The owner's GPU supply plans (server/platform/supply.js): fixed per-token prices and the ceilings of the host
+ // they are approved for. Live supply is an explicit per-model switch; its tick places launch requests only,
+ // and the provisioner below still checks the key, the monthly cap and the reservation before it rents.
+ const supply = require('./platform/supply').createSupply({ db, registry, clock, gpus, log });
// The public GPU marketplace prices (server/gpu/market.js): a due-based public-page reader for the console's
// The public GPU marketplace prices (server/gpu/market.js): a due-based public-page reader for the console's
// supply view. It never signs in or rents; a failed source keeps its last reading, marked stale.
// supply view. It never signs in or rents; a failed source keeps its last reading, marked stale.
const gpuMarket = require('./gpu/market').createGpuMarket({ db, config, clock, fetchImpl, log });
const gpuMarket = require('./gpu/market').createGpuMarket({ db, config, clock, fetchImpl, log });
@@ -229,7 +233,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -229,7 +233,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share });
+ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, supply, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -258,7 +262,9 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -258,7 +262,9 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
let gpuReconciler = null;
let gpuReconciler = null;
if (listen && config.nodeEnv !== 'test') {
if (listen && config.nodeEnv !== 'test') {
gpuReconciler = setInterval(() => {
gpuReconciler = setInterval(() => {
- ownEndpoints.tick()
+ // Supply queues a launch request first; the provisioner then fulfils it under the cap in the same minute.
+ supply.tick()
+ .then(() => ownEndpoints.tick())
.then(() => gpus.tick())
.then(() => gpus.tick())
.catch((err) => log.warn('[gpu] the reconciler failed: ' + err.message));
.catch((err) => log.warn('[gpu] the reconciler failed: ' + err.message));
}, 60 * 1000);
}, 60 * 1000);
@@ -364,7 +370,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -364,7 +370,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, modelSync, modelDiscovery, refusal, willingness, abliterationTax, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, modelSync, modelDiscovery, refusal, willingness, abliterationTax, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, supply, abuse, ownEndpoints, close };
}
}
if (require.main === module) {
if (require.main === module) {
@@ -72,9 +72,9 @@ function decide({ demand, quotes, settings, now = Date.now() }) {
@@ -72,9 +72,9 @@ function decide({ demand, quotes, settings, now = Date.now() }) {
? 'needs_measurement'
? 'needs_measurement'
: !capped
: !capped
? 'ceiling_required'
? 'ceiling_required'
- : reseller !== null && rental < reseller
- ? 'would_rent'
- : 'use_reseller',
+ : reseller != null && rental >= reseller
+ ? 'use_reseller'
+ : 'would_rent',
selected: chosen,
selected: chosen,
reseller_cost_usd: reseller,
reseller_cost_usd: reseller,
rental_cost_usd: rental,
rental_cost_usd: rental,
@@ -88,7 +88,23 @@ function decide({ demand, quotes, settings, now = Date.now() }) {
@@ -88,7 +88,23 @@ function decide({ demand, quotes, settings, now = Date.now() }) {
idle_minutes: settings.idle_minutes,
idle_minutes: settings.idle_minutes,
};
};
}
}
-function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
+function createSupply({ db, registry, clock = { now: () => Date.now() }, gpus = null, log = console }) {
+ /**
+ * The owner gate of live supply: the provisioner must have the marketplace key, the monthly cap and the
+ * explicit cost bound, and quote-only mode must be off. Without all of them a supply plan can be saved and
+ * priced, but it never asks for a rental.
+ */
+ function gate() {
+ if (!gpus || typeof gpus.config !== 'function') return { ok: false, reason: 'The GPU provisioner is off on this host.' };
+ const cfg = gpus.config();
+ if (!cfg.enabled) return { ok: false, reason: cfg.reason === 'no Vast.ai or RunPod API key'
+ ? 'Add a Vast.ai or RunPod API key first; nothing was changed.'
+ : cfg.reason === 'no monthly cap'
+ ? 'Set a positive monthly GPU cap first; nothing was changed.'
+ : 'Set ai.gpus.extra_hourly_nano and ai.gpus.costs_bounded=true first; nothing was changed.' };
+ if (cfg.dry_run) return { ok: false, reason: 'GPU rentals are in quote-only mode (ai.gpus.dry_run); turn that off first; nothing was changed.' };
+ return { ok: true, cfg };
+ }
async function save(b, actor) {
async function save(b, actor) {
if (
if (
typeof b.model !== 'string' ||
typeof b.model !== 'string' ||
@@ -110,18 +126,26 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
@@ -110,18 +126,26 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
'ai.invalid',
'ai.invalid',
'Model, runtime, prices, ceilings, VRAM and idle timeout are required',
'Model, runtime, prices, ceilings, VRAM and idle timeout are required',
);
);
- const value = Object.fromEntries(
- [
- 'model',
- 'runtime',
- 'input_per_million',
- 'output_per_million',
- 'hourly_ceiling_usd',
- 'monthly_ceiling_usd',
- 'required_vram_mb',
- 'idle_minutes',
- ].map((k) => [k, b[k]]),
- );
+ const enabled = b.enabled === true;
+ if (enabled) {
+ const g = gate();
+ if (!g.ok) throw new AiError(409, 'gpu.owner_gate', g.reason);
+ }
+ const value = {
+ ...Object.fromEntries(
+ [
+ 'model',
+ 'runtime',
+ 'input_per_million',
+ 'output_per_million',
+ 'hourly_ceiling_usd',
+ 'monthly_ceiling_usd',
+ 'required_vram_mb',
+ 'idle_minutes',
+ ].map((k) => [k, b[k]]),
+ ),
+ enabled,
+ };
await db
await db
.prepare(
.prepare(
'INSERT INTO exchange_supply_settings(model,settings,updated_at,actor) VALUES(?,?,?,?) ON CONFLICT(model) DO UPDATE SET settings=excluded.settings,updated_at=excluded.updated_at,actor=excluded.actor',
'INSERT INTO exchange_supply_settings(model,settings,updated_at,actor) VALUES(?,?,?,?) ON CONFLICT(model) DO UPDATE SET settings=excluded.settings,updated_at=excluded.updated_at,actor=excluded.actor',
@@ -154,7 +178,18 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
@@ -154,7 +178,18 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
},
},
{ actor },
{ actor },
);
);
- return { settings: value, dry_run: true };
+ await registry.audit(actor, enabled ? 'supply.open' : 'supply.save', 'gpu_supply', value.model, {
+ metadata: {
+ enabled,
+ input_per_million: value.input_per_million,
+ output_per_million: value.output_per_million,
+ hourly_ceiling_usd: value.hourly_ceiling_usd,
+ monthly_ceiling_usd: value.monthly_ceiling_usd,
+ required_vram_mb: value.required_vram_mb,
+ idle_minutes: value.idle_minutes,
+ },
+ });
+ return { settings: value, enabled, dry_run: !enabled };
}
}
async function report() {
async function report() {
const rows = await db
const rows = await db
@@ -177,8 +212,14 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
@@ -177,8 +212,14 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
)
)
.all(since);
.all(since);
const modelRows = await registry.listModels();
const modelRows = await registry.listModels();
+ const gpuProviders = new Map(providers.filter((p) => String(p.key).startsWith('gpu.')).map((p) => [p.key, p]));
+ const supplyGate = gate();
+ const liveSettings = rows.map((r) => JSON.parse(r.settings)).filter((s) => s.enabled === true);
return {
return {
- dry_run: true,
+ // Live supply is the owner's explicit switch plus every owner gate; a plan stays quote-only otherwise.
+ dry_run: !(liveSettings.length && supplyGate.ok),
+ live: Boolean(liveSettings.length && supplyGate.ok),
+ gate: supplyGate,
price_book: quotes,
price_book: quotes,
reports: rows.map((r) => {
reports: rows.map((r) => {
const settings = JSON.parse(r.settings);
const settings = JSON.parse(r.settings);
@@ -221,7 +262,7 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
@@ -221,7 +262,7 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
1e6,
1e6,
)
)
.filter(Number.isFinite);
.filter(Number.isFinite);
- return decide({
+ const plan = decide({
settings,
settings,
demand: {
demand: {
...usage,
...usage,
@@ -232,10 +273,68 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
@@ -232,10 +273,68 @@ function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
quotes: observations,
quotes: observations,
now: clock.now(),
now: clock.now(),
});
});
+ // The route the fixed prices would serve: a GPU host registers this model as a normal provider
+ // (server/gpu/provisioner.js), so the registry answer is the truth, not a second list.
+ const registered = modelRows.filter((m) => m.model_key === settings.model && gpuProviders.has(m.provider_key));
+ const row = registered.find((m) => gpuProviders.get(m.provider_key).status === 'active') || registered[0] || null;
+ const owner = row ? gpuProviders.get(row.provider_key) : null;
+ const route = row ? {
+ registered: true,
+ provider_key: row.provider_key,
+ provider_status: owner.status,
+ model_status: row.status,
+ fixed: row.metadata?.owner_fixed_price === true,
+ price: { in: row.cost?.in_per_mtok ?? null, out: row.cost?.out_per_mtok ?? null },
+ live: owner.status === 'active' && row.status === 'active' && row.cost?.in_per_mtok != null && row.cost?.out_per_mtok != null,
+ } : { registered: false, live: false, reason: 'No GPU host has registered this model yet.' };
+ return {
+ ...plan,
+ enabled: settings.enabled === true,
+ active: settings.enabled === true && supplyGate.ok,
+ route,
+ };
}),
}),
settings: rows.map((r) => JSON.parse(r.settings)),
settings: rows.map((r) => JSON.parse(r.settings)),
};
};
}
}
- return { save, report };
+
+ /**
+ * The reconciler half of live supply: when the owner enabled a plan and the plan says renting is cheaper
+ * than buying the same tokens, ask the existing GPU reconciler to launch it. It places a request only:
+ * the provisioner checks the key, the monthly cap, the reservation and the owner's approved hourly ceiling
+ * before it rents anything. One pending request and one live instance per model, however often this ticks.
+ */
+ async function tick() {
+ const g = gate();
+ if (!g.ok) return { queued: [], reason: g.reason };
+ const plan = await report();
+ const queued = [];
+ for (const row of plan.reports) {
+ if (!row.enabled || row.action !== 'would_rent' || !row.model) continue;
+ if (typeof gpus.supports === 'function' && !gpus.supports(row.model)) continue;
+ const running = await db.prepare("SELECT 1 FROM gpu_instances WHERE model_key = ? AND status IN ('starting','running','stopping') LIMIT 1").get(row.model);
+ if (running) continue;
+ const waiting = await db.prepare("SELECT 1 FROM gpu_requests WHERE model_key = ? AND action = 'launch' AND done_at IS NULL LIMIT 1").get(row.model);
+ if (waiting) continue;
+ try {
+ await gpus.request(row.model, 'launch', 'service:supply');
+ await registry.audit('service:supply', 'supply.launch_requested', 'gpu_supply', row.model, {
+ metadata: {
+ hourly_usd: row.selected?.hourly_usd ?? null,
+ input_per_million: row.price?.in ?? null,
+ output_per_million: row.price?.out ?? null,
+ rental_cost_usd: row.rental_cost_usd ?? null,
+ reseller_cost_usd: row.reseller_cost_usd ?? null,
+ },
+ });
+ queued.push(row.model);
+ } catch (err) {
+ log.warn(`[supply] could not request ${row.model}: ${String((err && err.message) || err).slice(0, 200)}`);
+ }
+ }
+ return { queued, checked: plan.reports.length };
+ }
+
+ return { save, report, tick, gate };
}
}
module.exports = { adapter, decide, createSupply };
module.exports = { adapter, decide, createSupply };
@@ -116,16 +116,24 @@ function serves(upstream, provider, row) {
@@ -116,16 +116,24 @@ function serves(upstream, provider, row) {
}
}
/**
/**
- * Is the credential this provider needs present right now? No credential for a keyless record (local,
- * whisper), and none needed for a free or pooled-subscription provider. A missing env/vault reference is
- * not configured: its models must not appear as offers.
+ * Is the credential this provider needs present right now, whether or not the provider is switched on? No
+ * credential for a keyless record (local, whisper), and none needed for a free or pooled-subscription provider.
+ * A missing env/vault reference is not present.
*/
*/
-function configured(provider, env = process.env) {
- if (!provider || provider.status !== 'active') return false;
+function credentialPresent(provider, env = process.env) {
+ if (!provider) return false;
if (provider.auth_mode === 'none') return true;
if (provider.auth_mode === 'none') return true;
if (provider.billing_profile === 'free') return true;
if (provider.billing_profile === 'free') return true;
if (provider.billing_profile === 'subscription' && !provider.secret_ref) return true;
if (provider.billing_profile === 'subscription' && !provider.secret_ref) return true;
try { return Boolean(resolveSecret(provider.secret_ref, env)); } catch (e) { return false; }
try { return Boolean(resolveSecret(provider.secret_ref, env)); } catch (e) { return false; }
}
}
-module.exports = { upstreamOf, serves, configured, familyOfModel, NAMES };
+/**
+ * Is the credential this provider needs present right now and is the provider on? A missing env/vault
+ * reference is not configured: its models must not appear as offers.
+ */
+function configured(provider, env = process.env) {
+ return Boolean(provider && provider.status === 'active' && credentialPresent(provider, env));
+}
+
+module.exports = { upstreamOf, serves, configured, credentialPresent, familyOfModel, NAMES };
@@ -136,6 +136,55 @@ const { decide, adapter } = require('../server/platform/supply');
@@ -136,6 +136,55 @@ const { decide, adapter } = require('../server/platform/supply');
assert.equal(report.dry_run,true);
assert.equal(report.dry_run,true);
assert.equal(report.price_book.length,1);
assert.equal(report.price_book.length,1);
assert.equal(report.reports[0].action,'would_stop');
assert.equal(report.reports[0].action,'would_stop');
+
+ // A model with no published resale price still rents when the GPU hour is cheaper than the tokens
+ // would be: before 2026-11-11 decide() answered "use_reseller" even when there was no reseller.
+ const noReseller = decide({ settings, demand: { tokens_in: 1e6, tokens_out: 1e6, reseller_cost_usd: null },
+ quotes: [{ hourly_usd: 0.5, measured_tokens_per_second: 50, vram_mb: 24000, observed_at: new Date().toISOString() }] });
+ assert.equal(noReseller.action, 'would_rent');
+
+ // Live supply is an owner switch behind every owner gate: without the provisioner it cannot be turned on.
+ await assert.rejects(supply.save({ ...settings, enabled: true }, 'owner'), (e) => e.code === 'gpu.owner_gate');
+
+ // Demand for the model, twenty measured requests and a registered GPU provider are what the plan reads.
+ const at = new Date().toISOString();
+ await h.registry.upsertProvider({ key: 'gpu.m', display_name: 'GPU m', kind: 'openai', status: 'active',
+ base_url: 'https://gpu.example/v1', auth_mode: 'none', capabilities: ['chat'], provider_access: 'OWN_DEPLOYMENT',
+ metadata: { gpu: { gpu_name: 'RTX 4090' } } }, { actor: 'test' });
+ await h.registry.upsertModel({ provider_key: 'gpu.m', model_key: 'm', type: 'chat', status: 'active',
+ cost: { in_per_mtok: 1, out_per_mtok: 2, source: 'urn:openvibe:owner-fixed-price', verified_at: at.slice(0, 10) },
+ metadata: { owner_fixed_price: true } }, { actor: 'test' });
+ await h.db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, input, input_hash,
+ synthetic, provider_key, model_key, tokens_in, tokens_out, cost_usd, created_at)
+ VALUES ('run_supply_demand', 'ai.complete', 1, 'succeeded', 'service', 'svc:test', '{}', 'h', 0, 'gpu.m', 'm', 1000000, 1000000, 3, ?)`)
+ .run(at);
+ for (let i = 0; i < 20; i++) await h.db.prepare(`INSERT INTO requests (operation, provider_key, model_key, status, tokens_out, latency_ms, created_at)
+ VALUES ('chat', 'gpu.m', 'm', 'ok', 100, 1000, ?)`).run(at);
+ const queued = [];
+ const fakeGpus = {
+ config: () => ({ enabled: true, dry_run: false, reason: '', cap_nano: 1000n, monthly_cap: 1 }),
+ request: async (model, action, actor) => {
+ queued.push([model, action, actor]);
+ await h.db.prepare('INSERT INTO gpu_requests (model_key, action, at, actor, created_at) VALUES (?, ?, ?, ?, ?)')
+ .run(model, action, at, actor, at);
+ return { ok: true, model_key: model, action };
+ },
+ };
+ const live = createSupply({ db: h.db, registry: h.registry, gpus: fakeGpus });
+ const saved = await live.save({ ...settings, enabled: true }, 'owner');
+ assert.equal(saved.enabled, true);
+ assert.equal(saved.dry_run, false);
+ const planned = await live.report();
+ assert.equal(planned.live, true);
+ assert.equal(planned.reports[0].enabled, true);
+ assert.equal(planned.reports[0].action, 'would_rent');
+ assert.equal(planned.reports[0].route.registered, true);
+ assert.equal(planned.reports[0].route.fixed, true);
+ const first = await live.tick();
+ assert.deepEqual(first.queued, ['m']);
+ const second = await live.tick();
+ assert.deepEqual(second.queued, [], 'one pending launch is enough: no request storm');
+ assert.deepEqual(queued, [['m', 'launch', 'service:supply']]);
await assert.rejects(createSearch().read('https://127.0.0.1'),/IP-literal/);
await assert.rejects(createSearch().read('https://127.0.0.1'),/IP-literal/);
console.log(
console.log(
'Exchange key isolation, concurrent caps, revocation, search normalization and dry-run gates passed',
'Exchange key isolation, concurrent caps, revocation, search normalization and dry-run gates passed',
@@ -93,6 +93,30 @@ t.test('the cheapest offer that fits is rented, registered as an offer and healt
@@ -93,6 +93,30 @@ t.test('the cheapest offer that fits is rented, registered as an offer and healt
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
+t.test('the approved supply ceiling bounds what the provisioner may rent', async () => {
+ const api = createGpuFetch();
+ let now = T0;
+ const h = await boot({ clock: { now: () => now }, fetchImpl: api.fetchImpl });
+ const savePlan = (hourlyCeiling) => h.db.prepare(`INSERT INTO exchange_supply_settings (model, settings, updated_at, actor)
+ VALUES ('deepseek-r1-32b', ?, ?, 'test') ON CONFLICT(model) DO UPDATE SET settings = excluded.settings, updated_at = excluded.updated_at`)
+ .run(JSON.stringify({ model: 'deepseek-r1-32b', runtime: 'vllm', input_per_million: 1, output_per_million: 2,
+ hourly_ceiling_usd: hourlyCeiling, monthly_ceiling_usd: 1000, required_vram_mb: 24000, idle_minutes: 10 }), new Date(now).toISOString());
+ try {
+ await settings(h, { cap: '50000000000' });
+ await savePlan(0.1); // cheaper than any offer in the recorded marketplace answer
+ const quote = await h.gpus.preview('deepseek-r1-32b');
+ assert.strictEqual(quote.selected, null, 'no offer above the approved hour is selected');
+ assert.ok(quote.reasons.some((r) => /approved supply ceiling/.test(r)), quote.reasons.join('; '));
+ const refused = await h.gpus.launch('deepseek-r1-32b');
+ assert.strictEqual(refused.ok, false);
+ assert.strictEqual(refused.code, 'gpu.no_offer');
+ assert.strictEqual(api.calls.create.length, 0, 'nothing over the approved ceiling was rented');
+ await savePlan(0.2);
+ const allowed = await h.gpus.preview('deepseek-r1-32b');
+ assert.strictEqual(String(allowed.selected && allowed.selected.id), '111', 'the cheapest offer under the ceiling is selected');
+ } finally { await h.stop(); }
+});
+
t.test('the monthly cap is never passed: the launch is refused and says why', async () => {
t.test('the monthly cap is never passed: the launch is refused and says why', async () => {
const api = createGpuFetch();
const api = createGpuFetch();
let now = T0;
let now = T0;
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * The owner's one action that opens an offer (server/exchange/offers.js and the console JSON API):
+ * an offer is live only when its provider is on, its key is present, its model is on and its price is
+ * approved; opening it approves the waiting prices (or writes the owner's fixed per-token price) and
+ * switches both on, with one audit row. A missing key is refused before anything is written.
+ *
+ * No provider is called, no key is printed, and every model/provider here is a local record.
+ */
+const assert = require('assert');
+const { boot, suite, token, request } = require('./helpers');
+
+const t = suite('offer-routes');
+const READ = token('console', ['ai.usage.read']);
+const WRITE = token('console', ['ai.provider.manage']);
+
+async function seed(h, key, { env = 'TEST_REFUSELESS_KEY', status = 'disabled', price = null } = {}) {
+ await h.registry.upsertProvider({
+ key, display_name: `${key} test`, kind: 'openai', status,
+ base_url: 'https://api.example.test/v1', auth_mode: 'bearer', secret_ref: `env:${env}`,
+ capabilities: ['chat'], provider_access: 'UNVERIFIED',
+ }, { actor: 'test' });
+ const model = await h.registry.upsertModel({
+ provider_key: key, model_key: 'abliterated-model', display_name: 'Abliterated test model',
+ type: 'chat', status: 'disabled', context_window: 32768,
+ cost: { in_per_mtok: 1, out_per_mtok: 3, source: 'https://api.example.test/pricing', verified_at: '2026-11-10' },
+ supports: { json: true, tools: false, streaming: true, vision: false },
+ metadata: { uncensored: true },
+ }, { actor: 'test' });
+ return model;
+}
+
+t.test('an unkeyed provider is not live, and opening it writes nothing', async () => {
+ const h = await boot({ env: { TEST_REFUSELESS_KEY: '' } });
+ try {
+ await seed(h, 'keyless');
+ const before = await h.registry.getModel('keyless', 'abliterated-model');
+ const route = await request(h.base, 'GET', '/api/v1/admin/console/models/keyless/abliterated-model/route', { tok: READ });
+ assert.strictEqual(route.status, 200, route.text);
+ assert.strictEqual(route.body.route.live, false);
+ assert.strictEqual(route.body.route.key_present, false);
+ assert.ok(route.body.route.reasons.includes('no_key'));
+ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/keyless/abliterated-model/open', { tok: WRITE, body: {} });
+ assert.strictEqual(opened.status, 409, opened.text);
+ assert.strictEqual(opened.body.code, 'offer.no_key');
+ const after = await h.registry.getModel('keyless', 'abliterated-model');
+ assert.strictEqual(after.status, 'disabled', 'the model was not switched on');
+ const provider = await h.registry.getProvider('keyless');
+ assert.strictEqual(provider.status, 'disabled', 'the provider was not switched on');
+ assert.strictEqual(before.cost.in_per_mtok, after.cost.in_per_mtok, 'the price was not touched');
+ } finally { await h.stop(); }
+});
+
+t.test('opening approves the waiting prices, switches the offer on, and the route is live', async () => {
+ const h = await boot({ env: { TEST_REFUSELESS_KEY: 'test-only-key' } });
+ try {
+ await seed(h, 'refuseless');
+ const store = h.priceBook.store;
+ await store.record({ provider_key: 'refuseless', model_key: 'abliterated-model', field: 'in_per_mtok',
+ old_price: 1, new_price: 2, change_pct: 1, source: 'https://api.example.test/pricing', checked_at: '2026-11-10', status: 'pending' });
+ await store.record({ provider_key: 'refuseless', model_key: 'abliterated-model', field: 'out_per_mtok',
+ old_price: 3, new_price: 5, change_pct: 0.66, source: 'https://api.example.test/pricing', checked_at: '2026-11-10', status: 'pending' });
+ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/refuseless/abliterated-model/open', { tok: WRITE, body: {} });
+ assert.strictEqual(opened.status, 200, opened.text);
+ assert.strictEqual(opened.body.route.live, true);
+ assert.strictEqual(opened.body.route.price.in, 2);
+ assert.strictEqual(opened.body.route.price.out, 5);
+ const model = await h.registry.getModel('refuseless', 'abliterated-model');
+ assert.strictEqual(model.status, 'active');
+ assert.strictEqual(model.cost.in_per_mtok, 2);
+ assert.strictEqual(model.cost.out_per_mtok, 5);
+ assert.strictEqual((await h.registry.getProvider('refuseless')).status, 'active');
+ const waiting = await h.priceBook.pending(50);
+ assert.strictEqual(waiting.length, 0, 'both waiting prices were decided');
+ const audit = await h.db.prepare("SELECT action, metadata FROM audit_log WHERE action = 'offer.opened' ORDER BY id DESC LIMIT 1").get();
+ assert.ok(audit, 'the open is audited');
+ assert.ok(JSON.parse(audit.metadata).live, 'the audit says the route is live');
+ } finally { await h.stop(); }
+});
+
+t.test('a fixed per-token price is the owner’s approval, without a waiting change', async () => {
+ const h = await boot({ env: { TEST_REFUSELESS_KEY: 'test-only-key' } });
+ try {
+ await seed(h, 'hosted');
+ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/hosted/abliterated-model/open', { tok: WRITE,
+ body: { price: { in_per_mtok: 0.2, out_per_mtok: 0.7, reason: 'owner approved a fixed host price' } } });
+ assert.strictEqual(opened.status, 200, opened.text);
+ assert.strictEqual(opened.body.route.live, true);
+ assert.strictEqual(opened.body.route.price.source, 'owner');
+ const model = await h.registry.getModel('hosted', 'abliterated-model');
+ assert.strictEqual(model.cost.in_per_mtok, 0.2);
+ assert.strictEqual(model.cost.out_per_mtok, 0.7);
+ assert.strictEqual(model.metadata.owner_fixed_price, true);
+ assert.strictEqual(model.metadata.owner_fixed_price_reason, 'owner approved a fixed host price');
+ // The price shown in the models console is the same live route.
+ const models = await request(h.base, 'GET', '/api/v1/admin/console/models', { tok: READ });
+ assert.strictEqual(models.status, 200, models.text);
+ const row = models.body.models.find((m) => m.provider === 'hosted');
+ assert.strictEqual(row.route.live, true);
+ assert.strictEqual(row.route.price.source, 'owner');
+ } finally { await h.stop(); }
+});
+
+t.test('an offer with no price and no waiting change stays closed', async () => {
+ const h = await boot({ env: { TEST_REFUSELESS_KEY: 'test-only-key' } });
+ try {
+ await seed(h, 'unpriced');
+ await h.registry.upsertModel({
+ provider_key: 'unpriced', model_key: 'abliterated-model', type: 'chat', status: 'disabled',
+ cost: { in_per_mtok: null, out_per_mtok: null }, metadata: {},
+ }, { actor: 'test' });
+ const opened = await request(h.base, 'POST', '/api/v1/admin/console/models/unpriced/abliterated-model/open', { tok: WRITE, body: {} });
+ assert.strictEqual(opened.status, 409, opened.text);
+ assert.strictEqual(opened.body.code, 'offer.no_price');
+ assert.strictEqual((await h.registry.getModel('unpriced', 'abliterated-model')).status, 'disabled');
+ assert.strictEqual((await h.registry.getProvider('unpriced')).status, 'disabled');
+ } finally { await h.stop(); }
+});
+
+t.run();