openvibers/openvibe.ai Commits
Add the GPU marketplace adapters and rent through them (#490)
Changes
nnew file mode 100644@@@ -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 };@@@ -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 });@@@ -7,6 +7,9 @@ const PROVIDERS = Object.freeze(['vast', 'runpod', 'salad', 'tensordock', 'shadeconst 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;@@@ -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,nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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,};nnew file mode 100644@@@ -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 };@@@ -69,11 +69,12 @@ t.test('a launch records its class and checkpoint, and a batch cap refuses beforconst 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(); }});nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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 };
@@ -5,10 +5,10 @@
@@ -5,10 +5,10 @@
*/
*/
const { AiError } = require('../util');
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 (body != null) headers['Content-Type'] = 'application/json';
- if (key) headers.Authorization = 'Bearer ' + key;
+ if (key) headers[keyHeader] = keyPrefix + key;
let res;
let res;
try {
try {
res = await fetchImpl(url, { method, headers, redirect: 'error', signal: AbortSignal.timeout(15000), body: body != null ? JSON.stringify(body) : undefined });
res = await fetchImpl(url, { method, headers, redirect: 'error', signal: AbortSignal.timeout(15000), body: body != null ? JSON.stringify(body) : undefined });
@@ -7,6 +7,9 @@ const PROVIDERS = Object.freeze(['vast', 'runpod', 'salad', 'tensordock', 'shade
@@ -7,6 +7,9 @@ const PROVIDERS = Object.freeze(['vast', 'runpod', 'salad', 'tensordock', 'shade
const DEFAULTS = Object.freeze({
const DEFAULTS = Object.freeze({
enabled: false, order: 50, classes: ['interactive', 'batch'], interruptible_allowed: false,
enabled: false, order: 50, classes: ['interactive', 'batch'], interruptible_allowed: false,
max_hourly_nano: null, max_replicas: 1, regions: [], monthly_cap_share_percent: 100,
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 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']);
@@ -21,7 +24,8 @@ function defaultsFor(provider) {
@@ -21,7 +24,8 @@ function defaultsFor(provider) {
* any provider setting makes its stored `enabled` the one that counts.
* any provider setting makes its stored `enabled` the one that counts.
*/
*/
function effectiveProvider(provider, saved, hasKey) {
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);
if (!saved) out.enabled = Boolean(hasKey);
return out;
return out;
}
}
@@ -57,6 +61,15 @@ function providerInput(body, current, provider) {
@@ -57,6 +61,15 @@ function providerInput(body, current, provider) {
} else if (k === 'regions') {
} 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');
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()))];
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;
if (provider === 'salad' && !Object.hasOwn(body, 'max_replicas') && current === DEFAULTS) out.max_replicas = 3;
@@ -23,6 +23,7 @@ const { STRATEGIES } = require('openvibe-sdk/compute');
@@ -23,6 +23,7 @@ const { STRATEGIES } = require('openvibe-sdk/compute');
const { AiError } = require('../util');
const { AiError } = require('../util');
const { classOf, checkpointOf, pickOffer } = require('./classes');
const { classOf, checkpointOf, pickOffer } = require('./classes');
const { createGpuPolicy } = require('./policy');
const { createGpuPolicy } = require('./policy');
+const { createAdapter, KEYED } = require('./adapters');
const money = require('./money');
const money = require('./money');
const catalog = require('./catalog');
const catalog = require('./catalog');
const { vastAdapter } = require('./vast');
const { vastAdapter } = require('./vast');
@@ -93,8 +94,10 @@ function createGpuProvisioner({
@@ -93,8 +94,10 @@ function createGpuProvisioner({
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).
// 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;
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 ──────────────────────────────────────────────
// ── the owner's settings ──────────────────────────────────────────────
@@ -116,6 +119,8 @@ function createGpuProvisioner({
@@ -116,6 +119,8 @@ function createGpuProvisioner({
};
};
// The newer provider keys live together in the sealed config namespace (server/settings.js).
// 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] || '');
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]));
const anyKey = PROVIDER_KEYS.some((name) => Boolean(keys[name]));
let reason = '';
let reason = '';
if (!anyKey) reason = 'no GPU provider key (marketplace API key)';
if (!anyKey) reason = 'no GPU provider key (marketplace API key)';
@@ -138,20 +143,23 @@ function createGpuProvisioner({
@@ -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. */
/** The owner's provider policy by provider key; an empty map means no policy is configured. */
async function providerPolicies() {
async function providerPolicies() {
if (!gpuPolicy) return new Map();
if (!gpuPolicy) return new Map();
const read = await gpuPolicy.read();
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. */
/** Live instances of one marketplace, for the owner's replica ceiling. */
@@ -263,7 +271,13 @@ function createGpuProvisioner({
@@ -263,7 +271,13 @@ function createGpuProvisioner({
const job_class = classOf(jobClass);
const job_class = classOf(jobClass);
const ckpt = checkpointOf(checkpoint);
const ckpt = checkpointOf(checkpoint);
const policies = await providerPolicies();
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 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).
@@ -291,9 +305,12 @@ function createGpuProvisioner({
@@ -291,9 +305,12 @@ function createGpuProvisioner({
continue;
continue;
}
}
// A quote never needs a key: quote-only mode is how the owner sees prices before adding one.
// 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;
if (!adapter) continue;
// Interruptible capacity is asked for only when the job may use it: batch, a checkpoint, and the
// 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.
// owner's permission for this provider. The adapter may still return only on-demand offers.
@@ -658,7 +675,7 @@ function createGpuProvisioner({
@@ -658,7 +675,7 @@ function createGpuProvisioner({
async function sweepOrphans(cfg) {
async function sweepOrphans(cfg) {
const t = now();
const t = now();
let seen = 0;
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);
const adapter = adapterFor(marketplace, cfg);
if (!adapter) continue;
if (!adapter) continue;
let instances = [];
let instances = [];
@@ -791,7 +808,7 @@ function createGpuProvisioner({
@@ -791,7 +808,7 @@ function createGpuProvisioner({
const state = String((info && info.state) || 'unknown').toLowerCase();
const state = String((info && info.state) || 'unknown').toLowerCase();
if (DEAD_STATES.includes(state)) {
if (DEAD_STATES.includes(state)) {
await destroy({ ...row, charged_at: row.charged_at }, 'the instance ' + 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 };
return { changed: true, action: 'died', reason: 'the instance ' + state };
}
}
const endpoint = info && info.endpoint ? String(info.endpoint) : '';
const endpoint = info && info.endpoint ? String(info.endpoint) : '';
@@ -802,7 +819,7 @@ function createGpuProvisioner({
@@ -802,7 +819,7 @@ function createGpuProvisioner({
.run(failures, iso(t), iso(t), row.id);
.run(failures, iso(t), iso(t), row.id);
if (failures >= HEALTH_FAILURES) {
if (failures >= HEALTH_FAILURES) {
await destroy({ ...row, charged_at: row.charged_at }, 'the endpoint never answered');
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: true, action: 'died', reason: 'the endpoint never answered' };
}
}
return { changed: false, health_failures: failures };
return { changed: false, health_failures: failures };
@@ -837,7 +854,7 @@ function createGpuProvisioner({
@@ -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
// 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).
// endpoint stops (they can start it again when they want it).
await destroy({ ...row, charged_at: row.charged_at }, 'the endpoint stopped answering');
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: true, action: 'died', reason: 'the endpoint stopped answering' };
}
}
return { changed: false };
return { changed: false };
@@ -900,6 +917,8 @@ function createGpuProvisioner({
@@ -900,6 +917,8 @@ function createGpuProvisioner({
/** One pass: the owner's requests, the live instances, and the models that are wanted. */
/** One pass: the owner's requests, the live instances, and the models that are wanted. */
async function tick({ actor = 'service:ai' } = {}) {
async function tick({ actor = 'service:ai' } = {}) {
const cfg = config();
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.
// Recovery and orphan cleanup also run while new rental spending is disabled.
await sweepOrphans(cfg);
await sweepOrphans(cfg);
if (!cfg.enabled) {
if (!cfg.enabled) {
@@ -986,7 +1005,8 @@ function createGpuProvisioner({
@@ -986,7 +1005,8 @@ function createGpuProvisioner({
instance: describe(live),
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 {
return {
strategy: cfg.strategy,
strategy: cfg.strategy,
dry_run: cfg.dry_run,
dry_run: cfg.dry_run,
@@ -1001,7 +1021,9 @@ function createGpuProvisioner({
@@ -1001,7 +1021,9 @@ 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: 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 ? {
class_caps: policyState ? {
interactive_cap_nano: policyState.global.interactive_cap_nano,
interactive_cap_nano: policyState.global.interactive_cap_nano,
batch_cap_nano: policyState.global.batch_cap_nano,
batch_cap_nano: policyState.global.batch_cap_nano,
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 };
@@ -36,7 +36,9 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
@@ -36,7 +36,9 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
async searchOffers(req) {
async searchOffers(req) {
const body = {
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 },
verified: { eq: true },
rentable: { eq: true },
rentable: { eq: true },
gpu_name: { in: req.gpuNames },
gpu_name: { in: req.gpuNames },
@@ -57,9 +59,12 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
@@ -57,9 +59,12 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
gpu_ram_mb: Number(o.gpu_ram) || 0,
gpu_ram_mb: Number(o.gpu_ram) || 0,
num_gpus: Number(o.num_gpus) || 1,
num_gpus: Number(o.num_gpus) || 1,
disk_gb: Number(o.disk_space) || 0,
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,
reliability: Number(o.reliability) || 0,
region: String(o.geolocation || ''),
region: String(o.geolocation || ''),
+ interruptible: Boolean(req.interruptible),
}));
}));
},
},
@@ -105,6 +110,12 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
@@ -105,6 +110,12 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
// when no live row of ours owns it.
// when no live row of ours owns it.
label: String(opts.label || ('openvibe-' + spec.key)),
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' });
const out = await apiJson(fetchImpl, baseUrl + '/api/v0/asks/' + offer.id + '/', { method: 'PUT', body, ...auth, label: 'Vast.ai' });
if (!out || out.success === false) {
if (!out || out.success === false) {
const err = new Error('Vast.ai did not rent the offer');
const err = new Error('Vast.ai did not rent the offer');
@@ -125,9 +136,13 @@ function vastAdapter({ fetchImpl = globalThis.fetch, key = null, baseUrl = BASE_
@@ -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: '' };
if (out && out.missing) return { remote_id: remoteId, state: 'deleted', deleted: true, endpoint: '' };
const instance = out && out.instances ? out.instances : out;
const instance = out && out.instances ? out.instances : out;
const status = String((instance && (instance.actual_status || instance.status)) || 'unknown');
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 {
return {
remote_id: remoteId,
remote_id: remoteId,
- state: status,
+ state: outbid ? 'preempted' : status,
+ outbid,
endpoint: endpointOf(instance, (opts_port(instance))),
endpoint: endpointOf(instance, (opts_port(instance))),
raw: out,
raw: out,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 };
@@ -69,11 +69,12 @@ t.test('a launch records its class and checkpoint, and a batch cap refuses befor
@@ -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);
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.job_class, 'batch');
assert.strictEqual(row.checkpoint, 's3://bucket/job-1/ckpt');
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');
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();
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(JSON.parse(audit.metadata).job_class, 'batch');
assert.strictEqual(api.calls.create.length, 1);
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(); }
} finally { await h.stop(); }
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,229 @@
@@ -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); });