openvibers/openvibe.ai Commits

Track sourced model sightings before provider listing (#468)

OP OpenVibers committed 2 days ago · 5b26793af7 · 11 files, +254 −8

* Record sourced model sightings before provider listing

* Document spotted models and run discovery suite

* Merge main into spotted model discovery

* Publish provider model sync events

* Keep every model event in recent syncs

Changes

ddiff --git a/migrations/0076_model_spots.sql b/migrations/0076_model_spots.sql
nnew file mode 100644
iindex 0000000..18093e0
---- /dev/null
++++ b/migrations/0076_model_spots.sql
@@@ -0,0 +1,22 @@
++-- phase: expand
++-- Early model sightings are separate from provider-listed registry models and never enable routing.
++CREATE TABLE model_spots (
++ id text COLLATE "C" PRIMARY KEY,
++ provider_key text COLLATE "C" NOT NULL,
++ model_key text COLLATE "C" NOT NULL,
++ display_name text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'spotted' CHECK (status IN ('spotted', 'promoted', 'dismissed')),
++ sources_json text COLLATE "C" NOT NULL DEFAULT '[]',
++ first_seen_at timestamptz NOT NULL,
++ last_seen_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL
++);
++CREATE INDEX model_spots_recent ON model_spots(last_seen_at DESC);
++CREATE TABLE model_discovery_runs (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ started_at timestamptz NOT NULL,
++ finished_at timestamptz,
++ status text COLLATE "C" NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
++ sources_read integer NOT NULL DEFAULT 0,
++ spotted integer NOT NULL DEFAULT 0
++);
ddiff --git a/migrations/0077_provider_model_events.sql b/migrations/0077_provider_model_events.sql
nnew file mode 100644
iindex 0000000..cef2d15
---- /dev/null
++++ b/migrations/0077_provider_model_events.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- Keep the exact model ids behind each owner-visible sync count and public Exchange event.
++ALTER TABLE provider_model_syncs ADD COLUMN added_models jsonb NOT NULL DEFAULT '[]'::jsonb;
++ALTER TABLE provider_model_syncs ADD COLUMN missing_models jsonb NOT NULL DEFAULT '[]'::jsonb;
ddiff --git a/server/api/exchange.js b/server/api/exchange.js
iindex 6b9723b..7443bad 100644
---- a/server/api/exchange.js
++++ b/server/api/exchange.js
@@@ -35,7 +35,7 @@ function memo(fn, ttlMs, clock) {
};
}
--function exchangeRouter({ db, registry, pool, quotas = null, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console, ownEndpoints = null, fetchImpl = globalThis.fetch, fidelity = null, share = null }) {
++function exchangeRouter({ db, registry, pool, quotas = null, env = process.env, clock = { now: () => Date.now() }, config = null, presets, receipts, auth, log = console, ownEndpoints = null, fetchImpl = globalThis.fetch, fidelity = null, share = null, modelSync = null }) {
// Bench's provider-fidelity read (stage B5): optional and fail-soft. Off in tests, where no Bench runs.
const fidelitySource = fidelity || require('../exchange/fidelity').createFidelitySource({
url: config && config.bench ? config.bench.url : null, fetchImpl, clock, log,
@@@ -173,6 +173,11 @@ function exchangeRouter({ db, registry, pool, quotas = null, env = process.env,
try { res.json(await market.read(req.query)); } catch (error) { next(error); }
});
++ r.get('/api/v1/exchange/model-events', publicCache(30), async (_req, res, next) => {
++ try { const events = modelSync ? await modelSync.events() : []; res.json({ count: events.length, events }); }
++ catch (err) { next(err); }
++ });
++
r.get('/api/v1/exchange/catalog', publicCache(30), async (req, res, next) => {
try {
const kind = req.query.kind == null || req.query.kind === '' ? null : String(req.query.kind).toLowerCase();
ddiff --git a/server/api/model-spots.js b/server/api/model-spots.js
nnew file mode 100644
iindex 0000000..7bf79fe
---- /dev/null
++++ b/server/api/model-spots.js
@@@ -0,0 +1,22 @@
++'use strict';
++const express = require('express');
++const { CAPS } = require('../auth');
++const { AiError, sendError } = require('../util');
++
++function modelSpotsRouter({ discovery, auth, registry, log = console }) {
++ const r = express.Router();
++ const wrap = fn => async (req, res) => { try { res.set('Cache-Control', 'no-store').json(await fn(req)); }
++ catch (error) { sendError(res, error, req.ov, log); } };
++ r.get('/api/v1/exchange/spotted', wrap(async () => ({ models: await discovery.list() })));
++ r.get('/api/v1/admin/console/model-spots', auth.requireCap(CAPS.usageRead), wrap(async () => ({ models: await discovery.list({ includeDismissed: true }) })));
++ r.post('/api/v1/admin/console/model-spots/refresh', auth.requireCap(CAPS.providerManage), wrap(async () => discovery.run()));
++ r.post('/api/v1/admin/console/model-spots/:id/status', express.json({ limit: '4kb' }), auth.requireCap(CAPS.providerManage), wrap(async req => {
++ const status = req.body?.status;
++ if (!['spotted', 'promoted', 'dismissed'].includes(status)) throw new AiError(422, 'ai.invalid', 'invalid spot status');
++ if (!await discovery.setStatus(req.params.id, status)) throw new AiError(404, 'ai.not_found', 'no such spotted model');
++ await registry.audit(req.principal.sub, `model.spot_${status}`, 'model_spot', req.params.id, { trace: req.ov?.traceId });
++ return { ok: true, id: req.params.id, status };
++ }));
++ return r;
++}
++module.exports = { modelSpotsRouter };
ddiff --git a/server/app.js b/server/app.js
iindex 74c3488..9666e23 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
--function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {
++function createApp({ choices = null, searchFetch = null, config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, creditGrants = null, sessionCookies = null, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, modelDiscovery = null, ownEndpoints = null, refusal = null, willingness = null, abliterationTax = null, batches = null, share = null }) {
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@@ -151,7 +151,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
app.use(exchangePlatform.router);
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// purpose — the site and anyone comparing prices reads the same numbers the router ranks on.
-- app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, quotas, env, clock, presets, receipts, auth, log, ownEndpoints, fetchImpl, share }));
++ app.use(require('./api/exchange').exchangeRouter({ config, db, registry, pool, quotas, env, clock, presets, receipts, auth, log, ownEndpoints, fetchImpl, share, modelSync }));
++ if (modelDiscovery) app.use(require('./api/model-spots').modelSpotsRouter({ discovery: modelDiscovery, auth, registry, log }));
// The 18+ board's public read API (server/willing/): the measured willingness rates, the published
// prompt set, and the abliteration tax. Read-only; plan=1 is the dry run and never calls a provider.
app.use(require('./api/willing').createWillingRouter({ db, willingness, abliterationTax, log }));
ddiff --git a/server/index.js b/server/index.js
iindex 607ff9f..d13e5f7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -206,6 +206,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// operator console. Off in test mode (the tests replay the recorded listings) and off under a drill.
const priceBook = require('./pricebook').createPriceBook({ db, registry, config, clock, log, offPeak });
const modelSync = require('./providers/model-sync').createModelSync({ db, registry, env, fetchImpl, clock, log });
++ const modelDiscovery = require('./providers/model-discovery').createModelDiscovery({ db, registry, fetchImpl, clock, log });
// The measured refusal rates (server/refusal/): the weekly job that asks every offer the same published,
// harmless questions and publishes how often it refused them. Due-based, like the price book.
const refusal = require('./refusal/job').createRefusalJob({ db, registry, pool, config, env, clock, log });
@@@ -228,7 +229,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
-- const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, abuse, priceBook, modelSync, ownEndpoints, refusal, willingness, abliterationTax, batches, share });
++ const app = createApp({ choices, searchFetch, config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, creditGrants, sessionCookies, gpus, gpuMarket, abuse, priceBook, modelSync, modelDiscovery, ownEndpoints, refusal, willingness, abliterationTax, batches, share });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
@@@ -284,6 +285,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// The price book reads the providers' listings once a week; it never starts in test mode or under a drill.
if (listen && config.priceBook && config.priceBook.enabled) priceBook.start();
if (listen && config.nodeEnv !== 'test' && !/^(1|true|yes|on)$/i.test(String(env.AI_DRILL || ''))) modelSync.start();
++ if (listen && config.nodeEnv !== 'test' && !/^(1|true|yes|on)$/i.test(String(env.AI_DRILL || ''))) modelDiscovery.start();
if (listen) measuredMetrics.start();
if (listen) observations.start();
// The public GPU price reader: public pages only, due-based, off in tests and under a drill.
@@@ -337,6 +339,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (memorySharing) memorySharing.stop();
priceBook.stop();
await modelSync.stop();
++ await modelDiscovery.stop();
await measuredMetrics.stop();
await observations.stop();
gpuMarket.stop();
@@@ -361,7 +364,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (!givenDb) await db.close();
}
-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, modelSync, refusal, willingness, abliterationTax, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };
++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, choices, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, modelSync, modelDiscovery, refusal, willingness, abliterationTax, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };
}
if (require.main === module) {
ddiff --git a/server/openapi.js b/server/openapi.js
iindex da3ced8..9a29a2f 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -637,6 +637,13 @@ route('get', '/exchange/directory', {
models: arrayOf({ type: 'object', additionalProperties: true }),
} }) },
});
++route('get', '/exchange/model-events', {
++ operationId: 'exchangeModelEvents', summary: 'New and retired models from provider syncs', tags: ['exchange'], security: [],
++ description: 'Public, bounded recent model events. Each event is tied to the provider sync that recorded it.',
++ responses: { 200: json('Recent model events', { type: 'object', required: ['count', 'events'], properties: {
++ count: integer(), events: arrayOf({ type: 'object', additionalProperties: true }),
++ } }) },
++});
route('get', '/exchange/willingness', {
operationId: 'exchangeWillingness', summary: 'The measured 18+ willingness rates, per offer', tags: ['exchange'], security: [],
description: 'Public, cached 60 seconds. The refusal and hedge rates of each offer on the published 60-prompt lawful adult set, with n and Wilson intervals. A rate nobody measured is absent, never zero. `plan=1` returns the dry run instead: the offers, prompts and estimated cost from the live rate cards; it never calls a provider and is not cached.',
@@@ -764,6 +771,13 @@ route('get', '/exchange/models/{org}/{model}', {
parameters: [path('org', str()), path('model', str())],
responses: { 200: json('The model', exchangeModel), 404: problem },
});
++route('get', '/exchange/spotted', {
++ operationId: 'exchangeSpottedModels', summary: 'Sourced model sightings before provider listing', tags: ['exchange'], security: [],
++ description: 'Public sightings with sources and dates. A sighting is not an available model or a routable offer.',
++ responses: { 200: json('Spotted models', { type: 'object', required: ['models'], properties: {
++ models: arrayOf({ type: 'object', additionalProperties: true }),
++ } }) },
++});
route('get', '/exchange/usage', {
operationId: 'exchangeUsage', summary: 'Requests, tokens and spend by day, model and product', tags: ['exchange'], security: [],
description: 'Public and aggregate only: never a prompt, an answer or a person. Read from the finished runs: a product is the service whose run answered (runs.requester_id), and each product\'s models are the provider and model that actually answered, not the route it asked for. Cached answers called no provider and are not counted; only aggregate rows are returned.',
ddiff --git a/server/providers/model-discovery.js b/server/providers/model-discovery.js
nnew file mode 100644
iindex 0000000..64eacbe
---- /dev/null
++++ b/server/providers/model-discovery.js
@@@ -0,0 +1,109 @@
++'use strict';
++
++const SOURCES = Object.freeze([
++ { id: 'google-notes', provider: 'gemini', url: 'https://aistudio.google.com/docs/changelog', kind: 'text' },
++ { id: 'openai-notes', provider: 'openai', url: 'https://developers.openai.com/api/docs/changelog', kind: 'text' },
++ { id: 'anthropic-notes', provider: 'anthropic', url: 'https://platform.claude.com/docs/en/release-notes/overview', kind: 'text' },
++ { id: 'google-sdk', provider: 'gemini', url: 'https://raw.githubusercontent.com/googleapis/python-genai/main/google/genai/types.py', kind: 'text' },
++ { id: 'openai-sdk', provider: 'openai', url: 'https://raw.githubusercontent.com/openai/openai-python/main/src/openai/types/chat_model.py', kind: 'text' },
++ { id: 'anthropic-sdk', provider: 'anthropic', url: 'https://raw.githubusercontent.com/anthropics/anthropic-sdk-python/main/src/anthropic/types/model.py', kind: 'text' },
++ { id: 'openrouter-newest', provider: 'openrouter', url: 'https://openrouter.ai/api/v1/models', kind: 'openrouter' },
++ { id: 'huggingface-google', provider: 'huggingface', url: 'https://huggingface.co/api/models?author=google&sort=createdAt&direction=-1&limit=30', kind: 'huggingface' },
++ { id: 'huggingface-meta', provider: 'huggingface', url: 'https://huggingface.co/api/models?author=meta-llama&sort=createdAt&direction=-1&limit=30', kind: 'huggingface' },
++ { id: 'huggingface-qwen', provider: 'huggingface', url: 'https://huggingface.co/api/models?author=Qwen&sort=createdAt&direction=-1&limit=30', kind: 'huggingface' },
++ { id: 'arena-codenames', provider: 'arena', url: 'https://lmarena.ai/leaderboard/text', kind: 'text' },
++]);
++const PATTERNS = Object.freeze({
++ gemini: /\b(?:gemini|gemma|imagen|veo)-[a-z0-9][a-z0-9._-]{2,90}\b/gi,
++ openai: /\b(?:gpt|o[1-9]|sora|dall-e)-[a-z0-9][a-z0-9._-]{2,90}\b/gi,
++ anthropic: /\bclaude-(?:opus|sonnet|haiku|fable|mythos)-[a-z0-9][a-z0-9._-]{2,90}\b/gi,
++ arena: /\b(?:anonymous|stealth|secret)-[a-z0-9][a-z0-9._-]{2,90}\b/gi,
++});
++const safeId = v => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._/-]{2,160}$/.test(v) && !v.includes('..');
++function findings(source, body, observedAt) {
++ const add = (model, url, publishedAt = null) => safeId(model) ? {
++ id: `${source.provider}/${model}`.toLowerCase(), provider: source.provider, model,
++ source: { url, observed_at: observedAt, ...(publishedAt ? { published_at: publishedAt } : {}) },
++ } : null;
++ if (source.kind === 'openrouter') {
++ const data = JSON.parse(body);
++ return (Array.isArray(data.data) ? data.data : []).slice(0, 100).map(m => add(m.id, `https://openrouter.ai/models/${m.id}`, Number.isFinite(Number(m.created)) ? new Date(Number(m.created) * 1000).toISOString() : null)).filter(Boolean);
++ }
++ if (source.kind === 'huggingface') {
++ const data = JSON.parse(body);
++ return (Array.isArray(data) ? data : []).slice(0, 30).map(m => add(m.id, `https://huggingface.co/${m.id}`, m.createdAt || null)).filter(Boolean);
++ }
++ const matches = [...String(body).matchAll(PATTERNS[source.provider] || /$^/g)].map(m => m[0]);
++ return [...new Set(matches)].slice(0, 100).map(model => add(model, source.url)).filter(Boolean);
++}
++
++function createModelDiscovery({ db, registry, fetchImpl = globalThis.fetch, clock = { now: Date.now }, sources = SOURCES, log = console }) {
++ let running = null;
++ let timer = null;
++ const at = () => new Date(clock.now()).toISOString();
++ const known = async () => new Set((await registry.listModels()).map(m => `${m.provider_key}/${m.model_key}`.toLowerCase()));
++ async function list({ includeDismissed = false } = {}) {
++ const listed = await known();
++ const rows = await db.prepare('SELECT * FROM model_spots ORDER BY last_seen_at DESC LIMIT 1000').all();
++ return rows.filter(r => !listed.has(r.id) && (includeDismissed || r.status !== 'dismissed')).map(r => ({
++ id: r.id, provider: r.provider_key, model: r.model_key, name: r.display_name,
++ status: 'spotted', promoted: r.status === 'promoted', dismissed: r.status === 'dismissed',
++ first_seen_at: r.first_seen_at, last_seen_at: r.last_seen_at, sources: JSON.parse(r.sources_json),
++ }));
++ }
++ async function run() {
++ if (running) return running;
++ running = (async () => {
++ const started = at();
++ const job = await db.prepare("INSERT INTO model_discovery_runs (started_at, status) VALUES (?, 'running') RETURNING id").get(started);
++ let read = 0, newCount = 0;
++ const listed = await known();
++ try {
++ for (const source of sources) {
++ try {
++ const response = await fetchImpl(source.url, { headers: { Accept: source.kind === 'text' ? 'text/html, text/plain' : 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(12000) });
++ if (!response.ok) continue;
++ const body = (await response.text()).slice(0, 2_000_000);
++ const found = findings(source, body, at());
++ read++;
++ for (const item of found) {
++ if (listed.has(item.id)) continue;
++ const old = await db.prepare('SELECT * FROM model_spots WHERE id = ?').get(item.id);
++ const sources = old ? JSON.parse(old.sources_json) : [];
++ if (!sources.some(s => s.url === item.source.url)) sources.push(item.source);
++ if (old) await db.prepare('UPDATE model_spots SET last_seen_at = ?, sources_json = ?, updated_at = ? WHERE id = ?')
++ .run(at(), JSON.stringify(sources.slice(-8)), at(), item.id);
++ else {
++ await db.prepare('INSERT INTO model_spots (id, provider_key, model_key, display_name, status, sources_json, first_seen_at, last_seen_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(item.id, item.provider, item.model, item.model, 'spotted', JSON.stringify(sources), at(), at(), at());
++ newCount++;
++ }
++ }
++ } catch { log.warn(`[model-discovery] ${source.id} unavailable`); }
++ }
++ const status = read ? 'succeeded' : 'failed';
++ await db.prepare('UPDATE model_discovery_runs SET finished_at = ?, status = ?, sources_read = ?, spotted = ? WHERE id = ?')
++ .run(at(), status, read, newCount, job.id);
++ return { status, sources_read: read, spotted: newCount, entries: await list() };
++ } catch (error) {
++ await db.prepare("UPDATE model_discovery_runs SET finished_at = ?, status = 'failed' WHERE id = ?").run(at(), job.id);
++ throw error;
++ }
++ })().finally(() => { running = null; });
++ return running;
++ }
++ async function tick() {
++ const last = await db.prepare('SELECT started_at FROM model_discovery_runs ORDER BY id DESC LIMIT 1').get();
++ if (!last || clock.now() - Date.parse(last.started_at) >= 86_400_000) await run();
++ }
++ async function setStatus(id, status) {
++ if (!['spotted', 'promoted', 'dismissed'].includes(status)) throw new RangeError('invalid spot status');
++ const row = await db.prepare('UPDATE model_spots SET status = ?, updated_at = ? WHERE id = ? RETURNING id').get(status, at(), id);
++ return Boolean(row);
++ }
++ return { list, run, tick, setStatus,
++ start() { if (!timer) { void tick().catch(() => log.warn('[model-discovery] schedule failed')); timer = setInterval(() => void tick().catch(() => log.warn('[model-discovery] schedule failed')), 60_000); timer.unref?.(); } },
++ async stop() { clearInterval(timer); timer = null; if (running) await running; },
++ };
++}
++module.exports = { SOURCES, findings, createModelDiscovery };
ddiff --git a/server/providers/model-sync.js b/server/providers/model-sync.js
iindex f9bbd3c..3bba196 100644
---- a/server/providers/model-sync.js
++++ b/server/providers/model-sync.js
@@@ -46,6 +46,16 @@ function createModelSync({ db, registry, env = process.env, fetchImpl = globalTh
let stopped = false;
const iso = () => new Date(now()).toISOString();
const read = provider => db.prepare(`SELECT * FROM provider_model_syncs ${provider ? 'WHERE provider_key = ?' : ''} ORDER BY id DESC LIMIT 50`).all(...(provider ? [provider] : []));
++ const ids = value => Array.isArray(value) ? value : typeof value === 'string' ? JSON.parse(value) : [];
++ async function events() {
++ const rows = await read();
++ return rows.filter(row => row.status === 'succeeded').flatMap(row => [
++ ...ids(row.added_models).map(model => ({ id: `${row.id}:added:${model}`, type: 'model.added', provider: row.provider_key,
++ model, occurred_at: row.finished_at, sync_id: row.id })),
++ ...ids(row.missing_models).map(model => ({ id: `${row.id}:retired:${model}`, type: 'model.retired', provider: row.provider_key,
++ model, occurred_at: row.finished_at, sync_id: row.id })),
++ ]);
++ }
function run(key, { actor = 'system', trigger = 'manual' } = {}) {
if (stopped) throw new AiError(503, 'ai.unavailable', 'model sync is stopping');
@@@ -62,8 +72,8 @@ function createModelSync({ db, registry, env = process.env, fetchImpl = globalTh
const [dev, router] = await Promise.all([source.read(), provider.key === 'openrouter' ? Promise.resolve({ entries: [], stale: true }) : routerSource.read()]);
const result = await catalog.sync({ registry, provider, apiKey, actor, now, enrich: enrichment(provider, dev, router),
fetchImpl: fetchImpl === globalThis.fetch ? guardedFetch : fetchImpl });
-- await db.prepare("UPDATE provider_model_syncs SET status = 'succeeded', finished_at = ?, added = ?, updated = ?, missing = ? WHERE id = ?")
-- .run(iso(), result.added.length, result.updated.length, result.missing.length, row.id);
++ await db.prepare("UPDATE provider_model_syncs SET status = 'succeeded', finished_at = ?, added = ?, updated = ?, missing = ?, added_models = ?, missing_models = ? WHERE id = ?")
++ .run(iso(), result.added.length, result.updated.length, result.missing.length, JSON.stringify(result.added), JSON.stringify(result.missing), row.id);
return { id: row.id, provider: key, ...result };
} catch (err) {
const safe = ['provider.auth', 'provider.http', 'provider.unreachable', 'provider.timeout', 'provider.catalog_invalid', 'provider.catalog_unsupported', 'provider.config', 'provider.no_credentials'].includes(err.code) ? err.code : 'provider.sync_failed';
@@@ -89,7 +99,7 @@ function createModelSync({ db, registry, env = process.env, fetchImpl = globalTh
}
} finally { ticking = false; }
}
-- return { run, read, tick,
++ return { run, read, events, tick,
start() { if (!timer) { timer = setInterval(() => tick().catch(() => log.warn('model sync schedule failed')), 60_000); timer.unref?.(); } },
async stop() { stopped = true; clearInterval(timer); timer = null; await Promise.allSettled([...active.values()]); },
};
ddiff --git a/test/model-discovery.test.js b/test/model-discovery.test.js
nnew file mode 100644
iindex 0000000..b53d337
---- /dev/null
++++ b/test/model-discovery.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { boot, request, token, suite } = require('./helpers');
++const { createModelDiscovery, findings } = require('../server/providers/model-discovery');
++const t = suite('model-discovery');
++const owner = token('exchange', ['ai.provider.manage', 'ai.usage.read']);
++const denied = token('chat', ['ai.run.read']);
++let h;
++t.test('official source adapters extract exact model ids without treating them as available', () => {
++ const at = '2026-11-11T00:00:00Z';
++ assert.deepEqual(findings({ provider: 'gemini', kind: 'text', url: 'https://example.com' }, 'Gemini gemini-3.5-flash and gemini-3.5-flash', at).map(x => x.model), ['gemini-3.5-flash']);
++ assert.deepEqual(findings({ provider: 'openrouter', kind: 'openrouter' }, JSON.stringify({ data: [{ id: 'google/gemini-4', created: 1794355200 }] }), at).map(x => x.id), ['openrouter/google/gemini-4']);
++ assert.deepEqual(findings({ provider: 'huggingface', kind: 'huggingface' }, JSON.stringify([{ id: 'google/gemma-4', createdAt: at }]), at).map(x => x.id), ['huggingface/google/gemma-4']);
++});
++
++t.test('daily refresh dedupes sightings, keeps sources and never adds a routable model', async () => {
++ h = await boot({ env: { AI_USER_MODULES: 'off' } });
++ const sources = [{ id: 'official', provider: 'gemini', kind: 'text', url: 'https://example.com/notes' },
++ { id: 'sdk', provider: 'gemini', kind: 'text', url: 'https://example.com/sdk' }];
++ let now = Date.parse('2026-11-11T00:00:00Z');
++ const discovery = createModelDiscovery({ db: h.db, registry: h.registry, sources, clock: { now: () => now },
++ fetchImpl: async () => new Response('gemini-9-pro gemini-9-pro'), log: { warn() {} } });
++ const first = await discovery.run();
++ assert.equal(first.status, 'succeeded');
++ assert.equal(first.spotted, 1);
++ assert.equal(first.entries[0].sources.length, 2);
++ assert.equal(await h.registry.getModel('gemini', 'gemini-9-pro'), null);
++ await discovery.tick();
++ assert.equal((await h.db.prepare('SELECT COUNT(*) AS n FROM model_discovery_runs').get()).n, 1);
++ now += 86_400_001;
++ assert.equal((await discovery.run()).spotted, 0);
++ assert.equal((await discovery.list()).length, 1);
++ assert.equal(await discovery.setStatus('gemini/gemini-9-pro', 'promoted'), true);
++ assert.equal((await discovery.list())[0].promoted, true);
++ assert.equal(await discovery.setStatus('gemini/gemini-9-pro', 'dismissed'), true);
++ assert.equal((await discovery.list()).length, 0);
++});
++
++t.test('public spots and owner promotion are capability gated', async () => {
++ const publicRead = await request(h.base, 'GET', '/api/v1/exchange/spotted');
++ assert.equal(publicRead.status, 200, publicRead.text);
++ const path = '/api/v1/admin/console/model-spots/gemini%2Fgemini-9-pro/status';
++ assert.equal((await request(h.base, 'POST', path, { tok: denied, body: { status: 'promoted' } })).status, 403);
++ const changed = await request(h.base, 'POST', path, { tok: owner, body: { status: 'promoted' } });
++ assert.equal(changed.status, 200, changed.text);
++ assert.equal((await request(h.base, 'GET', '/api/v1/exchange/spotted')).body.models[0].promoted, true);
++ await h.close();
++});
++
++t.run();
ddiff --git a/test/provider-model-sync.test.js b/test/provider-model-sync.test.js
iindex 5c74ece..4e84464 100644
---- a/test/provider-model-sync.test.js
++++ b/test/provider-model-sync.test.js
@@@ -43,6 +43,10 @@ t.test('boot and sync the registered endpoint into the existing model table, off
const r = await api('POST', prefix + '/sync');
assert.strictEqual(r.status, 200, r.text);
assert.deepStrictEqual(r.body.added, ['alpha', 'beta']);
++ const events = await api('GET', '/api/v1/exchange/model-events');
++ assert.strictEqual(events.status, 200, events.text);
++ assert.deepStrictEqual(events.body.events.filter(e => e.provider === 'sync-test').map(e => [e.type, e.model]),
++ [['model.added', 'alpha'], ['model.added', 'beta']]);
const alpha = await h.registry.getModel('sync-test', 'alpha');
assert.strictEqual(alpha.status, 'disabled');
assert.strictEqual(alpha.context_window, 2048, 'the provider context wins');
@@@ -87,6 +91,8 @@ t.test('removed models are retained disabled; a reappearing model still needs an
const gone = await h.registry.getModel('sync-test', 'gamma');
assert.strictEqual(gone.status, 'disabled');
assert.ok(gone.metadata.missing_since);
++ const retired = await api('GET', '/api/v1/exchange/model-events');
++ assert.ok(retired.body.events.some(e => e.type === 'model.retired' && e.provider === 'sync-test' && e.model === 'gamma'));
assert.strictEqual((await api('PATCH', '/api/v1/models/sync-test/gamma', { status: 'active' })).status, 409);
listing.push({ id: 'gamma' });
assert.strictEqual((await api('POST', prefix + '/sync')).status, 200);