openvibers/openvibe.ai Commits

Add Exchange search engines, BYOK routing and receipts (#399)

OP OpenVibers committed 4 days ago · 1209b38cb4 · 61 files, +1486 −212

* Add Exchange search engines with BYOK routing and receipts

* Merge main and reconcile search holds with expiry and idempotent inference

* Merge latest main workflow and shared library updates

Changes

ddiff --git a/README.md b/README.md
iindex f03e046..c88fea9 100644
---- a/README.md
++++ b/README.md
@@@ -1084,3 +1084,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
- openvibe-sdk: v0.125.0
- openvibe-shared: v4.175.0
<!-- versions:end -->
++
++### Exchange search engines
++
++The web, image, icon and fetch APIs, provider configuration, verified tariffs, personal keys and receipts are documented in [docs/search-engines.md](docs/search-engines.md). `GET /api/v1/exchange/search/engines` publishes the current adapter catalog; `/openapi.json` describes all four search/fetch endpoints.
ddiff --git a/docs/search-engines.md b/docs/search-engines.md
nnew file mode 100644
iindex 0000000..fc30b5e
---- /dev/null
++++ b/docs/search-engines.md
@@@ -0,0 +1,58 @@
++# Exchange search engines
++
++`POST /v1/search`, `/v1/search/images`, `/v1/search/icons` and `/v1/fetch` use the existing Exchange key, person, service or app authentication. The routes and JSON schemas are in `/openapi.json`. Search responses validate against `exchange.search-result@1` and `exchange.image-result@1`. Main already pins Contracts **0.372.0**, which includes the 0.371.0 search contracts and preserves the subsequent Link changes; this PR keeps that newer pin.
++
++```json
++{"q":"solar lantern","engine":"auto","freshness":"month","limit":10,"max_cost_usd":0.01}
++```
++
++Search requires a nonblank query of up to 500 characters (DeepSeek supports 128, including its date operator). Limit is 1–50; provider result limits may be lower. Freshness is `day`, `month` or `year`; auto considers only engines that support it. Image filters include `license`, `commercial`, `size`, `orientation`, `format`, `type: "icon"`, icon `set` and `style`. Size uses the longest side: small below 800px, medium 800–1999px, large at least 2000px. Unknown dimensions or licences never satisfy corresponding filters. Colour filtering remains unsupported.
++
++Every response has `receipt: {engine, cost_usd, time_ms}`. Cost is the provider cost in USD, and time includes fallback attempts. A personal key has **zero Exchange charge and zero fee**, even though the receipt still shows the upstream cost. Shared-key cost is settled against the Exchange key's monthly cap; concurrent searches reserve their potential costs before calling. `max_cost_usd` caps provider cost for either key source. Free allowances and volume discounts are excluded from the baseline tariff. A timeout or malformed successful response can already have cost money: its ceiling is conservatively counted before any fallback; known HTTP rejections cost zero. If all attempts fail, the known/conservative charge still settles. No test or key-save operation makes a paid call.
++
++Auto first selects the authenticated person's eligible stored keys, then sorts by price and skips unhealthy engines. Without a personal key, it sorts configured shared engines by price. A failure cools that engine/key down for 60 seconds; a person's bad key cannot poison shared-engine health. Once personal keys are selected, failure never switches to shared keys. An explicit engine may be retried during cooldown. Free engines are therefore usually preferred for unrestricted shared-key searches; select an engine explicitly to compare providers.
++
++## Configuration and prices
++
++Prices checked **2026-11-09**, USD per 1,000 requests unless qualified. `GET /api/v1/exchange/search/engines` exposes availability of shared keys, BYOK capability, supported freshness, health, tariff, source and check date. A keyed adapter is unavailable without its own key. Provider accounts and keys must already exist. The new migration only widens the existing encrypted credential store's provider allowlist.
++
++| Engine ID | Shared key environment variable | Baseline tariff | Public source |
++|---|---|---|---|
++| deepseek | SEARCH_DEEPSEEK_KEY | $0.75 search; $0.75 fetch; empty searches are free | [DeepSeek](https://api-docs.deepseek.com/guides/search/) |
++| brave | SEARCH_BRAVE_KEY | $5 | [Brave](https://api-dashboard.search.brave.com/app/plans) |
++| exa | SEARCH_EXA_KEY | $7 auto search with up to 10 results; $1 per 1,000 additional results | [Exa](https://exa.ai/docs/admin/pricing) |
++| tavily | SEARCH_TAVILY_KEY | $8 basic, one credit; pay as you go | [Tavily](https://www.tavily.com/pricing) |
++| serper | SEARCH_SERPER_KEY | $1, starter prepaid $50/50,000 pack | [Serper](https://serper.dev/) |
++| serpapi | SEARCH_SERPAPI_KEY | $25, starter $25/month for 1,000 | [SerpAPI](https://serpapi.com/pricing) |
++| jina | SEARCH_JINA_KEY | Search from $0.50; reader $0.05/M tokens | [Jina Reader](https://jina.ai/reader/#apiform), [public product table](https://dash.jina.ai/api/v1/product) |
++| perplexity | SEARCH_PERPLEXITY_KEY | No flat answer price: Sonar $0.25/M input, $2.50/M output, $0.0625/M cached; web search $2.50/1,000 tool calls | [pricing](https://docs.perplexity.ai/docs/getting-started/pricing), [Sonar model rates](https://docs.perplexity.ai/docs/agent-api/models) |
++| kagi | SEARCH_KAGI_KEY | $25 on the documented legacy v0 API | [Kagi](https://help.kagi.com/kagi/api/search-legacy.html) |
++| google | SEARCH_GOOGLE_KEY | $5 after the daily free allowance; also requires SEARCH_GOOGLE_CX | [Google](https://developers.google.com/custom-search/v1/overview) |
++| unsplash | SEARCH_UNSPLASH_KEY | $0 | [Unsplash](https://unsplash.com/developers) |
++| pexels | SEARCH_PEXELS_KEY | $0 | [Pexels](https://www.pexels.com/api/) |
++| pixabay | SEARCH_PIXABAY_KEY | $0 | [Pixabay](https://pixabay.com/api/docs/) |
++| brave-images | SEARCH_BRAVE_IMAGES_KEY | $5 | [Brave](https://api-dashboard.search.brave.com/app/plans) |
++
++Jina's current displayed $50/1B token pack is $0.05/M; search starts at 10,000 tokens. Receipts use returned usage, with a 100,000-token request budget ($0.005 ceiling). Perplexity uses the current Agent API with `perplexity/sonar`, web search enabled, at most three steps and 1,024 output tokens; its conservative admission ceiling is $0.10. The receipt uses `usage.cost.total_cost`. Its answer is retained in the snippet of each returned source. Google is closed to new customers and ends 2027-01-01; Pexels currently pauses new key issuance. Kagi uses the still-documented v0 endpoint and its corresponding tariff.
++
++The existing free engines remain: Openverse, Wikimedia Commons, Iconify, OpenVibe Search, and operator-configured SearXNG (`EXCHANGE_SEARXNG_URL`). Free direct fetching is `openvibe-egress`. Each adapter provides its own metadata instead of the router inventing a zero price or disabling BYOK.
++
++## Personal keys
++
++Use the same person authentication and origin rules as `/api/v1/me/credentials`:
++
++- `PUT /api/v1/me/search-credentials/:engine` with `{"key":"<your existing engine key>"}` stores/replaces a key without testing it or spending a search.
++- `GET /api/v1/me/search-credentials` lists stored engine IDs and update dates, never keys.
++- `DELETE /api/v1/me/search-credentials/:engine` removes it.
++
++Keys use the existing AES-256-GCM store and encryption configuration, bound to `self|subject|search-engine`. Brave web/images share the personal `brave` credential. Search keys are excluded from model credential listings/routing. A service/app cannot choose somebody else's key by submitting `subject` or `on_behalf_of`; only an authenticated person or their Exchange key reaches their own search keys. Secret-bearing management routes stay outside the public OpenAPI document, following the existing credential-route rule.
++
++## Images and fetch
++
++Image rows carry a source page, thumbnail, full image URL, width/height where supplied, licence, licence URL, author and attribution. Missing rights/creator data is null, especially for Brave's indexed images. Preserve provider attribution and links; Unsplash also returns `author_url` and `download_location` for consumers to implement its required download tracking when a photo is used. Image URLs are returned, never fetched during search.
++
++Fetch accepts `{"url":"https://example.org/article","engine":"auto"}`; choose `deepseek`, `jina` or `openvibe-egress` explicitly. Auto prefers an eligible personal reader key, otherwise free local egress. The local reader returns stripped text; Jina/DeepSeek return Markdown. Output is capped at 200,000 characters.
++
++Every provider API call uses a module-owned host allowlist, HTTPS, disabled redirects, a deadline, and the existing guarded fetcher (public addresses checked at connect time, 8 MiB response limit). Only `/v1/fetch` accepts a target URL; remote-reader targets pass the same public DNS/address guard before submission. Remote fetch redirects and page execution take place at the provider and remain subject to that provider's security rules. SearXNG's fixed origin is operator configuration, never request input. Keys, upstream error bodies and credential-bearing query strings are never returned in errors.
++
++`ov test search-adapters search-routing exchange-platform openapi me-credentials credentials ssrf` uses saved synthetic answers in `test/fixtures/search/` and injected transports; it needs no provider accounts, keys or internet access.
ddiff --git a/migrations/0055_search_credentials.sql b/migrations/0055_search_credentials.sql
nnew file mode 100644
iindex 0000000..3898ade
---- /dev/null
++++ b/migrations/0055_search_credentials.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- Search keys reuse the provider-bound encrypted store, in a namespace excluded from model routing.
++ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;
++ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check
++ CHECK (provider IN ('openai', 'anthropic', 'node', 'link', 'deepseek', 'minimax', 'openrouter', 'custom',
++ 'gemini', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks',
++ 'search-deepseek', 'search-brave', 'search-exa', 'search-tavily', 'search-serper',
++ 'search-serpapi', 'search-jina', 'search-perplexity', 'search-kagi', 'search-google',
++ 'search-unsplash', 'search-pexels', 'search-pixabay'));
ddiff --git a/server/app.js b/server/app.js
iindex 24a7ebc..bc0b268 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -22,7 +22,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {
++function createApp({ searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, abuse = null, priceBook = null, ownEndpoints = null, refusal = null, batches = null }) {
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@@ -104,7 +104,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
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 });
++ const exchangePlatform = require('./platform/router').platform({ db, auth, clock, log, credentials, env, searchFetch });
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
ddiff --git a/server/credentials.js b/server/credentials.js
iindex 469331c..24a80d2 100644
---- a/server/credentials.js
++++ b/server/credentials.js
@@@ -375,7 +375,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
/** Every self row for one person, newest first. */
async function selfRows(subject) {
if (!SUBJECT_RE.test(String(subject))) return [];
-- return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'link' ORDER BY provider").all(SELF_OWNER, subject);
++ return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'link' AND provider NOT LIKE 'search-%' ORDER BY provider").all(SELF_OWNER, subject);
}
async function listSelf(subject) {
return (await selfRows(subject)).map(selfView);
@@@ -595,8 +595,46 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return (await grants.allows(subject, owner, provider)) ? { service: owner, subject, provider } : null;
}
++ // Search providers share the existing encrypted owner/subject/provider store. A distinct
++ // namespace keeps the model credential contracts and model routing unchanged.
++ function searchProvider(engine) {
++ const { adapters, credentialId } = require('./search');
++ const a = adapters.find(a => a.id === engine && a.byok);
++ if (!a) throw new AiError(404, 'credential.not_found', 'Unknown search engine');
++ return 'search-' + credentialId(a);
++ }
++ async function putSearch(subject, engine, body) {
++ if (!SUBJECT_RE.test(String(subject))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');
++ const provider = searchProvider(engine);
++ if (!body || typeof body.key !== 'string' || body.key.length < 8 || body.key.length > 400 || Object.keys(body).some(k => k !== 'key'))
++ throw new AiError(422, 'input.invalid', 'A key of 8–400 characters is required');
++ const at = iso(clock.now());
++ await db.prepare(`INSERT INTO subject_credentials
++ (owner, subject, provider, key_enc, key_hint, models, is_default, spend_key, status, models_count, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, '{}', false, ?, 'untested', 0, ?, ?)
++ ON CONFLICT(owner, subject, provider) DO UPDATE SET key_enc = excluded.key_enc,
++ key_hint = excluded.key_hint, status = 'untested', updated_at = excluded.updated_at`)
++ .run(SELF_OWNER, subject, provider, seal(body.key, aadFor(SELF_OWNER, subject, provider)), hintOf(body.key), providerKeyFor(SELF_OWNER, subject, provider), at, at);
++ // Saving a search key never spends a query to test it.
++ return { engine, stored: true };
++ }
++ async function listSearch(subject) {
++ return (await db.prepare("SELECT provider, updated_at FROM subject_credentials WHERE owner = ? AND subject = ? AND provider LIKE 'search-%'").all(SELF_OWNER, subject))
++ .map(r => ({ engine: r.provider.slice(7), updated_at: r.updated_at }));
++ }
++ async function removeSearch(subject, engine) {
++ await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, searchProvider(engine));
++ return { engine, removed: true };
++ }
++ async function searchForRun(subject, engine) {
++ const r = await getRow(SELF_OWNER, subject, searchProvider(engine));
++ if (!r) return null;
++ return { apiKey: await openRow(r) };
++ }
++
return {
put, get, remove, admit, forRun, spentToday, providerKeyFor,
++ putSearch, listSearch, removeSearch, searchForRun,
listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, balanceSelf, markFailed, getSelfNode, putSelfNode, removeSelfNode,
allowedProvidersWithKey, consentUse,
};
ddiff --git a/server/index.js b/server/index.js
iindex d02ba44..7218de2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null } = {}) {
++async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null } = {}) {
config = config || load(env);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@@ -203,7 +203,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });
++ const app = createApp({ searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, abuse, priceBook, ownEndpoints, refusal, batches });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
ddiff --git a/server/me.js b/server/me.js
iindex 34d0bd6..867061c 100644
---- a/server/me.js
++++ b/server/me.js
@@@ -127,6 +127,18 @@ function meRouter({ credentials, preferences, grants, auth, registry, config, cl
try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }
};
++ r.get('/api/v1/me/search-credentials', guard, run(async req => ({ engines: await credentials.listSearch(personOf(req)) })));
++ r.put('/api/v1/me/search-credentials/:engine', guard, run(async req => {
++ const out = await credentials.putSearch(personOf(req), req.params.engine, req.body);
++ await audit(req, 'credential.search.put', { engine: out.engine });
++ return out;
++ }));
++ r.delete('/api/v1/me/search-credentials/:engine', guard, run(async req => {
++ const out = await credentials.removeSearch(personOf(req), req.params.engine);
++ await audit(req, 'credential.search.delete', { engine: out.engine });
++ return out;
++ }));
++
r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: [...(await credentials.listSelf(personOf(req))).map((c) => ({ ...c, id: c.provider, kind: 'api_key', label: c.provider, created: c.created_at, last_used: c.last_used_at || null })), ...(sessionCookies ? await sessionCookies.list('self', personOf(req)) : [])] })));
if (sessionCookies) {
r.post('/api/v1/me/credentials', guard, run(async (req) => {
ddiff --git a/server/openapi.js b/server/openapi.js
iindex c063dad..5d3cbc1 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -21,6 +21,7 @@
* server/runs.js decode(). Nothing is inferred.
*/
const pkg = require('../package.json');
++const { paths: searchPaths } = require('./search/openapi');
const MOUNT = '/api/v1';
// The public base of this API (BASE_URL in production).
@@@ -505,7 +506,7 @@ route('get', '/exchange/key-usage', {
});
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.',
++ description: 'Public. Engine IDs, kinds (web, images, icons, fetch), availability of shared keys, BYOK support, health, supported freshness filters, price_per_1000 in USD (null when variable), pricing_note, source URL and checked_on date. Personal key availability is private. Tariffs exclude free allowances and volume discounts.',
responses: { 200: json('The engines', { type: 'object', required: ['engines'], properties: { engines: arrayOf({ type: 'object' }) } }) },
});
route('get', '/own-endpoints', {
@@@ -867,7 +868,7 @@ function openapi() {
} },
},
},
-- paths: { ...Object.fromEntries(Object.entries(PATHS).sort(([a], [b]) => a.localeCompare(b)).map(([p, item]) => [MOUNT + p, item])) },
++ paths: { ...searchPaths, ...Object.fromEntries(Object.entries(PATHS).sort(([a], [b]) => a.localeCompare(b)).map(([p, item]) => [MOUNT + p, item])) },
};
}
@@@ -886,7 +887,7 @@ route('get', '/community/sell', {
/** The documented routes as "METHOD /api/v1/path": what the app must mount. */
function routes() {
-- return Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)).sort();
++ return [...Object.entries(PATHS).flatMap(([p, item]) => Object.keys(item).map((method) => `${method.toUpperCase()} ${MOUNT}${p}`)), ...Object.entries(searchPaths).flatMap(([p, item]) => Object.keys(item).map(method => `${method.toUpperCase()} ${p}`))].sort();
}
/** Serve GET /openapi.json here; GET /api/v1/openapi.json redirects to it (308). */
ddiff --git a/server/platform/keys.js b/server/platform/keys.js
iindex 73f4ce3..08cbe37 100644
---- a/server/platform/keys.js
++++ b/server/platform/keys.js
@@@ -85,16 +85,16 @@ function createKeys({ db, clock = { now: () => Date.now() } }) {
* 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.
++ * hung up) and gives the rest of the hold back. Free search calls take no hold; paid search holds its ceiling. Search skips the model list.
*/
-- async function reserve(key, model, free = false, limit = null) {
++ async function reserve(key, model, free = false, limit = null, search = false) {
// Re-read the credential: a previously resolved key may have expired or been revoked.
const active = await db.prepare(
'SELECT id FROM exchange_api_keys WHERE id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)',
).get(key.id, iso());
if (!active) throw new AiError(401, 'token.invalid', 'Invalid, expired or revoked Exchange key');
const allowed = JSON.parse(key.allowed_models);
-- if (!free && allowed.length && !allowed.includes(model))
++ if (!free && !search && 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),
ddiff --git a/server/platform/router.js b/server/platform/router.js
iindex c1efa09..dd914a2 100644
---- a/server/platform/router.js
++++ b/server/platform/router.js
@@@ -4,12 +4,12 @@ const { CAPS } = require('../auth');
const { AiError, sendError } = require('../util');
const { createKeys } = require('./keys');
const { claimRequest } = require('./idempotency');
--const { createSearch } = require('../search');
--function platform({ db, auth, clock, log, searchFetch }) {
++const { createSearch, CHARGE } = require('../search');
++function platform({ db, auth, clock, log, searchFetch, credentials, env = process.env }) {
const keys = createKeys({ db, clock });
const search = createSearch({
...(searchFetch ? { fetchImpl: searchFetch } : {}),
-- now: () => clock.now(),
++ now: () => clock.now(), credentials, env,
});
const r = express.Router();
const wrap = (fn) => async (req, res) => {
@@@ -18,6 +18,7 @@ function platform({ db, auth, clock, log, searchFetch }) {
const out = await fn(req, res);
if (out !== undefined) res.json(out);
} catch (e) {
++ if (e[CHARGE] != null) res.locals.searchCharge = e[CHARGE];
sendError(res, e, req.ov, log);
}
};
@@@ -54,6 +55,10 @@ function platform({ db, auth, clock, log, searchFetch }) {
person,
wrap((req) => keys.usage(owner(req))),
);
++ const searchContext = req => ({
++ subject: req.principal?.kind === 'user' ? req.principal.subject.id : null,
++ plan: req.searchPlan, budget: req.searchBudget,
++ });
r.get('/api/v1/exchange/search/engines', (_req, res) =>
res.json({ engines: search.engines() }),
);
@@@ -65,10 +70,12 @@ function platform({ db, auth, clock, log, searchFetch }) {
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)
++ if (entry.count >= 60) {
++ res.locals.searchCharge = 0;
return res
.status(429)
.json({ error: 'Search limit reached; try again in a minute' });
++ }
entry.count++;
searchWindows.set(id, entry);
next();
@@@ -78,13 +85,13 @@ function platform({ db, auth, clock, log, searchFetch }) {
'/v1/search' + (kind === 'web' ? '' : '/' + kind),
allow,
searchLimit,
-- wrap(async (req) => search.search(kind, req.body || {})),
++ wrap(async (req) => search.search(kind, req.body || {}, searchContext(req))),
);
r.post(
'/v1/fetch',
allow,
searchLimit,
-- wrap((req) => search.read(req.body?.url)),
++ wrap((req) => search.read(req.body || {}, searchContext(req))),
);
// Exchange keys only enter inference and the search surface. They never authorize management routes.
async function keyMiddleware(req, res, next) {
@@@ -124,16 +131,25 @@ function platform({ db, auth, clock, log, searchFetch }) {
throw new AiError(403, 'exchange.model_denied', 'This model is not allowed by this key');
if (inference && req.get('idempotency-key'))
claim = await claimRequest(db, key.owner, req.get('idempotency-key'), req.body);
-- const hold = models || claim?.replay
-- ? 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 (isSearch) {
++ const kind = req.path === '/v1/fetch' ? 'fetch' : req.path.split('/')[3] || 'web';
++ req.searchPlan = await search.prepare(kind, req.body || {}, { subject: key.owner });
++ }
++ const searchCeiling = req.searchPlan?.ceiling || 0;
++ const hold = models || claim?.replay ? null : await keys.reserve(
++ key, isSearch ? 'search:' + req.path : String(req.body?.model || ''),
++ isSearch && searchCeiling === 0,
++ isSearch ? searchCeiling : Number.isFinite(req.body?.max_cost_usd) ? req.body.max_cost_usd : null,
++ isSearch,
++ );
++ if (isSearch) {
++ req.searchBudget = hold.amount;
++ const minimum = Math.min(...req.searchPlan.candidates.map(c => c.own ? 0 : c.ceiling));
++ if (hold.amount + 1e-12 < minimum) {
++ await hold.settle({ cost_usd: 0 });
++ throw new AiError(429, 'exchange.key_cap', 'The remaining key cap cannot cover this search');
++ }
++ }
if (inference && !(claim?.replay && claim.maxCost === null)) {
req.body.max_cost_usd = claim?.replay ? claim.maxCost : Math.min(
hold.amount,
@@@ -183,9 +199,10 @@ function platform({ db, auth, clock, log, searchFetch }) {
}
const usage = result?.usage || {};
const cost =
-- (isSearch || res.getHeader('x-openvibe-replayed') === '1') && res.statusCode < 400
-- ? 0
-- : result?.x_openvibe?.cost_usd;
++ isSearch ? result?.[CHARGE] ?? res.locals.searchCharge
++ : res.getHeader('x-openvibe-replayed') === '1' && res.statusCode < 400
++ ? 0 : result?.x_openvibe?.cost_usd;
++ // Search uses only its own charge, never inference metadata.
// An early disconnect can precede run insertion. Keep its claim
// until a response finishes so a retry cannot take another hold.
hold.settle({
ddiff --git a/server/search/adapters/brave-images.js b/server/search/adapters/brave-images.js
nnew file mode 100644
iindex 0000000..c779d30
---- /dev/null
++++ b/server/search/adapters/brave-images.js
@@@ -0,0 +1,12 @@
++'use strict';
++const { requestFor, query } = require('../transport');
++module.exports = {
++ id: 'brave-images', byok: true, checked_on: '2026-11-09',
++ kinds: ['images'], key_env: 'SEARCH_BRAVE_IMAGES_KEY', credential_id: 'brave', hosts: ['api.search.brave.com'],
++ price_per_1000: 5, source: 'https://api-dashboard.search.brave.com/app/plans', freshness: [],
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://api.search.brave.com/res/v1/images/search', { q: b.q, count: b.limit, safesearch: 'strict' }), { headers: { 'x-subscription-token': key } });
++ // Search index results do not grant a reuse licence or identify the creator.
++ return out.results.map(r => ({ title: r.title || '', url: r.properties?.url, thumbnail: r.thumbnail?.src, width: r.properties?.width, height: r.properties?.height, license: null, author: null, source_page: r.url, attribution: null }));
++ },
++};
ddiff --git a/server/search/adapters/brave.js b/server/search/adapters/brave.js
nnew file mode 100644
iindex 0000000..84560c6
---- /dev/null
++++ b/server/search/adapters/brave.js
@@@ -0,0 +1,11 @@
++'use strict';
++const { requestFor, query, web } = require('../transport');
++module.exports = {
++ id: 'brave', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_BRAVE_KEY', hosts: ['api.search.brave.com'],
++ price_per_1000: 5, source: 'https://api-dashboard.search.brave.com/app/plans', freshness: ['day', 'month', 'year'],
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://api.search.brave.com/res/v1/web/search', { q: b.q, count: Math.min(b.limit, 20), freshness: { day: 'pd', month: 'pm', year: 'py' }[b.freshness] }), { headers: { 'x-subscription-token': key } });
++ return (out.web?.results || []).map(r => web(r, r.description, r.page_age));
++ },
++};
ddiff --git a/server/search/adapters/commons.js b/server/search/adapters/commons.js
iindex a4ad6af..cff1e67 100644
---- a/server/search/adapters/commons.js
++++ b/server/search/adapters/commons.js
@@@ -1,9 +1,14 @@
'use strict';
++const { requestFor } = require('../transport');
const plain = (s) => String(s || '').replace(/<[^>]*>/g, '');
module.exports = {
id: 'commons',
++ price_per_1000: 0, byok: false, checked_on: '2026-11-09', source: 'https://www.mediawiki.org/wiki/API:Main_page',
++ freshness: [],
++ hosts: ['commons.wikimedia.org'],
kinds: ['images'],
-- async search(b, get) {
++ async search(b, { fetchImpl }) {
++ const get = requestFor(this.hosts, fetchImpl);
const q = new URLSearchParams({
action: 'query',
format: 'json',
ddiff --git a/server/search/adapters/deepseek.js b/server/search/adapters/deepseek.js
nnew file mode 100644
iindex 0000000..c9347a8
---- /dev/null
++++ b/server/search/adapters/deepseek.js
@@@ -0,0 +1,18 @@
++'use strict';
++const { requestFor, post, bearer, query, since, web } = require('../transport');
++module.exports = {
++ id: 'deepseek', byok: true, checked_on: '2026-11-09',
++ kinds: ['web', 'fetch'], key_env: 'SEARCH_DEEPSEEK_KEY', hosts: ['api.deepseek.com'],
++ price_per_1000: 0.75, source: 'https://api-docs.deepseek.com/guides/search/',
++ freshness: ['day', 'month', 'year'], max_query: 128,
++ async search(b, { fetchImpl, key, now }) {
++ const q = b.q + (b.freshness ? ' after:' + since(b.freshness, now()).slice(0, 10) : '');
++ if ([...q].length > 128) throw new Error('DeepSeek query exceeds 128 characters');
++ const out = await requestFor(this.hosts, fetchImpl)('https://api.deepseek.com/v1/tools/search', post({ query: q, count: b.limit, need_content: false }, bearer(key)));
++ return { rows: out.results.map(r => web(r, r.snippet || r.summary, r.date)), cost: out.results.length ? 0.00075 : 0 };
++ },
++ async read(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)('https://api.deepseek.com/v1/tools/fetch', post({ url: b.url }, bearer(key)));
++ return { text: out.content, cost: out.content ? 0.00075 : 0 };
++ },
++};
ddiff --git a/server/search/adapters/exa.js b/server/search/adapters/exa.js
nnew file mode 100644
iindex 0000000..1a742c3
---- /dev/null
++++ b/server/search/adapters/exa.js
@@@ -0,0 +1,14 @@
++'use strict';
++const { requestFor, post, query, since, web } = require('../transport');
++module.exports = {
++ id: 'exa', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_EXA_KEY', hosts: ['api.exa.ai'],
++ price_per_1000: 7, source: 'https://exa.ai/docs/admin/pricing', freshness: ['day', 'month', 'year'],
++ pricing_note: 'Auto search, up to 10 results; $1/1000 for each additional result.',
++ ceiling: b => (7 + Math.max(0, b.limit - 10)) / 1000,
++ estimate(b) { return this.ceiling(b); },
++ async search(b, { fetchImpl, key, now }) {
++ const out = await requestFor(this.hosts, fetchImpl)('https://api.exa.ai/search', post({ query: b.q, type: 'auto', numResults: b.limit, contents: { highlights: true }, ...(b.freshness ? { startPublishedDate: since(b.freshness, now()) } : {}) }, { 'x-api-key': key }));
++ return { rows: out.results.map(r => web(r, r.highlights?.join(' ') || r.text, r.publishedDate)), cost: out.costDollars?.total ?? this.ceiling(b) };
++ },
++};
ddiff --git a/server/search/adapters/google.js b/server/search/adapters/google.js
nnew file mode 100644
iindex 0000000..1a586c1
---- /dev/null
++++ b/server/search/adapters/google.js
@@@ -0,0 +1,12 @@
++'use strict';
++const { requestFor, query, web } = require('../transport');
++module.exports = {
++ id: 'google', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_GOOGLE_KEY', requires: ['SEARCH_GOOGLE_CX'], hosts: ['customsearch.googleapis.com'],
++ price_per_1000: 5, source: 'https://developers.google.com/custom-search/v1/overview', freshness: ['day', 'month', 'year'],
++ pricing_note: 'After 100 free queries/day. Existing customers only; service ends 2027-01-01.',
++ async search(b, { fetchImpl, key, env }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://customsearch.googleapis.com/customsearch/v1', { key, cx: env.SEARCH_GOOGLE_CX, q: b.q, num: Math.min(b.limit, 10), dateRestrict: { day: 'd1', month: 'm1', year: 'y1' }[b.freshness] }));
++ return (out.items || []).map(r => web({ ...r, url: r.link }));
++ },
++};
ddiff --git a/server/search/adapters/iconify.js b/server/search/adapters/iconify.js
iindex c8d5f2e..29a5a8d 100644
---- a/server/search/adapters/iconify.js
++++ b/server/search/adapters/iconify.js
@@@ -1,8 +1,13 @@
'use strict';
++const { requestFor } = require('../transport');
module.exports = {
id: 'iconify',
++ price_per_1000: 0, byok: false, checked_on: '2026-11-09', source: 'https://iconify.design/docs/api/',
++ freshness: [],
++ hosts: ['api.iconify.design'],
kinds: ['icons'],
-- async search(b, get) {
++ async search(b, { fetchImpl }) {
++ const get = requestFor(this.hosts, fetchImpl);
const out = await get(
'https://api.iconify.design/search?' +
new URLSearchParams({
ddiff --git a/server/search/adapters/jina.js b/server/search/adapters/jina.js
nnew file mode 100644
iindex 0000000..6ad4330
---- /dev/null
++++ b/server/search/adapters/jina.js
@@@ -0,0 +1,22 @@
++'use strict';
++const { requestFor, post, bearer, query, web } = require('../transport');
++module.exports = {
++ id: 'jina', byok: true, checked_on: '2026-11-09',
++ kinds: ['web', 'fetch'], key_env: 'SEARCH_JINA_KEY', hosts: ['s.jina.ai', 'r.jina.ai'],
++ price_per_1000: 0.5, source: 'https://jina.ai/reader/#apiform',
++ pricing_note: 'Search from 10000 tokens/request; $50/1B token pack ($0.05/M). Reader is token based, not a fixed request fee.',
++ price_per_million_tokens: 0.05, freshness: [], ceiling: () => 0.005,
++ cost(out) {
++ const tokens = out.usage?.tokens ?? out.data?.usage?.tokens ?? (Array.isArray(out.data) ? out.data.reduce((n, r) => n + (r.usage?.tokens || 0), 0) : 0);
++ if (!Number.isFinite(tokens) || tokens <= 0) throw new Error('Jina usage is missing');
++ return tokens * 0.05 / 1e6;
++ },
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://s.jina.ai/', { q: b.q }), { headers: { ...bearer(key), 'x-token-budget': '100000' } });
++ return { rows: out.data.map(r => web(r, r.description || r.content, r.publishedTime)), cost: this.cost(out) };
++ },
++ async read(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)('https://r.jina.ai/', post({ url: b.url }, { ...bearer(key), 'x-token-budget': '100000', 'x-no-cache': 'true', 'x-engine': 'direct' }));
++ return { text: out.data.content, cost: this.cost(out) };
++ },
++};
ddiff --git a/server/search/adapters/kagi.js b/server/search/adapters/kagi.js
nnew file mode 100644
iindex 0000000..de724f6
---- /dev/null
++++ b/server/search/adapters/kagi.js
@@@ -0,0 +1,12 @@
++'use strict';
++const { requestFor, query, web } = require('../transport');
++module.exports = {
++ id: 'kagi', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_KAGI_KEY', hosts: ['kagi.com'],
++ price_per_1000: 25, source: 'https://help.kagi.com/kagi/api/search-legacy.html', freshness: [],
++ pricing_note: 'Documented v0 Search API; requires an API-enabled account.',
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://kagi.com/api/v0/search', { q: b.q }), { headers: { authorization: 'Bot ' + key } });
++ return out.data.filter(r => r.t === 0).map(r => web(r, r.snippet, r.published));
++ },
++};
ddiff --git a/server/search/adapters/openverse.js b/server/search/adapters/openverse.js
iindex 95ab1a7..823219c 100644
---- a/server/search/adapters/openverse.js
++++ b/server/search/adapters/openverse.js
@@@ -1,8 +1,13 @@
'use strict';
++const { requestFor } = require('../transport');
module.exports = {
id: 'openverse',
++ price_per_1000: 0, byok: false, checked_on: '2026-11-09', source: 'https://api.openverse.org/',
++ freshness: [],
++ hosts: ['api.openverse.org'],
kinds: ['images'],
-- async search(b, get) {
++ async search(b, { fetchImpl }) {
++ const get = requestFor(this.hosts, fetchImpl);
const q = new URLSearchParams({
q: b.q,
page_size: String(b.limit),
ddiff --git a/server/search/adapters/openvibe-egress.js b/server/search/adapters/openvibe-egress.js
nnew file mode 100644
iindex 0000000..53b7082
---- /dev/null
++++ b/server/search/adapters/openvibe-egress.js
@@@ -0,0 +1,11 @@
++'use strict';
++const { guardedFetch } = require('../../providers/guarded-fetch');
++module.exports = {
++ id: 'openvibe-egress', kinds: ['fetch'], byok: false, price_per_1000: 0,
++ source: 'https://openvibe.exchange', checked_on: '2026-11-09', freshness: [],
++ async read(b, { fetchImpl = guardedFetch }) {
++ const r = await fetchImpl(b.url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { accept: 'text/plain,text/html' } });
++ if (!r.ok) throw new Error('The page did not answer');
++ return { text: (await r.text()).replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '').replace(/<[^>]*>/g, ' ').replace(/[ \t]+/g, ' '), cost: 0 };
++ },
++};
ddiff --git a/server/search/adapters/openvibe.js b/server/search/adapters/openvibe.js
iindex f81b772..ae0f4b4 100644
---- a/server/search/adapters/openvibe.js
++++ b/server/search/adapters/openvibe.js
@@@ -1,8 +1,13 @@
'use strict';
++const { requestFor } = require('../transport');
module.exports = {
id: 'openvibe',
++ price_per_1000: 0, byok: false, checked_on: '2026-11-09', source: 'https://openvibe.search',
++ freshness: [],
++ hosts: ['openvibe.search'],
kinds: ['web'],
-- async search(b, get) {
++ async search(b, { fetchImpl }) {
++ const get = requestFor(this.hosts, fetchImpl);
const out = await get(
'https://openvibe.search/api/v1/search?' +
new URLSearchParams({ q: b.q, limit: String(b.limit) }),
ddiff --git a/server/search/adapters/perplexity.js b/server/search/adapters/perplexity.js
nnew file mode 100644
iindex 0000000..c908edf
---- /dev/null
++++ b/server/search/adapters/perplexity.js
@@@ -0,0 +1,21 @@
++'use strict';
++const { requestFor, post, bearer, web } = require('../transport');
++module.exports = {
++ id: 'perplexity', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_PERPLEXITY_KEY', hosts: ['api.perplexity.ai'],
++ price_per_1000: null, source: 'https://docs.perplexity.ai/docs/getting-started/pricing', freshness: [],
++ pricing_note: 'Sonar on Agent API: $0.25/M input, $2.50/M output, $0.0625/M cached; web search $2.50/1000 tool calls. No flat answer price.',
++ input_per_million: 0.25, output_per_million: 2.5, search_per_1000: 2.5,
++ ceiling: () => 0.1,
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)('https://api.perplexity.ai/v1/agent', post({ model: 'perplexity/sonar', input: b.q, instructions: 'Search the web before answering and cite your sources.', max_tool_calls: 1, tools: [{ type: 'web_search' }], max_steps: 3, max_output_tokens: 1024, store: false }, bearer(key)));
++ const content = (out.output || []).filter(r => r.type === 'message').flatMap(r => r.content || []).filter(r => r.type === 'output_text');
++ const answer = content.map(r => r.text).join('\n');
++ const citations = (out.output || []).filter(r => r.type === 'search_results').flatMap(r => r.results || []);
++ if (!answer) throw new Error('Sonar returned no answer');
++ const cost = out.usage?.cost?.total_cost;
++ if (!Number.isFinite(cost) || cost < 0) throw new Error('Sonar usage is missing');
++ // Answers retain their citations in the contract's result rows, without inventing a source URL.
++ return { rows: citations.map(r => web(r, answer, r.date)), cost };
++ },
++};
ddiff --git a/server/search/adapters/pexels.js b/server/search/adapters/pexels.js
nnew file mode 100644
iindex 0000000..1525e3e
---- /dev/null
++++ b/server/search/adapters/pexels.js
@@@ -0,0 +1,12 @@
++'use strict';
++const { requestFor, query } = require('../transport');
++module.exports = {
++ id: 'pexels', byok: true, checked_on: '2026-11-09',
++ kinds: ['images'], key_env: 'SEARCH_PEXELS_KEY', hosts: ['api.pexels.com'],
++ price_per_1000: 0, source: 'https://www.pexels.com/api/', freshness: [],
++ pricing_note: 'Free for existing keys; new key issuance is currently paused.',
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://api.pexels.com/v1/search', { query: b.q, per_page: b.limit, orientation: b.orientation }), { headers: { authorization: key } });
++ return out.photos.map(r => ({ title: r.alt || '', url: r.src.original, thumbnail: r.src.medium, width: r.width, height: r.height, license: 'pexels', license_url: 'https://www.pexels.com/license/', author: r.photographer, author_url: r.photographer_url, source_page: r.url, attribution: `Photo by ${r.photographer} on Pexels` }));
++ },
++};
ddiff --git a/server/search/adapters/pixabay.js b/server/search/adapters/pixabay.js
nnew file mode 100644
iindex 0000000..451a79e
---- /dev/null
++++ b/server/search/adapters/pixabay.js
@@@ -0,0 +1,11 @@
++'use strict';
++const { requestFor, query } = require('../transport');
++module.exports = {
++ id: 'pixabay', byok: true, checked_on: '2026-11-09',
++ kinds: ['images'], key_env: 'SEARCH_PIXABAY_KEY', hosts: ['pixabay.com'],
++ price_per_1000: 0, source: 'https://pixabay.com/api/docs/', freshness: [],
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://pixabay.com/api/', { key, q: b.q, per_page: Math.max(3, b.limit), safesearch: 'true', orientation: { landscape: 'horizontal', portrait: 'vertical' }[b.orientation] }));
++ return out.hits.map(r => ({ title: r.tags || '', url: r.largeImageURL || r.webformatURL, thumbnail: r.previewURL, width: r.imageWidth, height: r.imageHeight, license: 'pixabay', license_url: 'https://pixabay.com/service/license-summary/', author: r.user, source_page: r.pageURL, attribution: `Image by ${r.user} on Pixabay` }));
++ },
++};
ddiff --git a/server/search/adapters/searxng.js b/server/search/adapters/searxng.js
iindex 65701ed..d67000d 100644
---- a/server/search/adapters/searxng.js
++++ b/server/search/adapters/searxng.js
@@@ -1,9 +1,15 @@
'use strict';
++const { requestFor } = require('../transport');
module.exports = {
id: 'searxng',
++ price_per_1000: 0, byok: false, checked_on: '2026-11-09', source: 'https://docs.searxng.org/',
++ freshness: ['day', 'month', 'year'],
++ hosts: [],
kinds: ['web'],
setting: 'EXCHANGE_SEARXNG_URL',
-- async search(b, get, base) {
++ async search(b, { fetchImpl, env }) {
++ const base = env.EXCHANGE_SEARXNG_URL;
++ const get = requestFor([new URL(base).hostname], fetchImpl);
const url = new URL('/search', base);
url.search = new URLSearchParams({
q: b.q,
ddiff --git a/server/search/adapters/serpapi.js b/server/search/adapters/serpapi.js
nnew file mode 100644
iindex 0000000..197ba36
---- /dev/null
++++ b/server/search/adapters/serpapi.js
@@@ -0,0 +1,12 @@
++'use strict';
++const { requestFor, query, web } = require('../transport');
++module.exports = {
++ id: 'serpapi', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_SERPAPI_KEY', hosts: ['serpapi.com'],
++ price_per_1000: 25, source: 'https://serpapi.com/pricing', freshness: ['day', 'month', 'year'],
++ pricing_note: 'Starter subscription: $25/month for 1000 searches; lower rates at volume.',
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://serpapi.com/search.json', { engine: 'google', q: b.q, num: Math.min(b.limit, 10), api_key: key, tbs: b.freshness ? 'qdr:' + { day: 'd', month: 'm', year: 'y' }[b.freshness] : undefined }));
++ return (out.organic_results || []).map(r => web({ ...r, url: r.link }, r.snippet, r.date));
++ },
++};
ddiff --git a/server/search/adapters/serper.js b/server/search/adapters/serper.js
nnew file mode 100644
iindex 0000000..2ce574c
---- /dev/null
++++ b/server/search/adapters/serper.js
@@@ -0,0 +1,12 @@
++'use strict';
++const { requestFor, post, web } = require('../transport');
++module.exports = {
++ id: 'serper', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_SERPER_KEY', hosts: ['google.serper.dev'],
++ price_per_1000: 1, source: 'https://serper.dev/', freshness: ['day', 'month', 'year'],
++ pricing_note: 'Starter prepaid pack: $50 for 50000 credits; lower rates at volume.',
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)('https://google.serper.dev/search', post({ q: b.q, num: Math.min(b.limit, 10), ...(b.freshness ? { tbs: 'qdr:' + { day: 'd', month: 'm', year: 'y' }[b.freshness] } : {}) }, { 'x-api-key': key }));
++ return (out.organic || []).map(r => web({ ...r, url: r.link }, r.snippet, r.date));
++ },
++};
ddiff --git a/server/search/adapters/tavily.js b/server/search/adapters/tavily.js
nnew file mode 100644
iindex 0000000..a159753
---- /dev/null
++++ b/server/search/adapters/tavily.js
@@@ -0,0 +1,12 @@
++'use strict';
++const { requestFor, post, bearer, query, web } = require('../transport');
++module.exports = {
++ id: 'tavily', byok: true, checked_on: '2026-11-09',
++ kinds: ['web'], key_env: 'SEARCH_TAVILY_KEY', hosts: ['api.tavily.com'],
++ price_per_1000: 8, source: 'https://www.tavily.com/pricing', freshness: ['day', 'month', 'year'],
++ pricing_note: 'Pay as you go; basic search uses one credit. Free allowance excluded.',
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)('https://api.tavily.com/search', post({ query: b.q, search_depth: 'basic', auto_parameters: false, max_results: Math.min(20, b.limit), include_published_date: true, ...(b.freshness ? { time_range: b.freshness } : {}) }, bearer(key)));
++ return out.results.map(r => web(r, r.content, r.published_date));
++ },
++};
ddiff --git a/server/search/adapters/unsplash.js b/server/search/adapters/unsplash.js
nnew file mode 100644
iindex 0000000..620177c
---- /dev/null
++++ b/server/search/adapters/unsplash.js
@@@ -0,0 +1,11 @@
++'use strict';
++const { requestFor, query } = require('../transport');
++module.exports = {
++ id: 'unsplash', byok: true, checked_on: '2026-11-09',
++ kinds: ['images'], key_env: 'SEARCH_UNSPLASH_KEY', hosts: ['api.unsplash.com'],
++ price_per_1000: 0, source: 'https://unsplash.com/developers', freshness: [],
++ async search(b, { fetchImpl, key }) {
++ const out = await requestFor(this.hosts, fetchImpl)(query('https://api.unsplash.com/search/photos', { query: b.q, per_page: Math.min(30, b.limit), content_filter: 'high', orientation: { landscape: 'landscape', portrait: 'portrait', square: 'squarish' }[b.orientation] }), { headers: { authorization: 'Client-ID ' + key } });
++ return out.results.map(r => ({ title: r.alt_description || r.description || '', url: r.urls.full, thumbnail: r.urls.thumb, width: r.width, height: r.height, published: r.created_at || null, license: 'unsplash', license_url: 'https://unsplash.com/license', author: r.user.name, source_page: r.links.html, attribution: `Photo by ${r.user.name} on Unsplash`, author_url: r.user.links?.html, download_location: r.links.download_location }));
++ },
++};
ddiff --git a/server/search/index.js b/server/search/index.js
iindex efa5769..b4ad707 100644
---- a/server/search/index.js
++++ b/server/search/index.js
@@@ -1,192 +1,131 @@
'use strict';
const { guardedFetch } = require('../providers/guarded-fetch');
++const { publicTarget } = require('./transport');
const { AiError } = require('../util');
--const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify'].map(
-- (n) => require('./adapters/' + n),
--);
++const { validate } = require('openvibe-contracts');
++const net = require('node:net');
++const { createHash } = require('node:crypto');
++const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify',
++ 'deepseek', 'brave', 'exa', 'tavily', 'serper', 'serpapi', 'jina',
++ 'openvibe-egress', 'perplexity', 'kagi', 'google', 'unsplash', 'pexels', 'pixabay', 'brave-images']
++ .map(n => require('./adapters/' + n));
++const CHARGE = Symbol('search Exchange charge');
++const invalid = (message) => new AiError(422, 'ai.invalid', message);
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;
-- }
++ 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 credentialId = a => a.credential_id || a.id;
++const dimension = n => Number.isInteger(n) && n > 0 ? n : null;
++function inputFor(kind, b) {
++ if (!b || typeof b !== 'object' || Array.isArray(b)) throw invalid('A JSON object is required');
++ if (kind !== 'fetch' && (typeof b.q !== 'string' || !b.q.trim() || b.q.length > 500)) throw invalid('q must contain 1–500 characters');
++ if (kind === 'fetch' && (!safeUrl(b.url) || !String(b.url).startsWith('https://') || b.url.length > 8192)) throw invalid('An HTTPS URL is required');
++ if (kind === 'fetch' && net.isIP(new URL(b.url).hostname.replace(/^\[|\]$/g, ''))) throw invalid('IP-literal hosts are not called');
++ if (b.engine != null && typeof b.engine !== 'string') throw invalid('engine must be a string');
++ if (b.limit != null && (!Number.isInteger(b.limit) || b.limit < 1 || b.limit > 50)) throw invalid('limit must be 1–50');
++ if (b.max_cost_usd != null && (!Number.isFinite(b.max_cost_usd) || b.max_cost_usd < 0)) throw invalid('max_cost_usd must be nonnegative');
++ if (b.freshness && !['day', 'month', 'year'].includes(b.freshness)) throw invalid('freshness must be day, month or year');
++ if (b.orientation && !['square', 'portrait', 'landscape'].includes(b.orientation)) throw invalid('orientation must be square, portrait or landscape');
++ if (b.colour || b.color || b.type && b.type !== 'icon') throw invalid('This filter is not supported');
++ if (b.size && !['small', 'medium', 'large'].includes(b.size)) throw invalid('size must be small, medium or large');
++ return { ...b, q: kind === 'fetch' ? undefined : b.q.trim(), limit: b.limit || 10 };
++}
++function normalize(rows, engine, kind, b) {
++ let out = rows.filter(r => safeUrl(r.url)).map(r => ({
++ ...r, title: String(r.title || ''), url: safeUrl(r.url), engine,
++ snippet: String(r.snippet || ''), published: r.published == null ? null : String(r.published),
++ ...(kind === 'web' ? {} : {
++ thumbnail: safeUrl(r.thumbnail), source_page: safeUrl(r.source_page), license_url: safeUrl(r.license_url),
++ author_url: safeUrl(r.author_url), download_location: safeUrl(r.download_location),
++ width: dimension(r.width), height: dimension(r.height),
++ license: r.license == null ? null : String(r.license), author: r.author == null ? null : String(r.author),
++ attribution: r.attribution == null ? null : String(r.attribution),
++ }),
++ }));
++ const licenseId = v => String(v || '').toLowerCase().replace(/^cc[ -]/, '').replace(/[ -]\d+(?:\.\d+)*$/, '');
++ if (b.license) out = out.filter(r => licenseId(r.license) === licenseId(b.license));
++ if (b.commercial) out = out.filter(r => ['cc0', 'pdm', 'by', 'by-sa', 'unsplash', 'pexels', 'pixabay'].includes(licenseId(r.license)));
++ if (b.format) out = out.filter(r => r.format === b.format || new URL(r.url).pathname.toLowerCase().endsWith('.' + b.format));
++ if (b.orientation) out = out.filter(r => r.width && r.height && (b.orientation === 'square' ? r.width === r.height : b.orientation === 'portrait' ? r.height > r.width : r.width > r.height));
++ if (b.size) out = out.filter(r => r.width && r.height && (b.size === 'small' ? Math.max(r.width, r.height) < 800 : b.size === 'medium' ? Math.max(r.width, r.height) >= 800 && Math.max(r.width, r.height) < 2000 : Math.max(r.width, r.height) >= 2000));
++ if (b.style && b.style !== 'any') out = out.filter(r => r.style === b.style);
++ return out.slice(0, b.limit);
++}
++function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () => Date.now(), credentials = null, targetGuard = publicTarget } = {}) {
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();
-- }
++ const configured = a => (!a.key_env || Boolean(env[a.key_env])) && (!a.setting || Boolean(env[a.setting])) && (a.requires || []).every(k => env[k]);
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(),
++ return adapters.map(a => ({ id: a.id, kinds: a.kinds, price_per_1000: a.price_per_1000,
++ byok: a.byok, available: configured(a), freshness: a.freshness, healthy: (health.get(a.id) || 0) <= now(),
++ source: a.source, checked_on: a.checked_on, pricing_note: a.pricing_note || null,
}));
}
-- 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',
-- );
++ async function prepare(kind, body, { subject = null } = {}) {
++ const b = inputFor(kind, body);
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',
-- );
++ for (const [k, until] of health) if (until <= now()) health.delete(k);
++ const candidates = [];
++ for (const a of adapters) {
++ if (!a.kinds.includes(kind) || b.engine && b.engine !== 'auto' && b.engine !== a.id ||
++ b.freshness && !a.freshness.includes(b.freshness) || (a.requires || []).some(k => !env[k])) continue;
++ if (kind !== 'fetch' && a.max_query && [...b.q].length + (b.freshness ? 17 : 0) > a.max_query) continue;
++ const own = a.byok && subject && credentials ? await credentials.searchForRun(subject, credentialId(a)) : null;
++ const key = own?.apiKey || (a.key_env && env[a.key_env]);
++ if (a.key_env && !key || a.setting && !env[a.setting]) continue;
++ const healthKey = own ? `${a.id}:${createHash('sha256').update(key).digest('hex')}` : a.id;
++ const ceiling = a.ceiling ? a.ceiling(b) : a.price_per_1000 / 1000;
++ if (b.max_cost_usd != null && ceiling > b.max_cost_usd) continue;
++ candidates.push({ a, key, own: Boolean(own), healthKey, ceiling });
++ }
++ // Once the person has an eligible key, fallback stays within their keys (0% Exchange fee).
++ const cohort = candidates.some(c => c.own) ? candidates.filter(c => c.own) : candidates;
++ const selected = cohort.filter(c => b.engine && b.engine !== 'auto' || !health.has(c.healthKey));
++ const price = c => c.a.estimate ? c.a.estimate(b) : c.a.price_per_1000 == null ? c.ceiling : c.a.price_per_1000 / 1000;
++ selected.sort((a, b) => price(a) - price(b));
++ if (!selected.length) throw new AiError(503, 'search.unavailable', 'No configured engine serves this request within its price and freshness limits');
++ return { kind, b, candidates: selected, ceiling: Math.min(b.max_cost_usd ?? Infinity, selected.reduce((n, c) => n + (c.own ? 0 : c.ceiling), 0)) };
++ }
++ const receipt = (engine, cost, start) => ({ engine, cost_usd: cost, time_ms: Math.max(0, now() - start) });
++ async function execute(plan, budget = Infinity) {
const start = now();
-- for (const c of candidates) {
++ let incurred = 0, charge = 0;
++ for (const c of plan.candidates) {
++ if ((!c.own && c.ceiling + charge > budget + 1e-12) || c.ceiling + incurred > (plan.b.max_cost_usd ?? Infinity) + 1e-12) continue;
++ let attempted = false;
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);
++ if (plan.kind === 'fetch' && c.a.id !== 'openvibe-egress') await targetGuard(plan.b.url);
++ const options = { fetchImpl, key: c.key, env, now };
++ attempted = true;
++ const raw = plan.kind === 'fetch' ? await c.a.read(plan.b, options) : await c.a.search(plan.b, options);
++ const cost = raw.cost ?? c.a.price_per_1000 / 1000;
++ if (!Number.isFinite(cost) || cost < 0) throw new Error('Invalid engine cost');
++ const out = plan.kind === 'fetch'
++ ? { url: plan.b.url, text: String(raw.text || '').slice(0, 200000), receipt: receipt(c.a.id, cost + incurred, start) }
++ : { results: normalize(Array.isArray(raw) ? raw : raw.rows, c.a.id, plan.kind, plan.b), receipt: receipt(c.a.id, cost + incurred, start) };
++ if (plan.kind !== 'fetch' && !validate(plan.kind === 'web' ? 'exchange.search-result@1' : 'exchange.image-result@1', out).valid) throw new Error('Invalid search response');
++ health.delete(c.healthKey);
++ Object.defineProperty(out, CHARGE, { value: charge + (c.own ? 0 : cost) });
++ return out;
++ } catch (error) {
++ // A transport timeout or malformed successful response may already be billed.
++ // Keep its ceiling in both the fallback budget and receipt, rather than overspend.
++ if (attempted && !error.unbilled) { incurred += c.ceiling; if (!c.own) charge += c.ceiling; }
++ health.set(c.healthKey, now() + 60000);
++ if (health.size > 1000) health.delete(health.keys().next().value);
}
}
-- throw new AiError(
-- 503,
-- 'search.unavailable',
-- 'The selected engines did not answer',
-- );
++ throw Object.assign(new AiError(503, 'search.unavailable', 'The selected engines did not answer'), { [CHARGE]: charge });
}
-- 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,
-- },
-- };
++ async function search(kind, b, context = {}) {
++ return execute(context.plan || await prepare(kind, b, context), context.budget);
++ }
++ async function read(body, context = {}) {
++ const b = typeof body === 'string' ? { url: body } : body;
++ return execute(context.plan || await prepare('fetch', b, context), context.budget);
}
-- return { engines, search, read };
++ return { engines, search, read, prepare };
}
--module.exports = { createSearch };
++module.exports = { createSearch, adapters, credentialId, CHARGE };
ddiff --git a/server/search/openapi.js b/server/search/openapi.js
nnew file mode 100644
iindex 0000000..d3c5332
---- /dev/null
++++ b/server/search/openapi.js
@@@ -0,0 +1,46 @@
++'use strict';
++const { schema } = require('openvibe-contracts');
++const contract = id => {
++ const { $id, $schema, ...s } = schema(id);
++ return s;
++};
++const json = s => ({ content: { 'application/json': { schema: s } } });
++const properties = {
++ q: { type: 'string', minLength: 1, maxLength: 500 },
++ engine: { type: 'string', default: 'auto', description: 'Engine ID from the public catalog. Auto prefers personal keys, then price among healthy engines supporting freshness. Failed personal keys never fall back to shared keys.' },
++ limit: { type: 'integer', minimum: 1, maximum: 50, default: 10, description: 'Maximum returned results; some providers return fewer.' },
++ freshness: { type: 'string', enum: ['day', 'month', 'year'] },
++ max_cost_usd: { type: 'number', minimum: 0, description: 'Maximum provider cost for this call, including BYOK. Variable-cost engines must fit their documented conservative ceiling.' },
++};
++const errors = Object.fromEntries([401, 403, 422, 429, 503].map(n => [n, { description: 'Authentication, invalid request, cap/rate limit or engine unavailable (application/problem+json).' }]));
++const paths = {};
++for (const kind of ['web', 'images', 'icons']) {
++ paths['/v1/search' + (kind === 'web' ? '' : '/' + kind)] = { post: {
++ operationId: 'search' + kind[0].toUpperCase() + kind.slice(1), tags: ['exchange'],
++ summary: `Search ${kind} through Exchange`, security: [{ bearer: [] }],
++ description: 'Accepts an Exchange key, person token, authorized service or app token. Receipt reports engine, provider cost in USD and elapsed milliseconds, including fallbacks. If a timed-out or malformed upstream answer may already have been billed, its reserved ceiling is included conservatively. BYOK has zero Exchange charge and zero fee; its provider cost remains visible in the receipt. Shared keys settle that cost against the Exchange key cap. Sources and checked dates are in GET /api/v1/exchange/search/engines.',
++ requestBody: { required: true, ...json({ type: 'object', required: ['q'], properties: {
++ ...properties,
++ ...(kind === 'web' ? {} : {
++ license: { type: 'string', description: 'Exact licence filter; cc-by and by are equivalent. Unknown licences never pass commercial=true.' },
++ commercial: { type: 'boolean' }, format: { type: 'string' },
++ orientation: { type: 'string', enum: ['landscape', 'portrait', 'square'] },
++ size: { type: 'string', enum: ['small', 'medium', 'large'], description: 'Longest side: below 800px, 800–1999px, or 2000px and above. Missing dimensions do not match.' },
++ type: { type: 'string', enum: ['icon'] }, set: { type: 'string' },
++ style: { type: 'string', enum: ['any', 'line', 'solid'] },
++ }),
++ } }) },
++ responses: { 200: { description: kind === 'web' ? 'exchange.search-result@1. Sonar answer text is the snippet on each cited source.' : 'exchange.image-result@1. Images include size, author, licence, source page and attribution; unavailable metadata is null. Search results confer no additional licence.', ...json(contract(kind === 'web' ? 'exchange.search-result@1' : 'exchange.image-result@1')) }, ...errors },
++ } };
++}
++paths['/v1/fetch'] = { post: {
++ operationId: 'fetchSearchPage', tags: ['exchange'], summary: 'Read a public HTTPS page as text or Markdown', security: [{ bearer: [] }],
++ description: 'engine: auto, openvibe-egress, deepseek or jina. Auto prefers an eligible personal reader key, otherwise free local egress. Remote reader targets pass the existing public DNS/address guard before submission. Direct reads recheck DNS at connect time. Remote reader redirects are subject to the provider security policy. Receipt and cap semantics match search.',
++ requestBody: { required: true, ...json({ type: 'object', required: ['url'], properties: {
++ url: { type: 'string', format: 'uri', maxLength: 8192 }, engine: properties.engine, max_cost_usd: properties.max_cost_usd,
++ } }) },
++ responses: { 200: { description: 'The URL, extracted text (at most 200000 characters) and receipt', ...json({ type: 'object', required: ['url', 'text', 'receipt'], properties: {
++ url: { type: 'string', format: 'uri' }, text: { type: 'string' }, receipt: contract('exchange.search-result@1').properties.receipt,
++ } }) }, ...errors },
++} };
++module.exports = { paths };
ddiff --git a/server/search/transport.js b/server/search/transport.js
nnew file mode 100644
iindex 0000000..f237537
---- /dev/null
++++ b/server/search/transport.js
@@@ -0,0 +1,37 @@
++'use strict';
++const { guardedFetch } = require('../providers/guarded-fetch');
++const { safeLookup } = require('../fetcher');
++const net = require('node:net');
++
++function requestFor(hosts, fetchImpl = guardedFetch) {
++ return async (input, init = {}) => {
++ const url = new URL(String(input));
++ if (url.protocol !== 'https:' || url.username || url.password ||
++ url.port && url.port !== '443' || !hosts.includes(url.hostname))
++ throw new Error('Search endpoint is not allowed');
++ const r = await fetchImpl(url.href, {
++ ...init, redirect: 'error', signal: AbortSignal.timeout(15000),
++ headers: { accept: 'application/json', 'user-agent': 'OpenVibe.Exchange/1.0 (https://openvibe.exchange)', ...init.headers },
++ });
++ // Never include an upstream error or URL: some providers put keys in the query string.
++ if (!r.ok) throw Object.assign(new Error('Search engine unavailable'), { unbilled: true });
++ const body = await r.json();
++ if (body.error || body.code && body.code !== 200) throw new Error('Search engine refused the request');
++ return body;
++ };
++}
++const post = (body, headers = {}) => ({ method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) });
++const bearer = (key) => ({ authorization: `Bearer ${key}` });
++const query = (base, params) => base + '?' + new URLSearchParams(Object.entries(params).filter(([, v]) => v != null));
++const since = (freshness, now = Date.now()) => new Date(now - ({ day: 1, month: 30, year: 365 }[freshness] || 0) * 86400000).toISOString();
++const web = (r, snippet = r.snippet, published = r.published) => ({ title: String(r.title || ''), url: r.url, snippet: String(snippet || ''), published: published == null ? null : String(published) });
++// Remote readers must pass the same public-address rule before receiving a target URL.
++// Direct reads also recheck at connect time in guardedFetch (including DNS rebinding).
++async function publicTarget(input) {
++ const url = new URL(input);
++ if (url.protocol !== 'https:' || url.username || url.password || net.isIP(url.hostname.replace(/^\[|\]$/g, '')))
++ throw new Error('An HTTPS URL without credentials or IP-literal host is required');
++ await new Promise((resolve, reject) => safeLookup(url.hostname, { all: true }, (e) => e ? reject(e) : resolve()));
++ return url.href;
++}
++module.exports = { requestFor, post, bearer, query, since, web, publicTarget };
ddiff --git a/test/exchange-search-idempotency.test.js b/test/exchange-search-idempotency.test.js
nnew file mode 100644
iindex 0000000..7518cc3
---- /dev/null
++++ b/test/exchange-search-idempotency.test.js
@@@ -0,0 +1,143 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { boot, request } = require('./helpers');
++const { createKeys } = require('../server/platform/keys');
++
++(async () => {
++ let upstreamCalls = 0;
++ let searchStarted, releaseSearch;
++ let searchGate;
++ const h = await boot({
++ env: { SEARCH_BRAVE_KEY: 'shared-brave-fixture' },
++ searchFetch: async () => {
++ upstreamCalls++;
++ if (searchGate) { searchStarted(); await searchGate; }
++ return new Response(JSON.stringify(require('./fixtures/search/brave.json').response));
++ },
++ });
++ const prepare = h.db.prepare.bind(h.db);
++ const holds = [];
++ h.db.prepare = (sql) => {
++ const statement = prepare(sql);
++ if (!sql.includes('SET reserved = x.reserved + m.take')) return statement;
++ return { async get(...args) {
++ const row = await statement.get(...args);
++ holds.push(row ? Number(row.take) : null);
++ return row;
++ } };
++ };
++ try {
++ const keys = createKeys({ db: h.db });
++ const made = await keys.create('usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', {
++ name: 'Search and inference', monthly_cap_usd: 2, allowed_models: ['openvibe'],
++ expires_at: new Date(Date.now() + 60000).toISOString(),
++ });
++ const call = (path, body) => request(h.base, 'POST', path, {
++ tok: made.secret, body, headers: { 'idempotency-key': 'search-inference-same-001' },
++ });
++ const usage = () => h.db.prepare('SELECT * FROM exchange_key_usage WHERE key_id = ? ORDER BY model').all(made.key.id);
++ const month = () => h.db.prepare('SELECT spent,reserved FROM exchange_key_months WHERE key_id = ?').get(made.key.id);
++ const settled = async (count) => {
++ for (let i = 0; i < 200; i++) {
++ const rows = await usage();
++ if (rows.length === count && rows.every(row => row.status === 'settled') && (await month()).reserved === 0) return rows;
++ await new Promise(resolve => setTimeout(resolve, 10));
++ }
++ assert.fail('Exchange holds did not settle');
++ };
++ const searchBody = { q: 'lantern', engine: 'brave' };
++ const inferenceBody = { model: 'openvibe', messages: [{ role: 'user', content: 'Hello' }] };
++ const invalid = await call('/v1/search', { ...searchBody, q: '' });
++ assert.equal(invalid.status, 422);
++ assert.equal(holds.length, 0, 'search preparation rejects invalid input before a hold');
++
++ const search = await call('/v1/search', searchBody);
++ assert.equal(search.status, 200, search.text);
++ assert.equal(search.body.receipt.cost_usd, .005);
++ assert.deepEqual(holds, [.005], 'paid search holds its plan ceiling');
++ const searchRows = await settled(1);
++ assert.equal(searchRows[0].model, 'search:/v1/search');
++ assert.equal(searchRows[0].cost_usd, .005, 'search charges its receipt once');
++ assert.equal(searchRows[0].tokens_in, 0);
++ assert.equal(searchRows[0].tokens_out, 0);
++ assert.equal((await h.db.prepare('SELECT id FROM runs').all()).length, 0, 'search creates no inference ledger entry');
++ assert.equal((await h.db.prepare('SELECT * FROM exchange_key_requests').all()).length, 0, 'search does not claim inference idempotency');
++
++ const first = await call('/v1/chat/completions', inferenceBody);
++ assert.equal(first.status, 200, first.text);
++ assert.deepEqual(holds, [.005, 1], 'one bounded hold per search and inference');
++ const before = await settled(2);
++ const originalClaim = await h.db.prepare('SELECT max_cost_usd FROM exchange_key_requests WHERE owner = ?').get('usr_01JAB2C3D4E5F6G7H8J9K0MNPQ');
++ assert.equal(Number(originalClaim.max_cost_usd), 1);
++
++ // Exhaust the cap: a replay must still reuse its original admitted ceiling.
++ await h.db.prepare('UPDATE exchange_key_months SET spent = 2 WHERE key_id = ?').run(made.key.id);
++ const replay = await call('/v1/chat/completions', inferenceBody);
++ assert.equal(replay.status, 200, replay.text);
++ assert.equal(replay.headers.get('x-openvibe-replayed'), '1');
++ assert.deepEqual(replay.body, first.body);
++ assert.deepEqual(holds, [.005, 1], 'replay does not reserve');
++ assert.deepEqual(await usage(), before, 'replay does not charge or create a usage row');
++ assert.deepEqual(await month(), { spent: 2, reserved: 0 });
++ assert.equal((await h.db.prepare('SELECT id FROM runs').all()).length, 1);
++ const denied = await call('/v1/chat/completions', { ...inferenceBody, model: 'other' });
++ assert.equal(denied.status, 403, 'model restrictions apply before replay');
++ assert.equal(holds.length, 2);
++
++ // A positive remaining cap admits a partial hold, but cannot buy the cheapest candidate.
++ await h.db.prepare('UPDATE exchange_key_months SET spent = 1.999 WHERE key_id = ?').run(made.key.id);
++ const capped = await call('/v1/search', searchBody);
++ assert.equal(capped.status, 429, capped.text);
++ assert.ok(capped.text.includes('exchange.key_cap'));
++ assert.equal(holds.length, 3);
++ assert.ok(Math.abs(holds[2] - .001) < 1e-12);
++ const afterCap = await settled(3);
++ assert.equal(afterCap.filter(row => row.model === 'search:/v1/search' && row.cost_usd === 0).length, 1);
++ assert.deepEqual(await month(), { spent: 1.999, reserved: 0 }, 'cap rejection releases its partial hold');
++ assert.equal(upstreamCalls, 1, 'cap rejection never calls the provider');
++
++ const stale = await keys.resolve(made.secret);
++ await h.db.prepare('UPDATE exchange_api_keys SET expires_at = ? WHERE id = ?').run('2000-01-01T00:00:00.000Z', made.key.id);
++ for (const [path, body] of [['/v1/search', searchBody], ['/v1/chat/completions', inferenceBody]]) {
++ assert.equal((await call(path, body)).status, 401, 'expired key rejects search and inference replay');
++ }
++ for (const search of [false, true]) {
++ await assert.rejects(keys.reserve(stale, search ? 'search:/v1/search' : 'openvibe', false, .005, search), e => e.status === 401);
++ }
++ assert.equal(holds.length, 3, 'expired keys take no hold at lookup or reserve');
++ assert.deepEqual(await usage(), afterCap);
++ assert.deepEqual(await month(), { spent: 1.999, reserved: 0 });
++ assert.equal(upstreamCalls, 1);
++
++ // Disconnect while the provider is pending: close must release the hold even without finish.
++ const disconnected = await keys.create(stale.owner, {
++ name: 'Disconnected search', monthly_cap_usd: 1, allowed_models: ['openvibe'],
++ });
++ const started = new Promise(resolve => { searchStarted = resolve; });
++ searchGate = new Promise(resolve => { releaseSearch = resolve; });
++ const controller = new AbortController();
++ const pending = fetch(h.base + '/v1/search', {
++ method: 'POST', signal: controller.signal,
++ headers: { authorization: 'Bearer ' + disconnected.secret, 'content-type': 'application/json' },
++ body: JSON.stringify(searchBody),
++ }).catch(error => error);
++ await started;
++ controller.abort();
++ await pending;
++ let closed;
++ for (let i = 0; i < 200; i++) {
++ closed = await h.db.prepare('SELECT spent,reserved FROM exchange_key_months WHERE key_id = ?').get(disconnected.key.id);
++ if (closed?.reserved === 0) break;
++ await new Promise(resolve => setTimeout(resolve, 10));
++ }
++ assert.deepEqual(closed, { spent: .005, reserved: 0 }, 'close settles unknown cost at the hold and releases it');
++ const unresolved = await h.db.prepare('SELECT status,cost_usd FROM exchange_key_usage WHERE key_id = ?').get(disconnected.key.id);
++ assert.deepEqual(unresolved, { status: 'unresolved', cost_usd: .005 });
++ releaseSearch();
++ console.log('Shared-key search, inference replay, expiry and partial-cap settlement passed');
++ } finally {
++ releaseSearch?.();
++ h.db.prepare = prepare;
++ await h.stop();
++ }
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/fixtures/search/README.md b/test/fixtures/search/README.md
nnew file mode 100644
iindex 0000000..1c600de
---- /dev/null
++++ b/test/fixtures/search/README.md
@@@ -0,0 +1,17 @@
++# Search samples
++
++These saved, synthetic API answers use the provider-documented response fields and example.org URLs. No live keys, paid calls or user content were used. Each engine file includes the expected transport and parsed result. The adapters contain the corresponding public source URLs and pricing check date (2026-11-09).
++
++DeepSeek: https://api-docs.deepseek.com/guides/search/
++Brave: https://api-dashboard.search.brave.com/app/documentation/image-search
++Exa: https://exa.ai/docs/reference/search
++Tavily: https://docs.tavily.com/documentation/api-reference/endpoint/search
++Serper: https://serper.dev/
++SerpAPI: https://serpapi.com/search-api
++Jina: https://jina.ai/reader/
++Perplexity: https://docs.perplexity.ai/docs/agent-api/quickstart
++Kagi v0: https://help.kagi.com/kagi/api/search-legacy.html
++Google: https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list
++Unsplash: https://unsplash.com/documentation#search-photos
++Pexels: https://www.pexels.com/api/documentation/#photos-search
++Pixabay: https://pixabay.com/api/docs/
ddiff --git a/test/fixtures/search/brave-images.json b/test/fixtures/search/brave-images.json
nnew file mode 100644
iindex 0000000..0a2e4e3
---- /dev/null
++++ b/test/fixtures/search/brave-images.json
@@@ -0,0 +1,27 @@
++{
++ "response": {
++ "results": [
++ {
++ "title": "Cat",
++ "url": "https://example.org/article",
++ "properties": {
++ "url": "https://images.example.org/cat.jpg",
++ "width": 2400,
++ "height": 1600
++ },
++ "thumbnail": {
++ "src": "https://images.example.org/thumb.jpg"
++ }
++ }
++ ]
++ },
++ "request": {
++ "host": "api.search.brave.com",
++ "path": "/res/v1/images/search",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Cat",
++ "cost": 0.005
++ }
++}
ddiff --git a/test/fixtures/search/brave.json b/test/fixtures/search/brave.json
nnew file mode 100644
iindex 0000000..4a96f04
---- /dev/null
++++ b/test/fixtures/search/brave.json
@@@ -0,0 +1,23 @@
++{
++ "response": {
++ "web": {
++ "results": [
++ {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "description": "A new lantern",
++ "page_age": "2026-11-08T12:00:00Z"
++ }
++ ]
++ }
++ },
++ "request": {
++ "host": "api.search.brave.com",
++ "path": "/res/v1/web/search",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.005
++ }
++}
ddiff --git a/test/fixtures/search/commons.json b/test/fixtures/search/commons.json
nnew file mode 100644
iindex 0000000..ead09ea
---- /dev/null
++++ b/test/fixtures/search/commons.json
@@@ -0,0 +1,41 @@
++{
++ "response": {
++ "query": {
++ "pages": {
++ "1": {
++ "title": "Cat",
++ "imageinfo": [
++ {
++ "url": "https://images.example.org/cat.jpg",
++ "thumburl": "https://images.example.org/thumb.jpg",
++ "width": 2400,
++ "height": 1600,
++ "descriptionurl": "https://example.org/article",
++ "mime": "image/jpeg",
++ "extmetadata": {
++ "LicenseShortName": {
++ "value": "CC BY 4.0"
++ },
++ "Artist": {
++ "value": "<b>Alex</b>"
++ },
++ "LicenseUrl": {
++ "value": "https://creativecommons.org/licenses/by/4.0/"
++ }
++ }
++ }
++ ]
++ }
++ }
++ }
++ },
++ "request": {
++ "host": "commons.wikimedia.org",
++ "path": "/w/api.php",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Cat",
++ "cost": 0
++ }
++}
ddiff --git a/test/fixtures/search/deepseek-fetch.json b/test/fixtures/search/deepseek-fetch.json
nnew file mode 100644
iindex 0000000..413556a
---- /dev/null
++++ b/test/fixtures/search/deepseek-fetch.json
@@@ -0,0 +1,4 @@
++{
++ "title": "Lantern",
++ "content": "# Lantern\nA new lantern"
++}
ddiff --git a/test/fixtures/search/deepseek.json b/test/fixtures/search/deepseek.json
nnew file mode 100644
iindex 0000000..2a686b8
---- /dev/null
++++ b/test/fixtures/search/deepseek.json
@@@ -0,0 +1,22 @@
++{
++ "response": {
++ "results": [
++ {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "snippet": "A new lantern",
++ "summary": "Long summary",
++ "date": "2026-11-08T12:00:00Z"
++ }
++ ]
++ },
++ "request": {
++ "host": "api.deepseek.com",
++ "path": "/v1/tools/search",
++ "method": "POST"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.00075
++ }
++}
ddiff --git a/test/fixtures/search/exa.json b/test/fixtures/search/exa.json
nnew file mode 100644
iindex 0000000..3770fe9
---- /dev/null
++++ b/test/fixtures/search/exa.json
@@@ -0,0 +1,26 @@
++{
++ "response": {
++ "results": [
++ {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "highlights": [
++ "A new lantern"
++ ],
++ "publishedDate": "2026-11-08T12:00:00Z"
++ }
++ ],
++ "costDollars": {
++ "total": 0.007
++ }
++ },
++ "request": {
++ "host": "api.exa.ai",
++ "path": "/search",
++ "method": "POST"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.007
++ }
++}
ddiff --git a/test/fixtures/search/google.json b/test/fixtures/search/google.json
nnew file mode 100644
iindex 0000000..abf1483
---- /dev/null
++++ b/test/fixtures/search/google.json
@@@ -0,0 +1,20 @@
++{
++ "response": {
++ "items": [
++ {
++ "title": "Lantern",
++ "link": "https://example.org/article",
++ "snippet": "A new lantern"
++ }
++ ]
++ },
++ "request": {
++ "host": "customsearch.googleapis.com",
++ "path": "/customsearch/v1",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.005
++ }
++}
ddiff --git a/test/fixtures/search/iconify-collections.json b/test/fixtures/search/iconify-collections.json
nnew file mode 100644
iindex 0000000..2b99f8b
---- /dev/null
++++ b/test/fixtures/search/iconify-collections.json
@@@ -0,0 +1,12 @@
++{
++ "demo": {
++ "license": {
++ "spdx": "MIT",
++ "url": "https://example.org/license"
++ },
++ "author": {
++ "name": "Alex",
++ "url": "https://example.org"
++ }
++ }
++}
ddiff --git a/test/fixtures/search/iconify.json b/test/fixtures/search/iconify.json
nnew file mode 100644
iindex 0000000..bf27488
---- /dev/null
++++ b/test/fixtures/search/iconify.json
@@@ -0,0 +1,16 @@
++{
++ "response": {
++ "icons": [
++ "demo:cat-line"
++ ]
++ },
++ "request": {
++ "host": "api.iconify.design",
++ "path": "/search",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "demo:cat-line",
++ "cost": 0
++ }
++}
ddiff --git a/test/fixtures/search/jina-fetch.json b/test/fixtures/search/jina-fetch.json
nnew file mode 100644
iindex 0000000..e9e571d
---- /dev/null
++++ b/test/fixtures/search/jina-fetch.json
@@@ -0,0 +1,11 @@
++{
++ "code": 200,
++ "data": {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "content": "# Lantern\nA new lantern",
++ "usage": {
++ "tokens": 2000
++ }
++ }
++}
ddiff --git a/test/fixtures/search/jina.json b/test/fixtures/search/jina.json
nnew file mode 100644
iindex 0000000..3b0a90a
---- /dev/null
++++ b/test/fixtures/search/jina.json
@@@ -0,0 +1,25 @@
++{
++ "response": {
++ "code": 200,
++ "data": [
++ {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "description": "A new lantern",
++ "publishedTime": "2026-11-08T12:00:00Z",
++ "usage": {
++ "tokens": 10000
++ }
++ }
++ ]
++ },
++ "request": {
++ "host": "s.jina.ai",
++ "path": "/",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.0005
++ }
++}
ddiff --git a/test/fixtures/search/kagi.json b/test/fixtures/search/kagi.json
nnew file mode 100644
iindex 0000000..72cec5d
---- /dev/null
++++ b/test/fixtures/search/kagi.json
@@@ -0,0 +1,26 @@
++{
++ "response": {
++ "data": [
++ {
++ "t": 1,
++ "title": "Image block"
++ },
++ {
++ "t": 0,
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "snippet": "A new lantern",
++ "published": "2026-11-08T12:00:00Z"
++ }
++ ]
++ },
++ "request": {
++ "host": "kagi.com",
++ "path": "/api/v0/search",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.025
++ }
++}
ddiff --git a/test/fixtures/search/openverse.json b/test/fixtures/search/openverse.json
nnew file mode 100644
iindex 0000000..32c900f
---- /dev/null
++++ b/test/fixtures/search/openverse.json
@@@ -0,0 +1,27 @@
++{
++ "response": {
++ "results": [
++ {
++ "title": "Cat",
++ "url": "https://images.example.org/cat.jpg",
++ "thumbnail": "https://images.example.org/thumb.jpg",
++ "width": 2400,
++ "height": 1600,
++ "license": "by",
++ "creator": "Alex",
++ "foreign_landing_url": "https://example.org/article",
++ "attribution": "Cat \u2014 Alex (CC BY)",
++ "license_url": "https://creativecommons.org/licenses/by/4.0/"
++ }
++ ]
++ },
++ "request": {
++ "host": "api.openverse.org",
++ "path": "/v1/images/",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Cat",
++ "cost": 0
++ }
++}
ddiff --git a/test/fixtures/search/openvibe.json b/test/fixtures/search/openvibe.json
nnew file mode 100644
iindex 0000000..bd1c38f
---- /dev/null
++++ b/test/fixtures/search/openvibe.json
@@@ -0,0 +1,21 @@
++{
++ "response": {
++ "results": [
++ {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "description": "A new lantern",
++ "published_at": "2026-11-08T12:00:00Z"
++ }
++ ]
++ },
++ "request": {
++ "host": "openvibe.search",
++ "path": "/api/v1/search",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0
++ }
++}
ddiff --git a/test/fixtures/search/perplexity.json b/test/fixtures/search/perplexity.json
nnew file mode 100644
iindex 0000000..c98d712
---- /dev/null
++++ b/test/fixtures/search/perplexity.json
@@@ -0,0 +1,45 @@
++{
++ "response": {
++ "output": [
++ {
++ "type": "search_results",
++ "queries": [
++ "lantern"
++ ],
++ "results": [
++ {
++ "id": 1,
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "date": "2026-11-08T12:00:00Z",
++ "snippet": "Source description"
++ }
++ ]
++ },
++ {
++ "type": "message",
++ "content": [
++ {
++ "type": "output_text",
++ "text": "A new lantern",
++ "annotations": []
++ }
++ ]
++ }
++ ],
++ "usage": {
++ "cost": {
++ "total_cost": 0.003
++ }
++ }
++ },
++ "request": {
++ "host": "api.perplexity.ai",
++ "path": "/v1/agent",
++ "method": "POST"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.003
++ }
++}
ddiff --git a/test/fixtures/search/pexels.json b/test/fixtures/search/pexels.json
nnew file mode 100644
iindex 0000000..129f04e
---- /dev/null
++++ b/test/fixtures/search/pexels.json
@@@ -0,0 +1,27 @@
++{
++ "response": {
++ "photos": [
++ {
++ "alt": "Cat",
++ "src": {
++ "original": "https://images.example.org/cat.jpg",
++ "medium": "https://images.example.org/thumb.jpg"
++ },
++ "width": 2400,
++ "height": 1600,
++ "photographer": "Alex",
++ "photographer_url": "https://www.pexels.com/@alex",
++ "url": "https://example.org/article"
++ }
++ ]
++ },
++ "request": {
++ "host": "api.pexels.com",
++ "path": "/v1/search",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Cat",
++ "cost": 0
++ }
++}
ddiff --git a/test/fixtures/search/pixabay.json b/test/fixtures/search/pixabay.json
nnew file mode 100644
iindex 0000000..1a5ef22
---- /dev/null
++++ b/test/fixtures/search/pixabay.json
@@@ -0,0 +1,24 @@
++{
++ "response": {
++ "hits": [
++ {
++ "tags": "Cat",
++ "largeImageURL": "https://images.example.org/cat.jpg",
++ "previewURL": "https://images.example.org/thumb.jpg",
++ "imageWidth": 2400,
++ "imageHeight": 1600,
++ "user": "Alex",
++ "pageURL": "https://example.org/article"
++ }
++ ]
++ },
++ "request": {
++ "host": "pixabay.com",
++ "path": "/api/",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Cat",
++ "cost": 0
++ }
++}
ddiff --git a/test/fixtures/search/searxng.json b/test/fixtures/search/searxng.json
nnew file mode 100644
iindex 0000000..f10db5e
---- /dev/null
++++ b/test/fixtures/search/searxng.json
@@@ -0,0 +1,21 @@
++{
++ "response": {
++ "results": [
++ {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "content": "A new lantern",
++ "publishedDate": "2026-11-08T12:00:00Z"
++ }
++ ]
++ },
++ "request": {
++ "host": "search.example.org",
++ "path": "/search",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0
++ }
++}
ddiff --git a/test/fixtures/search/serpapi.json b/test/fixtures/search/serpapi.json
nnew file mode 100644
iindex 0000000..7b6127b
---- /dev/null
++++ b/test/fixtures/search/serpapi.json
@@@ -0,0 +1,21 @@
++{
++ "response": {
++ "organic_results": [
++ {
++ "title": "Lantern",
++ "link": "https://example.org/article",
++ "snippet": "A new lantern",
++ "date": "2026-11-08T12:00:00Z"
++ }
++ ]
++ },
++ "request": {
++ "host": "serpapi.com",
++ "path": "/search.json",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.025
++ }
++}
ddiff --git a/test/fixtures/search/serper.json b/test/fixtures/search/serper.json
nnew file mode 100644
iindex 0000000..d39e031
---- /dev/null
++++ b/test/fixtures/search/serper.json
@@@ -0,0 +1,21 @@
++{
++ "response": {
++ "organic": [
++ {
++ "title": "Lantern",
++ "link": "https://example.org/article",
++ "snippet": "A new lantern",
++ "date": "2026-11-08T12:00:00Z"
++ }
++ ]
++ },
++ "request": {
++ "host": "google.serper.dev",
++ "path": "/search",
++ "method": "POST"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.001
++ }
++}
ddiff --git a/test/fixtures/search/tavily.json b/test/fixtures/search/tavily.json
nnew file mode 100644
iindex 0000000..8f36a5d
---- /dev/null
++++ b/test/fixtures/search/tavily.json
@@@ -0,0 +1,21 @@
++{
++ "response": {
++ "results": [
++ {
++ "title": "Lantern",
++ "url": "https://example.org/article",
++ "content": "A new lantern",
++ "published_date": "2026-11-08T12:00:00Z"
++ }
++ ]
++ },
++ "request": {
++ "host": "api.tavily.com",
++ "path": "/search",
++ "method": "POST"
++ },
++ "expected": {
++ "title": "Lantern",
++ "cost": 0.008
++ }
++}
ddiff --git a/test/fixtures/search/unsplash.json b/test/fixtures/search/unsplash.json
nnew file mode 100644
iindex 0000000..0520455
---- /dev/null
++++ b/test/fixtures/search/unsplash.json
@@@ -0,0 +1,34 @@
++{
++ "response": {
++ "results": [
++ {
++ "alt_description": "Cat",
++ "urls": {
++ "full": "https://images.example.org/cat.jpg",
++ "thumb": "https://images.example.org/thumb.jpg"
++ },
++ "width": 2400,
++ "height": 1600,
++ "user": {
++ "name": "Alex",
++ "links": {
++ "html": "https://unsplash.com/@alex"
++ }
++ },
++ "links": {
++ "html": "https://example.org/article",
++ "download_location": "https://api.unsplash.com/photos/cat/download"
++ }
++ }
++ ]
++ },
++ "request": {
++ "host": "api.unsplash.com",
++ "path": "/search/photos",
++ "method": "GET"
++ },
++ "expected": {
++ "title": "Cat",
++ "cost": 0
++ }
++}
ddiff --git a/test/helpers.js b/test/helpers.js
iindex d8bfe49..ed30413 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
--async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup } = {}) {
++async function boot({ searchFetch, env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup } = {}) {
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
const fullEnv = {
@@@ -62,7 +62,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;
-- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup });
++ const h = await start({ searchFetch, config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup });
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
iindex 6cfdc3f..d060582 100644
---- a/test/openapi.test.js
++++ b/test/openapi.test.js
@@@ -76,7 +76,7 @@ const excluded = (key) => {
const documented = Object.entries(doc.paths).flatMap(([p, item]) => Object.keys(item).map((m) => `${m.toUpperCase()} ${p}`)).sort();
assert.ok(documented.length > 0, 'paths is not empty');
for (const [p, item] of Object.entries(doc.paths)) {
-- assert.ok(p.startsWith('/api/'), `${p} is outside /api`);
++ assert.ok(p.startsWith('/api/') || ['/v1/search', '/v1/search/images', '/v1/search/icons', '/v1/fetch'].includes(p), `${p} is outside /api`);
for (const [method, op] of Object.entries(item)) {
assert.ok(op.operationId, `${method.toUpperCase()} ${p} has an operationId`);
assert.ok(op.responses && Object.keys(op.responses).length, `${method.toUpperCase()} ${p} describes its responses`);
@@@ -88,7 +88,7 @@ const excluded = (key) => {
assert.ok(!text.includes(`"${field}"`), `the document must not name ${field}`);
}
-- const live = [...mounted(h.app)].filter((key) => key.split(' ')[1].startsWith(`${MOUNT}/`) && !excluded(key)).sort();
++ const live = [...mounted(h.app)].filter((key) => (key.split(' ')[1].startsWith(`${MOUNT}/`) || /^POST \/v1\/(search(?:\/images|\/icons)?|fetch)$/.test(key)) && !excluded(key)).sort();
assert.deepStrictEqual(documented, live, 'every mounted public /api/v1 route is documented and vice versa');
assert.deepStrictEqual(routes(), documented, 'server/openapi.js routes() and the document agree');
ddiff --git a/test/search-adapters.test.js b/test/search-adapters.test.js
nnew file mode 100644
iindex 0000000..b8a9a62
---- /dev/null
++++ b/test/search-adapters.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { validate } = require('openvibe-contracts');
++const { createSearch, adapters, CHARGE } = require('../server/search');
++const { requestFor, publicTarget } = require('../server/search/transport');
++const fixture = name => require('./fixtures/search/' + name + '.json');
++const KEY = 'fixture-search-key';
++const json = value => new Response(JSON.stringify(value), { status: 200 });
++(async () => {
++ for (const a of adapters.filter(a => a.id !== 'openvibe-egress')) {
++ const sample = fixture(a.id), calls = [];
++ const env = { [a.key_env]: KEY, EXCHANGE_SEARXNG_URL: 'https://search.example.org', SEARCH_GOOGLE_CX: 'fixture-engine' };
++ const fetchImpl = async (input, init) => {
++ const u = new URL(input);
++ calls.push({ u, init });
++ assert.equal(u.hostname, sample.request.host, a.id);
++ assert.equal(init.redirect, 'error');
++ assert.ok(init.signal);
++ if (u.pathname === '/collections') return json(fixture('iconify-collections'));
++ assert.equal(u.pathname, sample.request.path, a.id);
++ assert.equal(init.method || 'GET', sample.request.method, a.id);
++ if (a.byok) assert.ok(JSON.stringify(init).includes(KEY) || u.search.includes(KEY), a.id + ' authenticates');
++ return json(sample.response);
++ };
++ const search = createSearch({ fetchImpl, env });
++ const kind = a.kinds[0];
++ const out = await search.search(kind, { q: 'lantern', engine: a.id });
++ assert.equal(out.results.length, 1, a.id);
++ assert.equal(out.results[0].title, sample.expected.title, a.id);
++ assert.equal(out.receipt.cost_usd, sample.expected.cost, a.id);
++ assert.equal(out[CHARGE], sample.expected.cost);
++ assert.equal(out.receipt.engine, a.id);
++ assert.equal(validate(kind === 'web' ? 'exchange.search-result@1' : 'exchange.image-result@1', out).valid, true, a.id);
++ assert.ok(!JSON.stringify(out).includes(KEY));
++ if (kind === 'web') {
++ assert.equal(out.results[0].url, 'https://example.org/article');
++ assert.equal(out.results[0].snippet, 'A new lantern', a.id);
++ assert.equal(out.results[0].published, a.id === 'google' ? null : '2026-11-08T12:00:00Z');
++ } else if (kind === 'images') {
++ const row = out.results[0];
++ assert.equal(row.url, 'https://images.example.org/cat.jpg');
++ assert.equal(row.thumbnail, 'https://images.example.org/thumb.jpg');
++ assert.deepEqual([row.width, row.height], [2400, 1600]);
++ assert.equal(row.source_page, 'https://example.org/article');
++ assert.equal(row.author, a.id === 'brave-images' ? null : 'Alex');
++ if (a.id === 'brave-images') assert.deepEqual([row.license, row.attribution], [null, null]);
++ else assert.ok(row.license && row.attribution.includes('Alex'));
++ }
++ const card = search.engines().find(e => e.id === a.id);
++ assert.equal(card.price_per_1000, a.price_per_1000);
++ assert.equal(card.source, a.source);
++ assert.equal(card.checked_on, '2026-11-09');
++ if (a.byok) {
++ assert.equal(createSearch({ env: {} }).engines().find(e => e.id === a.id).available, false);
++ await assert.rejects(createSearch({ env: {}, fetchImpl: () => assert.fail('unconfigured engine called') }).search(kind, { q: 'x', engine: a.id }));
++ }
++ if (a.freshness.length) {
++ await search.search(kind, { q: 'lantern', engine: a.id, freshness: 'day' });
++ const last = calls.at(-1), sent = last.u.href + (last.init.body || '');
++ assert.match(sent, /after|freshness|PublishedDate|time_range|tbs|dateRestrict/);
++ }
++ }
++ for (const id of ['deepseek', 'jina']) {
++ let guarded = false;
++ const s = createSearch({ env: { ['SEARCH_' + id.toUpperCase() + '_KEY']: KEY }, targetGuard: async url => { assert.equal(url, 'https://example.org/article'); guarded = true; }, fetchImpl: async (url, init) => {
++ assert.ok(guarded, 'target checked before any remote read');
++ assert.equal(JSON.parse(init.body).url, 'https://example.org/article');
++ return json(fixture(id + '-fetch'));
++ } });
++ const out = await s.read({ engine: id, url: 'https://example.org/article' });
++ assert.equal(out.text, '# Lantern\nA new lantern');
++ assert.equal(out.receipt.cost_usd, id === 'deepseek' ? .00075 : .0001);
++ }
++ // Explicit SSRF targets are rejected before a network connection, including remote readers.
++ for (const url of ['http://localhost', 'https://127.0.0.1', 'https://[::1]', 'https://user:[email protected]']) {
++ await assert.rejects(createSearch({ env: { SEARCH_JINA_KEY: KEY }, fetchImpl: () => assert.fail('unsafe call') }).read({ engine: 'jina', url }));
++ }
++ await assert.rejects(publicTarget('https://127.0.0.1'), /IP-literal/);
++ await assert.rejects(requestFor(['api.exa.ai'], () => assert.fail('wrong host called'))('https://evil.example/search'));
++ await assert.rejects(requestFor(['api.exa.ai'], () => assert.fail('wrong protocol called'))('http://api.exa.ai/search'));
++ // Private DNS answers and HTTP redirects never become a second outbound call.
++ const dns = require('node:dns'), originalLookup = dns.lookup;
++ dns.lookup = (_host, _opts, cb) => cb(null, [{ address: '10.0.0.1', family: 4 }]);
++ try { await assert.rejects(publicTarget('https://private.example'), /non-public/); }
++ finally { dns.lookup = originalLookup; }
++ let redirects = 0;
++ await assert.rejects(requestFor(['api.exa.ai'], async () => { redirects++; return new Response(null, { status: 302, headers: { location: 'https://127.0.0.1/' } }); })('https://api.exa.ai/search'));
++ assert.equal(redirects, 1);
++ const empty = await createSearch({ env: { SEARCH_DEEPSEEK_KEY: KEY }, fetchImpl: async () => json({ results: [] }) }).search('web', { engine: 'deepseek', q: 'x' });
++ assert.equal(empty.receipt.cost_usd, 0);
++ console.log('search adapters: saved responses, authentication, tariffs, contracts, metadata, freshness and guarded fetch passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/search-routing.test.js b/test/search-routing.test.js
nnew file mode 100644
iindex 0000000..7e6671a
---- /dev/null
++++ b/test/search-routing.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { createSearch, CHARGE } = require('../server/search');
++const { boot, request, token, ALL } = require('./helpers');
++const { createKeys } = require('../server/platform/keys');
++const { createCredentials } = require('../server/credentials');
++const json = (body, status = 200) => new Response(JSON.stringify(body), { status });
++const sample = id => require('./fixtures/search/' + id + '.json').response;
++const alice = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';
++const bob = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY';
++(async () => {
++ let time = 0;
++ const seen = [], own = { alice: { brave: 'alice-brave-key' }, bob: {} };
++ const s = createSearch({ env: { SEARCH_BRAVE_KEY: 'shared-brave-key', SEARCH_DEEPSEEK_KEY: 'shared-deepseek-key', SEARCH_SERPER_KEY: 'shared-serper-key' }, now: () => time,
++ credentials: { searchForRun: async (subject, engine) => own[subject]?.[engine] ? { apiKey: own[subject][engine] } : null },
++ fetchImpl: async (url, init) => {
++ const host = new URL(url).hostname;
++ seen.push({ host, headers: init.headers });
++ if (host === 'api.search.brave.com') return json(sample('brave'));
++ if (host === 'api.deepseek.com') return json(sample('deepseek'));
++ return json({}, 503);
++ },
++ });
++ const byok = await s.search('web', { q: 'x', freshness: 'day' }, { subject: 'alice' });
++ assert.equal(byok.receipt.engine, 'brave');
++ assert.equal(byok.receipt.cost_usd, .005);
++ assert.equal(byok[CHARGE], 0);
++ assert.equal(seen[0].headers['x-subscription-token'], 'alice-brave-key');
++ const shared = await s.search('web', { q: 'x', freshness: 'day', subject: 'alice' }, { subject: 'bob' });
++ assert.equal(shared.receipt.engine, 'deepseek', 'body cannot claim another person; cheapest fresh shared engine wins');
++ assert.equal(shared[CHARGE], .00075);
++ assert.equal((await s.search('web', { q: 'x', freshness: 'day', max_cost_usd: .001 })).receipt.engine, 'deepseek');
++ await assert.rejects(s.search('web', { q: 'x', engine: 'brave', max_cost_usd: 0 }), /No configured engine/);
++ const countPriced = createSearch({ env: { SEARCH_EXA_KEY: 'shared-exa', SEARCH_SERPAPI_KEY: 'shared-serpapi' }, fetchImpl: async url => {
++ assert.equal(new URL(url).hostname, 'serpapi.com', '50 Exa results cost more than SerpAPI despite the lower base tariff');
++ return json(sample('serpapi'));
++ } });
++ assert.equal((await countPriced.search('web', { q: 'lantern', freshness: 'day', limit: 50 })).receipt.engine, 'serpapi');
++ // Failed shared hosts cool down for a minute; one person's bad key does not poison ours or another person.
++ let fail = true, calls = 0;
++ const health = createSearch({ env: { SEARCH_BRAVE_KEY: 'shared', SEARCH_DEEPSEEK_KEY: 'shared' }, now: () => time,
++ credentials: { searchForRun: async (subject, engine) => subject === 'alice' && ['brave', 'deepseek'].includes(engine) ? { apiKey: 'bad-person-key' } : null },
++ fetchImpl: async (url, init) => {
++ calls++;
++ if (Object.values(init.headers).includes('bad-person-key') || Object.values(init.headers).includes('Bearer bad-person-key')) return json({}, 401);
++ if (url.includes('deepseek') && fail) return json({}, 503);
++ return json(sample(url.includes('deepseek') ? 'deepseek' : 'brave'));
++ },
++ });
++ await assert.rejects(health.search('web', { q: 'x', freshness: 'day' }, { subject: 'alice' }));
++ await assert.rejects(health.search('web', { q: 'x', freshness: 'day' }, { subject: 'alice' }));
++ assert.equal((await health.search('web', { q: 'x', freshness: 'day' })).receipt.engine, 'brave');
++ const afterFail = calls; fail = false;
++ assert.equal((await health.search('web', { q: 'x', freshness: 'day' })).receipt.engine, 'brave');
++ assert.equal(calls, afterFail + 1);
++ time = 60001;
++ assert.equal((await health.search('web', { q: 'x', freshness: 'day' })).receipt.engine, 'deepseek');
++ assert.equal(health.engines().find(e => e.id === 'deepseek').healthy, true);
++ // A possibly billed timeout consumes its ceiling, leaving no room to overspend on fallback.
++ let budgetCalls = 0;
++ const bounded = createSearch({ env: { SEARCH_DEEPSEEK_KEY: 'shared', SEARCH_SERPER_KEY: 'shared' }, fetchImpl: async () => { budgetCalls++; throw new Error('timeout with upstream details'); } });
++ await assert.rejects(bounded.search('web', { q: 'x', freshness: 'day', max_cost_usd: .001 }), e => e[CHARGE] === .00075 && !e.message.includes('upstream details'));
++ assert.equal(budgetCalls, 1);
++ const refusedReader = createSearch({ env: { SEARCH_DEEPSEEK_KEY: 'shared' }, targetGuard: async () => { throw new Error('private target'); }, fetchImpl: () => assert.fail('private target called') });
++ await assert.rejects(refusedReader.read({ engine: 'deepseek', url: 'https://private.example' }), e => e[CHARGE] === 0);
++ const unknownLicense = createSearch({ env: { SEARCH_BRAVE_IMAGES_KEY: 'fixture-key' }, fetchImpl: async () => json(sample('brave-images')) });
++ assert.equal((await unknownLicense.search('images', { engine: 'brave-images', q: 'cat', commercial: true })).results.length, 0);
++ let targetChecks = 0;
++ const reader = createSearch({ env: {}, credentials: { searchForRun: async (_s, engine) => engine === 'jina' ? { apiKey: 'own-jina' } : null }, targetGuard: async () => { targetChecks++; }, fetchImpl: async () => json(require('./fixtures/search/jina-fetch.json')) });
++ const read = await reader.read({ url: 'https://example.org/article' }, { subject: alice });
++ assert.equal(read.receipt.engine, 'jina'); assert.equal(read[CHARGE], 0); assert.equal(targetChecks, 1);
++
++ const cipherKey = 'ab'.repeat(32), callsHttp = [];
++ const h = await boot({ env: { AI_CREDENTIALS_KEY: cipherKey, SEARCH_BRAVE_KEY: 'shared-brave-key' }, searchFetch: async (url, init) => { callsHttp.push({ url, init }); return json(sample('brave')); } });
++ try {
++ const creds = createCredentials({ db: h.db, config: { credentialsKey: cipherKey } });
++ const tok = token('alice', ALL, { sub: alice, actorType: 'user' });
++ const bobTok = token('bob', ALL, { sub: bob, actorType: 'user' });
++ const service = token('actor', ALL, { extra: { on_behalf_of: alice } });
++ const path = '/api/v1/me/search-credentials/brave';
++ const stored = await request(h.base, 'PUT', path, { tok, body: { key: 'alice-search-fixture' } });
++ assert.equal(stored.status, 200, JSON.stringify(stored.body));
++ assert.equal(callsHttp.length, 0, 'save makes no paid call');
++ assert.equal((await request(h.base, 'PUT', path, { tok: service, body: { key: 'intruder-fixture-key' } })).status, 403);
++ assert.equal((await request(h.base, 'GET', '/api/v1/me/search-credentials', { tok: bobTok })).body.engines.length, 0);
++ assert.equal((await creds.listSelf(alice)).length, 0, 'search keys never enter model routing');
++ assert.equal((await creds.searchForRun(alice, 'brave')).apiKey, 'alice-search-fixture');
++ const row = await h.db.prepare('SELECT key_enc FROM subject_credentials WHERE subject = ? AND provider = ?').get(alice, 'search-brave');
++ assert.ok(row.key_enc.startsWith('v2:') && !row.key_enc.includes('alice-search-fixture'));
++ await h.db.prepare('UPDATE subject_credentials SET subject = ? WHERE subject = ? AND provider = ?').run(bob, alice, 'search-brave');
++ await assert.rejects(creds.searchForRun(bob, 'brave'), 'ciphertext cannot move between people');
++ await h.db.prepare('UPDATE subject_credentials SET subject = ? WHERE subject = ? AND provider = ?').run(alice, bob, 'search-brave');
++ const keys = createKeys({ db: h.db });
++ const freeKey = await keys.create(alice, { name: 'BYOK only', monthly_cap_usd: 0, allowed_models: ['unrelated-model'] });
++ const result = await request(h.base, 'POST', '/v1/search', { tok: freeKey.secret, body: { q: 'lantern', engine: 'brave' } });
++ assert.equal(result.status, 200, JSON.stringify(result.body));
++ assert.equal(result.body.receipt.cost_usd, .005);
++ assert.equal(callsHttp.at(-1).init.headers['x-subscription-token'], 'alice-search-fixture');
++ assert.equal(JSON.stringify(result.body).includes('alice-search-fixture'), false);
++ const paidKey = await keys.create(bob, { name: 'One search', monthly_cap_usd: .005, allowed_models: ['unrelated-model'] });
++ const results = await Promise.all([1, 2].map(() => request(h.base, 'POST', '/v1/search', { tok: paidKey.secret, body: { q: 'lantern', engine: 'brave' } })));
++ assert.deepEqual(results.map(r => r.status).sort(), [200, 429], 'concurrent calls cannot exceed the cap');
++ for (let i = 0; i < 100; i++) {
++ const usage = (await keys.usage(bob)).rows;
++ if (usage.some(r => r.status === 'settled')) break;
++ await new Promise(resolve => setTimeout(resolve, 10));
++ }
++ const paid = (await keys.usage(bob)).rows;
++ assert.equal(paid.length, 1);
++ assert.equal(paid[0].status, 'settled');
++ assert.equal(paid[0].cost_usd, .005);
++ assert.equal((await keys.usage(alice)).rows[0].cost_usd, 0, 'BYOK is not billed by Exchange');
++ await request(h.base, 'DELETE', path, { tok });
++ assert.equal(await creds.searchForRun(alice, 'brave'), null);
++ } finally { await h.stop(); }
++ console.log('search routing: BYOK isolation, encrypted storage, no save-time spend, health, freshness, price and concurrent paid caps passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });