openvibers/openvibe.sdk Commits

openvibe-sdk 0.156.0: openvibe-sdk/exchange, reviewed compute prices, keyless (#235)

OP OpenVibers committed 23 h ago · a63532685e · 12 files, +205 −14

* openvibe-sdk/exchange: reviewed compute prices, keyless

Add computePrices({ kind }) to createExchangeClient and a standalone
createComputePriceClient() that needs no Exchange key. Both read
GET https://openvibe.exchange/api/compute/prices: the provider and region,
the GPU or computer, hourly and per-second prices, the checked and review
dates and the provider's price page; kind filters to cpu or gpu. The keyed
client reads the public site (siteUrl, default https://openvibe.exchange)
without its developer key, so the API host is unchanged. A 429 or 5xx is
retried with the module's jittered backoff; a refused book is an
ExchangeError.

Types for ComputePrice, ComputePriceBook, ComputePriceOptions and
ComputePriceClient, a README row, and test/exchange-compute.test.js. The
release metadata (version, STATUS, SDK_VERSION, tag) is left to the release.

* Merge main into sdk-exchange-compute-1112

* openvibe-sdk 0.156.0: openvibe-sdk/exchange, reviewed compute prices, keyless

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 32f2892..c50f91e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,16 @@
# Changelog
++## 0.156.0 — 2026-11-12
++
++- **`openvibe-sdk/exchange`: reviewed compute prices, keyless.**
++ - `createExchangeClient(…).computePrices({ kind })` and the standalone `createComputePriceClient()` read
++ `GET https://openvibe.exchange/api/compute/prices`: each GPU or computer's hourly and per-second price, the
++ provider's own price page, the date it was checked and the date it must be reviewed again, filtered to
++ `kind: 'cpu' | 'gpu'` when asked.
++ - The public endpoint needs no key, sends `Access-Control-Allow-Origin: *` and is cached for 60 seconds. The
++ keyed client still speaks the API host (unchanged); its price read goes to `siteUrl` (default
++ `https://openvibe.exchange`) without its developer key.
++
## 0.155.0 — 2026-11-12
- Add `design.icons.suggest(label, { context, limit, rank })` to `openvibe-sdk/design`: the best glyphs for a button's
ddiff --git a/README.md b/README.md
iindex be8540b..4285dc4 100644
---- a/README.md
++++ b/README.md
@@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.155.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.156.0
```
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.155.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.156.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.
@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.155.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.156.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).
@@@ -258,7 +258,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily |
| `openvibe-sdk/vibes` | server | `createVibesClient({appToken \| tokenProvider, billingUrl, fetchImpl?, timeoutMs?})`: Vibes Pay for developer apps (D12). `charges.create({amount, description, reference, return_url})` sends a whole number of Vibes with the reference as the `Idempotency-Key`, → `{id, pay_url, status, expires_at}`; `charges.get(id)`, `charges.refund(id, {reason?})`. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, constant time, replay window) and the legacy `sha256=<hex>` body-only form; also exported as `verifyWebhook`. A float, zero or negative amount is refused before any request; problem+json becomes `VibesError` (`status`, `code`, `body`, `retryable`), a 401 invalidates a token provider and retries once with the same key |
--| `openvibe-sdk/exchange` | server | `createExchangeClient({developerKey, baseUrl?, networkUrl?, fetchImpl?, timeoutMs?, maxRetries?, owner?, paths?})`: OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, item 7). Buyer: `chat(body, {signal})` → the OpenAI shape (`stream: true` answers an async iterable of chunks), `models()`, `offers(model)` (the offers that serve one model, with the price, the speed and the uptime), `catalog()`, `presets.list/create/update/remove` (a preset is stored under its name; there is no PATCH), `usage({days})` (the public aggregate; with `from`/`to`/`requester` the platform usage for that requester) and `receipt(id)`. Seller: `seller.offer({model, price, hours, concurrency, endpoint, machineId, owner})` registers the offer in the Network’s user-owned AI offers (the same registry OpenVibe Link’s "Offer to the network" writes to: `POST /internal/ai/offers/report`, the capability `network.resource.report`), `seller.earnings()`, `seller.verification()` (canaries, spot re-runs, reputation, uptime) and `seller.offers()`. A 429 or a 5xx is retried with a jittered backoff (a write only when it is idempotent); every call takes an AbortSignal. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, constant time, replay window) and the legacy `sha256=<hex>` body-only form; also exported as `verifyWebhook` |
++| `openvibe-sdk/exchange` | server | `createExchangeClient({developerKey, baseUrl?, networkUrl?, siteUrl?, fetchImpl?, timeoutMs?, maxRetries?, owner?, paths?})`: OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, item 7). Buyer: `chat(body, {signal})` → the OpenAI shape (`stream: true` answers an async iterable of chunks), `models()`, `offers(model)` (the offers that serve one model, with the price, the speed and the uptime), `catalog()`, `presets.list/create/update/remove` (a preset is stored under its name; there is no PATCH), `usage({days})` (the public aggregate; with `from`/`to`/`requester` the platform usage for that requester) and `receipt(id)`. Seller: `seller.offer({model, price, hours, concurrency, endpoint, machineId, owner})` registers the offer in the Network’s user-owned AI offers (the same registry OpenVibe Link’s "Offer to the network" writes to: `POST /internal/ai/offers/report`, the capability `network.resource.report`), `seller.earnings()`, `seller.verification()` (canaries, spot re-runs, reputation, uptime) and `seller.offers()`. A 429 or a 5xx is retried with a jittered backoff (a write only when it is idempotent); every call takes an AbortSignal. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, constant time, replay window) and the legacy `sha256=<hex>` body-only form; also exported as `verifyWebhook`. Compute: `computePrices({kind: 'cpu' | 'gpu'})` reads the reviewed GPU/computer tariffs (per hour, per second, checked and review dates, the provider's price page) from Exchange's public site, keyless; `createComputePriceClient({baseUrl?, fetchImpl?})` is that reader alone |
| `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})`, `startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?})` → `{run(), stop()}` (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) |
| `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card; the AI Exchange money math in integer nano-USD with `nanoUsd()`, `chargeTokens()`, `expectedCostNano()`, `costPerSuccessNano()`, `formatNanoUsd()` |
| `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes |
ddiff --git a/STATUS.json b/STATUS.json
iindex d13d1bb..ec4749d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.155.0",
++ "version": "0.156.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 087be0e..8bff064 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.155.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.156.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, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -67,7 +67,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.155.0';
++const SDK_VERSION = '0.156.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex b270b67..0fe5121 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -911,6 +911,7 @@
"catalog",
"catalogModels",
"chat",
++ "computePrices",
"models",
"offerList",
"offerReport",
@@@ -928,6 +929,7 @@
"RETRY_STATUS": "object",
"SIGNATURE_TOLERANCE_SEC": "number",
"createCatalogSearchClient": "function",
++ "createComputePriceClient": "function",
"createExchangeClient": "function",
"verifyWebhook": "function"
},
ddiff --git a/esm/exchange.mjs b/esm/exchange.mjs
iindex 7d0d8f2..66a93ad 100644
---- a/esm/exchange.mjs
++++ b/esm/exchange.mjs
@@@ -2,7 +2,7 @@
import cjs from '../src/exchange.js';
export const {
-- createCatalogSearchClient, createExchangeClient, ExchangeError, verifyWebhook, SIGNATURE_TOLERANCE_SEC, EXCHANGE_BASE_URL,
-- NETWORK_BASE_URL, DEFAULT_PATHS, RETRY_STATUS,
++ createCatalogSearchClient, createComputePriceClient, createExchangeClient, ExchangeError, verifyWebhook, SIGNATURE_TOLERANCE_SEC,
++ EXCHANGE_BASE_URL, NETWORK_BASE_URL, DEFAULT_PATHS, RETRY_STATUS,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 3074d74..6604f56 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.155.0",
++ "version": "0.156.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.155.0",
++ "version": "0.156.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 29a2fd8..d3e6612 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.155.0",
++ "version": "0.156.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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 3c41ffa..44d05aa 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -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.155.0';
++const SDK_VERSION = '0.156.0';
module.exports = {
createClient,
ddiff --git a/src/exchange.js b/src/exchange.js
iindex 214af17..57835d7 100644
---- a/src/exchange.js
++++ b/src/exchange.js
@@@ -5,6 +5,8 @@
*
* The buyer side speaks the Exchange's v1 API (https://api.openvibe.exchange) with a Network developer key:
* chat() (streaming and not, the OpenAI shape), models(), offers(model), presets(), usage() and receipt(id).
++ * computePrices({ kind }) reads the reviewed compute tariffs from the public site (https://openvibe.exchange),
++ * keyless; createComputePriceClient() is that reader on its own, for an app that holds no Exchange key.
* Every answered request carries a receipt; a request may name a preset, a saved routing profile.
*
* const exchange = createExchangeClient({ developerKey: process.env.OPENVIBE_DEVELOPER_KEY });
@@@ -69,6 +71,7 @@ const DEFAULT_PATHS = {
sellerOffers: '/api/v1/exchange/seller/offers',
offerReport: '/internal/ai/offers/report',
offerList: '/internal/ai/offers',
++ computePrices: '/api/compute/prices',
};
class ExchangeError extends Error {
@@@ -290,6 +293,7 @@ function createExchangeClient({
networkKey = null,
baseUrl = EXCHANGE_BASE_URL,
networkUrl = NETWORK_BASE_URL,
++ siteUrl = CATALOG_SEARCH_URL,
fetchImpl = globalThis.fetch,
timeoutMs = 60000,
maxRetries = 3,
@@@ -308,8 +312,10 @@ function createExchangeClient({
if (!Number.isFinite(retryBaseMs) || retryBaseMs < 0) throw new TypeError('createExchangeClient: retryBaseMs must be 0 or more');
const exchange = trimSlash(baseUrl);
const network = trimSlash(networkUrl);
++ const site = trimSlash(siteUrl);
if (!/^https?:\/\/[^/]+/i.test(exchange)) throw new TypeError('createExchangeClient: baseUrl is an http(s) origin');
if (!/^https?:\/\/[^/]+/i.test(network)) throw new TypeError('createExchangeClient: networkUrl is an http(s) origin');
++ if (!/^https?:\/\/[^/]+/i.test(site)) throw new TypeError('createExchangeClient: siteUrl is an http(s) origin');
const paths = { ...DEFAULT_PATHS, ...pathOptions };
const key = developerKey || networkKey;
const networkBearer = networkKey || developerKey;
@@@ -319,7 +325,7 @@ function createExchangeClient({
const url = `${base}${path}${queryOf(query)}`;
const send = {
method,
-- headers: { Accept: accept, Authorization: `Bearer ${bearer}`, ...(json !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(agent ? { 'User-Agent': agent } : {}), ...headers },
++ headers: { Accept: accept, ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...(json !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(agent ? { 'User-Agent': agent } : {}), ...headers },
body: json !== undefined ? JSON.stringify(json) : undefined,
};
const mayRetry = SAFE_METHODS.has(method) || idempotent;
@@@ -601,6 +607,17 @@ function createExchangeClient({
return await jsonCall('GET', paths.catalog, { signal });
},
++ /**
++ * The reviewed compute tariffs: per provider and region, the GPU or computer, its hourly and per-second
++ * price, the date it was checked and the date it must be reviewed again. Keyless and public; `kind`
++ * ('cpu' or 'gpu') filters the list. → { checked_at, prices }
++ */
++ async computePrices({ kind = null } = {}, { signal } = {}) {
++ computeKind(kind);
++ const book = await jsonCall('GET', paths.computePrices, { base: site, bearer: null, signal });
++ return computeBook(book, kind);
++ },
++
/** The routing presets: the five built-in ones and the caller's own. */
presets: {
/** The presets the caller may name. */
@@@ -666,6 +683,7 @@ function createExchangeClient({
module.exports = {
createCatalogSearchClient,
++ createComputePriceClient,
createExchangeClient,
ExchangeError,
verifyWebhook,
@@@ -676,6 +694,57 @@ module.exports = {
RETRY_STATUS,
};
++/** The book's own fields stay; `kind` filters its prices when asked. */
++function computeBook(book, kind) {
++ const prices = book && Array.isArray(book.prices) ? book.prices : [];
++ return { ...(book && typeof book === 'object' ? book : {}), prices: kind ? prices.filter((price) => price && price.kind === kind) : prices };
++}
++function computeKind(kind) {
++ if (kind != null && kind !== 'cpu' && kind !== 'gpu') throw new TypeError("computePrices: kind must be 'cpu' or 'gpu'");
++}
++
++/**
++ * The keyless compute price reader: one public GET, no key and no account.
++ *
++ * const compute = createComputePriceClient();
++ * const { checked_at, prices } = await compute.computePrices({ kind: 'gpu' });
++ *
++ * baseUrl is Exchange's public site (default https://openvibe.exchange), not the API host. A 429 or a 5xx is
++ * retried with the module's jittered backoff; every call takes an AbortSignal.
++ */
++function createComputePriceClient({ baseUrl = CATALOG_SEARCH_URL, fetchImpl = globalThis.fetch, timeoutMs = 15000, maxRetries = 2, retryBaseMs = 250, random = Math.random, agent = null } = {}) {
++ if (typeof fetchImpl !== 'function') throw new TypeError('createComputePriceClient: no fetch available; pass fetchImpl');
++ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('createComputePriceClient: timeoutMs must be a positive number');
++ if (!Number.isInteger(maxRetries) || maxRetries < 0) throw new TypeError('createComputePriceClient: maxRetries must be 0 or more');
++ const site = trimSlash(baseUrl);
++ if (!/^https?:\/\/[^/]+/i.test(site)) throw new TypeError('createComputePriceClient: baseUrl is an http(s) origin');
++ return {
++ async computePrices({ kind = null } = {}, { signal } = {}) {
++ computeKind(kind);
++ const url = `${site}${DEFAULT_PATHS.computePrices}`;
++ let attempt = 0;
++ for (;;) {
++ const link = linkedSignal(signal, timeoutMs);
++ let res;
++ try {
++ res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(agent ? { 'User-Agent': agent } : {}) }, signal: link.signal });
++ } catch (err) {
++ link.done();
++ if (signal && signal.aborted) throw new ExchangeError('the request was aborted');
++ if (attempt < maxRetries) { attempt++; await sleep(backoffMs(attempt - 1, retryBaseMs, random)); continue; }
++ throw new ExchangeError(`the exchange is unreachable: ${err.message}`);
++ }
++ const body = await res.json().catch(() => null);
++ link.done();
++ if (res.ok) return computeBook(body, kind);
++ if (RETRY_STATUS.has(res.status) && attempt < maxRetries) { attempt++; await sleep(backoffMs(attempt - 1, retryBaseMs, random)); continue; }
++ const { message, code } = openAiError(body, res.status);
++ throw new ExchangeError(message, { status: res.status, code, body });
++ }
++ },
++ };
++}
++
/** A public, keyless reader for Exchange's model and provider search. */
function createCatalogSearchClient({ baseUrl = CATALOG_SEARCH_URL, fetchImpl = globalThis.fetch } = {}) {
return {
ddiff --git a/test/exchange-compute.test.js b/test/exchange-compute.test.js
nnew file mode 100644
iindex 0000000..c3b6134
---- /dev/null
++++ b/test/exchange-compute.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++/** openvibe-sdk/exchange: the public, keyless compute price book. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createExchangeClient, createComputePriceClient, ExchangeError } = require('../src/exchange');
++
++const BOOK = {
++ checked_at: '2026-11-11T00:00:00.000Z',
++ prices: [
++ { id: 'daytona-4090-preemptible', provider: 'daytona', kind: 'gpu', gpu: 'RTX 4090', gpu_mb: 24576, hourly: 0.57, per_second: 0.57 / 3600, interruptible: true, checked_at: '2026-11-11T00:00:00.000Z', expires_at: Date.parse('2026-11-18T00:00:00Z'), source: 'https://www.daytona.io/pricing' },
++ { id: 'fly-iad-2x2', provider: 'fly', region: 'iad', kind: 'cpu', cpu: 2, ram_mb: 2048, hourly: 13.39 / 720, per_second: 13.39 / 720 / 3600, checked_at: '2026-11-11T00:00:00.000Z', expires_at: Date.parse('2026-11-18T00:00:00Z'), source: 'https://docs.fly.io/about/pricing' },
++ ],
++};
++
++run([
++ ['createComputePriceClient reads the public book with no key and filters by kind', async () => {
++ const server = await stubServer(async (req, res) => {
++ assert.equal(req.url, '/api/compute/prices');
++ assert.equal(req.headers.authorization, undefined);
++ assert.equal(req.headers.accept, 'application/json');
++ send(res, 200, BOOK);
++ });
++ try {
++ const compute = createComputePriceClient({ baseUrl: server.url, maxRetries: 0 });
++ const all = await compute.computePrices();
++ assert.equal(all.checked_at, BOOK.checked_at);
++ assert.equal(all.prices.length, 2);
++ const gpus = await compute.computePrices({ kind: 'gpu' });
++ assert.deepEqual(gpus.prices.map((p) => p.id), ['daytona-4090-preemptible']);
++ assert.equal(gpus.checked_at, BOOK.checked_at);
++ const cpus = await compute.computePrices({ kind: 'cpu' });
++ assert.deepEqual(cpus.prices.map((p) => p.id), ['fly-iad-2x2']);
++ await assert.rejects(compute.computePrices({ kind: 'tpu' }), TypeError);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['the keyed client also reads prices, from Exchange\'s site and without its key', async () => {
++ const server = await stubServer(async (req, res) => {
++ assert.equal(req.url, '/api/compute/prices');
++ assert.equal(req.headers.authorization, undefined);
++ send(res, 200, BOOK);
++ });
++ try {
++ const exchange = createExchangeClient({ developerKey: 'dev_key', siteUrl: server.url, maxRetries: 0 });
++ const book = await exchange.computePrices({ kind: 'cpu' });
++ assert.deepEqual(book.prices.map((p) => p.id), ['fly-iad-2x2']);
++ assert.equal(book.checked_at, BOOK.checked_at);
++ assert.match(exchange.baseUrl(), /^https:\/\/api\.openvibe\.exchange$/);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['a refused or broken price book is an ExchangeError, and options are checked', async () => {
++ const server = await stubServer(async (req, res) => send(res, 503, { detail: 'compute prices unavailable' }));
++ try {
++ const compute = createComputePriceClient({ baseUrl: server.url, maxRetries: 0 });
++ await assert.rejects(compute.computePrices(), (err) => err instanceof ExchangeError && err.status === 503 && /unavailable/.test(err.message));
++ assert.throws(() => createComputePriceClient({ baseUrl: 'nope' }), /baseUrl/);
++ assert.throws(() => createExchangeClient({ developerKey: 'k', siteUrl: 'nope' }), /siteUrl/);
++ } finally {
++ await server.close();
++ }
++ }],
++]);
ddiff --git a/types/exchange.d.ts b/types/exchange.d.ts
iindex 926a3f6..d558a63 100644
---- a/types/exchange.d.ts
++++ b/types/exchange.d.ts
@@@ -23,6 +23,7 @@ export type ExchangePaths = {
sellerOffers?: string;
offerReport?: string;
offerList?: string;
++ computePrices?: string;
};
export type ExchangeOptions = {
@@@ -34,6 +35,8 @@ export type ExchangeOptions = {
baseUrl?: string;
/** The Network's origin, where the user-owned AI offers live; default https://api.openvibe.network. */
networkUrl?: string;
++ /** Exchange's public site, where the compute price book lives; default https://openvibe.exchange. */
++ siteUrl?: string;
fetchImpl?: typeof fetch;
/** Per request, default 60000. */
timeoutMs?: number;
@@@ -48,6 +51,42 @@ export type ExchangeOptions = {
agent?: string;
};
++export type ComputePriceKind = 'cpu' | 'gpu';
++export interface ComputePrice {
++ id: string;
++ provider: string;
++ region?: string | null;
++ kind: ComputePriceKind;
++ cpu?: number | null;
++ ram_mb?: number | null;
++ gpu?: string | null;
++ gpu_mb?: number | null;
++ currency: string;
++ hourly: number;
++ per_second: number;
++ billing_seconds?: number | null;
++ promotion?: string | null;
++ promotion_expires?: string | null;
++ interruptible?: boolean;
++ checked_at: string;
++ /** Epoch milliseconds. */
++ expires_at: number;
++ source: string;
++ [field: string]: unknown;
++}
++export interface ComputePriceBook {
++ checked_at: string;
++ prices: ComputePrice[];
++ [field: string]: unknown;
++}
++export interface ComputePriceOptions {
++ /** Only this kind: a computer (cpu) or a GPU. */
++ kind?: ComputePriceKind;
++}
++export interface ComputePriceClient {
++ computePrices(query?: ComputePriceOptions, opts?: { signal?: AbortSignal }): Promise<ComputePriceBook>;
++}
++
export type ExchangeChatRequest = {
model: string;
messages: { role: string; content: string | unknown[] | null; [field: string]: unknown }[];
@@@ -367,6 +406,8 @@ export type ExchangeClient = {
offers(model: string, opts?: { signal?: AbortSignal }): Promise<ExchangeOffer[]>;
/** The whole public catalog. */
catalog(opts?: { signal?: AbortSignal }): Promise<ExchangeCatalog>;
++ /** The reviewed compute tariffs, keyless from the public site; `kind` filters the list. */
++ computePrices(query?: ComputePriceOptions, opts?: { signal?: AbortSignal }): Promise<ComputePriceBook>;
presets: {
list(opts?: { signal?: AbortSignal }): Promise<ExchangePreset[]>;
create(input: { name: string; policy: ExchangePresetPolicy }, opts?: { signal?: AbortSignal }): Promise<ExchangePreset>;
@@@ -404,6 +445,7 @@ export type ExchangeClient = {
};
export declare function createExchangeClient(options?: ExchangeOptions): ExchangeClient;
++export declare function createComputePriceClient(options?: { baseUrl?: string; fetchImpl?: typeof fetch; timeoutMs?: number; maxRetries?: number; retryBaseMs?: number; agent?: string }): ComputePriceClient;
/** The same check as exchange.webhooks.verify, for a receiver that only verifies webhooks. */
export declare function verifyWebhook(rawBody: string | Uint8Array | ArrayBufferView, signatureHeader: string, secret: string, opts?: { toleranceSec?: number; now?: number }): boolean;
export declare const SIGNATURE_TOLERANCE_SEC: number;