openvibers/openvibe.exchange Commits

Search models and provider offers by task, price and performance (#199)

OP OpenVibers committed 2 days ago · ef03a50b37 · 10 files, +173 −9

* Add model and provider search with live offer comparisons

* Merge remote-tracking branch 'origin/main' into ask-358-exchange

* Support benchmark task search and verify image source sheet

Changes

ddiff --git a/server/app.js b/server/app.js
iindex e2613bb..3dd5b86 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -263,6 +263,20 @@ async function createApp(opts = {}) {
app.use('/api/v1/analysis', rateLimit({ windowMs: 60000, limit: Number(process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN) || 120, standardHeaders: true, legacyHeaders: false }), require('./http/analysis').createAnalysisRoutes(ctx));
++ app.get('/api/v1/search', rateLimit({ windowMs: 60000, limit: Number(process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN) || 120, standardHeaders: true, legacyHeaders: false }), async (req, res, next) => {
++ try {
++ const { parseSearch, searchCatalog } = require('./model-search');
++ const params = parseSearch(req.query);
++ const [catalog, board] = await Promise.all([ai.catalog(), bench.board({ kind: params.ratingKind }).catch(() => ({ rows: [], unavailable: true }))]);
++ if (catalog.unavailable) return res.status(503).set('Cache-Control', 'no-store').json({ error: 'catalog_unavailable' });
++ const results = searchCatalog(catalog, board.rows || [], params);
++ return res.set('Cache-Control', 'public, max-age=30').json({ q: params.q, count: results.length, results: results.slice(0, 100), quality_unavailable: Boolean(board.unavailable) });
++ } catch (err) {
++ if (err instanceof RangeError) return res.status(400).json({ error: 'bad_filter', detail: err.message });
++ return next(err);
++ }
++ });
++
// ── Compare's JSON API (openvibe.compare) ───────────────
// Mounted before Exchange v1's catch-all, which answers 410 to everything else under /api/v1.
app.use('/api/v1/compare', createCompareApi(ctx));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4102003..ca7ef29 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -122,11 +122,23 @@ function createPageRoutes(ctx) {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
const family = typeof req.query.family === 'string' ? req.query.family.slice(0, 60) : '';
const sort = ['cheap', 'fast'].includes(req.query.sort) ? req.query.sort : '';
++ let searchResults = null;
++ if (q || req.query.capability || req.query.provider || req.query.max_price || req.query.min_price || req.query.max_speed_ms || req.query.rating_kind) {
++ const { parseSearch, searchCatalog } = require('../model-search');
++ try {
++ const params = parseSearch(req.query);
++ const board = await ctx.bench.board({ kind: params.ratingKind }).catch(() => ({ rows: [] }));
++ searchResults = searchCatalog(catalog, board.rows || [], params);
++ } catch (err) {
++ if (err instanceof RangeError) return page(req, res, { title: 'Invalid search', body: raw(`<p>${esc(err.message)}</p>`) }, 400);
++ throw err;
++ }
++ }
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: q ? `Models: ${q}` : 'Models',
description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',
-- body: raw(platform.modelsPage({ catalog, query: req.query })),
++ body: raw(platform.modelsPage({ catalog, query: req.query, searchResults })),
});
});
ddiff --git a/server/http/search.js b/server/http/search.js
iindex 6e2db9d..72837ae 100644
---- a/server/http/search.js
++++ b/server/http/search.js
@@@ -34,7 +34,8 @@ function createSearchRoutes({ config, ai }) {
'/v1/search' + (kind === 'web' ? '' : '/' + kind),
{
method: 'POST',
-- body: { q: req.body.q, engine, limit: 6 },
++ 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)])) },
},
)),
};
@@@ -46,7 +47,8 @@ function createSearchRoutes({ config, ai }) {
}
}),
);
-- await render(req, res, results, { q: String(req.body.q || '').slice(0, 500), kind });
++ 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)])) });
});
return r;
}
ddiff --git a/server/model-search.js b/server/model-search.js
nnew file mode 100644
iindex 0000000..47d402b
---- /dev/null
++++ b/server/model-search.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++const { ratingFor } = require('./analysis');
++
++const TASKS = {
++ coder: ['code', 'coding', 'programming', 'developer'],
++ vision: ['image', 'images', 'picture', 'visual'],
++ tools: ['agent', 'function', 'tool'],
++ json: ['structured', 'json'],
++};
++const clean = (value) => String(value || '').normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
++const number = (value) => value === '' || value == null ? null : Number.isFinite(Number(value)) ? Number(value) : null;
++
++function parseSearch(query = {}) {
++ const q = String(query.q || '').slice(0, 200).trim();
++ const words = clean(q).match(/[\p{L}\p{N}]+/gu) || [];
++ const intent = {
++ cheap: words.some((w) => ['cheap', 'budget', 'affordable', 'barato', 'economico', 'bonmarche'].includes(w)),
++ fast: words.some((w) => ['fast', 'quick', 'rapido', 'rapide', 'schnell'].includes(w)),
++ capabilities: Object.keys(TASKS).filter((key) => words.some((w) => w === key || TASKS[key].includes(w))),
++ };
++ const inlineMax = clean(q).match(/(?:under|below|less than|menos de|sous)\s*\$?\s*(\d+(?:\.\d+)?)/);
++ const maxPrice = query.max_price !== undefined ? number(query.max_price) : inlineMax ? number(inlineMax[1]) : null;
++ const minPrice = query.min_price !== undefined ? number(query.min_price) : null;
++ const maxSpeed = query.max_speed_ms !== undefined ? number(query.max_speed_ms) : null;
++ const capability = String(query.capability || '').toLowerCase();
++ if ([minPrice, maxPrice, maxSpeed].some((n) => n !== null && (n < 0 || n > 1e6))) throw new RangeError('Filters must be between 0 and 1000000.');
++ if (capability && !/^[a-z0-9_-]{1,40}$/.test(capability)) throw new RangeError('Invalid capability.');
++ const ratingKind = String(query.rating_kind || 'chat.arena');
++ if (!/^[a-z0-9][a-z0-9_.-]{0,79}$/.test(ratingKind)) throw new RangeError('Invalid benchmark kind.');
++ return { q, words, intent, maxPrice, minPrice, maxSpeed, capability, ratingKind,
++ provider: String(query.provider || '').slice(0, 80), sort: ['price', 'speed', 'quality'].includes(query.sort) ? query.sort : 'relevance' };
++}
++
++function searchCatalog(catalog, rows, params) {
++ const { words, intent, minPrice, maxPrice, maxSpeed, capability, provider } = params;
++ const ignored = new Set(['model', 'models', 'a', 'an', 'the', 'for', 'with', 'under', 'below', 'less', 'than', 'de', 'sous', 'cheap', 'budget', 'affordable', 'fast', 'quick', 'coder', 'coding', 'vision', 'image', 'images', 'picture', 'visual', 'code', 'programming', 'developer', 'agent', 'function', 'tool', 'json', 'structured', 'benchmark', 'benchmarks', 'quality']);
++ const terms = words.filter((w) => !ignored.has(w) && !/^\d+(?:\.\d+)?$/.test(w));
++ const results = [];
++ for (const model of catalog.models || []) {
++ const caps = model.capabilities || {};
++ const hasCap = (key) => key === 'coder' ? Boolean(caps.code || caps.coding || /cod(e|er|ing)/i.test(`${model.name} ${model.slug}`)) : Boolean(caps[key]);
++ if (capability && !hasCap(capability)) continue;
++ if (intent.capabilities.some((key) => !hasCap(key))) continue;
++ const hay = clean([model.name, model.slug, model.family, model.description, ...(model.offers || []).flatMap((o) => [o.provider, o.provider_name])].join(' '));
++ if (terms.some((term) => !hay.includes(term))) continue;
++ const offers = (model.offers || []).filter((o) => {
++ if (provider && !clean(`${o.provider} ${o.provider_name}`).includes(clean(provider))) return false;
++ const p = o.price && number(o.price.out);
++ const speed = o.speed && number(o.speed.p50_ms);
++ if (minPrice !== null && (p === null || p < minPrice)) return false;
++ if (maxPrice !== null && (p === null || p > maxPrice)) return false;
++ if (maxSpeed !== null && (speed === null || speed > maxSpeed)) return false;
++ return true;
++ });
++ if (!offers.length) continue;
++ offers.sort((a, b) => (number(a.price?.out) ?? Infinity) - (number(b.price?.out) ?? Infinity));
++ const best = intent.fast ? [...offers].sort((a, b) => (number(a.speed?.p50_ms) ?? Infinity) - (number(b.speed?.p50_ms) ?? Infinity))[0] : offers[0];
++ const rating = ratingFor(model, rows);
++ const nameScore = terms.reduce((score, term) => score + (clean(model.name).includes(term) ? 2 : 0), 0);
++ results.push({ model: model.slug, name: model.name, family: model.family, capabilities: caps,
++ provider: best.provider_name || best.provider, price: best.price || null, speed: best.speed || null,
++ quality: rating ? { elo: rating.elo, games: rating.games, kind: params.ratingKind } : null,
++ offers: offers.length, score: nameScore + (model.requests_7d || 0) / 10000 });
++ }
++ const sort = params.sort !== 'relevance' ? params.sort : intent.fast ? 'speed' : intent.cheap ? 'price' : 'relevance';
++ const metric = (r) => sort === 'speed' ? number(r.speed?.p50_ms) ?? Infinity : sort === 'quality' ? -(number(r.quality?.elo) ?? -Infinity) : number(r.price?.out) ?? Infinity;
++ results.sort((a, b) => sort === 'relevance' ? b.score - a.score || metric(a) - metric(b) : metric(a) - metric(b) || b.score - a.score);
++ return results;
++}
++
++module.exports = { parseSearch, searchCatalog };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 384fd0d..d68fa60 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -22,6 +22,7 @@ function analysisPaths(config) {
const query = (name) => ({ name, in: 'query', schema: { type: 'string' } });
const param = (name) => ({ name, in: 'path', required: true, schema: { type: 'string' } });
return {
++ '/api/v1/search': { get: { operationId: 'exchange.catalog.search', summary: 'Find models and provider offers by name, task, capability, price and speed', security: [], servers: [{ url: config.baseUrl }], parameters: ['q', 'provider', 'capability', 'min_price', 'max_price', 'max_speed_ms', 'sort', 'rating_kind'].map(query), responses: { '200': { description: 'Catalog matches with price, measured speed and optional Bench quality rating' }, '400': { description: 'Invalid filter' }, '503': { description: 'Catalog unavailable' } } } },
'/api/v1/analysis/compare': make('exchange.analysis.compare', ['models', 'measures', 'kind', 'day', 'rating_kind'].map(query)),
'/api/v1/analysis/models/{model}': make('exchange.analysis.model', [param('model'), query('days'), query('rating_kind')]),
'/api/v1/analysis/ratings/{kind}': make('exchange.analysis.ratings', [param('kind')]),
ddiff --git a/server/views/platform.js b/server/views/platform.js
iindex b163c82..3742346 100644
---- a/server/views/platform.js
++++ b/server/views/platform.js
@@@ -44,10 +44,11 @@ const dash = (v, unit = '') => (v == null ? '–' : `${Number(v).toLocaleString(
* The models as rows (home's popular models and /models): the name and its maker with badges, in / out per million,
* uptime and speed with their labels, and Try in Chat / How it ranks as small quiet buttons. Opus, 11-09.
*/
--function table(models) {
++function table(models, searchMeta = null) {
if (!models.length) return '<p class="xm-empty">No models match these filters.</p>';
return `<ul class="xm-list">${models.map((m) => {
-- const o = best(m);
++ const found = searchMeta && searchMeta.get(id(m));
++ const o = found ? { price: found.price, speed: found.speed, provider_name: found.provider } : best(m);
const badges = [
(m.offers || []).some(free) ? kit.badge('Free to try') : (m.offers || []).some(paused) ? kit.badge('Free paused') : '',
m.uncensored ? kit.badge('18+', 'bad') : '',
@@@ -55,7 +56,7 @@ function table(models) {
].filter(Boolean).join(' ');
const fact = (label, value) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`;
return `<li class="xm-row"><div class="xm-name"><a href="/models/${encodeURIComponent(id(m))}">${esc(m.name)}</a><span class="xm-maker">${esc(legacy.familyLabel(m))}</span>${badges ? `<span class="xm-badges">${badges}</span>` : ''}</div>`
-- + `<dl class="xm-facts">${fact('In / out per M', `${perM(o.price?.in)} / ${perM(o.price?.out)}`)}${fact('Uptime', o.uptime == null ? '–' : `${dash(o.uptime * 100)}%`)}${fact('Speed', o.speed?.tokens_per_second == null ? '–' : `${dash(o.speed.tokens_per_second)} tok/s`)}</dl>`
++ + `<dl class="xm-facts">${fact('In / out per M', `${perM(o.price?.in)} / ${perM(o.price?.out)}`)}${fact('Uptime', o.uptime == null ? '–' : `${dash(o.uptime * 100)}%`)}${fact('Speed', o.speed?.tokens_per_second != null ? `${dash(o.speed.tokens_per_second)} tok/s` : o.speed?.p50_ms == null ? '–' : `${dash(o.speed.p50_ms)} ms`)}${found ? fact('Quality', found.quality ? `${dash(found.quality.elo)} Elo · ${found.quality.games} games` : 'Not measured') : ''}${found ? fact('Provider', found.provider) : ''}</dl>`
+ `<p class="xm-acts">${kit.button({ href: chat(m), label: 'Try in Chat', kind: 'quiet', small: true })} ${kit.button({ href: rank(m), label: 'How it ranks', kind: 'quiet', small: true })}</p></li>`;
}).join('')}</ul>`;
}
@@@ -97,7 +98,7 @@ function home({ catalog, usage, viewer }) {
],
)}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}<div class="xp-reasons">${section('0% fee on your own key', '<p>Bring your provider key and pay the provider\'s price, nothing more. No cap, no end date.</p>')}${section('The cheapest route', '<p>Every call goes to the cheapest provider that is up. Open models on OpenVibe\'s own GPUs, at a fixed price per million tokens, are coming next.</p>')}${section('Uncensored, 18+', '<p>Uncensored models for adults who opt in. The content floor applies on every request.</p><a href="/uncensored">What the floor covers</a>')}</div>${pricing()}${section('Questions', '<details><summary>Can I use my existing client?</summary><p>Yes. Use the OpenAI-compatible endpoint with your Exchange key.</p></details><details><summary>Where can I talk to a model?</summary><p><a href="https://openvibe.chat/vibe">Open Chat</a> to talk or compare models.</p></details><details><summary>Where do the numbers come from?</summary><p>Prices come from the live catalogue. Speed and reliability come from measured requests. Missing measurements stay unknown.</p></details>')}</div>`;
}
--function modelsPage({ catalog, query = {} }) {
++function modelsPage({ catalog, query = {}, searchResults = null }) {
const all = listed(catalog);
let rows = all;
if (query.family && !query.maker) query = { ...query, maker: query.family };
@@@ -129,6 +130,10 @@ function modelsPage({ catalog, query = {} }) {
.toLowerCase()
.includes(String(query.q).toLowerCase()),
);
++ if (searchResults) {
++ const byId = new Map(all.map((m) => [id(m), m]));
++ rows = searchResults.map((r) => byId.get(r.model)).filter(Boolean);
++ }
const chips = [...new Set(all.map((m) => m.family).filter(Boolean))].map(
(v) => chip(v, 'maker', v, all.filter((m) => tests.maker(m, v)).length),
);
@@@ -148,6 +153,14 @@ function modelsPage({ catalog, query = {} }) {
all.filter((m) => tests[key](m, value)).length,
),
);
-- return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${kit.find({ action: '/models', name: 'q', value: query.q || '', placeholder: 'Find a model', icon: 'search' })}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
++ for (const [key, label, value] of [['capability', 'Vision', 'vision'], ['capability', 'Tools', 'tools'], ['max_price', 'Under $1/M out', '1'], ['max_speed_ms', 'Under 500 ms', '500']]) {
++ const params = new URLSearchParams(Object.entries(query).filter(([, v]) => typeof v === 'string'));
++ if (params.get(key) === value) params.delete(key); else params.set(key, value);
++ chips.push(`<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(params.toString())}">${label}</a>`);
++ }
++ const searchMeta = searchResults && new Map(searchResults.map((r) => [r.model, r]));
++ const field = (name, label, value = '') => `<label>${esc(label)} <input name="${name}" value="${esc(query[name] || '')}" placeholder="${esc(value)}"></label>`;
++ const filters = `<form action="/models" method="get">${field('q', 'Search', 'cheap vision model')} ${field('provider', 'Provider')} ${field('capability', 'Capability', 'vision')} ${field('min_price', 'Min $/M out')} ${field('max_price', 'Max $/M out')} ${field('max_speed_ms', 'Max median ms')} ${field('rating_kind', 'Benchmark', 'chat.arena')} ${kit.button({ label: 'Find', type: 'submit' })}</form>`;
++ return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${filters}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows, searchMeta)}<p class="ex-more"><a href="https://openvibe.compare/">Compare models side by side on OpenVibe.Compare →</a></p></div>`;
}
module.exports = { home, modelsPage, pricing, pricingPage, table };
ddiff --git a/server/views/search.js b/server/views/search.js
iindex cde200a..1aaff2a 100644
---- a/server/views/search.js
++++ b/server/views/search.js
@@@ -43,7 +43,7 @@ function results(list) {
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>` : ''}</li>`).join('') || '<li class="xs-note">Nothing found.</li>'}</ol>`}</section>`).join('')}</div>`;
++ : `<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>`;
}
function searchPage({ engines = [], found = [], query = {} } = {}) {
@@@ -58,6 +58,11 @@ function searchPage({ engines = [], found = [], query = {} } = {}) {
+ `<section class="xs-try"><h2>Try two engines</h2><form method="post" action="/search" class="xs-form">`
+ `<label class="xs-field xs-field--wide"><span>Search for</span><input name="q" required maxlength="500" placeholder="open source live streaming" value="${esc(query.q || '')}"></label>`
+ `<label class="xs-field"><span>Kind</span><select name="kind">${KINDS.map(([k, l]) => `<option value="${k}"${query.kind === k ? ' selected' : ''}>${l}</option>`).join('')}</select></label>`
++ + `<label class="xs-field"><span>Size</span><select name="size"><option value="">Any</option>${['small', 'medium', 'large'].map((v) => `<option value="${v}"${query.size === v ? ' selected' : ''}>${v}</option>`).join('')}</select></label>`
++ + `<label class="xs-field"><span>Color</span><select name="color"><option value="">Any</option>${['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black_and_white'].map((v) => `<option value="${v}"${query.color === v ? ' selected' : ''}>${v.replace('_', ' ')}</option>`).join('')}</select></label>`
++ + `<label class="xs-field"><span>Type</span><select name="type"><option value="">Any</option>${['photo', 'illustration', 'vector'].map((v) => `<option value="${v}"${query.type === v ? ' selected' : ''}>${v}</option>`).join('')}</select></label>`
++ + `<label class="xs-field"><span>License</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>`
+ `${pick('first', 'First engine', 0)}${pick('second', 'Second engine', 1)}`
+ `<p class="xs-go">${kit.button({ label: 'Compare', type: 'submit' })}</p></form>${results(found)}</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>`;
ddiff --git a/test/image-search-view.test.js b/test/image-search-view.test.js
nnew file mode 100644
iindex 0000000..870f88a
---- /dev/null
++++ b/test/image-search-view.test.js
@@@ -0,0 +1,13 @@
++'use strict';
++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"'));
++process.stdout.write('image search view passed\n');
ddiff --git a/test/model-search.test.js b/test/model-search.test.js
nnew file mode 100644
iindex 0000000..4ab0244
---- /dev/null
++++ b/test/model-search.test.js
@@@ -0,0 +1,19 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { createFakeAi } = require('./helpers/ai');
++const { parseSearch, searchCatalog } = require('../server/model-search');
++
++(async () => {
++ const catalog = await createFakeAi().catalog();
++ const fast = searchCatalog(catalog, [], parseSearch({ q: 'fast coder under $1' }));
++ assert.ok(Array.isArray(fast));
++ assert.ok(fast.every((r) => r.price.out <= 1));
++ const provider = searchCatalog(catalog, [], parseSearch({ provider: 'DeepInfra', max_price: '0.5' }));
++ assert.equal(provider.length, 1);
++ assert.equal(provider[0].provider, 'DeepInfra');
++ assert.equal(provider[0].price.out, 0.3);
++ const vision = searchCatalog(catalog, [], parseSearch({ q: 'cheap vision model' }));
++ assert.equal(vision.length, 0);
++ assert.throws(() => parseSearch({ max_price: '-1' }), RangeError);
++ process.stdout.write('model search passed\n');
++})().catch((err) => { process.stderr.write(String(err.stack || err)); process.exitCode = 1; });
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 377ba72..b1ac075 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -34,6 +34,19 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(!family.text.includes('Llama 3 8B'));
});
++ await check('public search returns filtered offers and the models page uses the same query', async () => {
++ const api = await t.get('/api/v1/search?q=DeepInfra&max_price=0.5');
++ assert.strictEqual(api.status, 200, api.text);
++ const body = api.json();
++ assert.strictEqual(body.count, 1);
++ assert.strictEqual(body.results[0].provider, 'DeepInfra');
++ assert.strictEqual(body.results[0].price.out, 0.3);
++ const page = await t.get('/models?q=DeepInfra&max_price=0.5');
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('DeepSeek Chat') && page.text.includes('Quality'));
++ assert.ok(!page.text.includes('Qwen3 32B'));
++ });
++
await check('a model at /models/…: the same page, with first token, errors and How it ranks', async () => {
const r = await t.get('/models/deepseek%2Fdeepseek-chat');
assert.strictEqual(r.status, 200);