openvibers/openvibe.ai Commits
Record ordered web and image-search attempts (#449)
Changes
@@@ -1099,7 +1099,7 @@ ai.openvibe.network notice from OpenVibe.Sites.Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.389.0++- openvibe-contracts: v0.399.0- openvibe-sdk: v0.125.0- openvibe-shared: v4.199.0<!-- versions:end -->@@@ -7,7 +7,7 @@"runtime": "node 22, express, postgresql","port": 4700,"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",-- "contracts": "openvibe-contracts v0.389.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",++ "contracts": "openvibe-contracts v0.399.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)","sdk": "openvibe-sdk v0.125.0","shared": "openvibe-shared v4.199.0","capabilities": [@@@ -1,6 +1,6 @@# 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.++`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`. Contracts **0.399.0** carries the search receipts and their optional ordered attempts.```json{"q":"solar lantern","engine":"auto","freshness":"month","limit":10,"max_cost_usd":0.01}@@@ -8,7 +8,7 @@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.++Every response has `receipt: {engine, cost_usd, time_ms, attempts?}`. Cost is the provider cost in USD, and time includes fallback attempts. `attempts` lists each engine in the order it was considered: `provider`, `engine`, `status` (`ok`, `error` or `skipped`), a safe `reason` (`disabled`, `unsupported`, `no_credentials`, `circuit_open`, `daily_cap`, `budget`, `timeout`, `upstream_error` or `invalid_response`), its `time_ms` and its `cost_usd`. Never an upstream error body or a credential. A provider turned off by its owner, one in cooldown and one over `max_cost_usd` stay in that list as skipped with their reason. 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.@@@ -32,9 +32,13 @@ Prices checked **2026-11-09**, USD per 1,000 requests unless qualified. `GET /ap| 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) |++| serper-images | SEARCH_SERPER_KEY | $1, shares the Serper pack | [Serper](https://serper.dev/) |++| meta | SEARCH_META_KEY | Muse Spark 1.3 tokens plus $2.50/1,000 queries | [Meta](https://dev.meta.ai/docs/pricing-rate-limits) |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.++Meta web search uses the Responses API with Muse Spark 1.3, one built-in query and `store: false`; it is explicit selection only (`engine: "meta"`) and reserves a $4 ceiling for the bounded call. Serper image search uses `/images`, shares the Serper credential, and is also explicit selection only (`engine: "serper-images"`).++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@@@ -49,10 +53,10 @@ Keys use the existing AES-256-GCM store and encryption configuration, bound to `## 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.++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 and Serper'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.++`ov test search-adapters search-routing search-attempts 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.@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0","pg": "^8.23.0"@@@ -1624,9 +1624,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.389.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",-- "integrity": "sha512-kA5uAQIDy/4a0MomMBl0VsgLKgLvLBFB5afRIm8DCx/l/OfM5wX5JJEaPot08WZfi+HSNpeXwIpEsYz5/w/VRA==",++ "version": "0.399.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0","pg": "^8.23.0"@@@ -146,7 +146,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, pconst 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, credentials, env, searchFetch });++ const exchangePlatform = require('./platform/router').platform({ db, auth, clock, log, credentials, registry, 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@@@ -5,12 +5,12 @@ const { AiError, sendError } = require('../util');const { createKeys } = require('./keys');const { claimRequest } = require('./idempotency');const { createSearch, CHARGE } = require('../search');--function platform({ db, auth, clock, log, searchFetch, credentials, env = process.env }) {++function platform({ db, auth, clock, log, searchFetch, credentials, registry, env = process.env }) {const keys = createKeys({ db, clock });const presets = require('../exchange/compat-presets').createCompatPresets(db);const search = createSearch({...(searchFetch ? { fetchImpl: searchFetch } : {}),-- now: () => clock.now(), credentials, env,++ now: () => clock.now(), credentials, registry, env,});const r = express.Router();const wrap = (fn) => async (req, res) => {@@@ -64,8 +64,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = processubject: 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() }),++ r.get('/api/v1/exchange/search/engines', wrap(async () =>++ ({ engines: await search.engines() })),);const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);const searchWindows = new Map();@@@ -680,7 +680,8 @@ const PROVIDERS = [}),// Providers without a hand-kept model list: the owner syncs and enables their models.{ key: 'meta', display_name: 'Meta Model API', base_url: 'https://api.meta.ai/v1',-- docs_url: 'https://dev.meta.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools'] },++ docs_url: 'https://dev.meta.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'web-search'],++ search_adapters: ['meta'] },{ key: 'deepseek', keys_url: 'https://platform.deepseek.com/api_keys', display_name: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', default_model: 'deepseek-flash',docs_url: 'https://api-docs.deepseek.com/', capabilities: ['chat', 'vision', 'tools', 'web-search'], search_adapters: ['deepseek'] },{ key: 'anthropic', keys_url: 'https://console.anthropic.com/settings/keys', display_name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', auth_mode: 'x-api-key',@@@ -103,7 +103,7 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.envdefaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;defaults.metadata.check_endpoint = null;}-- const metadata = { ...defaults.metadata, ...prev.metadata };++ const metadata = { ...defaults.metadata, ...prev.metadata, search_adapters: [...new Set([...(prev.metadata.search_adapters || []), ...(defaults.metadata.search_adapters || [])])] };if (!configured && prev.base_url !== p.base_url) {metadata.models_endpoint = defaults.metadata.models_endpoint;metadata.api_family = defaults.metadata.api_family;@@@ -126,6 +126,8 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.envprotocol.check_endpoint = null;}const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));++ const engines = [...new Set([...(prev.metadata.search_adapters || []), ...(protocol.search_adapters || [])])];++ if (engines.length !== (prev.metadata.search_adapters || []).length) additions.search_adapters = engines;if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });}for (const m of p.models) {nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++const { requestFor, post, bearer, web } = require('../transport');++const MAX_OUTPUT = 512;++module.exports = {++ id: 'meta', byok: true, manual_only: true, registry_required: true, checked_on: '2026-11-10',++ kinds: ['web'], key_env: 'SEARCH_META_KEY', hosts: ['api.meta.ai'], freshness: [],++ price_per_1000: null, source: 'https://dev.meta.ai/docs/pricing-rate-limits',++ pricing_note: 'Muse Spark 1.3 Standard: $1.25/M input, $0.15/M cached, $4.25/M output plus $2.50/1000 queries. Explicit selection only; up to $4 reserved for one built-in call and its model iterations.',++ // One built-in call permits at most two model iterations. Reserve both full context windows,++ // the output budget, and conservatively one search query per generated token.++ ceiling: () => 4,++ estimate: () => 0.01,++ async search(b, { fetchImpl, key }) {++ const out = await requestFor(this.hosts, fetchImpl)('https://api.meta.ai/v1/responses', post({++ model: 'muse-spark-1.3', input: b.q, instructions: 'Search the web for this question. Use one concise search query and cite the sources.',++ tools: [{ type: 'web_search', search_context_size: 'low' }], max_tool_calls: 1,++ include: ['web_search_call.results'], max_output_tokens: MAX_OUTPUT, store: false,++ }, bearer(key)));++ const usage = out.usage;++ const input = usage?.input_tokens, output = usage?.output_tokens, cached = usage?.input_tokens_details?.cached_tokens ?? 0;++ if (![input, output, cached].every(n => Number.isSafeInteger(n) && n >= 0) || cached > input) throw new Error('Meta usage is missing');++ const searches = (out.output || []).filter(item => item.type === 'web_search_call');++ let queries = 0;++ const rows = [];++ for (const call of searches) {++ if (call.status !== 'completed' || call.action?.type !== 'search') throw new Error('Meta search did not complete');++ const issued = Array.isArray(call.action.queries) ? call.action.queries : typeof call.action.query === 'string' ? [call.action.query] : null;++ if (!issued?.length || issued.some(q => typeof q !== 'string' || !q.trim())) throw new Error('Meta search query usage is missing');++ queries += issued.length;++ for (const result of call.results || []) if (result.type === 'text_result') rows.push(web(result, result.snippet, null));++ }++ if (!searches.length || !rows.length) throw new Error('Meta returned no search results');++ const cost = ((input - cached) * 1.25 + cached * 0.15 + output * 4.25) / 1e6 + queries * 0.0025;++ return { rows, cost };++ },++};nnew file mode 100644@@@ -0,0 +1,14 @@++'use strict';++const { requestFor, post } = require('../transport');++module.exports = {++ id: 'serper-images', credential_id: 'serper', byok: true, manual_only: true, checked_on: '2026-11-10',++ kinds: ['images'], key_env: 'SEARCH_SERPER_KEY', hosts: ['google.serper.dev'], freshness: [],++ price_per_1000: 1, source: 'https://serper.dev/',++ pricing_note: 'Starter prepaid pack: $50 for 50000 credits. Search results do not establish reuse rights.',++ async search(b, { fetchImpl, key }) {++ const out = await requestFor(this.hosts, fetchImpl)('https://google.serper.dev/images', post({ q: b.q, num: Math.min(b.limit, 10) }, { 'x-api-key': key }));++ return (out.images || []).map(r => ({ title: r.title || '', url: r.imageUrl, snippet: '', published: null,++ thumbnail: r.thumbnailUrl, width: r.imageWidth, height: r.imageHeight, source_page: r.link,++ license: null, license_url: null, author: null, attribution: null }));++ },++};@@@ -1,13 +1,14 @@'use strict';const { guardedFetch } = require('../providers/guarded-fetch');const { publicTarget } = require('./transport');--const { AiError } = require('../util');++const { AiError, providerSecret } = require('../util');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']++ 'openvibe-egress', 'perplexity', 'kagi', 'google', 'unsplash', 'pexels', 'pixabay', 'brave-images',++ 'meta', 'serper-images'].map(n => require('./adapters/' + n));const CHARGE = Symbol('search Exchange charge');const invalid = (message) => new AiError(422, 'ai.invalid', message);@@@ -54,57 +55,110 @@ function normalize(rows, engine, kind, b) {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 } = {}) {++function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () => Date.now(), credentials = null, registry = null, targetGuard = publicTarget } = {}) {const health = new Map();-- 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: 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,-- }));++ const providerFor = (a, providers) => providers.find(p => p.metadata?.search_adapters?.includes(a.id));++ function binding(a, providers) {++ const provider = providerFor(a, providers);++ // Existing environment-backed search stays available until the owner manages its registry row.++ // New adapters require an enabled registry entry when running inside the service.++ const managed = Boolean(registry && (a.registry_required || provider && (provider.origin === 'admin' || provider.status === 'active' || providerSecret(provider, env))));++ const key = managed ? providerSecret(provider, env) || (a.key_env && env[a.key_env]) : a.key_env && env[a.key_env];++ const settings = a.setting === 'EXCHANGE_SEARXNG_URL' && managed && provider?.base_url ? { ...env, EXCHANGE_SEARXNG_URL: provider.base_url } : env;++ const configured = (!a.key_env || Boolean(key)) && (!a.setting || Boolean(settings[a.setting])) && (a.requires || []).every(k => settings[k]);++ if (managed && (!provider || provider.status !== 'active')) return { provider, managed, key, env: settings, configured, available: false };++ return { provider, managed, key, env: settings, configured, available: configured };++ }++ async function engines() {++ const providers = registry ? await registry.listProviders() : [];++ return adapters.map(a => {++ const state = binding(a, providers);++ return { id: a.id, kinds: a.kinds, price_per_1000: a.price_per_1000,++ byok: a.byok, available: state.available, freshness: a.freshness, healthy: (health.get(a.id) || 0) <= now(),++ provider: state.provider?.key || null, manual_only: a.manual_only === true,++ source: a.source, checked_on: a.checked_on, pricing_note: a.pricing_note || null };++ });}async function prepare(kind, body, { subject = null } = {}) {const b = inputFor(kind, body);if (b.type === 'icon') kind = 'icons';for (const [k, until] of health) if (until <= now()) health.delete(k);const candidates = [];++ const providers = registry ? await registry.listProviders() : [];for (const a of adapters) {++ if (a.manual_only && (!b.engine || b.engine === 'auto')) continue;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 state = binding(a, providers);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 });++ if (!own && !state.available) {++ // An owner-managed provider that is off stays in the plan so the receipt says why it was skipped.++ if (state.managed && state.provider && state.configured)++ candidates.push({ a, key: state.key, own: false, healthKey: a.id, ceiling, provider: state.provider.key, managed: true, env: state.env, skipReason: 'disabled' });++ continue;++ }++ const key = own?.apiKey || state.key;++ if (a.key_env && !key || a.setting && !state.env?.[a.setting]) continue;++ const healthKey = own ? `${a.id}:${createHash('sha256').update(key).digest('hex')}` : a.id;++ candidates.push({ a, key, own: Boolean(own), healthKey, ceiling, provider: state.provider?.key || null, managed: state.managed, env: state.env || env,++ skipReason: b.max_cost_usd != null && ceiling > b.max_cost_usd ? 'budget' : null });}// 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));++ // A cooldown is a skip in the receipt, not a disappearance; an explicit engine may still retry.++ const selected = cohort.map(c => {++ if (!c.skipReason && !(b.engine && b.engine !== 'auto') && health.has(c.healthKey)) c.skipReason = 'circuit_open';++ return c;++ });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 runnable = selected.filter(c => !c.skipReason);++ if (!runnable.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, runnable.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) });++ const receipt = (engine, cost, start, attempts) => ({ engine, cost_usd: cost, time_ms: Math.max(0, now() - start), ...(attempts ? { attempts } : {}) });async function execute(plan, budget = Infinity) {const start = now();let incurred = 0, charge = 0;++ const attempts = [];++ const attempt = (c, status, started, cost, reason) => ({ provider: c.provider || credentialId(c.a), engine: c.a.id, status,++ ...(reason ? { reason } : {}), time_ms: Math.max(0, now() - started), cost_usd: cost });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;++ const attemptStart = now();++ if (c.skipReason) { attempts.push(attempt(c, 'skipped', attemptStart, 0, c.skipReason)); continue; }++ if ((!c.own && c.ceiling + charge > budget + 1e-12) || c.ceiling + incurred > (plan.b.max_cost_usd ?? Infinity) + 1e-12) {++ attempts.push(attempt(c, 'skipped', attemptStart, 0, 'budget')); continue;++ }let attempted = false;try {if (plan.kind === 'fetch' && c.a.id !== 'openvibe-egress') await targetGuard(plan.b.url);-- const options = { fetchImpl, key: c.key, env, now };++ let boundFetch = fetchImpl;++ if (!c.own && c.managed) {++ const current = await registry.getProvider(c.provider);++ if (!current || current.status !== 'active') { attempts.push(attempt(c, 'skipped', attemptStart, 0, 'disabled')); continue; }++ const expected = current.base_url && new URL(current.base_url).origin;++ // The adapter's own host allowlist covers multi-host readers (Jina); the provider's++ // registered origin covers a configured SearXNG address. Nothing else gets the key.++ const hosts = new Set((c.a.hosts || []).map(h => 'https://' + h));++ boundFetch = (url, init) => {++ const origin = new URL(String(url)).origin;++ if (!(expected && origin === expected) && !hosts.has(origin)) throw Object.assign(new Error('Search endpoint does not match the provider'), { unbilled: true });++ return fetchImpl(url, init);++ };++ // Key replacement/removal takes effect even for a previously prepared plan.++ c.key = providerSecret(current, env) || (c.a.key_env && env[c.a.key_env]);++ if (c.a.key_env && !c.key) { attempts.push(attempt(c, 'skipped', attemptStart, 0, 'no_credentials')); continue; }++ }++ const options = { fetchImpl: boundFetch, key: c.key, env: c.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) };++ ? { url: plan.b.url, text: String(raw.text || '').slice(0, 200000), receipt: receipt(c.a.id, cost + incurred, start, [...attempts, attempt(c, 'ok', attemptStart, cost)]) }++ : { results: normalize(Array.isArray(raw) ? raw : raw.rows, c.a.id, plan.kind, plan.b), receipt: receipt(c.a.id, cost + incurred, start, [...attempts, attempt(c, 'ok', attemptStart, cost)]) };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) });@@@ -112,12 +166,16 @@ function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () =>} 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; }++ const failedCost = attempted && !error.unbilled ? c.ceiling : 0;++ const reason = ['AbortError', 'TimeoutError'].includes(error.name) || error.code === 'provider.timeout' ? 'timeout'++ : /usage is missing|Invalid engine cost|Invalid search response/.test(error.message || '') ? 'invalid_response' : 'upstream_error';++ attempts.push(attempt(c, 'error', attemptStart, failedCost, reason));++ if (failedCost) { incurred += failedCost; if (!c.own) charge += failedCost; }health.set(c.healthKey, now() + 60000);if (health.size > 1000) health.delete(health.keys().next().value);}}-- throw Object.assign(new AiError(503, 'search.unavailable', 'The selected engines did not answer'), { [CHARGE]: charge });++ throw Object.assign(new AiError(503, 'search.unavailable', 'The selected engines did not answer', { attempts }), { [CHARGE]: charge });}async function search(kind, b, context = {}) {return execute(context.plan || await prepare(kind, b, context), context.budget);@@@ -18,7 +18,7 @@ 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.',++ description: 'Accepts an Exchange key, person token, authorized service or app token. Receipt reports engine, provider cost in USD and elapsed milliseconds, including fallbacks, and lists each ordered attempt (ok, error or skipped) with its provider, engine, safe reason, latency and cost. 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' ? {} : {nnew file mode 100644@@@ -0,0 +1,41 @@++{++ "request": {++ "host": "api.meta.ai",++ "path": "/v1/responses",++ "method": "POST"++ },++ "response": {++ "usage": {++ "input_tokens": 100,++ "input_tokens_details": {++ "cached_tokens": 20++ },++ "output_tokens": 10++ },++ "output": [++ {++ "type": "web_search_call",++ "status": "completed",++ "action": {++ "type": "search",++ "queries": [++ "lantern",++ "new lantern"++ ]++ },++ "results": [++ {++ "type": "text_result",++ "title": "Lantern",++ "url": "https://example.org/article",++ "snippet": "A new lantern"++ }++ ]++ }++ ]++ },++ "expected": {++ "title": "Lantern",++ "cost": 0.0051455++ }++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "request": {++ "host": "google.serper.dev",++ "path": "/images",++ "method": "POST"++ },++ "response": {++ "images": [++ {++ "title": "Cat",++ "imageUrl": "https://images.example.org/cat.jpg",++ "thumbnailUrl": "https://images.example.org/thumb.jpg",++ "imageWidth": 2400,++ "imageHeight": 1600,++ "link": "https://example.org/article"++ }++ ]++ },++ "expected": {++ "title": "Cat",++ "cost": 0.001++ }++}@@@ -35,23 +35,23 @@ const json = value => new Response(JSON.stringify(value), { status: 200 });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');++ assert.equal(out.results[0].published, ['google', 'meta'].includes(a.id) ? 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]);++ assert.equal(row.author, ['brave-images', 'serper-images'].includes(a.id) ? null : 'Alex');++ if (['brave-images', 'serper-images'].includes(a.id)) 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);++ const card = (await 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');++ assert.equal(card.checked_on, ['meta', 'serper-images'].includes(a.id) ? '2026-11-10' : '2026-11-09');if (a.byok) {-- assert.equal(createSearch({ env: {} }).engines().find(e => e.id === a.id).available, false);++ assert.equal((await 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) {nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++const assert = require('node:assert/strict');++const { validate } = require('openvibe-contracts');++const { createSearch, CHARGE } = require('../server/search');++require('../server/search/adapters/meta');++require('../server/search/adapters/serper-images');++const sample = id => require('./fixtures/search/' + id + '.json').response;++const json = body => new Response(JSON.stringify(body), { status: 200 });++const host = url => new URL(url).hostname;++const order = out => out.receipt.attempts.map(a => [a.provider, a.engine, a.status, a.reason || null]);++++(async () => {++ // A circuit-open engine, a provider that refuses, then the answer: every attempt stays in order.++ const search = createSearch({++ env: { SEARCH_DEEPSEEK_KEY: 'deepseek-key', SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' },++ fetchImpl: async (url) => {++ const h = host(url);++ if (h === 'api.deepseek.com') throw Object.assign(new Error('upstream took too long'), { name: 'TimeoutError' });++ if (h === 'google.serper.dev') throw new Error('Search engine refused the request');++ if (h === 'api.search.brave.com') return json(sample('brave'));++ throw new Error('unexpected host ' + h);++ },++ });++ await assert.rejects(search.search('web', { q: 'lantern', freshness: 'day', engine: 'deepseek' }));++ const fallback = await search.search('web', { q: 'lantern', freshness: 'day' });++ assert.equal(fallback.receipt.engine, 'brave');++ assert.deepEqual(order(fallback), [++ ['deepseek', 'deepseek', 'skipped', 'circuit_open'],++ ['serper', 'serper', 'error', 'upstream_error'],++ ['brave', 'brave', 'ok', null],++ ]);++ assert.deepEqual(fallback.receipt.attempts.map(a => a.cost_usd), [0, 0.001, 0.005]);++ assert.equal(fallback.receipt.cost_usd, 0.006);++ assert.equal(fallback[CHARGE], 0.006);++ assert.equal(validate('exchange.search-result@1', fallback).valid, true);++ assert.equal(JSON.stringify(fallback).includes('refused the request'), false, 'no upstream error body is kept');++++ // A provider that refuses the request is an error attempt; the next provider still answers.++ const refused = createSearch({++ env: { SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' },++ fetchImpl: async (url) => {++ const h = host(url);++ if (h === 'google.serper.dev') throw new Error('Search engine refused the request');++ if (h === 'api.search.brave.com') return json(sample('brave'));++ throw new Error('unexpected host ' + h);++ },++ });++ const afterRefusal = await refused.search('web', { q: 'x', freshness: 'day' });++ assert.deepEqual(order(afterRefusal), [++ ['serper', 'serper', 'error', 'upstream_error'],++ ['brave', 'brave', 'ok', null],++ ]);++ assert.equal(afterRefusal.receipt.cost_usd, 0.006);++++ // An owner-disabled provider is a skipped attempt with its reason, both before the plan and between plan and call.++ const serper = { key: 'serper', status: 'active', origin: 'admin', base_url: 'https://google.serper.dev', secret_ref: 'env:SEARCH_SERPER_KEY', metadata: { search_adapters: ['serper', 'serper-images'] } };++ const brave = { key: 'brave', status: 'active', origin: 'catalog', base_url: 'https://api.search.brave.com/res/v1', secret_ref: 'env:SEARCH_BRAVE_KEY', metadata: { search_adapters: ['brave', 'brave-images'] } };++ const registry = { listProviders: async () => [serper, brave], getProvider: async key => key === 'serper' ? serper : key === 'brave' ? brave : null };++ const denied = createSearch({++ env: { SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' }, registry,++ fetchImpl: async (url) => {++ const h = host(url);++ if (h === 'api.search.brave.com') return json(sample('brave'));++ throw new Error('disabled provider was called');++ },++ });++ const plan = await denied.prepare('web', { q: 'x', freshness: 'day' });++ assert.equal(plan.candidates[0].provider, 'serper');++ serper.status = 'disabled';++ const between = await denied.search('web', { q: 'x', freshness: 'day' }, { plan });++ assert.deepEqual(order(between), [++ ['serper', 'serper', 'skipped', 'disabled'],++ ['brave', 'brave', 'ok', null],++ ]);++ const after = await denied.search('web', { q: 'x', freshness: 'day' });++ assert.deepEqual(order(after), [++ ['serper', 'serper', 'skipped', 'disabled'],++ ['brave', 'brave', 'ok', null],++ ]);++ assert.equal((await denied.engines()).find(e => e.id === 'serper').available, false);++ assert.equal((await denied.engines()).find(e => e.id === 'brave').available, true);++++ // A provider over the call's spend cap is skipped as budget rather than called.++ const capped = createSearch({ env: { SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' }, fetchImpl: async (url) => {++ const h = host(url);++ if (h === 'google.serper.dev') return json(sample('serper'));++ throw new Error('provider over the cap was called');++ } });++ const cappedPlan = await capped.prepare('web', { q: 'x', freshness: 'day', max_cost_usd: 0.002 });++ const braveCandidate = cappedPlan.candidates.find(c => c.a.id === 'brave');++ const serperCandidate = cappedPlan.candidates.find(c => c.a.id === 'serper');++ assert.equal(braveCandidate.skipReason, 'budget');++ cappedPlan.candidates = [braveCandidate, serperCandidate];++ const capAnswer = await capped.search('web', { q: 'x', freshness: 'day', max_cost_usd: 0.002 }, { plan: cappedPlan });++ assert.deepEqual(order(capAnswer), [++ ['brave', 'brave', 'skipped', 'budget'],++ ['serper', 'serper', 'ok', null],++ ]);++++ // Image search writes the same ordered attempts and validates against the image contract.++ const images = createSearch({++ env: { SEARCH_SERPER_KEY: 'serper-key' },++ fetchImpl: async (url) => {++ assert.equal(host(url), 'google.serper.dev');++ return json(sample('serper-images'));++ },++ });++ const imageAnswer = await images.search('images', { q: 'cat', engine: 'serper-images' });++ assert.deepEqual(order(imageAnswer), [['serper', 'serper-images', 'ok', null]]);++ assert.equal(validate('exchange.image-result@1', imageAnswer).valid, true);++++ // A managed reader may use either of its registered hosts (Jina reads from r.jina.ai).++ const jina = { key: 'jina', status: 'active', origin: 'admin', base_url: 'https://s.jina.ai', secret_ref: 'env:SEARCH_JINA_KEY', metadata: { search_adapters: ['jina'] } };++ const reader = createSearch({++ env: { SEARCH_JINA_KEY: 'jina-key' },++ registry: { listProviders: async () => [jina], getProvider: async () => jina },++ targetGuard: async () => {},++ fetchImpl: async (url) => {++ assert.equal(host(url), 'r.jina.ai');++ return json(require('./fixtures/search/jina-fetch.json'));++ },++ });++ const read = await reader.read({ url: 'https://example.org/article', engine: 'jina' });++ assert.deepEqual(order(read), [['jina', 'jina', 'ok', null]]);++++ console.log('search attempts: ordered fallback, refusal, owner denial, spend cap, image and managed reader receipts passed');++})().catch(e => { console.error(e); process.exitCode = 1; });@@@ -55,7 +55,7 @@ const bob = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY';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);++ assert.equal((await 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'); } });
@@ -1099,7 +1099,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
@@ -1099,7 +1099,7 @@ ai.openvibe.network notice from OpenVibe.Sites.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.389.0
+- openvibe-contracts: v0.399.0
- openvibe-sdk: v0.125.0
- openvibe-sdk: v0.125.0
- openvibe-shared: v4.199.0
- openvibe-shared: v4.199.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"runtime": "node 22, express, postgresql",
"port": 4700,
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
- "contracts": "openvibe-contracts v0.389.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",
+ "contracts": "openvibe-contracts v0.399.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",
"sdk": "openvibe-sdk v0.125.0",
"sdk": "openvibe-sdk v0.125.0",
"shared": "openvibe-shared v4.199.0",
"shared": "openvibe-shared v4.199.0",
"capabilities": [
"capabilities": [
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# Exchange search engines
# 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.
+`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`. Contracts **0.399.0** carries the search receipts and their optional ordered attempts.
```json
```json
{"q":"solar lantern","engine":"auto","freshness":"month","limit":10,"max_cost_usd":0.01}
{"q":"solar lantern","engine":"auto","freshness":"month","limit":10,"max_cost_usd":0.01}
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
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.
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.
+Every response has `receipt: {engine, cost_usd, time_ms, attempts?}`. Cost is the provider cost in USD, and time includes fallback attempts. `attempts` lists each engine in the order it was considered: `provider`, `engine`, `status` (`ok`, `error` or `skipped`), a safe `reason` (`disabled`, `unsupported`, `no_credentials`, `circuit_open`, `daily_cap`, `budget`, `timeout`, `upstream_error` or `invalid_response`), its `time_ms` and its `cost_usd`. Never an upstream error body or a credential. A provider turned off by its owner, one in cooldown and one over `max_cost_usd` stay in that list as skipped with their reason. 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.
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.
@@ -32,9 +32,13 @@ Prices checked **2026-11-09**, USD per 1,000 requests unless qualified. `GET /ap
@@ -32,9 +32,13 @@ Prices checked **2026-11-09**, USD per 1,000 requests unless qualified. `GET /ap
| pexels | SEARCH_PEXELS_KEY | $0 | [Pexels](https://www.pexels.com/api/) |
| pexels | SEARCH_PEXELS_KEY | $0 | [Pexels](https://www.pexels.com/api/) |
| pixabay | SEARCH_PIXABAY_KEY | $0 | [Pixabay](https://pixabay.com/api/docs/) |
| 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) |
| brave-images | SEARCH_BRAVE_IMAGES_KEY | $5 | [Brave](https://api-dashboard.search.brave.com/app/plans) |
+| serper-images | SEARCH_SERPER_KEY | $1, shares the Serper pack | [Serper](https://serper.dev/) |
+| meta | SEARCH_META_KEY | Muse Spark 1.3 tokens plus $2.50/1,000 queries | [Meta](https://dev.meta.ai/docs/pricing-rate-limits) |
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.
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.
+Meta web search uses the Responses API with Muse Spark 1.3, one built-in query and `store: false`; it is explicit selection only (`engine: "meta"`) and reserves a $4 ceiling for the bounded call. Serper image search uses `/images`, shares the Serper credential, and is also explicit selection only (`engine: "serper-images"`).
+
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.
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
## Personal keys
@@ -49,10 +53,10 @@ Keys use the existing AES-256-GCM store and encryption configuration, bound to `
@@ -49,10 +53,10 @@ Keys use the existing AES-256-GCM store and encryption configuration, bound to `
## Images and fetch
## 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.
+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 and Serper'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.
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.
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.
+`ov test search-adapters search-routing search-attempts 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.
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1624,9 +1624,9 @@
@@ -1624,9 +1624,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.389.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",
- "integrity": "sha512-kA5uAQIDy/4a0MomMBl0VsgLKgLvLBFB5afRIm8DCx/l/OfM5wX5JJEaPot08WZfi+HSNpeXwIpEsYz5/w/VRA==",
+ "version": "0.399.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -146,7 +146,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -146,7 +146,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
const guard = baseAppOrCap(...caps);
const guard = baseAppOrCap(...caps);
return (req, res, next) => { if (req.exchangePrincipal) { req.principal = req.exchangePrincipal; return next(); } return guard(req, res, next); };
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, credentials, env, searchFetch });
+ const exchangePlatform = require('./platform/router').platform({ db, auth, clock, log, credentials, registry, env, searchFetch });
app.use(exchangePlatform.keyMiddleware);
app.use(exchangePlatform.keyMiddleware);
app.use(exchangePlatform.router);
app.use(exchangePlatform.router);
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
@@ -5,12 +5,12 @@ const { AiError, sendError } = require('../util');
@@ -5,12 +5,12 @@ const { AiError, sendError } = require('../util');
const { createKeys } = require('./keys');
const { createKeys } = require('./keys');
const { claimRequest } = require('./idempotency');
const { claimRequest } = require('./idempotency');
const { createSearch, CHARGE } = require('../search');
const { createSearch, CHARGE } = require('../search');
-function platform({ db, auth, clock, log, searchFetch, credentials, env = process.env }) {
+function platform({ db, auth, clock, log, searchFetch, credentials, registry, env = process.env }) {
const keys = createKeys({ db, clock });
const keys = createKeys({ db, clock });
const presets = require('../exchange/compat-presets').createCompatPresets(db);
const presets = require('../exchange/compat-presets').createCompatPresets(db);
const search = createSearch({
const search = createSearch({
...(searchFetch ? { fetchImpl: searchFetch } : {}),
...(searchFetch ? { fetchImpl: searchFetch } : {}),
- now: () => clock.now(), credentials, env,
+ now: () => clock.now(), credentials, registry, env,
});
});
const r = express.Router();
const r = express.Router();
const wrap = (fn) => async (req, res) => {
const wrap = (fn) => async (req, res) => {
@@ -64,8 +64,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
@@ -64,8 +64,8 @@ function platform({ db, auth, clock, log, searchFetch, credentials, env = proces
subject: req.principal?.kind === 'user' ? req.principal.subject.id : null,
subject: req.principal?.kind === 'user' ? req.principal.subject.id : null,
plan: req.searchPlan, budget: req.searchBudget,
plan: req.searchPlan, budget: req.searchBudget,
});
});
- r.get('/api/v1/exchange/search/engines', (_req, res) =>
- res.json({ engines: search.engines() }),
+ r.get('/api/v1/exchange/search/engines', wrap(async () =>
+ ({ engines: await search.engines() })),
);
);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const allow = auth.appOrCap(CAPS.appRun, CAPS.runCreate);
const searchWindows = new Map();
const searchWindows = new Map();
@@ -680,7 +680,8 @@ const PROVIDERS = [
@@ -680,7 +680,8 @@ const PROVIDERS = [
}),
}),
// Providers without a hand-kept model list: the owner syncs and enables their models.
// Providers without a hand-kept model list: the owner syncs and enables their models.
{ key: 'meta', display_name: 'Meta Model API', base_url: 'https://api.meta.ai/v1',
{ key: 'meta', display_name: 'Meta Model API', base_url: 'https://api.meta.ai/v1',
- docs_url: 'https://dev.meta.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools'] },
+ docs_url: 'https://dev.meta.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'web-search'],
+ search_adapters: ['meta'] },
{ key: 'deepseek', keys_url: 'https://platform.deepseek.com/api_keys', display_name: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', default_model: 'deepseek-flash',
{ key: 'deepseek', keys_url: 'https://platform.deepseek.com/api_keys', display_name: 'DeepSeek', base_url: 'https://api.deepseek.com/v1', default_model: 'deepseek-flash',
docs_url: 'https://api-docs.deepseek.com/', capabilities: ['chat', 'vision', 'tools', 'web-search'], search_adapters: ['deepseek'] },
docs_url: 'https://api-docs.deepseek.com/', capabilities: ['chat', 'vision', 'tools', 'web-search'], search_adapters: ['deepseek'] },
{ key: 'anthropic', keys_url: 'https://console.anthropic.com/settings/keys', display_name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', auth_mode: 'x-api-key',
{ key: 'anthropic', keys_url: 'https://console.anthropic.com/settings/keys', display_name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', auth_mode: 'x-api-key',
@@ -103,7 +103,7 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -103,7 +103,7 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
defaults.metadata.check_endpoint = null;
defaults.metadata.check_endpoint = null;
}
}
- const metadata = { ...defaults.metadata, ...prev.metadata };
+ const metadata = { ...defaults.metadata, ...prev.metadata, search_adapters: [...new Set([...(prev.metadata.search_adapters || []), ...(defaults.metadata.search_adapters || [])])] };
if (!configured && prev.base_url !== p.base_url) {
if (!configured && prev.base_url !== p.base_url) {
metadata.models_endpoint = defaults.metadata.models_endpoint;
metadata.models_endpoint = defaults.metadata.models_endpoint;
metadata.api_family = defaults.metadata.api_family;
metadata.api_family = defaults.metadata.api_family;
@@ -126,6 +126,8 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -126,6 +126,8 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
protocol.check_endpoint = null;
protocol.check_endpoint = null;
}
}
const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
+ const engines = [...new Set([...(prev.metadata.search_adapters || []), ...(protocol.search_adapters || [])])];
+ if (engines.length !== (prev.metadata.search_adapters || []).length) additions.search_adapters = engines;
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
}
}
for (const m of p.models) {
for (const m of p.models) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+const { requestFor, post, bearer, web } = require('../transport');
+const MAX_OUTPUT = 512;
+module.exports = {
+ id: 'meta', byok: true, manual_only: true, registry_required: true, checked_on: '2026-11-10',
+ kinds: ['web'], key_env: 'SEARCH_META_KEY', hosts: ['api.meta.ai'], freshness: [],
+ price_per_1000: null, source: 'https://dev.meta.ai/docs/pricing-rate-limits',
+ pricing_note: 'Muse Spark 1.3 Standard: $1.25/M input, $0.15/M cached, $4.25/M output plus $2.50/1000 queries. Explicit selection only; up to $4 reserved for one built-in call and its model iterations.',
+ // One built-in call permits at most two model iterations. Reserve both full context windows,
+ // the output budget, and conservatively one search query per generated token.
+ ceiling: () => 4,
+ estimate: () => 0.01,
+ async search(b, { fetchImpl, key }) {
+ const out = await requestFor(this.hosts, fetchImpl)('https://api.meta.ai/v1/responses', post({
+ model: 'muse-spark-1.3', input: b.q, instructions: 'Search the web for this question. Use one concise search query and cite the sources.',
+ tools: [{ type: 'web_search', search_context_size: 'low' }], max_tool_calls: 1,
+ include: ['web_search_call.results'], max_output_tokens: MAX_OUTPUT, store: false,
+ }, bearer(key)));
+ const usage = out.usage;
+ const input = usage?.input_tokens, output = usage?.output_tokens, cached = usage?.input_tokens_details?.cached_tokens ?? 0;
+ if (![input, output, cached].every(n => Number.isSafeInteger(n) && n >= 0) || cached > input) throw new Error('Meta usage is missing');
+ const searches = (out.output || []).filter(item => item.type === 'web_search_call');
+ let queries = 0;
+ const rows = [];
+ for (const call of searches) {
+ if (call.status !== 'completed' || call.action?.type !== 'search') throw new Error('Meta search did not complete');
+ const issued = Array.isArray(call.action.queries) ? call.action.queries : typeof call.action.query === 'string' ? [call.action.query] : null;
+ if (!issued?.length || issued.some(q => typeof q !== 'string' || !q.trim())) throw new Error('Meta search query usage is missing');
+ queries += issued.length;
+ for (const result of call.results || []) if (result.type === 'text_result') rows.push(web(result, result.snippet, null));
+ }
+ if (!searches.length || !rows.length) throw new Error('Meta returned no search results');
+ const cost = ((input - cached) * 1.25 + cached * 0.15 + output * 4.25) / 1e6 + queries * 0.0025;
+ return { rows, cost };
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+'use strict';
+const { requestFor, post } = require('../transport');
+module.exports = {
+ id: 'serper-images', credential_id: 'serper', byok: true, manual_only: true, checked_on: '2026-11-10',
+ kinds: ['images'], key_env: 'SEARCH_SERPER_KEY', hosts: ['google.serper.dev'], freshness: [],
+ price_per_1000: 1, source: 'https://serper.dev/',
+ pricing_note: 'Starter prepaid pack: $50 for 50000 credits. Search results do not establish reuse rights.',
+ async search(b, { fetchImpl, key }) {
+ const out = await requestFor(this.hosts, fetchImpl)('https://google.serper.dev/images', post({ q: b.q, num: Math.min(b.limit, 10) }, { 'x-api-key': key }));
+ return (out.images || []).map(r => ({ title: r.title || '', url: r.imageUrl, snippet: '', published: null,
+ thumbnail: r.thumbnailUrl, width: r.imageWidth, height: r.imageHeight, source_page: r.link,
+ license: null, license_url: null, author: null, attribution: null }));
+ },
+};
@@ -1,13 +1,14 @@
@@ -1,13 +1,14 @@
'use strict';
'use strict';
const { guardedFetch } = require('../providers/guarded-fetch');
const { guardedFetch } = require('../providers/guarded-fetch');
const { publicTarget } = require('./transport');
const { publicTarget } = require('./transport');
-const { AiError } = require('../util');
+const { AiError, providerSecret } = require('../util');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
const net = require('node:net');
const net = require('node:net');
const { createHash } = require('node:crypto');
const { createHash } = require('node:crypto');
const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify',
const adapters = ['openverse', 'commons', 'searxng', 'openvibe', 'iconify',
'deepseek', 'brave', 'exa', 'tavily', 'serper', 'serpapi', 'jina',
'deepseek', 'brave', 'exa', 'tavily', 'serper', 'serpapi', 'jina',
- 'openvibe-egress', 'perplexity', 'kagi', 'google', 'unsplash', 'pexels', 'pixabay', 'brave-images']
+ 'openvibe-egress', 'perplexity', 'kagi', 'google', 'unsplash', 'pexels', 'pixabay', 'brave-images',
+ 'meta', 'serper-images']
.map(n => require('./adapters/' + n));
.map(n => require('./adapters/' + n));
const CHARGE = Symbol('search Exchange charge');
const CHARGE = Symbol('search Exchange charge');
const invalid = (message) => new AiError(422, 'ai.invalid', message);
const invalid = (message) => new AiError(422, 'ai.invalid', message);
@@ -54,57 +55,110 @@ function normalize(rows, engine, kind, b) {
@@ -54,57 +55,110 @@ function normalize(rows, engine, kind, b) {
if (b.style && b.style !== 'any') out = out.filter(r => r.style === b.style);
if (b.style && b.style !== 'any') out = out.filter(r => r.style === b.style);
return out.slice(0, b.limit);
return out.slice(0, b.limit);
}
}
-function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () => Date.now(), credentials = null, targetGuard = publicTarget } = {}) {
+function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () => Date.now(), credentials = null, registry = null, targetGuard = publicTarget } = {}) {
const health = new Map();
const health = new Map();
- 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: 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,
- }));
+ const providerFor = (a, providers) => providers.find(p => p.metadata?.search_adapters?.includes(a.id));
+ function binding(a, providers) {
+ const provider = providerFor(a, providers);
+ // Existing environment-backed search stays available until the owner manages its registry row.
+ // New adapters require an enabled registry entry when running inside the service.
+ const managed = Boolean(registry && (a.registry_required || provider && (provider.origin === 'admin' || provider.status === 'active' || providerSecret(provider, env))));
+ const key = managed ? providerSecret(provider, env) || (a.key_env && env[a.key_env]) : a.key_env && env[a.key_env];
+ const settings = a.setting === 'EXCHANGE_SEARXNG_URL' && managed && provider?.base_url ? { ...env, EXCHANGE_SEARXNG_URL: provider.base_url } : env;
+ const configured = (!a.key_env || Boolean(key)) && (!a.setting || Boolean(settings[a.setting])) && (a.requires || []).every(k => settings[k]);
+ if (managed && (!provider || provider.status !== 'active')) return { provider, managed, key, env: settings, configured, available: false };
+ return { provider, managed, key, env: settings, configured, available: configured };
+ }
+ async function engines() {
+ const providers = registry ? await registry.listProviders() : [];
+ return adapters.map(a => {
+ const state = binding(a, providers);
+ return { id: a.id, kinds: a.kinds, price_per_1000: a.price_per_1000,
+ byok: a.byok, available: state.available, freshness: a.freshness, healthy: (health.get(a.id) || 0) <= now(),
+ provider: state.provider?.key || null, manual_only: a.manual_only === true,
+ source: a.source, checked_on: a.checked_on, pricing_note: a.pricing_note || null };
+ });
}
}
async function prepare(kind, body, { subject = null } = {}) {
async function prepare(kind, body, { subject = null } = {}) {
const b = inputFor(kind, body);
const b = inputFor(kind, body);
if (b.type === 'icon') kind = 'icons';
if (b.type === 'icon') kind = 'icons';
for (const [k, until] of health) if (until <= now()) health.delete(k);
for (const [k, until] of health) if (until <= now()) health.delete(k);
const candidates = [];
const candidates = [];
+ const providers = registry ? await registry.listProviders() : [];
for (const a of adapters) {
for (const a of adapters) {
+ if (a.manual_only && (!b.engine || b.engine === 'auto')) continue;
if (!a.kinds.includes(kind) || b.engine && b.engine !== 'auto' && b.engine !== a.id ||
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;
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;
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 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 state = binding(a, providers);
const ceiling = a.ceiling ? a.ceiling(b) : a.price_per_1000 / 1000;
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 });
+ if (!own && !state.available) {
+ // An owner-managed provider that is off stays in the plan so the receipt says why it was skipped.
+ if (state.managed && state.provider && state.configured)
+ candidates.push({ a, key: state.key, own: false, healthKey: a.id, ceiling, provider: state.provider.key, managed: true, env: state.env, skipReason: 'disabled' });
+ continue;
+ }
+ const key = own?.apiKey || state.key;
+ if (a.key_env && !key || a.setting && !state.env?.[a.setting]) continue;
+ const healthKey = own ? `${a.id}:${createHash('sha256').update(key).digest('hex')}` : a.id;
+ candidates.push({ a, key, own: Boolean(own), healthKey, ceiling, provider: state.provider?.key || null, managed: state.managed, env: state.env || env,
+ skipReason: b.max_cost_usd != null && ceiling > b.max_cost_usd ? 'budget' : null });
}
}
// Once the person has an eligible key, fallback stays within their keys (0% Exchange fee).
// 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 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));
+ // A cooldown is a skip in the receipt, not a disappearance; an explicit engine may still retry.
+ const selected = cohort.map(c => {
+ if (!c.skipReason && !(b.engine && b.engine !== 'auto') && health.has(c.healthKey)) c.skipReason = 'circuit_open';
+ return c;
+ });
const price = c => c.a.estimate ? c.a.estimate(b) : c.a.price_per_1000 == null ? c.ceiling : c.a.price_per_1000 / 1000;
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));
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 runnable = selected.filter(c => !c.skipReason);
+ if (!runnable.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, runnable.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) });
+ const receipt = (engine, cost, start, attempts) => ({ engine, cost_usd: cost, time_ms: Math.max(0, now() - start), ...(attempts ? { attempts } : {}) });
async function execute(plan, budget = Infinity) {
async function execute(plan, budget = Infinity) {
const start = now();
const start = now();
let incurred = 0, charge = 0;
let incurred = 0, charge = 0;
+ const attempts = [];
+ const attempt = (c, status, started, cost, reason) => ({ provider: c.provider || credentialId(c.a), engine: c.a.id, status,
+ ...(reason ? { reason } : {}), time_ms: Math.max(0, now() - started), cost_usd: cost });
for (const c of plan.candidates) {
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;
+ const attemptStart = now();
+ if (c.skipReason) { attempts.push(attempt(c, 'skipped', attemptStart, 0, c.skipReason)); continue; }
+ if ((!c.own && c.ceiling + charge > budget + 1e-12) || c.ceiling + incurred > (plan.b.max_cost_usd ?? Infinity) + 1e-12) {
+ attempts.push(attempt(c, 'skipped', attemptStart, 0, 'budget')); continue;
+ }
let attempted = false;
let attempted = false;
try {
try {
if (plan.kind === 'fetch' && c.a.id !== 'openvibe-egress') await targetGuard(plan.b.url);
if (plan.kind === 'fetch' && c.a.id !== 'openvibe-egress') await targetGuard(plan.b.url);
- const options = { fetchImpl, key: c.key, env, now };
+ let boundFetch = fetchImpl;
+ if (!c.own && c.managed) {
+ const current = await registry.getProvider(c.provider);
+ if (!current || current.status !== 'active') { attempts.push(attempt(c, 'skipped', attemptStart, 0, 'disabled')); continue; }
+ const expected = current.base_url && new URL(current.base_url).origin;
+ // The adapter's own host allowlist covers multi-host readers (Jina); the provider's
+ // registered origin covers a configured SearXNG address. Nothing else gets the key.
+ const hosts = new Set((c.a.hosts || []).map(h => 'https://' + h));
+ boundFetch = (url, init) => {
+ const origin = new URL(String(url)).origin;
+ if (!(expected && origin === expected) && !hosts.has(origin)) throw Object.assign(new Error('Search endpoint does not match the provider'), { unbilled: true });
+ return fetchImpl(url, init);
+ };
+ // Key replacement/removal takes effect even for a previously prepared plan.
+ c.key = providerSecret(current, env) || (c.a.key_env && env[c.a.key_env]);
+ if (c.a.key_env && !c.key) { attempts.push(attempt(c, 'skipped', attemptStart, 0, 'no_credentials')); continue; }
+ }
+ const options = { fetchImpl: boundFetch, key: c.key, env: c.env, now };
attempted = true;
attempted = true;
const raw = plan.kind === 'fetch' ? await c.a.read(plan.b, options) : await c.a.search(plan.b, options);
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;
const cost = raw.cost ?? c.a.price_per_1000 / 1000;
if (!Number.isFinite(cost) || cost < 0) throw new Error('Invalid engine cost');
if (!Number.isFinite(cost) || cost < 0) throw new Error('Invalid engine cost');
const out = plan.kind === 'fetch'
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) };
+ ? { url: plan.b.url, text: String(raw.text || '').slice(0, 200000), receipt: receipt(c.a.id, cost + incurred, start, [...attempts, attempt(c, 'ok', attemptStart, cost)]) }
+ : { results: normalize(Array.isArray(raw) ? raw : raw.rows, c.a.id, plan.kind, plan.b), receipt: receipt(c.a.id, cost + incurred, start, [...attempts, attempt(c, 'ok', attemptStart, cost)]) };
if (plan.kind !== 'fetch' && !validate(plan.kind === 'web' ? 'exchange.search-result@1' : 'exchange.image-result@1', out).valid) throw new Error('Invalid search response');
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);
health.delete(c.healthKey);
Object.defineProperty(out, CHARGE, { value: charge + (c.own ? 0 : cost) });
Object.defineProperty(out, CHARGE, { value: charge + (c.own ? 0 : cost) });
@@ -112,12 +166,16 @@ function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () =>
@@ -112,12 +166,16 @@ function createSearch({ fetchImpl = guardedFetch, env = process.env, now = () =>
} catch (error) {
} catch (error) {
// A transport timeout or malformed successful response may already be billed.
// A transport timeout or malformed successful response may already be billed.
// Keep its ceiling in both the fallback budget and receipt, rather than overspend.
// 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; }
+ const failedCost = attempted && !error.unbilled ? c.ceiling : 0;
+ const reason = ['AbortError', 'TimeoutError'].includes(error.name) || error.code === 'provider.timeout' ? 'timeout'
+ : /usage is missing|Invalid engine cost|Invalid search response/.test(error.message || '') ? 'invalid_response' : 'upstream_error';
+ attempts.push(attempt(c, 'error', attemptStart, failedCost, reason));
+ if (failedCost) { incurred += failedCost; if (!c.own) charge += failedCost; }
health.set(c.healthKey, now() + 60000);
health.set(c.healthKey, now() + 60000);
if (health.size > 1000) health.delete(health.keys().next().value);
if (health.size > 1000) health.delete(health.keys().next().value);
}
}
}
}
- throw Object.assign(new AiError(503, 'search.unavailable', 'The selected engines did not answer'), { [CHARGE]: charge });
+ throw Object.assign(new AiError(503, 'search.unavailable', 'The selected engines did not answer', { attempts }), { [CHARGE]: charge });
}
}
async function search(kind, b, context = {}) {
async function search(kind, b, context = {}) {
return execute(context.plan || await prepare(kind, b, context), context.budget);
return execute(context.plan || await prepare(kind, b, context), context.budget);
@@ -18,7 +18,7 @@ for (const kind of ['web', 'images', 'icons']) {
@@ -18,7 +18,7 @@ for (const kind of ['web', 'images', 'icons']) {
paths['/v1/search' + (kind === 'web' ? '' : '/' + kind)] = { post: {
paths['/v1/search' + (kind === 'web' ? '' : '/' + kind)] = { post: {
operationId: 'search' + kind[0].toUpperCase() + kind.slice(1), tags: ['exchange'],
operationId: 'search' + kind[0].toUpperCase() + kind.slice(1), tags: ['exchange'],
summary: `Search ${kind} through Exchange`, security: [{ bearer: [] }],
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.',
+ description: 'Accepts an Exchange key, person token, authorized service or app token. Receipt reports engine, provider cost in USD and elapsed milliseconds, including fallbacks, and lists each ordered attempt (ok, error or skipped) with its provider, engine, safe reason, latency and cost. 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: {
requestBody: { required: true, ...json({ type: 'object', required: ['q'], properties: {
...properties,
...properties,
...(kind === 'web' ? {} : {
...(kind === 'web' ? {} : {
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+{
+ "request": {
+ "host": "api.meta.ai",
+ "path": "/v1/responses",
+ "method": "POST"
+ },
+ "response": {
+ "usage": {
+ "input_tokens": 100,
+ "input_tokens_details": {
+ "cached_tokens": 20
+ },
+ "output_tokens": 10
+ },
+ "output": [
+ {
+ "type": "web_search_call",
+ "status": "completed",
+ "action": {
+ "type": "search",
+ "queries": [
+ "lantern",
+ "new lantern"
+ ]
+ },
+ "results": [
+ {
+ "type": "text_result",
+ "title": "Lantern",
+ "url": "https://example.org/article",
+ "snippet": "A new lantern"
+ }
+ ]
+ }
+ ]
+ },
+ "expected": {
+ "title": "Lantern",
+ "cost": 0.0051455
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "request": {
+ "host": "google.serper.dev",
+ "path": "/images",
+ "method": "POST"
+ },
+ "response": {
+ "images": [
+ {
+ "title": "Cat",
+ "imageUrl": "https://images.example.org/cat.jpg",
+ "thumbnailUrl": "https://images.example.org/thumb.jpg",
+ "imageWidth": 2400,
+ "imageHeight": 1600,
+ "link": "https://example.org/article"
+ }
+ ]
+ },
+ "expected": {
+ "title": "Cat",
+ "cost": 0.001
+ }
+}
@@ -35,23 +35,23 @@ const json = value => new Response(JSON.stringify(value), { status: 200 });
@@ -35,23 +35,23 @@ const json = value => new Response(JSON.stringify(value), { status: 200 });
if (kind === 'web') {
if (kind === 'web') {
assert.equal(out.results[0].url, 'https://example.org/article');
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].snippet, 'A new lantern', a.id);
- assert.equal(out.results[0].published, a.id === 'google' ? null : '2026-11-08T12:00:00Z');
+ assert.equal(out.results[0].published, ['google', 'meta'].includes(a.id) ? null : '2026-11-08T12:00:00Z');
} else if (kind === 'images') {
} else if (kind === 'images') {
const row = out.results[0];
const row = out.results[0];
assert.equal(row.url, 'https://images.example.org/cat.jpg');
assert.equal(row.url, 'https://images.example.org/cat.jpg');
assert.equal(row.thumbnail, 'https://images.example.org/thumb.jpg');
assert.equal(row.thumbnail, 'https://images.example.org/thumb.jpg');
assert.deepEqual([row.width, row.height], [2400, 1600]);
assert.deepEqual([row.width, row.height], [2400, 1600]);
assert.equal(row.source_page, 'https://example.org/article');
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]);
+ assert.equal(row.author, ['brave-images', 'serper-images'].includes(a.id) ? null : 'Alex');
+ if (['brave-images', 'serper-images'].includes(a.id)) assert.deepEqual([row.license, row.attribution], [null, null]);
else assert.ok(row.license && row.attribution.includes('Alex'));
else assert.ok(row.license && row.attribution.includes('Alex'));
}
}
- const card = search.engines().find(e => e.id === a.id);
+ const card = (await search.engines()).find(e => e.id === a.id);
assert.equal(card.price_per_1000, a.price_per_1000);
assert.equal(card.price_per_1000, a.price_per_1000);
assert.equal(card.source, a.source);
assert.equal(card.source, a.source);
- assert.equal(card.checked_on, '2026-11-09');
+ assert.equal(card.checked_on, ['meta', 'serper-images'].includes(a.id) ? '2026-11-10' : '2026-11-09');
if (a.byok) {
if (a.byok) {
- assert.equal(createSearch({ env: {} }).engines().find(e => e.id === a.id).available, false);
+ assert.equal((await 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 }));
await assert.rejects(createSearch({ env: {}, fetchImpl: () => assert.fail('unconfigured engine called') }).search(kind, { q: 'x', engine: a.id }));
}
}
if (a.freshness.length) {
if (a.freshness.length) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { validate } = require('openvibe-contracts');
+const { createSearch, CHARGE } = require('../server/search');
+require('../server/search/adapters/meta');
+require('../server/search/adapters/serper-images');
+const sample = id => require('./fixtures/search/' + id + '.json').response;
+const json = body => new Response(JSON.stringify(body), { status: 200 });
+const host = url => new URL(url).hostname;
+const order = out => out.receipt.attempts.map(a => [a.provider, a.engine, a.status, a.reason || null]);
+
+(async () => {
+ // A circuit-open engine, a provider that refuses, then the answer: every attempt stays in order.
+ const search = createSearch({
+ env: { SEARCH_DEEPSEEK_KEY: 'deepseek-key', SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' },
+ fetchImpl: async (url) => {
+ const h = host(url);
+ if (h === 'api.deepseek.com') throw Object.assign(new Error('upstream took too long'), { name: 'TimeoutError' });
+ if (h === 'google.serper.dev') throw new Error('Search engine refused the request');
+ if (h === 'api.search.brave.com') return json(sample('brave'));
+ throw new Error('unexpected host ' + h);
+ },
+ });
+ await assert.rejects(search.search('web', { q: 'lantern', freshness: 'day', engine: 'deepseek' }));
+ const fallback = await search.search('web', { q: 'lantern', freshness: 'day' });
+ assert.equal(fallback.receipt.engine, 'brave');
+ assert.deepEqual(order(fallback), [
+ ['deepseek', 'deepseek', 'skipped', 'circuit_open'],
+ ['serper', 'serper', 'error', 'upstream_error'],
+ ['brave', 'brave', 'ok', null],
+ ]);
+ assert.deepEqual(fallback.receipt.attempts.map(a => a.cost_usd), [0, 0.001, 0.005]);
+ assert.equal(fallback.receipt.cost_usd, 0.006);
+ assert.equal(fallback[CHARGE], 0.006);
+ assert.equal(validate('exchange.search-result@1', fallback).valid, true);
+ assert.equal(JSON.stringify(fallback).includes('refused the request'), false, 'no upstream error body is kept');
+
+ // A provider that refuses the request is an error attempt; the next provider still answers.
+ const refused = createSearch({
+ env: { SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' },
+ fetchImpl: async (url) => {
+ const h = host(url);
+ if (h === 'google.serper.dev') throw new Error('Search engine refused the request');
+ if (h === 'api.search.brave.com') return json(sample('brave'));
+ throw new Error('unexpected host ' + h);
+ },
+ });
+ const afterRefusal = await refused.search('web', { q: 'x', freshness: 'day' });
+ assert.deepEqual(order(afterRefusal), [
+ ['serper', 'serper', 'error', 'upstream_error'],
+ ['brave', 'brave', 'ok', null],
+ ]);
+ assert.equal(afterRefusal.receipt.cost_usd, 0.006);
+
+ // An owner-disabled provider is a skipped attempt with its reason, both before the plan and between plan and call.
+ const serper = { key: 'serper', status: 'active', origin: 'admin', base_url: 'https://google.serper.dev', secret_ref: 'env:SEARCH_SERPER_KEY', metadata: { search_adapters: ['serper', 'serper-images'] } };
+ const brave = { key: 'brave', status: 'active', origin: 'catalog', base_url: 'https://api.search.brave.com/res/v1', secret_ref: 'env:SEARCH_BRAVE_KEY', metadata: { search_adapters: ['brave', 'brave-images'] } };
+ const registry = { listProviders: async () => [serper, brave], getProvider: async key => key === 'serper' ? serper : key === 'brave' ? brave : null };
+ const denied = createSearch({
+ env: { SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' }, registry,
+ fetchImpl: async (url) => {
+ const h = host(url);
+ if (h === 'api.search.brave.com') return json(sample('brave'));
+ throw new Error('disabled provider was called');
+ },
+ });
+ const plan = await denied.prepare('web', { q: 'x', freshness: 'day' });
+ assert.equal(plan.candidates[0].provider, 'serper');
+ serper.status = 'disabled';
+ const between = await denied.search('web', { q: 'x', freshness: 'day' }, { plan });
+ assert.deepEqual(order(between), [
+ ['serper', 'serper', 'skipped', 'disabled'],
+ ['brave', 'brave', 'ok', null],
+ ]);
+ const after = await denied.search('web', { q: 'x', freshness: 'day' });
+ assert.deepEqual(order(after), [
+ ['serper', 'serper', 'skipped', 'disabled'],
+ ['brave', 'brave', 'ok', null],
+ ]);
+ assert.equal((await denied.engines()).find(e => e.id === 'serper').available, false);
+ assert.equal((await denied.engines()).find(e => e.id === 'brave').available, true);
+
+ // A provider over the call's spend cap is skipped as budget rather than called.
+ const capped = createSearch({ env: { SEARCH_SERPER_KEY: 'serper-key', SEARCH_BRAVE_KEY: 'brave-key' }, fetchImpl: async (url) => {
+ const h = host(url);
+ if (h === 'google.serper.dev') return json(sample('serper'));
+ throw new Error('provider over the cap was called');
+ } });
+ const cappedPlan = await capped.prepare('web', { q: 'x', freshness: 'day', max_cost_usd: 0.002 });
+ const braveCandidate = cappedPlan.candidates.find(c => c.a.id === 'brave');
+ const serperCandidate = cappedPlan.candidates.find(c => c.a.id === 'serper');
+ assert.equal(braveCandidate.skipReason, 'budget');
+ cappedPlan.candidates = [braveCandidate, serperCandidate];
+ const capAnswer = await capped.search('web', { q: 'x', freshness: 'day', max_cost_usd: 0.002 }, { plan: cappedPlan });
+ assert.deepEqual(order(capAnswer), [
+ ['brave', 'brave', 'skipped', 'budget'],
+ ['serper', 'serper', 'ok', null],
+ ]);
+
+ // Image search writes the same ordered attempts and validates against the image contract.
+ const images = createSearch({
+ env: { SEARCH_SERPER_KEY: 'serper-key' },
+ fetchImpl: async (url) => {
+ assert.equal(host(url), 'google.serper.dev');
+ return json(sample('serper-images'));
+ },
+ });
+ const imageAnswer = await images.search('images', { q: 'cat', engine: 'serper-images' });
+ assert.deepEqual(order(imageAnswer), [['serper', 'serper-images', 'ok', null]]);
+ assert.equal(validate('exchange.image-result@1', imageAnswer).valid, true);
+
+ // A managed reader may use either of its registered hosts (Jina reads from r.jina.ai).
+ const jina = { key: 'jina', status: 'active', origin: 'admin', base_url: 'https://s.jina.ai', secret_ref: 'env:SEARCH_JINA_KEY', metadata: { search_adapters: ['jina'] } };
+ const reader = createSearch({
+ env: { SEARCH_JINA_KEY: 'jina-key' },
+ registry: { listProviders: async () => [jina], getProvider: async () => jina },
+ targetGuard: async () => {},
+ fetchImpl: async (url) => {
+ assert.equal(host(url), 'r.jina.ai');
+ return json(require('./fixtures/search/jina-fetch.json'));
+ },
+ });
+ const read = await reader.read({ url: 'https://example.org/article', engine: 'jina' });
+ assert.deepEqual(order(read), [['jina', 'jina', 'ok', null]]);
+
+ console.log('search attempts: ordered fallback, refusal, owner denial, spend cap, image and managed reader receipts passed');
+})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -55,7 +55,7 @@ const bob = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY';
@@ -55,7 +55,7 @@ const bob = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY';
assert.equal(calls, afterFail + 1);
assert.equal(calls, afterFail + 1);
time = 60001;
time = 60001;
assert.equal((await health.search('web', { q: 'x', freshness: 'day' })).receipt.engine, 'deepseek');
assert.equal((await health.search('web', { q: 'x', freshness: 'day' })).receipt.engine, 'deepseek');
- assert.equal(health.engines().find(e => e.id === 'deepseek').healthy, true);
+ assert.equal((await health.engines()).find(e => e.id === 'deepseek').healthy, true);
// A possibly billed timeout consumes its ceiling, leaving no room to overspend on fallback.
// A possibly billed timeout consumes its ceiling, leaving no room to overspend on fallback.
let budgetCalls = 0;
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'); } });
const bounded = createSearch({ env: { SEARCH_DEEPSEEK_KEY: 'shared', SEARCH_SERPER_KEY: 'shared' }, fetchImpl: async () => { budgetCalls++; throw new Error('timeout with upstream details'); } });