openvibers/openvibe.sdk Commits

Add openvibe-sdk/exchange: the buyer and seller side of the model exchange (0.89.0) (#123)

OP OpenVibers committed 7 days ago · df8d2493b9 · 16 files, +1518 −16

* Add openvibe-sdk/exchange: the buyer and seller side of the model exchange

openvibe-sdk/exchange is the Exchange's client (the plan of 2026-11-06, "For
builders", item 7): the buyer side speaks the Exchange's v1 API with a Network
developer key, and the seller side registers an offer in the Network's
user-owned AI offers.

The buyer side: chat() (streaming and not, the OpenAI shape), models(),
offers(model), catalog(), presets() (list, create, update, delete), usage() and
receipt(id). Every call takes an AbortSignal, and a 429 or a 5xx is retried with
a jittered backoff: a safe method always, a write only when it is idempotent,
so a completion is never run twice.

The seller side: offer({model, price, hours, concurrency, endpoint, machineId,
owner}) writes to the same registry OpenVibe.Node's "Offer to the network"
writes to (POST /internal/ai/offers/report, the capability
network.resource.report), with the document that registry validates, field for
field; the SDK refuses an offer the registry would refuse before anything is
sent. earnings() and verification() read the seller dashboard: the tokens
served, the credits earned, the canaries, the spot re-runs, the reputation and
the uptime.

The webhooks (usage, earnings, an offer going down) carry
X-OpenVibe-Signature-V2; webhooks.verify checks the raw body in constant time
and refuses a timestamp more than 300 s either way.

The version is 0.89.0: 0.88.0 was taken while this branch was open. No tag is
made here; the PR is left for review.

* Merge main into feat/sdk-exchange

main moved to 0.88.0 (openvibe-sdk/share learns locked items, #121) while this
branch was open, so main is merged in and the exchange work sits on top of it.

The version is 0.89.0, the changelog keeps the 0.88.0 section under the new
one, and the generated files (the browser bundle, the API surface, the ESM
entries) are regenerated from the merged sources.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0fa4cf6..02597da 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,12 @@
# Changelog
++## 0.89.0 (2026-11-06)
++
++- Add `openvibe-sdk/exchange`: OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, "For builders", item 7).
++ - The buyer side speaks the Exchange’s v1 API with a Network developer key: `chat()` (streaming and not, the OpenAI shape), `models()`, `offers(model)`, `catalog()`, `presets()` (list, create, update, delete), `usage()` and `receipt(id)`. Every call takes an `AbortSignal`, and a 429 or a 5xx is retried with a jittered backoff: a safe method always, a write only when it is idempotent, so a completion is never run twice.
++ - The seller side registers an offer in the Network’s user-owned AI offers — the same registry OpenVibe.Node’s "Offer to the network" writes to (`POST /internal/ai/offers/report`, the capability `network.resource.report`), so any server that speaks the OpenAI API can be a provider and not only a Node. The SDK sends the document that registry validates, field for field, and refuses an offer the registry would refuse before anything is sent; `earnings()` and `verification()` read the seller dashboard (the tokens served, the credits earned, the canaries, the spot re-runs, the reputation, the uptime).
++ - The webhooks (usage, earnings, an offer going down) carry `X-OpenVibe-Signature-V2`; `webhooks.verify` checks the raw body in constant time and refuses a timestamp more than 300 s either way.
++
## 0.88.0 (2026-11-06)
- `openvibe-sdk/share` learns locked items (Locker). A share records how its item is sealed — `lock: none | people | password | link`, set by `register(ref, { lock })` and answered by GET — and `describe()` cannot override it. A locked item is never public (`share.lock_public`), and the lock cannot change after registration (`share.lock_fixed`). Expand-only migration: `shares.lock`, `share_members.key_slot`, `share_transfers.key_slot`.
ddiff --git a/README.md b/README.md
iindex e40e237..fd2439d 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.88.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.89.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.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.88.0 is tagged:
++After v0.89.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.88.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.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).
@@@ -220,6 +220,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.Node’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/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 5efb386..a3e8e73 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,12 +3,13 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.88.0",
-- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
++ "version": "0.89.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",
"contracts": "openvibe-contracts v0.292.0",
"features": [
++ "exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"the 1.0 core surface is frozen now, before 1.0 (core, auth, events, db, media): docs/one-point-oh.json gives every export its tier, test/surface.test.js pins the exact names, and removing or renaming one needs a major release; createCodeVerifier and pkceChallenge left in 0.86.0",
"share (openvibe-sdk/share): transactional local sharing, audience and membership checks, exact-name resolution, recipient-bound ownership transfers, audit, durable events/notifications, uniform API and agent client; locked items (Locker): the lock kind, per-member key slots checked against the key directory, a re-seal on removal through onReseal, and the transfer offer slot",
@@@ -31,7 +32,7 @@
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",
-- "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter \u2014 validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
++ "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter — validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
"the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js",
"the telemetry sink (openvibe-sdk/telemetry): telemetrySchema() DDL, createPgTelemetrySink() and pruneTelemetry(), a per-service PostgreSQL table every service stores platform.telemetry-sample@1 into",
"account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
@@@ -40,7 +41,7 @@
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
-- "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, \u00b1300 s) for developer apps"
++ "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps"
],
"updated": "2026-11-06"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex c7e7c7e..5ea1f11 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.88.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.89.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.88.0';
++const SDK_VERSION = '0.89.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 9db417a..1d97336 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -567,6 +567,30 @@
"verifyDelivery": "function",
"verifyDeliveryV2": "function"
},
++ "openvibe-sdk/exchange": {
++ "DEFAULT_PATHS": {
++ "object": [
++ "catalog",
++ "catalogModels",
++ "chat",
++ "models",
++ "offerList",
++ "offerReport",
++ "platformUsage",
++ "presets",
++ "receipts",
++ "sell",
++ "usage"
++ ]
++ },
++ "EXCHANGE_BASE_URL": "string",
++ "ExchangeError": "class",
++ "NETWORK_BASE_URL": "string",
++ "RETRY_STATUS": "object",
++ "SIGNATURE_TOLERANCE_SEC": "number",
++ "createExchangeClient": "function",
++ "verifyWebhook": "function"
++ },
"openvibe-sdk/explicit": {
"isExplicit": "function"
},
ddiff --git a/docs/exchange.md b/docs/exchange.md
nnew file mode 100644
iindex 0000000..5080c43
---- /dev/null
++++ b/docs/exchange.md
@@@ -0,0 +1,147 @@
++# openvibe-sdk/exchange
++
++OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, "For builders",
++item 7). The buyer side speaks the Exchange's v1 API with a Network developer key; the seller side registers an
++offer in the Network's user-owned AI offers, the same registry OpenVibe.Node's "Offer to the network" uses.
++
++```js
++const { createExchangeClient } = require('openvibe-sdk/exchange');
++
++const exchange = createExchangeClient({ developerKey: process.env.OPENVIBE_DEVELOPER_KEY });
++
++const answer = await exchange.chat({
++ model: 'openvibe',
++ messages: [{ role: 'user', content: 'Write a haiku about the sea.' }],
++});
++// { id: 'chatcmpl-…', object: 'chat.completion', choices, usage, x_openvibe: { run_id, cost_usd, … } }
++```
++
++## The buyer side
++
++Every call takes an `AbortSignal`, and a 429 or a 5xx is retried with a jittered backoff. A safe method is
++always retried; a write is only retried when it is idempotent (the seller report is), because a completion that ran
++twice is a completion billed twice.
++
++| call | where it goes | what it answers |
++|---|---|---|
++| `chat(body, {signal})` | `POST /v1/chat/completions` | the OpenAI shape: `{id, object, created, model, choices, usage, x_openvibe}` |
++| `models({signal})` | `GET /v1/models` | `{object: 'list', data: [...]}` |
++| `offers(model, {signal})` | `GET /api/v1/exchange/models/:org/:model` | the offers that serve one model, with the price, the measured speed and the uptime |
++| `catalog({signal})` | `GET /api/v1/exchange/catalog` | every model and its offers |
++| `presets.list({signal})` | `GET /api/v1/presets` | the five built-in routing profiles and the caller's own |
++| `presets.create({name, policy})` | `POST /api/v1/presets` | the stored preset |
++| `presets.update(name, {policy})` | `POST /api/v1/presets` | the replaced preset (the platform stores a preset under its name; there is no PATCH) |
++| `presets.remove(name)` | `DELETE /api/v1/presets/:name` | the removal |
++| `usage({days})` | `GET /api/v1/exchange/usage` | the public aggregate: requests, tokens and spend by day, by model and by product |
++| `usage({from, to, requester})` | `GET /api/v1/usage` | the platform's own usage for that requester (the capability `ai.usage.read`) |
++| `receipt(id, {signal})` | `GET /v1/receipts/:id` | the receipt of one answered request |
++
++A receipt says, in one place, the offer that answered, the price it charged, the tokens, why the router chose it,
++the fallbacks it would have used, who paid and the privacy label of the answer. It never carries the prompt or the
++answer.
++
++### Streaming
++
++`stream: true` answers an async iterable of chunks instead of the finished completion. The last chunk carries the
++OpenVibe fields (`x_openvibe`); the stream ends with `data: [DONE]`.
++
++```js
++for await (const chunk of exchange.chat({ model: 'openvibe', messages, stream: true })) {
++ process.stdout.write(chunk.choices[0]?.delta?.content || '');
++}
++```
++
++### A preset
++
++A preset is a saved routing profile: the objective (cheapest, fastest, balanced, private), the floor, the ceiling,
++the max price, the quantizations, the regions, the privacy class, whether a community machine may answer and the
++adult gate. A request names one as `model: '@preset/<name>'`.
++
++## The seller side
++
++The seller registers an offer in the Network's user-owned AI offers: the same registry OpenVibe.Node's "Offer to the
++network" writes to (`POST /internal/ai/offers/report`, the capability `network.resource.report`), so any server
++that speaks the OpenAI API can be a provider and not only a Node. There is one offer API and this is it; the SDK
++sends the document that registry validates, field for field.
++
++```js
++const offer = await exchange.seller.offer({
++ model: 'qwen3:32b',
++ price: { in_per_million: 0, out_per_million: 0 }, // 0 volunteers the model
++ hours: { start: '18:00', end: '23:30', tz: 'Europe/Lisbon' },
++ concurrency: 2,
++ endpoint: 'https://my-server.example/v1', // the OpenAI-compatible server
++ machineId: 'dev_…', // the machine the offer belongs to
++ owner: 'usr_…', // the person who owns it
++});
++// { offer: {offer_id: 'ai:dev_…', models, hours, concurrency, price, audience, ready, health}, report, endpoint }
++```
++
++The offer document is strict: the registry refuses a field it does not know, so the SDK sends exactly its shape
++(`offer_id`, `machine_id`, `owner`, `models`, `hours`, `concurrency`, `price`, `audience`, `group`,
++`ready`, `health`). The hours are one local window (start == end is all day, an overnight window wraps), the
++concurrency is 1 to 8, the price is credits per million tokens, and the audience is `everyone`, `followers` or
++`group` (with a group id). The SDK refuses an offer the registry would refuse before anything is sent.
++
++The endpoint is checked first: the SDK asks the server for its OpenAI model list, so a server that does not speak
++the OpenAI API is caught before the offer is registered (`probe: false` turns that off). The registry's offer
++document has no slot for a server address, so the router reaches a community offer over the seller's registered
++machine; a standalone server is registered by its address once the Exchange's seller route lands.
++
++### Earnings and verification
++
++```js
++const { totals, offers } = await exchange.seller.earnings();
++// totals: { requests, tokens_in, tokens_out, pending_credits_micro, spendable_credits_micro, self_served_requests }
++
++const { offers } = await exchange.seller.verification();
++// offers[0]: { verification: { status, canary_passes, canary_failures, spot_runs, spot_failures, … },
++// reputation, free_only, uptime_pct, verified }
++```
++
++The owner is named in `X-OV-Subject`; pass `owner` to the client or to the call. Before any traffic, the router
++runs canary prompts with known answers and checks the model id the server reports against the one the owner
++declared; about 1% of the answers are re-run on another trusted machine with the same model and compared. A new
++machine serves the free tier only until it passes, and a failure stops the traffic to it.
++
++## Webhooks
++
++The usage, the earnings and the offer-down events carry `X-OpenVibe-Signature-V2`: `t=<unix seconds>`,
++`v2=<hex HMAC-SHA256 of "<t>.<raw body>">`. The check is in constant time and refuses a timestamp more than 300
++seconds either way; the platform's legacy `sha256=<hex>` of the body alone is accepted too (it has no timestamp,
++so it is replayable).
++
++```js
++const { verifyWebhook } = require('openvibe-sdk/exchange');
++
++app.post('/webhooks/exchange', (req, res) => {
++ if (!verifyWebhook(req.rawBody, req.headers['x-openvibe-signature-v2'], process.env.OPENVIBE_WEBHOOK_SECRET)) {
++ return res.status(400).end();
++ }
++ res.end('ok');
++});
++```
++
++Always pass the raw body: a re-serialised object changes the bytes and the signature stops matching.
++
++## Errors
++
++A refusal becomes an `ExchangeError` with the status, the code and the body (`status`, `code`, `body`,
++`retryAfter`, and `retryable` for a status worth another try). The OpenAI-shaped `{error: {message, type, code,
++param}}` answer and the platform's problem+json are both read.
++
++## The client
++
++```
++createExchangeClient({
++ developerKey, // the Network developer key (Bearer)
++ networkKey, // the key on the Network's own origin; defaults to the developer key
++ baseUrl, // default https://api.openvibe.exchange
++ networkUrl, // default https://api.openvibe.network
++ fetchImpl, timeoutMs, // default 60000
++ maxRetries, retryBaseMs, // default 3 and 250
++ owner, // the seller subject (usr_…)
++ paths, // the paths, when a deployment moves them
++ agent,
++})
++```
ddiff --git a/esm/exchange.mjs b/esm/exchange.mjs
nnew file mode 100644
iindex 0000000..796db29
---- /dev/null
++++ b/esm/exchange.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/exchange.js. Do not edit.
++import cjs from '../src/exchange.js';
++
++export const {
++ 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 d85daac..d9b1174 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.88.0",
++ "version": "0.89.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.88.0",
++ "version": "0.89.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 257843e..1ab7626 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.88.0",
++ "version": "0.89.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",
@@@ -180,6 +180,12 @@
"import": "./esm/vibes.mjs",
"default": "./src/vibes.js"
},
++ "./exchange": {
++ "types": "./types/exchange.d.ts",
++ "browser": null,
++ "import": "./esm/exchange.mjs",
++ "default": "./src/exchange.js"
++ },
"./delivery": {
"types": "./types/delivery.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 008ea84..71fe21a 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -47,6 +47,7 @@ const ENTRIES = {
'runtime.mjs': '../src/runtime.js',
'storage.mjs': '../src/storage.js',
'commerce.mjs': '../src/commerce.js',
++ 'exchange.mjs': '../src/exchange.js',
'vibes.mjs': '../src/vibes.js',
'delivery.mjs': '../src/delivery.js',
'node.mjs': '../src/node.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 0d4d78a..545a9fd 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.88.0';
++const SDK_VERSION = '0.89.0';
module.exports = {
createClient,
ddiff --git a/src/exchange.js b/src/exchange.js
nnew file mode 100644
iindex 0000000..1ab8f90
---- /dev/null
++++ b/src/exchange.js
@@@ -0,0 +1,600 @@
++'use strict';
++/**
++ * openvibe-sdk/exchange: OpenVibe.Exchange, the buyer and the seller side of the model exchange
++ * (the plan of 2026-11-06, "For builders", item 7).
++ *
++ * 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).
++ * 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 });
++ * const answer = await exchange.chat({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] });
++ * // { id: 'chatcmpl-...', object: 'chat.completion', choices, usage, x_openvibe: { run_id, cost_usd, ... } }
++ * for await (const chunk of exchange.chat({ model: 'openvibe', messages, stream: true })) { ... }
++ *
++ * The seller side registers an offer in the Network's user-owned AI offers - the same registry OpenVibe.Node's
++ * "Offer to the network" uses (POST /internal/ai/offers/report, the capability network.resource.report), so any
++ * server that speaks the OpenAI API can be a provider and not only a Node. The offer document is the one that
++ * registry validates: the offer id, the machine, the owner, the models, the hours, the concurrency, the credit
++ * price, the audience, the ready flag and the health.
++ *
++ * const offer = await exchange.seller.offer({
++ * model: 'qwen3:32b', price: { in_per_million: 0, out_per_million: 0 },
++ * hours: { start: '18:00', end: '23:30', tz: 'Europe/Lisbon' }, concurrency: 2,
++ * endpoint: 'https://my-server.example/v1', machineId: 'dev_...', owner: 'usr_...',
++ * });
++ * const { totals, offers } = await exchange.seller.earnings();
++ *
++ * A 429 or a 5xx is retried with a jittered backoff (a safe method always, a write only when it is idempotent),
++ * and every call takes an AbortSignal.
++ *
++ * Webhooks (usage, earnings, an offer going down) carry X-OpenVibe-Signature-V2: t=<unix seconds>,
++ * v2=<hex HMAC-SHA256 of "<t>.<raw body>">. webhooks.verify checks it in constant time and refuses a timestamp
++ * more than 300 s either way; the platform's legacy sha256=<hex> of the body alone is accepted too.
++ */
++
++const crypto = require('node:crypto');
++
++/** The Exchange's own origin. */
++const EXCHANGE_BASE_URL = 'https://api.openvibe.exchange';
++/** The Network's origin: the user-owned AI offers live there. */
++const NETWORK_BASE_URL = 'https://api.openvibe.network';
++/** How far a signed webhook's timestamp may be from this server's clock, in seconds. */
++const SIGNATURE_TOLERANCE_SEC = 300;
++
++/** The statuses a call is retried on: a refusal is not retried, a busy or broken server is. */
++const RETRY_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
++const SAFE_METHODS = new Set(['GET', 'HEAD']);
++
++/** Where each call goes. A deployment may point another one at these paths. */
++const DEFAULT_PATHS = {
++ chat: '/v1/chat/completions',
++ models: '/v1/models',
++ receipts: '/v1/receipts',
++ presets: '/api/v1/presets',
++ catalog: '/api/v1/exchange/catalog',
++ catalogModels: '/api/v1/exchange/models',
++ usage: '/api/v1/exchange/usage',
++ platformUsage: '/api/v1/usage',
++ sell: '/api/v1/community/sell',
++ offerReport: '/internal/ai/offers/report',
++ offerList: '/internal/ai/offers',
++};
++
++class ExchangeError extends Error {
++ constructor(message, { status = null, code = null, body = null, retryAfter = null } = {}) {
++ super(message);
++ this.name = 'ExchangeError';
++ this.status = status;
++ this.code = code;
++ this.body = body;
++ this.retryAfter = retryAfter;
++ }
++ /** No status (the network, a timeout, a token), 5xx, 429 and 401 are worth retrying; a refusal is not. */
++ get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401; }
++}
++
++function trimSlash(s) { return String(s).replace(/\/+$/, ''); }
++
++function toBuffer(raw) {
++ if (Buffer.isBuffer(raw)) return raw;
++ if (raw instanceof Uint8Array) return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
++ return Buffer.from(String(raw));
++}
++
++function hmac(secret, ...parts) {
++ const h = crypto.createHmac('sha256', String(secret));
++ for (const p of parts) h.update(p);
++ return h.digest('hex');
++}
++
++/** Constant-time compare of two lower-case hex digests of the same length. */
++function sameHex(given, expected) {
++ if (typeof given !== 'string' || !/^[0-9a-f]{64}$/i.test(given)) return false;
++ const a = Buffer.from(given, 'hex');
++ const b = Buffer.from(expected, 'hex');
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/**
++ * Parse a signature header into { t, digests[] } (timestamped) or { t: null, digests[] }
++ * (body only). Unknown fields are ignored; a malformed or empty header returns null.
++ */
++function parseSignature(header) {
++ const s = String(header || '').trim();
++ if (!s) return null;
++ const bare = s.match(/^(?:sha256=)?([0-9a-f]{64})$/i);
++ if (bare) return { t: null, digests: [bare[1].toLowerCase()] };
++ let t = null;
++ const digests = [];
++ for (const part of s.split(',')) {
++ const i = part.indexOf('=');
++ if (i < 0) return null;
++ const key = part.slice(0, i).trim();
++ const value = part.slice(i + 1).trim();
++ if (key === 't') {
++ if (t !== null || !/^\d{1,12}$/.test(value)) return null;
++ t = Number(value);
++ } else if (key === 'v1' || key === 'v2') {
++ if (!/^[0-9a-f]{64}$/i.test(value)) return null;
++ digests.push(value.toLowerCase());
++ }
++ }
++ if (t === null || !digests.length) return null;
++ return { t, digests };
++}
++
++/**
++ * Verify an Exchange webhook against the RAW request body (never a re-serialised object).
++ *
++ * verifyWebhook(rawBody, signatureHeader, secret)
++ * verifyWebhook(rawBody, signatureHeader, secret, { toleranceSec: 300, now: Date.now() })
++ *
++ * Returns true only for a valid signature; malformed input is false, never an exception.
++ */
++function verifyWebhook(rawBody, signatureHeader, secret, { toleranceSec = SIGNATURE_TOLERANCE_SEC, now = Date.now() } = {}) {
++ if (!Number.isFinite(toleranceSec) || toleranceSec < 0) throw new TypeError('verifyWebhook: toleranceSec must be a number of seconds >= 0');
++ if (rawBody == null || typeof signatureHeader !== 'string' || !secret) return false;
++ const parsed = parseSignature(signatureHeader);
++ if (!parsed) return false;
++ let expected;
++ if (parsed.t !== null) {
++ if (Math.abs(Number(now) / 1000 - parsed.t) > toleranceSec) return false;
++ expected = hmac(secret, `${parsed.t}.`, toBuffer(rawBody));
++ } else {
++ expected = hmac(secret, toBuffer(rawBody));
++ }
++ let ok = false;
++ for (const digest of parsed.digests) if (sameHex(digest, expected)) ok = true;
++ return ok;
++}
++
++/** A signal that ends when the call's own signal or its timeout does. */
++function linkedSignal(signal, timeoutMs) {
++ const controller = new AbortController();
++ const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
++ const onAbort = () => controller.abort(signal.reason);
++ if (signal) {
++ if (signal.aborted) controller.abort(signal.reason);
++ else signal.addEventListener('abort', onAbort, { once: true });
++ }
++ return {
++ signal: controller.signal,
++ done() { clearTimeout(timer); if (signal) signal.removeEventListener('abort', onAbort); },
++ };
++}
++
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++/** The backoff before the next attempt: exponential, with a jitter, and never longer than 30 s. */
++function backoffMs(attempt, baseMs, random) {
++ const ceiling = Math.min(30000, baseMs * Math.pow(2, attempt));
++ return Math.round(ceiling * (0.5 + random() * 0.5));
++}
++
++function queryOf(query) {
++ const parts = [];
++ for (const [key, value] of Object.entries(query || {})) {
++ if (value == null || value === '') continue;
++ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
++ }
++ return parts.length ? `?${parts.join('&')}` : '';
++}
++
++/** The error an OpenAI-shaped answer carries: {error: {message, type, code, param}}. */
++function openAiError(body, status) {
++ const error = body && typeof body === 'object' ? body.error : null;
++ if (error && typeof error === 'object') {
++ return { message: String(error.message || 'the request failed'), code: error.code || null, type: error.type || null };
++ }
++ if (body && typeof body === 'object' && (body.detail || body.title || body.code)) {
++ return { message: String(body.detail || body.title || body.code), code: body.code || null, type: null };
++ }
++ return { message: `the request failed with status ${status}`, code: null, type: null };
++}
++
++/** The chunks of one SSE block; [DONE] ends the stream. */
++function parseSse(block) {
++ const out = [];
++ for (const line of String(block).split('\n')) {
++ if (!line.startsWith('data:')) continue;
++ const data = line.slice(5).trim();
++ if (!data || data === '[DONE]') continue;
++ try { out.push(JSON.parse(data)); } catch { /* a partial or unknown event is skipped */ }
++ }
++ return out;
++}
++
++/**
++ * The SSE stream of a chat completion, chunk by chunk. The last chunk carries the OpenVibe
++ * fields (x_openvibe); the stream ends with data: [DONE].
++ */
++async function* sseChunks(res) {
++ const body = res.body;
++ if (!body || typeof body.getReader !== 'function') {
++ const text = await res.text();
++ for (const chunk of parseSse(text)) yield chunk;
++ return;
++ }
++ const reader = body.getReader();
++ const decoder = new TextDecoder();
++ let buffer = '';
++ for (;;) {
++ const { done, value } = await reader.read();
++ if (done) break;
++ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
++ let cut = buffer.indexOf('\n\n');
++ while (cut >= 0) {
++ const raw = buffer.slice(0, cut);
++ buffer = buffer.slice(cut + 2);
++ for (const chunk of parseSse(raw)) yield chunk;
++ cut = buffer.indexOf('\n\n');
++ }
++ }
++ buffer += decoder.decode().replace(/\r\n/g, '\n');
++ for (const chunk of parseSse(buffer)) yield chunk;
++}
++
++/** The hours an offer is open: {start, end, tz}; start == end is all day and an overnight window wraps. */
++function checkHours(hours) {
++ const h = hours || {};
++ const hhmm = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
++ if (!hhmm.test(String(h.start)) || !hhmm.test(String(h.end))) throw new TypeError('offer.hours: start and end are HH:MM (24-hour)');
++ const tz = h.tz == null || h.tz === '' ? 'UTC' : String(h.tz);
++ if (tz.length > 64) throw new TypeError('offer.hours: tz is at most 64 characters');
++ return { start: String(h.start), end: String(h.end), tz };
++}
++
++/** The credit price per million tokens: a number for both sides, or {in_per_million, out_per_million}. */
++function checkPrice(price) {
++ const isAmount = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1000000;
++ if (typeof price === 'number') {
++ if (!isAmount(price)) throw new TypeError('offer.price: a price is a number from 0 to 1000000 credits per million tokens');
++ return { in_per_million: price, out_per_million: price };
++ }
++ if (!price || typeof price !== 'object') throw new TypeError('offer.price: a price is a number or {in_per_million, out_per_million}');
++ const input = price.in_per_million != null ? price.in_per_million : price.in;
++ const output = price.out_per_million != null ? price.out_per_million : price.out;
++ if (!isAmount(input) || !isAmount(output)) throw new TypeError('offer.price: in_per_million and out_per_million are numbers from 0 to 1000000');
++ return { in_per_million: input, out_per_million: output };
++}
++
++/**
++ * createExchangeClient({ developerKey, baseUrl, networkUrl, fetchImpl, timeoutMs, maxRetries, retryBaseMs, owner, paths })
++ *
++ * The developerKey is the Network developer key (Bearer). The networkKey is the key used on the Network's own
++ * origin when the seller side reports an offer; it defaults to the developer key. The seller side needs a key
++ * that holds the capability network.resource.report.
++ */
++function createExchangeClient({
++ developerKey,
++ networkKey = null,
++ baseUrl = EXCHANGE_BASE_URL,
++ networkUrl = NETWORK_BASE_URL,
++ fetchImpl = globalThis.fetch,
++ timeoutMs = 60000,
++ maxRetries = 3,
++ retryBaseMs = 250,
++ owner = null,
++ paths: pathOptions = {},
++ random = Math.random,
++ agent = null,
++} = {}) {
++ if (developerKey == null && networkKey == null) throw new TypeError('createExchangeClient: developerKey is required');
++ if (developerKey != null && (typeof developerKey !== 'string' || !developerKey)) throw new TypeError('createExchangeClient: developerKey is a non-empty string');
++ if (networkKey != null && (typeof networkKey !== 'string' || !networkKey)) throw new TypeError('createExchangeClient: networkKey is a non-empty string');
++ if (typeof fetchImpl !== 'function') throw new TypeError('createExchangeClient: no fetch available; pass fetchImpl');
++ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('createExchangeClient: timeoutMs must be a positive number');
++ if (!Number.isInteger(maxRetries) || maxRetries < 0) throw new TypeError('createExchangeClient: maxRetries must be 0 or more');
++ if (!Number.isFinite(retryBaseMs) || retryBaseMs < 0) throw new TypeError('createExchangeClient: retryBaseMs must be 0 or more');
++ const exchange = trimSlash(baseUrl);
++ const network = trimSlash(networkUrl);
++ 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');
++ const paths = { ...DEFAULT_PATHS, ...pathOptions };
++ const key = developerKey || networkKey;
++ const networkBearer = networkKey || developerKey;
++
++ /** One call: the fetch, the retries and the answer. → { res, json } */
++ async function call(method, path, { base = exchange, json, query, headers = {}, signal, accept = 'application/json', idempotent = false, bearer = key } = {}) {
++ 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 },
++ body: json !== undefined ? JSON.stringify(json) : undefined,
++ };
++ const mayRetry = SAFE_METHODS.has(method) || idempotent;
++ let attempt = 0;
++ for (;;) {
++ const link = linkedSignal(signal, timeoutMs);
++ let res;
++ try {
++ res = await fetchImpl(url, { ...send, signal: link.signal });
++ } catch (err) {
++ link.done();
++ if (signal && signal.aborted) throw new ExchangeError('the request was aborted');
++ if (attempt < maxRetries && mayRetry) {
++ attempt++;
++ await sleep(backoffMs(attempt - 1, retryBaseMs, random));
++ continue;
++ }
++ throw new ExchangeError(`the exchange is unreachable: ${err.message}`);
++ }
++ if (res.ok) {
++ if (accept === 'application/json') {
++ const data = await res.json().catch(() => null);
++ link.done();
++ return { res, json: data };
++ }
++ link.done();
++ return { res, json: null };
++ }
++ const retryAfter = Number(res.headers.get('retry-after'));
++ const data = await res.json().catch(() => null);
++ link.done();
++ if (RETRY_STATUS.has(res.status) && attempt < maxRetries && mayRetry) {
++ attempt++;
++ const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt - 1, retryBaseMs, random);
++ await sleep(wait);
++ continue;
++ }
++ const { message, code } = openAiError(data, res.status);
++ throw new ExchangeError(message, { status: res.status, code, body: data, retryAfter: Number.isFinite(retryAfter) ? retryAfter : null });
++ }
++ }
++
++ const jsonCall = async (method, path, options) => (await call(method, path, options)).json;
++
++ /** The model doc of the Exchange's public catalog: the offers that serve one model. */
++ async function modelDoc(model, { signal } = {}) {
++ const slug = String(model == null ? '' : model).trim().toLowerCase();
++ if (!slug) throw new TypeError('offers: a model is required');
++ const parts = slug.split('/').filter(Boolean);
++ const path = parts.length > 1
++ ? `${paths.catalogModels}/${parts.map((p) => encodeURIComponent(p)).join('/')}`
++ : `${paths.catalogModels}/${encodeURIComponent(slug)}`;
++ return await jsonCall('GET', path, { signal });
++ }
++
++ const seller = {
++ /**
++ * Register a community offer: what is served, at what price, when, and how many answers at once.
++ * The offer is reported to the Network's user-owned AI offers - the registry OpenVibe.Node's
++ * "Offer to the network" uses - with the document that registry validates.
++ *
++ * offer({ model, price, hours, concurrency, endpoint, machineId, owner, audience, group, ready, health })
++ */
++ async offer({
++ model,
++ models = null,
++ price,
++ hours,
++ concurrency,
++ endpoint = null,
++ endpointKey = null,
++ machineId,
++ owner: offerOwner = null,
++ audience = 'everyone',
++ group = null,
++ ready = true,
++ health = null,
++ source = 'sdk',
++ probe = true,
++ } = {}, { signal } = {}) {
++ const listed = models != null ? models : (model == null ? [] : [model]);
++ if (!Array.isArray(listed) || !listed.length) throw new TypeError('offer.model: a model is required');
++ if (listed.some((m) => typeof m !== 'string' || !m || m.length > 200)) throw new TypeError('offer.model: every model is a string of at most 200 characters');
++ const machine = String(machineId == null ? '' : machineId).trim();
++ if (!/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(machine)) throw new TypeError('offer.machineId: a machine id (dev_...) is required');
++ const subject = String(offerOwner || owner || '').trim();
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) throw new TypeError('offer.owner: an owner (usr_...) is required');
++ const cap = Number(concurrency);
++ if (!Number.isInteger(cap) || cap < 1 || cap > 8) throw new TypeError('offer.concurrency: an integer from 1 to 8');
++ if (!['everyone', 'followers', 'group'].includes(audience)) throw new TypeError('offer.audience: everyone, followers or group');
++ if (audience === 'group' && !(typeof group === 'string' && group.trim())) throw new TypeError('offer.group: a group id is required for the group audience');
++ const state = health || { status: 'up', checked_at: new Date().toISOString() };
++ if (!['up', 'degraded', 'down'].includes(state.status)) throw new TypeError('offer.health: up, degraded or down');
++ let address = null;
++ if (endpoint != null) {
++ let url;
++ try { url = new URL(String(endpoint)); } catch { throw new TypeError('offer.endpoint: an http(s) URL'); }
++ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new TypeError('offer.endpoint: an http(s) URL');
++ address = String(endpoint).replace(/\/+$/, '');
++ }
++ // The offer document is the one the registry validates: no field outside its shape is sent.
++ const document = {
++ offer_id: `ai:${machine}`,
++ machine_id: machine,
++ owner: subject,
++ models: listed,
++ hours: checkHours(hours),
++ concurrency: cap,
++ price: checkPrice(price),
++ audience,
++ group: audience === 'group' ? String(group).trim() : (group == null ? undefined : String(group)),
++ ready: ready === true,
++ health: { status: state.status, checked_at: state.checked_at || new Date().toISOString() },
++ };
++ if (document.group === undefined) delete document.group;
++ // The server is checked before the offer is registered: it must answer the OpenAI models list.
++ let checked = null;
++ if (address && probe) checked = await seller.probe({ endpoint: address, key: endpointKey, signal });
++ const report = await jsonCall('POST', paths.offerReport, {
++ base: network,
++ bearer: networkBearer,
++ json: { source, offers: [document] },
++ signal,
++ idempotent: true,
++ });
++ return { offer: document, report, endpoint: address ? { url: address, models: checked ? checked.models : null } : null };
++ },
++
++ /** Ask a server what it serves: the OpenAI models list at its endpoint. → { models } */
++ async probe({ endpoint, key = null, signal } = {}) {
++ let url;
++ try { url = new URL(String(endpoint || '').replace(/\/+$/, '') + '/models'); } catch { throw new TypeError('probe.endpoint: an http(s) URL'); }
++ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new TypeError('probe.endpoint: an http(s) URL');
++ const link = linkedSignal(signal, timeoutMs);
++ let res;
++ try {
++ res = await fetchImpl(url.toString(), { method: 'GET', headers: { Accept: 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) }, signal: link.signal });
++ } catch (err) {
++ link.done();
++ throw new ExchangeError(`the endpoint is unreachable: ${err.message}`);
++ }
++ const data = await res.json().catch(() => null);
++ link.done();
++ if (!res.ok) {
++ const { message, code } = openAiError(data, res.status);
++ throw new ExchangeError(message, { status: res.status, code, body: data });
++ }
++ const list = data && Array.isArray(data.data) ? data.data : [];
++ return { models: list.map((m) => (typeof m === 'string' ? m : m && m.id)).filter((m) => typeof m === 'string') };
++ },
++
++ /**
++ * The seller dashboard: the tokens served, the credits earned, the uptime, the verification and the
++ * reputation of every offer. The owner is named in X-OV-Subject.
++ */
++ async earnings({ owner: earningsOwner = null } = {}, { signal } = {}) {
++ const subject = String(earningsOwner || owner || '').trim();
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) throw new TypeError('earnings: an owner (usr_...) is required');
++ return await jsonCall('GET', paths.sell, { headers: { 'X-OV-Subject': subject }, signal });
++ },
++
++ /** The verification of every offer: canaries, the spot re-runs, the reputation and the uptime. */
++ async verification({ owner: verificationOwner = null } = {}, { signal } = {}) {
++ const sold = await seller.earnings({ owner: verificationOwner }, { signal });
++ const offers = Array.isArray(sold.offers) ? sold.offers : [];
++ return {
++ owner: sold.owner || null,
++ generated_at: sold.generated_at || null,
++ offers: offers.map((o) => ({
++ offer_id: o.offer_id,
++ machine_id: o.machine_id,
++ models: o.models || [],
++ health: o.health || null,
++ open_now: o.open_now == null ? null : o.open_now,
++ ready: o.ready === true,
++ verification: o.verification || null,
++ verified: o.verified === true,
++ reputation: o.reputation || 0,
++ free_only: o.free_only !== false,
++ uptime_pct: o.uptime_pct == null ? null : o.uptime_pct,
++ })),
++ };
++ },
++
++ /** The offers the Network holds, whole (the registry's own read). */
++ async offers({ owner: offerOwner = null, source: offerSource = null } = {}, { signal } = {}) {
++ return await jsonCall('GET', paths.offerList, {
++ base: network,
++ bearer: networkBearer,
++ query: { owner: offerOwner, source: offerSource },
++ signal,
++ });
++ },
++ };
++
++ return {
++ /**
++ * One chat completion. The body is the OpenAI shape; stream: true answers an async iterable of
++ * chunks instead of the finished completion.
++ */
++ async chat(body, { signal } = {}) {
++ if (!body || typeof body !== 'object') throw new TypeError('chat: a body is required');
++ const payload = { ...body };
++ const stream = payload.stream === true;
++ const out = await call('POST', paths.chat, { json: payload, signal, accept: stream ? 'text/event-stream' : 'application/json' });
++ if (stream) return sseChunks(out.res);
++ return out.json;
++ },
++
++ /** The models the Exchange can serve: the OpenAI models list. */
++ async models({ signal } = {}) {
++ return await jsonCall('GET', paths.models, { signal });
++ },
++
++ /** The offers that serve one model: its price, its measured speed and its uptime. */
++ async offers(model, { signal } = {}) {
++ const doc = await modelDoc(model, { signal });
++ return Array.isArray(doc.offers) ? doc.offers : [];
++ },
++
++ /** The whole public catalog: every model, its offers, its prices and its speed. */
++ async catalog({ signal } = {}) {
++ return await jsonCall('GET', paths.catalog, { signal });
++ },
++
++ /** The routing presets: the five built-in ones and the caller's own. */
++ presets: {
++ /** The presets the caller may name. */
++ async list({ signal } = {}) {
++ const out = await jsonCall('GET', paths.presets, { signal });
++ return Array.isArray(out && out.presets) ? out.presets : [];
++ },
++ /** Store one of the caller's own presets. → { preset } */
++ async create({ name, policy } = {}, { signal } = {}) {
++ const presetName = String(name == null ? '' : name).trim().toLowerCase();
++ if (!presetName) throw new TypeError('presets.create: a name is required');
++ if (!policy || typeof policy !== 'object') throw new TypeError('presets.create: a policy is required');
++ const out = await jsonCall('POST', paths.presets, { json: { name: presetName, policy }, signal });
++ return out && out.preset ? out.preset : out;
++ },
++ /**
++ * Replace one of the caller's own presets. The platform has no PATCH: a preset is stored under its
++ * name, so a second POST with the same name replaces it.
++ */
++ async update(name, { policy } = {}, { signal } = {}) {
++ const presetName = String(name == null ? '' : name).trim().toLowerCase();
++ if (!presetName) throw new TypeError('presets.update: a name is required');
++ if (!policy || typeof policy !== 'object') throw new TypeError('presets.update: a policy is required');
++ const out = await jsonCall('POST', paths.presets, { json: { name: presetName, policy }, signal });
++ return out && out.preset ? out.preset : out;
++ },
++ /** Delete one of the caller's own presets. */
++ async remove(name, { signal } = {}) {
++ const presetName = String(name == null ? '' : name).trim();
++ if (!presetName) throw new TypeError('presets.remove: a name is required');
++ return await jsonCall('DELETE', `${paths.presets}/${encodeURIComponent(presetName)}`, { signal });
++ },
++ },
++
++ /**
++ * The usage of the Exchange: by default the public aggregate (requests, tokens and spend by day, by
++ * model and by product); with from, to or requester, the platform's own usage for that requester,
++ * which needs the capability ai.usage.read.
++ */
++ async usage({ days = 30, from = null, to = null, requester = null } = {}, { signal } = {}) {
++ if (from || to || requester) {
++ return await jsonCall('GET', paths.platformUsage, { query: { from, to, requester }, signal });
++ }
++ const window = Math.max(1, Math.min(366, Number(days) || 30));
++ return await jsonCall('GET', paths.usage, { query: { days: window }, signal });
++ },
++
++ /** The receipt of one answered request: the offer, the price, the tokens and why it was chosen. */
++ async receipt(id, { signal } = {}) {
++ const receiptId = String(id == null ? '' : id).trim();
++ if (!receiptId) throw new TypeError('receipt: an id is required');
++ const out = await jsonCall('GET', `${paths.receipts}/${encodeURIComponent(receiptId)}`, { signal });
++ return out && out.receipt ? out.receipt : out;
++ },
++
++ seller,
++ webhooks: { verify: verifyWebhook },
++ baseUrl: () => exchange,
++ networkUrl: () => network,
++ };
++}
++
++module.exports = {
++ createExchangeClient,
++ ExchangeError,
++ verifyWebhook,
++ SIGNATURE_TOLERANCE_SEC,
++ EXCHANGE_BASE_URL,
++ NETWORK_BASE_URL,
++ DEFAULT_PATHS,
++ RETRY_STATUS,
++};
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 9de86d1..7e86a4d 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -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', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './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
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.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`);
ddiff --git a/test/exchange.test.js b/test/exchange.test.js
nnew file mode 100644
iindex 0000000..14c5888
---- /dev/null
++++ b/test/exchange.test.js
@@@ -0,0 +1,374 @@
++'use strict';
++/**
++ * openvibe-sdk/exchange against a fake Exchange and a fake Network: the buyer calls, the seller
++ * registration, the retries and the webhook signature.
++ */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { stubServer, send, problem, run } = require('./helpers');
++const { createExchangeClient, ExchangeError, verifyWebhook, SIGNATURE_TOLERANCE_SEC } = require('../src/exchange');
++
++const KEY = 'dev_key_for_tests';
++const OWNER = 'usr_' + '0'.repeat(26);
++const MACHINE = 'dev_' + '0'.repeat(26);
++
++/** A client pointed at the fake servers, with a backoff short enough for a test. */
++function client(exchange, network, options = {}) {
++ return createExchangeClient({
++ developerKey: KEY,
++ baseUrl: exchange.url,
++ networkUrl: (network || exchange).url,
++ retryBaseMs: 1,
++ maxRetries: 3,
++ ...options,
++ });
++}
++
++const completion = {
++ id: 'chatcmpl-run_1',
++ object: 'chat.completion',
++ created: 1760486400,
++ model: 'openvibe',
++ choices: [{ index: 0, message: { role: 'assistant', content: 'hello' }, finish_reason: 'stop' }],
++ usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
++ system_fingerprint: null,
++ x_openvibe: { run_id: 'run_1', provider: 'acme', fallback_used: false, cost_usd: 0.001, synthetic: false },
++};
++
++run([
++ ['chat answers the OpenAI shape', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ assert.equal(req.url, '/v1/chat/completions');
++ assert.equal(req.headers.authorization, 'Bearer ' + KEY);
++ send(res, 200, completion);
++ });
++ try {
++ const exchange = client(server);
++ const answer = await exchange.chat({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] });
++ assert.equal(answer.object, 'chat.completion');
++ assert.equal(answer.id, 'chatcmpl-run_1');
++ assert.equal(answer.usage.total_tokens, 5);
++ assert.equal(answer.x_openvibe.run_id, 'run_1');
++ const body = JSON.parse(String(server.requests[0].body));
++ assert.equal(body.model, 'openvibe');
++ assert.equal(body.messages[0].content, 'hi');
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['chat streams the chunks', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
++ res.write('data: ' + JSON.stringify({ id: 'chatcmpl-run_2', object: 'chat.completion.chunk', created: 1, model: 'openvibe', choices: [{ index: 0, delta: { role: 'assistant', content: 'he' }, finish_reason: null }] }) + '\n\n');
++ res.write('data: ' + JSON.stringify({ id: 'chatcmpl-run_2', object: 'chat.completion.chunk', created: 1, model: 'openvibe', choices: [{ index: 0, delta: { content: 'llo' }, finish_reason: 'stop' }] }) + '\n\n');
++ res.write('data: ' + JSON.stringify({ id: 'chatcmpl-run_2', object: 'chat.completion.chunk', created: 1, model: 'openvibe', choices: [], x_openvibe: { run_id: 'run_2' } }) + '\n\n');
++ res.write('data: [DONE]\n\n');
++ res.end();
++ });
++ try {
++ const exchange = client(server);
++ const stream = await exchange.chat({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }], stream: true });
++ const chunks = [];
++ for await (const chunk of stream) chunks.push(chunk);
++ assert.equal(chunks.length, 3);
++ assert.equal(chunks[0].choices[0].delta.content, 'he');
++ assert.equal(chunks[1].choices[0].finish_reason, 'stop');
++ assert.equal(chunks[2].x_openvibe.run_id, 'run_2');
++ assert.equal(JSON.parse(String(server.requests[0].body)).stream, true);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['models lists the models', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ assert.equal(req.url, '/v1/models');
++ send(res, 200, { object: 'list', data: [{ id: 'openvibe', object: 'model', created: 1760486400, owned_by: 'openvibe' }] });
++ });
++ try {
++ const answer = await client(server).models();
++ assert.equal(answer.object, 'list');
++ assert.equal(answer.data[0].id, 'openvibe');
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['offers lists the offers of one model', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ assert.equal(req.url, '/api/v1/exchange/models/deepseek/deepseek-chat');
++ send(res, 200, {
++ slug: 'deepseek/deepseek-chat',
++ canonical: 'deepseek/deepseek-chat',
++ name: 'DeepSeek Chat',
++ offers: [
++ { provider: 'acme', model: 'deepseek-chat', trust: 'first-party', price: { in: 0.1, out: 0.2, per: '1M tokens', currency: 'USD' } },
++ { provider: 'home', model: 'deepseek-chat', trust: 'community', price: null },
++ ],
++ });
++ });
++ try {
++ const offers = await client(server).offers('DeepSeek/DeepSeek-Chat');
++ assert.equal(offers.length, 2);
++ assert.equal(offers[0].provider, 'acme');
++ assert.equal(offers[1].trust, 'community');
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['presets are listed, stored, replaced and deleted', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ if (req.method === 'GET') return send(res, 200, { presets: [{ name: 'cheapest', policy: { objective: 'cheapest' } }] });
++ if (req.method === 'POST') {
++ const parsed = JSON.parse(String(body));
++ return send(res, 201, { preset: { name: parsed.name, policy: parsed.policy } });
++ }
++ return send(res, 200, { removed: req.url.split('/').pop() });
++ });
++ try {
++ const exchange = client(server);
++ const listed = await exchange.presets.list();
++ assert.equal(listed.length, 1);
++ assert.equal(listed[0].name, 'cheapest');
++ const created = await exchange.presets.create({ name: 'Mine', policy: { objective: 'fastest' } });
++ assert.equal(created.name, 'mine');
++ const updated = await exchange.presets.update('mine', { policy: { objective: 'balanced' } });
++ assert.equal(updated.policy.objective, 'balanced');
++ const posts = server.requests.filter((r) => r.method === 'POST');
++ assert.equal(posts.length, 2);
++ assert.equal(JSON.parse(String(posts[1].body)).name, 'mine');
++ await exchange.presets.remove('mine');
++ assert.ok(server.requests.some((r) => r.method === 'DELETE' && r.url === '/api/v1/presets/mine'));
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['usage reads the public aggregate, or the platform usage for a requester', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ if (req.url.startsWith('/api/v1/exchange/usage')) {
++ assert.equal(req.url, '/api/v1/exchange/usage?days=7');
++ return send(res, 200, { generated_at: 'now', days: 7, from: 'a', to: 'b', today: {}, totals: {}, by_day: [], by_model: [], by_product: [] });
++ }
++ assert.equal(req.url, '/api/v1/usage?requester=svc%3Aacme');
++ return send(res, 200, { usage: { requests: 2 } });
++ });
++ try {
++ const exchange = client(server);
++ const aggregate = await exchange.usage({ days: 7 });
++ assert.equal(aggregate.days, 7);
++ const own = await exchange.usage({ requester: 'svc:acme' });
++ assert.equal(own.usage.requests, 2);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['receipt reads one receipt', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ assert.equal(req.url, '/v1/receipts/rcpt_run_1');
++ send(res, 200, { receipt: { id: 'rcpt_run_1', offer: { provider: 'acme', model: 'openvibe' }, tokens: { tokens_in: 3, tokens_out: 2 } } });
++ });
++ try {
++ const receipt = await client(server).receipt('rcpt_run_1');
++ assert.equal(receipt.id, 'rcpt_run_1');
++ assert.equal(receipt.offer.provider, 'acme');
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['a 429 is retried and then answers', async () => {
++ let hits = 0;
++ const server = await stubServer(async (req, res, body) => {
++ hits++;
++ if (hits === 1) return send(res, 429, { error: { message: 'slow down', type: 'rate_limit', code: 'rate_limit' } }, { 'Retry-After': '0' });
++ send(res, 200, { object: 'list', data: [] });
++ });
++ try {
++ const answer = await client(server).models();
++ assert.equal(answer.object, 'list');
++ assert.equal(hits, 2);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['a 5xx on a write is not retried, and a refusal is not either', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ if (req.method === 'POST') return send(res, 500, { error: { message: 'boom', type: 'server_error', code: 'internal' } });
++ return problem(res, 400, 'input.invalid', 'nope');
++ });
++ try {
++ const exchange = client(server);
++ await assert.rejects(() => exchange.chat({ model: 'openvibe', messages: [{ role: 'user', content: 'hi' }] }), (err) => {
++ assert.ok(err instanceof ExchangeError);
++ assert.equal(err.status, 500);
++ assert.equal(err.retryable, true);
++ return true;
++ });
++ assert.equal(server.requests.filter((r) => r.method === 'POST').length, 1);
++ await assert.rejects(() => exchange.models(), (err) => {
++ assert.equal(err.status, 400);
++ assert.equal(err.retryable, false);
++ assert.equal(err.code, 'input.invalid');
++ return true;
++ });
++ assert.equal(server.requests.filter((r) => r.method === 'GET').length, 1);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['an aborted call stops', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ await new Promise((r) => setTimeout(r, 500));
++ send(res, 200, {});
++ });
++ try {
++ const controller = new AbortController();
++ const exchange = client(server);
++ const call = exchange.models({ signal: controller.signal });
++ setTimeout(() => controller.abort(), 20);
++ await assert.rejects(() => call, (err) => {
++ assert.ok(err instanceof ExchangeError);
++ assert.match(err.message, /aborted/);
++ return true;
++ });
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['the seller registers the offer in the Network registry', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ if (req.url === '/internal/ai/offers/report') {
++ assert.equal(req.headers.authorization, 'Bearer ' + KEY);
++ const parsed = JSON.parse(String(body));
++ assert.equal(parsed.source, 'sdk');
++ assert.equal(parsed.offers.length, 1);
++ const offer = parsed.offers[0];
++ assert.equal(offer.offer_id, 'ai:' + MACHINE);
++ assert.equal(offer.machine_id, MACHINE);
++ assert.equal(offer.owner, OWNER);
++ assert.deepEqual(offer.models, ['qwen3:32b']);
++ assert.deepEqual(offer.hours, { start: '18:00', end: '23:30', tz: 'Europe/Lisbon' });
++ assert.equal(offer.concurrency, 2);
++ assert.deepEqual(offer.price, { in_per_million: 0, out_per_million: 0 });
++ assert.equal(offer.audience, 'everyone');
++ assert.equal(offer.ready, true);
++ assert.equal(offer.health.status, 'up');
++ assert.deepEqual(Object.keys(offer).sort(), ['audience', 'concurrency', 'health', 'hours', 'machine_id', 'models', 'offer_id', 'owner', 'price', 'ready']);
++ return send(res, 200, { source: 'sdk', offers: 1, marked_down: 0, generated_at: 'now' });
++ }
++ if (req.url === '/v1/models') return send(res, 200, { object: 'list', data: [{ id: 'qwen3:32b', object: 'model' }] });
++ return problem(res, 404, 'not_found', 'no route');
++ });
++ try {
++ const exchange = client(server);
++ const result = await exchange.seller.offer({
++ model: 'qwen3:32b',
++ price: 0,
++ hours: { start: '18:00', end: '23:30', tz: 'Europe/Lisbon' },
++ concurrency: 2,
++ endpoint: server.url + '/v1',
++ machineId: MACHINE,
++ owner: OWNER,
++ });
++ assert.equal(result.report.offers, 1);
++ assert.deepEqual(result.endpoint.models, ['qwen3:32b']);
++ assert.equal(result.offer.offer_id, 'ai:' + MACHINE);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['the seller refuses an offer the registry would refuse', async () => {
++ const server = await stubServer(async (req, res) => send(res, 200, {}));
++ try {
++ const exchange = client(server);
++ const base = {
++ model: 'qwen3:32b',
++ price: 0,
++ hours: { start: '18:00', end: '23:30', tz: 'UTC' },
++ concurrency: 2,
++ machineId: MACHINE,
++ owner: OWNER,
++ };
++ await assert.rejects(() => exchange.seller.offer({ ...base, concurrency: 9 }), /concurrency/);
++ await assert.rejects(() => exchange.seller.offer({ ...base, hours: { start: '25:00', end: '23:30' } }), /hours/);
++ await assert.rejects(() => exchange.seller.offer({ ...base, price: -1 }), /price/);
++ await assert.rejects(() => exchange.seller.offer({ ...base, machineId: 'nope' }), /machineId/);
++ await assert.rejects(() => exchange.seller.offer({ ...base, audience: 'group' }), /group/);
++ assert.equal(server.requests.length, 0);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['earnings names the owner and verification maps the offers', async () => {
++ const server = await stubServer(async (req, res, body) => {
++ assert.equal(req.url, '/api/v1/community/sell');
++ assert.equal(req.headers['x-ov-subject'], OWNER);
++ send(res, 200, {
++ owner: OWNER,
++ generated_at: 'now',
++ offers: [{
++ offer_id: 'ai:' + MACHINE,
++ machine_id: MACHINE,
++ models: ['qwen3:32b'],
++ health: 'up',
++ open_now: true,
++ ready: true,
++ verification: { status: 'verified', canary_passes: 3, canary_failures: 0, spot_runs: 1, spot_failures: 0 },
++ verified: true,
++ reputation: 80,
++ free_only: false,
++ uptime_pct: 99.4,
++ }],
++ totals: { requests: 4, tokens_in: 10, tokens_out: 20, pending_credits_micro: 0, spendable_credits_micro: 5, self_served_requests: 1 },
++ });
++ });
++ try {
++ const exchange = client(server, null, { owner: OWNER });
++ const sold = await exchange.seller.earnings();
++ assert.equal(sold.totals.spendable_credits_micro, 5);
++ const verification = await exchange.seller.verification();
++ assert.equal(verification.offers.length, 1);
++ assert.equal(verification.offers[0].verified, true);
++ assert.equal(verification.offers[0].reputation, 80);
++ assert.equal(verification.offers[0].uptime_pct, 99.4);
++ assert.equal(verification.offers[0].verification.canary_passes, 3);
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['a webhook signature is checked in constant time', async () => {
++ const secret = 'webhook_secret';
++ const body = JSON.stringify({ type: 'exchange.earnings', credits: 12 });
++ const stamp = Math.floor(Date.now() / 1000);
++ const digest = crypto.createHmac('sha256', secret).update(stamp + '.' + body).digest('hex');
++ const header = 't=' + stamp + ',v2=' + digest;
++ assert.equal(verifyWebhook(body, header, secret), true);
++ assert.equal(verifyWebhook(body, header, 'another_secret'), false);
++ assert.equal(verifyWebhook(body + ' ', header, secret), false);
++ assert.equal(verifyWebhook(body, header, secret, { now: (stamp + SIGNATURE_TOLERANCE_SEC + 10) * 1000 }), false);
++ const legacy = crypto.createHmac('sha256', secret).update(body).digest('hex');
++ assert.equal(verifyWebhook(body, 'sha256=' + legacy, secret), true);
++ assert.equal(verifyWebhook(body, 'not a signature', secret), false);
++ assert.equal(verifyWebhook(null, header, secret), false);
++ }],
++
++ ['the client refuses what it cannot do', async () => {
++ assert.throws(() => createExchangeClient({}), /developerKey/);
++ assert.throws(() => createExchangeClient({ developerKey: 'k', fetchImpl: null }), /fetch/);
++ assert.throws(() => createExchangeClient({ developerKey: 'k', baseUrl: 'nope' }), /baseUrl/);
++ const exchange = createExchangeClient({ developerKey: 'k' });
++ assert.equal(exchange.baseUrl(), 'https://api.openvibe.exchange');
++ assert.equal(exchange.networkUrl(), 'https://api.openvibe.network');
++ await assert.rejects(() => exchange.receipt(''), /receipt/);
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 00d5e3e..5348b15 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -24,7 +24,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8', shell });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/vibes.js', 'esm/vibes.mjs', 'types/vibes.d.ts', 'src/exchange.js', 'esm/exchange.mjs', 'types/exchange.d.ts', 'docs/exchange.md', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'src/vision.js', 'esm/vision.mjs', 'types/vision.d.ts', 'src/redact.js', 'esm/redact.mjs', 'types/redact.d.ts', 'docs/redact.md', 'src/share.js', 'src/share/store.js', 'esm/share.mjs', 'types/share.d.ts', 'docs/share.md', 'src/locker.js', 'src/locker/seal.js', 'src/locker/server.js', 'esm/locker.mjs', 'esm/locker-server.mjs', 'types/locker.d.ts', 'types/locker-server.d.ts', 'types/locker-vectors.d.ts', 'esm/locker-vectors.mjs', 'docs/locker.md', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -68,7 +68,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, 81); // 0.86.0 adds openvibe-sdk/locker, its server and its vectors
++ assert.equal(subpaths.length, 82); // 0.89.0 adds openvibe-sdk/exchange
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/exchange.d.ts b/types/exchange.d.ts
nnew file mode 100644
iindex 0000000..9e13f69
---- /dev/null
++++ b/types/exchange.d.ts
@@@ -0,0 +1,333 @@
++/** OpenVibe.Exchange: the buyer side (chat, models, offers, presets, usage, receipts) and the seller side. */
++export declare class ExchangeError extends Error {
++ constructor(message: string, init?: { status?: number | null; code?: string | null; body?: unknown; retryAfter?: number | null });
++ status: number | null;
++ code: string | null;
++ body: unknown;
++ retryAfter: number | null;
++ /** No status, 5xx, 429 or 401. */
++ readonly retryable: boolean;
++}
++
++/** Where each call goes; a deployment may point another one at these paths. */
++export type ExchangePaths = {
++ chat?: string;
++ models?: string;
++ receipts?: string;
++ presets?: string;
++ catalog?: string;
++ catalogModels?: string;
++ usage?: string;
++ platformUsage?: string;
++ sell?: string;
++ offerReport?: string;
++ offerList?: string;
++};
++
++export type ExchangeOptions = {
++ /** The Network developer key (Bearer) the buyer side speaks with. */
++ developerKey?: string;
++ /** The key used on the Network's origin when the seller side reports an offer; defaults to the developer key. */
++ networkKey?: string;
++ /** The Exchange's origin; default https://api.openvibe.exchange. */
++ baseUrl?: string;
++ /** The Network's origin, where the user-owned AI offers live; default https://api.openvibe.network. */
++ networkUrl?: string;
++ fetchImpl?: typeof fetch;
++ /** Per request, default 60000. */
++ timeoutMs?: number;
++ /** How many times a 429 or a 5xx is retried, default 3. */
++ maxRetries?: number;
++ /** The first backoff before the jitter, in ms; default 250. */
++ retryBaseMs?: number;
++ /** The seller subject (usr_...), used by the seller side when a call names no other owner. */
++ owner?: string;
++ paths?: ExchangePaths;
++ /** The User-Agent the calls carry. */
++ agent?: string;
++};
++
++export type ExchangeChatRequest = {
++ model: string;
++ messages: { role: string; content: string | unknown[] | null; [field: string]: unknown }[];
++ stream?: boolean;
++ stream_options?: { include_usage?: boolean };
++ temperature?: number;
++ max_tokens?: number;
++ max_completion_tokens?: number;
++ tools?: unknown[];
++ tool_choice?: unknown;
++ [field: string]: unknown;
++};
++
++export type ExchangeUsage = {
++ prompt_tokens: number;
++ completion_tokens: number;
++ total_tokens: number;
++};
++
++export type ExchangeChatCompletion = {
++ id: string;
++ object: 'chat.completion' | string;
++ created: number;
++ model: string;
++ choices: { index: number; message: { role: string; content: string | null; tool_calls?: unknown[] }; finish_reason: string | null }[];
++ usage: ExchangeUsage;
++ system_fingerprint?: string | null;
++ x_openvibe?: { run_id: string; provider?: string; fallback_used?: boolean; cost_usd?: number; synthetic?: boolean };
++};
++
++export type ExchangeChatChunk = {
++ id: string;
++ object: 'chat.completion.chunk' | string;
++ created: number;
++ model: string;
++ choices: { index: number; delta: { role?: string; content?: string | null; tool_calls?: unknown[] }; finish_reason: string | null }[];
++ usage?: ExchangeUsage;
++ x_openvibe?: { run_id: string; provider?: string; fallback_used?: boolean; cost_usd?: number; synthetic?: boolean };
++};
++
++export type ExchangeModel = { id: string; object: 'model' | string; created?: number; owned_by?: string; [field: string]: unknown };
++
++export type ExchangeModelList = { object: 'list'; data: ExchangeModel[] };
++
++export type ExchangeOffer = {
++ provider: string;
++ provider_name?: string;
++ model: string;
++ id?: string | null;
++ trust: 'community' | 'first-party' | string;
++ access?: string;
++ free?: boolean;
++ price?: { in: number; out: number; cached?: number; per: string; currency: string } | null;
++ speed?: { p50_ms: number | null; p95_ms: number | null };
++ uptime?: number | null;
++ requests_7d?: number;
++ requests_today?: number;
++ data_policy?: { trains: boolean | null; retention_days: number | null; region: string | null } | null;
++ updated_at?: string | null;
++ [field: string]: unknown;
++};
++
++export type ExchangeModelDoc = {
++ slug: string;
++ canonical: string;
++ name: string;
++ family?: string;
++ type?: string;
++ context?: number | null;
++ max_output?: number | null;
++ capabilities?: { tools: boolean; json: boolean; vision: boolean; stream: boolean };
++ modalities?: string[];
++ community?: boolean;
++ priced?: boolean;
++ offers: ExchangeOffer[];
++ [field: string]: unknown;
++};
++
++export type ExchangeCatalog = {
++ generated_at: string;
++ window_days: number;
++ count: number;
++ families?: { name: string; models: number; offers: number }[];
++ models: ExchangeModelDoc[];
++};
++
++export type ExchangePresetPolicy = {
++ objective?: 'cheapest' | 'fastest' | 'balanced' | 'private';
++ floor?: 'fast' | 'balanced' | 'strong' | 'frontier';
++ ceiling?: number;
++ max_price?: number;
++ quantization?: string[];
++ region?: string[];
++ data_policy?: 'private' | 'anonymized' | 'community';
++ community?: boolean;
++ adult?: boolean;
++ [field: string]: unknown;
++};
++
++export type ExchangePreset = {
++ name: string;
++ description?: string | null;
++ policy: ExchangePresetPolicy;
++ builtin?: boolean;
++ [field: string]: unknown;
++};
++
++export type ExchangeUsageAggregate = {
++ generated_at: string;
++ days: number;
++ from: string;
++ to: string;
++ today: { requests: number; tokens_in: number; tokens_out: number; cost_usd: number };
++ totals: { requests: number; tokens_in: number; tokens_out: number; cost_usd: number };
++ by_day: { day: string; requests: number; tokens_in: number; tokens_out: number; cost_usd: number }[];
++ by_model: { provider: string; model: string | null; requests: number; tokens_in: number; tokens_out: number; cost_usd: number }[];
++ by_product: { product: string; requests: number; tokens_in: number; tokens_out: number; cost_usd: number; models?: unknown[] }[];
++ [field: string]: unknown;
++};
++
++export type ExchangeReceipt = {
++ id: string;
++ run_id?: string;
++ offer?: { provider: string; model: string | null; deployment?: string | null; access?: string };
++ price?: { in: number | null; out: number | null; cached?: number | null; source?: string | null };
++ tokens?: { tokens_in: number; tokens_out: number; tokens_cached?: number; tokens_cache_write?: number; tokens_reasoning?: number };
++ latency_ms?: number | null;
++ why?: string[];
++ fallbacks?: { offer: string; provider?: string | null; model?: string | null; why?: string | null; status?: string; reason?: string | null }[];
++ paid_by?: { paid_by: string; detail?: string | null; fee_rate?: number | null };
++ privacy?: { label: string; [field: string]: unknown } | string;
++ preset?: string | null;
++ created_at?: string;
++ [field: string]: unknown;
++};
++
++/** The offer document the Network's user-owned AI offers registry validates. */
++export type ExchangeOfferDocument = {
++ offer_id: string;
++ machine_id: string;
++ owner: string;
++ models: string[];
++ hours: { start: string; end: string; tz: string };
++ concurrency: number;
++ price: { in_per_million: number; out_per_million: number };
++ audience: 'everyone' | 'followers' | 'group';
++ group?: string;
++ ready: boolean;
++ health: { status: 'up' | 'degraded' | 'down'; checked_at: string };
++};
++
++export type ExchangeOfferInput = {
++ /** The model served; models may name several. */
++ model?: string;
++ models?: string[];
++ /** Credits per million tokens: a number for both sides, or {in_per_million, out_per_million}. */
++ price: number | { in_per_million?: number; out_per_million?: number; in?: number; out?: number };
++ /** The local window the offer is open: {start, end, tz}; start == end is all day. */
++ hours: { start: string; end: string; tz?: string };
++ /** How many answers may run at once, 1 to 8. */
++ concurrency: number;
++ /** The OpenAI-compatible server that serves the model. */
++ endpoint?: string;
++ /** The key the endpoint needs, when it needs one. */
++ endpointKey?: string;
++ /** The machine the offer belongs to (dev_...). */
++ machineId: string;
++ /** The owner (usr_...); defaults to the client's owner. */
++ owner?: string;
++ audience?: 'everyone' | 'followers' | 'group';
++ /** The group id, required for the group audience. */
++ group?: string;
++ ready?: boolean;
++ health?: { status: 'up' | 'degraded' | 'down'; checked_at?: string };
++ /** The reporter name; the registry keeps one set of offers per source. */
++ source?: string;
++ /** Whether the endpoint is asked for its model list first; default true. */
++ probe?: boolean;
++};
++
++export type ExchangeOfferResult = {
++ offer: ExchangeOfferDocument;
++ report: { source: string; offers: number; marked_down: number; generated_at?: string };
++ endpoint: { url: string; models: string[] | null } | null;
++};
++
++export type ExchangeSellerVerification = {
++ status: 'new' | 'verified' | 'failed' | 'paused' | string;
++ canary_passes: number;
++ canary_failures: number;
++ spot_runs: number;
++ spot_failures: number;
++ failure_rate?: number;
++ last_canary_at?: string | null;
++ last_spot_at?: string | null;
++ first_verified_at?: string | null;
++ failure_reason?: string | null;
++};
++
++export type ExchangeSellerOffer = {
++ offer_id: string;
++ machine_id: string;
++ models: string[];
++ hours?: { start: string; end: string; tz: string } | null;
++ concurrency?: number | null;
++ price?: { in_per_million: number; out_per_million: number };
++ audience?: string | null;
++ group?: string | null;
++ health?: string | null;
++ open_now?: boolean | null;
++ ready: boolean;
++ verification: ExchangeSellerVerification | null;
++ verified: boolean;
++ reputation: number;
++ free_only: boolean;
++ uptime_pct?: number | null;
++ tokens_per_s?: number | null;
++};
++
++export type ExchangeEarnings = {
++ owner: string;
++ generated_at: string;
++ offers: ExchangeSellerOffer[];
++ totals: {
++ requests: number;
++ tokens_in: number;
++ tokens_out: number;
++ pending_credits_micro: number;
++ spendable_credits_micro: number;
++ self_served_requests: number;
++ };
++ kudos?: number;
++ note?: string;
++ [field: string]: unknown;
++};
++
++export type ExchangeClient = {
++ /** One chat completion; stream: true answers an async iterable of chunks. */
++ chat(body: ExchangeChatRequest, opts?: { signal?: AbortSignal }): Promise<ExchangeChatCompletion | AsyncIterable<ExchangeChatChunk>>;
++ /** The models the Exchange can serve. */
++ models(opts?: { signal?: AbortSignal }): Promise<ExchangeModelList>;
++ /** The offers that serve one model. */
++ offers(model: string, opts?: { signal?: AbortSignal }): Promise<ExchangeOffer[]>;
++ /** The whole public catalog. */
++ catalog(opts?: { signal?: AbortSignal }): Promise<ExchangeCatalog>;
++ presets: {
++ list(opts?: { signal?: AbortSignal }): Promise<ExchangePreset[]>;
++ create(input: { name: string; policy: ExchangePresetPolicy }, opts?: { signal?: AbortSignal }): Promise<ExchangePreset>;
++ /** The platform has no PATCH: a preset is stored under its name, so a second POST replaces it. */
++ update(name: string, input: { policy: ExchangePresetPolicy }, opts?: { signal?: AbortSignal }): Promise<ExchangePreset>;
++ remove(name: string, opts?: { signal?: AbortSignal }): Promise<unknown>;
++ };
++ /** The public aggregate, or the platform's own usage for a requester. */
++ usage(query?: { days?: number; from?: string; to?: string; requester?: string }, opts?: { signal?: AbortSignal }): Promise<ExchangeUsageAggregate | { usage: unknown }>;
++ /** The receipt of one answered request. */
++ receipt(id: string, opts?: { signal?: AbortSignal }): Promise<ExchangeReceipt>;
++ seller: {
++ /** Register a community offer in the Network's user-owned AI offers. */
++ offer(input: ExchangeOfferInput, opts?: { signal?: AbortSignal }): Promise<ExchangeOfferResult>;
++ /** The model list at an OpenAI-compatible endpoint. */
++ probe(input: { endpoint: string; key?: string | null }, opts?: { signal?: AbortSignal }): Promise<{ models: string[] }>;
++ /** The seller dashboard: the tokens served, the credits earned, the uptime and the verification. */
++ earnings(opts?: { owner?: string } & { signal?: AbortSignal }): Promise<ExchangeEarnings>;
++ /** The verification, the reputation and the uptime of every offer. */
++ verification(opts?: { owner?: string } & { signal?: AbortSignal }): Promise<{ owner: string | null; generated_at: string | null; offers: ExchangeSellerOffer[] }>;
++ /** The offers the Network holds, whole. */
++ offers(opts?: { owner?: string; source?: string } & { signal?: AbortSignal }): Promise<{ offers: ExchangeOfferDocument[]; count?: number; generated_at?: string }>;
++ };
++ webhooks: {
++ /** Constant-time check of the RAW body against X-OpenVibe-Signature-V2 (or a legacy sha256= header). */
++ verify(rawBody: string | Uint8Array | ArrayBufferView, signatureHeader: string, secret: string, opts?: { toleranceSec?: number; now?: number }): boolean;
++ };
++ baseUrl(): string;
++ networkUrl(): string;
++};
++
++export declare function createExchangeClient(options?: ExchangeOptions): ExchangeClient;
++/** 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;
++export declare const EXCHANGE_BASE_URL: string;
++export declare const NETWORK_BASE_URL: string;
++export declare const DEFAULT_PATHS: Required<ExchangePaths>;
++export declare const RETRY_STATUS: ReadonlySet<number>;