openvibers/openvibe.exchange Commits
Expose public model directory API and owner key access (#203)
Changes
@@@ -277,6 +277,26 @@ async function createApp(opts = {}) {}});++ // Public model directory reader. The site and SDK clients share the same live catalog.++ app.get('/api/v1/models', async (req, res) => {++ const catalog = await ai.catalog();++ if (catalog.unavailable) return res.status(503).json({ error: 'catalog_unavailable' });++ const q = String(req.query.q || '').toLowerCase().slice(0, 120);++ const provider = String(req.query.provider || '').toLowerCase().slice(0, 80);++ const models = (catalog.models || []).filter(m => (!q || `${m.name} ${m.slug} ${m.family}`.toLowerCase().includes(q))++ && (!provider || (m.offers || []).some(o => String(o.provider || o.provider_key || '').toLowerCase() === provider)));++ return res.set('Cache-Control', 'public, max-age=30').json({ count: models.length, models });++ });++ const modelDirectoryDetail = async (req, res) => {++ const catalog = await ai.catalog();++ if (catalog.unavailable) return res.status(503).json({ error: 'catalog_unavailable' });++ const id = (req.params.id || `${req.params.org}/${req.params.model}`).toLowerCase();++ const model = (catalog.models || []).find(m => m.slug === id);++ return model ? res.set('Cache-Control', 'public, max-age=30').json({ model }) : res.status(404).json({ error: 'model_not_found' });++ };++ app.get('/api/v1/models/:id', modelDirectoryDetail);++ app.get('/api/v1/models/:org/:model', modelDirectoryDetail);++// ── 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));@@@ -164,7 +164,16 @@ function createConsoleRoutes({ config, ai }) {} else if (action === 'reset') await ai.adminRequest(`/api/v1/providers/${key}/reset`, { method: 'POST', subject: req.viewer.subject });else if (action === 'key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'POST', subject: req.viewer.subject, body: { api_key: String(req.body.api_key || '') } });else if (action === 'remove-key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'DELETE', subject: req.viewer.subject });-- else if (action === 'check') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject });++ else if (action === 'check') {++ const pasted = Boolean(req.body.api_key);++ const result = await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject,++ ...(pasted ? { body: { api_key: String(req.body.api_key) } } : {}) });++ if (pasted) {++ const data = await ai.adminRequest('/api/v1/admin/console/providers');++ data.providers = (data.providers || []).map(p => p.key === req.params.key ? { ...p, last_check: result } : p);++ return page(req, res, 'providers', data);++ }++ }else if (action === 'sync') {await ai.adminRequest(`/api/v1/admin/console/providers/${key}/sync`, { method: 'POST', subject: req.viewer.subject });return res.redirect(303, `/console/providers/${key}/models`);@@@ -168,7 +168,9 @@ function createPageRoutes(ctx) {return page(req, res, {index: true, cache: req.method === 'GET' ? PUBLIC_CACHE : null, agentLayer: true,title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,-- jsonLd: [jsonLd], head: '',++ jsonLd: [jsonLd, { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: model.name,++ url: `${config.baseUrl}${modelPath(model)}`, description: `${model.name}: context, model limits, price and available providers.`,++ applicationCategory: 'DeveloperApplication' }], head: '',body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community, analysis })),}, status);}@@@ -67,12 +67,12 @@ function providerCard(p, models) {const keyStatus = keyState.source === 'not_required' ? '<p>No key required.</p>': keyState.hint ? `<p>Key ends in ${esc(keyState.hint)}${keyState.set_at ? ` · set on ${esc(keyState.set_at)}` : ''}.</p>` : '';const keyActions = `${keyStatus}-- ${check ? `<p>Check: ${check.ok ? 'OK' : esc(check.error || 'Failed')} · ${esc(check.latency_ms)} ms · ${esc(check.checked_at)}</p>` : ''}++ ${check ? `<p>Check: ${check.ok ? 'OK' : esc(check.error || 'Failed')} · ${esc(check.latency_ms)} ms · ${esc(check.checked_at)}${check.ok && Number.isFinite(Number(check.models_count)) ? ` · ${esc(check.models_count)} models` : ''}</p>${Array.isArray(check.models) && check.models.length ? `<details><summary>Models this key can use</summary><ul>${check.models.map(id => `<li>${esc(id)}</li>`).join('')}</ul></details>` : ''}` : ''}<form method="post" action="/console/providers/${key}/check"><button class="ovk-btn ovk-btn--quiet" type="submit">Check provider</button></form>${p.key_present || keyState.source === 'stored' ? `<form method="post" action="/console/providers/${key}/remove-key"><button class="ovk-btn ovk-btn--quiet" type="submit">Remove key and disable</button></form>` : ''}`;-- const keyForm = `<form class="exc-form exc-form--key" method="post" action="/console/providers/${key}/key">++ const keyForm = `${p.keys_url ? `<p><a href="${esc(p.keys_url)}" rel="noopener noreferrer" target="_blank">Create or find your API key</a>. Paste it below, save it, then Check provider to list the models it can use.</p>` : ''}<form class="exc-form exc-form--key" method="post" action="/console/providers/${key}/key"><label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label>-- <p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p>++ <p class="exc-actions"><button class="ovk-btn ovk-btn--quiet" type="submit" formaction="/console/providers/${key}/check">Check pasted key</button> ${btn(p.key_present ? 'Replace key' : 'Save key')}</p></form>`;const modelActions = p.metadata?.models_endpoint || ['openai', 'anthropic', 'responses', 'gemini'].includes(p.kind) ? `<p><a href="/console/providers/${key}/models">Models</a> · <a href="/console/sync">Sync log</a></p>@@@ -26,6 +26,7 @@ assert.ok(html.includes('type="password"'), 'keys are typed into password fieldsassert.ok(html.includes('Key ends in …abcd') && html.includes('set on 2026-11-10'), 'only the key marker and date are rendered');assert.ok(html.includes('http_401') && html.includes('42 ms'), 'the last check and latency are visible');assert.ok(html.includes('action="/console/providers/openai/check"') && html.includes('action="/console/providers/openai/remove-key"'), 'explicit check and removal controls');++assert.ok(html.includes('formaction="/console/providers/openai/check"') && html.includes('Check pasted key'), 'the key field can be checked before saving');assert.ok(html.includes('action="/console/providers/openai/sync"') && html.includes('href="/console/providers/openai/models"'), 'models can be read and opened per provider');assert.ok(html.includes('name="auto_list_new_models" value="1" checked') && html.includes('value="60" selected'), 'stored model sync preferences render');const locked = providersSection({ providers: [{ ...providers[0], key_present: false }], models: [], presets: [] });@@@ -34,6 +34,19 @@ const { createFakeAi, downAi } = require('./helpers/ai');assert.ok(!family.text.includes('Llama 3 8B'));});++ await check('public directory JSON and model structured data match the catalog', async () => {++ const list = await t.get('/api/v1/models?q=deepseek');++ assert.strictEqual(list.status, 200, list.text);++ assert.ok(list.json().models.some(m => m.slug === 'deepseek/deepseek-chat'));++ const one = await t.get('/api/v1/models/deepseek/deepseek-chat');++ assert.strictEqual(one.status, 200, one.text);++ assert.strictEqual(one.json().model.slug, 'deepseek/deepseek-chat');++ assert.strictEqual((await t.get('/api/v1/models/deepseek%2Fdeepseek-chat')).json().model.slug, 'deepseek/deepseek-chat');++ assert.strictEqual((await t.get('/api/v1/models/nope/nope')).status, 404);++ const html = await t.get('/m/deepseek/deepseek-chat');++ assert.ok(html.text.includes('schema.org') && html.text.includes('SoftwareApplication'));++ });++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);
@@ -277,6 +277,26 @@ async function createApp(opts = {}) {
@@ -277,6 +277,26 @@ async function createApp(opts = {}) {
}
}
});
});
+ // Public model directory reader. The site and SDK clients share the same live catalog.
+ app.get('/api/v1/models', async (req, res) => {
+ const catalog = await ai.catalog();
+ if (catalog.unavailable) return res.status(503).json({ error: 'catalog_unavailable' });
+ const q = String(req.query.q || '').toLowerCase().slice(0, 120);
+ const provider = String(req.query.provider || '').toLowerCase().slice(0, 80);
+ const models = (catalog.models || []).filter(m => (!q || `${m.name} ${m.slug} ${m.family}`.toLowerCase().includes(q))
+ && (!provider || (m.offers || []).some(o => String(o.provider || o.provider_key || '').toLowerCase() === provider)));
+ return res.set('Cache-Control', 'public, max-age=30').json({ count: models.length, models });
+ });
+ const modelDirectoryDetail = async (req, res) => {
+ const catalog = await ai.catalog();
+ if (catalog.unavailable) return res.status(503).json({ error: 'catalog_unavailable' });
+ const id = (req.params.id || `${req.params.org}/${req.params.model}`).toLowerCase();
+ const model = (catalog.models || []).find(m => m.slug === id);
+ return model ? res.set('Cache-Control', 'public, max-age=30').json({ model }) : res.status(404).json({ error: 'model_not_found' });
+ };
+ app.get('/api/v1/models/:id', modelDirectoryDetail);
+ app.get('/api/v1/models/:org/:model', modelDirectoryDetail);
+
// ── 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));
@@ -164,7 +164,16 @@ function createConsoleRoutes({ config, ai }) {
@@ -164,7 +164,16 @@ function createConsoleRoutes({ config, ai }) {
} else if (action === 'reset') await ai.adminRequest(`/api/v1/providers/${key}/reset`, { method: 'POST', subject: req.viewer.subject });
} else if (action === 'reset') await ai.adminRequest(`/api/v1/providers/${key}/reset`, { method: 'POST', subject: req.viewer.subject });
else if (action === 'key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'POST', subject: req.viewer.subject, body: { api_key: String(req.body.api_key || '') } });
else if (action === 'key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'POST', subject: req.viewer.subject, body: { api_key: String(req.body.api_key || '') } });
else if (action === 'remove-key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'DELETE', subject: req.viewer.subject });
else if (action === 'remove-key') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/key`, { method: 'DELETE', subject: req.viewer.subject });
- else if (action === 'check') await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject });
+ else if (action === 'check') {
+ const pasted = Boolean(req.body.api_key);
+ const result = await ai.adminRequest(`/api/v1/admin/console/providers/${key}/check`, { method: 'POST', subject: req.viewer.subject,
+ ...(pasted ? { body: { api_key: String(req.body.api_key) } } : {}) });
+ if (pasted) {
+ const data = await ai.adminRequest('/api/v1/admin/console/providers');
+ data.providers = (data.providers || []).map(p => p.key === req.params.key ? { ...p, last_check: result } : p);
+ return page(req, res, 'providers', data);
+ }
+ }
else if (action === 'sync') {
else if (action === 'sync') {
await ai.adminRequest(`/api/v1/admin/console/providers/${key}/sync`, { method: 'POST', subject: req.viewer.subject });
await ai.adminRequest(`/api/v1/admin/console/providers/${key}/sync`, { method: 'POST', subject: req.viewer.subject });
return res.redirect(303, `/console/providers/${key}/models`);
return res.redirect(303, `/console/providers/${key}/models`);
@@ -168,7 +168,9 @@ function createPageRoutes(ctx) {
@@ -168,7 +168,9 @@ function createPageRoutes(ctx) {
return page(req, res, {
return page(req, res, {
index: true, cache: req.method === 'GET' ? PUBLIC_CACHE : null, agentLayer: true,
index: true, cache: req.method === 'GET' ? PUBLIC_CACHE : null, agentLayer: true,
title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,
title: model.name, description: `${model.name}: price, speed, uptime and data policy of every offer.`,
- jsonLd: [jsonLd], head: '',
+ jsonLd: [jsonLd, { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: model.name,
+ url: `${config.baseUrl}${modelPath(model)}`, description: `${model.name}: context, model limits, price and available providers.`,
+ applicationCategory: 'DeveloperApplication' }], head: '',
body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community, analysis })),
body: raw(views.modelPage({ model, viewer: req.viewer, result, problem, text, adult: isAdult, community, analysis })),
}, status);
}, status);
}
}
@@ -67,12 +67,12 @@ function providerCard(p, models) {
@@ -67,12 +67,12 @@ function providerCard(p, models) {
const keyStatus = keyState.source === 'not_required' ? '<p>No key required.</p>'
const keyStatus = keyState.source === 'not_required' ? '<p>No key required.</p>'
: keyState.hint ? `<p>Key ends in ${esc(keyState.hint)}${keyState.set_at ? ` · set on ${esc(keyState.set_at)}` : ''}.</p>` : '';
: keyState.hint ? `<p>Key ends in ${esc(keyState.hint)}${keyState.set_at ? ` · set on ${esc(keyState.set_at)}` : ''}.</p>` : '';
const keyActions = `${keyStatus}
const keyActions = `${keyStatus}
- ${check ? `<p>Check: ${check.ok ? 'OK' : esc(check.error || 'Failed')} · ${esc(check.latency_ms)} ms · ${esc(check.checked_at)}</p>` : ''}
+ ${check ? `<p>Check: ${check.ok ? 'OK' : esc(check.error || 'Failed')} · ${esc(check.latency_ms)} ms · ${esc(check.checked_at)}${check.ok && Number.isFinite(Number(check.models_count)) ? ` · ${esc(check.models_count)} models` : ''}</p>${Array.isArray(check.models) && check.models.length ? `<details><summary>Models this key can use</summary><ul>${check.models.map(id => `<li>${esc(id)}</li>`).join('')}</ul></details>` : ''}` : ''}
<form method="post" action="/console/providers/${key}/check"><button class="ovk-btn ovk-btn--quiet" type="submit">Check provider</button></form>
<form method="post" action="/console/providers/${key}/check"><button class="ovk-btn ovk-btn--quiet" type="submit">Check provider</button></form>
${p.key_present || keyState.source === 'stored' ? `<form method="post" action="/console/providers/${key}/remove-key"><button class="ovk-btn ovk-btn--quiet" type="submit">Remove key and disable</button></form>` : ''}`;
${p.key_present || keyState.source === 'stored' ? `<form method="post" action="/console/providers/${key}/remove-key"><button class="ovk-btn ovk-btn--quiet" type="submit">Remove key and disable</button></form>` : ''}`;
- const keyForm = `<form class="exc-form exc-form--key" method="post" action="/console/providers/${key}/key">
+ const keyForm = `${p.keys_url ? `<p><a href="${esc(p.keys_url)}" rel="noopener noreferrer" target="_blank">Create or find your API key</a>. Paste it below, save it, then Check provider to list the models it can use.</p>` : ''}<form class="exc-form exc-form--key" method="post" action="/console/providers/${key}/key">
<label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label>
<label><span>${p.key_present ? 'Replace the key' : 'Add the key'}</span><input name="api_key" type="password" autocomplete="off" minlength="8" maxlength="400" required placeholder="Paste the provider's API key"><small>Stored sealed and bound to ${esc(host(p.base_url) || 'this provider')}; never shown again.</small></label>
- <p class="exc-actions">${btn(p.key_present ? 'Replace key' : 'Save key')}</p>
+ <p class="exc-actions"><button class="ovk-btn ovk-btn--quiet" type="submit" formaction="/console/providers/${key}/check">Check pasted key</button> ${btn(p.key_present ? 'Replace key' : 'Save key')}</p>
</form>`;
</form>`;
const modelActions = p.metadata?.models_endpoint || ['openai', 'anthropic', 'responses', 'gemini'].includes(p.kind) ? `
const modelActions = p.metadata?.models_endpoint || ['openai', 'anthropic', 'responses', 'gemini'].includes(p.kind) ? `
<p><a href="/console/providers/${key}/models">Models</a> · <a href="/console/sync">Sync log</a></p>
<p><a href="/console/providers/${key}/models">Models</a> · <a href="/console/sync">Sync log</a></p>
@@ -26,6 +26,7 @@ assert.ok(html.includes('type="password"'), 'keys are typed into password fields
@@ -26,6 +26,7 @@ assert.ok(html.includes('type="password"'), 'keys are typed into password fields
assert.ok(html.includes('Key ends in …abcd') && html.includes('set on 2026-11-10'), 'only the key marker and date are rendered');
assert.ok(html.includes('Key ends in …abcd') && html.includes('set on 2026-11-10'), 'only the key marker and date are rendered');
assert.ok(html.includes('http_401') && html.includes('42 ms'), 'the last check and latency are visible');
assert.ok(html.includes('http_401') && html.includes('42 ms'), 'the last check and latency are visible');
assert.ok(html.includes('action="/console/providers/openai/check"') && html.includes('action="/console/providers/openai/remove-key"'), 'explicit check and removal controls');
assert.ok(html.includes('action="/console/providers/openai/check"') && html.includes('action="/console/providers/openai/remove-key"'), 'explicit check and removal controls');
+assert.ok(html.includes('formaction="/console/providers/openai/check"') && html.includes('Check pasted key'), 'the key field can be checked before saving');
assert.ok(html.includes('action="/console/providers/openai/sync"') && html.includes('href="/console/providers/openai/models"'), 'models can be read and opened per provider');
assert.ok(html.includes('action="/console/providers/openai/sync"') && html.includes('href="/console/providers/openai/models"'), 'models can be read and opened per provider');
assert.ok(html.includes('name="auto_list_new_models" value="1" checked') && html.includes('value="60" selected'), 'stored model sync preferences render');
assert.ok(html.includes('name="auto_list_new_models" value="1" checked') && html.includes('value="60" selected'), 'stored model sync preferences render');
const locked = providersSection({ providers: [{ ...providers[0], key_present: false }], models: [], presets: [] });
const locked = providersSection({ providers: [{ ...providers[0], key_present: false }], models: [], presets: [] });
@@ -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 directory JSON and model structured data match the catalog', async () => {
+ const list = await t.get('/api/v1/models?q=deepseek');
+ assert.strictEqual(list.status, 200, list.text);
+ assert.ok(list.json().models.some(m => m.slug === 'deepseek/deepseek-chat'));
+ const one = await t.get('/api/v1/models/deepseek/deepseek-chat');
+ assert.strictEqual(one.status, 200, one.text);
+ assert.strictEqual(one.json().model.slug, 'deepseek/deepseek-chat');
+ assert.strictEqual((await t.get('/api/v1/models/deepseek%2Fdeepseek-chat')).json().model.slug, 'deepseek/deepseek-chat');
+ assert.strictEqual((await t.get('/api/v1/models/nope/nope')).status, 404);
+ const html = await t.get('/m/deepseek/deepseek-chat');
+ assert.ok(html.text.includes('schema.org') && html.text.includes('SoftwareApplication'));
+ });
+
await check('public search returns filtered offers and the models page uses the same query', async () => {
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');
const api = await t.get('/api/v1/search?q=DeepInfra&max_price=0.5');
assert.strictEqual(api.status, 200, api.text);
assert.strictEqual(api.status, 200, api.text);