openvibers/openvibe.ai Commits
The model catalogue keeps itself current: every provider's own model list, described (L33) (#221)
Changes
@@@ -46,6 +46,7 @@ const { staff: staffMap } = require('openvibe-contracts');const { createSessions, sameString, random } = require('./session');const sso = require('./sso');const q = require('./queries');++const catalog = require('../providers/catalog');const { CSP, esc, pages } = require('./views');/** Network staff capability -> AI capability held by a console session (documented in README "Operator console"). */@@@ -88,6 +89,8 @@ const NOTICES = {provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',provider_key: 'Key replaced. Runs use it from now on.',model_added: 'Model registered.',++ models_synced: 'Model list read. New models are listed switched off: turn on the ones routes may use.',++ model_status: 'Model switched.',route_used: 'Route changed: new runs use the provider and model you chose (the previous one is kept as a fallback).',route_default: 'Route back to its default.',circuit_reset: 'Circuit reset: the provider will be tried again.',@@@ -343,6 +346,9 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, eauth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },}, { ...who(req), origin: 'admin' });++ // Its model list, read right away (best effort: a provider whose list cannot be read is still added).++ const added = await registry.getProvider(key);++ await catalog.sync({ registry, provider: added, apiKey: resolveSecret(added.secret_ref, env), fetchImpl, actor: who(req).actor }).catch(() => null);res.redirect(303, '/console/providers?done=provider_added');}));r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {@@@ -355,6 +361,24 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, eawait registry.audit(who(req).actor, 'provider.key_replaced', 'provider', p.key, { trace: who(req).trace });res.redirect(303, '/console/providers?done=provider_key');}));++ // Read the provider's own model list into the catalogue (L33): new models land switched off, prices stay a person's.++ r.post('/console/providers/:key/sync', needs(CAPS.providerManage), wrap(async (req, res) => {++ const p = await registry.getProvider(req.params.key);++ if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');++ const apiKey = resolveSecret(p.secret_ref, env);++ if (!apiKey && p.auth_mode !== 'none') throw new AiError(422, 'ai.invalid', `${p.key} has no key to read its model list with`);++ const out = await catalog.sync({ registry, provider: p, apiKey, fetchImpl, actor: who(req).actor });++ await registry.audit(who(req).actor, 'provider.models_synced', 'provider', p.key, { trace: who(req).trace, metadata: { added: out.added.length, updated: out.updated.length, missing: out.missing.length } });++ res.redirect(303, '/console/providers?done=models_synced');++ }));++ // Turn one model on or off (a model routes may use, or not).++ r.post('/console/models/:provider/:model/status', needs(CAPS.providerManage), wrap(async (req, res) => {++ const m = await registry.getModel(req.params.provider, req.params.model);++ if (!m) throw new AiError(404, 'ai.not_found', 'no such model');++ const status = (req.body || {}).status === 'active' ? 'active' : 'disabled';++ await registry.upsertModel({ provider_key: m.provider_key, model_key: m.model_key, status }, who(req));++ res.redirect(303, '/console/providers?done=model_status');++ }));r.post('/console/providers/:key/models', needs(CAPS.providerManage), wrap(async (req, res) => {const p = await registry.getProvider(req.params.key);if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');@@@ -212,6 +212,7 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,return h && String(p.secret_ref || '') === `vault:${p.key}` ? html`<br><span class="muted">key ${h.hint}, set ${when(h.updated_at)}</span>` : '';};const manageRow = (p) => (canManage && p.kind !== 'stub' && p.kind !== 'whisper' ? html`<tr class="sub"><td colspan="10"><details><summary>Key and models for <code>${p.key}</code></summary>++<form class="inline" method="post" action="/console/providers/${enc(p.key)}/sync">${csrfField(csrf)}<button type="submit">Read its model list</button> <span class="muted">every model it offers, described (what it does, how strong); new ones arrive switched off</span></form><form class="inline" method="post" action="/console/providers/${enc(p.key)}/key">${csrfField(csrf)}<label>New API key <input type="password" name="api_key" required minlength="8" autocomplete="off" spellcheck="false"></label><button type="submit">Replace key</button></form><form class="inline" method="post" action="/console/providers/${enc(p.key)}/models">${csrfField(csrf)}<label>Model <input type="text" name="model_key" required maxlength="200" placeholder="deepseek-flash" spellcheck="false"></label><label><input type="checkbox" name="vision" value="1"> reads pictures</label> <label><input type="checkbox" name="tools" value="1"> tools</label>@@@ -231,8 +232,10 @@ ${canManage ? html`<td><form class="inline" method="post" action="/console/provi${p.health.state !== 'closed' || p.health.consecutive_failures ? html` <form class="inline" method="post" action="/console/providers/${enc(p.key)}/reset">${csrfField(csrf)}<button type="submit">Reset circuit<span class="sr-only"> of ${p.key}</span></button></form>` : ''}</td>` : ''}</tr>${manageRow(p)}`), 'no providers.')}${canManage ? '' : html`<p class="muted">Enabling, disabling and circuit resets need <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}--${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Context / max output', 'Cost per Mtok (in / out / cached)', 'Supports'], models.map((m) => html`<tr>--<td><code>${m.provider_key}</code></td><td><code>${m.model_key}</code></td><td>${m.type}</td><td>${badge(m.status)}</td>++${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Strength', 'Context / max output', 'Cost per Mtok (in / out / cached)', 'Supports'], models.map((m) => html`<tr>++<td><code>${m.provider_key}</code></td><td><code>${m.model_key}</code>${m.display_name && m.display_name !== m.model_key ? html`<br><span class="muted">${m.display_name}</span>` : ''}</td><td>${m.type}</td>++<td>${badge(m.status)}${m.metadata && m.metadata.missing_since ? html`<br><span class="muted">no longer listed since ${when(m.metadata.missing_since)}</span>` : ''}${canManage ? html`<form class="inline" method="post" action="/console/models/${enc(m.provider_key)}/${enc(m.model_key)}/status">${csrfField(csrf)}<input type="hidden" name="status" value="${m.status === 'active' ? 'disabled' : 'active'}"><button type="submit">${m.status === 'active' ? 'Turn off' : 'Turn on'}<span class="sr-only"> ${m.model_key}</span></button></form>` : ''}</td>++<td>${m.metadata && m.metadata.tier ? html`${m.metadata.tier}${m.metadata.reasoning ? ', thinks first' : ''}${m.metadata.note ? html`<br><span class="muted">${m.metadata.note}</span>` : ''}` : '—'}</td><td>${m.context_window ? n(m.context_window) : '—'} / ${m.max_output ? n(m.max_output) : '—'}</td><td>${m.cost.in_per_mtok != null ? usd(m.cost.in_per_mtok) : '—'} / ${m.cost.out_per_mtok != null ? usd(m.cost.out_per_mtok) : '—'} / ${m.cost.cached_per_mtok != null ? usd(m.cost.cached_per_mtok) : '—'}</td><td>${Object.entries(m.supports).filter(([, v]) => v).map(([k]) => k).join(', ') || '—'}</td></tr>`), 'no models registered (providers use their default model).')}`,nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++/**++ * The model catalogue keeps itself current (plan L33 P1; the owner, 2026-10-31: "support Anthropic… Gemini… all the++ * DeepSeek, the mainstream ones… and make the whole thing smarter"). A provider's own model list is read with its key++ * and each model is described: what it is (chat, vision, embedding, speech to text, image), how strong (fast, balanced,++ * strong, frontier), whether it sees images, calls tools or thinks first, and its context window and output cap.++ *++ * describe(id, hints) → { type, tier, vision, tools, reasoning, contextWindow, maxOutput, note }++ * listRemote(provider, key) → [{ id, name, hints }] from the provider's model list:++ * Anthropic GET /v1/models (max_input_tokens, max_tokens, capabilities);++ * OpenAI-compatible GET /models (OpenRouter adds context_length, modalities and++ * supported parameters; Gemini names its models models/…)++ * sync({ registry, provider, apiKey }) → { added, updated, missing }: new models land switched off (a person turns++ * 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).++ * 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.++ */++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 = [++ [/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' }],++ [/^veo|^sora|video/i, { type: 'video', tier: 'strong' }],++ [/^claude-(fable|mythos)/i, { tier: 'frontier', vision: true, tools: true, reasoning: true, note: "Anthropic's most capable: the hardest, longest work" }],++ [/^claude-opus/i, { tier: 'frontier', vision: true, tools: true, reasoning: true, note: 'hard changes, design, long agentic work' }],++ [/^claude-sonnet/i, { tier: 'strong', vision: true, tools: true, reasoning: true, note: 'fast and capable: most tasks' }],++ [/^claude-haiku/i, { tier: 'fast', vision: true, tools: true, note: 'the quickest Claude: small jobs and high volume' }],++ [/^claude-/i, { tier: 'balanced', vision: true, tools: true }],++ [/gemini-.*flash-lite/i, { tier: 'fast', vision: true, tools: true, note: 'the cheapest Gemini' }],++ [/gemini-.*flash/i, { tier: 'balanced', vision: true, tools: true, reasoning: true, note: 'quick, long context, sees images' }],++ [/gemini-.*pro/i, { tier: 'strong', vision: true, tools: true, reasoning: true, note: 'long context, sees images, thinks first' }],++ [/^gemma/i, { tier: 'fast' }],++ [/deepseek-reasoner|deepseek-r\d/i, { tier: 'strong', tools: true, reasoning: true, note: 'thinks first: harder problems, very cheap' }],++ [/deepseek/i, { tier: 'balanced', tools: true, note: 'quick and very cheap' }],++ [/^(gpt-[45][.\d]*-?(mini|nano)|o\d-mini)/i, { tier: 'fast', vision: true, tools: true }],++ [/^o\d/i, { tier: 'strong', tools: true, reasoning: true }],++ [/^gpt-5/i, { tier: 'strong', vision: true, tools: true, reasoning: true }],++ [/^gpt-4/i, { tier: 'balanced', vision: true, tools: true }],++ [/^grok-.*mini|grok-.*fast/i, { tier: 'fast', tools: true }],++ [/^grok/i, { tier: 'strong', vision: true, tools: true, reasoning: true }],++ [/magistral|mistral-large|mistral-medium/i, { tier: 'strong', tools: true }],++ [/ministral|mistral-small|mistral-tiny|open-mistral/i, { tier: 'fast', tools: true }],++ [/codestral|devstral/i, { tier: 'balanced', tools: true, note: 'code' }],++ [/reason|thinking/i, { tier: 'strong', tools: true, reasoning: true, note: 'thinks first' }], // kimi-k2-thinking, …-reasoner++ [/minimax/i, { tier: 'balanced', tools: true }],++ [/llama|qwen|kimi|glm|nemotron|phi-?\d/i, { tier: 'balanced', tools: true }],++];++const TIERS = ['fast', 'balanced', 'strong', 'frontier'];++++/** What a model id means, with whatever its provider's list said (hints) taking precedence. */++function describe(id, hints = {}) {++ const bare = String(id || '').replace(/^models\//, ''); // Gemini names its models models/…++ const leaf = bare.includes('/') ? bare.slice(bare.lastIndexOf('/') + 1) : bare; // OpenRouter: provider/model++ const fam = (FAMILIES.find(([re]) => re.test(leaf)) || [null, {}])[1];++ const out = {++ type: fam.type || 'chat', tier: fam.tier || 'balanced', vision: !!fam.vision, tools: !!fam.tools, reasoning: !!fam.reasoning,++ contextWindow: null, maxOutput: null, note: fam.note || null,++ };++ 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 (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;++}++++async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis.fetch } = {}) {++ let res;++ try { res = await fetchImpl(url, { headers, 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(() => '');++ let j = {};++ try { j = text ? JSON.parse(text) : {}; } catch { j = {}; }++ if (!res.ok) {++ const msg = (j.error && (j.error.message || j.error)) || `HTTP ${res.status}`;++ throw new ProviderError(typeof msg === 'string' ? msg : JSON.stringify(msg), { status: res.status, code: res.status === 401 || res.status === 403 ? 'provider.auth' : 'provider.http' });++ }++ return j;++}++++/** A provider's own model list, read with its key → [{ id, name, hints }]. */++async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {}) {++ const base = String(provider.base_url || '').replace(/\/+$/, '');++ if (!base) throw new ProviderError('the provider has no base URL', { code: 'provider.config' });++ 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 [];++ 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 });++ 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;++ 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 MODEL_TYPES = ['chat', 'vision', 'embedding', 'stt', 'image'];++++/** 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() }) {++ 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++ 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 };++ delete metadata.missing_since;++ await registry.upsertModel({++ provider_key: provider.key, model_key: m.id, 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',++ 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,++ }, { 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 });++ result.missing.push(id);++ }++ return result;++}++++module.exports = { describe, listRemote, sync, FAMILIES, TIERS };@@@ -63,6 +63,8 @@ async function fakeFetch(url, opts = {}) {return json(200, { access_token: userToken(c), refresh_token: `rt_${crypto.randomBytes(6).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });}if (u === `${NETWORK}/oauth/revoke`) { revoked.push(JSON.parse(String(opts.body)).token); return json(200, { revoked: true }); }++ // The added provider's own model list (L33: read when it is added, and again from its row).++ if (u === 'https://api.cheap.test/v1/models') return json(200, { data: [{ id: 'cheap-1' }, { id: 'cheap-pro-reasoner' }] });return globalThis.fetch(url, opts);}@@@ -400,6 +402,19 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, reassert.strictEqual(x.status, 303, x.text);const m = await h.registry.getModel('cheapapi', 'cheap-2');assert.deepStrictEqual([m.cost.in_per_mtok, m.cost.out_per_mtok, m.supports.tools], [0.1, 0.4, true]);++ // Its model list was read when it was added: listed, described, switched off until a person turns one on (L33).++ const listed = await h.registry.getModel('cheapapi', 'cheap-pro-reasoner');++ assert.ok(listed && listed.status === 'disabled' && listed.metadata.tier === 'strong' && listed.metadata.reasoning, 'read on add, described, off');++ x = await get('/console/providers', cookies.owner);++ assert.ok(x.text.includes('action="/console/providers/cheapapi/sync"') && x.text.includes('action="/console/models/cheapapi/cheap-pro-reasoner/status"'), 'the page offers the list and the switch');++ x = await post('/console/providers/cheapapi/sync', cookies.owner, { _csrf: csrf.owner });++ assert.strictEqual(x.status, 303, x.text);++ assert.match(x.headers.get('location'), /done=models_synced$/);++ assert.strictEqual((await auditRows("action = 'provider.models_synced' AND target_id = 'cheapapi' AND actor = ?", people.owner)).length, 1);++ x = await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.owner, { status: 'active', _csrf: csrf.owner });++ assert.strictEqual(x.status, 303);++ assert.strictEqual((await h.registry.getModel('cheapapi', 'cheap-pro-reasoner')).status, 'active', 'turned on');++ assert.strictEqual((await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.admin, { status: 'disabled', _csrf: csrf.admin })).status, 403, 'only the owner switches models');// Point default.json at it: a new version, the previous primary kept as the first fallback; then back to the default.const before = await h.registry.getRoute('default.json');x = await get('/console/routes/default.json', cookies.owner);nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++// The model catalogue keeps itself current (plan L33 P1): a provider's own model list, read with its key, becomes++// described models — what each is, how strong, whether it sees, uses tools or thinks first, its context — that arrive++// switched off, keep a person's prices and status when read again, and are marked (never deleted) when the provider++// stops listing them. Fake Anthropic (Models API, paged) and OpenRouter (/models with context and modalities) servers.++const assert = require('assert');++const http = require('http');++const { suite } = require('./helpers');++const { createRegistry } = require('../server/registry');++const catalog = require('../server/providers/catalog');++const { testDb } = require('./db');++++const t = suite('model-catalog');++++function server(handler) {++ return new Promise((resolve) => {++ const seen = [];++ const s = http.createServer((req, res) => {++ seen.push({ url: req.url, headers: req.headers });++ const out = handler(req);++ res.writeHead(out.status || 200, { 'content-type': 'application/json' });++ res.end(JSON.stringify(out.body || out));++ });++ s.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, seen, close: () => new Promise((r) => s.close(r)) }));++ });++}++++t.test('what a model id means: its kind, its strength and what it can do', () => {++ const d = (id) => catalog.describe(id);++ assert.deepStrictEqual([d('claude-opus-5-5').tier, d('claude-sonnet-5-5').tier, d('claude-haiku-5-5').tier, d('claude-fable-5-1').tier], ['frontier', 'strong', 'fast', 'frontier']);++ assert.ok(d('claude-opus-5-5').vision && d('claude-opus-5-5').tools && d('claude-opus-5-5').reasoning);++ assert.deepStrictEqual([d('models/gemini-2.5-pro').tier, d('gemini-2.5-flash').tier, d('gemini-2.5-flash-lite').tier], ['strong', 'balanced', 'fast'], 'Gemini, models/ or not');++ assert.strictEqual(d('gemini-2.5-flash-image').type, 'image');++ assert.ok(d('deepseek-reasoner').reasoning && d('deepseek-reasoner').tier === 'strong' && d('deepseek-chat').tier === 'balanced');++ assert.deepStrictEqual([d('text-embedding-3-small').type, d('whisper-1').type, d('veo-3').type], ['embedding', 'stt', 'video']);++ assert.strictEqual(d('anthropic/claude-sonnet-5-5').tier, 'strong', 'OpenRouter ids name the model after the slash');++ assert.strictEqual(d('something-new').tier, 'balanced', 'an unknown model is a balanced chat model');++ assert.ok(d('kimi-k2-thinking').reasoning && d('acme-reasoner').tier === 'strong', 'a thinking model by its name');++ const hinted = catalog.describe('claude-opus-5-5', { contextWindow: 1000000, maxOutput: 128000, vision: false });++ assert.ok(hinted.contextWindow === 1000000 && hinted.maxOutput === 128000 && hinted.vision === false, 'what the provider says wins');++});++++t.test("Anthropic's list (paged, with limits and capabilities) and OpenRouter's (context, modalities, tools)", async () => {++ const anthropic = await server((req) => (req.url.includes('after_id=claude-sonnet-5-5')++ ? { data: [{ id: 'claude-haiku-5-5', display_name: 'Claude Haiku 5.5', max_input_tokens: 1000000, max_tokens: 64000 }], has_more: false }++ : { data: [{ id: 'claude-opus-5-5', display_name: 'Claude Opus 5.5', max_input_tokens: 1000000, max_tokens: 128000, capabilities: { image_input: { supported: true }, thinking: { supported: true } } },++ { id: 'claude-sonnet-5-5', display_name: 'Claude Sonnet 5.5', max_input_tokens: 1000000, max_tokens: 128000 }], has_more: true, last_id: 'claude-sonnet-5-5' }));++ try {++ // listRemote only reads an anthropic.com list; point a record at the fake through a base URL that says so.++ const list = await catalog.listRemote({ kind: 'anthropic', base_url: `${anthropic.url}/v1` }, 'ak');++ assert.deepStrictEqual(list, [], 'an Anthropic-format endpoint of another provider has no list to read');++ const real = await catalog.listRemote({ kind: 'anthropic', base_url: 'https://api.anthropic.com/v1' }, 'ak', { fetchImpl: (u, o) => globalThis.fetch(u.replace('https://api.anthropic.com', anthropic.url), o) });++ assert.deepStrictEqual(real.map((m) => m.id), ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5'], 'every page');++ assert.strictEqual(anthropic.seen[0].headers['x-api-key'], 'ak');++ assert.ok(anthropic.seen[0].headers['anthropic-version']);++ assert.deepStrictEqual(real[0].hints, { contextWindow: 1000000, maxOutput: 128000, vision: true, reasoning: true });++ } finally { await anthropic.close(); }++++ const openrouter = await server(() => ({ data: [++ { id: 'google/gemini-2.5-pro', name: 'Google: Gemini 2.5 Pro', context_length: 1048576, architecture: { input_modalities: ['text', 'image'] }, supported_parameters: ['tools', 'reasoning'] },++ { id: 'deepseek/deepseek-chat', name: 'DeepSeek V3', context_length: 163840, architecture: { input_modalities: ['text'] }, supported_parameters: ['tools'] },++ ] }));++ try {++ const list = await catalog.listRemote({ kind: 'openai', base_url: `${openrouter.url}/api/v1` }, 'ok');++ assert.strictEqual(openrouter.seen[0].headers.authorization, 'Bearer ok');++ assert.deepStrictEqual(list[0], { id: 'google/gemini-2.5-pro', name: 'Google: Gemini 2.5 Pro', hints: { contextWindow: 1048576, maxOutput: undefined, vision: true, tools: true, reasoning: true } });++ assert.strictEqual(list[1].hints.vision, false);++ } finally { await openrouter.close(); }++});++++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: {} });++ let listed = [++ { id: 'deepseek-chat', object: 'model' }, { id: 'deepseek-reasoner', object: 'model' },++ ];++ const fake = await server(() => ({ data: listed }));++ try {++ await registry.upsertProvider({ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: `${fake.url}/v1`, auth_mode: 'bearer', secret_ref: null, capabilities: ['chat'], timeout_ms: 30000, priority: 10 }, { actor: 'test', origin: 'admin' });++ // A person registered deepseek-chat with its prices and turned it on before any sync.++ await registry.upsertModel({ provider_key: 'deepseek', model_key: 'deepseek-chat', status: 'active', cost: { in_per_mtok: 0.27, out_per_mtok: 1.1, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-10-31' } });++ const provider = await registry.getProvider('deepseek');++ const first = await catalog.sync({ registry, provider, apiKey: 'k' });++ assert.deepStrictEqual(first.added, ['deepseek-reasoner']);++ assert.deepStrictEqual(first.updated, ['deepseek-chat']);++ const chat = await registry.getModel('deepseek', 'deepseek-chat');++ const reasoner = await registry.getModel('deepseek', 'deepseek-reasoner');++ assert.ok(chat.status === 'active' && chat.cost.in_per_mtok === 0.27 && chat.cost.source, "the person's status and prices stay");++ assert.ok(reasoner.status === 'disabled' && reasoner.cost.in_per_mtok == null, 'a new model is off, with no price until a person types one');++ assert.ok(reasoner.metadata.tier === 'strong' && reasoner.metadata.reasoning === true && reasoner.supports.tools, 'and described');++ listed = [{ id: 'deepseek-chat' }];++ const second = await catalog.sync({ registry, provider, apiKey: 'k' });++ assert.deepStrictEqual(second.missing, ['deepseek-reasoner']);++ const gone = await registry.getModel('deepseek', 'deepseek-reasoner');++ assert.ok(gone && gone.metadata.missing_since, 'no longer listed: marked, not deleted');++ const third = await catalog.sync({ registry, provider, apiKey: 'k' });++ assert.deepStrictEqual(third.missing, [], 'and marked once');++ } finally { await fake.close(); await rdb.close(); }++});++++t.run();
@@ -46,6 +46,7 @@ const { staff: staffMap } = require('openvibe-contracts');
@@ -46,6 +46,7 @@ const { staff: staffMap } = require('openvibe-contracts');
const { createSessions, sameString, random } = require('./session');
const { createSessions, sameString, random } = require('./session');
const sso = require('./sso');
const sso = require('./sso');
const q = require('./queries');
const q = require('./queries');
+const catalog = require('../providers/catalog');
const { CSP, esc, pages } = require('./views');
const { CSP, esc, pages } = require('./views');
/** Network staff capability -> AI capability held by a console session (documented in README "Operator console"). */
/** Network staff capability -> AI capability held by a console session (documented in README "Operator console"). */
@@ -88,6 +89,8 @@ const NOTICES = {
@@ -88,6 +89,8 @@ const NOTICES = {
provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',
provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',
provider_key: 'Key replaced. Runs use it from now on.',
provider_key: 'Key replaced. Runs use it from now on.',
model_added: 'Model registered.',
model_added: 'Model registered.',
+ models_synced: 'Model list read. New models are listed switched off: turn on the ones routes may use.',
+ model_status: 'Model switched.',
route_used: 'Route changed: new runs use the provider and model you chose (the previous one is kept as a fallback).',
route_used: 'Route changed: new runs use the provider and model you chose (the previous one is kept as a fallback).',
route_default: 'Route back to its default.',
route_default: 'Route back to its default.',
circuit_reset: 'Circuit reset: the provider will be tried again.',
circuit_reset: 'Circuit reset: the provider will be tried again.',
@@ -343,6 +346,9 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -343,6 +346,9 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,
auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,
capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },
capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },
}, { ...who(req), origin: 'admin' });
}, { ...who(req), origin: 'admin' });
+ // Its model list, read right away (best effort: a provider whose list cannot be read is still added).
+ const added = await registry.getProvider(key);
+ await catalog.sync({ registry, provider: added, apiKey: resolveSecret(added.secret_ref, env), fetchImpl, actor: who(req).actor }).catch(() => null);
res.redirect(303, '/console/providers?done=provider_added');
res.redirect(303, '/console/providers?done=provider_added');
}));
}));
r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {
r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {
@@ -355,6 +361,24 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -355,6 +361,24 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
await registry.audit(who(req).actor, 'provider.key_replaced', 'provider', p.key, { trace: who(req).trace });
await registry.audit(who(req).actor, 'provider.key_replaced', 'provider', p.key, { trace: who(req).trace });
res.redirect(303, '/console/providers?done=provider_key');
res.redirect(303, '/console/providers?done=provider_key');
}));
}));
+ // Read the provider's own model list into the catalogue (L33): new models land switched off, prices stay a person's.
+ r.post('/console/providers/:key/sync', needs(CAPS.providerManage), wrap(async (req, res) => {
+ const p = await registry.getProvider(req.params.key);
+ if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
+ const apiKey = resolveSecret(p.secret_ref, env);
+ if (!apiKey && p.auth_mode !== 'none') throw new AiError(422, 'ai.invalid', `${p.key} has no key to read its model list with`);
+ const out = await catalog.sync({ registry, provider: p, apiKey, fetchImpl, actor: who(req).actor });
+ await registry.audit(who(req).actor, 'provider.models_synced', 'provider', p.key, { trace: who(req).trace, metadata: { added: out.added.length, updated: out.updated.length, missing: out.missing.length } });
+ res.redirect(303, '/console/providers?done=models_synced');
+ }));
+ // Turn one model on or off (a model routes may use, or not).
+ r.post('/console/models/:provider/:model/status', needs(CAPS.providerManage), wrap(async (req, res) => {
+ const m = await registry.getModel(req.params.provider, req.params.model);
+ if (!m) throw new AiError(404, 'ai.not_found', 'no such model');
+ const status = (req.body || {}).status === 'active' ? 'active' : 'disabled';
+ await registry.upsertModel({ provider_key: m.provider_key, model_key: m.model_key, status }, who(req));
+ res.redirect(303, '/console/providers?done=model_status');
+ }));
r.post('/console/providers/:key/models', needs(CAPS.providerManage), wrap(async (req, res) => {
r.post('/console/providers/:key/models', needs(CAPS.providerManage), wrap(async (req, res) => {
const p = await registry.getProvider(req.params.key);
const p = await registry.getProvider(req.params.key);
if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
@@ -212,6 +212,7 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,
@@ -212,6 +212,7 @@ function providers({ staff, csrf, rows, models, canManage, stubFallback, notice,
return h && String(p.secret_ref || '') === `vault:${p.key}` ? html`<br><span class="muted">key ${h.hint}, set ${when(h.updated_at)}</span>` : '';
return h && String(p.secret_ref || '') === `vault:${p.key}` ? html`<br><span class="muted">key ${h.hint}, set ${when(h.updated_at)}</span>` : '';
};
};
const manageRow = (p) => (canManage && p.kind !== 'stub' && p.kind !== 'whisper' ? html`<tr class="sub"><td colspan="10"><details><summary>Key and models for <code>${p.key}</code></summary>
const manageRow = (p) => (canManage && p.kind !== 'stub' && p.kind !== 'whisper' ? html`<tr class="sub"><td colspan="10"><details><summary>Key and models for <code>${p.key}</code></summary>
+<form class="inline" method="post" action="/console/providers/${enc(p.key)}/sync">${csrfField(csrf)}<button type="submit">Read its model list</button> <span class="muted">every model it offers, described (what it does, how strong); new ones arrive switched off</span></form>
<form class="inline" method="post" action="/console/providers/${enc(p.key)}/key">${csrfField(csrf)}<label>New API key <input type="password" name="api_key" required minlength="8" autocomplete="off" spellcheck="false"></label><button type="submit">Replace key</button></form>
<form class="inline" method="post" action="/console/providers/${enc(p.key)}/key">${csrfField(csrf)}<label>New API key <input type="password" name="api_key" required minlength="8" autocomplete="off" spellcheck="false"></label><button type="submit">Replace key</button></form>
<form class="inline" method="post" action="/console/providers/${enc(p.key)}/models">${csrfField(csrf)}<label>Model <input type="text" name="model_key" required maxlength="200" placeholder="deepseek-flash" spellcheck="false"></label>
<form class="inline" method="post" action="/console/providers/${enc(p.key)}/models">${csrfField(csrf)}<label>Model <input type="text" name="model_key" required maxlength="200" placeholder="deepseek-flash" spellcheck="false"></label>
<label><input type="checkbox" name="vision" value="1"> reads pictures</label> <label><input type="checkbox" name="tools" value="1"> tools</label>
<label><input type="checkbox" name="vision" value="1"> reads pictures</label> <label><input type="checkbox" name="tools" value="1"> tools</label>
@@ -231,8 +232,10 @@ ${canManage ? html`<td><form class="inline" method="post" action="/console/provi
@@ -231,8 +232,10 @@ ${canManage ? html`<td><form class="inline" method="post" action="/console/provi
${p.health.state !== 'closed' || p.health.consecutive_failures ? html` <form class="inline" method="post" action="/console/providers/${enc(p.key)}/reset">${csrfField(csrf)}<button type="submit">Reset circuit<span class="sr-only"> of ${p.key}</span></button></form>` : ''}</td>` : ''}
${p.health.state !== 'closed' || p.health.consecutive_failures ? html` <form class="inline" method="post" action="/console/providers/${enc(p.key)}/reset">${csrfField(csrf)}<button type="submit">Reset circuit<span class="sr-only"> of ${p.key}</span></button></form>` : ''}</td>` : ''}
</tr>${manageRow(p)}`), 'no providers.')}
</tr>${manageRow(p)}`), 'no providers.')}
${canManage ? '' : html`<p class="muted">Enabling, disabling and circuit resets need <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}
${canManage ? '' : html`<p class="muted">Enabling, disabling and circuit resets need <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}
-${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Context / max output', 'Cost per Mtok (in / out / cached)', 'Supports'], models.map((m) => html`<tr>
-<td><code>${m.provider_key}</code></td><td><code>${m.model_key}</code></td><td>${m.type}</td><td>${badge(m.status)}</td>
+${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Strength', 'Context / max output', 'Cost per Mtok (in / out / cached)', 'Supports'], models.map((m) => html`<tr>
+<td><code>${m.provider_key}</code></td><td><code>${m.model_key}</code>${m.display_name && m.display_name !== m.model_key ? html`<br><span class="muted">${m.display_name}</span>` : ''}</td><td>${m.type}</td>
+<td>${badge(m.status)}${m.metadata && m.metadata.missing_since ? html`<br><span class="muted">no longer listed since ${when(m.metadata.missing_since)}</span>` : ''}${canManage ? html`<form class="inline" method="post" action="/console/models/${enc(m.provider_key)}/${enc(m.model_key)}/status">${csrfField(csrf)}<input type="hidden" name="status" value="${m.status === 'active' ? 'disabled' : 'active'}"><button type="submit">${m.status === 'active' ? 'Turn off' : 'Turn on'}<span class="sr-only"> ${m.model_key}</span></button></form>` : ''}</td>
+<td>${m.metadata && m.metadata.tier ? html`${m.metadata.tier}${m.metadata.reasoning ? ', thinks first' : ''}${m.metadata.note ? html`<br><span class="muted">${m.metadata.note}</span>` : ''}` : '—'}</td>
<td>${m.context_window ? n(m.context_window) : '—'} / ${m.max_output ? n(m.max_output) : '—'}</td>
<td>${m.context_window ? n(m.context_window) : '—'} / ${m.max_output ? n(m.max_output) : '—'}</td>
<td>${m.cost.in_per_mtok != null ? usd(m.cost.in_per_mtok) : '—'} / ${m.cost.out_per_mtok != null ? usd(m.cost.out_per_mtok) : '—'} / ${m.cost.cached_per_mtok != null ? usd(m.cost.cached_per_mtok) : '—'}</td>
<td>${m.cost.in_per_mtok != null ? usd(m.cost.in_per_mtok) : '—'} / ${m.cost.out_per_mtok != null ? usd(m.cost.out_per_mtok) : '—'} / ${m.cost.cached_per_mtok != null ? usd(m.cost.cached_per_mtok) : '—'}</td>
<td>${Object.entries(m.supports).filter(([, v]) => v).map(([k]) => k).join(', ') || '—'}</td></tr>`), 'no models registered (providers use their default model).')}`,
<td>${Object.entries(m.supports).filter(([, v]) => v).map(([k]) => k).join(', ') || '—'}</td></tr>`), 'no models registered (providers use their default model).')}`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+/**
+ * The model catalogue keeps itself current (plan L33 P1; the owner, 2026-10-31: "support Anthropic… Gemini… all the
+ * DeepSeek, the mainstream ones… and make the whole thing smarter"). A provider's own model list is read with its key
+ * and each model is described: what it is (chat, vision, embedding, speech to text, image), how strong (fast, balanced,
+ * strong, frontier), whether it sees images, calls tools or thinks first, and its context window and output cap.
+ *
+ * describe(id, hints) → { type, tier, vision, tools, reasoning, contextWindow, maxOutput, note }
+ * listRemote(provider, key) → [{ id, name, hints }] from the provider's model list:
+ * Anthropic GET /v1/models (max_input_tokens, max_tokens, capabilities);
+ * OpenAI-compatible GET /models (OpenRouter adds context_length, modalities and
+ * supported parameters; Gemini names its models models/…)
+ * sync({ registry, provider, apiKey }) → { added, updated, missing }: new models land switched off (a person turns
+ * 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).
+ * 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.
+ */
+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 = [
+ [/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' }],
+ [/^veo|^sora|video/i, { type: 'video', tier: 'strong' }],
+ [/^claude-(fable|mythos)/i, { tier: 'frontier', vision: true, tools: true, reasoning: true, note: "Anthropic's most capable: the hardest, longest work" }],
+ [/^claude-opus/i, { tier: 'frontier', vision: true, tools: true, reasoning: true, note: 'hard changes, design, long agentic work' }],
+ [/^claude-sonnet/i, { tier: 'strong', vision: true, tools: true, reasoning: true, note: 'fast and capable: most tasks' }],
+ [/^claude-haiku/i, { tier: 'fast', vision: true, tools: true, note: 'the quickest Claude: small jobs and high volume' }],
+ [/^claude-/i, { tier: 'balanced', vision: true, tools: true }],
+ [/gemini-.*flash-lite/i, { tier: 'fast', vision: true, tools: true, note: 'the cheapest Gemini' }],
+ [/gemini-.*flash/i, { tier: 'balanced', vision: true, tools: true, reasoning: true, note: 'quick, long context, sees images' }],
+ [/gemini-.*pro/i, { tier: 'strong', vision: true, tools: true, reasoning: true, note: 'long context, sees images, thinks first' }],
+ [/^gemma/i, { tier: 'fast' }],
+ [/deepseek-reasoner|deepseek-r\d/i, { tier: 'strong', tools: true, reasoning: true, note: 'thinks first: harder problems, very cheap' }],
+ [/deepseek/i, { tier: 'balanced', tools: true, note: 'quick and very cheap' }],
+ [/^(gpt-[45][.\d]*-?(mini|nano)|o\d-mini)/i, { tier: 'fast', vision: true, tools: true }],
+ [/^o\d/i, { tier: 'strong', tools: true, reasoning: true }],
+ [/^gpt-5/i, { tier: 'strong', vision: true, tools: true, reasoning: true }],
+ [/^gpt-4/i, { tier: 'balanced', vision: true, tools: true }],
+ [/^grok-.*mini|grok-.*fast/i, { tier: 'fast', tools: true }],
+ [/^grok/i, { tier: 'strong', vision: true, tools: true, reasoning: true }],
+ [/magistral|mistral-large|mistral-medium/i, { tier: 'strong', tools: true }],
+ [/ministral|mistral-small|mistral-tiny|open-mistral/i, { tier: 'fast', tools: true }],
+ [/codestral|devstral/i, { tier: 'balanced', tools: true, note: 'code' }],
+ [/reason|thinking/i, { tier: 'strong', tools: true, reasoning: true, note: 'thinks first' }], // kimi-k2-thinking, …-reasoner
+ [/minimax/i, { tier: 'balanced', tools: true }],
+ [/llama|qwen|kimi|glm|nemotron|phi-?\d/i, { tier: 'balanced', tools: true }],
+];
+const TIERS = ['fast', 'balanced', 'strong', 'frontier'];
+
+/** What a model id means, with whatever its provider's list said (hints) taking precedence. */
+function describe(id, hints = {}) {
+ const bare = String(id || '').replace(/^models\//, ''); // Gemini names its models models/…
+ const leaf = bare.includes('/') ? bare.slice(bare.lastIndexOf('/') + 1) : bare; // OpenRouter: provider/model
+ const fam = (FAMILIES.find(([re]) => re.test(leaf)) || [null, {}])[1];
+ const out = {
+ type: fam.type || 'chat', tier: fam.tier || 'balanced', vision: !!fam.vision, tools: !!fam.tools, reasoning: !!fam.reasoning,
+ contextWindow: null, maxOutput: null, note: fam.note || null,
+ };
+ 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 (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;
+}
+
+async function getJson(url, headers, { timeoutMs = 20000, fetchImpl = globalThis.fetch } = {}) {
+ let res;
+ try { res = await fetchImpl(url, { headers, 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(() => '');
+ let j = {};
+ try { j = text ? JSON.parse(text) : {}; } catch { j = {}; }
+ if (!res.ok) {
+ const msg = (j.error && (j.error.message || j.error)) || `HTTP ${res.status}`;
+ throw new ProviderError(typeof msg === 'string' ? msg : JSON.stringify(msg), { status: res.status, code: res.status === 401 || res.status === 403 ? 'provider.auth' : 'provider.http' });
+ }
+ return j;
+}
+
+/** A provider's own model list, read with its key → [{ id, name, hints }]. */
+async function listRemote(provider, apiKey, { fetchImpl = globalThis.fetch } = {}) {
+ const base = String(provider.base_url || '').replace(/\/+$/, '');
+ if (!base) throw new ProviderError('the provider has no base URL', { code: 'provider.config' });
+ 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 [];
+ 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 });
+ 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;
+ 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 MODEL_TYPES = ['chat', 'vision', 'embedding', 'stt', 'image'];
+
+/** 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() }) {
+ 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
+ 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 };
+ delete metadata.missing_since;
+ await registry.upsertModel({
+ provider_key: provider.key, model_key: m.id, 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',
+ 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,
+ }, { 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 });
+ result.missing.push(id);
+ }
+ return result;
+}
+
+module.exports = { describe, listRemote, sync, FAMILIES, TIERS };
@@ -63,6 +63,8 @@ async function fakeFetch(url, opts = {}) {
@@ -63,6 +63,8 @@ async function fakeFetch(url, opts = {}) {
return json(200, { access_token: userToken(c), refresh_token: `rt_${crypto.randomBytes(6).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });
return json(200, { access_token: userToken(c), refresh_token: `rt_${crypto.randomBytes(6).toString('hex')}`, token_type: 'Bearer', expires_in: 3600 });
}
}
if (u === `${NETWORK}/oauth/revoke`) { revoked.push(JSON.parse(String(opts.body)).token); return json(200, { revoked: true }); }
if (u === `${NETWORK}/oauth/revoke`) { revoked.push(JSON.parse(String(opts.body)).token); return json(200, { revoked: true }); }
+ // The added provider's own model list (L33: read when it is added, and again from its row).
+ if (u === 'https://api.cheap.test/v1/models') return json(200, { data: [{ id: 'cheap-1' }, { id: 'cheap-pro-reasoner' }] });
return globalThis.fetch(url, opts);
return globalThis.fetch(url, opts);
}
}
@@ -400,6 +402,19 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, re
@@ -400,6 +402,19 @@ t.test('owner: add a provider with its key (kept encrypted), replace the key, re
assert.strictEqual(x.status, 303, x.text);
assert.strictEqual(x.status, 303, x.text);
const m = await h.registry.getModel('cheapapi', 'cheap-2');
const m = await h.registry.getModel('cheapapi', 'cheap-2');
assert.deepStrictEqual([m.cost.in_per_mtok, m.cost.out_per_mtok, m.supports.tools], [0.1, 0.4, true]);
assert.deepStrictEqual([m.cost.in_per_mtok, m.cost.out_per_mtok, m.supports.tools], [0.1, 0.4, true]);
+ // Its model list was read when it was added: listed, described, switched off until a person turns one on (L33).
+ const listed = await h.registry.getModel('cheapapi', 'cheap-pro-reasoner');
+ assert.ok(listed && listed.status === 'disabled' && listed.metadata.tier === 'strong' && listed.metadata.reasoning, 'read on add, described, off');
+ x = await get('/console/providers', cookies.owner);
+ assert.ok(x.text.includes('action="/console/providers/cheapapi/sync"') && x.text.includes('action="/console/models/cheapapi/cheap-pro-reasoner/status"'), 'the page offers the list and the switch');
+ x = await post('/console/providers/cheapapi/sync', cookies.owner, { _csrf: csrf.owner });
+ assert.strictEqual(x.status, 303, x.text);
+ assert.match(x.headers.get('location'), /done=models_synced$/);
+ assert.strictEqual((await auditRows("action = 'provider.models_synced' AND target_id = 'cheapapi' AND actor = ?", people.owner)).length, 1);
+ x = await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.owner, { status: 'active', _csrf: csrf.owner });
+ assert.strictEqual(x.status, 303);
+ assert.strictEqual((await h.registry.getModel('cheapapi', 'cheap-pro-reasoner')).status, 'active', 'turned on');
+ assert.strictEqual((await post('/console/models/cheapapi/cheap-pro-reasoner/status', cookies.admin, { status: 'disabled', _csrf: csrf.admin })).status, 403, 'only the owner switches models');
// Point default.json at it: a new version, the previous primary kept as the first fallback; then back to the default.
// Point default.json at it: a new version, the previous primary kept as the first fallback; then back to the default.
const before = await h.registry.getRoute('default.json');
const before = await h.registry.getRoute('default.json');
x = await get('/console/routes/default.json', cookies.owner);
x = await get('/console/routes/default.json', cookies.owner);
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+// The model catalogue keeps itself current (plan L33 P1): a provider's own model list, read with its key, becomes
+// described models — what each is, how strong, whether it sees, uses tools or thinks first, its context — that arrive
+// switched off, keep a person's prices and status when read again, and are marked (never deleted) when the provider
+// stops listing them. Fake Anthropic (Models API, paged) and OpenRouter (/models with context and modalities) servers.
+const assert = require('assert');
+const http = require('http');
+const { suite } = require('./helpers');
+const { createRegistry } = require('../server/registry');
+const catalog = require('../server/providers/catalog');
+const { testDb } = require('./db');
+
+const t = suite('model-catalog');
+
+function server(handler) {
+ return new Promise((resolve) => {
+ const seen = [];
+ const s = http.createServer((req, res) => {
+ seen.push({ url: req.url, headers: req.headers });
+ const out = handler(req);
+ res.writeHead(out.status || 200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(out.body || out));
+ });
+ s.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, seen, close: () => new Promise((r) => s.close(r)) }));
+ });
+}
+
+t.test('what a model id means: its kind, its strength and what it can do', () => {
+ const d = (id) => catalog.describe(id);
+ assert.deepStrictEqual([d('claude-opus-5-5').tier, d('claude-sonnet-5-5').tier, d('claude-haiku-5-5').tier, d('claude-fable-5-1').tier], ['frontier', 'strong', 'fast', 'frontier']);
+ assert.ok(d('claude-opus-5-5').vision && d('claude-opus-5-5').tools && d('claude-opus-5-5').reasoning);
+ assert.deepStrictEqual([d('models/gemini-2.5-pro').tier, d('gemini-2.5-flash').tier, d('gemini-2.5-flash-lite').tier], ['strong', 'balanced', 'fast'], 'Gemini, models/ or not');
+ assert.strictEqual(d('gemini-2.5-flash-image').type, 'image');
+ assert.ok(d('deepseek-reasoner').reasoning && d('deepseek-reasoner').tier === 'strong' && d('deepseek-chat').tier === 'balanced');
+ assert.deepStrictEqual([d('text-embedding-3-small').type, d('whisper-1').type, d('veo-3').type], ['embedding', 'stt', 'video']);
+ assert.strictEqual(d('anthropic/claude-sonnet-5-5').tier, 'strong', 'OpenRouter ids name the model after the slash');
+ assert.strictEqual(d('something-new').tier, 'balanced', 'an unknown model is a balanced chat model');
+ assert.ok(d('kimi-k2-thinking').reasoning && d('acme-reasoner').tier === 'strong', 'a thinking model by its name');
+ const hinted = catalog.describe('claude-opus-5-5', { contextWindow: 1000000, maxOutput: 128000, vision: false });
+ assert.ok(hinted.contextWindow === 1000000 && hinted.maxOutput === 128000 && hinted.vision === false, 'what the provider says wins');
+});
+
+t.test("Anthropic's list (paged, with limits and capabilities) and OpenRouter's (context, modalities, tools)", async () => {
+ const anthropic = await server((req) => (req.url.includes('after_id=claude-sonnet-5-5')
+ ? { data: [{ id: 'claude-haiku-5-5', display_name: 'Claude Haiku 5.5', max_input_tokens: 1000000, max_tokens: 64000 }], has_more: false }
+ : { data: [{ id: 'claude-opus-5-5', display_name: 'Claude Opus 5.5', max_input_tokens: 1000000, max_tokens: 128000, capabilities: { image_input: { supported: true }, thinking: { supported: true } } },
+ { id: 'claude-sonnet-5-5', display_name: 'Claude Sonnet 5.5', max_input_tokens: 1000000, max_tokens: 128000 }], has_more: true, last_id: 'claude-sonnet-5-5' }));
+ try {
+ // listRemote only reads an anthropic.com list; point a record at the fake through a base URL that says so.
+ const list = await catalog.listRemote({ kind: 'anthropic', base_url: `${anthropic.url}/v1` }, 'ak');
+ assert.deepStrictEqual(list, [], 'an Anthropic-format endpoint of another provider has no list to read');
+ const real = await catalog.listRemote({ kind: 'anthropic', base_url: 'https://api.anthropic.com/v1' }, 'ak', { fetchImpl: (u, o) => globalThis.fetch(u.replace('https://api.anthropic.com', anthropic.url), o) });
+ assert.deepStrictEqual(real.map((m) => m.id), ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5'], 'every page');
+ assert.strictEqual(anthropic.seen[0].headers['x-api-key'], 'ak');
+ assert.ok(anthropic.seen[0].headers['anthropic-version']);
+ assert.deepStrictEqual(real[0].hints, { contextWindow: 1000000, maxOutput: 128000, vision: true, reasoning: true });
+ } finally { await anthropic.close(); }
+
+ const openrouter = await server(() => ({ data: [
+ { id: 'google/gemini-2.5-pro', name: 'Google: Gemini 2.5 Pro', context_length: 1048576, architecture: { input_modalities: ['text', 'image'] }, supported_parameters: ['tools', 'reasoning'] },
+ { id: 'deepseek/deepseek-chat', name: 'DeepSeek V3', context_length: 163840, architecture: { input_modalities: ['text'] }, supported_parameters: ['tools'] },
+ ] }));
+ try {
+ const list = await catalog.listRemote({ kind: 'openai', base_url: `${openrouter.url}/api/v1` }, 'ok');
+ assert.strictEqual(openrouter.seen[0].headers.authorization, 'Bearer ok');
+ assert.deepStrictEqual(list[0], { id: 'google/gemini-2.5-pro', name: 'Google: Gemini 2.5 Pro', hints: { contextWindow: 1048576, maxOutput: undefined, vision: true, tools: true, reasoning: true } });
+ assert.strictEqual(list[1].hints.vision, false);
+ } finally { await openrouter.close(); }
+});
+
+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: {} });
+ let listed = [
+ { id: 'deepseek-chat', object: 'model' }, { id: 'deepseek-reasoner', object: 'model' },
+ ];
+ const fake = await server(() => ({ data: listed }));
+ try {
+ await registry.upsertProvider({ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: `${fake.url}/v1`, auth_mode: 'bearer', secret_ref: null, capabilities: ['chat'], timeout_ms: 30000, priority: 10 }, { actor: 'test', origin: 'admin' });
+ // A person registered deepseek-chat with its prices and turned it on before any sync.
+ await registry.upsertModel({ provider_key: 'deepseek', model_key: 'deepseek-chat', status: 'active', cost: { in_per_mtok: 0.27, out_per_mtok: 1.1, source: 'https://api-docs.deepseek.com/quick_start/pricing', verified_at: '2026-10-31' } });
+ const provider = await registry.getProvider('deepseek');
+ const first = await catalog.sync({ registry, provider, apiKey: 'k' });
+ assert.deepStrictEqual(first.added, ['deepseek-reasoner']);
+ assert.deepStrictEqual(first.updated, ['deepseek-chat']);
+ const chat = await registry.getModel('deepseek', 'deepseek-chat');
+ const reasoner = await registry.getModel('deepseek', 'deepseek-reasoner');
+ assert.ok(chat.status === 'active' && chat.cost.in_per_mtok === 0.27 && chat.cost.source, "the person's status and prices stay");
+ assert.ok(reasoner.status === 'disabled' && reasoner.cost.in_per_mtok == null, 'a new model is off, with no price until a person types one');
+ assert.ok(reasoner.metadata.tier === 'strong' && reasoner.metadata.reasoning === true && reasoner.supports.tools, 'and described');
+ listed = [{ id: 'deepseek-chat' }];
+ const second = await catalog.sync({ registry, provider, apiKey: 'k' });
+ assert.deepStrictEqual(second.missing, ['deepseek-reasoner']);
+ const gone = await registry.getModel('deepseek', 'deepseek-reasoner');
+ assert.ok(gone && gone.metadata.missing_since, 'no longer listed: marked, not deleted');
+ const third = await catalog.sync({ registry, provider, apiKey: 'k' });
+ assert.deepStrictEqual(third.missing, [], 'and marked once');
+ } finally { await fake.close(); await rdb.close(); }
+});
+
+t.run();