openvibers/openvibe.exchange Commits
Search models and provider offers by task, price and performance (#199)
Changes
@@@ -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));@@@ -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 })),});});@@@ -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;}nnew file mode 100644@@@ -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 };@@@ -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')]),@@@ -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 };@@@ -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>`;nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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; });@@@ -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);
@@ -263,6 +263,20 @@ async function createApp(opts = {}) {
@@ -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.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) ───────────────
// ── Compare's JSON API (openvibe.compare) ───────────────
// Mounted before Exchange v1's catch-all, which answers 410 to everything else under /api/v1.
// Mounted before Exchange v1's catch-all, which answers 410 to everything else under /api/v1.
app.use('/api/v1/compare', createCompareApi(ctx));
app.use('/api/v1/compare', createCompareApi(ctx));
@@ -122,11 +122,23 @@ function createPageRoutes(ctx) {
@@ -122,11 +122,23 @@ function createPageRoutes(ctx) {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
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 family = typeof req.query.family === 'string' ? req.query.family.slice(0, 60) : '';
const sort = ['cheap', 'fast'].includes(req.query.sort) ? req.query.sort : '';
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, {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: q ? `Models: ${q}` : 'Models',
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.',
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 })),
});
});
});
});
@@ -34,7 +34,8 @@ function createSearchRoutes({ config, ai }) {
@@ -34,7 +34,8 @@ function createSearchRoutes({ config, ai }) {
'/v1/search' + (kind === 'web' ? '' : '/' + kind),
'/v1/search' + (kind === 'web' ? '' : '/' + kind),
{
{
method: 'POST',
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 }) {
@@ -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;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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 };
@@ -22,6 +22,7 @@ function analysisPaths(config) {
@@ -22,6 +22,7 @@ function analysisPaths(config) {
const query = (name) => ({ name, in: 'query', schema: { type: 'string' } });
const query = (name) => ({ name, in: 'query', schema: { type: 'string' } });
const param = (name) => ({ name, in: 'path', required: true, schema: { type: 'string' } });
const param = (name) => ({ name, in: 'path', required: true, schema: { type: 'string' } });
return {
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/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/models/{model}': make('exchange.analysis.model', [param('model'), query('days'), query('rating_kind')]),
'/api/v1/analysis/ratings/{kind}': make('exchange.analysis.ratings', [param('kind')]),
'/api/v1/analysis/ratings/{kind}': make('exchange.analysis.ratings', [param('kind')]),
@@ -44,10 +44,11 @@ const dash = (v, unit = '') => (v == null ? '–' : `${Number(v).toLocaleString(
@@ -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,
* 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.
* 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>';
if (!models.length) return '<p class="xm-empty">No models match these filters.</p>';
return `<ul class="xm-list">${models.map((m) => {
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 = [
const badges = [
(m.offers || []).some(free) ? kit.badge('Free to try') : (m.offers || []).some(paused) ? kit.badge('Free paused') : '',
(m.offers || []).some(free) ? kit.badge('Free to try') : (m.offers || []).some(paused) ? kit.badge('Free paused') : '',
m.uncensored ? kit.badge('18+', 'bad') : '',
m.uncensored ? kit.badge('18+', 'bad') : '',
@@ -55,7 +56,7 @@ function table(models) {
@@ -55,7 +56,7 @@ function table(models) {
].filter(Boolean).join(' ');
].filter(Boolean).join(' ');
const fact = (label, value) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`;
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>`
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>`;
+ `<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>`;
}).join('')}</ul>`;
}
}
@@ -97,7 +98,7 @@ function home({ catalog, usage, viewer }) {
@@ -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>`;
)}${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);
const all = listed(catalog);
let rows = all;
let rows = all;
if (query.family && !query.maker) query = { ...query, maker: query.family };
if (query.family && !query.maker) query = { ...query, maker: query.family };
@@ -129,6 +130,10 @@ function modelsPage({ catalog, query = {} }) {
@@ -129,6 +130,10 @@ function modelsPage({ catalog, query = {} }) {
.toLowerCase()
.toLowerCase()
.includes(String(query.q).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(
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),
(v) => chip(v, 'maker', v, all.filter((m) => tests.maker(m, v)).length),
);
);
@@ -148,6 +153,14 @@ function modelsPage({ catalog, query = {} }) {
@@ -148,6 +153,14 @@ function modelsPage({ catalog, query = {} }) {
all.filter((m) => tests[key](m, value)).length,
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 };
module.exports = { home, modelsPage, pricing, pricingPage, table };
@@ -43,7 +43,7 @@ function results(list) {
@@ -43,7 +43,7 @@ function results(list) {
if (!list.length) return '';
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
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-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 = {} } = {}) {
function searchPage({ engines = [], found = [], query = {} } = {}) {
@@ -58,6 +58,11 @@ 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">`
+ `<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 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>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)}`
+ `${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-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>`;
+ `<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>`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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; });
@@ -34,6 +34,19 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -34,6 +34,19 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(!family.text.includes('Llama 3 8B'));
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 () => {
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');
const r = await t.get('/models/deepseek%2Fdeepseek-chat');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);