openvibers/openvibe.exchange Commits
Show per-model sync events in Exchange owner console (#207)
Changes
@@@ -114,6 +114,14 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (}}++ async function modelEvents() {++ try {++ const body = await get('/api/v1/exchange/model-events', 'model events');++ if (!Array.isArray(body.events)) throw new Error('invalid model events');++ return body;++ } catch (err) { warned('model events', err); return { unavailable: true, count: 0, events: [] }; }++ }++/** AI's search engines (GET /api/v1/exchange/search/engines, public): ids, kinds, prices, availability. */let enginesHit = { at: 0, value: null };async function searchEngines() {@@@ -355,7 +363,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (return createCredentialsClient(client, { baseUrl: config.ai.url });}-- return { catalog, directory, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };++ return { catalog, directory, modelEvents, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };}/**@@@ -283,6 +283,11 @@ async function createApp(opts = {}) {if (directory.unavailable) return null;return require('./model-directory').withLiveCatalog(directory.models, catalog.models || []);};++ app.get('/api/v1/model-events', async (_req, res) => {++ const events = await ai.modelEvents();++ return events.unavailable ? res.status(503).json({ error: 'model_events_unavailable' })++ : res.set('Cache-Control', 'public, max-age=30').json(events);++ });app.get('/api/v1/models', async (req, res) => {const rows = await directoryRows();if (!rows) return res.status(503).json({ error: 'directory_unavailable' });@@@ -178,7 +178,9 @@ ${Array.isArray(data.sync_log) ? syncSection(data) : ''}function syncSection(data = {}) {const rows = Array.isArray(data.sync_log) ? data.sync_log : [];-- return `<table><caption>Model sync log</caption><thead><tr><th>Provider</th><th>Started</th><th>Status</th><th>Added</th><th>Updated</th><th>Gone</th><th>Error</th></tr></thead><tbody>${rows.map(r => `<tr><td>${esc(r.provider_key)}</td><td>${esc(r.started_at)}</td><td>${esc(r.status)}</td><td>${esc(r.added)}</td><td>${esc(r.updated)}</td><td>${esc(r.missing)}</td><td>${esc(r.error_code || '')}</td></tr>`).join('')}</tbody></table>`;++ const lines = (r, key, label) => Array.isArray(r[key]) && r[key].length++ ? `<tr><td colspan="7"><details><summary>${esc(r.provider_key)} · ${r[key].length} ${label}</summary><ul>${r[key].map(model => `<li>${esc(label === 'new models' ? 'New model' : 'Retired model')}: <a href="/models/${encodeURIComponent(`${r.provider_key}/${model}`)}">${esc(model)}</a></li>`).join('')}</ul></details></td></tr>` : '';++ return `<table><caption>Model sync log</caption><thead><tr><th>Provider</th><th>Started</th><th>Status</th><th>Added</th><th>Updated</th><th>Gone</th><th>Error</th></tr></thead><tbody>${rows.map(r => `<tr><td>${esc(r.provider_key)}</td><td>${esc(r.started_at)}</td><td>${esc(r.status)}</td><td>${esc(r.added)}</td><td>${esc(r.updated)}</td><td>${esc(r.missing)}</td><td>${esc(r.error_code || '')}</td></tr>${lines(r, 'added_models', 'new models')}${lines(r, 'missing_models', 'retired models')}`).join('')}</tbody></table>`;}// ── Price book ──@@@ -1,7 +1,7 @@'use strict';// The owner console's designed AI sections (views/console-ai.js): providers, models and the price book.const assert = require('assert');--const { providersSection, modelsSection, pricebookSection } = require('../server/views/console-ai');++const { providersSection, modelsSection, pricebookSection, syncSection } = require('../server/views/console-ai');const secret = 'sk-live-never-on-a-page';const providers = [@@@ -67,4 +67,8 @@ assert.ok(many.includes('Accept all 4 prices') && many.includes('name="ids" valuassert.ok(many.includes('3 models from 2 providers: 2 new to OpenVibe'), 'the summary counts models and the new ones');assert.ok(many.includes('<s>$0.30</s><b>$0.22</b><i>-27%</i>'), 'a changed price shows old, new and the change');assert.ok(!/action="\/console\/pricebook\/changes\//.test(many), 'the waiting list decides through the grouped route');--console.log('console-ai: providers, models and the price book passed');++const sync = syncSection({ sync_log: [{ provider_key: 'gemini', started_at: '2026-11-11', status: 'succeeded',++ added: 1, updated: 0, missing: 1, added_models: ['gemini-new'], missing_models: ['gemini-old'] }] });++assert.ok(sync.includes('New model') && sync.includes('gemini-new') && sync.includes('Retired model') && sync.includes('gemini-old'));++assert.ok(sync.includes('/models/gemini%2Fgemini-new'));++console.log('console-ai: providers, models, sync events and the price book passed');@@@ -79,6 +79,8 @@ function createFakeAi({ chat = null, community = [] } = {}) {{ id: 'arena/anonymous-silver', provider: 'arena', model: 'anonymous-silver', name: 'anonymous-silver', status: 'spotted', routing_enabled: false,first_seen_at: '2026-11-11T00:00:00Z', sources: [{ url: 'https://lmarena.ai/leaderboard/text', observed_at: '2026-11-11T00:00:00Z' }], price: {} },] }; },++ async modelEvents() { return { count: 1, events: [{ id: '42:added:gemini-new', type: 'model.added', provider: 'gemini',++ model: 'gemini-new', occurred_at: '2026-11-11T00:00:00Z', sync_id: 42 }] }; },// AI's search engines as GET /api/v1/exchange/search/engines answers them.async searchEngines() {return [@@@ -72,6 +72,11 @@ const { createFakeAi, downAi } = require('./helpers/ai');const html = await t.get('/m/deepseek/deepseek-chat');assert.ok(html.text.includes('schema.org') && html.text.includes('SoftwareApplication'));});++ await check('public model event feed relays exact sync findings', async () => {++ const r = await t.get('/api/v1/model-events');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().events[0].model, 'gemini-new');++ });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');
@@ -114,6 +114,14 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -114,6 +114,14 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
}
}
}
}
+ async function modelEvents() {
+ try {
+ const body = await get('/api/v1/exchange/model-events', 'model events');
+ if (!Array.isArray(body.events)) throw new Error('invalid model events');
+ return body;
+ } catch (err) { warned('model events', err); return { unavailable: true, count: 0, events: [] }; }
+ }
+
/** AI's search engines (GET /api/v1/exchange/search/engines, public): ids, kinds, prices, availability. */
/** AI's search engines (GET /api/v1/exchange/search/engines, public): ids, kinds, prices, availability. */
let enginesHit = { at: 0, value: null };
let enginesHit = { at: 0, value: null };
async function searchEngines() {
async function searchEngines() {
@@ -355,7 +363,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -355,7 +363,7 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
return createCredentialsClient(client, { baseUrl: config.ai.url });
return createCredentialsClient(client, { baseUrl: config.ai.url });
}
}
- return { catalog, directory, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };
+ return { catalog, directory, modelEvents, searchEngines, usage, communityOffers, chat, presetsFor, setKeyPrivacy, ownedProjects, gpuQuote, ownStatus, ownList, ownCreate, ownStop, adminRequest, meRequest, credentialsFor, configured: Boolean(tokens) };
}
}
/**
/**
@@ -283,6 +283,11 @@ async function createApp(opts = {}) {
@@ -283,6 +283,11 @@ async function createApp(opts = {}) {
if (directory.unavailable) return null;
if (directory.unavailable) return null;
return require('./model-directory').withLiveCatalog(directory.models, catalog.models || []);
return require('./model-directory').withLiveCatalog(directory.models, catalog.models || []);
};
};
+ app.get('/api/v1/model-events', async (_req, res) => {
+ const events = await ai.modelEvents();
+ return events.unavailable ? res.status(503).json({ error: 'model_events_unavailable' })
+ : res.set('Cache-Control', 'public, max-age=30').json(events);
+ });
app.get('/api/v1/models', async (req, res) => {
app.get('/api/v1/models', async (req, res) => {
const rows = await directoryRows();
const rows = await directoryRows();
if (!rows) return res.status(503).json({ error: 'directory_unavailable' });
if (!rows) return res.status(503).json({ error: 'directory_unavailable' });
@@ -178,7 +178,9 @@ ${Array.isArray(data.sync_log) ? syncSection(data) : ''}
@@ -178,7 +178,9 @@ ${Array.isArray(data.sync_log) ? syncSection(data) : ''}
function syncSection(data = {}) {
function syncSection(data = {}) {
const rows = Array.isArray(data.sync_log) ? data.sync_log : [];
const rows = Array.isArray(data.sync_log) ? data.sync_log : [];
- return `<table><caption>Model sync log</caption><thead><tr><th>Provider</th><th>Started</th><th>Status</th><th>Added</th><th>Updated</th><th>Gone</th><th>Error</th></tr></thead><tbody>${rows.map(r => `<tr><td>${esc(r.provider_key)}</td><td>${esc(r.started_at)}</td><td>${esc(r.status)}</td><td>${esc(r.added)}</td><td>${esc(r.updated)}</td><td>${esc(r.missing)}</td><td>${esc(r.error_code || '')}</td></tr>`).join('')}</tbody></table>`;
+ const lines = (r, key, label) => Array.isArray(r[key]) && r[key].length
+ ? `<tr><td colspan="7"><details><summary>${esc(r.provider_key)} · ${r[key].length} ${label}</summary><ul>${r[key].map(model => `<li>${esc(label === 'new models' ? 'New model' : 'Retired model')}: <a href="/models/${encodeURIComponent(`${r.provider_key}/${model}`)}">${esc(model)}</a></li>`).join('')}</ul></details></td></tr>` : '';
+ return `<table><caption>Model sync log</caption><thead><tr><th>Provider</th><th>Started</th><th>Status</th><th>Added</th><th>Updated</th><th>Gone</th><th>Error</th></tr></thead><tbody>${rows.map(r => `<tr><td>${esc(r.provider_key)}</td><td>${esc(r.started_at)}</td><td>${esc(r.status)}</td><td>${esc(r.added)}</td><td>${esc(r.updated)}</td><td>${esc(r.missing)}</td><td>${esc(r.error_code || '')}</td></tr>${lines(r, 'added_models', 'new models')}${lines(r, 'missing_models', 'retired models')}`).join('')}</tbody></table>`;
}
}
// ── Price book ──
// ── Price book ──
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The owner console's designed AI sections (views/console-ai.js): providers, models and the price book.
// The owner console's designed AI sections (views/console-ai.js): providers, models and the price book.
const assert = require('assert');
const assert = require('assert');
-const { providersSection, modelsSection, pricebookSection } = require('../server/views/console-ai');
+const { providersSection, modelsSection, pricebookSection, syncSection } = require('../server/views/console-ai');
const secret = 'sk-live-never-on-a-page';
const secret = 'sk-live-never-on-a-page';
const providers = [
const providers = [
@@ -67,4 +67,8 @@ assert.ok(many.includes('Accept all 4 prices') && many.includes('name="ids" valu
@@ -67,4 +67,8 @@ assert.ok(many.includes('Accept all 4 prices') && many.includes('name="ids" valu
assert.ok(many.includes('3 models from 2 providers: 2 new to OpenVibe'), 'the summary counts models and the new ones');
assert.ok(many.includes('3 models from 2 providers: 2 new to OpenVibe'), 'the summary counts models and the new ones');
assert.ok(many.includes('<s>$0.30</s><b>$0.22</b><i>-27%</i>'), 'a changed price shows old, new and the change');
assert.ok(many.includes('<s>$0.30</s><b>$0.22</b><i>-27%</i>'), 'a changed price shows old, new and the change');
assert.ok(!/action="\/console\/pricebook\/changes\//.test(many), 'the waiting list decides through the grouped route');
assert.ok(!/action="\/console\/pricebook\/changes\//.test(many), 'the waiting list decides through the grouped route');
-console.log('console-ai: providers, models and the price book passed');
+const sync = syncSection({ sync_log: [{ provider_key: 'gemini', started_at: '2026-11-11', status: 'succeeded',
+ added: 1, updated: 0, missing: 1, added_models: ['gemini-new'], missing_models: ['gemini-old'] }] });
+assert.ok(sync.includes('New model') && sync.includes('gemini-new') && sync.includes('Retired model') && sync.includes('gemini-old'));
+assert.ok(sync.includes('/models/gemini%2Fgemini-new'));
+console.log('console-ai: providers, models, sync events and the price book passed');
@@ -79,6 +79,8 @@ function createFakeAi({ chat = null, community = [] } = {}) {
@@ -79,6 +79,8 @@ function createFakeAi({ chat = null, community = [] } = {}) {
{ id: 'arena/anonymous-silver', provider: 'arena', model: 'anonymous-silver', name: 'anonymous-silver', status: 'spotted', routing_enabled: false,
{ id: 'arena/anonymous-silver', provider: 'arena', model: 'anonymous-silver', name: 'anonymous-silver', status: 'spotted', routing_enabled: false,
first_seen_at: '2026-11-11T00:00:00Z', sources: [{ url: 'https://lmarena.ai/leaderboard/text', observed_at: '2026-11-11T00:00:00Z' }], price: {} },
first_seen_at: '2026-11-11T00:00:00Z', sources: [{ url: 'https://lmarena.ai/leaderboard/text', observed_at: '2026-11-11T00:00:00Z' }], price: {} },
] }; },
] }; },
+ async modelEvents() { return { count: 1, events: [{ id: '42:added:gemini-new', type: 'model.added', provider: 'gemini',
+ model: 'gemini-new', occurred_at: '2026-11-11T00:00:00Z', sync_id: 42 }] }; },
// AI's search engines as GET /api/v1/exchange/search/engines answers them.
// AI's search engines as GET /api/v1/exchange/search/engines answers them.
async searchEngines() {
async searchEngines() {
return [
return [
@@ -72,6 +72,11 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -72,6 +72,11 @@ const { createFakeAi, downAi } = require('./helpers/ai');
const html = await t.get('/m/deepseek/deepseek-chat');
const html = await t.get('/m/deepseek/deepseek-chat');
assert.ok(html.text.includes('schema.org') && html.text.includes('SoftwareApplication'));
assert.ok(html.text.includes('schema.org') && html.text.includes('SoftwareApplication'));
});
});
+ await check('public model event feed relays exact sync findings', async () => {
+ const r = await t.get('/api/v1/model-events');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().events[0].model, 'gemini-new');
+ });
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');