openvibers/openvibe.ai Commits

Sync registry provider models with owner controls (#437)

OP OpenVibers committed 3 days ago · ce4dadfc7a · 17 files, +505 −58

* Sync provider models with owner controls and preserved pricing

* Honor model disable and retirement before every provider attempt

* Move model sync migration past concurrent metrics migrations

* Merge main with measured metrics before provider sync release

Changes

ddiff --git a/docs/provider-registry.md b/docs/provider-registry.md
iindex beeaed0..e081f94 100644
---- a/docs/provider-registry.md
++++ b/docs/provider-registry.md
@@@ -67,3 +67,30 @@ latency and check time are stored; no upstream response text is logged or return
endpoint access, not that a particular model can run. Missing keys result in zero requests.
For Fable: key marker/date, explicit check button, check status/latency and remove-and-disable controls in each provider.
++
++Model discovery uses the existing `providers/catalog.js` sync and the existing `models` table. The registered
++models endpoint is authoritative, uses the provider's authentication on its bound origin, and must return a complete
++list before removals are considered. A provider can sync from the console or on an hourly, six-hour or daily
++schedule. Schedules start off and only run for active providers. Duplicate syncs for one provider are refused.
++`provider_model_syncs` records results, counts and safe error codes, never upstream bodies. Console reads return
++the most recent 50 results. Lists support root arrays and token pagination, including
++[Cohere's page token protocol](https://docs.cohere.com/reference/list-models).
++
++New models are disabled unless the owner selects `auto_list_new_models`. Uncensored models always arrive disabled,
++carry their 18+ floor and continue through the existing adult policy gate. Previously listed models that disappear
++are retained disabled with `missing_since`; a model that returns stays disabled until explicitly enabled. Invalid
++or incomplete listings never retire models. Owner names, content tiers, prices and price-book decisions survive
++sync and boot-time seeding. The existing owner price override remains the charged rate.
++
++The provider list can supply token prices directly. Missing prices and context use the already cached
++[models.dev data](https://models.dev/) and [OpenRouter public list](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties).
++Prices are taken only from the matching provider, retain their source and date, and never replace a known price.
++Another reseller's price is not used as a provider's cost. Missing fields stay null; stale enrichment never fills a
++price. The public metadata cache is reused for both sources and never receives a provider key.
++
++Owner API: `POST /api/v1/admin/console/providers/:key/sync`, `POST .../:key/model-settings`,
++`GET /api/v1/admin/console/models?provider=:key`, and `GET /api/v1/admin/console/sync`. Model settings use the existing
++`PATCH /api/v1/models/:provider/:model` with display name, status, content tier and owner price override.
++
++For Fable: provider model links, sync button/schedule and automatic-listing preference; per-provider models with
++name, content tier, gone state and price provenance; the model sync log. Added controls are plain kit markup.
ddiff --git a/migrations/0070_provider_model_sync.sql b/migrations/0070_provider_model_sync.sql
nnew file mode 100644
iindex 0000000..e4cd38e
---- /dev/null
++++ b/migrations/0070_provider_model_sync.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- Bounded owner-visible history of model discovery. Models remain in the existing models table.
++CREATE TABLE provider_model_syncs (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ provider_key text COLLATE "C" NOT NULL REFERENCES providers(key),
++ trigger_kind text COLLATE "C" NOT NULL,
++ actor text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
++ started_at timestamptz NOT NULL,
++ finished_at timestamptz,
++ added integer NOT NULL DEFAULT 0,
++ updated integer NOT NULL DEFAULT 0,
++ missing integer NOT NULL DEFAULT 0,
++ error_code text COLLATE "C"
++);
++CREATE INDEX provider_model_syncs_recent ON provider_model_syncs(provider_key, started_at DESC);
ddiff --git a/server/api/admin.js b/server/api/admin.js
iindex 4ed785a..4cf48be 100644
---- a/server/api/admin.js
++++ b/server/api/admin.js
@@@ -117,6 +117,20 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, aut
const b = body(req);
const prev = await registry.getModel(req.params.provider, req.params.model);
const metadata = { ...(prev.metadata || {}), ...(b.metadata || {}) };
++ if (b.display_name !== undefined) {
++ if (typeof b.display_name !== 'string' || !b.display_name.trim() || b.display_name.length > 200) throw new AiError(422, 'ai.invalid', 'display_name must contain 1-200 characters');
++ metadata.owner_display_name = true;
++ }
++ if (b.status === 'active' && prev.metadata?.missing_since) throw new AiError(409, 'ai.model_gone', 'sync the provider before enabling a model that is no longer listed');
++ if (b.content_tier !== undefined) {
++ if (!['standard', 'uncensored'].includes(b.content_tier)) throw new AiError(422, 'ai.invalid', 'content_tier must be standard or uncensored');
++ const provider = await registry.getProvider(req.params.provider);
++ if (b.content_tier === 'standard' && require('../providers/pool-providers').isUncensored(provider, { model_key: req.params.model })) throw new AiError(422, 'ai.invalid', 'an uncensored provider or model keeps its 18+ tier');
++ metadata.uncensored = b.content_tier === 'uncensored';
++ metadata.owner_content_tier = true;
++ if (metadata.uncensored) metadata.min_age = 18;
++ else delete metadata.min_age;
++ }
if (b.owner_override !== undefined) {
const override = b.owner_override;
if (override === null) {
ddiff --git a/server/api/console-data.js b/server/api/console-data.js
iindex fec564e..05670c4 100644
---- a/server/api/console-data.js
++++ b/server/api/console-data.js
@@@ -11,7 +11,7 @@ const gpuCatalog = require('../gpu/catalog');
const { tokenCosts } = require('../gpu/token-cost');
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
--function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
++function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, modelSync = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
const r = express.Router();
const supply = require('../platform/supply').createSupply({ db, registry, clock });
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
@@@ -25,6 +25,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
const read = auth.requireCap(CAPS.usageRead);
const write = auth.requireCap(CAPS.providerManage);
const workflowWrite = auth.requireCap(CAPS.workflowManage);
++ const modelView = (m, provider) => ({ ...m, provider: m.provider_key, model: m.model_key, accepted_price: m.cost,
++ content_tier: require('../providers/pool-providers').isUncensored(provider, m) ? 'uncensored' : 'standard',
++ owner_override: m.metadata?.owner_override || null });
const actor = (req) => {
const subject = String(req.get('x-ov-subject') || '');
return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) ? subject : req.principal.sub;
@@@ -59,11 +62,15 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
}))) : [];
const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
-- return { providers: rows, models: (await registry.listModels()).map((m) => ({ ...m, accepted_price: m.cost,
-- owner_override: m.metadata && m.metadata.owner_override || null })), presets: poolProviders.map((p) => ({ key: p.key, display_name: p.display_name, base_url: p.base_url, default_model: p.default_model, provider_access: p.provider_access, uncensored: Boolean(p.uncensored), models: p.models })), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };
++ return { providers: rows, models: (await registry.listModels()).map(m => modelView(m, rows.find(p => p.key === m.provider_key))), presets: poolProviders.map((p) => ({ key: p.key, display_name: p.display_name, base_url: p.base_url, default_model: p.default_model, provider_access: p.provider_access, uncensored: Boolean(p.uncensored), models: p.models })), credit_estimates: estimates, session_credentials: cookies, session_balances: balances };
}
-- if (section === 'models') return { models: (await registry.listModels()).map((m) => ({ ...m, accepted_price: m.cost,
-- owner_override: m.metadata && m.metadata.owner_override || null })) };
++ if (section === 'models') {
++ const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
++ const providers = await registry.listProviders();
++ return { models: (await registry.listModels({ provider })).map(m => modelView(m, providers.find(p => p.key === m.provider_key))),
++ provider: provider || null, sync_log: modelSync ? await modelSync.read(provider) : [] };
++ }
++ if (section === 'sync') return { sync_log: modelSync ? await modelSync.read() : [] };
if (section === 'runs') {
const { filters, form, bad } = q.parseRunFilters(req.query);
const list = await q.listRuns(db, filters);
@@@ -139,6 +146,19 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
if (current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });
return { provider: provider.key, ...result };
}));
++ r.post('/api/v1/admin/console/providers/:key/sync', write, wrap(async (req) => {
++ if (!modelSync) throw new AiError(503, 'ai.unavailable', 'model sync is unavailable');
++ return modelSync.run(req.params.key, { actor: actor(req) });
++ }));
++ r.post('/api/v1/admin/console/providers/:key/model-settings', json, write, wrap(async (req) => {
++ const provider = await registry.getProvider(req.params.key);
++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
++ const b = req.body || {};
++ if (typeof b.auto_list_new_models !== 'boolean' || ![0, 60, 360, 1440].includes(b.model_sync_interval_minutes)) throw new AiError(422, 'ai.invalid', 'choose a model sync interval and automatic listing preference');
++ await registry.upsertProvider({ key: provider.key, metadata: { ...provider.metadata,
++ auto_list_new_models: b.auto_list_new_models, model_sync_interval_minutes: b.model_sync_interval_minutes } }, { actor: actor(req), origin: 'admin' });
++ return { ok: true, provider: provider.key };
++ }));
r.post('/api/v1/admin/console/providers', json, write, wrap(async (req) => {
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
const b = req.body || {};
ddiff --git a/server/app.js b/server/app.js
iindex 627f3b2..4fbc193 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, ownEndpoints = null, refusal = 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, ownEndpoints = null, refusal = 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') });
@@@ -275,7 +275,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
-- app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));
++ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, abuse, priceBook, modelSync, creditGrants, sessionCookies, vault, settings, config, log }));
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
ddiff --git a/server/exchange/model-metadata.js b/server/exchange/model-metadata.js
iindex c38f240..b1356a2 100644
---- a/server/exchange/model-metadata.js
++++ b/server/exchange/model-metadata.js
@@@ -16,7 +16,7 @@ function indexMetadata(data) {
// A reseller's name is not the maker. Prefer explicit canonical identity, then a
// first-party record; unprefixed reseller-only records leave maker unknown.
const maker = m.maker || (canonical.includes('/') ? canonical.split('/')[0] : null);
-- entries.push({ id: m.id || id, provider, maker, model: m });
++ entries.push({ id: m.id || id, provider, provider_api: p.api || null, maker, model: m });
}
}
return entries;
@@@ -41,31 +41,31 @@ function lookupMetadata(entries, id, provider) {
};
}
--function createModelMetadata({ db, fetchImpl = globalThis.fetch, now = Date.now, enabled = true, log = console }) {
++function createModelMetadata({ db, fetchImpl = globalThis.fetch, now = Date.now, enabled = true, log = console, sourceId = 'models.dev', sourceUrl = URL, index = indexMetadata }) {
let snapshot = null; let loaded = false; let retryAt = 0; let pending = null;
async function refresh() {
if (!loaded) {
-- const row = await db.prepare('SELECT payload, checked_at FROM exchange_model_metadata WHERE id = ?').get('models.dev');
-- if (row) snapshot = { entries: indexMetadata(typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload), checked_at: new Date(row.checked_at).toISOString() };
++ const row = await db.prepare('SELECT payload, checked_at FROM exchange_model_metadata WHERE id = ?').get(sourceId);
++ if (row) snapshot = { entries: index(typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload), checked_at: new Date(row.checked_at).toISOString() };
loaded = true;
}
const expired = !snapshot || now() - Date.parse(snapshot.checked_at) >= DAY_MS;
if (expired && enabled && now() >= retryAt) {
retryAt = now() + 60_000;
try {
-- const response = await fetchImpl(URL, { signal: AbortSignal.timeout(15_000) });
++ const response = await fetchImpl(sourceUrl, { redirect: 'error', signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error('metadata HTTP failure');
const raw = await response.text();
if (raw.length > 32 * 1024 * 1024) throw new Error('metadata exceeds size limit');
-- const data = JSON.parse(raw); const entries = indexMetadata(data);
++ const data = JSON.parse(raw); const entries = index(data);
if (!entries.length) throw new Error('metadata has no models');
const checked_at = new Date(now()).toISOString();
await db.prepare(`INSERT INTO exchange_model_metadata (id, payload, checked_at) VALUES (?, ?::jsonb, ?)
-- ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, checked_at = EXCLUDED.checked_at`).run('models.dev', raw, checked_at);
++ ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, checked_at = EXCLUDED.checked_at`).run(sourceId, raw, checked_at);
snapshot = { entries, checked_at };
-- } catch (_) { log.warn?.('models.dev metadata refresh failed; keeping last copy'); }
++ } catch (_) { log.warn?.('model metadata refresh failed; keeping last copy'); }
}
-- return { entries: snapshot?.entries || [], source: URL, checked_at: snapshot?.checked_at || null,
++ return { entries: snapshot?.entries || [], source: sourceUrl, checked_at: snapshot?.checked_at || null,
stale: !snapshot || now() - Date.parse(snapshot.checked_at) >= DAY_MS };
}
return { read() { if (!pending) pending = refresh().finally(() => { pending = null; }); return pending; } };
ddiff --git a/server/index.js b/server/index.js
iindex 5f94004..cd11c37 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -202,6 +202,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
// keeps the price the router charges in step with it. A change of more than 20% waits for the owner on the
// 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 });
// 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 });
@@@ -220,7 +221,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, abuse, priceBook, ownEndpoints, refusal, batches, gpuMarket, 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, abuse, priceBook, modelSync, ownEndpoints, refusal, batches, gpuMarket, share });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
@@@ -275,6 +276,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (listen && config.nodeEnv !== 'test' && memorySharing) memorySharing.start();
// 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) measuredMetrics.start();
if (listen) observations.start();
// The public GPU price reader: public pages only, due-based, off in tests and under a drill.
@@@ -325,6 +327,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
memoryJobs.stop();
if (memorySharing) memorySharing.stop();
priceBook.stop();
++ await modelSync.stop();
await measuredMetrics.stop();
await observations.stop();
gpuMarket.stop();
@@@ -347,7 +350,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, refusal, 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, refusal, batches, batcher, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, gpuMarket, abuse, ownEndpoints, close };
}
if (require.main === module) {
ddiff --git a/server/providers/catalog.js b/server/providers/catalog.js
iindex d30791c..183dbde 100644
---- a/server/providers/catalog.js
++++ b/server/providers/catalog.js
@@@ -14,7 +14,7 @@
* one on: routing would otherwise change what OpenVibe spends), a known model keeps its
* status and its prices, and one the provider no longer lists is marked, never deleted.
*
-- * Prices are never read from here: a person types them from the provider's own price page (registry.upsertModel).
++ * Listed prices and optional public metadata fill previously unknown prices; owner and price-book values win.
* The families below are what the ids mean; what a provider's list says (Anthropic's capabilities, OpenRouter's
* modalities and context) wins over them.
*/
@@@ -22,6 +22,8 @@ const { ProviderError, deadline, describeAbort } = require('./common');
// Each family: the id it matches, then what it is. First match wins, so the narrower come first.
const FAMILIES = [
++ [/rerank/i, { type: 'rerank', tier: 'fast' }],
++ [/tts|text-to-speech|speech-/i, { type: 'speech', tier: 'fast' }],
[/embed/i, { type: 'embedding', tier: 'fast' }],
[/whisper|transcri|(^|[-_])stt([-_]|$)/i, { type: 'stt', tier: 'fast' }],
[/^(gpt-image|dall-e|imagen)|image-(preview|generation)|-image$|flash-image/i, { type: 'image', tier: 'balanced' }],
@@@ -64,6 +66,7 @@ function describe(id, hints = {}) {
if (hints.vision != null) out.vision = !!hints.vision;
if (hints.tools != null) out.tools = !!hints.tools;
if (hints.reasoning != null) out.reasoning = !!hints.reasoning;
++ if (MODEL_TYPES.includes(hints.type)) out.type = hints.type;
if (Number(hints.contextWindow) > 0) out.contextWindow = Math.round(Number(hints.contextWindow));
if (Number(hints.maxOutput) > 0) out.maxOutput = Math.round(Number(hints.maxOutput));
return out;
@@@ -71,7 +74,7 @@ function describe(id, hints = {}) {
async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis.fetch } = {}) {
let res;
-- try { res = await fetchImpl(url, { headers, signal: deadline(null, timeoutMs) }); } catch (err) {
++ try { res = await fetchImpl(url, { headers, redirect: 'error', signal: deadline(null, timeoutMs) }); } catch (err) {
throw describeAbort(err, null, timeoutMs) || new ProviderError(`connection failed: ${err.message}`, { code: 'provider.unreachable' });
}
const text = await res.text().catch(() => '');
@@@ -88,73 +91,112 @@ async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis
async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {}) {
const base = String(provider.base_url || '').replace(/\/+$/, '');
if (!base) throw new ProviderError('the provider has no base URL', { code: 'provider.config' });
++ const endpoint = provider.metadata?.models_endpoint || `${base}/models`;
++ if (new URL(endpoint).origin !== new URL(base).origin) throw new ProviderError('model list must use the provider origin', { code: 'provider.config' });
if (provider.kind === 'anthropic') {
// Anthropic's Models API pages by after_id; its Anthropic-format look-alikes (DeepSeek, MiniMax) have no list.
-- if (!/anthropic\.com/i.test(base)) return [];
++ if (!/anthropic\.com/i.test(base) && !provider.metadata?.models_endpoint) return [];
const out = [];
let after = null;
for (let page = 0; page < 10; page++) {
-- const j = await getJson(`${base}/models?limit=100${after ? `&after_id=${encodeURIComponent(after)}` : ''}`, { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, { fetchImpl });
++ const url = new URL(endpoint);
++ url.searchParams.set('limit', '100');
++ if (after) url.searchParams.set('after_id', after);
++ const auth = provider.auth_mode === 'none' ? {} : provider.auth_mode === 'bearer' ? { Authorization: `Bearer ${apiKey}` } : { 'x-api-key': apiKey };
++ const j = await getJson(url.href, { ...auth, 'anthropic-version': '2023-06-01' }, { fetchImpl });
++ if (!Array.isArray(j.data)) throw new ProviderError('invalid model list', { code: 'provider.catalog_invalid' });
for (const m of j.data || []) {
const caps = m.capabilities || {};
const has = (k) => (caps[k] && typeof caps[k] === 'object' ? caps[k].supported !== false : caps[k] == null ? null : !!caps[k]);
out.push({ id: m.id, name: m.display_name || m.id, hints: { contextWindow: m.max_input_tokens, maxOutput: m.max_tokens, vision: has('image_input') ?? has('vision'), reasoning: has('thinking') ?? has('extended_thinking') } });
}
-- if (!j.has_more || !j.last_id) break;
++ if (!j.has_more) break;
++ if (!j.last_id || page === 9) throw new ProviderError('incomplete model list', { code: 'provider.catalog_invalid' });
after = j.last_id;
}
return out;
}
-- const j = await getJson(`${base}/models`, apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, { fetchImpl });
-- return (Array.isArray(j.data) ? j.data : Array.isArray(j.models) ? j.models : []).map((m) => {
-- const id = String(m.id || m.name || '').replace(/^models\//, '');
-- const arch = m.architecture || {};
-- const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : null;
-- return {
-- id, name: m.name && m.name !== m.id ? String(m.name).replace(/^models\//, '') : (m.display_name || id),
-- hints: {
-- contextWindow: m.context_length || m.context_window || m.inputTokenLimit || (m.top_provider && m.top_provider.context_length),
-- maxOutput: m.outputTokenLimit || (m.top_provider && m.top_provider.max_completion_tokens),
-- vision: Array.isArray(arch.input_modalities) ? arch.input_modalities.includes('image') : null,
-- tools: params ? params.includes('tools') : null,
-- reasoning: params ? params.includes('reasoning') || params.includes('include_reasoning') : null,
-- },
-- };
-- }).filter((m) => m.id);
++ const headers = provider.auth_mode === 'none' || !apiKey ? {} : provider.auth_mode === 'x-api-key' ? { [provider.metadata?.auth_header || 'x-api-key']: apiKey } : { Authorization: `Bearer ${apiKey}` };
++ const out = [];
++ const tokens = new Set();
++ const url = new URL(endpoint);
++ for (let page = 0; page < 10; page++) {
++ const j = await getJson(url.href, headers, { fetchImpl });
++ const rows = Array.isArray(j) ? j : Array.isArray(j?.data) ? j.data : j?.models;
++ if (!Array.isArray(rows) || rows.some(m => !m || typeof (m.id || m.name) !== 'string' || !(m.id || m.name).trim())) {
++ throw new ProviderError('invalid model list', { code: 'provider.catalog_invalid' });
++ }
++ out.push(...rows.map(remoteModel));
++ const token = j.next_page_token || j.nextPageToken;
++ if (!token && !j.has_more) return out;
++ if (typeof token !== 'string' || tokens.has(token) || page === 9) throw new ProviderError('incomplete model list', { code: 'provider.catalog_invalid' });
++ tokens.add(token);
++ url.searchParams.set(j.next_page_token ? 'page_token' : 'pageToken', token);
++ }
++}
++
++function remoteModel(m) {
++ const id = String(m.id || m.name || '').replace(/^models\//, '');
++ const arch = m.architecture || {};
++ const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : null;
++ const type = m.type === 'language' ? 'chat' : m.type;
++ return {
++ id, name: m.name && m.name !== m.id ? String(m.name).replace(/^models\//, '') : (m.display_name || id),
++ hints: {
++ contextWindow: m.context_length || m.context_window || m.inputTokenLimit || (m.top_provider && m.top_provider.context_length),
++ maxOutput: m.outputTokenLimit || (m.top_provider && m.top_provider.max_completion_tokens),
++ vision: Array.isArray(arch.input_modalities) ? arch.input_modalities.includes('image') : null,
++ tools: params ? params.includes('tools') : null,
++ reasoning: params ? params.includes('reasoning') || params.includes('include_reasoning') : null,
++ ...(MODEL_TYPES.includes(type) ? { type } : {}),
++ },
++ ...(m.pricing ? { cost: listedCost(m.pricing) } : {}),
++ };
}
--const MODEL_TYPES = ['chat', 'vision', 'embedding', 'stt', 'image'];
++const price = (value, scale = 1) => value != null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) * scale : null;
++function listedCost(pricing) {
++ return { in_per_mtok: price(pricing.prompt, 1e6), out_per_mtok: price(pricing.completion, 1e6), cached_per_mtok: price(pricing.input_cache_read, 1e6) };
++}
++const MODEL_TYPES = ['chat', 'vision', 'embedding', 'stt', 'image', 'video', 'speech', 'rerank'];
/** Read the provider's list and bring its models into the catalogue. */
--async function sync({ registry, provider, apiKey, fetchImpl = globalThis.fetch, actor = 'system', now = () => Date.now() }) {
++async function sync({ registry, provider, apiKey, fetchImpl = globalThis.fetch, actor = 'system', now = () => Date.now(), enrich = null }) {
const remote = await listRemote(provider, apiKey, { fetchImpl });
const known = new Map((await registry.listModels({ provider: provider.key })).map((m) => [m.model_key, m]));
const seen = new Set();
const result = { added: [], updated: [], missing: [], skipped: [] };
const at = new Date(now()).toISOString();
for (const m of remote) {
-- const d = describe(m.id, m.hints);
-- if (!MODEL_TYPES.includes(d.type)) { result.skipped.push(m.id); continue; } // video: not a model type here yet
++ const extra = enrich ? enrich(m.id) : {};
++ const d = describe(m.id, { ...extra.hints, ...Object.fromEntries(Object.entries(m.hints || {}).filter(([, v]) => v != null)) });
++ if (!MODEL_TYPES.includes(d.type)) { result.skipped.push(m.id); continue; }
seen.add(m.id);
const prev = known.get(m.id);
-- const metadata = { ...((prev && prev.metadata) || {}), tier: d.tier, reasoning: d.reasoning, note: d.note, synced_at: at, listed: true };
++ const uncensored = require('./pool-providers').isUncensored(provider, { model_key: m.id, metadata: prev?.metadata || {} });
++ const metadata = { tier: d.tier, reasoning: d.reasoning, note: d.note, ...prev?.metadata, synced_at: at, listed: true,
++ ...(uncensored ? { uncensored: true, min_age: 18 } : {}),
++ ...(extra.source ? { catalogue_enrichment: { source: extra.source, checked_at: extra.checked_at } } : {}) };
delete metadata.missing_since;
++ const ownCost = m.cost && Object.values(m.cost).some(v => v != null) ? { ...m.cost, source: provider.metadata?.models_endpoint || `${provider.base_url.replace(/\/+$/, '')}/models`, verified_at: at.slice(0, 10) } : null;
++ const hasPrice = prev?.metadata?.price_book || ['in_per_mtok', 'out_per_mtok', 'cached_per_mtok'].some(f => prev?.cost?.[f] != null);
++ const cost = hasPrice ? prev.cost : ownCost || extra.cost || prev?.cost;
await registry.upsertModel({
-- provider_key: provider.key, model_key: m.id, display_name: m.name || m.id, type: d.type,
++ provider_key: provider.key, model_key: m.id, display_name: prev?.display_name || m.name || m.id, type: d.type,
// A new model is switched off until a person turns it on; a known one keeps its status (and its prices).
-- status: prev ? prev.status : 'disabled',
++ status: prev ? prev.status : provider.metadata?.auto_list_new_models === true && !uncensored ? 'active' : 'disabled',
context_window: d.contextWindow ?? (prev && prev.context_window) ?? null, max_output: d.maxOutput ?? (prev && prev.max_output) ?? null,
-- supports: { json: true, tools: d.tools, streaming: true, vision: d.vision }, metadata,
++ supports: { json: true, tools: d.tools, streaming: true, vision: d.vision }, metadata, ...(cost ? { cost } : {}),
}, { actor });
(prev ? result.updated : result.added).push(m.id);
}
for (const [id, prev] of known) {
if (seen.has(id) || (prev.metadata && prev.metadata.missing_since)) continue;
if (!(prev.metadata && prev.metadata.listed)) continue; // typed in by a person, never listed: leave it be
-- await registry.upsertModel({ provider_key: provider.key, model_key: id, metadata: { ...prev.metadata, listed: false, missing_since: at } }, { actor });
++ await registry.upsertModel({ provider_key: provider.key, model_key: id, status: 'disabled', metadata: { ...prev.metadata, listed: false, missing_since: at } }, { actor });
result.missing.push(id);
}
return result;
}
--module.exports = { describe, listRemote, sync, FAMILIES, TIERS };
++module.exports = { describe, listRemote, remoteModel, sync, FAMILIES, TIERS };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex 7750e34..4ccb768 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -1095,6 +1095,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function skipReason(p, a, features, model = undefined) {
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
++ // Read the current model state even on pinned/default routes: a sync retirement or owner switch
++ // must take effect before the next attempt, without requiring the route to be rebuilt.
++ const row = model ? await registry.getModel(p.key, model) : null;
++ if (row?.metadata?.missing_since) return 'model_gone';
++ if (row && row.status !== 'active') return 'model_disabled';
if (p.auth_mode !== 'none' && !credentialNotRequired(p) && !providerSecret(p, env)) return 'no_credentials';
if (features.some(f => !a.supports(f))) return 'unsupported';
const h = await health(p.key, model, { stamp: stampFor(p) });
ddiff --git a/server/providers/model-sync.js b/server/providers/model-sync.js
nnew file mode 100644
iindex 0000000..f9bbd3c
---- /dev/null
++++ b/server/providers/model-sync.js
@@@ -0,0 +1,98 @@
++'use strict';
++
++const catalog = require('./catalog');
++const { AiError, providerSecret } = require('../util');
++const { guardedFetch } = require('./guarded-fetch');
++const { createModelMetadata } = require('../exchange/model-metadata');
++const pool = require('./pool-providers');
++const number = v => v != null && v !== '' && Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : null;
++const normal = v => String(v || '').replace(/\/+$/, '');
++
++/** Provider-specific prices only: another reseller's price is never the cost of this provider. */
++function enrichment(provider, dev, router) {
++ const sourceId = provider.metadata?.models_dev_id || provider.key;
++ const candidates = dev.entries.filter(e => e.provider === sourceId || (!provider.metadata?.models_dev_id && e.provider_api && normal(e.provider_api) === normal(provider.base_url)));
++ const direct = new Map(candidates.sort((a, b) => Number(a.provider === sourceId) - Number(b.provider === sourceId)).map(e => [e.id, e.model]));
++ const routed = new Map(router.entries.map(m => [m.id, m]));
++ return id => {
++ const m = direct.get(id);
++ const other = routed.get(id);
++ const hints = { ...other?.hints };
++ if (m) {
++ if (m.limit?.context) hints.contextWindow = m.limit.context;
++ if (m.limit?.output) hints.maxOutput = m.limit.output;
++ if (Array.isArray(m.modalities?.input)) hints.vision = m.modalities.input.includes('image');
++ if (typeof m.tool_call === 'boolean') hints.tools = m.tool_call;
++ if (typeof m.reasoning === 'boolean') hints.reasoning = m.reasoning;
++ }
++ const cost = m && !dev.stale && m.cost ? {
++ in_per_mtok: number(m.cost.input), out_per_mtok: number(m.cost.output), cached_per_mtok: number(m.cost.cache_read),
++ source: dev.source, verified_at: dev.checked_at?.slice(0, 10),
++ } : provider.key === 'openrouter' && !router.stale && other?.cost ? { ...other.cost, source: router.source, verified_at: router.checked_at?.slice(0, 10) } : null;
++ return { hints, cost, source: m ? dev.source : other ? router.source : null, checked_at: m ? dev.checked_at : router.checked_at };
++ };
++}
++
++function createModelSync({ db, registry, env = process.env, fetchImpl = globalThis.fetch, clock = { now: Date.now }, log = console, metadata = null, routerMetadata = null }) {
++ const now = () => clock.now();
++ const source = metadata || createModelMetadata({ db, fetchImpl, now, log });
++ const routerSource = routerMetadata || createModelMetadata({ db, fetchImpl, now, log, sourceId: 'openrouter',
++ sourceUrl: pool.byKey.get('openrouter').models_endpoint,
++ index: data => Array.isArray(data.data) ? data.data.map(catalog.remoteModel) : [],
++ });
++ const active = new Map();
++ let timer = null;
++ let ticking = false;
++ 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] : []));
++
++ function run(key, { actor = 'system', trigger = 'manual' } = {}) {
++ if (stopped) throw new AiError(503, 'ai.unavailable', 'model sync is stopping');
++ if (active.has(key)) throw new AiError(409, 'provider.sync_running', 'a model sync is already running');
++ const task = (async () => {
++ const provider = await registry.getProvider(key);
++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
++ const row = await db.prepare("INSERT INTO provider_model_syncs (provider_key, trigger_kind, actor, status, started_at) VALUES (?, ?, ?, 'running', ?) RETURNING id")
++ .get(key, trigger, actor, iso());
++ try {
++ if (!provider.metadata?.models_endpoint && !['openai', 'anthropic', 'responses', 'gemini'].includes(provider.kind)) throw new AiError(422, 'provider.catalog_unsupported', 'this provider has no model list');
++ const apiKey = providerSecret(provider, env);
++ if (provider.auth_mode !== 'none' && !apiKey && !provider.metadata?.models_public) throw new AiError(409, 'provider.no_credentials', 'set a key before reading the model list');
++ 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);
++ 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';
++ await db.prepare("UPDATE provider_model_syncs SET status = 'failed', finished_at = ?, error_code = ? WHERE id = ?").run(iso(), safe, row.id);
++ throw new AiError(err.status >= 400 && err.status < 600 ? err.status : 502, safe, 'model sync failed; see the sync log');
++ }
++ })().finally(() => active.delete(key));
++ active.set(key, task);
++ return task;
++ }
++
++ async function tick() {
++ if (ticking || stopped) return;
++ ticking = true;
++ try {
++ for (const provider of await registry.listProviders()) {
++ if (stopped) break;
++ const minutes = Number(provider.metadata?.model_sync_interval_minutes || 0);
++ if (provider.status !== 'active' || ![60, 360, 1440].includes(minutes) || active.has(provider.key)) continue;
++ const last = (await read(provider.key))[0];
++ if (last && now() - Date.parse(last.started_at) < minutes * 60_000) continue;
++ await run(provider.key, { trigger: 'scheduled', actor: 'service:ai' }).catch(() => {});
++ }
++ } finally { ticking = false; }
++ }
++ return { run, read, 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()]); },
++ };
++}
++
++module.exports = { createModelSync, enrichment };
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex bfd5361..03cfa8e 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -616,6 +616,7 @@ const PROVIDERS = [
},
{
key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',
++ models_public: true,
check_endpoint: 'https://openrouter.ai/api/v1/key',
docs_url: 'https://openrouter.ai/docs/api-reference/overview',
display_name: 'OpenRouter (the last fallback)',
@@@ -648,17 +649,17 @@ const PROVIDERS = [
['chutes', 'Chutes', 'https://llm.chutes.ai/v1', 'Qwen/Qwen3-32B-TEE', 'https://www.chutes.ai/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.104, cached: null, out: 0.416 }, 40960, false],
['siliconflow', 'SiliconFlow', 'https://api.siliconflow.com/v1', 'Qwen/Qwen3-32B', 'https://docs.siliconflow.com/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, null, false],
['nebius', 'Nebius Token Factory', 'https://api.tokenfactory.nebius.com/v1', 'Qwen/Qwen3-30B-A3B-Instruct-2507', 'https://nebius.com/services/token-factory/models/qwen-models-inference', 'UNKNOWN', 'UNVERIFIED', { in: 0.10, cached: null, out: 0.30 }, 262144, false, 'https://docs.tokenfactory.nebius.com/quickstart'],
-- ['together', 'Together AI', 'https://api.together.ai/v1', 'openai/gpt-oss-120b', 'https://www.together.ai/pricing', 'UNKNOWN', 'RESTRICTED', { in: 0.15, cached: null, out: 0.60 }, 131072, false, 'https://docs.together.ai/docs/openai-api-compatibility', 'https://api.together.ai/settings/api-keys', ''],
++ ['together', 'Together AI', 'https://api.together.ai/v1', 'openai/gpt-oss-120b', 'https://www.together.ai/pricing', 'UNKNOWN', 'RESTRICTED', { in: 0.15, cached: null, out: 0.60 }, 131072, false, 'https://docs.together.ai/docs/openai-api-compatibility', 'https://api.together.ai/settings/api-keys', '', 'togetherai'],
['fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1', 'accounts/fireworks/models/gpt-oss-120b', 'https://fireworks.ai/models', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: null, out: 0.60 }, 131072, false, 'https://docs.fireworks.ai/tools-sdks/openai-compatibility', 'https://fireworks.ai/account/api-keys', ''],
['groq', 'Groq', 'https://api.groq.com/openai/v1', 'openai/gpt-oss-120b', 'https://console.groq.com/docs/model/openai/gpt-oss-120b', 'UNKNOWN', 'UNVERIFIED', { in: 0.15, cached: 0.075, out: 0.60 }, 131072, false, 'https://console.groq.com/docs/openai', 'https://console.groq.com/keys', 'openai/gpt-oss-20b'],
['cerebras', 'Cerebras', 'https://api.cerebras.ai/v1', 'gpt-oss-120b', 'https://api.cerebras.ai/public/v1/models', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false, 'https://inference-docs.cerebras.ai/api-reference/models', 'https://cloud.cerebras.ai/', ''],
['sambanova', 'SambaNova', 'https://api.sambanova.ai/v1', 'gpt-oss-120b', 'https://cloud.sambanova.ai/plans/pricing', 'UNKNOWN', 'UNVERIFIED', { in: 0.22, cached: null, out: 0.59 }, 131072, false, 'https://docs-prod.sambanova.ai/docs/api-reference/endpoints/model-list'],
['baseten', 'Baseten', 'https://inference.baseten.co/v1', 'openai/gpt-oss-120b', 'https://www.baseten.co/pricing/', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false],
['hyperbolic', 'Hyperbolic', 'https://api.hyperbolic.ai/inference/v1', 'openai/gpt-oss-120b', 'https://docs.hyperbolic.xyz/docs/hyperbolic-ai-inference-pricing', 'UNKNOWN', 'UNVERIFIED', { in: null, cached: null, out: null }, 131072, false, 'https://docs.hyperbolic.ai/inference/quickstart'],
-- ].map(([key, display_name, base_url, model_key, source, privacyLabel, provider_access, prices, context_window, uncensored, docs_url, keys_url, credential_default_model]) => {
++ ].map(([key, display_name, base_url, model_key, source, privacyLabel, provider_access, prices, context_window, uncensored, docs_url, keys_url, credential_default_model, models_dev_id]) => {
const privateTier = privacyLabel === 'PRIVATE';
return {
-- key, display_name, base_url, docs_url, keys_url, credential_default_model, default_model: model_key, timeout_ms: 60000,
++ key, display_name, base_url, docs_url, keys_url, credential_default_model, models_dev_id, default_model: model_key, timeout_ms: 60000,
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access, priority: 40, ...(uncensored ? { uncensored: true } : {}),
privacy: privacy({ label: privateTier ? 'private' : 'anonymized', terms_url: privateTier ? source : null,
@@@ -686,7 +687,7 @@ const PROVIDERS = [
docs_url: 'https://platform.claude.com/docs/en/api/overview', capabilities: ['chat', 'messages', 'vision', 'tools'] },
{ key: 'openai', keys_url: 'https://platform.openai.com/api-keys', display_name: 'OpenAI', base_url: 'https://api.openai.com/v1', default_model: 'gpt-5-mini',
docs_url: 'https://platform.openai.com/docs/api-reference/models/list', capabilities: ['chat', 'vision', 'tools', 'image', 'video', 'speech', 'transcribe', 'embed'] },
-- { key: 'gemini', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google Gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
++ { key: 'gemini', models_dev_id: 'google', keys_url: 'https://aistudio.google.com/apikey', display_name: 'Google Gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
api_family: 'google', docs_url: 'https://ai.google.dev/gemini-api/docs/openai', capabilities: ['chat', 'vision', 'tools', 'embed'] },
{ key: 'xai', keys_url: 'https://console.x.ai/', display_name: 'xAI', base_url: 'https://api.x.ai/v1',
docs_url: 'https://docs.x.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'image', 'video'] },
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex 86bb4b5..2d14448 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -30,6 +30,7 @@ function recordOf(p) {
catalog: 'pool-providers',
api_family: p.api_family, models_endpoint: p.models_endpoint,
check_endpoint: p.check_endpoint || null,
++ models_public: p.models_public === true, models_dev_id: p.models_dev_id || null,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
search_adapters: p.search_adapters || [],
data_policy: p.data_policy,
@@@ -124,7 +125,7 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
protocol.check_endpoint = null;
}
-- const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
++ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'models_public', 'models_dev_id', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
}
for (const m of p.models) {
@@@ -140,10 +141,12 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
const next = modelOf(p, m);
await registry.upsertModel({
...next,
++ display_name: row.metadata?.owner_display_name ? row.display_name : next.display_name,
status: row.status,
context_window: row.context_window ?? next.context_window,
max_output: row.max_output ?? next.max_output,
-- metadata: { ...row.metadata, ...next.metadata },
++ metadata: { ...row.metadata, ...next.metadata,
++ ...(row.metadata?.owner_content_tier ? { uncensored: row.metadata.uncensored, min_age: row.metadata.min_age } : {}) },
}, { actor });
}
}
ddiff --git a/server/registry.js b/server/registry.js
iindex 1a2e42a..5b4f233 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -147,7 +147,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
if (!input.model_key || String(input.model_key).length > 200) throw new AiError(422, 'ai.invalid', 'model_key required');
const prev = await getModel(input.provider_key, input.model_key);
const type = input.type || (prev && prev.type) || 'chat';
-- if (!['chat', 'vision', 'embedding', 'stt', 'image'].includes(type)) throw new AiError(422, 'ai.invalid', 'model type must be chat, vision, embedding, stt or image');
++ if (!['chat', 'vision', 'embedding', 'stt', 'image', 'video', 'speech', 'rerank'].includes(type)) throw new AiError(422, 'ai.invalid', 'model type must be chat, vision, embedding, stt, image, video, speech or rerank');
const status = input.status || (prev && prev.status) || 'active';
if (!['active', 'disabled'].includes(status)) throw new AiError(422, 'ai.invalid', 'model status must be active or disabled');
// A new cost replaces the old one whole: new prices never inherit the old ones' source or verification date.
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 88ec261..1d85373 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -162,7 +162,10 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
// Once the price book has checked a price it owns it: the seed does not write over it.
const owned = prev && prev.metadata && prev.metadata.price_book;
if (!prev || (!owned && prev.metadata && prev.metadata.configured_by === 'DEEPSEEK_API_KEY')) {
-- await registry.upsertModel({ provider_key: 'deepseek', ...m, status: prev ? prev.status : 'active' }, { actor: 'seed' });
++ await registry.upsertModel({ provider_key: 'deepseek', ...m, status: prev ? prev.status : 'active',
++ display_name: prev?.metadata?.owner_display_name ? prev.display_name : m.display_name,
++ metadata: { ...m.metadata, ...prev?.metadata },
++ }, { actor: 'seed' });
}
}
}
ddiff --git a/test/console-data.test.js b/test/console-data.test.js
iindex 113a406..4d1deeb 100644
---- a/test/console-data.test.js
++++ b/test/console-data.test.js
@@@ -39,7 +39,7 @@ app.use(consoleDataRouter({
assert.ok(added.filter((p) => p.key !== 'together').every((p) => p.provider_access === 'UNVERIFIED'));
assert.strictEqual(providerData.presets.find((p) => p.key === 'together').provider_access, 'RESTRICTED');
const models = await fetch(`${base}/api/v1/admin/console/models`);
-- assert.deepStrictEqual(await models.json(), { models: [] });
++ assert.deepStrictEqual(await models.json(), { models: [], provider: null, sync_log: [] });
const book = await fetch(`${base}/api/v1/admin/console/pricebook`);
assert.ok(Array.isArray((await book.json()).pending));
const write = await fetch(`${base}/api/v1/admin/console/providers/openai/key`, {
ddiff --git a/test/model-catalog.test.js b/test/model-catalog.test.js
iindex 9b0eb71..8d9624c 100644
---- a/test/model-catalog.test.js
++++ b/test/model-catalog.test.js
@@@ -81,6 +81,29 @@ t.test("Anthropic's list (paged, with limits and capabilities) and OpenRouter's
} finally { await openrouter.close(); }
});
++t.test('array lists and token pagination are complete before a sync can retire models', async () => {
++ const provider = { kind: 'openai', base_url: 'https://catalog.example/v1', auth_mode: 'bearer' };
++ const json = data => new Response(JSON.stringify(data));
++ const array = await catalog.listRemote(provider, 'test-only', { fetchImpl: async () => json([{ id: 'flux', type: 'image' }]) });
++ assert.strictEqual(catalog.describe(array[0].id, array[0].hints).type, 'image');
++ const seen = [];
++ const paged = await catalog.listRemote(provider, 'test-only', { fetchImpl: async (url, options) => {
++ assert.strictEqual(options.headers.Authorization, 'Bearer test-only');
++ seen.push(url);
++ return json(seen.length === 1 ? { models: [{ name: 'command' }], next_page_token: 'next-page' } : { models: [{ name: 'rerank-v4' }] });
++ } });
++ assert.deepStrictEqual(paged.map(m => m.id), ['command', 'rerank-v4']);
++ assert.strictEqual(new URL(seen[1]).searchParams.get('page_token'), 'next-page');
++ let writes = 0;
++ await assert.rejects(() => catalog.sync({ provider, apiKey: 'test-only', registry: { listModels() { writes++; } },
++ fetchImpl: async () => json({ models: [{ name: 'command' }], next_page_token: 'repeating' }),
++ }), e => e.code === 'provider.catalog_invalid');
++ assert.strictEqual(writes, 0, 'a partial list never begins applying changes');
++ await assert.rejects(() => catalog.listRemote({ ...provider, metadata: { models_endpoint: 'https://different.example/models' } }, 'test-only', {
++ fetchImpl: async () => { throw new Error('must not send the key'); },
++ }), e => e.code === 'provider.config');
++});
++
t.test('sync: new models arrive switched off; a known one keeps its status and prices; an unlisted one is marked', async () => {
const rdb = await testDb();
const registry = createRegistry(rdb.db, { env: {} });
ddiff --git a/test/provider-model-sync.test.js b/test/provider-model-sync.test.js
nnew file mode 100644
iindex 0000000..5c74ece
---- /dev/null
++++ b/test/provider-model-sync.test.js
@@@ -0,0 +1,192 @@
++'use strict';
++const assert = require('assert');
++const { boot, request, token, suite } = require('./helpers');
++const { enrichment } = require('../server/providers/model-sync');
++const t = suite('provider-model-sync');
++const owner = token('exchange', ['ai.provider.manage', 'ai.usage.read']);
++const denied = token('chat', ['ai.run.read']);
++const secret = 'synthetic-provider-sync-key-not-real';
++let time = Date.now();
++let listing = [{ id: 'alpha', context_length: 2048 }, { id: 'beta', pricing: { prompt: '0.000001', completion: '0.000002' } }];
++let fault = null;
++let malformed = false;
++let waitFor = null;
++let h;
++const calls = [];
++const logs = [];
++const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++async function fetchImpl(input, init = {}) {
++ const url = String(input); calls.push(url);
++ if (url !== 'https://models.example/catalog') assert.ok(!JSON.stringify(init).includes(secret), 'public metadata never receives the provider key');
++ if (url === 'https://models.dev/api.json') return json({ 'sync-test': { api: 'https://models.example/v1', models: {
++ alpha: { id: 'alpha', limit: { context: 8192, output: 1024 }, cost: { input: 0.1, output: 0.2 }, tool_call: true },
++ } } });
++ if (url === 'https://openrouter.ai/api/v1/models') return json({ data: [{ id: 'gamma', context_length: 4096, pricing: { prompt: '0.000099', completion: '0.000099' } }] });
++ if (url === 'https://models.example/catalog') {
++ assert.strictEqual(init.headers.Authorization, `Bearer ${secret}`);
++ if (waitFor) await waitFor;
++ if (fault) return json({ error: { message: secret } }, fault);
++ if (malformed) return json({ message: 'temporarily unavailable' });
++ return json({ data: listing });
++ }
++ return json({}, 404);
++}
++const api = (method, path, body, tok = owner) => request(h.base, method, path, { tok, body });
++const prefix = '/api/v1/admin/console/providers/sync-test';
++const ownCalls = () => calls.filter(u => u === 'https://models.example/catalog').length;
++
++t.test('boot and sync the registered endpoint into the existing model table, off by default', async () => {
++ h = await boot({ env: { SYNC_KEY: secret, AI_USER_MODULES: 'off' }, clock: { now: () => time }, fetchImpl,
++ log: { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) } });
++ await h.registry.upsertProvider({ key: 'sync-test', kind: 'openai', status: 'active', base_url: 'https://models.example/v1', auth_mode: 'bearer', secret_ref: 'env:SYNC_KEY', capabilities: ['chat'],
++ metadata: { models_endpoint: 'https://models.example/catalog' } });
++ const r = await api('POST', prefix + '/sync');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.added, ['alpha', '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');
++ assert.strictEqual(alpha.max_output, 1024, 'missing context fields come from the existing metadata cache');
++ assert.strictEqual(alpha.cost.in_per_mtok, 0.1);
++ assert.strictEqual(alpha.cost.source, 'https://models.dev/api.json');
++ assert.strictEqual((await h.registry.getModel('sync-test', 'beta')).cost.out_per_mtok, 2);
++ const page = await api('GET', '/api/v1/admin/console/models?provider=sync-test');
++ assert.strictEqual(page.body.models[0].provider, 'sync-test');
++ assert.strictEqual(page.body.models[0].model, 'alpha');
++ assert.strictEqual(page.body.sync_log[0].status, 'succeeded');
++ assert.ok(!page.text.includes(secret));
++});
++
++t.test('owner names, prices and tiers survive; automatic listing never enables uncensored models', async () => {
++ assert.strictEqual((await api('POST', prefix + '/model-settings', { auto_list_new_models: true, model_sync_interval_minutes: 60 })).status, 200);
++ const edit = await api('PATCH', '/api/v1/models/sync-test/alpha', { display_name: 'Owner alpha', content_tier: 'uncensored', status: 'disabled', owner_override: { reason: 'agreed', cost: { in_per_mtok: 0.4 } } });
++ assert.strictEqual(edit.status, 200, edit.text);
++ listing = [...listing, { id: 'gamma' }, { id: 'delta-uncensored' }, { id: 'video-model' }];
++ const r = await api('POST', prefix + '/sync');
++ assert.strictEqual(r.status, 200, r.text);
++ const alpha = await h.registry.getModel('sync-test', 'alpha');
++ assert.strictEqual(alpha.display_name, 'Owner alpha');
++ assert.strictEqual(alpha.status, 'disabled');
++ assert.strictEqual(alpha.metadata.min_age, 18);
++ assert.strictEqual(alpha.metadata.owner_override.cost.in_per_mtok, 0.4);
++ assert.strictEqual(alpha.cost.in_per_mtok, 0.1);
++ const gamma = await h.registry.getModel('sync-test', 'gamma');
++ assert.strictEqual(gamma.status, 'active');
++ assert.strictEqual(gamma.context_window, 4096);
++ assert.strictEqual(gamma.cost.in_per_mtok, null, 'another reseller price is not this provider cost');
++ const adult = await h.registry.getModel('sync-test', 'delta-uncensored');
++ assert.strictEqual(adult.status, 'disabled');
++ assert.strictEqual(adult.metadata.min_age, 18);
++ assert.strictEqual((await h.registry.getModel('sync-test', 'video-model')).type, 'video');
++ assert.strictEqual((await api('PATCH', '/api/v1/models/sync-test/delta-uncensored', { content_tier: 'standard' })).status, 422);
++});
++
++t.test('removed models are retained disabled; a reappearing model still needs an owner enable', async () => {
++ listing = listing.filter(m => m.id !== 'gamma');
++ assert.strictEqual((await api('POST', prefix + '/sync')).status, 200);
++ const gone = await h.registry.getModel('sync-test', 'gamma');
++ assert.strictEqual(gone.status, 'disabled');
++ assert.ok(gone.metadata.missing_since);
++ 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);
++ const back = await h.registry.getModel('sync-test', 'gamma');
++ assert.strictEqual(back.status, 'disabled');
++ assert.strictEqual(back.metadata.missing_since, undefined);
++});
++
++t.test('provider failures are logged without upstream text and do not retire models', async () => {
++ fault = 401;
++ const r = await api('POST', prefix + '/sync');
++ assert.strictEqual(r.status, 401, r.text);
++ const log = await api('GET', '/api/v1/admin/console/sync');
++ assert.strictEqual(log.body.sync_log[0].error_code, 'provider.auth');
++ assert.ok(!log.text.includes(secret) && !logs.join('\n').includes(secret));
++ assert.strictEqual((await h.registry.getModel('sync-test', 'gamma')).metadata.listed, true);
++ fault = null;
++});
++
++t.test('schedules are due-based, skip disabled providers and reuse cached public metadata', async () => {
++ const before = ownCalls();
++ time += 60 * 60_000;
++ await h.modelSync.tick();
++ assert.strictEqual(ownCalls(), before + 1);
++ await h.modelSync.tick();
++ assert.strictEqual(ownCalls(), before + 1);
++ assert.strictEqual(calls.filter(u => u === 'https://models.dev/api.json').length, 1);
++ assert.strictEqual(calls.filter(u => u === 'https://openrouter.ai/api/v1/models').length, 1);
++ await h.registry.upsertProvider({ key: 'sync-test', status: 'disabled' });
++ time += 60 * 60_000;
++ await h.modelSync.tick();
++ assert.strictEqual(ownCalls(), before + 1);
++});
++
++t.test('a malformed successful response never marks every model gone', async () => {
++ malformed = true;
++ const r = await api('POST', prefix + '/sync');
++ assert.strictEqual(r.status, 502);
++ assert.strictEqual(r.body.code, 'provider.catalog_invalid');
++ assert.strictEqual((await h.registry.getModel('sync-test', 'gamma')).metadata.listed, true);
++ malformed = false;
++});
++
++t.test('concurrent syncs of the same provider cannot overlap', async () => {
++ let release;
++ waitFor = new Promise(resolve => { release = resolve; });
++ const first = h.modelSync.run('sync-test');
++ assert.throws(() => h.modelSync.run('sync-test'), err => err.code === 'provider.sync_running');
++ release(); await first; waitFor = null;
++});
++
++t.test('sync and preferences require the existing provider-manage gate', async () => {
++ for (const suffix of ['/sync', '/model-settings']) assert.strictEqual((await api('POST', prefix + suffix, {}, denied)).status, 403);
++ assert.strictEqual((await api('POST', prefix + '/model-settings', { auto_list_new_models: true, model_sync_interval_minutes: 1 })).status, 422);
++});
++
++t.test('boot-time provider seeds preserve owner names and content tiers too', async () => {
++ const { seed } = require('../server/workflows/seed');
++ const options = { registry: h.registry, quotas: h.quotas, config: h.config, env: h.env, db: h.db };
++ await h.registry.upsertProvider({ key: 'deepseek', status: 'active' }, { origin: 'admin' });
++ await seed(options);
++ const qwen = require('../server/providers/pool-providers').byKey.get('qwen').models[0].model_key;
++ for (const [provider, model] of [['deepseek', 'deepseek-flash'], ['qwen', qwen]]) {
++ const r = await api('PATCH', `/api/v1/models/${provider}/${encodeURIComponent(model)}`, { display_name: 'Owner label', content_tier: 'uncensored', owner_override: { reason: 'owner rate', cost: { in_per_mtok: 2 } } });
++ assert.strictEqual(r.status, 200, r.text);
++ }
++ await seed(options);
++ for (const [provider, model] of [['deepseek', 'deepseek-flash'], ['qwen', qwen]]) {
++ const row = await h.registry.getModel(provider, model);
++ assert.strictEqual(row.display_name, 'Owner label');
++ assert.strictEqual(row.metadata.uncensored, true);
++ assert.strictEqual(row.metadata.min_age, 18);
++ assert.strictEqual(row.metadata.owner_override.cost.in_per_mtok, 2);
++ }
++});
++
++t.test('pinned routes skip disabled and gone models and keep the reason on the receipt', async () => {
++ for (const key of ['sync-first', 'sync-next']) {
++ await h.registry.upsertProvider({ key, kind: 'stub', status: 'active', auth_mode: 'none', capabilities: ['chat'], default_model: 'm' });
++ await h.registry.upsertModel({ provider_key: key, model_key: 'm', status: 'active' });
++ }
++ const route = { key: 'sync-fallback', primary: { provider: 'sync-first', model: 'm' }, fallbacks: [{ provider: 'sync-next', model: 'm' }] };
++ const run = () => h.pool.execute(route, 'chat', { messages: [{ role: 'user', content: 'hello' }], maxTokens: 20 });
++ const first = await run();
++ assert.strictEqual(first.provider, 'sync-first');
++ await h.registry.upsertModel({ provider_key: 'sync-first', model_key: 'm', status: 'disabled' });
++ const disabled = await run();
++ assert.strictEqual(disabled.provider, 'sync-next');
++ assert.ok(disabled.explain.attempts.some(a => a.provider === 'sync-first' && a.error === 'model_disabled'));
++ await h.registry.upsertModel({ provider_key: 'sync-first', model_key: 'm', status: 'active', metadata: { missing_since: new Date(time).toISOString() } });
++ const gone = await run();
++ assert.strictEqual(gone.provider, 'sync-next');
++ assert.ok(gone.explain.attempts.some(a => a.provider === 'sync-first' && a.error === 'model_gone'));
++});
++
++t.test('metadata prices use a matching provider, preserve zero and refuse stale price data', () => {
++ const dev = { entries: [{ provider: 'other', id: 'm', model: { cost: { input: 99 } } }, { provider: 'p', id: 'm', model: { cost: { input: 0 } } }], source: 'https://models.dev/api.json', checked_at: new Date(time).toISOString(), stale: false };
++ const router = { entries: [], stale: true };
++ assert.strictEqual(enrichment({ key: 'p' }, dev, router)('m').cost.in_per_mtok, 0);
++ assert.strictEqual(enrichment({ key: 'p' }, { ...dev, stale: true }, router)('m').cost, null);
++});
++
++t.run(() => h && h.stop());