openvibers/openvibe.ai Commits
Track sourced model sightings before provider listing (#468)
Changes
nnew file mode 100644@@@ -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++);nnew file mode 100644@@@ -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;@@@ -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();nnew file mode 100644@@@ -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 };@@@ -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, papp.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 }));@@@ -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) {@@@ -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.',nnew file mode 100644@@@ -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 };@@@ -46,6 +46,16 @@ function createModelSync({ db, registry, env = process.env, fetchImpl = globalThlet 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 = globalThconst [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()]); },};nnew file mode 100644@@@ -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();@@@ -43,6 +43,10 @@ t.test('boot and sync the registered endpoint into the existing model table, offconst 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 anconst 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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -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;
@@ -35,7 +35,7 @@ function memo(fn, ttlMs, clock) {
@@ -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.
// 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({
const fidelitySource = fidelity || require('../exchange/fidelity').createFidelitySource({
url: config && config.bench ? config.bench.url : null, fetchImpl, clock, log,
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,
@@ -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); }
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) => {
r.get('/api/v1/exchange/catalog', publicCache(30), async (req, res, next) => {
try {
try {
const kind = req.query.kind == null || req.query.kind === '' ? null : String(req.query.kind).toLowerCase();
const kind = req.query.kind == null || req.query.kind === '' ? null : String(req.query.kind).toLowerCase();
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 };
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
const ownerInputs = require('./owner-inputs');
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();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.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') });
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
@@ -151,7 +151,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
app.use(exchangePlatform.router);
app.use(exchangePlatform.router);
// OpenVibe.Exchange's read API (server/api/exchange.js): the public catalog and aggregate usage. Public on
// 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.
// 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
// 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.
// 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 }));
app.use(require('./api/willing').createWillingRouter({ db, willingness, abliterationTax, log }));
@@ -206,6 +206,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -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.
// 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 priceBook = require('./pricebook').createPriceBook({ db, registry, config, clock, log, offPeak });
const modelSync = require('./providers/model-sync').createModelSync({ db, registry, env, fetchImpl, clock, log });
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,
// 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.
// 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 });
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 = {
@@ -228,7 +229,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
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 jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -284,6 +285,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -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.
// 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.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 || ''))) 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) measuredMetrics.start();
if (listen) observations.start();
if (listen) observations.start();
// The public GPU price reader: public pages only, due-based, off in tests and under a drill.
// 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 = {
@@ -337,6 +339,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (memorySharing) memorySharing.stop();
if (memorySharing) memorySharing.stop();
priceBook.stop();
priceBook.stop();
await modelSync.stop();
await modelSync.stop();
+ await modelDiscovery.stop();
await measuredMetrics.stop();
await measuredMetrics.stop();
await observations.stop();
await observations.stop();
gpuMarket.stop();
gpuMarket.stop();
@@ -361,7 +364,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -361,7 +364,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (!givenDb) await db.close();
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) {
if (require.main === module) {
@@ -637,6 +637,13 @@ route('get', '/exchange/directory', {
@@ -637,6 +637,13 @@ route('get', '/exchange/directory', {
models: arrayOf({ type: 'object', additionalProperties: true }),
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', {
route('get', '/exchange/willingness', {
operationId: 'exchangeWillingness', summary: 'The measured 18+ willingness rates, per offer', tags: ['exchange'], security: [],
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.',
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}', {
@@ -764,6 +771,13 @@ route('get', '/exchange/models/{org}/{model}', {
parameters: [path('org', str()), path('model', str())],
parameters: [path('org', str()), path('model', str())],
responses: { 200: json('The model', exchangeModel), 404: problem },
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', {
route('get', '/exchange/usage', {
operationId: 'exchangeUsage', summary: 'Requests, tokens and spend by day, model and product', tags: ['exchange'], security: [],
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.',
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.',
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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 };
@@ -46,6 +46,16 @@ function createModelSync({ db, registry, env = process.env, fetchImpl = globalTh
@@ -46,6 +46,16 @@ function createModelSync({ db, registry, env = process.env, fetchImpl = globalTh
let stopped = false;
let stopped = false;
const iso = () => new Date(now()).toISOString();
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 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' } = {}) {
function run(key, { actor = 'system', trigger = 'manual' } = {}) {
if (stopped) throw new AiError(503, 'ai.unavailable', 'model sync is stopping');
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
@@ -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 [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),
const result = await catalog.sync({ registry, provider, apiKey, actor, now, enrich: enrichment(provider, dev, router),
fetchImpl: fetchImpl === globalThis.fetch ? guardedFetch : fetchImpl });
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 };
return { id: row.id, provider: key, ...result };
} catch (err) {
} 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';
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
@@ -89,7 +99,7 @@ function createModelSync({ db, registry, env = process.env, fetchImpl = globalTh
}
}
} finally { ticking = false; }
} 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?.(); } },
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()]); },
async stop() { stopped = true; clearInterval(timer); timer = null; await Promise.allSettled([...active.values()]); },
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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();
@@ -43,6 +43,10 @@ t.test('boot and sync the registered endpoint into the existing model table, off
@@ -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');
const r = await api('POST', prefix + '/sync');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.deepStrictEqual(r.body.added, ['alpha', 'beta']);
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');
const alpha = await h.registry.getModel('sync-test', 'alpha');
assert.strictEqual(alpha.status, 'disabled');
assert.strictEqual(alpha.status, 'disabled');
assert.strictEqual(alpha.context_window, 2048, 'the provider context wins');
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
@@ -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');
const gone = await h.registry.getModel('sync-test', 'gamma');
assert.strictEqual(gone.status, 'disabled');
assert.strictEqual(gone.status, 'disabled');
assert.ok(gone.metadata.missing_since);
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);
assert.strictEqual((await api('PATCH', '/api/v1/models/sync-test/gamma', { status: 'active' })).status, 409);
listing.push({ id: 'gamma' });
listing.push({ id: 'gamma' });
assert.strictEqual((await api('POST', prefix + '/sync')).status, 200);
assert.strictEqual((await api('POST', prefix + '/sync')).status, 200);