openvibers/openvibe.ai Commits

Record ordered web and image-search attempts (#449)

OP OpenVibers committed 3 days ago · d2f008066d · 18 files, +356 −50

* Record ordered web and image-search attempts

* Load the new search adapters in the attempt tests

Changes

ddiff --git a/README.md b/README.md
iindex 4f0b8eb..62ea80b 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 5b7e488..2a5a17a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/docs/search-engines.md b/docs/search-engines.md
iindex fc30b5e..64ffe2d 100644
---- a/docs/search-engines.md
++++ b/docs/search-engines.md
@@@ -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.
ddiff --git a/package-lock.json b/package-lock.json
iindex 6121a4c..74db7eb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex fccee04..a29603b 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex 4fbc193..842c6b7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -146,7 +146,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
const guard = baseAppOrCap(...caps);
return (req, res, next) => { if (req.exchangePrincipal) { req.principal = req.exchangePrincipal; return next(); } return guard(req, res, next); };
} };
-- const exchangePlatform = require('./platform/router').platform({ db, auth, clock, log, 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
ddiff --git a/server/platform/router.js b/server/platform/router.js
iindex 6532dc1..7936887 100644
---- a/server/platform/router.js
++++ b/server/platform/router.js
@@@ -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 = proces
subject: req.principal?.kind === 'user' ? req.principal.subject.id : null,
plan: req.searchPlan, budget: req.searchBudget,
});
-- r.get('/api/v1/exchange/search/engines', (_req, res) =>
-- res.json({ engines: search.engines() }),
++ 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();
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex 7e457a5..2b160a7 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -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',
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex 2d14448..e29d0d5 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -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.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.env
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 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) {
ddiff --git a/server/search/adapters/meta.js b/server/search/adapters/meta.js
nnew file mode 100644
iindex 0000000..7c1e3e9
---- /dev/null
++++ b/server/search/adapters/meta.js
@@@ -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 };
++ },
++};
ddiff --git a/server/search/adapters/serper-images.js b/server/search/adapters/serper-images.js
nnew file mode 100644
iindex 0000000..322d47c
---- /dev/null
++++ b/server/search/adapters/serper-images.js
@@@ -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 }));
++ },
++};
ddiff --git a/server/search/index.js b/server/search/index.js
iindex b4ad707..2eef78d 100644
---- a/server/search/index.js
++++ b/server/search/index.js
@@@ -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);
ddiff --git a/server/search/openapi.js b/server/search/openapi.js
iindex d3c5332..66286a4 100644
---- a/server/search/openapi.js
++++ b/server/search/openapi.js
@@@ -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' ? {} : {
ddiff --git a/test/fixtures/search/meta.json b/test/fixtures/search/meta.json
nnew file mode 100644
iindex 0000000..b90578b
---- /dev/null
++++ b/test/fixtures/search/meta.json
@@@ -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
++ }
++}
ddiff --git a/test/fixtures/search/serper-images.json b/test/fixtures/search/serper-images.json
nnew file mode 100644
iindex 0000000..6e78a6e
---- /dev/null
++++ b/test/fixtures/search/serper-images.json
@@@ -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
++ }
++}
ddiff --git a/test/search-adapters.test.js b/test/search-adapters.test.js
iindex b8a9a62..0b66007 100644
---- a/test/search-adapters.test.js
++++ b/test/search-adapters.test.js
@@@ -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) {
ddiff --git a/test/search-attempts.test.js b/test/search-attempts.test.js
nnew file mode 100644
iindex 0000000..69a2241
---- /dev/null
++++ b/test/search-attempts.test.js
@@@ -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; });
ddiff --git a/test/search-routing.test.js b/test/search-routing.test.js
iindex 7e6671a..284c706 100644
---- a/test/search-routing.test.js
++++ b/test/search-routing.test.js
@@@ -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'); } });