openvibers/openvibe.ai Commits

Add Exchange keys, free search and dry-run GPU supply planning (#379)

OP OpenVibers committed 4 days ago · 3cde99a4d7 · 19 files, +1302 −7

* Add scoped Exchange keys, free search and dry-run GPU supply planning

* Merge main

* Exchange: observed speeds stay until the router has 20 samples; the key and search routes documented

The catalog overwrote each offer's ttft and tokens/s with null whenever provider_metrics had fewer
than 20 samples, dropping the observed runs' numbers (exchange-observed and exchange-read failed).
OpenAPI documents GET/POST /exchange/keys, DELETE /exchange/keys/{id}, GET /exchange/key-usage and
GET /exchange/search/engines.

* Exchange keys: bounded holds that add up, released on a hang-up

A call held the key's whole remaining cap, so a key ran one paid call at a time, and a client that
hung up before 'finish' left the hold in place for the rest of the month. Now each call holds its
max_cost_usd (or $1), never more than what is left, in one locked statement so two calls never
both take the last of the cap; settle charges the cost (the hold when unknown) and gives the rest
back; 'close' settles too.

Changes

ddiff --git a/docs/exchange-platform.md b/docs/exchange-platform.md
nnew file mode 100644
iindex 0000000..6897b78
---- /dev/null
++++ b/docs/exchange-platform.md
@@@ -0,0 +1,41 @@
++# Exchange platform APIs
++
++The API host is `https://api.openvibe.exchange` (OpenVibe.AI). Exchange is the human site.
++
++- `GET/POST /api/v1/exchange/keys`: own Network user token; create with `name`, `monthly_cap_usd`, `allowed_models`.
++ Creation returns `{key, secret}` once. Lists contain metadata and prefixes only; SHA-256 digests are stored.
++- `DELETE /api/v1/exchange/keys/:id`: revoke only a key owned by the signed-in person.
++- `GET /api/v1/exchange/key-usage`: private key/model/day usage, at most 1,000 grouped rows.
++- `/api/v1/exchange/deployments` aliases the existing private `/api/v1/own-endpoints` APIs and owner gates.
++- `POST /v1/search`, `/v1/search/images`, `/v1/search/icons`: `{q, engine?: "auto", limit?: 10}`.
++ Receipts contain engine, cost in USD and elapsed milliseconds. Openverse, Commons and Iconify need no key;
++ SearXNG requires `EXCHANGE_SEARXNG_URL` on an HTTPS host with JSON search enabled. OpenVibe Search uses its public index.
++ Paid engines are not enabled. Unsupported filters return an error rather than silently broadening a licence search.
++- `POST /v1/fetch {url}` uses guarded HTTPS egress with pinned public DNS, no redirects, an 8 MB transfer cap and a
++ 10-second timeout; scripts/styles/HTML tags are removed and output is capped at 200,000 characters.
++
++Exchange inference keys authorize model listing, chat completions, search and fetch only. They cannot manage keys,
++read another account's usage, edit operator settings, or create deployments. Inference admission reserves the remaining
++monthly cap atomically, allowing one inference call per key at a time. The native `max_cost_usd` liability limit is passed
++to the run. A completed receipt settles the reservation; an unreported/failed request consumes the conservative reservation
++until reconciled. A disconnected process leaves its reservation locked. Usage labels these rows `reserved` or `unresolved`;
++they are not a Billing charge. Free search is zero-cost and limited to 60 calls/minute per authenticated principal.
++
++## GPU supply preview
++
++`GET /api/v1/admin/console/supply` compares yesterday's measured demand with the cheapest recorded reseller rate,
++dated GPU prices, measured output throughput (at least 20 requests for that model/GPU), required VRAM and owner ceilings.
++The output is always `dry_run: true`; actions are proposals. Both adapter mutations reject even with keys and ceilings.
++No provider account is created and no rental API is called by this manager.
++
++`POST /api/v1/admin/console/supply` saves model, runtime (`vllm` or `sglang`), `input_per_million`, `output_per_million`,
++`hourly_ceiling_usd`, `monthly_ceiling_usd`, `required_vram_mb`, and `idle_minutes`. Fixed rates update GPU model rate cards,
++which are the source for routing, billing estimates, and every catalog listing. Registration of a GPU also reads them.
++The runtime and idle timeout are the proposed configuration, not a launched deployment.
++
++The price-book recording endpoint `POST /api/v1/admin/console/supply/quotes {quotes:[...]}` accepts dated public listing
++records: `provider` (vast/runpod), `gpu`, `region`, `hourly_usd`, `vram_mb`, `source` (HTTPS), `observed_at` (ISO timestamp).
++It requires the same operator capability as provider configuration. The parser reads bytes only. Stale quotes (over seven
++days) and missing throughput never cause a rent proposal. Recording listings does not rent anything or contact providers.
++
++Migration 0052 is expand-only. These changes need money/security review before deployment; this PR does not deploy.
ddiff --git a/migrations/0052_exchange_platform.sql b/migrations/0052_exchange_platform.sql
nnew file mode 100644
iindex 0000000..49aee5b
---- /dev/null
++++ b/migrations/0052_exchange_platform.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++CREATE TABLE IF NOT EXISTS exchange_api_keys (
++ id text PRIMARY KEY, owner text NOT NULL, name text NOT NULL, digest text NOT NULL UNIQUE,
++ prefix text NOT NULL, monthly_cap_usd double precision NOT NULL CHECK(monthly_cap_usd >= 0),
++ allowed_models text NOT NULL DEFAULT '[]', created_at text NOT NULL, revoked_at text
++);
++CREATE TABLE IF NOT EXISTS exchange_key_months (
++ key_id text NOT NULL REFERENCES exchange_api_keys(id), month text NOT NULL,
++ spent double precision NOT NULL DEFAULT 0, reserved double precision NOT NULL DEFAULT 0,
++ PRIMARY KEY(key_id,month)
++);
++CREATE TABLE IF NOT EXISTS exchange_key_usage (
++ id text PRIMARY KEY, key_id text NOT NULL REFERENCES exchange_api_keys(id), day text NOT NULL,
++ model text NOT NULL, tokens_in bigint NOT NULL DEFAULT 0, tokens_out bigint NOT NULL DEFAULT 0,
++ cost_usd double precision NOT NULL DEFAULT 0, status text NOT NULL
++);
++CREATE TABLE IF NOT EXISTS exchange_supply_settings (
++ model text PRIMARY KEY, settings text NOT NULL, updated_at text NOT NULL, actor text NOT NULL
++);
++CREATE TABLE IF NOT EXISTS gpu_price_book (
++ provider text NOT NULL, gpu text NOT NULL, region text NOT NULL, hourly_usd double precision NOT NULL CHECK(hourly_usd > 0),
++ vram_mb bigint NOT NULL CHECK(vram_mb > 0), source text NOT NULL, observed_at text NOT NULL,
++ PRIMARY KEY(provider,gpu,region)
++);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 804cbef..fdd5785 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -26,6 +26,8 @@ const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type'
const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';
const TABLES = [
++ { table: 'exchange_api_keys', subject: 'owner', file: 'exchange-keys.json', columns: ['id','name','prefix','monthly_cap_usd','allowed_models','created_at','revoked_at'], erase: { keep: 'extraErase deletes key usage and cap rows before deleting keys' } },
++ { table: 'exchange_supply_settings', subject: 'actor', file: null, erase: { keep: 'owner pricing decisions are the operator audit trail' } },
{ table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
@@@ -85,11 +87,14 @@ const TABLES = [
/** The runs AI made for the person, with their input and their answer. */
async function extraExport(db, subject) {
const runs = await db.many(`SELECT ${RUN_COLUMNS} FROM runs WHERE ${PERSON_RUN} ORDER BY created_at DESC LIMIT 5000`, [subject]);
-- return runs.length ? [{ name: 'runs.json', content: runs }] : [];
++ const usage = await db.many('SELECT u.day,u.model,u.tokens_in,u.tokens_out,u.cost_usd,u.status FROM exchange_key_usage u JOIN exchange_api_keys k ON k.id=u.key_id WHERE k.owner=$1 ORDER BY u.day DESC LIMIT 5000', [subject]);
++ return [...(runs.length ? [{ name: 'runs.json', content: runs }] : []), { name:'exchange-key-usage.json', content:usage }];
}
/** The person's runs go, with the rows that hang under them (the requests, the citations, the cached answers). */
async function extraErase(t, subjects, counts) {
++ for (const table of ['exchange_key_usage','exchange_key_months']) counts.add(counts.erased, table, await t.exec(`DELETE FROM ${table} WHERE key_id IN (SELECT id FROM exchange_api_keys WHERE owner = ANY($1::text[]))`, [subjects]));
++ counts.add(counts.erased, 'exchange_api_keys', await t.exec('DELETE FROM exchange_api_keys WHERE owner = ANY($1::text[])', [subjects]));
// A private GPU rent is stopped and anonymized now; its Billing hold and money stay until the reconciler
// gives the instance back and settles the seconds (the table's erase is a keep for that reason).
counts.add(counts.erased, 'own_endpoints', await t.exec(`UPDATE own_endpoints
ddiff --git a/server/api/console-data.js b/server/api/console-data.js
iindex 27ca856..9f4eccb 100644
---- a/server/api/console-data.js
++++ b/server/api/console-data.js
@@@ -11,6 +11,7 @@ const poolSeed = require('../providers/pool-seed');
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, gpus = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
const r = express.Router();
++ const supply = require('../platform/supply').createSupply({ db, registry, clock });
const json = express.json({ limit: '64kb' });
const read = auth.requireCap(CAPS.usageRead);
const write = auth.requireCap(CAPS.providerManage);
@@@ -25,6 +26,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
};
r.get('/api/v1/admin/console/:section', read, wrap(async (req) => {
const section = req.params.section;
++ if (section === 'supply') return supply.report();
if (section === 'overview') {
const since = new Date(clock.now() - 86400000).toISOString();
return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };
@@@ -72,6 +74,8 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
if (!gpus) throw new AiError(409, 'ai.conflict', 'GPU quotes are off on this host');
return await gpus.preview(String(req.params.model), { strategy: req.query.strategy || undefined });
}));
++ r.post('/api/v1/admin/console/supply/quotes', json, write, wrap(async req => ({ recorded: await require('../pricebook/gpus').recordGpuQuotes(db, JSON.stringify(req.body.quotes)), dry_run: true })));
++ r.post('/api/v1/admin/console/supply', json, write, wrap(req => supply.save(req.body || {}, actor(req))));
r.post('/api/v1/admin/console/providers/:key/key', json, write, wrap(async (req) => {
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
const provider = await registry.getProvider(req.params.key);
ddiff --git a/server/app.js b/server/app.js
iindex 99f98d5..3bfa9eb 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -98,11 +98,21 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
app.get('/api/ready', readiness.handler);
// GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
openapi.mount(app);
++ // Opaque Exchange keys are admitted only by the platform middleware; management keeps Network auth.
++ const baseAppOrCap = auth.appOrCap.bind(auth);
++ auth = { ...auth, appOrCap: (...caps) => {
++ const guard = baseAppOrCap(...caps);
++ return (req, res, next) => { if (req.exchangePrincipal) { req.principal = req.exchangePrincipal; return next(); } return guard(req, res, next); };
++ } };
++ const exchangePlatform = require('./platform/router').platform({ db, auth, clock, log });
++ app.use(exchangePlatform.keyMiddleware);
++ app.use(exchangePlatform.router);
// 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, quotas, env, clock, presets, receipts, auth, log, ownEndpoints }));
// A person's own endpoint (the Exchange remake, product line 6): the private rent's person API and its
// OpenAI-compatible proxy. Only the owner's own Network user token reaches it; a service token never does.
++ app.use((req,_res,next) => { if (/^\/api\/v1\/exchange\/deployments(?:\/|$)/.test(req.path)) req.url = req.url.replace('/api/v1/exchange/deployments','/api/v1/own-endpoints'); next(); });
if (ownEndpoints) app.use(require('./api/own-endpoints').createOwnEndpointsRouter({ endpoints: ownEndpoints, auth, config, clock, 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.
ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex 5acdbac..bb55176 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -211,6 +211,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// up | degraded | down | exhausted (out of balance), from the router's breaker.
status: statusOf(provider.key),
// Rounded to the micro-dollar: a derived cached price was printed as 0.020000000000000004.
++ on_our_gpus: provider.key.startsWith('gpu.'),
++ fixed_price: row.metadata?.owner_fixed_price === true,
price: price ? { in: usd6(price.in), out: usd6(price.out), cached: usd6(price.cached), per: '1M tokens', currency: 'USD' } : null,
// Where the number came from and when someone checked it; null when nobody recorded it.
price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,
@@@ -238,6 +240,7 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
variant: (row.metadata && row.metadata.variant) || null,
uncensored: UNCENSORED.test(`${canonical} ${(row.metadata && row.metadata.variant) || ''}`) || Boolean(row.metadata && row.metadata.uncensored === true),
type: row.type,
++ open_weights: row.metadata?.open_weights === true,
context: num(row.context_window),
max_output: num(row.max_output),
capabilities: capabilitiesOf(row),
@@@ -259,7 +262,17 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// else the answering attempt's total latency.
const measured = await seen.read({ days });
observed.fold(measured, drafts);
++ // The router's own measurements win once they have 20 samples; until then the observed runs' numbers stay.
++ const performance = await db.prepare('SELECT * FROM provider_metrics').all();
++ for (const d of drafts) {
++ const p = performance.find((m) => m.provider_key === d.providerKey && m.model_key === d.modelKey);
++ if (p && Number(p.ttft_samples) >= 20) d.offer.speed.ttft_ms = Number(p.ttft_ms);
++ if (p && Number(p.decode_samples) >= 20) d.offer.speed.tokens_per_second = Number(p.decode_tps);
++ d.offer.error_rate = d.offer.uptime == null ? null : 1 - d.offer.uptime;
++ }
++ const daysByModel = await db.prepare("SELECT model_key,substr(created_at,1,10) AS activity_day,COUNT(*)::int requests FROM runs WHERE created_at >= ? AND synthetic=0 AND status='succeeded' GROUP BY model_key,substr(created_at,1,10)").all(new Date(now-7*86400000).toISOString());
++ const firstTokens = measured.latencies.map(r=>r.ttft_ms == null ? NaN : Number(r.ttft_ms)).filter(Number.isFinite).sort((a,b)=>a-b);
let models = [...groups.values()];
if (kind) models = models.filter((m) => m.kind === kind);
for (const m of models) {
@@@ -272,6 +285,9 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
m.requests_today = m.offers.reduce((n, o) => n + o.requests_today, 0);
m.cached_7d = m.offers.reduce((n, o) => n + o.cached_7d, 0);
m.cached_today = m.offers.reduce((n, o) => n + o.cached_today, 0);
++ const activity = new Map();
++ for (const d of daysByModel.filter(d=>m.offers.some(o=>o.model===d.model_key))) activity.set(d.activity_day,(activity.get(d.activity_day)||0)+Number(d.requests));
++ m.activity = [...activity].sort(([a],[b])=>a.localeCompare(b)).map(([day,requests])=>({day,requests}));
m.community = m.offers.some((o) => o.trust === 'community');
m.priced = m.offers.some((o) => o.price);
// Free right now: a free-tier offer whose provider can answer (not exhausted, not down).
@@@ -295,6 +311,7 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
}
return {
generated_at: new Date(now).toISOString(),
++ median_ttft_ms: firstTokens.length >= 20 ? firstTokens[Math.floor(firstTokens.length/2)] : null,
window_days: days,
...(kind ? { kind } : {}),
count: models.length,
ddiff --git a/server/gpu/provisioner.js b/server/gpu/provisioner.js
iindex 2cef339..b7e51b5 100644
---- a/server/gpu/provisioner.js
++++ b/server/gpu/provisioner.js
@@@ -419,6 +419,8 @@ function createGpuProvisioner({
async function register(spec, row) {
const base = String(row.endpoint || '').replace(/\/+$/, '') + '/v1';
if (!/^https?:\/\/\S+$/i.test(base)) throw new AiError(502, 'gpu.no_endpoint', 'the instance has no usable endpoint');
++ const fixedRow = await db.prepare('SELECT settings FROM exchange_supply_settings WHERE model = ?').get(spec.model_key);
++ const fixed = fixedRow ? JSON.parse(fixedRow.settings) : null;
const price = money.usdPerMillionTokens(toNano(row.hourly_nano), spec.throughput_tokens_per_hour);
const gpu = {
marketplace: row.marketplace,
@@@ -444,7 +446,7 @@ function createGpuProvisioner({
priority: 40,
billing_profile: 'metered',
provider_access: 'OWN_DEPLOYMENT',
-- metadata: { gpu },
++ metadata: { gpu, owner_fixed_price: Boolean(fixed), open_weights: true },
}, { actor: 'service:ai', origin: 'gpu' });
await registry.upsertModel({
provider_key: spec.provider_key,
@@@ -454,14 +456,14 @@ function createGpuProvisioner({
status: 'active',
context_window: spec.context_window,
cost: {
-- in_per_mtok: price,
-- out_per_mtok: price,
-- cached_per_mtok: price,
-- source: PRICE_SOURCE[row.marketplace] || 'urn:openvibe:ai:unverified:gpu',
++ in_per_mtok: fixed ? fixed.input_per_million : price,
++ out_per_mtok: fixed ? fixed.output_per_million : price,
++ cached_per_mtok: fixed ? fixed.input_per_million : price,
++ source: fixed ? 'urn:openvibe:owner-fixed-price' : PRICE_SOURCE[row.marketplace] || 'urn:openvibe:ai:unverified:gpu',
verified_at: iso(now()).slice(0, 10),
},
supports: { json: false, tools: false, streaming: true, vision: false },
-- metadata: { gpu },
++ metadata: { gpu, owner_fixed_price: Boolean(fixed), open_weights: true },
}, { actor: 'service:ai' });
return base;
}
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 303ae03..9a64272 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -468,6 +468,43 @@ route('get', '/exchange/endpoints', {
models: arrayOf({ type: 'object', properties: { key: str(), display_name: str(), context_window: integer(), vram_mb: integer(), gpu_names: arrayOf(str()) } }),
} }) },
});
++const exchangeKey = {
++ type: 'object', required: ['id', 'name', 'prefix', 'monthly_cap_usd', 'allowed_models', 'created_at'],
++ properties: {
++ id: str({ pattern: '^xkey_' }), name: str({ maxLength: 80 }), prefix: str({ description: 'The first ten characters of the secret (ovx_…), to tell keys apart' }),
++ monthly_cap_usd: { type: 'number', minimum: 0 }, allowed_models: arrayOf(str()), created_at: str({ format: 'date-time' }),
++ revoked_at: nullable('string', { format: 'date-time' }),
++ },
++};
++route('get', '/exchange/keys', {
++ operationId: 'listExchangeKeys', summary: 'Your Exchange API keys', tags: ['exchange'], security: [{ bearer: [] }],
++ description: 'Only a person (a usr_ subject). Never the secrets: each key shows its prefix, monthly cap, allowed models and whether it was revoked.',
++ responses: { 200: json('The keys, newest first', { type: 'object', required: ['keys'], properties: { keys: arrayOf(exchangeKey) } }), ...errors([401, 403]) },
++});
++route('post', '/exchange/keys', {
++ operationId: 'createExchangeKey', summary: 'Make an Exchange API key with a monthly cap and allowed models', tags: ['exchange'], security: [{ bearer: [] }],
++ description: 'Only a person. The secret (ovx_…) is in this answer only; it is stored as a digest. A run with the key is refused once the month\'s cap is spent, and for a model not in allowed_models.',
++ requestBody: body({ type: 'object', required: ['name', 'monthly_cap_usd', 'allowed_models'], properties: {
++ name: str({ minLength: 1, maxLength: 80 }), monthly_cap_usd: { type: 'number', minimum: 0 }, allowed_models: arrayOf(str({ maxLength: 200 })),
++ } }),
++ responses: { 201: json('The key and its secret, shown once', { type: 'object', required: ['key', 'secret'], properties: { key: exchangeKey, secret: str({ pattern: '^ovx_' }) } }), 422: problem, ...errors([401, 403]) },
++});
++route('delete', '/exchange/keys/{id}', {
++ operationId: 'revokeExchangeKey', summary: 'Revoke an Exchange API key', tags: ['exchange'], security: [{ bearer: [] }],
++ description: 'Only its owner. Runs with the key are refused from now on; its usage stays in the history.',
++ parameters: [path('id', str({ pattern: '^xkey_' }))],
++ responses: { 200: json('Revoked', { type: 'object', required: ['id', 'revoked'], properties: { id: str(), revoked: { type: 'boolean' } } }), 404: problem, ...errors([401, 403]) },
++});
++route('get', '/exchange/key-usage', {
++ operationId: 'exchangeKeyUsage', summary: 'What your Exchange keys spent, by day, key and model', tags: ['exchange'], security: [{ bearer: [] }],
++ description: 'Only a person. One row per day, key, model and status (settled, reserved, unresolved): requests, tokens and dollars. Private to the owner.',
++ responses: { 200: json('The usage rows', { type: 'object', required: ['rows'], properties: { rows: arrayOf({ type: 'object' }) } }), ...errors([401, 403]) },
++});
++route('get', '/exchange/search/engines', {
++ operationId: 'exchangeSearchEngines', summary: 'The search engines behind web, image and icon search', tags: ['exchange'], security: [],
++ description: 'Public. Which engines answer each kind of search (web, images, icons) and whether each is configured here.',
++ responses: { 200: json('The engines', { type: 'object', required: ['engines'], properties: { engines: arrayOf({ type: 'object' }) } }) },
++});
route('get', '/own-endpoints', {
operationId: 'listOwnEndpoints', summary: 'The caller\'s private endpoints', tags: ['exchange'], security: [{ bearer: [] }],
description: 'Only the person themselves: a Network user token (audience openvibe.network or openvibe.ai) whose subject is usr_…. A service, app, module or node token is refused whatever capabilities it holds. A private endpoint is never registered as an offer, so no router and no catalogue sees it.',
ddiff --git a/server/platform/keys.js b/server/platform/keys.js
nnew file mode 100644
iindex 0000000..c67e993
---- /dev/null
++++ b/server/platform/keys.js
@@@ -0,0 +1,151 @@
++'use strict';
++const { randomBytes, randomUUID, createHash } = require('node:crypto');
++const { AiError } = require('../util');
++const hash = (s) => createHash('sha256').update(s).digest('hex');
++// A call with no max_cost_usd holds this much of its key's cap until it settles.
++const DEFAULT_HOLD_USD = 1;
++function createKeys({ db, clock = { now: () => Date.now() } }) {
++ const iso = () => new Date(clock.now()).toISOString();
++ const publicKey = (r) => ({
++ id: r.id,
++ name: r.name,
++ prefix: r.prefix,
++ monthly_cap_usd: Number(r.monthly_cap_usd),
++ allowed_models: JSON.parse(r.allowed_models),
++ created_at: r.created_at,
++ revoked_at: r.revoked_at,
++ });
++ async function list(owner) {
++ return (
++ await db
++ .prepare(
++ 'SELECT * FROM exchange_api_keys WHERE owner = ? ORDER BY created_at DESC',
++ )
++ .all(owner)
++ ).map(publicKey);
++ }
++ async function create(owner, b) {
++ if (
++ typeof b.name !== 'string' ||
++ !b.name.trim() ||
++ b.name.length > 80 ||
++ !Number.isFinite(b.monthly_cap_usd) ||
++ b.monthly_cap_usd < 0 ||
++ !Array.isArray(b.allowed_models) ||
++ b.allowed_models.length > 100 ||
++ b.allowed_models.some(
++ (m) => typeof m !== 'string' || !m || m.length > 200,
++ )
++ )
++ throw new AiError(
++ 422,
++ 'ai.invalid',
++ 'A name, nonnegative monthly cap and allowed model list are required',
++ );
++ const secret = 'ovx_' + randomBytes(32).toString('base64url');
++ const id = 'xkey_' + randomUUID();
++ await db
++ .prepare(
++ 'INSERT INTO exchange_api_keys(id,owner,name,digest,prefix,monthly_cap_usd,allowed_models,created_at) VALUES(?,?,?,?,?,?,?,?)',
++ )
++ .run(
++ id,
++ owner,
++ b.name.trim(),
++ hash(secret),
++ secret.slice(0, 10),
++ b.monthly_cap_usd,
++ JSON.stringify(b.allowed_models),
++ iso(),
++ );
++ return { key: (await list(owner)).find((k) => k.id === id), secret };
++ }
++ async function revoke(owner, id) {
++ const r = await db
++ .prepare(
++ 'UPDATE exchange_api_keys SET revoked_at = ? WHERE owner = ? AND id = ? RETURNING id',
++ )
++ .get(iso(), owner, id);
++ if (!r) throw new AiError(404, 'ai.not_found', 'No such key');
++ return { id, revoked: true };
++ }
++ async function resolve(secret) {
++ if (!/^ovx_[A-Za-z0-9_-]{43}$/.test(secret)) return null;
++ return db
++ .prepare(
++ 'SELECT * FROM exchange_api_keys WHERE digest = ? AND revoked_at IS NULL',
++ )
++ .get(hash(secret));
++ }
++ /**
++ * A hold for one call against the key's monthly cap: the call's own ceiling (`limit`, its max_cost_usd) or
++ * DEFAULT_HOLD_USD, never more than what is left this month. Holds add up, so calls run side by side while their
++ * holds fit under the cap; settle() charges what the call cost (the hold when the cost is unknown, e.g. the client
++ * hung up) and gives the rest of the hold back. Search calls (`free`) take no hold and skip the model list.
++ */
++ async function reserve(key, model, free = false, limit = null) {
++ const allowed = JSON.parse(key.allowed_models);
++ if (!free && allowed.length && !allowed.includes(model))
++ throw new AiError(403, 'exchange.model_denied', 'This model is not allowed by this key');
++ const month = iso().slice(0, 7),
++ day = iso().slice(0, 10),
++ id = randomUUID();
++ await db
++ .prepare('INSERT INTO exchange_key_months(key_id,month) VALUES(?,?) ON CONFLICT DO NOTHING')
++ .run(key.id, month);
++ let amount = 0;
++ if (!free) {
++ const want = Number.isFinite(limit) && limit > 0 ? Math.min(limit, DEFAULT_HOLD_USD * 100) : DEFAULT_HOLD_USD;
++ // One statement, so two calls never both take the last of the cap: the row is locked, and the second
++ // reads what the first left.
++ const row = await db
++ .prepare(
++ `WITH m AS (
++ SELECT key_id, month, LEAST(?::double precision, monthly.cap - spent - reserved) AS take
++ FROM exchange_key_months, (SELECT ?::double precision AS cap) monthly
++ WHERE key_id = ? AND month = ? AND spent + reserved < monthly.cap
++ FOR UPDATE OF exchange_key_months
++ )
++ UPDATE exchange_key_months x SET reserved = x.reserved + m.take FROM m
++ WHERE x.key_id = m.key_id AND x.month = m.month
++ AND EXISTS (SELECT 1 FROM exchange_api_keys k WHERE k.id = x.key_id AND k.revoked_at IS NULL)
++ RETURNING m.take AS take`,
++ )
++ .get(want, key.monthly_cap_usd, key.id, month);
++ if (!row)
++ throw new AiError(429, 'exchange.key_cap', "This key's monthly cap is spent");
++ amount = Number(row.take);
++ }
++ await db
++ .prepare('INSERT INTO exchange_key_usage(id,key_id,day,model,cost_usd,status) VALUES(?,?,?,?,?,?)')
++ .run(id, key.id, day, model, amount, 'reserved');
++ let settled = false;
++ return {
++ amount,
++ async settle({ cost_usd, tokens_in = 0, tokens_out = 0 } = {}) {
++ if (settled) return;
++ settled = true;
++ const known = Number.isFinite(cost_usd) && cost_usd >= 0;
++ const cost = known ? cost_usd : amount;
++ await db
++ .prepare('UPDATE exchange_key_usage SET cost_usd = ?, tokens_in = ?, tokens_out = ?, status = ? WHERE id = ?')
++ .run(cost, tokens_in, tokens_out, known ? 'settled' : 'unresolved', id);
++ if (!free)
++ await db
++ .prepare('UPDATE exchange_key_months SET spent = spent + ?, reserved = GREATEST(0, reserved - ?) WHERE key_id = ? AND month = ?')
++ .run(cost, amount, key.id, month);
++ },
++ };
++ }
++ async function usage(owner) {
++ return {
++ rows: await db
++ .prepare(
++ 'SELECT u.day,u.key_id,u.model,u.status,COUNT(*)::int AS requests,SUM(u.tokens_in)::bigint AS tokens_in,SUM(u.tokens_out)::bigint AS tokens_out,SUM(u.cost_usd)::double precision AS cost_usd FROM exchange_key_usage u JOIN exchange_api_keys k ON k.id=u.key_id WHERE k.owner=? GROUP BY u.day,u.key_id,u.model,u.status ORDER BY u.day DESC LIMIT 1000',
++ )
++ .all(owner),
++ };
++ }
++ return { list, create, revoke, resolve, reserve, usage };
++}
++module.exports = { createKeys };
ddiff --git a/server/platform/router.js b/server/platform/router.js
nnew file mode 100644
iindex 0000000..502e395
---- /dev/null
++++ b/server/platform/router.js
@@@ -0,0 +1,196 @@
++'use strict';
++const express = require('express');
++const { CAPS } = require('../auth');
++const { AiError, sendError } = require('../util');
++const { createKeys } = require('./keys');
++const { createSearch } = require('../search');
++function platform({ db, auth, clock, log, searchFetch }) {
++ const keys = createKeys({ db, clock });
++ const search = createSearch({
++ ...(searchFetch ? { fetchImpl: searchFetch } : {}),
++ now: () => clock.now(),
++ });
++ const r = express.Router();
++ const wrap = (fn) => async (req, res) => {
++ try {
++ res.set('Cache-Control', 'no-store');
++ const out = await fn(req, res);
++ if (out !== undefined) res.json(out);
++ } catch (e) {
++ sendError(res, e, req.ov, log);
++ }
++ };
++ const person = auth.personOrCap(CAPS.runCreate);
++ const owner = (req) => {
++ if (req.principal?.kind !== 'user')
++ throw new AiError(
++ 403,
++ 'ai.denied',
++ 'A person must manage their own keys',
++ );
++ return req.principal.subject.id;
++ };
++ r.get(
++ '/api/v1/exchange/keys',
++ person,
++ wrap((req) => keys.list(owner(req)).then((keys) => ({ keys }))),
++ );
++ r.post(
++ '/api/v1/exchange/keys',
++ person,
++ wrap((req, res) => {
++ res.status(201);
++ return keys.create(owner(req), req.body || {});
++ }),
++ );
++ r.delete(
++ '/api/v1/exchange/keys/:id',
++ person,
++ wrap((req) => keys.revoke(owner(req), req.params.id)),
++ );
++ r.get(
++ '/api/v1/exchange/key-usage',
++ person,
++ wrap((req) => keys.usage(owner(req))),
++ );
++ r.get('/api/v1/exchange/search/engines', (_req, res) =>
++ res.json({ engines: search.engines() }),
++ );
++ const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
++ const searchWindows = new Map();
++ const searchLimit = (req, res, next) => {
++ const id = req.principal?.subject?.id || req.principal?.sub;
++ const now = clock.now();
++ for (const [key, value] of searchWindows)
++ if (value.until <= now) searchWindows.delete(key);
++ const entry = searchWindows.get(id) || { until: now + 60000, count: 0 };
++ if (entry.count >= 60)
++ return res
++ .status(429)
++ .json({ error: 'Search limit reached; try again in a minute' });
++ entry.count++;
++ searchWindows.set(id, entry);
++ next();
++ };
++ for (const kind of ['web', 'images', 'icons'])
++ r.post(
++ '/v1/search' + (kind === 'web' ? '' : '/' + kind),
++ allow,
++ searchLimit,
++ wrap(async (req) => search.search(kind, req.body || {})),
++ );
++ r.post(
++ '/v1/fetch',
++ allow,
++ searchLimit,
++ wrap((req) => search.read(req.body?.url)),
++ );
++ // Exchange keys only enter inference and the search surface. They never authorize management routes.
++ async function keyMiddleware(req, res, next) {
++ const token = String(req.headers.authorization || '').replace(
++ /^Bearer /,
++ '',
++ );
++ if (!token.startsWith('ovx_')) return next();
++ try {
++ const isSearch =
++ req.method === 'POST' &&
++ [
++ '/v1/search',
++ '/v1/search/images',
++ '/v1/search/icons',
++ '/v1/fetch',
++ ].includes(req.path);
++ const inference =
++ req.method === 'POST' && req.path === '/v1/chat/completions';
++ const models = req.method === 'GET' && req.path === '/v1/models';
++ if (!isSearch && !inference && !models)
++ throw new AiError(
++ 403,
++ 'exchange.key_scope',
++ 'This key only authorizes model and search calls',
++ );
++ const key = await keys.resolve(token);
++ if (!key)
++ throw new AiError(
++ 401,
++ 'token.invalid',
++ 'Invalid or revoked Exchange key',
++ );
++ const hold = models
++ ? null
++ : await keys.reserve(
++ key,
++ isSearch
++ ? 'search:' + req.path
++ : String(req.body?.model || ''),
++ isSearch,
++ Number.isFinite(req.body?.max_cost_usd) ? req.body.max_cost_usd : null,
++ );
++ if (inference) {
++ req.body.max_cost_usd = Math.min(
++ hold.amount,
++ Number.isFinite(req.body.max_cost_usd)
++ ? req.body.max_cost_usd
++ : hold.amount,
++ );
++ }
++ req.exchangePrincipal = {
++ kind: 'user',
++ sub: key.owner,
++ subject: { type: 'user', id: key.owner },
++ ns: ['ai.*'],
++ claims: {},
++ exchangeKeyId: key.id,
++ };
++ if (hold) {
++ let result = null,
++ tail = '';
++ const json = res.json.bind(res);
++ res.json = (v) => {
++ result = v;
++ return json(v);
++ };
++ const write = res.write.bind(res);
++ res.write = (chunk, ...args) => {
++ tail = (tail + String(chunk)).slice(-65536);
++ return write(chunk, ...args);
++ };
++ // 'close' without 'finish' is a client that hung up: the hold still settles (at the hold, when
++ // the cost is unknown), or the key would keep it until the month ends.
++ const done = () => {
++ if (!result && tail)
++ for (const line of tail
++ .split('\n')
++ .filter((l) => l.startsWith('data: '))) {
++ try {
++ const v = JSON.parse(line.slice(6));
++ if (v.usage)
++ result = { ...result, usage: v.usage };
++ if (v.x_openvibe) result = { ...result, ...v };
++ } catch {}
++ }
++ const usage = result?.usage || {};
++ const cost =
++ isSearch && res.statusCode < 400
++ ? 0
++ : result?.x_openvibe?.cost_usd;
++ hold.settle({
++ cost_usd: cost,
++ tokens_in: usage.prompt_tokens || 0,
++ tokens_out: usage.completion_tokens || 0,
++ }).catch(() =>
++ log.error('Exchange usage settlement failed'),
++ );
++ };
++ res.once('finish', done);
++ res.once('close', done);
++ }
++ next();
++ } catch (e) {
++ sendError(res, e, req.ov, log);
++ }
++ }
++ return { router: r, keyMiddleware };
++}
++module.exports = { platform };
ddiff --git a/server/platform/supply.js b/server/platform/supply.js
nnew file mode 100644
iindex 0000000..0a52b4a
---- /dev/null
++++ b/server/platform/supply.js
@@@ -0,0 +1,241 @@
++'use strict';
++const { AiError } = require('../util');
++const { vastAdapter } = require('../gpu/vast');
++const { runpodAdapter } = require('../gpu/runpod');
++// This manager cannot mutate a marketplace, even when accounts are configured.
++function adapter(
++ provider,
++ { key, hourlyCeiling, monthlyCeiling, fetchImpl } = {},
++) {
++ const upstream =
++ provider === 'vast'
++ ? vastAdapter({ key, fetchImpl })
++ : provider === 'runpod'
++ ? runpodAdapter({ key, fetchImpl })
++ : null;
++ if (!upstream) throw new Error('Unknown marketplace');
++ const refuse = async () => {
++ if (!key || !(hourlyCeiling > 0) || !(monthlyCeiling > 0))
++ throw new AiError(
++ 409,
++ 'gpu.owner_gate',
++ 'Owner keys and both spend ceilings are required',
++ );
++ throw new AiError(
++ 409,
++ 'gpu.dry_run',
++ 'The supply manager is dry-run only',
++ );
++ };
++ return { id: provider, rent: refuse, stop: refuse };
++}
++function decide({ demand, quotes, settings, now = Date.now() }) {
++ const input = Number(demand.tokens_in) || 0,
++ output = Number(demand.tokens_out) || 0;
++ const load = output / 24;
++ const fresh = quotes.filter(
++ (q) =>
++ Number.isFinite(q.hourly_usd) &&
++ q.hourly_usd > 0 &&
++ q.measured_tokens_per_second > 0 &&
++ q.vram_mb >= settings.required_vram_mb &&
++ Date.parse(q.observed_at) >= now - 7 * 86400000,
++ );
++ const ranked = fresh
++ .map((q) => ({
++ ...q,
++ utilisation: load / (q.measured_tokens_per_second * 3600),
++ cost_per_million: load > 0 ? (q.hourly_usd * 1e6) / load : null,
++ }))
++ .filter((q) => q.utilisation <= 1)
++ .sort((a, b) => a.hourly_usd - b.hourly_usd);
++ const chosen = ranked[0] || null;
++ const reseller = Number.isFinite(demand.reseller_cost_usd)
++ ? demand.reseller_cost_usd
++ : null;
++ const rental = chosen ? chosen.hourly_usd * 24 : null;
++ const revenue =
++ (input * settings.input_per_million +
++ output * settings.output_per_million) /
++ 1e6;
++ const capped =
++ chosen &&
++ chosen.hourly_usd <= settings.hourly_ceiling_usd &&
++ chosen.hourly_usd * 24 * 31 <= settings.monthly_ceiling_usd;
++ return {
++ dry_run: true,
++ model: settings.model,
++ action:
++ !input && !output
++ ? 'would_stop'
++ : !chosen
++ ? 'needs_measurement'
++ : !capped
++ ? 'ceiling_required'
++ : reseller !== null && rental < reseller
++ ? 'would_rent'
++ : 'use_reseller',
++ selected: chosen,
++ reseller_cost_usd: reseller,
++ rental_cost_usd: rental,
++ revenue_usd: revenue,
++ margin_usd: rental == null ? null : revenue - rental,
++ price: {
++ in: settings.input_per_million,
++ out: settings.output_per_million,
++ },
++ runtime: settings.runtime,
++ idle_minutes: settings.idle_minutes,
++ };
++}
++function createSupply({ db, registry, clock = { now: () => Date.now() } }) {
++ async function save(b, actor) {
++ if (
++ typeof b.model !== 'string' ||
++ !b.model ||
++ !['vllm', 'sglang'].includes(b.runtime) ||
++ ![
++ 'input_per_million',
++ 'output_per_million',
++ 'hourly_ceiling_usd',
++ 'monthly_ceiling_usd',
++ 'required_vram_mb',
++ 'idle_minutes',
++ ].every((k) => Number.isFinite(b[k]) && b[k] >= 0) ||
++ b.required_vram_mb <= 0 ||
++ b.idle_minutes < 1
++ )
++ throw new AiError(
++ 422,
++ 'ai.invalid',
++ 'Model, runtime, prices, ceilings, VRAM and idle timeout are required',
++ );
++ const value = Object.fromEntries(
++ [
++ 'model',
++ 'runtime',
++ 'input_per_million',
++ 'output_per_million',
++ 'hourly_ceiling_usd',
++ 'monthly_ceiling_usd',
++ 'required_vram_mb',
++ 'idle_minutes',
++ ].map((k) => [k, b[k]]),
++ );
++ await db
++ .prepare(
++ 'INSERT INTO exchange_supply_settings(model,settings,updated_at,actor) VALUES(?,?,?,?) ON CONFLICT(model) DO UPDATE SET settings=excluded.settings,updated_at=excluded.updated_at,actor=excluded.actor',
++ )
++ .run(
++ b.model,
++ JSON.stringify(value),
++ new Date(clock.now()).toISOString(),
++ actor,
++ );
++ for (const m of await registry.listModels())
++ if (m.model_key === b.model && m.provider_key.startsWith('gpu.'))
++ await registry.upsertModel(
++ {
++ ...m,
++ cost: {
++ in_per_mtok: b.input_per_million,
++ out_per_mtok: b.output_per_million,
++ cached_per_mtok: b.input_per_million,
++ source: 'urn:openvibe:owner-fixed-price',
++ verified_at: new Date(clock.now())
++ .toISOString()
++ .slice(0, 10),
++ },
++ metadata: {
++ ...m.metadata,
++ owner_fixed_price: true,
++ open_weights: true,
++ },
++ },
++ { actor },
++ );
++ return { settings: value, dry_run: true };
++ }
++ async function report() {
++ const rows = await db
++ .prepare(
++ 'SELECT settings FROM exchange_supply_settings ORDER BY model',
++ )
++ .all();
++ const since = new Date(clock.now() - 86400000).toISOString();
++ const demand = await db
++ .prepare(
++ "SELECT model_key,SUM(tokens_in)::double precision tokens_in,SUM(tokens_out)::double precision tokens_out,SUM(cost_usd)::double precision reseller_cost_usd FROM runs WHERE created_at >= ? AND status='succeeded' AND synthetic=0 GROUP BY model_key",
++ )
++ .all(since);
++ // Quote inputs must be recorded, dated price-book observations with a measured throughput for this model.
++ const quotes = await db.prepare('SELECT * FROM gpu_price_book').all();
++ const providers = await registry.listProviders();
++ const measured = await db
++ .prepare(
++ "SELECT provider_key,model_key,COUNT(*)::int samples,SUM(tokens_out)*1000.0/NULLIF(SUM(latency_ms),0) tps FROM requests WHERE created_at >= ? AND status='ok' AND tokens_out>0 AND latency_ms>0 GROUP BY provider_key,model_key",
++ )
++ .all(since);
++ const modelRows = await registry.listModels();
++ return {
++ dry_run: true,
++ price_book: quotes,
++ reports: rows.map((r) => {
++ const settings = JSON.parse(r.settings);
++ const observations = quotes.map((q) => {
++ const provider = providers.find(
++ (p) => p.metadata?.gpu?.gpu_name === q.gpu,
++ );
++ const sample = measured.find(
++ (m) =>
++ m.provider_key === provider?.key &&
++ m.model_key === settings.model &&
++ m.samples >= 20,
++ );
++ return {
++ ...q,
++ vram_mb: Number(q.vram_mb),
++ hourly_usd: Number(q.hourly_usd),
++ measured_tokens_per_second: sample
++ ? Number(sample.tps)
++ : null,
++ sample_count: sample?.samples || 0,
++ };
++ });
++ const usage =
++ demand.find((d) => d.model_key === settings.model) || {};
++ const resale = modelRows
++ .filter(
++ (m) =>
++ m.model_key === settings.model &&
++ !m.provider_key.startsWith('gpu.') &&
++ m.cost?.in_per_mtok != null &&
++ m.cost?.out_per_mtok != null,
++ )
++ .map(
++ (m) =>
++ ((Number(usage.tokens_in) || 0) *
++ Number(m.cost.in_per_mtok) +
++ (Number(usage.tokens_out) || 0) *
++ Number(m.cost.out_per_mtok)) /
++ 1e6,
++ )
++ .filter(Number.isFinite);
++ return decide({
++ settings,
++ demand: {
++ ...usage,
++ reseller_cost_usd: resale.length
++ ? Math.min(...resale)
++ : null,
++ },
++ quotes: observations,
++ now: clock.now(),
++ });
++ }),
++ settings: rows.map((r) => JSON.parse(r.settings)),
++ };
++ }
++ return { save, report };
++}
++module.exports = { adapter, decide, createSupply };
ddiff --git a/server/pricebook/gpus.js b/server/pricebook/gpus.js
nnew file mode 100644
iindex 0000000..3523bd5
---- /dev/null
++++ b/server/pricebook/gpus.js
@@@ -0,0 +1,48 @@
++'use strict';
++// Recorded public listings enter as bytes, like the other price-book parsers. This never calls a marketplace.
++function parseGpuQuotes(bytes) {
++ const rows = JSON.parse(String(bytes));
++ if (!Array.isArray(rows)) throw new Error('GPU listing must be an array');
++ return rows.map((r) => {
++ if (
++ !['vast', 'runpod'].includes(r.provider) ||
++ typeof r.gpu !== 'string' ||
++ typeof r.region !== 'string' ||
++ !Number.isFinite(r.hourly_usd) ||
++ r.hourly_usd <= 0 ||
++ !Number.isInteger(r.vram_mb) ||
++ r.vram_mb <= 0 ||
++ !/^https:\/\//.test(r.source) ||
++ !Number.isFinite(Date.parse(r.observed_at))
++ )
++ throw new Error('Invalid GPU price-book observation');
++ return {
++ provider: r.provider,
++ gpu: r.gpu,
++ region: r.region,
++ hourly_usd: r.hourly_usd,
++ vram_mb: r.vram_mb,
++ source: r.source,
++ observed_at: r.observed_at,
++ };
++ });
++}
++async function recordGpuQuotes(db, bytes) {
++ const rows = parseGpuQuotes(bytes);
++ for (const r of rows)
++ await db
++ .prepare(
++ 'INSERT INTO gpu_price_book(provider,gpu,region,hourly_usd,vram_mb,source,observed_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(provider,gpu,region) DO UPDATE SET hourly_usd=excluded.hourly_usd,vram_mb=excluded.vram_mb,source=excluded.source,observed_at=excluded.observed_at WHERE excluded.observed_at > gpu_price_book.observed_at',
++ )
++ .run(
++ r.provider,
++ r.gpu,
++ r.region,
++ r.hourly_usd,
++ r.vram_mb,
++ r.source,
++ r.observed_at,
++ );
++ return rows.length;
++}
++module.exports = { parseGpuQuotes, recordGpuQuotes };
ddiff --git a/server/search/adapters/commons.js b/server/search/adapters/commons.js
nnew file mode 100644
iindex 0000000..a4ad6af
---- /dev/null
++++ b/server/search/adapters/commons.js
@@@ -0,0 +1,49 @@
++'use strict';
++const plain = (s) => String(s || '').replace(/<[^>]*>/g, '');
++module.exports = {
++ id: 'commons',
++ kinds: ['images'],
++ async search(b, get) {
++ const q = new URLSearchParams({
++ action: 'query',
++ format: 'json',
++ generator: 'search',
++ gsrnamespace: '6',
++ gsrsearch: b.q + (b.format === 'svg' ? ' filetype:svg' : ''),
++ gsrlimit: String(b.limit),
++ prop: 'imageinfo',
++ iiprop: 'url|size|mime|extmetadata',
++ iiurlwidth: '400',
++ });
++ const out = await get('https://commons.wikimedia.org/w/api.php?' + q);
++ return Object.values(out.query?.pages || {}).flatMap((p) => {
++ const i = p.imageinfo?.[0];
++ if (!i) return [];
++ const m = i.extmetadata || {};
++ const license = plain(m.LicenseShortName?.value)
++ .toLowerCase()
++ .replace(/^cc /, '')
++ .replace(/ /g, '-');
++ const author = plain(m.Artist?.value);
++ return [
++ {
++ title: p.title,
++ url: i.url,
++ thumbnail: i.thumburl || i.url,
++ width: i.width,
++ height: i.height,
++ license,
++ license_url: m.LicenseUrl?.value || null,
++ author,
++ source_page: i.descriptionurl,
++ attribution: `${p.title} — ${author} (${license})`,
++ format:
++ i.mime === 'image/svg+xml'
++ ? 'svg'
++ : i.mime?.split('/')[1],
++ published: null,
++ },
++ ];
++ });
++ },
++};
ddiff --git a/server/search/adapters/iconify.js b/server/search/adapters/iconify.js
nnew file mode 100644
iindex 0000000..c8d5f2e
---- /dev/null
++++ b/server/search/adapters/iconify.js
@@@ -0,0 +1,47 @@
++'use strict';
++module.exports = {
++ id: 'iconify',
++ kinds: ['icons'],
++ async search(b, get) {
++ const out = await get(
++ 'https://api.iconify.design/search?' +
++ new URLSearchParams({
++ query: b.q,
++ limit: String(Math.max(32, b.limit)),
++ ...(b.set ? { prefix: b.set } : {}),
++ }),
++ );
++ const prefixes = [
++ ...new Set(
++ (out.icons || []).slice(0, b.limit).map((s) => s.split(':')[0]),
++ ),
++ ];
++ const info = await get(
++ 'https://api.iconify.design/collections?prefixes=' +
++ prefixes.join(','),
++ );
++ return (out.icons || []).slice(0, b.limit).map((name) => {
++ const [set, icon] = name.split(':');
++ const c = info[set] || {};
++ return {
++ name,
++ set,
++ title: name,
++ url: `https://api.iconify.design/${set}/${icon}.svg`,
++ thumbnail: `https://api.iconify.design/${set}/${icon}.svg`,
++ license: c.license?.spdx || c.license?.title || null,
++ license_url: c.license?.url || null,
++ author: c.author?.name || null,
++ source_page: c.author?.url || null,
++ attribution: `${name} — ${c.author?.name || set} (${c.license?.spdx || c.license?.title || 'licence unknown'})`,
++ format: 'svg',
++ style: /(outline|line)/.test(icon)
++ ? 'line'
++ : /(fill|solid)/.test(icon)
++ ? 'solid'
++ : 'unknown',
++ published: null,
++ };
++ });
++ },
++};
ddiff --git a/server/search/adapters/openverse.js b/server/search/adapters/openverse.js
nnew file mode 100644
iindex 0000000..95ab1a7
---- /dev/null
++++ b/server/search/adapters/openverse.js
@@@ -0,0 +1,43 @@
++'use strict';
++module.exports = {
++ id: 'openverse',
++ kinds: ['images'],
++ async search(b, get) {
++ const q = new URLSearchParams({
++ q: b.q,
++ page_size: String(b.limit),
++ mature: 'false',
++ });
++ for (const [a, k] of [
++ ['license', 'license'],
++ ['format', 'extension'],
++ ['orientation', 'aspect_ratio'],
++ ['size', 'size'],
++ ])
++ if (b[a])
++ q.set(
++ k,
++ a === 'license'
++ ? String(b[a])
++ .toLowerCase()
++ .replace(/^cc[ -]/, '')
++ : b[a],
++ );
++ if (b.commercial) q.set('license_type', 'commercial');
++ const out = await get('https://api.openverse.org/v1/images/?' + q);
++ return (out.results || []).map((r) => ({
++ title: r.title,
++ url: r.url,
++ thumbnail: r.thumbnail,
++ width: r.width,
++ height: r.height,
++ license: r.license,
++ license_url: r.license_url,
++ author: r.creator,
++ source_page: r.foreign_landing_url,
++ attribution: r.attribution,
++ format: r.filetype,
++ published: null,
++ }));
++ },
++};
ddiff --git a/server/search/adapters/openvibe.js b/server/search/adapters/openvibe.js
nnew file mode 100644
iindex 0000000..f81b772
---- /dev/null
++++ b/server/search/adapters/openvibe.js
@@@ -0,0 +1,17 @@
++'use strict';
++module.exports = {
++ id: 'openvibe',
++ kinds: ['web'],
++ async search(b, get) {
++ const out = await get(
++ 'https://openvibe.search/api/v1/search?' +
++ new URLSearchParams({ q: b.q, limit: String(b.limit) }),
++ );
++ return (out.results || []).map((r) => ({
++ title: r.title,
++ url: r.url,
++ snippet: r.snippet || r.description || '',
++ published: r.published_at || null,
++ }));
++ },
++};
ddiff --git a/server/search/adapters/searxng.js b/server/search/adapters/searxng.js
nnew file mode 100644
iindex 0000000..65701ed
---- /dev/null
++++ b/server/search/adapters/searxng.js
@@@ -0,0 +1,22 @@
++'use strict';
++module.exports = {
++ id: 'searxng',
++ kinds: ['web'],
++ setting: 'EXCHANGE_SEARXNG_URL',
++ async search(b, get, base) {
++ const url = new URL('/search', base);
++ url.search = new URLSearchParams({
++ q: b.q,
++ format: 'json',
++ safesearch: '1',
++ ...(b.freshness ? { time_range: b.freshness } : {}),
++ }).toString();
++ const out = await get(url);
++ return (out.results || []).map((r) => ({
++ title: r.title,
++ url: r.url,
++ snippet: r.content || '',
++ published: r.publishedDate || null,
++ }));
++ },
++};
ddiff --git a/server/search/index.js b/server/search/index.js
nnew file mode 100644
iindex 0000000..efa5769
---- /dev/null
++++ b/server/search/index.js
@@@ -0,0 +1,192 @@
++'use strict';
++const { guardedFetch } = require('../providers/guarded-fetch');
++const { AiError } = require('../util');
++const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify'].map(
++ (n) => require('./adapters/' + n),
++);
++const safeUrl = (s) => {
++ try {
++ const u = new URL(s);
++ return ['https:', 'http:'].includes(u.protocol) &&
++ !u.username &&
++ !u.password
++ ? u.href
++ : null;
++ } catch {
++ return null;
++ }
++};
++function createSearch({
++ fetchImpl = guardedFetch,
++ env = process.env,
++ now = () => Date.now(),
++} = {}) {
++ const health = new Map();
++ async function get(url) {
++ const r = await fetchImpl(String(url), {
++ headers: {
++ accept: 'application/json',
++ 'user-agent':
++ 'OpenVibe.Exchange/1.0 (https://openvibe.exchange)',
++ },
++ signal: AbortSignal.timeout(10000),
++ redirect: 'error',
++ });
++ if (!r.ok) throw new Error('Engine unavailable');
++ return r.json();
++ }
++ function engines() {
++ return adapters.map((a) => ({
++ id: a.id,
++ kinds: a.kinds,
++ price_per_1000: 0,
++ byok: false,
++ available: !a.setting || Boolean(env[a.setting]),
++ freshness: a.id === 'searxng' ? 'day, month, year' : 'index',
++ healthy: (health.get(a.id) || 0) <= now(),
++ }));
++ }
++ async function search(kind, b) {
++ if (typeof b.q !== 'string' || !b.q.trim() || b.q.length > 500)
++ throw new AiError(
++ 422,
++ 'ai.invalid',
++ 'q must contain 1–500 characters',
++ );
++ if (b.colour || b.color || (b.type && b.type !== 'icon'))
++ throw new AiError(
++ 422,
++ 'ai.invalid',
++ 'This filter is not supported by the configured engines',
++ );
++ if (b.type === 'icon') kind = 'icons';
++ const input = {
++ ...b,
++ q: b.q.trim(),
++ limit: Math.min(50, Math.max(1, Number(b.limit) || 10)),
++ };
++ if (b.freshness && !['day', 'month', 'year'].includes(b.freshness))
++ throw new AiError(
++ 422,
++ 'ai.invalid',
++ 'freshness must be day, month or year',
++ );
++ const candidates = engines().filter(
++ (e) =>
++ e.available &&
++ e.kinds.includes(kind) &&
++ (!b.freshness || e.id === 'searxng') &&
++ (b.engine && b.engine !== 'auto'
++ ? e.id === b.engine
++ : e.healthy),
++ );
++ if (!candidates.length)
++ throw new AiError(
++ 503,
++ 'search.unavailable',
++ 'No configured engine serves this request',
++ );
++ const start = now();
++ for (const c of candidates) {
++ try {
++ const a = adapters.find((a) => a.id === c.id);
++ let rows = await a.search(
++ input,
++ get,
++ a.setting && env[a.setting],
++ );
++ rows = rows
++ .filter((r) => safeUrl(r.url))
++ .map((r) => ({
++ ...r,
++ url: safeUrl(r.url),
++ thumbnail: safeUrl(r.thumbnail),
++ source_page: safeUrl(r.source_page),
++ engine: c.id,
++ snippet: r.snippet || '',
++ }));
++ if (b.license) {
++ const licenseId = (value) =>
++ String(value || '')
++ .toLowerCase()
++ .replace(/^cc[ -]/, '')
++ .replace(/[ -]\d+(?:\.\d+)*$/, '');
++ rows = rows.filter(
++ (r) => licenseId(r.license) === licenseId(b.license),
++ );
++ }
++ if (b.commercial)
++ rows = rows.filter(
++ (r) =>
++ r.license && !/nc|noncommercial/i.test(r.license),
++ );
++ if (b.format)
++ rows = rows.filter(
++ (r) =>
++ r.format === b.format ||
++ new URL(r.url).pathname
++ .toLowerCase()
++ .endsWith('.' + b.format),
++ );
++ if (b.orientation)
++ rows = rows.filter((r) =>
++ b.orientation === 'square'
++ ? r.width === r.height
++ : b.orientation === 'portrait'
++ ? r.height > r.width
++ : r.width > r.height,
++ );
++ if (b.style && b.style !== 'any')
++ rows = rows.filter((r) => r.style === b.style);
++ return {
++ results: rows.slice(0, input.limit),
++ receipt: {
++ engine: c.id,
++ cost_usd: 0,
++ time_ms: now() - start,
++ },
++ };
++ } catch {
++ health.set(c.id, now() + 60000);
++ }
++ }
++ throw new AiError(
++ 503,
++ 'search.unavailable',
++ 'The selected engines did not answer',
++ );
++ }
++ async function read(url) {
++ if (!safeUrl(url) || !String(url).startsWith('https://'))
++ throw new AiError(422, 'ai.invalid', 'An HTTPS URL is required');
++ const start = now();
++ const r = await fetchImpl(url, {
++ signal: AbortSignal.timeout(10000),
++ redirect: 'error',
++ headers: { accept: 'text/plain,text/html' },
++ });
++ if (!r.ok)
++ throw new AiError(
++ 502,
++ 'search.fetch_failed',
++ 'The page did not answer',
++ );
++ const text = await r.text();
++ return {
++ url,
++ text: text
++ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
++ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/[ \t]+/g, ' ')
++ .slice(0, 200000),
++ receipt: {
++ engine: 'openvibe-egress',
++ cost_usd: 0,
++ time_ms: now() - start,
++ },
++ };
++ }
++ return { engines, search, read };
++}
++module.exports = { createSearch };
ddiff --git a/test/exchange-platform.test.js b/test/exchange-platform.test.js
nnew file mode 100644
iindex 0000000..30ee1d0
---- /dev/null
++++ b/test/exchange-platform.test.js
@@@ -0,0 +1,149 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { boot } = require('./helpers');
++const { createKeys } = require('../server/platform/keys');
++const { createSearch } = require('../server/search');
++const { decide, adapter } = require('../server/platform/supply');
++(async () => {
++ const h = await boot();
++ try {
++ const keys = createKeys({ db: h.db });
++ const made = await keys.create('alice', {
++ name: 'test',
++ monthly_cap_usd: 10,
++ allowed_models: ['model'],
++ });
++ assert.ok(made.secret.startsWith('ovx_'));
++ assert.equal(
++ JSON.stringify(await keys.list('alice')).includes(made.secret),
++ false,
++ );
++ assert.equal((await keys.list('bob')).length, 0);
++ const key = await keys.resolve(made.secret);
++ assert.ok(key);
++ const publicModels = await fetch(h.base + '/v1/models', { headers: { authorization: 'Bearer ' + made.secret } });
++ assert.equal(publicModels.status, 200);
++ const manage = await fetch(h.base + '/api/v1/exchange/keys', { headers: { authorization: 'Bearer ' + made.secret } });
++ assert.equal(manage.status, 403);
++
++ await assert.rejects(
++ keys.reserve(key, 'other'),
++ (e) => e.status === 403,
++ );
++ // Calls run side by side: each holds its own ceiling ($1 by default), and holds add up to the cap.
++ const holds = await Promise.all([keys.reserve(key, 'model'), keys.reserve(key, 'model')]);
++ assert.deepEqual(holds.map((x) => x.amount), [1, 1]);
++ await holds[0].settle({ cost_usd: 0.25, tokens_in: 5, tokens_out: 6 });
++ await holds[1].settle({}); // the cost never came (the client hung up): the hold is charged
++ // A call's own max_cost_usd is its hold, never more than what is left: 10 - 0.25 - 1 = 8.75.
++ const big = await keys.reserve(key, 'model', false, 50);
++ assert.equal(big.amount, 8.75);
++ await assert.rejects(keys.reserve(key, 'model'), (e) => e.status === 429, 'the cap is fully held');
++ await big.settle({ cost_usd: 0.5 });
++ const after = await keys.reserve(key, 'model', false, 100);
++ assert.equal(after.amount, 8.25, 'the unused hold came back');
++ await after.settle({ cost_usd: 8.25 });
++ await assert.rejects(keys.reserve(key, 'model'), (e) => e.status === 429, 'the cap is spent');
++ await assert.rejects(
++ keys.revoke('bob', key.id),
++ (e) => e.status === 404,
++ );
++ await keys.revoke('alice', key.id);
++ assert.equal(await keys.resolve(made.secret), undefined);
++ const search = createSearch({
++ fetchImpl: async (url) =>
++ new Response(
++ JSON.stringify({
++ results: [
++ {
++ title: 'Example',
++ url: 'https://example.org/a.jpg',
++ license: 'cc0',
++ creator: 'A',
++ foreign_landing_url: 'https://example.org',
++ attribution: 'A CC0',
++ },
++ { title: 'bad', url: 'javascript:alert(1)' },
++ ],
++ }),
++ { status: 200 },
++ ),
++ });
++ const results = await search.search('images', {
++ q: 'cat',
++ engine: 'openverse',
++ commercial: true,
++ });
++ assert.equal(results.results.length, 1);
++ assert.equal(results.receipt.cost_usd, 0);
++ assert.equal(results.results[0].attribution, 'A CC0');
++ await assert.rejects(
++ search.search('web', { q: 'x', engine: 'searxng' }),
++ (e) => e.status === 503,
++ );
++ await assert.rejects(
++ search.read('http://localhost'),
++ (e) => e.status === 422,
++ );
++ const settings = {
++ model: 'm',
++ required_vram_mb: 24000,
++ input_per_million: 1,
++ output_per_million: 2,
++ hourly_ceiling_usd: 1,
++ monthly_ceiling_usd: 1000,
++ runtime: 'vllm',
++ idle_minutes: 10,
++ };
++ const d = decide({
++ settings,
++ demand: { tokens_in: 1e6, tokens_out: 1e6, reseller_cost_usd: 30 },
++ quotes: [
++ {
++ hourly_usd: 0.5,
++ measured_tokens_per_second: 50,
++ vram_mb: 24000,
++ observed_at: new Date().toISOString(),
++ },
++ ],
++ });
++ assert.equal(d.action, 'would_rent');
++ assert.equal(d.dry_run, true);
++ assert.equal(
++ decide({ settings, demand: {}, quotes: [] }).action,
++ 'would_stop',
++ );
++ for (const p of ['vast', 'runpod']) {
++ await assert.rejects(
++ adapter(p).rent(),
++ (e) => e.code === 'gpu.owner_gate',
++ );
++ await assert.rejects(
++ adapter(p, {
++ key: 'test',
++ hourlyCeiling: 1,
++ monthlyCeiling: 1,
++ }).stop(),
++ (e) => e.code === 'gpu.dry_run',
++ );
++ }
++ const { createSupply } = require('../server/platform/supply');
++ const { recordGpuQuotes } = require('../server/pricebook/gpus');
++ const supply = createSupply({db:h.db,registry:h.registry});
++ await supply.save(settings,'owner');
++ await recordGpuQuotes(h.db,JSON.stringify([{provider:'vast',gpu:'RTX 4090',region:'US',hourly_usd:.5,vram_mb:24000,source:'https://vast.ai',observed_at:new Date().toISOString()}]));
++ const report=await supply.report();
++ assert.equal(report.dry_run,true);
++ assert.equal(report.price_book.length,1);
++ assert.equal(report.reports[0].action,'would_stop');
++ await assert.rejects(createSearch().read('https://127.0.0.1'),/IP-literal/);
++ console.log(
++ 'Exchange key isolation, concurrent caps, revocation, search normalization and dry-run gates passed',
++ );
++ } finally {
++ await h.stop();
++ }
++})().catch((e) => {
++ console.error(e);
++ process.exitCode = 1;
++});