openvibers/openvibe.exchange Commits

Expose public model directory API and owner key access (#203)

OP OpenVibers committed 2 days ago · 35e63dc1d1 · 6 files, +50 −5

* Expose public model directory API and structured model data

* Show pasted key checks in the owner provider card

* Accept encoded model ids in the public directory API

* Preserve saved-key check request shape

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 3dd5b86..235f851 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/http/console.js b/server/http/console.js
iindex 55c8f26..d02190a 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -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`);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ca7ef29..5bc626d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
}
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex d6fa093..005848a 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -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>
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
iindex da5ed9e..c2aa803 100644
---- a/test/console-ai.test.js
++++ b/test/console-ai.test.js
@@@ -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('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: [] });
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex b1ac075..45b073f 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -34,6 +34,19 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(!family.text.includes('Llama 3 8B'));
});
++ await check('public 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);