openvibers/openvibe.ai Commits
Exchange: a public seller API for community endpoints (#327)
Changes
@@@ -82,6 +82,21 @@ server {proxy_set_header CF-Connecting-IP $remote_addr;proxy_set_header X-Forwarded-Proto $scheme;}++ # The seller API (design EXCHANGE-REMAKE-2026-11-06, the SDK gap): a standalone OpenAI-compatible server is++ # registered, listed and removed with the seller's own developer key. Writes are allowed here and nowhere else++ # under /api/v1/exchange/.++ location ^~ /api/v1/exchange/seller {++ limit_req zone=ovai_apps burst=10 nodelay;++ limit_req_status 429;++ limit_except GET POST DELETE { deny all; }++ proxy_pass http://127.0.0.1:4700;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ }location = /openapi.json {limit_except GET { deny all; }proxy_pass http://127.0.0.1:4700;@@@ -21,6 +21,9 @@ capacity per request — because the machine sees the prompt.hours, a credit price and an audience.4. **AI** (`server/community/`) reads `GET /internal/ai/offers`, verifies the machine, and places only opted-in requests.++A second source needs no Node at all: a standalone OpenAI-compatible server is registered by its own address on the++public seller API (see **Community endpoints** below) and joins the same list, verification and ledger.++## Opting in`POST /v1/chat/completions` / `POST /v1/messages` (and the direct runs API) take either header@@@ -31,6 +34,49 @@ The answer carries `x_openvibe.community` (machine, owner, model, price, verificresponse header `X-OpenVibe-Community: 1`. A failure on the community machine falls back to the ordinary network, andthat answer carries no community label.++## Community endpoints++++The design's SDK gap: any server that speaks the OpenAI Chat Completions API can sell, not only a Node machine. The++seller registers it with their own Network developer key on the Exchange's public API host, documented in++`/openapi.json`:++++```++POST /api/v1/exchange/seller/offers ai.app.run (a developer key), or a person's own token → 201 { offer }++GET /api/v1/exchange/seller/offers the caller's own offers, with their verification++DELETE /api/v1/exchange/seller/offers/:id remove one (its verification and earnings stay as history)++```++++The body: `base_url` (https), `models` (1–40), `price` (`in_per_million`/`out_per_million` in credits, 0 volunteers),++`hours` (`start`, `end`, `tz`), `concurrency` (1–8), `audience` (`everyone` | `followers` | `group`), `group`, `name`.++The owner is never in the body: it is the person the key acts for. A developer app's owner is its **project owner**,++read from Network (`GET /internal/projects/:project_id`, `network.project.read`, five-minute cache); a first-party++service token is refused.++++**Reaching it.** AI calls `POST <base_url>/chat/completions` only through `server/community/endpoints.js`:++++- https only, no credentials in the URL, port 443 or 8443;++- never an IP literal (`127.0.0.1`, `10.0.0.0/8`, `169.254.169.254`, `[::1]`, and the IPv6 forms that wrap one) and never++ a name that can only be local (`.local`, `.internal`, `localhost`);++- the DNS answer is judged inside the connection's own lookup (`openvibe-shared/egress`), so the socket connects to the++ checked address and a second answer cannot swap it; a name answering a private address is refused;++- redirects are followed by hand, at most two, 307/308 only, and every hop is judged again — a redirect to a private++ address is refused;++- a timeout (`AI_COMMUNITY_ENDPOINT_TIMEOUT_MS`) and a response byte cap (`AI_COMMUNITY_ENDPOINT_MAX_BYTES`) on every++ call; a non-200, non-JSON or non-completion answer is a failure, and the router falls back;++- the request is built fresh: `model`, `messages`, `max_tokens`, `temperature`, `tools`, `stream: false`. Never the++ buyer's subject, `on_behalf_of`, run id, key, cookies or forwarding headers (`X-OV-*`, `X-Forwarded-*`). The buyer's++ own `user` field is not passed through either. No bearer token is sent: the endpoint must accept the POST from++ OpenVibe as it is (put it behind an unguessable base path if you want it hard to find);++- the seller's declared `concurrency` is enforced in-process: over it the call is busy and the router falls back.++++**Same trust as a Node machine.** The endpoint joins `server/community/index.js`'s offer list, so it gets the same three++canaries, the same model fingerprint (the `model` its answer reports must be the declared one), the same ~1% spot++re-runs against a verified peer, the free tier only until verified, and the self-dealing rule (the owner, a merged++account, or the project that registered it earns 0). Its answers carry `provider: community-endpoint:<id>`,++`provider_access: COMMUNITY_ENDPOINT`, `community.kind: "endpoint"` and the privacy label `community`, so a receipt says++plainly a community endpoint answered. It sees the prompt: only requests that opt in to community capacity reach it.++An endpoint's uptime is observed from its own calls, not from a reporter.++## Trust before traffic- **Canaries**: three known-answer prompts. A wrong answer marks the offer `failed`, reputation 0, and it is never@@@ -71,7 +117,11 @@ list (models, price, hours, health; never an owner subject or machine id).- `AI_COMMUNITY` (default on): with no `OV_OAUTH_CLIENT_SECRET` or `OV_BOT_INTERNAL_URL` the list is empty.- `AI_COMMUNITY_REFRESH_MS` (30000), `AI_COMMUNITY_CANARY_MAX_AGE_MS` (6h), `AI_COMMUNITY_CANARY_TIMEOUT_MS` (30000),`AI_COMMUNITY_SPOT_RATE` (0.01).--- Network grants AI `network.registry.read` and `network.follows.read` (the `followers` audience).++- `AI_COMMUNITY_ENDPOINTS` (default on), `AI_COMMUNITY_ENDPOINT_TIMEOUT_MS` (60000),++ `AI_COMMUNITY_ENDPOINT_MAX_BYTES` (2 MiB), `AI_COMMUNITY_ENDPOINT_MAX_REDIRECTS` (2),++ `AI_COMMUNITY_ENDPOINT_MAX_PER_OWNER` (20).++- Network grants AI `network.registry.read`, `network.follows.read` (the `followers` audience) and++ `network.project.read` (a developer app's owner for the seller API).## Tests@@@ -79,5 +129,9 @@ list (models, price, hours, health; never an owner subject or machine id).- `node test/community-run.test.js` — the run path: no opt-in reaches nothing; with it the answer is labelled; a failedmachine falls back to the network.- `node test/community-api.test.js` — the dashboard route and the public offers route.++- `node test/community-endpoints.test.js` — the egress guard (IP literals, a private DNS answer, a redirect to one, no++ buyer identity, the timeout/concurrency), canaries, fingerprint, free tier and self-dealing for an endpoint.++- `node test/community-endpoint-api.test.js` — the seller API: the owner from the developer key, refusals, list and++ remove.- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -splugins/ai/tests`.nnew file mode 100644@@@ -0,0 +1,27 @@++-- phase: expand++-- Community endpoint offers (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"). A seller++-- registers a standalone OpenAI-compatible server by its own https address, with their Network developer key, on the++-- public seller API. Only AI's own egress guard reaches the address (https, a public DNS answer, never an IP literal,++-- timeouts, a response cap, and never the buyer's identity in a header). The verification state, spot re-runs, the++-- reputation and the seller ledger live in community_offers/community_usage: an endpoint is verified exactly as a++-- Node machine is (canaries, the model fingerprint, about 1% spot re-runs, free tier only until verified,++-- self-dealing earns 0). Nothing here stores a prompt, an answer or a buyer.++CREATE TABLE IF NOT EXISTS community_endpoint_offers (++ id text COLLATE "C" PRIMARY KEY, -- ep_<ULID>++ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ project_id text COLLATE "C", -- the developer app's project (a person's own token has none)++ app_id text COLLATE "C", -- the app that registered it (null for a person)++ created_by text COLLATE "C" NOT NULL, -- app_…, or the person's token sub++ name text COLLATE "C",++ base_url text COLLATE "C" NOT NULL, -- https origin and path, no trailing slash, never an address++ models jsonb NOT NULL,++ hours jsonb NOT NULL,++ concurrency integer NOT NULL CHECK (concurrency BETWEEN 1 AND 8),++ price_in numeric(20, 6) NOT NULL CHECK (price_in >= 0),++ price_out numeric(20, 6) NOT NULL CHECK (price_out >= 0),++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('everyone', 'followers', 'group')),++ group_id text COLLATE "C",++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS community_endpoint_offers_owner ON community_endpoint_offers (owner_subject, created_at DESC);@@@ -54,6 +54,10 @@ const TABLES = [// counter; listing them here is what makes the guard (and the export count) know they exist.{ table: 'abuse_daily_usage', subject: 'subject', value: (usr) => `user:${usr}`, file: null, erase: { keep: 'erased by extraErase after the device rows are read' } },{ table: 'abuse_devices', subject: 'subject', file: null, erase: { keep: 'erased by extraErase after the shared counter is found' } },++ // A community endpoint (the design's SDK gap) is the person's own capacity: it goes in their export and is++ // removed when the account goes, so it stops receiving traffic too.++ { table: 'community_endpoint_offers', subject: 'owner_subject', file: 'community-endpoint-offers.json', order: 'created_at',++ columns: ['id', 'name', 'base_url', 'models', 'hours', 'concurrency', 'price_in', 'price_out', 'audience', 'group_id', 'created_at', 'updated_at'] },// The person's own quota (server/quota.js): a limit that only means something while the account lives.{ table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },// What the person used, counted against their allowance: the spend ledger, kept.nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The public seller API (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"): a standalone++ * OpenAI-compatible server can sell to the Exchange with the seller's own Network developer key, not only an++ * OpenVibe Node machine. Served on the Exchange's public API host (api.openvibe.exchange) and documented in++ * /openapi.json.++ *++ * POST /api/v1/exchange/seller/offers ai.app.run (a developer key), or a person's own token++ * body: base_url, models, price, hours, concurrency, audience, group++ * → 201 { offer }++ * GET /api/v1/exchange/seller/offers the caller's own offers, with their verification++ * DELETE /api/v1/exchange/seller/offers/:id remove one (its verification and earnings stay as history)++ *++ * The owner is the person the key acts for: a person's own subject, or a developer app's project owner (read from++ * Network with network.project.read). A first-party service token is refused. The key is never a buyer credential:++ * AI reaches the endpoint only through server/community/endpoints.js's egress guard.++ */++const express = require('express');++const { CAPS } = require('../auth');++const { sendError } = require('../util');++++function sellerRouter({ endpoints, community = null, auth, log = console }) {++ const r = express.Router();++ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);++ const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {});++ const wrap = (fn) => async (req, res) => {++ try {++ const out = await fn(req, res);++ if (out !== undefined) res.json(out);++ } catch (err) { sendError(res, err, req.ov, log); }++ };++++ r.post('/api/v1/exchange/seller/offers', allow, wrap(async (req, res) => {++ const who = await endpoints.ownerFor(req.principal);++ const offer = await endpoints.create({ owner: who.owner, projectId: who.projectId, appId: who.appId, createdBy: req.principal.sub, input: body(req) });++ res.status(201).set('Cache-Control', 'no-store');++ return { offer };++ }));++++ r.get('/api/v1/exchange/seller/offers', allow, wrap(async (req, res) => {++ const who = await endpoints.ownerFor(req.principal);++ res.set('Cache-Control', 'no-store');++ const offers = await endpoints.list(who.owner);++ // The verification is the same the /sell dashboard shows: canaries, spot re-runs, reputation and free-only.++ let dashboard = null;++ if (community && typeof community.sell === 'function') {++ try { dashboard = await community.sell(who.owner); } catch (err) { log.warn(`[ai] seller dashboard ${who.owner}: ${err.message}`); }++ }++ const byOffer = new Map(((dashboard && dashboard.offers) || []).map((o) => [o.offer_id, o]));++ return {++ owner: who.owner,++ offers: offers.map((o) => {++ const state = byOffer.get(o.offer_id);++ return { ...o, verification: state ? state.verification : null, reputation: state ? state.reputation : 0,++ free_only: state ? state.free_only !== false : true, verified: state ? state.verified === true : false };++ }),++ };++ }));++++ r.delete('/api/v1/exchange/seller/offers/:id', allow, wrap(async (req, res) => {++ const who = await endpoints.ownerFor(req.principal);++ res.set('Cache-Control', 'no-store');++ return await endpoints.remove({ id: req.params.id, owner: who.owner });++ }));++++ return r;++}++++module.exports = { sellerRouter };@@@ -24,7 +24,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -117,6 +117,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, env, clock, presets, receipts, auth, log }));++ // The public seller API: a standalone OpenAI-compatible server registered by its own https address with the++ // seller's developer key (the design's SDK gap). Its offers join the community router's trust and ledger.++ if (endpoints) app.use(require('./api/seller').sellerRouter({ endpoints, community, auth, log }));// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).release.mount(app, { registry: metrics.registry });nnew file mode 100644@@@ -0,0 +1,376 @@++'use strict';++/**++ * Community endpoints (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"): a standalone++ * OpenAI-compatible server can sell to the Exchange, not only an OpenVibe Node machine. A seller registers its own++ * https address with their Network developer key on the public seller API (server/api/seller.js); AI stores the++ * offer here, reads it beside Network's Node offers, and reaches it through this module only.++ *++ * The egress guard, on every registration, every dispatch and every redirect hop:++ * - https only, no credentials in the URL, no port but 443 or 8443;++ * - never an IP literal, and never a name that can only mean this machine or a private network;++ * - the DNS answer is judged inside the connection's own lookup (openvibe-shared/egress), so the socket connects++ * to the checked address and a second answer cannot swap it (no rebinding); an address that is loopback,++ * RFC 1918, link-local (169.254.169.254), CGNAT, documentation, benchmarking, multicast, or an IPv6 form that++ * wraps one of those is refused;++ * - redirects are followed by hand, at most two, by 307/308 only, and every hop is judged again;++ * - a timeout and a response byte cap on every call;++ * - the request is built fresh: the model, the conversation, the output limit and the tools. Never the buyer's++ * subject, on_behalf_of, run id, key, cookies or forwarding headers.++ *++ * Trust is the Node machine's (server/community/index.js): the same canaries, the same model fingerprint, the same++ * ~1% spot re-runs, the free tier only until verified, and the self-dealing rule. An endpoint is a community source:++ * it sees the prompt, and only requests that opt in ever reach it.++ */++const net = require('net');++const https = require('https');++const { ids } = require('openvibe-contracts');++const egress = require('openvibe-shared/egress');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { AiError, parseJson } = require('../util');++++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const PROJECT = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const ID = /^ep_[0-9A-HJKMNP-TV-Z]{26}$/;++const HHMM = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;++const AUDIENCES = new Set(['everyone', 'followers', 'group']);++const MAX_MODELS = 40;++const MAX_MODEL = 200;++const MAX_PRICE = 1000000;++const MAX_MESSAGES = 200;++const MAX_CONTENT = 65536;++const MAX_BYTES = 2 * 1024 * 1024;++const ALLOWED_PORTS = new Set(['', '443', '8443']);++++const isPrice = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_PRICE;++const roundPrice = (v) => Math.round(Number(v) * 1e6) / 1e6;++++/**++ * The one URL rule. Returns a URL whose pathname has no trailing slash (an OpenAI base URL such as++ * https://host/v1), or throws AiError 422 `community.endpoint_url` with a message that never echoes the host.++ */++function judgeBaseUrl(raw) {++ let u;++ try { u = new URL(String(raw == null ? '' : raw)); } catch { throw new AiError(422, 'community.endpoint_url', 'base_url must be an https URL'); }++ if (u.protocol !== 'https:') throw new AiError(422, 'community.endpoint_url', 'base_url must be https');++ if (u.username || u.password) throw new AiError(422, 'community.endpoint_url', 'credentials in base_url are not allowed');++ if (u.search || u.hash) throw new AiError(422, 'community.endpoint_url', 'base_url carries no query or fragment');++ if (!ALLOWED_PORTS.has(u.port)) throw new AiError(422, 'community.endpoint_url', 'base_url must use port 443 or 8443');++ const host = egress.normalizeHost(u.hostname);++ // Node never calls the lookup hook for an IP literal, so the literal is the check (Actor #297).++ if (net.isIP(host)) throw new AiError(422, 'community.endpoint_url', 'base_url must name a host, not an IP address');++ if (egress.isInternalName(host)) throw new AiError(422, 'community.endpoint_url', 'base_url must be a public host name');++ u.hostname = host;++ u.pathname = u.pathname.replace(/\/+$/, '') || '';++ return u;++}++++/** A conversation as the OpenAI Chat Completions body takes it: text only, the caps the machine path uses. */++const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('\n') : '').slice(0, MAX_CONTENT);++++/** Validate a registration body. → the stored fields; throws AiError 422 `community.endpoint_invalid`. */++function checkInput(input) {++ const b = input && typeof input === 'object' && !Array.isArray(input) ? input : {};++ const allowed = new Set(['base_url', 'models', 'price', 'hours', 'concurrency', 'audience', 'group', 'name']);++ for (const k of Object.keys(b)) if (!allowed.has(k)) throw new AiError(422, 'community.endpoint_invalid', `unknown field ${k}`);++ const url = judgeBaseUrl(b.base_url);++ if (!Array.isArray(b.models) || b.models.length < 1 || b.models.length > MAX_MODELS) {++ throw new AiError(422, 'community.endpoint_invalid', `models must be an array of 1 to ${MAX_MODELS} model ids`);++ }++ const models = [...new Set(b.models.map((m) => String(m == null ? '' : m).trim()).filter(Boolean))];++ if (models.length !== b.models.length || models.some((m) => m.length > MAX_MODEL)) {++ throw new AiError(422, 'community.endpoint_invalid', `models must be unique strings of at most ${MAX_MODEL} characters`);++ }++ const h = b.hours && typeof b.hours === 'object' && !Array.isArray(b.hours) ? b.hours : {};++ if (!HHMM.test(String(h.start)) || !HHMM.test(String(h.end))) throw new AiError(422, 'community.endpoint_invalid', 'hours.start and hours.end are HH:MM');++ const tz = h.tz == null || h.tz === '' ? 'UTC' : String(h.tz);++ if (tz.length > 64) throw new AiError(422, 'community.endpoint_invalid', 'hours.tz is at most 64 characters');++ try { new Intl.DateTimeFormat('en-GB', { timeZone: tz }); } catch { throw new AiError(422, 'community.endpoint_invalid', 'hours.tz is not a known time zone'); }++ if (!Number.isInteger(b.concurrency) || b.concurrency < 1 || b.concurrency > 8) {++ throw new AiError(422, 'community.endpoint_invalid', 'concurrency must be an integer from 1 to 8');++ }++ let price;++ if (typeof b.price === 'number') price = { in_per_million: b.price, out_per_million: b.price };++ else if (b.price && typeof b.price === 'object' && !Array.isArray(b.price)) {++ const pin = b.price.in_per_million != null ? b.price.in_per_million : b.price.in;++ const pout = b.price.out_per_million != null ? b.price.out_per_million : b.price.out;++ price = { in_per_million: pin, out_per_million: pout };++ } else throw new AiError(422, 'community.endpoint_invalid', 'price is a number or {in_per_million, out_per_million}');++ if (!isPrice(price.in_per_million) || !isPrice(price.out_per_million)) {++ throw new AiError(422, 'community.endpoint_invalid', `price must be 0 to ${MAX_PRICE} credits per million tokens`);++ }++ const audience = b.audience == null ? 'everyone' : String(b.audience);++ if (!AUDIENCES.has(audience)) throw new AiError(422, 'community.endpoint_invalid', 'audience must be everyone, followers or group');++ let group = null;++ if (audience === 'group') {++ group = String(b.group == null ? '' : b.group).trim();++ if (!group || group.length > 80) throw new AiError(422, 'community.endpoint_invalid', 'audience group needs a group id of 1 to 80 characters');++ }++ const name = b.name == null ? null : String(b.name).trim();++ if (name && name.length > 100) throw new AiError(422, 'community.endpoint_invalid', 'name is at most 100 characters');++ return {++ base_url: url.toString().replace(/\/+$/, ''), models,++ hours: { start: String(h.start), end: String(h.end), tz },++ concurrency: b.concurrency, price: { in_per_million: roundPrice(price.in_per_million), out_per_million: roundPrice(price.out_per_million) },++ audience, group, name: name || null,++ };++}++++/** A jsonb column as its value: the driver may hand back the parsed object or its text. */++const jsonOf = (v, fallback) => (v && typeof v === 'object' ? v : parseJson(v, fallback));++++/** The seller's own view of an offer (never a token: this API has none). */++function viewOf(row) {++ if (!row) return null;++ const models = jsonOf(row.models, []);++ const hours = jsonOf(row.hours, {});++ return {++ id: row.id, offer_id: `endpoint:${row.id}`, name: row.name || null, base_url: row.base_url,++ models: Array.isArray(models) ? models : [], hours: hours && typeof hours === 'object' ? hours : {},++ concurrency: Number(row.concurrency),++ price: { in_per_million: roundPrice(row.price_in), out_per_million: roundPrice(row.price_out) },++ audience: row.audience, group: row.group_id || null,++ created_at: row.created_at, updated_at: row.updated_at,++ };++}++++/** The offer the community router places on: the shape a Network doc normalizes to, plus its kind and address. */++function offerOf(row) {++ const view = viewOf(row);++ return {++ offer_id: view.offer_id, machine_id: `endpoint:${row.id}`, owner: row.owner_subject, kind: 'endpoint',++ endpoint: { id: row.id, base_url: row.base_url }, projectId: row.project_id || null,++ models: view.models, hours: view.hours, concurrency: view.concurrency, price: view.price,++ audience: view.audience, group: view.group, ready: true, health: 'up', name: view.name,++ };++}++++function createEndpointOffers({ db, config, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, lookup = null, transport = null }) {++ const cfg = (config && config.community && config.community.endpoints) || {};++ const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');++ const secret = env.OV_OAUTH_CLIENT_SECRET || '';++ const clientId = (config && config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';++ let projectTokens = tokens;++ if (!projectTokens && secret && network) {++ projectTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'network.project.read' }, fetch: fetchImpl });++ }++ const maxPerOwner = Math.max(1, Math.min(100, Number(cfg.maxPerOwner) || 20));++ const timeoutMs = Math.max(5000, Math.min(120000, Number(cfg.timeoutMs) || 60000));++ const maxBytes = Math.max(65536, Math.min(16 * 1024 * 1024, Number(cfg.maxBytes) || MAX_BYTES));++ const maxRedirects = Math.max(0, Math.min(3, cfg.maxRedirects == null ? 2 : Number(cfg.maxRedirects)));++ const safeLookup = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });++++ const active = new Map(); // offer id -> calls in flight++ const owners = new Map(); // project id -> { at, owner } (5 minutes)++ const nowIso = () => new Date(clock.now()).toISOString();++++ /** The person a seller acts for. A person's own token is themselves; an app's project owner comes from Network. */++ async function ownerFor(principal) {++ if (!principal) throw new AiError(401, 'token.missing', 'a seller is a person or a developer app');++ if (principal.kind === 'user') {++ const id = principal.subject && principal.subject.id;++ if (!USR.test(String(id))) throw new AiError(422, 'community.endpoint_owner', 'the person has no subject');++ return { owner: String(id), projectId: null, appId: null };++ }++ if (principal.kind === 'app') {++ const projectId = String(principal.projectId || '');++ if (!PROJECT.test(projectId)) throw new AiError(422, 'community.endpoint_owner', 'the app has no project');++ const hit = owners.get(projectId);++ if (hit && clock.now() - hit.at < 300000 && hit.owner) return { owner: hit.owner, projectId, appId: principal.appId || null };++ if (!projectTokens || !network) throw new AiError(503, 'community.unavailable', 'the seller API cannot read project owners here');++ let res;++ try {++ const token = await projectTokens.getToken({ audience: 'openvibe.network' });++ res = await fetchImpl(`${network}/internal/projects/${encodeURIComponent(projectId)}`, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000),++ });++ } catch (err) {++ throw new AiError(503, 'community.unavailable', `Network did not answer for the project: ${String(err && err.message || err).slice(0, 200)}`);++ }++ if (res.status === 404) throw new AiError(403, 'community.endpoint_owner', 'the developer app has no project');++ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for the project`);++ const body = await res.json().catch(() => null);++ const owner = body && body.project && body.project.owner && body.project.owner.id;++ if (!USR.test(String(owner))) throw new AiError(503, 'community.unavailable', 'the project has no owner');++ owners.set(projectId, { at: clock.now(), owner: String(owner) });++ return { owner: String(owner), projectId, appId: principal.appId || null };++ }++ throw new AiError(403, 'community.endpoint_owner', 'a seller is a person or a developer app, not a service');++ }++++ const rows = () => db.prepare('SELECT * FROM community_endpoint_offers ORDER BY created_at DESC');++++ /** Every registered endpoint as a community offer, for the router's offer list. */++ async function liveOffers() {++ if (!db) return [];++ const list = await rows().all();++ return list.map(offerOf);++ }++++ async function list(owner) {++ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');++ return (await rows().all()).filter((r) => r.owner_subject === owner).map(viewOf);++ }++++ async function create({ owner, projectId = null, appId = null, createdBy = null, input }) {++ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');++ const checked = checkInput(input);++ const existing = (await rows().all()).filter((r) => r.owner_subject === owner);++ if (existing.length >= maxPerOwner) throw new AiError(409, 'community.endpoint_limit', `at most ${maxPerOwner} endpoint offers per seller`);++ const at = nowIso();++ const row = {++ id: `ep_${ids.ulid()}`, owner, projectId, appId, createdBy: createdBy || (appId || owner), checked, at,++ };++ await db.prepare(`INSERT INTO community_endpoint_offers (id, owner_subject, project_id, app_id, created_by, name, base_url, models, hours,++ concurrency, price_in, price_out, audience, group_id, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(row.id, owner, projectId, appId, row.createdBy, checked.name, checked.base_url, JSON.stringify(checked.models), JSON.stringify(checked.hours),++ checked.concurrency, checked.price.in_per_million, checked.price.out_per_million, checked.audience, checked.group, at, at);++ return viewOf(await db.prepare('SELECT * FROM community_endpoint_offers WHERE id = ?').get(row.id));++ }++++ /** Remove an offer. Its verification and ledger rows stay: they are history and earnings, not the live offer. */++ async function remove({ id, owner }) {++ if (!ID.test(String(id))) throw new AiError(404, 'community.endpoint_not_found', `no endpoint offer ${id}`);++ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');++ const found = await db.prepare('SELECT id FROM community_endpoint_offers WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));++ if (!found) throw new AiError(404, 'community.endpoint_not_found', `no endpoint offer ${id}`);++ await db.prepare('DELETE FROM community_endpoint_offers WHERE id = ?').run(String(id));++ return { id: String(id), removed: true };++ }++++ /** A registration of the seller's own, by id (the verify route uses it). */++ async function own({ id, owner }) {++ if (!ID.test(String(id))) return null;++ const row = await db.prepare('SELECT * FROM community_endpoint_offers WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));++ return row ? offerOf(row) : null;++ }++++ /** One response, without following anything: the transport seam tests script (redirect chains included). */++ function once(target, { body, timeoutMs: callTimeout, signal }) {++ const send = {++ method: 'POST',++ url: target.toString(),++ headers: {++ 'Content-Type': 'application/json',++ Accept: 'application/json',++ 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.services)',++ 'Content-Length': Buffer.byteLength(body),++ },++ body, lookup: safeLookup, timeoutMs: callTimeout, maxBytes,++ };++ if (transport) return transport(send);++ return new Promise((resolve, reject) => {++ let settled = false;++ const fail = (err) => { if (settled) return; settled = true; try { req.destroy(); } catch { /* */ } reject(err); };++ const ok = (value) => { if (settled) return; settled = true; resolve(value); };++ const req = https.request(target, {++ method: 'POST', headers: send.headers, lookup: safeLookup, timeout: callTimeout, agent: false,++ }, (res) => {++ const status = res.statusCode || 0;++ if (status >= 300 && status < 400) { res.resume(); return ok({ status, headers: res.headers, body: '' }); }++ const declared = Number(res.headers['content-length']);++ if (Number.isFinite(declared) && declared > maxBytes) { res.resume(); return fail(new AiError(413, 'community.endpoint_too_large', `the endpoint answered more than ${maxBytes} bytes`)); }++ let bytes = 0;++ const chunks = [];++ res.on('data', (c) => {++ bytes += c.length;++ if (bytes > maxBytes) { res.destroy(); return fail(new AiError(413, 'community.endpoint_too_large', `the endpoint answered more than ${maxBytes} bytes`)); }++ chunks.push(c);++ });++ res.on('error', (err) => fail(new AiError(502, 'community.endpoint_unavailable', `the endpoint's answer failed: ${String(err && err.message || err).slice(0, 200)}`)));++ res.on('end', () => ok({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));++ });++ req.on('error', (err) => fail(new AiError(502, 'community.endpoint_unavailable', err && err.code === 'EGRESS_DENIED'++ ? 'the endpoint resolves to a non-public address'++ : `the endpoint could not be reached: ${String(err && err.message || err).slice(0, 200)}`)));++ req.on('timeout', () => fail(new AiError(504, 'community.endpoint_timeout', `the endpoint did not answer within ${callTimeout}ms`)));++ const timer = setTimeout(() => fail(new AiError(504, 'community.endpoint_timeout', `the endpoint did not answer within ${callTimeout}ms`)), callTimeout);++ if (signal) {++ const onAbort = () => fail(new AiError(409, 'run.cancelled', 'cancelled'));++ if (signal.aborted) return onAbort();++ signal.addEventListener('abort', onAbort, { once: true });++ req.on('close', () => signal.removeEventListener('abort', onAbort));++ }++ req.on('close', () => clearTimeout(timer));++ req.end(body);++ });++ }++++ /** POST one chat completion, redirects judged hop by hop. → { status, headers, body } */++ async function send(rawUrl, body, { timeoutMs: callTimeout = timeoutMs, signal = null } = {}) {++ let target = judgeBaseUrl(rawUrl);++ for (let hop = 0; hop <= maxRedirects; hop++) {++ const res = await once(target, { body, timeoutMs: callTimeout, signal });++ if (res.status < 300 || res.status >= 400) return res;++ const location = res.headers && res.headers.location;++ if (!location) throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected without a location');++ if (![307, 308].includes(res.status)) throw new AiError(502, 'community.endpoint_redirect', `the endpoint answered ${res.status} to a POST; only 307 and 308 are followed`);++ let next;++ try { next = judgeBaseUrl(new URL(String(location), target).toString()); } catch (err) {++ throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected to an address that is not allowed');++ }++ if (hop === maxRedirects) throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected too many times');++ target = next;++ }++ throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected too many times');++ }++++ /** The answer in the shape server/community/index.js reads from a machine. */++ function parseAnswer(text) {++ let data;++ try { data = JSON.parse(text || ''); } catch { throw new AiError(502, 'community.endpoint_response', 'the endpoint did not answer JSON'); }++ const choice = data && Array.isArray(data.choices) ? data.choices[0] : null;++ const message = choice && choice.message && typeof choice.message === 'object' ? choice.message : null;++ if (!message) throw new AiError(502, 'community.endpoint_response', 'the endpoint did not answer a chat completion');++ const content = typeof message.content === 'string' ? message.content : Array.isArray(message.content)++ ? message.content.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('') : '';++ const answer = { role: 'assistant', content: content.slice(0, MAX_CONTENT) };++ if (Array.isArray(message.tool_calls) && message.tool_calls.length) {++ answer.tool_calls = message.tool_calls.slice(0, 32).filter((c) => c && c.function && typeof c.function.name === 'string')++ .map((c, i) => ({ id: String(c.id || `call_${i + 1}`).slice(0, 200), type: 'function',++ function: { name: String(c.function.name).slice(0, 64), arguments: typeof c.function.arguments === 'string' ? c.function.arguments.slice(0, MAX_CONTENT) : JSON.stringify(c.function.arguments || {}) } }));++ if (!answer.tool_calls.length) delete answer.tool_calls;++ }++ const usage = data.usage && typeof data.usage === 'object' ? data.usage : {};++ return {++ text: answer.content, message: answer,++ model: typeof data.model === 'string' ? data.model : null,++ usage: { input: Math.max(0, Math.trunc(Number(usage.prompt_tokens) || 0)), output: Math.max(0, Math.trunc(Number(usage.completion_tokens) || 0)), cached: 0 },++ finish: choice.finish_reason || (answer.tool_calls ? 'tool_calls' : 'stop'),++ };++ }++++ /**++ * One chat completion on a seller's endpoint. The seller's declared concurrency is honoured here: over it, the++ * call is busy and the router falls back, rather than piling requests on the server.++ */++ async function chat(offer, { model, messages, maxTokens = 512, temperature = null, tools = null, timeoutMs: callTimeout = timeoutMs, signal = null } = {}) {++ const id = offer && offer.endpoint && offer.endpoint.id;++ if (!id) throw new AiError(502, 'community.endpoint_unavailable', 'not an endpoint offer');++ const cap = Math.max(1, Number(offer.concurrency) || 1);++ const inFlight = active.get(id) || 0;++ if (inFlight >= cap) throw new AiError(503, 'community.busy', 'the endpoint is at its concurrency limit');++ active.set(id, inFlight + 1);++ try {++ const list = (Array.isArray(messages) ? messages : []).slice(0, MAX_MESSAGES).map((m) => {++ const one = { role: ['system', 'user', 'assistant', 'tool'].includes(m && m.role) ? m.role : 'user', content: textOf(m && m.content) };++ if (one.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) one.tool_calls = m.tool_calls.slice(0, 32);++ if (one.role === 'tool' && m.tool_call_id) one.tool_call_id = String(m.tool_call_id).slice(0, 200);++ return one;++ });++ const request = { model: String(model || ''), messages: list, max_tokens: Math.max(1, Math.min(Number(maxTokens) || 512, 32768)), stream: false };++ if (temperature != null && Number.isFinite(Number(temperature))) request.temperature = Number(temperature);++ if (Array.isArray(tools) && tools.length) { request.tools = tools; request.tool_choice = 'auto'; }++ const res = await send(`${offer.endpoint.base_url}/chat/completions`, JSON.stringify(request), { timeoutMs: callTimeout, signal });++ if (res.status !== 200) throw new AiError(502, 'community.endpoint_unavailable', `the endpoint answered HTTP ${res.status}`);++ return parseAnswer(res.body);++ } finally {++ const left = (active.get(id) || 1) - 1;++ if (left <= 0) active.delete(id); else active.set(id, left);++ }++ }++++ return { enabled: Boolean(db), liveOffers, list, create, remove, ownerFor, chat, judgeBaseUrl };++}++++module.exports = { createEndpointOffers, judgeBaseUrl };@@@ -21,7 +21,7 @@* A volunteer (price 0) earns kudos in an append-only ledger; they never expire, buy queue priority and a bigger free* output allowance, and are never cash.*-- * createCommunity({ db, config, env, machines, clock, fetchImpl, log, tokens, random })++ * createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log, tokens, random })* .enabled* .offers() the live offers (refreshed from Network at most refreshMs)* .choose({ ctx, operation }) one eligible {offer, model, key}, or null@@@ -118,7 +118,7 @@ function similar(a, b) {return same / Math.max(x.size, y.size) >= 0.5;}--function createCommunity({ db, config, env = process.env, machines = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {++function createCommunity({ db, config, env = process.env, machines = null, endpoints = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {const cfg = (config && config.community) || {};const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');const secret = env.OV_OAUTH_CLIENT_SECRET || '';@@@ -135,7 +135,11 @@ function createCommunity({ db, config, env = process.env, machines = null, clockif (!identityTokens && secret && network) {identityTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl });}-- const enabled = Boolean(cfg.enabled && db && machines && network && registryTokens);++ // A Node offer comes from Network and is reached through Bot; a community endpoint is registered here and reached++ // through its own https address. Each works without the other.++ const registryEnabled = Boolean(cfg.enabled && db && machines && network && registryTokens);++ const endpointsEnabled = Boolean(cfg.enabled && db && endpoints && endpoints.enabled);++ const enabled = registryEnabled || endpointsEnabled;let cache = { at: 0, offers: [] };let refreshing = null;const followersCache = new Map();@@@ -167,23 +171,37 @@ function createCommunity({ db, config, env = process.env, machines = null, clockasync function fetchOffers() {if (!enabled) return [];-- const token = await registryTokens.getToken({ audience: 'openvibe.network' });-- let res;-- try {-- res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });-- } catch (err) {-- throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);++ let offers = [];++ if (registryEnabled) {++ try {++ const token = await registryTokens.getToken({ audience: 'openvibe.network' });++ let res;++ try {++ res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });++ } catch (err) {++ throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);++ }++ const body = await res.json().catch(() => null);++ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);++ offers = ((body && body.offers) || []).map(normalize).filter(Boolean);++ } catch (err) {++ // With endpoints registered here, Network being away must not take them down.++ if (!endpointsEnabled) throw err;++ log.warn(`[community] ${err.message}`);++ }++ }++ if (endpointsEnabled) {++ try { offers = offers.concat(await endpoints.liveOffers()); } catch (err) { log.warn(`[community] endpoints: ${err.message}`); }}-- const body = await res.json().catch(() => null);-- if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);-- const offers = ((body && body.offers) || []).map(normalize).filter(Boolean);await noteHealth(offers);return offers;}-- /** Keep a simple uptime denominator: each refresh is one observation of each offer and model. */++ /** Keep a simple uptime denominator: each refresh is one observation of each Node offer and model. A community++ * endpoint has no reporter, so its observations are its own calls (noteDispatch). */async function noteHealth(offers) {for (const offer of offers) {++ if (offer.kind === 'endpoint') continue;for (const model of offer.models) {try {const key = keyOf(offer, model);@@@ -211,6 +229,22 @@ function createCommunity({ db, config, env = process.env, machines = null, clockreturn await refreshing;}++ /** One observation of a community endpoint: its answer arrived or it did not. Machines report their own health. */++ async function noteDispatch(offer, model, ok) {++ if (!offer || offer.kind !== 'endpoint') return;++ const key = keyOf(offer, model);++ const at = nowIso(clock);++ try {++ await db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status, checks, up_checks, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', 1, ?, ?, ?)++ ON CONFLICT (offer_key) DO UPDATE SET models = excluded.models, price_in = excluded.price_in, price_out = excluded.price_out,++ checks = community_offers.checks + 1, up_checks = community_offers.up_checks + excluded.up_checks, updated_at = excluded.updated_at`)++ .run(key, offer.offer_id, offer.machine_id, offer.owner, model, JSON.stringify(offer.models), offer.price.in_per_million, offer.price.out_per_million,++ ok ? 1 : 0, at, at);++ await refreshReputation(key);++ } catch (err) { log.warn(`[community] endpoint health ${key}: ${err.message}`); }++ }++async function stateOf(key) {return await db.prepare('SELECT * FROM community_offers WHERE offer_key = ?').get(key);}@@@ -250,8 +284,20 @@ function createCommunity({ db, config, env = process.env, machines = null, clockawait refreshReputation(key);}-- /** Low-level: one chat completion on the person's machine, through Bot, as its owner. */++ /** Low-level: one chat completion on the person's machine (through Bot), or on a registered endpoint (through the++ * egress guard), as its owner. */async function dispatch(offer, model, messages, { maxTokens = 512, temperature = null, tools = null, timeoutMs = 60000, requestId = null } = {}) {++ if (offer.kind === 'endpoint') {++ try {++ return await endpoints.chat(offer, {++ model, messages, maxTokens, temperature, tools,++ timeoutMs: Math.max(5000, Math.min(300000, timeoutMs)), signal: AbortSignal.timeout(Math.max(5000, timeoutMs) + 10000),++ });++ } catch (err) {++ if (err && err.code === 'community.busy') throw new AiError(503, 'community.busy', String(err.message || 'the endpoint is busy'));++ throw err;++ }++ }if (!machines || !machines.enabled) throw new AiError(503, 'community.unavailable', 'OpenVibe.Bot is not configured, so a community machine cannot be reached');try {return await machines.chat({@@@ -278,8 +324,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clocklet answer;try {answer = await dispatch(offer, model, canary.messages, { maxTokens: 64, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `canary-${key}` });++ await noteDispatch(offer, model, true);} catch (err) {if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) throw err;++ await noteDispatch(offer, model, false);await markCanary(key, { ok: false, reason: `canary_call: ${String(err && err.message || err).slice(0, 200)}` });throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary: ${String(err && err.message || err).slice(0, 200)}`);}@@@ -410,7 +458,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clock/** About 1%: the same prompt on a trusted machine with the same model, compared. No trusted peer, no spot. */async function spotCheck(chosen, messages, answer) {if (!(cfg.spotRate > 0) || random() >= cfg.spotRate) return;-- const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id && o.health === 'up' && o.ready && o.models.includes(chosen.model));++ const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id++ // A second registration of the same endpoint is not an independent machine.++ && !(o.endpoint && chosen.offer.endpoint && o.endpoint.base_url === chosen.offer.endpoint.base_url)++ && o.health === 'up' && o.ready && o.models.includes(chosen.model));const trusted = [];for (const peer of peers) {const s = await stateOf(keyOf(peer, chosen.model));@@@ -441,12 +492,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock* - until the machine has `trustDays` of verified service a seller earns at most the daily cap;* - a volunteer (price 0) earns kudos instead, never cash.*/-- async function recordUsage(chosen, { requestId, usage, verified, requester = null, latencyMs = null, chargeId = null }) {++ async function recordUsage(chosen, { requestId, usage, verified, requester = null, projectId = null, latencyMs = null, chargeId = null }) {const tin = Math.max(0, Math.trunc(Number(usage && usage.input) || 0));const tout = Math.max(0, Math.trunc(Number(usage && usage.output) || 0));const owner = await survivor(chosen.offer.owner);const person = requester ? await survivor(requester) : null;-- const selfServed = Boolean(person && owner && person === owner);++ // Self-dealing: the owner themselves, an account merged into it, or the project that registered the endpoint.++ const selfServed = Boolean((person && owner && person === owner)++ || (projectId && chosen.offer.projectId && String(projectId) === String(chosen.offer.projectId)));let credits = verified && !selfServed ? Math.round(tin * chosen.offer.price.in_per_million + tout * chosen.offer.price.out_per_million) : 0;let earning = !verified ? 'free' : selfServed ? 'self' : (chargeId ? 'spendable' : 'pending');let capped = false;@@@ -506,11 +559,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clockconst timeoutMs = Math.max(5000, Math.min(Number(req.timeoutMs) || 60000, 300000));const requestId = String(ctx.runId || `community-${clock.now()}`);const requester = requesterOf(ctx);-- const selfServed = Boolean(requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)));++ const selfServed = Boolean((requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)))++ || (ctx.projectId && chosen.offer.projectId && String(ctx.projectId) === String(chosen.offer.projectId)));++ const isEndpoint = chosen.offer.kind === 'endpoint';++ const source = isEndpoint ? `community-endpoint:${chosen.offer.endpoint.id}` : `community:${chosen.offer.machine_id}`;const facts = {-- resolved_model: `community/${chosen.model}`, revision: null, deployment: `community:${chosen.offer.machine_id}`,-- provider_access: 'COMMUNITY', rate_version: 'community', policy: 'community',-- community: { machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,++ resolved_model: `community/${chosen.model}`, revision: null, deployment: source,++ provider_access: isEndpoint ? 'COMMUNITY_ENDPOINT' : 'COMMUNITY', rate_version: 'community', policy: 'community',++ community: { kind: isEndpoint ? 'endpoint' : 'machine', machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },};if (typeof ctx.onRoute === 'function') {@@@ -525,14 +581,16 @@ function createCommunity({ db, config, env = process.env, machines = null, clocktemperature: req.temperature == null ? null : Number(req.temperature),tools, timeoutMs, requestId,});++ await noteDispatch(chosen.offer, chosen.model, true);} catch (err) {if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) return null;++ await noteDispatch(chosen.offer, chosen.model, false);throw err;}const usage = answer.usage || { input: 0, output: 0, cached: 0 };await spotCheck(chosen, messages, answer);const latencyMs = clock.now() - t0;-- const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, latencyMs,++ const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, projectId: ctx.projectId || null, latencyMs,chargeId: ctx.requesterChargeId || null });const text = answer.text || '';const message = answer.message || { role: 'assistant', content: text };@@@ -545,14 +603,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clockcredits_micro: recorded.credits_micro, earning_status: recorded.earning_status, self_served: recorded.self_served, kudos: recorded.kudos };const explain = {objective: 'community', reasons: [recorded.self_served-- ? 'self-served: the requester owns this machine, so the answer earns no credits'-- : `a community machine offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],-- selected: `community:${chosen.offer.machine_id}/${chosen.model}`, paid_by: 'community', candidates: [],-- attempts: [{ key: 'community', provider: `community:${chosen.offer.machine_id}`, model: chosen.model, why: 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],++ ? 'self-served: the requester owns this offer, so the answer earns no credits'++ : `${isEndpoint ? 'a community endpoint' : 'a community machine'} offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],++ selected: `${source}/${chosen.model}`, paid_by: 'community', candidates: [],++ attempts: [{ key: 'community', provider: source, model: chosen.model, why: isEndpoint ? 'an opted-in request placed on a verified community endpoint' : 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],community, openvibe: facts,};return {-- result, provider: `community:${chosen.offer.machine_id}`, providerKind: 'community', model: answer.model || chosen.model,++ result, provider: source, providerKind: 'community', model: answer.model || chosen.model,fallbackUsed: false, usage, cost: 0, latencyMs, ttftMs: null, synthetic: false, tried: [], startedAt: t0, explain,};}@@@ -617,7 +675,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clockconst kudos = Math.max(0, Number(kudosRow.balance) || 0);const offerViews = await Promise.all(list.map(async (o) => {const openNow = o.hours ? withinHours(o, clock.now()) : null;-- const out = { offer_id: o.offer_id, machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,++ const out = { offer_id: o.offer_id, kind: o.kind || 'machine', machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,price: o.price, audience: o.audience, group: o.group, health: o.health, open_now: openNow, ready: o.ready && openNow !== false,verification: null, reputation: 0, free_only: true, verified: false,tokens_per_s: Math.max(0, ...served.filter((x) => x.offer_key.startsWith(`${o.offer_id}|`)).map((x) => x.tokens_per_s || 0)) || null };@@@ -671,7 +729,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clockconst openNow = withinHours(o, clock.now());return {// The public id is a stable hash: Exchange can list and link an offer without publishing which machine it is.-- offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, models: o.models, hours: o.hours, concurrency: o.concurrency,++ offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, kind: o.kind || 'machine', models: o.models, hours: o.hours, concurrency: o.concurrency,price: o.price, audience: o.audience, health: o.health, open_now: openNow, ready: o.ready && openNow,};});@@@ -429,6 +429,17 @@ function load(env = process.env) {trustDays: Math.max(1, Math.min(365, int(env.AI_COMMUNITY_TRUST_DAYS, 7))),dailyEarningCapMicro: Math.max(0, Math.min(1e15, int(env.AI_COMMUNITY_DAILY_CAP_CREDITS, 100) * 1e6)),kudosPerThousandTokens: Math.max(1, Math.min(1000, int(env.AI_COMMUNITY_KUDOS_PER_1K, 1))),++ // A community endpoint is a standalone OpenAI-compatible server registered by its own https address with++ // the seller's Network developer key (server/community/endpoints.js). AI reaches it only through the egress++ // guard: https, a public DNS answer, no IP literal, a timeout, a response byte cap, and never the buyer's++ // identity in a header. maxPerOwner bounds one seller's registrations.++ endpoints: {++ enabled: bool(env.AI_COMMUNITY_ENDPOINTS, true),++ timeoutMs: Math.max(5000, Math.min(120000, int(env.AI_COMMUNITY_ENDPOINT_TIMEOUT_MS, 60000))),++ maxBytes: Math.max(65536, Math.min(16 * 1024 * 1024, int(env.AI_COMMUNITY_ENDPOINT_MAX_BYTES, 2 * 1024 * 1024))),++ maxRedirects: Math.max(0, Math.min(3, int(env.AI_COMMUNITY_ENDPOINT_MAX_REDIRECTS, 2))),++ maxPerOwner: Math.max(1, Math.min(100, int(env.AI_COMMUNITY_ENDPOINT_MAX_PER_OWNER, 20))),++ },},// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.@@@ -93,6 +93,8 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =resolved_model: (x.openvibe && x.openvibe.resolved_model) || null,// The host's own model answered (local / local2): the receipt says so, beside the provider name.local: Boolean(x.provider_local),++ // A community answer says which kind of community source it was: a person's machine or a registered endpoint.++ community: (x.openvibe && x.openvibe.community && x.openvibe.community.kind) || null,},price: {in_per_mtok: round(price && price.in),@@@ -125,6 +127,7 @@ function compact(receipt) {run_id: receipt.run_id,offer: offer.provider ? `${offer.provider}${offer.model ? `/${offer.model}` : ''}` : null,local: Boolean(offer.local),++ community: offer.community || null,price: { in: receipt.price && receipt.price.in_per_mtok, out: receipt.price && receipt.price.out_per_mtok, cached: receipt.price && receipt.price.cached_per_mtok },tokens: { in: receipt.usage && receipt.usage.tokens_in, out: receipt.usage && receipt.usage.tokens_out, cached: receipt.usage && receipt.usage.tokens_cached },cost_usd: receipt.cost_usd,@@@ -32,7 +32,7 @@ const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, abuse: givenAbuse = null } = {}) {++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, abuse: givenAbuse = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -65,7 +65,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst machines = machineModels || require('./machines').createMachineModels({ config, env, fetchImpl });// Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a// machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.-- const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });++ // A community endpoint (the design's SDK gap) is registered here with the seller's own developer key and reached++ // through the egress guard; it joins the same offer list, verification and ledger.++ const endpoints = givenEndpoints || (config.community.endpoints && config.community.endpoints.enabled === false++ ? null++ : require('./community/endpoints').createEndpointOffers({ db, config, env, clock, fetchImpl, log }));++ const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log });// Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.@@@ -152,7 +157,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, presets, receipts, balance, gpus, abuse });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, presets, receipts, balance, gpus, abuse });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -241,7 +246,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, gpus, abuse, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, endpoints, gpus, abuse, close };}if (require.main === module) {@@@ -360,6 +360,56 @@ route('get', '/exchange/usage', {} }) },});++// ── The seller API: a standalone OpenAI-compatible endpoint (the design's SDK gap) ───────────++const sellerEndpointOffer = {++ type: 'object', required: ['id', 'offer_id', 'base_url', 'models', 'hours', 'concurrency', 'price', 'audience'],++ properties: {++ id: str({ pattern: '^ep_[0-9A-HJKMNP-TV-Z]{26}$' }),++ offer_id: str({ description: 'The offer id the router and the receipt use: endpoint:<id>' }),++ name: nullable('string'),++ base_url: str({ description: 'The https base URL the router calls; /chat/completions is appended. Port 443 or 8443, a host name (never an IP address).' }),++ models: arrayOf(str()), hours: { type: 'object', properties: { start: str(), end: str(), tz: str() } },++ concurrency: integer({ minimum: 1, maximum: 8 }),++ price: { type: 'object', properties: { in_per_million: { type: 'number' }, out_per_million: { type: 'number' } } },++ audience: str({ enum: ['everyone', 'followers', 'group'] }), group: nullable('string'),++ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }),++ verification: nullable('object', { description: 'Canaries, spot re-runs, reputation and free-only, as the /sell dashboard shows them' }),++ reputation: integer({ minimum: 0, maximum: 100 }), free_only: { type: 'boolean' }, verified: { type: 'boolean' },++ },++};++const sellerEndpointInput = {++ type: 'object', required: ['base_url', 'models', 'price', 'hours', 'concurrency'],++ properties: {++ base_url: str({ description: 'https, a public host name (no IP literal), port 443 or 8443, an optional base path such as /v1' }),++ models: { type: 'array', items: str(), minItems: 1, maxItems: 40 },++ price: { description: 'Credits per million tokens: a number for both sides, or {in_per_million, out_per_million}; 0 volunteers the model', oneOf: [++ { type: 'number', minimum: 0, maximum: 1000000 },++ { type: 'object', required: ['in_per_million', 'out_per_million'], properties: { in_per_million: { type: 'number', minimum: 0, maximum: 1000000 }, out_per_million: { type: 'number', minimum: 0, maximum: 1000000 } } },++ ] },++ hours: { type: 'object', required: ['start', 'end'], properties: { start: str({ pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }), end: str({ pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }), tz: str() } },++ concurrency: integer({ minimum: 1, maximum: 8 }),++ audience: str({ enum: ['everyone', 'followers', 'group'] }),++ group: str({ description: 'Required when audience is group' }),++ name: str({ maxLength: 100 }),++ },++};++route('post', '/exchange/seller/offers', {++ operationId: 'createSellerEndpointOffer', summary: 'Register a standalone OpenAI-compatible server to sell', tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'ai.app.run (a Network developer key), or a person\'s own token; a first-party service token is refused. The owner is the person the key acts for: an app\'s project owner, or the person themselves. The router reaches base_url only through the egress guard: https, a public DNS answer, never an IP literal, a timeout, a response byte cap, and never the buyer\'s identity in a header. A new endpoint serves the free tier only until its canaries, model fingerprint and spot re-runs verify it, exactly as a Node machine does; a request on the seller\'s own account is self-served and earns 0. An answer from it is labelled a community endpoint on the receipt (provider community-endpoint:<id>, provider_access COMMUNITY_ENDPOINT) and only requests that opt in to community capacity reach it.',++ requestBody: body(sellerEndpointInput),++ responses: { 201: json('The registered offer', { type: 'object', required: ['offer'], properties: { offer: sellerEndpointOffer } }), 409: problem, 422: problem, ...errors([401, 403, 503]) },++});++route('get', '/exchange/seller/offers', {++ operationId: 'listSellerEndpointOffers', summary: 'The caller\'s own endpoint offers, with their verification', tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'ai.app.run, or a person\'s own token. Only the caller\'s offers: an app sees the offers of its project owner. The verification is the same the /sell dashboard shows.',++ responses: { 200: json('The offers', { type: 'object', required: ['owner', 'offers'], properties: { owner: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), offers: arrayOf(sellerEndpointOffer) } }), ...errors([401, 403, 503]) },++});++route('delete', '/exchange/seller/offers/{id}', {++ operationId: 'deleteSellerEndpointOffer', summary: 'Remove one of the caller\'s endpoint offers', tags: ['exchange'], security: SERVICE_OR_APP,++ description: 'ai.app.run, or a person\'s own token. The offer stops receiving traffic on the next offer refresh (within 30 seconds); its verification and earnings stay as history.',++ parameters: [path('id', str({ pattern: '^ep_[0-9A-HJKMNP-TV-Z]{26}$' }))],++ responses: { 200: json('The removal', { type: 'object', properties: { id: str(), removed: { type: 'boolean' } } }), 404: problem, ...errors([401, 403]) },++});route('post', '/exchange/tryit/challenge', {operationId: 'exchangeTryitChallenge', summary: 'A proof-of-work challenge for the anonymous Try it', tags: ['exchange'], security: [],description: 'Public. The anonymous Try it (no account, no Balance) must send a solved challenge in x-ov-pow: <challenge>:<nonce>, where sha256("<challenge>:<nonce>") has `bits` leading zero bits. The challenge is single use and expires; AI stores only its hash. The response also sets the signed, HttpOnly `ovai_device` cookie (when it is not already present), which counts accounts per device. A first-party service may forward the client address in x-ov-client-ip.',@@@ -581,7 +581,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now? explain.attempts[0].provider : null;const record = (provider ? await registry.getProvider(provider) : null)|| (credentialProvider ? await registry.getProvider(credentialProvider) : null);-- const communityAnswer = Boolean(provider && String(provider).startsWith('community:')) || Boolean(explain && explain.community);++ // A community machine (`community:`) or a registered community endpoint (`community-endpoint:`) sees the prompt.++ const communityAnswer = Boolean(provider && /^community[:-]/.test(String(provider))) || Boolean(explain && explain.community);return communityAnswer? privacy.labelFor({ record: { metadata: { data_policy: { trust: 'community' } } } }): privacy.labelFor({ record, credential: byok ? { provider: credentialProvider } : null });nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++// The public seller API (design EXCHANGE-REMAKE-2026-11-06, the SDK gap): a developer key registers, lists and removes++// a standalone OpenAI-compatible endpoint offer on the Exchange's public API. The owner is the app's project owner,++// read from Network; a first-party service token is refused; the seller never names an owner.++const assert = require('assert');++const { boot, request, token, suite } = require('./helpers');++const { createEndpointOffers } = require('../server/community/endpoints');++++const t = suite('community-endpoint-api');++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++const APP = '01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++const PROJECT = '01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';++const silent = { log() {}, warn() {}, error() {} };++++const INPUT = {++ base_url: 'https://models.example/v1', models: ['qwen3:32b'],++ price: { in_per_million: 10, out_per_million: 30 },++ hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2, audience: 'everyone',++};++++function appToken(caps = ['ai.app.run']) {++ return token('app', caps, {++ sub: `app:app_${APP}`, actorType: 'app', ns: [`prj_${PROJECT}`, `app.prj_${PROJECT}.*`],++ extra: { project_id: `prj_${PROJECT}`, env: 'production' },++ });++}++++function endpointStore(db, { project = OWNER } = {}) {++ return createEndpointOffers({++ db,++ config: { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, endpoints: { enabled: true } } },++ env: {}, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, log: silent,++ tokens: { getToken: async () => 'test-token' },++ fetchImpl: async (url) => (String(url).includes('/internal/projects/')++ ? (project ? { ok: true, status: 200, json: async () => ({ project: { id: `prj_${PROJECT}`, owner: { type: 'user', id: project } } }) }++ : { ok: false, status: 404, json: async () => null })++ : { ok: false, status: 404, json: async () => null }),++ });++}++++let h;++t.test('boot', async () => {++ h = await boot({ communityEndpoints: (db) => endpointStore(db) });++});++++t.test('the seller API is guarded: a token is required, and a service token is not a seller', async () => {++ const anon = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { body: INPUT });++ assert.strictEqual(anon.status, 401, anon.text);++ const service = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: token('exchange', ['ai.run.create']), body: INPUT });++ assert.strictEqual(service.status, 403, service.text);++ assert.strictEqual(service.body.code, 'community.endpoint_owner');++ const appWithoutCap = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(['ai.run.create']), body: INPUT });++ assert.strictEqual(appWithoutCap.status, 403, appWithoutCap.text);++ assert.strictEqual(appWithoutCap.body.code, 'capability.denied');++});++++t.test('a developer key registers its own endpoint offer, and the owner is the project owner', async () => {++ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });++ assert.strictEqual(created.status, 201, created.text);++ const offer = created.body.offer;++ assert.match(offer.id, /^ep_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(offer.offer_id, `endpoint:${offer.id}`);++ assert.strictEqual(offer.base_url, 'https://models.example/v1');++ assert.deepStrictEqual(offer.models, ['qwen3:32b']);++ assert.deepStrictEqual(offer.hours, { start: '00:00', end: '23:59', tz: 'UTC' });++ assert.deepStrictEqual(offer.price, { in_per_million: 10, out_per_million: 30 });++ assert.strictEqual(offer.concurrency, 2);++ assert.strictEqual(offer.audience, 'everyone');++++ const listed = await request(h.base, 'GET', '/api/v1/exchange/seller/offers', { tok: appToken() });++ assert.strictEqual(listed.status, 200, listed.text);++ assert.strictEqual(listed.body.owner, OWNER, 'the owner is the project\'s person, never the app');++ assert.strictEqual(listed.body.offers.length, 1);++ assert.strictEqual(listed.body.offers[0].id, offer.id);++++ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${offer.id}`, { tok: appToken() });++ assert.strictEqual(removed.status, 200, removed.text);++ assert.deepStrictEqual(removed.body, { id: offer.id, removed: true });++++ const after = await request(h.base, 'GET', '/api/v1/exchange/seller/offers', { tok: appToken() });++ assert.strictEqual(after.status, 200);++ assert.deepStrictEqual(after.body.offers, []);++});++++t.test('an address the egress guard would refuse is refused at registration, with the seller\'s key', async () => {++ for (const [base_url, re] of [['http://models.example/v1', /https/], ['https://127.0.0.1/v1', /IP address/],++ ['https://169.254.169.254/latest', /IP address/], ['https://[::1]/v1', /IP address/], ['https://localhost/v1', /public host name/]]) {++ const out = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: { ...INPUT, base_url } });++ assert.strictEqual(out.status, 422, out.text);++ assert.strictEqual(out.body.code, 'community.endpoint_url');++ assert.match(out.body.detail, re);++ }++});++++t.test('a key removes its own offers only', async () => {++ const other = await h.endpoints.create({ owner: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2', input: { ...INPUT, base_url: 'https://other.example/v1' } });++ const notMine = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${other.id}`, { tok: appToken() });++ assert.strictEqual(notMine.status, 404, notMine.text);++ assert.strictEqual(notMine.body.code, 'community.endpoint_not_found');++++ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });++ assert.strictEqual(created.status, 201, created.text);++ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: appToken() });++ assert.strictEqual(removed.status, 200, removed.text);++ const again = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: appToken() });++ assert.strictEqual(again.status, 404, again.text);++});++++t.test('Exchange lists an endpoint publicly without its address or owner', async () => {++ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });++ assert.strictEqual(created.status, 201, created.text);++ await h.community.offers({ force: true });++ const listed = await request(h.base, 'GET', '/api/v1/community/offers');++ assert.strictEqual(listed.status, 200, listed.text);++ const offer = listed.body.offers.find((o) => o.kind === 'endpoint');++ assert.ok(offer, 'the endpoint is in the public list');++ assert.match(offer.offer_id, /^community_[0-9a-f]{16}$/);++ assert.strictEqual(offer.base_url, undefined, 'the address is never public');++ assert.strictEqual(offer.owner, undefined);++ assert.deepStrictEqual(offer.models, ['qwen3:32b']);++});++++t.test('a project Network does not know is refused, never guessed', async () => {++ const h2 = await boot({ communityEndpoints: (db) => endpointStore(db, { project: null }) });++ try {++ const out = await request(h2.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });++ assert.strictEqual(out.status, 403, out.text);++ assert.strictEqual(out.body.code, 'community.endpoint_owner');++ } finally { await h2.stop(); }++});++++t.run();nnew file mode 100644@@@ -0,0 +1,280 @@++'use strict';++// Community endpoints (the design's SDK gap): a standalone OpenAI-compatible server is registered by its own https++// address, and AI reaches it only through the egress guard. The tests are the guard's own: https only, no IP literal++// (127.0.0.1, 10/8, 169.254.169.254, [::1], a NAT64 spelling), no private DNS answer, no redirect to one; a timeout and++// a response cap; and never the buyer's identity in a header. Then the same trust as a Node machine: the canaries, the++// model fingerprint, the free tier only until verified, and self-dealing earns 0.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++const { createEndpointOffers, judgeBaseUrl } = require('../server/community/endpoints');++const { createCommunity } = require('../server/community');++const { buildReceipt } = require('../server/exchange/receipts');++++const t = suite('community-endpoints');++const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';++const OTHER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2';++const silent = { log() {}, warn() {}, error() {} };++++const INPUT = {++ base_url: 'https://models.example/v1', models: ['qwen3:32b'],++ price: { in_per_million: 10, out_per_million: 30 },++ hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2, audience: 'everyone',++};++++function cfg(over = {}) {++ return { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, refreshMs: 1000, canaryMaxAgeMs: 21600000, spotRate: 0, canaryTimeoutMs: 5000, trustDays: 7, dailyEarningCapMicro: 100000000, kudosPerThousandTokens: 1, ...over } };++}++++function networkFetch(owner = OWNER) {++ return async (url) => {++ if (String(url).includes('/internal/projects/')) return { ok: true, status: 200, json: async () => ({ project: { id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', owner: { type: 'user', id: owner } } }) };++ return { ok: false, status: 404, json: async () => null };++ };++}++++/** A scripted OpenAI endpoint: canaries answer correctly; anything else echoes, or says what the test wants. */++function fakeEndpoint({ canary = true, model = 'qwen3:32b', answer = null } = {}) {++ const calls = [];++ const transport = async ({ url, headers, body }) => {++ const request = JSON.parse(body);++ calls.push({ url, headers, request });++ const last = String((request.messages[request.messages.length - 1] || {}).content || '');++ let text;++ if (last.includes('OPENVIBE-CANARY')) text = 'OPENVIBE-CANARY-7F3A';++ else if (last.includes('17 + 25')) text = canary ? '42' : '41';++ else if (last.includes('canary-ok')) text = 'canary-ok';++ else text = answer != null ? answer : `You said: ${last}`;++ return {++ status: 200, headers: {},++ body: JSON.stringify({ model, choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 10, completion_tokens: 5 } }),++ };++ };++ return { calls, transport };++}++++function storeFor(db, { transport = null, lookup = null, owner = OWNER, fetchImpl = networkFetch(owner), over = {} } = {}) {++ let ms = Date.parse('2026-11-06T12:00:00Z');++ return createEndpointOffers({++ db, config: cfg(over), env: {}, fetchImpl, transport, lookup,++ clock: { now: () => ms }, log: silent, tokens: { getToken: async () => 'test-token' },++ });++}++++t.test('the URL guard is https, a public host name, 443/8443 — never an address', async () => {++ for (const bad of ['http://models.example/v1', 'https://127.0.0.1/v1', 'https://10.0.0.1/v1', 'https://169.254.169.254/latest',++ 'https://[::1]/v1', 'https://[64:ff9b::7f00:1]/v1', 'https://localhost/v1', 'https://models.local/v1',++ 'https://user:[email protected]/v1', 'https://models.example:8080/v1', 'https://models.example/v1?x=1']) {++ assert.throws(() => judgeBaseUrl(bad), /https|IP address|public host|credentials|port 443|query or fragment/, bad);++ }++ assert.strictEqual(String(judgeBaseUrl('https://models.example/v1')), 'https://models.example/v1');++ assert.strictEqual(judgeBaseUrl('https://models.example/').pathname, '/');++ assert.strictEqual(judgeBaseUrl('https://models.example:8443/v1').port, '8443');++});++++t.test('the registration checks models, hours, time zone, price, concurrency and audience', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const store = storeFor(h.db);++ const bad = [++ [{ ...INPUT, models: [] }, /models/], [{ ...INPUT, models: ['a', 'a'] }, /models/],++ [{ ...INPUT, hours: { start: '25:00', end: '01:00', tz: 'UTC' } }, /hours/],++ [{ ...INPUT, hours: { start: '00:00', end: '01:00', tz: 'Not/AZone' } }, /time zone/],++ [{ ...INPUT, concurrency: 9 }, /concurrency/], [{ ...INPUT, price: { in_per_million: -1, out_per_million: 0 } }, /price/],++ [{ ...INPUT, audience: 'nobody' }, /audience/], [{ ...INPUT, audience: 'group' }, /group/],++ [{ ...INPUT, owner: OWNER }, /unknown field/],++ ];++ for (const [input, re] of bad) await assert.rejects(() => store.create({ owner: OWNER, input }), re);++ const offer = await store.create({ owner: OWNER, input: { ...INPUT, audience: 'group', group: 'g1' } });++ assert.strictEqual(offer.offer_id, `endpoint:${offer.id}`);++ assert.deepStrictEqual(offer.models, ['qwen3:32b']);++ assert.strictEqual(offer.hours.tz, 'UTC');++ assert.deepStrictEqual(offer.price, { in_per_million: 10, out_per_million: 30 });++ assert.strictEqual(offer.group, 'g1');++ } finally { await h.stop(); }++});++++t.test('a name that answers a private address is refused by the connection lookup', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const lookup = (host, options, cb) => cb(null, [{ address: '10.1.2.3', family: 4 }]);++ const store = storeFor(h.db, { lookup });++ await store.create({ owner: OWNER, input: INPUT });++ const offer = (await store.liveOffers())[0];++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /non-public address/);++ } finally { await h.stop(); }++});++++t.test('a redirect is judged again: to a private address it is refused', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ for (const location of ['https://127.0.0.1/v1/chat/completions', 'https://[::1]/v1/chat/completions', 'https://169.254.169.254/v1/chat/completions']) {++ const transport = async () => ({ status: 307, headers: { location }, body: '' });++ const store = storeFor(h.db, { transport });++ await store.create({ owner: OWNER, input: INPUT });++ const offer = (await store.liveOffers())[0];++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /not allowed|non-public/, location);++ }++ // 301/302 are not followed for a POST at all.++ const transport = async () => ({ status: 302, headers: { location: 'https://other.example/v1/chat/completions' }, body: '' });++ const store = storeFor(h.db, { transport });++ await store.create({ owner: OWNER, input: INPUT });++ const offer = (await store.liveOffers())[0];++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /307 and 308/);++ } finally { await h.stop(); }++});++++t.test('a dispatch carries no buyer identity and no caller field beyond the request itself', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const fake = fakeEndpoint();++ const store = storeFor(h.db, { transport: fake.transport });++ await store.create({ owner: OWNER, input: INPUT });++ const offer = (await store.liveOffers())[0];++ await store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] });++ const sent = fake.calls[0];++ assert.deepStrictEqual(Object.keys(sent.headers).sort(), ['Accept', 'Content-Length', 'Content-Type', 'User-Agent']);++ assert.ok(!/x-ov|x-openvibe|cookie|forwarded|authorization|openai-organization/i.test(Object.keys(sent.headers).join(' ')));++ assert.deepStrictEqual(Object.keys(sent.request).sort(), ['max_tokens', 'messages', 'model', 'stream']);++ assert.strictEqual(sent.request.user, undefined, 'the caller\'s user field is never passed through');++ assert.strictEqual(sent.request.stream, false);++ assert.ok(sent.url.endsWith('/v1/chat/completions'));++ } finally { await h.stop(); }++});++++t.test('a registered endpoint joins the router: canaries first, free tier until verified, then a labelled answer', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const fake = fakeEndpoint();++ const store = storeFor(h.db, { transport: fake.transport });++ await store.create({ owner: OWNER, input: INPUT });++ const community = createCommunity({++ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,++ });++ const out = await community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }], maxTokens: 64 },++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r1', onBehalfOf: OTHER } });++ assert.ok(out, 'an endpoint offer is eligible');++ assert.match(out.provider, /^community-endpoint:ep_/);++ assert.strictEqual(out.explain.openvibe.provider_access, 'COMMUNITY_ENDPOINT');++ assert.strictEqual(out.explain.openvibe.deployment, out.provider);++ assert.strictEqual(out.explain.community.kind, 'endpoint');++ assert.strictEqual(out.explain.community.verification, 'verified');++ assert.strictEqual(out.explain.community.free_tier, true);++ assert.strictEqual(out.result.text, 'You said: hello');++ // Three canary calls, then the request.++ assert.strictEqual(fake.calls.length, 4);++ const state = await h.db.prepare('SELECT * FROM community_offers WHERE machine_id = ?').get(out.explain.community.machine_id);++ assert.strictEqual(state.status, 'verified');++ assert.ok(Number(state.canary_passes) >= 1, 'one round of three canaries passed');++ const receipt = buildReceipt({ run: { id: 'run_1' }, explain: out.explain, usage: out.usage, provider: out.provider, model: 'qwen3:32b' });++ assert.strictEqual(receipt.offer.provider_access, 'COMMUNITY_ENDPOINT');++ assert.strictEqual(receipt.offer.community, 'endpoint', 'the receipt says a community endpoint answered');++ assert.match(receipt.offer.provider, /^community-endpoint:ep_/);++ } finally { await h.stop(); }++});++++t.test('a wrong canary answer or a fingerprint mismatch fails verification and stops traffic', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ for (const fake of [fakeEndpoint({ canary: false }), fakeEndpoint({ model: 'some-other-model' })]) {++ const store = storeFor(h.db, { transport: fake.transport });++ const created = await store.create({ owner: OWNER, input: INPUT });++ const community = createCommunity({++ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,++ });++ await assert.rejects(() => community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r2', onBehalfOf: OTHER } }), /canary|answers as/);++ const row = await h.db.prepare('SELECT status FROM community_offers WHERE offer_id = ?').get(`endpoint:${created.id}`);++ assert.strictEqual(row.status, 'failed');++ }++ } finally { await h.stop(); }++});++++t.test('a request on the seller\'s own account is self-served and earns 0', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const fake = fakeEndpoint();++ const store = storeFor(h.db, { transport: fake.transport });++ await store.create({ owner: OWNER, input: INPUT });++ const community = createCommunity({++ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,++ });++ const out = await community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r3', onBehalfOf: OWNER } });++ assert.strictEqual(out.explain.community.self_served, true);++ assert.strictEqual(out.explain.community.credits_micro, 0);++ assert.strictEqual(out.explain.community.earning_status, 'self');++ } finally { await h.stop(); }++});++++t.test('a request that opts in reaches the endpoint through the OpenAI route and is labelled', async () => {++ const fake = fakeEndpoint();++ const h = await boot({ communityEndpoints: (db) => storeFor(db, { transport: fake.transport }) });++ try {++ await h.endpoints.create({ owner: OWNER, input: INPUT });++ const r = await request(h.base, 'POST', '/v1/chat/completions', {++ tok: token('live', ALL), headers: { 'x-openvibe-community': '1' },++ body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] },++ });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('x-openvibe-community'), '1');++ assert.match(r.body.x_openvibe.provider, /^community-endpoint:ep_/);++ assert.strictEqual(r.body.x_openvibe.community.kind, 'endpoint');++ assert.strictEqual(r.body.x_openvibe.community.verification, 'verified');++ assert.strictEqual(r.body.choices[0].message.content, 'You said: hi');++ } finally { await h.stop(); }++});++++t.test('about 1% of endpoint answers are re-run on a trusted peer and compared', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ const calls = [];++ const transport = async ({ url, body }) => {++ const request = JSON.parse(body);++ calls.push(request);++ const last = String((request.messages[request.messages.length - 1] || {}).content || '');++ const canary = last.includes('OPENVIBE-CANARY') ? 'OPENVIBE-CANARY-7F3A' : last.includes('17 + 25') ? '42' : last.includes('canary-ok') ? 'canary-ok' : null;++ const text = canary || (String(url).includes('a.example') ? 'You said: hello' : 'an answer that shares no words at all with that one');++ return { status: 200, headers: {}, body: JSON.stringify({ model: 'qwen3:32b', choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }) };++ };++ const store = storeFor(h.db, { transport });++ // A is cheaper, so it answers; B is verified first, so it is the trusted peer for the spot re-run.++ const a = await store.create({ owner: OWNER, input: { ...INPUT, base_url: 'https://a.example/v1', price: 10 } });++ const b = await store.create({ owner: OWNER, input: { ...INPUT, base_url: 'https://b.example/v1', price: 20 } });++ const offers = await store.liveOffers();++ const offerB = offers.find((o) => o.endpoint.id === b.id);++ const community = createCommunity({++ db: h.db, config: cfg({ spotRate: 0.01 }), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,++ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 0,++ });++ await community.verify(offerB, 'qwen3:32b');++ await assert.rejects(() => community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },++ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r4', onBehalfOf: OTHER } }), /disagreed with a trusted/);++ const row = await h.db.prepare('SELECT status FROM community_offers WHERE offer_id = ?').get(`endpoint:${a.id}`);++ assert.strictEqual(row.status, 'failed', 'a spot mismatch fails the offer');++ } finally { await h.stop(); }++});++++t.test('the seller\'s concurrency is respected: over it the router falls back', async () => {++ const h = await boot({ env: { AI_COMMUNITY: 'false' } });++ try {++ let release;++ const gate = new Promise((r) => { release = r; });++ const transport = async () => {++ await gate;++ return { status: 200, headers: {}, body: JSON.stringify({ model: 'qwen3:32b', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], usage: {} }) };++ };++ const store = storeFor(h.db, { transport, over: {} });++ await store.create({ owner: OWNER, input: { ...INPUT, concurrency: 1 } });++ const offer = (await store.liveOffers())[0];++ const first = store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'one' }] });++ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'two' }] }), /concurrency limit/);++ release();++ assert.strictEqual((await first).text, 'ok');++ } finally { await h.stop(); }++});++++t.run();@@@ -45,7 +45,7 @@ function tmpDir() {// A boot on a dir used before is a restart on the same database: its database stays open.const restartable = new Map();--async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community } = {}) {++async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints } = {}) {const reuse = dir !== undefined;if (dir === undefined) dir = tmpDir();const fullEnv = {@@@ -59,7 +59,9 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, researchif (reuse) restartable.set(dir, testdb);// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });-- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community });++ // An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.++ const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;++ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };}
@@ -82,6 +82,21 @@ server {
@@ -82,6 +82,21 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+ # The seller API (design EXCHANGE-REMAKE-2026-11-06, the SDK gap): a standalone OpenAI-compatible server is
+ # registered, listed and removed with the seller's own developer key. Writes are allowed here and nowhere else
+ # under /api/v1/exchange/.
+ location ^~ /api/v1/exchange/seller {
+ limit_req zone=ovai_apps burst=10 nodelay;
+ limit_req_status 429;
+ limit_except GET POST DELETE { deny all; }
+ proxy_pass http://127.0.0.1:4700;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
location = /openapi.json {
location = /openapi.json {
limit_except GET { deny all; }
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:4700;
proxy_pass http://127.0.0.1:4700;
@@ -21,6 +21,9 @@ capacity per request — because the machine sees the prompt.
@@ -21,6 +21,9 @@ capacity per request — because the machine sees the prompt.
hours, a credit price and an audience.
hours, a credit price and an audience.
4. **AI** (`server/community/`) reads `GET /internal/ai/offers`, verifies the machine, and places only opted-in requests.
4. **AI** (`server/community/`) reads `GET /internal/ai/offers`, verifies the machine, and places only opted-in requests.
+A second source needs no Node at all: a standalone OpenAI-compatible server is registered by its own address on the
+public seller API (see **Community endpoints** below) and joins the same list, verification and ledger.
+
## Opting in
## Opting in
`POST /v1/chat/completions` / `POST /v1/messages` (and the direct runs API) take either header
`POST /v1/chat/completions` / `POST /v1/messages` (and the direct runs API) take either header
@@ -31,6 +34,49 @@ The answer carries `x_openvibe.community` (machine, owner, model, price, verific
@@ -31,6 +34,49 @@ The answer carries `x_openvibe.community` (machine, owner, model, price, verific
response header `X-OpenVibe-Community: 1`. A failure on the community machine falls back to the ordinary network, and
response header `X-OpenVibe-Community: 1`. A failure on the community machine falls back to the ordinary network, and
that answer carries no community label.
that answer carries no community label.
+## Community endpoints
+
+The design's SDK gap: any server that speaks the OpenAI Chat Completions API can sell, not only a Node machine. The
+seller registers it with their own Network developer key on the Exchange's public API host, documented in
+`/openapi.json`:
+
+```
+POST /api/v1/exchange/seller/offers ai.app.run (a developer key), or a person's own token → 201 { offer }
+GET /api/v1/exchange/seller/offers the caller's own offers, with their verification
+DELETE /api/v1/exchange/seller/offers/:id remove one (its verification and earnings stay as history)
+```
+
+The body: `base_url` (https), `models` (1–40), `price` (`in_per_million`/`out_per_million` in credits, 0 volunteers),
+`hours` (`start`, `end`, `tz`), `concurrency` (1–8), `audience` (`everyone` | `followers` | `group`), `group`, `name`.
+The owner is never in the body: it is the person the key acts for. A developer app's owner is its **project owner**,
+read from Network (`GET /internal/projects/:project_id`, `network.project.read`, five-minute cache); a first-party
+service token is refused.
+
+**Reaching it.** AI calls `POST <base_url>/chat/completions` only through `server/community/endpoints.js`:
+
+- https only, no credentials in the URL, port 443 or 8443;
+- never an IP literal (`127.0.0.1`, `10.0.0.0/8`, `169.254.169.254`, `[::1]`, and the IPv6 forms that wrap one) and never
+ a name that can only be local (`.local`, `.internal`, `localhost`);
+- the DNS answer is judged inside the connection's own lookup (`openvibe-shared/egress`), so the socket connects to the
+ checked address and a second answer cannot swap it; a name answering a private address is refused;
+- redirects are followed by hand, at most two, 307/308 only, and every hop is judged again — a redirect to a private
+ address is refused;
+- a timeout (`AI_COMMUNITY_ENDPOINT_TIMEOUT_MS`) and a response byte cap (`AI_COMMUNITY_ENDPOINT_MAX_BYTES`) on every
+ call; a non-200, non-JSON or non-completion answer is a failure, and the router falls back;
+- the request is built fresh: `model`, `messages`, `max_tokens`, `temperature`, `tools`, `stream: false`. Never the
+ buyer's subject, `on_behalf_of`, run id, key, cookies or forwarding headers (`X-OV-*`, `X-Forwarded-*`). The buyer's
+ own `user` field is not passed through either. No bearer token is sent: the endpoint must accept the POST from
+ OpenVibe as it is (put it behind an unguessable base path if you want it hard to find);
+- the seller's declared `concurrency` is enforced in-process: over it the call is busy and the router falls back.
+
+**Same trust as a Node machine.** The endpoint joins `server/community/index.js`'s offer list, so it gets the same three
+canaries, the same model fingerprint (the `model` its answer reports must be the declared one), the same ~1% spot
+re-runs against a verified peer, the free tier only until verified, and the self-dealing rule (the owner, a merged
+account, or the project that registered it earns 0). Its answers carry `provider: community-endpoint:<id>`,
+`provider_access: COMMUNITY_ENDPOINT`, `community.kind: "endpoint"` and the privacy label `community`, so a receipt says
+plainly a community endpoint answered. It sees the prompt: only requests that opt in to community capacity reach it.
+An endpoint's uptime is observed from its own calls, not from a reporter.
+
## Trust before traffic
## Trust before traffic
- **Canaries**: three known-answer prompts. A wrong answer marks the offer `failed`, reputation 0, and it is never
- **Canaries**: three known-answer prompts. A wrong answer marks the offer `failed`, reputation 0, and it is never
@@ -71,7 +117,11 @@ list (models, price, hours, health; never an owner subject or machine id).
@@ -71,7 +117,11 @@ list (models, price, hours, health; never an owner subject or machine id).
- `AI_COMMUNITY` (default on): with no `OV_OAUTH_CLIENT_SECRET` or `OV_BOT_INTERNAL_URL` the list is empty.
- `AI_COMMUNITY` (default on): with no `OV_OAUTH_CLIENT_SECRET` or `OV_BOT_INTERNAL_URL` the list is empty.
- `AI_COMMUNITY_REFRESH_MS` (30000), `AI_COMMUNITY_CANARY_MAX_AGE_MS` (6h), `AI_COMMUNITY_CANARY_TIMEOUT_MS` (30000),
- `AI_COMMUNITY_REFRESH_MS` (30000), `AI_COMMUNITY_CANARY_MAX_AGE_MS` (6h), `AI_COMMUNITY_CANARY_TIMEOUT_MS` (30000),
`AI_COMMUNITY_SPOT_RATE` (0.01).
`AI_COMMUNITY_SPOT_RATE` (0.01).
-- Network grants AI `network.registry.read` and `network.follows.read` (the `followers` audience).
+- `AI_COMMUNITY_ENDPOINTS` (default on), `AI_COMMUNITY_ENDPOINT_TIMEOUT_MS` (60000),
+ `AI_COMMUNITY_ENDPOINT_MAX_BYTES` (2 MiB), `AI_COMMUNITY_ENDPOINT_MAX_REDIRECTS` (2),
+ `AI_COMMUNITY_ENDPOINT_MAX_PER_OWNER` (20).
+- Network grants AI `network.registry.read`, `network.follows.read` (the `followers` audience) and
+ `network.project.read` (a developer app's owner for the seller API).
## Tests
## Tests
@@ -79,5 +129,9 @@ list (models, price, hours, health; never an owner subject or machine id).
@@ -79,5 +129,9 @@ list (models, price, hours, health; never an owner subject or machine id).
- `node test/community-run.test.js` — the run path: no opt-in reaches nothing; with it the answer is labelled; a failed
- `node test/community-run.test.js` — the run path: no opt-in reaches nothing; with it the answer is labelled; a failed
machine falls back to the network.
machine falls back to the network.
- `node test/community-api.test.js` — the dashboard route and the public offers route.
- `node test/community-api.test.js` — the dashboard route and the public offers route.
+- `node test/community-endpoints.test.js` — the egress guard (IP literals, a private DNS answer, a redirect to one, no
+ buyer identity, the timeout/concurrency), canaries, fingerprint, free tier and self-dealing for an endpoint.
+- `node test/community-endpoint-api.test.js` — the seller API: the owner from the developer key, refusals, list and
+ remove.
- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -s
- Network: `node test/run.js ai-offers`; Bot: `node test/community-ai.test.js`; Node: `python3 -m unittest discover -s
plugins/ai/tests`.
plugins/ai/tests`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+-- phase: expand
+-- Community endpoint offers (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"). A seller
+-- registers a standalone OpenAI-compatible server by its own https address, with their Network developer key, on the
+-- public seller API. Only AI's own egress guard reaches the address (https, a public DNS answer, never an IP literal,
+-- timeouts, a response cap, and never the buyer's identity in a header). The verification state, spot re-runs, the
+-- reputation and the seller ledger live in community_offers/community_usage: an endpoint is verified exactly as a
+-- Node machine is (canaries, the model fingerprint, about 1% spot re-runs, free tier only until verified,
+-- self-dealing earns 0). Nothing here stores a prompt, an answer or a buyer.
+CREATE TABLE IF NOT EXISTS community_endpoint_offers (
+ id text COLLATE "C" PRIMARY KEY, -- ep_<ULID>
+ owner_subject text COLLATE "C" NOT NULL CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ project_id text COLLATE "C", -- the developer app's project (a person's own token has none)
+ app_id text COLLATE "C", -- the app that registered it (null for a person)
+ created_by text COLLATE "C" NOT NULL, -- app_…, or the person's token sub
+ name text COLLATE "C",
+ base_url text COLLATE "C" NOT NULL, -- https origin and path, no trailing slash, never an address
+ models jsonb NOT NULL,
+ hours jsonb NOT NULL,
+ concurrency integer NOT NULL CHECK (concurrency BETWEEN 1 AND 8),
+ price_in numeric(20, 6) NOT NULL CHECK (price_in >= 0),
+ price_out numeric(20, 6) NOT NULL CHECK (price_out >= 0),
+ audience text COLLATE "C" NOT NULL CHECK (audience IN ('everyone', 'followers', 'group')),
+ group_id text COLLATE "C",
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS community_endpoint_offers_owner ON community_endpoint_offers (owner_subject, created_at DESC);
@@ -54,6 +54,10 @@ const TABLES = [
@@ -54,6 +54,10 @@ const TABLES = [
// counter; listing them here is what makes the guard (and the export count) know they exist.
// counter; listing them here is what makes the guard (and the export count) know they exist.
{ table: 'abuse_daily_usage', subject: 'subject', value: (usr) => `user:${usr}`, file: null, erase: { keep: 'erased by extraErase after the device rows are read' } },
{ table: 'abuse_daily_usage', subject: 'subject', value: (usr) => `user:${usr}`, file: null, erase: { keep: 'erased by extraErase after the device rows are read' } },
{ table: 'abuse_devices', subject: 'subject', file: null, erase: { keep: 'erased by extraErase after the shared counter is found' } },
{ table: 'abuse_devices', subject: 'subject', file: null, erase: { keep: 'erased by extraErase after the shared counter is found' } },
+ // A community endpoint (the design's SDK gap) is the person's own capacity: it goes in their export and is
+ // removed when the account goes, so it stops receiving traffic too.
+ { table: 'community_endpoint_offers', subject: 'owner_subject', file: 'community-endpoint-offers.json', order: 'created_at',
+ columns: ['id', 'name', 'base_url', 'models', 'hours', 'concurrency', 'price_in', 'price_out', 'audience', 'group_id', 'created_at', 'updated_at'] },
// The person's own quota (server/quota.js): a limit that only means something while the account lives.
// The person's own quota (server/quota.js): a limit that only means something while the account lives.
{ table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },
{ table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },
// What the person used, counted against their allowance: the spend ledger, kept.
// What the person used, counted against their allowance: the spend ledger, kept.
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The public seller API (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"): a standalone
+ * OpenAI-compatible server can sell to the Exchange with the seller's own Network developer key, not only an
+ * OpenVibe Node machine. Served on the Exchange's public API host (api.openvibe.exchange) and documented in
+ * /openapi.json.
+ *
+ * POST /api/v1/exchange/seller/offers ai.app.run (a developer key), or a person's own token
+ * body: base_url, models, price, hours, concurrency, audience, group
+ * → 201 { offer }
+ * GET /api/v1/exchange/seller/offers the caller's own offers, with their verification
+ * DELETE /api/v1/exchange/seller/offers/:id remove one (its verification and earnings stay as history)
+ *
+ * The owner is the person the key acts for: a person's own subject, or a developer app's project owner (read from
+ * Network with network.project.read). A first-party service token is refused. The key is never a buyer credential:
+ * AI reaches the endpoint only through server/community/endpoints.js's egress guard.
+ */
+const express = require('express');
+const { CAPS } = require('../auth');
+const { sendError } = require('../util');
+
+function sellerRouter({ endpoints, community = null, auth, log = console }) {
+ const r = express.Router();
+ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
+ const body = (req) => (req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {});
+ const wrap = (fn) => async (req, res) => {
+ try {
+ const out = await fn(req, res);
+ if (out !== undefined) res.json(out);
+ } catch (err) { sendError(res, err, req.ov, log); }
+ };
+
+ r.post('/api/v1/exchange/seller/offers', allow, wrap(async (req, res) => {
+ const who = await endpoints.ownerFor(req.principal);
+ const offer = await endpoints.create({ owner: who.owner, projectId: who.projectId, appId: who.appId, createdBy: req.principal.sub, input: body(req) });
+ res.status(201).set('Cache-Control', 'no-store');
+ return { offer };
+ }));
+
+ r.get('/api/v1/exchange/seller/offers', allow, wrap(async (req, res) => {
+ const who = await endpoints.ownerFor(req.principal);
+ res.set('Cache-Control', 'no-store');
+ const offers = await endpoints.list(who.owner);
+ // The verification is the same the /sell dashboard shows: canaries, spot re-runs, reputation and free-only.
+ let dashboard = null;
+ if (community && typeof community.sell === 'function') {
+ try { dashboard = await community.sell(who.owner); } catch (err) { log.warn(`[ai] seller dashboard ${who.owner}: ${err.message}`); }
+ }
+ const byOffer = new Map(((dashboard && dashboard.offers) || []).map((o) => [o.offer_id, o]));
+ return {
+ owner: who.owner,
+ offers: offers.map((o) => {
+ const state = byOffer.get(o.offer_id);
+ return { ...o, verification: state ? state.verification : null, reputation: state ? state.reputation : 0,
+ free_only: state ? state.free_only !== false : true, verified: state ? state.verified === true : false };
+ }),
+ };
+ }));
+
+ r.delete('/api/v1/exchange/seller/offers/:id', allow, wrap(async (req, res) => {
+ const who = await endpoints.ownerFor(req.principal);
+ res.set('Cache-Control', 'no-store');
+ return await endpoints.remove({ id: req.params.id, owner: who.owner });
+ }));
+
+ return r;
+}
+
+module.exports = { sellerRouter };
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -117,6 +117,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -117,6 +117,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, env, clock, presets, receipts, auth, log }));
app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, env, clock, presets, receipts, auth, log }));
+ // The public seller API: a standalone OpenAI-compatible server registered by its own https address with the
+ // seller's developer key (the design's SDK gap). Its offers join the community router's trust and ledger.
+ if (endpoints) app.use(require('./api/seller').sellerRouter({ endpoints, community, auth, log }));
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
// GET /release.json (ADR-016) and POST /release-metrics (open tabs' update reports into /metrics).
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
new file mode 100644
new file mode 100644
@@ -0,0 +1,376 @@
@@ -0,0 +1,376 @@
+'use strict';
+/**
+ * Community endpoints (design EXCHANGE-REMAKE-2026-11-06, "Gap found while building the SDK"): a standalone
+ * OpenAI-compatible server can sell to the Exchange, not only an OpenVibe Node machine. A seller registers its own
+ * https address with their Network developer key on the public seller API (server/api/seller.js); AI stores the
+ * offer here, reads it beside Network's Node offers, and reaches it through this module only.
+ *
+ * The egress guard, on every registration, every dispatch and every redirect hop:
+ * - https only, no credentials in the URL, no port but 443 or 8443;
+ * - never an IP literal, and never a name that can only mean this machine or a private network;
+ * - the DNS answer is judged inside the connection's own lookup (openvibe-shared/egress), so the socket connects
+ * to the checked address and a second answer cannot swap it (no rebinding); an address that is loopback,
+ * RFC 1918, link-local (169.254.169.254), CGNAT, documentation, benchmarking, multicast, or an IPv6 form that
+ * wraps one of those is refused;
+ * - redirects are followed by hand, at most two, by 307/308 only, and every hop is judged again;
+ * - a timeout and a response byte cap on every call;
+ * - the request is built fresh: the model, the conversation, the output limit and the tools. Never the buyer's
+ * subject, on_behalf_of, run id, key, cookies or forwarding headers.
+ *
+ * Trust is the Node machine's (server/community/index.js): the same canaries, the same model fingerprint, the same
+ * ~1% spot re-runs, the free tier only until verified, and the self-dealing rule. An endpoint is a community source:
+ * it sees the prompt, and only requests that opt in ever reach it.
+ */
+const net = require('net');
+const https = require('https');
+const { ids } = require('openvibe-contracts');
+const egress = require('openvibe-shared/egress');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { AiError, parseJson } = require('../util');
+
+const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const PROJECT = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ID = /^ep_[0-9A-HJKMNP-TV-Z]{26}$/;
+const HHMM = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
+const AUDIENCES = new Set(['everyone', 'followers', 'group']);
+const MAX_MODELS = 40;
+const MAX_MODEL = 200;
+const MAX_PRICE = 1000000;
+const MAX_MESSAGES = 200;
+const MAX_CONTENT = 65536;
+const MAX_BYTES = 2 * 1024 * 1024;
+const ALLOWED_PORTS = new Set(['', '443', '8443']);
+
+const isPrice = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_PRICE;
+const roundPrice = (v) => Math.round(Number(v) * 1e6) / 1e6;
+
+/**
+ * The one URL rule. Returns a URL whose pathname has no trailing slash (an OpenAI base URL such as
+ * https://host/v1), or throws AiError 422 `community.endpoint_url` with a message that never echoes the host.
+ */
+function judgeBaseUrl(raw) {
+ let u;
+ try { u = new URL(String(raw == null ? '' : raw)); } catch { throw new AiError(422, 'community.endpoint_url', 'base_url must be an https URL'); }
+ if (u.protocol !== 'https:') throw new AiError(422, 'community.endpoint_url', 'base_url must be https');
+ if (u.username || u.password) throw new AiError(422, 'community.endpoint_url', 'credentials in base_url are not allowed');
+ if (u.search || u.hash) throw new AiError(422, 'community.endpoint_url', 'base_url carries no query or fragment');
+ if (!ALLOWED_PORTS.has(u.port)) throw new AiError(422, 'community.endpoint_url', 'base_url must use port 443 or 8443');
+ const host = egress.normalizeHost(u.hostname);
+ // Node never calls the lookup hook for an IP literal, so the literal is the check (Actor #297).
+ if (net.isIP(host)) throw new AiError(422, 'community.endpoint_url', 'base_url must name a host, not an IP address');
+ if (egress.isInternalName(host)) throw new AiError(422, 'community.endpoint_url', 'base_url must be a public host name');
+ u.hostname = host;
+ u.pathname = u.pathname.replace(/\/+$/, '') || '';
+ return u;
+}
+
+/** A conversation as the OpenAI Chat Completions body takes it: text only, the caps the machine path uses. */
+const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('\n') : '').slice(0, MAX_CONTENT);
+
+/** Validate a registration body. → the stored fields; throws AiError 422 `community.endpoint_invalid`. */
+function checkInput(input) {
+ const b = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
+ const allowed = new Set(['base_url', 'models', 'price', 'hours', 'concurrency', 'audience', 'group', 'name']);
+ for (const k of Object.keys(b)) if (!allowed.has(k)) throw new AiError(422, 'community.endpoint_invalid', `unknown field ${k}`);
+ const url = judgeBaseUrl(b.base_url);
+ if (!Array.isArray(b.models) || b.models.length < 1 || b.models.length > MAX_MODELS) {
+ throw new AiError(422, 'community.endpoint_invalid', `models must be an array of 1 to ${MAX_MODELS} model ids`);
+ }
+ const models = [...new Set(b.models.map((m) => String(m == null ? '' : m).trim()).filter(Boolean))];
+ if (models.length !== b.models.length || models.some((m) => m.length > MAX_MODEL)) {
+ throw new AiError(422, 'community.endpoint_invalid', `models must be unique strings of at most ${MAX_MODEL} characters`);
+ }
+ const h = b.hours && typeof b.hours === 'object' && !Array.isArray(b.hours) ? b.hours : {};
+ if (!HHMM.test(String(h.start)) || !HHMM.test(String(h.end))) throw new AiError(422, 'community.endpoint_invalid', 'hours.start and hours.end are HH:MM');
+ const tz = h.tz == null || h.tz === '' ? 'UTC' : String(h.tz);
+ if (tz.length > 64) throw new AiError(422, 'community.endpoint_invalid', 'hours.tz is at most 64 characters');
+ try { new Intl.DateTimeFormat('en-GB', { timeZone: tz }); } catch { throw new AiError(422, 'community.endpoint_invalid', 'hours.tz is not a known time zone'); }
+ if (!Number.isInteger(b.concurrency) || b.concurrency < 1 || b.concurrency > 8) {
+ throw new AiError(422, 'community.endpoint_invalid', 'concurrency must be an integer from 1 to 8');
+ }
+ let price;
+ if (typeof b.price === 'number') price = { in_per_million: b.price, out_per_million: b.price };
+ else if (b.price && typeof b.price === 'object' && !Array.isArray(b.price)) {
+ const pin = b.price.in_per_million != null ? b.price.in_per_million : b.price.in;
+ const pout = b.price.out_per_million != null ? b.price.out_per_million : b.price.out;
+ price = { in_per_million: pin, out_per_million: pout };
+ } else throw new AiError(422, 'community.endpoint_invalid', 'price is a number or {in_per_million, out_per_million}');
+ if (!isPrice(price.in_per_million) || !isPrice(price.out_per_million)) {
+ throw new AiError(422, 'community.endpoint_invalid', `price must be 0 to ${MAX_PRICE} credits per million tokens`);
+ }
+ const audience = b.audience == null ? 'everyone' : String(b.audience);
+ if (!AUDIENCES.has(audience)) throw new AiError(422, 'community.endpoint_invalid', 'audience must be everyone, followers or group');
+ let group = null;
+ if (audience === 'group') {
+ group = String(b.group == null ? '' : b.group).trim();
+ if (!group || group.length > 80) throw new AiError(422, 'community.endpoint_invalid', 'audience group needs a group id of 1 to 80 characters');
+ }
+ const name = b.name == null ? null : String(b.name).trim();
+ if (name && name.length > 100) throw new AiError(422, 'community.endpoint_invalid', 'name is at most 100 characters');
+ return {
+ base_url: url.toString().replace(/\/+$/, ''), models,
+ hours: { start: String(h.start), end: String(h.end), tz },
+ concurrency: b.concurrency, price: { in_per_million: roundPrice(price.in_per_million), out_per_million: roundPrice(price.out_per_million) },
+ audience, group, name: name || null,
+ };
+}
+
+/** A jsonb column as its value: the driver may hand back the parsed object or its text. */
+const jsonOf = (v, fallback) => (v && typeof v === 'object' ? v : parseJson(v, fallback));
+
+/** The seller's own view of an offer (never a token: this API has none). */
+function viewOf(row) {
+ if (!row) return null;
+ const models = jsonOf(row.models, []);
+ const hours = jsonOf(row.hours, {});
+ return {
+ id: row.id, offer_id: `endpoint:${row.id}`, name: row.name || null, base_url: row.base_url,
+ models: Array.isArray(models) ? models : [], hours: hours && typeof hours === 'object' ? hours : {},
+ concurrency: Number(row.concurrency),
+ price: { in_per_million: roundPrice(row.price_in), out_per_million: roundPrice(row.price_out) },
+ audience: row.audience, group: row.group_id || null,
+ created_at: row.created_at, updated_at: row.updated_at,
+ };
+}
+
+/** The offer the community router places on: the shape a Network doc normalizes to, plus its kind and address. */
+function offerOf(row) {
+ const view = viewOf(row);
+ return {
+ offer_id: view.offer_id, machine_id: `endpoint:${row.id}`, owner: row.owner_subject, kind: 'endpoint',
+ endpoint: { id: row.id, base_url: row.base_url }, projectId: row.project_id || null,
+ models: view.models, hours: view.hours, concurrency: view.concurrency, price: view.price,
+ audience: view.audience, group: view.group, ready: true, health: 'up', name: view.name,
+ };
+}
+
+function createEndpointOffers({ db, config, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, lookup = null, transport = null }) {
+ const cfg = (config && config.community && config.community.endpoints) || {};
+ const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');
+ const secret = env.OV_OAUTH_CLIENT_SECRET || '';
+ const clientId = (config && config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';
+ let projectTokens = tokens;
+ if (!projectTokens && secret && network) {
+ projectTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'network.project.read' }, fetch: fetchImpl });
+ }
+ const maxPerOwner = Math.max(1, Math.min(100, Number(cfg.maxPerOwner) || 20));
+ const timeoutMs = Math.max(5000, Math.min(120000, Number(cfg.timeoutMs) || 60000));
+ const maxBytes = Math.max(65536, Math.min(16 * 1024 * 1024, Number(cfg.maxBytes) || MAX_BYTES));
+ const maxRedirects = Math.max(0, Math.min(3, cfg.maxRedirects == null ? 2 : Number(cfg.maxRedirects)));
+ const safeLookup = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });
+
+ const active = new Map(); // offer id -> calls in flight
+ const owners = new Map(); // project id -> { at, owner } (5 minutes)
+ const nowIso = () => new Date(clock.now()).toISOString();
+
+ /** The person a seller acts for. A person's own token is themselves; an app's project owner comes from Network. */
+ async function ownerFor(principal) {
+ if (!principal) throw new AiError(401, 'token.missing', 'a seller is a person or a developer app');
+ if (principal.kind === 'user') {
+ const id = principal.subject && principal.subject.id;
+ if (!USR.test(String(id))) throw new AiError(422, 'community.endpoint_owner', 'the person has no subject');
+ return { owner: String(id), projectId: null, appId: null };
+ }
+ if (principal.kind === 'app') {
+ const projectId = String(principal.projectId || '');
+ if (!PROJECT.test(projectId)) throw new AiError(422, 'community.endpoint_owner', 'the app has no project');
+ const hit = owners.get(projectId);
+ if (hit && clock.now() - hit.at < 300000 && hit.owner) return { owner: hit.owner, projectId, appId: principal.appId || null };
+ if (!projectTokens || !network) throw new AiError(503, 'community.unavailable', 'the seller API cannot read project owners here');
+ let res;
+ try {
+ const token = await projectTokens.getToken({ audience: 'openvibe.network' });
+ res = await fetchImpl(`${network}/internal/projects/${encodeURIComponent(projectId)}`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000),
+ });
+ } catch (err) {
+ throw new AiError(503, 'community.unavailable', `Network did not answer for the project: ${String(err && err.message || err).slice(0, 200)}`);
+ }
+ if (res.status === 404) throw new AiError(403, 'community.endpoint_owner', 'the developer app has no project');
+ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for the project`);
+ const body = await res.json().catch(() => null);
+ const owner = body && body.project && body.project.owner && body.project.owner.id;
+ if (!USR.test(String(owner))) throw new AiError(503, 'community.unavailable', 'the project has no owner');
+ owners.set(projectId, { at: clock.now(), owner: String(owner) });
+ return { owner: String(owner), projectId, appId: principal.appId || null };
+ }
+ throw new AiError(403, 'community.endpoint_owner', 'a seller is a person or a developer app, not a service');
+ }
+
+ const rows = () => db.prepare('SELECT * FROM community_endpoint_offers ORDER BY created_at DESC');
+
+ /** Every registered endpoint as a community offer, for the router's offer list. */
+ async function liveOffers() {
+ if (!db) return [];
+ const list = await rows().all();
+ return list.map(offerOf);
+ }
+
+ async function list(owner) {
+ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');
+ return (await rows().all()).filter((r) => r.owner_subject === owner).map(viewOf);
+ }
+
+ async function create({ owner, projectId = null, appId = null, createdBy = null, input }) {
+ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');
+ const checked = checkInput(input);
+ const existing = (await rows().all()).filter((r) => r.owner_subject === owner);
+ if (existing.length >= maxPerOwner) throw new AiError(409, 'community.endpoint_limit', `at most ${maxPerOwner} endpoint offers per seller`);
+ const at = nowIso();
+ const row = {
+ id: `ep_${ids.ulid()}`, owner, projectId, appId, createdBy: createdBy || (appId || owner), checked, at,
+ };
+ await db.prepare(`INSERT INTO community_endpoint_offers (id, owner_subject, project_id, app_id, created_by, name, base_url, models, hours,
+ concurrency, price_in, price_out, audience, group_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(row.id, owner, projectId, appId, row.createdBy, checked.name, checked.base_url, JSON.stringify(checked.models), JSON.stringify(checked.hours),
+ checked.concurrency, checked.price.in_per_million, checked.price.out_per_million, checked.audience, checked.group, at, at);
+ return viewOf(await db.prepare('SELECT * FROM community_endpoint_offers WHERE id = ?').get(row.id));
+ }
+
+ /** Remove an offer. Its verification and ledger rows stay: they are history and earnings, not the live offer. */
+ async function remove({ id, owner }) {
+ if (!ID.test(String(id))) throw new AiError(404, 'community.endpoint_not_found', `no endpoint offer ${id}`);
+ if (!USR.test(String(owner))) throw new AiError(422, 'community.endpoint_owner', 'owner must be a usr_ subject');
+ const found = await db.prepare('SELECT id FROM community_endpoint_offers WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));
+ if (!found) throw new AiError(404, 'community.endpoint_not_found', `no endpoint offer ${id}`);
+ await db.prepare('DELETE FROM community_endpoint_offers WHERE id = ?').run(String(id));
+ return { id: String(id), removed: true };
+ }
+
+ /** A registration of the seller's own, by id (the verify route uses it). */
+ async function own({ id, owner }) {
+ if (!ID.test(String(id))) return null;
+ const row = await db.prepare('SELECT * FROM community_endpoint_offers WHERE id = ? AND owner_subject = ?').get(String(id), String(owner));
+ return row ? offerOf(row) : null;
+ }
+
+ /** One response, without following anything: the transport seam tests script (redirect chains included). */
+ function once(target, { body, timeoutMs: callTimeout, signal }) {
+ const send = {
+ method: 'POST',
+ url: target.toString(),
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.services)',
+ 'Content-Length': Buffer.byteLength(body),
+ },
+ body, lookup: safeLookup, timeoutMs: callTimeout, maxBytes,
+ };
+ if (transport) return transport(send);
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const fail = (err) => { if (settled) return; settled = true; try { req.destroy(); } catch { /* */ } reject(err); };
+ const ok = (value) => { if (settled) return; settled = true; resolve(value); };
+ const req = https.request(target, {
+ method: 'POST', headers: send.headers, lookup: safeLookup, timeout: callTimeout, agent: false,
+ }, (res) => {
+ const status = res.statusCode || 0;
+ if (status >= 300 && status < 400) { res.resume(); return ok({ status, headers: res.headers, body: '' }); }
+ const declared = Number(res.headers['content-length']);
+ if (Number.isFinite(declared) && declared > maxBytes) { res.resume(); return fail(new AiError(413, 'community.endpoint_too_large', `the endpoint answered more than ${maxBytes} bytes`)); }
+ let bytes = 0;
+ const chunks = [];
+ res.on('data', (c) => {
+ bytes += c.length;
+ if (bytes > maxBytes) { res.destroy(); return fail(new AiError(413, 'community.endpoint_too_large', `the endpoint answered more than ${maxBytes} bytes`)); }
+ chunks.push(c);
+ });
+ res.on('error', (err) => fail(new AiError(502, 'community.endpoint_unavailable', `the endpoint's answer failed: ${String(err && err.message || err).slice(0, 200)}`)));
+ res.on('end', () => ok({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
+ });
+ req.on('error', (err) => fail(new AiError(502, 'community.endpoint_unavailable', err && err.code === 'EGRESS_DENIED'
+ ? 'the endpoint resolves to a non-public address'
+ : `the endpoint could not be reached: ${String(err && err.message || err).slice(0, 200)}`)));
+ req.on('timeout', () => fail(new AiError(504, 'community.endpoint_timeout', `the endpoint did not answer within ${callTimeout}ms`)));
+ const timer = setTimeout(() => fail(new AiError(504, 'community.endpoint_timeout', `the endpoint did not answer within ${callTimeout}ms`)), callTimeout);
+ if (signal) {
+ const onAbort = () => fail(new AiError(409, 'run.cancelled', 'cancelled'));
+ if (signal.aborted) return onAbort();
+ signal.addEventListener('abort', onAbort, { once: true });
+ req.on('close', () => signal.removeEventListener('abort', onAbort));
+ }
+ req.on('close', () => clearTimeout(timer));
+ req.end(body);
+ });
+ }
+
+ /** POST one chat completion, redirects judged hop by hop. → { status, headers, body } */
+ async function send(rawUrl, body, { timeoutMs: callTimeout = timeoutMs, signal = null } = {}) {
+ let target = judgeBaseUrl(rawUrl);
+ for (let hop = 0; hop <= maxRedirects; hop++) {
+ const res = await once(target, { body, timeoutMs: callTimeout, signal });
+ if (res.status < 300 || res.status >= 400) return res;
+ const location = res.headers && res.headers.location;
+ if (!location) throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected without a location');
+ if (![307, 308].includes(res.status)) throw new AiError(502, 'community.endpoint_redirect', `the endpoint answered ${res.status} to a POST; only 307 and 308 are followed`);
+ let next;
+ try { next = judgeBaseUrl(new URL(String(location), target).toString()); } catch (err) {
+ throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected to an address that is not allowed');
+ }
+ if (hop === maxRedirects) throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected too many times');
+ target = next;
+ }
+ throw new AiError(502, 'community.endpoint_redirect', 'the endpoint redirected too many times');
+ }
+
+ /** The answer in the shape server/community/index.js reads from a machine. */
+ function parseAnswer(text) {
+ let data;
+ try { data = JSON.parse(text || ''); } catch { throw new AiError(502, 'community.endpoint_response', 'the endpoint did not answer JSON'); }
+ const choice = data && Array.isArray(data.choices) ? data.choices[0] : null;
+ const message = choice && choice.message && typeof choice.message === 'object' ? choice.message : null;
+ if (!message) throw new AiError(502, 'community.endpoint_response', 'the endpoint did not answer a chat completion');
+ const content = typeof message.content === 'string' ? message.content : Array.isArray(message.content)
+ ? message.content.filter((p) => p && p.type === 'text').map((p) => String(p.text || '')).join('') : '';
+ const answer = { role: 'assistant', content: content.slice(0, MAX_CONTENT) };
+ if (Array.isArray(message.tool_calls) && message.tool_calls.length) {
+ answer.tool_calls = message.tool_calls.slice(0, 32).filter((c) => c && c.function && typeof c.function.name === 'string')
+ .map((c, i) => ({ id: String(c.id || `call_${i + 1}`).slice(0, 200), type: 'function',
+ function: { name: String(c.function.name).slice(0, 64), arguments: typeof c.function.arguments === 'string' ? c.function.arguments.slice(0, MAX_CONTENT) : JSON.stringify(c.function.arguments || {}) } }));
+ if (!answer.tool_calls.length) delete answer.tool_calls;
+ }
+ const usage = data.usage && typeof data.usage === 'object' ? data.usage : {};
+ return {
+ text: answer.content, message: answer,
+ model: typeof data.model === 'string' ? data.model : null,
+ usage: { input: Math.max(0, Math.trunc(Number(usage.prompt_tokens) || 0)), output: Math.max(0, Math.trunc(Number(usage.completion_tokens) || 0)), cached: 0 },
+ finish: choice.finish_reason || (answer.tool_calls ? 'tool_calls' : 'stop'),
+ };
+ }
+
+ /**
+ * One chat completion on a seller's endpoint. The seller's declared concurrency is honoured here: over it, the
+ * call is busy and the router falls back, rather than piling requests on the server.
+ */
+ async function chat(offer, { model, messages, maxTokens = 512, temperature = null, tools = null, timeoutMs: callTimeout = timeoutMs, signal = null } = {}) {
+ const id = offer && offer.endpoint && offer.endpoint.id;
+ if (!id) throw new AiError(502, 'community.endpoint_unavailable', 'not an endpoint offer');
+ const cap = Math.max(1, Number(offer.concurrency) || 1);
+ const inFlight = active.get(id) || 0;
+ if (inFlight >= cap) throw new AiError(503, 'community.busy', 'the endpoint is at its concurrency limit');
+ active.set(id, inFlight + 1);
+ try {
+ const list = (Array.isArray(messages) ? messages : []).slice(0, MAX_MESSAGES).map((m) => {
+ const one = { role: ['system', 'user', 'assistant', 'tool'].includes(m && m.role) ? m.role : 'user', content: textOf(m && m.content) };
+ if (one.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) one.tool_calls = m.tool_calls.slice(0, 32);
+ if (one.role === 'tool' && m.tool_call_id) one.tool_call_id = String(m.tool_call_id).slice(0, 200);
+ return one;
+ });
+ const request = { model: String(model || ''), messages: list, max_tokens: Math.max(1, Math.min(Number(maxTokens) || 512, 32768)), stream: false };
+ if (temperature != null && Number.isFinite(Number(temperature))) request.temperature = Number(temperature);
+ if (Array.isArray(tools) && tools.length) { request.tools = tools; request.tool_choice = 'auto'; }
+ const res = await send(`${offer.endpoint.base_url}/chat/completions`, JSON.stringify(request), { timeoutMs: callTimeout, signal });
+ if (res.status !== 200) throw new AiError(502, 'community.endpoint_unavailable', `the endpoint answered HTTP ${res.status}`);
+ return parseAnswer(res.body);
+ } finally {
+ const left = (active.get(id) || 1) - 1;
+ if (left <= 0) active.delete(id); else active.set(id, left);
+ }
+ }
+
+ return { enabled: Boolean(db), liveOffers, list, create, remove, ownerFor, chat, judgeBaseUrl };
+}
+
+module.exports = { createEndpointOffers, judgeBaseUrl };
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
* A volunteer (price 0) earns kudos in an append-only ledger; they never expire, buy queue priority and a bigger free
* A volunteer (price 0) earns kudos in an append-only ledger; they never expire, buy queue priority and a bigger free
* output allowance, and are never cash.
* output allowance, and are never cash.
*
*
- * createCommunity({ db, config, env, machines, clock, fetchImpl, log, tokens, random })
+ * createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log, tokens, random })
* .enabled
* .enabled
* .offers() the live offers (refreshed from Network at most refreshMs)
* .offers() the live offers (refreshed from Network at most refreshMs)
* .choose({ ctx, operation }) one eligible {offer, model, key}, or null
* .choose({ ctx, operation }) one eligible {offer, model, key}, or null
@@ -118,7 +118,7 @@ function similar(a, b) {
@@ -118,7 +118,7 @@ function similar(a, b) {
return same / Math.max(x.size, y.size) >= 0.5;
return same / Math.max(x.size, y.size) >= 0.5;
}
}
-function createCommunity({ db, config, env = process.env, machines = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {
+function createCommunity({ db, config, env = process.env, machines = null, endpoints = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, tokens = null, random = Math.random }) {
const cfg = (config && config.community) || {};
const cfg = (config && config.community) || {};
const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');
const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');
const secret = env.OV_OAUTH_CLIENT_SECRET || '';
const secret = env.OV_OAUTH_CLIENT_SECRET || '';
@@ -135,7 +135,11 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -135,7 +135,11 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
if (!identityTokens && secret && network) {
if (!identityTokens && secret && network) {
identityTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl });
identityTokens = createServiceTokenClient({ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret, scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl });
}
}
- const enabled = Boolean(cfg.enabled && db && machines && network && registryTokens);
+ // A Node offer comes from Network and is reached through Bot; a community endpoint is registered here and reached
+ // through its own https address. Each works without the other.
+ const registryEnabled = Boolean(cfg.enabled && db && machines && network && registryTokens);
+ const endpointsEnabled = Boolean(cfg.enabled && db && endpoints && endpoints.enabled);
+ const enabled = registryEnabled || endpointsEnabled;
let cache = { at: 0, offers: [] };
let cache = { at: 0, offers: [] };
let refreshing = null;
let refreshing = null;
const followersCache = new Map();
const followersCache = new Map();
@@ -167,23 +171,37 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -167,23 +171,37 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
async function fetchOffers() {
async function fetchOffers() {
if (!enabled) return [];
if (!enabled) return [];
- const token = await registryTokens.getToken({ audience: 'openvibe.network' });
- let res;
- try {
- res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
- } catch (err) {
- throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);
+ let offers = [];
+ if (registryEnabled) {
+ try {
+ const token = await registryTokens.getToken({ audience: 'openvibe.network' });
+ let res;
+ try {
+ res = await fetchImpl(`${network}/internal/ai/offers`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
+ } catch (err) {
+ throw new AiError(503, 'community.unavailable', `Network did not answer for community offers: ${String(err && err.message || err).slice(0, 200)}`);
+ }
+ const body = await res.json().catch(() => null);
+ if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);
+ offers = ((body && body.offers) || []).map(normalize).filter(Boolean);
+ } catch (err) {
+ // With endpoints registered here, Network being away must not take them down.
+ if (!endpointsEnabled) throw err;
+ log.warn(`[community] ${err.message}`);
+ }
+ }
+ if (endpointsEnabled) {
+ try { offers = offers.concat(await endpoints.liveOffers()); } catch (err) { log.warn(`[community] endpoints: ${err.message}`); }
}
}
- const body = await res.json().catch(() => null);
- if (!res.ok) throw new AiError(503, 'community.unavailable', `Network answered ${res.status} for community offers`);
- const offers = ((body && body.offers) || []).map(normalize).filter(Boolean);
await noteHealth(offers);
await noteHealth(offers);
return offers;
return offers;
}
}
- /** Keep a simple uptime denominator: each refresh is one observation of each offer and model. */
+ /** Keep a simple uptime denominator: each refresh is one observation of each Node offer and model. A community
+ * endpoint has no reporter, so its observations are its own calls (noteDispatch). */
async function noteHealth(offers) {
async function noteHealth(offers) {
for (const offer of offers) {
for (const offer of offers) {
+ if (offer.kind === 'endpoint') continue;
for (const model of offer.models) {
for (const model of offer.models) {
try {
try {
const key = keyOf(offer, model);
const key = keyOf(offer, model);
@@ -211,6 +229,22 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -211,6 +229,22 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
return await refreshing;
return await refreshing;
}
}
+ /** One observation of a community endpoint: its answer arrived or it did not. Machines report their own health. */
+ async function noteDispatch(offer, model, ok) {
+ if (!offer || offer.kind !== 'endpoint') return;
+ const key = keyOf(offer, model);
+ const at = nowIso(clock);
+ try {
+ await db.prepare(`INSERT INTO community_offers (offer_key, offer_id, machine_id, owner_subject, model, models, price_in, price_out, status, checks, up_checks, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', 1, ?, ?, ?)
+ ON CONFLICT (offer_key) DO UPDATE SET models = excluded.models, price_in = excluded.price_in, price_out = excluded.price_out,
+ checks = community_offers.checks + 1, up_checks = community_offers.up_checks + excluded.up_checks, updated_at = excluded.updated_at`)
+ .run(key, offer.offer_id, offer.machine_id, offer.owner, model, JSON.stringify(offer.models), offer.price.in_per_million, offer.price.out_per_million,
+ ok ? 1 : 0, at, at);
+ await refreshReputation(key);
+ } catch (err) { log.warn(`[community] endpoint health ${key}: ${err.message}`); }
+ }
+
async function stateOf(key) {
async function stateOf(key) {
return await db.prepare('SELECT * FROM community_offers WHERE offer_key = ?').get(key);
return await db.prepare('SELECT * FROM community_offers WHERE offer_key = ?').get(key);
}
}
@@ -250,8 +284,20 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -250,8 +284,20 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
await refreshReputation(key);
await refreshReputation(key);
}
}
- /** Low-level: one chat completion on the person's machine, through Bot, as its owner. */
+ /** Low-level: one chat completion on the person's machine (through Bot), or on a registered endpoint (through the
+ * egress guard), as its owner. */
async function dispatch(offer, model, messages, { maxTokens = 512, temperature = null, tools = null, timeoutMs = 60000, requestId = null } = {}) {
async function dispatch(offer, model, messages, { maxTokens = 512, temperature = null, tools = null, timeoutMs = 60000, requestId = null } = {}) {
+ if (offer.kind === 'endpoint') {
+ try {
+ return await endpoints.chat(offer, {
+ model, messages, maxTokens, temperature, tools,
+ timeoutMs: Math.max(5000, Math.min(300000, timeoutMs)), signal: AbortSignal.timeout(Math.max(5000, timeoutMs) + 10000),
+ });
+ } catch (err) {
+ if (err && err.code === 'community.busy') throw new AiError(503, 'community.busy', String(err.message || 'the endpoint is busy'));
+ throw err;
+ }
+ }
if (!machines || !machines.enabled) throw new AiError(503, 'community.unavailable', 'OpenVibe.Bot is not configured, so a community machine cannot be reached');
if (!machines || !machines.enabled) throw new AiError(503, 'community.unavailable', 'OpenVibe.Bot is not configured, so a community machine cannot be reached');
try {
try {
return await machines.chat({
return await machines.chat({
@@ -278,8 +324,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -278,8 +324,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
let answer;
let answer;
try {
try {
answer = await dispatch(offer, model, canary.messages, { maxTokens: 64, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `canary-${key}` });
answer = await dispatch(offer, model, canary.messages, { maxTokens: 64, temperature: 0, timeoutMs: cfg.canaryTimeoutMs || 30000, requestId: `canary-${key}` });
+ await noteDispatch(offer, model, true);
} catch (err) {
} catch (err) {
if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) throw err;
if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) throw err;
+ await noteDispatch(offer, model, false);
await markCanary(key, { ok: false, reason: `canary_call: ${String(err && err.message || err).slice(0, 200)}` });
await markCanary(key, { ok: false, reason: `canary_call: ${String(err && err.message || err).slice(0, 200)}` });
throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary: ${String(err && err.message || err).slice(0, 200)}`);
throw new AiError(502, 'community.verification_failed', `community machine ${offer.machine_id} failed its canary: ${String(err && err.message || err).slice(0, 200)}`);
}
}
@@ -410,7 +458,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -410,7 +458,10 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
/** About 1%: the same prompt on a trusted machine with the same model, compared. No trusted peer, no spot. */
/** About 1%: the same prompt on a trusted machine with the same model, compared. No trusted peer, no spot. */
async function spotCheck(chosen, messages, answer) {
async function spotCheck(chosen, messages, answer) {
if (!(cfg.spotRate > 0) || random() >= cfg.spotRate) return;
if (!(cfg.spotRate > 0) || random() >= cfg.spotRate) return;
- const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id && o.health === 'up' && o.ready && o.models.includes(chosen.model));
+ const peers = (await offers()).filter((o) => o.offer_id !== chosen.offer.offer_id
+ // A second registration of the same endpoint is not an independent machine.
+ && !(o.endpoint && chosen.offer.endpoint && o.endpoint.base_url === chosen.offer.endpoint.base_url)
+ && o.health === 'up' && o.ready && o.models.includes(chosen.model));
const trusted = [];
const trusted = [];
for (const peer of peers) {
for (const peer of peers) {
const s = await stateOf(keyOf(peer, chosen.model));
const s = await stateOf(keyOf(peer, chosen.model));
@@ -441,12 +492,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -441,12 +492,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
* - until the machine has `trustDays` of verified service a seller earns at most the daily cap;
* - until the machine has `trustDays` of verified service a seller earns at most the daily cap;
* - a volunteer (price 0) earns kudos instead, never cash.
* - a volunteer (price 0) earns kudos instead, never cash.
*/
*/
- async function recordUsage(chosen, { requestId, usage, verified, requester = null, latencyMs = null, chargeId = null }) {
+ async function recordUsage(chosen, { requestId, usage, verified, requester = null, projectId = null, latencyMs = null, chargeId = null }) {
const tin = Math.max(0, Math.trunc(Number(usage && usage.input) || 0));
const tin = Math.max(0, Math.trunc(Number(usage && usage.input) || 0));
const tout = Math.max(0, Math.trunc(Number(usage && usage.output) || 0));
const tout = Math.max(0, Math.trunc(Number(usage && usage.output) || 0));
const owner = await survivor(chosen.offer.owner);
const owner = await survivor(chosen.offer.owner);
const person = requester ? await survivor(requester) : null;
const person = requester ? await survivor(requester) : null;
- const selfServed = Boolean(person && owner && person === owner);
+ // Self-dealing: the owner themselves, an account merged into it, or the project that registered the endpoint.
+ const selfServed = Boolean((person && owner && person === owner)
+ || (projectId && chosen.offer.projectId && String(projectId) === String(chosen.offer.projectId)));
let credits = verified && !selfServed ? Math.round(tin * chosen.offer.price.in_per_million + tout * chosen.offer.price.out_per_million) : 0;
let credits = verified && !selfServed ? Math.round(tin * chosen.offer.price.in_per_million + tout * chosen.offer.price.out_per_million) : 0;
let earning = !verified ? 'free' : selfServed ? 'self' : (chargeId ? 'spendable' : 'pending');
let earning = !verified ? 'free' : selfServed ? 'self' : (chargeId ? 'spendable' : 'pending');
let capped = false;
let capped = false;
@@ -506,11 +559,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -506,11 +559,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
const timeoutMs = Math.max(5000, Math.min(Number(req.timeoutMs) || 60000, 300000));
const timeoutMs = Math.max(5000, Math.min(Number(req.timeoutMs) || 60000, 300000));
const requestId = String(ctx.runId || `community-${clock.now()}`);
const requestId = String(ctx.runId || `community-${clock.now()}`);
const requester = requesterOf(ctx);
const requester = requesterOf(ctx);
- const selfServed = Boolean(requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)));
+ const selfServed = Boolean((requester && (await survivor(requester)) === (await survivor(chosen.offer.owner)))
+ || (ctx.projectId && chosen.offer.projectId && String(ctx.projectId) === String(chosen.offer.projectId)));
+ const isEndpoint = chosen.offer.kind === 'endpoint';
+ const source = isEndpoint ? `community-endpoint:${chosen.offer.endpoint.id}` : `community:${chosen.offer.machine_id}`;
const facts = {
const facts = {
- resolved_model: `community/${chosen.model}`, revision: null, deployment: `community:${chosen.offer.machine_id}`,
- provider_access: 'COMMUNITY', rate_version: 'community', policy: 'community',
- community: { machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,
+ resolved_model: `community/${chosen.model}`, revision: null, deployment: source,
+ provider_access: isEndpoint ? 'COMMUNITY_ENDPOINT' : 'COMMUNITY', rate_version: 'community', policy: 'community',
+ community: { kind: isEndpoint ? 'endpoint' : 'machine', machine_id: chosen.offer.machine_id, owner: chosen.offer.owner, model: chosen.model, price: chosen.offer.price,
verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },
verification: chosen.status, reputation: chosen.reputation, free_tier: freeTier(ctx), self_served: selfServed },
};
};
if (typeof ctx.onRoute === 'function') {
if (typeof ctx.onRoute === 'function') {
@@ -525,14 +581,16 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -525,14 +581,16 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
temperature: req.temperature == null ? null : Number(req.temperature),
temperature: req.temperature == null ? null : Number(req.temperature),
tools, timeoutMs, requestId,
tools, timeoutMs, requestId,
});
});
+ await noteDispatch(chosen.offer, chosen.model, true);
} catch (err) {
} catch (err) {
if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) return null;
if (err && (err.code === 'community.busy' || err.code === 'community.not_open')) return null;
+ await noteDispatch(chosen.offer, chosen.model, false);
throw err;
throw err;
}
}
const usage = answer.usage || { input: 0, output: 0, cached: 0 };
const usage = answer.usage || { input: 0, output: 0, cached: 0 };
await spotCheck(chosen, messages, answer);
await spotCheck(chosen, messages, answer);
const latencyMs = clock.now() - t0;
const latencyMs = clock.now() - t0;
- const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, latencyMs,
+ const recorded = await recordUsage(chosen, { requestId, usage, verified: chosen.status !== 'new', requester, projectId: ctx.projectId || null, latencyMs,
chargeId: ctx.requesterChargeId || null });
chargeId: ctx.requesterChargeId || null });
const text = answer.text || '';
const text = answer.text || '';
const message = answer.message || { role: 'assistant', content: text };
const message = answer.message || { role: 'assistant', content: text };
@@ -545,14 +603,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -545,14 +603,14 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
credits_micro: recorded.credits_micro, earning_status: recorded.earning_status, self_served: recorded.self_served, kudos: recorded.kudos };
credits_micro: recorded.credits_micro, earning_status: recorded.earning_status, self_served: recorded.self_served, kudos: recorded.kudos };
const explain = {
const explain = {
objective: 'community', reasons: [recorded.self_served
objective: 'community', reasons: [recorded.self_served
- ? 'self-served: the requester owns this machine, so the answer earns no credits'
- : `a community machine offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],
- selected: `community:${chosen.offer.machine_id}/${chosen.model}`, paid_by: 'community', candidates: [],
- attempts: [{ key: 'community', provider: `community:${chosen.offer.machine_id}`, model: chosen.model, why: 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],
+ ? 'self-served: the requester owns this offer, so the answer earns no credits'
+ : `${isEndpoint ? 'a community endpoint' : 'a community machine'} offered ${chosen.model}${chosen.offer.price.in_per_million || chosen.offer.price.out_per_million ? ` at ${chosen.offer.price.in_per_million}/${chosen.offer.price.out_per_million} credits per million` : ' for free'}`],
+ selected: `${source}/${chosen.model}`, paid_by: 'community', candidates: [],
+ attempts: [{ key: 'community', provider: source, model: chosen.model, why: isEndpoint ? 'an opted-in request placed on a verified community endpoint' : 'an opted-in request placed on a verified community machine', status: 'ok', error: null }],
community, openvibe: facts,
community, openvibe: facts,
};
};
return {
return {
- result, provider: `community:${chosen.offer.machine_id}`, providerKind: 'community', model: answer.model || chosen.model,
+ result, provider: source, providerKind: 'community', model: answer.model || chosen.model,
fallbackUsed: false, usage, cost: 0, latencyMs, ttftMs: null, synthetic: false, tried: [], startedAt: t0, explain,
fallbackUsed: false, usage, cost: 0, latencyMs, ttftMs: null, synthetic: false, tried: [], startedAt: t0, explain,
};
};
}
}
@@ -617,7 +675,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -617,7 +675,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
const kudos = Math.max(0, Number(kudosRow.balance) || 0);
const kudos = Math.max(0, Number(kudosRow.balance) || 0);
const offerViews = await Promise.all(list.map(async (o) => {
const offerViews = await Promise.all(list.map(async (o) => {
const openNow = o.hours ? withinHours(o, clock.now()) : null;
const openNow = o.hours ? withinHours(o, clock.now()) : null;
- const out = { offer_id: o.offer_id, machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,
+ const out = { offer_id: o.offer_id, kind: o.kind || 'machine', machine_id: o.machine_id, models: o.models, hours: o.hours, concurrency: o.concurrency,
price: o.price, audience: o.audience, group: o.group, health: o.health, open_now: openNow, ready: o.ready && openNow !== false,
price: o.price, audience: o.audience, group: o.group, health: o.health, open_now: openNow, ready: o.ready && openNow !== false,
verification: null, reputation: 0, free_only: true, verified: false,
verification: null, reputation: 0, free_only: true, verified: false,
tokens_per_s: Math.max(0, ...served.filter((x) => x.offer_key.startsWith(`${o.offer_id}|`)).map((x) => x.tokens_per_s || 0)) || null };
tokens_per_s: Math.max(0, ...served.filter((x) => x.offer_key.startsWith(`${o.offer_id}|`)).map((x) => x.tokens_per_s || 0)) || null };
@@ -671,7 +729,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
@@ -671,7 +729,7 @@ function createCommunity({ db, config, env = process.env, machines = null, clock
const openNow = withinHours(o, clock.now());
const openNow = withinHours(o, clock.now());
return {
return {
// The public id is a stable hash: Exchange can list and link an offer without publishing which machine it is.
// The public id is a stable hash: Exchange can list and link an offer without publishing which machine it is.
- offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, models: o.models, hours: o.hours, concurrency: o.concurrency,
+ offer_id: `community_${sha256(o.offer_id).slice(0, 16)}`, kind: o.kind || 'machine', models: o.models, hours: o.hours, concurrency: o.concurrency,
price: o.price, audience: o.audience, health: o.health, open_now: openNow, ready: o.ready && openNow,
price: o.price, audience: o.audience, health: o.health, open_now: openNow, ready: o.ready && openNow,
};
};
});
});
@@ -429,6 +429,17 @@ function load(env = process.env) {
@@ -429,6 +429,17 @@ function load(env = process.env) {
trustDays: Math.max(1, Math.min(365, int(env.AI_COMMUNITY_TRUST_DAYS, 7))),
trustDays: Math.max(1, Math.min(365, int(env.AI_COMMUNITY_TRUST_DAYS, 7))),
dailyEarningCapMicro: Math.max(0, Math.min(1e15, int(env.AI_COMMUNITY_DAILY_CAP_CREDITS, 100) * 1e6)),
dailyEarningCapMicro: Math.max(0, Math.min(1e15, int(env.AI_COMMUNITY_DAILY_CAP_CREDITS, 100) * 1e6)),
kudosPerThousandTokens: Math.max(1, Math.min(1000, int(env.AI_COMMUNITY_KUDOS_PER_1K, 1))),
kudosPerThousandTokens: Math.max(1, Math.min(1000, int(env.AI_COMMUNITY_KUDOS_PER_1K, 1))),
+ // A community endpoint is a standalone OpenAI-compatible server registered by its own https address with
+ // the seller's Network developer key (server/community/endpoints.js). AI reaches it only through the egress
+ // guard: https, a public DNS answer, no IP literal, a timeout, a response byte cap, and never the buyer's
+ // identity in a header. maxPerOwner bounds one seller's registrations.
+ endpoints: {
+ enabled: bool(env.AI_COMMUNITY_ENDPOINTS, true),
+ timeoutMs: Math.max(5000, Math.min(120000, int(env.AI_COMMUNITY_ENDPOINT_TIMEOUT_MS, 60000))),
+ maxBytes: Math.max(65536, Math.min(16 * 1024 * 1024, int(env.AI_COMMUNITY_ENDPOINT_MAX_BYTES, 2 * 1024 * 1024))),
+ maxRedirects: Math.max(0, Math.min(3, int(env.AI_COMMUNITY_ENDPOINT_MAX_REDIRECTS, 2))),
+ maxPerOwner: Math.max(1, Math.min(100, int(env.AI_COMMUNITY_ENDPOINT_MAX_PER_OWNER, 20))),
+ },
},
},
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
// ── Tier budgets (T6 step 8, server/govern.js): openvibe-sdk/govern in Valkey, off unless AI_GOVERN_TIERS=1.
@@ -93,6 +93,8 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =
@@ -93,6 +93,8 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =
resolved_model: (x.openvibe && x.openvibe.resolved_model) || null,
resolved_model: (x.openvibe && x.openvibe.resolved_model) || null,
// The host's own model answered (local / local2): the receipt says so, beside the provider name.
// The host's own model answered (local / local2): the receipt says so, beside the provider name.
local: Boolean(x.provider_local),
local: Boolean(x.provider_local),
+ // A community answer says which kind of community source it was: a person's machine or a registered endpoint.
+ community: (x.openvibe && x.openvibe.community && x.openvibe.community.kind) || null,
},
},
price: {
price: {
in_per_mtok: round(price && price.in),
in_per_mtok: round(price && price.in),
@@ -125,6 +127,7 @@ function compact(receipt) {
@@ -125,6 +127,7 @@ function compact(receipt) {
run_id: receipt.run_id,
run_id: receipt.run_id,
offer: offer.provider ? `${offer.provider}${offer.model ? `/${offer.model}` : ''}` : null,
offer: offer.provider ? `${offer.provider}${offer.model ? `/${offer.model}` : ''}` : null,
local: Boolean(offer.local),
local: Boolean(offer.local),
+ community: offer.community || null,
price: { in: receipt.price && receipt.price.in_per_mtok, out: receipt.price && receipt.price.out_per_mtok, cached: receipt.price && receipt.price.cached_per_mtok },
price: { in: receipt.price && receipt.price.in_per_mtok, out: receipt.price && receipt.price.out_per_mtok, cached: receipt.price && receipt.price.cached_per_mtok },
tokens: { in: receipt.usage && receipt.usage.tokens_in, out: receipt.usage && receipt.usage.tokens_out, cached: receipt.usage && receipt.usage.tokens_cached },
tokens: { in: receipt.usage && receipt.usage.tokens_in, out: receipt.usage && receipt.usage.tokens_out, cached: receipt.usage && receipt.usage.tokens_cached },
cost_usd: receipt.cost_usd,
cost_usd: receipt.cost_usd,
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, abuse: givenAbuse = null } = {}) {
+async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, abuse: givenAbuse = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -65,7 +65,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -65,7 +65,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const machines = machineModels || require('./machines').createMachineModels({ config, env, fetchImpl });
const machines = machineModels || require('./machines').createMachineModels({ config, env, fetchImpl });
// Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a
// Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a
// machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.
// machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.
- const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });
+ // A community endpoint (the design's SDK gap) is registered here with the seller's own developer key and reached
+ // through the egress guard; it joins the same offer list, verification and ledger.
+ const endpoints = givenEndpoints || (config.community.endpoints && config.community.endpoints.enabled === false
+ ? null
+ : require('./community/endpoints').createEndpointOffers({ db, config, env, clock, fetchImpl, log }));
+ const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, endpoints, clock, fetchImpl, log });
// Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.
// Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.
const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });
const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
@@ -152,7 +157,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -152,7 +157,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, presets, receipts, balance, gpus, abuse });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, presets, receipts, balance, gpus, abuse });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -241,7 +246,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -241,7 +246,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, gpus, abuse, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, endpoints, gpus, abuse, close };
}
}
if (require.main === module) {
if (require.main === module) {
@@ -360,6 +360,56 @@ route('get', '/exchange/usage', {
@@ -360,6 +360,56 @@ route('get', '/exchange/usage', {
} }) },
} }) },
});
});
+// ── The seller API: a standalone OpenAI-compatible endpoint (the design's SDK gap) ───────────
+const sellerEndpointOffer = {
+ type: 'object', required: ['id', 'offer_id', 'base_url', 'models', 'hours', 'concurrency', 'price', 'audience'],
+ properties: {
+ id: str({ pattern: '^ep_[0-9A-HJKMNP-TV-Z]{26}$' }),
+ offer_id: str({ description: 'The offer id the router and the receipt use: endpoint:<id>' }),
+ name: nullable('string'),
+ base_url: str({ description: 'The https base URL the router calls; /chat/completions is appended. Port 443 or 8443, a host name (never an IP address).' }),
+ models: arrayOf(str()), hours: { type: 'object', properties: { start: str(), end: str(), tz: str() } },
+ concurrency: integer({ minimum: 1, maximum: 8 }),
+ price: { type: 'object', properties: { in_per_million: { type: 'number' }, out_per_million: { type: 'number' } } },
+ audience: str({ enum: ['everyone', 'followers', 'group'] }), group: nullable('string'),
+ created_at: str({ format: 'date-time' }), updated_at: str({ format: 'date-time' }),
+ verification: nullable('object', { description: 'Canaries, spot re-runs, reputation and free-only, as the /sell dashboard shows them' }),
+ reputation: integer({ minimum: 0, maximum: 100 }), free_only: { type: 'boolean' }, verified: { type: 'boolean' },
+ },
+};
+const sellerEndpointInput = {
+ type: 'object', required: ['base_url', 'models', 'price', 'hours', 'concurrency'],
+ properties: {
+ base_url: str({ description: 'https, a public host name (no IP literal), port 443 or 8443, an optional base path such as /v1' }),
+ models: { type: 'array', items: str(), minItems: 1, maxItems: 40 },
+ price: { description: 'Credits per million tokens: a number for both sides, or {in_per_million, out_per_million}; 0 volunteers the model', oneOf: [
+ { type: 'number', minimum: 0, maximum: 1000000 },
+ { type: 'object', required: ['in_per_million', 'out_per_million'], properties: { in_per_million: { type: 'number', minimum: 0, maximum: 1000000 }, out_per_million: { type: 'number', minimum: 0, maximum: 1000000 } } },
+ ] },
+ hours: { type: 'object', required: ['start', 'end'], properties: { start: str({ pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }), end: str({ pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' }), tz: str() } },
+ concurrency: integer({ minimum: 1, maximum: 8 }),
+ audience: str({ enum: ['everyone', 'followers', 'group'] }),
+ group: str({ description: 'Required when audience is group' }),
+ name: str({ maxLength: 100 }),
+ },
+};
+route('post', '/exchange/seller/offers', {
+ operationId: 'createSellerEndpointOffer', summary: 'Register a standalone OpenAI-compatible server to sell', tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'ai.app.run (a Network developer key), or a person\'s own token; a first-party service token is refused. The owner is the person the key acts for: an app\'s project owner, or the person themselves. The router reaches base_url only through the egress guard: https, a public DNS answer, never an IP literal, a timeout, a response byte cap, and never the buyer\'s identity in a header. A new endpoint serves the free tier only until its canaries, model fingerprint and spot re-runs verify it, exactly as a Node machine does; a request on the seller\'s own account is self-served and earns 0. An answer from it is labelled a community endpoint on the receipt (provider community-endpoint:<id>, provider_access COMMUNITY_ENDPOINT) and only requests that opt in to community capacity reach it.',
+ requestBody: body(sellerEndpointInput),
+ responses: { 201: json('The registered offer', { type: 'object', required: ['offer'], properties: { offer: sellerEndpointOffer } }), 409: problem, 422: problem, ...errors([401, 403, 503]) },
+});
+route('get', '/exchange/seller/offers', {
+ operationId: 'listSellerEndpointOffers', summary: 'The caller\'s own endpoint offers, with their verification', tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'ai.app.run, or a person\'s own token. Only the caller\'s offers: an app sees the offers of its project owner. The verification is the same the /sell dashboard shows.',
+ responses: { 200: json('The offers', { type: 'object', required: ['owner', 'offers'], properties: { owner: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), offers: arrayOf(sellerEndpointOffer) } }), ...errors([401, 403, 503]) },
+});
+route('delete', '/exchange/seller/offers/{id}', {
+ operationId: 'deleteSellerEndpointOffer', summary: 'Remove one of the caller\'s endpoint offers', tags: ['exchange'], security: SERVICE_OR_APP,
+ description: 'ai.app.run, or a person\'s own token. The offer stops receiving traffic on the next offer refresh (within 30 seconds); its verification and earnings stay as history.',
+ parameters: [path('id', str({ pattern: '^ep_[0-9A-HJKMNP-TV-Z]{26}$' }))],
+ responses: { 200: json('The removal', { type: 'object', properties: { id: str(), removed: { type: 'boolean' } } }), 404: problem, ...errors([401, 403]) },
+});
route('post', '/exchange/tryit/challenge', {
route('post', '/exchange/tryit/challenge', {
operationId: 'exchangeTryitChallenge', summary: 'A proof-of-work challenge for the anonymous Try it', tags: ['exchange'], security: [],
operationId: 'exchangeTryitChallenge', summary: 'A proof-of-work challenge for the anonymous Try it', tags: ['exchange'], security: [],
description: 'Public. The anonymous Try it (no account, no Balance) must send a solved challenge in x-ov-pow: <challenge>:<nonce>, where sha256("<challenge>:<nonce>") has `bits` leading zero bits. The challenge is single use and expires; AI stores only its hash. The response also sets the signed, HttpOnly `ovai_device` cookie (when it is not already present), which counts accounts per device. A first-party service may forward the client address in x-ov-client-ip.',
description: 'Public. The anonymous Try it (no account, no Balance) must send a solved challenge in x-ov-pow: <challenge>:<nonce>, where sha256("<challenge>:<nonce>") has `bits` leading zero bits. The challenge is single use and expires; AI stores only its hash. The response also sets the signed, HttpOnly `ovai_device` cookie (when it is not already present), which counts accounts per device. A first-party service may forward the client address in x-ov-client-ip.',
@@ -581,7 +581,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -581,7 +581,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
? explain.attempts[0].provider : null;
? explain.attempts[0].provider : null;
const record = (provider ? await registry.getProvider(provider) : null)
const record = (provider ? await registry.getProvider(provider) : null)
|| (credentialProvider ? await registry.getProvider(credentialProvider) : null);
|| (credentialProvider ? await registry.getProvider(credentialProvider) : null);
- const communityAnswer = Boolean(provider && String(provider).startsWith('community:')) || Boolean(explain && explain.community);
+ // A community machine (`community:`) or a registered community endpoint (`community-endpoint:`) sees the prompt.
+ const communityAnswer = Boolean(provider && /^community[:-]/.test(String(provider))) || Boolean(explain && explain.community);
return communityAnswer
return communityAnswer
? privacy.labelFor({ record: { metadata: { data_policy: { trust: 'community' } } } })
? privacy.labelFor({ record: { metadata: { data_policy: { trust: 'community' } } } })
: privacy.labelFor({ record, credential: byok ? { provider: credentialProvider } : null });
: privacy.labelFor({ record, credential: byok ? { provider: credentialProvider } : null });
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+// The public seller API (design EXCHANGE-REMAKE-2026-11-06, the SDK gap): a developer key registers, lists and removes
+// a standalone OpenAI-compatible endpoint offer on the Exchange's public API. The owner is the app's project owner,
+// read from Network; a first-party service token is refused; the seller never names an owner.
+const assert = require('assert');
+const { boot, request, token, suite } = require('./helpers');
+const { createEndpointOffers } = require('../server/community/endpoints');
+
+const t = suite('community-endpoint-api');
+const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+const APP = '01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+const PROJECT = '01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
+const silent = { log() {}, warn() {}, error() {} };
+
+const INPUT = {
+ base_url: 'https://models.example/v1', models: ['qwen3:32b'],
+ price: { in_per_million: 10, out_per_million: 30 },
+ hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2, audience: 'everyone',
+};
+
+function appToken(caps = ['ai.app.run']) {
+ return token('app', caps, {
+ sub: `app:app_${APP}`, actorType: 'app', ns: [`prj_${PROJECT}`, `app.prj_${PROJECT}.*`],
+ extra: { project_id: `prj_${PROJECT}`, env: 'production' },
+ });
+}
+
+function endpointStore(db, { project = OWNER } = {}) {
+ return createEndpointOffers({
+ db,
+ config: { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, endpoints: { enabled: true } } },
+ env: {}, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, log: silent,
+ tokens: { getToken: async () => 'test-token' },
+ fetchImpl: async (url) => (String(url).includes('/internal/projects/')
+ ? (project ? { ok: true, status: 200, json: async () => ({ project: { id: `prj_${PROJECT}`, owner: { type: 'user', id: project } } }) }
+ : { ok: false, status: 404, json: async () => null })
+ : { ok: false, status: 404, json: async () => null }),
+ });
+}
+
+let h;
+t.test('boot', async () => {
+ h = await boot({ communityEndpoints: (db) => endpointStore(db) });
+});
+
+t.test('the seller API is guarded: a token is required, and a service token is not a seller', async () => {
+ const anon = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { body: INPUT });
+ assert.strictEqual(anon.status, 401, anon.text);
+ const service = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: token('exchange', ['ai.run.create']), body: INPUT });
+ assert.strictEqual(service.status, 403, service.text);
+ assert.strictEqual(service.body.code, 'community.endpoint_owner');
+ const appWithoutCap = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(['ai.run.create']), body: INPUT });
+ assert.strictEqual(appWithoutCap.status, 403, appWithoutCap.text);
+ assert.strictEqual(appWithoutCap.body.code, 'capability.denied');
+});
+
+t.test('a developer key registers its own endpoint offer, and the owner is the project owner', async () => {
+ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
+ assert.strictEqual(created.status, 201, created.text);
+ const offer = created.body.offer;
+ assert.match(offer.id, /^ep_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(offer.offer_id, `endpoint:${offer.id}`);
+ assert.strictEqual(offer.base_url, 'https://models.example/v1');
+ assert.deepStrictEqual(offer.models, ['qwen3:32b']);
+ assert.deepStrictEqual(offer.hours, { start: '00:00', end: '23:59', tz: 'UTC' });
+ assert.deepStrictEqual(offer.price, { in_per_million: 10, out_per_million: 30 });
+ assert.strictEqual(offer.concurrency, 2);
+ assert.strictEqual(offer.audience, 'everyone');
+
+ const listed = await request(h.base, 'GET', '/api/v1/exchange/seller/offers', { tok: appToken() });
+ assert.strictEqual(listed.status, 200, listed.text);
+ assert.strictEqual(listed.body.owner, OWNER, 'the owner is the project\'s person, never the app');
+ assert.strictEqual(listed.body.offers.length, 1);
+ assert.strictEqual(listed.body.offers[0].id, offer.id);
+
+ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${offer.id}`, { tok: appToken() });
+ assert.strictEqual(removed.status, 200, removed.text);
+ assert.deepStrictEqual(removed.body, { id: offer.id, removed: true });
+
+ const after = await request(h.base, 'GET', '/api/v1/exchange/seller/offers', { tok: appToken() });
+ assert.strictEqual(after.status, 200);
+ assert.deepStrictEqual(after.body.offers, []);
+});
+
+t.test('an address the egress guard would refuse is refused at registration, with the seller\'s key', async () => {
+ for (const [base_url, re] of [['http://models.example/v1', /https/], ['https://127.0.0.1/v1', /IP address/],
+ ['https://169.254.169.254/latest', /IP address/], ['https://[::1]/v1', /IP address/], ['https://localhost/v1', /public host name/]]) {
+ const out = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: { ...INPUT, base_url } });
+ assert.strictEqual(out.status, 422, out.text);
+ assert.strictEqual(out.body.code, 'community.endpoint_url');
+ assert.match(out.body.detail, re);
+ }
+});
+
+t.test('a key removes its own offers only', async () => {
+ const other = await h.endpoints.create({ owner: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2', input: { ...INPUT, base_url: 'https://other.example/v1' } });
+ const notMine = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${other.id}`, { tok: appToken() });
+ assert.strictEqual(notMine.status, 404, notMine.text);
+ assert.strictEqual(notMine.body.code, 'community.endpoint_not_found');
+
+ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
+ assert.strictEqual(created.status, 201, created.text);
+ const removed = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: appToken() });
+ assert.strictEqual(removed.status, 200, removed.text);
+ const again = await request(h.base, 'DELETE', `/api/v1/exchange/seller/offers/${created.body.offer.id}`, { tok: appToken() });
+ assert.strictEqual(again.status, 404, again.text);
+});
+
+t.test('Exchange lists an endpoint publicly without its address or owner', async () => {
+ const created = await request(h.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
+ assert.strictEqual(created.status, 201, created.text);
+ await h.community.offers({ force: true });
+ const listed = await request(h.base, 'GET', '/api/v1/community/offers');
+ assert.strictEqual(listed.status, 200, listed.text);
+ const offer = listed.body.offers.find((o) => o.kind === 'endpoint');
+ assert.ok(offer, 'the endpoint is in the public list');
+ assert.match(offer.offer_id, /^community_[0-9a-f]{16}$/);
+ assert.strictEqual(offer.base_url, undefined, 'the address is never public');
+ assert.strictEqual(offer.owner, undefined);
+ assert.deepStrictEqual(offer.models, ['qwen3:32b']);
+});
+
+t.test('a project Network does not know is refused, never guessed', async () => {
+ const h2 = await boot({ communityEndpoints: (db) => endpointStore(db, { project: null }) });
+ try {
+ const out = await request(h2.base, 'POST', '/api/v1/exchange/seller/offers', { tok: appToken(), body: INPUT });
+ assert.strictEqual(out.status, 403, out.text);
+ assert.strictEqual(out.body.code, 'community.endpoint_owner');
+ } finally { await h2.stop(); }
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
+'use strict';
+// Community endpoints (the design's SDK gap): a standalone OpenAI-compatible server is registered by its own https
+// address, and AI reaches it only through the egress guard. The tests are the guard's own: https only, no IP literal
+// (127.0.0.1, 10/8, 169.254.169.254, [::1], a NAT64 spelling), no private DNS answer, no redirect to one; a timeout and
+// a response cap; and never the buyer's identity in a header. Then the same trust as a Node machine: the canaries, the
+// model fingerprint, the free tier only until verified, and self-dealing earns 0.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+const { createEndpointOffers, judgeBaseUrl } = require('../server/community/endpoints');
+const { createCommunity } = require('../server/community');
+const { buildReceipt } = require('../server/exchange/receipts');
+
+const t = suite('community-endpoints');
+const OWNER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1';
+const OTHER = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B2';
+const silent = { log() {}, warn() {}, error() {} };
+
+const INPUT = {
+ base_url: 'https://models.example/v1', models: ['qwen3:32b'],
+ price: { in_per_million: 10, out_per_million: 30 },
+ hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2, audience: 'everyone',
+};
+
+function cfg(over = {}) {
+ return { networkInternalUrl: 'http://network.test', console: { clientId: 'ai' }, community: { enabled: true, refreshMs: 1000, canaryMaxAgeMs: 21600000, spotRate: 0, canaryTimeoutMs: 5000, trustDays: 7, dailyEarningCapMicro: 100000000, kudosPerThousandTokens: 1, ...over } };
+}
+
+function networkFetch(owner = OWNER) {
+ return async (url) => {
+ if (String(url).includes('/internal/projects/')) return { ok: true, status: 200, json: async () => ({ project: { id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1', owner: { type: 'user', id: owner } } }) };
+ return { ok: false, status: 404, json: async () => null };
+ };
+}
+
+/** A scripted OpenAI endpoint: canaries answer correctly; anything else echoes, or says what the test wants. */
+function fakeEndpoint({ canary = true, model = 'qwen3:32b', answer = null } = {}) {
+ const calls = [];
+ const transport = async ({ url, headers, body }) => {
+ const request = JSON.parse(body);
+ calls.push({ url, headers, request });
+ const last = String((request.messages[request.messages.length - 1] || {}).content || '');
+ let text;
+ if (last.includes('OPENVIBE-CANARY')) text = 'OPENVIBE-CANARY-7F3A';
+ else if (last.includes('17 + 25')) text = canary ? '42' : '41';
+ else if (last.includes('canary-ok')) text = 'canary-ok';
+ else text = answer != null ? answer : `You said: ${last}`;
+ return {
+ status: 200, headers: {},
+ body: JSON.stringify({ model, choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 10, completion_tokens: 5 } }),
+ };
+ };
+ return { calls, transport };
+}
+
+function storeFor(db, { transport = null, lookup = null, owner = OWNER, fetchImpl = networkFetch(owner), over = {} } = {}) {
+ let ms = Date.parse('2026-11-06T12:00:00Z');
+ return createEndpointOffers({
+ db, config: cfg(over), env: {}, fetchImpl, transport, lookup,
+ clock: { now: () => ms }, log: silent, tokens: { getToken: async () => 'test-token' },
+ });
+}
+
+t.test('the URL guard is https, a public host name, 443/8443 — never an address', async () => {
+ for (const bad of ['http://models.example/v1', 'https://127.0.0.1/v1', 'https://10.0.0.1/v1', 'https://169.254.169.254/latest',
+ 'https://[::1]/v1', 'https://[64:ff9b::7f00:1]/v1', 'https://localhost/v1', 'https://models.local/v1',
+ 'https://user:[email protected]/v1', 'https://models.example:8080/v1', 'https://models.example/v1?x=1']) {
+ assert.throws(() => judgeBaseUrl(bad), /https|IP address|public host|credentials|port 443|query or fragment/, bad);
+ }
+ assert.strictEqual(String(judgeBaseUrl('https://models.example/v1')), 'https://models.example/v1');
+ assert.strictEqual(judgeBaseUrl('https://models.example/').pathname, '/');
+ assert.strictEqual(judgeBaseUrl('https://models.example:8443/v1').port, '8443');
+});
+
+t.test('the registration checks models, hours, time zone, price, concurrency and audience', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const store = storeFor(h.db);
+ const bad = [
+ [{ ...INPUT, models: [] }, /models/], [{ ...INPUT, models: ['a', 'a'] }, /models/],
+ [{ ...INPUT, hours: { start: '25:00', end: '01:00', tz: 'UTC' } }, /hours/],
+ [{ ...INPUT, hours: { start: '00:00', end: '01:00', tz: 'Not/AZone' } }, /time zone/],
+ [{ ...INPUT, concurrency: 9 }, /concurrency/], [{ ...INPUT, price: { in_per_million: -1, out_per_million: 0 } }, /price/],
+ [{ ...INPUT, audience: 'nobody' }, /audience/], [{ ...INPUT, audience: 'group' }, /group/],
+ [{ ...INPUT, owner: OWNER }, /unknown field/],
+ ];
+ for (const [input, re] of bad) await assert.rejects(() => store.create({ owner: OWNER, input }), re);
+ const offer = await store.create({ owner: OWNER, input: { ...INPUT, audience: 'group', group: 'g1' } });
+ assert.strictEqual(offer.offer_id, `endpoint:${offer.id}`);
+ assert.deepStrictEqual(offer.models, ['qwen3:32b']);
+ assert.strictEqual(offer.hours.tz, 'UTC');
+ assert.deepStrictEqual(offer.price, { in_per_million: 10, out_per_million: 30 });
+ assert.strictEqual(offer.group, 'g1');
+ } finally { await h.stop(); }
+});
+
+t.test('a name that answers a private address is refused by the connection lookup', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const lookup = (host, options, cb) => cb(null, [{ address: '10.1.2.3', family: 4 }]);
+ const store = storeFor(h.db, { lookup });
+ await store.create({ owner: OWNER, input: INPUT });
+ const offer = (await store.liveOffers())[0];
+ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /non-public address/);
+ } finally { await h.stop(); }
+});
+
+t.test('a redirect is judged again: to a private address it is refused', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ for (const location of ['https://127.0.0.1/v1/chat/completions', 'https://[::1]/v1/chat/completions', 'https://169.254.169.254/v1/chat/completions']) {
+ const transport = async () => ({ status: 307, headers: { location }, body: '' });
+ const store = storeFor(h.db, { transport });
+ await store.create({ owner: OWNER, input: INPUT });
+ const offer = (await store.liveOffers())[0];
+ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /not allowed|non-public/, location);
+ }
+ // 301/302 are not followed for a POST at all.
+ const transport = async () => ({ status: 302, headers: { location: 'https://other.example/v1/chat/completions' }, body: '' });
+ const store = storeFor(h.db, { transport });
+ await store.create({ owner: OWNER, input: INPUT });
+ const offer = (await store.liveOffers())[0];
+ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] }), /307 and 308/);
+ } finally { await h.stop(); }
+});
+
+t.test('a dispatch carries no buyer identity and no caller field beyond the request itself', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const fake = fakeEndpoint();
+ const store = storeFor(h.db, { transport: fake.transport });
+ await store.create({ owner: OWNER, input: INPUT });
+ const offer = (await store.liveOffers())[0];
+ await store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'hi' }] });
+ const sent = fake.calls[0];
+ assert.deepStrictEqual(Object.keys(sent.headers).sort(), ['Accept', 'Content-Length', 'Content-Type', 'User-Agent']);
+ assert.ok(!/x-ov|x-openvibe|cookie|forwarded|authorization|openai-organization/i.test(Object.keys(sent.headers).join(' ')));
+ assert.deepStrictEqual(Object.keys(sent.request).sort(), ['max_tokens', 'messages', 'model', 'stream']);
+ assert.strictEqual(sent.request.user, undefined, 'the caller\'s user field is never passed through');
+ assert.strictEqual(sent.request.stream, false);
+ assert.ok(sent.url.endsWith('/v1/chat/completions'));
+ } finally { await h.stop(); }
+});
+
+t.test('a registered endpoint joins the router: canaries first, free tier until verified, then a labelled answer', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const fake = fakeEndpoint();
+ const store = storeFor(h.db, { transport: fake.transport });
+ await store.create({ owner: OWNER, input: INPUT });
+ const community = createCommunity({
+ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
+ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,
+ });
+ const out = await community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }], maxTokens: 64 },
+ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r1', onBehalfOf: OTHER } });
+ assert.ok(out, 'an endpoint offer is eligible');
+ assert.match(out.provider, /^community-endpoint:ep_/);
+ assert.strictEqual(out.explain.openvibe.provider_access, 'COMMUNITY_ENDPOINT');
+ assert.strictEqual(out.explain.openvibe.deployment, out.provider);
+ assert.strictEqual(out.explain.community.kind, 'endpoint');
+ assert.strictEqual(out.explain.community.verification, 'verified');
+ assert.strictEqual(out.explain.community.free_tier, true);
+ assert.strictEqual(out.result.text, 'You said: hello');
+ // Three canary calls, then the request.
+ assert.strictEqual(fake.calls.length, 4);
+ const state = await h.db.prepare('SELECT * FROM community_offers WHERE machine_id = ?').get(out.explain.community.machine_id);
+ assert.strictEqual(state.status, 'verified');
+ assert.ok(Number(state.canary_passes) >= 1, 'one round of three canaries passed');
+ const receipt = buildReceipt({ run: { id: 'run_1' }, explain: out.explain, usage: out.usage, provider: out.provider, model: 'qwen3:32b' });
+ assert.strictEqual(receipt.offer.provider_access, 'COMMUNITY_ENDPOINT');
+ assert.strictEqual(receipt.offer.community, 'endpoint', 'the receipt says a community endpoint answered');
+ assert.match(receipt.offer.provider, /^community-endpoint:ep_/);
+ } finally { await h.stop(); }
+});
+
+t.test('a wrong canary answer or a fingerprint mismatch fails verification and stops traffic', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ for (const fake of [fakeEndpoint({ canary: false }), fakeEndpoint({ model: 'some-other-model' })]) {
+ const store = storeFor(h.db, { transport: fake.transport });
+ const created = await store.create({ owner: OWNER, input: INPUT });
+ const community = createCommunity({
+ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
+ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,
+ });
+ await assert.rejects(() => community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r2', onBehalfOf: OTHER } }), /canary|answers as/);
+ const row = await h.db.prepare('SELECT status FROM community_offers WHERE offer_id = ?').get(`endpoint:${created.id}`);
+ assert.strictEqual(row.status, 'failed');
+ }
+ } finally { await h.stop(); }
+});
+
+t.test('a request on the seller\'s own account is self-served and earns 0', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const fake = fakeEndpoint();
+ const store = storeFor(h.db, { transport: fake.transport });
+ await store.create({ owner: OWNER, input: INPUT });
+ const community = createCommunity({
+ db: h.db, config: cfg(), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
+ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 1,
+ });
+ const out = await community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r3', onBehalfOf: OWNER } });
+ assert.strictEqual(out.explain.community.self_served, true);
+ assert.strictEqual(out.explain.community.credits_micro, 0);
+ assert.strictEqual(out.explain.community.earning_status, 'self');
+ } finally { await h.stop(); }
+});
+
+t.test('a request that opts in reaches the endpoint through the OpenAI route and is labelled', async () => {
+ const fake = fakeEndpoint();
+ const h = await boot({ communityEndpoints: (db) => storeFor(db, { transport: fake.transport }) });
+ try {
+ await h.endpoints.create({ owner: OWNER, input: INPUT });
+ const r = await request(h.base, 'POST', '/v1/chat/completions', {
+ tok: token('live', ALL), headers: { 'x-openvibe-community': '1' },
+ body: { model: 'openvibe/auto', messages: [{ role: 'user', content: 'hi' }] },
+ });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('x-openvibe-community'), '1');
+ assert.match(r.body.x_openvibe.provider, /^community-endpoint:ep_/);
+ assert.strictEqual(r.body.x_openvibe.community.kind, 'endpoint');
+ assert.strictEqual(r.body.x_openvibe.community.verification, 'verified');
+ assert.strictEqual(r.body.choices[0].message.content, 'You said: hi');
+ } finally { await h.stop(); }
+});
+
+t.test('about 1% of endpoint answers are re-run on a trusted peer and compared', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ const calls = [];
+ const transport = async ({ url, body }) => {
+ const request = JSON.parse(body);
+ calls.push(request);
+ const last = String((request.messages[request.messages.length - 1] || {}).content || '');
+ const canary = last.includes('OPENVIBE-CANARY') ? 'OPENVIBE-CANARY-7F3A' : last.includes('17 + 25') ? '42' : last.includes('canary-ok') ? 'canary-ok' : null;
+ const text = canary || (String(url).includes('a.example') ? 'You said: hello' : 'an answer that shares no words at all with that one');
+ return { status: 200, headers: {}, body: JSON.stringify({ model: 'qwen3:32b', choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }) };
+ };
+ const store = storeFor(h.db, { transport });
+ // A is cheaper, so it answers; B is verified first, so it is the trusted peer for the spot re-run.
+ const a = await store.create({ owner: OWNER, input: { ...INPUT, base_url: 'https://a.example/v1', price: 10 } });
+ const b = await store.create({ owner: OWNER, input: { ...INPUT, base_url: 'https://b.example/v1', price: 20 } });
+ const offers = await store.liveOffers();
+ const offerB = offers.find((o) => o.endpoint.id === b.id);
+ const community = createCommunity({
+ db: h.db, config: cfg({ spotRate: 0.01 }), env: {}, endpoints: store, fetchImpl: networkFetch(), log: silent,
+ tokens: { getToken: async () => 'test-token' }, clock: { now: () => Date.parse('2026-11-06T12:00:00Z') }, random: () => 0,
+ });
+ await community.verify(offerB, 'qwen3:32b');
+ await assert.rejects(() => community.execute({ operation: 'chat', req: { messages: [{ role: 'user', content: 'hello' }] },
+ ctx: { communityOptIn: true, creditsRequired: true, runId: 'r4', onBehalfOf: OTHER } }), /disagreed with a trusted/);
+ const row = await h.db.prepare('SELECT status FROM community_offers WHERE offer_id = ?').get(`endpoint:${a.id}`);
+ assert.strictEqual(row.status, 'failed', 'a spot mismatch fails the offer');
+ } finally { await h.stop(); }
+});
+
+t.test('the seller\'s concurrency is respected: over it the router falls back', async () => {
+ const h = await boot({ env: { AI_COMMUNITY: 'false' } });
+ try {
+ let release;
+ const gate = new Promise((r) => { release = r; });
+ const transport = async () => {
+ await gate;
+ return { status: 200, headers: {}, body: JSON.stringify({ model: 'qwen3:32b', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' } }], usage: {} }) };
+ };
+ const store = storeFor(h.db, { transport, over: {} });
+ await store.create({ owner: OWNER, input: { ...INPUT, concurrency: 1 } });
+ const offer = (await store.liveOffers())[0];
+ const first = store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'one' }] });
+ await assert.rejects(() => store.chat(offer, { model: 'qwen3:32b', messages: [{ role: 'user', content: 'two' }] }), /concurrency limit/);
+ release();
+ assert.strictEqual((await first).text, 'ok');
+ } finally { await h.stop(); }
+});
+
+t.run();
@@ -45,7 +45,7 @@ function tmpDir() {
@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
const restartable = new Map();
-async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community } = {}) {
+async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints } = {}) {
const reuse = dir !== undefined;
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
if (dir === undefined) dir = tmpDir();
const fullEnv = {
const fullEnv = {
@@ -59,7 +59,9 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
@@ -59,7 +59,9 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
if (reuse) restartable.set(dir, testdb);
if (reuse) restartable.set(dir, testdb);
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
// Research reads a canned web (test/fixtures/research-fetch.js) unless a test brings its own: no test reaches the internet.
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
const researcher = research || require('../server/research').createResearch({ config: config.research, env: {}, fetchImpl: require('./fixtures/research-fetch').createResearchFetch().fetchImpl, log: silent });
- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community });
+ // An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
+ const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
+ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
}