openvibers/openvibe.ai Commits
Add Exchange keys, free search and dry-run GPU supply planning (#379)
Changes
nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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)++);@@@ -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@@@ -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);@@@ -98,11 +98,21 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysapp.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.@@@ -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,@@@ -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;}@@@ -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.',nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++ },++ ];++ });++ },++};nnew file mode 100644@@@ -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,++ };++ });++ },++};nnew file mode 100644@@@ -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,++ }));++ },++};nnew file mode 100644@@@ -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,++ }));++ },++};nnew file mode 100644@@@ -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,++ }));++ },++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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;++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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)
+);
@@ -26,6 +26,8 @@ const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type'
@@ -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 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 = [
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: '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: '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',
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
@@ -85,11 +87,14 @@ const TABLES = [
@@ -85,11 +87,14 @@ const TABLES = [
/** The runs AI made for the person, with their input and their answer. */
/** The runs AI made for the person, with their input and their answer. */
async function extraExport(db, subject) {
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]);
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). */
/** 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) {
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
// 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).
// 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
counts.add(counts.erased, 'own_endpoints', await t.exec(`UPDATE own_endpoints
@@ -11,6 +11,7 @@ const poolSeed = require('../providers/pool-seed');
@@ -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. */
/** 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 }) {
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 r = express.Router();
+ const supply = require('../platform/supply').createSupply({ db, registry, clock });
const json = express.json({ limit: '64kb' });
const json = express.json({ limit: '64kb' });
const read = auth.requireCap(CAPS.usageRead);
const read = auth.requireCap(CAPS.usageRead);
const write = auth.requireCap(CAPS.providerManage);
const write = auth.requireCap(CAPS.providerManage);
@@ -25,6 +26,7 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -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) => {
r.get('/api/v1/admin/console/:section', read, wrap(async (req) => {
const section = req.params.section;
const section = req.params.section;
+ if (section === 'supply') return supply.report();
if (section === 'overview') {
if (section === 'overview') {
const since = new Date(clock.now() - 86400000).toISOString();
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() };
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,
@@ -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');
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 });
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) => {
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');
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);
const provider = await registry.getProvider(req.params.key);
@@ -98,11 +98,21 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -98,11 +98,21 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
// GET /openapi.json: the public OpenAPI 3.1 document (server/openapi.js); /api/v1/openapi.json redirects here.
openapi.mount(app);
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
// 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, quotas, env, clock, presets, receipts, auth, log, ownEndpoints }));
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
// 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.
// 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 }));
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
// 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.
// seller's developer key (the design's SDK gap). Its offers join the community router's trust and ledger.
@@ -211,6 +211,8 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -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.
// up | degraded | down | exhausted (out of balance), from the router's breaker.
status: statusOf(provider.key),
status: statusOf(provider.key),
// Rounded to the micro-dollar: a derived cached price was printed as 0.020000000000000004.
// 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,
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.
// 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,
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: (
@@ -238,6 +240,7 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
variant: (row.metadata && row.metadata.variant) || null,
variant: (row.metadata && row.metadata.variant) || null,
uncensored: UNCENSORED.test(`${canonical} ${(row.metadata && row.metadata.variant) || ''}`) || Boolean(row.metadata && row.metadata.uncensored === true),
uncensored: UNCENSORED.test(`${canonical} ${(row.metadata && row.metadata.variant) || ''}`) || Boolean(row.metadata && row.metadata.uncensored === true),
type: row.type,
type: row.type,
+ open_weights: row.metadata?.open_weights === true,
context: num(row.context_window),
context: num(row.context_window),
max_output: num(row.max_output),
max_output: num(row.max_output),
capabilities: capabilitiesOf(row),
capabilities: capabilitiesOf(row),
@@ -259,7 +262,17 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -259,7 +262,17 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
// else the answering attempt's total latency.
// else the answering attempt's total latency.
const measured = await seen.read({ days });
const measured = await seen.read({ days });
observed.fold(measured, drafts);
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()];
let models = [...groups.values()];
if (kind) models = models.filter((m) => m.kind === kind);
if (kind) models = models.filter((m) => m.kind === kind);
for (const m of models) {
for (const m of models) {
@@ -272,6 +285,9 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -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.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_7d = m.offers.reduce((n, o) => n + o.cached_7d, 0);
m.cached_today = m.offers.reduce((n, o) => n + o.cached_today, 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.community = m.offers.some((o) => o.trust === 'community');
m.priced = m.offers.some((o) => o.price);
m.priced = m.offers.some((o) => o.price);
// Free right now: a free-tier offer whose provider can answer (not exhausted, not down).
// 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: (
@@ -295,6 +311,7 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
}
}
return {
return {
generated_at: new Date(now).toISOString(),
generated_at: new Date(now).toISOString(),
+ median_ttft_ms: firstTokens.length >= 20 ? firstTokens[Math.floor(firstTokens.length/2)] : null,
window_days: days,
window_days: days,
...(kind ? { kind } : {}),
...(kind ? { kind } : {}),
count: models.length,
count: models.length,
@@ -419,6 +419,8 @@ function createGpuProvisioner({
@@ -419,6 +419,8 @@ function createGpuProvisioner({
async function register(spec, row) {
async function register(spec, row) {
const base = String(row.endpoint || '').replace(/\/+$/, '') + '/v1';
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');
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 price = money.usdPerMillionTokens(toNano(row.hourly_nano), spec.throughput_tokens_per_hour);
const gpu = {
const gpu = {
marketplace: row.marketplace,
marketplace: row.marketplace,
@@ -444,7 +446,7 @@ function createGpuProvisioner({
@@ -444,7 +446,7 @@ function createGpuProvisioner({
priority: 40,
priority: 40,
billing_profile: 'metered',
billing_profile: 'metered',
provider_access: 'OWN_DEPLOYMENT',
provider_access: 'OWN_DEPLOYMENT',
- metadata: { gpu },
+ metadata: { gpu, owner_fixed_price: Boolean(fixed), open_weights: true },
}, { actor: 'service:ai', origin: 'gpu' });
}, { actor: 'service:ai', origin: 'gpu' });
await registry.upsertModel({
await registry.upsertModel({
provider_key: spec.provider_key,
provider_key: spec.provider_key,
@@ -454,14 +456,14 @@ function createGpuProvisioner({
@@ -454,14 +456,14 @@ function createGpuProvisioner({
status: 'active',
status: 'active',
context_window: spec.context_window,
context_window: spec.context_window,
cost: {
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),
verified_at: iso(now()).slice(0, 10),
},
},
supports: { json: false, tools: false, streaming: true, vision: false },
supports: { json: false, tools: false, streaming: true, vision: false },
- metadata: { gpu },
+ metadata: { gpu, owner_fixed_price: Boolean(fixed), open_weights: true },
}, { actor: 'service:ai' });
}, { actor: 'service:ai' });
return base;
return base;
}
}
@@ -468,6 +468,43 @@ route('get', '/exchange/endpoints', {
@@ -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()) } }),
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', {
route('get', '/own-endpoints', {
operationId: 'listOwnEndpoints', summary: 'The caller\'s private endpoints', tags: ['exchange'], security: [{ bearer: [] }],
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.',
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.',
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,196 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,241 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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,
+ },
+ ];
+ });
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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,
+ };
+ });
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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,
+ }));
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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,
+ }));
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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,
+ }));
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -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;
+});