openvibers/openvibe.ai Commits

The model catalogue keeps itself current: every provider's own model list, described (L33) (#221)

OP OpenVibers committed 13 days ago · c79e6dc8cc · 5 files, +305 −2

The owner: "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, embedding, speech
to text, image), how strong (fast, balanced, strong, frontier), whether it sees images, uses tools or thinks
first, its context window and output cap, and a line on what it is good for.

- server/providers/catalog.js: Anthropic's Models API (paged; its limits and capabilities win), any
  OpenAI-compatible /models (OpenRouter's context, modalities and supported parameters; Gemini's models/ names),
  and the families the ids belong to (Claude, Gemini, DeepSeek, GPT, o-series, Grok, Mistral, open models).
- sync: new models arrive switched off (routing would otherwise change what OpenVibe spends), a known model keeps
  its status and a person's prices, and one the provider stops listing is marked, never deleted. Prices are never
  read: a person types them from the provider's price page.
- The console reads a provider's list when it is added and from its row ("Read its model list"), shows each
  model's strength, and switches a model on or off (the owner; audited).

Changes

ddiff --git a/server/console/index.js b/server/console/index.js
iindex 05442e3..11d8486 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -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, e
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 },
}, { ...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, e
await 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');
ddiff --git a/server/console/views.js b/server/console/views.js
iindex ba437b3..cb32f3d 100644
---- a/server/console/views.js
++++ b/server/console/views.js
@@@ -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).')}`,
ddiff --git a/server/providers/catalog.js b/server/providers/catalog.js
nnew file mode 100644
iindex 0000000..d30791c
---- /dev/null
++++ b/server/providers/catalog.js
@@@ -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 };
ddiff --git a/test/console.test.js b/test/console.test.js
iindex 6d208a3..6189535 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -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, re
assert.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);
ddiff --git a/test/model-catalog.test.js b/test/model-catalog.test.js
nnew file mode 100644
iindex 0000000..202ee70
---- /dev/null
++++ b/test/model-catalog.test.js
@@@ -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();