@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.
- package/CHANGELOG.md +17 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +3 -2
- package/src/agentic-cli-freshness.lib.mjs +118 -0
- package/src/agentic-cli-updater.lib.mjs +8 -4
- package/src/docker-sidecar.lib.mjs +17 -1
- package/src/hive-models.lib.mjs +181 -0
- package/src/hive-models.mjs +20 -0
- package/src/locales/en.lino +2 -1
- package/src/locales/hi.lino +2 -1
- package/src/locales/ru.lino +2 -1
- package/src/locales/zh.lino +2 -1
- package/src/model-catalogue-fetch.lib.mjs +333 -0
- package/src/model-catalogue-render.lib.mjs +191 -0
- package/src/model-catalogue-sources.lib.mjs +224 -0
- package/src/model-catalogue.lib.mjs +385 -0
- package/src/models/catalog.mjs +408 -0
- package/src/models/index.mjs +23 -362
- package/src/router-isolation.lib.mjs +103 -19
- package/src/router-routes.lib.mjs +250 -0
- package/src/router-sidecar.lib.mjs +33 -13
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.escalate.lib.mjs +3 -0
- package/src/solve.mjs +12 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +12 -0
- package/src/telegram-bot.mjs +4 -1
- package/src/telegram-models-command.lib.mjs +157 -0
- package/src/telegram-ui-messages.lib.mjs +1 -1
|
@@ -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
|
+
};
|