openvibers/openvibe.exchange Commits

Search: one engine by default, honest price words, one combined cap when comparing (#259)

OP OpenVibers committed 2 h ago · 0a8093ed45 · 7 files, +408 −95

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bc1b432..af50db6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ What changed in OpenVibe.Exchange, newest first.
## Unreleased
++- **Search: one engine by default, honest prices, one combined cap when you compare (2026-11-13).** A search now
++ runs one engine, the router's pick unless you name one; comparing two or three engines is an explicit choice and
++ shares ONE spending cap that is shown before the run. Prices are words, never "Free": no provider fee, your
++ instance, included allowance (with the allowance), or your key. Every filter says whether the chosen engine
++ applies it, we filter the results, or it is not supported and will be refused rather than dropped. Image results
++ keep the provider's rank beside our order, and the engine list names each supplier's upstream (Google, Bing,
++ Yandex). New image engines: SerpAPI Google Images and reverse image, SearchAPI Images, TinEye. Pexels says new
++ keys are paused. Unsplash results have a "Use this picture" action that records its required download event once.
++ The picture and search views now allow provider thumbnails and the AI image proxy in the CSP.
++
- **Measured, and your own AI usage (2026-11-12).** A model page now has a **Measured** panel from
OpenVibe.Report's public performance rows: first token and whole-answer percentiles, tokens a second, success,
cache hits, what a call and a million output tokens were charged, and calls in the last 30 days. A model below
ddiff --git a/public/css/platform.css b/public/css/platform.css
iindex e9abadc..59c4fe0 100644
---- a/public/css/platform.css
++++ b/public/css/platform.css
@@@ -353,6 +353,32 @@
.xs-kinds input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
.xs-pair { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: end; gap: 10px; }
.xs-vs { padding-bottom: 12px; color: var(--ovk-muted); font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
++/* One engine by default; comparing is an explicit switch. CSS only, so the form works without JavaScript. */
++.xs-modes { display: inline-flex; justify-self: start; gap: 4px; margin: 0; padding: 4px; border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel-2); min-width: 0; }
++.xs-modes label { position: relative; display: inline-flex; }
++.xs-modes input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
++.xs-modes span { display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 999px; font-size: .88rem; color: var(--ovk-muted); }
++.xs-modes input:checked + span { background: color-mix(in srgb, var(--ovk-accent) 20%, transparent); color: var(--ovk-text); font-weight: 650; }
++.xs-modes input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
++.xs-single { display: grid; }
++.xs-compare { display: none; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
++.xs-form:has(input[name="mode"][value="compare"]:checked) .xs-single { display: none; }
++.xs-form:has(input[name="mode"][value="compare"]:checked) .xs-compare { display: grid; }
++.xs-cap small { color: var(--ovk-muted); font-size: .82rem; line-height: 1.4; }
++.xs-check { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: .9rem; }
++.xs-check input { width: 18px; height: 18px; accent-color: var(--ovk-accent); }
++.xs-support { border: 1px solid var(--ovk-line); border-radius: 12px; }
++.xs-support > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; cursor: pointer; list-style: none; font-weight: 600; font-size: .92rem; }
++.xs-support > summary::-webkit-details-marker { display: none; }
++.xs-support-engine { display: grid; gap: 2px; padding: 9px 14px; border-top: 1px solid var(--ovk-line); }
++.xs-support-engine span { color: var(--ovk-muted); font-size: .84rem; line-height: 1.45; overflow-wrap: anywhere; }
++.xs-support > .xs-note { padding: 0 14px 12px; }
++.xs-upstream { color: var(--ovk-accent) !important; }
++.xs-price-note { color: var(--ovk-muted); font-size: .8rem !important; line-height: 1.4; }
++.xs-use { margin: 6px 0 0; }
++.xs-attempts { margin-top: 4px; }
++.xs-result .xs-attempts ul { margin: 4px 0 0; padding-left: 16px; }
++.xs-result .xs-attempts li { color: var(--ovk-muted); font-size: .8rem; }
.xs-more { border: 1px solid var(--ovk-line); border-radius: 12px; }
.xs-more > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 40px 0 14px; position: relative; cursor: pointer; list-style: none; font-weight: 600; font-size: .92rem; }
.xs-more > summary::-webkit-details-marker { display: none; }
@@@ -391,6 +417,7 @@
.xs-try { padding: 14px; }
.xs-more-body { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.xs-more-body > :last-child { grid-column: 1 / -1; }
++ .xs-compare { grid-template-columns: minmax(0, 1fr); }
}
/* Grid children never stretch the page for a long line (the head's subtitle widened /search to 514 px on a phone). */
.xs > * { min-width: 0; }
ddiff --git a/server/app.js b/server/app.js
iindex 4ba9577..62b9884 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -158,7 +158,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],
++ imgSrc: ["'self'", 'data:', 'https:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
ddiff --git a/server/http/search.js b/server/http/search.js
iindex 6faea8a..2e0572f 100644
---- a/server/http/search.js
++++ b/server/http/search.js
@@@ -4,6 +4,85 @@ const views = require('../views/search');
const { raw } = require('../render/html');
const { send } = require('../render/layout');
const { sameOrigin } = require('./principal');
++
++const FILTERS = {
++ web: ['freshness', 'safe_search'],
++ images: ['license', 'commercial', 'format', 'orientation', 'size', 'color', 'after', 'type', 'safe_search'],
++ icons: ['set', 'style', 'license', 'format'],
++};
++const one = (value, max = 80) => String(value == null ? '' : value).slice(0, max);
++const capOf = (value) => {
++ if (value == null || value === '') return null;
++ const n = Number(value);
++ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e6) / 1e6 : null;
++};
++function filterBody(body, kind) {
++ const out = {};
++ for (const key of FILTERS[kind] || []) {
++ const value = body[key];
++ if (value == null || value === '' || value === 'false') continue;
++ if (key === 'commercial') out.commercial = true;
++ else if (key === 'safe_search') out.safe_search = 'strict';
++ else out[key] = one(value);
++ }
++ return out;
++}
++/** One engine by default; compare is explicit and runs two or three distinct engines. */
++function selectedEngines(body, engines, kind) {
++ const mode = body.mode === 'compare' ? 'compare' : 'single';
++ if (mode === 'single') return { mode, engines: [(body.engine || body.first) && (body.engine || body.first) !== 'auto' ? one(body.engine || body.first) : 'auto'] };
++ const serving = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));
++ const picks = [body.first, body.second, body.third]
++ .map((x) => one(x)).filter((x) => x && x !== 'auto');
++ const unique = [...new Set(picks)].filter((id) => serving.some((e) => e.id === id)).slice(0, 3);
++ if (!unique.length) unique.push(...serving.filter((e) => e.available !== false).slice(0, 3).map((e) => e.id));
++ return { mode, engines: unique };
++}
++function why(err) {
++ const status = err && err.status;
++ if (status === 401 || status === 403) return 'Your sign-in could not search. Sign out and in again.';
++ if (status === 429) return 'Too many searches this minute, or the key cap cannot cover this search. Try again shortly.';
++ if (status === 422) return (err && err.message) || 'This engine does not take this kind of search or cannot honour a filter.';
++ if (status === 503) return (err && err.message) || 'No configured engine serves this search within its price and freshness limits.';
++ return 'This engine did not answer. Try again, or choose another engine.';
++}
++/**
++ * Runs a single search once, or an explicit comparison sequentially against one combined cap. Sequential
++ * calls are deliberate: the remaining cap is passed to each call, so the total can never exceed it.
++ */
++async function runSearch({ ai, token, body, kind, engines = [] }) {
++ const chosen = selectedEngines(body, engines, kind);
++ const cap = capOf(body.max_cost_usd);
++ const filters = filterBody(body, kind);
++ const results = [];
++ let spent = 0;
++ let note = '';
++ if (chosen.mode === 'compare' && chosen.engines.length < 2) {
++ note = 'Choose two or three engines to compare. One engine is the default, with one cap.';
++ return { mode: chosen.mode, engines: chosen.engines, cap, spent, results, note };
++ }
++ for (const engine of chosen.engines) {
++ const remaining = cap == null ? null : Math.max(0, Math.round((cap - spent) * 1e6) / 1e6);
++ if (chosen.mode === 'compare' && remaining != null && spent > 0 && remaining <= 0) {
++ results.push({ engine, meta: engines.find((e) => e.id === engine) || null, error: 'The one combined cap is spent; this engine was not called.' });
++ continue;
++ }
++ const payload = { q: one(body.q, 500), engine, limit: 6, ...filters };
++ if (remaining != null) payload.max_cost_usd = remaining;
++ try {
++ const out = await ai.meRequest(token, '/v1/search' + (kind === 'web' ? '' : '/' + kind), { method: 'POST', body: payload });
++ const actual = out.receipt?.engine || engine;
++ results.push({ engine, meta: engines.find((e) => e.id === actual) || engines.find((e) => e.id === engine) || null, ...out });
++ spent = Math.round((spent + (Number(out.receipt?.cost_usd) || 0)) * 1e6) / 1e6;
++ } catch (err) {
++ console.warn('[exchange] search', engine, kind, err && err.status, err && err.code, (err && err.message) || '');
++ results.push({ engine, meta: engines.find((e) => e.id === engine) || null, error: why(err) });
++ }
++ }
++ if (cap != null && spent > cap + 1e-9) note = 'A provider may already have billed a timed-out attempt; the combined cap is a limit, not a promise about a failed call.';
++ return { mode: chosen.mode, engines: chosen.engines, cap, spent, results, note };
++}
++
function createSearchRoutes({ config, ai }) {
const r = express.Router();
r.use(express.urlencoded({ extended: false, limit: '8kb' }));
@@@ -16,43 +95,44 @@ function createSearchRoutes({ config, ai }) {
description: 'One search API for the web, pictures and icons: many engines, one key, a receipt with every answer.',
body: raw(views.searchPage({ engines: await ai.searchEngines(), found, query })),
});
-- r.get('/search', (req, res) => render(req, res).catch((err) => res.status(500).end(String(err && err.message || err))));
++ r.get('/search', (req, res) => {
++ const kind = ['web', 'images', 'icons'].includes(req.query.kind) ? req.query.kind : 'web';
++ return render(req, res, [], {
++ q: one(req.query.q, 500), kind, mode: req.query.mode === 'compare' ? 'compare' : 'single',
++ engine: one(req.query.engine), first: one(req.query.first), second: one(req.query.second), third: one(req.query.third),
++ max_cost_usd: req.query.max_cost_usd == null ? '' : one(req.query.max_cost_usd, 30),
++ note: req.query.chosen === '1' ? 'The Unsplash download event was recorded once for that picture.' : one(req.query.note, 240),
++ }).catch((err) => res.status(500).end(String((err && err.message) || err)));
++ });
r.post('/search', async (req, res) => {
if (req.viewer?.kind !== 'user')
return res.redirect(303, '/auth/login?next=%2Fsearch');
if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
-- const kind = ['web', 'images', 'icons'].includes(req.body.kind)
-- ? req.body.kind
-- : 'web';
-- const results = await Promise.all(
-- [req.body.first, req.body.second].map(async (engine) => {
-- try {
-- return {
-- engine,
-- ...(await ai.meRequest(
-- req.viewer.token,
-- '/v1/search' + (kind === 'web' ? '' : '/' + kind),
-- {
-- method: 'POST',
-- body: { q: req.body.q, engine, limit: 6, safe_search: 'strict',
-- ...Object.fromEntries(['size', 'color', 'type', 'license', 'after'].filter((key) => req.body[key]).map((key) => [key, String(req.body[key]).slice(0, 80)])) },
-- },
-- )),
-- };
-- } catch (err) {
-- const status = err && err.status;
-- console.warn('[exchange] search', engine, kind, status || '', (err && err.code) || '', (err && err.message) || '');
-- const why = status === 401 || status === 403 ? 'Your sign-in could not search. Sign out and in again.'
-- : status === 429 ? 'Too many searches this minute. Try again shortly.'
-- : status === 400 || status === 422 ? 'This engine does not take this kind of search.'
-- : 'This engine did not answer. Try the other one, or again in a minute.';
-- return { engine, error: why };
-- }
-- }),
-- );
-- await render(req, res, results, { q: String(req.body.q || '').slice(0, 500), kind,
-- ...Object.fromEntries(['size', 'color', 'type', 'license', 'after'].map((key) => [key, String(req.body[key] || '').slice(0, 80)])) });
++ const kind = ['web', 'images', 'icons'].includes(req.body.kind) ? req.body.kind : 'web';
++ const body = req.body || {};
++ const out = await runSearch({ ai, token: req.viewer.token, body, kind, engines: await ai.searchEngines() });
++ await render(req, res, out.results, {
++ q: one(body.q, 500), kind, mode: out.mode,
++ engine: one(body.engine), first: one(body.first), second: one(body.second), third: one(body.third),
++ max_cost_usd: capOf(body.max_cost_usd) == null ? '' : String(capOf(body.max_cost_usd)),
++ spent: out.spent, note: out.note,
++ ...Object.fromEntries(Object.entries(filterBody(body, kind)).map(([key, value]) => [key, String(value)])),
++ });
++ });
++ // A person choosing an Unsplash picture is the moment for its required download event, not search.
++ r.post('/search/unsplash', async (req, res) => {
++ if (req.viewer?.kind !== 'user')
++ return res.redirect(303, '/auth/login?next=%2Fsearch');
++ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
++ try {
++ await ai.meRequest(req.viewer.token, '/v1/search/images/unsplash/download', {
++ method: 'POST', body: { download_location: one(req.body.download_location, 8192) },
++ });
++ return res.redirect(303, '/search?chosen=1');
++ } catch (err) {
++ return res.redirect(303, '/search?note=' + encodeURIComponent(why(err)));
++ }
});
return r;
}
--module.exports = { createSearchRoutes };
++module.exports = { createSearchRoutes, selectedEngines, runSearch, filterBody };
ddiff --git a/server/views/search.js b/server/views/search.js
iindex 5376f58..1e28e73 100644
---- a/server/views/search.js
++++ b/server/views/search.js
@@@ -1,20 +1,19 @@
'use strict';
/**
-- * /search (Opus, 11-09; the tool first 11-11): "Compare two engines" at the top (what to search for, the kind as a
-- * switch, the two engines side by side, the picture filters in one fold), the results under it, then the engines AI
-- * has, grouped by what they find (web, pictures, icons), each with what it is, its price per 1,000 searches and whether
-- * it works now. The engine list is AI's own (GET /api/v1/exchange/search/engines), so a new engine shows here the day
-- * AI adds it, named from its id until this list knows it.
++ * /search (Opus, 11-09; one engine and honest words 11-13): one engine by default, the router's pick unless
++ * the person names one. Comparing two or three engines is an explicit choice with ONE combined spending cap.
++ * Prices are words, never "Free": no provider fee, your instance, included allowance or your key. The filters
++ * say whether the chosen engine applies them, we filter locally, or they are not supported and therefore
++ * refused rather than dropped. Results keep the provider's rank beside our order.
*/
const kit = require('openvibe-shared/kit');
const { esc } = require('../render/html');
--// What each engine is, in a line.
const ENGINES = {
openvibe: ['OpenVibe Search', "OpenVibe's own index of the network's pages."],
searxng: ['SearXNG', 'Many search engines at once, through our own instance.'],
openverse: ['Openverse', 'Openly licensed pictures, with their licence and author.'],
-- commons: ['Wikimedia Commons', "Wikipedia's free media, with the attribution line."],
++ commons: ['Wikimedia Commons', "Wikimedia's open media, with the attribution line."],
iconify: ['Iconify', 'Open icon sets, as SVG with their licence.'],
deepseek: ['DeepSeek Search', 'Web search with fetched page text.'],
brave: ['Brave Search', 'An independent web index.'],
@@@ -24,70 +23,160 @@ const ENGINES = {
serper: ['Serper', "Google's results, fast."],
'serper-images': ['Serper Images', 'Google Images results, fast.'],
serpapi: ['SerpAPI', "Google's results with every feature."],
++ 'serpapi-images': ['SerpAPI Google Images', "Google Images through SerpAPI."],
++ 'serpapi-reverse': ['SerpAPI Reverse Image', 'Pages that carry a picture, through Google.'],
++ 'searchapi-images': ['SearchAPI Images', 'One supplier for Google, Bing and Yandex images.'],
++ tineye: ['TinEye', 'Reverse image matching against its own index.'],
jina: ['Jina', 'Search and a reader that cleans pages.'],
perplexity: ['Perplexity Sonar', 'An answer with its sources.'],
kagi: ['Kagi', 'A paid index without ads.'],
google: ['Google Programmable Search', 'Google, in a site-scoped engine.'],
meta: ['Meta AI search', 'A Muse Spark answer with the web pages it read.'],
'openvibe-egress': ['OpenVibe Fetch', 'Reads one page you name, through OpenVibe.'],
-- unsplash: ['Unsplash', 'Free photographs.'],
-- pexels: ['Pexels', 'Free photographs and videos.'],
-- pixabay: ['Pixabay', 'Free pictures and illustrations.'],
++ unsplash: ['Unsplash', 'Stock photographs.'],
++ pexels: ['Pexels', 'Stock photographs and videos.'],
++ pixabay: ['Pixabay', 'Stock pictures and illustrations.'],
};
const KINDS = [['web', 'Web'], ['images', 'Pictures'], ['icons', 'Icons']];
++const GROUPS = [...KINDS, ['reverse_image', 'Reverse image']];
++const FILTERS = {
++ web: [['freshness', 'Freshness'], ['safe_search', 'Strict safe search']],
++ images: [
++ ['license', 'Licence'], ['commercial', 'Commercial use'], ['format', 'Format'],
++ ['orientation', 'Orientation'], ['size', 'Size'], ['color', 'Colour'],
++ ['after', 'After'], ['type', 'Type'], ['safe_search', 'Strict safe search'],
++ ],
++ icons: [['set', 'Icon set'], ['style', 'Style'], ['license', 'Licence'], ['format', 'Format']],
++};
++const OPEN_DATA = new Set(['openverse', 'commons', 'iconify', 'openvibe']);
const titled = (id) => String(id || '').split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
const nameOf = (e) => (ENGINES[e.id] || [titled(e.id)])[0];
--// A missing price is not free: an engine billed per token says so instead of claiming "Free".
--const price = (e) => (e.price_per_1000 == null || !Number.isFinite(Number(e.price_per_1000)) ? 'Priced per call'
-- : !Number(e.price_per_1000) ? 'Free' : `$${Number(e.price_per_1000).toLocaleString('en-US', { maximumFractionDigits: 3 })} per 1,000`);
--const state = (e) => (e.available === false ? kit.badge(e.byok ? 'Your key' : 'Not set up yet') : e.healthy === false ? kit.badge('Resting', 'warn') : kit.badge('Ready', 'ok'));
++const lineOf = (e) => (ENGINES[e.id] || [])[1] || '';
++const money = (n) => `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 6 })}`;
++const priceWords = (e) => {
++ if (e.id === 'searxng') return 'Your instance';
++ if (e.byok) return 'Your key';
++ if (e.price_per_1000 == null) return 'Priced per call';
++ if (OPEN_DATA.has(e.id) || Number(e.price_per_1000) === 0) return 'No provider fee';
++ return `${money(e.price_per_1000)} per 1,000`;
++};
++const priceNote = (e) => {
++ const bits = [];
++ if (e.byok && Number(e.price_per_1000) > 0) bits.push(`The provider charges ${money(e.price_per_1000)} per 1,000`);
++ if (e.included_allowance) bits.push(`Included allowance: ${e.included_allowance}`);
++ if (e.onboarding === 'paused') bits.push('Not offered to new keys while issuance is paused');
++ if (!bits.length) bits.push(priceWords(e) === 'No provider fee' ? 'Open data; no provider query fee' : '');
++ return bits.filter(Boolean).join(' · ');
++};
++const state = (e) => {
++ if (e.onboarding === 'paused' && e.available === false) return kit.badge('New keys paused', 'warn');
++ if (e.available === false) return kit.badge(e.byok ? 'Add your key' : 'Not set up yet');
++ if (e.healthy === false) return kit.badge('Resting', 'warn');
++ return kit.badge('Ready', 'ok');
++};
++const supportWord = (v) => (v === 'native' ? 'Provider applies it' : v === 'local' ? 'We filter' : 'Not supported');
++const supportShort = (v) => (v === 'native' ? 'Provider' : v === 'local' ? 'We filter' : 'Not supported');
++const filterSupport = (e, kind) => (FILTERS[kind] || []).map(([key, label]) => [label, e && e.filters ? e.filters[key] || 'unsupported' : 'unsupported']);
function engineRow(e) {
-- const line = (ENGINES[e.id] || [])[1];
-- return `<li class="xs-engine${e.available === false ? ' is-off' : ''}"><div class="xs-engine-main"><strong>${esc(nameOf(e))}</strong>${line ? `<span>${esc(line)}</span>` : ''}</div>`
-- + `<span class="xs-engine-price"${e.pricing_note ? ` title="${esc(e.pricing_note)}"` : ''}>${esc(price(e))}</span>${state(e)}</li>`;
++ const note = priceNote(e);
++ return `<li class="xs-engine${e.available === false ? ' is-off' : ''}"><div class="xs-engine-main"><strong>${esc(nameOf(e))}</strong>`
++ + (lineOf(e) ? `<span>${esc(lineOf(e))}</span>` : '')
++ + `${e.upstreams && e.upstreams.length && !['openverse', 'commons', 'iconify', 'unsplash', 'pexels', 'pixabay', 'tineye', 'searxng', 'openvibe'].includes(e.id) ? `<span class="xs-upstream">Upstream: ${esc(e.upstreams.join(', '))}</span>` : ''}`
++ + `${note ? `<span class="xs-price-note">${esc(note)}</span>` : ''}</div>`
++ + `<span class="xs-engine-price">${esc(priceWords(e))}</span>${state(e)}</li>`;
++}
++
++function enginePick(field, label, engines, chosen, includeAuto = false, optional = false) {
++ const rows = engines.map((e) => `<option value="${esc(e.id)}"${e.id === chosen ? ' selected' : ''}>${esc(nameOf(e))}${e.available === false ? ' (needs a key)' : ''}</option>`).join('');
++ const empty = optional ? `<option value=""${chosen ? '' : ' selected'}>No third engine</option>` : '';
++ return `<label class="xs-field"><span>${label}</span><select name="${field}">${empty}${includeAuto ? `<option value="auto"${chosen === 'auto' ? ' selected' : ''}>Choose for me (the router's pick)</option>` : ''}${rows}</select></label>`;
}
--function results(list) {
++function resultRows(list, engines, query) {
if (!list.length) return '';
-- return `<div class="xs-results">${list.map((x) => `<section class="xs-result"><h3>${esc(nameOf({ id: x.engine }))}</h3>${x.error
-- ? `<p class="xs-note">${esc(x.error)}</p>`
-- : `<p class="xs-receipt">${esc(Number(x.receipt.cost_usd) ? `$${Number(x.receipt.cost_usd)}` : 'Free')} · ${Number(x.receipt.time_ms)} ms</p><ol>${x.results.map((r) => `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title)}</a>${r.snippet || r.attribution ? `<p>${esc(r.snippet || r.attribution)}</p>` : ''}${r.thumbnail ? `<img src="${esc(r.thumbnail)}" alt="" loading="lazy" width="160">` : ''}${r.source_page ? `<details><summary>Source and usage</summary><p><a href="${esc(r.source_page)}" rel="noopener noreferrer">Source page</a>${r.author ? ` · ${esc(r.author)}` : ''}${r.license ? ` · ${r.license_url ? `<a href="${esc(r.license_url)}" rel="noopener noreferrer">${esc(r.license)}</a>` : esc(r.license)}` : ' · Reuse rights unknown'}${r.width && r.height ? ` · ${Number(r.width)} × ${Number(r.height)} px` : ''}${r.published ? ` · ${esc(r.published)}` : ''}</p></details>` : ''}</li>`).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`}</section>`).join('')}</div>`;
++ const byId = new Map(engines.map((e) => [e.id, e]));
++ return `<div class="xs-results">${list.map((x) => {
++ const engineId = x.receipt?.engine || x.engine;
++ const meta = byId.get(engineId) || byId.get(x.engine) || { id: engineId };
++ const cost = Number(x.receipt?.cost_usd) || 0;
++ const receipt = x.error ? '' : `<p class="xs-receipt">${cost > 0 ? `${money(cost)} provider cost` : 'No provider fee'} · ${Number(x.receipt?.time_ms) || 0} ms</p>`;
++ const attempts = x.receipt?.attempts?.length
++ ? `<details class="xs-attempts"><summary>Every attempt</summary><ul>${x.receipt.attempts.map((a) => `<li>${esc(a.engine)}: ${esc(a.status)}${a.reason ? ` · ${esc(a.reason)}` : ''}${Number(a.cost_usd) ? ` · ${money(a.cost_usd)}` : ''}</li>`).join('')}</ul></details>`
++ : '';
++ const rows = x.error ? `<p class="xs-note">${esc(x.error)}</p>` : `<ol>${x.results.map((r, i) => {
++ const upstream = r.upstream || (meta.upstreams && meta.upstreams[0]) || null;
++ const use = r.download_location && engineId === 'unsplash'
++ ? `<form method="post" action="/search/unsplash" class="xs-use"><input type="hidden" name="download_location" value="${esc(r.download_location)}"><input type="hidden" name="url" value="${esc(r.url)}">${kit.button({ label: 'Use this picture', type: 'submit', kind: 'quiet' })}</form>`
++ : '';
++ return `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title || r.url)}</a>${r.snippet || r.attribution ? `<p>${esc(r.snippet || r.attribution)}</p>` : ''}${r.thumbnail ? `<img src="${esc(r.thumbnail)}" alt="" loading="lazy" width="160">` : ''}`
++ + `<details><summary>Source, rights and rank</summary><p><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">Source page</a>${r.author ? ` · ${esc(r.author)}` : ''}${r.license ? ` · ${r.license_url ? `<a href="${esc(r.license_url)}" rel="noopener noreferrer">${esc(r.license)}</a>` : esc(r.license)}` : ' · Reuse rights unknown'}${r.width && r.height ? ` · ${Number(r.width)} × ${Number(r.height)} px` : ''}`
++ + ` · Our order #${Number(r.rank) || i + 1}${r.upstream_rank ? ` · Provider's rank #${Number(r.upstream_rank)}` : ''}${upstream ? ` · Upstream: ${esc(upstream)}` : ''}${r.reverse_image ? ' · Reverse image match' : ''}${r.published ? ` · ${esc(r.published)}` : ''}</p></details>${use}</li>`;
++ }).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`;
++ return `<section class="xs-result"><h3>${esc(nameOf({ id: x.engine }))}</h3>${receipt}${attempts}${rows}</section>`;
++ }).join('')}</div>`;
}
function searchPage({ engines = [], found = [], query = {} } = {}) {
-- const groups = KINDS.map(([kind, label]) => {
-- const rows = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));
++ const kind = KINDS.some(([k]) => k === query.kind) ? query.kind : 'web';
++ const mode = query.mode === 'compare' ? 'compare' : 'single';
++ const forKind = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(kind));
++ const ready = forKind.filter((e) => e.available !== false);
++ const singleDefault = query.engine || 'auto';
++ const compareDefaults = [
++ query.first && query.first !== 'auto' ? query.first : (ready[0] || forKind[0] || {}).id,
++ query.second || (ready[1] || forKind[1] || {}).id,
++ query.third || '',
++ ];
++ const selectedIds = mode === 'compare'
++ ? compareDefaults.filter(Boolean)
++ : singleDefault === 'auto'
++ ? (ready.length ? ready : forKind).slice(0, 3).map((e) => e.id)
++ : [singleDefault].filter(Boolean);
++ const selected = selectedIds.map((id) => engines.find((e) => e.id === id)).filter(Boolean);
++ const support = selected.map((e) => `<div class="xs-support-engine"><strong>${esc(nameOf(e))}</strong><span>${filterSupport(e, kind).map(([label, v]) => `${esc(label)}: ${esc(supportShort(v))}`).join(' · ')}</span></div>`).join('');
++
++ const field = (name, label, type = 'text', extra = '') => `<label class="xs-field"><span>${label}</span><input name="${name}" type="${type}" ${extra} value="${esc(query[name] || '')}"></label>`;
++ const select = (name, label, values) => `<label class="xs-field"><span>${label}</span><select name="${name}"><option value="">Any</option>${values.map((v) => `<option value="${esc(v[0])}"${query[name] === v[0] ? ' selected' : ''}>${esc(v[1])}</option>`).join('')}</select></label>`;
++ const filters = kind === 'images'
++ ? select('size', 'Size', [['small', 'Small (under 800 px)'], ['medium', 'Medium (800 to 1999 px)'], ['large', 'Large (2000 px and up)']])
++ + select('orientation', 'Orientation', [['landscape', 'Landscape'], ['portrait', 'Portrait'], ['square', 'Square']])
++ + select('color', 'Colour', ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black_and_white'].map((v) => [v, v.replace(/_/g, ' ')]))
++ + select('type', 'Type', [['photo', 'Photo'], ['illustration', 'Illustration'], ['vector', 'Vector']])
++ + field('license', 'Licence', 'text', 'placeholder="cc0"')
++ + `<label class="xs-check"><input type="checkbox" name="commercial" value="true"${query.commercial ? ' checked' : ''}><span>Commercial use only</span></label>`
++ + field('format', 'Format', 'text', 'placeholder="jpeg or svg"')
++ + field('after', 'After', 'date')
++ + `<label class="xs-check"><input type="checkbox" name="safe_search" value="strict"${query.safe_search ? ' checked' : ''}><span>Strict safe search only</span></label>`
++ : kind === 'icons'
++ ? field('set', 'Icon set', 'text', 'placeholder="lucide"') + select('style', 'Style', [['any', 'Any'], ['line', 'Line'], ['solid', 'Solid']]) + field('license', 'Licence')
++ : select('freshness', 'Freshness', [['day', 'Past day'], ['month', 'Past month'], ['year', 'Past year']])
++ + `<label class="xs-check"><input type="checkbox" name="safe_search" value="strict"${query.safe_search ? ' checked' : ''}><span>Strict safe search only</span></label>`;
++ const filterCount = Object.keys(FILTERS[kind].reduce((a, [key]) => ({ ...a, [key]: query[key] }), {})).filter((key) => query[key]).length;
++
++ const groups = GROUPS.map(([groupKind, label]) => {
++ const rows = engines.filter((e) => Array.isArray(e.kinds) && e.kinds.includes(groupKind));
return rows.length ? `<section class="xs-group"><h3>${esc(label)}<span class="ovk-count">${rows.length}</span></h3><ul class="xs-engines">${rows.map(engineRow).join('')}</ul></section>` : '';
}).join('');
-- const kind = KINDS.some(([k]) => k === query.kind) ? query.kind : 'web';
-- // The engines in each list grouped by what they find; the first two that work are picked unless the search named some.
-- // The first two that work for the chosen kind (Web picked two picture engines when those came first in AI's list).
-- const forKind = (e) => !Array.isArray(e.kinds) || e.kinds.includes(kind);
-- const ready = [...engines.filter((e) => e.available !== false && forKind(e)), ...engines.filter((e) => e.available !== false && !forKind(e))];
-- const defaults = [query.first || (ready[0] || engines[0] || {}).id, query.second || (ready[1] || engines[1] || {}).id];
-- const pick = (field, label, chosen) => `<label class="xs-field"><span>${label}</span><select name="${field}">${KINDS.map(([k, l]) => {
-- const rows = engines.filter((e) => (Array.isArray(e.kinds) ? e.kinds[0] === k : k === 'web'));
-- return rows.length ? `<optgroup label="${esc(l)}">${rows.map((e) => `<option value="${esc(e.id)}"${e.id === chosen ? ' selected' : ''}>${esc(nameOf(e))}${e.available === false ? ' (needs a key)' : ''}</option>`).join('')}</optgroup>` : '';
-- }).join('')}</select></label>`;
-- const sel = (name, label, values) => `<label class="xs-field"><span>${label}</span><select name="${name}"><option value="">Any</option>${values.map((v) => `<option value="${v}"${query[name] === v ? ' selected' : ''}>${v.replace(/_/g, ' ')}</option>`).join('')}</select></label>`;
-- const set = ['size', 'color', 'type', 'license', 'after'].filter((k) => query[k]).length;
-- const filters = `<details class="xs-more"${set ? ' open' : ''}><summary>Picture filters${set ? `<span class="ovk-count">${set}</span>` : ''}<small>for Pictures</small></summary><div class="xs-more-body">`
-- + sel('size', 'Size', ['small', 'medium', 'large'])
-- + sel('color', 'Colour', ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black_and_white'])
-- + sel('type', 'Type', ['photo', 'illustration', 'vector'])
-- + `<label class="xs-field"><span>Licence</span><input name="license" value="${esc(query.license || '')}" placeholder="cc0"></label>`
-- + `<label class="xs-field"><span>After</span><input name="after" type="date" value="${esc(query.after || '')}"></label></div></details>`;
-- const kinds = `<fieldset class="xs-kinds"><legend class="ex-sr">What to find</legend>${KINDS.map(([k, l]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}><span>${l}</span></label>`).join('')}</fieldset>`;
-- return `<div class="ex-wrap xs">${kit.head({ title: 'Search', sub: 'Web, pictures and icons from many engines, with the same key as your models.' })}`
-- + `<section class="xs-try" aria-labelledby="xs-try-h"><h2 id="xs-try-h">Compare two engines</h2><form method="post" action="/search" class="xs-form">`
++ const single = `<div class="xs-single">${enginePick('engine', 'Engine', forKind, singleDefault, true)}</div>`;
++ const compare = `<div class="xs-compare">${enginePick('first', 'First engine', forKind, compareDefaults[0])}${enginePick('second', 'Second engine', forKind, compareDefaults[1])}${enginePick('third', 'Third engine (optional)', forKind, compareDefaults[2], false, true)}</div>`;
++ const resultHead = mode === 'compare'
++ ? `<p class="xs-receipt">One combined cap ${query.max_cost_usd != null && query.max_cost_usd !== '' ? money(query.max_cost_usd) : 'not set'} · spent ${money(query.spent || 0)}</p>`
++ : `<p class="xs-receipt">Cap ${query.max_cost_usd != null && query.max_cost_usd !== '' ? money(query.max_cost_usd) : 'not set'} · spent ${money(query.spent || 0)}</p>`;
++ return `<div class="ex-wrap xs">${kit.head({ title: 'Search', sub: 'Web, pictures and icons from many engines, with the same key as your models. One engine by default; comparing is your choice.' })}`
++ + `<section class="xs-try" aria-labelledby="xs-try-h"><h2 id="xs-try-h">Search</h2><form method="post" action="/search" class="xs-form">`
+ `<div class="xs-q"><input name="q" required maxlength="500" placeholder="What are you looking for?" value="${esc(query.q || '')}" aria-label="Search for">`
-- + `${kit.button({ label: 'Compare', type: 'submit', icon: 'search' })}</div>`
-- + `${kinds}<div class="xs-pair">${pick('first', 'First engine', defaults[0])}<span class="xs-vs" aria-hidden="true">vs</span>${pick('second', 'Second engine', defaults[1])}</div>`
-- + `${filters}</form>${results(found)}</section>`
++ + `${kit.button({ label: 'Search', type: 'submit', icon: 'search' })}</div>`
++ + `<fieldset class="xs-kinds"><legend class="ex-sr">What to find</legend>${KINDS.map(([k, l]) => `<label><input type="radio" name="kind" value="${k}"${k === kind ? ' checked' : ''}><span>${l}</span></label>`).join('')}</fieldset>`
++ + `<fieldset class="xs-modes"><legend class="ex-sr">How many engines</legend><label><input type="radio" name="mode" value="single"${mode === 'single' ? ' checked' : ''}><span>One engine</span></label><label><input type="radio" name="mode" value="compare"${mode === 'compare' ? ' checked' : ''}><span>Compare 2 or 3 engines</span></label></fieldset>`
++ + `${single}${compare}`
++ + `<label class="xs-field xs-cap"><span>Spending cap, USD</span><input name="max_cost_usd" type="number" min="0" step="0.001" placeholder="Set a cap" value="${query.max_cost_usd != null ? esc(query.max_cost_usd) : ''}"><small>${mode === 'compare' ? 'One combined cap for every engine in this comparison. The search stops before it is exceeded.' : 'The most this search may spend with the provider.'}</small></label>`
++ + `<details class="xs-more"${filterCount ? ' open' : ''}><summary>Filters${filterCount ? `<span class="ovk-count">${filterCount}</span>` : ''}<small>for ${esc((KINDS.find(([k]) => k === kind) || [])[1] || 'Web')}</small></summary><div class="xs-more-body">${filters}</div></details>`
++ + `<details class="xs-support"${support ? ' open' : ''}><summary>What the chosen ${mode === 'compare' ? 'engines' : 'engine'} can filter</summary>${support || '<p class="xs-note">Choose an engine to see what it can honour.</p>'}<p class="xs-note">A filter the chosen engine cannot honour is refused before the search, never dropped.</p></details>`
++ + `</form>${query.note ? `<p class="xs-note">${esc(query.note)}</p>` : ''}${resultHead}${resultRows(found, engines, query)}</section>`
+ `<section class="xs-list" aria-labelledby="xs-list-h"><h2 id="xs-list-h">The engines<span class="ovk-count">${engines.length}</span></h2>`
+ (groups || '<p class="xs-note">The engine list is not reachable right now.</p>') + '</section>'
-- + `<p class="xs-note">Pictures come with their licence and author: keep the attribution line with each one. With your own engine key a search costs the engine's price and nothing more.</p></div>`;
++ + `<p class="xs-note">The words matter: “No provider fee” is open data; “Your instance” is a SearXNG you run; “Included allowance” is a provider free tier; “Your key” is your own key and the provider may charge. Nothing here is labelled free. Pictures come with their licence and author: keep the attribution line with each one.</p></div>`;
}
module.exports = { searchPage, ENGINES };
ddiff --git a/test/image-search-view.test.js b/test/image-search-view.test.js
iindex 4d89e0a..03a315c 100644
---- a/test/image-search-view.test.js
++++ b/test/image-search-view.test.js
@@@ -2,17 +2,54 @@
const assert = require('node:assert/strict');
const { searchPage } = require('../server/views/search');
--const html = searchPage({ query: { kind: 'images', color: 'blue', size: 'large' }, found: [{ engine: 'openverse', receipt: { cost_usd: 0, time_ms: 12 }, results: [{
-- title: 'Blue mountain', url: 'https://example.org/image.jpg', thumbnail: 'https://example.org/thumb.jpg', source_page: 'https://example.org/page',
-- license: 'cc-by', license_url: 'https://example.org/license', author: 'Alice', width: 2400, height: 1600, published: '2026-10-01',
--}] }] });
--assert.ok(html.includes('Source and usage'));
--assert.ok(html.includes('Reuse rights unknown') === false);
--assert.ok(html.includes('2400 × 1600 px'));
--assert.ok(html.includes('name="color"') && html.includes('name="after"'));
--// The default engines follow the kind: Web picks the first two working web engines even when picture engines come first.
--const E = (id, kinds, available = true) => ({ id, kinds, available, price_per_1000: 0 });
--const web = searchPage({ engines: [E('searxng', ['web'], false), E('openverse', ['images']), E('commons', ['images']), E('openvibe', ['web']), E('deepseek', ['web'])] });
--assert.match(web, /name="first">[\s\S]*?value="openvibe" selected/);
--assert.match(web, /name="second">[\s\S]*?value="deepseek" selected/);
++const E = (id, kinds, extra = {}) => ({
++ id, kinds, available: true, price_per_1000: 0, byok: false, healthy: true,
++ filters: { license: 'local', color: 'unsupported' }, upstreams: [], ...extra,
++});
++const engines = [
++ E('openverse', ['images'], { filters: { license: 'native', color: 'unsupported' } }),
++ E('unsplash', ['images'], { byok: true, filters: { license: 'local', color: 'native', safe_search: 'native' } }),
++ E('pexels', ['images'], { byok: true, onboarding: 'paused', available: false }),
++ E('serpapi-images', ['images'], { byok: true, price_per_1000: 25, included_allowance: '250 searches a month', upstreams: ['google'], filters: { license: 'unsupported', color: 'unsupported' } }),
++ E('tineye', ['reverse_image'], { byok: true, price_per_1000: 40, filters: {} }),
++ E('searxng', ['web'], { available: true }),
++ E('openvibe', ['web']),
++];
++
++const result = searchPage({
++ engines,
++ query: { kind: 'images', color: 'blue', size: 'large', first: 'openverse', max_cost_usd: '0.05' },
++ found: [{ engine: 'openverse', receipt: { cost_usd: 0, time_ms: 12 }, results: [{
++ title: 'Blue mountain', url: 'https://example.org/image.jpg', thumbnail: 'https://example.org/thumb.jpg',
++ source_page: 'https://example.org/page', license: 'cc-by', license_url: 'https://example.org/license',
++ author: 'Alice', width: 2400, height: 1600, rank: 1, upstream_rank: 2, upstream: 'openverse',
++ }] }],
++});
++assert.ok(result.includes('Source, rights and rank'));
++assert.ok(result.includes('2400 × 1600 px'));
++assert.ok(result.includes('name="color"') && result.includes('name="after"'));
++assert.ok(result.includes('name="max_cost_usd"'));
++assert.ok(result.includes('Our order #1') && result.includes("Provider's rank #2"));
++assert.ok(result.includes('No provider fee') && !result.includes('>Free<'));
++assert.match(result, /name="mode" value="single" checked/);
++assert.match(result, /name="engine">[\s\S]*?value="openverse" selected/);
++const defaulted = searchPage({ engines, query: { kind: 'images' } });
++assert.match(defaulted, /name="engine">[\s\S]*?value="auto" selected/);
++assert.ok(result.includes('Compare 2 or 3 engines'));
++assert.ok(result.includes('One combined cap') === false, 'single mode has one cap, not a combined one');
++assert.ok(result.includes('Licence: Provider') && result.includes('Colour: Not supported'));
++assert.ok(result.includes('Your key') && result.includes('Your instance') && result.includes('No provider fee'));
++assert.ok(result.includes('Included allowance: 250 searches a month'));
++assert.ok(result.includes('New keys paused'));
++assert.ok(result.includes('Reverse image'));
++
++const compare = searchPage({
++ engines, query: { kind: 'images', mode: 'compare', first: 'openverse', second: 'unsplash', max_cost_usd: '0.02', spent: 0.01 },
++});
++assert.match(compare, /name="mode" value="compare" checked/);
++assert.ok(compare.includes('One combined cap $0.02') && compare.includes('spent $0.01'));
++assert.match(compare, /name="second">[\s\S]*?value="unsplash" selected/);
++
++// The engine list never says a provider is free; only the words the brief allows.
++assert.ok(!result.includes('Free'));
process.stdout.write('image search view passed\n');
ddiff --git a/test/search-runs.test.js b/test/search-runs.test.js
nnew file mode 100644
iindex 0000000..7adc5ca
---- /dev/null
++++ b/test/search-runs.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { runSearch, selectedEngines } = require('../server/http/search');
++
++const card = (id, kinds = ['web'], extra = {}) => ({ id, kinds, available: true, price_per_1000: 0, byok: false, ...extra });
++const engines = [card('openvibe'), card('deepseek'), card('brave'), card('openverse', ['images'])];
++
++(async () => {
++ const calls = [];
++ const ai = {
++ async meRequest(_token, path, { body }) {
++ calls.push({ path, body });
++ const engine = body.engine === 'auto' ? 'openvibe' : body.engine;
++ return { results: [], receipt: { engine, cost_usd: body.engine === 'brave' ? 0.002 : 0.003, time_ms: 1 } };
++ },
++ };
++
++ // One engine by default: the router's pick, not two engines paid twice.
++ let out = await runSearch({ ai, token: 't', body: { q: 'x' }, kind: 'web', engines });
++ assert.equal(out.mode, 'single');
++ assert.equal(calls.length, 1);
++ assert.equal(calls[0].body.engine, 'auto');
++ assert.equal(out.spent, 0.003);
++
++ // An explicit engine is still one call.
++ calls.length = 0;
++ out = await runSearch({ ai, token: 't', body: { q: 'x', first: 'deepseek' }, kind: 'web', engines });
++ assert.deepEqual(calls.map((c) => c.body.engine), ['deepseek']);
++
++ // Compare is explicit, sequential, and every call gets what is left of ONE combined cap.
++ calls.length = 0;
++ out = await runSearch({
++ ai, token: 't', kind: 'web', engines,
++ body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'deepseek', third: 'brave', max_cost_usd: '0.01' },
++ });
++ assert.equal(out.mode, 'compare');
++ assert.deepEqual(calls.map((c) => c.body.engine), ['openvibe', 'deepseek', 'brave']);
++ assert.deepEqual(calls.map((c) => c.body.max_cost_usd), [0.01, 0.007, 0.004]);
++ assert.ok(out.spent <= 0.01 + 1e-9);
++
++ // A duplicate choice never runs the same engine twice; fewer than two means a note, not a paid call.
++ calls.length = 0;
++ out = await runSearch({ ai, token: 't', kind: 'web', engines, body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'openvibe' } });
++ assert.equal(out.results.length, 0);
++ assert.match(out.note, /two or three/);
++ assert.equal(calls.length, 0);
++
++ // Four picks are capped at three.
++ calls.length = 0;
++ out = await runSearch({ ai, token: 't', kind: 'web', engines, body: { q: 'x', mode: 'compare', first: 'openvibe', second: 'deepseek', third: 'brave', fourth: 'openverse' } });
++ assert.equal(calls.length, 3);
++
++ // Image filters are passed through, and strict safe search is a filter rather than a label.
++ calls.length = 0;
++ await runSearch({ ai, token: 't', kind: 'images', engines, body: { q: 'x', first: 'openverse', license: 'cc0', commercial: 'true', safe_search: 'strict', size: 'large' } });
++ assert.equal(calls[0].path, '/v1/search/images');
++ assert.equal(calls[0].body.license, 'cc0');
++ assert.equal(calls[0].body.commercial, true);
++ assert.equal(calls[0].body.safe_search, 'strict');
++ assert.equal(calls[0].body.size, 'large');
++
++ // A filter the chosen engine cannot honour is shown to the person, not turned into an empty result.
++ const refuses = { async meRequest() { throw Object.assign(new Error('openverse cannot honour color; choose an engine that supports it'), { status: 422 }); } };
++ out = await runSearch({ ai: refuses, token: 't', kind: 'images', engines, body: { q: 'x', first: 'openverse', color: 'blue' } });
++ assert.match(out.results[0].error, /cannot honour color/);
++
++ // The view and the handler agree on the default.
++ assert.deepEqual(selectedEngines({}, engines, 'web'), { mode: 'single', engines: ['auto'] });
++ console.log('search runs: one engine by default, explicit compare and one combined cap passed');
++})().catch(e => { console.error(e); process.exitCode = 1; });