openvibers/openvibe.ai Commits
Add GPU job classes and checkpointed interruptible capacity (#489)
Changes
nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- GPU job classes (2026-11-11): a rental is interactive or batch, and a batch job may name where it++-- resumes from. Interruptible capacity is only ever chosen for a batch job with a checkpoint.++-- Additive only: existing rows are interactive and have no checkpoint.++ALTER TABLE gpu_instances ADD COLUMN IF NOT EXISTS job_class text COLLATE "C" NOT NULL DEFAULT 'interactive';++ALTER TABLE gpu_instances ADD COLUMN IF NOT EXISTS checkpoint text COLLATE "C";++ALTER TABLE gpu_instances ADD COLUMN IF NOT EXISTS interruptible boolean NOT NULL DEFAULT false;++ALTER TABLE gpu_requests ADD COLUMN IF NOT EXISTS job_class text COLLATE "C" NOT NULL DEFAULT 'interactive';++ALTER TABLE gpu_requests ADD COLUMN IF NOT EXISTS checkpoint text COLLATE "C";++CREATE INDEX IF NOT EXISTS gpu_instances_class ON gpu_instances (job_class, month);@@@ -155,7 +155,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,}));r.get('/api/v1/admin/console/gpus/:model/quote', read, wrap(async (req) => {if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU quotes are off on this host');-- return await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined });++ return await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined, jobClass: req.query.class || undefined, checkpoint: req.query.checkpoint || undefined });}));// Read the public marketplace pages now instead of waiting for the due tick. It still never rents anything.r.post('/api/v1/admin/console/gpus/prices/refresh', write, wrap(async () => {@@@ -286,7 +286,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU controls are off on this host');const action = String(req.params.action);if (!['launch', 'stop'].includes(action)) throw new AiError(404, 'ai.not_found', 'no such GPU action');-- await gpus.request(String(req.params.model || ''), action, actor(req));++ await gpus.request(String(req.params.model || ''), action, actor(req), { jobClass: (req.body || {}).class, checkpoint: (req.body || {}).checkpoint });return { ok: true };}));r.post('/api/v1/admin/console/pricebook/changes/:id', write, wrap(async (req) => {nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Job classes for the GPU provisioner (2026-11-11): a job is `interactive` or `batch`.++ *++ * Interruptible capacity may take a batch job only when the job says where it resumes from (its++ * checkpoint). A batch job without one, and every interactive job, runs on non-interruptible++ * capacity only. The choice is deterministic: class first (a provider that does not serve the class++ * is out), then the hourly cost, then the owner's provider order, then the offer id.++ *++ * These are pure functions: no database, no network, no money. The provisioner applies them to the++ * offers its adapters return and to the owner's policy rows (server/gpu/policy.js).++ */++const { AiError } = require('../util');++++const JOB_CLASSES = Object.freeze(['interactive', 'batch']);++const DEFAULT_CLASS = 'interactive';++const CHECKPOINT_MAX = 500;++++function classOf(value) {++ if (value == null || value === '') return DEFAULT_CLASS;++ const key = String(value).trim().toLowerCase();++ if (!JOB_CLASSES.includes(key)) throw new AiError(422, 'gpu.invalid_class', 'a GPU job is interactive or batch');++ return key;++}++++/**++ * Where a batch job resumes from. A path, a URL or an explicit storage reference, never a secret: it is++ * stored with the instance row and shown in the console.++ */++function checkpointOf(value) {++ if (value == null || value === '') return null;++ if (typeof value !== 'string') throw new AiError(422, 'gpu.invalid_checkpoint', 'a checkpoint is a path or URL');++ const text = value.trim();++ if (text.length < 3 || text.length > CHECKPOINT_MAX || /[\u0000-\u001f]/.test(text)) {++ throw new AiError(422, 'gpu.invalid_checkpoint', `a checkpoint must be 3-${CHECKPOINT_MAX} printable characters`);++ }++ return text;++}++++/** May this provider take a job of the class at all? */++function providerTakesClass(policy, jobClass) {++ const classes = Array.isArray(policy && policy.classes) && policy.classes.length ? policy.classes : JOB_CLASSES;++ return classes.includes(jobClass);++}++++/**++ * Is this offer allowed for the job? An interruptible offer needs all three: a batch job, a declared++ * checkpoint and the owner permitting interruptible capacity for that provider.++ */++function offerAllowed(offer, policy, jobClass, checkpoint) {++ if (!providerTakesClass(policy, jobClass)) return 'the provider is not allowed for ' + jobClass + ' jobs';++ if (!offer || !offer.interruptible) return null;++ if (jobClass !== 'batch') return 'interruptible capacity takes batch jobs only';++ if (!checkpoint) return 'interruptible capacity needs a checkpoint to resume from';++ if (!(policy && policy.interruptible_allowed)) return 'the owner does not allow interruptible capacity on this provider';++ return null;++}++++const num = (value, fallback = Infinity) => {++ const n = Number(value);++ return Number.isFinite(n) ? n : fallback;++};++const orderOf = (policy) => (Number.isInteger(policy && policy.order) ? policy.order : 50);++++/**++ * Filter the offers for a job and pick the first one under the owner's deterministic rule.++ *++ * offers the adapters' offers; each carries marketplace, id, price_per_hour, interruptible++ * policies a Map (or object) of marketplace key -> the owner's provider policy++ * jobClass interactive | batch++ * checkpoint where a batch job resumes, or null++ * strategy 'cheapest' (cost then order) or 'fastest' (measured latency, then cost, then order)++ *++ * Returns { offers, selected, reasons }: `offers` is the eligible list in the chosen order, so the++ * console can show why the first one won.++ */++function pickOffer(offers, { policies = {}, jobClass = DEFAULT_CLASS, checkpoint = null, strategy = 'cheapest' } = {}) {++ const policyOf = (key) => (policies.get ? policies.get(key) : policies[key]) || null;++ const reasons = [];++ const eligible = [];++ for (const offer of offers || []) {++ const policy = policyOf(offer.marketplace);++ if (policy && policy.configured && policy.enabled === false) { reasons.push(offer.marketplace + ': disabled by the owner'); continue; }++ const why = offerAllowed(offer, policy, jobClass, checkpoint);++ if (why) { reasons.push(offer.marketplace + ': ' + why); continue; }++ eligible.push(offer);++ }++ const price = (offer) => num(offer.price_per_hour);++ const latency = (offer) => num(offer.latency_ms);++ eligible.sort((a, b) => {++ if (strategy === 'fastest') {++ const byLatency = latency(a) - latency(b);++ if (byLatency) return byLatency;++ }++ const byPrice = price(a) - price(b);++ if (byPrice) return byPrice;++ const byOrder = orderOf(policyOf(a.marketplace)) - orderOf(policyOf(b.marketplace));++ if (byOrder) return byOrder;++ return String(a.marketplace + ':' + a.id).localeCompare(String(b.marketplace + ':' + b.id));++ });++ if (!eligible.length) reasons.push('no offer serves the ' + jobClass + ' class' + (jobClass === 'batch' ? (checkpoint ? ' with a checkpoint' : ' on non-interruptible capacity') : ''));++ return {++ offers: eligible,++ selected: eligible[0] || null,++ reasons,++ job_class: jobClass,++ checkpoint,++ };++}++++module.exports = { JOB_CLASSES, DEFAULT_CLASS, classOf, checkpointOf, providerTakesClass, offerAllowed, pickOffer };@@@ -38,9 +38,9 @@ const WEIGHTS_FILE = /^[A-Za-z0-9][A-Za-z0-9._ -]{0,180}\.gguf$/;const BILLING_OFF = 'Balance billing is not on yet, so an endpoint cannot be paid for. The owner must turn on Balance first.';function plainGpuReason(reason) {-- if (reason === 'no Vast.ai or RunPod API key') return 'The owner has not added a GPU provider key (Vast.ai or RunPod) yet, so no endpoint can start.';++ if (/GPU provider key|Vast\.ai or RunPod API key/.test(String(reason || ''))) return 'The owner has not added a GPU provider key (Vast.ai or RunPod) yet, so no endpoint can start.';if (reason === 'no monthly cap') return 'The owner has not set a monthly GPU cap yet, so no endpoint can start.';-- return 'The GPU provider is not set up yet, so no endpoint can start.';++ return 'The GPU provider is not set up yet (a GPU provider key and a monthly cap are needed), so no endpoint can start.';}/** A short, plain sentence for why a rent stopped. The status line is a sentence, never a code. */@@@ -11,6 +11,21 @@ const DEFAULTS = Object.freeze({const GLOBAL = Object.freeze({ quote_only: true, interactive_cap_nano: null, batch_cap_nano: null });const OWNED = new Set(['svc:exchange']);++function defaultsFor(provider) {++ return { ...DEFAULTS, ...(provider === 'salad' ? { classes: ['batch'], max_replicas: 3 } : {}) };++}++++/**++ * The policy in force for a provider. Before the owner has saved one, a provider whose key is already++ * in the vault stays on: adding this table must not silently stop the rentals the owner had. Saving++ * any provider setting makes its stored `enabled` the one that counts.++ */++function effectiveProvider(provider, saved, hasKey) {++ const out = { ...defaultsFor(provider), ...(saved || {}) };++ if (!saved) out.enabled = Boolean(hasKey);++ return out;++}++function invalid(message) { throw new AiError(422, 'gpu.policy_invalid', message); }function nano(value, name) {if (value == null || value === '') return null;@@@ -72,18 +87,24 @@ function createGpuPolicy({ db, settings, clock = { now: () => Date.now() } }) {const global = { ...GLOBAL, ...(await row('global') || {}), monthly_cap_nano: values.monthly_cap };const providers = [];for (const key of PROVIDERS) {-- const policy = { ...DEFAULTS, ...(key === 'salad' ? { classes: ['batch'], max_replicas: 3 } : {}), ...(await row(key) || {}) };const marker = summary.secrets && summary.secrets[`${key === 'runpod_serverless' ? 'runpod' : key}.key`];-- providers.push({ key, ...policy, key_state: key === 'reserved' ? { required: false } : { required: true, present: Boolean(marker), last_four: marker?.hint || null } });++ const saved = await row(key);++ const policy = effectiveProvider(key, saved, Boolean(marker));++ providers.push({ key, ...policy, configured: Boolean(saved), key_state: key === 'reserved' ? { required: false } : { required: true, present: Boolean(marker), last_four: marker?.hint || null } });}return { global, providers };}async function save(scope, body, actor, trace = null) {if (scope !== 'global' && !PROVIDERS.includes(scope)) invalid('unknown GPU provider');if (!body || typeof body !== 'object' || Array.isArray(body)) invalid('settings must be an object');-- const before = scope === 'global'-- ? { ...GLOBAL, monthly_cap_nano: settings.gpus.values().monthly_cap, ...(await row('global') || {}) }-- : { ...DEFAULTS, ...(scope === 'salad' ? { classes: ['batch'], max_replicas: 3 } : {}), ...(await row(scope) || {}) };++ let before;++ if (scope === 'global') {++ before = { ...GLOBAL, monthly_cap_nano: settings.gpus.values().monthly_cap, ...(await row('global') || {}) };++ } else {++ const summary = await settings.gpus.store.summary();++ const marker = summary.secrets && summary.secrets[`${scope === 'runpod_serverless' ? 'runpod' : scope}.key`];++ before = effectiveProvider(scope, await row(scope), Boolean(marker));++ }const after = scope === 'global' ? globalInput(body, before) : providerInput(body, before, scope);const changes = Object.keys(after).filter(k => JSON.stringify(before[k]) !== JSON.stringify(after[k]));if (!changes.length) return scope === 'global' ? (await read()).global : (await read()).providers.find(x => x.key === scope);@@@ -117,4 +138,4 @@ function createGpuPolicy({ db, settings, clock = { now: () => Date.now() } }) {return { read, save, setKey };}--module.exports = { createGpuPolicy, PROVIDERS, DEFAULTS, GLOBAL };++module.exports = { createGpuPolicy, PROVIDERS, DEFAULTS, GLOBAL, defaultsFor, effectiveProvider };@@@ -19,8 +19,10 @@* deterministic: the same offers in the same order always give the same instance.*/const { randomUUID } = require('node:crypto');--const { choose, STRATEGIES } = require('openvibe-sdk/compute');++const { STRATEGIES } = require('openvibe-sdk/compute');const { AiError } = require('../util');++const { classOf, checkpointOf, pickOffer } = require('./classes');++const { createGpuPolicy } = require('./policy');const money = require('./money');const catalog = require('./catalog');const { vastAdapter } = require('./vast');@@@ -89,6 +91,10 @@ function createGpuProvisioner({const now = () => clock.now();const iso = (ms) => new Date(ms).toISOString();const monthOf = (ms) => new Date(ms).toISOString().slice(0, 7);++ // The owner's per-provider policy, saved through the Exchange owner console (server/gpu/policy.js).++ const gpuPolicy = settings && typeof settings.gpus === 'object' ? createGpuPolicy({ db, settings, clock }) : null;++ /** The provider keys from the sealed config store. */++ const PROVIDER_KEYS = Object.freeze(['vast', 'runpod']);// ── the owner's settings ──────────────────────────────────────────────@@@ -104,36 +110,54 @@ function createGpuProvisioner({let extraHourly = null;try { extraHourly = money.nanoCount(v.extra_hourly_nano, 'the hourly storage and network allowance'); } catch {}const bounded = v.costs_bounded === true && extraHourly != null && extraHourly >= 0n;-- const vastKey = String(v.vast_key || '');-- const runpodKey = String(v.runpod_key || '');++ const keys = {++ vast: String(v.vast_key || ''),++ runpod: String(v.runpod_key || ''),++ };++ // The newer provider keys live together in the sealed config namespace (server/settings.js).++ for (const name of PROVIDER_KEYS) if (!keys[name] && v.provider_keys && typeof v.provider_keys === 'object') keys[name] = String(v.provider_keys[name] || '');++ const anyKey = PROVIDER_KEYS.some((name) => Boolean(keys[name]));let reason = '';-- if (!vastKey && !runpodKey) reason = 'no Vast.ai or RunPod API key';++ if (!anyKey) reason = 'no GPU provider key (marketplace API key)';else if (cap == null || cap <= 0n) reason = 'no monthly cap';else if (!bounded) reason = 'storage and network costs need an explicit hourly bound';return {strategy: STRATEGIES.includes(v.strategy) ? v.strategy : 'cheapest',dry_run: v.dry_run === true,-- vast_key: vastKey,-- runpod_key: runpodKey,++ keys,++ vast_key: keys.vast,++ runpod_key: keys.runpod,cap_nano: cap,extra_hourly_nano: extraHourly,idle_minutes: clampInt(v.idle_minutes, 1, 1440, DEFAULT_IDLE_MINUTES),max_runtime_minutes: clampInt(v.max_runtime_minutes, 1, 10080, DEFAULT_MAX_RUNTIME_MINUTES),warm_window: String(v.warm_window || '').trim(),region: String(v.region || DEFAULT_REGION).trim().toUpperCase() || DEFAULT_REGION,-- enabled: Boolean(vastKey || runpodKey) && cap != null && cap > 0n && bounded,++ enabled: anyKey && cap != null && cap > 0n && bounded,reason,};}/** The adapter of a marketplace, or null when its key is not set. */function adapterFor(marketplace, cfg) {-- if (marketplace === 'runpod') {-- if (!cfg.runpod_key) return null;-- return runpodAdapter({ fetchImpl, key: cfg.runpod_key });-- }-- if (!cfg.vast_key) return null;-- return vastAdapter({ fetchImpl, key: cfg.vast_key });++ const key = cfg.keys && cfg.keys[marketplace];++ if (!key) return null;++ if (marketplace === 'runpod') return runpodAdapter({ fetchImpl, key });++ if (marketplace === 'vast') return vastAdapter({ fetchImpl, key });++ return null;++ }++++ /** The owner's provider policy by provider key; an empty map means no policy is configured. */++ async function providerPolicies() {++ if (!gpuPolicy) return new Map();++ const read = await gpuPolicy.read();++ return new Map((read.providers || []).map((p) => [p.key, p]));++ }++++ /** Live instances of one marketplace, for the owner's replica ceiling. */++ async function liveForMarketplace(marketplace) {++ const row = await db.prepare("SELECT COUNT(*) AS n FROM gpu_instances WHERE marketplace = ? AND status IN ('starting','running','stopping')").get(String(marketplace));++ return Number(row && row.n) || 0;}/** Is the moment inside the owner's warm window (UTC)? Then an idle instance stays up. */@@@ -158,16 +182,29 @@ function createGpuProvisioner({const cfg = config();const ledger = await handle.prepare('SELECT COALESCE(SUM(nano_usd), 0) AS total FROM gpu_spend WHERE month = ?').get(month);const settled = toNano(ledger && ledger.total);++ // What each job class and each marketplace has already cost this month. The join is left so a++ // spend row whose instance row is gone still counts towards the month.++ const settledBy = async (column) => {++ const rows = await handle.prepare(`SELECT COALESCE(i.${column}, 'interactive') AS k, COALESCE(SUM(s.nano_usd), 0) AS total++ FROM gpu_spend s LEFT JOIN gpu_instances i ON i.id = s.instance_id WHERE s.month = ? GROUP BY COALESCE(i.${column}, 'interactive')`).all(month);++ return new Map((rows || []).map((r) => [String(r.k), toNano(r.total)]));++ };++ const byClass = await settledBy('job_class');++ const byMarketplace = await settledBy('marketplace');const rows = await handle.prepare('SELECT * FROM gpu_instances WHERE status IN (?, ?, ?)').all('starting', 'running', 'stopping');let reserved = 0n;let running = 0n;++ const add = (map, key, value) => map.set(key, (map.get(key) || 0n) + value);for (const row of rows) {const remaining = toNano(row.reserved_nano) > toNano(row.spent_nano) ? toNano(row.reserved_nano) - toNano(row.spent_nano) : 0n;const started = toMs(row.started_at);const accrued = started == null ? 0n : money.nanoForSeconds(toNano(row.hourly_nano), Math.max(0, now() - Math.max(toMs(row.charged_at) || started, Date.parse(month + '-01T00:00:00Z'))) / 1000);running += accrued;// Pending deletion may run beyond its reservation. Never make that overspend look like free budget.-- reserved += remaining > accrued ? remaining : accrued;++ const committed = remaining > accrued ? remaining : accrued;++ reserved += committed;++ add(byClass, String(row.job_class || 'interactive'), committed);++ add(byMarketplace, String(row.marketplace || ''), committed);}return {month,@@@ -176,6 +213,8 @@ function createGpuProvisioner({committed_nano: settled + reserved,reserved_nano: reserved,settled_nano: settled,++ by_class: Object.fromEntries(byClass),++ by_marketplace: Object.fromEntries(byMarketplace),};}@@@ -215,13 +254,16 @@ function createGpuProvisioner({}/** A quote never creates a request, takes a Billing hold or rents anything. */-- async function preview(modelKey, { strategy = config().strategy, region = config().region, runtimeSpec = null, rentalOnly = false } = {}) {++ async function preview(modelKey, { strategy = config().strategy, region = config().region, runtimeSpec = null, rentalOnly = false, jobClass = 'interactive', checkpoint = null } = {}) {const spec = runtimeSpec || models.find(m => m.key === modelKey) || catalog.find(modelKey);if (!spec) throw new AiError(404, 'ai.not_found', 'no such GPU model');if (!STRATEGIES.includes(strategy)) throw new AiError(422, 'ai.invalid', 'unknown compute strategy');if (strategy === 'own' || strategy === 'private') return { dry_run: true, strategy, selected: null, offers: [], reasons: ['rented marketplace GPUs are not own or OpenVibe machines; use a Link endpoint'] };const cfg = config();-- const names = ['vast', 'runpod'].filter(name => !rentalOnly || cfg[name + '_key']);++ const job_class = classOf(jobClass);++ const ckpt = checkpointOf(checkpoint);++ const policies = await providerPolicies();++ const names = PROVIDER_KEYS.filter(name => !rentalOnly || (cfg.keys && cfg.keys[name]));const offers = [];const failures = [];// The owner's approved supply plan pins the hourly and monthly ceilings per model (server/platform/supply.js).@@@ -241,27 +283,58 @@ function createGpuProvisioner({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 });++ const policy = policies.get(name) || null;++ if (policy && policy.configured && policy.enabled === false) { failures.push(name + ': disabled by the owner'); continue; }++ if (policy && Array.isArray(policy.classes) && !policy.classes.includes(job_class)) { failures.push(name + ': does not serve ' + job_class + ' jobs'); continue; }++ if (policy && policy.configured && Number.isInteger(policy.max_replicas) && await liveForMarketplace(name) >= policy.max_replicas) {++ failures.push(name + ': the owner’s replica ceiling is reached');++ continue;++ }++ // A quote never needs a key: quote-only mode is how the owner sees prices before adding one.++ const adapter = name === 'vast'++ ? vastAdapter({ fetchImpl, key: (cfg.keys && cfg.keys.vast) || null })++ : (name === 'runpod' ? runpodAdapter({ fetchImpl, key: (cfg.keys && cfg.keys.runpod) || null }) : null);++ if (!adapter) continue;++ // Interruptible capacity is asked for only when the job may use it: batch, a checkpoint, and the++ // owner's permission for this provider. The adapter may still return only on-demand offers.++ const interruptible = job_class === 'batch' && Boolean(ckpt) && Boolean(policy && policy.interruptible_allowed);try {const found = await adapter.searchOffers({ gpuNames: spec.requirements.gpu_names, vramMb: spec.requirements.vram_mb,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, interruptible,++ checkpoint: ckpt });// Never trust marketplace-side filters alone.-- const fitting = found.filter(o => o.gpu_ram_mb >= spec.requirements.vram_mb && o.num_gpus >= spec.requirements.num_gpus++ let 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()));++ // The owner's per-provider bounds: an hourly ceiling and a list of allowed regions.++ if (policy && policy.max_hourly_nano != null) {++ const ceiling = Number(policy.max_hourly_nano) / 1e9;++ const below = fitting.filter(o => Number(o.price_per_hour) <= ceiling);++ if (below.length < fitting.length) failures.push(name + ': offers above the owner’s hourly ceiling were left out');++ fitting = below;++ }++ if (policy && Array.isArray(policy.regions) && policy.regions.length) {++ const wanted = new Set(policy.regions.map(x => String(x).toUpperCase()));++ fitting = fitting.filter(o => wanted.has(String(o.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);++ // The class rule is applied here and again by pickOffer, so an adapter can never slip an++ // interruptible offer into an interactive or checkpoint-less job.++ const allowed = inside.filter(o => {++ const marked = { ...o, interruptible: Boolean(o.interruptible) };++ return !marked.interruptible || (job_class === 'batch' && Boolean(ckpt) && Boolean(policy && policy.interruptible_allowed));++ });++ offers.push(...allowed);} catch { failures.push(name + ': quote API unavailable'); }}-- const candidates = offers.map(o => ({ id: o.marketplace + ':' + o.id, tier: 'partner', health: 'up', region: o.region,-- hourly: Number(o.price_per_hour), currency: 'USD', free_ram_mb: 2048, free_slots: 1, gpu_mb: o.gpu_ram_mb,-- latency_ms: o.latency_ms }));-- const decision = choose({ strategy, region }, candidates, { currency: 'USD' }, { now: now() });-- const selected = offers.find(o => o.marketplace + ':' + o.id === decision.selected) || null;-- 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) };++ const decision = pickOffer(offers, { policies, jobClass: job_class, checkpoint: ckpt, strategy });++ const selected = decision.selected || null;++ return { dry_run: true, strategy, job_class, checkpoint: ckpt, selected, offers: decision.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. */@@@ -270,12 +343,14 @@ function createGpuProvisioner({}/** 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', { jobClass = 'interactive', checkpoint = null } = {}) {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()));-- return { ok: true, model_key: modelKey, action };++ const job_class = classOf(jobClass);++ const ckpt = action === 'launch' ? checkpointOf(checkpoint) : null;++ await db.prepare('INSERT INTO gpu_requests (model_key, action, job_class, checkpoint, at, actor, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ .run(String(modelKey), action, job_class, ckpt, iso(now()), String(actor || '').slice(0, 200), iso(now()));++ return { ok: true, model_key: modelKey, action, job_class, checkpoint: ckpt };}/** Is the model wanted? It is wanted when the owner asked for it or when it was used recently. */@@@ -314,16 +389,18 @@ function createGpuProvisioner({* beforeCreate({ offer, hourly, reserved, maxMinutes, marketplace }) the endpoint's Billing hold, if* any, is taken here - before the marketplace is called, so nothing runs that the person cannot pay.*/-- async function launchSpec(spec, { actor = 'service:ai', ownEndpointId = null, idleMinutes = null, maxRuntimeMinutes = null, beforeCreate = null, strategy = config().strategy, dryRun = false } = {}) {++ async function launchSpec(spec, { actor = 'service:ai', ownEndpointId = null, idleMinutes = null, maxRuntimeMinutes = null, beforeCreate = null, strategy = config().strategy, dryRun = false, jobClass = 'interactive', checkpoint = null } = {}) {const cfg = config();++ const job_class = classOf(jobClass);++ const ckpt = checkpointOf(checkpoint);if (cfg.dry_run && ownEndpointId) return { ok: false, code: 'gpu.dry_run', reason: 'GPU rentals are in quote-only mode' };-- if (dryRun || cfg.dry_run) return { ok: true, ...await preview(spec.key, { strategy, runtimeSpec: spec }) };++ if (dryRun || cfg.dry_run) return { ok: true, ...await preview(spec.key, { strategy, runtimeSpec: spec, jobClass: job_class, checkpoint: ckpt }) };if (!cfg.enabled) return { ok: false, code: 'gpu.off', reason: cfg.reason };if (!STRATEGIES.includes(strategy)) throw new AiError(422, 'ai.invalid', 'unknown compute strategy');if (strategy === 'private' || strategy === 'own') return { ok: false, code: 'gpu.no_offer', reason: 'this strategy requires an existing Link or OpenVibe machine' };const existing = ownEndpointId ? await liveInstanceForEndpoint(ownEndpointId) : await liveInstance(spec.key);if (existing) return { ok: true, instance: describe(existing), note: 'already running' };-- const quote = await preview(spec.key, { strategy, runtimeSpec: spec, rentalOnly: true });++ const quote = await preview(spec.key, { strategy, runtimeSpec: spec, rentalOnly: true, jobClass: job_class, checkpoint: ckpt });const offer = quote.selected;if (!offer) return { ok: false, code: 'gpu.no_offer', reason: quote.reasons.join('; ') };const marketplace = offer.marketplace;@@@ -352,6 +429,24 @@ function createGpuProvisioner({if (sum.committed_nano + reserved > cap) {return { ok: false, code: 'gpu.cap', reason: 'the monthly cap leaves no room for ' + spec.display_name };}++ // The owner's per-class ceilings and each provider's share of the month. Both are settled money++ // plus live reservations, so a reservation is never counted twice or left out.++ const policyState = gpuPolicy ? await gpuPolicy.read() : null;++ const classCap = policyState && policyState.global ? policyState.global[job_class + '_cap_nano'] : null;++ if (classCap != null) {++ const classCommitted = BigInt((sum.by_class && sum.by_class[job_class]) || 0);++ if (classCommitted + reserved > BigInt(classCap)) {++ return { ok: false, code: 'gpu.class_cap', reason: 'the ' + job_class + ' class cap leaves no room for ' + spec.display_name };++ }++ }++ const providerPolicy = policyState ? policyState.providers.find((p) => p.key === marketplace) : null;++ if (providerPolicy && providerPolicy.configured && providerPolicy.monthly_cap_share_percent < 100) {++ const shareCap = cap * BigInt(providerPolicy.monthly_cap_share_percent) / 100n;++ const providerCommitted = BigInt((sum.by_marketplace && sum.by_marketplace[marketplace]) || 0);++ if (providerCommitted + reserved > shareCap) {++ return { ok: false, code: 'gpu.provider_cap', reason: 'the owner’s share of the monthly cap for ' + marketplace + ' leaves no room for ' + spec.display_name };++ }++ }// 2b. the payer's hold, for a person's own endpoint: taken before the marketplace is called, so an// instance never runs that the person cannot pay for.@@@ -378,11 +473,11 @@ function createGpuProvisioner({await tdb.prepare(`INSERT INTO gpu_instances (id, model_key, provider_key, marketplace, offer_id, remote_id, status, endpoint, month,gpu_name, gpu_ram_mb, num_gpus, disk_gb, reliability, region, price_per_hour, hourly_nano, reserved_nano, spent_nano,max_runtime_minutes, started_at, charged_at, destroy_failures, label, cold_start_ms, health_failures, created_at, updated_at,-- own_endpoint_id, idle_minutes)++ own_endpoint_id, idle_minutes, job_class, checkpoint, interruptible)VALUES (@id, @model_key, @provider_key, @marketplace, @offer_id, NULL, 'starting', NULL, @month,@gpu_name, @gpu_ram_mb, @num_gpus, @disk_gb, @reliability, @region, @price_per_hour, @hourly_nano, @reserved_nano, 0,@max_runtime_minutes, @started_at, @charged_at, 0, @label, NULL, 0, @created_at, @updated_at,-- @own_endpoint_id, @idle_minutes)`)++ @own_endpoint_id, @idle_minutes, @job_class, @checkpoint, @interruptible)`).run({id, model_key: spec.key, provider_key: spec.provider_key, marketplace,offer_id: String(offer.id), month: monthOf(t),@@@ -393,15 +488,16 @@ function createGpuProvisioner({max_runtime_minutes: maxMinutes, started_at: iso(t), charged_at: iso(t), label: id,created_at: iso(t), updated_at: iso(t),own_endpoint_id: ownEndpointId, idle_minutes: idleMinutes == null ? null : clampInt(idleMinutes, 1, 1440, cfg.idle_minutes),++ job_class, checkpoint: ckpt, interruptible: Boolean(offer.interruptible),});});} catch (err) {if (err.code === 'gpu.cap' || err.code === 'gpu.uncertain') return { ok: false, code: err.code, reason: err.message };throw err;}-- await audit(actor, 'gpu.placement', 'gpu_instance', id, { strategy, marketplace, offer_id: offer.id, reasons: quote.reasons, base_hourly_nano: baseHourly.toString(), extra_hourly_nano: cfg.extra_hourly_nano.toString(), hourly_nano: hourly.toString() });++ await audit(actor, 'gpu.placement', 'gpu_instance', id, { strategy, job_class, checkpoint: ckpt, interruptible: Boolean(offer.interruptible), marketplace, offer_id: offer.id, reasons: quote.reasons, base_hourly_nano: baseHourly.toString(), extra_hourly_nano: cfg.extra_hourly_nano.toString(), hourly_nano: hourly.toString() });await audit(actor, 'gpu.instance.requested', 'gpu_instance', id, {-- model: spec.key, marketplace, offer_id: String(offer.id), hourly_nano: hourly.toString(),++ model: spec.key, marketplace, offer_id: String(offer.id), hourly_nano: hourly.toString(), job_class, checkpoint: ckpt, interruptible: Boolean(offer.interruptible),reserved_nano: reserved.toString(), max_runtime_minutes: maxMinutes, label: id,...(ownEndpointId ? { own_endpoint_id: ownEndpointId } : {}),});@@@ -414,6 +510,9 @@ function createGpuProvisioner({command: catalog.commandOf(spec),weights_url: catalog.weightsUrl(spec),label: id,++ job_class,++ checkpoint: ckpt,++ interruptible: Boolean(offer.interruptible),});} catch (err) {const reason = 'provision outcome unknown; reservation retained for reconciliation';@@@ -782,6 +881,9 @@ function createGpuProvisioner({max_runtime_minutes: row.max_runtime_minutes,idle_minutes: row.idle_minutes == null ? null : Number(row.idle_minutes),own_endpoint_id: row.own_endpoint_id || null,++ job_class: row.job_class || 'interactive',++ checkpoint: row.checkpoint || null,++ interruptible: Boolean(row.interruptible),label: row.label,cold_start_ms: row.cold_start_ms == null ? null : String(row.cold_start_ms),destroy_failures: Number(row.destroy_failures || 0),@@@ -824,7 +926,7 @@ function createGpuProvisioner({for (const req of requests || []) {try {if (req.action === 'launch') {-- const res = await launch(req.model_key, { actor: req.actor || actor });++ const res = await launch(req.model_key, { actor: req.actor || actor, jobClass: req.job_class || 'interactive', checkpoint: req.checkpoint || null });if (!res.ok) {await db.prepare('UPDATE gpu_requests SET done_at = ?, error = ? WHERE id = ?').run(iso(now()), String(res.reason).slice(0, 300), req.id);@@@ -884,6 +986,7 @@ function createGpuProvisioner({instance: describe(live),};});++ const policyState = gpuPolicy ? await gpuPolicy.read() : null;return {strategy: cfg.strategy,dry_run: cfg.dry_run,@@@ -898,7 +1001,12 @@ function createGpuProvisioner({max_runtime_minutes: cfg.max_runtime_minutes,warm_window: cfg.warm_window,region: cfg.region,-- marketplaces: { vast: Boolean(cfg.vast_key), runpod: Boolean(cfg.runpod_key) },++ marketplaces: Object.fromEntries(PROVIDER_KEYS.map((name) => [name, Boolean(cfg.keys && cfg.keys[name])])),++ class_caps: policyState ? {++ interactive_cap_nano: policyState.global.interactive_cap_nano,++ batch_cap_nano: policyState.global.batch_cap_nano,++ } : null,++ by_class: sum.by_class,models: list,instances: rows.map(describe),};nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++/**++ * GPU job classes (2026-11-11): a job is interactive or batch, interruptible capacity needs a batch++ * job with a checkpoint, and the choice is class, then cost, then the owner's provider order.++ * The offers here are synthetic; the marketplace is the recorded fake, so no test reaches the network.++ */++const assert = require('assert');++const { boot, suite } = require('./helpers');++const { createGpuFetch } = require('./fixtures/gpu-fetch');++const { classOf, checkpointOf, pickOffer } = require('../server/gpu/classes');++const { createGpuPolicy } = require('../server/gpu/policy');++++const T0 = Date.parse('2026-11-06T12:00:00Z');++const OWNER = 'usr_' + 'A'.repeat(26);++const offer = (marketplace, id, price, extra = {}) => ({ marketplace, id: String(id), price_per_hour: String(price), gpu_ram_mb: 24576, num_gpus: 1, disk_gb: 100, reliability: 0.99, region: 'US', ...extra });++const policy = (extra = {}) => ({ enabled: true, configured: true, order: 50, classes: ['interactive', 'batch'], interruptible_allowed: false, max_hourly_nano: null, max_replicas: 10, regions: [], monthly_cap_share_percent: 100, ...extra });++++async function settings(h, { cap = '50000000000' } = {}) {++ const store = h.settings.gpus.store;++ await store.apply({ monthly_cap: Number(cap), costs_bounded: true, extra_hourly_nano: 0 }, { merge: true, actor: { type: 'user', id: OWNER }, reason: 'the test' });++ await store.setSecret('vast.key', 'vast-test-key', { actor: { type: 'user', id: OWNER }, reason: 'the test' });++}++++const t = suite('gpu-classes');++++t.test('a class is interactive or batch; a checkpoint is a bounded printable reference', () => {++ assert.strictEqual(classOf(undefined), 'interactive');++ assert.strictEqual(classOf('BATCH'), 'batch');++ assert.throws(() => classOf('interruptible'), /interactive or batch/);++ assert.strictEqual(checkpointOf(null), null);++ assert.strictEqual(checkpointOf(' s3://bucket/job-1/ckpt '), 's3://bucket/job-1/ckpt');++ assert.throws(() => checkpointOf('x'), /3-500/);++ assert.throws(() => checkpointOf('a\nb'), /printable/);++});++++t.test('the picker filters by class and interruptible rule, then costs, then the owner order', () => {++ const offers = [offer('vast', 'a', 0.30, { interruptible: true }), offer('runpod', 'b', 0.20), offer('shadeform', 'c', 0.10)];++ const policies = { vast: policy({ order: 1, interruptible_allowed: true }), runpod: policy({ order: 9 }), shadeform: policy({ order: 30 }) };++ // Batch without a checkpoint: the interruptible Vast offer is out; the cheapest on-demand wins.++ let picked = pickOffer(offers, { policies, jobClass: 'batch', checkpoint: null });++ assert.strictEqual(picked.selected.id, 'c');++ assert.ok(picked.reasons.some((r) => /interruptible capacity needs a checkpoint/.test(r)), picked.reasons.join('; '));++ // Batch with a checkpoint: the Vast bid is allowed and still not cheapest, so cost still wins.++ picked = pickOffer(offers, { policies, jobClass: 'batch', checkpoint: 's3://bucket/ckpt' });++ assert.strictEqual(picked.selected.id, 'c');++ // Equal price: the owner's order breaks the tie, then the id.++ const tied = offer('runpod', 'b', 0.10);++ const tied2 = offer('shadeform', 'c', 0.10);++ picked = pickOffer([tied2, tied], { policies, jobClass: 'batch', checkpoint: 's3://x/y' });++ assert.strictEqual(picked.selected.marketplace, 'runpod');++ // Interactive never takes interruptible capacity.++ picked = pickOffer([offer('vast', 'a', 0.01, { interruptible: true }), offer('runpod', 'b', 0.20)], { policies, jobClass: 'interactive' });++ assert.strictEqual(picked.selected.id, 'b');++ // A provider the owner disabled is out, and a provider that serves only interactive is out of batch.++ picked = pickOffer(offers, { policies: { ...policies, shadeform: policy({ enabled: false }), runpod: policy({ classes: ['interactive'] }) }, jobClass: 'batch', checkpoint: 's3://x/y' });++ assert.strictEqual(picked.selected.id, 'a');++});++++t.test('a launch records its class and checkpoint, and a batch cap refuses before renting', async () => {++ const api = createGpuFetch();++ const h = await boot({ clock: { now: () => T0 }, fetchImpl: api.fetchImpl });++ try {++ await settings(h);++ const p = createGpuPolicy({ db: h.db, settings: h.settings, clock: { now: () => T0 } });++ await p.save('global', { monthly_cap_nano: '50000000000' }, OWNER);++ await p.save('vast', { enabled: true, classes: ['batch'], interruptible_allowed: true, max_replicas: 5 }, OWNER);++ const res = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'batch', checkpoint: 's3://bucket/job-1/ckpt' });++ assert.ok(res.ok, res.reason);++ const row = await h.db.prepare('SELECT job_class, checkpoint, interruptible FROM gpu_instances WHERE id = ?').get(res.instance.id);++ assert.strictEqual(row.job_class, 'batch');++ assert.strictEqual(row.checkpoint, 's3://bucket/job-1/ckpt');++ assert.strictEqual(row.interruptible, false, 'the recorded on-demand offer is not interruptible');++ assert.strictEqual((await h.gpus.state()).instances[0].job_class, 'batch');++ const audit = await h.db.prepare("SELECT metadata FROM audit_log WHERE action = 'gpu.placement' ORDER BY id DESC LIMIT 1").get();++ assert.strictEqual(JSON.parse(audit.metadata).job_class, 'batch');++ assert.strictEqual(api.calls.create.length, 1);++ } finally { await h.stop(); }++});++++t.test('the interactive and batch class caps are enforced on the reservation', async () => {++ const api = createGpuFetch();++ const h = await boot({ clock: { now: () => T0 }, fetchImpl: api.fetchImpl });++ try {++ await settings(h);++ const p = createGpuPolicy({ db: h.db, settings: h.settings, clock: { now: () => T0 } });++ await p.save('global', { interactive_cap_nano: '50000000000', batch_cap_nano: '10000000' }, OWNER);++ await p.save('vast', { enabled: true, classes: ['interactive', 'batch'] }, OWNER);++ const batch = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'batch', checkpoint: 's3://bucket/ckpt' });++ assert.strictEqual(batch.ok, false);++ assert.strictEqual(batch.code, 'gpu.class_cap');++ assert.strictEqual(api.calls.create.length, 0);++ // The interactive cap is large enough for the same offer.++ const interactive = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'interactive' });++ assert.ok(interactive.ok, interactive.reason);++ } finally { await h.stop(); }++});++++t.test('the owner’s provider share of the monthly cap is enforced', async () => {++ const api = createGpuFetch();++ const h = await boot({ clock: { now: () => T0 }, fetchImpl: api.fetchImpl });++ try {++ await settings(h);++ const p = createGpuPolicy({ db: h.db, settings: h.settings, clock: { now: () => T0 } });++ await p.save('global', { monthly_cap_nano: '50000000000' }, OWNER);++ await p.save('vast', { enabled: true, monthly_cap_share_percent: 0 }, OWNER);++ const res = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'interactive' });++ assert.strictEqual(res.ok, false);++ assert.strictEqual(res.code, 'gpu.provider_cap');++ assert.strictEqual(api.calls.create.length, 0);++ } finally { await h.stop(); }++});++++t.test('a request keeps the class and checkpoint for the reconciler', async () => {++ const h = await boot({ clock: { now: () => T0 }, fetchImpl: createGpuFetch().fetchImpl });++ try {++ await h.gpus.request('deepseek-r1-32b', 'launch', OWNER, { jobClass: 'batch', checkpoint: 's3://bucket/ckpt' });++ const row = await h.db.prepare("SELECT job_class, checkpoint FROM gpu_requests WHERE model_key = 'deepseek-r1-32b' ORDER BY id DESC LIMIT 1").get();++ assert.strictEqual(row.job_class, 'batch');++ assert.strictEqual(row.checkpoint, 's3://bucket/ckpt');++ await assert.rejects(() => h.gpus.request('deepseek-r1-32b', 'launch', OWNER, { jobClass: 'soon' }), /interactive or batch/);++ } finally { await h.stop(); }++});++++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- GPU job classes (2026-11-11): a rental is interactive or batch, and a batch job may name where it
+-- resumes from. Interruptible capacity is only ever chosen for a batch job with a checkpoint.
+-- Additive only: existing rows are interactive and have no checkpoint.
+ALTER TABLE gpu_instances ADD COLUMN IF NOT EXISTS job_class text COLLATE "C" NOT NULL DEFAULT 'interactive';
+ALTER TABLE gpu_instances ADD COLUMN IF NOT EXISTS checkpoint text COLLATE "C";
+ALTER TABLE gpu_instances ADD COLUMN IF NOT EXISTS interruptible boolean NOT NULL DEFAULT false;
+ALTER TABLE gpu_requests ADD COLUMN IF NOT EXISTS job_class text COLLATE "C" NOT NULL DEFAULT 'interactive';
+ALTER TABLE gpu_requests ADD COLUMN IF NOT EXISTS checkpoint text COLLATE "C";
+CREATE INDEX IF NOT EXISTS gpu_instances_class ON gpu_instances (job_class, month);
@@ -155,7 +155,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -155,7 +155,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
}));
}));
r.get('/api/v1/admin/console/gpus/:model/quote', read, wrap(async (req) => {
r.get('/api/v1/admin/console/gpus/:model/quote', read, wrap(async (req) => {
if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU quotes are off on this host');
if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU quotes are off on this host');
- return await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined });
+ return await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined, jobClass: req.query.class || undefined, checkpoint: req.query.checkpoint || undefined });
}));
}));
// Read the public marketplace pages now instead of waiting for the due tick. It still never rents anything.
// Read the public marketplace pages now instead of waiting for the due tick. It still never rents anything.
r.post('/api/v1/admin/console/gpus/prices/refresh', write, wrap(async () => {
r.post('/api/v1/admin/console/gpus/prices/refresh', write, wrap(async () => {
@@ -286,7 +286,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -286,7 +286,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU controls are off on this host');
if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU controls are off on this host');
const action = String(req.params.action);
const action = String(req.params.action);
if (!['launch', 'stop'].includes(action)) throw new AiError(404, 'ai.not_found', 'no such GPU action');
if (!['launch', 'stop'].includes(action)) throw new AiError(404, 'ai.not_found', 'no such GPU action');
- await gpus.request(String(req.params.model || ''), action, actor(req));
+ await gpus.request(String(req.params.model || ''), action, actor(req), { jobClass: (req.body || {}).class, checkpoint: (req.body || {}).checkpoint });
return { ok: true };
return { ok: true };
}));
}));
r.post('/api/v1/admin/console/pricebook/changes/:id', write, wrap(async (req) => {
r.post('/api/v1/admin/console/pricebook/changes/:id', write, wrap(async (req) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Job classes for the GPU provisioner (2026-11-11): a job is `interactive` or `batch`.
+ *
+ * Interruptible capacity may take a batch job only when the job says where it resumes from (its
+ * checkpoint). A batch job without one, and every interactive job, runs on non-interruptible
+ * capacity only. The choice is deterministic: class first (a provider that does not serve the class
+ * is out), then the hourly cost, then the owner's provider order, then the offer id.
+ *
+ * These are pure functions: no database, no network, no money. The provisioner applies them to the
+ * offers its adapters return and to the owner's policy rows (server/gpu/policy.js).
+ */
+const { AiError } = require('../util');
+
+const JOB_CLASSES = Object.freeze(['interactive', 'batch']);
+const DEFAULT_CLASS = 'interactive';
+const CHECKPOINT_MAX = 500;
+
+function classOf(value) {
+ if (value == null || value === '') return DEFAULT_CLASS;
+ const key = String(value).trim().toLowerCase();
+ if (!JOB_CLASSES.includes(key)) throw new AiError(422, 'gpu.invalid_class', 'a GPU job is interactive or batch');
+ return key;
+}
+
+/**
+ * Where a batch job resumes from. A path, a URL or an explicit storage reference, never a secret: it is
+ * stored with the instance row and shown in the console.
+ */
+function checkpointOf(value) {
+ if (value == null || value === '') return null;
+ if (typeof value !== 'string') throw new AiError(422, 'gpu.invalid_checkpoint', 'a checkpoint is a path or URL');
+ const text = value.trim();
+ if (text.length < 3 || text.length > CHECKPOINT_MAX || /[\u0000-\u001f]/.test(text)) {
+ throw new AiError(422, 'gpu.invalid_checkpoint', `a checkpoint must be 3-${CHECKPOINT_MAX} printable characters`);
+ }
+ return text;
+}
+
+/** May this provider take a job of the class at all? */
+function providerTakesClass(policy, jobClass) {
+ const classes = Array.isArray(policy && policy.classes) && policy.classes.length ? policy.classes : JOB_CLASSES;
+ return classes.includes(jobClass);
+}
+
+/**
+ * Is this offer allowed for the job? An interruptible offer needs all three: a batch job, a declared
+ * checkpoint and the owner permitting interruptible capacity for that provider.
+ */
+function offerAllowed(offer, policy, jobClass, checkpoint) {
+ if (!providerTakesClass(policy, jobClass)) return 'the provider is not allowed for ' + jobClass + ' jobs';
+ if (!offer || !offer.interruptible) return null;
+ if (jobClass !== 'batch') return 'interruptible capacity takes batch jobs only';
+ if (!checkpoint) return 'interruptible capacity needs a checkpoint to resume from';
+ if (!(policy && policy.interruptible_allowed)) return 'the owner does not allow interruptible capacity on this provider';
+ return null;
+}
+
+const num = (value, fallback = Infinity) => {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+};
+const orderOf = (policy) => (Number.isInteger(policy && policy.order) ? policy.order : 50);
+
+/**
+ * Filter the offers for a job and pick the first one under the owner's deterministic rule.
+ *
+ * offers the adapters' offers; each carries marketplace, id, price_per_hour, interruptible
+ * policies a Map (or object) of marketplace key -> the owner's provider policy
+ * jobClass interactive | batch
+ * checkpoint where a batch job resumes, or null
+ * strategy 'cheapest' (cost then order) or 'fastest' (measured latency, then cost, then order)
+ *
+ * Returns { offers, selected, reasons }: `offers` is the eligible list in the chosen order, so the
+ * console can show why the first one won.
+ */
+function pickOffer(offers, { policies = {}, jobClass = DEFAULT_CLASS, checkpoint = null, strategy = 'cheapest' } = {}) {
+ const policyOf = (key) => (policies.get ? policies.get(key) : policies[key]) || null;
+ const reasons = [];
+ const eligible = [];
+ for (const offer of offers || []) {
+ const policy = policyOf(offer.marketplace);
+ if (policy && policy.configured && policy.enabled === false) { reasons.push(offer.marketplace + ': disabled by the owner'); continue; }
+ const why = offerAllowed(offer, policy, jobClass, checkpoint);
+ if (why) { reasons.push(offer.marketplace + ': ' + why); continue; }
+ eligible.push(offer);
+ }
+ const price = (offer) => num(offer.price_per_hour);
+ const latency = (offer) => num(offer.latency_ms);
+ eligible.sort((a, b) => {
+ if (strategy === 'fastest') {
+ const byLatency = latency(a) - latency(b);
+ if (byLatency) return byLatency;
+ }
+ const byPrice = price(a) - price(b);
+ if (byPrice) return byPrice;
+ const byOrder = orderOf(policyOf(a.marketplace)) - orderOf(policyOf(b.marketplace));
+ if (byOrder) return byOrder;
+ return String(a.marketplace + ':' + a.id).localeCompare(String(b.marketplace + ':' + b.id));
+ });
+ if (!eligible.length) reasons.push('no offer serves the ' + jobClass + ' class' + (jobClass === 'batch' ? (checkpoint ? ' with a checkpoint' : ' on non-interruptible capacity') : ''));
+ return {
+ offers: eligible,
+ selected: eligible[0] || null,
+ reasons,
+ job_class: jobClass,
+ checkpoint,
+ };
+}
+
+module.exports = { JOB_CLASSES, DEFAULT_CLASS, classOf, checkpointOf, providerTakesClass, offerAllowed, pickOffer };
@@ -38,9 +38,9 @@ const WEIGHTS_FILE = /^[A-Za-z0-9][A-Za-z0-9._ -]{0,180}\.gguf$/;
@@ -38,9 +38,9 @@ const WEIGHTS_FILE = /^[A-Za-z0-9][A-Za-z0-9._ -]{0,180}\.gguf$/;
const BILLING_OFF = 'Balance billing is not on yet, so an endpoint cannot be paid for. The owner must turn on Balance first.';
const BILLING_OFF = 'Balance billing is not on yet, so an endpoint cannot be paid for. The owner must turn on Balance first.';
function plainGpuReason(reason) {
function plainGpuReason(reason) {
- if (reason === 'no Vast.ai or RunPod API key') return 'The owner has not added a GPU provider key (Vast.ai or RunPod) yet, so no endpoint can start.';
+ if (/GPU provider key|Vast\.ai or RunPod API key/.test(String(reason || ''))) return 'The owner has not added a GPU provider key (Vast.ai or RunPod) yet, so no endpoint can start.';
if (reason === 'no monthly cap') return 'The owner has not set a monthly GPU cap yet, so no endpoint can start.';
if (reason === 'no monthly cap') return 'The owner has not set a monthly GPU cap yet, so no endpoint can start.';
- return 'The GPU provider is not set up yet, so no endpoint can start.';
+ return 'The GPU provider is not set up yet (a GPU provider key and a monthly cap are needed), so no endpoint can start.';
}
}
/** A short, plain sentence for why a rent stopped. The status line is a sentence, never a code. */
/** A short, plain sentence for why a rent stopped. The status line is a sentence, never a code. */
@@ -11,6 +11,21 @@ const DEFAULTS = Object.freeze({
@@ -11,6 +11,21 @@ const DEFAULTS = Object.freeze({
const GLOBAL = Object.freeze({ quote_only: true, interactive_cap_nano: null, batch_cap_nano: null });
const GLOBAL = Object.freeze({ quote_only: true, interactive_cap_nano: null, batch_cap_nano: null });
const OWNED = new Set(['svc:exchange']);
const OWNED = new Set(['svc:exchange']);
+function defaultsFor(provider) {
+ return { ...DEFAULTS, ...(provider === 'salad' ? { classes: ['batch'], max_replicas: 3 } : {}) };
+}
+
+/**
+ * The policy in force for a provider. Before the owner has saved one, a provider whose key is already
+ * in the vault stays on: adding this table must not silently stop the rentals the owner had. Saving
+ * any provider setting makes its stored `enabled` the one that counts.
+ */
+function effectiveProvider(provider, saved, hasKey) {
+ const out = { ...defaultsFor(provider), ...(saved || {}) };
+ if (!saved) out.enabled = Boolean(hasKey);
+ return out;
+}
+
function invalid(message) { throw new AiError(422, 'gpu.policy_invalid', message); }
function invalid(message) { throw new AiError(422, 'gpu.policy_invalid', message); }
function nano(value, name) {
function nano(value, name) {
if (value == null || value === '') return null;
if (value == null || value === '') return null;
@@ -72,18 +87,24 @@ function createGpuPolicy({ db, settings, clock = { now: () => Date.now() } }) {
@@ -72,18 +87,24 @@ function createGpuPolicy({ db, settings, clock = { now: () => Date.now() } }) {
const global = { ...GLOBAL, ...(await row('global') || {}), monthly_cap_nano: values.monthly_cap };
const global = { ...GLOBAL, ...(await row('global') || {}), monthly_cap_nano: values.monthly_cap };
const providers = [];
const providers = [];
for (const key of PROVIDERS) {
for (const key of PROVIDERS) {
- const policy = { ...DEFAULTS, ...(key === 'salad' ? { classes: ['batch'], max_replicas: 3 } : {}), ...(await row(key) || {}) };
const marker = summary.secrets && summary.secrets[`${key === 'runpod_serverless' ? 'runpod' : key}.key`];
const marker = summary.secrets && summary.secrets[`${key === 'runpod_serverless' ? 'runpod' : key}.key`];
- providers.push({ key, ...policy, key_state: key === 'reserved' ? { required: false } : { required: true, present: Boolean(marker), last_four: marker?.hint || null } });
+ const saved = await row(key);
+ const policy = effectiveProvider(key, saved, Boolean(marker));
+ providers.push({ key, ...policy, configured: Boolean(saved), key_state: key === 'reserved' ? { required: false } : { required: true, present: Boolean(marker), last_four: marker?.hint || null } });
}
}
return { global, providers };
return { global, providers };
}
}
async function save(scope, body, actor, trace = null) {
async function save(scope, body, actor, trace = null) {
if (scope !== 'global' && !PROVIDERS.includes(scope)) invalid('unknown GPU provider');
if (scope !== 'global' && !PROVIDERS.includes(scope)) invalid('unknown GPU provider');
if (!body || typeof body !== 'object' || Array.isArray(body)) invalid('settings must be an object');
if (!body || typeof body !== 'object' || Array.isArray(body)) invalid('settings must be an object');
- const before = scope === 'global'
- ? { ...GLOBAL, monthly_cap_nano: settings.gpus.values().monthly_cap, ...(await row('global') || {}) }
- : { ...DEFAULTS, ...(scope === 'salad' ? { classes: ['batch'], max_replicas: 3 } : {}), ...(await row(scope) || {}) };
+ let before;
+ if (scope === 'global') {
+ before = { ...GLOBAL, monthly_cap_nano: settings.gpus.values().monthly_cap, ...(await row('global') || {}) };
+ } else {
+ const summary = await settings.gpus.store.summary();
+ const marker = summary.secrets && summary.secrets[`${scope === 'runpod_serverless' ? 'runpod' : scope}.key`];
+ before = effectiveProvider(scope, await row(scope), Boolean(marker));
+ }
const after = scope === 'global' ? globalInput(body, before) : providerInput(body, before, scope);
const after = scope === 'global' ? globalInput(body, before) : providerInput(body, before, scope);
const changes = Object.keys(after).filter(k => JSON.stringify(before[k]) !== JSON.stringify(after[k]));
const changes = Object.keys(after).filter(k => JSON.stringify(before[k]) !== JSON.stringify(after[k]));
if (!changes.length) return scope === 'global' ? (await read()).global : (await read()).providers.find(x => x.key === scope);
if (!changes.length) return scope === 'global' ? (await read()).global : (await read()).providers.find(x => x.key === scope);
@@ -117,4 +138,4 @@ function createGpuPolicy({ db, settings, clock = { now: () => Date.now() } }) {
@@ -117,4 +138,4 @@ function createGpuPolicy({ db, settings, clock = { now: () => Date.now() } }) {
return { read, save, setKey };
return { read, save, setKey };
}
}
-module.exports = { createGpuPolicy, PROVIDERS, DEFAULTS, GLOBAL };
+module.exports = { createGpuPolicy, PROVIDERS, DEFAULTS, GLOBAL, defaultsFor, effectiveProvider };
@@ -19,8 +19,10 @@
@@ -19,8 +19,10 @@
* deterministic: the same offers in the same order always give the same instance.
* deterministic: the same offers in the same order always give the same instance.
*/
*/
const { randomUUID } = require('node:crypto');
const { randomUUID } = require('node:crypto');
-const { choose, STRATEGIES } = require('openvibe-sdk/compute');
+const { STRATEGIES } = require('openvibe-sdk/compute');
const { AiError } = require('../util');
const { AiError } = require('../util');
+const { classOf, checkpointOf, pickOffer } = require('./classes');
+const { createGpuPolicy } = require('./policy');
const money = require('./money');
const money = require('./money');
const catalog = require('./catalog');
const catalog = require('./catalog');
const { vastAdapter } = require('./vast');
const { vastAdapter } = require('./vast');
@@ -89,6 +91,10 @@ function createGpuProvisioner({
@@ -89,6 +91,10 @@ function createGpuProvisioner({
const now = () => clock.now();
const now = () => clock.now();
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
const monthOf = (ms) => new Date(ms).toISOString().slice(0, 7);
const monthOf = (ms) => new Date(ms).toISOString().slice(0, 7);
+ // The owner's per-provider policy, saved through the Exchange owner console (server/gpu/policy.js).
+ const gpuPolicy = settings && typeof settings.gpus === 'object' ? createGpuPolicy({ db, settings, clock }) : null;
+ /** The provider keys from the sealed config store. */
+ const PROVIDER_KEYS = Object.freeze(['vast', 'runpod']);
// ── the owner's settings ──────────────────────────────────────────────
// ── the owner's settings ──────────────────────────────────────────────
@@ -104,36 +110,54 @@ function createGpuProvisioner({
@@ -104,36 +110,54 @@ function createGpuProvisioner({
let extraHourly = null;
let extraHourly = null;
try { extraHourly = money.nanoCount(v.extra_hourly_nano, 'the hourly storage and network allowance'); } catch {}
try { extraHourly = money.nanoCount(v.extra_hourly_nano, 'the hourly storage and network allowance'); } catch {}
const bounded = v.costs_bounded === true && extraHourly != null && extraHourly >= 0n;
const bounded = v.costs_bounded === true && extraHourly != null && extraHourly >= 0n;
- const vastKey = String(v.vast_key || '');
- const runpodKey = String(v.runpod_key || '');
+ const keys = {
+ vast: String(v.vast_key || ''),
+ runpod: String(v.runpod_key || ''),
+ };
+ // The newer provider keys live together in the sealed config namespace (server/settings.js).
+ for (const name of PROVIDER_KEYS) if (!keys[name] && v.provider_keys && typeof v.provider_keys === 'object') keys[name] = String(v.provider_keys[name] || '');
+ const anyKey = PROVIDER_KEYS.some((name) => Boolean(keys[name]));
let reason = '';
let reason = '';
- if (!vastKey && !runpodKey) reason = 'no Vast.ai or RunPod API key';
+ if (!anyKey) reason = 'no GPU provider key (marketplace API key)';
else if (cap == null || cap <= 0n) reason = 'no monthly cap';
else if (cap == null || cap <= 0n) reason = 'no monthly cap';
else if (!bounded) reason = 'storage and network costs need an explicit hourly bound';
else if (!bounded) reason = 'storage and network costs need an explicit hourly bound';
return {
return {
strategy: STRATEGIES.includes(v.strategy) ? v.strategy : 'cheapest',
strategy: STRATEGIES.includes(v.strategy) ? v.strategy : 'cheapest',
dry_run: v.dry_run === true,
dry_run: v.dry_run === true,
- vast_key: vastKey,
- runpod_key: runpodKey,
+ keys,
+ vast_key: keys.vast,
+ runpod_key: keys.runpod,
cap_nano: cap,
cap_nano: cap,
extra_hourly_nano: extraHourly,
extra_hourly_nano: extraHourly,
idle_minutes: clampInt(v.idle_minutes, 1, 1440, DEFAULT_IDLE_MINUTES),
idle_minutes: clampInt(v.idle_minutes, 1, 1440, DEFAULT_IDLE_MINUTES),
max_runtime_minutes: clampInt(v.max_runtime_minutes, 1, 10080, DEFAULT_MAX_RUNTIME_MINUTES),
max_runtime_minutes: clampInt(v.max_runtime_minutes, 1, 10080, DEFAULT_MAX_RUNTIME_MINUTES),
warm_window: String(v.warm_window || '').trim(),
warm_window: String(v.warm_window || '').trim(),
region: String(v.region || DEFAULT_REGION).trim().toUpperCase() || DEFAULT_REGION,
region: String(v.region || DEFAULT_REGION).trim().toUpperCase() || DEFAULT_REGION,
- enabled: Boolean(vastKey || runpodKey) && cap != null && cap > 0n && bounded,
+ enabled: anyKey && cap != null && cap > 0n && bounded,
reason,
reason,
};
};
}
}
/** The adapter of a marketplace, or null when its key is not set. */
/** The adapter of a marketplace, or null when its key is not set. */
function adapterFor(marketplace, cfg) {
function adapterFor(marketplace, cfg) {
- if (marketplace === 'runpod') {
- if (!cfg.runpod_key) return null;
- return runpodAdapter({ fetchImpl, key: cfg.runpod_key });
- }
- if (!cfg.vast_key) return null;
- return vastAdapter({ fetchImpl, key: cfg.vast_key });
+ const key = cfg.keys && cfg.keys[marketplace];
+ if (!key) return null;
+ if (marketplace === 'runpod') return runpodAdapter({ fetchImpl, key });
+ if (marketplace === 'vast') return vastAdapter({ fetchImpl, key });
+ return null;
+ }
+
+ /** The owner's provider policy by provider key; an empty map means no policy is configured. */
+ async function providerPolicies() {
+ if (!gpuPolicy) return new Map();
+ const read = await gpuPolicy.read();
+ return new Map((read.providers || []).map((p) => [p.key, p]));
+ }
+
+ /** Live instances of one marketplace, for the owner's replica ceiling. */
+ async function liveForMarketplace(marketplace) {
+ const row = await db.prepare("SELECT COUNT(*) AS n FROM gpu_instances WHERE marketplace = ? AND status IN ('starting','running','stopping')").get(String(marketplace));
+ return Number(row && row.n) || 0;
}
}
/** Is the moment inside the owner's warm window (UTC)? Then an idle instance stays up. */
/** Is the moment inside the owner's warm window (UTC)? Then an idle instance stays up. */
@@ -158,16 +182,29 @@ function createGpuProvisioner({
@@ -158,16 +182,29 @@ function createGpuProvisioner({
const cfg = config();
const cfg = config();
const ledger = await handle.prepare('SELECT COALESCE(SUM(nano_usd), 0) AS total FROM gpu_spend WHERE month = ?').get(month);
const ledger = await handle.prepare('SELECT COALESCE(SUM(nano_usd), 0) AS total FROM gpu_spend WHERE month = ?').get(month);
const settled = toNano(ledger && ledger.total);
const settled = toNano(ledger && ledger.total);
+ // What each job class and each marketplace has already cost this month. The join is left so a
+ // spend row whose instance row is gone still counts towards the month.
+ const settledBy = async (column) => {
+ const rows = await handle.prepare(`SELECT COALESCE(i.${column}, 'interactive') AS k, COALESCE(SUM(s.nano_usd), 0) AS total
+ FROM gpu_spend s LEFT JOIN gpu_instances i ON i.id = s.instance_id WHERE s.month = ? GROUP BY COALESCE(i.${column}, 'interactive')`).all(month);
+ return new Map((rows || []).map((r) => [String(r.k), toNano(r.total)]));
+ };
+ const byClass = await settledBy('job_class');
+ const byMarketplace = await settledBy('marketplace');
const rows = await handle.prepare('SELECT * FROM gpu_instances WHERE status IN (?, ?, ?)').all('starting', 'running', 'stopping');
const rows = await handle.prepare('SELECT * FROM gpu_instances WHERE status IN (?, ?, ?)').all('starting', 'running', 'stopping');
let reserved = 0n;
let reserved = 0n;
let running = 0n;
let running = 0n;
+ const add = (map, key, value) => map.set(key, (map.get(key) || 0n) + value);
for (const row of rows) {
for (const row of rows) {
const remaining = toNano(row.reserved_nano) > toNano(row.spent_nano) ? toNano(row.reserved_nano) - toNano(row.spent_nano) : 0n;
const remaining = toNano(row.reserved_nano) > toNano(row.spent_nano) ? toNano(row.reserved_nano) - toNano(row.spent_nano) : 0n;
const started = toMs(row.started_at);
const started = toMs(row.started_at);
const accrued = started == null ? 0n : money.nanoForSeconds(toNano(row.hourly_nano), Math.max(0, now() - Math.max(toMs(row.charged_at) || started, Date.parse(month + '-01T00:00:00Z'))) / 1000);
const accrued = started == null ? 0n : money.nanoForSeconds(toNano(row.hourly_nano), Math.max(0, now() - Math.max(toMs(row.charged_at) || started, Date.parse(month + '-01T00:00:00Z'))) / 1000);
running += accrued;
running += accrued;
// Pending deletion may run beyond its reservation. Never make that overspend look like free budget.
// Pending deletion may run beyond its reservation. Never make that overspend look like free budget.
- reserved += remaining > accrued ? remaining : accrued;
+ const committed = remaining > accrued ? remaining : accrued;
+ reserved += committed;
+ add(byClass, String(row.job_class || 'interactive'), committed);
+ add(byMarketplace, String(row.marketplace || ''), committed);
}
}
return {
return {
month,
month,
@@ -176,6 +213,8 @@ function createGpuProvisioner({
@@ -176,6 +213,8 @@ function createGpuProvisioner({
committed_nano: settled + reserved,
committed_nano: settled + reserved,
reserved_nano: reserved,
reserved_nano: reserved,
settled_nano: settled,
settled_nano: settled,
+ by_class: Object.fromEntries(byClass),
+ by_marketplace: Object.fromEntries(byMarketplace),
};
};
}
}
@@ -215,13 +254,16 @@ function createGpuProvisioner({
@@ -215,13 +254,16 @@ function createGpuProvisioner({
}
}
/** A quote never creates a request, takes a Billing hold or rents anything. */
/** A quote never creates a request, takes a Billing hold or rents anything. */
- async function preview(modelKey, { strategy = config().strategy, region = config().region, runtimeSpec = null, rentalOnly = false } = {}) {
+ async function preview(modelKey, { strategy = config().strategy, region = config().region, runtimeSpec = null, rentalOnly = false, jobClass = 'interactive', checkpoint = null } = {}) {
const spec = runtimeSpec || models.find(m => m.key === modelKey) || catalog.find(modelKey);
const spec = runtimeSpec || models.find(m => m.key === modelKey) || catalog.find(modelKey);
if (!spec) throw new AiError(404, 'ai.not_found', 'no such GPU model');
if (!spec) throw new AiError(404, 'ai.not_found', 'no such GPU model');
if (!STRATEGIES.includes(strategy)) throw new AiError(422, 'ai.invalid', 'unknown compute strategy');
if (!STRATEGIES.includes(strategy)) throw new AiError(422, 'ai.invalid', 'unknown compute strategy');
if (strategy === 'own' || strategy === 'private') return { dry_run: true, strategy, selected: null, offers: [], reasons: ['rented marketplace GPUs are not own or OpenVibe machines; use a Link endpoint'] };
if (strategy === 'own' || strategy === 'private') return { dry_run: true, strategy, selected: null, offers: [], reasons: ['rented marketplace GPUs are not own or OpenVibe machines; use a Link endpoint'] };
const cfg = config();
const cfg = config();
- const names = ['vast', 'runpod'].filter(name => !rentalOnly || cfg[name + '_key']);
+ const job_class = classOf(jobClass);
+ const ckpt = checkpointOf(checkpoint);
+ const policies = await providerPolicies();
+ const names = PROVIDER_KEYS.filter(name => !rentalOnly || (cfg.keys && cfg.keys[name]));
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).
// The owner's approved supply plan pins the hourly and monthly ceilings per model (server/platform/supply.js).
@@ -241,27 +283,58 @@ function createGpuProvisioner({
@@ -241,27 +283,58 @@ function createGpuProvisioner({
return true;
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 policy = policies.get(name) || null;
+ if (policy && policy.configured && policy.enabled === false) { failures.push(name + ': disabled by the owner'); continue; }
+ if (policy && Array.isArray(policy.classes) && !policy.classes.includes(job_class)) { failures.push(name + ': does not serve ' + job_class + ' jobs'); continue; }
+ if (policy && policy.configured && Number.isInteger(policy.max_replicas) && await liveForMarketplace(name) >= policy.max_replicas) {
+ failures.push(name + ': the owner’s replica ceiling is reached');
+ continue;
+ }
+ // A quote never needs a key: quote-only mode is how the owner sees prices before adding one.
+ const adapter = name === 'vast'
+ ? vastAdapter({ fetchImpl, key: (cfg.keys && cfg.keys.vast) || null })
+ : (name === 'runpod' ? runpodAdapter({ fetchImpl, key: (cfg.keys && cfg.keys.runpod) || null }) : null);
+ if (!adapter) continue;
+ // Interruptible capacity is asked for only when the job may use it: batch, a checkpoint, and the
+ // owner's permission for this provider. The adapter may still return only on-demand offers.
+ const interruptible = job_class === 'batch' && Boolean(ckpt) && Boolean(policy && policy.interruptible_allowed);
try {
try {
const found = await adapter.searchOffers({ gpuNames: spec.requirements.gpu_names, vramMb: spec.requirements.vram_mb,
const found = await adapter.searchOffers({ gpuNames: spec.requirements.gpu_names, vramMb: spec.requirements.vram_mb,
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, interruptible,
+ checkpoint: ckpt });
// Never trust marketplace-side filters alone.
// Never trust marketplace-side filters alone.
- const fitting = found.filter(o => o.gpu_ram_mb >= spec.requirements.vram_mb && o.num_gpus >= spec.requirements.num_gpus
+ let 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()));
+ // The owner's per-provider bounds: an hourly ceiling and a list of allowed regions.
+ if (policy && policy.max_hourly_nano != null) {
+ const ceiling = Number(policy.max_hourly_nano) / 1e9;
+ const below = fitting.filter(o => Number(o.price_per_hour) <= ceiling);
+ if (below.length < fitting.length) failures.push(name + ': offers above the owner’s hourly ceiling were left out');
+ fitting = below;
+ }
+ if (policy && Array.isArray(policy.regions) && policy.regions.length) {
+ const wanted = new Set(policy.regions.map(x => String(x).toUpperCase()));
+ fitting = fitting.filter(o => wanted.has(String(o.region || '').toUpperCase()));
+ }
const inside = fitting.filter(withinApproved);
const inside = fitting.filter(withinApproved);
if (approved && inside.length < fitting.length) failures.push('offers above the approved supply ceiling were left out');
if (approved && inside.length < fitting.length) failures.push('offers above the approved supply ceiling were left out');
- offers.push(...inside);
+ // The class rule is applied here and again by pickOffer, so an adapter can never slip an
+ // interruptible offer into an interactive or checkpoint-less job.
+ const allowed = inside.filter(o => {
+ const marked = { ...o, interruptible: Boolean(o.interruptible) };
+ return !marked.interruptible || (job_class === 'batch' && Boolean(ckpt) && Boolean(policy && policy.interruptible_allowed));
+ });
+ offers.push(...allowed);
} 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,
- hourly: Number(o.price_per_hour), currency: 'USD', free_ram_mb: 2048, free_slots: 1, gpu_mb: o.gpu_ram_mb,
- latency_ms: o.latency_ms }));
- const decision = choose({ strategy, region }, candidates, { currency: 'USD' }, { now: now() });
- const selected = offers.find(o => o.marketplace + ':' + o.id === decision.selected) || null;
- 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) };
+ const decision = pickOffer(offers, { policies, jobClass: job_class, checkpoint: ckpt, strategy });
+ const selected = decision.selected || null;
+ return { dry_run: true, strategy, job_class, checkpoint: ckpt, selected, offers: decision.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. */
/** May this provisioner serve the model? The supply tick asks before it queues a launch. */
@@ -270,12 +343,14 @@ function createGpuProvisioner({
@@ -270,12 +343,14 @@ function createGpuProvisioner({
}
}
/** 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', { jobClass = 'interactive', checkpoint = null } = {}) {
if (!supports(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 (?, ?, ?, ?, ?)')
- .run(String(modelKey), action, iso(now()), String(actor || '').slice(0, 200), iso(now()));
- return { ok: true, model_key: modelKey, action };
+ const job_class = classOf(jobClass);
+ const ckpt = action === 'launch' ? checkpointOf(checkpoint) : null;
+ await db.prepare('INSERT INTO gpu_requests (model_key, action, job_class, checkpoint, at, actor, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ .run(String(modelKey), action, job_class, ckpt, iso(now()), String(actor || '').slice(0, 200), iso(now()));
+ return { ok: true, model_key: modelKey, action, job_class, checkpoint: ckpt };
}
}
/** Is the model wanted? It is wanted when the owner asked for it or when it was used recently. */
/** Is the model wanted? It is wanted when the owner asked for it or when it was used recently. */
@@ -314,16 +389,18 @@ function createGpuProvisioner({
@@ -314,16 +389,18 @@ function createGpuProvisioner({
* beforeCreate({ offer, hourly, reserved, maxMinutes, marketplace }) the endpoint's Billing hold, if
* beforeCreate({ offer, hourly, reserved, maxMinutes, marketplace }) the endpoint's Billing hold, if
* any, is taken here - before the marketplace is called, so nothing runs that the person cannot pay.
* any, is taken here - before the marketplace is called, so nothing runs that the person cannot pay.
*/
*/
- async function launchSpec(spec, { actor = 'service:ai', ownEndpointId = null, idleMinutes = null, maxRuntimeMinutes = null, beforeCreate = null, strategy = config().strategy, dryRun = false } = {}) {
+ async function launchSpec(spec, { actor = 'service:ai', ownEndpointId = null, idleMinutes = null, maxRuntimeMinutes = null, beforeCreate = null, strategy = config().strategy, dryRun = false, jobClass = 'interactive', checkpoint = null } = {}) {
const cfg = config();
const cfg = config();
+ const job_class = classOf(jobClass);
+ const ckpt = checkpointOf(checkpoint);
if (cfg.dry_run && ownEndpointId) return { ok: false, code: 'gpu.dry_run', reason: 'GPU rentals are in quote-only mode' };
if (cfg.dry_run && ownEndpointId) return { ok: false, code: 'gpu.dry_run', reason: 'GPU rentals are in quote-only mode' };
- if (dryRun || cfg.dry_run) return { ok: true, ...await preview(spec.key, { strategy, runtimeSpec: spec }) };
+ if (dryRun || cfg.dry_run) return { ok: true, ...await preview(spec.key, { strategy, runtimeSpec: spec, jobClass: job_class, checkpoint: ckpt }) };
if (!cfg.enabled) return { ok: false, code: 'gpu.off', reason: cfg.reason };
if (!cfg.enabled) return { ok: false, code: 'gpu.off', reason: cfg.reason };
if (!STRATEGIES.includes(strategy)) throw new AiError(422, 'ai.invalid', 'unknown compute strategy');
if (!STRATEGIES.includes(strategy)) throw new AiError(422, 'ai.invalid', 'unknown compute strategy');
if (strategy === 'private' || strategy === 'own') return { ok: false, code: 'gpu.no_offer', reason: 'this strategy requires an existing Link or OpenVibe machine' };
if (strategy === 'private' || strategy === 'own') return { ok: false, code: 'gpu.no_offer', reason: 'this strategy requires an existing Link or OpenVibe machine' };
const existing = ownEndpointId ? await liveInstanceForEndpoint(ownEndpointId) : await liveInstance(spec.key);
const existing = ownEndpointId ? await liveInstanceForEndpoint(ownEndpointId) : await liveInstance(spec.key);
if (existing) return { ok: true, instance: describe(existing), note: 'already running' };
if (existing) return { ok: true, instance: describe(existing), note: 'already running' };
- const quote = await preview(spec.key, { strategy, runtimeSpec: spec, rentalOnly: true });
+ const quote = await preview(spec.key, { strategy, runtimeSpec: spec, rentalOnly: true, jobClass: job_class, checkpoint: ckpt });
const offer = quote.selected;
const offer = quote.selected;
if (!offer) return { ok: false, code: 'gpu.no_offer', reason: quote.reasons.join('; ') };
if (!offer) return { ok: false, code: 'gpu.no_offer', reason: quote.reasons.join('; ') };
const marketplace = offer.marketplace;
const marketplace = offer.marketplace;
@@ -352,6 +429,24 @@ function createGpuProvisioner({
@@ -352,6 +429,24 @@ function createGpuProvisioner({
if (sum.committed_nano + reserved > cap) {
if (sum.committed_nano + reserved > cap) {
return { ok: false, code: 'gpu.cap', reason: 'the monthly cap leaves no room for ' + spec.display_name };
return { ok: false, code: 'gpu.cap', reason: 'the monthly cap leaves no room for ' + spec.display_name };
}
}
+ // The owner's per-class ceilings and each provider's share of the month. Both are settled money
+ // plus live reservations, so a reservation is never counted twice or left out.
+ const policyState = gpuPolicy ? await gpuPolicy.read() : null;
+ const classCap = policyState && policyState.global ? policyState.global[job_class + '_cap_nano'] : null;
+ if (classCap != null) {
+ const classCommitted = BigInt((sum.by_class && sum.by_class[job_class]) || 0);
+ if (classCommitted + reserved > BigInt(classCap)) {
+ return { ok: false, code: 'gpu.class_cap', reason: 'the ' + job_class + ' class cap leaves no room for ' + spec.display_name };
+ }
+ }
+ const providerPolicy = policyState ? policyState.providers.find((p) => p.key === marketplace) : null;
+ if (providerPolicy && providerPolicy.configured && providerPolicy.monthly_cap_share_percent < 100) {
+ const shareCap = cap * BigInt(providerPolicy.monthly_cap_share_percent) / 100n;
+ const providerCommitted = BigInt((sum.by_marketplace && sum.by_marketplace[marketplace]) || 0);
+ if (providerCommitted + reserved > shareCap) {
+ return { ok: false, code: 'gpu.provider_cap', reason: 'the owner’s share of the monthly cap for ' + marketplace + ' leaves no room for ' + spec.display_name };
+ }
+ }
// 2b. the payer's hold, for a person's own endpoint: taken before the marketplace is called, so an
// 2b. the payer's hold, for a person's own endpoint: taken before the marketplace is called, so an
// instance never runs that the person cannot pay for.
// instance never runs that the person cannot pay for.
@@ -378,11 +473,11 @@ function createGpuProvisioner({
@@ -378,11 +473,11 @@ function createGpuProvisioner({
await tdb.prepare(`INSERT INTO gpu_instances (id, model_key, provider_key, marketplace, offer_id, remote_id, status, endpoint, month,
await tdb.prepare(`INSERT INTO gpu_instances (id, model_key, provider_key, marketplace, offer_id, remote_id, status, endpoint, month,
gpu_name, gpu_ram_mb, num_gpus, disk_gb, reliability, region, price_per_hour, hourly_nano, reserved_nano, spent_nano,
gpu_name, gpu_ram_mb, num_gpus, disk_gb, reliability, region, price_per_hour, hourly_nano, reserved_nano, spent_nano,
max_runtime_minutes, started_at, charged_at, destroy_failures, label, cold_start_ms, health_failures, created_at, updated_at,
max_runtime_minutes, started_at, charged_at, destroy_failures, label, cold_start_ms, health_failures, created_at, updated_at,
- own_endpoint_id, idle_minutes)
+ own_endpoint_id, idle_minutes, job_class, checkpoint, interruptible)
VALUES (@id, @model_key, @provider_key, @marketplace, @offer_id, NULL, 'starting', NULL, @month,
VALUES (@id, @model_key, @provider_key, @marketplace, @offer_id, NULL, 'starting', NULL, @month,
@gpu_name, @gpu_ram_mb, @num_gpus, @disk_gb, @reliability, @region, @price_per_hour, @hourly_nano, @reserved_nano, 0,
@gpu_name, @gpu_ram_mb, @num_gpus, @disk_gb, @reliability, @region, @price_per_hour, @hourly_nano, @reserved_nano, 0,
@max_runtime_minutes, @started_at, @charged_at, 0, @label, NULL, 0, @created_at, @updated_at,
@max_runtime_minutes, @started_at, @charged_at, 0, @label, NULL, 0, @created_at, @updated_at,
- @own_endpoint_id, @idle_minutes)`)
+ @own_endpoint_id, @idle_minutes, @job_class, @checkpoint, @interruptible)`)
.run({
.run({
id, model_key: spec.key, provider_key: spec.provider_key, marketplace,
id, model_key: spec.key, provider_key: spec.provider_key, marketplace,
offer_id: String(offer.id), month: monthOf(t),
offer_id: String(offer.id), month: monthOf(t),
@@ -393,15 +488,16 @@ function createGpuProvisioner({
@@ -393,15 +488,16 @@ function createGpuProvisioner({
max_runtime_minutes: maxMinutes, started_at: iso(t), charged_at: iso(t), label: id,
max_runtime_minutes: maxMinutes, started_at: iso(t), charged_at: iso(t), label: id,
created_at: iso(t), updated_at: iso(t),
created_at: iso(t), updated_at: iso(t),
own_endpoint_id: ownEndpointId, idle_minutes: idleMinutes == null ? null : clampInt(idleMinutes, 1, 1440, cfg.idle_minutes),
own_endpoint_id: ownEndpointId, idle_minutes: idleMinutes == null ? null : clampInt(idleMinutes, 1, 1440, cfg.idle_minutes),
+ job_class, checkpoint: ckpt, interruptible: Boolean(offer.interruptible),
});
});
});
});
} catch (err) {
} catch (err) {
if (err.code === 'gpu.cap' || err.code === 'gpu.uncertain') return { ok: false, code: err.code, reason: err.message };
if (err.code === 'gpu.cap' || err.code === 'gpu.uncertain') return { ok: false, code: err.code, reason: err.message };
throw err;
throw err;
}
}
- await audit(actor, 'gpu.placement', 'gpu_instance', id, { strategy, marketplace, offer_id: offer.id, reasons: quote.reasons, base_hourly_nano: baseHourly.toString(), extra_hourly_nano: cfg.extra_hourly_nano.toString(), hourly_nano: hourly.toString() });
+ await audit(actor, 'gpu.placement', 'gpu_instance', id, { strategy, job_class, checkpoint: ckpt, interruptible: Boolean(offer.interruptible), marketplace, offer_id: offer.id, reasons: quote.reasons, base_hourly_nano: baseHourly.toString(), extra_hourly_nano: cfg.extra_hourly_nano.toString(), hourly_nano: hourly.toString() });
await audit(actor, 'gpu.instance.requested', 'gpu_instance', id, {
await audit(actor, 'gpu.instance.requested', 'gpu_instance', id, {
- model: spec.key, marketplace, offer_id: String(offer.id), hourly_nano: hourly.toString(),
+ model: spec.key, marketplace, offer_id: String(offer.id), hourly_nano: hourly.toString(), job_class, checkpoint: ckpt, interruptible: Boolean(offer.interruptible),
reserved_nano: reserved.toString(), max_runtime_minutes: maxMinutes, label: id,
reserved_nano: reserved.toString(), max_runtime_minutes: maxMinutes, label: id,
...(ownEndpointId ? { own_endpoint_id: ownEndpointId } : {}),
...(ownEndpointId ? { own_endpoint_id: ownEndpointId } : {}),
});
});
@@ -414,6 +510,9 @@ function createGpuProvisioner({
@@ -414,6 +510,9 @@ function createGpuProvisioner({
command: catalog.commandOf(spec),
command: catalog.commandOf(spec),
weights_url: catalog.weightsUrl(spec),
weights_url: catalog.weightsUrl(spec),
label: id,
label: id,
+ job_class,
+ checkpoint: ckpt,
+ interruptible: Boolean(offer.interruptible),
});
});
} catch (err) {
} catch (err) {
const reason = 'provision outcome unknown; reservation retained for reconciliation';
const reason = 'provision outcome unknown; reservation retained for reconciliation';
@@ -782,6 +881,9 @@ function createGpuProvisioner({
@@ -782,6 +881,9 @@ function createGpuProvisioner({
max_runtime_minutes: row.max_runtime_minutes,
max_runtime_minutes: row.max_runtime_minutes,
idle_minutes: row.idle_minutes == null ? null : Number(row.idle_minutes),
idle_minutes: row.idle_minutes == null ? null : Number(row.idle_minutes),
own_endpoint_id: row.own_endpoint_id || null,
own_endpoint_id: row.own_endpoint_id || null,
+ job_class: row.job_class || 'interactive',
+ checkpoint: row.checkpoint || null,
+ interruptible: Boolean(row.interruptible),
label: row.label,
label: row.label,
cold_start_ms: row.cold_start_ms == null ? null : String(row.cold_start_ms),
cold_start_ms: row.cold_start_ms == null ? null : String(row.cold_start_ms),
destroy_failures: Number(row.destroy_failures || 0),
destroy_failures: Number(row.destroy_failures || 0),
@@ -824,7 +926,7 @@ function createGpuProvisioner({
@@ -824,7 +926,7 @@ function createGpuProvisioner({
for (const req of requests || []) {
for (const req of requests || []) {
try {
try {
if (req.action === 'launch') {
if (req.action === 'launch') {
- const res = await launch(req.model_key, { actor: req.actor || actor });
+ const res = await launch(req.model_key, { actor: req.actor || actor, jobClass: req.job_class || 'interactive', checkpoint: req.checkpoint || null });
if (!res.ok) {
if (!res.ok) {
await db.prepare('UPDATE gpu_requests SET done_at = ?, error = ? WHERE id = ?')
await db.prepare('UPDATE gpu_requests SET done_at = ?, error = ? WHERE id = ?')
.run(iso(now()), String(res.reason).slice(0, 300), req.id);
.run(iso(now()), String(res.reason).slice(0, 300), req.id);
@@ -884,6 +986,7 @@ function createGpuProvisioner({
@@ -884,6 +986,7 @@ function createGpuProvisioner({
instance: describe(live),
instance: describe(live),
};
};
});
});
+ const policyState = gpuPolicy ? await gpuPolicy.read() : null;
return {
return {
strategy: cfg.strategy,
strategy: cfg.strategy,
dry_run: cfg.dry_run,
dry_run: cfg.dry_run,
@@ -898,7 +1001,12 @@ function createGpuProvisioner({
@@ -898,7 +1001,12 @@ function createGpuProvisioner({
max_runtime_minutes: cfg.max_runtime_minutes,
max_runtime_minutes: cfg.max_runtime_minutes,
warm_window: cfg.warm_window,
warm_window: cfg.warm_window,
region: cfg.region,
region: cfg.region,
- marketplaces: { vast: Boolean(cfg.vast_key), runpod: Boolean(cfg.runpod_key) },
+ marketplaces: Object.fromEntries(PROVIDER_KEYS.map((name) => [name, Boolean(cfg.keys && cfg.keys[name])])),
+ class_caps: policyState ? {
+ interactive_cap_nano: policyState.global.interactive_cap_nano,
+ batch_cap_nano: policyState.global.batch_cap_nano,
+ } : null,
+ by_class: sum.by_class,
models: list,
models: list,
instances: rows.map(describe),
instances: rows.map(describe),
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+/**
+ * GPU job classes (2026-11-11): a job is interactive or batch, interruptible capacity needs a batch
+ * job with a checkpoint, and the choice is class, then cost, then the owner's provider order.
+ * The offers here are synthetic; the marketplace is the recorded fake, so no test reaches the network.
+ */
+const assert = require('assert');
+const { boot, suite } = require('./helpers');
+const { createGpuFetch } = require('./fixtures/gpu-fetch');
+const { classOf, checkpointOf, pickOffer } = require('../server/gpu/classes');
+const { createGpuPolicy } = require('../server/gpu/policy');
+
+const T0 = Date.parse('2026-11-06T12:00:00Z');
+const OWNER = 'usr_' + 'A'.repeat(26);
+const offer = (marketplace, id, price, extra = {}) => ({ marketplace, id: String(id), price_per_hour: String(price), gpu_ram_mb: 24576, num_gpus: 1, disk_gb: 100, reliability: 0.99, region: 'US', ...extra });
+const policy = (extra = {}) => ({ enabled: true, configured: true, order: 50, classes: ['interactive', 'batch'], interruptible_allowed: false, max_hourly_nano: null, max_replicas: 10, regions: [], monthly_cap_share_percent: 100, ...extra });
+
+async function settings(h, { cap = '50000000000' } = {}) {
+ const store = h.settings.gpus.store;
+ await store.apply({ monthly_cap: Number(cap), costs_bounded: true, extra_hourly_nano: 0 }, { merge: true, actor: { type: 'user', id: OWNER }, reason: 'the test' });
+ await store.setSecret('vast.key', 'vast-test-key', { actor: { type: 'user', id: OWNER }, reason: 'the test' });
+}
+
+const t = suite('gpu-classes');
+
+t.test('a class is interactive or batch; a checkpoint is a bounded printable reference', () => {
+ assert.strictEqual(classOf(undefined), 'interactive');
+ assert.strictEqual(classOf('BATCH'), 'batch');
+ assert.throws(() => classOf('interruptible'), /interactive or batch/);
+ assert.strictEqual(checkpointOf(null), null);
+ assert.strictEqual(checkpointOf(' s3://bucket/job-1/ckpt '), 's3://bucket/job-1/ckpt');
+ assert.throws(() => checkpointOf('x'), /3-500/);
+ assert.throws(() => checkpointOf('a\nb'), /printable/);
+});
+
+t.test('the picker filters by class and interruptible rule, then costs, then the owner order', () => {
+ const offers = [offer('vast', 'a', 0.30, { interruptible: true }), offer('runpod', 'b', 0.20), offer('shadeform', 'c', 0.10)];
+ const policies = { vast: policy({ order: 1, interruptible_allowed: true }), runpod: policy({ order: 9 }), shadeform: policy({ order: 30 }) };
+ // Batch without a checkpoint: the interruptible Vast offer is out; the cheapest on-demand wins.
+ let picked = pickOffer(offers, { policies, jobClass: 'batch', checkpoint: null });
+ assert.strictEqual(picked.selected.id, 'c');
+ assert.ok(picked.reasons.some((r) => /interruptible capacity needs a checkpoint/.test(r)), picked.reasons.join('; '));
+ // Batch with a checkpoint: the Vast bid is allowed and still not cheapest, so cost still wins.
+ picked = pickOffer(offers, { policies, jobClass: 'batch', checkpoint: 's3://bucket/ckpt' });
+ assert.strictEqual(picked.selected.id, 'c');
+ // Equal price: the owner's order breaks the tie, then the id.
+ const tied = offer('runpod', 'b', 0.10);
+ const tied2 = offer('shadeform', 'c', 0.10);
+ picked = pickOffer([tied2, tied], { policies, jobClass: 'batch', checkpoint: 's3://x/y' });
+ assert.strictEqual(picked.selected.marketplace, 'runpod');
+ // Interactive never takes interruptible capacity.
+ picked = pickOffer([offer('vast', 'a', 0.01, { interruptible: true }), offer('runpod', 'b', 0.20)], { policies, jobClass: 'interactive' });
+ assert.strictEqual(picked.selected.id, 'b');
+ // A provider the owner disabled is out, and a provider that serves only interactive is out of batch.
+ picked = pickOffer(offers, { policies: { ...policies, shadeform: policy({ enabled: false }), runpod: policy({ classes: ['interactive'] }) }, jobClass: 'batch', checkpoint: 's3://x/y' });
+ assert.strictEqual(picked.selected.id, 'a');
+});
+
+t.test('a launch records its class and checkpoint, and a batch cap refuses before renting', async () => {
+ const api = createGpuFetch();
+ const h = await boot({ clock: { now: () => T0 }, fetchImpl: api.fetchImpl });
+ try {
+ await settings(h);
+ const p = createGpuPolicy({ db: h.db, settings: h.settings, clock: { now: () => T0 } });
+ await p.save('global', { monthly_cap_nano: '50000000000' }, OWNER);
+ await p.save('vast', { enabled: true, classes: ['batch'], interruptible_allowed: true, max_replicas: 5 }, OWNER);
+ const res = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'batch', checkpoint: 's3://bucket/job-1/ckpt' });
+ assert.ok(res.ok, res.reason);
+ const row = await h.db.prepare('SELECT job_class, checkpoint, interruptible FROM gpu_instances WHERE id = ?').get(res.instance.id);
+ assert.strictEqual(row.job_class, 'batch');
+ assert.strictEqual(row.checkpoint, 's3://bucket/job-1/ckpt');
+ assert.strictEqual(row.interruptible, false, 'the recorded on-demand offer is not interruptible');
+ assert.strictEqual((await h.gpus.state()).instances[0].job_class, 'batch');
+ const audit = await h.db.prepare("SELECT metadata FROM audit_log WHERE action = 'gpu.placement' ORDER BY id DESC LIMIT 1").get();
+ assert.strictEqual(JSON.parse(audit.metadata).job_class, 'batch');
+ assert.strictEqual(api.calls.create.length, 1);
+ } finally { await h.stop(); }
+});
+
+t.test('the interactive and batch class caps are enforced on the reservation', async () => {
+ const api = createGpuFetch();
+ const h = await boot({ clock: { now: () => T0 }, fetchImpl: api.fetchImpl });
+ try {
+ await settings(h);
+ const p = createGpuPolicy({ db: h.db, settings: h.settings, clock: { now: () => T0 } });
+ await p.save('global', { interactive_cap_nano: '50000000000', batch_cap_nano: '10000000' }, OWNER);
+ await p.save('vast', { enabled: true, classes: ['interactive', 'batch'] }, OWNER);
+ const batch = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'batch', checkpoint: 's3://bucket/ckpt' });
+ assert.strictEqual(batch.ok, false);
+ assert.strictEqual(batch.code, 'gpu.class_cap');
+ assert.strictEqual(api.calls.create.length, 0);
+ // The interactive cap is large enough for the same offer.
+ const interactive = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'interactive' });
+ assert.ok(interactive.ok, interactive.reason);
+ } finally { await h.stop(); }
+});
+
+t.test('the owner’s provider share of the monthly cap is enforced', async () => {
+ const api = createGpuFetch();
+ const h = await boot({ clock: { now: () => T0 }, fetchImpl: api.fetchImpl });
+ try {
+ await settings(h);
+ const p = createGpuPolicy({ db: h.db, settings: h.settings, clock: { now: () => T0 } });
+ await p.save('global', { monthly_cap_nano: '50000000000' }, OWNER);
+ await p.save('vast', { enabled: true, monthly_cap_share_percent: 0 }, OWNER);
+ const res = await h.gpus.launch('deepseek-r1-32b', { jobClass: 'interactive' });
+ assert.strictEqual(res.ok, false);
+ assert.strictEqual(res.code, 'gpu.provider_cap');
+ assert.strictEqual(api.calls.create.length, 0);
+ } finally { await h.stop(); }
+});
+
+t.test('a request keeps the class and checkpoint for the reconciler', async () => {
+ const h = await boot({ clock: { now: () => T0 }, fetchImpl: createGpuFetch().fetchImpl });
+ try {
+ await h.gpus.request('deepseek-r1-32b', 'launch', OWNER, { jobClass: 'batch', checkpoint: 's3://bucket/ckpt' });
+ const row = await h.db.prepare("SELECT job_class, checkpoint FROM gpu_requests WHERE model_key = 'deepseek-r1-32b' ORDER BY id DESC LIMIT 1").get();
+ assert.strictEqual(row.job_class, 'batch');
+ assert.strictEqual(row.checkpoint, 's3://bucket/ckpt');
+ await assert.rejects(() => h.gpus.request('deepseek-r1-32b', 'launch', OWNER, { jobClass: 'soon' }), /interactive or batch/);
+ } finally { await h.stop(); }
+});
+
+t.run();