openvibers/openvibe.sdk Commits
openvibe-sdk/exchange: the seller side gains the endpoint form (0.93.0) (#127)
Changes
@@@ -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@@@ -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).@@@ -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",@@@ -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,@@@ -641,6 +641,7 @@"presets","receipts","sell",++ "sellerOffers","usage"]},@@@ -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.```jsconst 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@@@ -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"@@@ -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",@@@ -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,@@@ -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;@@@ -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 });@@@ -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. */
@@ -1,5 +1,22 @@
@@ -1,5 +1,22 @@
# Changelog
# 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)
## 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
- Add `openvibe-sdk/chat-apps`: the three chat apps (Telegram, Discord, Slack) in one place, so a service that answers
@@ -2,16 +2,16 @@
@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
> 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.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**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.
**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
```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).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@ -3,14 +3,14 @@
@@ -3,14 +3,14 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
"contracts": "openvibe-contracts v0.298.0",
"contracts": "openvibe-contracts v0.298.0",
"features": [
"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",
"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",
"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",
"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",
"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",
@@ -1,4 +1,4 @@
@@ -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
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.92.0';
+const SDK_VERSION = '0.93.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -641,6 +641,7 @@
@@ -641,6 +641,7 @@
"presets",
"presets",
"receipts",
"receipts",
"sell",
"sell",
+ "sellerOffers",
"usage"
"usage"
]
]
},
},
@@ -59,10 +59,10 @@ adult gate. A request names one as `model: '@preset/<name>'`.
@@ -59,10 +59,10 @@ adult gate. A request names one as `model: '@preset/<name>'`.
## The seller side
## 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
```js
const offer = await exchange.seller.offer({
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
@@ -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.
`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 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
### Earnings and verification
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.92.0",
+ "version": "0.93.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.92.0",
+ "version": "0.93.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.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.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.92.0';
+const SDK_VERSION = '0.93.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -12,16 +12,24 @@
@@ -12,16 +12,24 @@
* // { id: 'chatcmpl-...', object: 'chat.completion', choices, usage, x_openvibe: { run_id, cost_usd, ... } }
* // { 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 })) { ... }
* 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({
* 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,
* 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();
* const { totals, offers } = await exchange.seller.earnings();
*
*
@@ -57,6 +65,7 @@ const DEFAULT_PATHS = {
@@ -57,6 +65,7 @@ const DEFAULT_PATHS = {
usage: '/api/v1/exchange/usage',
usage: '/api/v1/exchange/usage',
platformUsage: '/api/v1/usage',
platformUsage: '/api/v1/usage',
sell: '/api/v1/community/sell',
sell: '/api/v1/community/sell',
+ sellerOffers: '/api/v1/exchange/seller/offers',
offerReport: '/internal/ai/offers/report',
offerReport: '/internal/ai/offers/report',
offerList: '/internal/ai/offers',
offerList: '/internal/ai/offers',
};
};
@@ -245,6 +254,15 @@ function checkHours(hours) {
@@ -245,6 +254,15 @@ function checkHours(hours) {
return { start: String(h.start), end: String(h.end), tz };
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}. */
/** The credit price per million tokens: a number for both sides, or {in_per_million, out_per_million}. */
function checkPrice(price) {
function checkPrice(price) {
const isAmount = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1000000;
const isAmount = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1000000;
@@ -359,10 +377,14 @@ function createExchangeClient({
@@ -359,10 +377,14 @@ function createExchangeClient({
const seller = {
const seller = {
/**
/**
* Register a community offer: what is served, at what price, when, and how many answers at once.
* 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 })
* 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({
async offer({
model,
model,
@@ -372,10 +394,11 @@ function createExchangeClient({
@@ -372,10 +394,11 @@ function createExchangeClient({
concurrency,
concurrency,
endpoint = null,
endpoint = null,
endpointKey = null,
endpointKey = null,
- machineId,
+ machineId = null,
owner: offerOwner = null,
owner: offerOwner = null,
audience = 'everyone',
audience = 'everyone',
group = null,
group = null,
+ name = null,
ready = true,
ready = true,
health = null,
health = null,
source = 'sdk',
source = 'sdk',
@@ -385,31 +408,40 @@ function createExchangeClient({
@@ -385,31 +408,40 @@ function createExchangeClient({
if (!Array.isArray(listed) || !listed.length) throw new TypeError('offer.model: a model is required');
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');
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();
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);
const cap = Number(concurrency);
if (!Number.isInteger(cap) || cap < 1 || cap > 8) throw new TypeError('offer.concurrency: an integer from 1 to 8');
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 (!['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');
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() };
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');
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.
// The offer document is the one the registry validates: no field outside its shape is sent.
const document = {
const document = {
offer_id: `ai:${machine}`,
offer_id: `ai:${machine}`,
machine_id: machine,
machine_id: machine,
owner: subject,
owner: subject,
models: listed,
models: listed,
- hours: checkHours(hours),
+ hours: window,
concurrency: cap,
concurrency: cap,
- price: checkPrice(price),
+ price: priced,
audience,
audience,
group: audience === 'group' ? String(group).trim() : (group == null ? undefined : String(group)),
group: audience === 'group' ? String(group).trim() : (group == null ? undefined : String(group)),
ready: ready === true,
ready: ready === true,
@@ -429,6 +461,18 @@ function createExchangeClient({
@@ -429,6 +461,18 @@ function createExchangeClient({
return { offer: document, report, endpoint: address ? { url: address, models: checked ? checked.models : null } : null };
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 } */
/** Ask a server what it serves: the OpenAI models list at its endpoint. → { models } */
async probe({ endpoint, key = null, signal } = {}) {
async probe({ endpoint, key = null, signal } = {}) {
let url;
let url;
@@ -346,6 +346,77 @@ run([
@@ -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 () => {
['a webhook signature is checked in constant time', async () => {
const secret = 'webhook_secret';
const secret = 'webhook_secret';
const body = JSON.stringify({ type: 'exchange.earnings', credits: 12 });
const body = JSON.stringify({ type: 'exchange.earnings', credits: 12 });
@@ -20,6 +20,7 @@ export type ExchangePaths = {
@@ -20,6 +20,7 @@ export type ExchangePaths = {
usage?: string;
usage?: string;
platformUsage?: string;
platformUsage?: string;
sell?: string;
sell?: string;
+ sellerOffers?: string;
offerReport?: string;
offerReport?: string;
offerList?: string;
offerList?: string;
};
};
@@ -170,7 +171,7 @@ export type ExchangeUsageAggregate = {
@@ -170,7 +171,7 @@ export type ExchangeUsageAggregate = {
export type ExchangeReceipt = {
export type ExchangeReceipt = {
id: string;
id: string;
run_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 };
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 };
tokens?: { tokens_in: number; tokens_out: number; tokens_cached?: number; tokens_cache_write?: number; tokens_reasoning?: number };
latency_ms?: number | null;
latency_ms?: number | null;
@@ -233,6 +234,54 @@ export type ExchangeOfferResult = {
@@ -233,6 +234,54 @@ export type ExchangeOfferResult = {
endpoint: { url: string; models: string[] | null } | null;
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 = {
export type ExchangeSellerVerification = {
status: 'new' | 'verified' | 'failed' | 'paused' | string;
status: 'new' | 'verified' | 'failed' | 'paused' | string;
canary_passes: number;
canary_passes: number;
@@ -304,8 +353,13 @@ export type ExchangeClient = {
@@ -304,8 +353,13 @@ export type ExchangeClient = {
/** The receipt of one answered request. */
/** The receipt of one answered request. */
receipt(id: string, opts?: { signal?: AbortSignal }): Promise<ExchangeReceipt>;
receipt(id: string, opts?: { signal?: AbortSignal }): Promise<ExchangeReceipt>;
seller: {
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: 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. */
/** The model list at an OpenAI-compatible endpoint. */
probe(input: { endpoint: string; key?: string | null }, opts?: { signal?: AbortSignal }): Promise<{ models: string[] }>;
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. */
/** The seller dashboard: the tokens served, the credits earned, the uptime and the verification. */