@link-assistant/hive-mind 2.18.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Which places a model list may come from, and the rule that keeps them free.
3
+ *
4
+ * Issue #2202 asks for a live model catalogue with one hard constraint attached:
5
+ * "models extraction should never trigger any tokens expense, otherwise such
6
+ * methods must be excluded from our codebase" (R7). That is a property of the
7
+ * *sources*, not of the caller, so it is enforced here — at the only place a
8
+ * source can be declared — rather than trusted at each call site.
9
+ *
10
+ * Two guards do it, and they are deliberately redundant:
11
+ *
12
+ * 1. `assertTokenFreeSource` rejects any descriptor that is not explicitly
13
+ * marked `billable: false`. A new source cannot be added by omission.
14
+ * 2. `assertTokenFreeUrl` rejects any URL whose path is a completion endpoint,
15
+ * whatever descriptor it arrived under. Listing endpoints return a catalogue
16
+ * and no `usage` block; completion endpoints are the ones that bill. A typo
17
+ * that turns `/v1/models` into `/v1/messages` throws instead of spending.
18
+ *
19
+ * The rejected-methods table below is the other half of R7: it records the
20
+ * extraction methods that were considered and excluded, so "we don't do that"
21
+ * is a reviewable statement in the codebase rather than an absence.
22
+ *
23
+ * This is a leaf module: it imports nothing from the repository, so the guards
24
+ * can be unit-tested — and imported by a source — without pulling in a catalogue.
25
+ *
26
+ * @see https://github.com/link-assistant/hive-mind/issues/2202
27
+ */
28
+
29
+ /** One hour, the freshness floor requirement R9 states. */
30
+ export const MODEL_CATALOGUE_TTL_MS = 60 * 60 * 1000;
31
+
32
+ /**
33
+ * Cache lifetime in milliseconds.
34
+ *
35
+ * R9 says the data is "cached for at least 1 hour, so we don't request that data
36
+ * from bot too much". `HIVE_MIND_MODEL_CATALOGUE_TTL_MINUTES` can therefore only
37
+ * ever *raise* the TTL: a lower value would ask providers more often, which is
38
+ * the thing the requirement exists to prevent.
39
+ */
40
+ export const resolveModelCatalogueTtlMs = (env = process.env) => {
41
+ const minutes = Number.parseFloat(String(env?.HIVE_MIND_MODEL_CATALOGUE_TTL_MINUTES ?? '').trim());
42
+ if (!Number.isFinite(minutes) || minutes <= 0) return MODEL_CATALOGUE_TTL_MS;
43
+ return Math.max(MODEL_CATALOGUE_TTL_MS, Math.round(minutes * 60 * 1000));
44
+ };
45
+
46
+ /**
47
+ * Path fragments that mean "this request runs a model".
48
+ *
49
+ * Anthropic bills `/v1/messages` and `/v1/complete`, OpenAI bills
50
+ * `/chat/completions` and `/responses`, Gemini bills `:generateContent` and
51
+ * `:streamGenerateContent`, and the router proxies all of them under its own
52
+ * prefixes — so the match is on the suffix, not the whole URL.
53
+ */
54
+ export const BILLABLE_PATH_PATTERNS = Object.freeze(['/v1/messages', '/v1/complete', '/chat/completions', '/completions', '/responses', '/embeddings', ':generatecontent', ':streamgeneratecontent', ':counttokens', '/v1/batches', '/v1/messages/batches']);
55
+
56
+ /** True when a URL's path would run (and bill) a model rather than list them. */
57
+ export const isBillableCatalogueUrl = url => {
58
+ const raw = String(url || '');
59
+ if (!raw) return false;
60
+ let pathname;
61
+ try {
62
+ pathname = new URL(raw).pathname.toLowerCase();
63
+ } catch {
64
+ pathname = raw.toLowerCase();
65
+ }
66
+ // `/v1/models` ends in `/models`, and `…/models/gpt-5` is still a listing.
67
+ // Only a *terminal* billable segment counts, so a provider that one day serves
68
+ // `/v1/models/completions-preview` is not mistaken for a completion call.
69
+ return BILLABLE_PATH_PATTERNS.some(pattern => pathname === pattern || pathname.endsWith(pattern));
70
+ };
71
+
72
+ /**
73
+ * Throw unless `url` is a listing endpoint.
74
+ *
75
+ * @param {string} url
76
+ * @param {string} sourceId - named in the error, so the offending source is obvious
77
+ */
78
+ export const assertTokenFreeUrl = (url, sourceId = 'unknown') => {
79
+ if (isBillableCatalogueUrl(url)) {
80
+ throw new Error(`Refusing to fetch a model catalogue from a billable endpoint (source "${sourceId}"): ${url}. Model extraction must never spend tokens (issue #2202, R7).`);
81
+ }
82
+ return url;
83
+ };
84
+
85
+ /** Throw unless a source descriptor has explicitly declared itself free. */
86
+ export const assertTokenFreeSource = source => {
87
+ if (!source || typeof source !== 'object') throw new Error('Model catalogue source must be an object (issue #2202, R7).');
88
+ if (source.billable !== false) {
89
+ throw new Error(`Model catalogue source "${source.id ?? 'unnamed'}" must declare billable: false. Sources that can spend tokens are excluded from this codebase (issue #2202, R7).`);
90
+ }
91
+ return source;
92
+ };
93
+
94
+ /**
95
+ * Extraction methods considered for R7 and deliberately not implemented.
96
+ *
97
+ * The issue explicitly raises one of them — "including but not exclusive to
98
+ * usage of TUI" — and then says such methods "must be excluded from our
99
+ * codebase" if they can cost tokens. Recording *why* each was excluded keeps the
100
+ * next person from re-adding it, and gives `/models --details` something honest
101
+ * to print when a live source is unavailable.
102
+ */
103
+ export const REJECTED_EXTRACTION_METHODS = Object.freeze([
104
+ Object.freeze({
105
+ id: 'claude-tui-model-picker',
106
+ label: 'Drive the Claude Code TUI `/model` picker',
107
+ reason: "Starting the TUI starts a session. Claude Code sends a request as part of session start, so reading the picker is not free even if the picker itself is only a menu — and the cost is invisible, arriving on someone else's bill.",
108
+ }),
109
+ Object.freeze({
110
+ id: 'codex-tui-model-picker',
111
+ label: 'Drive the Codex TUI `/model` picker',
112
+ reason: '`codex debug models` returns the same catalogue as JSON from the installed binary with no network call at all, so the TUI adds cost and flakiness for nothing.',
113
+ }),
114
+ Object.freeze({
115
+ id: 'probe-completion-endpoint',
116
+ label: 'Ask a completion endpoint whether a model id exists',
117
+ reason: 'A `POST /v1/messages` with one token still bills a request, and a 404 for an unknown model is indistinguishable from a 404 for an unentitled one. `assertTokenFreeUrl` refuses these URLs outright.',
118
+ }),
119
+ Object.freeze({
120
+ id: 'scrape-vendor-docs-html',
121
+ label: 'Scrape vendor documentation pages for model tables',
122
+ reason: 'Free, but unversioned and layout-dependent. models.dev already aggregates the same specifications behind a stable JSON contract, so it is the fallback instead (R8).',
123
+ }),
124
+ ]);
125
+
126
+ /**
127
+ * The sources a live catalogue is assembled from, in the order they are tried.
128
+ *
129
+ * `rank` is that order and is also the precedence used when two sources describe
130
+ * the same model: the router speaks for what is actually reachable *right now*
131
+ * through the gateway a task will use, the local CLI speaks for what an
132
+ * installed tool will accept, the vendor endpoint speaks for the account's
133
+ * entitlements, and models.dev only ever contributes metadata (R8) — never
134
+ * availability, because it does not know about this account.
135
+ *
136
+ * Every entry is `billable: false` and every entry is checked by
137
+ * `assertTokenFreeSource` at module load, below.
138
+ */
139
+ export const MODEL_CATALOGUE_SOURCES = Object.freeze([
140
+ Object.freeze({
141
+ id: 'router',
142
+ rank: 1,
143
+ label: 'Link.Assistant Router',
144
+ kind: 'live',
145
+ billable: false,
146
+ contributes: 'availability',
147
+ tools: Object.freeze(['claude', 'agent', 'codex', 'opencode', 'qwen', 'gemini']),
148
+ why: 'The gateway a routed task actually talks to. Its catalogue is the merge of every provider it holds credentials for, and it reports degradation, so it is the only source that can say a model is reachable *through the path the task will take*.',
149
+ }),
150
+ Object.freeze({
151
+ id: 'codex-cli',
152
+ rank: 2,
153
+ label: 'codex debug models',
154
+ kind: 'live',
155
+ billable: false,
156
+ contributes: 'availability',
157
+ tools: Object.freeze(['codex']),
158
+ why: 'The installed Codex CLI answers from its own compiled catalogue with no network call, so it is both free and authoritative about what this binary will accept.',
159
+ }),
160
+ Object.freeze({
161
+ id: 'anthropic-api',
162
+ rank: 3,
163
+ label: 'Anthropic GET /v1/models',
164
+ kind: 'live',
165
+ billable: false,
166
+ contributes: 'availability',
167
+ tools: Object.freeze(['claude', 'agent']),
168
+ why: "Anthropic's listing endpoint returns the models this API key is entitled to. Listing is not metered: the response carries no `usage` block.",
169
+ }),
170
+ Object.freeze({
171
+ id: 'openai-api',
172
+ rank: 4,
173
+ label: 'OpenAI GET /v1/models',
174
+ kind: 'live',
175
+ billable: false,
176
+ contributes: 'availability',
177
+ tools: Object.freeze(['codex', 'opencode']),
178
+ why: "OpenAI's listing endpoint, same shape and same reasoning as Anthropic's.",
179
+ }),
180
+ Object.freeze({
181
+ id: 'models-dev',
182
+ rank: 5,
183
+ label: 'models.dev',
184
+ kind: 'metadata',
185
+ billable: false,
186
+ contributes: 'metadata',
187
+ tools: Object.freeze(['claude', 'agent', 'codex', 'opencode', 'qwen', 'gemini']),
188
+ why: 'The R8 fallback: context windows, pricing, modalities and release dates for models no first-party source described. It never adds a model to the available list — it only annotates one.',
189
+ }),
190
+ Object.freeze({
191
+ id: 'bundled',
192
+ rank: 6,
193
+ label: 'Bundled with this installation',
194
+ kind: 'bundled',
195
+ billable: false,
196
+ contributes: 'availability',
197
+ tools: Object.freeze(['claude', 'agent', 'codex', 'opencode', 'qwen', 'gemini']),
198
+ why: 'src/models/catalog.mjs, the aliases and defaults that ship with Hive Mind. Always present, never stale in the network sense, and the answer when every live source is unreachable.',
199
+ }),
200
+ ]);
201
+
202
+ for (const source of MODEL_CATALOGUE_SOURCES) assertTokenFreeSource(source);
203
+
204
+ /** Descriptor by id, or null. */
205
+ export const getModelCatalogueSource = id => MODEL_CATALOGUE_SOURCES.find(source => source.id === id) ?? null;
206
+
207
+ /** The sources that can say anything about `tool`, in rank order. */
208
+ export const listModelCatalogueSourcesForTool = tool => {
209
+ const name = String(tool || '').toLowerCase();
210
+ if (!name) return [...MODEL_CATALOGUE_SOURCES];
211
+ return MODEL_CATALOGUE_SOURCES.filter(source => source.tools.includes(name));
212
+ };
213
+
214
+ export default {
215
+ MODEL_CATALOGUE_TTL_MS,
216
+ MODEL_CATALOGUE_SOURCES,
217
+ REJECTED_EXTRACTION_METHODS,
218
+ assertTokenFreeSource,
219
+ assertTokenFreeUrl,
220
+ getModelCatalogueSource,
221
+ isBillableCatalogueUrl,
222
+ listModelCatalogueSourcesForTool,
223
+ resolveModelCatalogueTtlMs,
224
+ };
@@ -0,0 +1,385 @@
1
+ /**
2
+ * The live model catalogue: hot load, cache, and merge (issue #2202, R2/R8/R9).
3
+ *
4
+ * R2 asks for "an experimental mechanism that will try to get real time data
5
+ * about models", R8 for the technical details behind each one, and R9 for that
6
+ * data to be "cached for at least 1 hour, so we don't request that data from bot
7
+ * too much". This module is those three sentences:
8
+ *
9
+ * - **Hot load.** Every applicable source from ./model-catalogue-sources.lib.mjs
10
+ * is read in rank order through ./model-catalogue-fetch.lib.mjs. A source that
11
+ * does not apply is `skipped`, not an error — a host with no `OPENAI_API_KEY`
12
+ * is a normal host.
13
+ * - **Cache.** One JSON file in the bot state directory, keyed by source *and*
14
+ * tool, written atomically under the same `withStateLock` primitive the other
15
+ * background maintainers use, so two `/models` calls cannot interleave a write.
16
+ * The TTL can only be raised (see `resolveModelCatalogueTtlMs`).
17
+ * - **Merge.** The bundled catalogue and the live one are joined into three
18
+ * groups — in both, live only, bundled only — which is exactly what R5 asks
19
+ * `/models` to show: "from fully supported, to hot loaded".
20
+ *
21
+ * Nothing here can spend tokens: the sources module refuses to describe a
22
+ * billable source and the fetch module refuses to open a billable URL.
23
+ *
24
+ * @see https://github.com/link-assistant/hive-mind/issues/2202
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ import { fetchAnthropicCatalogue, fetchCodexCliCatalogue, fetchModelsDevMetadata, fetchOpenAiCatalogue, fetchRouterCatalogue } from './model-catalogue-fetch.lib.mjs';
31
+ import { listModelCatalogueSourcesForTool, resolveModelCatalogueTtlMs } from './model-catalogue-sources.lib.mjs';
32
+ import { agentModels, claudeModels, CODEX_MODEL_VARIANTS, defaultModels, geminiModels, opencodeModels, qwenModels } from './models/catalog.mjs';
33
+ import { resolveRouterBaseUrl, resolveRouterDialect } from './router-isolation.lib.mjs';
34
+ import { acquireRouterSidecar, releaseRouterSidecar } from './router-sidecar.lib.mjs';
35
+ import { resolveBotStateDir } from './session-store.lib.mjs';
36
+ import { withStateLock } from './state-lock.lib.mjs';
37
+
38
+ const CACHE_FILE_NAME = 'model-catalogue-cache.json';
39
+ const CACHE_LOCK_NAME = 'model-catalogue';
40
+ const CACHE_VERSION = 1;
41
+
42
+ /** Tools this catalogue can describe, in the order `/models --all` prints them. */
43
+ export const MODEL_CATALOGUE_TOOLS = Object.freeze(['claude', 'codex', 'agent', 'opencode', 'qwen', 'gemini']);
44
+
45
+ /** The bundled alias map for a tool — the leaf catalogue, without the mapping layer. */
46
+ export const getBundledModelMap = tool => {
47
+ switch (String(tool || '').toLowerCase()) {
48
+ case 'agent':
49
+ return agentModels;
50
+ case 'opencode':
51
+ return opencodeModels;
52
+ case 'codex':
53
+ return CODEX_MODEL_VARIANTS;
54
+ case 'qwen':
55
+ return qwenModels;
56
+ case 'gemini':
57
+ return geminiModels;
58
+ default:
59
+ return claudeModels;
60
+ }
61
+ };
62
+
63
+ /**
64
+ * The distinct model ids a tool ships with.
65
+ *
66
+ * The alias maps are many-to-one — `opus`, `opus-5` and `claude-opus-5` all
67
+ * resolve to one model — so the resolved values are what a live catalogue can be
68
+ * compared against; the aliases are a presentation detail.
69
+ */
70
+ export const listBundledModelIds = tool => [...new Set(Object.values(getBundledModelMap(tool)))].sort();
71
+
72
+ /** Aliases that resolve to one bundled model id, so `/models` can show them. */
73
+ export const listBundledAliasesFor = (tool, modelId) =>
74
+ Object.entries(getBundledModelMap(tool))
75
+ .filter(([alias, resolved]) => resolved === modelId && alias !== modelId)
76
+ .map(([alias]) => alias)
77
+ .sort();
78
+
79
+ /**
80
+ * Whether the live sources may be contacted.
81
+ *
82
+ * R2 calls this "experimental", so it is switchable — but it defaults *on* for
83
+ * an explicit `/models` call, because a user asking what models exist has asked
84
+ * for the network call by asking the question. Setting
85
+ * `HIVE_MIND_MODELS_HOT_LOAD=0` turns every live source into a `skipped` one and
86
+ * leaves the bundled catalogue, which always answers.
87
+ */
88
+ export const isModelHotLoadEnabled = (env = process.env) => !/^(0|false|off|no)$/i.test(String(env?.HIVE_MIND_MODELS_HOT_LOAD ?? '').trim());
89
+
90
+ /**
91
+ * Whether `/models` may take a router lease.
92
+ *
93
+ * Separate from the hot-load switch because it is the one source that costs
94
+ * more than an HTTP request: it can start a container. `HIVE_MIND_MODELS_ROUTER=0`
95
+ * keeps every other live source and drops just this one.
96
+ */
97
+ export const isRouterCatalogueEnabled = (env = process.env) => !/^(0|false|off|no)$/i.test(String(env?.HIVE_MIND_MODELS_ROUTER ?? '').trim());
98
+
99
+ export const resolveModelCatalogueCachePath = (env = process.env) => path.join(resolveBotStateDir(env), CACHE_FILE_NAME);
100
+
101
+ /** Read the cache. A missing or corrupt file is an empty cache, never a throw. */
102
+ export const readModelCatalogueCache = ({ env = process.env, fsImpl = fs } = {}) => {
103
+ try {
104
+ const parsed = JSON.parse(fsImpl.readFileSync(resolveModelCatalogueCachePath(env), 'utf8'));
105
+ if (parsed?.version !== CACHE_VERSION) return { version: CACHE_VERSION, entries: {} };
106
+ return { version: CACHE_VERSION, entries: {}, ...parsed };
107
+ } catch {
108
+ return { version: CACHE_VERSION, entries: {} };
109
+ }
110
+ };
111
+
112
+ /** Persist the cache atomically: write a sibling `.tmp`, then rename over it. */
113
+ export const writeModelCatalogueCache = (cache, { env = process.env, fsImpl = fs } = {}) => {
114
+ const target = resolveModelCatalogueCachePath(env);
115
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
116
+ const temporary = `${target}.tmp`;
117
+ fsImpl.writeFileSync(temporary, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
118
+ fsImpl.renameSync(temporary, target);
119
+ return cache;
120
+ };
121
+
122
+ /** Cache key. One entry per source *and* tool: the answers differ per tool. */
123
+ export const modelCatalogueCacheKey = (sourceId, tool) => `${sourceId}:${String(tool || 'all').toLowerCase()}`;
124
+
125
+ /** Age of a cache entry in milliseconds, or null when it has no timestamp. */
126
+ export const modelCatalogueEntryAgeMs = (entry, now = Date.now()) => {
127
+ const fetchedAt = Date.parse(entry?.fetchedAt ?? '');
128
+ return Number.isFinite(fetchedAt) ? Math.max(0, now - fetchedAt) : null;
129
+ };
130
+
131
+ /** True when an entry is younger than the TTL. */
132
+ export const isModelCatalogueEntryFresh = (entry, { ttlMs = null, env = process.env, now = Date.now() } = {}) => {
133
+ const age = modelCatalogueEntryAgeMs(entry, now);
134
+ if (age === null) return false;
135
+ return age < (ttlMs ?? resolveModelCatalogueTtlMs(env));
136
+ };
137
+
138
+ /**
139
+ * Call one source, honouring the cache.
140
+ *
141
+ * Read-through with one deliberate asymmetry: a *failed* live read does not
142
+ * overwrite a good cached answer. The router does the same thing internally
143
+ * ("last known catalog retained"), and for the same reason — a provider blip
144
+ * should not turn into an empty model list in front of a user.
145
+ */
146
+ const loadOneSource = async (source, { tool, env, cache, refresh, now, fetchers, routerContext }) => {
147
+ const key = modelCatalogueCacheKey(source.id, tool);
148
+ const cached = cache.entries?.[key] ?? null;
149
+ const fresh = !refresh && isModelCatalogueEntryFresh(cached, { env, now });
150
+ if (fresh && cached?.status === 'ok') {
151
+ return { ...cached, id: source.id, label: source.label, kind: source.kind, cached: true, ageMs: modelCatalogueEntryAgeMs(cached, now) };
152
+ }
153
+
154
+ const fetcher = fetchers[source.id];
155
+ const result = fetcher ? await fetcher({ tool, env, routerContext }) : { status: 'skipped', models: [], meta: {}, error: 'no reader for this source' };
156
+
157
+ if (result.status !== 'ok' && cached?.status === 'ok') {
158
+ return { ...cached, id: source.id, label: source.label, kind: source.kind, cached: true, stale: true, ageMs: modelCatalogueEntryAgeMs(cached, now), error: result.error ?? null };
159
+ }
160
+
161
+ const entry = { fetchedAt: new Date(now).toISOString(), status: result.status, models: result.models ?? [], meta: result.meta ?? {}, error: result.error ?? null };
162
+ if (result.status === 'ok') cache.entries[key] = entry;
163
+ return { ...entry, id: source.id, label: source.label, kind: source.kind, cached: false, ageMs: 0 };
164
+ };
165
+
166
+ /**
167
+ * Open a router session for a catalogue read, and hand back how to close it.
168
+ *
169
+ * R3 asks that the router be "initialized, mapped, and mounted with claude and
170
+ * codex credential files/folders" before its API is used, and that is precisely
171
+ * `acquireRouterSidecar`: it creates the network and volume, starts the pinned
172
+ * image with every credential directory that exists on this host mounted into
173
+ * it, waits for health, and mints a token. So `/models` takes a lease exactly
174
+ * like a task does rather than reimplementing any of it.
175
+ *
176
+ * The lease is real and is released in `finally`: when another task is already
177
+ * holding the sidecar up the acquire reuses the running container and the
178
+ * release leaves it running; when nothing else is, `/models` starts it and
179
+ * stops it again. Because the answer is cached for an hour (R9), that happens
180
+ * at most once an hour rather than once per question.
181
+ *
182
+ * An operator-run router (`HIVE_MIND_ROUTER_URL`) is not ours to start:
183
+ * `acquireRouterSidecar` returns the configured URL and the operator's token
184
+ * without touching Docker, and `close()` is then a no-op.
185
+ */
186
+ export const openRouterCatalogueSession = async ({ env = process.env, run = undefined, acquire = acquireRouterSidecar, release = releaseRouterSidecar, log = null, sessionId = null } = {}) => {
187
+ const closed = { available: false, close: async () => {} };
188
+ if (!isRouterCatalogueEnabled(env)) return { ...closed, reason: 'router catalogue reads are disabled (HIVE_MIND_MODELS_ROUTER=0)' };
189
+
190
+ const endpoint = resolveRouterBaseUrl({ env });
191
+ if (endpoint.error) return { ...closed, reason: endpoint.error };
192
+
193
+ const id = sessionId || `models-${process.pid}-${Date.now()}`;
194
+ let lease;
195
+ try {
196
+ lease = await acquire({ sessionId: id, env, log, ...(run ? { run } : {}) });
197
+ } catch (error) {
198
+ // Docker missing, daemon down, image unpullable: a host without a router is
199
+ // a normal host, so this is a skipped source and not a failed command.
200
+ return { ...closed, reason: String(error?.message ?? error) };
201
+ }
202
+ if (lease?.error || !lease?.token) return { ...closed, reason: lease?.error || 'router issued no token' };
203
+
204
+ const { dialect } = resolveRouterDialect({ env });
205
+ return {
206
+ available: true,
207
+ baseUrl: lease.baseUrl,
208
+ token: lease.token,
209
+ dialect,
210
+ external: Boolean(lease.external),
211
+ // The sidecar publishes no host port (see `buildRouterSidecarRunArgs`), so
212
+ // its catalogue is read from inside the container; an external router is on
213
+ // the network and is read over it.
214
+ transport: lease.external ? 'http' : 'exec',
215
+ close: async () => {
216
+ if (lease.external) return;
217
+ try {
218
+ await release({ sessionId: id, env, log, ...(run ? { run } : {}) });
219
+ } catch {
220
+ // The lease expires with its token either way; a failed release must
221
+ // never turn a successful catalogue read into a failed command.
222
+ }
223
+ },
224
+ };
225
+ };
226
+
227
+ /** The default readers, one per source id, with their arguments already bound. */
228
+ export const buildDefaultCatalogueFetchers = ({ fetchImpl = globalThis.fetch, run = undefined } = {}) => ({
229
+ router: async ({ tool, routerContext }) => {
230
+ if (!routerContext?.available) return { status: 'skipped', models: [], meta: {}, error: routerContext?.reason ?? 'router unavailable' };
231
+ return fetchRouterCatalogue({ baseUrl: routerContext.baseUrl, dialect: routerContext.dialect, token: routerContext.token, transport: routerContext.transport, tool, fetchImpl, ...(run ? { run } : {}) });
232
+ },
233
+ 'codex-cli': async () => fetchCodexCliCatalogue(run ? { run } : {}),
234
+ 'anthropic-api': async ({ env }) => fetchAnthropicCatalogue({ env, fetchImpl }),
235
+ 'openai-api': async ({ env }) => fetchOpenAiCatalogue({ env, fetchImpl }),
236
+ 'models-dev': async () => fetchModelsDevMetadata({ fetchImpl }),
237
+ bundled: async ({ tool }) => ({ status: 'ok', models: listBundledModelIds(tool).map(id => ({ id, label: null })), meta: { default: defaultModels[String(tool || 'claude').toLowerCase()] ?? null }, error: null }),
238
+ });
239
+
240
+ /**
241
+ * Hot-load every source that can speak about `tool`.
242
+ *
243
+ * The router lease is opened lazily and only when the router source is actually
244
+ * going to be read: with a fresh cache entry the answer is already on disk, and
245
+ * starting a container to re-derive it would defeat the point of R9's cache.
246
+ *
247
+ * @returns {Promise<{tool: string, ttlMs: number, hotLoad: boolean, sources: object[], metadata: object, router: object}>}
248
+ */
249
+ export const loadModelCatalogue = async ({ tool = 'claude', env = process.env, fsImpl = fs, refresh = false, now = Date.now(), fetchImpl = globalThis.fetch, run = undefined, fetchers = null, openRouter = openRouterCatalogueSession, log = null, lockOptions = {} } = {}) => {
250
+ const toolName = String(tool || 'claude').toLowerCase();
251
+ const hotLoad = isModelHotLoadEnabled(env);
252
+ const readers = fetchers ?? buildDefaultCatalogueFetchers({ fetchImpl, run });
253
+ const applicable = listModelCatalogueSourcesForTool(toolName).filter(source => hotLoad || source.kind === 'bundled');
254
+
255
+ return withStateLock(
256
+ CACHE_LOCK_NAME,
257
+ async () => {
258
+ const cache = readModelCatalogueCache({ env, fsImpl });
259
+ cache.entries = cache.entries ?? {};
260
+
261
+ let routerSession = { available: false, reason: hotLoad ? 'router was not needed' : 'hot load is disabled' };
262
+ const routerEntry = cache.entries[modelCatalogueCacheKey('router', toolName)];
263
+ const routerIsFresh = !refresh && routerEntry?.status === 'ok' && isModelCatalogueEntryFresh(routerEntry, { env, now });
264
+ if (hotLoad && !routerIsFresh && applicable.some(source => source.id === 'router')) {
265
+ routerSession = await openRouter({ env, run, log });
266
+ }
267
+
268
+ const sources = [];
269
+ try {
270
+ for (const source of applicable) {
271
+ try {
272
+ sources.push(await loadOneSource(source, { tool: toolName, env, cache, refresh, now, fetchers: readers, routerContext: routerSession }));
273
+ } catch (error) {
274
+ // A reader that throws is a bug in that reader, not a reason to
275
+ // return nothing: the remaining sources — including `bundled`, which
276
+ // cannot fail — still answer.
277
+ sources.push({ id: source.id, label: source.label, kind: source.kind, status: 'error', models: [], meta: {}, error: String(error?.message ?? error), cached: false, ageMs: 0 });
278
+ }
279
+ }
280
+ } finally {
281
+ if (routerSession.available) await routerSession.close();
282
+ }
283
+
284
+ try {
285
+ writeModelCatalogueCache(cache, { env, fsImpl });
286
+ } catch {
287
+ // A read-only state directory degrades to "no cache", not to no answer.
288
+ }
289
+ const metadata = sources.find(source => source.id === 'models-dev')?.meta?.metadata ?? {};
290
+ return { tool: toolName, ttlMs: resolveModelCatalogueTtlMs(env), hotLoad, sources, metadata, router: { available: routerSession.available, reason: routerSession.reason ?? null, external: routerSession.external ?? false, transport: routerSession.transport ?? null } };
291
+ },
292
+ { env, ...lockOptions }
293
+ );
294
+ };
295
+
296
+ /**
297
+ * Join the bundled catalogue with what the live sources reported.
298
+ *
299
+ * The three groups are R5's "from fully supported, to hot loaded":
300
+ *
301
+ * - **bundledAndLive** — shipped with this installation *and* reachable now.
302
+ * These are the ones `--model` accepts and that will work.
303
+ * - **liveOnly** — a provider or the router knows them, this installation does
304
+ * not. This is the group the issue is about: a new model appears here the day
305
+ * it ships, without a release.
306
+ * - **bundledOnly** — shipped, but no live source confirmed them. Either no live
307
+ * source could be reached, or the model is retired.
308
+ */
309
+ export const mergeModelCatalogue = ({ tool = 'claude', catalogue = null, metadata = null } = {}) => {
310
+ const toolName = String(tool || 'claude').toLowerCase();
311
+ const bundled = listBundledModelIds(toolName);
312
+ const bundledSet = new Set(bundled);
313
+ const specs = metadata ?? catalogue?.metadata ?? {};
314
+
315
+ /** id -> the live sources that reported it, in rank order. */
316
+ const liveById = new Map();
317
+ for (const source of catalogue?.sources ?? []) {
318
+ if (source.kind !== 'live' || source.status !== 'ok') continue;
319
+ for (const model of source.models ?? []) {
320
+ if (!model?.id) continue;
321
+ const existing = liveById.get(model.id) ?? { id: model.id, label: model.label ?? null, sources: [], services: [] };
322
+ existing.label = existing.label ?? model.label ?? null;
323
+ if (!existing.sources.includes(source.id)) existing.sources.push(source.id);
324
+ if (model.service && !existing.services.includes(model.service)) existing.services.push(model.service);
325
+ liveById.set(model.id, existing);
326
+ }
327
+ }
328
+
329
+ const describe = (id, live) => ({
330
+ id,
331
+ label: live?.label ?? null,
332
+ aliases: bundledSet.has(id) ? listBundledAliasesFor(toolName, id) : [],
333
+ sources: live?.sources ?? [],
334
+ services: live?.services ?? [],
335
+ // R8: the specification, from models.dev when no first-party source carried
336
+ // it. Looked up bare and by last path segment, because `opencode/grok-code`
337
+ // is one model with a routing prefix.
338
+ spec: specs?.[id] ?? specs?.[id.includes('/') ? id.split('/').pop() : id] ?? null,
339
+ });
340
+
341
+ const bundledAndLive = [];
342
+ const bundledOnly = [];
343
+ for (const id of bundled) {
344
+ const live = liveById.get(id);
345
+ (live ? bundledAndLive : bundledOnly).push(describe(id, live));
346
+ }
347
+ const liveOnly = [...liveById.keys()]
348
+ .filter(id => !bundledSet.has(id))
349
+ .sort()
350
+ .map(id => describe(id, liveById.get(id)));
351
+
352
+ return {
353
+ tool: toolName,
354
+ default: defaultModels[toolName] ?? null,
355
+ bundledAndLive,
356
+ liveOnly,
357
+ bundledOnly,
358
+ counts: { bundled: bundled.length, live: liveById.size, bundledAndLive: bundledAndLive.length, liveOnly: liveOnly.length, bundledOnly: bundledOnly.length },
359
+ };
360
+ };
361
+
362
+ /** Hot-load and merge in one call — what `/models` and `hive-models` use. */
363
+ export const getMergedModelCatalogue = async (options = {}) => {
364
+ const catalogue = await loadModelCatalogue(options);
365
+ return { ...mergeModelCatalogue({ tool: catalogue.tool, catalogue }), catalogue };
366
+ };
367
+
368
+ export default {
369
+ MODEL_CATALOGUE_TOOLS,
370
+ buildDefaultCatalogueFetchers,
371
+ getBundledModelMap,
372
+ getMergedModelCatalogue,
373
+ isModelCatalogueEntryFresh,
374
+ isModelHotLoadEnabled,
375
+ listBundledAliasesFor,
376
+ listBundledModelIds,
377
+ loadModelCatalogue,
378
+ mergeModelCatalogue,
379
+ modelCatalogueCacheKey,
380
+ modelCatalogueEntryAgeMs,
381
+ readModelCatalogueCache,
382
+ resolveModelCatalogueCachePath,
383
+ openRouterCatalogueSession,
384
+ writeModelCatalogueCache,
385
+ };