openvibers/openvibe.exchange Commits
Use shared composer for model directory search (#208)
Changes
@@@ -31,6 +31,7 @@ const platform = require('../views/platform');const credentialViews = require('../views/credentials');const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });++const COMPOSER_HEAD = raw(`<link rel="stylesheet" href="${ovServe.url('thread.css')}"><script src="${ovServe.url('ov-composer.js')}" defer></script>`);const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');@@@ -113,6 +114,7 @@ function createPageRoutes(ctx) {title: null,description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',jsonLd: homeJsonLd(config),++ head: COMPOSER_HEAD,body: raw(platform.home({ catalog, usage, viewer: req.viewer, q, config })),});});@@@ -139,6 +141,7 @@ function createPageRoutes(ctx) {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.',++ head: COMPOSER_HEAD,body: raw(platform.modelsPage({ catalog: req.query.status && req.query.status !== 'available' ? { ...catalog, models: [] } : catalog, query: req.query, searchResults })+ require('../model-directory').directoryList(directory.models || [], req.query, (catalog.models || []).map(m => m.slug))),});@@@ -1,5 +1,6 @@'use strict';const kit = require('openvibe-shared/kit');++const thread = require('openvibe-shared/thread');const legacy = require('./exchange');const listed = (catalog) => legacy.chatModels(catalog);const unavailable = (catalog) =>@@@ -32,6 +33,24 @@ const chat = (m) =>const rank = (m) =>`https://openvibe.compare/things/${encodeURIComponent(id(m))}`;const section = (title, body) => kit.section({ title, body });++const modelSearch = (query = {}, filters = false) => {++ const field = (name, label, placeholder = '') => `<label>${esc(label)} <input name="${name}" value="${esc(query[name] || '')}" placeholder="${esc(placeholder)}"></label>`;++ const options = filters ? { label: 'Search filters', html: [++ 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'),++ '<button class="ovk-btn ovk-btn--quiet" type="submit">Apply filters</button>',++ ].join('') } : null;++ const form = thread.composer({ action: '/models', method: 'get', name: 'q', value: query.q || '',++ id: filters ? 'model-search' : 'home-model-search', label: 'Search models',++ placeholder: 'Find a model or a provider', sendLabel: 'Search models', maxlength: 120,++ options, attrs: { 'data-ovc-no-draft': '', style: '--ovt-line:var(--border,#1f2d47);--ovt-accent:var(--accent,#3b82f6)' },++ commands: [{ name: 'gemini', hint: 'Find Gemini models', insert: 'gemini' },++ { name: 'gpt', hint: 'Find GPT models', insert: 'gpt' }],++ });++ // The shared prompt is required by default. Filters can be submitted on their own.++ return filters ? form.replace(' data-ovt-text required', ' data-ovt-text') : form;++};const code = (m) =>kit.snippet({id: 'platform-curl',@@@ -79,7 +98,7 @@ function pricingPage() {}function home({ catalog, usage, viewer }) {const models = listed(catalog);-- return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><h1>One API for every model.<br>Pay what the model costs.</h1>${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model', icon: 'search' })}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })} ${kit.button({ href: '/compute', label: 'Compute prices', kind: 'quiet' })}</p>${code()}</section>${kit.stats(++ return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><h1>One API for every model.<br>Pay what the model costs.</h1>${modelSearch()}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })} ${kit.button({ href: '/compute', label: 'Compute prices', kind: 'quiet' })}</p>${code()}</section>${kit.stats([[models.length, 'models'],[@@@ -159,8 +178,7 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {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>`;++ const filters = modelSearch(query, true);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 };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++const assert = require('node:assert/strict');++const { createFakeAi } = require('./helpers/ai');++const platform = require('../server/views/platform');++++(async () => {++ const catalog = await createFakeAi().catalog();++ const home = platform.home({ catalog, usage: {}, viewer: { kind: 'anonymous' } });++ assert.match(home, /<form[^>]+data-ovt-composer/);++ assert.match(home, /action="\/models"/);++ assert.match(home, /name="q"/);++ const list = platform.modelsPage({ catalog, query: { q: 'Gemini', provider: 'google', capability: 'vision' } });++ assert.equal((list.match(/name="q"/g) || []).length, 1, 'one main search box');++ assert.match(list, /class="ovt-c-row"/);++ assert.match(list, /class="ovt-c-tools"/);++ assert.match(list, /name="provider" value="google"/);++ assert.match(list, /name="capability" value="vision"/);++ assert.match(list, /data-ovt-text>Gemini<\/textarea>/);++ assert.match(list, /<button[^>]+type="submit">Apply filters<\/button>/);++ assert.doesNotMatch(list, /data-ovt-text required/, 'filter-only search must be submittable');++ process.stdout.write('model composer passed\n');++})().catch((err) => { process.stderr.write(String(err.stack || err)); process.exitCode = 1; });@@@ -18,6 +18,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');assert.ok(r.text.includes('Get a key') && r.text.includes('api.openvibe.exchange/v1/chat/completions'));assert.ok(r.text.includes('tokens routed this week') && r.text.includes('median time to first token'));assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Try in Chat') && r.text.includes('How it ranks'));++ assert.ok(r.text.includes('data-ovt-composer') && r.text.includes('name="q"') && r.text.includes('ov-composer.'));assert.strictEqual((r.text.match(/<h1[\s>]/g)||[]).length,1);});@@@ -26,6 +27,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');assert.strictEqual(r.status, 200);assert.ok(r.text.includes('DeepSeek Chat'));assert.ok(r.text.includes('$0.2 / $0.3'), 'the cheapest offer, not the dearest');++ assert.ok(r.text.includes('class="ovt-c-row"') && r.text.includes('class="ovt-c-tools"'));++ assert.ok(r.text.includes('Search filters') && r.text.includes('name="provider"') && r.text.includes('Apply filters'));++ assert.ok(!r.text.includes('<input name="q"'), 'the main search box is the shared composer');const filtered = await t.get('/models?q=llama');assert.ok(filtered.text.includes('Llama 3 8B abliterated'));assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');
@@ -31,6 +31,7 @@ const platform = require('../views/platform');
@@ -31,6 +31,7 @@ const platform = require('../views/platform');
const credentialViews = require('../views/credentials');
const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
+const COMPOSER_HEAD = raw(`<link rel="stylesheet" href="${ovServe.url('thread.css')}"><script src="${ovServe.url('ov-composer.js')}" defer></script>`);
const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];
const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sandbox/*', '/installs', '/offers', '/safety', '/developers'];
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
@@ -113,6 +114,7 @@ function createPageRoutes(ctx) {
@@ -113,6 +114,7 @@ function createPageRoutes(ctx) {
title: null,
title: null,
description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',
description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
+ head: COMPOSER_HEAD,
body: raw(platform.home({ catalog, usage, viewer: req.viewer, q, config })),
body: raw(platform.home({ catalog, usage, viewer: req.viewer, q, config })),
});
});
});
});
@@ -139,6 +141,7 @@ function createPageRoutes(ctx) {
@@ -139,6 +141,7 @@ function createPageRoutes(ctx) {
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.',
+ head: COMPOSER_HEAD,
body: raw(platform.modelsPage({ catalog: req.query.status && req.query.status !== 'available' ? { ...catalog, models: [] } : catalog, query: req.query, searchResults })
body: raw(platform.modelsPage({ catalog: req.query.status && req.query.status !== 'available' ? { ...catalog, models: [] } : catalog, query: req.query, searchResults })
+ require('../model-directory').directoryList(directory.models || [], req.query, (catalog.models || []).map(m => m.slug))),
+ require('../model-directory').directoryList(directory.models || [], req.query, (catalog.models || []).map(m => m.slug))),
});
});
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const thread = require('openvibe-shared/thread');
const legacy = require('./exchange');
const legacy = require('./exchange');
const listed = (catalog) => legacy.chatModels(catalog);
const listed = (catalog) => legacy.chatModels(catalog);
const unavailable = (catalog) =>
const unavailable = (catalog) =>
@@ -32,6 +33,24 @@ const chat = (m) =>
@@ -32,6 +33,24 @@ const chat = (m) =>
const rank = (m) =>
const rank = (m) =>
`https://openvibe.compare/things/${encodeURIComponent(id(m))}`;
`https://openvibe.compare/things/${encodeURIComponent(id(m))}`;
const section = (title, body) => kit.section({ title, body });
const section = (title, body) => kit.section({ title, body });
+const modelSearch = (query = {}, filters = false) => {
+ const field = (name, label, placeholder = '') => `<label>${esc(label)} <input name="${name}" value="${esc(query[name] || '')}" placeholder="${esc(placeholder)}"></label>`;
+ const options = filters ? { label: 'Search filters', html: [
+ 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'),
+ '<button class="ovk-btn ovk-btn--quiet" type="submit">Apply filters</button>',
+ ].join('') } : null;
+ const form = thread.composer({ action: '/models', method: 'get', name: 'q', value: query.q || '',
+ id: filters ? 'model-search' : 'home-model-search', label: 'Search models',
+ placeholder: 'Find a model or a provider', sendLabel: 'Search models', maxlength: 120,
+ options, attrs: { 'data-ovc-no-draft': '', style: '--ovt-line:var(--border,#1f2d47);--ovt-accent:var(--accent,#3b82f6)' },
+ commands: [{ name: 'gemini', hint: 'Find Gemini models', insert: 'gemini' },
+ { name: 'gpt', hint: 'Find GPT models', insert: 'gpt' }],
+ });
+ // The shared prompt is required by default. Filters can be submitted on their own.
+ return filters ? form.replace(' data-ovt-text required', ' data-ovt-text') : form;
+};
const code = (m) =>
const code = (m) =>
kit.snippet({
kit.snippet({
id: 'platform-curl',
id: 'platform-curl',
@@ -79,7 +98,7 @@ function pricingPage() {
@@ -79,7 +98,7 @@ function pricingPage() {
}
}
function home({ catalog, usage, viewer }) {
function home({ catalog, usage, viewer }) {
const models = listed(catalog);
const models = listed(catalog);
- return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><h1>One API for every model.<br>Pay what the model costs.</h1>${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model', icon: 'search' })}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })} ${kit.button({ href: '/compute', label: 'Compute prices', kind: 'quiet' })}</p>${code()}</section>${kit.stats(
+ return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><h1>One API for every model.<br>Pay what the model costs.</h1>${modelSearch()}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })} ${kit.button({ href: '/compute', label: 'Compute prices', kind: 'quiet' })}</p>${code()}</section>${kit.stats(
[
[
[models.length, 'models'],
[models.length, 'models'],
[
[
@@ -159,8 +178,7 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
@@ -159,8 +178,7 @@ function modelsPage({ catalog, query = {}, searchResults = null }) {
chips.push(`<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(params.toString())}">${label}</a>`);
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 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>`;
+ const filters = modelSearch(query, true);
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>`;
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { createFakeAi } = require('./helpers/ai');
+const platform = require('../server/views/platform');
+
+(async () => {
+ const catalog = await createFakeAi().catalog();
+ const home = platform.home({ catalog, usage: {}, viewer: { kind: 'anonymous' } });
+ assert.match(home, /<form[^>]+data-ovt-composer/);
+ assert.match(home, /action="\/models"/);
+ assert.match(home, /name="q"/);
+ const list = platform.modelsPage({ catalog, query: { q: 'Gemini', provider: 'google', capability: 'vision' } });
+ assert.equal((list.match(/name="q"/g) || []).length, 1, 'one main search box');
+ assert.match(list, /class="ovt-c-row"/);
+ assert.match(list, /class="ovt-c-tools"/);
+ assert.match(list, /name="provider" value="google"/);
+ assert.match(list, /name="capability" value="vision"/);
+ assert.match(list, /data-ovt-text>Gemini<\/textarea>/);
+ assert.match(list, /<button[^>]+type="submit">Apply filters<\/button>/);
+ assert.doesNotMatch(list, /data-ovt-text required/, 'filter-only search must be submittable');
+ process.stdout.write('model composer passed\n');
+})().catch((err) => { process.stderr.write(String(err.stack || err)); process.exitCode = 1; });
@@ -18,6 +18,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -18,6 +18,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('Get a key') && r.text.includes('api.openvibe.exchange/v1/chat/completions'));
assert.ok(r.text.includes('Get a key') && r.text.includes('api.openvibe.exchange/v1/chat/completions'));
assert.ok(r.text.includes('tokens routed this week') && r.text.includes('median time to first token'));
assert.ok(r.text.includes('tokens routed this week') && r.text.includes('median time to first token'));
assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Try in Chat') && r.text.includes('How it ranks'));
assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Try in Chat') && r.text.includes('How it ranks'));
+ assert.ok(r.text.includes('data-ovt-composer') && r.text.includes('name="q"') && r.text.includes('ov-composer.'));
assert.strictEqual((r.text.match(/<h1[\s>]/g)||[]).length,1);
assert.strictEqual((r.text.match(/<h1[\s>]/g)||[]).length,1);
});
});
@@ -26,6 +27,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -26,6 +27,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('DeepSeek Chat'));
assert.ok(r.text.includes('DeepSeek Chat'));
assert.ok(r.text.includes('$0.2 / $0.3'), 'the cheapest offer, not the dearest');
assert.ok(r.text.includes('$0.2 / $0.3'), 'the cheapest offer, not the dearest');
+ assert.ok(r.text.includes('class="ovt-c-row"') && r.text.includes('class="ovt-c-tools"'));
+ assert.ok(r.text.includes('Search filters') && r.text.includes('name="provider"') && r.text.includes('Apply filters'));
+ assert.ok(!r.text.includes('<input name="q"'), 'the main search box is the shared composer');
const filtered = await t.get('/models?q=llama');
const filtered = await t.get('/models?q=llama');
assert.ok(filtered.text.includes('Llama 3 8B abliterated'));
assert.ok(filtered.text.includes('Llama 3 8B abliterated'));
assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');
assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');