openvibers/openvibe.sdk Commits

openvibe-sdk/exchange: the seller side gains the endpoint form (0.93.0) (#127)

OP OpenVibers committed 7 days ago · 3dac7784f5 · 12 files, +259 −42

Any OpenAI-compatible server can sell, not only an OpenVibe Node machine.

- seller.offer({ endpoint, models, price, hours, concurrency, audience, name })
  with no machineId registers the server on the Exchange's public seller API
  (POST /api/v1/exchange/seller/offers) with the same developer key the buyer
  side uses. The owner is the person the key acts for, never a body field, and
  an http endpoint is refused before anything is sent.
- seller.endpointOffers() lists the caller's offers with their verification;
  seller.removeEndpointOffer(id) removes one. The machineId form still reports a
  Node machine's offer to Network.
- Docs and types describe the egress guard, the same trust as a Node machine and
  the community-endpoint receipt label.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 96a0eae..425a7f3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,22 @@
# Changelog
++## 0.93.0 (2026-11-06)
++
++- `openvibe-sdk/exchange`: the seller side gains the **endpoint form**, so any OpenAI-compatible server can sell, not
++ only an OpenVibe Node machine. `seller.offer({ endpoint, models, price, hours, concurrency, audience, name })` — with
++ no `machineId` — registers the server on OpenVibe.Exchange's public seller API
++ (`POST /api/v1/exchange/seller/offers`) with the same developer key the buyer side uses. The owner is the person the
++ key acts for (a developer app's project owner), never a body field, and the SDK refuses an endpoint that is not https
++ before anything is sent.
++ - The platform reaches that address only through its egress guard (https, a public DNS answer checked inside the
++ connection, never an IP literal, redirects judged again, a timeout and a response byte cap, no buyer identity in a
++ header) and verifies it exactly as a Node machine (canaries, the model fingerprint, about 1% spot re-runs, the free
++ tier until verified, self-dealing earns 0). Its answers are labelled `community-endpoint:<id>` on the receipt.
++ - `seller.endpointOffers()` lists the caller's endpoint offers with their verification;
++ `seller.removeEndpointOffer(id)` removes one. `machineId` still selects the Network registry form.
++ - `seller.probe()` is unchanged, and `seller.offer` still probes the server's OpenAI model list first when `endpoint`
++ is given (`probe: false` turns that off).
++
## 0.92.0 (2026-11-06)
- Add `openvibe-sdk/chat-apps`: the three chat apps (Telegram, Discord, Slack) in one place, so a service that answers
ddiff --git a/README.md b/README.md
iindex 3164328..ce6a1f6 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.92.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.93.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.92.0 is tagged:
++After v0.93.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.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).
ddiff --git a/STATUS.json b/STATUS.json
iindex 4467d37..5a44a9f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,14 +3,14 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.92.0",
++ "version": "0.93.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.298.0",
"features": [
"chat-apps (openvibe-sdk/chat-apps): the three chat apps (Telegram, Discord, Slack) in one place — the delivery checks (Telegram's secret token, Slack's v0 signing, Discord's Ed25519), the button callback (signed once, used once, expiring), each app's payload as the same shape and splitText() for a long answer",
-- "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",
++ "exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, endpointOffers, removeEndpointOffer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check; an offer is a Node machine's Network report or a standalone OpenAI-compatible endpoint registered on the public seller API",
"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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 71770e2..e74b67c 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.92.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.93.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.92.0';
++const SDK_VERSION = '0.93.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 9f9eabc..e7f709d 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -641,6 +641,7 @@
"presets",
"receipts",
"sell",
++ "sellerOffers",
"usage"
]
},
ddiff --git a/docs/exchange.md b/docs/exchange.md
iindex 5080c43..aa2fdb8 100644
---- a/docs/exchange.md
++++ b/docs/exchange.md
@@@ -59,10 +59,10 @@ 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.
++An offer has two forms. A **Node machine** reports its offer to 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`). A **standalone server** registers itself on the Exchange's public seller API with the
++developer key, so it needs no Node at all. The SDK validates both before anything is sent.
```js
const offer = await exchange.seller.offer({
@@@ -84,9 +84,39 @@ concurrency is 1 to 8, the price is credits per million tokens, and the audience
`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.
++the OpenAI API is caught before the offer is registered (`probe: false` turns that off).
++
++### A standalone server: the endpoint form
++
++A server that speaks the OpenAI Chat Completions API can sell without an OpenVibe Node. Give `endpoint` and no
++`machineId`; the offer is registered on the Exchange's public seller API
++(`POST /api/v1/exchange/seller/offers`) with the same developer key as the buyer side.
++
++```js
++const { offer, endpoint } = await exchange.seller.offer({
++ endpoint: 'https://my-server.example/v1', // https only: the platform refuses an IP literal or port other than 443/8443
++ models: ['qwen3:32b', 'qwen3:8b'],
++ price: { in_per_million: 10, out_per_million: 30 }, // 0 volunteers
++ hours: { start: '00:00', end: '23:59', tz: 'Europe/Lisbon' },
++ concurrency: 2,
++ audience: 'everyone',
++ name: 'my 3090',
++});
++// { offer: { id: 'ep_…', offer_id: 'endpoint:ep_…', base_url, models, hours, price, audience, … },
++// endpoint: { url, models } }
++
++const { owner, offers } = await exchange.seller.endpointOffers();
++await exchange.seller.removeEndpointOffer(offer.id);
++```
++
++The owner is the person the developer key acts for (a developer app's project owner), never a body field. AI reaches
++the address only through its egress guard: https, a public DNS answer checked inside the connection (never an IP
++literal), redirects judged again, a timeout, a response byte cap, and no buyer identity in any header. No bearer token
++is sent, so the endpoint must accept the POST as it is (`endpointKey` only authenticates the SDK's own probe). The
++endpoint is verified exactly as a Node machine (the canaries, the model fingerprint, about 1% spot re-runs), serves the
++free tier only until verified, and earns 0 on the owner's own requests. An answer from it is labelled a community
++endpoint on the receipt (`provider: community-endpoint:<id>`, `provider_access: COMMUNITY_ENDPOINT`). It sees the
++prompt: only requests that opt in to community capacity reach it.
### Earnings and verification
ddiff --git a/package-lock.json b/package-lock.json
iindex af97e6e..3f4f22c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.92.0",
++ "version": "0.93.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.92.0",
++ "version": "0.93.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex ecf672e..e961231 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.92.0",
++ "version": "0.93.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 564250f..6ffbf9a 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.92.0';
++const SDK_VERSION = '0.93.0';
module.exports = {
createClient,
ddiff --git a/src/exchange.js b/src/exchange.js
iindex 1ab8f90..cda71ef 100644
---- a/src/exchange.js
++++ b/src/exchange.js
@@@ -12,16 +12,24 @@
* // { 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.
++ * The seller side registers an offer in one of two forms. With `machineId`, it reports a Node machine's offer to the
++ * Network's user-owned AI offers (POST /internal/ai/offers/report, the capability network.resource.report). With
++ * `endpoint` and no machine, it registers a standalone OpenAI-compatible server on the Exchange's public seller API
++ * (POST /api/v1/exchange/seller/offers) with the same developer key the buyer side uses; AI reaches that address only
++ * through its egress guard (https, a public DNS answer, never an IP literal, a timeout, a response cap, no buyer
++ * identity in a header) and verifies it exactly as a Node machine (canaries, fingerprint, spot re-runs, free tier only
++ * until verified, self-dealing earns 0).
*
* const offer = await exchange.seller.offer({
-- * model: 'qwen3:32b', price: { in_per_million: 0, out_per_million: 0 },
++ * endpoint: 'https://my-server.example/v1',
++ * models: ['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_...',
++ * });
++ * // { offer: { id: 'ep_…', offer_id: 'endpoint:ep_…', … }, endpoint: { url, models } }
++ *
++ * const nodeOffer = await exchange.seller.offer({
++ * model: 'qwen3:32b', price: 0, hours, concurrency: 2,
++ * machineId: 'dev_…', owner: 'usr_…',
* });
* const { totals, offers } = await exchange.seller.earnings();
*
@@@ -57,6 +65,7 @@ const DEFAULT_PATHS = {
usage: '/api/v1/exchange/usage',
platformUsage: '/api/v1/usage',
sell: '/api/v1/community/sell',
++ sellerOffers: '/api/v1/exchange/seller/offers',
offerReport: '/internal/ai/offers/report',
offerList: '/internal/ai/offers',
};
@@@ -245,6 +254,15 @@ function checkHours(hours) {
return { start: String(h.start), end: String(h.end), tz };
}
++/** A server address: http(s), no credentials, no trailing slash. The Exchange's endpoint form requires https. */
++function endpointUrl(raw) {
++ let u;
++ try { u = new URL(String(raw == null ? '' : raw)); } catch { throw new TypeError('offer.endpoint: an http(s) URL'); }
++ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new TypeError('offer.endpoint: an http(s) URL');
++ if (u.username || u.password) throw new TypeError('offer.endpoint: no credentials in the URL');
++ return u.toString().replace(/\/+$/, '');
++}
++
/** 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;
@@@ -359,10 +377,14 @@ function createExchangeClient({
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.
*
++ * With `machineId`, 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 })
++ *
++ * With `endpoint` and no machineId, it registers a standalone OpenAI-compatible server on the Exchange's public
++ * seller API. The server's address is all the platform needs; the developer key names the owner.
++ * offer({ endpoint, models, price, hours, concurrency, audience, group, name })
*/
async offer({
model,
@@@ -372,10 +394,11 @@ function createExchangeClient({
concurrency,
endpoint = null,
endpointKey = null,
-- machineId,
++ machineId = null,
owner: offerOwner = null,
audience = 'everyone',
group = null,
++ name = null,
ready = true,
health = null,
source = 'sdk',
@@@ -385,31 +408,40 @@ function createExchangeClient({
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 address = endpoint == null ? null : endpointUrl(endpoint);
++ const priced = checkPrice(price);
++ const window = checkHours(hours);
++ // The endpoint form: a standalone server, registered with the developer key. The server is checked first:
++ // it must answer the OpenAI models list (probe: false turns that off).
++ if (address && !machine) {
++ if (!address.startsWith('https://')) throw new TypeError('offer.endpoint: an https URL is required to sell an endpoint');
++ const body = { base_url: address, models: listed, price: priced, hours: window, concurrency: cap, audience };
++ if (audience === 'group') body.group = String(group).trim();
++ if (name != null) body.name = String(name).trim();
++ let checked = null;
++ if (probe) checked = await seller.probe({ endpoint: address, key: endpointKey, signal });
++ // Each call registers a new offer, so it is never retried (a lost answer must not create two).
++ const out = await jsonCall('POST', paths.sellerOffers, { json: body, signal });
++ return { offer: out && out.offer ? out.offer : out, endpoint: { url: address, models: checked ? checked.models : null } };
++ }
++ if (!/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(machine)) throw new TypeError('offer.machineId: a machine id (dev_...) is required, or register an endpoint with offer.endpoint');
++ 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 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),
++ hours: window,
concurrency: cap,
-- price: checkPrice(price),
++ price: priced,
audience,
group: audience === 'group' ? String(group).trim() : (group == null ? undefined : String(group)),
ready: ready === true,
@@@ -429,6 +461,18 @@ function createExchangeClient({
return { offer: document, report, endpoint: address ? { url: address, models: checked ? checked.models : null } : null };
},
++ /** The caller's own endpoint offers, with their verification (the developer key names the owner). */
++ async endpointOffers({ signal } = {}) {
++ return await jsonCall('GET', paths.sellerOffers, { signal });
++ },
++
++ /** Remove one of the caller's endpoint offers; only its owner's key may. */
++ async removeEndpointOffer(id, { signal } = {}) {
++ const offerId = String(id == null ? '' : id).trim();
++ if (!offerId) throw new TypeError('removeEndpointOffer: an id is required');
++ return await jsonCall('DELETE', `${paths.sellerOffers}/${encodeURIComponent(offerId)}`, { signal });
++ },
++
/** Ask a server what it serves: the OpenAI models list at its endpoint. → { models } */
async probe({ endpoint, key = null, signal } = {}) {
let url;
ddiff --git a/test/exchange.test.js b/test/exchange.test.js
iindex 14c5888..0a5b062 100644
---- a/test/exchange.test.js
++++ b/test/exchange.test.js
@@@ -346,6 +346,77 @@ run([
}
}],
++ ['the seller registers a standalone endpoint on the Exchange, lists it and removes it', async () => {
++ const offer = {
++ id: 'ep_' + '0'.repeat(26), offer_id: 'endpoint:ep_' + '0'.repeat(26), name: 'my 3090',
++ base_url: 'https://my-server.example/v1', models: ['qwen3:32b', 'qwen3:8b'],
++ hours: { start: '00:00', end: '23:59', tz: 'Europe/Lisbon' }, concurrency: 2,
++ price: { in_per_million: 10, out_per_million: 30 }, audience: 'everyone', group: null,
++ };
++ const server = await stubServer(async (req, res, body) => {
++ assert.equal(req.headers.authorization, 'Bearer ' + KEY);
++ if (req.method === 'POST' && req.url === '/api/v1/exchange/seller/offers') {
++ const parsed = JSON.parse(String(body));
++ assert.deepEqual(parsed, {
++ base_url: 'https://my-server.example/v1', models: ['qwen3:32b', 'qwen3:8b'],
++ price: { in_per_million: 10, out_per_million: 30 },
++ hours: { start: '00:00', end: '23:59', tz: 'Europe/Lisbon' },
++ concurrency: 2, audience: 'everyone',
++ });
++ return send(res, 201, { offer });
++ }
++ if (req.method === 'GET' && req.url === '/api/v1/exchange/seller/offers') {
++ return send(res, 200, { owner: OWNER, offers: [offer] });
++ }
++ if (req.method === 'DELETE' && req.url === `/api/v1/exchange/seller/offers/${offer.id}`) {
++ return send(res, 200, { id: offer.id, removed: true });
++ }
++ return problem(res, 404, 'not_found', 'no route');
++ });
++ try {
++ const exchange = client(server);
++ const registered = await exchange.seller.offer({
++ endpoint: 'https://my-server.example/v1',
++ models: ['qwen3:32b', 'qwen3:8b'],
++ price: { in_per_million: 10, out_per_million: 30 },
++ hours: { start: '00:00', end: '23:59', tz: 'Europe/Lisbon' },
++ concurrency: 2,
++ probe: false,
++ });
++ assert.equal(registered.offer.id, offer.id);
++ assert.deepEqual(registered.endpoint, { url: 'https://my-server.example/v1', models: null });
++ const listed = await exchange.seller.endpointOffers();
++ assert.equal(listed.owner, OWNER);
++ assert.equal(listed.offers[0].id, offer.id);
++ const removed = await exchange.seller.removeEndpointOffer(offer.id);
++ assert.deepEqual(removed, { id: offer.id, removed: true });
++ } finally {
++ await server.close();
++ }
++ }],
++
++ ['the endpoint form is https only and is not sent when it is refused', async () => {
++ const server = await stubServer(async (req, res) => send(res, 200, {}));
++ try {
++ const exchange = client(server);
++ await assert.rejects(() => exchange.seller.offer({
++ endpoint: 'http://my-server.example/v1', models: ['qwen3:32b'], price: 0, hours: { start: '00:00', end: '23:59' },
++ concurrency: 1, probe: false,
++ }), /https/);
++ await assert.rejects(() => exchange.seller.offer({
++ endpoint: 'https://my-server.example/v1', models: ['qwen3:32b'], price: -1, hours: { start: '00:00', end: '23:59' },
++ concurrency: 1, probe: false,
++ }), /price/);
++ await assert.rejects(() => exchange.seller.offer({
++ endpoint: 'https://my-server.example/v1', models: ['qwen3:32b'], price: 0, hours: { start: '25:00', end: '23:59' },
++ concurrency: 1, probe: false,
++ }), /hours/);
++ assert.equal(server.requests.length, 0);
++ } 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 });
ddiff --git a/types/exchange.d.ts b/types/exchange.d.ts
iindex 9e13f69..0d500d8 100644
---- a/types/exchange.d.ts
++++ b/types/exchange.d.ts
@@@ -20,6 +20,7 @@ export type ExchangePaths = {
usage?: string;
platformUsage?: string;
sell?: string;
++ sellerOffers?: string;
offerReport?: string;
offerList?: string;
};
@@@ -170,7 +171,7 @@ export type ExchangeUsageAggregate = {
export type ExchangeReceipt = {
id: string;
run_id?: string;
-- offer?: { provider: string; model: string | null; deployment?: string | null; access?: string };
++ offer?: { provider: string; model: string | null; deployment?: string | null; access?: string; provider_access?: string | null; local?: boolean; community?: 'machine' | 'endpoint' | null };
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;
@@@ -233,6 +234,54 @@ export type ExchangeOfferResult = {
endpoint: { url: string; models: string[] | null } | null;
};
++/** A standalone OpenAI-compatible server registered on the Exchange's public seller API. The key names the owner. */
++export type ExchangeEndpointOfferInput = {
++ /** The https base URL, e.g. https://my-server.example/v1; /chat/completions is appended. */
++ endpoint: string;
++ /** 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}; 0 volunteers. */
++ price: number | { in_per_million: number; out_per_million: 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;
++ audience?: 'everyone' | 'followers' | 'group';
++ /** The group id, required for the group audience. */
++ group?: string;
++ /** A label for the seller's own dashboard. */
++ name?: string;
++ /** The key the endpoint needs for the probe, when it needs one. AI sends no key. */
++ endpointKey?: string;
++ /** Whether the endpoint is asked for its model list first; default true. */
++ probe?: boolean;
++};
++
++export type ExchangeEndpointOffer = {
++ id: string;
++ offer_id: string;
++ name: string | null;
++ base_url: 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 | null;
++ created_at?: string;
++ updated_at?: string;
++ verification?: ExchangeSellerVerification | null;
++ reputation?: number;
++ free_only?: boolean;
++ verified?: boolean;
++};
++
++export type ExchangeEndpointOfferResult = {
++ offer: ExchangeEndpointOffer;
++ endpoint: { url: string; models: string[] | null };
++};
++
export type ExchangeSellerVerification = {
status: 'new' | 'verified' | 'failed' | 'paused' | string;
canary_passes: number;
@@@ -304,8 +353,13 @@ export type ExchangeClient = {
/** 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. */
++ /** Register a Node machine's offer in the Network's user-owned AI offers, or an endpoint with the Exchange. */
offer(input: ExchangeOfferInput, opts?: { signal?: AbortSignal }): Promise<ExchangeOfferResult>;
++ offer(input: ExchangeEndpointOfferInput, opts?: { signal?: AbortSignal }): Promise<ExchangeEndpointOfferResult>;
++ /** The caller's own endpoint offers, with their verification. */
++ endpointOffers(opts?: { signal?: AbortSignal }): Promise<{ owner: string; offers: ExchangeEndpointOffer[] }>;
++ /** Remove one of the caller's endpoint offers. */
++ removeEndpointOffer(id: string, opts?: { signal?: AbortSignal }): Promise<{ id: string; removed: boolean }>;
/** 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. */