openvibers/openvibe.ai Commits

Add the GPU marketplace adapters and rent through them (#490)

OP OpenVibers committed 1 days ago · 3140cf6176 · 14 files, +1197 −28

* Add GPU job classes and checkpointed interruptible capacity

* Add GPU marketplace adapters for Salad, TensorDock, Shadeform, Verda, Thunder, RunPod Serverless and reserved servers

* Merge branch 'feat/gpu-classes-1111' into feat/gpu-adapters-1111

* Keep the no-key reason in the owner's own words

* Rent through every adapter, with provider options and a checkpointed bid

* Merge branch 'feat/gpu-classes-1111' into feat/gpu-adapters-1111

* Say GPU provider key and API key in the off reason

* Merge branch 'feat/gpu-classes-1111' into feat/gpu-adapters-1111

* Name the provider key in the own-endpoint reason

* Merge branch 'feat/gpu-classes-1111' into feat/gpu-adapters-1111

* Merge main into the adapter branch

Changes

ddiff --git a/server/gpu/adapters.js b/server/gpu/adapters.js
nnew file mode 100644
iindex 0000000..70487a9
---- /dev/null
++++ b/server/gpu/adapters.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * The marketplace adapters, built from the owner's policy options. Every adapter has Vast.ai's
++ * interface (searchOffers, createInstance, showInstance, destroyInstance); SaladCloud's job queue
++ * has the batch adapter's (submitJob, showJob, cancelJob).
++ *
++ * A keyed provider without its key builds nothing, so the provisioner's rule 1 still holds: no key,
++ * no search, no instance, no money. The reserved server has no key because nothing is rented by an
++ * API: the owner registers the machine's endpoint and monthly price.
++ */
++const { vastAdapter } = require('./vast');
++const { runpodAdapter } = require('./runpod');
++const { saladAdapter, saladQueueAdapter } = require('./salad');
++const { tensordockAdapter } = require('./tensordock');
++const { shadeformAdapter } = require('./shadeform');
++const { verdaAdapter } = require('./verda');
++const { thunderAdapter } = require('./thunder');
++const { runpodServerlessAdapter } = require('./runpod-serverless');
++const { reservedAdapter } = require('./reserved');
++
++const PROVIDERS = Object.freeze([
++ 'vast', 'runpod', 'salad', 'tensordock', 'shadeform', 'verda', 'thunder', 'runpod_serverless', 'reserved',
++]);
++// The ones that can be quoted and rented right now. The owner enables them in the console.
++const KEYED = Object.freeze(PROVIDERS.filter((p) => p !== 'reserved'));
++
++/** The string option, or the fallback; policy options are all strings. */
++function option(options, name, fallback = '') {
++ const value = options && options[name];
++ return value == null || value === '' ? fallback : String(value);
++}
++
++/**
++ * Build one adapter, or null when it cannot run (no key, or the reserved box is not registered).
++ *
++ * key the owner's key from the sealed store
++ * options the owner's policy options (organization, project, image, template_id, ...)
++ */
++function createAdapter(provider, { fetchImpl = globalThis.fetch, key = null, options = {}, allowNoKey = false } = {}) {
++ if (!allowNoKey && !key && provider !== 'reserved') return null;
++ switch (provider) {
++ case 'vast':
++ return vastAdapter({ fetchImpl, key });
++ case 'runpod':
++ return runpodAdapter({ fetchImpl, key });
++ case 'salad':
++ return saladAdapter({ fetchImpl, key, organization: option(options, 'organization'), project: option(options, 'project'), image: option(options, 'image'), replicas: Number(option(options, 'replicas', '3')) || 3 });
++ case 'salad_queue':
++ return saladQueueAdapter({ fetchImpl, key, organization: option(options, 'organization'), project: option(options, 'project'), queue: option(options, 'queue') });
++ case 'tensordock':
++ return tensordockAdapter({ fetchImpl, key, image: option(options, 'image'), location: option(options, 'location') });
++ case 'shadeform':
++ return shadeformAdapter({ fetchImpl, key });
++ case 'verda':
++ return verdaAdapter({ fetchImpl, key, image: option(options, 'image'), location: option(options, 'location') });
++ case 'thunder':
++ return thunderAdapter({ fetchImpl, key, template: option(options, 'template', 'ubuntu-22.04') });
++ case 'runpod_serverless':
++ // RunPod Serverless shares the RunPod key.
++ return runpodServerlessAdapter({ fetchImpl, key, templateId: option(options, 'template_id'), pools: option(options, 'pools') ? option(options, 'pools').split(',').map((p) => p.trim()).filter(Boolean) : [], maxWorkers: Number(option(options, 'max_workers', '3')) || 3 });
++ case 'reserved':
++ return option(options, 'endpoint') && option(options, 'monthly_nano')
++ ? reservedAdapter({ endpoint: option(options, 'endpoint'), monthlyNano: option(options, 'monthly_nano'), name: option(options, 'name', 'reserved') })
++ : null;
++ default:
++ return null;
++ }
++}
++
++module.exports = { PROVIDERS, KEYED, createAdapter, option };
ddiff --git a/server/gpu/http.js b/server/gpu/http.js
iindex 1416b71..0fc2484 100644
---- a/server/gpu/http.js
++++ b/server/gpu/http.js
@@@ -5,10 +5,10 @@
*/
const { AiError } = require('../util');
--async function apiJson(fetchImpl, url, { method = 'GET', body = null, key = null, label = 'the marketplace', allowMissing = false } = {}) {
-- const headers = { Accept: 'application/json' };
++async function apiJson(fetchImpl, url, { method = 'GET', body = null, key = null, label = 'the marketplace', allowMissing = false, headers: extra = null, keyHeader = 'Authorization', keyPrefix = 'Bearer ' } = {}) {
++ const headers = { Accept: 'application/json', ...(extra || {}) };
if (body != null) headers['Content-Type'] = 'application/json';
-- if (key) headers.Authorization = 'Bearer ' + key;
++ if (key) headers[keyHeader] = keyPrefix + key;
let res;
try {
res = await fetchImpl(url, { method, headers, redirect: 'error', signal: AbortSignal.timeout(15000), body: body != null ? JSON.stringify(body) : undefined });
ddiff --git a/server/gpu/policy.js b/server/gpu/policy.js
iindex 8a2508f..adc561d 100644
---- a/server/gpu/policy.js
++++ b/server/gpu/policy.js
@@@ -7,6 +7,9 @@ const PROVIDERS = Object.freeze(['vast', 'runpod', 'salad', 'tensordock', 'shade
const DEFAULTS = Object.freeze({
enabled: false, order: 50, classes: ['interactive', 'batch'], interruptible_allowed: false,
max_hourly_nano: null, max_replicas: 1, regions: [], monthly_cap_share_percent: 100,
++ // Provider-specific settings the adapter needs: a Salad organization and project, a RunPod
++ // Serverless template, a reserved server's endpoint and monthly price. All strings.
++ options: {},
});
const GLOBAL = Object.freeze({ quote_only: true, interactive_cap_nano: null, batch_cap_nano: null });
const OWNED = new Set(['svc:exchange']);
@@@ -21,7 +24,8 @@ function defaultsFor(provider) {
* any provider setting makes its stored `enabled` the one that counts.
*/
function effectiveProvider(provider, saved, hasKey) {
-- const out = { ...defaultsFor(provider), ...(saved || {}) };
++ const base = defaultsFor(provider);
++ const out = { ...base, ...(saved || {}), options: { ...(base.options || {}), ...((saved && saved.options) || {}) } };
if (!saved) out.enabled = Boolean(hasKey);
return out;
}
@@@ -57,6 +61,15 @@ function providerInput(body, current, provider) {
} else if (k === 'regions') {
if (!Array.isArray(v) || v.length > 20 || v.some(x => typeof x !== 'string' || !/^[A-Za-z0-9_-]{2,32}$/.test(x))) invalid('regions must be region codes');
out[k] = [...new Set(v.map(x => x.toUpperCase()))];
++ } else if (k === 'options') {
++ if (!v || typeof v !== 'object' || Array.isArray(v)) invalid('options must be an object of strings');
++ const entries = Object.entries(v);
++ if (entries.length > 20) invalid('at most 20 provider options');
++ for (const [name, value] of entries) {
++ if (!/^[a-z][a-z0-9_]{0,40}$/.test(name)) invalid('option names are lowercase words');
++ if (typeof value !== 'string' || value.length > 2048) invalid('option values are strings of at most 2048 characters');
++ }
++ out[k] = Object.fromEntries(entries);
}
}
if (provider === 'salad' && !Object.hasOwn(body, 'max_replicas') && current === DEFAULTS) out.max_replicas = 3;
ddiff --git a/server/gpu/provisioner.js b/server/gpu/provisioner.js
iindex 8a6940c..ed548d8 100644
---- a/server/gpu/provisioner.js
++++ b/server/gpu/provisioner.js
@@@ -23,6 +23,7 @@ const { STRATEGIES } = require('openvibe-sdk/compute');
const { AiError } = require('../util');
const { classOf, checkpointOf, pickOffer } = require('./classes');
const { createGpuPolicy } = require('./policy');
++const { createAdapter, KEYED } = require('./adapters');
const money = require('./money');
const catalog = require('./catalog');
const { vastAdapter } = require('./vast');
@@@ -93,8 +94,10 @@ function createGpuProvisioner({
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 provider keys from the sealed config store. The reserved server has none. */
++ const PROVIDER_KEYS = KEYED;
++ const ALL_PROVIDERS = Object.freeze([...KEYED, 'reserved']);
++ let currentPolicies = new Map();
// ── the owner's settings ──────────────────────────────────────────────
@@@ -116,6 +119,8 @@ function createGpuProvisioner({
};
// 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] || '');
++ // RunPod Serverless uses the same RunPod key.
++ keys.runpod_serverless = keys.runpod_serverless || keys.runpod;
const anyKey = PROVIDER_KEYS.some((name) => Boolean(keys[name]));
let reason = '';
if (!anyKey) reason = 'no GPU provider key (marketplace API key)';
@@@ -138,20 +143,23 @@ function createGpuProvisioner({
};
}
-- /** The adapter of a marketplace, or null when its key is not set. */
-- function adapterFor(marketplace, cfg) {
-- 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 adapter of a marketplace, or null when it is not usable. */
++ function adapterFor(marketplace, cfg, { allowNoKey = false } = {}) {
++ const policy = currentPolicies.get(marketplace) || null;
++ return createAdapter(marketplace, {
++ fetchImpl,
++ key: (cfg.keys && cfg.keys[marketplace]) || null,
++ options: (policy && policy.options) || {},
++ allowNoKey,
++ });
}
/** 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]));
++ currentPolicies = new Map((read.providers || []).map((p) => [p.key, p]));
++ return currentPolicies;
}
/** Live instances of one marketplace, for the owner's replica ceiling. */
@@@ -263,7 +271,13 @@ function createGpuProvisioner({
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]));
++ // The reserved server is registered by its endpoint rather than a key, so it joins the search
++ // when the owner has saved one.
++ const reservedReady = Boolean((policies.get('reserved') || {}).options && (policies.get('reserved') || {}).options.endpoint && (policies.get('reserved') || {}).options.monthly_nano);
++ const names = [...PROVIDER_KEYS, 'reserved'].filter((name) => {
++ if (name === 'reserved') return reservedReady;
++ return !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).
@@@ -291,9 +305,12 @@ function createGpuProvisioner({
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);
++ const adapter = createAdapter(name, {
++ fetchImpl,
++ key: (cfg.keys && cfg.keys[name]) || null,
++ options: (policy && policy.options) || {},
++ allowNoKey: true,
++ });
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.
@@@ -658,7 +675,7 @@ function createGpuProvisioner({
async function sweepOrphans(cfg) {
const t = now();
let seen = 0;
-- for (const marketplace of ['vast', 'runpod']) {
++ for (const marketplace of ALL_PROVIDERS.filter((name) => typeof (adapterFor(name, cfg) || {}).listInstances === 'function')) {
const adapter = adapterFor(marketplace, cfg);
if (!adapter) continue;
let instances = [];
@@@ -791,7 +808,7 @@ function createGpuProvisioner({
const state = String((info && info.state) || 'unknown').toLowerCase();
if (DEAD_STATES.includes(state)) {
await destroy({ ...row, charged_at: row.charged_at }, 'the instance ' + state);
-- if (!priv) await request(spec.key, 'launch', 'service:ai');
++ if (!priv) await request(spec.key, 'launch', 'service:ai', { jobClass: row.job_class, checkpoint: row.checkpoint });
return { changed: true, action: 'died', reason: 'the instance ' + state };
}
const endpoint = info && info.endpoint ? String(info.endpoint) : '';
@@@ -802,7 +819,7 @@ function createGpuProvisioner({
.run(failures, iso(t), iso(t), row.id);
if (failures >= HEALTH_FAILURES) {
await destroy({ ...row, charged_at: row.charged_at }, 'the endpoint never answered');
-- if (!priv) await request(spec.key, 'launch', 'service:ai');
++ if (!priv) await request(spec.key, 'launch', 'service:ai', { jobClass: row.job_class, checkpoint: row.checkpoint });
return { changed: true, action: 'died', reason: 'the endpoint never answered' };
}
return { changed: false, health_failures: failures };
@@@ -837,7 +854,7 @@ function createGpuProvisioner({
// the instance died: a shared offer leaves the pool and a new one is rented; a person's own
// endpoint stops (they can start it again when they want it).
await destroy({ ...row, charged_at: row.charged_at }, 'the endpoint stopped answering');
-- if (!priv) await request(spec.key, 'launch', 'service:ai');
++ if (!priv) await request(spec.key, 'launch', 'service:ai', { jobClass: row.job_class, checkpoint: row.checkpoint });
return { changed: true, action: 'died', reason: 'the endpoint stopped answering' };
}
return { changed: false };
@@@ -900,6 +917,8 @@ function createGpuProvisioner({
/** One pass: the owner's requests, the live instances, and the models that are wanted. */
async function tick({ actor = 'service:ai' } = {}) {
const cfg = config();
++ // The provider options are read before anything is built from them.
++ await providerPolicies();
// Recovery and orphan cleanup also run while new rental spending is disabled.
await sweepOrphans(cfg);
if (!cfg.enabled) {
@@@ -986,7 +1005,8 @@ function createGpuProvisioner({
instance: describe(live),
};
});
-- const policyState = gpuPolicy ? await gpuPolicy.read() : null;
++ const policyMap = await providerPolicies();
++ const policyState = gpuPolicy ? { global: (await gpuPolicy.read()).global, providers: [...policyMap.values()] } : null;
return {
strategy: cfg.strategy,
dry_run: cfg.dry_run,
@@@ -1001,7 +1021,9 @@ function createGpuProvisioner({
max_runtime_minutes: cfg.max_runtime_minutes,
warm_window: cfg.warm_window,
region: cfg.region,
-- marketplaces: Object.fromEntries(PROVIDER_KEYS.map((name) => [name, Boolean(cfg.keys && cfg.keys[name])])),
++ marketplaces: Object.fromEntries([...PROVIDER_KEYS, 'reserved'].map((name) => [name, name === 'reserved'
++ ? Boolean((currentPolicies.get('reserved') || {}).options && (currentPolicies.get('reserved') || {}).options.endpoint)
++ : 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,
ddiff --git a/server/gpu/reserved.js b/server/gpu/reserved.js
nnew file mode 100644
iindex 0000000..5b00cb9
---- /dev/null
++++ b/server/gpu/reserved.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * A reserved server: a fixed monthly box the owner registers by endpoint and monthly price
++ * (Hetzner GEX44 class). Nothing is rented by an API and no account is created here.
++ *
++ * Its hourly cost is the monthly price ÷ 730 (the hours in a month), exact integer nano-USD. The
++ * "instance" is the endpoint itself: createInstance answers at once, showInstance reports the
++ * registered endpoint, and destroyInstance gives the row back without touching the box — only the
++ * owner can stop a machine he pays for monthly.
++ */
++const money = require('./money');
++
++function reservedAdapter({ endpoint = '', monthlyNano = null, name = 'reserved' } = {}) {
++ const url = String(endpoint || '').replace(/\/+$/, '');
++ const monthly = monthlyNano == null || monthlyNano === '' ? null : String(monthlyNano);
++ const hourly = monthly != null && /^[0-9]+$/.test(monthly) ? (BigInt(monthly) / 730n).toString() : null;
++ const usd = hourly == null ? null : money.usdText(BigInt(hourly)).slice(1);
++
++ return {
++ id: 'reserved',
++ label: 'Reserved server',
++ baseUrl: url,
++ billing: 'monthly, registered by the owner; hourly is the monthly price ÷ 730',
++
++ async searchOffers(req = {}) {
++ if (!url || hourly == null) return [];
++ if (req.maxPricePerHour && Number(usd) > Number(req.maxPricePerHour)) return [];
++ return [{
++ id: 'reserved:' + name,
++ marketplace: 'reserved',
++ gpu_name: String(name || 'reserved server'),
++ gpu_ram_mb: Number(req.vramMb) || 0,
++ num_gpus: 1,
++ disk_gb: Number(req.diskGb) || 0,
++ price_per_hour: usd,
++ reliability: 0.99,
++ region: '',
++ interruptible: false,
++ }];
++ },
++
++ async createInstance(offer, spec, opts = {}) {
++ if (!url) throw Object.assign(new Error('no reserved server endpoint is registered'), { status: 422 });
++ return { remote_id: 'reserved:' + String(name || spec.key), endpoint: url, state: 'running', reserved: true };
++ },
++
++ async showInstance(remoteId) {
++ if (!url) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
++ return { remote_id: remoteId, state: 'running', endpoint: url, reserved: true };
++ },
++
++ async destroyInstance() {
++ // The box keeps running: the owner pays for it monthly either way. The row is released.
++ return true;
++ },
++ };
++}
++
++module.exports = { reservedAdapter };
ddiff --git a/server/gpu/runpod-serverless.js b/server/gpu/runpod-serverless.js
nnew file mode 100644
iindex 0000000..67bdcb0
---- /dev/null
++++ b/server/gpu/runpod-serverless.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * RunPod Serverless: flex workers that scale to zero, with RunPod's OpenAI-compatible vLLM endpoint.
++ *
++ * From RunPod's REST v2 API and docs (docs.runpod.io/api-reference-v2/serverless):
++ * POST /v2/serverless create an endpoint {name, templateId, type:"QUEUE", gpu:{pools},
++ * workers:{min,max,idleTimeout}, scaling}
++ * GET /v2/serverless/{id} the endpoint and its worker/status settings
++ * DELETE /v2/serverless/{id} delete the endpoint and terminate its workers
++ *
++ * The inference URL is https://api.runpod.ai/v2/{id}/openai/v1 (docs.runpod.io/serverless/vllm/overview).
++ * Flex prices are per second; the table is RunPod's own (runpod.io/pricing via
++ * docs.runpod.io/serverless/endpoints/endpoint-configurations, checked 2026-11-11).
++ *
++ * Nothing here creates an endpoint until the owner has a template and a key. min workers stays 0 so an
++ * idle endpoint costs nothing; the provisioner scales nothing by itself except through create/destroy.
++ */
++const { apiJson } = require('./http');
++
++const BASE_URL = 'https://api.runpod.io';
++const INFERENCE_URL = 'https://api.runpod.ai';
++// Flex rates per GPU-hour, by the pools RunPod publishes. The 24 GB pool is the L4/A5000/3090 class.
++const POOLS = Object.freeze([
++ { pool: 'ADA_24', gpu_name: 'RTX 4090', hourly: '1.10', vram_mb: 24576 },
++ { pool: 'AMPERE_24', gpu_name: 'L4 / A5000 / RTX 3090', hourly: '0.69', vram_mb: 24576 },
++ { pool: 'AMPERE_48', gpu_name: 'A6000 / A40', hourly: '1.22', vram_mb: 49152 },
++ { pool: 'ADA_48_PRO', gpu_name: 'L40 / L40S / 6000 Ada', hourly: '1.75', vram_mb: 49152 },
++ { pool: 'AMPERE_80', gpu_name: 'A100 80GB', hourly: '2.72', vram_mb: 81920 },
++ { pool: 'HOPPER_80', gpu_name: 'H100 80GB', hourly: '4.79', vram_mb: 81920 },
++ { pool: 'HOPPER_141', gpu_name: 'H200 141GB', hourly: '5.93', vram_mb: 141312 },
++]);
++
++/** A RunPod Serverless adapter: searchOffers, createInstance, showInstance, destroyInstance. */
++function runpodServerlessAdapter({ fetchImpl = globalThis.fetch, key = null, templateId = '', pools = [], maxWorkers = 3, baseUrl = BASE_URL } = {}) {
++ const auth = { key, label: 'RunPod Serverless' };
++ const openaiBaseUrl = (id) => INFERENCE_URL + '/v2/' + encodeURIComponent(id) + '/openai/v1';
++ const adapter = {
++ id: 'runpod_serverless',
++ label: 'RunPod Serverless',
++ baseUrl,
++ inferenceUrl: INFERENCE_URL,
++ billing: 'per second, rounded up from worker start to stop',
++
++ /** The OpenAI-compatible vLLM base URL of an endpoint. */
++ openaiBaseUrl,
++
++ async searchOffers(req = {}) {
++ if (!templateId) return [];
++ const wanted = (req.gpuNames || []).map((n) => String(n).toUpperCase());
++ return POOLS.filter((p) => !pools.length || pools.includes(p.pool))
++ .filter((p) => !wanted.length || wanted.some((w) => p.gpu_name.toUpperCase().includes(w) || w.includes(p.gpu_name.toUpperCase())))
++ .filter((p) => !req.vramMb || p.vram_mb >= Number(req.vramMb))
++ .filter((p) => !req.maxPricePerHour || Number(p.hourly) <= Number(req.maxPricePerHour))
++ .map((p) => ({
++ id: p.pool,
++ marketplace: 'runpod_serverless',
++ gpu_name: p.gpu_name,
++ gpu_ram_mb: p.vram_mb,
++ num_gpus: 1,
++ disk_gb: Number(req.diskGb) || 0,
++ price_per_hour: p.hourly,
++ reliability: 0.99,
++ region: '',
++ interruptible: false,
++ template_id: templateId,
++ }));
++ },
++
++ async createInstance(offer, spec, opts = {}) {
++ const body = {
++ name: String(opts.label || ('openvibe-' + spec.key)).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 50),
++ templateId: String(offer.template_id || templateId),
++ type: 'QUEUE',
++ gpu: { pools: [String(offer.id)] },
++ // Scale to zero: no idle worker is paid for; a request brings one up.
++ workers: { min: 0, max: Math.max(1, Math.min(10, Number(maxWorkers) || 3)), idleTimeout: 5 },
++ scaling: { type: 'QUEUE_DELAY', queueDelay: 4 },
++ };
++ const out = await apiJson(fetchImpl, baseUrl + '/v2/serverless', { ...auth, method: 'POST', body });
++ const id = String((out && out.id) || '');
++ return { remote_id: id, endpoint: id ? openaiBaseUrl(id) : '', state: String((out && out.status) || 'starting'), raw: out };
++ },
++
++ async showInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v2/serverless/' + encodeURIComponent(remoteId), { ...auth, allowMissing: true });
++ if (!out || out.missing) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
++ const workers = out.workers || {};
++ return {
++ remote_id: remoteId,
++ state: 'running',
++ endpoint: openaiBaseUrl(remoteId),
++ workers: { min: Number(workers.min) || 0, max: Number(workers.max) || 0 },
++ raw: out,
++ };
++ },
++
++ async destroyInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v2/serverless/' + encodeURIComponent(remoteId), { ...auth, method: 'DELETE', allowMissing: true });
++ return Boolean(out === null || out.missing || out.deleted !== false);
++ },
++ };
++ return adapter;
++}
++
++module.exports = { runpodServerlessAdapter, BASE_URL, POOLS };
ddiff --git a/server/gpu/salad.js b/server/gpu/salad.js
nnew file mode 100644
iindex 0000000..358b420
---- /dev/null
++++ b/server/gpu/salad.js
@@@ -0,0 +1,182 @@
++'use strict';
++/**
++ * SaladCloud: consumer GPUs for interruptible batch work.
++ *
++ * From SaladCloud's public API (docs.salad.com/reference/api-usage; the OpenAPI lives at
++ * github.com/SaladTechnologies/salad-cloud-docs/tree/main/api-specs):
++ * GET /organizations/{org}/gpu-classes the cards and their prices per priority
++ * POST /organizations/{org}/projects/{project}/containers create a container group
++ * GET /organizations/{org}/projects/{project}/containers/{name} the group and its state
++ * DELETE /organizations/{org}/projects/{project}/containers/{name} delete it
++ * POST /organizations/{org}/projects/{project}/queues/{queue}/jobs submit a batch job
++ * GET /organizations/{org}/projects/{project}/queues/{queue}/jobs/{id}
++ * DELETE /organizations/{org}/projects/{project}/queues/{queue}/jobs/{id}
++ *
++ * The key goes in the `Salad-Api-Key` header and nowhere else. Salad's own guidance is three
++ * replicas for availability; that is the owner's default for batch (three at $0.16 is $0.48/hour).
++ */
++const { apiJson } = require('./http');
++
++const BASE_URL = 'https://api.salad.com/api/public';
++const AUTH = { keyHeader: 'Salad-Api-Key', keyPrefix: '' };
++// The GPU class name is the only identity Salad returns for a card; this is the VRAM floor used to
++// match a model's requirement. Unknown cards get 0 and are filtered by the caller's own check.
++const VRAM_MB = Object.freeze({ '3090': 24576, '4090': 24576, '5090': 32768, 'l40': 49152, 'l40s': 49152, 'a100': 81920, 'h100': 81920 });
++
++function vramOf(name) {
++ const text = String(name || '').toLowerCase();
++ for (const [needle, mb] of Object.entries(VRAM_MB)) if (text.includes(needle)) return mb;
++ return 0;
++}
++
++function moneyOf(value) {
++ const m = /([0-9]+(?:\.[0-9]+)?)/.exec(String(value == null ? '' : value));
++ return m ? m[1] : '0';
++}
++
++/** A SaladCloud adapter: searchOffers, createInstance, showInstance, destroyInstance. */
++function saladAdapter({ fetchImpl = globalThis.fetch, key = null, organization = '', project = '', image = '', replicas = 3, baseUrl = BASE_URL } = {}) {
++ const auth = { ...AUTH, key, label: 'SaladCloud' };
++ const orgPath = `${baseUrl}/organizations/${encodeURIComponent(organization)}`;
++ const projectPath = `${orgPath}/projects/${encodeURIComponent(project)}`;
++
++ function requireNames() {
++ if (!organization || !project) {
++ const err = new Error('SaladCloud needs the owner\'s organization and project names');
++ err.status = 422;
++ throw err;
++ }
++ }
++
++ return {
++ id: 'salad',
++ label: 'SaladCloud',
++ baseUrl,
++ // SaladCloud is batch only: its lower tiers are preemptible and it documents itself as
++ // unsuitable for real-time user requests.
++ batchOnly: true,
++
++ async searchOffers(req = {}) {
++ requireNames();
++ const out = await apiJson(fetchImpl, orgPath + '/gpu-classes', auth);
++ const wanted = (req.gpuNames || []).map((n) => String(n).toLowerCase());
++ const offers = [];
++ for (const gpu of (out && out.items) || []) {
++ const name = String(gpu.name || '');
++ const vram = vramOf(name);
++ if (req.vramMb && vram && vram < Number(req.vramMb)) continue;
++ if (wanted.length && !wanted.some((w) => name.toLowerCase().includes(w) || w.includes(name.toLowerCase()))) continue;
++ const prices = Array.isArray(gpu.prices) ? gpu.prices : [];
++ // The owner's cheapest (batch/lowest) tier for interruptible jobs; the high tier when
++ // the job may not be interrupted. Salad keeps serving an existing group at its tier.
++ const preferred = req.interruptible ? ['batch', 'low', 'medium', 'high'] : ['high', 'medium', 'low', 'batch'];
++ const priceRow = preferred.map((p) => prices.find((x) => x.priority === p)).find(Boolean) || prices[0];
++ if (!priceRow) continue;
++ const price = moneyOf(priceRow.price);
++ if (!(Number(price) > 0)) continue;
++ offers.push({
++ id: String(gpu.id || name),
++ marketplace: 'salad',
++ gpu_name: name,
++ gpu_ram_mb: vram,
++ num_gpus: Number(gpu.gpu_count) || 1,
++ disk_gb: Number(req.diskGb) || 0,
++ price_per_hour: price,
++ reliability: 0.95,
++ region: '',
++ interruptible: Boolean(req.interruptible),
++ priority: priceRow.priority,
++ replicas: Number(gpu.gpu_count) > 1 ? replicas : replicas,
++ });
++ }
++ return offers;
++ },
++
++ async createInstance(offer, spec, opts = {}) {
++ requireNames();
++ const name = String(opts.label || ('openvibe-' + spec.key)).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 63);
++ const imageName = String(image || (spec.runtime && spec.runtime.image) || '');
++ if (!imageName) throw Object.assign(new Error('SaladCloud needs a container image for the model'), { status: 422 });
++ const ports = [8000, 8080, 5000, 8888];
++ const body = {
++ name,
++ display_name: name,
++ replicas: Math.max(1, Math.min(10, Number(opts.replicas || offer.replicas || replicas) || 3)),
++ autostart_policy: true,
++ restart_policy: 'always',
++ container: {
++ image: imageName,
++ priority: offer.interruptible ? 'batch' : 'high',
++ command: opts.command ? ['sh', '-lc', String(opts.command)] : undefined,
++ environment_variables: {
++ MODEL_ALIAS: String(spec.model_key || spec.key || ''),
++ MODEL_URL: String(opts.weights_url || ''),
++ },
++ resources: {
++ cpu: 4,
++ memory: 8192,
++ gpu_classes: [String(offer.id)],
++ storage_amount: Math.max(1073741824, (Number(spec.requirements && spec.requirements.disk_gb) || 50) * 1073741824),
++ },
++ },
++ // A readiness probe on a few common ports is harmless; the caller health-checks the
++ // endpoint it actually reached after create.
++ networking: { protocol: 'http', port: 8000, auth: false },
++ };
++ const created = await apiJson(fetchImpl, projectPath + '/containers', { ...auth, method: 'POST', body });
++ return {
++ remote_id: String((created && created.name) || name),
++ endpoint: String((created && created.networking && created.networking.dns) || (created && created.endpoint) || ''),
++ state: 'starting',
++ };
++ },
++
++ async showInstance(remoteId) {
++ requireNames();
++ const out = await apiJson(fetchImpl, projectPath + '/containers/' + encodeURIComponent(remoteId), { ...auth, allowMissing: true });
++ if (!out || out.missing) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
++ const state = String((out.current_state && out.current_state.status) || out.status || 'unknown');
++ const networking = out.networking || {};
++ return {
++ remote_id: remoteId,
++ state: state === 'running' ? 'running' : (state === 'failed' ? 'failed' : state),
++ endpoint: String(networking.dns || networking.url || ''),
++ raw: out,
++ };
++ },
++
++ async destroyInstance(remoteId) {
++ requireNames();
++ const out = await apiJson(fetchImpl, projectPath + '/containers/' + encodeURIComponent(remoteId), { ...auth, method: 'DELETE', allowMissing: true });
++ return Boolean(out === null || out.missing || out.removed === undefined || out.removed !== false);
++ },
++ };
++}
++
++/**
++ * SaladCloud's job queue: the batch adapter for work its container groups pick up. A job is the
++ * provider's own unit (input, status, output); nothing here rents a container.
++ */
++function saladQueueAdapter({ fetchImpl = globalThis.fetch, key = null, organization = '', project = '', queue = '', baseUrl = BASE_URL } = {}) {
++ const auth = { ...AUTH, key, label: 'SaladCloud queue' };
++ const path = `${baseUrl}/organizations/${encodeURIComponent(organization)}/projects/${encodeURIComponent(project)}/queues/${encodeURIComponent(queue)}/jobs`;
++ return {
++ id: 'salad_queue',
++ label: 'SaladCloud queue',
++ async submitJob(input, { webhook = null, metadata = null } = {}) {
++ const out = await apiJson(fetchImpl, path, { ...auth, method: 'POST', body: { input, ...(webhook ? { webhook } : {}), ...(metadata ? { metadata } : {}) } });
++ return { id: String((out && out.id) || ''), status: String((out && out.status) || 'pending') };
++ },
++ async showJob(id) {
++ const out = await apiJson(fetchImpl, path + '/' + encodeURIComponent(id), { ...auth, allowMissing: true });
++ if (!out || out.missing) return { id, status: 'missing' };
++ return { id: String(out.id), status: String(out.status || 'unknown'), output: out.output || null, events: out.events || [] };
++ },
++ async cancelJob(id) {
++ const out = await apiJson(fetchImpl, path + '/' + encodeURIComponent(id), { ...auth, method: 'DELETE', allowMissing: true });
++ return Boolean(out === null || out.missing || out.status !== 'failed');
++ },
++ };
++}
++
++module.exports = { saladAdapter, saladQueueAdapter, BASE_URL };
ddiff --git a/server/gpu/shadeform.js b/server/gpu/shadeform.js
nnew file mode 100644
iindex 0000000..374fc14
---- /dev/null
++++ b/server/gpu/shadeform.js
@@@ -0,0 +1,143 @@
++'use strict';
++/**
++ * Shadeform: one key for the underlying clouds it aggregates, on demand (and spot where a cloud
++ * offers it).
++ *
++ * From Shadeform's OpenAPI (https://api.shadeform.ai/v1/openapi.json, also linked from
++ * docs.shadeform.ai/api-reference):
++ * GET /v1/instances/types every type, its specs and its per-region availability
++ * POST /v1/instances/create create; the answer is {id,status} while it is "creating"
++ * GET /v1/instances/{id}/info the instance, its status and its ip/http port
++ * POST /v1/instances/{id}/delete delete; billing stops at "deleting"
++ *
++ * The key goes in the X-API-KEY header. Shadeform charges the cloud's own rate: the marketplace
++ * itself adds no fee (docs.shadeform.ai/getting-started/faq, 2026-11-08).
++ */
++const { apiJson } = require('./http');
++
++const BASE_URL = 'https://api.shadeform.ai';
++const AUTH = { keyHeader: 'X-API-KEY', keyPrefix: ' ' };
++
++function cleanKey(value) {
++ return String(value || '').trim();
++}
++
++// The type's own name is the only VRAM identity Shadeform returns; this is the matching floor.
++const VRAM_MB = Object.freeze({ A4000: 16384, A5000: 24576, A6000: 49152, '3090': 24576, '4090': 24576, L4: 24576, L40: 49152, L40S: 49152, A100: 81920, H100: 81920, H200: 141312, B200: 196608 });
++
++function vramOf(gpuType, numGpus) {
++ const text = String(gpuType || '').toUpperCase();
++ for (const [needle, mb] of Object.entries(VRAM_MB)) if (text.includes(needle.toUpperCase())) return mb * Math.max(1, Number(numGpus) || 1);
++ return 0;
++}
++
++/** A Shadeform adapter: searchOffers, createInstance, showInstance, destroyInstance. */
++function shadeformAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_URL } = {}) {
++ const auth = { key: cleanKey(key), label: 'Shadeform', ...AUTH, keyPrefix: '' };
++
++ async function typePage(query) {
++ const qs = Object.entries(query).filter(([, v]) => v != null && v !== '').map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
++ return apiJson(fetchImpl, baseUrl + '/v1/instances/types' + (qs ? '?' + qs : ''), auth);
++ }
++
++ function offerOf(type, availability, region) {
++ const hourly = availability.hourly_price != null && Number(availability.hourly_price) > 0
++ ? String(availability.hourly_price)
++ : String(Number(type.hourly_price) / 100);
++ return {
++ // The rental is identified by type + cloud + region + rental type; the create call needs all
++ // four, so the id carries them and the adapter reads them back out.
++ id: [type.shade_instance_type, type.cloud, region, availability.rental_type || 'on_demand'].join('|'),
++ cloud: String(type.cloud || ''),
++ shade_instance_type: String(type.shade_instance_type || ''),
++ rental_type: String(availability.rental_type || 'on_demand'),
++ region: String(region || ''),
++ marketplace: 'shadeform',
++ gpu_name: String(type.gpu_type || ''),
++ gpu_ram_mb: vramOf(type.gpu_type, type.num_gpus),
++ num_gpus: Number(type.num_gpus) || 1,
++ disk_gb: Number(type.storage_in_gb) || 0,
++ price_per_hour: hourly,
++ reliability: 0.99,
++ interruptible: String(availability.rental_type) === 'spot',
++ };
++ }
++
++ return {
++ id: 'shadeform',
++ label: 'Shadeform',
++ baseUrl,
++
++ async searchOffers(req = {}) {
++ const params = {
++ gpu_type: (req.gpuNames || [])[0] || '',
++ num_gpus: req.numGpus && Number(req.numGpus) > 1 ? req.numGpus : '',
++ region: req.region || '',
++ available: 'true',
++ shade_cloud: 'true',
++ sort: 'price',
++ };
++ const out = await typePage(params);
++ const offers = [];
++ for (const type of (out && out.instance_types) || []) {
++ for (const availability of (type.availability || [])) {
++ if (availability.available !== true) continue;
++ const region = String(availability.region || '');
++ if (req.region && region.toLowerCase() !== String(req.region).toLowerCase()) continue;
++ const rental = String(availability.rental_type || 'on_demand');
++ if (req.interruptible ? rental !== 'spot' : rental !== 'on_demand') continue;
++ if (Number(type.num_gpus) < Number(req.numGpus || 1)) continue;
++ if (req.diskGb && Number(type.storage_in_gb) < Number(req.diskGb)) continue;
++ const offer = offerOf(type, availability, region);
++ if (req.maxPricePerHour && Number(offer.price_per_hour) > Number(req.maxPricePerHour)) continue;
++ offers.push(offer);
++ }
++ }
++ return offers;
++ },
++
++ async createInstance(offer, spec, opts = {}) {
++ const parts = String(offer.id || '').split('|');
++ const body = {
++ cloud: offer.cloud || parts[1],
++ region: offer.region || parts[2],
++ shade_instance_type: offer.shade_instance_type || parts[0],
++ rental_type: offer.rental_type || parts[3] || 'on_demand',
++ shade_cloud: true,
++ name: String(opts.label || ('openvibe-' + spec.key)).replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 50),
++ launch_configuration: {
++ type: 'docker',
++ docker_configuration: {
++ image: String((spec.runtime && spec.runtime.image) || (opts.image || '')),
++ args: String(opts.command || ''),
++ port_mappings: [{ internal_port: 8000, external_port: 8000 }],
++ },
++ },
++ };
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances/create', { ...auth, method: 'POST', body });
++ return { remote_id: String((out && out.id) || ''), endpoint: '', state: String((out && out.status) || 'creating') };
++ },
++
++ async showInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances/' + encodeURIComponent(remoteId) + '/info', { ...auth, allowMissing: true });
++ if (!out || out.missing) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
++ const status = String(out.status || 'unknown').toLowerCase();
++ const details = out.details || {};
++ const port = Number(details.http_port) || 8000;
++ const host = out.ip || out.internal_hostname || '';
++ return {
++ remote_id: remoteId,
++ state: status === 'active' ? 'running' : (status === 'pending' ? 'starting' : status),
++ endpoint: host ? 'http://' + host + ':' + port : '',
++ raw: out,
++ };
++ },
++
++ async destroyInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances/' + encodeURIComponent(remoteId) + '/delete', { ...auth, method: 'POST', allowMissing: true });
++ return Boolean(out === null || out.missing || out.deleted !== false);
++ },
++ };
++}
++
++module.exports = { shadeformAdapter, BASE_URL };
ddiff --git a/server/gpu/tensordock.js b/server/gpu/tensordock.js
nnew file mode 100644
iindex 0000000..f35de21
---- /dev/null
++++ b/server/gpu/tensordock.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * TensorDock: marketplace VMs, on demand and spot.
++ *
++ * Calls follow TensorDock's public client API (dashboard.tensordock.com/api/docs):
++ * GET /api/v0/client/list_gpus the cards hosts offer
++ * POST /api/v0/client/deploy/single deploy one VM; spot uses price_type "spot" and a bid
++ * GET /api/v0/client/list_instances the VMs on the account and their state
++ * POST /api/v0/client/deploy/{id}/delete delete a VM
++ *
++ * TensorDock spot instances are bids: the spot price is per virtual machine, not per GPU, and an
++ * outbid VM is stopped while its storage keeps billing (docs.tensordock.com/virtual-machines/spot-instances).
++ * The key goes in the Authorization header; no request carries the key in a URL.
++ */
++const { apiJson } = require('./http');
++
++const BASE_URL = 'https://dashboard.tensordock.com/api/v0';
++
++function vramOf(gpu) {
++ const explicit = Number(gpu && (gpu.vram_gb || gpu.ram_gb));
++ if (Number.isFinite(explicit) && explicit > 0) return explicit * 1024;
++ const text = String((gpu && (gpu.model || gpu.name)) || '').toLowerCase();
++ const m = /(\d{2})\s*gb/.exec(text);
++ return m ? Number(m[1]) * 1024 : 0;
++}
++
++/** A TensorDock adapter: searchOffers, createInstance, showInstance, destroyInstance. */
++function tensordockAdapter({ fetchImpl = globalThis.fetch, key = null, image = 'ubuntu22.04', location = '', baseUrl = BASE_URL } = {}) {
++ const auth = { key, label: 'TensorDock' };
++
++ return {
++ id: 'tensordock',
++ label: 'TensorDock',
++ baseUrl,
++
++ async searchOffers(req = {}) {
++ const out = await apiJson(fetchImpl, baseUrl + '/client/list_gpus', auth);
++ const gpus = Array.isArray(out) ? out : ((out && (out.gpus || out.data)) || []);
++ const wanted = (req.gpuNames || []).map((n) => String(n).toLowerCase());
++ const offers = [];
++ for (const gpu of gpus) {
++ const name = String(gpu.model || gpu.name || gpu.gpu || '');
++ const lower = name.toLowerCase();
++ if (wanted.length && !wanted.some((w) => lower.includes(w) || w.includes(lower))) continue;
++ const vram = vramOf(gpu);
++ if (req.vramMb && vram && vram < Number(req.vramMb)) continue;
++ const count = Number(gpu.num_gpus || gpu.count || 1);
++ if (count < Number(req.numGpus || 1)) continue;
++ const onDemand = gpu.price_per_hour != null ? String(gpu.price_per_hour) : (gpu.price != null ? String(gpu.price) : '');
++ const spot = gpu.spot_price != null ? String(gpu.spot_price) : '';
++ const price = req.interruptible ? spot : onDemand;
++ if (!(Number(price) > 0)) continue;
++ if (req.maxPricePerHour && Number(price) > Number(req.maxPricePerHour)) continue;
++ offers.push({
++ id: String(gpu.id || gpu.gpu_id || name),
++ marketplace: 'tensordock',
++ gpu_name: name,
++ gpu_ram_mb: vram,
++ num_gpus: count,
++ disk_gb: Number(gpu.storage || req.diskGb || 0),
++ price_per_hour: price,
++ reliability: 0.98,
++ region: String(gpu.location || gpu.location_code || location || ''),
++ location_code: String(gpu.location || gpu.location_code || location || ''),
++ interruptible: Boolean(req.interruptible),
++ });
++ }
++ return offers;
++ },
++
++ async createInstance(offer, spec, opts = {}) {
++ const body = {
++ gpu_model: String(offer.gpu_name),
++ gpu_count: Number(offer.num_gpus) || 1,
++ gpu_ram: Number(offer.gpu_ram_mb) || 0,
++ cpu_cores: 4,
++ ram: 16,
++ storage: Math.max(50, Number(spec.requirements && spec.requirements.disk_gb) || 50),
++ operating_system: String((spec.runtime && spec.runtime.image) || image),
++ price_type: offer.interruptible || opts.interruptible ? 'spot' : 'ondemand',
++ // A spot deployment sets its bid; on-demand pays the host's price.
++ ...(offer.interruptible || opts.interruptible ? { price: String(offer.price_per_hour) } : {}),
++ location: offer.location_code || location || undefined,
++ name: String(opts.label || ('openvibe-' + spec.key)),
++ port_forwards: [{ internal_port: 8000, external_port: 8000, protocol: 'http' }],
++ };
++ const out = await apiJson(fetchImpl, baseUrl + '/client/deploy/single', { ...auth, method: 'POST', body });
++ const remoteId = String((out && (out.vm_id || out.id || (out.data && (out.data.vm_id || out.data.id)))) || '');
++ return { remote_id: remoteId, endpoint: '', state: 'starting', raw: out };
++ },
++
++ async showInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/client/list_instances', { ...auth, allowMissing: true });
++ const list = (out && (out.instances || out.data)) || [];
++ const found = (Array.isArray(list) ? list : Object.values(list)).find((x) => String(x.id || x.vm_id) === String(remoteId));
++ if (!found) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
++ const state = String(found.status || found.state || 'unknown').toLowerCase();
++ const host = found.ip || found.host || (found.port_forwards && found.port_forwards[0] && found.port_forwards[0].host);
++ const port = Number(found.port || (found.port_forwards && found.port_forwards[0] && found.port_forwards[0].external_port)) || 8000;
++ return {
++ remote_id: remoteId,
++ state: state === 'running' ? 'running' : (['deploying', 'starting', 'pending', 'provisioning'].includes(state) ? 'starting' : state),
++ endpoint: host ? 'http://' + host + ':' + port : '',
++ outbid: /outbid|preempt|spot.*(stopped|evicted)/.test(String(found.status || found.state || '') + ' ' + String(found.message || '')),
++ raw: found,
++ };
++ },
++
++ async destroyInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/client/deploy/' + encodeURIComponent(remoteId) + '/delete', { ...auth, method: 'POST', allowMissing: true });
++ return Boolean(out === null || out.missing || out.success !== false);
++ },
++ };
++}
++
++module.exports = { tensordockAdapter, BASE_URL };
ddiff --git a/server/gpu/thunder.js b/server/gpu/thunder.js
nnew file mode 100644
iindex 0000000..f7ea1e7
---- /dev/null
++++ b/server/gpu/thunder.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * Thunder Compute: on-demand GPU instances billed per minute, never preempted.
++ *
++ * From Thunder's OpenAPI (https://api.thundercompute.com:8443/openapi.json, documented at
++ * thundercompute.com/docs/api-reference):
++ * GET /v2/specs every GPU type/count with its VRAM and storage range
++ * POST /v1/instances/create create; the answer carries the instance uuid
++ * GET /v1/instances/list every instance and its status/ip
++ * POST /v1/instances/{id}/delete delete it
++ *
++ * The key is a bearer token. Thunder's public price list is per GPU-hour; the rates below are the
++ * ones the provider publishes (thundercompute.com/pricing, checked 2026-11-11). A card without a
++ * published rate is not offered rather than guessed at.
++ */
++const { apiJson } = require('./http');
++
++const BASE_URL = 'https://api.thundercompute.com:8443';
++const PRICES = Object.freeze({ H100: '3.20', A100: '1.09' });
++const VRAM_MB = Object.freeze({ H100: 81920, A100: 81920, A6000: 49152, L40: 49152 });
++
++/** The card and count from a spec key such as "H100_1x" or "A100_80GB_2x". */
++function parseSpec(key, spec) {
++ const text = String(key || '');
++ const count = Number(spec && spec.gpuCount) || Number((/_(\d+)x$/i.exec(text) || [])[1]) || 1;
++ const name = String((spec && spec.displayName) || text.split('_')[0] || '').trim();
++ const type = Object.keys(PRICES).find((p) => name.toUpperCase().includes(p)) || name.toUpperCase();
++ return { type, count, name: name || type };
++}
++
++/** A Thunder Compute adapter: searchOffers, createInstance, showInstance, destroyInstance. */
++function thunderAdapter({ fetchImpl = globalThis.fetch, key = null, template = 'ubuntu-22.04', baseUrl = BASE_URL } = {}) {
++ const auth = { key, label: 'Thunder Compute' };
++
++ return {
++ id: 'thunder',
++ label: 'Thunder Compute',
++ baseUrl,
++ billing: 'per minute',
++
++ async searchOffers(req = {}) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v2/specs', auth);
++ const specs = (out && out.specs) || {};
++ const wanted = (req.gpuNames || []).map((n) => String(n).toUpperCase());
++ const offers = [];
++ for (const [specKey, spec] of Object.entries(specs)) {
++ const parsed = parseSpec(specKey, spec);
++ const price = PRICES[parsed.type];
++ if (!price) continue;
++ if (wanted.length && !wanted.some((w) => parsed.type.toUpperCase().includes(w) || w.includes(parsed.type.toUpperCase()))) continue;
++ if (Number(spec.gpuCount) < Number(req.numGpus || 1)) continue;
++ const vram = VRAM_MB[parsed.type] || 0;
++ if (req.vramMb && vram && vram < Number(req.vramMb)) continue;
++ if (req.maxPricePerHour && Number(price) > Number(req.maxPricePerHour)) continue;
++ offers.push({
++ id: specKey,
++ marketplace: 'thunder',
++ gpu_name: parsed.name,
++ gpu_type: parsed.type,
++ gpu_ram_mb: vram,
++ num_gpus: parsed.count,
++ disk_gb: Number(req.diskGb) || 0,
++ price_per_hour: price,
++ reliability: 0.99,
++ region: 'US',
++ interruptible: false,
++ });
++ }
++ return offers;
++ },
++
++ async createInstance(offer, spec, opts = {}) {
++ const body = {
++ gpu_type: String(offer.gpu_type || offer.gpu_name || '').split(' ')[0],
++ num_gpus: Number(offer.num_gpus) || 1,
++ cpu_cores: 4,
++ disk_size_gb: Math.max(50, Number(spec.requirements && spec.requirements.disk_gb) || 50),
++ template: String(opts.template || template),
++ };
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances/create', { ...auth, method: 'POST', body });
++ return { remote_id: String((out && (out.uuid || out.id)) || ''), endpoint: '', state: 'starting', raw: out };
++ },
++
++ async showInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances/list', { ...auth, allowMissing: true });
++ const list = (out && (out.instances || out)) || {};
++ const found = Array.isArray(list) ? list.find((x) => String(x.uuid || x.id) === String(remoteId)) : list[remoteId];
++ if (!found) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
++ const state = String(found.status || 'unknown').toLowerCase();
++ const host = found.ip || '';
++ const port = Number(found.port) || 8000;
++ return {
++ remote_id: remoteId,
++ state: state === 'running' ? 'running' : (['provisioning', 'starting', 'pending', 'creating'].includes(state) ? 'starting' : state),
++ endpoint: host ? 'http://' + host + ':' + port : '',
++ raw: found,
++ };
++ },
++
++ async destroyInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances/' + encodeURIComponent(remoteId) + '/delete', { ...auth, method: 'POST', allowMissing: true });
++ return Boolean(out === null || out.missing || out.message || out.deleted !== false);
++ },
++ };
++}
++
++module.exports = { thunderAdapter, BASE_URL, PRICES };
ddiff --git a/server/gpu/vast.js b/server/gpu/vast.js
iindex 8b8f225..4dfbd3b 100644
---- a/server/gpu/vast.js
++++ b/server/gpu/vast.js
@@@ -36,7 +36,9 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
async searchOffers(req) {
const body = {
-- type: 'ondemand',
++ // An interruptible job bids: type "bid" returns the hosts' minimum bids, and the
++ // create call offers that price. On-demand ("ondemand") is the default.
++ type: req.interruptible ? 'bid' : 'ondemand',
verified: { eq: true },
rentable: { eq: true },
gpu_name: { in: req.gpuNames },
@@@ -57,9 +59,12 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
gpu_ram_mb: Number(o.gpu_ram) || 0,
num_gpus: Number(o.num_gpus) || 1,
disk_gb: Number(o.disk_space) || 0,
-- price_per_hour: String(o.dph_total == null ? '0' : o.dph_total),
++ // A bid's price is what the host asks as a floor; the bid we place is that price.
++ price_per_hour: String(o.min_bid != null ? o.min_bid : (o.dph_total == null ? '0' : o.dph_total)),
++ bid_price: req.interruptible ? String(o.min_bid != null ? o.min_bid : (o.dph_total == null ? '0' : o.dph_total)) : null,
reliability: Number(o.reliability) || 0,
region: String(o.geolocation || ''),
++ interruptible: Boolean(req.interruptible),
}));
},
@@@ -105,6 +110,12 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
// when no live row of ours owns it.
label: String(opts.label || ('openvibe-' + spec.key)),
};
++ // An interruptible rental carries its bid price. It may be paused when outbid; the job
++ // resumes from its checkpoint on another offer (the provisioner requeues it).
++ if (opts.interruptible || offer.interruptible) {
++ body.type = 'bid';
++ body.price = String(offer.bid_price || offer.price_per_hour || '0');
++ }
const out = await apiJson(fetchImpl, baseUrl + '/api/v0/asks/' + offer.id + '/', { method: 'PUT', body, ...auth, label: 'Vast.ai' });
if (!out || out.success === false) {
const err = new Error('Vast.ai did not rent the offer');
@@@ -125,9 +136,13 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
if (out && out.missing) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
const instance = out && out.instances ? out.instances : out;
const status = String((instance && (instance.actual_status || instance.status)) || 'unknown');
++ const message = String((instance && (instance.status_msg || instance.status_message)) || '');
++ // The outbid case: Vast stops an interruptible rental when a higher bid takes the host.
++ const outbid = /outbid|bid (is |was )?(too )?low|preempt/i.test(message) || /preempt/i.test(status);
return {
remote_id: remoteId,
-- state: status,
++ state: outbid ? 'preempted' : status,
++ outbid,
endpoint: endpointOf(instance, (opts_port(instance))),
raw: out,
};
ddiff --git a/server/gpu/verda.js b/server/gpu/verda.js
nnew file mode 100644
iindex 0000000..18e501a
---- /dev/null
++++ b/server/gpu/verda.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * Verda (formerly DataCrunch): on-demand and spot datacenter GPUs.
++ *
++ * From Verda's public OpenAPI (https://api.datacrunch.io/v1/openapi.json, documented at
++ * docs.verda.com/api):
++ * GET /v1/instance-types the types, their GPUs, VRAM and hourly + spot prices
++ * POST /v1/instances deploy an instance (is_spot true for spot)
++ * GET /v1/instances/{id} the instance and its status/ip
++ * PUT /v1/instances {action:"delete", id} gives the instance back
++ *
++ * The key is a bearer token. Spot instances may be evicted by Verda at any time without warning.
++ */
++const { apiJson } = require('./http');
++
++const BASE_URL = 'https://api.datacrunch.io';
++
++function modelMatch(name, wanted) {
++ const text = String(name || '').toLowerCase();
++ return !wanted.length || wanted.some((w) => text.includes(String(w).toLowerCase()) || String(w).toLowerCase().includes(text));
++}
++
++/** A Verda adapter: searchOffers, createInstance, showInstance, destroyInstance. */
++function verdaAdapter({ fetchImpl = globalThis.fetch, key = null, image = '', location = '', baseUrl = BASE_URL } = {}) {
++ const auth = { key, label: 'Verda' };
++
++ return {
++ id: 'verda',
++ label: 'Verda',
++ baseUrl,
++
++ async searchOffers(req = {}) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instance-types', auth);
++ const types = Array.isArray(out) ? out : ((out && (out.instance_types || out.data)) || []);
++ const wanted = req.gpuNames || [];
++ const offers = [];
++ for (const type of types) {
++ if (!type || !type.instance_type) continue;
++ const gpu = type.gpu || {};
++ const memory = type.gpu_memory || {};
++ const name = String(type.name || type.model || type.instance_type);
++ if (!modelMatch(name, wanted)) continue;
++ const vramMb = Number(memory.size_in_gigabytes || type.vram_gb || 0) * 1024;
++ if (req.vramMb && vramMb && vramMb < Number(req.vramMb)) continue;
++ const numGpus = Number(gpu.number_of_gpus) || 1;
++ if (numGpus < Number(req.numGpus || 1)) continue;
++ const spot = type.spot_price != null && Number(type.spot_price) > 0 ? String(type.spot_price) : '';
++ const onDemand = type.price_per_hour != null ? String(type.price_per_hour) : '';
++ const price = req.interruptible ? spot : onDemand;
++ if (!(Number(price) > 0)) continue;
++ if (req.maxPricePerHour && Number(price) > Number(req.maxPricePerHour)) continue;
++ const locationCode = location || String((type.locations && type.locations[0] && (type.locations[0].code || type.locations[0].location_code)) || type.location_code || '');
++ offers.push({
++ id: String(type.instance_type),
++ marketplace: 'verda',
++ gpu_name: name,
++ gpu_ram_mb: vramMb,
++ num_gpus: numGpus,
++ disk_gb: Number(type.storage || req.diskGb || 0),
++ price_per_hour: price,
++ reliability: 0.99,
++ region: locationCode,
++ location_code: locationCode,
++ interruptible: Boolean(req.interruptible),
++ });
++ }
++ return offers;
++ },
++
++ async createInstance(offer, spec, opts = {}) {
++ const disk = Math.max(50, Number(spec.requirements && spec.requirements.disk_gb) || 50);
++ const body = {
++ instance_type: String(offer.id),
++ image: String((spec.runtime && spec.runtime.image) || image || 'ubuntu-22.04-cuda-12.4-docker'),
++ is_spot: Boolean(offer.interruptible || opts.interruptible),
++ description: String(opts.label || ('openvibe-' + spec.key)),
++ hostname: String(opts.label || '').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 40) || undefined,
++ os_volume: { name: String(opts.label || spec.key), size: disk },
++ };
++ if (offer.location_code || location) body.location_code = offer.location_code || location;
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances', { ...auth, method: 'POST', body });
++ return { remote_id: String((out && out.id) || ''), endpoint: '', state: 'starting', raw: out };
++ },
++
++ async showInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances/' + encodeURIComponent(remoteId), { ...auth, allowMissing: true });
++ if (!out || out.missing) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
++ const state = String(out.status || 'unknown').toLowerCase();
++ const ip = out.ip || out.main_ip || out.hostname || '';
++ return {
++ remote_id: remoteId,
++ state: state === 'running' ? 'running' : (['provisioning', 'starting', 'pending'].includes(state) ? 'starting' : state),
++ endpoint: ip ? 'http://' + ip + ':8000' : '',
++ raw: out,
++ };
++ },
++
++ async destroyInstance(remoteId) {
++ const out = await apiJson(fetchImpl, baseUrl + '/v1/instances', {
++ ...auth, method: 'PUT', body: { action: 'delete', id: String(remoteId) }, allowMissing: true,
++ });
++ return Boolean(out === null || out.deleted !== false);
++ },
++ };
++}
++
++module.exports = { verdaAdapter, BASE_URL };
ddiff --git a/test/gpu-classes.test.js b/test/gpu-classes.test.js
iindex 9ee2edf..745093e 100644
---- a/test/gpu-classes.test.js
++++ b/test/gpu-classes.test.js
@@@ -69,11 +69,12 @@ t.test('a launch records its class and checkpoint, and a batch cap refuses befor
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(row.interruptible, true, 'a checkpointed batch job bids on interruptible capacity');
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);
++ assert.strictEqual(api.calls.create[0].body.type, 'bid', 'the rental carries the bid');
} finally { await h.stop(); }
});
ddiff --git a/test/gpu-providers.test.js b/test/gpu-providers.test.js
nnew file mode 100644
iindex 0000000..1819db6
---- /dev/null
++++ b/test/gpu-providers.test.js
@@@ -0,0 +1,229 @@
++'use strict';
++/**
++ * The marketplace adapters, against fakes written from each provider's own API documentation.
++ * No test reaches a provider: every request is answered here and every request shape is asserted.
++ */
++const assert = require('node:assert/strict');
++const { vastAdapter } = require('../server/gpu/vast');
++const { saladAdapter, saladQueueAdapter } = require('../server/gpu/salad');
++const { tensordockAdapter } = require('../server/gpu/tensordock');
++const { shadeformAdapter } = require('../server/gpu/shadeform');
++const { verdaAdapter } = require('../server/gpu/verda');
++const { thunderAdapter } = require('../server/gpu/thunder');
++const { runpodServerlessAdapter } = require('../server/gpu/runpod-serverless');
++const { reservedAdapter } = require('../server/gpu/reserved');
++const { createAdapter } = require('../server/gpu/adapters');
++
++function fake(handler) {
++ const calls = [];
++ const fetchImpl = async (url, opts = {}) => {
++ const call = { url: String(url), method: String(opts.method || 'GET'), headers: opts.headers || {}, body: opts.body ? JSON.parse(opts.body) : null };
++ calls.push(call);
++ const out = handler(call) || {};
++ const status = out.status == null ? 200 : out.status;
++ return { ok: status >= 200 && status < 300, status, text: async () => (out.body === undefined ? '' : JSON.stringify(out.body)) };
++ };
++ return { fetchImpl, calls };
++}
++const spec = { key: 'deepseek-r1-32b', model_key: 'deepseek-r1-32b', runtime: { image: 'vllm/vllm-openai:latest' }, requirements: { disk_gb: 100, vram_mb: 24000, num_gpus: 1 } };
++
++(async () => {
++ // ── Vast.ai interruptible bids: search type bid, a bid price at create, the outbid case ──
++ {
++ const f = fake((c) => {
++ if (c.url.includes('/api/v0/bundles')) return { body: { offers: [{ id: 7, gpu_name: 'RTX 4090', gpu_ram: 24576, num_gpus: 1, disk_space: 100, min_bid: 0.14, reliability: 0.99, geolocation: 'US' }] } };
++ if (c.url.includes('/api/v0/asks/')) return { body: { success: true, new_contract: 'inst-7' } };
++ if (c.url.includes('/api/v0/instances/')) return { body: { id: 'inst-7', actual_status: 'exited', status_msg: 'Your bid is below the market price; the instance was outbid' } };
++ return { status: 404, body: {} };
++ });
++ const a = vastAdapter({ fetchImpl: f.fetchImpl, key: 'k' });
++ const offers = await a.searchOffers({ gpuNames: ['RTX 4090'], vramMb: 24000, numGpus: 1, diskGb: 100, region: 'US', maxPricePerHour: 1, interruptible: true });
++ assert.equal(offers.length, 1);
++ assert.equal(offers[0].interruptible, true);
++ assert.equal(offers[0].bid_price, '0.14');
++ assert.equal(f.calls[0].body.type, 'bid');
++ await a.createInstance(offers[0], spec, { label: 'openvibe-abcdef123456', interruptible: true });
++ const rent = f.calls.find((c) => c.url.includes('/api/v0/asks/'));
++ assert.equal(rent.body.type, 'bid');
++ assert.equal(rent.body.price, '0.14');
++ const shown = await a.showInstance('inst-7');
++ assert.equal(shown.outbid, true);
++ assert.equal(shown.state, 'preempted');
++ }
++
++ // ── SaladCloud: a container group at the batch tier and three replicas, its queue adapter ──
++ {
++ const f = fake((c) => {
++ if (c.url.endsWith('/gpu-classes')) return { body: { items: [{ id: 'gpu-1', name: 'RTX 4090', gpu_count: 1, prices: [{ priority: 'batch', price: '$0.16/hr' }, { priority: 'high', price: '$0.44/hr' }] }] } };
++ if (c.url.endsWith('/containers') && c.method === 'POST') return { status: 201, body: { name: 'openvibe-a1', networking: { dns: 'openvibe-a1.salad.com' } } };
++ if (c.url.includes('/containers/') && c.method === 'GET') return { body: { current_state: { status: 'running' }, networking: { dns: 'openvibe-a1.salad.com' } } };
++ if (c.url.includes('/containers/') && c.method === 'DELETE') return { body: { removed: true } };
++ if (c.url.includes('/queues/q/jobs') && c.method === 'POST') return { status: 201, body: { id: 'job-1', status: 'pending' } };
++ if (c.url.includes('/queues/q/jobs/job-1') && c.method === 'GET') return { body: { id: 'job-1', status: 'succeeded', output: { text: 'ok' } } };
++ if (c.url.includes('/queues/q/jobs/job-1') && c.method === 'DELETE') return { body: { ok: true } };
++ return { status: 404, body: {} };
++ });
++ const a = saladAdapter({ fetchImpl: f.fetchImpl, key: 'salad-key', organization: 'org', project: 'proj' });
++ const offers = await a.searchOffers({ gpuNames: ['RTX 4090'], vramMb: 24000, diskGb: 100, interruptible: true });
++ assert.equal(offers.length, 1);
++ assert.equal(offers[0].price_per_hour, '0.16');
++ assert.equal(offers[0].interruptible, true);
++ const created = await a.createInstance(offers[0], spec, { label: 'openvibe-a1', interruptible: true });
++ assert.equal(created.remote_id, 'openvibe-a1');
++ const create = f.calls.find((c) => c.url.endsWith('/containers') && c.method === 'POST');
++ assert.equal(create.headers['Salad-Api-Key'], 'salad-key');
++ assert.equal(create.body.replicas, 3, 'the owner default for batch is three replicas');
++ assert.equal(create.body.container.priority, 'batch');
++ assert.deepEqual(create.body.container.resources.gpu_classes, ['gpu-1']);
++ const shown = await a.showInstance('openvibe-a1');
++ assert.equal(shown.state, 'running');
++ assert.equal(shown.endpoint, 'openvibe-a1.salad.com');
++ assert.equal(await a.destroyInstance('openvibe-a1'), true);
++ const queue = saladQueueAdapter({ fetchImpl: f.fetchImpl, key: 'salad-key', organization: 'org', project: 'proj', queue: 'q' });
++ const submitted = await queue.submitJob({ prompt: 'x' });
++ assert.equal(submitted.id, 'job-1');
++ assert.equal((await queue.showJob('job-1')).status, 'succeeded');
++ assert.equal(await queue.cancelJob('job-1'), true);
++ }
++
++ // ── Shadeform: one key, type + cloud + region + rental type ──
++ {
++ const f = fake((c) => {
++ if (c.url.includes('/v1/instances/types')) return { body: { instance_types: [{ shade_instance_type: 'A6000', cloud: 'hyperstack', gpu_type: 'A6000', num_gpus: 1, storage_in_gb: 500, hourly_price: 210, availability: [
++ { region: 'canada-1', rental_type: 'on_demand', available: true, hourly_price: '2.10' },
++ { region: 'canada-1', rental_type: 'spot', available: true, hourly_price: '1.10' },
++ ] }] } };
++ if (c.url.endsWith('/v1/instances/create')) return { body: { id: 'sh-1', status: 'creating' } };
++ if (c.url.includes('/v1/instances/sh-1/info')) return { body: { id: 'sh-1', status: 'active', ip: '1.2.3.4', details: { http_port: 8000 } } };
++ if (c.url.includes('/v1/instances/sh-1/delete')) return { body: {} };
++ return { status: 404, body: {} };
++ });
++ const a = shadeformAdapter({ fetchImpl: f.fetchImpl, key: 'shade-key' });
++ const onDemand = await a.searchOffers({ gpuNames: ['A6000'], numGpus: 1, diskGb: 100, region: 'canada-1' });
++ assert.equal(onDemand.length, 1);
++ assert.equal(onDemand[0].price_per_hour, '2.10');
++ assert.equal(onDemand[0].interruptible, false);
++ const spot = await a.searchOffers({ gpuNames: ['A6000'], numGpus: 1, diskGb: 100, region: 'canada-1', interruptible: true });
++ assert.equal(spot[0].price_per_hour, '1.10');
++ assert.equal(spot[0].interruptible, true);
++ assert.equal(f.calls[0].headers['X-API-KEY'], 'shade-key');
++ const created = await a.createInstance(onDemand[0], spec, { command: 'serve' });
++ assert.equal(created.remote_id, 'sh-1');
++ const create = f.calls.find((c) => c.url.endsWith('/create'));
++ assert.equal(create.body.shade_instance_type, 'A6000');
++ assert.equal(create.body.cloud, 'hyperstack');
++ assert.equal(create.body.region, 'canada-1');
++ assert.equal(create.body.rental_type, 'on_demand');
++ assert.equal((await a.showInstance('sh-1')).endpoint, 'http://1.2.3.4:8000');
++ assert.equal(await a.destroyInstance('sh-1'), true);
++ }
++
++ // ── Verda: on demand and spot, delete through the action endpoint ──
++ {
++ const f = fake((c) => {
++ if (c.url.endsWith('/v1/instance-types')) return { body: [{ instance_type: '1H100.80S.30V', name: 'H100 SXM5 80GB', gpu: { number_of_gpus: 1 }, gpu_memory: { size_in_gigabytes: 80 }, price_per_hour: '3.17', spot_price: '1.29' }] };
++ if (c.url.endsWith('/v1/instances') && c.method === 'POST') return { body: { id: 'v-1', status: 'provisioning' } };
++ if (c.url.endsWith('/v1/instances/v-1')) return { body: { id: 'v-1', status: 'running', ip: '5.6.7.8' } };
++ if (c.url.endsWith('/v1/instances') && c.method === 'PUT') return { body: { deleted: true } };
++ return { status: 404, body: {} };
++ });
++ const a = verdaAdapter({ fetchImpl: f.fetchImpl, key: 'verda-key' });
++ const spot = await a.searchOffers({ gpuNames: ['H100'], vramMb: 80000, numGpus: 1, interruptible: true });
++ assert.equal(spot[0].price_per_hour, '1.29');
++ assert.equal(spot[0].interruptible, true);
++ const created = await a.createInstance(spot[0], spec, { label: 'openvibe-v1' });
++ assert.equal(created.remote_id, 'v-1');
++ assert.equal(f.calls.find((c) => c.url.endsWith('/v1/instances') && c.method === 'POST').body.is_spot, true);
++ assert.equal((await a.showInstance('v-1')).endpoint, 'http://5.6.7.8:8000');
++ assert.equal(await a.destroyInstance('v-1'), true);
++ assert.equal(f.calls.find((c) => c.method === 'PUT').body.action, 'delete');
++ }
++
++ // ── Thunder Compute: on demand, per-minute billing, only published rates ──
++ {
++ const f = fake((c) => {
++ if (c.url.endsWith('/v2/specs')) return { body: { specs: { H100_1x: { displayName: 'H100', gpuCount: 1, vramGB: 80 }, A6000_1x: { displayName: 'RTX A6000', gpuCount: 1, vramGB: 48 } } } };
++ if (c.url.endsWith('/v1/instances/create')) return { status: 201, body: { uuid: 't-1', identifier: 1 } };
++ if (c.url.endsWith('/v1/instances/list')) return { body: { 't-1': { id: 't-1', uuid: 't-1', status: 'running', ip: '9.9.9.9', port: 8000 } } };
++ if (c.url.includes('/v1/instances/t-1/delete')) return { body: { message: 'Success' } };
++ return { status: 404, body: {} };
++ });
++ const a = thunderAdapter({ fetchImpl: f.fetchImpl, key: 'thunder-key' });
++ const offers = await a.searchOffers({ gpuNames: ['H100'], vramMb: 80000, numGpus: 1 });
++ assert.equal(offers.length, 1, 'a card without a published rate is not offered');
++ assert.equal(offers[0].price_per_hour, '3.20');
++ assert.equal(offers[0].interruptible, false);
++ const created = await a.createInstance(offers[0], spec, { label: 'openvibe-t1' });
++ assert.equal(created.remote_id, 't-1');
++ assert.equal(f.calls.find((c) => c.url.endsWith('/create')).body.num_gpus, 1);
++ assert.equal((await a.showInstance('t-1')).endpoint, 'http://9.9.9.9:8000');
++ assert.equal(await a.destroyInstance('t-1'), true);
++ }
++
++ // ── TensorDock: a spot VM is a bid; the delete is the deploy delete ──
++ {
++ const f = fake((c) => {
++ if (c.url.endsWith('/client/list_gpus')) return { body: { gpus: [{ id: 'g-1', model: 'RTX 4090', num_gpus: 1, vram_gb: 24, price_per_hour: '0.37', spot_price: '0.20', location: 'us-east-1' }] } };
++ if (c.url.endsWith('/client/deploy/single')) return { body: { vm_id: 'td-1', success: true } };
++ if (c.url.endsWith('/client/list_instances')) return { body: { instances: [{ id: 'td-1', status: 'running', ip: '4.4.4.4', port_forwards: [{ external_port: 8000 }] }] } };
++ if (c.url.includes('/client/deploy/td-1/delete')) return { body: { success: true } };
++ return { status: 404, body: {} };
++ });
++ const a = tensordockAdapter({ fetchImpl: f.fetchImpl, key: 'td-key' });
++ const spot = await a.searchOffers({ gpuNames: ['RTX 4090'], vramMb: 24000, numGpus: 1, interruptible: true });
++ assert.equal(spot[0].price_per_hour, '0.20');
++ const created = await a.createInstance(spot[0], spec, { label: 'openvibe-td1' });
++ assert.equal(created.remote_id, 'td-1');
++ const deploy = f.calls.find((c) => c.url.endsWith('/client/deploy/single')).body;
++ assert.equal(deploy.price_type, 'spot');
++ assert.equal(deploy.price, '0.20');
++ assert.equal((await a.showInstance('td-1')).endpoint, 'http://4.4.4.4:8000');
++ assert.equal(await a.destroyInstance('td-1'), true);
++ }
++
++ // ── RunPod Serverless: scale to zero and the OpenAI-compatible vLLM URL ──
++ {
++ const f = fake((c) => {
++ if (c.url.endsWith('/v2/serverless') && c.method === 'POST') return { body: { id: 'ep-1', status: 'creating' } };
++ if (c.url.endsWith('/v2/serverless/ep-1') && c.method === 'GET') return { body: { id: 'ep-1', workers: { min: 0, max: 3 } } };
++ if (c.url.endsWith('/v2/serverless/ep-1') && c.method === 'DELETE') return { body: { deleted: true } };
++ return { status: 404, body: {} };
++ });
++ const a = runpodServerlessAdapter({ fetchImpl: f.fetchImpl, key: 'rp-key', templateId: 'tpl-1' });
++ const offers = await a.searchOffers({ gpuNames: ['L40S'], vramMb: 48000, numGpus: 1 });
++ assert.equal(offers.length, 1);
++ assert.equal(offers[0].price_per_hour, '1.75');
++ const created = await a.createInstance(offers[0], spec, { label: 'openvibe-rp1' });
++ assert.equal(created.remote_id, 'ep-1');
++ assert.equal(created.endpoint, 'https://api.runpod.ai/v2/ep-1/openai/v1');
++ const body = f.calls.find((c) => c.url.endsWith('/v2/serverless') && c.method === 'POST').body;
++ assert.equal(body.templateId, 'tpl-1');
++ assert.equal(body.workers.min, 0, 'scale to zero');
++ assert.deepEqual(body.gpu.pools, ['ADA_48_PRO']);
++ assert.equal((await a.showInstance('ep-1')).workers.min, 0);
++ assert.equal(await a.destroyInstance('ep-1'), true);
++ }
++
++ // ── the reserved server: hourly = monthly ÷ 730 and nothing is rented ──
++ {
++ let called = 0;
++ const a = reservedAdapter({ endpoint: 'https://box.example/v1', monthlyNano: '730000000000', name: 'hetzner-gex44' });
++ assert.equal((await a.searchOffers({}))[0].price_per_hour, '1');
++ const created = await a.createInstance({ id: 'reserved:hetzner-gex44' }, spec, {});
++ assert.equal(created.endpoint, 'https://box.example/v1');
++ assert.equal(created.state, 'running');
++ assert.equal((await a.showInstance(created.remote_id)).state, 'running');
++ assert.equal(await a.destroyInstance(created.remote_id), true);
++ assert.equal(called, 0);
++ }
++
++ // ── the registry builds nothing without a key and needs the reserved box's own settings ──
++ {
++ assert.equal(createAdapter('salad', { key: null, options: {} }), null);
++ assert.equal(createAdapter('reserved', { options: {} }), null);
++ assert.ok(createAdapter('reserved', { options: { endpoint: 'https://box.example/v1', monthly_nano: '1' } }));
++ assert.ok(createAdapter('runpod_serverless', { key: 'k', options: { template_id: 'tpl' } }));
++ }
++
++ console.log('GPU provider adapters: Vast bids/outbid, Salad groups and queue, TensorDock spot, Shadeform on-demand, Verda spot, Thunder per-minute, RunPod Serverless scale-to-zero, reserved monthly: passed');
++})().catch((err) => { console.error(err); process.exit(1); });