openvibers/openvibe.sdk Commits
0.100.0: openvibe-sdk/compute (strategies, placement, scaling, billing arithmetic, provider adapters) (#138)
Changes
@@@ -1,5 +1,9 @@# Changelog++## 0.100.0 (2026-11-08)++++- Add `openvibe-sdk/compute`: shared compute strategies, hard placement constraints, RAM-aware pool scaling, exact billing arithmetic and provider quote/lifecycle adapters. See `docs/compute.md` for gates and unsupported purchase paths.++## 0.99.0 (2026-11-08)- **`openvibe-sdk/events`: `outboxSchema()` indexes the rejected rows `rejected()` counts.** Every outbox made with the@@@ -2,7 +2,7 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.99.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.100.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.@@@ -11,7 +11,7 @@After v0.97.1 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.99.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.100.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.99.0",++ "version": "0.100.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.99.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.100.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.99.0';++const SDK_VERSION = '0.100.0';module.exports = {createClient,@@@ -527,6 +527,15 @@"actingHeaders": "function","createCommunityClient": "function"},++ "openvibe-sdk/compute": {++ "STRATEGIES": "array",++ "TIERS": "array",++ "charge": "function",++ "choose": "function",++ "createProviders": "function",++ "micros": "function",++ "scale": "function"++ },"openvibe-sdk/consent": {"DEFAULT_TIMEOUT_MS": "number","DEFAULT_TTL_MS": "number",nnew file mode 100644@@@ -0,0 +1,41 @@++# Compute placement and provider adapters++++`require('openvibe-sdk/compute')` exports `choose`, `scale`, `micros`, `charge`, and++`createProviders`. Run owns inventory, authorization, pool state, reservation transactions and++reconciliation. This module does no background work and cannot authorize spending.++++`choose(request, machines, pool, {now,current})` applies ownership, trust, health, resource,++region, price and egress constraints before calling the existing `placement.plan()`.++Strategies: `cheapest`, `fastest`, `private`, `own`, `automatic`. Automatic walks own,++home, dedicated, community, partner, skipping tiers with unavailable capacity, excessive++queue wait, unhealthy machines or costs above the ceiling. `current` provides price++hysteresis; a hard failure switches immediately. Decisions include price, tier and reasons.++Only trusted operator inventory may assign a tier; a public offer cannot claim to be private.++Currency must match; this module never guesses exchange rates. Home public egress requires++`ovh-tunnel`. Own machines must match the authenticated requester.++++`scale(pool,machines,waiting,{now})` returns provision/drain/release actions. Capacity uses++the larger of measured peak RAM and 300 MB/browser or 2048 MB/desktop, plus host-reported++free slots. The inventory must subtract OS RAM. A draining machine cannot accept placements;++release waits for authoritative zero sessions. Missing observations are not zero sessions.++++Providers implement `quote`, `provision`, `health`, `release`, with normalized hourly price,++currency, RAM/GPU, billing quantum and quote expiry. All network calls use a deadline, reject++redirects, and keep credentials out of URLs and errors. Provision requires a key, but the caller++MUST also reserve the entire rental against an owner-set cap before calling it.++++Sources (checked 2026-11-08):++- [Hetzner Cloud](https://docs.hetzner.cloud/reference/cloud): authenticated server types and server lifecycle.++- [Hetzner Robot](https://robot.hetzner.com/doc/webservice/en.html): auction purchases have separate cancellation terms.++- [OVH schema](https://api.ovh.com/1.0/cloud.json): public pricing plus authenticated project flavors and instance lifecycle.++- [Vast API](https://vast.ai/developers/api): public offers, PUT to rent, instance lifecycle.++- [RunPod schema](https://raw.githubusercontent.com/runpod/docs/main/api-reference/openapi.json): REST v1 pods; GraphQL GPU catalogue.++++No-key reads: Hetzner auction, Vast offers, RunPod GPU prices, community offers, subject to++provider rate limits/access policy. Cloud requires its token; OVH needs project credentials++to associate catalogue prices with usable flavors. An unavailable API is not a free quote.++Auction purchase is deliberately disabled until setup fees and cancellation liability can be++reserved. Community inference offers are token-priced and cannot be treated as hourly machine++rentals; use AI's existing router or lease an existing desk through Bot. Neither adapter++pretends to support machine purchases. Quotes exclude variable disk/egress charges; callers++must reject provisioning unless those costs are bounded separately. GPU runtimes belong to AI.nnew file mode 100644@@@ -0,0 +1,8 @@++// Generated by scripts/esm.js from src/compute/index.js. Do not edit.++import cjs from '../src/compute/index.js';++++export const {++ choose, scale, micros, charge, STRATEGIES, TIERS,++ createProviders,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.99.0",++ "version": "0.100.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.99.0",++ "version": "0.100.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.99.0",++ "version": "0.100.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -510,6 +510,12 @@"browser": null,"import": "./esm/reports.mjs","default": "./src/reports.js"++ },++ "./compute": {++ "types": "./types/compute.d.ts",++ "browser": null,++ "import": "./esm/compute.mjs",++ "default": "./src/compute/index.js"}},"bin": {@@@ -12,6 +12,7 @@ const path = require('path');const ROOT = path.join(__dirname, '..');const ENTRIES = {++ 'compute.mjs': '../src/compute/index.js','share.mjs': '../src/share.js','locker.mjs': '../src/locker.js','locker-server.mjs': '../src/locker/server.js',nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++const { plan } = require('../placement');++const STRATEGIES = ['automatic', 'cheapest', 'fastest', 'private', 'own'];++const TIERS = ['own', 'home', 'dedicated', 'community', 'partner'];++const finite = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;++++/** Pure policy over trusted inventory, not over client-supplied machine claims. Money is per hour. */++function choose(request, machines, pool = {}, { now = Date.now(), current = null } = {}) {++ const strategy = request.strategy || pool.strategy || 'automatic';++ if (!STRATEGIES.includes(strategy)) throw new TypeError('unknown compute strategy');++ const ram = Math.max(request.kind === 'desktop' ? 2048 : 300, Number(request.ram_mb) || 0);++ const ceiling = Math.min(...[request.max_hourly, pool.max_hourly].filter(finite), Infinity);++ const rejected = [];++ const eligible = machines.filter((m) => {++ let why;++ if (!TIERS.includes(m.tier)) why = 'unknown tier';++ else if (m.tier === 'own' && (!request.owner || m.owner !== request.owner)) why = 'not your machine';++ else if (strategy === 'own' && m.tier !== 'own') why = 'own machines only';++ else if (strategy === 'private' && !['own', 'home', 'dedicated'].includes(m.tier)) why = 'OpenVibe or own machines only';++ else if (request.machine && m.id !== request.machine) why = 'another machine requested';++ else if (m.health !== 'up' || m.draining) why = 'unhealthy or draining';++ else if (request.region && m.region !== request.region) why = 'outside required region';++ else if (request.gpu_mb && !(m.gpu_mb >= request.gpu_mb)) why = 'insufficient GPU memory';++ else if (!(m.free_ram_mb >= ram) || !(m.free_slots > 0)) why = 'no capacity';++ else if (m.tier === 'home' && request.public_egress && m.egress !== 'ovh-tunnel') why = 'home egress is not protected';++ else if (!finite(m.hourly)) why = 'unknown hourly cost';++ else if (m.currency !== (pool.currency || 'USD')) why = 'different currency';++ else if (m.hourly > ceiling) why = 'over hourly ceiling';++ else if (strategy === 'automatic' && (m.queue_ms || 0) > (pool.target_wait_ms ?? 30000)) why = 'queue exceeds target';++ if (why) { rejected.push({ id: m.id, reason: why }); return false; }++ return true;++ });++ let choices = eligible;++ if (strategy === 'automatic' && choices.length) {++ const tier = TIERS.find((t) => choices.some((m) => m.tier === t));++ choices = choices.filter((m) => m.tier === tier);++ }++ // Cost/latency remain the shared placement planner's responsibility. Best-fit RAM and a session ceiling++ // provide deterministic packing within equal-price/latency choices without changing the financial score.++ choices = choices.slice().sort((a, b) => (a.free_ram_mb - ram) - (b.free_ram_mb - ram) || (a.sessions || 0) - (b.sessions || 0) || a.id.localeCompare(b.id));++ const offers = choices.map((m) => ({ offer_id: m.id, trust: 'first-party', health: { status: 'up' },++ pricing: { marginal_usd_per_unit: m.hourly }, latency_ms: finite(m.latency_ms) ? { p95: m.latency_ms } : {},++ }));++ const p = plan({ objective: strategy === 'fastest' ? 'lowest-latency' : 'cheapest' }, offers, { now, current, minGain: pool.min_gain ?? 0.15 });++ const chosen = choices.find((m) => m.id === p.selected) || null;++ return { strategy, selected: chosen && chosen.id, tier: chosen && chosen.tier, hourly: chosen && chosen.hourly,++ currency: pool.currency || 'USD', reasons: [...p.reasons, ...(strategy === 'automatic' && chosen ? [`first eligible tier: ${chosen.tier}`] : [])],++ rejected, switched: Boolean(current && current !== p.selected), decided_at: new Date(now).toISOString() };++}++++/** Build a new desired state; never mutate input or call a provider. Observations must be authoritative. */++function scale(pool, machines, waiting, { now = Date.now() } = {}) {++ const ram = Math.max(pool.kind === 'desktop' ? 2048 : 300, pool.measured_peak_ram_mb || 0);++ const target = pool.target_wait_ms ?? 30000;++ const warm = Math.max(0, pool.min_warm || 0);++ const grace = pool.idle_grace_ms ?? 600000;++ const max = Math.max(warm, pool.max_machines ?? 10);++ const live = machines.filter((m) => m.state !== 'released');++ const ready = live.filter((m) => m.health === 'up' && !m.draining);++ const slots = (m) => Math.max(0, Math.min(m.free_slots || 0, Math.floor((m.free_ram_mb || 0) / ram)));++ const due = waiting.filter((s) => now - s.created_at >= target);++ const capacity = ready.reduce((n, m) => n + slots(m), 0);++ const pending = live.filter((m) => m.state === 'provisioning').length;++ const need = Math.max(warm - ready.length - pending, Math.ceil(Math.max(0, due.length - capacity) / Math.max(1, pool.slots_per_machine || 1)) - pending);++ const actions = [];++ for (let n = 0; n < Math.min(need, max - live.length); n++) actions.push({ action: 'provision', reason: due.length > capacity ? 'queue wait exceeded target' : 'minimum warm pool' });++ let remaining = ready.length;++ for (const m of live.slice().sort((a, b) => (b.hourly || 0) - (a.hourly || 0))) {++ if (m.draining && m.sessions === 0) actions.push({ action: 'release', id: m.id, reason: 'drain completed' });++ else if (!m.draining && m.managed && m.sessions === 0 && finite(m.idle_since) && now - m.idle_since >= grace && remaining > warm && !waiting.length) {++ actions.push({ action: 'drain', id: m.id, reason: 'idle grace elapsed' }); remaining--;++ }++ }++ return { actions, ram_per_session_mb: ram, waiting: waiting.length, overdue: due.length, free_slots: capacity };++}++++/** Exact decimal money, in millionths of the quote currency; reject negatives, NaN and excess precision. */++function micros(value) {++ const m = /^(\d+)(?:\.(\d{1,6}))?$/.exec(String(value));++ if (!m) throw new TypeError('money must be a nonnegative decimal with at most six places');++ return BigInt(m[1]) * 1000000n + BigInt((m[2] || '').padEnd(6, '0'));++}++function charge(hourlyMicros, milliseconds, quantumSeconds = 1) {++ if (!finite(milliseconds) || !finite(quantumSeconds) || !quantumSeconds) throw new TypeError('invalid billing duration');++ const seconds = BigInt(Math.ceil(milliseconds / (quantumSeconds * 1000)) * quantumSeconds);++ return (BigInt(hourlyMicros) * seconds + 3599n) / 3600n;++}++module.exports = { choose, scale, micros, charge, STRATEGIES, TIERS, ...require('./providers') };nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++const { createHash } = require('node:crypto');++const enc = encodeURIComponent;++++/** Provider errors intentionally exclude response bodies (which can contain credentials/user-data). */++async function json(fetch, url, { method = 'GET', headers = {}, body } = {}) {++ const r = await fetch(url, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },++ ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: AbortSignal.timeout(15000) });++ if (r.status === 404) return null;++ if (!r.ok) throw new Error(`provider HTTP ${r.status}`);++ return r.status === 204 ? {} : r.json();++}++const positive = (v) => Number.isFinite(Number(v)) && Number(v) > 0;++const quote = (provider, id, region, hourly, currency, ram_mb, extra = {}) => ({ provider, id: String(id), region,++ hourly: positive(hourly) ? Number(hourly) : null, currency, ram_mb: Number(ram_mb) || 0, gpu_mb: 0,++ setup: 0, billing_seconds: 3600, tier: 'partner', ...extra });++function createProviders({ fetch: fetchImpl = globalThis.fetch, config = {}, now = Date.now } = {}) {++ const call = (url, opts) => json(fetchImpl, url, opts);++ const c = (name) => config[name] || {};++ function keyed(name) { if (!c(name).key) throw new Error(`${name}: key required`); }++ const bearer = (name) => c(name).key ? { Authorization: `Bearer ${c(name).key}` } : {};++ const hc = 'https://api.hetzner.cloud/v1';++ const ovh = 'https://eu.api.ovh.com/1.0';++ async function ovhCall(path, method = 'GET', body) {++ const v = c('ovh');++ if (!v.key || !v.application_secret || !v.consumer_key) throw new Error('ovh: application and consumer credentials required');++ const url = ovh + path;++ const timestamp = String(await call(ovh + '/auth/time'));++ const signature = '$1$' + createHash('sha1').update([v.application_secret, v.consumer_key, method, url, body ? JSON.stringify(body) : '', timestamp].join('+')).digest('hex');++ return call(url, { method, body, headers: { 'X-Ovh-Application': v.key, 'X-Ovh-Consumer': v.consumer_key, 'X-Ovh-Timestamp': timestamp, 'X-Ovh-Signature': signature } });++ }++ const providers = {++ hetzner: {++ async quote() {++ // Cloud catalogue requires an API token; no scraped price is substituted.++ keyed('hetzner');++ const r = await call(hc + '/server_types?per_page=50', { headers: bearer('hetzner') });++ return (r.server_types || []).flatMap((s) => (s.prices || []).map((p) => quote('hetzner', s.name, p.location, p.price_hourly.gross, 'EUR', s.memory * 1024,++ { tier: 'dedicated', cpu: s.cores, source: hc + '/server_types', included_traffic: p.included_traffic, egress_hourly: null })));++ },++ async provision(q, spec) {++ keyed('hetzner');++ if (!spec.image) throw new Error('hetzner: prepared Link image required');++ const r = await call(hc + '/servers', { method: 'POST', headers: bearer('hetzner'), body: { name: spec.label, server_type: q.id, location: q.region,++ image: spec.image, start_after_create: true, public_net: { enable_ipv4: false, enable_ipv6: true }, labels: { 'openvibe-broker': spec.label } } });++ return { id: String(r.server.id) };++ },++ async health(id) { const r = await call(hc + '/servers/' + enc(id), { headers: bearer('hetzner') }); return r ? { state: r.server.status, deleted: false } : { deleted: true }; },++ async release(id) { keyed('hetzner'); await call(hc + '/servers/' + enc(id), { method: 'DELETE', headers: bearer('hetzner') }); },++ },++ 'hetzner-auction': {++ async quote() {++ const url = 'https://www.hetzner.com/_resources/app/data/app/live_data_sb.json';++ const r = await call(url);++ return (r.server || []).map((s) => quote('hetzner-auction', s.Id, s.Details?.Datacenter?.Name, s.Prices?.hourly?.EUR, 'EUR', (s.Hardware?.RAM?.Size || 0) * 1024,++ { tier: 'dedicated', monthly: s.Prices?.monthly?.EUR, setup: s.Prices?.setup?.EUR ?? null, source: url, provisionable: false, reason: 'dedicated purchase requires reviewed cancellation and setup terms' }));++ },++ async provision() { throw new Error('auction: quote only; dedicated purchase and cancellation terms require review'); },++ async health() { return { state: 'quote-only' }; },++ async release() { throw new Error('auction: no broker-owned purchase'); },++ },++ ovh: {++ async quote() {++ const url = ovh + '/order/catalog/public/cloud?ovhSubsidiary=IE';++ const catalog = await call(url);++ const plans = (catalog.addons || []).filter(p => p.product === 'publiccloud-instance');++ const priced = (p) => p.pricings?.find(v => v.interval === 1 && v.intervalUnit === 'hour' && v.mode === 'default' && v.type === 'consumption');++ const cost = (v) => (v.price + (v.tax || 0)) / 1e8;++ // Public prices can be compared without credentials; a project flavor is needed to rent.++ if (!c('ovh').project || !c('ovh').key) return plans.flatMap(p => {++ const v = priced(p); if (!v) return [];++ return [quote('ovh', p.planCode, 'catalogue', cost(v), catalog.locale.currencyCode, (p.blobs?.technical?.memory?.size || 0) * 1024,++ { tier: 'dedicated', source: url, provisionable: false, reason: 'project credentials required to resolve region and availability' })];++ });++ const flavors = await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/flavor');++ return flavors.flatMap((f) => {++ const plan = plans.find(p => p.planCode === f.planCodes?.hourly);++ const price = plan && priced(plan);++ if (!price || !f.available) return [];++ return [quote('ovh', f.id, f.region, cost(price), catalog.locale.currencyCode, f.ram * 1024,++ { tier: 'dedicated', cpu: f.vcpus, source: url, billing_seconds: 3600 })];++ });++ },++ async provision(q, spec) {++ keyed('ovh'); if (!spec.image) throw new Error('ovh: prepared Link image required');++ const r = await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/instance', 'POST', { name: spec.label, flavorId: q.id, region: q.region, imageId: spec.image, monthlyBilling: false });++ return { id: r.id };++ },++ async health(id) { const r = await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/instance/' + enc(id)); return r ? { state: r.status, deleted: false } : { deleted: true }; },++ async release(id) { await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/instance/' + enc(id), 'DELETE'); },++ },++ vast: {++ async quote(req = {}) {++ const url = 'https://console.vast.ai/api/v0/bundles/';++ const r = await call(url, { method: 'POST', headers: bearer('vast'), body: { type: 'ondemand', rentable: { eq: true }, verified: { eq: true }, gpu_ram: { gte: req.gpu_mb || 1 }, limit: 100 } });++ return (r.offers || []).map((s) => quote('vast', s.id, s.geolocation, s.dph_total, 'USD', s.cpu_ram,++ { gpu_mb: s.gpu_ram * s.num_gpus, gpu: s.gpu_name, billing_seconds: 1, source: url, tier: 'partner', disk_hourly: s.storage_cost, egress_hourly: null }));++ },++ async provision(q, spec) {++ keyed('vast'); if (!spec.image) throw new Error('vast: image required');++ const r = await call('https://console.vast.ai/api/v0/asks/' + enc(q.id) + '/', { method: 'PUT', headers: bearer('vast'), body: { image: spec.image, label: spec.label, disk: spec.disk_gb || 20, runtype: 'args', args: spec.command || '' } });++ if (!r?.new_contract) throw new Error('vast: create outcome unknown');++ return { id: String(r.new_contract) };++ },++ async health(id) { const r = await call('https://console.vast.ai/api/v0/instances/' + enc(id) + '/', { headers: bearer('vast') }); return r ? { state: (r.instances || r).actual_status, deleted: false } : { deleted: true }; },++ async release(id) { keyed('vast'); await call('https://console.vast.ai/api/v0/instances/' + enc(id) + '/', { method: 'DELETE', headers: bearer('vast') }); },++ },++ runpod: {++ async quote() {++ const url = 'https://api.runpod.io/graphql';++ const r = await call(url, { method: 'POST', body: { query: '{ gpuTypes { id displayName memoryInGb securePrice communityPrice } }' } });++ if (r.errors) throw new Error('runpod: price catalogue unavailable');++ return (r.data?.gpuTypes || []).map((s) => quote('runpod', s.id, 'any', s.securePrice, 'USD', 0,++ { gpu_mb: s.memoryInGb * 1024, gpu: s.displayName, billing_seconds: 1, source: url, egress_hourly: null }));++ },++ async provision(q, spec) {++ keyed('runpod'); if (!spec.image) throw new Error('runpod: image required');++ const r = await call('https://rest.runpod.io/v1/pods', { method: 'POST', headers: bearer('runpod'), body: { name: spec.label, imageName: spec.image, gpuTypeIds: [q.id], gpuCount: 1,++ cloudType: 'SECURE', containerDiskInGb: spec.disk_gb || 20, volumeInGb: 0, ...(spec.command ? { dockerStartCmd: spec.command } : {}) } });++ if (!r?.id) throw new Error('runpod: create outcome unknown');++ return { id: r.id };++ },++ async health(id) { const r = await call('https://rest.runpod.io/v1/pods/' + enc(id), { headers: bearer('runpod') }); return r ? { state: r.desiredStatus, deleted: false } : { deleted: true }; },++ async release(id) { keyed('runpod'); await call('https://rest.runpod.io/v1/pods/' + enc(id), { method: 'DELETE', headers: bearer('runpod') }); },++ },++ community: {++ async quote() {++ const url = 'https://ai.openvibe.services/api/v1/community/offers';++ const r = await call(url);++ if (!r) throw new Error('community: offers endpoint unavailable');++ return (r.offers || []).map((s) => quote('community', s.id || s.offer_id, s.region || 'unknown', s.price_per_hour, 'USD', s.ram_mb,++ { tier: 'community', gpu_mb: s.gpu_ram_mb || 0, source: url, provisionable: false, reason: 'inference offers are token-priced; not a machine rental' }));++ },++ async provision() { throw new Error('community: use the existing AI offer router or Bot desk lease'); },++ async health() { return { state: 'offer' }; },++ async release() { throw new Error('community: no broker-owned machine'); },++ },++ };++ // Quotes have short leases. Provisioners must re-quote before taking a reservation.++ for (const [name, p] of Object.entries(providers)) {++ const read = p.quote;++ p.quote = async (...args) => (await read(...args)).map((q) => ({ ...q, quoted_at: now(), expires_at: now() + 60000 }));++ p.id = name;++ p.configured = Boolean(c(name).key);++ }++ return providers;++}++module.exports = { createProviders };@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.99.0';++const SDK_VERSION = '0.100.0';module.exports = {createClient,@@@ -76,7 +76,7 @@ run([for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);}assert.deepEqual(problems, []);-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './menu', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './compute', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './menu', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windowsfor (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/cam/index.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {assert.ok(rel.includes(must), `${must} is scanned`);nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++const assert=require('node:assert/strict');++const {createProviders}=require('../src/compute');++(async()=>{++ const calls=[];++ const fetch=async(url,opts)=>{++ calls.push({url,...opts});++ let body={};++ if(url.includes('server_types')) body={server_types:[{name:'cx',memory:4,cores:2,prices:[{location:'fsn1',price_hourly:{gross:'0.01'}}]}]};++ else if(url.endsWith('/servers') && opts.method==='POST') body={server:{id:42}};++ else if(url.includes('/servers/')) body={server:{status:'running'}};++ else if(url.includes('live_data')) body={server:[{Id:1,Prices:{hourly:{EUR:0.06},monthly:{EUR:40},setup:{EUR:0}},Hardware:{RAM:{Size:64}},Details:{Datacenter:{Name:'FSN1'}}}]};++ else if(url.includes('bundles')) body={offers:[{id:2,dph_total:0.2,cpu_ram:32000,gpu_ram:24000,num_gpus:1,geolocation:'US'}]};++ else if(url.includes('/asks/')) body={new_contract:9};++ else if(url.includes('/instances/')) body={instances:{actual_status:'running'}};++ else if(url.includes('graphql')) body={data:{gpuTypes:[{id:'a',displayName:'GPU',memoryInGb:24,securePrice:0.3}]}};++ else if(url.endsWith('/pods') && opts.method==='POST') body={id:'p'};++ else if(url.includes('/pods/')) body={desiredStatus:'RUNNING'};++ else if(url.includes('community/offers')) body={offers:[{id:'c',region:'US'}]};++ else if(url.endsWith('/auth/time')) body=100;++ else if(url.includes('/order/catalog')) body={locale:{currencyCode:'USD'},addons:[{planCode:'b-hour',product:'publiccloud-instance',pricings:[{interval:1,intervalUnit:'hour',type:'consumption',mode:'default',price:2000000}]}]};++ else if(url.endsWith('/flavor')) body=[{id:'b',available:true,ram:4,region:'GRA',planCodes:{hourly:'b-hour'}}];++ else if(url.endsWith('/instance')) body={id:'i'};++ else if(url.includes('/instance/')) body={status:'ACTIVE'};++ return {ok:true,status:200,json:async()=>body};++ };++ const config=Object.fromEntries(['hetzner','vast','runpod','ovh'].map(n=>[n,{key:'stub',application_secret:'stub',consumer_key:'stub',project:'project'}]));++ const ps=createProviders({fetch,config,now:()=>100});++ for(const name of ['hetzner','ovh','vast','runpod']){++ const q=(await ps[name].quote())[0]; assert.ok(q.hourly>0,name); assert.equal(q.expires_at,60100);++ const r=await ps[name].provision(q,{label:'test',image:'prepared'}); assert.ok(r.id);++ assert.ok((await ps[name].health(r.id)).state); await ps[name].release(r.id);++ }++ assert.equal((await ps['hetzner-auction'].quote())[0].provisionable,false);++ assert.equal((await ps.community.quote())[0].hourly,null);++ assert.equal(calls.find(c=>c.url.includes('/asks/')).method,'PUT');++ assert.ok(calls.find(c=>c.url==='https://rest.runpod.io/v1/pods'));++ assert.ok(calls.filter(c=>c.url.includes('/cloud/project/')).every(c=>c.headers['X-Ovh-Signature'].startsWith('$1$')));++ const noKey=createProviders({fetch});++ for(const name of ['hetzner','ovh','vast','runpod']) await assert.rejects(noKey[name].provision({id:'1'},{image:'x'}),/key|credentials/);++ await assert.rejects(ps.community.provision(),/router/);++ await assert.rejects(ps['hetzner-auction'].provision(),/quote only/);++ console.log('compute provider API stubs and key gates: passed');++})().catch(e=>{console.error(e);process.exit(1);});nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++const assert = require('node:assert/strict');++const { choose, scale, micros, charge } = require('../src/compute');++const machine = (id, tier, hourly, extra = {}) => ({ id, tier, hourly, currency: 'USD', free_ram_mb: 4096, free_slots: 4, health: 'up', region: 'us', latency_ms: 50, ...extra });++const machines = [machine('mine', 'own', 0, {owner: 'alice'}), machine('home', 'home', 0.01), machine('dedicated', 'dedicated', 0.02, {latency_ms: 10}), machine('community', 'community', 0.001), machine('burst', 'partner', 1)];++for (const [strategy, expected] of [['automatic','mine'],['cheapest','mine'],['fastest','dedicated'],['private','mine'],['own','mine']]) assert.equal(choose({strategy, owner:'alice'}, machines).selected, expected);++assert.equal(choose({strategy:'own', owner:'bob'}, machines).selected, null);++assert.equal(choose({strategy:'private', owner:'bob'}, machines).selected, 'home');++assert.equal(choose({owner:'bob', public_egress:true}, machines).selected, 'dedicated');++assert.equal(choose({owner:'bob'}, machines.map(m => m.id === 'home' ? {...m,queue_ms:31000} : m)).selected, 'dedicated');++assert.equal(choose({owner:'bob'}, machines.map(m => m.id === 'home' ? {...m,health:'down'} : m)).selected, 'dedicated');++assert.equal(choose({owner:'bob',max_hourly:0.005}, machines).selected, 'community');++assert.equal(choose({strategy:'private'}, [machine('bad','community',0)]).selected, null);++assert.equal(choose({}, [machine('unpriced','dedicated',null)]).selected, null);++assert.equal(choose({region:'eu'}, machines).selected, null);++assert.equal(choose({gpu_mb:24000}, machines).selected, null);++assert.equal(choose({}, [machine('eur','home',0,{currency:'EUR'})]).selected, null);++const a=machine('a','dedicated',1,{state:'running',managed:true,sessions:0,idle_since:0});++assert.equal(scale({min_warm:1},[],[],{now:100}).actions[0].action,'provision');++assert.equal(scale({},[a],Array.from({length:5},()=>({created_at:0})),{now:40000}).actions[0].action,'provision');++assert.deepEqual(scale({},[a],[],{now:700000}).actions,[{action:'drain',id:'a',reason:'idle grace elapsed'}]);++assert.equal(scale({},[{...a,draining:true}],[],{now:700000}).actions[0].action,'release');++assert.equal(scale({},[{...a,draining:true,sessions:1}],[],{now:700000}).actions.length,0);++assert.equal(scale({min_warm:1},[a],[],{now:700000}).actions.length,0);++assert.equal(scale({min_warm:1},[{...a,state:'provisioning',health:'starting'}],[],{now:700000}).actions.length,0);++assert.equal(choose({max_hourly:100},[machine('expensive','dedicated',10)],{max_hourly:1}).selected,null);++assert.equal(micros('1.000001'),1000001n);++assert.throws(()=>micros('NaN'));++assert.throws(()=>micros('-1'));++assert.equal(charge(micros('1'),1,3600),1000000n);++assert.equal(charge(micros('1'),3600000,1),1000000n);++console.log('compute strategies, hard constraints, scale up/down/drain and exact billing: passed');@@@ -72,7 +72,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 88); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps, 0.94.0 reports, 0.95.0 cam, 0.97.0 menu++ assert.equal(subpaths.length, 89); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps, 0.94.0 reports, 0.95.0 cam, 0.97.0 menuassert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });@@@ -24,7 +24,7 @@ const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();--const TIMEOUT_MS = 120000;++const TIMEOUT_MS = 240000; // PGlite test files may exceed two minutes on the shared worker./** `<label>: skipped (<why>)` on a line of its own (SKIP_RE from openvibe-shared/test-runner). */const SKIP_RE = /^[ \t]*[\w .,'()/+#-]{1,120}: skipped \((.+)\)[ \t]*$/gm;nnew file mode 100644@@@ -0,0 +1,15 @@++export type Strategy = 'automatic' | 'cheapest' | 'fastest' | 'private' | 'own';++export type Tier = 'own' | 'home' | 'dedicated' | 'community' | 'partner';++export interface Machine { id: string; tier: Tier; owner?: string; health: string; draining?: boolean; region: string; hourly: number | null; currency: string; free_ram_mb: number; free_slots: number; gpu_mb?: number; latency_ms?: number; queue_ms?: number; sessions?: number; state?: string; managed?: boolean; idle_since?: number; egress?: string }++export interface Request { strategy?: Strategy; owner?: string; kind?: string; ram_mb?: number; gpu_mb?: number; region?: string; machine?: string; public_egress?: boolean; max_hourly?: number }++export interface Pool { strategy?: Strategy; currency?: string; kind?: string; target_wait_ms?: number; max_hourly?: number; min_gain?: number; measured_peak_ram_mb?: number; min_warm?: number; max_machines?: number; slots_per_machine?: number; idle_grace_ms?: number }++export interface Decision { strategy: Strategy; selected: string | null; tier: Tier | null; hourly: number | null; currency: string; reasons: string[]; rejected: Array<{id: string; reason: string}>; switched: boolean; decided_at: string }++export function choose(request: Request, machines: Machine[], pool?: Pool, context?: {now?: number; current?: string | null}): Decision;++export function scale(pool: Pool, machines: Machine[], waiting: Array<{created_at: number}>, context?: {now?: number}): {actions: Array<{action: string; id?: string; reason: string}>; ram_per_session_mb: number; waiting: number; overdue: number; free_slots: number};++export function micros(value: string | number): bigint;++export function charge(hourlyMicros: bigint | string, milliseconds: number, quantumSeconds?: number): bigint;++export interface Quote { provider: string; id: string; region: string; hourly: number | null; currency: string; ram_mb: number; gpu_mb: number; setup: number; billing_seconds: number; tier: Tier; quoted_at: number; expires_at: number; provisionable?: boolean; reason?: string }++export interface Provider {id: string; configured: boolean; quote(request?: {gpu_mb?: number}): Promise<Quote[]>; provision(quote: Quote, spec: {label: string; image: string; command?: string[]; disk_gb?: number}): Promise<{id: string}>; health(id: string): Promise<{state?: string; deleted?: boolean}>; release(id: string): Promise<void>}++export function createProviders(options?: {fetch?: typeof fetch; config?: Record<string, {key?: string; application_secret?: string; consumer_key?: string; project?: string}>; now?: () => number}): Record<string, Provider>;++export const STRATEGIES: Strategy[];++export const TIERS: Tier[];
@@ -1,5 +1,9 @@
@@ -1,5 +1,9 @@
# Changelog
# Changelog
+## 0.100.0 (2026-11-08)
+
+- Add `openvibe-sdk/compute`: shared compute strategies, hard placement constraints, RAM-aware pool scaling, exact billing arithmetic and provider quote/lifecycle adapters. See `docs/compute.md` for gates and unsupported purchase paths.
+
## 0.99.0 (2026-11-08)
## 0.99.0 (2026-11-08)
- **`openvibe-sdk/events`: `outboxSchema()` indexes the rejected rows `rejected()` counts.** Every outbox made with the
- **`openvibe-sdk/events`: `outboxSchema()` indexes the rejected rows `rejected()` counts.** Every outbox made with the
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.99.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.100.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
After v0.97.1 is tagged:
After v0.97.1 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.99.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.100.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.99.0",
+ "version": "0.100.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.99.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.100.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.99.0';
+const SDK_VERSION = '0.100.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -527,6 +527,15 @@
@@ -527,6 +527,15 @@
"actingHeaders": "function",
"actingHeaders": "function",
"createCommunityClient": "function"
"createCommunityClient": "function"
},
},
+ "openvibe-sdk/compute": {
+ "STRATEGIES": "array",
+ "TIERS": "array",
+ "charge": "function",
+ "choose": "function",
+ "createProviders": "function",
+ "micros": "function",
+ "scale": "function"
+ },
"openvibe-sdk/consent": {
"openvibe-sdk/consent": {
"DEFAULT_TIMEOUT_MS": "number",
"DEFAULT_TIMEOUT_MS": "number",
"DEFAULT_TTL_MS": "number",
"DEFAULT_TTL_MS": "number",
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+# Compute placement and provider adapters
+
+`require('openvibe-sdk/compute')` exports `choose`, `scale`, `micros`, `charge`, and
+`createProviders`. Run owns inventory, authorization, pool state, reservation transactions and
+reconciliation. This module does no background work and cannot authorize spending.
+
+`choose(request, machines, pool, {now,current})` applies ownership, trust, health, resource,
+region, price and egress constraints before calling the existing `placement.plan()`.
+Strategies: `cheapest`, `fastest`, `private`, `own`, `automatic`. Automatic walks own,
+home, dedicated, community, partner, skipping tiers with unavailable capacity, excessive
+queue wait, unhealthy machines or costs above the ceiling. `current` provides price
+hysteresis; a hard failure switches immediately. Decisions include price, tier and reasons.
+Only trusted operator inventory may assign a tier; a public offer cannot claim to be private.
+Currency must match; this module never guesses exchange rates. Home public egress requires
+`ovh-tunnel`. Own machines must match the authenticated requester.
+
+`scale(pool,machines,waiting,{now})` returns provision/drain/release actions. Capacity uses
+the larger of measured peak RAM and 300 MB/browser or 2048 MB/desktop, plus host-reported
+free slots. The inventory must subtract OS RAM. A draining machine cannot accept placements;
+release waits for authoritative zero sessions. Missing observations are not zero sessions.
+
+Providers implement `quote`, `provision`, `health`, `release`, with normalized hourly price,
+currency, RAM/GPU, billing quantum and quote expiry. All network calls use a deadline, reject
+redirects, and keep credentials out of URLs and errors. Provision requires a key, but the caller
+MUST also reserve the entire rental against an owner-set cap before calling it.
+
+Sources (checked 2026-11-08):
+- [Hetzner Cloud](https://docs.hetzner.cloud/reference/cloud): authenticated server types and server lifecycle.
+- [Hetzner Robot](https://robot.hetzner.com/doc/webservice/en.html): auction purchases have separate cancellation terms.
+- [OVH schema](https://api.ovh.com/1.0/cloud.json): public pricing plus authenticated project flavors and instance lifecycle.
+- [Vast API](https://vast.ai/developers/api): public offers, PUT to rent, instance lifecycle.
+- [RunPod schema](https://raw.githubusercontent.com/runpod/docs/main/api-reference/openapi.json): REST v1 pods; GraphQL GPU catalogue.
+
+No-key reads: Hetzner auction, Vast offers, RunPod GPU prices, community offers, subject to
+provider rate limits/access policy. Cloud requires its token; OVH needs project credentials
+to associate catalogue prices with usable flavors. An unavailable API is not a free quote.
+Auction purchase is deliberately disabled until setup fees and cancellation liability can be
+reserved. Community inference offers are token-priced and cannot be treated as hourly machine
+rentals; use AI's existing router or lease an existing desk through Bot. Neither adapter
+pretends to support machine purchases. Quotes exclude variable disk/egress charges; callers
+must reject provisioning unless those costs are bounded separately. GPU runtimes belong to AI.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+// Generated by scripts/esm.js from src/compute/index.js. Do not edit.
+import cjs from '../src/compute/index.js';
+
+export const {
+ choose, scale, micros, charge, STRATEGIES, TIERS,
+ createProviders,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.99.0",
+ "version": "0.100.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.99.0",
+ "version": "0.100.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.99.0",
+ "version": "0.100.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -510,6 +510,12 @@
@@ -510,6 +510,12 @@
"browser": null,
"browser": null,
"import": "./esm/reports.mjs",
"import": "./esm/reports.mjs",
"default": "./src/reports.js"
"default": "./src/reports.js"
+ },
+ "./compute": {
+ "types": "./types/compute.d.ts",
+ "browser": null,
+ "import": "./esm/compute.mjs",
+ "default": "./src/compute/index.js"
}
}
},
},
"bin": {
"bin": {
@@ -12,6 +12,7 @@ const path = require('path');
@@ -12,6 +12,7 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const ENTRIES = {
const ENTRIES = {
+ 'compute.mjs': '../src/compute/index.js',
'share.mjs': '../src/share.js',
'share.mjs': '../src/share.js',
'locker.mjs': '../src/locker.js',
'locker.mjs': '../src/locker.js',
'locker-server.mjs': '../src/locker/server.js',
'locker-server.mjs': '../src/locker/server.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+const { plan } = require('../placement');
+const STRATEGIES = ['automatic', 'cheapest', 'fastest', 'private', 'own'];
+const TIERS = ['own', 'home', 'dedicated', 'community', 'partner'];
+const finite = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
+
+/** Pure policy over trusted inventory, not over client-supplied machine claims. Money is per hour. */
+function choose(request, machines, pool = {}, { now = Date.now(), current = null } = {}) {
+ const strategy = request.strategy || pool.strategy || 'automatic';
+ if (!STRATEGIES.includes(strategy)) throw new TypeError('unknown compute strategy');
+ const ram = Math.max(request.kind === 'desktop' ? 2048 : 300, Number(request.ram_mb) || 0);
+ const ceiling = Math.min(...[request.max_hourly, pool.max_hourly].filter(finite), Infinity);
+ const rejected = [];
+ const eligible = machines.filter((m) => {
+ let why;
+ if (!TIERS.includes(m.tier)) why = 'unknown tier';
+ else if (m.tier === 'own' && (!request.owner || m.owner !== request.owner)) why = 'not your machine';
+ else if (strategy === 'own' && m.tier !== 'own') why = 'own machines only';
+ else if (strategy === 'private' && !['own', 'home', 'dedicated'].includes(m.tier)) why = 'OpenVibe or own machines only';
+ else if (request.machine && m.id !== request.machine) why = 'another machine requested';
+ else if (m.health !== 'up' || m.draining) why = 'unhealthy or draining';
+ else if (request.region && m.region !== request.region) why = 'outside required region';
+ else if (request.gpu_mb && !(m.gpu_mb >= request.gpu_mb)) why = 'insufficient GPU memory';
+ else if (!(m.free_ram_mb >= ram) || !(m.free_slots > 0)) why = 'no capacity';
+ else if (m.tier === 'home' && request.public_egress && m.egress !== 'ovh-tunnel') why = 'home egress is not protected';
+ else if (!finite(m.hourly)) why = 'unknown hourly cost';
+ else if (m.currency !== (pool.currency || 'USD')) why = 'different currency';
+ else if (m.hourly > ceiling) why = 'over hourly ceiling';
+ else if (strategy === 'automatic' && (m.queue_ms || 0) > (pool.target_wait_ms ?? 30000)) why = 'queue exceeds target';
+ if (why) { rejected.push({ id: m.id, reason: why }); return false; }
+ return true;
+ });
+ let choices = eligible;
+ if (strategy === 'automatic' && choices.length) {
+ const tier = TIERS.find((t) => choices.some((m) => m.tier === t));
+ choices = choices.filter((m) => m.tier === tier);
+ }
+ // Cost/latency remain the shared placement planner's responsibility. Best-fit RAM and a session ceiling
+ // provide deterministic packing within equal-price/latency choices without changing the financial score.
+ choices = choices.slice().sort((a, b) => (a.free_ram_mb - ram) - (b.free_ram_mb - ram) || (a.sessions || 0) - (b.sessions || 0) || a.id.localeCompare(b.id));
+ const offers = choices.map((m) => ({ offer_id: m.id, trust: 'first-party', health: { status: 'up' },
+ pricing: { marginal_usd_per_unit: m.hourly }, latency_ms: finite(m.latency_ms) ? { p95: m.latency_ms } : {},
+ }));
+ const p = plan({ objective: strategy === 'fastest' ? 'lowest-latency' : 'cheapest' }, offers, { now, current, minGain: pool.min_gain ?? 0.15 });
+ const chosen = choices.find((m) => m.id === p.selected) || null;
+ return { strategy, selected: chosen && chosen.id, tier: chosen && chosen.tier, hourly: chosen && chosen.hourly,
+ currency: pool.currency || 'USD', reasons: [...p.reasons, ...(strategy === 'automatic' && chosen ? [`first eligible tier: ${chosen.tier}`] : [])],
+ rejected, switched: Boolean(current && current !== p.selected), decided_at: new Date(now).toISOString() };
+}
+
+/** Build a new desired state; never mutate input or call a provider. Observations must be authoritative. */
+function scale(pool, machines, waiting, { now = Date.now() } = {}) {
+ const ram = Math.max(pool.kind === 'desktop' ? 2048 : 300, pool.measured_peak_ram_mb || 0);
+ const target = pool.target_wait_ms ?? 30000;
+ const warm = Math.max(0, pool.min_warm || 0);
+ const grace = pool.idle_grace_ms ?? 600000;
+ const max = Math.max(warm, pool.max_machines ?? 10);
+ const live = machines.filter((m) => m.state !== 'released');
+ const ready = live.filter((m) => m.health === 'up' && !m.draining);
+ const slots = (m) => Math.max(0, Math.min(m.free_slots || 0, Math.floor((m.free_ram_mb || 0) / ram)));
+ const due = waiting.filter((s) => now - s.created_at >= target);
+ const capacity = ready.reduce((n, m) => n + slots(m), 0);
+ const pending = live.filter((m) => m.state === 'provisioning').length;
+ const need = Math.max(warm - ready.length - pending, Math.ceil(Math.max(0, due.length - capacity) / Math.max(1, pool.slots_per_machine || 1)) - pending);
+ const actions = [];
+ for (let n = 0; n < Math.min(need, max - live.length); n++) actions.push({ action: 'provision', reason: due.length > capacity ? 'queue wait exceeded target' : 'minimum warm pool' });
+ let remaining = ready.length;
+ for (const m of live.slice().sort((a, b) => (b.hourly || 0) - (a.hourly || 0))) {
+ if (m.draining && m.sessions === 0) actions.push({ action: 'release', id: m.id, reason: 'drain completed' });
+ else if (!m.draining && m.managed && m.sessions === 0 && finite(m.idle_since) && now - m.idle_since >= grace && remaining > warm && !waiting.length) {
+ actions.push({ action: 'drain', id: m.id, reason: 'idle grace elapsed' }); remaining--;
+ }
+ }
+ return { actions, ram_per_session_mb: ram, waiting: waiting.length, overdue: due.length, free_slots: capacity };
+}
+
+/** Exact decimal money, in millionths of the quote currency; reject negatives, NaN and excess precision. */
+function micros(value) {
+ const m = /^(\d+)(?:\.(\d{1,6}))?$/.exec(String(value));
+ if (!m) throw new TypeError('money must be a nonnegative decimal with at most six places');
+ return BigInt(m[1]) * 1000000n + BigInt((m[2] || '').padEnd(6, '0'));
+}
+function charge(hourlyMicros, milliseconds, quantumSeconds = 1) {
+ if (!finite(milliseconds) || !finite(quantumSeconds) || !quantumSeconds) throw new TypeError('invalid billing duration');
+ const seconds = BigInt(Math.ceil(milliseconds / (quantumSeconds * 1000)) * quantumSeconds);
+ return (BigInt(hourlyMicros) * seconds + 3599n) / 3600n;
+}
+module.exports = { choose, scale, micros, charge, STRATEGIES, TIERS, ...require('./providers') };
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+const { createHash } = require('node:crypto');
+const enc = encodeURIComponent;
+
+/** Provider errors intentionally exclude response bodies (which can contain credentials/user-data). */
+async function json(fetch, url, { method = 'GET', headers = {}, body } = {}) {
+ const r = await fetch(url, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
+ ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'error', signal: AbortSignal.timeout(15000) });
+ if (r.status === 404) return null;
+ if (!r.ok) throw new Error(`provider HTTP ${r.status}`);
+ return r.status === 204 ? {} : r.json();
+}
+const positive = (v) => Number.isFinite(Number(v)) && Number(v) > 0;
+const quote = (provider, id, region, hourly, currency, ram_mb, extra = {}) => ({ provider, id: String(id), region,
+ hourly: positive(hourly) ? Number(hourly) : null, currency, ram_mb: Number(ram_mb) || 0, gpu_mb: 0,
+ setup: 0, billing_seconds: 3600, tier: 'partner', ...extra });
+function createProviders({ fetch: fetchImpl = globalThis.fetch, config = {}, now = Date.now } = {}) {
+ const call = (url, opts) => json(fetchImpl, url, opts);
+ const c = (name) => config[name] || {};
+ function keyed(name) { if (!c(name).key) throw new Error(`${name}: key required`); }
+ const bearer = (name) => c(name).key ? { Authorization: `Bearer ${c(name).key}` } : {};
+ const hc = 'https://api.hetzner.cloud/v1';
+ const ovh = 'https://eu.api.ovh.com/1.0';
+ async function ovhCall(path, method = 'GET', body) {
+ const v = c('ovh');
+ if (!v.key || !v.application_secret || !v.consumer_key) throw new Error('ovh: application and consumer credentials required');
+ const url = ovh + path;
+ const timestamp = String(await call(ovh + '/auth/time'));
+ const signature = '$1$' + createHash('sha1').update([v.application_secret, v.consumer_key, method, url, body ? JSON.stringify(body) : '', timestamp].join('+')).digest('hex');
+ return call(url, { method, body, headers: { 'X-Ovh-Application': v.key, 'X-Ovh-Consumer': v.consumer_key, 'X-Ovh-Timestamp': timestamp, 'X-Ovh-Signature': signature } });
+ }
+ const providers = {
+ hetzner: {
+ async quote() {
+ // Cloud catalogue requires an API token; no scraped price is substituted.
+ keyed('hetzner');
+ const r = await call(hc + '/server_types?per_page=50', { headers: bearer('hetzner') });
+ return (r.server_types || []).flatMap((s) => (s.prices || []).map((p) => quote('hetzner', s.name, p.location, p.price_hourly.gross, 'EUR', s.memory * 1024,
+ { tier: 'dedicated', cpu: s.cores, source: hc + '/server_types', included_traffic: p.included_traffic, egress_hourly: null })));
+ },
+ async provision(q, spec) {
+ keyed('hetzner');
+ if (!spec.image) throw new Error('hetzner: prepared Link image required');
+ const r = await call(hc + '/servers', { method: 'POST', headers: bearer('hetzner'), body: { name: spec.label, server_type: q.id, location: q.region,
+ image: spec.image, start_after_create: true, public_net: { enable_ipv4: false, enable_ipv6: true }, labels: { 'openvibe-broker': spec.label } } });
+ return { id: String(r.server.id) };
+ },
+ async health(id) { const r = await call(hc + '/servers/' + enc(id), { headers: bearer('hetzner') }); return r ? { state: r.server.status, deleted: false } : { deleted: true }; },
+ async release(id) { keyed('hetzner'); await call(hc + '/servers/' + enc(id), { method: 'DELETE', headers: bearer('hetzner') }); },
+ },
+ 'hetzner-auction': {
+ async quote() {
+ const url = 'https://www.hetzner.com/_resources/app/data/app/live_data_sb.json';
+ const r = await call(url);
+ return (r.server || []).map((s) => quote('hetzner-auction', s.Id, s.Details?.Datacenter?.Name, s.Prices?.hourly?.EUR, 'EUR', (s.Hardware?.RAM?.Size || 0) * 1024,
+ { tier: 'dedicated', monthly: s.Prices?.monthly?.EUR, setup: s.Prices?.setup?.EUR ?? null, source: url, provisionable: false, reason: 'dedicated purchase requires reviewed cancellation and setup terms' }));
+ },
+ async provision() { throw new Error('auction: quote only; dedicated purchase and cancellation terms require review'); },
+ async health() { return { state: 'quote-only' }; },
+ async release() { throw new Error('auction: no broker-owned purchase'); },
+ },
+ ovh: {
+ async quote() {
+ const url = ovh + '/order/catalog/public/cloud?ovhSubsidiary=IE';
+ const catalog = await call(url);
+ const plans = (catalog.addons || []).filter(p => p.product === 'publiccloud-instance');
+ const priced = (p) => p.pricings?.find(v => v.interval === 1 && v.intervalUnit === 'hour' && v.mode === 'default' && v.type === 'consumption');
+ const cost = (v) => (v.price + (v.tax || 0)) / 1e8;
+ // Public prices can be compared without credentials; a project flavor is needed to rent.
+ if (!c('ovh').project || !c('ovh').key) return plans.flatMap(p => {
+ const v = priced(p); if (!v) return [];
+ return [quote('ovh', p.planCode, 'catalogue', cost(v), catalog.locale.currencyCode, (p.blobs?.technical?.memory?.size || 0) * 1024,
+ { tier: 'dedicated', source: url, provisionable: false, reason: 'project credentials required to resolve region and availability' })];
+ });
+ const flavors = await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/flavor');
+ return flavors.flatMap((f) => {
+ const plan = plans.find(p => p.planCode === f.planCodes?.hourly);
+ const price = plan && priced(plan);
+ if (!price || !f.available) return [];
+ return [quote('ovh', f.id, f.region, cost(price), catalog.locale.currencyCode, f.ram * 1024,
+ { tier: 'dedicated', cpu: f.vcpus, source: url, billing_seconds: 3600 })];
+ });
+ },
+ async provision(q, spec) {
+ keyed('ovh'); if (!spec.image) throw new Error('ovh: prepared Link image required');
+ const r = await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/instance', 'POST', { name: spec.label, flavorId: q.id, region: q.region, imageId: spec.image, monthlyBilling: false });
+ return { id: r.id };
+ },
+ async health(id) { const r = await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/instance/' + enc(id)); return r ? { state: r.status, deleted: false } : { deleted: true }; },
+ async release(id) { await ovhCall('/cloud/project/' + enc(c('ovh').project) + '/instance/' + enc(id), 'DELETE'); },
+ },
+ vast: {
+ async quote(req = {}) {
+ const url = 'https://console.vast.ai/api/v0/bundles/';
+ const r = await call(url, { method: 'POST', headers: bearer('vast'), body: { type: 'ondemand', rentable: { eq: true }, verified: { eq: true }, gpu_ram: { gte: req.gpu_mb || 1 }, limit: 100 } });
+ return (r.offers || []).map((s) => quote('vast', s.id, s.geolocation, s.dph_total, 'USD', s.cpu_ram,
+ { gpu_mb: s.gpu_ram * s.num_gpus, gpu: s.gpu_name, billing_seconds: 1, source: url, tier: 'partner', disk_hourly: s.storage_cost, egress_hourly: null }));
+ },
+ async provision(q, spec) {
+ keyed('vast'); if (!spec.image) throw new Error('vast: image required');
+ const r = await call('https://console.vast.ai/api/v0/asks/' + enc(q.id) + '/', { method: 'PUT', headers: bearer('vast'), body: { image: spec.image, label: spec.label, disk: spec.disk_gb || 20, runtype: 'args', args: spec.command || '' } });
+ if (!r?.new_contract) throw new Error('vast: create outcome unknown');
+ return { id: String(r.new_contract) };
+ },
+ async health(id) { const r = await call('https://console.vast.ai/api/v0/instances/' + enc(id) + '/', { headers: bearer('vast') }); return r ? { state: (r.instances || r).actual_status, deleted: false } : { deleted: true }; },
+ async release(id) { keyed('vast'); await call('https://console.vast.ai/api/v0/instances/' + enc(id) + '/', { method: 'DELETE', headers: bearer('vast') }); },
+ },
+ runpod: {
+ async quote() {
+ const url = 'https://api.runpod.io/graphql';
+ const r = await call(url, { method: 'POST', body: { query: '{ gpuTypes { id displayName memoryInGb securePrice communityPrice } }' } });
+ if (r.errors) throw new Error('runpod: price catalogue unavailable');
+ return (r.data?.gpuTypes || []).map((s) => quote('runpod', s.id, 'any', s.securePrice, 'USD', 0,
+ { gpu_mb: s.memoryInGb * 1024, gpu: s.displayName, billing_seconds: 1, source: url, egress_hourly: null }));
+ },
+ async provision(q, spec) {
+ keyed('runpod'); if (!spec.image) throw new Error('runpod: image required');
+ const r = await call('https://rest.runpod.io/v1/pods', { method: 'POST', headers: bearer('runpod'), body: { name: spec.label, imageName: spec.image, gpuTypeIds: [q.id], gpuCount: 1,
+ cloudType: 'SECURE', containerDiskInGb: spec.disk_gb || 20, volumeInGb: 0, ...(spec.command ? { dockerStartCmd: spec.command } : {}) } });
+ if (!r?.id) throw new Error('runpod: create outcome unknown');
+ return { id: r.id };
+ },
+ async health(id) { const r = await call('https://rest.runpod.io/v1/pods/' + enc(id), { headers: bearer('runpod') }); return r ? { state: r.desiredStatus, deleted: false } : { deleted: true }; },
+ async release(id) { keyed('runpod'); await call('https://rest.runpod.io/v1/pods/' + enc(id), { method: 'DELETE', headers: bearer('runpod') }); },
+ },
+ community: {
+ async quote() {
+ const url = 'https://ai.openvibe.services/api/v1/community/offers';
+ const r = await call(url);
+ if (!r) throw new Error('community: offers endpoint unavailable');
+ return (r.offers || []).map((s) => quote('community', s.id || s.offer_id, s.region || 'unknown', s.price_per_hour, 'USD', s.ram_mb,
+ { tier: 'community', gpu_mb: s.gpu_ram_mb || 0, source: url, provisionable: false, reason: 'inference offers are token-priced; not a machine rental' }));
+ },
+ async provision() { throw new Error('community: use the existing AI offer router or Bot desk lease'); },
+ async health() { return { state: 'offer' }; },
+ async release() { throw new Error('community: no broker-owned machine'); },
+ },
+ };
+ // Quotes have short leases. Provisioners must re-quote before taking a reservation.
+ for (const [name, p] of Object.entries(providers)) {
+ const read = p.quote;
+ p.quote = async (...args) => (await read(...args)).map((q) => ({ ...q, quoted_at: now(), expires_at: now() + 60000 }));
+ p.id = name;
+ p.configured = Boolean(c(name).key);
+ }
+ return providers;
+}
+module.exports = { createProviders };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.99.0';
+const SDK_VERSION = '0.100.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './menu', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
+ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './compute', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './menu', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/cam/index.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/cam/index.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
assert.ok(rel.includes(must), `${must} is scanned`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+const assert=require('node:assert/strict');
+const {createProviders}=require('../src/compute');
+(async()=>{
+ const calls=[];
+ const fetch=async(url,opts)=>{
+ calls.push({url,...opts});
+ let body={};
+ if(url.includes('server_types')) body={server_types:[{name:'cx',memory:4,cores:2,prices:[{location:'fsn1',price_hourly:{gross:'0.01'}}]}]};
+ else if(url.endsWith('/servers') && opts.method==='POST') body={server:{id:42}};
+ else if(url.includes('/servers/')) body={server:{status:'running'}};
+ else if(url.includes('live_data')) body={server:[{Id:1,Prices:{hourly:{EUR:0.06},monthly:{EUR:40},setup:{EUR:0}},Hardware:{RAM:{Size:64}},Details:{Datacenter:{Name:'FSN1'}}}]};
+ else if(url.includes('bundles')) body={offers:[{id:2,dph_total:0.2,cpu_ram:32000,gpu_ram:24000,num_gpus:1,geolocation:'US'}]};
+ else if(url.includes('/asks/')) body={new_contract:9};
+ else if(url.includes('/instances/')) body={instances:{actual_status:'running'}};
+ else if(url.includes('graphql')) body={data:{gpuTypes:[{id:'a',displayName:'GPU',memoryInGb:24,securePrice:0.3}]}};
+ else if(url.endsWith('/pods') && opts.method==='POST') body={id:'p'};
+ else if(url.includes('/pods/')) body={desiredStatus:'RUNNING'};
+ else if(url.includes('community/offers')) body={offers:[{id:'c',region:'US'}]};
+ else if(url.endsWith('/auth/time')) body=100;
+ else if(url.includes('/order/catalog')) body={locale:{currencyCode:'USD'},addons:[{planCode:'b-hour',product:'publiccloud-instance',pricings:[{interval:1,intervalUnit:'hour',type:'consumption',mode:'default',price:2000000}]}]};
+ else if(url.endsWith('/flavor')) body=[{id:'b',available:true,ram:4,region:'GRA',planCodes:{hourly:'b-hour'}}];
+ else if(url.endsWith('/instance')) body={id:'i'};
+ else if(url.includes('/instance/')) body={status:'ACTIVE'};
+ return {ok:true,status:200,json:async()=>body};
+ };
+ const config=Object.fromEntries(['hetzner','vast','runpod','ovh'].map(n=>[n,{key:'stub',application_secret:'stub',consumer_key:'stub',project:'project'}]));
+ const ps=createProviders({fetch,config,now:()=>100});
+ for(const name of ['hetzner','ovh','vast','runpod']){
+ const q=(await ps[name].quote())[0]; assert.ok(q.hourly>0,name); assert.equal(q.expires_at,60100);
+ const r=await ps[name].provision(q,{label:'test',image:'prepared'}); assert.ok(r.id);
+ assert.ok((await ps[name].health(r.id)).state); await ps[name].release(r.id);
+ }
+ assert.equal((await ps['hetzner-auction'].quote())[0].provisionable,false);
+ assert.equal((await ps.community.quote())[0].hourly,null);
+ assert.equal(calls.find(c=>c.url.includes('/asks/')).method,'PUT');
+ assert.ok(calls.find(c=>c.url==='https://rest.runpod.io/v1/pods'));
+ assert.ok(calls.filter(c=>c.url.includes('/cloud/project/')).every(c=>c.headers['X-Ovh-Signature'].startsWith('$1$')));
+ const noKey=createProviders({fetch});
+ for(const name of ['hetzner','ovh','vast','runpod']) await assert.rejects(noKey[name].provision({id:'1'},{image:'x'}),/key|credentials/);
+ await assert.rejects(ps.community.provision(),/router/);
+ await assert.rejects(ps['hetzner-auction'].provision(),/quote only/);
+ console.log('compute provider API stubs and key gates: passed');
+})().catch(e=>{console.error(e);process.exit(1);});
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { choose, scale, micros, charge } = require('../src/compute');
+const machine = (id, tier, hourly, extra = {}) => ({ id, tier, hourly, currency: 'USD', free_ram_mb: 4096, free_slots: 4, health: 'up', region: 'us', latency_ms: 50, ...extra });
+const machines = [machine('mine', 'own', 0, {owner: 'alice'}), machine('home', 'home', 0.01), machine('dedicated', 'dedicated', 0.02, {latency_ms: 10}), machine('community', 'community', 0.001), machine('burst', 'partner', 1)];
+for (const [strategy, expected] of [['automatic','mine'],['cheapest','mine'],['fastest','dedicated'],['private','mine'],['own','mine']]) assert.equal(choose({strategy, owner:'alice'}, machines).selected, expected);
+assert.equal(choose({strategy:'own', owner:'bob'}, machines).selected, null);
+assert.equal(choose({strategy:'private', owner:'bob'}, machines).selected, 'home');
+assert.equal(choose({owner:'bob', public_egress:true}, machines).selected, 'dedicated');
+assert.equal(choose({owner:'bob'}, machines.map(m => m.id === 'home' ? {...m,queue_ms:31000} : m)).selected, 'dedicated');
+assert.equal(choose({owner:'bob'}, machines.map(m => m.id === 'home' ? {...m,health:'down'} : m)).selected, 'dedicated');
+assert.equal(choose({owner:'bob',max_hourly:0.005}, machines).selected, 'community');
+assert.equal(choose({strategy:'private'}, [machine('bad','community',0)]).selected, null);
+assert.equal(choose({}, [machine('unpriced','dedicated',null)]).selected, null);
+assert.equal(choose({region:'eu'}, machines).selected, null);
+assert.equal(choose({gpu_mb:24000}, machines).selected, null);
+assert.equal(choose({}, [machine('eur','home',0,{currency:'EUR'})]).selected, null);
+const a=machine('a','dedicated',1,{state:'running',managed:true,sessions:0,idle_since:0});
+assert.equal(scale({min_warm:1},[],[],{now:100}).actions[0].action,'provision');
+assert.equal(scale({},[a],Array.from({length:5},()=>({created_at:0})),{now:40000}).actions[0].action,'provision');
+assert.deepEqual(scale({},[a],[],{now:700000}).actions,[{action:'drain',id:'a',reason:'idle grace elapsed'}]);
+assert.equal(scale({},[{...a,draining:true}],[],{now:700000}).actions[0].action,'release');
+assert.equal(scale({},[{...a,draining:true,sessions:1}],[],{now:700000}).actions.length,0);
+assert.equal(scale({min_warm:1},[a],[],{now:700000}).actions.length,0);
+assert.equal(scale({min_warm:1},[{...a,state:'provisioning',health:'starting'}],[],{now:700000}).actions.length,0);
+assert.equal(choose({max_hourly:100},[machine('expensive','dedicated',10)],{max_hourly:1}).selected,null);
+assert.equal(micros('1.000001'),1000001n);
+assert.throws(()=>micros('NaN'));
+assert.throws(()=>micros('-1'));
+assert.equal(charge(micros('1'),1,3600),1000000n);
+assert.equal(charge(micros('1'),3600000,1),1000000n);
+console.log('compute strategies, hard constraints, scale up/down/drain and exact billing: passed');
@@ -72,7 +72,7 @@ run([
@@ -72,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 88); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps, 0.94.0 reports, 0.95.0 cam, 0.97.0 menu
+ assert.equal(subpaths.length, 89); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps, 0.94.0 reports, 0.95.0 cam, 0.97.0 menu
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -24,7 +24,7 @@ const files = fs.readdirSync(__dirname)
@@ -24,7 +24,7 @@ const files = fs.readdirSync(__dirname)
.filter((f) => f.endsWith('.test.js'))
.filter((f) => f.endsWith('.test.js'))
.filter((f) => !filters.length || filters.some((w) => f.includes(w)))
.filter((f) => !filters.length || filters.some((w) => f.includes(w)))
.sort();
.sort();
-const TIMEOUT_MS = 120000;
+const TIMEOUT_MS = 240000; // PGlite test files may exceed two minutes on the shared worker.
/** `<label>: skipped (<why>)` on a line of its own (SKIP_RE from openvibe-shared/test-runner). */
/** `<label>: skipped (<why>)` on a line of its own (SKIP_RE from openvibe-shared/test-runner). */
const SKIP_RE = /^[ \t]*[\w .,'()/+#-]{1,120}: skipped \((.+)\)[ \t]*$/gm;
const SKIP_RE = /^[ \t]*[\w .,'()/+#-]{1,120}: skipped \((.+)\)[ \t]*$/gm;
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+export type Strategy = 'automatic' | 'cheapest' | 'fastest' | 'private' | 'own';
+export type Tier = 'own' | 'home' | 'dedicated' | 'community' | 'partner';
+export interface Machine { id: string; tier: Tier; owner?: string; health: string; draining?: boolean; region: string; hourly: number | null; currency: string; free_ram_mb: number; free_slots: number; gpu_mb?: number; latency_ms?: number; queue_ms?: number; sessions?: number; state?: string; managed?: boolean; idle_since?: number; egress?: string }
+export interface Request { strategy?: Strategy; owner?: string; kind?: string; ram_mb?: number; gpu_mb?: number; region?: string; machine?: string; public_egress?: boolean; max_hourly?: number }
+export interface Pool { strategy?: Strategy; currency?: string; kind?: string; target_wait_ms?: number; max_hourly?: number; min_gain?: number; measured_peak_ram_mb?: number; min_warm?: number; max_machines?: number; slots_per_machine?: number; idle_grace_ms?: number }
+export interface Decision { strategy: Strategy; selected: string | null; tier: Tier | null; hourly: number | null; currency: string; reasons: string[]; rejected: Array<{id: string; reason: string}>; switched: boolean; decided_at: string }
+export function choose(request: Request, machines: Machine[], pool?: Pool, context?: {now?: number; current?: string | null}): Decision;
+export function scale(pool: Pool, machines: Machine[], waiting: Array<{created_at: number}>, context?: {now?: number}): {actions: Array<{action: string; id?: string; reason: string}>; ram_per_session_mb: number; waiting: number; overdue: number; free_slots: number};
+export function micros(value: string | number): bigint;
+export function charge(hourlyMicros: bigint | string, milliseconds: number, quantumSeconds?: number): bigint;
+export interface Quote { provider: string; id: string; region: string; hourly: number | null; currency: string; ram_mb: number; gpu_mb: number; setup: number; billing_seconds: number; tier: Tier; quoted_at: number; expires_at: number; provisionable?: boolean; reason?: string }
+export interface Provider {id: string; configured: boolean; quote(request?: {gpu_mb?: number}): Promise<Quote[]>; provision(quote: Quote, spec: {label: string; image: string; command?: string[]; disk_gb?: number}): Promise<{id: string}>; health(id: string): Promise<{state?: string; deleted?: boolean}>; release(id: string): Promise<void>}
+export function createProviders(options?: {fetch?: typeof fetch; config?: Record<string, {key?: string; application_secret?: string; consumer_key?: string; project?: string}>; now?: () => number}): Record<string, Provider>;
+export const STRATEGIES: Strategy[];
+export const TIERS: Tier[];