openvibers/openvibe.ai Commits
Sync registry provider models with owner controls (#437)
Changes
@@@ -67,3 +67,30 @@ latency and check time are stored; no upstream response text is logged or returnendpoint 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.nnew file mode 100644@@@ -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);@@@ -117,6 +117,20 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, autconst 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) {@@@ -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 || {};@@@ -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).@@@ -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; } };@@@ -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) {@@@ -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 = globalThisasync 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 };@@@ -1095,6 +1095,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noasync 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) });nnew file mode 100644@@@ -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 };@@@ -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'] },@@@ -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.envprotocol.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.envconst 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 });}}@@@ -147,7 +147,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.eif (!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.@@@ -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' });}}}@@@ -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`, {@@@ -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: {} });nnew file mode 100644@@@ -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());
@@ -67,3 +67,30 @@ latency and check time are stored; no upstream response text is logged or return
@@ -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.
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.
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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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);
@@ -117,6 +117,20 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, aut
@@ -117,6 +117,20 @@ function adminRouter({ config = {}, db, registry, pool, quotas, cache, runs, aut
const b = body(req);
const b = body(req);
const prev = await registry.getModel(req.params.provider, req.params.model);
const prev = await registry.getModel(req.params.provider, req.params.model);
const metadata = { ...(prev.metadata || {}), ...(b.metadata || {}) };
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) {
if (b.owner_override !== undefined) {
const override = b.owner_override;
const override = b.owner_override;
if (override === null) {
if (override === null) {
@@ -11,7 +11,7 @@ const gpuCatalog = require('../gpu/catalog');
@@ -11,7 +11,7 @@ const gpuCatalog = require('../gpu/catalog');
const { tokenCosts } = require('../gpu/token-cost');
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. */
/** 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 r = express.Router();
const supply = require('../platform/supply').createSupply({ db, registry, clock });
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:
// 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,
@@ -25,6 +25,9 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
const read = auth.requireCap(CAPS.usageRead);
const read = auth.requireCap(CAPS.usageRead);
const write = auth.requireCap(CAPS.providerManage);
const write = auth.requireCap(CAPS.providerManage);
const workflowWrite = auth.requireCap(CAPS.workflowManage);
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 actor = (req) => {
const subject = String(req.get('x-ov-subject') || '');
const subject = String(req.get('x-ov-subject') || '');
return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) ? subject : req.principal.sub;
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,
@@ -59,11 +62,15 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
}))) : [];
}))) : [];
const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
const cookies = sessionCookies ? await sessionCookies.list('operator', 'owner') : [];
const balances = sessionCookies ? await Promise.all(cookies.map((c) => sessionCookies.balance('operator', 'owner', c.id))) : [];
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') {
if (section === 'runs') {
const { filters, form, bad } = q.parseRunFilters(req.query);
const { filters, form, bad } = q.parseRunFilters(req.query);
const list = await q.listRuns(db, filters);
const list = await q.listRuns(db, filters);
@@ -139,6 +146,19 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
@@ -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 });
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 };
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) => {
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');
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
const b = req.body || {};
const b = req.body || {};
@@ -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, 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();
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') });
@@ -275,7 +275,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -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.
// 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 }));
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(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;
// 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.
// 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).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
@@ -16,7 +16,7 @@ function indexMetadata(data) {
@@ -16,7 +16,7 @@ function indexMetadata(data) {
// A reseller's name is not the maker. Prefer explicit canonical identity, then a
// A reseller's name is not the maker. Prefer explicit canonical identity, then a
// first-party record; unprefixed reseller-only records leave maker unknown.
// first-party record; unprefixed reseller-only records leave maker unknown.
const maker = m.maker || (canonical.includes('/') ? canonical.split('/')[0] : null);
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;
return entries;
@@ -41,31 +41,31 @@ function lookupMetadata(entries, id, provider) {
@@ -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;
let snapshot = null; let loaded = false; let retryAt = 0; let pending = null;
async function refresh() {
async function refresh() {
if (!loaded) {
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;
loaded = true;
}
}
const expired = !snapshot || now() - Date.parse(snapshot.checked_at) >= DAY_MS;
const expired = !snapshot || now() - Date.parse(snapshot.checked_at) >= DAY_MS;
if (expired && enabled && now() >= retryAt) {
if (expired && enabled && now() >= retryAt) {
retryAt = now() + 60_000;
retryAt = now() + 60_000;
try {
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');
if (!response.ok) throw new Error('metadata HTTP failure');
const raw = await response.text();
const raw = await response.text();
if (raw.length > 32 * 1024 * 1024) throw new Error('metadata exceeds size limit');
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');
if (!entries.length) throw new Error('metadata has no models');
const checked_at = new Date(now()).toISOString();
const checked_at = new Date(now()).toISOString();
await db.prepare(`INSERT INTO exchange_model_metadata (id, payload, checked_at) VALUES (?, ?::jsonb, ?)
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 };
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 };
stale: !snapshot || now() - Date.parse(snapshot.checked_at) >= DAY_MS };
}
}
return { read() { if (!pending) pending = refresh().finally(() => { pending = null; }); return pending; } };
return { read() { if (!pending) pending = refresh().finally(() => { pending = null; }); return pending; } };
@@ -202,6 +202,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -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
// 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.
// 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 });
// 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 });
@@ -220,7 +221,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -220,7 +221,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, 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 jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -275,6 +276,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -275,6 +276,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
if (listen && config.nodeEnv !== 'test' && memorySharing) memorySharing.start();
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.
// 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) 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.
@@ -325,6 +327,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -325,6 +327,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
memoryJobs.stop();
memoryJobs.stop();
if (memorySharing) memorySharing.stop();
if (memorySharing) memorySharing.stop();
priceBook.stop();
priceBook.stop();
+ await modelSync.stop();
await measuredMetrics.stop();
await measuredMetrics.stop();
await observations.stop();
await observations.stop();
gpuMarket.stop();
gpuMarket.stop();
@@ -347,7 +350,7 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -347,7 +350,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, 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) {
if (require.main === module) {
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
* one on: routing would otherwise change what OpenVibe spends), a known model keeps its
* 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.
* 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
* 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.
* modalities and context) wins over them.
*/
*/
@@ -22,6 +22,8 @@ const { ProviderError, deadline, describeAbort } = require('./common');
@@ -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.
// Each family: the id it matches, then what it is. First match wins, so the narrower come first.
const FAMILIES = [
const FAMILIES = [
+ [/rerank/i, { type: 'rerank', tier: 'fast' }],
+ [/tts|text-to-speech|speech-/i, { type: 'speech', tier: 'fast' }],
[/embed/i, { type: 'embedding', tier: 'fast' }],
[/embed/i, { type: 'embedding', tier: 'fast' }],
[/whisper|transcri|(^|[-_])stt([-_]|$)/i, { type: 'stt', 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' }],
[/^(gpt-image|dall-e|imagen)|image-(preview|generation)|-image$|flash-image/i, { type: 'image', tier: 'balanced' }],
@@ -64,6 +66,7 @@ function describe(id, hints = {}) {
@@ -64,6 +66,7 @@ function describe(id, hints = {}) {
if (hints.vision != null) out.vision = !!hints.vision;
if (hints.vision != null) out.vision = !!hints.vision;
if (hints.tools != null) out.tools = !!hints.tools;
if (hints.tools != null) out.tools = !!hints.tools;
if (hints.reasoning != null) out.reasoning = !!hints.reasoning;
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.contextWindow) > 0) out.contextWindow = Math.round(Number(hints.contextWindow));
if (Number(hints.maxOutput) > 0) out.maxOutput = Math.round(Number(hints.maxOutput));
if (Number(hints.maxOutput) > 0) out.maxOutput = Math.round(Number(hints.maxOutput));
return out;
return out;
@@ -71,7 +74,7 @@ function describe(id, hints = {}) {
@@ -71,7 +74,7 @@ function describe(id, hints = {}) {
async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis.fetch } = {}) {
async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis.fetch } = {}) {
let res;
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' });
throw describeAbort(err, null, timeoutMs) || new ProviderError(`connection failed: ${err.message}`, { code: 'provider.unreachable' });
}
}
const text = await res.text().catch(() => '');
const text = await res.text().catch(() => '');
@@ -88,73 +91,112 @@ async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis
@@ -88,73 +91,112 @@ async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis
async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {}) {
async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {}) {
const base = String(provider.base_url || '').replace(/\/+$/, '');
const base = String(provider.base_url || '').replace(/\/+$/, '');
if (!base) throw new ProviderError('the provider has no base URL', { code: 'provider.config' });
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') {
if (provider.kind === 'anthropic') {
// Anthropic's Models API pages by after_id; its Anthropic-format look-alikes (DeepSeek, MiniMax) have no list.
// 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 = [];
const out = [];
let after = null;
let after = null;
for (let page = 0; page < 10; page++) {
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 || []) {
for (const m of j.data || []) {
const caps = m.capabilities || {};
const caps = m.capabilities || {};
const has = (k) => (caps[k] && typeof caps[k] === 'object' ? caps[k].supported !== false : caps[k] == null ? null : !!caps[k]);
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') } });
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;
after = j.last_id;
}
}
return out;
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. */
/** 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 remote = await listRemote(provider, apiKey, { fetchImpl });
const known = new Map((await registry.listModels({ provider: provider.key })).map((m) => [m.model_key, m]));
const known = new Map((await registry.listModels({ provider: provider.key })).map((m) => [m.model_key, m]));
const seen = new Set();
const seen = new Set();
const result = { added: [], updated: [], missing: [], skipped: [] };
const result = { added: [], updated: [], missing: [], skipped: [] };
const at = new Date(now()).toISOString();
const at = new Date(now()).toISOString();
for (const m of remote) {
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);
seen.add(m.id);
const prev = known.get(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;
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({
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).
// 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,
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 });
}, { actor });
(prev ? result.updated : result.added).push(m.id);
(prev ? result.updated : result.added).push(m.id);
}
}
for (const [id, prev] of known) {
for (const [id, prev] of known) {
if (seen.has(id) || (prev.metadata && prev.metadata.missing_since)) continue;
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
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);
result.missing.push(id);
}
}
return result;
return result;
}
}
-module.exports = { describe, listRemote, sync, FAMILIES, TIERS };
+module.exports = { describe, listRemote, remoteModel, sync, FAMILIES, TIERS };
@@ -1095,6 +1095,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1095,6 +1095,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
async function skipReason(p, a, features, model = undefined) {
async function skipReason(p, a, features, model = undefined) {
if (!p || !a) return 'unknown_provider';
if (!p || !a) return 'unknown_provider';
if (p.status !== 'active') return 'disabled';
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 (p.auth_mode !== 'none' && !credentialNotRequired(p) && !providerSecret(p, env)) return 'no_credentials';
if (features.some(f => !a.supports(f))) return 'unsupported';
if (features.some(f => !a.supports(f))) return 'unsupported';
const h = await health(p.key, model, { stamp: stampFor(p) });
const h = await health(p.key, model, { stamp: stampFor(p) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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 };
@@ -616,6 +616,7 @@ const PROVIDERS = [
@@ -616,6 +616,7 @@ const PROVIDERS = [
},
},
{
{
key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',
key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',
+ models_public: true,
check_endpoint: 'https://openrouter.ai/api/v1/key',
check_endpoint: 'https://openrouter.ai/api/v1/key',
docs_url: 'https://openrouter.ai/docs/api-reference/overview',
docs_url: 'https://openrouter.ai/docs/api-reference/overview',
display_name: 'OpenRouter (the last fallback)',
display_name: 'OpenRouter (the last fallback)',
@@ -648,17 +649,17 @@ const PROVIDERS = [
@@ -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],
['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],
['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'],
['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', ''],
['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'],
['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/', ''],
['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'],
['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],
['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'],
['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';
const privateTier = privacyLabel === 'PRIVATE';
return {
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'],
capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'tools'],
provider_access, priority: 40, ...(uncensored ? { uncensored: true } : {}),
provider_access, priority: 40, ...(uncensored ? { uncensored: true } : {}),
privacy: privacy({ label: privateTier ? 'private' : 'anonymized', terms_url: privateTier ? source : null,
privacy: privacy({ label: privateTier ? 'private' : 'anonymized', terms_url: privateTier ? source : null,
@@ -686,7 +687,7 @@ const PROVIDERS = [
@@ -686,7 +687,7 @@ const PROVIDERS = [
docs_url: 'https://platform.claude.com/docs/en/api/overview', capabilities: ['chat', 'messages', 'vision', 'tools'] },
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',
{ 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'] },
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'] },
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',
{ 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'] },
docs_url: 'https://docs.x.ai/docs/api-reference', capabilities: ['chat', 'vision', 'tools', 'image', 'video'] },
@@ -30,6 +30,7 @@ function recordOf(p) {
@@ -30,6 +30,7 @@ function recordOf(p) {
catalog: 'pool-providers',
catalog: 'pool-providers',
api_family: p.api_family, models_endpoint: p.models_endpoint,
api_family: p.api_family, models_endpoint: p.models_endpoint,
check_endpoint: p.check_endpoint || null,
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,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
search_adapters: p.search_adapters || [],
search_adapters: p.search_adapters || [],
data_policy: p.data_policy,
data_policy: p.data_policy,
@@ -124,7 +125,7 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -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.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
protocol.check_endpoint = 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 });
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) {
for (const m of p.models) {
@@ -140,10 +141,12 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
@@ -140,10 +141,12 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
const next = modelOf(p, m);
const next = modelOf(p, m);
await registry.upsertModel({
await registry.upsertModel({
...next,
...next,
+ display_name: row.metadata?.owner_display_name ? row.display_name : next.display_name,
status: row.status,
status: row.status,
context_window: row.context_window ?? next.context_window,
context_window: row.context_window ?? next.context_window,
max_output: row.max_output ?? next.max_output,
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 });
}, { actor });
}
}
}
}
@@ -147,7 +147,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -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');
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 prev = await getModel(input.provider_key, input.model_key);
const type = input.type || (prev && prev.type) || 'chat';
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';
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');
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.
// A new cost replaces the old one whole: new prices never inherit the old ones' source or verification date.
@@ -162,7 +162,10 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -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.
// 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;
const owned = prev && prev.metadata && prev.metadata.price_book;
if (!prev || (!owned && prev.metadata && prev.metadata.configured_by === 'DEEPSEEK_API_KEY')) {
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' });
}
}
}
}
}
}
@@ -39,7 +39,7 @@ app.use(consoleDataRouter({
@@ -39,7 +39,7 @@ app.use(consoleDataRouter({
assert.ok(added.filter((p) => p.key !== 'together').every((p) => p.provider_access === 'UNVERIFIED'));
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');
assert.strictEqual(providerData.presets.find((p) => p.key === 'together').provider_access, 'RESTRICTED');
const models = await fetch(`${base}/api/v1/admin/console/models`);
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`);
const book = await fetch(`${base}/api/v1/admin/console/pricebook`);
assert.ok(Array.isArray((await book.json()).pending));
assert.ok(Array.isArray((await book.json()).pending));
const write = await fetch(`${base}/api/v1/admin/console/providers/openai/key`, {
const write = await fetch(`${base}/api/v1/admin/console/providers/openai/key`, {
@@ -81,6 +81,29 @@ t.test("Anthropic's list (paged, with limits and capabilities) and OpenRouter's
@@ -81,6 +81,29 @@ t.test("Anthropic's list (paged, with limits and capabilities) and OpenRouter's
} finally { await openrouter.close(); }
} 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 () => {
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 rdb = await testDb();
const registry = createRegistry(rdb.db, { env: {} });
const registry = createRegistry(rdb.db, { env: {} });
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -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());