openvibers/openvibe.ai Commits

Open an approved offer and bound live GPU supply (#476)

OP OpenVibers committed 2 days ago · b51864cf23 · 11 files, +614 −42

The owner console could approve a price and switch a provider and a model on,
but not as one decision, and a saved GPU supply plan never told the provisioner
to rent. This adds both, behind owner-only routes and default-off switches.

- `GET /api/v1/admin/console/models/:provider/:model/route` answers the exact
  gates the catalogue and router apply (provider on, key present, model on and
  listed, resellable access, an input and output price).
- `POST .../open` approves that model's waiting price-book changes, or writes
  the owner's fixed per-token price, then switches the provider and the model
  on, audits `offer.opened`, and re-reads the route. A missing key is refused
  with `offer.no_key` before anything is written.
- Exchange's Models page shows the route and one Open action; a missing key or
  price disables it with the reason in the owner's words.
- A GPU supply plan now has an `enabled` switch (default off). Enabling needs
  the marketplace key, monthly cap, bounded extras and `ai.gpus.dry_run=false`,
  otherwise `gpu.owner_gate` and no change. The provisioner tick asks the
  planner first and queues one launch when renting beats buying the same
  tokens; the plan's hourly and monthly ceilings are enforced at rent time and
  its fixed prices are what the registered GPU offer serves.
- `decide()` no longer answers `use_reseller` when no resale price exists: a
  model with no alternative can now be rented, which is the abliterated case.

Tests: offer-routes, exchange-platform (gate, plan, one launch, dedupe), gpu
(ceiling), console-data, gpu-market, price-book, owner-config. No provider is
called, no key is printed, and nothing rents in the tests.

Changes

ddiff --git a/docs/exchange-platform.md b/docs/exchange-platform.md
iindex be055e0..2755c8c 100644
---- a/docs/exchange-platform.md
++++ b/docs/exchange-platform.md
@@@ -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.
++## 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
ddiff --git a/server/api/console-data.js b/server/api/console-data.js
iindex fa05ab1..6210948 100644
---- a/server/api/console-data.js
++++ b/server/api/console-data.js
@@@ -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 }) })));
ddiff --git a/server/app.js b/server/app.js
iindex 9666e23..de1b257 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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).
ddiff --git a/server/exchange/offers.js b/server/exchange/offers.js
nnew file mode 100644
iindex 0000000..5705dda
---- /dev/null
++++ b/server/exchange/offers.js
@@@ -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 };
ddiff --git a/server/gpu/provisioner.js b/server/gpu/provisioner.js
iindex b7e51b5..0076d14 100644
---- a/server/gpu/provisioner.js
++++ b/server/gpu/provisioner.js
@@@ -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,
ddiff --git a/server/index.js b/server/index.js
iindex d13e5f7..9d4a62c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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) {
ddiff --git a/server/platform/supply.js b/server/platform/supply.js
iindex 0a52b4a..088c725 100644
---- a/server/platform/supply.js
++++ b/server/platform/supply.js
@@@ -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 };
ddiff --git a/server/providers/upstream.js b/server/providers/upstream.js
iindex ebcaf70..1a56bf7 100644
---- a/server/providers/upstream.js
++++ b/server/providers/upstream.js
@@@ -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 };
ddiff --git a/test/exchange-platform.test.js b/test/exchange-platform.test.js
iindex 30ee1d0..da70247 100644
---- a/test/exchange-platform.test.js
++++ b/test/exchange-platform.test.js
@@@ -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',
ddiff --git a/test/gpu.test.js b/test/gpu.test.js
iindex 715db35..9be5394 100644
---- a/test/gpu.test.js
++++ b/test/gpu.test.js
@@@ -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;
ddiff --git a/test/offer-routes.test.js b/test/offer-routes.test.js
nnew file mode 100644
iindex 0000000..5fd8a7e
---- /dev/null
++++ b/test/offer-routes.test.js
@@@ -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();