openvibers/openvibe.exchange Commits

Show per-model sync events in Exchange owner console (#207)

OP OpenVibers committed 2 days ago · b53fe2f36e · 6 files, +30 −4

Changes

ddiff --git a/server/ai.js b/server/ai.js
iindex 4f5358e..819a993 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -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) };
}
/**
ddiff --git a/server/app.js b/server/app.js
iindex 688eb06..7c2f8be 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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' });
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex 005848a..befa5b2 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -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 ──
ddiff --git a/test/console-ai.test.js b/test/console-ai.test.js
iindex c2aa803..98a678f 100644
---- a/test/console-ai.test.js
++++ b/test/console-ai.test.js
@@@ -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" 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('<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');
ddiff --git a/test/helpers/ai.js b/test/helpers/ai.js
iindex 45d12c8..8cc7142 100644
---- a/test/helpers/ai.js
++++ b/test/helpers/ai.js
@@@ -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 [
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 7cab080..ca6d189 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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');