@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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token-free readers behind the live model catalogue (issue #2202, R2/R7/R8).
|
|
3
|
+
*
|
|
4
|
+
* One function per source in `MODEL_CATALOGUE_SOURCES`, each returning the same
|
|
5
|
+
* envelope so the orchestrator in ./model-catalogue.lib.mjs never has to know
|
|
6
|
+
* which one it called:
|
|
7
|
+
*
|
|
8
|
+
* { status, models: [{ id, label, … }], meta: {}, error: string|null }
|
|
9
|
+
*
|
|
10
|
+
* `status` is `ok`, `skipped` (the source does not apply here — no credential,
|
|
11
|
+
* no router, no binary) or `error` (it applied and failed). The distinction
|
|
12
|
+
* matters to the caller: `skipped` is normal, `error` is worth showing.
|
|
13
|
+
*
|
|
14
|
+
* Every network call goes through `assertTokenFreeUrl`, so a URL that would run
|
|
15
|
+
* a model throws before a socket is opened rather than after a bill is incurred.
|
|
16
|
+
*
|
|
17
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2202
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
|
|
23
|
+
import { assertTokenFreeUrl } from './model-catalogue-sources.lib.mjs';
|
|
24
|
+
import { ROUTER_SIDECAR_CONTAINER_NAME, ROUTER_SIDECAR_PORT } from './router-isolation.lib.mjs';
|
|
25
|
+
import { buildRouterCatalogueEndpoints, ROUTER_TOOL_SERVICE } from './router-routes.lib.mjs';
|
|
26
|
+
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_CATALOGUE_TIMEOUT_MS = 20_000;
|
|
30
|
+
|
|
31
|
+
const ok = (models, meta = {}) => ({ status: 'ok', models, meta, error: null });
|
|
32
|
+
const skipped = reason => ({ status: 'skipped', models: [], meta: {}, error: reason });
|
|
33
|
+
const failed = error => ({ status: 'error', models: [], meta: {}, error: String(error?.message ?? error) });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reduce a provider's listing response to `{ id, label, … }` records.
|
|
37
|
+
*
|
|
38
|
+
* The shapes differ enough that sniffing them at the merge would be guesswork,
|
|
39
|
+
* so the shape is declared by the caller — for the router it comes from the
|
|
40
|
+
* route table, which already records one per catalogue endpoint.
|
|
41
|
+
*/
|
|
42
|
+
export const normalizeCataloguePayload = ({ shape, payload } = {}) => {
|
|
43
|
+
if (!payload || typeof payload !== 'object') return [];
|
|
44
|
+
if (shape === 'gemini') {
|
|
45
|
+
const entries = Array.isArray(payload.models) ? payload.models : [];
|
|
46
|
+
return entries
|
|
47
|
+
.map(entry => ({
|
|
48
|
+
id: String(entry?.name ?? '').replace(/^models\//, ''),
|
|
49
|
+
label: entry?.displayName ?? null,
|
|
50
|
+
contextWindow: entry?.inputTokenLimit ?? null,
|
|
51
|
+
maxOutput: entry?.outputTokenLimit ?? null,
|
|
52
|
+
}))
|
|
53
|
+
.filter(entry => entry.id);
|
|
54
|
+
}
|
|
55
|
+
if (shape === 'codex-cli') {
|
|
56
|
+
const entries = Array.isArray(payload.models) ? payload.models : [];
|
|
57
|
+
return entries
|
|
58
|
+
.map(entry => ({
|
|
59
|
+
id: String(entry?.slug ?? ''),
|
|
60
|
+
label: entry?.display_name ?? null,
|
|
61
|
+
visibility: entry?.visibility ?? null,
|
|
62
|
+
supportedInApi: entry?.supported_in_api ?? null,
|
|
63
|
+
}))
|
|
64
|
+
.filter(entry => entry.id);
|
|
65
|
+
}
|
|
66
|
+
// `anthropic` and `openai` both answer `{ data: [ { id, … } ] }`; they differ
|
|
67
|
+
// only in the optional fields, which are carried through as-is.
|
|
68
|
+
const entries = Array.isArray(payload.data) ? payload.data : [];
|
|
69
|
+
return entries
|
|
70
|
+
.map(entry => ({
|
|
71
|
+
id: String(entry?.id ?? ''),
|
|
72
|
+
label: entry?.display_name ?? null,
|
|
73
|
+
createdAt: entry?.created_at ?? (typeof entry?.created === 'number' ? new Date(entry.created * 1000).toISOString() : null),
|
|
74
|
+
ownedBy: entry?.owned_by ?? null,
|
|
75
|
+
}))
|
|
76
|
+
.filter(entry => entry.id);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The router's own health signal, carried alongside the models.
|
|
81
|
+
*
|
|
82
|
+
* The router's listing response is a superset of OpenAI's: it adds
|
|
83
|
+
* `using_fallback`, `degraded_providers`, `degraded_reasons` and
|
|
84
|
+
* `healthy_providers`. That is exactly what R5 asks `/models` to surface — "we
|
|
85
|
+
* should see which models are loaded live" — so it is preserved rather than
|
|
86
|
+
* normalised away.
|
|
87
|
+
*/
|
|
88
|
+
export const extractRouterCatalogueMeta = payload => ({
|
|
89
|
+
usingFallback: payload?.using_fallback ?? null,
|
|
90
|
+
degradedProviders: Array.isArray(payload?.degraded_providers) ? payload.degraded_providers : [],
|
|
91
|
+
degradedReasons: payload?.degraded_reasons ?? null,
|
|
92
|
+
healthyProviders: Array.isArray(payload?.healthy_providers) ? payload.healthy_providers : [],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch a JSON document with the billable-URL guard in front of it.
|
|
97
|
+
*
|
|
98
|
+
* `fetchImpl` is injected rather than closed over so tests can drive every
|
|
99
|
+
* branch without a network, following the pattern the rest of the repository
|
|
100
|
+
* already uses.
|
|
101
|
+
*/
|
|
102
|
+
export const fetchJsonCatalogue = async (url, { sourceId = 'unknown', headers = {}, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_CATALOGUE_TIMEOUT_MS } = {}) => {
|
|
103
|
+
assertTokenFreeUrl(url, sourceId);
|
|
104
|
+
if (typeof fetchImpl !== 'function') throw new Error('No fetch implementation available');
|
|
105
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
106
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetchImpl(url, { headers, signal: controller?.signal });
|
|
109
|
+
if (!response?.ok) throw new Error(`HTTP ${response?.status ?? '?'} from ${url}`);
|
|
110
|
+
return await response.json();
|
|
111
|
+
} finally {
|
|
112
|
+
if (timer) clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read the router's live catalogue.
|
|
118
|
+
*
|
|
119
|
+
* Two paths, because the sidecar Hive Mind starts publishes no port (see the
|
|
120
|
+
* `No -p` note in src/router-sidecar.lib.mjs): an operator-run router is fetched
|
|
121
|
+
* over the network, while the local sidecar is read from *inside* the container
|
|
122
|
+
* with `bun`, the same client `checkRouterSidecarHealth` uses for the same
|
|
123
|
+
* reason — the router's image ships no curl.
|
|
124
|
+
*
|
|
125
|
+
* A token is required either way. For the sidecar the caller supplies one it
|
|
126
|
+
* already leased, so `/models` mints nothing on its own.
|
|
127
|
+
*/
|
|
128
|
+
export const fetchRouterCatalogue = async ({ baseUrl, dialect, token = null, tool = null, containerName = ROUTER_SIDECAR_CONTAINER_NAME, transport = 'exec', fetchImpl = globalThis.fetch, run = execFileAsync, timeoutMs = DEFAULT_CATALOGUE_TIMEOUT_MS } = {}) => {
|
|
129
|
+
if (!baseUrl || !dialect) return skipped('router is not configured');
|
|
130
|
+
const endpoints = buildRouterCatalogueEndpoints({ baseUrl, dialect });
|
|
131
|
+
if (endpoints.length === 0) return skipped('this router dialect exposes no catalogue route');
|
|
132
|
+
if (!token) return skipped('no router token available');
|
|
133
|
+
|
|
134
|
+
// On the canonical dialect there is one catalogue per service, so asking about
|
|
135
|
+
// one tool should not fan out across all five. On the legacy dialect the single
|
|
136
|
+
// `/v1/models` already answers for every adopted provider, and the filter falls
|
|
137
|
+
// through to it because no entry matches the tool's service name.
|
|
138
|
+
const service = tool ? ROUTER_TOOL_SERVICE[String(tool).toLowerCase()] : null;
|
|
139
|
+
const wanted = service ? endpoints.filter(endpoint => endpoint.service === service) : [];
|
|
140
|
+
const selected = wanted.length > 0 ? wanted : endpoints;
|
|
141
|
+
|
|
142
|
+
const models = [];
|
|
143
|
+
const meta = { endpoints: [], usingFallback: null, degradedProviders: [], healthyProviders: [] };
|
|
144
|
+
const errors = [];
|
|
145
|
+
for (const endpoint of selected) {
|
|
146
|
+
assertTokenFreeUrl(endpoint.url, 'router');
|
|
147
|
+
try {
|
|
148
|
+
const payload = transport === 'http' ? await fetchJsonCatalogue(endpoint.url, { sourceId: 'router', headers: { Authorization: `Bearer ${token}` }, fetchImpl, timeoutMs }) : await fetchRouterCatalogueViaExec({ url: endpoint.url, token, containerName, run, timeoutMs });
|
|
149
|
+
const entries = normalizeCataloguePayload({ shape: endpoint.shape, payload });
|
|
150
|
+
for (const entry of entries) models.push({ ...entry, service: endpoint.service });
|
|
151
|
+
const endpointMeta = extractRouterCatalogueMeta(payload);
|
|
152
|
+
meta.endpoints.push({ service: endpoint.service, url: endpoint.url, count: entries.length, ...endpointMeta });
|
|
153
|
+
if (endpointMeta.usingFallback) meta.usingFallback = true;
|
|
154
|
+
meta.degradedProviders.push(...endpointMeta.degradedProviders);
|
|
155
|
+
meta.healthyProviders.push(...endpointMeta.healthyProviders);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
errors.push(`${endpoint.service}: ${error?.message ?? error}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (models.length === 0 && errors.length > 0) return failed(errors.join('; '));
|
|
162
|
+
meta.degradedProviders = [...new Set(meta.degradedProviders)];
|
|
163
|
+
meta.healthyProviders = [...new Set(meta.healthyProviders)];
|
|
164
|
+
if (errors.length > 0) meta.partialErrors = errors;
|
|
165
|
+
return ok(models, meta);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read one catalogue endpoint from inside the sidecar.
|
|
170
|
+
*
|
|
171
|
+
* TLS verification is disabled for this call for the same reason the health
|
|
172
|
+
* probe disables it: the certificate names the network alias, the request is a
|
|
173
|
+
* loopback socket inside the container, and there is no network for anyone to
|
|
174
|
+
* sit in the middle of. The alternative — shipping the CA back into an image
|
|
175
|
+
* Hive Mind does not own — buys nothing here.
|
|
176
|
+
*/
|
|
177
|
+
export const fetchRouterCatalogueViaExec = async ({ url, token, containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs = DEFAULT_CATALOGUE_TIMEOUT_MS } = {}) => {
|
|
178
|
+
assertTokenFreeUrl(url, 'router');
|
|
179
|
+
// The container reaches itself on loopback; the authority in `url` is the
|
|
180
|
+
// alias, which does not resolve from inside.
|
|
181
|
+
const loopback = String(url).replace(/^https?:\/\/[^/]+/, `https://127.0.0.1:${ROUTER_SIDECAR_PORT}`);
|
|
182
|
+
assertTokenFreeUrl(loopback, 'router');
|
|
183
|
+
const script = `fetch(${JSON.stringify(loopback)},{headers:{Authorization:"Bearer "+process.env.ROUTER_CATALOGUE_TOKEN}}).then(r=>r.ok?r.text():Promise.reject(new Error("HTTP "+r.status))).then(t=>{process.stdout.write(t)}).catch(e=>{process.stderr.write(String(e&&e.message||e));process.exit(1)})`;
|
|
184
|
+
try {
|
|
185
|
+
const { stdout } = await run('docker', ['exec', '--env', 'NODE_TLS_REJECT_UNAUTHORIZED=0', '--env', `ROUTER_CATALOGUE_TOKEN=${token}`, containerName, 'bun', '-e', script], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 });
|
|
186
|
+
return JSON.parse(stdout);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// Node builds a failed `execFile`'s message as "Command failed: <argv…>",
|
|
189
|
+
// and this argv carries the leased router token. That message would travel
|
|
190
|
+
// straight into the source footer `/models` prints and into
|
|
191
|
+
// `hive-models --json`. So the failure is re-raised from the parts that are
|
|
192
|
+
// safe to show, and anything left is scrubbed of the token as well — docker
|
|
193
|
+
// itself sometimes echoes the environment back in its own error text.
|
|
194
|
+
//
|
|
195
|
+
// The cause is the caught error, but scrubbed first and in place: its
|
|
196
|
+
// message *and* its stack (which repeats the message) hold the argv, and a
|
|
197
|
+
// cause is printed in full by Node's default handler and by util.inspect.
|
|
198
|
+
scrubRouterToken(error, token);
|
|
199
|
+
throw new Error(redactRouterToken(describeExecFailure(error, containerName), token), { cause: error });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** A failed `docker exec` described without repeating the command line. */
|
|
204
|
+
const describeExecFailure = (error, containerName) => {
|
|
205
|
+
const detail = String(error?.stderr || '')
|
|
206
|
+
.trim()
|
|
207
|
+
.split('\n')
|
|
208
|
+
.map(line => line.trim())
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.join(' ');
|
|
211
|
+
if (detail) return `docker exec into ${containerName} failed: ${detail}`;
|
|
212
|
+
if (error?.code === 'ENOENT') return 'docker is not installed';
|
|
213
|
+
return `docker exec into ${containerName} failed (${error?.code ?? error?.signal ?? 'unknown error'})`;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Scrub a leased token out of a caught error, in place.
|
|
218
|
+
*
|
|
219
|
+
* Node puts the whole argv in `message`, repeats it in `stack`, and `exec`-style
|
|
220
|
+
* failures also carry it in `cmd`. Every string the error owns is rewritten, so
|
|
221
|
+
* attaching it as a `cause` cannot resurrect the secret somewhere downstream.
|
|
222
|
+
*/
|
|
223
|
+
const scrubRouterToken = (error, token) => {
|
|
224
|
+
if (!error || typeof error !== 'object') return error;
|
|
225
|
+
for (const key of ['message', 'stack', 'cmd', 'stdout', 'stderr']) {
|
|
226
|
+
const value = error[key];
|
|
227
|
+
if (typeof value !== 'string') continue;
|
|
228
|
+
const redacted = redactRouterToken(value, token);
|
|
229
|
+
if (redacted !== value) {
|
|
230
|
+
try {
|
|
231
|
+
error[key] = redacted;
|
|
232
|
+
} catch {
|
|
233
|
+
// A frozen error cannot be scrubbed; the caller still only surfaces the
|
|
234
|
+
// description built above, which never quotes the command line.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return error;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** Replace a leased token wherever it survived into text meant for a human. */
|
|
242
|
+
const redactRouterToken = (text, token) => {
|
|
243
|
+
const secret = String(token || '');
|
|
244
|
+
if (secret.length < 8) return text;
|
|
245
|
+
return String(text).split(secret).join('***');
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read the installed Codex CLI's compiled catalogue.
|
|
250
|
+
*
|
|
251
|
+
* No network, no account, no cost — the binary answers from what it was built
|
|
252
|
+
* with, which is also precisely the set it will accept as `--model`.
|
|
253
|
+
*/
|
|
254
|
+
export const fetchCodexCliCatalogue = async ({ run = execFileAsync, timeoutMs = DEFAULT_CATALOGUE_TIMEOUT_MS, binary = 'codex' } = {}) => {
|
|
255
|
+
try {
|
|
256
|
+
const { stdout } = await run(binary, ['debug', 'models'], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 });
|
|
257
|
+
const payload = JSON.parse(stdout);
|
|
258
|
+
return ok(normalizeCataloguePayload({ shape: 'codex-cli', payload }), { binary });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error?.code === 'ENOENT') return skipped('codex CLI is not installed');
|
|
261
|
+
return failed(error);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** Anthropic's listing endpoint, paginated with `after_id` and never metered. */
|
|
266
|
+
export const fetchAnthropicCatalogue = async ({ env = process.env, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_CATALOGUE_TIMEOUT_MS, baseUrl = 'https://api.anthropic.com', maxPages = 5 } = {}) => {
|
|
267
|
+
const apiKey = String(env?.ANTHROPIC_API_KEY || '').trim();
|
|
268
|
+
if (!apiKey) return skipped('ANTHROPIC_API_KEY is not set');
|
|
269
|
+
const headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
|
|
270
|
+
const models = [];
|
|
271
|
+
let afterId = null;
|
|
272
|
+
try {
|
|
273
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
274
|
+
const url = `${baseUrl.replace(/\/$/, '')}/v1/models?limit=100${afterId ? `&after_id=${encodeURIComponent(afterId)}` : ''}`;
|
|
275
|
+
const payload = await fetchJsonCatalogue(url, { sourceId: 'anthropic-api', headers, fetchImpl, timeoutMs });
|
|
276
|
+
models.push(...normalizeCataloguePayload({ shape: 'anthropic', payload }));
|
|
277
|
+
if (!payload?.has_more || !payload?.last_id) break;
|
|
278
|
+
afterId = payload.last_id;
|
|
279
|
+
}
|
|
280
|
+
return ok(models, { baseUrl });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return failed(error);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** OpenAI's listing endpoint. Same reasoning as Anthropic's, single page. */
|
|
287
|
+
export const fetchOpenAiCatalogue = async ({ env = process.env, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_CATALOGUE_TIMEOUT_MS, baseUrl = 'https://api.openai.com' } = {}) => {
|
|
288
|
+
const apiKey = String(env?.OPENAI_API_KEY || '').trim();
|
|
289
|
+
if (!apiKey) return skipped('OPENAI_API_KEY is not set');
|
|
290
|
+
try {
|
|
291
|
+
const payload = await fetchJsonCatalogue(`${baseUrl.replace(/\/$/, '')}/v1/models`, { sourceId: 'openai-api', headers: { Authorization: `Bearer ${apiKey}` }, fetchImpl, timeoutMs });
|
|
292
|
+
return ok(normalizeCataloguePayload({ shape: 'openai', payload }), { baseUrl });
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return failed(error);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* models.dev, the R8 fallback — metadata only.
|
|
300
|
+
*
|
|
301
|
+
* Returned as a flat map from bare model id to specification, so the merge can
|
|
302
|
+
* annotate a model without models.dev ever being able to claim one is available:
|
|
303
|
+
* it aggregates published specifications and knows nothing about this account.
|
|
304
|
+
*/
|
|
305
|
+
export const fetchModelsDevMetadata = async ({ fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_CATALOGUE_TIMEOUT_MS, url = 'https://models.dev/api.json' } = {}) => {
|
|
306
|
+
try {
|
|
307
|
+
const payload = await fetchJsonCatalogue(url, { sourceId: 'models-dev', fetchImpl, timeoutMs });
|
|
308
|
+
const metadata = {};
|
|
309
|
+
for (const provider of Object.values(payload ?? {})) {
|
|
310
|
+
for (const [modelId, model] of Object.entries(provider?.models ?? {})) {
|
|
311
|
+
// First provider wins: the same id under two providers describes the
|
|
312
|
+
// same model, and the extra copies only differ in pricing presentation.
|
|
313
|
+
if (!metadata[modelId]) metadata[modelId] = { ...model, provider: provider?.name ?? provider?.id ?? null };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return ok([], { metadata, providerCount: Object.keys(payload ?? {}).length });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return failed(error);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export default {
|
|
323
|
+
DEFAULT_CATALOGUE_TIMEOUT_MS,
|
|
324
|
+
extractRouterCatalogueMeta,
|
|
325
|
+
fetchAnthropicCatalogue,
|
|
326
|
+
fetchCodexCliCatalogue,
|
|
327
|
+
fetchJsonCatalogue,
|
|
328
|
+
fetchModelsDevMetadata,
|
|
329
|
+
fetchOpenAiCatalogue,
|
|
330
|
+
fetchRouterCatalogue,
|
|
331
|
+
fetchRouterCatalogueViaExec,
|
|
332
|
+
normalizeCataloguePayload,
|
|
333
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for the merged model catalogue (issue #2202, R5).
|
|
3
|
+
*
|
|
4
|
+
* R5 asks that `/models` show "list of merged models (from fully supported, to
|
|
5
|
+
* hot loaded)… which models are loaded live, and available for use with all our
|
|
6
|
+
* tools, and which models are included with Hive Mind installation". Those are
|
|
7
|
+
* the three groups `mergeModelCatalogue` produces, and this module is the only
|
|
8
|
+
* place that decides how they look — so the CLI and the Telegram command cannot
|
|
9
|
+
* drift apart, and both can be tested without a network.
|
|
10
|
+
*
|
|
11
|
+
* Pure formatting: no I/O, no imports beyond the source table it labels with.
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2202
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getModelCatalogueSource } from './model-catalogue-sources.lib.mjs';
|
|
17
|
+
|
|
18
|
+
/** Telegram refuses a message over 4096 characters; leave room for the footer. */
|
|
19
|
+
export const TELEGRAM_MESSAGE_BUDGET = 3600;
|
|
20
|
+
|
|
21
|
+
export const GROUP_TITLES = Object.freeze({
|
|
22
|
+
bundledAndLive: { title: 'Bundled and live', note: 'shipped with this installation and confirmed reachable now' },
|
|
23
|
+
liveOnly: { title: 'Hot loaded', note: 'a live source has them, this installation does not — use with --model at your own risk' },
|
|
24
|
+
bundledOnly: { title: 'Bundled only', note: 'shipped, but no live source confirmed them' },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* With hot load off there is nothing to compare against, so "no live source
|
|
29
|
+
* confirmed them" would be an accusation rather than a fact.
|
|
30
|
+
*/
|
|
31
|
+
export const groupHeading = (key, { hotLoad = true } = {}) => (!hotLoad && key === 'bundledOnly' ? { title: 'Bundled', note: 'shipped with this installation; live sources were not consulted' } : GROUP_TITLES[key]);
|
|
32
|
+
|
|
33
|
+
/** "2 minutes", "1 hour" — a duration a person reads rather than parses. */
|
|
34
|
+
export const formatAge = ms => {
|
|
35
|
+
if (!Number.isFinite(ms) || ms < 0) return 'unknown';
|
|
36
|
+
const seconds = Math.round(ms / 1000);
|
|
37
|
+
if (seconds < 60) return `${seconds}s`;
|
|
38
|
+
const minutes = Math.round(seconds / 60);
|
|
39
|
+
if (minutes < 60) return `${minutes} min`;
|
|
40
|
+
const hours = Math.floor(minutes / 60);
|
|
41
|
+
return `${hours}h ${minutes % 60}m`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 200000 → "200K", 1000000 → "1M". Context windows are quoted, not computed. */
|
|
45
|
+
export const formatTokenCount = value => {
|
|
46
|
+
const tokens = Number(value);
|
|
47
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return null;
|
|
48
|
+
if (tokens >= 1_000_000) return `${Math.round((tokens / 1_000_000) * 10) / 10}M`;
|
|
49
|
+
if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`;
|
|
50
|
+
return String(tokens);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The R8 technical detail line for one model.
|
|
55
|
+
*
|
|
56
|
+
* Whatever the sources actually carried, in a fixed order, and nothing when
|
|
57
|
+
* they carried nothing — an invented number is worse than a blank.
|
|
58
|
+
*/
|
|
59
|
+
export const formatModelSpec = (model = {}) => {
|
|
60
|
+
const spec = model.spec ?? {};
|
|
61
|
+
const parts = [];
|
|
62
|
+
const context = formatTokenCount(model.contextWindow ?? spec?.limit?.context);
|
|
63
|
+
if (context) parts.push(`${context} ctx`);
|
|
64
|
+
const output = formatTokenCount(model.maxOutput ?? spec?.limit?.output);
|
|
65
|
+
if (output) parts.push(`${output} out`);
|
|
66
|
+
const input = spec?.cost?.input;
|
|
67
|
+
const outputCost = spec?.cost?.output;
|
|
68
|
+
if (Number.isFinite(Number(input)) && Number.isFinite(Number(outputCost))) parts.push(`$${input}/$${outputCost} per Mtok`);
|
|
69
|
+
if (spec?.reasoning === true) parts.push('reasoning');
|
|
70
|
+
if (Array.isArray(spec?.modalities?.input) && spec.modalities.input.length > 1) parts.push(spec.modalities.input.join('+'));
|
|
71
|
+
if (spec?.release_date) parts.push(String(spec.release_date));
|
|
72
|
+
return parts.join(' · ');
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Source ids as a reader-facing list: `router, anthropic-api` → their labels. */
|
|
76
|
+
export const formatModelSources = (model = {}) =>
|
|
77
|
+
(model.sources ?? [])
|
|
78
|
+
.map(id => getModelCatalogueSource(id)?.label ?? id)
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(', ');
|
|
81
|
+
|
|
82
|
+
/** One line per source: what it said, and how old the answer is. */
|
|
83
|
+
export const describeCatalogueSources = (catalogue = {}) =>
|
|
84
|
+
(catalogue.sources ?? []).map(source => {
|
|
85
|
+
// A metadata source contributes specifications, not names, so counting its
|
|
86
|
+
// models would always print "0" and read like a failure.
|
|
87
|
+
const okDetail = source.kind === 'metadata' ? `specifications for ${Object.keys(source.meta?.metadata ?? {}).length} model(s)` : `${source.models?.length ?? 0} model(s)`;
|
|
88
|
+
const detail = source.status === 'ok' ? okDetail : source.error || source.status;
|
|
89
|
+
const age = source.cached ? ` · cached ${formatAge(source.ageMs)}` : '';
|
|
90
|
+
const stale = source.stale ? ' · stale' : '';
|
|
91
|
+
return { id: source.id, label: source.label ?? source.id, status: source.status, text: `${source.label ?? source.id}: ${source.status} — ${detail}${age}${stale}` };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const modelLine = (model, { details = false } = {}) => {
|
|
95
|
+
const columns = [model.id];
|
|
96
|
+
if (model.aliases?.length > 0) columns.push(`(${model.aliases.join(', ')})`);
|
|
97
|
+
if (model.label && model.label !== model.id) columns.push(`— ${model.label}`);
|
|
98
|
+
const spec = details ? formatModelSpec(model) : '';
|
|
99
|
+
if (spec) columns.push(`[${spec}]`);
|
|
100
|
+
const sources = details ? formatModelSources(model) : '';
|
|
101
|
+
if (sources) columns.push(`via ${sources}`);
|
|
102
|
+
return columns.join(' ');
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The plain-text rendering used by `hive-models`.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} merged result of `mergeModelCatalogue`, with `.catalogue`
|
|
109
|
+
* @param {object} options `details` adds the R8 specification columns
|
|
110
|
+
*/
|
|
111
|
+
export const formatModelCatalogueText = (merged = {}, { details = false, defaultModel = null } = {}) => {
|
|
112
|
+
const catalogue = merged.catalogue ?? {};
|
|
113
|
+
const lines = [];
|
|
114
|
+
lines.push(`Models for ${merged.tool}${merged.default ? ` (default: ${merged.default})` : ''}`);
|
|
115
|
+
lines.push(`${merged.counts?.bundledAndLive ?? 0} bundled and live · ${merged.counts?.liveOnly ?? 0} hot loaded · ${merged.counts?.bundledOnly ?? 0} bundled only`);
|
|
116
|
+
|
|
117
|
+
for (const key of ['bundledAndLive', 'liveOnly', 'bundledOnly']) {
|
|
118
|
+
const group = merged[key] ?? [];
|
|
119
|
+
if (group.length === 0) continue;
|
|
120
|
+
lines.push('');
|
|
121
|
+
const heading = groupHeading(key, { hotLoad: catalogue.hotLoad !== false });
|
|
122
|
+
lines.push(`${heading.title} (${group.length}) — ${heading.note}`);
|
|
123
|
+
for (const model of group) {
|
|
124
|
+
const marker = defaultModel && (model.id === defaultModel || model.aliases?.includes(defaultModel)) ? '*' : ' ';
|
|
125
|
+
lines.push(` ${marker} ${modelLine(model, { details })}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sources = describeCatalogueSources(catalogue);
|
|
130
|
+
if (sources.length > 0) {
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push('Sources, in the order they are trusted:');
|
|
133
|
+
for (const source of sources) lines.push(` - ${source.text}`);
|
|
134
|
+
}
|
|
135
|
+
// Only when the router never became a source line of its own; otherwise this
|
|
136
|
+
// repeats what the list above already said.
|
|
137
|
+
const routerReported = (catalogue.sources ?? []).some(source => source.id === 'router');
|
|
138
|
+
if (!routerReported && catalogue.router && !catalogue.router.available && catalogue.router.reason) lines.push(` - router: not read — ${catalogue.router.reason}`);
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push(`Live answers are cached for ${formatAge(catalogue.ttlMs ?? 0)}; pass --refresh to ignore the cache.`);
|
|
141
|
+
if (catalogue.hotLoad === false) lines.push('Hot load is disabled (HIVE_MIND_MODELS_HOT_LOAD), so only the bundled catalogue was consulted.');
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The Telegram rendering.
|
|
147
|
+
*
|
|
148
|
+
* Same content, but budgeted: a full catalogue is far longer than one message,
|
|
149
|
+
* so each group is truncated with an explicit "and N more" rather than being
|
|
150
|
+
* silently cut by the API.
|
|
151
|
+
*/
|
|
152
|
+
export const formatModelCatalogueTelegram = (merged = {}, { details = false, budget = TELEGRAM_MESSAGE_BUDGET, perGroupLimit = 40 } = {}) => {
|
|
153
|
+
const catalogue = merged.catalogue ?? {};
|
|
154
|
+
const lines = [];
|
|
155
|
+
lines.push(`🧠 *Models for ${merged.tool}*${merged.default ? ` — default \`${merged.default}\`` : ''}`);
|
|
156
|
+
lines.push(`${merged.counts?.bundledAndLive ?? 0} bundled and live · ${merged.counts?.liveOnly ?? 0} hot loaded · ${merged.counts?.bundledOnly ?? 0} bundled only`);
|
|
157
|
+
|
|
158
|
+
for (const key of ['bundledAndLive', 'liveOnly', 'bundledOnly']) {
|
|
159
|
+
const group = merged[key] ?? [];
|
|
160
|
+
if (group.length === 0) continue;
|
|
161
|
+
lines.push('');
|
|
162
|
+
const heading = groupHeading(key, { hotLoad: catalogue.hotLoad !== false });
|
|
163
|
+
lines.push(`*${heading.title}* (${group.length}) — _${heading.note}_`);
|
|
164
|
+
for (const model of group.slice(0, perGroupLimit)) lines.push(`• \`${model.id}\`${details && formatModelSpec(model) ? ` — ${formatModelSpec(model)}` : ''}`);
|
|
165
|
+
if (group.length > perGroupLimit) lines.push(`… and ${group.length - perGroupLimit} more`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lines.push('');
|
|
169
|
+
const sources = describeCatalogueSources(catalogue);
|
|
170
|
+
lines.push(`Sources: ${sources.map(source => `${source.label} (${source.status})`).join(' · ') || 'none'}`);
|
|
171
|
+
lines.push(`Cached for ${formatAge(catalogue.ttlMs ?? 0)}; \`/models --refresh\` re-reads them.`);
|
|
172
|
+
|
|
173
|
+
const text = lines.join('\n');
|
|
174
|
+
if (text.length <= budget) return text;
|
|
175
|
+
// Trim whole lines from the model listings rather than cutting mid-token, and
|
|
176
|
+
// keep the footer, which is the part that explains what was left out.
|
|
177
|
+
const footer = lines.slice(-3).join('\n');
|
|
178
|
+
const head = [];
|
|
179
|
+
let used = footer.length + 24;
|
|
180
|
+
for (const line of lines.slice(0, -3)) {
|
|
181
|
+
if (used + line.length + 1 > budget) {
|
|
182
|
+
head.push('… list truncated; run `hive-models` for the full catalogue');
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
head.push(line);
|
|
186
|
+
used += line.length + 1;
|
|
187
|
+
}
|
|
188
|
+
return `${head.join('\n')}\n\n${footer}`;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export default { GROUP_TITLES, groupHeading, TELEGRAM_MESSAGE_BUDGET, describeCatalogueSources, formatAge, formatModelCatalogueTelegram, formatModelCatalogueText, formatModelSources, formatModelSpec, formatTokenCount };
|